From 1dfbca08a1beff9e1ba870c245b52ab8b8bc8c41 Mon Sep 17 00:00:00 2001 From: Public Source Publisher Date: Thu, 1 Oct 2026 01:35:57 +0200 Subject: [PATCH] Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d --- .dockerignore | 18 + .env.example | 3 + .gitattributes | 6 + .gitignore | 40 + .gitleaks.toml | 13 + CHANGELOG.md | 313 + Dockerfile | 73 + PUBLIC_SOURCE_EXPORT.md | 3 + PUBLIC_SOURCE_MANIFEST.json | 2636 ++++++ README.md | 52 + SECURITY.md | 43 + backend/app/__init__.py | 3 + backend/app/api/__init__.py | 1 + backend/app/api/connector_runtime.py | 11 + backend/app/api/routes_actions.py | 510 ++ backend/app/api/routes_ai.py | 210 + backend/app/api/routes_ai_remediation.py | 83 + backend/app/api/routes_ai_security.py | 83 + backend/app/api/routes_approvals.py | 150 + backend/app/api/routes_apps.py | 207 + backend/app/api/routes_audit.py | 25 + backend/app/api/routes_authentik.py | 92 + backend/app/api/routes_connectors.py | 133 + backend/app/api/routes_containers.py | 68 + backend/app/api/routes_deluge.py | 208 + backend/app/api/routes_deployments.py | 140 + backend/app/api/routes_enrollments.py | 102 + backend/app/api/routes_gitea_actions.py | 37 + backend/app/api/routes_health.py | 7 + backend/app/api/routes_integrations.py | 150 + backend/app/api/routes_inventory.py | 151 + backend/app/api/routes_jobs.py | 70 + backend/app/api/routes_knowledge.py | 41 + backend/app/api/routes_media_pipeline.py | 60 + backend/app/api/routes_nginx_proxy_manager.py | 108 + backend/app/api/routes_notifications.py | 139 + backend/app/api/routes_observability.py | 633 ++ backend/app/api/routes_playbooks.py | 227 + backend/app/api/routes_plex.py | 169 + backend/app/api/routes_policies.py | 91 + .../app/api/routes_privatemuse_artifacts.py | 139 + backend/app/api/routes_prowlarr.py | 98 + backend/app/api/routes_radarr.py | 177 + backend/app/api/routes_runtime.py | 258 + backend/app/api/routes_safety_center.py | 67 + backend/app/api/routes_setup.py | 233 + backend/app/api/routes_sonarr.py | 177 + backend/app/api/routes_stacks.py | 40 + backend/app/api/routes_system.py | 76 + backend/app/api/routes_tautulli.py | 122 + backend/app/api/routes_unraid.py | 166 + backend/app/api/routes_voice.py | 388 + backend/app/api/routes_voice_operator.py | 40 + backend/app/api/routes_widefrog.py | 160 + backend/app/connectors/authentik.py | 817 ++ backend/app/connectors/base.py | 14 + backend/app/connectors/deluge.py | 370 + backend/app/connectors/gitea_actions.py | 496 ++ backend/app/connectors/nginx_proxy_manager.py | 645 ++ backend/app/connectors/plex.py | 329 + backend/app/connectors/portainer.py | 492 ++ backend/app/connectors/prowlarr.py | 299 + backend/app/connectors/radarr.py | 314 + backend/app/connectors/sonarr.py | 300 + backend/app/connectors/tautulli.py | 384 + backend/app/connectors/unraid.py | 742 ++ backend/app/connectors/widefrog.py | 210 + backend/app/core/config.py | 362 + backend/app/core/network_security.py | 70 + backend/app/core/redaction.py | 34 + backend/app/core/security.py | 255 + backend/app/core/time.py | 8 + backend/app/core/version.py | 1 + backend/app/db/init_db.py | 944 +++ backend/app/db/models.py | 398 + backend/app/db/session.py | 44 + backend/app/main.py | 363 + backend/app/mcp_server.py | 839 ++ backend/app/schemas/ai_remediation.py | 126 + backend/app/schemas/ai_security.py | 87 + backend/app/schemas/deployment.py | 372 + backend/app/schemas/enrollment.py | 375 + backend/app/services/__init__.py | 1 + backend/app/services/action_execution.py | 2264 +++++ backend/app/services/action_registry.py | 614 ++ .../app/services/ai_remediation_planner.py | 551 ++ backend/app/services/ai_security_reviewer.py | 1017 +++ backend/app/services/app_classifier.py | 195 + backend/app/services/app_registry.py | 42 + backend/app/services/approval_service.py | 151 + backend/app/services/audit_logger.py | 66 + backend/app/services/connector_health.py | 91 + .../app/services/container_restart_service.py | 296 + .../services/controller_artifact_promoter.py | 382 + .../services/controller_bootstrap_import.py | 35 + backend/app/services/deployment_broker.py | 562 ++ .../app/services/deployment_control_plane.py | 613 ++ .../services/deployment_target_registry.py | 537 ++ backend/app/services/discovery_scheduler.py | 327 + backend/app/services/enrollment_plane.py | 986 +++ backend/app/services/enrollment_profiles.py | 60 + .../gitea_project_auto_registration.py | 611 ++ backend/app/services/healthcheck_service.py | 3 + .../home_assistant_voice_readiness.py | 197 + backend/app/services/incident_service.py | 143 + backend/app/services/inventory_discovery.py | 1715 ++++ backend/app/services/job_runner.py | 116 + backend/app/services/knowledge_service.py | 396 + backend/app/services/maintenance_scheduler.py | 124 + .../mcp_hub_deployment_reconciliation.py | 691 ++ backend/app/services/mcp_operations.py | 405 + ...dec_dashboard_deployment_reconciliation.py | 768 ++ .../app/services/media_pipeline_service.py | 637 ++ backend/app/services/n8n_integration.py | 295 + .../notification_channels/__init__.py | 2 + .../notification_channels/external.py | 134 + .../notification_channels/home_assistant.py | 211 + backend/app/services/notification_service.py | 1050 +++ backend/app/services/policy_engine.py | 239 + .../portfolio_deployment_enrollment.py | 815 ++ .../services/portfolio_deployment_recovery.py | 387 + backend/app/services/preflight_service.py | 30 + .../privatemuse_artifact_installer.py | 233 + .../privatemuse_deployment_enrollment.py | 546 ++ .../privatemuse_deployment_recovery.py | 1228 +++ .../services/privatemuse_webhook_repair.py | 324 + .../services/repopulse_adoption_journal.py | 300 + .../services/repopulse_adoption_ownership.py | 629 ++ .../services/repopulse_adoption_preflight.py | 735 ++ .../app/services/repopulse_adoption_runner.py | 1277 +++ .../repopulse_legacy_adoption_protocol.py | 984 +++ backend/app/services/repopulse_proxy_stage.py | 114 + .../app/services/repopulse_secret_import.py | 88 + .../services/repopulse_target_credential.py | 96 + backend/app/services/repopulse_trust_stage.py | 92 + backend/app/services/request_metrics.py | 130 + backend/app/services/runner_fleet.py | 82 + .../runnerless_target_provisioning.py | 531 ++ backend/app/services/runtime_service.py | 603 ++ backend/app/services/safety_center_service.py | 850 ++ backend/app/services/secret_store.py | 197 + backend/app/services/setup_service.py | 646 ++ backend/app/services/unraid_vm_service.py | 363 + backend/app/services/voice_catalog.py | 582 ++ .../app/services/workmesh_first_deployment.py | 588 ++ backend/app/services/workmesh_promotion.py | 722 ++ backend/requirements-dev.txt | 3 + backend/requirements.txt | 9 + backend/tests/test_ai_remediation_planner.py | 345 + backend/tests/test_ai_security_reviewer.py | 1054 +++ .../test_appops_self_upgrade_controller.py | 377 + backend/tests/test_authentik_connector.py | 673 ++ backend/tests/test_container_restart.py | 2866 +++++++ .../test_controller_artifact_promoter.py | 80 + .../test_controller_bootstrap_import_host.py | 43 + .../test_controller_state_reconciliation.py | 255 + backend/tests/test_deluge_connector.py | 405 + backend/tests/test_deploy_broker.py | 7318 +++++++++++++++++ .../tests/test_deployment_auto_rollback.py | 400 + backend/tests/test_deployment_contract.py | 85 + .../tests/test_deployment_control_plane.py | 345 + backend/tests/test_deployment_hygiene.py | 54 + .../tests/test_deployment_target_registry.py | 239 + backend/tests/test_dockdeck_classifier.py | 32 + backend/tests/test_enrollment_plane.py | 453 + backend/tests/test_gitea_actions.py | 326 + .../test_gitea_project_auto_registration.py | 395 + .../tests/test_home_assistant_voice_export.py | 217 + backend/tests/test_inventory_discovery.py | 1237 +++ backend/tests/test_live_observability.py | 164 + .../tests/test_managed_validation_checkout.py | 13 + .../tests/test_managed_validation_parallel.py | 18 + .../test_mcp_hub_deployment_reconciliation.py | 282 + .../test_mcp_hub_policy_reconciliation.py | 165 + .../tests/test_mcp_rag_n8n_integrations.py | 1264 +++ ...dec_dashboard_deployment_reconciliation.py | 190 + ...a_codec_dashboard_reconcile_diagnostics.py | 382 + ...a_codec_dashboard_reconciliation_action.py | 117 + backend/tests/test_media_pipeline.py | 386 + .../tests/test_mediarr_validation_window.py | 68 + .../test_nginx_proxy_manager_connector.py | 927 +++ backend/tests/test_notifications.py | 1082 +++ backend/tests/test_performance_regressions.py | 35 + backend/tests/test_plex_connector.py | 345 + .../tests/test_portainer_restart_timeout.py | 41 + .../test_portfolio_deployment_enrollment.py | 156 + .../test_portfolio_deployment_recovery.py | 62 + .../tests/test_privatemuse_adoption_health.py | 206 + .../test_privatemuse_artifact_installer.py | 265 + .../test_privatemuse_deployment_action.py | 249 + .../test_privatemuse_deployment_enrollment.py | 189 + .../test_privatemuse_deployment_recovery.py | 630 ++ .../tests/test_privatemuse_f84_adoption.py | 535 ++ .../test_privatemuse_f84_receipt_diagnosis.py | 311 + .../tests/test_privatemuse_webhook_repair.py | 177 + backend/tests/test_production_runtime.py | 411 + backend/tests/test_prowlarr_connector.py | 333 + backend/tests/test_repopulse_adoption_cli.py | 413 + .../tests/test_repopulse_adoption_effects.py | 1082 +++ .../tests/test_repopulse_adoption_executor.py | 818 ++ .../tests/test_repopulse_adoption_journal.py | 497 ++ .../test_repopulse_adoption_ownership.py | 606 ++ .../test_repopulse_adoption_preflight.py | 368 + .../tests/test_repopulse_adoption_runner.py | 631 ++ ...test_repopulse_adoption_storage_effects.py | 345 + ...est_repopulse_controller_adoption_store.py | 1062 +++ ...test_repopulse_legacy_adoption_protocol.py | 512 ++ .../test_repopulse_maintenance_environment.py | 75 + backend/tests/test_repopulse_proxy_stage.py | 95 + backend/tests/test_repopulse_secret_import.py | 61 + .../test_repopulse_secret_import_host.py | 43 + .../test_repopulse_target_attestation.py | 308 + .../tests/test_repopulse_target_credential.py | 71 + backend/tests/test_repopulse_trust_stage.py | 84 + backend/tests/test_request_metrics.py | 13 + backend/tests/test_run_repopulse_adoption.py | 123 + .../tests/test_runner_3_5_upgrade_contract.py | 94 + .../tests/test_runner_capacity_controller.py | 220 + backend/tests/test_runner_capacity_policy.py | 189 + backend/tests/test_runner_fleet.py | 180 + .../test_runner_fleet_scheduler_recovery.py | 1047 +++ .../test_runner_fleet_v2_execution_surface.py | 50 + ...test_runner_scheduler_liveness_recovery.py | 101 + backend/tests/test_secret_storage.py | 271 + backend/tests/test_security_boundaries.py | 122 + backend/tests/test_setup_onboarding.py | 343 + backend/tests/test_setup_reconciliation.py | 535 ++ backend/tests/test_sqlite_runtime.py | 27 + backend/tests/test_tautulli_connector.py | 325 + backend/tests/test_unraid_connector.py | 478 ++ backend/tests/test_unraid_vm_actions.py | 154 + backend/tests/test_widefrog_connector.py | 274 + .../tests/test_workmesh_promotion_contract.py | 34 + components.json | 20 + docker-compose.yml | 14 + docker/entrypoint.sh | 47 + eslint.config.js | 29 + index.html | 20 + package.json | 95 + pnpm-lock.yaml | 5601 +++++++++++++ pnpm-workspace.yaml | 383 + postcss.config.js | 6 + public/apple-touch-icon.png | Bin 0 -> 6015 bytes public/appops-icon-192.png | Bin 0 -> 6567 bytes public/appops-icon-32.png | Bin 0 -> 1168 bytes public/appops-icon.png | Bin 0 -> 21422 bytes public/appops-icon.svg | 12 + public/favicon.ico | Bin 0 -> 8565 bytes public/fixtures/appops-canary.safetensors | Bin 0 -> 67 bytes public/robots.txt | 14 + public/site.webmanifest | 21 + src/App.css | 8 + src/App.tsx | 48 + src/api/appopsClient.livePolicy.test.ts | 17 + src/api/appopsClient.ts | 12 + src/api/liveClient.ts | 818 ++ src/api/operatorSession.test.ts | 26 + src/api/operatorSession.ts | 31 + src/components/ActionResultNotice.tsx | 25 + src/components/AppDetailActionPanel.tsx | 42 + src/components/AppMetadataEditor.tsx | 96 + src/components/AppShell.tsx | 133 + src/components/CommandPalette.tsx | 142 + .../ConnectorActionResultNotice.tsx | 40 + .../ExternalNotificationChannels.tsx | 115 + src/components/LiveActivityFeed.tsx | 138 + src/components/MetricCard.tsx | 76 + src/components/OperatorReadOnlyBanner.tsx | 22 + src/components/PolicyAction.tsx | 289 + src/components/RiskBadge.tsx | 28 + src/components/RouteLoadingCard.tsx | 31 + src/components/Sidebar.tsx | 101 + src/components/StatusChip.tsx | 61 + src/components/SystemLoadSvgChart.tsx | 38 + src/components/TopStatusBar.tsx | 158 + .../integrations/GovernedIntegrationFlow.tsx | 271 + src/components/ui/accordion.tsx | 56 + src/components/ui/alert-dialog.tsx | 139 + src/components/ui/alert.tsx | 59 + src/components/ui/aspect-ratio.tsx | 5 + src/components/ui/avatar.tsx | 48 + src/components/ui/badge.tsx | 38 + src/components/ui/breadcrumb.tsx | 115 + src/components/ui/button.tsx | 58 + src/components/ui/calendar.tsx | 72 + src/components/ui/card.tsx | 86 + src/components/ui/carousel.tsx | 260 + src/components/ui/chart.tsx | 363 + src/components/ui/checkbox.tsx | 28 + src/components/ui/collapsible.tsx | 9 + src/components/ui/command.tsx | 154 + src/components/ui/context-menu.tsx | 198 + src/components/ui/dialog.tsx | 120 + src/components/ui/drawer.tsx | 116 + src/components/ui/dropdown-menu.tsx | 198 + src/components/ui/form.tsx | 178 + src/components/ui/hover-card.tsx | 27 + src/components/ui/input-otp.tsx | 69 + src/components/ui/input.tsx | 22 + src/components/ui/label.tsx | 24 + src/components/ui/menubar.tsx | 234 + src/components/ui/navigation-menu.tsx | 129 + src/components/ui/pagination.tsx | 117 + src/components/ui/popover.tsx | 29 + src/components/ui/progress.tsx | 26 + src/components/ui/radio-group.tsx | 42 + src/components/ui/resizable.tsx | 43 + src/components/ui/scroll-area.tsx | 46 + src/components/ui/select.tsx | 158 + src/components/ui/separator.tsx | 29 + src/components/ui/sheet.tsx | 139 + src/components/ui/sidebar.tsx | 770 ++ src/components/ui/skeleton.tsx | 15 + src/components/ui/slider.tsx | 26 + src/components/ui/sonner.tsx | 29 + src/components/ui/switch.tsx | 27 + src/components/ui/table.tsx | 117 + src/components/ui/tabs.tsx | 53 + src/components/ui/textarea.tsx | 23 + src/components/ui/toast.tsx | 127 + src/components/ui/toaster.tsx | 33 + src/components/ui/toggle-group.tsx | 59 + src/components/ui/toggle.tsx | 44 + src/components/ui/tooltip.tsx | 28 + src/components/ui/use-toast.ts | 3 + src/globals.css | 374 + src/hooks/use-mobile.tsx | 21 + src/hooks/use-toast.ts | 188 + src/hooks/useOperatorSession.ts | 17 + src/lib/accessibilityShell.test.ts | 28 + src/lib/actionRegistryView.test.ts | 104 + src/lib/actionRegistryView.ts | 202 + src/lib/actionResultStatus.test.ts | 68 + src/lib/actionResultStatus.ts | 60 + src/lib/aiChatView.test.ts | 12 + src/lib/aiChatView.ts | 13 + src/lib/aiDashboardView.test.ts | 36 + src/lib/aiDashboardView.ts | 39 + src/lib/aiRecommendationView.test.ts | 15 + src/lib/aiRecommendationView.ts | 17 + src/lib/aiWorkflowActions.test.ts | 26 + src/lib/aiWorkflowActions.ts | 28 + src/lib/appCatalogView.test.ts | 93 + src/lib/appCatalogView.ts | 59 + src/lib/appDetailActions.test.ts | 31 + src/lib/appDetailActions.ts | 9 + src/lib/appFocusView.test.ts | 55 + src/lib/appFocusView.ts | 87 + src/lib/appReviewClosure.test.ts | 77 + src/lib/appReviewClosure.ts | 125 + src/lib/approvalFollowupView.test.ts | 100 + src/lib/approvalFollowupView.ts | 142 + src/lib/auditEventDetailView.test.ts | 87 + src/lib/auditEventDetailView.ts | 64 + src/lib/auditLogView.test.ts | 78 + src/lib/auditLogView.ts | 89 + src/lib/backendDateTime.test.ts | 14 + src/lib/backendDateTime.ts | 16 + src/lib/clipboard.ts | 18 + src/lib/commandActionFollowup.test.ts | 58 + src/lib/commandActionFollowup.ts | 63 + src/lib/commandActionView.test.ts | 38 + src/lib/commandActionView.ts | 50 + src/lib/commandCenterQuery.test.ts | 17 + src/lib/commandCenterQuery.ts | 3 + src/lib/commandPaletteNavigation.test.ts | 25 + src/lib/commandPaletteNavigation.ts | 19 + src/lib/commandPaletteShortcut.test.ts | 16 + src/lib/commandPaletteShortcut.ts | 11 + src/lib/commandPreflight.test.ts | 26 + src/lib/commandPreflight.ts | 13 + src/lib/connectorActionFollowup.test.ts | 130 + src/lib/connectorActionFollowup.ts | 90 + src/lib/connectorConfigLink.test.ts | 14 + src/lib/connectorConfigLink.ts | 3 + src/lib/connectorDrillView.test.ts | 89 + src/lib/connectorDrillView.ts | 104 + src/lib/connectorMutationView.test.ts | 48 + src/lib/connectorMutationView.ts | 46 + src/lib/connectorPanelStatus.test.ts | 21 + src/lib/connectorPanelStatus.ts | 4 + src/lib/containerFocusView.test.ts | 57 + src/lib/containerFocusView.ts | 100 + src/lib/containerInventoryMerge.test.ts | 106 + src/lib/containerInventoryMerge.ts | 69 + src/lib/containerLogExport.test.ts | 26 + src/lib/containerLogExport.ts | 30 + src/lib/exposureWarningTriage.test.ts | 38 + src/lib/exposureWarningTriage.ts | 114 + .../homeAssistantNotificationState.test.ts | 110 + src/lib/homeAssistantNotificationState.ts | 165 + src/lib/ignoredLoadError.test.ts | 15 + src/lib/ignoredLoadError.ts | 4 + src/lib/incidentView.test.ts | 73 + src/lib/incidentView.ts | 87 + src/lib/integrationFlow.test.ts | 82 + src/lib/integrationFlow.ts | 60 + src/lib/inventoryActionFollowup.test.ts | 51 + src/lib/inventoryActionFollowup.ts | 193 + src/lib/inventoryLedgerView.test.ts | 50 + src/lib/inventoryLedgerView.ts | 68 + src/lib/inventoryReviewQueue.test.ts | 147 + src/lib/inventoryReviewQueue.ts | 222 + src/lib/jobCancelFollowup.test.ts | 56 + src/lib/jobCancelFollowup.ts | 39 + src/lib/jobRunView.test.ts | 68 + src/lib/jobRunView.ts | 78 + src/lib/liveActivityFeedView.test.ts | 80 + src/lib/liveActivityFeedView.ts | 103 + src/lib/mediaDownloadTriage.test.ts | 37 + src/lib/mediaDownloadTriage.ts | 64 + src/lib/mediaPipelinePageLoad.test.ts | 94 + src/lib/mediaPipelinePageLoad.ts | 134 + src/lib/navigationGroups.test.ts | 82 + src/lib/navigationGroups.ts | 136 + src/lib/networkSnapshot.test.ts | 58 + src/lib/networkSnapshot.ts | 158 + src/lib/npmAppDetailLoadState.test.ts | 60 + src/lib/npmAppDetailLoadState.ts | 82 + src/lib/operationMapView.test.ts | 111 + src/lib/operationMapView.ts | 157 + src/lib/operatorActionQueue.test.ts | 141 + src/lib/operatorActionQueue.ts | 184 + src/lib/operatorError.test.ts | 20 + src/lib/operatorError.ts | 16 + src/lib/overviewTopIssue.test.ts | 49 + src/lib/overviewTopIssue.ts | 16 + src/lib/playbookView.test.ts | 63 + src/lib/playbookView.ts | 82 + src/lib/policyActionState.test.ts | 48 + src/lib/policyActionState.ts | 59 + src/lib/policyView.test.ts | 86 + src/lib/policyView.ts | 89 + src/lib/remediationFollowupView.test.ts | 87 + src/lib/remediationFollowupView.ts | 92 + src/lib/remediationPlannerLoadState.test.ts | 66 + src/lib/remediationPlannerLoadState.ts | 45 + src/lib/responsiveLayout.test.ts | 167 + src/lib/reviewWorkload.test.ts | 109 + src/lib/reviewWorkload.ts | 136 + src/lib/routeContentWidth.ts | 41 + src/lib/routeLoadingView.test.ts | 47 + src/lib/routeLoadingView.ts | 177 + src/lib/routeSplitting.test.ts | 35 + src/lib/routeTitle.test.ts | 35 + src/lib/routeTitle.ts | 33 + src/lib/safetyActionFeedback.test.ts | 44 + src/lib/safetyActionFeedback.ts | 68 + src/lib/secretOwnerLink.test.ts | 59 + src/lib/secretOwnerLink.ts | 90 + src/lib/settingsRuntimeView.test.ts | 56 + src/lib/settingsRuntimeView.ts | 33 + src/lib/setupBannerView.test.ts | 57 + src/lib/setupBannerView.ts | 15 + src/lib/setupCompletionView.test.ts | 56 + src/lib/setupCompletionView.ts | 9 + src/lib/setupConnectorContext.test.ts | 88 + src/lib/setupConnectorContext.ts | 59 + src/lib/setupConnectorFeedback.test.ts | 56 + src/lib/setupConnectorFeedback.ts | 123 + src/lib/setupConnectorForms.test.ts | 45 + src/lib/setupConnectorForms.ts | 44 + src/lib/setupContextView.test.ts | 28 + src/lib/setupContextView.ts | 34 + src/lib/setupDeepLink.test.ts | 27 + src/lib/setupDeepLink.ts | 22 + src/lib/setupNextActions.test.ts | 99 + src/lib/setupNextActions.ts | 89 + src/lib/systemLoadChartView.test.ts | 64 + src/lib/systemLoadChartView.ts | 84 + src/lib/systemSummaryView.test.ts | 19 + src/lib/systemSummaryView.ts | 24 + src/lib/utils.ts | 6 + src/main.tsx | 16 + src/pages/AiRemediationPlanner.tsx | 544 ++ src/pages/AiSecurityReviewer.tsx | 649 ++ src/pages/AppDetail.tsx | 2712 ++++++ src/pages/Approvals.tsx | 349 + src/pages/Apps.tsx | 550 ++ src/pages/Apps.ux.test.ts | 28 + src/pages/Audit.tsx | 382 + src/pages/CommandCenter.tsx | 924 +++ src/pages/Connectors.tsx | 468 ++ src/pages/Containers.tsx | 538 ++ src/pages/Deployments.tsx | 322 + src/pages/Incidents.tsx | 276 + src/pages/Index.tsx | 7 + src/pages/Integrations.tsx | 243 + src/pages/Integrations.ux.test.ts | 56 + src/pages/Inventory.loadState.test.ts | 38 + src/pages/Inventory.tsx | 944 +++ src/pages/Jobs.tsx | 275 + src/pages/MediaPipeline.tsx | 556 ++ src/pages/Network.tsx | 476 ++ src/pages/NotFound.tsx | 27 + src/pages/Notifications.tsx | 632 ++ src/pages/Notifications.ux.test.ts | 18 + src/pages/OperationsMap.tsx | 338 + src/pages/Overview.liveData.test.ts | 53 + src/pages/Overview.tsx | 515 ++ src/pages/Playbooks.tsx | 273 + src/pages/Policies.tsx | 437 + src/pages/RunnerFleet.tsx | 80 + src/pages/Safety.tsx | 481 ++ src/pages/Secrets.tsx | 247 + src/pages/Settings.tsx | 569 ++ src/pages/Setup.tsx | 1146 +++ src/pages/Stacks.tsx | 172 + src/pages/Storage.liveData.test.ts | 13 + src/pages/Storage.tsx | 366 + src/pages/Unraid.tsx | 329 + src/pages/VirtualMachines.tsx | 230 + src/pages/Voice.tsx | 485 ++ src/pages/ai/AIChat.tsx | 175 + src/pages/ai/AIDashboard.loadState.test.ts | 11 + src/pages/ai/AIDashboard.tsx | 228 + src/pages/ai/AIRecommendations.tsx | 138 + src/pages/ai/AITools.tsx | 144 + src/routes/appRoutes.tsx | 80 + src/types/ai.ts | 71 + src/types/appops.ts | 2711 ++++++ src/utils/toast.ts | 17 + src/vite-env.d.ts | 1 + tailwind.config.ts | 230 + tsconfig.app.json | 30 + tsconfig.json | 19 + tsconfig.node.json | 22 + vite.config.ts | 28 + 528 files changed, 131054 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitleaks.toml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 PUBLIC_SOURCE_EXPORT.md create mode 100644 PUBLIC_SOURCE_MANIFEST.json create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/connector_runtime.py create mode 100644 backend/app/api/routes_actions.py create mode 100644 backend/app/api/routes_ai.py create mode 100644 backend/app/api/routes_ai_remediation.py create mode 100644 backend/app/api/routes_ai_security.py create mode 100644 backend/app/api/routes_approvals.py create mode 100644 backend/app/api/routes_apps.py create mode 100644 backend/app/api/routes_audit.py create mode 100644 backend/app/api/routes_authentik.py create mode 100644 backend/app/api/routes_connectors.py create mode 100644 backend/app/api/routes_containers.py create mode 100644 backend/app/api/routes_deluge.py create mode 100644 backend/app/api/routes_deployments.py create mode 100644 backend/app/api/routes_enrollments.py create mode 100644 backend/app/api/routes_gitea_actions.py create mode 100644 backend/app/api/routes_health.py create mode 100644 backend/app/api/routes_integrations.py create mode 100644 backend/app/api/routes_inventory.py create mode 100644 backend/app/api/routes_jobs.py create mode 100644 backend/app/api/routes_knowledge.py create mode 100644 backend/app/api/routes_media_pipeline.py create mode 100644 backend/app/api/routes_nginx_proxy_manager.py create mode 100644 backend/app/api/routes_notifications.py create mode 100644 backend/app/api/routes_observability.py create mode 100644 backend/app/api/routes_playbooks.py create mode 100644 backend/app/api/routes_plex.py create mode 100644 backend/app/api/routes_policies.py create mode 100644 backend/app/api/routes_privatemuse_artifacts.py create mode 100644 backend/app/api/routes_prowlarr.py create mode 100644 backend/app/api/routes_radarr.py create mode 100644 backend/app/api/routes_runtime.py create mode 100644 backend/app/api/routes_safety_center.py create mode 100644 backend/app/api/routes_setup.py create mode 100644 backend/app/api/routes_sonarr.py create mode 100644 backend/app/api/routes_stacks.py create mode 100644 backend/app/api/routes_system.py create mode 100644 backend/app/api/routes_tautulli.py create mode 100644 backend/app/api/routes_unraid.py create mode 100644 backend/app/api/routes_voice.py create mode 100644 backend/app/api/routes_voice_operator.py create mode 100644 backend/app/api/routes_widefrog.py create mode 100644 backend/app/connectors/authentik.py create mode 100644 backend/app/connectors/base.py create mode 100644 backend/app/connectors/deluge.py create mode 100644 backend/app/connectors/gitea_actions.py create mode 100644 backend/app/connectors/nginx_proxy_manager.py create mode 100644 backend/app/connectors/plex.py create mode 100644 backend/app/connectors/portainer.py create mode 100644 backend/app/connectors/prowlarr.py create mode 100644 backend/app/connectors/radarr.py create mode 100644 backend/app/connectors/sonarr.py create mode 100644 backend/app/connectors/tautulli.py create mode 100644 backend/app/connectors/unraid.py create mode 100644 backend/app/connectors/widefrog.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/network_security.py create mode 100644 backend/app/core/redaction.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/core/time.py create mode 100644 backend/app/core/version.py create mode 100644 backend/app/db/init_db.py create mode 100644 backend/app/db/models.py create mode 100644 backend/app/db/session.py create mode 100644 backend/app/main.py create mode 100644 backend/app/mcp_server.py create mode 100644 backend/app/schemas/ai_remediation.py create mode 100644 backend/app/schemas/ai_security.py create mode 100644 backend/app/schemas/deployment.py create mode 100644 backend/app/schemas/enrollment.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/action_execution.py create mode 100644 backend/app/services/action_registry.py create mode 100644 backend/app/services/ai_remediation_planner.py create mode 100644 backend/app/services/ai_security_reviewer.py create mode 100644 backend/app/services/app_classifier.py create mode 100644 backend/app/services/app_registry.py create mode 100644 backend/app/services/approval_service.py create mode 100644 backend/app/services/audit_logger.py create mode 100644 backend/app/services/connector_health.py create mode 100644 backend/app/services/container_restart_service.py create mode 100644 backend/app/services/controller_artifact_promoter.py create mode 100644 backend/app/services/controller_bootstrap_import.py create mode 100644 backend/app/services/deployment_broker.py create mode 100644 backend/app/services/deployment_control_plane.py create mode 100644 backend/app/services/deployment_target_registry.py create mode 100644 backend/app/services/discovery_scheduler.py create mode 100644 backend/app/services/enrollment_plane.py create mode 100644 backend/app/services/enrollment_profiles.py create mode 100644 backend/app/services/gitea_project_auto_registration.py create mode 100644 backend/app/services/healthcheck_service.py create mode 100644 backend/app/services/home_assistant_voice_readiness.py create mode 100644 backend/app/services/incident_service.py create mode 100644 backend/app/services/inventory_discovery.py create mode 100644 backend/app/services/job_runner.py create mode 100644 backend/app/services/knowledge_service.py create mode 100644 backend/app/services/maintenance_scheduler.py create mode 100644 backend/app/services/mcp_hub_deployment_reconciliation.py create mode 100644 backend/app/services/mcp_operations.py create mode 100644 backend/app/services/media_codec_dashboard_deployment_reconciliation.py create mode 100644 backend/app/services/media_pipeline_service.py create mode 100644 backend/app/services/n8n_integration.py create mode 100644 backend/app/services/notification_channels/__init__.py create mode 100644 backend/app/services/notification_channels/external.py create mode 100644 backend/app/services/notification_channels/home_assistant.py create mode 100644 backend/app/services/notification_service.py create mode 100644 backend/app/services/policy_engine.py create mode 100644 backend/app/services/portfolio_deployment_enrollment.py create mode 100644 backend/app/services/portfolio_deployment_recovery.py create mode 100644 backend/app/services/preflight_service.py create mode 100644 backend/app/services/privatemuse_artifact_installer.py create mode 100644 backend/app/services/privatemuse_deployment_enrollment.py create mode 100644 backend/app/services/privatemuse_deployment_recovery.py create mode 100644 backend/app/services/privatemuse_webhook_repair.py create mode 100644 backend/app/services/repopulse_adoption_journal.py create mode 100644 backend/app/services/repopulse_adoption_ownership.py create mode 100644 backend/app/services/repopulse_adoption_preflight.py create mode 100644 backend/app/services/repopulse_adoption_runner.py create mode 100644 backend/app/services/repopulse_legacy_adoption_protocol.py create mode 100644 backend/app/services/repopulse_proxy_stage.py create mode 100644 backend/app/services/repopulse_secret_import.py create mode 100644 backend/app/services/repopulse_target_credential.py create mode 100644 backend/app/services/repopulse_trust_stage.py create mode 100644 backend/app/services/request_metrics.py create mode 100644 backend/app/services/runner_fleet.py create mode 100644 backend/app/services/runnerless_target_provisioning.py create mode 100644 backend/app/services/runtime_service.py create mode 100644 backend/app/services/safety_center_service.py create mode 100644 backend/app/services/secret_store.py create mode 100644 backend/app/services/setup_service.py create mode 100644 backend/app/services/unraid_vm_service.py create mode 100644 backend/app/services/voice_catalog.py create mode 100644 backend/app/services/workmesh_first_deployment.py create mode 100644 backend/app/services/workmesh_promotion.py create mode 100644 backend/requirements-dev.txt create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_ai_remediation_planner.py create mode 100644 backend/tests/test_ai_security_reviewer.py create mode 100644 backend/tests/test_appops_self_upgrade_controller.py create mode 100644 backend/tests/test_authentik_connector.py create mode 100644 backend/tests/test_container_restart.py create mode 100644 backend/tests/test_controller_artifact_promoter.py create mode 100644 backend/tests/test_controller_bootstrap_import_host.py create mode 100644 backend/tests/test_controller_state_reconciliation.py create mode 100644 backend/tests/test_deluge_connector.py create mode 100644 backend/tests/test_deploy_broker.py create mode 100644 backend/tests/test_deployment_auto_rollback.py create mode 100644 backend/tests/test_deployment_contract.py create mode 100644 backend/tests/test_deployment_control_plane.py create mode 100644 backend/tests/test_deployment_hygiene.py create mode 100644 backend/tests/test_deployment_target_registry.py create mode 100644 backend/tests/test_dockdeck_classifier.py create mode 100644 backend/tests/test_enrollment_plane.py create mode 100644 backend/tests/test_gitea_actions.py create mode 100644 backend/tests/test_gitea_project_auto_registration.py create mode 100644 backend/tests/test_home_assistant_voice_export.py create mode 100644 backend/tests/test_inventory_discovery.py create mode 100644 backend/tests/test_live_observability.py create mode 100644 backend/tests/test_managed_validation_checkout.py create mode 100644 backend/tests/test_managed_validation_parallel.py create mode 100644 backend/tests/test_mcp_hub_deployment_reconciliation.py create mode 100644 backend/tests/test_mcp_hub_policy_reconciliation.py create mode 100644 backend/tests/test_mcp_rag_n8n_integrations.py create mode 100644 backend/tests/test_media_codec_dashboard_deployment_reconciliation.py create mode 100644 backend/tests/test_media_codec_dashboard_reconcile_diagnostics.py create mode 100644 backend/tests/test_media_codec_dashboard_reconciliation_action.py create mode 100644 backend/tests/test_media_pipeline.py create mode 100644 backend/tests/test_mediarr_validation_window.py create mode 100644 backend/tests/test_nginx_proxy_manager_connector.py create mode 100644 backend/tests/test_notifications.py create mode 100644 backend/tests/test_performance_regressions.py create mode 100644 backend/tests/test_plex_connector.py create mode 100644 backend/tests/test_portainer_restart_timeout.py create mode 100644 backend/tests/test_portfolio_deployment_enrollment.py create mode 100644 backend/tests/test_portfolio_deployment_recovery.py create mode 100644 backend/tests/test_privatemuse_adoption_health.py create mode 100644 backend/tests/test_privatemuse_artifact_installer.py create mode 100644 backend/tests/test_privatemuse_deployment_action.py create mode 100644 backend/tests/test_privatemuse_deployment_enrollment.py create mode 100644 backend/tests/test_privatemuse_deployment_recovery.py create mode 100644 backend/tests/test_privatemuse_f84_adoption.py create mode 100644 backend/tests/test_privatemuse_f84_receipt_diagnosis.py create mode 100644 backend/tests/test_privatemuse_webhook_repair.py create mode 100644 backend/tests/test_production_runtime.py create mode 100644 backend/tests/test_prowlarr_connector.py create mode 100644 backend/tests/test_repopulse_adoption_cli.py create mode 100644 backend/tests/test_repopulse_adoption_effects.py create mode 100644 backend/tests/test_repopulse_adoption_executor.py create mode 100644 backend/tests/test_repopulse_adoption_journal.py create mode 100644 backend/tests/test_repopulse_adoption_ownership.py create mode 100644 backend/tests/test_repopulse_adoption_preflight.py create mode 100644 backend/tests/test_repopulse_adoption_runner.py create mode 100644 backend/tests/test_repopulse_adoption_storage_effects.py create mode 100644 backend/tests/test_repopulse_controller_adoption_store.py create mode 100644 backend/tests/test_repopulse_legacy_adoption_protocol.py create mode 100644 backend/tests/test_repopulse_maintenance_environment.py create mode 100644 backend/tests/test_repopulse_proxy_stage.py create mode 100644 backend/tests/test_repopulse_secret_import.py create mode 100644 backend/tests/test_repopulse_secret_import_host.py create mode 100644 backend/tests/test_repopulse_target_attestation.py create mode 100644 backend/tests/test_repopulse_target_credential.py create mode 100644 backend/tests/test_repopulse_trust_stage.py create mode 100644 backend/tests/test_request_metrics.py create mode 100644 backend/tests/test_run_repopulse_adoption.py create mode 100644 backend/tests/test_runner_3_5_upgrade_contract.py create mode 100644 backend/tests/test_runner_capacity_controller.py create mode 100644 backend/tests/test_runner_capacity_policy.py create mode 100644 backend/tests/test_runner_fleet.py create mode 100644 backend/tests/test_runner_fleet_scheduler_recovery.py create mode 100644 backend/tests/test_runner_fleet_v2_execution_surface.py create mode 100644 backend/tests/test_runner_scheduler_liveness_recovery.py create mode 100644 backend/tests/test_secret_storage.py create mode 100644 backend/tests/test_security_boundaries.py create mode 100644 backend/tests/test_setup_onboarding.py create mode 100644 backend/tests/test_setup_reconciliation.py create mode 100644 backend/tests/test_sqlite_runtime.py create mode 100644 backend/tests/test_tautulli_connector.py create mode 100644 backend/tests/test_unraid_connector.py create mode 100644 backend/tests/test_unraid_vm_actions.py create mode 100644 backend/tests/test_widefrog_connector.py create mode 100644 backend/tests/test_workmesh_promotion_contract.py create mode 100644 components.json create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 postcss.config.js create mode 100644 public/apple-touch-icon.png create mode 100644 public/appops-icon-192.png create mode 100644 public/appops-icon-32.png create mode 100644 public/appops-icon.png create mode 100644 public/appops-icon.svg create mode 100644 public/favicon.ico create mode 100644 public/fixtures/appops-canary.safetensors create mode 100644 public/robots.txt create mode 100644 public/site.webmanifest create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/api/appopsClient.livePolicy.test.ts create mode 100644 src/api/appopsClient.ts create mode 100644 src/api/liveClient.ts create mode 100644 src/api/operatorSession.test.ts create mode 100644 src/api/operatorSession.ts create mode 100644 src/components/ActionResultNotice.tsx create mode 100644 src/components/AppDetailActionPanel.tsx create mode 100644 src/components/AppMetadataEditor.tsx create mode 100644 src/components/AppShell.tsx create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/components/ConnectorActionResultNotice.tsx create mode 100644 src/components/ExternalNotificationChannels.tsx create mode 100644 src/components/LiveActivityFeed.tsx create mode 100644 src/components/MetricCard.tsx create mode 100644 src/components/OperatorReadOnlyBanner.tsx create mode 100644 src/components/PolicyAction.tsx create mode 100644 src/components/RiskBadge.tsx create mode 100644 src/components/RouteLoadingCard.tsx create mode 100644 src/components/Sidebar.tsx create mode 100644 src/components/StatusChip.tsx create mode 100644 src/components/SystemLoadSvgChart.tsx create mode 100644 src/components/TopStatusBar.tsx create mode 100644 src/components/integrations/GovernedIntegrationFlow.tsx create mode 100644 src/components/ui/accordion.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/aspect-ratio.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/breadcrumb.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/carousel.tsx create mode 100644 src/components/ui/chart.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/components/ui/collapsible.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/context-menu.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/drawer.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/hover-card.tsx create mode 100644 src/components/ui/input-otp.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/menubar.tsx create mode 100644 src/components/ui/navigation-menu.tsx create mode 100644 src/components/ui/pagination.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/radio-group.tsx create mode 100644 src/components/ui/resizable.tsx create mode 100644 src/components/ui/scroll-area.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/sheet.tsx create mode 100644 src/components/ui/sidebar.tsx create mode 100644 src/components/ui/skeleton.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/ui/toast.tsx create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/toggle-group.tsx create mode 100644 src/components/ui/toggle.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/components/ui/use-toast.ts create mode 100644 src/globals.css create mode 100644 src/hooks/use-mobile.tsx create mode 100644 src/hooks/use-toast.ts create mode 100644 src/hooks/useOperatorSession.ts create mode 100644 src/lib/accessibilityShell.test.ts create mode 100644 src/lib/actionRegistryView.test.ts create mode 100644 src/lib/actionRegistryView.ts create mode 100644 src/lib/actionResultStatus.test.ts create mode 100644 src/lib/actionResultStatus.ts create mode 100644 src/lib/aiChatView.test.ts create mode 100644 src/lib/aiChatView.ts create mode 100644 src/lib/aiDashboardView.test.ts create mode 100644 src/lib/aiDashboardView.ts create mode 100644 src/lib/aiRecommendationView.test.ts create mode 100644 src/lib/aiRecommendationView.ts create mode 100644 src/lib/aiWorkflowActions.test.ts create mode 100644 src/lib/aiWorkflowActions.ts create mode 100644 src/lib/appCatalogView.test.ts create mode 100644 src/lib/appCatalogView.ts create mode 100644 src/lib/appDetailActions.test.ts create mode 100644 src/lib/appDetailActions.ts create mode 100644 src/lib/appFocusView.test.ts create mode 100644 src/lib/appFocusView.ts create mode 100644 src/lib/appReviewClosure.test.ts create mode 100644 src/lib/appReviewClosure.ts create mode 100644 src/lib/approvalFollowupView.test.ts create mode 100644 src/lib/approvalFollowupView.ts create mode 100644 src/lib/auditEventDetailView.test.ts create mode 100644 src/lib/auditEventDetailView.ts create mode 100644 src/lib/auditLogView.test.ts create mode 100644 src/lib/auditLogView.ts create mode 100644 src/lib/backendDateTime.test.ts create mode 100644 src/lib/backendDateTime.ts create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/commandActionFollowup.test.ts create mode 100644 src/lib/commandActionFollowup.ts create mode 100644 src/lib/commandActionView.test.ts create mode 100644 src/lib/commandActionView.ts create mode 100644 src/lib/commandCenterQuery.test.ts create mode 100644 src/lib/commandCenterQuery.ts create mode 100644 src/lib/commandPaletteNavigation.test.ts create mode 100644 src/lib/commandPaletteNavigation.ts create mode 100644 src/lib/commandPaletteShortcut.test.ts create mode 100644 src/lib/commandPaletteShortcut.ts create mode 100644 src/lib/commandPreflight.test.ts create mode 100644 src/lib/commandPreflight.ts create mode 100644 src/lib/connectorActionFollowup.test.ts create mode 100644 src/lib/connectorActionFollowup.ts create mode 100644 src/lib/connectorConfigLink.test.ts create mode 100644 src/lib/connectorConfigLink.ts create mode 100644 src/lib/connectorDrillView.test.ts create mode 100644 src/lib/connectorDrillView.ts create mode 100644 src/lib/connectorMutationView.test.ts create mode 100644 src/lib/connectorMutationView.ts create mode 100644 src/lib/connectorPanelStatus.test.ts create mode 100644 src/lib/connectorPanelStatus.ts create mode 100644 src/lib/containerFocusView.test.ts create mode 100644 src/lib/containerFocusView.ts create mode 100644 src/lib/containerInventoryMerge.test.ts create mode 100644 src/lib/containerInventoryMerge.ts create mode 100644 src/lib/containerLogExport.test.ts create mode 100644 src/lib/containerLogExport.ts create mode 100644 src/lib/exposureWarningTriage.test.ts create mode 100644 src/lib/exposureWarningTriage.ts create mode 100644 src/lib/homeAssistantNotificationState.test.ts create mode 100644 src/lib/homeAssistantNotificationState.ts create mode 100644 src/lib/ignoredLoadError.test.ts create mode 100644 src/lib/ignoredLoadError.ts create mode 100644 src/lib/incidentView.test.ts create mode 100644 src/lib/incidentView.ts create mode 100644 src/lib/integrationFlow.test.ts create mode 100644 src/lib/integrationFlow.ts create mode 100644 src/lib/inventoryActionFollowup.test.ts create mode 100644 src/lib/inventoryActionFollowup.ts create mode 100644 src/lib/inventoryLedgerView.test.ts create mode 100644 src/lib/inventoryLedgerView.ts create mode 100644 src/lib/inventoryReviewQueue.test.ts create mode 100644 src/lib/inventoryReviewQueue.ts create mode 100644 src/lib/jobCancelFollowup.test.ts create mode 100644 src/lib/jobCancelFollowup.ts create mode 100644 src/lib/jobRunView.test.ts create mode 100644 src/lib/jobRunView.ts create mode 100644 src/lib/liveActivityFeedView.test.ts create mode 100644 src/lib/liveActivityFeedView.ts create mode 100644 src/lib/mediaDownloadTriage.test.ts create mode 100644 src/lib/mediaDownloadTriage.ts create mode 100644 src/lib/mediaPipelinePageLoad.test.ts create mode 100644 src/lib/mediaPipelinePageLoad.ts create mode 100644 src/lib/navigationGroups.test.ts create mode 100644 src/lib/navigationGroups.ts create mode 100644 src/lib/networkSnapshot.test.ts create mode 100644 src/lib/networkSnapshot.ts create mode 100644 src/lib/npmAppDetailLoadState.test.ts create mode 100644 src/lib/npmAppDetailLoadState.ts create mode 100644 src/lib/operationMapView.test.ts create mode 100644 src/lib/operationMapView.ts create mode 100644 src/lib/operatorActionQueue.test.ts create mode 100644 src/lib/operatorActionQueue.ts create mode 100644 src/lib/operatorError.test.ts create mode 100644 src/lib/operatorError.ts create mode 100644 src/lib/overviewTopIssue.test.ts create mode 100644 src/lib/overviewTopIssue.ts create mode 100644 src/lib/playbookView.test.ts create mode 100644 src/lib/playbookView.ts create mode 100644 src/lib/policyActionState.test.ts create mode 100644 src/lib/policyActionState.ts create mode 100644 src/lib/policyView.test.ts create mode 100644 src/lib/policyView.ts create mode 100644 src/lib/remediationFollowupView.test.ts create mode 100644 src/lib/remediationFollowupView.ts create mode 100644 src/lib/remediationPlannerLoadState.test.ts create mode 100644 src/lib/remediationPlannerLoadState.ts create mode 100644 src/lib/responsiveLayout.test.ts create mode 100644 src/lib/reviewWorkload.test.ts create mode 100644 src/lib/reviewWorkload.ts create mode 100644 src/lib/routeContentWidth.ts create mode 100644 src/lib/routeLoadingView.test.ts create mode 100644 src/lib/routeLoadingView.ts create mode 100644 src/lib/routeSplitting.test.ts create mode 100644 src/lib/routeTitle.test.ts create mode 100644 src/lib/routeTitle.ts create mode 100644 src/lib/safetyActionFeedback.test.ts create mode 100644 src/lib/safetyActionFeedback.ts create mode 100644 src/lib/secretOwnerLink.test.ts create mode 100644 src/lib/secretOwnerLink.ts create mode 100644 src/lib/settingsRuntimeView.test.ts create mode 100644 src/lib/settingsRuntimeView.ts create mode 100644 src/lib/setupBannerView.test.ts create mode 100644 src/lib/setupBannerView.ts create mode 100644 src/lib/setupCompletionView.test.ts create mode 100644 src/lib/setupCompletionView.ts create mode 100644 src/lib/setupConnectorContext.test.ts create mode 100644 src/lib/setupConnectorContext.ts create mode 100644 src/lib/setupConnectorFeedback.test.ts create mode 100644 src/lib/setupConnectorFeedback.ts create mode 100644 src/lib/setupConnectorForms.test.ts create mode 100644 src/lib/setupConnectorForms.ts create mode 100644 src/lib/setupContextView.test.ts create mode 100644 src/lib/setupContextView.ts create mode 100644 src/lib/setupDeepLink.test.ts create mode 100644 src/lib/setupDeepLink.ts create mode 100644 src/lib/setupNextActions.test.ts create mode 100644 src/lib/setupNextActions.ts create mode 100644 src/lib/systemLoadChartView.test.ts create mode 100644 src/lib/systemLoadChartView.ts create mode 100644 src/lib/systemSummaryView.test.ts create mode 100644 src/lib/systemSummaryView.ts create mode 100644 src/lib/utils.ts create mode 100644 src/main.tsx create mode 100644 src/pages/AiRemediationPlanner.tsx create mode 100644 src/pages/AiSecurityReviewer.tsx create mode 100644 src/pages/AppDetail.tsx create mode 100644 src/pages/Approvals.tsx create mode 100644 src/pages/Apps.tsx create mode 100644 src/pages/Apps.ux.test.ts create mode 100644 src/pages/Audit.tsx create mode 100644 src/pages/CommandCenter.tsx create mode 100644 src/pages/Connectors.tsx create mode 100644 src/pages/Containers.tsx create mode 100644 src/pages/Deployments.tsx create mode 100644 src/pages/Incidents.tsx create mode 100644 src/pages/Index.tsx create mode 100644 src/pages/Integrations.tsx create mode 100644 src/pages/Integrations.ux.test.ts create mode 100644 src/pages/Inventory.loadState.test.ts create mode 100644 src/pages/Inventory.tsx create mode 100644 src/pages/Jobs.tsx create mode 100644 src/pages/MediaPipeline.tsx create mode 100644 src/pages/Network.tsx create mode 100644 src/pages/NotFound.tsx create mode 100644 src/pages/Notifications.tsx create mode 100644 src/pages/Notifications.ux.test.ts create mode 100644 src/pages/OperationsMap.tsx create mode 100644 src/pages/Overview.liveData.test.ts create mode 100644 src/pages/Overview.tsx create mode 100644 src/pages/Playbooks.tsx create mode 100644 src/pages/Policies.tsx create mode 100644 src/pages/RunnerFleet.tsx create mode 100644 src/pages/Safety.tsx create mode 100644 src/pages/Secrets.tsx create mode 100644 src/pages/Settings.tsx create mode 100644 src/pages/Setup.tsx create mode 100644 src/pages/Stacks.tsx create mode 100644 src/pages/Storage.liveData.test.ts create mode 100644 src/pages/Storage.tsx create mode 100644 src/pages/Unraid.tsx create mode 100644 src/pages/VirtualMachines.tsx create mode 100644 src/pages/Voice.tsx create mode 100644 src/pages/ai/AIChat.tsx create mode 100644 src/pages/ai/AIDashboard.loadState.test.ts create mode 100644 src/pages/ai/AIDashboard.tsx create mode 100644 src/pages/ai/AIRecommendations.tsx create mode 100644 src/pages/ai/AITools.tsx create mode 100644 src/routes/appRoutes.tsx create mode 100644 src/types/ai.ts create mode 100644 src/types/appops.ts create mode 100644 src/utils/toast.ts create mode 100644 src/vite-env.d.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6cf670a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules +.git +dist +build +__pycache__ +**/__pycache__ +.pytest_cache +.ruff_cache +.venv +backend/.venv +*.db +appops.db +coverage +logs +data +.env +.env.* +!/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8e6a9e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +APPOPS_OPERATOR_TOKEN= +APPOPS_SECRET_KEY= +# Generate both independently: python -c "import secrets; print(secrets.token_urlsafe(48))" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d979c26 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +*.sh text eol=lf +Dockerfile text eol=lf +docker/Dockerfile text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +ops/gitea-deploy-broker/*.py text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..316ce95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Environment +.env +.env.local + +# Python/backend artifacts +*.pyc +__pycache__/ +backend/.venv/ +backend/.env +ops/gitea-deploy-broker/private-tls.json +*.db +*.db-shm +*.db-wal +appops-gateway.cdx.json +/data/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..562c942 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,13 @@ +# Reviewed noncredential schema declaration and deterministic test inputs only. +[extend] +useDefault = true +[[allowlists]] +description = "Pydantic enrollment secrets container declaration, no secret value" +paths = ['backend/app/schemas/enrollment\.py$'] +regexTarget = "line" +regexes = ['secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1\(\)'] +[[allowlists]] +description = "Synthetic noncredential test encryption key and idempotency/reason fixtures" +paths = ['backend/tests/test_(repopulse_adoption_runner|deployment_control_plane|enrollment_plane|mcp_rag_n8n_integrations)\.py$'] +regexTarget = "line" +regexes = ['appops-test-key-with-at-least-32-bytes', 'apply-wsbc-(direct|evidence)-0001', 'enroll-wsbc-(interrupted|compensate)-0001', 'N8N-REASON-SYNTHETIC'] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3350446 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,313 @@ +# Changelog + +## Unreleased + +### Added + +- A root-only local `--revalidate-delivery` controller operation can create one + additive, fresh exact-SHA validation attempt for an immutable + `validation_failed` or `validation_timeout` webhook record. It preserves the + original audit bytes, refuses other terminal states and changed policy or + branch head, exposes no HTTP mutation route, and rechecks exact branch head + immediately before deployment. + +### Fixed + +- The fixed two-runner Gitea Actions fleet now has a guarded, rollbackable + in-place upgrade from the affected 2.0.0 runner baseline to the immutable + 3.5.0 image. Future fleet bootstrap also uses that reviewed pin; the upgrade + preserves separate runner credentials, labels and private-DinD topology and + recreates no production application, Gitea or DinD container. +- Gitea Actions scheduler recovery now proves it has a complete, bounded runner + inventory and fails closed on malformed, truncated or duplicate fixed-runner + state. Any disabled or busy registration vetoes recovery of that runner. +- Gitea Actions fleet recovery now treats the fixed private `gitea-runner-dind` + daemon as a bounded recovery target, persists `unless-stopped`, counts + start/restart attempts against one recovery budget and avoids querying DinD + networks while the daemon is down. +- A controller restart while a durable webhook delivery is `deploying` now + reconciles the exact repository/revision receipt. A verified receipt closes + the delivery as `deployed`; missing proof closes it as + `deployment_interrupted` without starting an unsafe duplicate deploy. +- Deployment-controller, enrollment and reconciliation writers now serialize + exclusively on the physical cache lock. This prevents an Unraid `shfs` + alias from making one process lock the same inode twice and deadlock itself. +- Controller promotion stops the old writer before acquiring the cache lock, + then holds that lock throughout backup, state reconciliation, controller + replacement and health verification. + +## 1.8.0 - 2026-09-14 + +### Added + +- Capability-based CI runner fleet telemetry, queue explanations and the + AppOps Runner Fleet view. The view joins Gitea scheduler facts with a + private, read-only controller projection; no Docker socket or token reaches + AppOps. +- A root-owned, fixed-identity CI fleet service with health, storage and + address-pool telemetry, bounded stale Gitea-task-network collection and a + three-attempt recovery budget per runner over 30 minutes. +- A rolling runner-fleet promotion procedure: two single-slot general + validation runners, separate workspaces, a private DinD daemon and a + `198.19.0.0/16` pool allocated as `/27` task networks (2,048 possible + networks). + +### Changed + +- Managed validation now asks for `linux-validation`; `unraid-deploy` is no + longer configured as an AppOps CI restart/deployment target. +- Automatic cache cleanup is restricted to dangling or expired cache inside + the dedicated CI daemon and only under its critical disk watermark. Generic + host Docker, network and volume prunes remain forbidden. +- The root-managed 30-minute exact-SHA validation window explicitly covers + scheduler queueing on the bounded two-slot CI fleet. A terminal delivery + record remains immutable; recovery always starts with a new protected + revision rather than bypassing the gate. + +### Security + +- CI code retains no production Docker socket or deployment credential. Safe + cleanup accepts only empty, older, unlabelled Gitea task networks in the + dedicated CI daemon; unknown, attached and production resources fail closed. + +## 1.7.0 - 2026-09-14 + +### Added + +- Runnerless Validated Delivery: signed Gitea main pushes are durably queued, + await explicit exact-SHA push validation, and call the existing receipt-bound + controller only after every required context succeeds. +- Durable `awaiting_validation`, validation failure/timeout and superseded + delivery states, bounded Gitea status polling, crash-safe resume and a + secret-free Delivery Reliability view in AppOps. +- Root-owned validation-token/config checks, controller backup manifests and + delivery inspection tooling. + +### Changed + +- Removed AppOps' `runs-on: unraid-deploy` production workflow. Managed + validation remains an ordinary `linux-validation` CI job and now runs for + protected-main pushes as well as pull requests. + +### Security + +- The validation credential stays controller-side, read-only and mode `0600`. + Exact repository identity, branch, SHA, HMAC and receipt checks remain + authoritative; missing policy/status/API reachability never deploys open. + +## 1.6.0 - 2026-09-14 + +### Added + +- Enrollment & Provisioning Plane v1 with strict `EnrollmentPlanV1`, trusted + server-side profiles, durable enrollment jobs, machine-readable readiness, + typed operator APIs and typed MCP tools. +- Controller bootstrap that registers one validated target through HMAC, creates + its bounded appdata layout and per-target credential, and seals only + profile-owned runtime values. +- Exact-target `deployment.apply` and `deployment.rollback` policies with + enrollment provenance; policy defaults remain deny-by-default. + +### Security + +- New enrollments refuse container, hostname, Authentik and storage takeover; + no arbitrary Compose, environment, callback, Docker, Portainer-exec or shell + capability was added. +- Enrollment credentials and OIDC client secrets are generated server-side, + encrypted at rest, write-only, and excluded from evidence, UI and MCP. +- Failed enrollment compensates only resources that the same execution proved + it created; incomplete compensation stays explicitly visible. +- Controller HMAC requests now bind exact method and endpoint, use a + single-use durable nonce, and fail closed for the legacy request format. +- Enrollment rejects the development secret-store fallback, mismatched OIDC + role claims/models, unsafe controller registries and unverified NPM ownership + markers. + +- Changed local fallback operator authentication from reload-fragile frontend memory to a revocable, process-local HttpOnly browser session. Raw operator keys are exchanged once, never stored in JavaScript/browser storage, and cookie mutations require an explicit CSRF header. +- Fixed managed deployment provenance by forwarding the broker's exact revision, repository, tree, + build ID and build time into immutable OCI image labels while retaining the application version + in a separate vendor label. + +## 1.5.2 - 2026-08-27 + +- Measure active Gitea Actions age from `started_at`, so an old queued job that + just acquired a runner is no longer reported as a stale active execution. + +## 1.5.1 - 2026-08-27 + +### Fixed +- Canonical Compose now binds the persistent `/data/secret-key` through + `APPOPS_SECRET_KEY_FILE`, keeping encrypted secret storage production-ready + after the isolated data-mount migration. +- The MCP handshake now derives its advertised version from the same central + application version used by the API and runtime status. + +## 1.5.0 - 2026-08-27 + +### Added +- Cached, read-only Gitea Actions runner and queue observability in the Overview, private API and MCP surface. +- Explicit runner-label-to-container guidance for stalled queues; restart remains an approval-gated Command Center action and never executes automatically. + +### Changed +- SBOM generation now uses pnpm 11's native CycloneDX 1.6 command, removing the incompatible npm-specific generator and its transitive dependency tree. +- Canonical Compose now mounts the dedicated `APPOPS_DATA_PATH` instead of using the Git checkout as `/data`; Gitea observer configuration references only the mounted token file. + +### Security +- Gitea admin credentials support mounted secret files and are never returned by status, UI or MCP responses. +- Fixed Gitea admin endpoints, bounded job samples and a cache that clients cannot bypass prevent generic admin access and polling amplification. + +## Unreleased - 2026-08-11 + +### Added +- Governed MCP server operations: ChatGPT can request an exact typed action, follow approval/job state, execute only an operator-approved fingerprint and cancel a queued/running job record. +- Server-side execution concurrency is capped at one operation; work and post-checks run in AppOps rather than in the browser. +- Private MCP 2.0 Streamable HTTP server with authenticated read-only AppOps tools and a capability resource that explicitly excludes mutation, shell and Docker socket access. +- Persistent local, extractive RAG retrieval with citations across allowlisted documentation and redacted live AppOps context; AI Chat and MCP share the retrieval service. +- Dedicated n8n machine authentication plus cursor events, policy preflight, idempotent approval requests and approval/job status reads. No n8n execution endpoint is exposed. +- n8n can query the shared local RAG service with citations through its dedicated machine-authenticated integration path. +- An `/integrations` cockpit for readiness, endpoint discovery, RAG indexing and cited query verification. + +### Security +- Every MCP mutation requires a separate expiring human approval even when the ordinary manual policy would allow it. MCP cannot approve requests, change the approved context, invoke an unknown action, open a shell or access the Docker socket. +- MCP, n8n, operator and voice identities remain separate. Production bootstraps distinct mode-`0600` MCP and n8n token files and never exposes their values in the UI or status API. +- Hardened the private machine interfaces with recursive cross-boundary redaction, pre-parse body limits, n8n middleware authentication and mutation throttling, SQL-bounded event cursors, serialized idempotency and bounded reservation retention. +- Destructive action defaults remain authoritative; automation can request governance but cannot approve or execute actions. + +### Fixed +- The integrations cockpit no longer reports healthy MCP/n8n services as stopped or RAG as empty when an operator tab is locked; unavailable state is explicit and protected controls remain disabled until re-authentication. +- Unraid parity monitoring now uses the live `array.parityCheckStatus` and `array.parities` GraphQL fields instead of unsupported `parityStatus`/`system` queries. +- The parity endpoint distinguishes an active or paused check from healthy and unhealthy parity disks and exposes progress, speed, correction mode and sync-error metadata. +- The Unraid connector safely follows the appliance's same-host HTTP-to-HTTPS API redirect while retaining the API key; redirects to other hosts or non-HTTPS targets are refused. + +## 1.3.0 - 2026-07-29 + +### Added +- Private operator authentication for all mutations and sensitive administrative reads, with an in-memory-only frontend unlock session. +- Persistent server-side operator-token bootstrap through a mode-`0600` file in `/data`, avoiding any workstation runtime dependency. +- Approval fingerprints and actor attribution, centralized audit redaction, connector URL SSRF controls and strict high-risk request contracts. +- Backup checksums, retention, free-space protection and disposable restore verification. +- CI gates for frontend/backend tests, safety validation, container smoke testing and a CycloneDX 1.6 SBOM artifact. + +### Changed +- TLS verification is secure by default and production secret/token strength is enforced. +- Mutation requests have bounded bodies and rate limiting; production images require operator authentication. +- Responsive dashboard layouts, dependency compatibility and repository hygiene were hardened. + +### Security +- The private deployment model explicitly separates operator and voice credentials and keeps destructive actions blocked by policy. +- Tracked test databases and the duplicate Dockerfile were removed; the root Dockerfile is canonical. + +### Verified +- Backend regression suite: `381 passed`; 28 remaining warnings are deprecated timestamp calls in test fixtures. +- Frontend type-check, lint, all `243` tests and the Vite production build are green. +- Ruff, dependency audits, diff validation, secret-pattern scanning, CycloneDX SBOM generation and private safety validation are green. +- Docker image verification remains delegated to CI/a Docker-capable private host because Docker is unavailable on the current workstation. + +## Unreleased - 2026-07-19 + +### Added +- Guarded Unraid VM start/resume and approval-aware pause with live state prechecks, JobRuns, post-checks and Audit; destructive stop/reboot/reset/force-stop remain blocked. +- Live Home Assistant/Gemini Assist readiness for API reachability, installed AppOps scripts and loaded conversation components. +- Read-only re-verification and `no-store` download of existing SQLite backups. + +### Changed +- Authentik outpost status now distinguishes API-reported health from credential-free reachability and never forwards the Authentik bearer token to the configured outpost origin. +- Live audit copy now distinguishes saved Voice bridge configuration from actual Home Assistant reachability, and the route smoke explicitly covers the VM manager surface. +- Vite and the ESLint toolchain now use patched compatible releases; the remaining transitive `flatted` advisory is resolved with a scoped workspace override, and pnpm is pinned for reproducible local/container builds. +- The production container is now presented as `Unraid-AppOps-Gateway`, with a stable hostname, OCI metadata, Unraid WebUI/icon labels and matching SVG/PNG/ICO/PWA assets. The existing `/srv/example/appdata/unraid-appops-gateway-1216` data path remains unchanged. +- Obsolete admin-token examples and the unused browser voice-token bridge are removed; `APP_OPS_VOICE_TOKEN` remains server-side and is only supplied by Home Assistant to protected voice endpoints. +- Patched compatible transitive versions are pinned for the dependency-audit findings in `lodash`, `glob`, `minimatch`, `brace-expansion`, `picomatch`, `postcss` and `yaml`. +- Backend security pins move to FastAPI 0.139.0/Starlette 1.3.1, cryptography 48.0.1 and pytest 9.0.3 to clear the Python dependency audit. +- Active setup, deployment, API and media-pipeline documentation now consistently describes the live-only frontend instead of the removed demo/mock runtime. +- Canonical upgrade post-checks allow 30 seconds for the one-time cold migration read, preventing a false rollback while an oversized legacy inventory is being pruned. +- The one-time container migration strips obsolete admin/browser token variables while preserving all current connector, Unraid, Portainer and Home Assistant voice configuration. +- Upgrade post-check failures now propagate correctly through Bash conditionals and retry three times instead of logging a timed-out endpoint as successful. +- Production discovery retains detailed rows for the newest 48 successful runs. Unchanged missing containers are recorded once instead of being copied into every run, preventing unbounded SQLite growth and multi-second setup/runtime reads. +- Synchronous runtime and setup status aggregation now runs in FastAPI's worker pool instead of blocking the async event loop during cold inventory reads. +- The alternate Dockerfile now defaults the frontend to live API mode, matching the root production Dockerfile. Compose requires externally supplied voice and encryption keys instead of runnable placeholder secrets. +- Inventory discovery reconciles existing same-name/same-app mappings when Docker recreates a container with a new ID, removes duplicate stale mappings and writes a metadata-only audit event. Genuine unknown containers remain in review. +- The latest-discovery cache now uses weak Session object keys instead of reusable numeric object IDs, preventing detached ORM rows from leaking into later requests. +- Launchpad and Media Codec Dashboard receive evidence-based classifications from their live names/images instead of remaining generic unknowns. +- Action Registry renders 30 rows per page with filter-aware pagination instead of mounting all 181 actions at once. +- Exposure rows now include concrete risk evidence such as missing Authentik/access-list coverage, missing certificates and non-forced SSL; high-risk rows sort first in the UI. +- Inventory change detection canonicalizes Portainer port, mount and network ordering, so identical runtime snapshots no longer reopen reviewed apps as false drift. Historical missing containers remain visible in the ledger but are no longer presented as bulk import/edit work. +- Setup status now coalesces discovery history by current container name, preventing recreated Docker IDs from inflating discovered/review counts. `needsReview` reports the actual current workload while `reviewBlockers` separately indicates whether that workload still blocks first-run completion. +- Completed setup labels the accepted first-run review step as `Reviewbaseline vastgelegd`; later/current review workload is shown separately as `open inventory review`. +- Apps without a registered AppOps connector now report connector status `none`; Jellyfin no longer advertises missing connector configuration or an unavailable scan executor. +- Action Registry responses now distinguish real Command Center execution, approval execution, dedicated surfaces, governance-only actions and blocked actions. +- Approval decisions use the correct `PATCH` API and a strict persisted state machine. Approved Deluge pause-all/resume-all and Plex scan-all requests can be executed separately through audited JobRuns; unsupported approvals stay non-executable. +- Overview and TopStatusBar use shared React Query caching with independent fail-soft polling intervals, focus refresh and no background-tab polling. +- Inventory review now coalesces recreated Docker container IDs by unique container name, preferring the current non-missing instance while retaining per-run history for audit and drift. The review grid renders 24 cards per page instead of mounting the complete ledger at once. +- Overview action cards use a balanced two-column desktop layout before expanding to four columns on ultrawide screens. +- Overview starts its primary runtime, app, activity, chart and storage snapshot in parallel with secondary Safety/setup/notification intelligence, but no longer blocks first paint on the connector-heavy secondary reads. +- Inventory latest-state and drift fallback queries now use indexed SQLite window functions, the mapping UI loads the compact canonical app catalog, and no-op startup classification avoids full history scans. +- FastAPI database initialization now uses the supported application lifespan hook. +- Backend persistence timestamps now use one Python-forward-compatible UTC helper while preserving existing SQLite semantics; audit listing no longer uses SQLAlchemy's legacy query API. +- Successful canonical upgrades retain the newest 20 timestamped backups and remove only scoped legacy AppOps image tags after health validation. +- Canonical upgrades automatically migrate the former `unraid-appops-gateway-1216` name to `Unraid-AppOps-Gateway`, roll back from the previous immutable image ID and never retain versioned `pre`/`failed` containers. Production uses the stable `unraid-appops-gateway:production` tag with one `rollback` image tag. +- Runtime and OpenAPI version reporting now share release version `1.2.1`. +- Safety refreshes expired persistent snapshots before falling back to stale data. +- Notification evaluation prefers the most specific matching rule and retires exact duplicates plus generic media umbrella overlaps from the active inbox. +- Conservative app classification patterns reduce canonical `Unknown` noise without overriding explicit categories. +- Canonical classification now uses app IDs, mapped container names and discovered images, and normalizes humanized names consistently. +- Historical AppOps rollback registrations and the superseded unqualified deployment record no longer pollute active app, inventory, setup, operations-map, Safety or AI views; history remains stored. +- AI Security's unavailable Media Pipeline fallback now captures and redacts its initialization error safely instead of retaining an expired exception reference. +- The secondary top-bar activity chip now waits for a true wide-desktop viewport, preventing it from colliding with infrastructure status around 1280px. +- AI Security excludes current and rollback AppOps deployment containers from discovery-review findings and invalidates pre-fix cached reviews. +- Managed canonical apps can now be normalized from App Detail through an audited metadata-only editor for name, category and criticality. +- Locally documented custom apps now receive evidence-based categories and protection defaults; canonical `Unknown` coverage reaches zero without generic guessing. +- App category filters are generated from the live catalog, and App Detail derives credential-free Open links from configured connector URLs or mapped published ports. +- Inventory baseline updates now persist the current live ledger, reset stale acknowledgements, drive subsequent drift comparisons and write a real audit event instead of returning a placeholder success. +- Discovery's short-lived latest-container cache is scoped to the active database session so ORM rows cannot leak across requests as detached or stale runtime data. +- The configured Home Assistant notification channel was validated end to end against the live API and a delivered test notification; the existing Home Assistant container now uses `unless-stopped` restart policy. +- Overview formats Unraid boot timestamps as compact uptime durations so Server Health remains readable at five-column desktop widths. +- Inventory loads runs, review records, apps, mappings and drift independently; a slow source now produces a partial warning without clearing successful live ledger data or fabricating an empty-discovery state. +- Large inventory ledger reads use a dedicated 30-second request budget, and the empty-discovery prompt only appears after review records successfully load as empty. +- The read-only container list also uses a 30-second cold-start budget, keeping Inventory's optional runtime comparison from timing out behind simultaneous ledger reads. +- Exposure Center and Containers now progressively enrich canonical live data with optional connector details and Portainer telemetry. +- Exposure Center no longer flashes false `0` or `Unknown` detail metrics while NPM and Authentik enrichment is still loading; canonical exposure counts remain available immediately and optional values are labelled as loading or unavailable. +- Authentik outposts that exist but provide no runtime health field are labelled `Unverified` instead of being overstated as unavailable. +- Overview quick actions use a two-column mobile grid and expand to a wrapped desktop row, preventing clipped controls on narrow screens. + +### Verified +- Backend regression suite: `378 passed`; 28 remaining warnings are deprecated timestamp calls in test fixtures. +- Frontend type-check, lint and Vite production build are green on the patched toolchain. +- Ruff, Python compileall, diff validation and tracked secret-pattern scanning are green. +- Python dependency audit reports no known vulnerabilities; pnpm reports no high-severity advisories. + +## 1.2.1 - 2026-07-02 + +### Added +- SQLite-safe online backup flow through `POST /api/admin/backups/sqlite` and Settings > Backups. +- Server-side backup listing for `/data/backups/appops-db-*.db`. +- Backup audit event `runtime.sqlite_backup` with integrity-check metadata and no secret values. +- Root `SECURITY.md` with no-secrets, local-first and exposure guidance. + +### Changed +- `stack.redeploy` is documented as approval-only V1 behavior. +- Connector binding lookup now computes latest discovery state once per request, fixing live `/connectors` loading timeouts. +- Legacy AppOps deployment artifacts are filtered from operator inventory lists. +- Current roadmap and implementation status now distinguish active 1216 production status from historical validation notes. + +### Removed +- Un-routed legacy AI pages with static/demo controls under `src/pages/ai`. + +### Verified +- Live deployment on canonical `http://198.18.10.150:1216`. +- `scripts/validate_live_deployment.ps1` result: `PASS=39 WARN=1 FAIL=0`. +- Production container image: `unraid-appops-gateway:connector-audit-34cc5a6-1216`. + +## 1.2.0 - 2026-07-02 + +### Added +- Live route hardening for AppDetail, Apps, Containers, Stacks and Overview action controls. +- Policy action fallback handling so missing handlers no longer silently no-op. +- Active roadmap source at `docs/ROADMAP_CURRENT.md`. + +### Changed +- App lifecycle controls now route to real restart, approval or review flows. +- Read-only or unsupported actions are explicitly disabled in V1. + +## Historical Notes + +Detailed sprint history, validation snapshots and superseded deployment notes remain in `docs/IMPLEMENTATION_STATUS.md` and `docs/LIVE_VALIDATION_REPORT.md`. Those files are archives unless repeated in `docs/ROADMAP_CURRENT.md` or this changelog. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f28f58 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,73 @@ +FROM node:22-bookworm-slim AS frontend-builder +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY index.html postcss.config.js tailwind.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts components.json ./ +COPY public ./public +COPY src ./src +ARG VITE_APPOPS_API_MODE=live +ARG VITE_APPOPS_API_BASE_URL= +ENV VITE_APPOPS_API_MODE=${VITE_APPOPS_API_MODE} \ + VITE_APPOPS_API_BASE_URL=${VITE_APPOPS_API_BASE_URL} +RUN pnpm run build + +FROM python:3.13-slim AS runtime +ARG APPOPS_BUILD_ID=development +ARG APPOPS_SOURCE_REVISION=development +ARG APPOPS_SOURCE_REPOSITORY=local/unraid-appops-gateway +ARG APPOPS_SOURCE_TREE=development +ARG APPOPS_BUILD_DATE=1970-01-01T00:00:00Z +LABEL org.opencontainers.image.title="Unraid AppOps Gateway" \ + org.opencontainers.image.description="Local-first, policy-gated operations gateway for Unraid and connected services" \ + org.opencontainers.image.version="${APPOPS_BUILD_ID}" \ + org.opencontainers.image.revision="${APPOPS_SOURCE_REVISION}" \ + org.opencontainers.image.source="${APPOPS_SOURCE_REPOSITORY}" \ + org.opencontainers.image.source-tree="${APPOPS_SOURCE_TREE}" \ + org.opencontainers.image.created="${APPOPS_BUILD_DATE}" \ + io.itworx.app.version="1.8.0" \ + io.itworx.app.operator-auth="http-only-session" \ + org.opencontainers.image.vendor="AppOps" \ + net.unraid.docker.managed="dockerman" \ + net.unraid.docker.webui="http://[IP]:[PORT:1216]/" \ + net.unraid.docker.icon="/boot/config/plugins/dockerMan/images/unraid-appops-gateway.png" +ENV APPOPS_ENV=production \ + APPOPS_DEPLOYMENT_MODE=private \ + APPOPS_REQUIRE_OPERATOR_AUTH=false \ + APPOPS_OPERATOR_TOKEN_FILE=/data/operator-token \ + APP_OPS_VOICE_TOKEN_FILE=/data/voice-token \ + APPOPS_BIND_HOST=0.0.0.0 \ + APPOPS_PORT=1216 \ + APPOPS_DATA_DIR=/data \ + APPOPS_DB_PATH=/data/appops.db \ + APPOPS_STATIC_DIR=/app/dist \ + APPOPS_LOG_LEVEL=info \ + APPOPS_DISCOVERY_HISTORY_RUNS=48 \ + APPOPS_ENABLE_MCP=false \ + APPOPS_MCP_TOKEN_FILE=/data/mcp-token \ + APPOPS_MCP_ALLOWED_HOSTS=localhost:*,127.0.0.1:* \ + APPOPS_ENABLE_N8N=false \ + APPOPS_N8N_TOKEN_FILE=/data/n8n-token \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 +WORKDIR /app +RUN mkdir -p /etc/appops && printf '%s\n' "$APPOPS_SOURCE_REPOSITORY" "$APPOPS_SOURCE_REVISION" "$APPOPS_SOURCE_TREE" > /etc/appops/source-metadata && chmod 0444 /etc/appops/source-metadata +RUN apt-get update \ + && apt-get install -y --no-install-recommends gosu \ + && rm -rf /var/lib/apt/lists/* \ + && addgroup --system appops && adduser --system --ingroup appops appops \ + && adduser appops users \ + && mkdir -p /data \ + && chown -R appops:appops /data /app +COPY backend/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt +COPY backend/app ./backend/app +COPY docs ./docs +COPY README.md CHANGELOG.md ./ +COPY --from=frontend-builder /app/dist ./dist +COPY docker/entrypoint.sh /entrypoint.sh +RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh +EXPOSE 1216 +VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:1216/api/health', timeout=3).read()" +ENTRYPOINT ["/entrypoint.sh"] +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "1216"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..7df17d9 --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `3bd6f6fee310125bfbb4d4650389a817f498724d`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..a030bb4 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,2636 @@ +{ + "schemaVersion": 1, + "sourceRevision": "3bd6f6fee310125bfbb4d4650389a817f498724d", + "files": [ + { + "path": ".dockerignore", + "sha256": "af011acca8928d370fc3d9cdfdf158d4ca2846fecebed9284c2f3e3657c321dc", + "bytes": 181 + }, + { + "path": ".env.example", + "sha256": "811312075478e38981f18d92bc40ff29406d048ea83af45109868d072a2d3279", + "bytes": 137 + }, + { + "path": ".gitattributes", + "sha256": "04dc46ed1e39c1057a2804e31555ce31cf764a0567a230ad14e88ad77983fcb3", + "bytes": 154 + }, + { + "path": ".gitignore", + "sha256": "bad7ad21cb68e34a98607c46b6cd90bed376e0d3662dceb7f40250676da9c0a1", + "bytes": 494 + }, + { + "path": ".gitleaks.toml", + "sha256": "56434c668e32ca14d6669550642e8ae36517bc4b1a54ed90e1f8fbbff6ca97b6", + "bytes": 774 + }, + { + "path": "CHANGELOG.md", + "sha256": "69b807ce37905800e94d6f05990256f245e730a75a65d51b3eb9502e529fe524", + "bytes": 25357 + }, + { + "path": "Dockerfile", + "sha256": "67b2a4dee95c5dc4edd2f794bdd5a3a96361eeb13fea053ecf095ff6327728db", + "bytes": 3551 + }, + { + "path": "README.md", + "sha256": "f637f498b0da533b687c3bd196b1bb7faa07197406799ef57e52f056c3ca05f4", + "bytes": 3270 + }, + { + "path": "SECURITY.md", + "sha256": "a52ff84a76dc1677aad52fd03f6e225b43a2712f2016217ce24a17d081d7ac92", + "bytes": 2865 + }, + { + "path": "backend/app/__init__.py", + "sha256": "d114be3265c7fb2fabf4b9ddd8b58b088949033875198d9b8e82cd02ef948298", + "bytes": 60 + }, + { + "path": "backend/app/api/__init__.py", + "sha256": "7d3052781c7fb5664b1820c844e23ea1cb864bbe71ade67b088a253dfad2e316", + "bytes": 25 + }, + { + "path": "backend/app/api/connector_runtime.py", + "sha256": "c0fc02702bb9db9a6ecdf489be57072583043134879386627d0322e7bafd5fb7", + "bytes": 335 + }, + { + "path": "backend/app/api/routes_actions.py", + "sha256": "c2510d89b9832bb38102e621752f3658a5c196c56ecd03f94c5c6b85c0020f88", + "bytes": 20255 + }, + { + "path": "backend/app/api/routes_ai.py", + "sha256": "6dbf65e1ac61f9d691faba9f03acc709f018b7386d5cdc7aa3a84dc4a7a6a900", + "bytes": 7991 + }, + { + "path": "backend/app/api/routes_ai_remediation.py", + "sha256": "e2e314dfbff2ae18386a44656ac5ed1d7d6b10815dd7ed2517b701455719c0e9", + "bytes": 3339 + }, + { + "path": "backend/app/api/routes_ai_security.py", + "sha256": "f7c86218513bcd2b25274c2731e5378bf1a2650938c1df6aea4c27b9d18e5ed9", + "bytes": 3254 + }, + { + "path": "backend/app/api/routes_approvals.py", + "sha256": "bc3081608e97876cf5ff60ffbfb6af219d957a5cb0d6427a1c5fb6027776291e", + "bytes": 5607 + }, + { + "path": "backend/app/api/routes_apps.py", + "sha256": "b3141f323256444535e98c4633208b47f2e8af5cc13027c82bdb3ed93b222533", + "bytes": 8159 + }, + { + "path": "backend/app/api/routes_audit.py", + "sha256": "7a563f0b95931234b685b72bf3091243366aad17b90986797051bd9b7ab64bac", + "bytes": 655 + }, + { + "path": "backend/app/api/routes_authentik.py", + "sha256": "6737971d0890f858dbd99da195483ced14966e9c4ece41ca180d26ed994ddb7a", + "bytes": 2197 + }, + { + "path": "backend/app/api/routes_connectors.py", + "sha256": "59f85d0749474ebe700667a19af88e22a2d9176addaa7b16b02223367a68f80d", + "bytes": 5334 + }, + { + "path": "backend/app/api/routes_containers.py", + "sha256": "6850c36e2e8837c1f48a9ddda466e861da0bd034e3d1d4f60382b1c272d88e91", + "bytes": 2343 + }, + { + "path": "backend/app/api/routes_deluge.py", + "sha256": "311ce171fa9c214ae2967ca7138a31855a46bf6c39d596fccd1a67d6f91ccb41", + "bytes": 7440 + }, + { + "path": "backend/app/api/routes_deployments.py", + "sha256": "d050e157b8e0d2edbc7310f957eb208838d6746f94fcb39ec6d428d021e67ba3", + "bytes": 4633 + }, + { + "path": "backend/app/api/routes_enrollments.py", + "sha256": "834a6fc2d2ebb0c39c84d3c7417794f0e538b74e6e5e3e47ce5b35aca354c807", + "bytes": 3542 + }, + { + "path": "backend/app/api/routes_gitea_actions.py", + "sha256": "d4917f72a9be1b1874a76f42a0a6ab16cda3b7d49b16fa10253ea83c6fc81f5e", + "bytes": 1258 + }, + { + "path": "backend/app/api/routes_health.py", + "sha256": "e65c718fb1dc5416ad221395709276bfada526311caf80595bd7b043fca9ea46", + "bytes": 129 + }, + { + "path": "backend/app/api/routes_integrations.py", + "sha256": "b77512bb33e1624caafb312bc4d63a5219fba2406d0437bb009025504dafd2ab", + "bytes": 5406 + }, + { + "path": "backend/app/api/routes_inventory.py", + "sha256": "4a0eacc801369200b19dd4e916a315369297bc1a9bec76637b2844b4363b0961", + "bytes": 5200 + }, + { + "path": "backend/app/api/routes_jobs.py", + "sha256": "a5b5e6a36d5f72e0ce869463b13301c3bd5a0a1afe7e2e1c206ee76c7d4cbdf9", + "bytes": 2334 + }, + { + "path": "backend/app/api/routes_knowledge.py", + "sha256": "e056ac30912342294bd77de9feb901b1a72a28d0b302efdb83aa872a0eb87342", + "bytes": 1299 + }, + { + "path": "backend/app/api/routes_media_pipeline.py", + "sha256": "60c39ab790c21386aafcd84cd4bd674d3ed9d72a3d2b5963b15d69b3c72919e8", + "bytes": 1818 + }, + { + "path": "backend/app/api/routes_nginx_proxy_manager.py", + "sha256": "c11d8a7a140b70c640297aaba724502849e679cb9d155ba1dae25b986550897e", + "bytes": 2704 + }, + { + "path": "backend/app/api/routes_notifications.py", + "sha256": "dad11325360a61d8b787d1b40334ddd5c3e349397c3e9ab2b0d5f382bc4d035d", + "bytes": 4882 + }, + { + "path": "backend/app/api/routes_observability.py", + "sha256": "bab0c4fba6d6c58eb7db0a5280db9ab3efef6614890aa29614cf06053aea55fd", + "bytes": 26303 + }, + { + "path": "backend/app/api/routes_playbooks.py", + "sha256": "4482160d3a5de87289d8b2a1d6c735bea17974ee7e29fee2de08718e14b30f63", + "bytes": 11137 + }, + { + "path": "backend/app/api/routes_plex.py", + "sha256": "6959a85c035d10cdcede423e2d370734b92ef3122350b545f2abb09e3eabe6ce", + "bytes": 6233 + }, + { + "path": "backend/app/api/routes_policies.py", + "sha256": "c6e746343dd5bddba6443ce19a57970cf142a74cbbc8a931dfd5a300877f6276", + "bytes": 3488 + }, + { + "path": "backend/app/api/routes_privatemuse_artifacts.py", + "sha256": "9c25a7c6af56a66fb96ba5da5319137a35d3cfb9cde6027591ed9d91ec57c26f", + "bytes": 4333 + }, + { + "path": "backend/app/api/routes_prowlarr.py", + "sha256": "0ad4530a6a17bb6128d6294f19be71a453e2beb8e7b8aeb2d6620f0bbbc5b22e", + "bytes": 3689 + }, + { + "path": "backend/app/api/routes_radarr.py", + "sha256": "bc74dfb8a7db81e2dd7eacfd4c2c63502ec081d8f99272bc36a06de2e23c8f11", + "bytes": 5532 + }, + { + "path": "backend/app/api/routes_runtime.py", + "sha256": "3bcafb2d2541463fed162d8afcdf560949deeb026702bbeffd365ef0b916865d", + "bytes": 8717 + }, + { + "path": "backend/app/api/routes_safety_center.py", + "sha256": "de5312bda8907e8f43df68f02a471807553cf3b4dcada292f2add898e2cff673", + "bytes": 2402 + }, + { + "path": "backend/app/api/routes_setup.py", + "sha256": "65d46a902f4ef113e3a94dc048d23744bbf906cf5abaacef397aaa56deee618a", + "bytes": 10054 + }, + { + "path": "backend/app/api/routes_sonarr.py", + "sha256": "3a927b3d91258b4135412adbaf269cd3ce8f725f562761ea955ac1920d85eefb", + "bytes": 5556 + }, + { + "path": "backend/app/api/routes_stacks.py", + "sha256": "2ab8805d280f588916b535132807091e60ab844f4836136f36c7215fff09f757", + "bytes": 1196 + }, + { + "path": "backend/app/api/routes_system.py", + "sha256": "dd00924212e383fb40927b5915b3be75c9d131c4e43261b86e13a09c83d5d1bb", + "bytes": 3583 + }, + { + "path": "backend/app/api/routes_tautulli.py", + "sha256": "636453676f36b1dcbb3e6b0efdddccecb79be93c5b685d9a06c808ded0e96e7c", + "bytes": 4076 + }, + { + "path": "backend/app/api/routes_unraid.py", + "sha256": "35e7f4c81f5c3eed196c380bc1e817eafd9e428d574323a91990be6a705c84c0", + "bytes": 6782 + }, + { + "path": "backend/app/api/routes_voice.py", + "sha256": "21a9d2770228d2c1374c5ca6a79e3e388888b2322fae90f784c02dc84b4a95c5", + "bytes": 14825 + }, + { + "path": "backend/app/api/routes_voice_operator.py", + "sha256": "534addc180f5564550fda13e58e23e8ce4336c88535d318a41438a89690514ff", + "bytes": 1441 + }, + { + "path": "backend/app/api/routes_widefrog.py", + "sha256": "c97c342e400faa1390837e95fdffa6395c1b389ab652b6e8e061aaf3b0dfb05a", + "bytes": 6010 + }, + { + "path": "backend/app/connectors/authentik.py", + "sha256": "f5b1e2bb8d7a4d27573bf0330602ac9d14e05f41587984745af2951db780745b", + "bytes": 42748 + }, + { + "path": "backend/app/connectors/base.py", + "sha256": "db048bfa831948dda6a3e980c1c45c1e56c009e9dcfbfae398fb69b7b234f0bc", + "bytes": 310 + }, + { + "path": "backend/app/connectors/deluge.py", + "sha256": "a2c8c40688df14beb1a2cd17a8739911cb55db5ff595ccaa88349a25bc991d61", + "bytes": 16149 + }, + { + "path": "backend/app/connectors/gitea_actions.py", + "sha256": "c5af5d78b4ce55a60fe409ae371f7fb7a49ed6b8a4d5674364845e8fddb74445", + "bytes": 19157 + }, + { + "path": "backend/app/connectors/nginx_proxy_manager.py", + "sha256": "eb1319d26d395f9bd7bf5a18b71a4a4dd6c2149e004115e00d2c108f2490ac4a", + "bytes": 32949 + }, + { + "path": "backend/app/connectors/plex.py", + "sha256": "5f39b0edd79a7151c4b830a30f8f0cccc0c833c57808249760fb24a7509b0fe2", + "bytes": 15886 + }, + { + "path": "backend/app/connectors/portainer.py", + "sha256": "60e0a28c440ffc8a989b2d8c7a61769132fce06fcb7f5ea708d1e0ec000e237f", + "bytes": 19493 + }, + { + "path": "backend/app/connectors/prowlarr.py", + "sha256": "d4b3c28f519ce0d5ab486ad468d0161e1de82d14057dc98db86e9f719105c113", + "bytes": 13623 + }, + { + "path": "backend/app/connectors/radarr.py", + "sha256": "1af462945a3bfc286e6ac6f45144d12f84803d980f0e6b1912c4e55dc591c0b4", + "bytes": 12397 + }, + { + "path": "backend/app/connectors/sonarr.py", + "sha256": "17a9609c47641bc32e99149093262af468e5d966ae207d2186d73b87d8c42a86", + "bytes": 11784 + }, + { + "path": "backend/app/connectors/tautulli.py", + "sha256": "890e545c6334f4f508816740ca56856d53c5f7aba81c864e28cac767b5adbe19", + "bytes": 19341 + }, + { + "path": "backend/app/connectors/unraid.py", + "sha256": "3f15567e688f98d1d1c0089fca3d45bb6ddfb7038187f090baa223e55c82a37d", + "bytes": 30947 + }, + { + "path": "backend/app/connectors/widefrog.py", + "sha256": "033e49404afc68e07ecdcf2f4e50d04587a5a14502a83094f1a3ed4c24749e28", + "bytes": 10831 + }, + { + "path": "backend/app/core/config.py", + "sha256": "8a680ef32bd2571b470f35cda29c98a43c732a5c8448a6efb791ca90aded218a", + "bytes": 23635 + }, + { + "path": "backend/app/core/network_security.py", + "sha256": "963fead292ba28c54f9ae87eb4615bc230cfc8172f55b0dbee411dfed3cfa90f", + "bytes": 2877 + }, + { + "path": "backend/app/core/redaction.py", + "sha256": "65537744de2b6f5982bdc437b75063d16dd2bff66c9662a6d466ba7a1147874a", + "bytes": 1435 + }, + { + "path": "backend/app/core/security.py", + "sha256": "87fdad8a4154b0462d6d8ed219530988870bb80192fc604adcef3aa1aa8cca7f", + "bytes": 10483 + }, + { + "path": "backend/app/core/time.py", + "sha256": "7858a63d3655e936a52798f93ea191323d4b3037ed11e1f204e23b70bed38df1", + "bytes": 236 + }, + { + "path": "backend/app/core/version.py", + "sha256": "04ace8d7f0d2b4707a9d5a4d8f005693025705412e461f309ad24120b3c6c1ff", + "bytes": 23 + }, + { + "path": "backend/app/db/init_db.py", + "sha256": "a5b5afaa023e56aca19e7ab393e96b749f8d7693b56f87143305b65f44b78a26", + "bytes": 71011 + }, + { + "path": "backend/app/db/models.py", + "sha256": "d95d938ddb03bcd05c45a4830e82b6fee563d74c34796eded1ffc8e7a9bee1e4", + "bytes": 15425 + }, + { + "path": "backend/app/db/session.py", + "sha256": "0b92ff98aaffda4251305c12ee01c314828e104f338bd98bd74b9405780e5255", + "bytes": 1251 + }, + { + "path": "backend/app/main.py", + "sha256": "26520d580c9637ec42645e29d453895cdb5f143bf2f7fadaa090e390595cf1c0", + "bytes": 15528 + }, + { + "path": "backend/app/mcp_server.py", + "sha256": "68cf119eeee630bd946ddb951dc921092a5805ad889ce8fc6b66e07578fdf354", + "bytes": 35275 + }, + { + "path": "backend/app/schemas/ai_remediation.py", + "sha256": "7e78e89c1b0ec9fbd2d2701394aa577cbccf313a28182d318b88bc921040e7c8", + "bytes": 3722 + }, + { + "path": "backend/app/schemas/ai_security.py", + "sha256": "d5b44158f447a4ea50c425c3a3437dd1f218f507fb3e124a63acf97b83982854", + "bytes": 2912 + }, + { + "path": "backend/app/schemas/deployment.py", + "sha256": "3ddf1e28f580934663190b74274dc5aca48485077675abb7a74035a091fdc50e", + "bytes": 16229 + }, + { + "path": "backend/app/schemas/enrollment.py", + "sha256": "f7a4fbd2f2e28403c880d3974d248705dfc6257fe41d0c9ffdf2869fa50bf918", + "bytes": 16375 + }, + { + "path": "backend/app/services/__init__.py", + "sha256": "7eb70257593da06f682a3ddda54a9d260d4fc514f645237f5ca74b08f8da61a6", + "bytes": 2 + }, + { + "path": "backend/app/services/action_execution.py", + "sha256": "5115ac093d517f929490d8cc9a83bb9875622b70fdc78980f8a6ff857094ed6e", + "bytes": 79621 + }, + { + "path": "backend/app/services/action_registry.py", + "sha256": "7f6fd680a0511714a8734247e065f9db4c608feeabd87c39f9ae71df874c98b5", + "bytes": 20912 + }, + { + "path": "backend/app/services/ai_remediation_planner.py", + "sha256": "5334b2e6895944e50017b1617f6b0125591f020bd7194c77adf733515b5832a4", + "bytes": 44579 + }, + { + "path": "backend/app/services/ai_security_reviewer.py", + "sha256": "c467feca1fb12fa927e62827499167a2ec5c6dc8670eb20c00e1b1bb3dbf89fe", + "bytes": 58886 + }, + { + "path": "backend/app/services/app_classifier.py", + "sha256": "02f1c03452b2dcb4623c084f398efa3c20d4bb495b3214322674a623bbb68f19", + "bytes": 16690 + }, + { + "path": "backend/app/services/app_registry.py", + "sha256": "16d42c1867c709dd19e11106ec2d3086ec555b772f99fc288f31be93d833265b", + "bytes": 1594 + }, + { + "path": "backend/app/services/approval_service.py", + "sha256": "30d7b60123c6407b9e6dd98b8f29e5c7c1996cf48d6fb65942cca1c4f5d6536a", + "bytes": 5297 + }, + { + "path": "backend/app/services/audit_logger.py", + "sha256": "6f799aec15bdeaff850076ba032ac4d1b799686066a684df3bd6024a6e91888e", + "bytes": 1996 + }, + { + "path": "backend/app/services/connector_health.py", + "sha256": "ee9ef534cdd3bed2ddfac2865cee11da05b8c2e17e800aac498c89b0145df96e", + "bytes": 3526 + }, + { + "path": "backend/app/services/container_restart_service.py", + "sha256": "8f8e9a8b011e50b103e6942f7bb36fa028a50b12998324504bd047266a60dd29", + "bytes": 9446 + }, + { + "path": "backend/app/services/controller_artifact_promoter.py", + "sha256": "c5a03b5c95b880def92e584a3f3b67996514898547042ca7cbf9d5a31aa048eb", + "bytes": 16503 + }, + { + "path": "backend/app/services/controller_bootstrap_import.py", + "sha256": "85586f86be6f925c827fe3ea5111380dd0e83d28d2bdb89073e50e6fbeb9d0c4", + "bytes": 1030 + }, + { + "path": "backend/app/services/deployment_broker.py", + "sha256": "1b14fe5b5869e83a953ed1837022b93aadbf1ac713183469f23e68c07d19256c", + "bytes": 22882 + }, + { + "path": "backend/app/services/deployment_control_plane.py", + "sha256": "8a091f19f4729de53be592830c4790db6166f740ae8b0e28d22fe177ac1fb456", + "bytes": 29425 + }, + { + "path": "backend/app/services/deployment_target_registry.py", + "sha256": "6a9d2d2d6e1f2604e44cb5370bd7d39d760f2a7e67bfee2f5e93456d8e7b5d97", + "bytes": 20712 + }, + { + "path": "backend/app/services/discovery_scheduler.py", + "sha256": "2493c63f150274b96231f65eb88289f0ab9a3d420f4ce667d4241356bcd4aada", + "bytes": 13844 + }, + { + "path": "backend/app/services/enrollment_plane.py", + "sha256": "f9c43d95395eaac54f051e01a75df2498fff9c2f09effb56e40d50c537a9efaa", + "bytes": 52498 + }, + { + "path": "backend/app/services/enrollment_profiles.py", + "sha256": "8bdabdac591303ce027c8b65c80f4a84c51d0fa1d4c68fe876e445b95d4461cb", + "bytes": 1743 + }, + { + "path": "backend/app/services/gitea_project_auto_registration.py", + "sha256": "4cb6c77e499c86fa01cef1b0b14cc13c2f3f0773946d0793944ac3c90e42b7a3", + "bytes": 25754 + }, + { + "path": "backend/app/services/healthcheck_service.py", + "sha256": "7650961ad99fee9be8d8194c923f84996529e14f05f465eecbfa171cf65336ee", + "bytes": 80 + }, + { + "path": "backend/app/services/home_assistant_voice_readiness.py", + "sha256": "1d2109fc8f090cb8f912d941f57cca9d9c2d98eeab7dcd4a796996fb40597916", + "bytes": 8717 + }, + { + "path": "backend/app/services/incident_service.py", + "sha256": "fb2c7f9907273c79850e69ec8d99480b71d287878d80e3da80c681496f2aab72", + "bytes": 5713 + }, + { + "path": "backend/app/services/inventory_discovery.py", + "sha256": "ab6d151974a9e849e4c9b42efc5cee305297b6e9eaefd154d64e1502dd1590e5", + "bytes": 71492 + }, + { + "path": "backend/app/services/job_runner.py", + "sha256": "64423fb4c73a2eded275d0c741f9ef35f5e23b7bd5df2f25adbbf9a47d1b92d3", + "bytes": 3089 + }, + { + "path": "backend/app/services/knowledge_service.py", + "sha256": "5743e2d354a329b4dd239635ae2f210f452748973c5bf84c16a42441bec12677", + "bytes": 17970 + }, + { + "path": "backend/app/services/maintenance_scheduler.py", + "sha256": "b74ee0d1315e8394709eb25d12ad78d413f291ea8520e493a3811685e4896fe2", + "bytes": 5315 + }, + { + "path": "backend/app/services/mcp_hub_deployment_reconciliation.py", + "sha256": "d8d41d679cf388e41c654bf2963544584f0be3eb5a75f64349edc884052136eb", + "bytes": 30371 + }, + { + "path": "backend/app/services/mcp_operations.py", + "sha256": "ead789c0b591072c3a5acbb369854d2a26488adf637136d10c829d8f4a7f2107", + "bytes": 14994 + }, + { + "path": "backend/app/services/media_codec_dashboard_deployment_reconciliation.py", + "sha256": "63fab9ddd894bcba5d987b48e3303c6adb073bc0b2e4927fd9a238de4c1b83ac", + "bytes": 44011 + }, + { + "path": "backend/app/services/media_pipeline_service.py", + "sha256": "fde5460fe3fff3eacf3e02f3bf7838da2bd46756f809349e1e29977ffba9eae8", + "bytes": 32423 + }, + { + "path": "backend/app/services/n8n_integration.py", + "sha256": "0f86bfd24301c4a8964cfe174dcade05345be35294b41459a57bd597ec092742", + "bytes": 11450 + }, + { + "path": "backend/app/services/notification_channels/__init__.py", + "sha256": "cb7596ec2317e2f6a2f269d18774d92e64e756ff3315fdcf1b70ffc21317be50", + "bytes": 48 + }, + { + "path": "backend/app/services/notification_channels/external.py", + "sha256": "4eb71e90885a32b853e0c2e6a7ca235a93d641a6d5a029112243e13fbe72cdf5", + "bytes": 6927 + }, + { + "path": "backend/app/services/notification_channels/home_assistant.py", + "sha256": "6b89e496c7d2b01383cb55b783509dd3805fe965409dabfaac9537317d84b25d", + "bytes": 8216 + }, + { + "path": "backend/app/services/notification_service.py", + "sha256": "0b4bfe56117d389565195e3c1468bc94a6dc8dbbddcf51e0acc4871880448636", + "bytes": 46351 + }, + { + "path": "backend/app/services/policy_engine.py", + "sha256": "64eb58c79cd5077d99f550d3f45c53f1d31c654f3acebb7fce16764d724e6614", + "bytes": 10265 + }, + { + "path": "backend/app/services/portfolio_deployment_enrollment.py", + "sha256": "c045e37e0baf197fac4260897ecffefe3174d3b650a5d1e1b79c6ebb0de1a79e", + "bytes": 34273 + }, + { + "path": "backend/app/services/portfolio_deployment_recovery.py", + "sha256": "61dd19c41162b5a34bf672bc7fc0b5c3a99389efd16602af4d17912121954524", + "bytes": 17494 + }, + { + "path": "backend/app/services/preflight_service.py", + "sha256": "751bdcb2c65c1b51676522a7cd7e4d6350a484ac792615a0920dd00a150e4c4e", + "bytes": 1068 + }, + { + "path": "backend/app/services/privatemuse_artifact_installer.py", + "sha256": "97d7746bcac11ce0dc3c5335e13bb0b57f3131f92db309c26e5681b2a2126ea0", + "bytes": 9638 + }, + { + "path": "backend/app/services/privatemuse_deployment_enrollment.py", + "sha256": "489f453c6a2ccc25a573570f1ce079f3f2f80d3d38c371f691ddfaf0bdd6ce4d", + "bytes": 22422 + }, + { + "path": "backend/app/services/privatemuse_deployment_recovery.py", + "sha256": "8a95a950708be8c649a98abc14595871477d91e46552b64a276146a84b9d850c", + "bytes": 49027 + }, + { + "path": "backend/app/services/privatemuse_webhook_repair.py", + "sha256": "ac8dfdf6dbd3cad25804e78cd9b3837eb4d40d390169e19d5959daaaeeaa8838", + "bytes": 12208 + }, + { + "path": "backend/app/services/repopulse_adoption_journal.py", + "sha256": "7eb51356acb297fea0862e6aa1f49d946c0b7be236f68806721aec3d01230f84", + "bytes": 10602 + }, + { + "path": "backend/app/services/repopulse_adoption_ownership.py", + "sha256": "04f5bf4579bd23ca5899839381eb2ca433bf24eaa62fd8a380a61b34231ebd83", + "bytes": 24281 + }, + { + "path": "backend/app/services/repopulse_adoption_preflight.py", + "sha256": "03900eb86d8aea3f5845fb4d3bbf5e53a5e84a0c0414fc37dc4cc435754e2c04", + "bytes": 37888 + }, + { + "path": "backend/app/services/repopulse_adoption_runner.py", + "sha256": "7731d4d50166ea005021c427625f3b49a8f0ccd07807d83ecdba9811415980b0", + "bytes": 55045 + }, + { + "path": "backend/app/services/repopulse_legacy_adoption_protocol.py", + "sha256": "ba76f36cffb712f54c53de9182f56ca2596c1a995201922b96c8b6c8ac5af717", + "bytes": 39668 + }, + { + "path": "backend/app/services/repopulse_proxy_stage.py", + "sha256": "0e29e8e523ebf32443aa5b7171a73dc021d96ff09dc902faceef80b6564c3e60", + "bytes": 3984 + }, + { + "path": "backend/app/services/repopulse_secret_import.py", + "sha256": "ffdd6c2da7a3f09e06a01b9993a91f74a35a57ba999a5e782c39a8f3d533c3c6", + "bytes": 2888 + }, + { + "path": "backend/app/services/repopulse_target_credential.py", + "sha256": "462a4f26779e220808679cf5750b608e89a77b7bfc84b5335df77b1ab41c8bc9", + "bytes": 3573 + }, + { + "path": "backend/app/services/repopulse_trust_stage.py", + "sha256": "cd81eac3c4183c6e006c43d4381a54e70c0724330f10f8d6f8ef9789e90c5a34", + "bytes": 3303 + }, + { + "path": "backend/app/services/request_metrics.py", + "sha256": "9174cf797f242d2150a167a3903439f82e813a8e2aec60b99ca18381e45a040f", + "bytes": 4942 + }, + { + "path": "backend/app/services/runner_fleet.py", + "sha256": "96ade4a3a188428892fd50e9e29730f24cfa9c647c09c567a3306367c3b8a4bb", + "bytes": 3294 + }, + { + "path": "backend/app/services/runnerless_target_provisioning.py", + "sha256": "73c13ab940865cf699a278e6f342c59c848fc99cbcc669ab5159d53596bd5bfd", + "bytes": 18083 + }, + { + "path": "backend/app/services/runtime_service.py", + "sha256": "2db97c9e987cfaa2a87956a5d5b289446fea9510b195376088f5c4b6a389fbea", + "bytes": 24067 + }, + { + "path": "backend/app/services/safety_center_service.py", + "sha256": "fad46789e55a02e0c8071affc1eb0a1f0f0a03258ff52d914e9aa67e92a61903", + "bytes": 35432 + }, + { + "path": "backend/app/services/secret_store.py", + "sha256": "806fe998b97e51c38e8379f75ffc206a163e1cb3fa44ed3cae495ab912f941fb", + "bytes": 7239 + }, + { + "path": "backend/app/services/setup_service.py", + "sha256": "65d702f7e57240dfb17d5f830fcc3fef2041748699c685f89acbbc08a069d6b1", + "bytes": 28412 + }, + { + "path": "backend/app/services/unraid_vm_service.py", + "sha256": "427ce90941e8640e7934b18703bc84bae76567456b6b16993303ca60665fea34", + "bytes": 12972 + }, + { + "path": "backend/app/services/voice_catalog.py", + "sha256": "260aae70bc4c8f3a19cf8abe86d7d7b4326bb613a53492dbe25860c06435ecf6", + "bytes": 26706 + }, + { + "path": "backend/app/services/workmesh_first_deployment.py", + "sha256": "0f9717d107ec42b10ee0a952e58f32ac641b12de569e14fa81e5e2264d779bf3", + "bytes": 23076 + }, + { + "path": "backend/app/services/workmesh_promotion.py", + "sha256": "8099dfcc9831bf319ceec325396a418d3f1e97eeb7f93d137eb1d5941e5ec78d", + "bytes": 30570 + }, + { + "path": "backend/requirements-dev.txt", + "sha256": "32319e895d95fcd1eb3f89985a375ad3a50d3110c1771c00e370516d0da8c45b", + "bytes": 49 + }, + { + "path": "backend/requirements.txt", + "sha256": "be1771b3495f31012773d03585ad58eafe10fbf66b846543a1240ba759c72c25", + "bytes": 165 + }, + { + "path": "backend/tests/test_ai_remediation_planner.py", + "sha256": "8d21f67cabb009fe245bb9bb73d2d67e80d4aa2cbb93dc3557a842abba724964", + "bytes": 15412 + }, + { + "path": "backend/tests/test_ai_security_reviewer.py", + "sha256": "f6c79cda4434bb8a63872fcd36f5094efbac1df6b55a501f3f3771a47a799fe2", + "bytes": 48253 + }, + { + "path": "backend/tests/test_appops_self_upgrade_controller.py", + "sha256": "211f8164ad1c69d08213a0eaa94d83ee976ff1cd8ba538101f76714c04ae75e4", + "bytes": 19200 + }, + { + "path": "backend/tests/test_authentik_connector.py", + "sha256": "9da3c46807c6f620d5c4416a1712e832def66b7b47965c2c86bcf093093f8707", + "bytes": 28925 + }, + { + "path": "backend/tests/test_container_restart.py", + "sha256": "c056ea15ae61af9b0d5b0c225e5ff00c4ac549d8ffe7c2f9b4a44fdc9a0f3813", + "bytes": 108726 + }, + { + "path": "backend/tests/test_controller_artifact_promoter.py", + "sha256": "ba8e4d21f215d3e119fe78b1e8631e2b30d942385d0b293aeb8ad0923d16be90", + "bytes": 3259 + }, + { + "path": "backend/tests/test_controller_bootstrap_import_host.py", + "sha256": "c287f145d78e5941935536ea29733f975b031a6533766fc5ad0267ad0ede32c9", + "bytes": 1620 + }, + { + "path": "backend/tests/test_controller_state_reconciliation.py", + "sha256": "300c6949c24a1c363f1fd024b2b61a97b8e64c8e2144b77bf85cd436d986e588", + "bytes": 10998 + }, + { + "path": "backend/tests/test_deluge_connector.py", + "sha256": "1604a463cb96bfd9f5defb8560d161d45b346f0e24a28e1f659d6316351eabf1", + "bytes": 15345 + }, + { + "path": "backend/tests/test_deploy_broker.py", + "sha256": "bc2638a415fbba5bb4fdd7097a8c5c11513d855633bca4fd7a1b4d7e01cc2779", + "bytes": 294495 + }, + { + "path": "backend/tests/test_deployment_auto_rollback.py", + "sha256": "c204313b9098f6e6f3f78981e3fff8ff00c17c8bd3ad9a7d3910649efe1de471", + "bytes": 13755 + }, + { + "path": "backend/tests/test_deployment_contract.py", + "sha256": "6b47048ac64a9ec99d1b89ec3676c40ac7d321caa40fa56f6e4adcfc99089831", + "bytes": 4018 + }, + { + "path": "backend/tests/test_deployment_control_plane.py", + "sha256": "7dff2957220996a4c76227c65743147e947a44534ce7a0c2814fe62fce4b1a86", + "bytes": 11853 + }, + { + "path": "backend/tests/test_deployment_hygiene.py", + "sha256": "e0f1b27d460da14d81b7b6192261f7ae3154e4efc9cfd4a0bec925452e4919a2", + "bytes": 2523 + }, + { + "path": "backend/tests/test_deployment_target_registry.py", + "sha256": "68b463a5ed77de13a5aa06b0e4a2f5f176f16c490857e37c8453a2c59bd7a34a", + "bytes": 9676 + }, + { + "path": "backend/tests/test_dockdeck_classifier.py", + "sha256": "f4bc608fff6e348fc15644e691f8776b0571082dc8b39341a07c4d387c8b6819", + "bytes": 1207 + }, + { + "path": "backend/tests/test_enrollment_plane.py", + "sha256": "41047aa8fa6b790e72af392074883e9c8f2f0233898c25d2f8d103391624ab40", + "bytes": 20135 + }, + { + "path": "backend/tests/test_gitea_actions.py", + "sha256": "8cf67824e24182d941cdef3061d69569a32062e9b9c840f774d732d015f31212", + "bytes": 11092 + }, + { + "path": "backend/tests/test_gitea_project_auto_registration.py", + "sha256": "c0ad06c3011be96ddbd8440215075ba7056af40fe9295fddb4bd13bad16e95bc", + "bytes": 13643 + }, + { + "path": "backend/tests/test_home_assistant_voice_export.py", + "sha256": "8ef147b714c5bef8ea290ed91f2bff41c8771ec0cb9c0a443cc4304a512a51db", + "bytes": 8264 + }, + { + "path": "backend/tests/test_inventory_discovery.py", + "sha256": "5eacccae9089f6f998f6c5f26e8c51144d8e481bd21e183e75bd69a893287c35", + "bytes": 61686 + }, + { + "path": "backend/tests/test_live_observability.py", + "sha256": "a87a2e8aad801a042c83631ae3da2bed877f6075b1e9e598eca878b3ad4cc43e", + "bytes": 5365 + }, + { + "path": "backend/tests/test_managed_validation_checkout.py", + "sha256": "d2537215ae75b39786f8cc6f465adbe0c089b25c309e030017cf04265a23f7cd", + "bytes": 475 + }, + { + "path": "backend/tests/test_managed_validation_parallel.py", + "sha256": "d38a75bb1aa3d843bb0ad6a7f93d411b5cb46b428abdc3d8cae30d1a308267bb", + "bytes": 710 + }, + { + "path": "backend/tests/test_mcp_hub_deployment_reconciliation.py", + "sha256": "de75645a94043380846aed2bf3b7481e46a11d936b66be394f30bb1f17a3ba96", + "bytes": 13614 + }, + { + "path": "backend/tests/test_mcp_hub_policy_reconciliation.py", + "sha256": "ec9632cc53648e5ed3a7b9825d5deaff143b353567fdbf8ca943e96e0a44a58f", + "bytes": 5206 + }, + { + "path": "backend/tests/test_mcp_rag_n8n_integrations.py", + "sha256": "bd90a81a9416bb52e401550d408d7e0f889d151b255c229286593146e156fdfe", + "bytes": 49510 + }, + { + "path": "backend/tests/test_media_codec_dashboard_deployment_reconciliation.py", + "sha256": "4e01932c91df7637284e214a576b436d105315f86edab179ca90504b94fd6ca9", + "bytes": 9351 + }, + { + "path": "backend/tests/test_media_codec_dashboard_reconcile_diagnostics.py", + "sha256": "7738869e213874f38849f1e48dcf9ff4c80e087438edcd96cdbb8a5330017e4b", + "bytes": 15657 + }, + { + "path": "backend/tests/test_media_codec_dashboard_reconciliation_action.py", + "sha256": "5199cbcdf2ec9c41f3ac40de1358d446fdb25750fd062ad6d8a559dbc0451c64", + "bytes": 4179 + }, + { + "path": "backend/tests/test_media_pipeline.py", + "sha256": "31d81554d362684246557446c2ca9751204b41489f0d4c6c04642083d1dbacb6", + "bytes": 23486 + }, + { + "path": "backend/tests/test_mediarr_validation_window.py", + "sha256": "17a03cbe34a31de08c0d575199da069560ca1b46d8c183bd62b0e88c259398ee", + "bytes": 2603 + }, + { + "path": "backend/tests/test_nginx_proxy_manager_connector.py", + "sha256": "9f06d2f66cea752c10739a6216cd0ce77247a29c52f1b21b987bcdead00772cf", + "bytes": 34905 + }, + { + "path": "backend/tests/test_notifications.py", + "sha256": "ecafc9517db054c6dd02a5fc23e68b64c59bc534ce2bb35d1e7b06a9124ec8d4", + "bytes": 47374 + }, + { + "path": "backend/tests/test_performance_regressions.py", + "sha256": "935dfcba3e2e67dcf8fe41ee3e8ecae1a21c2b42e1a8ca7ca476d130c05cab7e", + "bytes": 1619 + }, + { + "path": "backend/tests/test_plex_connector.py", + "sha256": "34c1a7284c95082622d1f9b5dc664eb3961bd734805bb9c605f2120e728d3de1", + "bytes": 13435 + }, + { + "path": "backend/tests/test_portainer_restart_timeout.py", + "sha256": "b572edbb6fd2ba2fffcebabb70ffd5052768b48bdeddfa5b21e6c244d8451f52", + "bytes": 1380 + }, + { + "path": "backend/tests/test_portfolio_deployment_enrollment.py", + "sha256": "29faf7c514a40b113fccccf6a507c6dd8223ac82c98c7b4e9518c113affc0662", + "bytes": 6890 + }, + { + "path": "backend/tests/test_portfolio_deployment_recovery.py", + "sha256": "807bbbe6b4731fc9625dfaa50873272cbcc43c7107f9b846f963d9be7bb0e544", + "bytes": 3007 + }, + { + "path": "backend/tests/test_privatemuse_adoption_health.py", + "sha256": "4f4e7c4b66a8ad4768d784dcf7dadf40cca7d17c54fe13a3df9a0066f2c48ff7", + "bytes": 8862 + }, + { + "path": "backend/tests/test_privatemuse_artifact_installer.py", + "sha256": "712cfeaedb30b8b41fef2fd5d4696760dfc5ca3ef1ef73e982119429aad34b02", + "bytes": 8631 + }, + { + "path": "backend/tests/test_privatemuse_deployment_action.py", + "sha256": "aa20d95ee8ec95e5c2d37fc30e9c51a480a6564b85ff66b5cf7bc63204fbc346", + "bytes": 9298 + }, + { + "path": "backend/tests/test_privatemuse_deployment_enrollment.py", + "sha256": "fd715169a54ed33be255bcfbf95a1f742dc59f44b82fd6f96753185a31097f25", + "bytes": 7679 + }, + { + "path": "backend/tests/test_privatemuse_deployment_recovery.py", + "sha256": "c0e331e40b47724ab0c1a3811d2a543ea915f5c6c93e1a06f6cd19ea36effd45", + "bytes": 23603 + }, + { + "path": "backend/tests/test_privatemuse_f84_adoption.py", + "sha256": "0cf767594f623b6741b59fc93ac66f3b12364e517237ab6c2d400c96c1eb1b32", + "bytes": 22306 + }, + { + "path": "backend/tests/test_privatemuse_f84_receipt_diagnosis.py", + "sha256": "eddfa2c557be54d379931ae5830fb9ed45b52b56afad3172566b92d4244d72f0", + "bytes": 12417 + }, + { + "path": "backend/tests/test_privatemuse_webhook_repair.py", + "sha256": "b50f18d2c16f9dd9e65724e148f07ca724be59d86b1c3076df9fc7ae9ff04da9", + "bytes": 6568 + }, + { + "path": "backend/tests/test_production_runtime.py", + "sha256": "5a7e7a126b865751b9604ba8e5a1bbd361a19806cd4f246d0c9d999e6ce857ca", + "bytes": 15508 + }, + { + "path": "backend/tests/test_prowlarr_connector.py", + "sha256": "4144526b426741ee3bc21e27453795561950bb7232f768600c76a1315e1ce6f8", + "bytes": 12665 + }, + { + "path": "backend/tests/test_repopulse_adoption_cli.py", + "sha256": "d2b01130c0f14f971ab349c7c407ab4674283ebe3fbc06c5171a831b15bcdcd5", + "bytes": 13639 + }, + { + "path": "backend/tests/test_repopulse_adoption_effects.py", + "sha256": "714245e7c56cd1ad72125d8fc8c2249d2ed569ea186f9e409bf00465217d5541", + "bytes": 41040 + }, + { + "path": "backend/tests/test_repopulse_adoption_executor.py", + "sha256": "3ff74bc5f7b4205c01a849768aece1605b5b575e9af2375fade1f6d1f74d5271", + "bytes": 36022 + }, + { + "path": "backend/tests/test_repopulse_adoption_journal.py", + "sha256": "35e309b83094846e90ce74060138f9869a5f3ae7d7aec57bd5c8fcfcdbc442e8", + "bytes": 19361 + }, + { + "path": "backend/tests/test_repopulse_adoption_ownership.py", + "sha256": "a2d4cce849a6315eda43e3466175baf1ed802ed1766f921b2897207b04edc24e", + "bytes": 22608 + }, + { + "path": "backend/tests/test_repopulse_adoption_preflight.py", + "sha256": "7bf05b6d105eed033dd49a65ed889cf1df2c5d443d4dfe34865bec183ec8f312", + "bytes": 13589 + }, + { + "path": "backend/tests/test_repopulse_adoption_runner.py", + "sha256": "7acd1c8ee716ad15262d53b41d450ef31c9eb0d4e3a8d717b0c460d238ad4287", + "bytes": 24934 + }, + { + "path": "backend/tests/test_repopulse_adoption_storage_effects.py", + "sha256": "47532512ff083faf830805bc2283162a63d977ffb691785b26e0fc0d5c6f0ce2", + "bytes": 11967 + }, + { + "path": "backend/tests/test_repopulse_controller_adoption_store.py", + "sha256": "b6893f8c495e70e69c0c751a6f2da671ba9ea13d481dac2d6b5f42868a93cfef", + "bytes": 42894 + }, + { + "path": "backend/tests/test_repopulse_legacy_adoption_protocol.py", + "sha256": "882809daed5b5067f0e2595842f253f791aad46c2472488322f5d88c304ed6af", + "bytes": 20431 + }, + { + "path": "backend/tests/test_repopulse_maintenance_environment.py", + "sha256": "e3ca6fb7a437080813e17dba0027895b630c2f6652b7c151aaa0982e1c490777", + "bytes": 3054 + }, + { + "path": "backend/tests/test_repopulse_proxy_stage.py", + "sha256": "20d65a51c6fdc7e7fd053ea53d29cb7d35afd908f61651ade16c941fa8a7abdb", + "bytes": 3411 + }, + { + "path": "backend/tests/test_repopulse_secret_import.py", + "sha256": "428e2f3289c94de5515ba49925f3ec936e46cf30766bcb2f7d11b4a3dba4cd63", + "bytes": 2952 + }, + { + "path": "backend/tests/test_repopulse_secret_import_host.py", + "sha256": "ed76eb223ef611710fa398b37d82d5d8dfbbfe7519a09b408f06d2ad08725c0f", + "bytes": 1729 + }, + { + "path": "backend/tests/test_repopulse_target_attestation.py", + "sha256": "b415dde436417ac08ed3199044f11921f0141988b10eaaf2e100725f0b153911", + "bytes": 11872 + }, + { + "path": "backend/tests/test_repopulse_target_credential.py", + "sha256": "63119ea35395496fe30b39f328f0fbfa1ea646fa68eea66463fb42ca6f86d664", + "bytes": 2682 + }, + { + "path": "backend/tests/test_repopulse_trust_stage.py", + "sha256": "aa9f8381ae38a311eb82ef35901aac98d352a63e313af035417417d95a4be49f", + "bytes": 2628 + }, + { + "path": "backend/tests/test_request_metrics.py", + "sha256": "ce59cf3fbada6fc26e230b78cee2eaecd7d6887d4d6916cbccf5569ed549ae07", + "bytes": 486 + }, + { + "path": "backend/tests/test_run_repopulse_adoption.py", + "sha256": "79d8ede61eda9940a83b899f0374c040420acde74e0d7d590baa876ce717f57e", + "bytes": 4577 + }, + { + "path": "backend/tests/test_runner_3_5_upgrade_contract.py", + "sha256": "4adc35c7c56311e8fc04a5b6463cf8f1b63c701314f2b97ab9b600d1c17f3a4d", + "bytes": 3336 + }, + { + "path": "backend/tests/test_runner_capacity_controller.py", + "sha256": "cc9b8a1d753f0e3564223ffa5fac72760ef383806dd4d84bce8226683e4de2d6", + "bytes": 7129 + }, + { + "path": "backend/tests/test_runner_capacity_policy.py", + "sha256": "83bee6591bd408b4f8969367e8a6039c398149b5396b36fb5f5bca03e119ca1d", + "bytes": 5481 + }, + { + "path": "backend/tests/test_runner_fleet.py", + "sha256": "13355a9adc94c5438ab2faedec8b6ff63335057c22b2b6ca3ade5710b0467c6e", + "bytes": 6146 + }, + { + "path": "backend/tests/test_runner_fleet_scheduler_recovery.py", + "sha256": "7553054ae84d262008f5fdf5bb7a705ea720ffae7fcbc4bf4cccd19a931da2c0", + "bytes": 35999 + }, + { + "path": "backend/tests/test_runner_fleet_v2_execution_surface.py", + "sha256": "83c18e71546fdac11b48d5054acf0218404fe5c9da1e2bd43d2e03521d884019", + "bytes": 2387 + }, + { + "path": "backend/tests/test_runner_scheduler_liveness_recovery.py", + "sha256": "d212deab8c8a9c2a164eaf2721575d6b6a43f13f0e4bdf4cae1b3fe92cc5a776", + "bytes": 3925 + }, + { + "path": "backend/tests/test_secret_storage.py", + "sha256": "99ce500f5885391e482e7d17af3c6858e39bfbefb747ff164868c17ae526d2bc", + "bytes": 11149 + }, + { + "path": "backend/tests/test_security_boundaries.py", + "sha256": "4caab73e22ec7b76cfbc8bb2a3f52d038a58d2d90e1e89aa5794827373f7468f", + "bytes": 5668 + }, + { + "path": "backend/tests/test_setup_onboarding.py", + "sha256": "da1a59af44eef448f1d7e5e609c08933b3f5b8277a864c40ab17919e032e6fb5", + "bytes": 12909 + }, + { + "path": "backend/tests/test_setup_reconciliation.py", + "sha256": "1f68ca2796923b5d83aa515c538e9505e5441b6b0a42e79fb19ab5b6bd59bafa", + "bytes": 20101 + }, + { + "path": "backend/tests/test_sqlite_runtime.py", + "sha256": "67386dbd6be865b6986a02c3ee699c653ddf238f7a8ec1aacd8d77fb6d628b3a", + "bytes": 897 + }, + { + "path": "backend/tests/test_tautulli_connector.py", + "sha256": "107e50a5cfa659d8f338441a899ed17843b41189781095d9f7aaabed70afc166", + "bytes": 12696 + }, + { + "path": "backend/tests/test_unraid_connector.py", + "sha256": "2b133351fb85b8c3aa49cb1a97900451dd4dfe05264807dd00ab21edb6261d6b", + "bytes": 15279 + }, + { + "path": "backend/tests/test_unraid_vm_actions.py", + "sha256": "5a389a8d49d7964680812c71c3d4b6a23eb95ca792a02117f50c463b0ddc891c", + "bytes": 5728 + }, + { + "path": "backend/tests/test_widefrog_connector.py", + "sha256": "f0a681dba1dfaaf6db87d1208e625fc5cbede419aee92b243ecb547952263c32", + "bytes": 10080 + }, + { + "path": "backend/tests/test_workmesh_promotion_contract.py", + "sha256": "a35faae710da61d6e8ab94b081f562c9dfa5f2541097b15f9d4884288f3dceae", + "bytes": 1399 + }, + { + "path": "components.json", + "sha256": "1b55b1d844ca520d6df6293c24d1366839511cf2d00a285300574327eb61ac27", + "bytes": 434 + }, + { + "path": "docker-compose.yml", + "sha256": "5c8600a4727d4ef390d4390caeffec8a52798f384370de29c09bdf312280460e", + "bytes": 514 + }, + { + "path": "docker/entrypoint.sh", + "sha256": "604cfab77ca76fc4b8d9d0cff89d5969b04189f3d99645b017d46ff023733b48", + "bytes": 1670 + }, + { + "path": "eslint.config.js", + "sha256": "fa3046d07e1e054f8ba6e71bfc5eb2bf1898102ad7fa3bf3f53944cfec25fa06", + "bytes": 819 + }, + { + "path": "index.html", + "sha256": "40f169899dae9138af4adb6e905f33f01a548852b763499f4b4b8dfc97c34d7a", + "bytes": 787 + }, + { + "path": "package.json", + "sha256": "65d37aaac88b46f00c56ffb6fddfdc9cc25f8b63b1b4c2c4d9547accd9a2483d", + "bytes": 3625 + }, + { + "path": "pnpm-lock.yaml", + "sha256": "281ba56e326813ea51eb0cc1c6aeee0d0ec221a1f999893ab9785a3660746d15", + "bytes": 215211 + }, + { + "path": "pnpm-workspace.yaml", + "sha256": "b059f783c0ec992afc463b9d327611321a44ce054100ea36eac8b7980d2ac45c", + "bytes": 8906 + }, + { + "path": "postcss.config.js", + "sha256": "98e8d8a143f16216f4b50a2520024f6254987049b2e199026742a96e16ba411e", + "bytes": 87 + }, + { + "path": "public/apple-touch-icon.png", + "sha256": "3a3fbf1a014bc37ec60042a55098a785ff042355b7cd4d0cef334d7c74f2579d", + "bytes": 6015 + }, + { + "path": "public/appops-icon-192.png", + "sha256": "1c388d073c49546182cb6ff2587df04601f8e2d32eb9f1038bb1a4a6fca63de8", + "bytes": 6567 + }, + { + "path": "public/appops-icon-32.png", + "sha256": "0ecd683259139ab78d65ad0c32e0244fb602f774e26960270bc603080fc02257", + "bytes": 1168 + }, + { + "path": "public/appops-icon.png", + "sha256": "8ee85e4a161efd7c7c7a602dbe519c81978c80a633df7ccac0857011b8313d33", + "bytes": 21422 + }, + { + "path": "public/appops-icon.svg", + "sha256": "7fd713b80fcb10865f335d5b838ba526dd69300485e5e50f316e34891bc3494a", + "bytes": 686 + }, + { + "path": "public/favicon.ico", + "sha256": "0395c80ce28e7baaca9d113733f5d4653d24d46e42861db9f0cab7f6dbd9dfbb", + "bytes": 8565 + }, + { + "path": "public/fixtures/appops-canary.safetensors", + "sha256": "672d64f90962ff97c40d760074b698d1c89ef622f1a858a826c6ed71c671b854", + "bytes": 67 + }, + { + "path": "public/robots.txt", + "sha256": "52710c261d851bc1ad35604017862f2a8444e7a0f02cd7b8b49e3ba92e6d4cea", + "bytes": 174 + }, + { + "path": "public/site.webmanifest", + "sha256": "acd6a012777779cb69b66d26a7105e7a3a6e7b4a1160c7fc630a666c1ba43bba", + "bytes": 496 + }, + { + "path": "src/App.css", + "sha256": "52775c12355a313625382ef76be2fb43191aea240fde9e211625e71b6ca6a01c", + "bytes": 189 + }, + { + "path": "src/App.tsx", + "sha256": "02997738e5cb38585b51ca80d00947c27e021a64ae51cd44c4ef5fdb31d30b87", + "bytes": 1554 + }, + { + "path": "src/api/appopsClient.livePolicy.test.ts", + "sha256": "6f74277eb9c953746aabc729d2418179a0fa281326522a7cd1b5ceb5108da826", + "bytes": 847 + }, + { + "path": "src/api/appopsClient.ts", + "sha256": "a13a16a3c7c4a527a8d6ee4ff239755115e52aafd0ec2ffc1ce8797e935da572", + "bytes": 249 + }, + { + "path": "src/api/liveClient.ts", + "sha256": "0bca40e1cf3bdc01eb6b8dadd7b6ab846572fe6d43e04e3217548a9c33aaabed", + "bytes": 60464 + }, + { + "path": "src/api/operatorSession.test.ts", + "sha256": "782fdc153fee197f65279d86389ea5bee8082a4d9c5b76f669bae4a66d3a341a", + "bytes": 1058 + }, + { + "path": "src/api/operatorSession.ts", + "sha256": "b958f87b23370c5859797f843e4e59287e12821a738b9040903b529d031de0dd", + "bytes": 1124 + }, + { + "path": "src/components/ActionResultNotice.tsx", + "sha256": "7e0b65f03a13433ce89a230e971de6f8b7e2c202b54a51818d85034f0141062f", + "bytes": 859 + }, + { + "path": "src/components/AppDetailActionPanel.tsx", + "sha256": "63c4e0c5f8b4d06d310ac8b6c6a01da99e55e8f6c323a0382afa17fd7a65ac6b", + "bytes": 2765 + }, + { + "path": "src/components/AppMetadataEditor.tsx", + "sha256": "e6da9298eebaea4500a2598f9b22b59438699c83573dcb3838678cffda877c1a", + "bytes": 4272 + }, + { + "path": "src/components/AppShell.tsx", + "sha256": "f883bc4cbdbb0ddb60555cdab1a091073ee489e73dc974702406de760dbbd328", + "bytes": 6537 + }, + { + "path": "src/components/CommandPalette.tsx", + "sha256": "a107981246a2c52aa49e50400f871e7bad71ab6a5db02afb505ef9224748d351", + "bytes": 6222 + }, + { + "path": "src/components/ConnectorActionResultNotice.tsx", + "sha256": "c6ac179adffe350d371057ea0ef8c3d64b0002588941df5e2980cd03b1460e00", + "bytes": 1716 + }, + { + "path": "src/components/ExternalNotificationChannels.tsx", + "sha256": "9ace9cbd3219b58047b16369d624115ee736972e9781e04e95672140b360d2f7", + "bytes": 6532 + }, + { + "path": "src/components/LiveActivityFeed.tsx", + "sha256": "d09f74ee336aadfaeb490b6cd8105ee9662731bb1a999a472b2e4efe4c85b8a7", + "bytes": 6028 + }, + { + "path": "src/components/MetricCard.tsx", + "sha256": "9b9acb24e2c43624f35d2d266a8d61dab6740e0c9e2325a0c1f6ad0cf4f4afd9", + "bytes": 2398 + }, + { + "path": "src/components/OperatorReadOnlyBanner.tsx", + "sha256": "9d6cbcc962c90e7ba5c45bf5f556e00e7046af1d485442d4b4dbb46920064850", + "bytes": 1035 + }, + { + "path": "src/components/PolicyAction.tsx", + "sha256": "82fc1b5392289e5a2a40c5ea969ec19c79d9a4cd5006e2c3933d4fac8290f8ae", + "bytes": 11163 + }, + { + "path": "src/components/RiskBadge.tsx", + "sha256": "588c6d178e9c920f43bbbbac8bc3cc9b872c07dd3f77aaa2d89e73db831ca085", + "bytes": 601 + }, + { + "path": "src/components/RouteLoadingCard.tsx", + "sha256": "9388e0e07dd001cc276bbeae8122f41aae835122371732b8082b621a571f828d", + "bytes": 1132 + }, + { + "path": "src/components/Sidebar.tsx", + "sha256": "701863584b1eb5484f78a425e67e5b2c6a8d30a384827e5b0fce1129280efda1", + "bytes": 4541 + }, + { + "path": "src/components/StatusChip.tsx", + "sha256": "e413aec2f07dd00f542289af2b06ba03c2243e4f8588acaecf62d27f724fb0b9", + "bytes": 1879 + }, + { + "path": "src/components/SystemLoadSvgChart.tsx", + "sha256": "517fbdd3350fbf477671fc4426cd93f88db801b1d103f2da4743e9e8dad9ceee", + "bytes": 2060 + }, + { + "path": "src/components/TopStatusBar.tsx", + "sha256": "60cb0512a582f3eca2fdd513e3dd6cd4789c09b7f872ccc26a65bd0c34bfba42", + "bytes": 7681 + }, + { + "path": "src/components/integrations/GovernedIntegrationFlow.tsx", + "sha256": "c16d01f703a8a71c62dc9dcee180cdfc5e245fe749937eaad9bcc3e4a01dc08a", + "bytes": 13222 + }, + { + "path": "src/components/ui/accordion.tsx", + "sha256": "ed40a1ef1b8911a50008d4d5593cf31340a909c9aac6d99e8f5a5f03e013e153", + "bytes": 2046 + }, + { + "path": "src/components/ui/alert-dialog.tsx", + "sha256": "f6ec16b715aa309b5077cb3fab2c513d36a467d3359869c2a5524d9c325436ff", + "bytes": 4588 + }, + { + "path": "src/components/ui/alert.tsx", + "sha256": "0474ee4d0a0f95ed578ada1ba19637205d468843bbf8fd81388b3e77d7d564e5", + "bytes": 1655 + }, + { + "path": "src/components/ui/aspect-ratio.tsx", + "sha256": "67c60b91df6dc9fc729390742b04c02d40c19f51f7a2d4f79110911a061266bb", + "bytes": 148 + }, + { + "path": "src/components/ui/avatar.tsx", + "sha256": "bf9efb595e6eb5a10ba278f14483b36b294e50ef9f2bd1af1c74879eed8c316e", + "bytes": 1465 + }, + { + "path": "src/components/ui/badge.tsx", + "sha256": "9468f95ed7eddf53848db7fae4b4a8ea851d89d497c07f804513bb43cd820651", + "bytes": 1260 + }, + { + "path": "src/components/ui/breadcrumb.tsx", + "sha256": "e9c60a0b7f08c2b3d16a2e5aa81084322b45849080f1e4959589b7015fcc294e", + "bytes": 2840 + }, + { + "path": "src/components/ui/button.tsx", + "sha256": "82cecf4e194e76ab5cdd9a49f4090ec5c053ad0327e73d8c63516bf9869bfca9", + "bytes": 2247 + }, + { + "path": "src/components/ui/calendar.tsx", + "sha256": "07216b587b4640383e19069a8af9459a37f516bc5abcef8e642fcf5d6aba4aa2", + "bytes": 2859 + }, + { + "path": "src/components/ui/card.tsx", + "sha256": "36ba6142b3e6944123e26148cb5eae10a5cbd8dd32cdf5f6274d256db66db50c", + "bytes": 2000 + }, + { + "path": "src/components/ui/carousel.tsx", + "sha256": "dd9bd68d7d94a64a2e18780852893f9d57b4504ef0f9b816ba553613bd380a30", + "bytes": 6549 + }, + { + "path": "src/components/ui/chart.tsx", + "sha256": "afcf6b7e4286d40a303502e0fa1ef76423cfed67155007f9ef6073ca35e62940", + "bytes": 10913 + }, + { + "path": "src/components/ui/checkbox.tsx", + "sha256": "6fcb6710c47bdb31a4e2c21d46484f52162c3f306f1ce14a8d9d0551c50e1fca", + "bytes": 1092 + }, + { + "path": "src/components/ui/collapsible.tsx", + "sha256": "f375e9bb056882208fc140c8f65d8878398c891351d369950bf4cd431e4a98e6", + "bytes": 329 + }, + { + "path": "src/components/ui/command.tsx", + "sha256": "a94c5d41eee831860b8b13d55ac984a29329c35376d7c21aa3dd43f47523b95c", + "bytes": 5254 + }, + { + "path": "src/components/ui/context-menu.tsx", + "sha256": "355d0bf38cb4c1d7c098b0a9ce98f9345d2f115f484b73190442a0d1f4624cab", + "bytes": 7485 + }, + { + "path": "src/components/ui/dialog.tsx", + "sha256": "0895f5892e374e971abc59a24a9dee366b5fdda9455f13ff4b1947696033a9d5", + "bytes": 3754 + }, + { + "path": "src/components/ui/drawer.tsx", + "sha256": "c3d4ea2ef108375d0b23203adc5107de2ebd02b5766615c14daf018ce51d8a37", + "bytes": 3146 + }, + { + "path": "src/components/ui/dropdown-menu.tsx", + "sha256": "d9af479d2782fa5956ecd238dc50bd3a56d7cde45f28da0e2b0935058c17841d", + "bytes": 7533 + }, + { + "path": "src/components/ui/form.tsx", + "sha256": "983dacc3646fa2913abcf8cf296a08d6b7543dfb95d9f3626976b954273017cd", + "bytes": 4375 + }, + { + "path": "src/components/ui/hover-card.tsx", + "sha256": "643dc4bb95e37a4c8244c9b00f6042ad52ad8deeb44eb212c7e6aadce0662350", + "bytes": 1220 + }, + { + "path": "src/components/ui/input-otp.tsx", + "sha256": "556420a76a560cffae8e5ad002ecb4706717a160d707f5358765505450ef9ee7", + "bytes": 2241 + }, + { + "path": "src/components/ui/input.tsx", + "sha256": "eb6f6ee89acf84aa282626f35d1433fb29534c1a89959efdc85ea80a77a6e712", + "bytes": 988 + }, + { + "path": "src/components/ui/label.tsx", + "sha256": "2b525af16feb9c9efb8da26403b4dbee8fc2da41834272d306495a421b46baf6", + "bytes": 743 + }, + { + "path": "src/components/ui/menubar.tsx", + "sha256": "74411cc2825c310339baf3375c7c7f46c8664456ef33b8ad6a29d2023165e3c9", + "bytes": 8256 + }, + { + "path": "src/components/ui/navigation-menu.tsx", + "sha256": "e09eed189061f1106526e50b73e88cd05600f1c18dff9a0b448335a445611e82", + "bytes": 5260 + }, + { + "path": "src/components/ui/pagination.tsx", + "sha256": "effbf78257243dd21277774fd7e1ff5f135cb157f3117ebd3d75c19bc4aee151", + "bytes": 2890 + }, + { + "path": "src/components/ui/popover.tsx", + "sha256": "3c3c138c4ca584e2449599fa87467cf96085fe6518d03645b3adfc83f118202b", + "bytes": 1268 + }, + { + "path": "src/components/ui/progress.tsx", + "sha256": "88abb42c48e2be6b18ce063f7c2884ce26c25768d1dae07606782779c00343fe", + "bytes": 810 + }, + { + "path": "src/components/ui/radio-group.tsx", + "sha256": "a16e8061e94274df6b38fbd411cd8bcebdbdb66e56b5aa992ac4cc659b25b384", + "bytes": 1521 + }, + { + "path": "src/components/ui/resizable.tsx", + "sha256": "7a886c2329ec540125959a1b9e9e2e8207dff04b9f48c4d37ee0bc1e44c391ba", + "bytes": 1762 + }, + { + "path": "src/components/ui/scroll-area.tsx", + "sha256": "b6b89d2406d22a01f4ccc3f9b741ffb153a566a75f472e65bbefd2893cbd5eb5", + "bytes": 1697 + }, + { + "path": "src/components/ui/select.tsx", + "sha256": "312df96c87864e3bb783990db389a0964f7bd29efe28d666cdfb384460208597", + "bytes": 5767 + }, + { + "path": "src/components/ui/separator.tsx", + "sha256": "a5be8ab18e91d288a4997493e42a607a19327952b944f7cbbdddfb2a949a773a", + "bytes": 794 + }, + { + "path": "src/components/ui/sheet.tsx", + "sha256": "ad23e915830d0c4dc28b32b4c783e7fa370f2b36ecdbc308ed628664b99880af", + "bytes": 4437 + }, + { + "path": "src/components/ui/sidebar.tsx", + "sha256": "c903e86d12e5c8e9c573c40dda55c5a99336ec49055ecdfc53413cb86155e1ef", + "bytes": 24430 + }, + { + "path": "src/components/ui/skeleton.tsx", + "sha256": "72f65ca8c456b0e33367d8e636d75ec4938c0d4020971385fee40b135662b5d1", + "bytes": 279 + }, + { + "path": "src/components/ui/slider.tsx", + "sha256": "19d1c4ee8b9dd6c479ba7c8e646f539ecae773df780c51325238083b1dab3079", + "bytes": 1110 + }, + { + "path": "src/components/ui/sonner.tsx", + "sha256": "4b4a75ca0de153a49abbc03d98742b5fb4245536a4ef55fa00c07a39051a0050", + "bytes": 916 + }, + { + "path": "src/components/ui/switch.tsx", + "sha256": "2458cfd71630f191fdd2dea8a5a65bc4589b578100fb1526f7da50c74d7da332", + "bytes": 1210 + }, + { + "path": "src/components/ui/table.tsx", + "sha256": "e563104e9ce3dfb69fef6e755ca172c5aa4d20c8a9a4d0c36540b9582bbf5d86", + "bytes": 3010 + }, + { + "path": "src/components/ui/tabs.tsx", + "sha256": "f4ebbc4ad957933954bcf9f4dbe1c4bd848bd855bf21bfe13efb12ed03d081b7", + "bytes": 2150 + }, + { + "path": "src/components/ui/textarea.tsx", + "sha256": "bb756ca145d4a339442d27d3a20a87c3aa15ecde88df771b0a7bda7c5066a7bc", + "bytes": 964 + }, + { + "path": "src/components/ui/toast.tsx", + "sha256": "6e3e7846717800100f1538d3c6a69b79b40c03e32e28120f44440ea3c05257ff", + "bytes": 4972 + }, + { + "path": "src/components/ui/toaster.tsx", + "sha256": "fc6d7beec69a040dbbd08e7087fa4ff277a6a08649f14f2d7e04219af081927a", + "bytes": 810 + }, + { + "path": "src/components/ui/toggle-group.tsx", + "sha256": "298e5296d6af1f334a9b9805bd8a94fd1514176170a662e1b427bef6bc6337a5", + "bytes": 1812 + }, + { + "path": "src/components/ui/toggle.tsx", + "sha256": "8a86336b5b7ad800cf5783da6ef8f1f11c8f0542994e32a1e421b5a5127f9887", + "bytes": 1546 + }, + { + "path": "src/components/ui/tooltip.tsx", + "sha256": "0d43ec6da30ba92a66c6b098a5123563ebd4153c8d53983f105109b8da7031c4", + "bytes": 1183 + }, + { + "path": "src/components/ui/use-toast.ts", + "sha256": "3fbe3b856affdf39f3eb2a82c898b96d44560d93d05674ab2fd6105825ca78d1", + "bytes": 85 + }, + { + "path": "src/globals.css", + "sha256": "a0d2c3eb9e84b77a566e89fe3912f87d8871894cba18860e4be006e779c609f1", + "bytes": 7429 + }, + { + "path": "src/hooks/use-mobile.tsx", + "sha256": "f664fb30e3e4fe093448ac8e9572a1d718a133c9ed1ab5287fa864f146465496", + "bytes": 606 + }, + { + "path": "src/hooks/use-toast.ts", + "sha256": "873fdbd9df8874b2cf49827d39d8a607da8813786e265ac489457a6e8a371c90", + "bytes": 4143 + }, + { + "path": "src/hooks/useOperatorSession.ts", + "sha256": "86a822990e2789a47fa5a85dc955188c0879d14013b8a68a381aaf1f813fdce8", + "bytes": 809 + }, + { + "path": "src/lib/accessibilityShell.test.ts", + "sha256": "0843ff7d713449df6d8febf7ab409ea1622355ff91324b6f447f183dfe43f423", + "bytes": 1486 + }, + { + "path": "src/lib/actionRegistryView.test.ts", + "sha256": "9e0dba631122db5548b888af10e2772043981c9e5b6157c1d5b73748aa33e806", + "bytes": 4449 + }, + { + "path": "src/lib/actionRegistryView.ts", + "sha256": "57c7b0bbf06c077b21c7f05d312c67fff9216560ab1abe437e3fb6be82e6a9c7", + "bytes": 5979 + }, + { + "path": "src/lib/actionResultStatus.test.ts", + "sha256": "deca4ea91565037b6d89d5fb72ade2bfbea9b4a0cf72e89cfc74738e1e9d5b8b", + "bytes": 2544 + }, + { + "path": "src/lib/actionResultStatus.ts", + "sha256": "55f228a94e3b1a7b70a5f113fad7d39b1f80887e9516f07c5cc0cdf2bc0bf425", + "bytes": 1877 + }, + { + "path": "src/lib/aiChatView.test.ts", + "sha256": "0e0cba13723528ffa1eadbd1e41fef4b22a6a74773e6ace8b59b5da87cf23d41", + "bytes": 508 + }, + { + "path": "src/lib/aiChatView.ts", + "sha256": "cbf54370d1dfd86433ced068d17023bb5ee50ecd60ff1f2820243c3efd68fce8", + "bytes": 465 + }, + { + "path": "src/lib/aiDashboardView.test.ts", + "sha256": "823d62436597cdebcbb3c085154c7ba42f2af5f66269124061d7cbb39d951461", + "bytes": 1433 + }, + { + "path": "src/lib/aiDashboardView.ts", + "sha256": "f83beb40fa8a87cd363b2565687ec4e9734a319666bc9894fdf60374860f7c92", + "bytes": 1459 + }, + { + "path": "src/lib/aiRecommendationView.test.ts", + "sha256": "a752618920b8fec3e1bd1de3fc7c48e5f8b1505a71de648d8063fde4be4629a5", + "bytes": 673 + }, + { + "path": "src/lib/aiRecommendationView.ts", + "sha256": "cd3befd3269c8eb75ae955a85116d771f02ac2717e22cfe5749802f2dd9986f3", + "bytes": 673 + }, + { + "path": "src/lib/aiWorkflowActions.test.ts", + "sha256": "f5bb5e15e9a0aa1b7b9d4beb974f345e8b374e12ae9d67b9897b27d83d876cc6", + "bytes": 1286 + }, + { + "path": "src/lib/aiWorkflowActions.ts", + "sha256": "e3d04caf9a67c974def16fde41700e1d05c7486d3ae58f968d8b68b4b2107b24", + "bytes": 1472 + }, + { + "path": "src/lib/appCatalogView.test.ts", + "sha256": "8f2c18c466bdd1c90b8c91cdf83b47c5b1ec850f8bfcc401e8e428b3441755f4", + "bytes": 3310 + }, + { + "path": "src/lib/appCatalogView.ts", + "sha256": "5a3a2916cb6e0d0ffdc18d8070138a8b9f5f992471e3976527bcbbde40e9c0be", + "bytes": 2247 + }, + { + "path": "src/lib/appDetailActions.test.ts", + "sha256": "dde84c458b6b693c0fd26af7c9472d6c6cab994839176a35a2e542ade7a71cb7", + "bytes": 931 + }, + { + "path": "src/lib/appDetailActions.ts", + "sha256": "244917e435dbca3dca26fefd827560133d855c9574d94fe3e932f0a73b7dd16c", + "bytes": 299 + }, + { + "path": "src/lib/appFocusView.test.ts", + "sha256": "659c389af58a99af1bc82a3009db6414646df5ef1196f31e7ca15bb5c3b0fb33", + "bytes": 2034 + }, + { + "path": "src/lib/appFocusView.ts", + "sha256": "187d31e6049e6ffdd1c9ca96adfc3e78b326387cd0fa0fd1c131a3b57e06f376", + "bytes": 3198 + }, + { + "path": "src/lib/appReviewClosure.test.ts", + "sha256": "a73a9139ac2a868c7fcd1e97c85cb39859008ca0f578aafd4c4b4483503fc8fc", + "bytes": 2578 + }, + { + "path": "src/lib/appReviewClosure.ts", + "sha256": "aeb5650988f41a60676b2273824751e6814ce5a51b186776158f0bd849b4db58", + "bytes": 4465 + }, + { + "path": "src/lib/approvalFollowupView.test.ts", + "sha256": "99e74a407f3f6101ee0fdc10fb763170e4b26aa66911fcd4bfcf42ede8b711cf", + "bytes": 4377 + }, + { + "path": "src/lib/approvalFollowupView.ts", + "sha256": "d99fa72e29de9aa31442e95020c38f05d8dbeb9b15ef4b10bb142bd20bbb8e0d", + "bytes": 5410 + }, + { + "path": "src/lib/auditEventDetailView.test.ts", + "sha256": "04da4bffd752ef36c64ae56537dc1a41ab26ff409c02ac8cc16099e8c13bb9a3", + "bytes": 3008 + }, + { + "path": "src/lib/auditEventDetailView.ts", + "sha256": "f08c184d055f9bcd4013e51a8d20e4809bb9fcb8f5fe4773b6cf2e3d4619c0ca", + "bytes": 1973 + }, + { + "path": "src/lib/auditLogView.test.ts", + "sha256": "8adfccf33810b4f6d631623884a3e448f838d7c9d70fc3b511464a6ee8d337ec", + "bytes": 3126 + }, + { + "path": "src/lib/auditLogView.ts", + "sha256": "5fcdb632f9bf3979ac650dd9a19a2806c31d6f0ebed9a6d08c173dd38c33a223", + "bytes": 2735 + }, + { + "path": "src/lib/backendDateTime.test.ts", + "sha256": "c469e5e1de42f1965f56b4ffcc577d65060b1c1b29c77d9b9570d1d5dbdcaa3b", + "bytes": 539 + }, + { + "path": "src/lib/backendDateTime.ts", + "sha256": "ebb03a0250608cd73dbd39da7b0d6861423c1f09adca0b5b02d73c98422e5c89", + "bytes": 697 + }, + { + "path": "src/lib/clipboard.ts", + "sha256": "0ae5f82ae2fca36d7cae4c9fd44a2c8db414a72113aa97b7d640f8471449e739", + "bytes": 645 + }, + { + "path": "src/lib/commandActionFollowup.test.ts", + "sha256": "fccff3676fa8204701e29b200c9352e9cda123cdca69066cd3535f8a388c8714", + "bytes": 1901 + }, + { + "path": "src/lib/commandActionFollowup.ts", + "sha256": "17034d36bb0a2bb0745a819adba87fd28b4b52c2a5ffa6313b2b3139c3f2af00", + "bytes": 2365 + }, + { + "path": "src/lib/commandActionView.test.ts", + "sha256": "29bae57957eac3c3585027e6fe9b94288c233eaf1f8b05d1f0025e6435979f8e", + "bytes": 1661 + }, + { + "path": "src/lib/commandActionView.ts", + "sha256": "bc7c558562f87393d1caec9906aafac4147f97c85ec481fd356774b5f03f35c1", + "bytes": 1731 + }, + { + "path": "src/lib/commandCenterQuery.test.ts", + "sha256": "4c9ec154d287844d287cd808e163f66e7b11cad9138dcc9e1921a6dd7aee421d", + "bytes": 722 + }, + { + "path": "src/lib/commandCenterQuery.ts", + "sha256": "713947f4cf4a2336e403fd58e8a6e74ab36e6805946f947be09f8aa4c308a0c5", + "bytes": 130 + }, + { + "path": "src/lib/commandPaletteNavigation.test.ts", + "sha256": "84e06a3403152a176d530ff07d4f98b76e71bf9dbf18183823725457009edad1", + "bytes": 1297 + }, + { + "path": "src/lib/commandPaletteNavigation.ts", + "sha256": "25834670a58cce9b52103ff218d824586af68c65db566cf765d6244dd6a17ca3", + "bytes": 510 + }, + { + "path": "src/lib/commandPaletteShortcut.test.ts", + "sha256": "a6c277e5f00ef416d3fa77eea43d604f7ab9641dff5af8052688328e01b9c75b", + "bytes": 740 + }, + { + "path": "src/lib/commandPaletteShortcut.ts", + "sha256": "6248ecac5a44105d49bc44fd8b82b57e1ccaf104c0f923d11c8028ac884cf51d", + "bytes": 428 + }, + { + "path": "src/lib/commandPreflight.test.ts", + "sha256": "c26d52fa312223db4008ebef6684a8b01a96fc78f83b075e3409a28ef70c25f1", + "bytes": 780 + }, + { + "path": "src/lib/commandPreflight.ts", + "sha256": "8e43e796862070d454b4163d9a71c64620f71c1e81eb49f5cee0a4cb173cb1b7", + "bytes": 343 + }, + { + "path": "src/lib/connectorActionFollowup.test.ts", + "sha256": "ecf1a0ee0a2a18728492add50f43755d3745ba1173297826622453c17a1a2a2c", + "bytes": 4581 + }, + { + "path": "src/lib/connectorActionFollowup.ts", + "sha256": "dc23ba2db8a551ccf5b6310f4d96746ffc9502aaeca0046801fa37c98334934c", + "bytes": 3466 + }, + { + "path": "src/lib/connectorConfigLink.test.ts", + "sha256": "8db76f54df0bb742c220b8c99a36965fe216bd018fa84e2bbb0d84bf04a186b2", + "bytes": 581 + }, + { + "path": "src/lib/connectorConfigLink.ts", + "sha256": "b165f628661f258daa81d91279b056f8b74b56b451b8c2aba3f1c67f6650fc43", + "bytes": 153 + }, + { + "path": "src/lib/connectorDrillView.test.ts", + "sha256": "2eb3528f4860254574179c5e0885684312c785284d2cc13d3f4b5109b78b4810", + "bytes": 3095 + }, + { + "path": "src/lib/connectorDrillView.ts", + "sha256": "853a24c3f208c8b49c7f5f50fe72e82781722ae42a18e9e33b82d987087f64e2", + "bytes": 3693 + }, + { + "path": "src/lib/connectorMutationView.test.ts", + "sha256": "e7b5e1ccd2dda5b76394a6cd3d765e5a2d3fae0d6bb57e4901ec5d2d25363068", + "bytes": 1703 + }, + { + "path": "src/lib/connectorMutationView.ts", + "sha256": "7c63c79a82a1ce44b06b85758df1f1161792071df07e9e3280008dc4f7ee0c00", + "bytes": 1454 + }, + { + "path": "src/lib/connectorPanelStatus.test.ts", + "sha256": "3d8b59b88edffcc78b813e62010bc0403ebc63404f1802d9367e4150ef798e65", + "bytes": 846 + }, + { + "path": "src/lib/connectorPanelStatus.ts", + "sha256": "88e477d31d421cf7a7424b60da81800fddd721f0ff7843431bebea109cd3dc4e", + "bytes": 169 + }, + { + "path": "src/lib/containerFocusView.test.ts", + "sha256": "12ab2c4471b22dc42e4af53bac661ebbc83f8c97329e5c150621b2206ebf3486", + "bytes": 2147 + }, + { + "path": "src/lib/containerFocusView.ts", + "sha256": "f49e92b781e4f839b02d426be92257f9f8804f218deceaf96b8808ad6e9739ab", + "bytes": 4039 + }, + { + "path": "src/lib/containerInventoryMerge.test.ts", + "sha256": "2c4dde193e1dcd5b8e3a22a937ac5e925b6cfee1fc32e05e3dd13ef56821e4f7", + "bytes": 3514 + }, + { + "path": "src/lib/containerInventoryMerge.ts", + "sha256": "6dc23961e422e7b129b756802c265ebbf2c0d4114d7bf95d25dc50c89eaefef4", + "bytes": 2684 + }, + { + "path": "src/lib/containerLogExport.test.ts", + "sha256": "be226ad260558924bb6c4dc17b7fae49875be659e88c4b4b51423fd3bfea8ab6", + "bytes": 1008 + }, + { + "path": "src/lib/containerLogExport.ts", + "sha256": "8ef35303748c2a63cb28659a257c411c46702937111074f6fefa0cf9c1cde8e0", + "bytes": 875 + }, + { + "path": "src/lib/exposureWarningTriage.test.ts", + "sha256": "b5b545ce2082c53f969e64c1b3cbb37021781bbc9d7096b464dc6f3af5c4298c", + "bytes": 1538 + }, + { + "path": "src/lib/exposureWarningTriage.ts", + "sha256": "ebdaed9eee77801d35b6c202d5fee5faec18b2fc48d55173971a57e0a27a47eb", + "bytes": 4258 + }, + { + "path": "src/lib/homeAssistantNotificationState.test.ts", + "sha256": "6e2115b235fc13c20d53fc45417063caddcfccf41b01c61697ca921c85da5b73", + "bytes": 4674 + }, + { + "path": "src/lib/homeAssistantNotificationState.ts", + "sha256": "46f7dd0029f807e7b5fe4e59f5b36ed34d7652db70680fe379e664f9941f073f", + "bytes": 6012 + }, + { + "path": "src/lib/ignoredLoadError.test.ts", + "sha256": "e07c7873274ac7dadc2288152d099e1f0e16aa092032d96df952bf2374244796", + "bytes": 705 + }, + { + "path": "src/lib/ignoredLoadError.ts", + "sha256": "4bf5d2f71ef73d20f4ab5ac091f220685dd197431a0226dbeb67e62b2998bd43", + "bytes": 171 + }, + { + "path": "src/lib/incidentView.test.ts", + "sha256": "3166ba431cf4c49d4abf0c0f2f41cb5b86bd09d92b3a5089ec68efdc5304b40a", + "bytes": 2931 + }, + { + "path": "src/lib/incidentView.ts", + "sha256": "3392be4a0cfbc4626aafc9031fda6c552ea9eea1998a4d18c350c729aaf3df75", + "bytes": 3011 + }, + { + "path": "src/lib/integrationFlow.test.ts", + "sha256": "4978fea0f3b0d64fd1954112a663944041a1e2c965759b7a315264e6ec73ab3d", + "bytes": 2613 + }, + { + "path": "src/lib/integrationFlow.ts", + "sha256": "ce62f8fed26d67c7052cbef5e94b530b001ce99d3a140d457ad392deb8c704ea", + "bytes": 1882 + }, + { + "path": "src/lib/inventoryActionFollowup.test.ts", + "sha256": "8e9fc4b1925413955b8eea75b080d9f6796a4af842b43c4373318ee1dfbac0fe", + "bytes": 2095 + }, + { + "path": "src/lib/inventoryActionFollowup.ts", + "sha256": "5250ab6f4624ab0bcd2616f1d9a8cd33f7a7a03eeb85b18bf28d90006232d20b", + "bytes": 8352 + }, + { + "path": "src/lib/inventoryLedgerView.test.ts", + "sha256": "d2a060955b3c0801a422d0d0548507bfd5757a42f3cc4e842e01fadbfa5b6ff2", + "bytes": 1831 + }, + { + "path": "src/lib/inventoryLedgerView.ts", + "sha256": "dea0a312629442fd5b8d8e796f949e985cea808e9f41e42f352b93cfc5b28c4e", + "bytes": 2636 + }, + { + "path": "src/lib/inventoryReviewQueue.test.ts", + "sha256": "c6a1b626ede2ff5e8aa46facccd0f63785c9603dadf383bc2fc2f360f01054b2", + "bytes": 6766 + }, + { + "path": "src/lib/inventoryReviewQueue.ts", + "sha256": "52fff24248475fd6b63aa67467b2fc38531d29b9d08e955cbb372643562810d4", + "bytes": 8686 + }, + { + "path": "src/lib/jobCancelFollowup.test.ts", + "sha256": "b656c1a71b3e32e31c9cd7fbebfba6849c5da142e64daa9d6b502c099e280d56", + "bytes": 1765 + }, + { + "path": "src/lib/jobCancelFollowup.ts", + "sha256": "bfd0bd2414456332435381a19e110a7e2cc77e1a1785f770f81137576e39d792", + "bytes": 1516 + }, + { + "path": "src/lib/jobRunView.test.ts", + "sha256": "b661916bf58d083eb306bfd5b5eae5159d5e1e77c31de592cfe2f99656153935", + "bytes": 2548 + }, + { + "path": "src/lib/jobRunView.ts", + "sha256": "0044b69dbb06106d62992ec3796090cb53449742c829453cfcf3540090ab4605", + "bytes": 2431 + }, + { + "path": "src/lib/liveActivityFeedView.test.ts", + "sha256": "e37d1f504adc8099d685f216d552512210cf0dc8510e29488c8f310c00a7cce3", + "bytes": 2709 + }, + { + "path": "src/lib/liveActivityFeedView.ts", + "sha256": "848802ac8113b07f767b83ec4ae073942f86d04b25bf653dbca0953fd494d665", + "bytes": 3477 + }, + { + "path": "src/lib/mediaDownloadTriage.test.ts", + "sha256": "faf79fa60bd5f4f833c8f93b47d81c872d1552193a2e7119446466ee3bf44d01", + "bytes": 1292 + }, + { + "path": "src/lib/mediaDownloadTriage.ts", + "sha256": "b28028d6d83851566d2042d4f09ba915d3698e6f677c2328d22031ab16e74c4a", + "bytes": 2241 + }, + { + "path": "src/lib/mediaPipelinePageLoad.test.ts", + "sha256": "c7ada2379437e37c2b337fa0357a74ce369397f9da3513f50eb5acb7cf1f6961", + "bytes": 3732 + }, + { + "path": "src/lib/mediaPipelinePageLoad.ts", + "sha256": "f74493963242cdf112c422443eb6973dfc27ccd85786d1bfcdbb1ff64ca55c22", + "bytes": 4467 + }, + { + "path": "src/lib/navigationGroups.test.ts", + "sha256": "9bd5774181ee5627e9e027eef92eaa8a9ad900c08d17128b6abc313cce8d1cb5", + "bytes": 2533 + }, + { + "path": "src/lib/navigationGroups.ts", + "sha256": "1d65cd0d71b1982f98fd588f3338643f663fd4c556e9c7860c89128eed73dc8a", + "bytes": 4158 + }, + { + "path": "src/lib/networkSnapshot.test.ts", + "sha256": "5d804cd73197f129946300191e97d85d62e3b3f0f4f05023bf00174f30bf5b2f", + "bytes": 2713 + }, + { + "path": "src/lib/networkSnapshot.ts", + "sha256": "aaca8cab3a5effedc6d61b927448d71f143886cf9e40451ec5d36f489edd79b7", + "bytes": 5551 + }, + { + "path": "src/lib/npmAppDetailLoadState.test.ts", + "sha256": "0fd5758f873abe30c253838f46e0855edde822552bc7ccdc949ffc1aa2079912", + "bytes": 1984 + }, + { + "path": "src/lib/npmAppDetailLoadState.ts", + "sha256": "db5a7f29eac32ee11e7daeff309750795a7529d266b1681a7bd336403b77bd5a", + "bytes": 3734 + }, + { + "path": "src/lib/operationMapView.test.ts", + "sha256": "d581db7f4ff9b6f3e1ec446b61fccec52f994b241d201cfb69589b61c4a3c839", + "bytes": 4499 + }, + { + "path": "src/lib/operationMapView.ts", + "sha256": "b1e65c2c96e648f7a6533d6f891d0492ebf73995d2fc3e268bbf1b13f0341c91", + "bytes": 5945 + }, + { + "path": "src/lib/operatorActionQueue.test.ts", + "sha256": "0cefd25a008d308624eaf1e4c1f561703d46878e3fa37e2ba77da7ed66ac6cef", + "bytes": 4337 + }, + { + "path": "src/lib/operatorActionQueue.ts", + "sha256": "b4dcc30a99ac4f5424271537d8a085fdf2eda2dd5bdf140da73b360f75f8cd26", + "bytes": 6346 + }, + { + "path": "src/lib/operatorError.test.ts", + "sha256": "a351d32e9bd1d971375b6c3595b917510634091395001c4f938f3986c77d92ca", + "bytes": 808 + }, + { + "path": "src/lib/operatorError.ts", + "sha256": "7e4671c53e9890b5800757135743536526a8177b706f9f56cac8cf01b25483a0", + "bytes": 646 + }, + { + "path": "src/lib/overviewTopIssue.test.ts", + "sha256": "7f9a7ad8959e3f75bfaa3860b74fb7b475f712cc8567c7dacd9f4ca3512be6d6", + "bytes": 1652 + }, + { + "path": "src/lib/overviewTopIssue.ts", + "sha256": "8837e98cdba46989a3095073568c51d1f5f55b5911e8876df9e0e57dc1eb5b5d", + "bytes": 534 + }, + { + "path": "src/lib/playbookView.test.ts", + "sha256": "14577de443ca62cc94f7546a79457ce44f803008f4ad443b4326f62626b4bf4d", + "bytes": 2632 + }, + { + "path": "src/lib/playbookView.ts", + "sha256": "263ab994d5674fe62c944601a51d94989acaa81ca30d1c6e14e5b9ef3eae2912", + "bytes": 2853 + }, + { + "path": "src/lib/policyActionState.test.ts", + "sha256": "e6d53b4d104c971380781b545a5203d4efcf2d9867b109b9a3e94676de90ed1e", + "bytes": 1970 + }, + { + "path": "src/lib/policyActionState.ts", + "sha256": "267a29b79429fda951780bbfec21f36a2a1325ae65d4d820bb4743225f6c84ea", + "bytes": 1659 + }, + { + "path": "src/lib/policyView.test.ts", + "sha256": "496c944c93000c9a6011a86b7cd3e08ae9457a9dd4768d45a13dbe848b1a5e19", + "bytes": 2750 + }, + { + "path": "src/lib/policyView.ts", + "sha256": "ce6f793426ee1e03246da7e64ddda016696713482fa44052ccbdc8d5e8e0e2f8", + "bytes": 3222 + }, + { + "path": "src/lib/remediationFollowupView.test.ts", + "sha256": "d22632069d9e7e3c44e66a1ae0c4118edac93f0e5eef9d6e809937a909f4ed54", + "bytes": 3050 + }, + { + "path": "src/lib/remediationFollowupView.ts", + "sha256": "0c742a971a580f9a0c6df540c7235d3903185bcc8bc0e0a242f573227eaeaafe", + "bytes": 3566 + }, + { + "path": "src/lib/remediationPlannerLoadState.test.ts", + "sha256": "226e1f77ad93daf88347f240b781314685921fcc5362b8f39b47778cb3ebb865", + "bytes": 2392 + }, + { + "path": "src/lib/remediationPlannerLoadState.ts", + "sha256": "f2a04fb438e7d74f4eff80933fa03012c2904b756504c50b6e445885a00d64b0", + "bytes": 1871 + }, + { + "path": "src/lib/responsiveLayout.test.ts", + "sha256": "5fde962ff1b6acfb6faee8c7f73a42847476f9a74f8478d5c5b0d3a2bcdbabf5", + "bytes": 9103 + }, + { + "path": "src/lib/reviewWorkload.test.ts", + "sha256": "40a3bb921ccb34ae2b815569998cf87c9f952392a89e4adbcee0e531dc6574a7", + "bytes": 4632 + }, + { + "path": "src/lib/reviewWorkload.ts", + "sha256": "e1e48763a8fe2a3dd794654bcfc21e22959e4cff4e2e0bbaa909c38949e87aab", + "bytes": 4492 + }, + { + "path": "src/lib/routeContentWidth.ts", + "sha256": "bce36988fcd52185810687ce6ba183745ee28a8278673ce80737fc0c139a559f", + "bytes": 1005 + }, + { + "path": "src/lib/routeLoadingView.test.ts", + "sha256": "864db0a208541a6fc3d58cb6364c37c63774ef6de3b4744b954f1b2f8608bc75", + "bytes": 3012 + }, + { + "path": "src/lib/routeLoadingView.ts", + "sha256": "f121688ca32031994ebddf4dd5ecd2188c8dc404db2b263d1e1e76a3faba926a", + "bytes": 7442 + }, + { + "path": "src/lib/routeSplitting.test.ts", + "sha256": "6bd5ed0137fa8b13e34f54f9752e5caf8ffa296e25db90bba7311650c1722cec", + "bytes": 1626 + }, + { + "path": "src/lib/routeTitle.test.ts", + "sha256": "88ead3127838539bd32231601e9dbcdfa185ab754c561ae97fbcc9a045d4a07c", + "bytes": 1684 + }, + { + "path": "src/lib/routeTitle.ts", + "sha256": "e0ebb1b6b54f8700ecb4a4447b8449b2514722d4796b60b6f80a9fb48a8f2f82", + "bytes": 1137 + }, + { + "path": "src/lib/safetyActionFeedback.test.ts", + "sha256": "e32448d762636e3f855d3be21d83bb456978433989c73aeecf1dd59e54cd92cd", + "bytes": 1623 + }, + { + "path": "src/lib/safetyActionFeedback.ts", + "sha256": "17c43cd7bea1532613e4540cb3fd13eb84c38217db9768010e941fef5ae8c6bf", + "bytes": 2354 + }, + { + "path": "src/lib/secretOwnerLink.test.ts", + "sha256": "df8888f49421e97ad7ae1b22211918c8e6083f1cb0b115bb890e9de9be8a6db0", + "bytes": 2970 + }, + { + "path": "src/lib/secretOwnerLink.ts", + "sha256": "69bb73076c6a4423bb5c49a75a8087e470b1899d3f6f91caa01a54b32c7de2ae", + "bytes": 2881 + }, + { + "path": "src/lib/settingsRuntimeView.test.ts", + "sha256": "434b64f818d9849bbfe38433bc0fd6a433fc5f6dedbec8d0cc7de0f9178355c3", + "bytes": 1840 + }, + { + "path": "src/lib/settingsRuntimeView.ts", + "sha256": "0e14e314a6f44022f42561c744b3fd4181792c351c1c0f12365de88124d3ea1b", + "bytes": 1738 + }, + { + "path": "src/lib/setupBannerView.test.ts", + "sha256": "9036b33571862e4557cedb8771c995e91faefb7fdbeccad873cfcaaa150236aa", + "bytes": 1740 + }, + { + "path": "src/lib/setupBannerView.ts", + "sha256": "c973c2f3f5671a71bb7e08b5bfa2c1c2e52a0aa0cc27ea9dd90978d99f9054b3", + "bytes": 586 + }, + { + "path": "src/lib/setupCompletionView.test.ts", + "sha256": "2e2ea7df54a08215719c109cad28fe5fbd82cca6f174f6a94018fb879d7630ad", + "bytes": 1654 + }, + { + "path": "src/lib/setupCompletionView.ts", + "sha256": "e1dec4f5e41e68d80d1579924a031c3ad2d3e51c89d0fd2af257673eb001cb73", + "bytes": 357 + }, + { + "path": "src/lib/setupConnectorContext.test.ts", + "sha256": "33c4ee8d11601cbcdc15100f6b926c94cef3b0f0b5fd6e79f4c590e7896c6183", + "bytes": 2985 + }, + { + "path": "src/lib/setupConnectorContext.ts", + "sha256": "6ab3f5d8dace518f5a49a3969df4438f13e84d6988a6c9e9f878e8855cf5e48a", + "bytes": 2037 + }, + { + "path": "src/lib/setupConnectorFeedback.test.ts", + "sha256": "8e5648374ac85efd44e16cedbab932ec3ba028c0719f91c43107d5531be5f096", + "bytes": 1990 + }, + { + "path": "src/lib/setupConnectorFeedback.ts", + "sha256": "47c32af9154bdbfcf157af16f070cae4046e01926183969b26e4a2360bf4dd96", + "bytes": 4534 + }, + { + "path": "src/lib/setupConnectorForms.test.ts", + "sha256": "79e414bcdfbe254e5269ea5839df47732df7be7e325c9d6c9373d0fcbb1a3589", + "bytes": 1727 + }, + { + "path": "src/lib/setupConnectorForms.ts", + "sha256": "1cdcb3d3a77717171b9a46d8813cb4decf4b0fa5ed732f8626efe4d0b0954372", + "bytes": 1366 + }, + { + "path": "src/lib/setupContextView.test.ts", + "sha256": "aa26bf6fbf198e154df714ba27138b48674c121b988240a4b32f4996ea982a2c", + "bytes": 1128 + }, + { + "path": "src/lib/setupContextView.ts", + "sha256": "7e9fb2c981d28aec612e48e1a967549847c0260cac8a75d9dc5845261a0a61d0", + "bytes": 867 + }, + { + "path": "src/lib/setupDeepLink.test.ts", + "sha256": "9d33a929a657dded0f9fe9916c36986da23811c2b3939674057c86b7dabacbeb", + "bytes": 792 + }, + { + "path": "src/lib/setupDeepLink.ts", + "sha256": "7074671f591c17e25d6c7c946b884f713391a13f498e0b31a067f190333da468", + "bytes": 664 + }, + { + "path": "src/lib/setupNextActions.test.ts", + "sha256": "a0f88f11bddbc34377488402c60dc3d2b43c5fbffc3ea780638b142f13ecd49b", + "bytes": 3001 + }, + { + "path": "src/lib/setupNextActions.ts", + "sha256": "6740191931b55f854bb475887a95dcf1eef868415251f70594ccbf436d973b4c", + "bytes": 2597 + }, + { + "path": "src/lib/systemLoadChartView.test.ts", + "sha256": "43d0a9255c67b7f69589039209e450c67375f25c019a059a2cdb3e81b5016554", + "bytes": 2615 + }, + { + "path": "src/lib/systemLoadChartView.ts", + "sha256": "58bcd8ff936ffc766fb0dbdebc812da4549176c679f7785bb82f350836e14005", + "bytes": 2745 + }, + { + "path": "src/lib/systemSummaryView.test.ts", + "sha256": "732193633172716bafc7f7a402a737aafdccb1f3f8ef99f0a7107915bb380937", + "bytes": 770 + }, + { + "path": "src/lib/systemSummaryView.ts", + "sha256": "d217d53f6377a2eae4420d6343f7fabc5baa1b5036c6e607c1f24ebe93c90045", + "bytes": 964 + }, + { + "path": "src/lib/utils.ts", + "sha256": "c0adf27efbfa148d5abcf99e860f8e3e6c8fc77a1f99e023c08ef4f12e6ece29", + "bytes": 175 + }, + { + "path": "src/main.tsx", + "sha256": "8127fbff2a3f8492c50df072a0aec60550523011569666e58db97e7b2376ac6c", + "bytes": 656 + }, + { + "path": "src/pages/AiRemediationPlanner.tsx", + "sha256": "2f1709a2f13894d56798da7c5da32af2a238a8cf461cc65ba578b00e710158c8", + "bytes": 27923 + }, + { + "path": "src/pages/AiSecurityReviewer.tsx", + "sha256": "0bfdbc7864289687efcac5a518a6a8f07131123eeb9de35a6c9befec65141314", + "bytes": 36293 + }, + { + "path": "src/pages/AppDetail.tsx", + "sha256": "4ed3d6186de9752b66ace4929a155f815174554ee1fa2c27e9702e8932460493", + "bytes": 175871 + }, + { + "path": "src/pages/Approvals.tsx", + "sha256": "f6030b89cc984e9c14e0230a53dd6ea20fa91a6e095b2c160951f855f2d07a70", + "bytes": 17836 + }, + { + "path": "src/pages/Apps.tsx", + "sha256": "e70f0a6468bce5eeb2f55ca608eacdf7c1683388666a3cf8c89d5dc434e23687", + "bytes": 28092 + }, + { + "path": "src/pages/Apps.ux.test.ts", + "sha256": "3f1f7340089d38aec21b1334c19209d5baa22deac8b2e1106de125f9f6bf4124", + "bytes": 1392 + }, + { + "path": "src/pages/Audit.tsx", + "sha256": "b6689fb4977346f8ce297537e341a0d3ecfd69052147d5ff3014d5e266a31089", + "bytes": 19055 + }, + { + "path": "src/pages/CommandCenter.tsx", + "sha256": "439611dfde2b32f562b92956efb6827731f09d3a135d3a792a3bd7f1c66e6772", + "bytes": 47531 + }, + { + "path": "src/pages/Connectors.tsx", + "sha256": "8efca981b445f395faae41495351a82f660bce7b4ada73237e758b04a2f71d87", + "bytes": 22478 + }, + { + "path": "src/pages/Containers.tsx", + "sha256": "4d92c038f5a4f514e1909f87cf7ef11a97a4574ecfb4ca61b3291f53e0f19ed8", + "bytes": 28963 + }, + { + "path": "src/pages/Deployments.tsx", + "sha256": "28feee08485feb772ef5aa5e2fa256046b561db26eeaf95fea69696972c90d88", + "bytes": 22470 + }, + { + "path": "src/pages/Incidents.tsx", + "sha256": "301b712e23b04372dce04a45c1b9873477ce474a92c630fd7d10e01da76c61df", + "bytes": 13772 + }, + { + "path": "src/pages/Index.tsx", + "sha256": "1472eef5e47ea5a49496a0051b98473aab69864a2a176eca6e0d23fc6d06b5f4", + "bytes": 137 + }, + { + "path": "src/pages/Integrations.tsx", + "sha256": "ba2ae4393300e204576044076ed1b7ceb4783c6d534140dd6c6b1e990e182d46", + "bytes": 14461 + }, + { + "path": "src/pages/Integrations.ux.test.ts", + "sha256": "c4534ca8fe188688adcac5fd4fd5e16fe60738ec1af2733d6a96af601b7f76c4", + "bytes": 3024 + }, + { + "path": "src/pages/Inventory.loadState.test.ts", + "sha256": "400bf7c5a945f94f6f1e2dc3f8f0b6b8ce2986f9591442424e4c35dec0e2a783", + "bytes": 2032 + }, + { + "path": "src/pages/Inventory.tsx", + "sha256": "fa1d460004db7a3822539e5163e1397455bbff737045a19b4e229856ce922151", + "bytes": 50289 + }, + { + "path": "src/pages/Jobs.tsx", + "sha256": "0b9fe93e6c589725b2ee9961de20130aab5c30bd14dabb9400f7a0f082494f52", + "bytes": 13645 + }, + { + "path": "src/pages/MediaPipeline.tsx", + "sha256": "5dcaf25ee05e9112e6e0e55938828e65ebdedb71eded9e39c997ab589ae0595c", + "bytes": 29073 + }, + { + "path": "src/pages/Network.tsx", + "sha256": "f493c20b338728d2493c9e5e2baa1911114254488c1c5c24da48694a1516be2d", + "bytes": 28306 + }, + { + "path": "src/pages/NotFound.tsx", + "sha256": "b3e61b6e2aa5c0f8b7c1dcab968c5327014139aa5ba7f8ce617445078b6be8ec", + "bytes": 767 + }, + { + "path": "src/pages/Notifications.tsx", + "sha256": "8f0be09f92b3dcc8bf3d76b98a34f9039fc18ea11a67fcbafefd3dc3944d7591", + "bytes": 34487 + }, + { + "path": "src/pages/Notifications.ux.test.ts", + "sha256": "46b0544649bbe2b5fecab17f0af3850a20dcc8c3c5e783b62aa04e95c012b42e", + "bytes": 1000 + }, + { + "path": "src/pages/OperationsMap.tsx", + "sha256": "fc79daacb74d3fd4393e8b1a42ecc757b926b56d57bac62af9409ed39c88811d", + "bytes": 17270 + }, + { + "path": "src/pages/Overview.liveData.test.ts", + "sha256": "b37909a0a7eaaa95b5d23bea6443010cb13edb0e3882131f54f6e90c160852ef", + "bytes": 2642 + }, + { + "path": "src/pages/Overview.tsx", + "sha256": "72cb27313eb5fb8dede582244344d56391f036ebbd6a86cf06c2316b9e6d6496", + "bytes": 28274 + }, + { + "path": "src/pages/Playbooks.tsx", + "sha256": "837dbcf42ce81b9445cf97787b30055dd05e40a44cad46ba51c26703d54298aa", + "bytes": 12898 + }, + { + "path": "src/pages/Policies.tsx", + "sha256": "91f22d585e3393f0d55e787d558ebb993b3e7b938848b78903447e68d57e10d8", + "bytes": 24072 + }, + { + "path": "src/pages/RunnerFleet.tsx", + "sha256": "e23f7fa912e0afba012ba406904e68b6511a6be70bcbf2be7ec32652b6aa8746", + "bytes": 6737 + }, + { + "path": "src/pages/Safety.tsx", + "sha256": "dec8c57399a8c62e46f0395f753b587826e45d86744e82b168102fd7e6481c78", + "bytes": 24620 + }, + { + "path": "src/pages/Secrets.tsx", + "sha256": "bcf2cf758ef1b46c91a5f2af708b8b1aa97f681d787327c895c0cdd76b217800", + "bytes": 12877 + }, + { + "path": "src/pages/Settings.tsx", + "sha256": "658b5357896810316fbd40bc673b0b0b311df81e4c9e249323718beae9ea4982", + "bytes": 33258 + }, + { + "path": "src/pages/Setup.tsx", + "sha256": "f50b3b52e0b6602035568dfb47a9e3d618d7a83393700a34546e06c66091ce87", + "bytes": 68534 + }, + { + "path": "src/pages/Stacks.tsx", + "sha256": "db78808ce06e4e40f4a8cffb397db6f6ad26778da3f52d22014ca2960941dcae", + "bytes": 8689 + }, + { + "path": "src/pages/Storage.liveData.test.ts", + "sha256": "0f716a0b2cb9cfe5ea9b0177cc54825d2958e3f82cce940c1bea73f8bdb5702d", + "bytes": 571 + }, + { + "path": "src/pages/Storage.tsx", + "sha256": "1dca9a4468b5900b2826ddc6a8a403f05a08d0047c0a0a8196a2cd8abc95f49b", + "bytes": 19864 + }, + { + "path": "src/pages/Unraid.tsx", + "sha256": "ee772dda5f42d2279c95c60343e67493f8e8e788a184e7f392395fe4d397bc7a", + "bytes": 19007 + }, + { + "path": "src/pages/VirtualMachines.tsx", + "sha256": "439b1c79e7cb6e6e429afb4ca10d6c95aef77afe1df5c12a60c87b2d455a49e1", + "bytes": 12434 + }, + { + "path": "src/pages/Voice.tsx", + "sha256": "68f1515a18932317e0fb7840b24cf430d6be2cb945586b26c5d6212bed3affa8", + "bytes": 28574 + }, + { + "path": "src/pages/ai/AIChat.tsx", + "sha256": "ba5b1f95efe1b9f907c9c1b4ae1a2969ad021c10cbf8236f1b7d5fa7c9f07be0", + "bytes": 7743 + }, + { + "path": "src/pages/ai/AIDashboard.loadState.test.ts", + "sha256": "e08f2489df97326932b164ec4ee60fda7bb39629acc7f0a2a4408e174d743a7e", + "bytes": 449 + }, + { + "path": "src/pages/ai/AIDashboard.tsx", + "sha256": "86d402798cc1cd5f1dbe4832b5cb3f50b29cd3699fe3b78286dbca69f7d4d1b3", + "bytes": 10913 + }, + { + "path": "src/pages/ai/AIRecommendations.tsx", + "sha256": "45680e3abce7f92fc6908a94ab26f929abb1779612bd88d51947cc433874f30c", + "bytes": 6440 + }, + { + "path": "src/pages/ai/AITools.tsx", + "sha256": "263e42ee9888723562392f6ffd602aaee3b9c7b0903d512f1e09ab4c038bb4d6", + "bytes": 7585 + }, + { + "path": "src/routes/appRoutes.tsx", + "sha256": "0029e9e5e2a4cd25405a6bf514aa9db3622aba76a583ca3e0a8eff9c4a92af05", + "bytes": 4255 + }, + { + "path": "src/types/ai.ts", + "sha256": "8b34557943e0ee884230f56625849fa2d211d706a0063b80c7d819e685852f66", + "bytes": 1667 + }, + { + "path": "src/types/appops.ts", + "sha256": "f2f038f2406b90fc2ccb0f4c4b46b2b7bb745dc3d37ef7549058f9581db00fc2", + "bytes": 73168 + }, + { + "path": "src/utils/toast.ts", + "sha256": "2eee6c32614a2a09dd05db833d1633e03241225e2e6600faaed3bfb18d340ae2", + "bytes": 373 + }, + { + "path": "src/vite-env.d.ts", + "sha256": "424faf9241dd699dda995b367ed36665732da1e6ec1f33b2fd40394488ecac92", + "bytes": 39 + }, + { + "path": "tailwind.config.ts", + "sha256": "079adb488b5f6507b1659e3ebed9e3847f8ac5a72535d5bf3431c71599f5dc4f", + "bytes": 6254 + }, + { + "path": "tsconfig.app.json", + "sha256": "2e93d1b5d766b335241703d6402572e103eefd548ab94e269155d6c003d733b5", + "bytes": 682 + }, + { + "path": "tsconfig.json", + "sha256": "b0e4ec3b15062a81cea2369fba9a08ecf8f61478b489b3eefbd6a59020df4ab8", + "bytes": 400 + }, + { + "path": "tsconfig.node.json", + "sha256": "8dc2fd2a2ff7497afa9e29037a36bcddbfa7c89ea8ff3b7c62b2814b55995449", + "bytes": 503 + }, + { + "path": "vite.config.ts", + "sha256": "39d7a1e257b47d71124b3ead4caaac6a7460cd94fa90a527da9ebb9e80e7b8ec", + "bytes": 645 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f315c3 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Unraid AppOps Gateway + +A self-hosted operations dashboard that brings application discovery, connector health, policy-controlled actions and an auditable activity history together. Designed for operators managing Unraid and Portainer installations. + +## Capabilities + +- Discover services, review inventory and deliberately bring applications under management. +- Configure Portainer, optional Unraid and application-specific connectors through `/setup`. +- Inspect health, notification rules, diagnostics and SQLite-safe backups. +- Apply typed action policies; blocked destructive operations remain blocked. +- Integrate optional Home Assistant voice commands and separately authenticated MCP/n8n clients. +- Use deployment and enrollment control planes with pre-registered identities and immutable revisions. + +The gateway does not require direct Docker socket access and exposes no arbitrary shell endpoint. Connector credentials are write-only through the API and encrypted at rest. Optional integrations require their own endpoints and scoped credentials. + +## Local evaluation + +The public Compose configuration is isolated from the owner's deployment. Requirements: Docker Engine with Compose. + +```bash +cp .env.example .env +# Set APPOPS_OPERATOR_TOKEN and APPOPS_SECRET_KEY to independent random values. +docker compose up --build -d +``` + +Open `http://localhost:1216/setup`. Keep the operator token and encryption key private. The stack binds only to loopback, persists `./data`, enables operator authentication, and disables MCP/n8n until explicitly configured. It has no production host mounts, proxy networks or broker endpoints. Configure Portainer in the setup wizard; missing optional connectors remain visible as partial coverage. + +Before production use, put the application behind a trusted authenticated TLS reverse proxy or VPN. Never expose it directly to the internet. Preserve both `/data` and its encryption key, test restore, and review every connector's permissions. See [Security policy](SECURITY.md). + +## Development + +React 19, TypeScript, Vite, Tailwind and TanStack Query form the frontend. FastAPI provides the backend and serves the built frontend; SQLite persists state. Python dependencies are pinned in `backend/requirements.txt`. + +```bash +corepack enable +pnpm install --frozen-lockfile +pnpm dev +pnpm check-types +pnpm lint +pnpm test:frontend +pnpm build +python -m pip install -r backend/requirements.txt +python -m pytest backend/tests -q +``` + +The current frontend uses the live backend facade; the historical mock-mode setting is no longer an offline demo switch. Evaluate the full local container with an empty data directory and no live connectors to explore setup safely. + +## Publication and licensing + +`Unraid-AppOps-Gateway-Public` is a curated snapshot maintained from the private canonical repository. Updates are published automatically after changes merge to canonical `main`; private history, operational evidence and internal Actions are excluded. The public source manifest records the exact source revision. + +No project-wide license has been selected or included. Public visibility does not grant an open-source license. Third-party dependencies retain their own terms. Contact the owner before redistributing or reusing the project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..97c5459 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported Deployment + +The supported production deployment is the canonical single-container instance on port `1216`. + +AppOps is designed for trusted homelab/LAN operation behind network controls such as VPN, reverse-proxy authentication or trusted internal access. Do not expose the gateway directly to the public internet. + +## No-Secrets Policy + +- Do not commit API keys, tokens, passwords, cookies, private keys or production database files. +- Do not paste raw secrets into issues, changelogs, AI prompts, logs or screenshots. +- Connector secrets must be stored through encrypted secret storage or environment/key-file configuration. +- API responses and UI surfaces must show only write-only state such as `hasSecret`, `missing`, `secretRef` or redacted metadata. +- Backups may contain encrypted local state. Protect `/data`, `/data/backups` and the `APPOPS_SECRET_KEY` or `APPOPS_SECRET_KEY_FILE` together. + +## Local-First Data Warning + +AppOps stores operational state locally in SQLite under `/data/appops.db`. This can include encrypted connector secrets, audit events, inventory metadata, notification events and operator decisions. + +SQLite online backups are written to `/data/backups`. They can be integrity-checked and downloaded from Settings, so treat downloaded copies as sensitive local state even though connector secrets remain encrypted. Restore is a stop-container/manual operation in V1. Losing the secret key means encrypted connector secrets in a restored database cannot be decrypted. + +## Exposure Guidance + +- Keep AppOps behind trusted LAN/VPN/reverse-proxy auth. +- Use TLS at the reverse proxy if accessing outside localhost/LAN. +- Do not mount `/var/run/docker.sock`. +- Do not run the container privileged. +- Do not use host networking for the default deployment. +- Keep destructive actions such as delete, prune, format and secret-show blocked unless a future reviewed policy/approval/audit/rollback design is implemented. +- Enrollment accepts only the reviewed `EnrollmentPlanV1` profiles. It cannot + claim an existing container, hostname, Authentik application, appdata path or + controller target, and it never accepts arbitrary Compose, callbacks, + environment values or controller commands. +- Automatic production delivery accepts only an HMAC-verified Gitea main push + after the controller has independently read the configured exact-SHA, + push-only validation contexts with its root-owned read credential. Webhook + payloads, Actions jobs and UI clients cannot claim validation success or + supply the credential; Gitea API outages fail closed. + +## Reporting + +Report suspected security issues privately to the project owner/operator. Include the affected route, version, deployment context and redacted reproduction steps. Never include raw secrets in the report. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..fdbc263 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +from .core.config import Settings + +settings = Settings() diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..5540a68 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API submodule exports diff --git a/backend/app/api/connector_runtime.py b/backend/app/api/connector_runtime.py new file mode 100644 index 0000000..30ebdbc --- /dev/null +++ b/backend/app/api/connector_runtime.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlmodel import Session + +from ..db.session import get_session +from ..services.setup_service import apply_stored_connector_settings + + +def load_connector_runtime(session: Session = Depends(get_session)) -> None: + apply_stored_connector_settings(session) diff --git a/backend/app/api/routes_actions.py b/backend/app/api/routes_actions.py new file mode 100644 index 0000000..113641a --- /dev/null +++ b/backend/app/api/routes_actions.py @@ -0,0 +1,510 @@ +from __future__ import annotations + +import hashlib +import json + +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session, select + +from ..db.session import get_session +from ..db.models import App, AppSetting, utc_now +from ..services.audit_logger import log_event +from ..services.container_restart_service import execute_container_restart +from ..services.policy_engine import evaluate_policy +from ..services.action_registry import ( + ACTIONS, + ACTION_RISK, + action_blast_radius, + action_capability, + action_category, + action_default_target, + action_label, + action_operator_guidance, + action_safeguards, +) +from ..services.approval_service import create_approval +from ..services.action_execution import action_execution_profile, execute_governed_action +from ..services.preflight_service import build_preflight + +router = APIRouter(prefix='/api/actions') + + +def _idempotency_context(session: Session, key: str | None, payload: dict) -> tuple[AppSetting | None, str | None]: + if not key: + return None, None + normalized = key.strip() + if not normalized or len(normalized) > 128: + raise HTTPException(status_code=400, detail='Idempotency-Key must contain 1 to 128 characters.') + request_hash = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + setting_key = f'action-idempotency:{hashlib.sha256(normalized.encode()).hexdigest()}' + existing = session.get(AppSetting, setting_key) + if existing: + if existing.value_json.get('requestHash') != request_hash: + raise HTTPException(status_code=409, detail='Idempotency-Key was already used for a different request.') + return existing, request_hash + return AppSetting(key=setting_key, is_secret=False), request_hash + + +def _idempotent_response(session: Session, row: AppSetting | None, request_hash: str | None, response: dict) -> dict: + if row is None or request_hash is None: + return response + if row.value_json.get('response') is not None: + return {**row.value_json['response'], 'idempotentReplay': True} + row.value_json = {'requestHash': request_hash, 'response': response} + row.updated_at = utc_now() + session.add(row) + session.commit() + return response + + +class ActionRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + input: str | None = Field(default=None, max_length=2000) + action: str | None = Field(default=None, max_length=160) + intent: str | None = Field(default=None, max_length=160) + target: str | None = Field(default=None, max_length=240) + source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$') + risk: str | None = Field(default=None, pattern='^(low|medium|high|critical)$') + policy: str | None = Field(default=None, pattern='^(allowed|approval_required|blocked)$') + reason: str | None = Field(default=None, max_length=1000) + plan: list[str] | dict | None = None + postChecks: list[str] = Field(default_factory=list, max_length=30) + + +def _request_payload(payload: ActionRequest) -> dict: + return payload.model_dump(exclude_none=True) + + +def _extract_plan(payload: dict) -> dict: + plan = payload.get('plan') + if isinstance(plan, dict): + return plan + + if isinstance(payload.get('intent'), str): + return { + 'intent': payload.get('intent'), + 'target': payload.get('target'), + 'source': payload.get('source', 'manual'), + 'risk': payload.get('risk', 'low'), + 'plan': payload.get('steps', []) or payload.get('plan', []), + 'postChecks': payload.get('postChecks', []), + 'policy': payload.get('policy'), + } + + return {} + + +def _build_heuristic_plan(payload: dict, session: Session) -> dict | None: + input_text = str(payload.get('input', '')).lower() + source = str(payload.get('source', 'manual')).strip() or 'manual' + + apps = session.exec(select(App.id)).all() + available_apps = [app_id for app_id in apps] + + def has_app(name: str) -> bool: + return name in available_apps + + if ('restart' in input_text or 'herstart' in input_text): + if has_app('widefrog') and 'widefrog' in input_text: + return { + 'intent': 'container.restart', + 'target': 'widefrog', + 'source': source, + 'risk': 'medium', + 'policy': 'allowed', + 'reason': 'Media app restart is low-medium risk.', + 'plan': ['Verify container status', 'Restart widefrog container', 'Health check'], + 'postChecks': ['HTTP 200 at port 1207', 'Container running'], + } + if has_app('authentik') and 'authentik' in input_text: + return { + 'intent': 'container.restart', + 'target': 'authentik', + 'source': source, + 'risk': 'high', + 'policy': 'approval_required', + 'reason': 'Auth service is critical and requires explicit approval.', + 'plan': ['Check current sessions', 'Stop if maintenance mode', 'Restart container'], + 'postChecks': ['SSO endpoint responds', 'Login flow returns'], + } + + health_terms = {'health', 'check', 'controleer'} + if any(term in input_text for term in health_terms) and ('unraid' in input_text or 'array' in input_text): + return { + 'intent': 'unraid.health_summary', + 'target': 'unraid', + 'source': source, + 'risk': 'low', + 'policy': 'allowed', + 'reason': 'Read-only health summary.', + 'plan': ['Collect array status', 'Collect disk warnings', 'Collect cache usage'], + 'postChecks': ['Sanity of returned summary'], + } + + if 'volume' in input_text and 'delete' in input_text: + return { + 'intent': 'volume.delete', + 'target': 'volume', + 'source': source, + 'risk': 'critical', + 'policy': 'blocked', + 'reason': 'Destructive action is blocked by default.', + 'plan': [], + 'postChecks': [], + } + + aliases = { + 'scan plex': 'plex.scan_all_libraries', + 'scan bibliotheek': 'plex.scan_all_libraries', + 'pauzeer deluge': 'deluge.pause_all', + 'pause deluge': 'deluge.pause_all', + 'hervat deluge': 'deluge.resume_all', + 'resume deluge': 'deluge.resume_all', + 'controleer media pipeline': 'media_pipeline.check', + 'check media pipeline': 'media_pipeline.check', + 'diagnose media pipeline': 'media_pipeline.diagnose', + 'controleer veiligheid': 'safety.summary', + 'safety summary': 'safety.summary', + 'notificatie overzicht': 'notifications.summary', + 'notification summary': 'notifications.summary', + 'setup status': 'setup.status', + } + matched_action = next((action for phrase, action in aliases.items() if phrase in input_text and action in ACTIONS), None) + if not matched_action: + matched_action = next((action for action in ACTIONS if action.lower() in input_text), None) + if not matched_action: + input_terms = set(input_text.replace('.', ' ').replace('_', ' ').split()) + candidates = [] + for action in ACTIONS: + label_terms = set(action_label(action).lower().replace('/', ' ').split()) + overlap = len(input_terms & label_terms) + if overlap >= 2: + candidates.append((overlap, action)) + if candidates: + matched_action = max(candidates)[1] + + if matched_action: + target = action_default_target(matched_action) + matched_app = next((app_id for app_id in available_apps if app_id.lower() in input_text), None) + if matched_app and matched_action.startswith('container.'): + target = matched_app + policy = evaluate_policy(session, action=matched_action, target=target, source=source) + capability = action_capability(matched_action) + return { + 'intent': matched_action, + 'target': target, + 'source': source, + 'risk': str(policy.get('risk') or ACTION_RISK.get(matched_action, 'medium')), + 'policy': policy['decision'], + 'reason': policy['reason'], + 'plan': [ + f'Validate current state of {target}', + ('Collect read-only result' if capability == 'read_only' else f'Run {action_label(matched_action)} through its guarded executor'), + ], + 'postChecks': [f'Confirm {target} state and record the result in Audit'], + } + + return None + + +def _registry_item(action: str, session: Session) -> dict: + target = action_default_target(action) + policy = evaluate_policy(session, action=action, target=target, source='manual') + decision = str(policy.get('decision', 'blocked')) + risk = str(policy.get('risk') or ACTION_RISK.get(action, 'medium')) + capability = action_capability(action) + execution = action_execution_profile(action, decision, capability) + return { + 'id': action, + 'label': action_label(action), + 'category': action_category(action), + 'target': target, + 'risk': risk, + 'capability': capability, + 'policyDecision': decision, + 'policyReason': policy.get('reason') or 'Policy preview.', + **execution, + } + + +@router.get('/registry') +def action_registry(session: Session = Depends(get_session)): + items = [] + summary = { + 'total': 0, + 'allowed': 0, + 'approvalRequired': 0, + 'blocked': 0, + 'readOnly': 0, + 'operator': 0, + 'destructive': 0, + 'highRisk': 0, + 'commandExecutable': 0, + 'approvalExecutable': 0, + 'dedicatedSurface': 0, + 'governanceOnly': 0, + } + + for action in sorted(ACTIONS): + item = _registry_item(action, session) + decision = item['policyDecision'] + risk = item['risk'] + capability = item['capability'] + + summary['total'] += 1 + if decision == 'allowed': + summary['allowed'] += 1 + elif decision == 'approval_required': + summary['approvalRequired'] += 1 + elif decision == 'blocked': + summary['blocked'] += 1 + if capability == 'read_only': + summary['readOnly'] += 1 + elif capability == 'destructive': + summary['destructive'] += 1 + else: + summary['operator'] += 1 + if risk in {'high', 'critical'}: + summary['highRisk'] += 1 + execution_mode = item['executionMode'] + if execution_mode == 'command_center': + summary['commandExecutable'] += 1 + elif execution_mode == 'approval_then_execute': + summary['approvalExecutable'] += 1 + elif execution_mode == 'dedicated_surface': + summary['dedicatedSurface'] += 1 + elif execution_mode == 'governance_only': + summary['governanceOnly'] += 1 + + items.append(item) + + return {'summary': summary, 'items': items} + + +@router.get('/registry/{action_id}') +def action_registry_detail(action_id: str, session: Session = Depends(get_session)): + if action_id not in ACTIONS: + raise HTTPException(status_code=404, detail='Unknown action id.') + + item = _registry_item(action_id, session) + return { + **item, + 'blastRadius': action_blast_radius(action_id, item['capability']), + 'safeguards': action_safeguards(action_id, item['policyDecision'], item['capability']), + 'operatorGuidance': action_operator_guidance(action_id, item['policyDecision'], item['capability']), + 'relatedSurfaces': [ + '/command-center', + '/policies', + '/audit', + ], + } + + +@router.post('/plan') +def action_plan(payload: ActionRequest, session: Session = Depends(get_session)): + payload = _request_payload(payload) + source = str(payload.get('source', 'manual')).strip() or 'manual' + plan = _extract_plan(payload) + + if not plan: + plan = _build_heuristic_plan(payload, session) + + if not plan: + return {'decision': 'blocked', 'reason': 'No actionable intent detected.'} + + return { + 'intent': plan['intent'], + 'target': plan['target'], + 'source': source, + 'risk': plan['risk'], + 'policy': plan['policy'], + 'reason': plan['reason'], + 'plan': plan['plan'], + 'postChecks': plan['postChecks'], + } + + +@router.post('/preflight') +def preflight(payload: ActionRequest, session: Session = Depends(get_session)): + payload = _request_payload(payload) + action = str(payload.get('action', '')).strip() + target = str(payload.get('target', '')).strip() + source = str(payload.get('source', 'manual')).strip() or 'manual' + + if not action or not target: + if plan := _build_heuristic_plan(payload, session): + action = plan['intent'] + target = plan['target'] + source = plan['source'] + else: + action = payload.get('action', '') + target = payload.get('target', '') + + policy = evaluate_policy(session, action=action, target=target, source=source) + risk = policy.get('risk', 'medium') + return build_preflight(action, target, policy['decision'], risk) + + +@router.post('/execute') +def execute(payload: ActionRequest, session: Session = Depends(get_session), idempotency_key: str | None = Header(default=None, alias='Idempotency-Key')): + payload = _request_payload(payload) + idempotency_row, request_hash = _idempotency_context(session, idempotency_key, payload) + if idempotency_row is not None and idempotency_row.value_json.get('response') is not None: + return _idempotent_response(session, idempotency_row, request_hash, {}) + plan = _extract_plan(payload) + if not plan: + return _idempotent_response(session, idempotency_row, request_hash, {'success': False, 'message': 'No valid action plan provided.'}) + + action = str(plan.get('intent', '')).strip() + target = str(plan.get('target', '')).strip() + source = str(plan.get('source', 'manual')).strip() or 'manual' + steps = plan.get('plan', []) if isinstance(plan.get('plan', []), list) else [] + + if action == 'deployment.target.promote_workmesh': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk='high', + policy_decision='blocked', + reason='WorkMesh promotion requires its exact-revision deployment preflight endpoint.', + result='blocked', + execution_plan={'plan': steps, 'executor': '/api/deployments/workmesh/promotions/preflight'}, + ) + return _idempotent_response(session, idempotency_row, request_hash, { + 'success': False, + 'decision': 'blocked', + 'status': 'failed', + 'policy_decision': 'blocked', + 'result': 'preflight_required', + 'message': 'Use the fixed WorkMesh revision preflight and approval workflow.', + 'auditId': audit.id, + }) + + decision = evaluate_policy(session, action=action, target=target, source=source) + risk = str(decision.get('risk', 'medium')).strip() or 'medium' + + # Always audit every request. + if decision['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision['decision'], + reason=decision['reason'], + result='blocked', + execution_plan={'plan': steps, 'policyReason': decision['reason']}, + ) + return _idempotent_response(session, idempotency_row, request_hash, { + 'success': False, + 'decision': 'blocked', + 'status': 'failed', + 'policy_decision': decision['decision'], + 'result': 'blocked', + 'message': decision['reason'], + 'auditId': audit.id, + }) + + if decision['decision'] == 'approval_required': + approval = create_approval( + session, + action=action, + target=target, + source=source, + risk=risk, + reason=decision['reason'], + requester=source, + ) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision['decision'], + reason=decision['reason'], + result='approval_requested', + execution_plan={'plan': steps, 'approvalId': approval.id}, + redacted_metadata={'approval_id': approval.id}, + ) + return _idempotent_response(session, idempotency_row, request_hash, { + 'success': False, + 'decision': 'approval_required', + 'status': 'failed', + 'policy_decision': decision['decision'], + 'result': 'approval_required', + 'message': 'Action requires approval before execution.', + 'approvalId': approval.id, + 'auditId': audit.id, + }) + + if action in { + 'deployment.targets.sync', + 'deployment.target.deploy_workmesh', + 'deployment.target.register_portfolio', + 'deployment.target.register_privatemuse', + }: + response = execute_governed_action( + session, + action=action, + target=target, + source=source, + risk=risk, + reason=str(decision.get('reason') or 'Execute the fixed deployment target operation.'), + original_policy_decision=str(decision['decision']), + ) + return _idempotent_response(session, idempotency_row, request_hash, response) + if action == 'container.restart': + response = execute_container_restart( + session=session, + action=action, + target=target, + source=source, + risk=risk, + steps=steps if isinstance(steps, list) else None, + ) + return _idempotent_response(session, idempotency_row, request_hash, response) + + # Fail closed for allowed actions without a real executor. This avoids + # presenting read-only checks or future actions as successfully executed. + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision['decision'], + reason='No live executor is registered for this action.', + result='unsupported', + execution_plan={'plan': steps, 'executor': None}, + ) + + return _idempotent_response(session, idempotency_row, request_hash, { + 'success': False, + 'decision': 'allowed', + 'status': 'failed', + 'policy_decision': decision['decision'], + 'result': 'unsupported', + 'message': 'No live executor is registered for this action.', + 'auditId': audit.id, + }) + + +@router.post('/container/{app_id}/restart') +def restart_container(app_id: str, session: Session = Depends(get_session)): + return execute_container_restart( + session=session, + action='container.restart', + target=app_id, + source='manual', + risk='medium', + steps=['Resolve container', 'Restart container', 'Post-check status'], + ) diff --git a/backend/app/api/routes_ai.py b/backend/app/api/routes_ai.py new file mode 100644 index 0000000..a754d0c --- /dev/null +++ b/backend/app/api/routes_ai.py @@ -0,0 +1,210 @@ +from datetime import UTC, datetime +from typing import Any +import uuid + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..core.redaction import redact_message +from ..db.session import get_session +from ..services import ai_security_reviewer, knowledge_service + +router = APIRouter(prefix='/api/ai') + + +_AI_TOOLS = [ + { + 'id': 'read_container_status', + 'name': 'read_container_status', + 'description': 'Leest containerstatus via de Portainer read facade.', + 'type': 'read', + 'permissionLevel': 1, + 'requiresApproval': False, + 'rateLimit': '60/min', + 'tool': 'read_container_status', + 'enabled': True, + 'allowedByDefault': True, + 'scope': 'read', + }, + { + 'id': 'read_unraid_health', + 'name': 'read_unraid_health', + 'description': 'Leest Unraid array, cache, parity en docker health zonder mutaties.', + 'type': 'read', + 'permissionLevel': 1, + 'requiresApproval': False, + 'rateLimit': '30/min', + 'tool': 'read_unraid_health', + 'enabled': True, + 'allowedByDefault': True, + 'scope': 'read', + }, + { + 'id': 'read_audit', + 'name': 'read_audit', + 'description': 'Leest geredigeerde audit events voor analyse en incidentcontext.', + 'type': 'read', + 'permissionLevel': 1, + 'requiresApproval': False, + 'rateLimit': '30/min', + 'tool': 'read_audit', + 'enabled': True, + 'allowedByDefault': True, + 'scope': 'read', + }, + { + 'id': 'query_knowledge', + 'name': 'query_knowledge', + 'description': 'Zoekt lokale documentatie en actuele, geredigeerde AppOps-context met bronverwijzingen.', + 'type': 'read', + 'permissionLevel': 1, + 'requiresApproval': False, + 'rateLimit': '30/min', + 'tool': 'query_knowledge', + 'enabled': True, + 'allowedByDefault': True, + 'scope': 'read', + }, + { + 'id': 'draft_playbook_run', + 'name': 'draft_playbook_run', + 'description': 'Maakt alleen een voorstel voor playbook-uitvoering; uitvoering loopt via policy en approval.', + 'type': 'mutate', + 'permissionLevel': 3, + 'requiresApproval': True, + 'rateLimit': '5/min', + 'tool': 'draft_playbook_run', + 'enabled': True, + 'allowedByDefault': False, + 'scope': 'mutate', + }, +] + + +def _redact_payload(value: Any) -> Any: + if isinstance(value, str): + return redact_message(value) + if isinstance(value, list): + return [_redact_payload(item) for item in value] + if isinstance(value, dict): + return {str(key): _redact_payload(item) for key, item in value.items()} + return value + +@router.get('/status') +def status(session: Session = Depends(get_session)): + summary = ai_security_reviewer.get_summary(session) + current_risks = int(summary.get('criticalFindings') or 0) + int(summary.get('highFindings') or 0) + int(summary.get('mediumFindings') or 0) + recommended_actions = int(summary.get('recommendationsCount') or 0) + data_completeness = summary.get('dataCompleteness') if isinstance(summary.get('dataCompleteness'), dict) else {} + missing_sources = data_completeness.get('missingSources') or [] + if missing_sources: + storage_forecast = f"Coverage partial; ontbrekende bronnen: {', '.join(str(item) for item in missing_sources[:4])}." + else: + storage_forecast = 'Geen storage forecast beschikbaar in de advisory AI status.' + + return { + 'dailySummary': redact_message( + f"AI advisor draait read-only. Security status: {summary.get('status', 'unknown')}; " + f"{current_risks} open risico(s), {recommended_actions} aanbeveling(en)." + ), + 'currentRisks': current_risks, + 'recommendedActions': recommended_actions, + 'storageForecast': redact_message(storage_forecast), + 'securityScore': int(summary.get('score') or 0), + 'autopilotStatus': 'off', + } + +@router.post('/chat') +def chat(payload: dict, session: Session = Depends(get_session)): + content = redact_message(str(payload.get('content') or '')) + try: + grounded = knowledge_service.query(session, content, limit=5) + except ValueError: + grounded = { + 'answer': 'Stel een concrete vraag over AppOps, policies, apps, audit of platformwerking.', + 'citations': [], + 'confidence': 'none', + } + citations = grounded.get('citations') or [] + evidence = '; '.join( + f"[{item.get('rank')}] {item.get('title')} — {item.get('uri')}" + for item in citations[:5] + if isinstance(item, dict) + ) + return { + 'id': f"chat-{uuid.uuid4().hex[:12]}", + 'role': 'assistant', + 'content': grounded['answer'], + 'timestamp': datetime.now(UTC).isoformat(), + 'metadata': { + 'evidence': evidence or 'Geen relevante bron gevonden; er is niet gegokt.', + 'risk': 'low', + 'policyResult': 'allowed', + 'nextStep': 'RAG blijft read-only. Uitvoering loopt uitsluitend via Command Center, policy en approvals.', + 'citations': citations, + 'confidence': grounded.get('confidence', 'none'), + }, + } + +@router.get('/recommendations') +def recommendations(session: Session = Depends(get_session)): + results: list[dict[str, Any]] = [] + for recommendation in ai_security_reviewer.get_recommendations(session): + evidence = recommendation.get('evidence') or [] + evidence_text = '; '.join( + redact_message(f"{item.get('source', 'source')}:{item.get('label', 'evidence')}={item.get('value', item.get('observed', ''))}") + for item in evidence[:3] + if isinstance(item, dict) + ) or redact_message(str(recommendation.get('description') or 'Security reviewer recommendation.')) + status = recommendation.get('status') or 'open' + if status == 'accepted': + status = 'applied' + confidence = recommendation.get('confidence') or 'medium' + confidence_score = {'low': 0.45, 'medium': 0.7, 'high': 0.9}.get(str(confidence), 0.7) + severity = str(recommendation.get('severity') or 'medium') + if severity == 'info': + severity = 'low' + results.append({ + 'id': recommendation.get('id'), + 'title': redact_message(str(recommendation.get('title') or 'AI recommendation')), + 'severity': severity, + 'confidence': confidence_score, + 'evidence': evidence_text, + 'affectedApps': [str(item) for item in recommendation.get('affectedResources', [])], + 'suggestedAction': redact_message(str(recommendation.get('suggestedAction') or 'Review in AI Security Reviewer')), + 'status': status, + }) + return results + +@router.post('/analyze/incident/{incident_id}') +def analyze_incident(incident_id: str): + return {'incident': incident_id, 'analysis': 'No blocking issues.'} + +@router.post('/summarize/logs') +def summarize_logs(payload: dict): + return {'summary': 'No critical events.'} + +@router.get('/tools') +def tools(): + return _AI_TOOLS + + +@router.get('/history') +def history(): + return [] + +@router.get('/memory') +def memory(): + return [] + +@router.post('/memory') +def create_memory(payload: dict): + return {'success': True, 'memory': _redact_payload(payload)} + +@router.patch('/memory/{memory_id}') +def update_memory(memory_id: str, payload: dict): + return {'success': True, 'id': memory_id, 'memory': _redact_payload(payload)} + +@router.delete('/memory/{memory_id}') +def delete_memory(memory_id: str): + return {'success': True, 'id': memory_id} diff --git a/backend/app/api/routes_ai_remediation.py b/backend/app/api/routes_ai_remediation.py new file mode 100644 index 0000000..998f3f6 --- /dev/null +++ b/backend/app/api/routes_ai_remediation.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from ..db.session import get_session +from ..services import ai_remediation_planner +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/ai/remediation', dependencies=[Depends(load_connector_runtime)]) + + +@router.get('/status') +def status(session: Session = Depends(get_session)): + plans = ai_remediation_planner.get_plans(session).plans + return { + 'enabled': True, + 'mode': 'deterministic_rule_based', + 'externalAiRequired': False, + 'canExecuteActions': False, + 'totalPlans': len(plans), + 'openPlans': sum(1 for plan in plans if plan.status == 'draft'), + 'reviewedPlans': sum(1 for plan in plans if plan.status == 'reviewed'), + 'dismissedPlans': sum(1 for plan in plans if plan.status == 'dismissed'), + } + + +@router.get('/plans') +def plans(session: Session = Depends(get_session)): + return ai_remediation_planner.get_plans(session).model_dump() + + +@router.get('/plans/{plan_id}') +def plan_detail(plan_id: str, session: Session = Depends(get_session)): + plan = ai_remediation_planner.get_plan(session, plan_id) + if not plan: + raise HTTPException(status_code=404, detail='plan_not_found') + return plan.model_dump() + + +@router.post('/plans/generate') +def generate(session: Session = Depends(get_session)): + return ai_remediation_planner.generate_plans(session, audit_run=True).model_dump() + + +@router.post('/plans/from-finding/{finding_id}') +def from_finding(finding_id: str, session: Session = Depends(get_session)): + return ai_remediation_planner.generate_from_finding(session, finding_id).model_dump() + + +@router.post('/plans/from-recommendation/{recommendation_id}') +def from_recommendation(recommendation_id: str, session: Session = Depends(get_session)): + return ai_remediation_planner.generate_from_recommendation(session, recommendation_id).model_dump() + + +@router.post('/plans/{plan_id}/review') +def review(plan_id: str, session: Session = Depends(get_session)): + if not ai_remediation_planner.get_plan(session, plan_id): + raise HTTPException(status_code=404, detail='plan_not_found') + return ai_remediation_planner.review_plan(session, plan_id) + + +@router.post('/plans/{plan_id}/dismiss') +def dismiss(plan_id: str, session: Session = Depends(get_session)): + if not ai_remediation_planner.get_plan(session, plan_id): + raise HTTPException(status_code=404, detail='plan_not_found') + return ai_remediation_planner.dismiss_plan(session, plan_id) + + +@router.post('/plans/{plan_id}/create-approval-proposal') +def create_approval_proposal(plan_id: str, session: Session = Depends(get_session)): + proposal = ai_remediation_planner.create_approval_proposal(session, plan_id) + if not proposal: + raise HTTPException(status_code=404, detail='plan_not_found') + return proposal.model_dump() + + +@router.post('/plans/{plan_id}/dry-run') +def dry_run(plan_id: str, session: Session = Depends(get_session)): + result = ai_remediation_planner.dry_run(session, plan_id) + if not result: + raise HTTPException(status_code=404, detail='plan_not_found') + return result.model_dump() diff --git a/backend/app/api/routes_ai_security.py b/backend/app/api/routes_ai_security.py new file mode 100644 index 0000000..141b549 --- /dev/null +++ b/backend/app/api/routes_ai_security.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from ..db.session import get_session +from ..schemas.ai_security import SecurityReviewScope +from ..services import ai_security_reviewer +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/ai/security', dependencies=[Depends(load_connector_runtime)]) + + +@router.get('/status') +def status(session: Session = Depends(get_session)): + summary = ai_security_reviewer.get_summary(session) + return { + 'enabled': True, + 'mode': 'deterministic_rule_based', + 'externalAiRequired': False, + 'canExecuteActions': False, + 'status': summary['status'], + 'score': summary['score'], + 'generatedAt': summary['generatedAt'], + } + + +@router.get('/summary') +def summary(session: Session = Depends(get_session)): + return ai_security_reviewer.get_summary(session) + + +@router.get('/findings') +def findings(scope: SecurityReviewScope = 'all', session: Session = Depends(get_session)): + return ai_security_reviewer.get_findings(session, scope) + + +@router.get('/findings/{finding_id}') +def finding_detail(finding_id: str, session: Session = Depends(get_session)): + finding = ai_security_reviewer.get_finding(session, finding_id) + if not finding: + raise HTTPException(status_code=404, detail='finding_not_found') + return finding + + +@router.get('/recommendations') +def recommendations(session: Session = Depends(get_session)): + return ai_security_reviewer.get_recommendations(session) + + +@router.post('/review') +def review(session: Session = Depends(get_session)): + return ai_security_reviewer.generate_review(session, audit_run=True).model_dump() + + +@router.post('/review/scope/{scope}') +def review_scope(scope: SecurityReviewScope, session: Session = Depends(get_session)): + return ai_security_reviewer.generate_review(session, scope=scope, audit_run=True).model_dump() + + +@router.post('/findings/{finding_id}/acknowledge') +def acknowledge_finding(finding_id: str, session: Session = Depends(get_session)): + return ai_security_reviewer.set_finding_status(session, finding_id, 'acknowledged') + + +@router.post('/findings/{finding_id}/dismiss') +def dismiss_finding(finding_id: str, session: Session = Depends(get_session)): + return ai_security_reviewer.set_finding_status(session, finding_id, 'dismissed') + + +@router.post('/recommendations/{recommendation_id}/snooze') +def snooze_recommendation(recommendation_id: str, session: Session = Depends(get_session)): + return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'snoozed') + + +@router.post('/recommendations/{recommendation_id}/dismiss') +def dismiss_recommendation(recommendation_id: str, session: Session = Depends(get_session)): + return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'dismissed') + + +@router.post('/recommendations/{recommendation_id}/accept') +def accept_recommendation(recommendation_id: str, session: Session = Depends(get_session)): + return ai_security_reviewer.set_recommendation_status(session, recommendation_id, 'accepted') diff --git a/backend/app/api/routes_approvals.py b/backend/app/api/routes_approvals.py new file mode 100644 index 0000000..559ea67 --- /dev/null +++ b/backend/app/api/routes_approvals.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, ConfigDict, Field + +from ..db.session import get_session +from ..services.audit_logger import log_event +from ..services.action_execution import approval_execution_supported, execute_approved_action +from ..services.approval_service import ( + ApprovalStateError, + approval_fingerprint, + create_approval, + dismiss_expired_approvals, + get_approval, + list_approvals, + mark_approval_executed, + update_approval_status, +) + +router = APIRouter(prefix='/api/approvals') + + +class ApprovalCreateRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + action: str = Field(min_length=1, max_length=160) + target: str = Field(min_length=1, max_length=240) + source: str = Field(default='manual', pattern='^(manual|voice|automation|system)$') + risk: str = Field(default='medium', pattern='^(low|medium|high|critical)$') + reason: str | None = Field(default=None, max_length=1000) + requester: str = Field(default='manual', max_length=120) + plan: list[str] = Field(default_factory=list, max_length=30) + + +class ApprovalDecisionRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + status: str = Field(min_length=1, max_length=32) + + +@router.get('') +def list_all(include_dismissed: bool = Query(default=False), session=Depends(get_session)): + return [ + { + **approval.model_dump(), + 'executionSupported': approval_execution_supported(approval.action), + 'executionReason': None if approval_execution_supported(approval.action) else 'Geen rollback-veilige executor geregistreerd.', + } + for approval in list_approvals(session, include_dismissed=include_dismissed) + ] + + +@router.post('/cleanup') +def cleanup_expired(session=Depends(get_session)): + dismissed = dismiss_expired_approvals(session) + audit = log_event( + session, + source='manual', + identity='operator', + action_id='approval.cleanup', + target='approval-queue', + target_type='approval', + risk='low', + policy_decision='allowed', + reason=f'{len(dismissed)} expired approval(s) archived; active approvals were retained.', + result='success', + execution_plan={'steps': ['expire overdue pending approvals', 'archive expired approvals', 'retain approval and audit records']}, + redacted_metadata={'dismissedCount': len(dismissed)}, + ) + return { + 'success': True, + 'message': f'{len(dismissed)} verlopen approval(s) opgeruimd.', + 'dismissedCount': len(dismissed), + 'auditId': audit.id, + } + + +@router.post('') +def create(payload: ApprovalCreateRequest, session=Depends(get_session)): + approval = create_approval( + session, + action=payload.action, + target=payload.target, + source=payload.source, + risk=payload.risk, + reason=payload.reason, + requester=payload.requester, + context={'plan': payload.plan}, + ) + return {'success': True, 'message': 'Approval created', 'id': approval.id} + + +@router.patch('/{approval_id}') +def update(approval_id: str, payload: ApprovalDecisionRequest, session=Depends(get_session)): + status = payload.status + try: + updated = update_approval_status(session, approval_id, status) + except ApprovalStateError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if not updated: + return {'success': False, 'message': 'Approval not found', 'id': approval_id} + + audit = log_event( + session, + source='manual', + identity='operator', + action_id=f'approval.{status}', + target=updated.target, + target_type='approval', + risk=updated.risk, + policy_decision='approval_required', + reason=updated.reason or f'Approval {approval_id} marked {status}.', + result='success', + execution_plan={'steps': ['load approval', f'mark {status}', 'write audit']}, + redacted_metadata={'approvalId': updated.id, 'approvalStatus': updated.status, 'requestedAction': updated.action}, + ) + + return { + 'success': True, + 'id': updated.id, + 'status': updated.status, + 'auditId': audit.id, + 'message': f'Approval {updated.status}', + } + + +@router.post('/{approval_id}/execute') +def execute_approved(approval_id: str, session=Depends(get_session)): + approval = get_approval(session, approval_id) + if not approval: + raise HTTPException(status_code=404, detail='Approval not found.') + if approval.status != 'approved': + raise HTTPException(status_code=409, detail='Only an approved request can be executed.') + expected = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json) + if not approval.action_fingerprint or approval.action_fingerprint != expected: + raise HTTPException(status_code=409, detail='Approval action context changed after review.') + + result = execute_approved_action(session, approval) + if not result.get('success'): + return result + + mark_approval_executed( + session, + approval, + job_id=str(result['jobId']), + audit_event_id=str(result['auditId']), + ) + return { + **result, + 'approvalId': approval.id, + 'approvalStatus': 'executed', + } diff --git a/backend/app/api/routes_apps.py b/backend/app/api/routes_apps.py new file mode 100644 index 0000000..31f419e --- /dev/null +++ b/backend/app/api/routes_apps.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import re +from urllib.parse import urlsplit + +from fastapi import APIRouter, Depends +from sqlmodel import Session, select + +from ..db.models import App, AppContainerMapping, DiscoveredContainer +from ..db.session import get_session +from ..core.config import settings +from ..services import safety_center_service +from ..services.app_registry import category_label, list_active_apps +from ..services.audit_logger import log_event +from ..services.inventory_discovery import serialize_inventory_app, sync_app_connector_statuses + +router = APIRouter(prefix='/api/apps') + + +def _published_port(values: list | None) -> int | None: + for value in values or []: + match = re.match(r'^(\d+):\d+', str(value)) + if match: + return int(match.group(1)) + return None + + +def _credential_free_url(raw: str) -> str | None: + raw = str(raw or '').strip() + if not raw: + return None + parsed = urlsplit(raw if '://' in raw else f'http://{raw}') + try: + hostname = parsed.hostname + parsed_port = parsed.port + except ValueError: + return None + if parsed.scheme not in {'http', 'https'} or not hostname: + return None + host = f'[{hostname}]' if ':' in hostname else hostname + port = f':{parsed_port}' if parsed_port else '' + path = parsed.path.rstrip('/') + return f'{parsed.scheme}://{host}{port}{path}' + + +def _base_runtime_url() -> str | None: + safe = _credential_free_url(str(settings.apis.get('UNRAID_URL') or settings.apis.get('PORTAINER_URL') or '')) + if not safe: + return None + parsed = urlsplit(safe) + return f'{parsed.scheme}://{parsed.netloc}' + + +def _runtime_hints_by_app(session: Session) -> dict[str, dict]: + from ..services.inventory_discovery import _latest_by_container + + configured_urls = { + 'sonarr': 'SONARR_URL', + 'radarr': 'RADARR_URL', + 'prowlarr': 'PROWLARR_URL', + 'deluge': 'DELUGE_URL', + 'plex': 'PLEX_URL', + 'tautulli': 'TAUTULLI_URL', + 'nginx_proxy_manager': 'NPM_URL', + 'authentik': 'AUTHENTIK_URL', + 'home_assistant': 'HOME_ASSISTANT_URL', + 'widefrog': 'WIDEFROG_URL', + 'portainer': 'PORTAINER_URL', + 'unraid': 'UNRAID_URL', + } + hints: dict[str, dict] = {} + for app_id, env_name in configured_urls.items(): + value = _credential_free_url(str(settings.apis.get(env_name) or '')) + if value: + hints[app_id] = {'url': value} + + base_url = _base_runtime_url() + latest = _latest_by_container(session) + mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.is_primary.desc())).all() + for mapping in mappings: + if mapping.app_id in hints or not base_url: + continue + item = latest.get(mapping.container_id) + port = _published_port(item.ports_json if item else None) + if port: + hints[mapping.app_id] = {'url': base_url, 'port': port} + return hints + + +def _serialize_app(app: App, hints: dict | None = None) -> dict: + return { + 'id': app.id, + 'name': app.name, + 'category': app.category, + 'status': app.status, + 'criticality': app.criticality, + 'connectorStatus': app.connector_status, + 'containerStatus': app.container_status, + 'voiceEnabled': app.voice_enabled, + 'lastAction': app.last_action, + 'protected': app.protected, + 'discoveryStatus': app.discovery_status, + 'discoverySource': app.discovery_source, + 'needsReview': app.needs_review, + **(hints or {}), + } + + +def _serialize_discovered_app(item: DiscoveredContainer) -> dict: + return serialize_inventory_app(item) + + +@router.get('') +def list_apps(include_discovered: bool = False, session: Session = Depends(get_session)): + sync_app_connector_statuses(session) + runtime_hints = _runtime_hints_by_app(session) + apps = [_serialize_app(app, runtime_hints.get(app.id)) for app in list_active_apps(session)] + + if include_discovered: + managed_container_ids = { + mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all() + } + latest_by_container: dict[str, DiscoveredContainer] = {} + for item in session.exec(select(DiscoveredContainer)).all(): + latest_by_container[item.container_id] = item + for item in latest_by_container.values(): + if item.container_id in managed_container_ids or item.import_status == 'managed': + continue + if item.import_status in {'discovered', 'unmapped', 'changed', 'ignored'}: + apps.append(_serialize_discovered_app(item)) + + return apps + + +@router.get('/{app_id}') +def get_app(app_id: str, session: Session = Depends(get_session)): + sync_app_connector_statuses(session) + app = session.get(App, app_id) + if app: + return _serialize_app(app, _runtime_hints_by_app(session).get(app.id)) + + if app_id.startswith('discovered-'): + discovered_id = app_id.replace('discovered-', '', 1) + item = session.get(DiscoveredContainer, discovered_id) + if item: + return _serialize_discovered_app(item) + + return None + + +@router.patch('/{app_id}/metadata') +def update_app_metadata(app_id: str, payload: dict, session: Session = Depends(get_session)): + app = session.get(App, app_id) + if not app: + return {'success': False, 'status': 'not_found', 'message': 'app_not_found'} + + name = str(payload.get('name') or '').strip()[:80] + category = re.sub(r'[^a-zA-Z0-9_\- ]', '', str(payload.get('category') or '')).strip()[:40] + criticality = str(payload.get('criticality') or '').strip().lower() + if not name or not category or criticality not in {'low', 'medium', 'high', 'critical'}: + return {'success': False, 'status': 'validation_error', 'message': 'invalid_app_metadata'} + + before = {'name': app.name, 'category': app.category, 'criticality': app.criticality} + app.name = name + app.category = category_label(category) + app.criticality = criticality + session.add(app) + session.commit() + session.refresh(app) + safety_center_service.invalidate_cache() + log_event( + session, + source='manual', + identity='operator', + action_id='app.metadata.update', + target=app.id, + target_type='app', + risk='low', + policy_decision='allowed', + reason='Operator updated AppOps registry metadata only; no container or external service was mutated.', + result='success', + before_state=before, + after_state={'name': app.name, 'category': app.category, 'criticality': app.criticality}, + redacted_metadata={'fields': ['name', 'category', 'criticality']}, + ) + return {'success': True, 'status': 'success', 'message': 'app_metadata_updated', 'app': _serialize_app(app, _runtime_hints_by_app(session).get(app.id))} + + +@router.patch('/{app_id}/container-mapping') +def update_container_mapping(app_id: str, payload: dict, session: Session = Depends(get_session)): + container_id = str(payload.get('containerId') or '') + container_name = str(payload.get('containerName') or container_id) + if not session.get(App, app_id): + return {'success': False, 'message': 'app_not_found'} + if not container_id: + return {'success': False, 'message': 'container_id_required'} + + mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == container_id)).first() + if not mapping: + mapping = AppContainerMapping(id=f'map-{container_id}', app_id=app_id, container_id=container_id, container_name=container_name) + else: + mapping.app_id = app_id + mapping.container_name = container_name + session.add(mapping) + session.commit() + safety_center_service.invalidate_cache() + return {'success': True, 'message': 'mapping_updated', 'mappingId': mapping.id} diff --git a/backend/app/api/routes_audit.py b/backend/app/api/routes_audit.py new file mode 100644 index 0000000..ca112f7 --- /dev/null +++ b/backend/app/api/routes_audit.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +from ..db.session import get_session +from ..services.audit_logger import list_events + +router = APIRouter(prefix='/api/audit') + + +@router.get('') +def list_audit( + limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), + session=Depends(get_session), +): + return list_events(session, limit=limit, offset=offset) + + +@router.post('/export') +def export_audit(payload: dict): + return { + 'success': True, + 'message': f"Exporting audit in {str(payload.get('format', 'json')).upper()} format", + } diff --git a/backend/app/api/routes_authentik.py b/backend/app/api/routes_authentik.py new file mode 100644 index 0000000..4241b07 --- /dev/null +++ b/backend/app/api/routes_authentik.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from ..connectors.authentik import AuthentikConnector +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/authentik', dependencies=[Depends(load_connector_runtime)]) + + +def _shape(payload: dict[str, Any]) -> dict[str, Any]: + data = payload.get('data') + if not isinstance(data, dict): + data = {key: value for key, value in payload.items() if key not in {'success', 'status', 'message'}} + return { + **payload, + 'success': bool(payload.get('success')), + 'status': str(payload.get('status', 'ok')), + 'message': str(payload.get('message', 'ok')), + 'data': data, + } + + +def _connector() -> AuthentikConnector: + return AuthentikConnector() + + +@router.get('/health') +def health(): + return _shape(_connector().health()) + + +@router.get('/system') +def system_status(): + return _shape(_connector().system_status()) + + +@router.get('/applications') +def applications(): + return _shape(_connector().applications()) + + +@router.get('/providers') +def providers(): + return _shape(_connector().providers()) + + +@router.get('/providers/proxy') +def proxy_providers(): + return _shape(_connector().proxy_providers()) + + +@router.get('/providers/oauth2') +def oauth2_providers(): + return _shape(_connector().oauth2_providers()) + + +@router.get('/outposts') +def outposts(): + return _shape(_connector().outposts()) + + +@router.get('/outposts/health') +def outpost_health(): + return _shape(_connector().outpost_health()) + + +@router.get('/flows') +def flows_summary(): + return _shape(_connector().flows_summary()) + + +@router.get('/policies') +def policies_summary(): + return _shape(_connector().policies_summary()) + + +@router.get('/events') +def events(limit: int = 25): + return _shape(_connector().events(limit)) + + +@router.get('/auth-coverage') +def auth_coverage(): + return _shape(_connector().auth_coverage()) + + +@router.get('/security-warnings') +def security_warnings(): + return _shape(_connector().security_warnings()) diff --git a/backend/app/api/routes_connectors.py b/backend/app/api/routes_connectors.py new file mode 100644 index 0000000..b51a251 --- /dev/null +++ b/backend/app/api/routes_connectors.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlmodel import Session, select + +# Kept imported here for compatibility with tests and callers that patch the +# route module's Portainer connector class. +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message +from ..db.models import Connector +from ..db.session import get_session +from ..services.connector_health import refresh_connector_statuses, test_and_persist_connector +from ..services.inventory_discovery import ( + bind_connector_to_discovered, + list_connector_bindings, + list_connector_candidates, + unbind_connector_from_app, +) + +router = APIRouter(prefix='/api/connectors') + + +class ConnectorBindRequest(BaseModel): + discoveredContainerId: str + + +class ConnectorUnbindRequest(BaseModel): + appId: str + + +def _serialize_connector(connector: Connector) -> dict: + return { + 'id': connector.id, + 'name': connector.name, + 'status': connector.status, + 'lastTest': connector.last_test, + 'capabilities': connector.capabilities, + 'type': connector.type, + 'endpoint': None, + 'secretRef': connector.secret_ref, + } + + +def _required_env_vars(connector_id: str) -> list[str]: + mapping = { + 'portainer': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'], + 'sonarr': ['SONARR_URL', 'SONARR_API_KEY'], + 'radarr': ['RADARR_URL', 'RADARR_API_KEY'], + 'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'], + 'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'], + 'plex': ['PLEX_URL', 'PLEX_TOKEN'], + 'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'], + 'nginx_proxy_manager': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], + 'authentik': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], + 'widefrog': ['WIDEFROG_URL'], + 'unraid': ['UNRAID_URL', 'UNRAID_API_KEY'], + 'home_assistant': ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'], + } + return mapping.get(connector_id, []) + + +@router.get('') +def list_connectors(refresh: bool = False, session: Session = Depends(get_session)): + if refresh: + refresh_connector_statuses(session) + connectors = session.exec(select(Connector).order_by(Connector.id)).all() + return [_serialize_connector(connector) for connector in connectors] + + +@router.get('/{connector_id}') +def connector_status(connector_id: str, session: Session = Depends(get_session)): + connector = session.get(Connector, connector_id) + if not connector: + return {'success': False, 'message': 'connector_not_found', 'id': connector_id} + return _serialize_connector(connector) + + +@router.get('/{connector_id}/candidates') +def connector_candidates(connector_id: str, session: Session = Depends(get_session)): + connector = session.get(Connector, connector_id) + if not connector: + return {'success': False, 'message': 'connector_not_found', 'id': connector_id} + return list_connector_candidates(session, connector_id) + + +@router.get('/{connector_id}/bindings') +def connector_bindings(connector_id: str, session: Session = Depends(get_session)): + connector = session.get(Connector, connector_id) + if not connector: + return {'success': False, 'message': 'connector_not_found', 'id': connector_id} + return list_connector_bindings(session, connector_id) + + +@router.post('/{connector_id}/bind') +def bind_connector(connector_id: str, payload: ConnectorBindRequest, session: Session = Depends(get_session)): + connector = session.get(Connector, connector_id) + if not connector: + return {'success': False, 'message': 'connector_not_found', 'id': connector_id} + return bind_connector_to_discovered(session, connector_id, payload.discoveredContainerId) + + +@router.post('/{connector_id}/unbind') +def unbind_connector(connector_id: str, payload: ConnectorUnbindRequest, session: Session = Depends(get_session)): + connector = session.get(Connector, connector_id) + if not connector: + return {'success': False, 'message': 'connector_not_found', 'id': connector_id} + return unbind_connector_from_app(session, connector_id, payload.appId) + + +@router.post('/{connector_id}/test') +def test_connector(connector_id: str, session: Session = Depends(get_session)): + connector = session.get(Connector, connector_id) + if not connector: + return {'success': False, 'status': 'disconnected', 'message': f'Connector {connector_id} not found'} + + result = test_and_persist_connector(session, connector_id) + + status = str(result.get('status', 'disconnected')) + reason = result.get('reason') + success = status == 'connected' + payload: dict[str, object] = { + 'success': success, + 'status': status, + 'id': connector_id, + 'message': redact_message(str(reason)) if reason else ('Connector test succeeded.' if success else 'Connector test failed.'), + 'missing_config': status == 'missing_config', + 'required_env_vars': _required_env_vars(connector_id), + 'secretRef': connector.secret_ref, + } + if reason: + payload['reason'] = redact_message(str(reason)) + return payload diff --git a/backend/app/api/routes_containers.py b/backend/app/api/routes_containers.py new file mode 100644 index 0000000..c09fd07 --- /dev/null +++ b/backend/app/api/routes_containers.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message +from ..db.session import get_session +from ..services.setup_service import apply_stored_connector_settings + +router = APIRouter(prefix='/api/containers') + + +def _raise_connector_error(response: dict): + status = str(response.get('status', 'error')) + reason = redact_message(str(response.get('reason', 'connection_failed'))) + status_code = 503 if status == 'missing_config' else 502 + raise HTTPException( + status_code=status_code, + detail={ + 'status': status, + 'reason': reason, + 'connector': 'portainer', + }, + ) + + +@router.get('') +def list_containers( + include_stats: bool = Query(False), + stats_limit: int = Query(200, ge=1, le=500), + session: Session = Depends(get_session), +): + apply_stored_connector_settings(session) + response = PortainerConnector().list_containers(include_stats=include_stats, stats_limit=stats_limit) + if response.get('status') == 'ok': + return response.get('containers', []) + + _raise_connector_error(response) + + +@router.get('/{container_name}') +def get_container(container_name: str, session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + response = PortainerConnector().get_container_status(container_name) + status = response.get('status') + if status == 'ok': + return response.get('container') + + if status == 'not_found': + return None + + _raise_connector_error(response) + + +@router.get('/{container_name}/logs') +def get_container_logs(container_name: str, session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + response = PortainerConnector().get_container_logs(container_name) + return { + 'container': response.get('container', container_name), + 'status': response.get('status', 'unknown'), + 'reason': redact_message(str(response.get('reason', ''))) if response.get('reason') else None, + 'logs': [ + redact_message(str(line)) + for line in response.get('logs', []) + ], + } diff --git a/backend/app/api/routes_deluge.py b/backend/app/api/routes_deluge.py new file mode 100644 index 0000000..1dca586 --- /dev/null +++ b/backend/app/api/routes_deluge.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.deluge import DelugeConnector +from ..db.session import get_session +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.job_runner import complete_job, create_job, set_job_progress, start_job +from ..services.policy_engine import evaluate_policy +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/deluge', dependencies=[Depends(load_connector_runtime)]) + + +_PAUSE_STEPS = [ + 'Validate torrent id', + 'Evaluate policy', + 'Check Deluge health/session', + 'Pause torrent', + 'Fetch torrent status', + 'Complete audit/job', +] + +_RESUME_STEPS = [ + 'Validate torrent id', + 'Evaluate policy', + 'Check Deluge health/session', + 'Resume torrent', + 'Fetch torrent status', + 'Complete audit/job', +] + +_BULK_STEPS = [ + 'Validate action scope', + 'Evaluate policy', + 'Require operator approval', + 'Call Deluge bulk operation', + 'Complete audit/job', +] + + +@router.get('/health') +def health(): + return DelugeConnector().health() + + +@router.get('/session') +def session_status(): + return DelugeConnector().session_status() + + +@router.get('/torrents') +def torrents(): + return DelugeConnector().torrent_status() + + +@router.get('/active') +def active(): + return DelugeConnector().active_downloads() + + +@router.get('/stalled') +def stalled(): + return DelugeConnector().stalled_downloads() + + +@router.get('/paused') +def paused(): + return DelugeConnector().paused_downloads() + + +@router.get('/speeds') +def speeds(): + return DelugeConnector().speed_status() + + +@router.post('/torrents/{torrent_id}/pause') +def pause_torrent(torrent_id: str, payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_deluge_action( + session=session, + payload=payload or {}, + action='deluge.pause_torrent', + steps=_PAUSE_STEPS, + connector_call=lambda connector: connector.pause_torrent(torrent_id), + default_success_message='torrent_paused', + metadata={'torrentId': torrent_id}, + ) + + +@router.post('/torrents/{torrent_id}/resume') +def resume_torrent(torrent_id: str, payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_deluge_action( + session=session, + payload=payload or {}, + action='deluge.resume_torrent', + steps=_RESUME_STEPS, + connector_call=lambda connector: connector.resume_torrent(torrent_id), + default_success_message='torrent_resumed', + metadata={'torrentId': torrent_id}, + ) + + +@router.post('/pause-all') +def pause_all(payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_deluge_action( + session=session, + payload=payload or {}, + action='deluge.pause_all', + steps=_BULK_STEPS, + connector_call=lambda connector: connector.pause_all(), + default_success_message='all_torrents_paused', + metadata={'scope': 'all'}, + ) + + +@router.post('/resume-all') +def resume_all(payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_deluge_action( + session=session, + payload=payload or {}, + action='deluge.resume_all', + steps=_BULK_STEPS, + connector_call=lambda connector: connector.resume_all(), + default_success_message='all_torrents_resumed', + metadata={'scope': 'all'}, + ) + + +def _execute_deluge_action( + *, + session: Session, + payload: dict[str, Any], + action: str, + steps: list[str], + connector_call: Callable[[DelugeConnector], dict[str, Any]], + default_success_message: str, + metadata: dict[str, Any], +) -> dict[str, Any]: + target = 'deluge' + source = str(payload.get('source', 'manual')).strip() or 'manual' + policy = evaluate_policy(session=session, action=action, target=target, source=source) + risk = str(policy.get('risk', 'medium')) + + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + execution_plan={'plan': steps, 'policyReason': policy['reason']}, + redacted_metadata=metadata, + ) + return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id} + + if policy['decision'] == 'approval_required': + approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + execution_plan={'plan': steps, 'approvalId': approval.id}, + redacted_metadata={**metadata, 'approval_id': approval.id}, + ) + return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id} + + job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps) + start_job(session, job) + set_job_progress(session, job, 35, 'Policy allowed; calling Deluge connector.') + + connector_result = connector_call(DelugeConnector()) + success = bool(connector_result.get('success')) + message = str(connector_result.get('message', default_success_message if success else 'deluge_failed')) + completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if success else 'failed', + execution_plan={'plan': steps, 'jobId': completed.id}, + after_state={'status': connector_result.get('status'), 'message': message}, + redacted_metadata={**metadata, 'connector': 'deluge'}, + ) + + if not success: + return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')} + + return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})} diff --git a/backend/app/api/routes_deployments.py b/backend/app/api/routes_deployments.py new file mode 100644 index 0000000..62f097d --- /dev/null +++ b/backend/app/api/routes_deployments.py @@ -0,0 +1,140 @@ +"""Operator API for Deployment Control Plane V1. + +All mutations accept a typed plan and an idempotency key. There is no endpoint +for command execution, Compose upload, arbitrary host paths or raw secrets. +""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session + +from ..core.config import settings +from ..core.security import require_operator_request +from ..db.session import get_session +from ..schemas.deployment import DeploymentPlanV1 +from ..services.deployment_control_plane import ( + DeploymentControlError, + DeploymentControlPlane, +) +from ..services.deployment_broker import DeploymentBrokerClient + + +def _require_deployment_operator(request: Request) -> str: + if not settings.operator_auth_required: + raise HTTPException( + status_code=503, + detail="Deployment Control Plane requires the configured operator authentication boundary.", + ) + return require_operator_request(request) + + +router = APIRouter( + prefix="/api/deployments", + tags=["deployments"], + dependencies=[Depends(_require_deployment_operator)], +) + + +class DeploymentRequestV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + plan: DeploymentPlanV1 + operation: Literal["preflight", "apply", "rollback"] = "preflight" + + +class WorkMeshPromotionPreflightRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + revision: str = Field(pattern=r"^[0-9a-f]{40}$") + + +DeploymentSession = Annotated[Session, Depends(get_session)] +DeploymentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")] +DeploymentLimit = Annotated[int, Query(ge=1, le=250)] + + +def _idempotency_header(value: str | None) -> str: + if not value: + raise HTTPException(status_code=400, detail="Idempotency-Key is required for deployment operations.") + return value.strip() + + +@router.post("") +def request_deployment( + payload: DeploymentRequestV1, + session: DeploymentSession, + idempotency_key: DeploymentIdempotencyKey = None, +): + try: + return DeploymentControlPlane().execute( + session, + plan=payload.plan, + idempotency_key=_idempotency_header(idempotency_key), + operation=payload.operation, + ) + except DeploymentControlError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.post("/workmesh/promotions/preflight") +def preflight_workmesh_promotion( + payload: WorkMeshPromotionPreflightRequest, + session: DeploymentSession, + idempotency_key: DeploymentIdempotencyKey = None, +): + from ..services.workmesh_promotion import ( + WorkMeshPromotionError, + WorkMeshPromotionService, + ) + + try: + return WorkMeshPromotionService().preflight( + session, + revision=payload.revision, + idempotency_key=_idempotency_header(idempotency_key), + ) + except WorkMeshPromotionError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get("/workmesh/promotions/{promotion_id}") +def get_workmesh_promotion(promotion_id: str, session: DeploymentSession): + from ..services.workmesh_promotion import WorkMeshPromotionService + + result = WorkMeshPromotionService().refresh(session, promotion_id) + if result is None: + raise HTTPException(status_code=404, detail="WorkMesh promotion not found.") + return result + + +@router.get("/workmesh/promotions") +def list_workmesh_promotions( + session: DeploymentSession, + limit: DeploymentLimit = 20, +): + from ..services.workmesh_promotion import WorkMeshPromotionService + + return {"items": WorkMeshPromotionService.list_promotions(session, limit=limit)} + + +@router.get("") +def list_deployments(session: DeploymentSession, limit: DeploymentLimit = 100): + return {"items": DeploymentControlPlane().list_jobs(session, limit=limit)} + + +@router.get("/delivery-status") +def get_delivery_status(): + """Expose the controller's bounded, secret-free runnerless delivery view.""" + return DeploymentBrokerClient().delivery_status() + + +@router.get("/{job_id}") +def get_deployment(job_id: str, session: DeploymentSession): + try: + return DeploymentControlPlane().refresh(session, job_id) + except DeploymentControlError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/app/api/routes_enrollments.py b/backend/app/api/routes_enrollments.py new file mode 100644 index 0000000..6f4e295 --- /dev/null +++ b/backend/app/api/routes_enrollments.py @@ -0,0 +1,102 @@ +"""Operator API for the bounded Enrollment & Provisioning Plane v1.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request +from pydantic import BaseModel, ConfigDict +from sqlmodel import Session + +from ..core.config import settings +from ..core.security import require_operator_request +from ..db.session import get_session +from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1 +from ..services.enrollment_plane import EnrollmentError, EnrollmentPlane +from ..services.repopulse_adoption_journal import ( + RepoPulseAdoptionJournalError, + get_repopulse_adoption_status, +) + + +def _require_operator(request: Request) -> str: + if not settings.operator_auth_required: + raise HTTPException(status_code=503, detail="Enrollment requires the configured operator authentication boundary.") + return require_operator_request(request) + + +router = APIRouter( + prefix="/api/enrollments", + tags=["enrollments"], + dependencies=[Depends(_require_operator)], +) + + +class EnrollmentRequestV1(BaseModel): + model_config = ConfigDict(extra="forbid") + + plan: EnrollmentPlanV1 + operation: Literal["preflight", "enroll"] = "preflight" + + +EnrollmentSession = Annotated[Session, Depends(get_session)] +EnrollmentIdempotencyKey = Annotated[str | None, Header(alias="Idempotency-Key")] +EnrollmentLimit = Annotated[int, Query(ge=1, le=250)] + + +def _idempotency(value: str | None) -> str: + if not value: + raise HTTPException(status_code=400, detail="Idempotency-Key is required for enrollment mutations.") + return value.strip() + + +@router.get("/capabilities") +def enrollment_capabilities(session: EnrollmentSession): + return EnrollmentPlane().capabilities(session) + + +@router.get("/trust") +def enrollment_trust_status(session: EnrollmentSession): + return EnrollmentPlane.trust_status(session) + + +@router.put("/trust") +def update_enrollment_trust(payload: EnrollmentTrustConfigV1, session: EnrollmentSession): + return EnrollmentPlane.set_trust_config(session, payload) + + +@router.post("") +def request_enrollment( + payload: EnrollmentRequestV1, + session: EnrollmentSession, + idempotency_key: EnrollmentIdempotencyKey = None, +): + plane = EnrollmentPlane() + try: + if payload.operation == "preflight": + return plane.preflight(session, payload.plan) + return plane.enroll(session, payload.plan, idempotency_key=_idempotency(idempotency_key)) + except EnrollmentError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@router.get("") +def list_enrollments(session: EnrollmentSession, limit: EnrollmentLimit = 100): + return {"items": EnrollmentPlane().list_enrollments(session, limit=limit)} + + +@router.get("/repopulse/adoption") +def repopulse_adoption_status(session: EnrollmentSession): + """Read the sole fixed migration journal through the operator boundary.""" + try: + return get_repopulse_adoption_status(session) + except RepoPulseAdoptionJournalError as exc: + raise HTTPException(status_code=503, detail=exc.code) from None + + +@router.get("/{enrollment_id}") +def get_enrollment(enrollment_id: str, session: EnrollmentSession): + try: + return EnrollmentPlane().get_enrollment(session, enrollment_id) + except EnrollmentError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/backend/app/api/routes_gitea_actions.py b/backend/app/api/routes_gitea_actions.py new file mode 100644 index 0000000..9377fa4 --- /dev/null +++ b/backend/app/api/routes_gitea_actions.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.gitea_actions import GiteaActionsConnector +from ..db.session import get_session +from ..services.gitea_project_auto_registration import GiteaProjectAutoRegistration +from ..services.runner_fleet import RunnerFleetService + +router = APIRouter(prefix="/api/gitea/actions", tags=["gitea-actions"]) + + +@router.get("/status") +def gitea_actions_status(): + """Return bounded runner and queue state without exposing the admin credential.""" + + return GiteaActionsConnector().snapshot() + + +@router.get("/fleet") +def gitea_actions_fleet(): + """Return the joined, secret-free CI runner fleet projection.""" + + return RunnerFleetService().snapshot() + + +@router.get("/projects") +def gitea_project_catalog(session: Session = Depends(get_session)): + """Return the persisted owned-project registration catalog.""" + + return GiteaProjectAutoRegistration.catalog(session) + + +@router.post("/projects/reconcile") +def reconcile_gitea_project_catalog(session: Session = Depends(get_session)): + """Refresh all owned Gitea projects and safely activate proven deploy targets.""" + + return GiteaProjectAutoRegistration().sync(session, force=True) diff --git a/backend/app/api/routes_health.py b/backend/app/api/routes_health.py new file mode 100644 index 0000000..88b5df3 --- /dev/null +++ b/backend/app/api/routes_health.py @@ -0,0 +1,7 @@ +from fastapi import APIRouter + +router = APIRouter() + +@router.get('/api/health') +def health(): + return {'status': 'ok'} diff --git a/backend/app/api/routes_integrations.py b/backend/app/api/routes_integrations.py new file mode 100644 index 0000000..c259c50 --- /dev/null +++ b/backend/app/api/routes_integrations.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session + +from ..core.config import settings +from ..core.security import n8n_auth, operator_auth +from ..db.session import get_session +from ..mcp_server import runtime_status as mcp_runtime_status +from ..services import knowledge_service, n8n_integration + +router = APIRouter(prefix="/api/integrations") +n8n_router = APIRouter(prefix="/api/integrations/n8n", dependencies=[Depends(n8n_auth)]) + + +class PreflightRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + action: str = Field(min_length=1, max_length=160) + target: str = Field(min_length=1, max_length=240) + + +class ApprovalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + action: str = Field(min_length=1, max_length=160) + target: str = Field(min_length=1, max_length=240) + reason: str = Field(min_length=3, max_length=1000) + plan: list[str] = Field(default_factory=list, max_length=30) + + +class KnowledgeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + question: str = Field(min_length=2, max_length=500) + limit: int = Field(default=6, ge=1, le=10) + + +class HeartbeatRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + workflowId: str = Field(min_length=1, max_length=120) + workflowVersion: str = Field(default="1", max_length=40) + branch: str = Field(default="unknown", max_length=40) + processedEvents: int = Field(default=0, ge=0, le=100_000) + backlogRemaining: bool = False + confidence: str = Field(default="", max_length=20) + + +@router.get("/status", dependencies=[Depends(operator_auth)]) +def status(session: Session = Depends(get_session)): + rag = knowledge_service.index_status(session) + mcp_runtime = mcp_runtime_status() + n8n_runtime = n8n_integration.heartbeat_status(session) + return { + "mcp": { + "enabled": settings.mcp_enabled, + "authenticated": bool(settings.mcp_token), + "transport": "streamable_http", + "endpoint": "/mcp", + "scope": "governed_operations", + "canRequestApproval": True, + "canExecuteApproved": True, + "requiresHumanApproval": True, + "maxConcurrentExecutions": 1, + **mcp_runtime, + }, + "rag": rag, + "n8n": { + "enabled": settings.n8n_enabled, + "authenticated": bool(settings.n8n_token), + "endpoint": "/api/integrations/n8n", + "canExecute": False, + "capabilities": [ + "event_feed", + "knowledge_query", + "preflight", + "create_approval", + "read_approval", + "read_job", + ], + **n8n_runtime, + }, + } + + +@n8n_router.post("/heartbeat") +def heartbeat(payload: HeartbeatRequest, session: Session = Depends(get_session)): + return n8n_integration.record_heartbeat(session, payload.model_dump()) + + +@n8n_router.get("/events") +def events( + cursor: str | None = Query(default=None, max_length=600), + limit: int = Query(default=100, ge=1, le=250), + session: Session = Depends(get_session), +): + try: + return n8n_integration.event_feed(session, cursor=cursor, limit=limit) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@n8n_router.post("/knowledge/query") +def knowledge_query(payload: KnowledgeRequest, session: Session = Depends(get_session)): + try: + return knowledge_service.query(session, payload.question, limit=payload.limit) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@n8n_router.post("/preflight") +def preflight(payload: PreflightRequest, session: Session = Depends(get_session)): + return n8n_integration.preflight(session, payload.action, payload.target) + + +@n8n_router.post("/approvals") +def create_approval( + payload: ApprovalRequest, + idempotency_key: str = Header( + alias="Idempotency-Key", min_length=16, max_length=128 + ), + session: Session = Depends(get_session), +): + try: + return n8n_integration.request_approval( + session, + action=payload.action, + target=payload.target, + reason=payload.reason, + plan=payload.plan, + idempotency_key=idempotency_key, + ) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + +@n8n_router.get("/approvals/{approval_id}") +def approval(approval_id: str, session: Session = Depends(get_session)): + result = n8n_integration.approval_status(session, approval_id) + if not result: + raise HTTPException(status_code=404, detail="approval_not_found") + return result + + +@n8n_router.get("/jobs/{job_id}") +def job(job_id: str, session: Session = Depends(get_session)): + result = n8n_integration.job_status(session, job_id) + if not result: + raise HTTPException(status_code=404, detail="job_not_found") + return result diff --git a/backend/app/api/routes_inventory.py b/backend/app/api/routes_inventory.py new file mode 100644 index 0000000..0265044 --- /dev/null +++ b/backend/app/api/routes_inventory.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session + +from ..db.session import get_session +from ..services.inventory_discovery import ( + calculate_drift, + acknowledge_drift, + complete_app_review, + create_drift_incident, + get_run_detail, + ignore_discovered_container, + import_discovered_container, + list_discovered, + list_inventory_apps, + list_inventory_containers, + list_mappings, + list_runs, + list_suggestions, + list_unknown, + list_unmapped, + map_discovered_to_existing_app, + prune_historical_discovery_rows, + run_discovery, + sync_app_connector_statuses, + update_discovered_review_metadata, + update_inventory_baseline, +) + +router = APIRouter(prefix='/api/inventory') + + +@router.post('/discover') +def discover(session: Session = Depends(get_session)): + return run_discovery(session=session) + + +@router.get('/runs') +def runs( + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + session: Session = Depends(get_session), +): + return list_runs(session, limit=limit, offset=offset) + + +@router.get('/runs/{run_id}') +def run_detail(run_id: str, session: Session = Depends(get_session)): + result = get_run_detail(session, run_id) + if not result: + return {'success': False, 'message': 'discovery_run_not_found', 'id': run_id} + return result + + +@router.get('/discovered-containers') +def discovered_containers(session: Session = Depends(get_session)): + return list_discovered(session) + + +@router.get('/containers') +def inventory_containers(include_ignored: bool = True, session: Session = Depends(get_session)): + return list_inventory_containers(session, include_ignored=include_ignored) + + +@router.get('/apps') +def inventory_apps(include_unmanaged: bool = True, include_ignored: bool = False, session: Session = Depends(get_session)): + return list_inventory_apps(session, include_unmanaged=include_unmanaged, include_ignored=include_ignored) + + +@router.get('/suggestions') +def suggestions(session: Session = Depends(get_session)): + return list_suggestions(session) + + +@router.get('/unmapped') +def unmapped(include_ignored: bool = False, session: Session = Depends(get_session)): + return list_unmapped(session, include_ignored=include_ignored) + + +@router.get('/unknown') +def unknown(include_ignored: bool = False, session: Session = Depends(get_session)): + return list_unknown(session, include_ignored=include_ignored) + + +@router.get('/mappings') +def mappings(session: Session = Depends(get_session)): + return list_mappings(session) + + +@router.get('/drift') +def drift(session: Session = Depends(get_session)): + return calculate_drift(session) + + +@router.post('/drift/{drift_id}/acknowledge') +def acknowledge_inventory_drift(drift_id: str, session: Session = Depends(get_session)): + return acknowledge_drift(session, drift_id) + + +@router.post('/drift/{drift_id}/incident') +def create_inventory_drift_incident(drift_id: str, session: Session = Depends(get_session)): + return create_drift_incident(session, drift_id) + + +@router.post('/import') +def import_container(payload: dict, session: Session = Depends(get_session)): + discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '') + return import_discovered_container(session, discovered_id) + + +@router.post('/ignore') +def ignore_container(payload: dict, session: Session = Depends(get_session)): + discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '') + return ignore_discovered_container(session, discovered_id) + + +@router.post('/review/update') +def update_review_metadata(payload: dict, session: Session = Depends(get_session)): + discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '') + return update_discovered_review_metadata(session, discovered_id, payload) + + +@router.post('/map-existing') +def map_existing(payload: dict, session: Session = Depends(get_session)): + discovered_id = str(payload.get('discoveredContainerId') or payload.get('id') or '') + app_id = str(payload.get('appId') or '') + return map_discovered_to_existing_app(session, discovered_id, app_id) + + +@router.post('/review/complete') +def complete_review(payload: dict | None = None, session: Session = Depends(get_session)): + app_ids = payload.get('appIds') if isinstance(payload, dict) else None + if not isinstance(app_ids, list): + app_ids = None + return complete_app_review(session, app_ids=[str(item) for item in app_ids] if app_ids else None) + + +@router.post('/maintenance/sync-status') +def sync_status(session: Session = Depends(get_session)): + return sync_app_connector_statuses(session) + + +@router.post('/maintenance/prune-discovery-history') +def prune_discovery_history(session: Session = Depends(get_session)): + return prune_historical_discovery_rows(session) + + +@router.post('/baseline/update') +def update_baseline(session: Session = Depends(get_session)): + return update_inventory_baseline(session) diff --git a/backend/app/api/routes_jobs.py b/backend/app/api/routes_jobs.py new file mode 100644 index 0000000..e41838c --- /dev/null +++ b/backend/app/api/routes_jobs.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..db.session import get_session +from ..services.audit_logger import log_event +from ..services.job_runner import cancel_job, get_job, list_jobs + +router = APIRouter(prefix='/api/jobs') + + +@router.get('') +def list_all(session=Depends(get_session)): + return list_jobs(session) + + +@router.get('/{job_id}') +def get_single(job_id: str, session=Depends(get_session)): + job = get_job(session, job_id) + if not job: + return {'success': False, 'message': f'Job {job_id} not found'} + return job + + +@router.post('/{job_id}/cancel') +def cancel(job_id: str, session=Depends(get_session)): + job = get_job(session, job_id) + if not job: + return {'success': False, 'message': 'Job not found', 'id': job_id} + + from ..services.privatemuse_deployment_recovery import ( + privatemuse_recovery_job_cancellation_reason, + ) + + cancellation_reason = privatemuse_recovery_job_cancellation_reason(session, job) + if not cancellation_reason: + from ..services.workmesh_first_deployment import ( + workmesh_first_deploy_job_cancellation_reason, + ) + + cancellation_reason = workmesh_first_deploy_job_cancellation_reason(session, job) + if cancellation_reason: + return { + 'success': False, + 'message': cancellation_reason, + 'id': job.id, + 'status': job.status, + } + + if job.status in {'succeeded', 'failed', 'cancelled'}: + return {'success': False, 'message': f'Job already {job.status}', 'id': job.id} + + previous_status = job.status + updated = cancel_job(session, job) + audit = log_event( + session, + source='manual', + identity='operator', + action_id='job.cancel', + target=updated.id, + target_type='job', + risk='low', + policy_decision='allowed', + reason='Operator cancelled an AppOps job record.', + result='success', + before_state={'status': previous_status}, + after_state={'status': updated.status}, + redacted_metadata={'jobId': updated.id, 'actionId': updated.action_id}, + ) + return {'success': True, 'id': updated.id, 'status': updated.status, 'auditId': audit.id} diff --git a/backend/app/api/routes_knowledge.py b/backend/app/api/routes_knowledge.py new file mode 100644 index 0000000..c2aeb97 --- /dev/null +++ b/backend/app/api/routes_knowledge.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session + +from ..core.security import operator_auth +from ..db.session import get_session +from ..services import knowledge_service + + +router = APIRouter(prefix="/api/ai/rag", dependencies=[Depends(operator_auth)]) + + +class RagQueryRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + question: str = Field(min_length=2, max_length=500) + limit: int = Field(default=6, ge=1, le=10) + + +@router.get("/status") +def status(session: Session = Depends(get_session)): + return knowledge_service.index_status(session) + + +@router.post("/reindex") +def reindex(session: Session = Depends(get_session)): + return knowledge_service.rebuild_index(session) + + +@router.post("/query") +def query(payload: RagQueryRequest, session: Session = Depends(get_session)): + try: + return knowledge_service.query(session, payload.question, limit=payload.limit) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("/evaluate") +def evaluate(session: Session = Depends(get_session)): + return knowledge_service.evaluate_retrieval(session) diff --git a/backend/app/api/routes_media_pipeline.py b/backend/app/api/routes_media_pipeline.py new file mode 100644 index 0000000..cc8ef8f --- /dev/null +++ b/backend/app/api/routes_media_pipeline.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..db.session import get_session +from ..services import media_pipeline_service +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/media-pipeline', dependencies=[Depends(load_connector_runtime)]) + + +@router.get('/summary') +def summary(refresh: bool = False, session: Session = Depends(get_session)): + return media_pipeline_service.summary(session, force_refresh=refresh) + + +@router.get('/health') +def health(refresh: bool = False, session: Session = Depends(get_session)): + return media_pipeline_service.summary(session, force_refresh=refresh) + + +@router.get('/flow') +def flow(session: Session = Depends(get_session)): + return media_pipeline_service.flow(session) + + +@router.get('/issues') +def issues(session: Session = Depends(get_session)): + return media_pipeline_service.issues(session) + + +@router.get('/downloads') +def downloads(session: Session = Depends(get_session)): + return media_pipeline_service.downloads(session) + + +@router.get('/library') +def library(session: Session = Depends(get_session)): + return media_pipeline_service.library(session) + + +@router.get('/activity') +def activity(): + return media_pipeline_service.activity() + + +@router.get('/recommendations') +def recommendations(session: Session = Depends(get_session)): + return media_pipeline_service.recommendations(session) + + +@router.post('/check') +def check(session: Session = Depends(get_session)): + return media_pipeline_service.check(session) + + +@router.post('/diagnose') +def diagnose(payload: dict | None = None, session: Session = Depends(get_session)): + return media_pipeline_service.diagnose(session, payload or {}) diff --git a/backend/app/api/routes_nginx_proxy_manager.py b/backend/app/api/routes_nginx_proxy_manager.py new file mode 100644 index 0000000..794eb54 --- /dev/null +++ b/backend/app/api/routes_nginx_proxy_manager.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/nginx-proxy-manager', dependencies=[Depends(load_connector_runtime)]) +alias_router = APIRouter(prefix='/api/apps/npm', dependencies=[Depends(load_connector_runtime)]) + + +def _shape(payload: dict[str, Any]) -> dict[str, Any]: + data = payload.get('data') + if not isinstance(data, dict): + data = {key: value for key, value in payload.items() if key not in {'success', 'status', 'message'}} + return { + **payload, + 'success': bool(payload.get('success')), + 'status': str(payload.get('status', 'ok')), + 'message': str(payload.get('message', 'ok')), + 'data': data, + } + + +def _connector() -> NginxProxyManagerConnector: + return NginxProxyManagerConnector() + + +@router.get('/health') +def health(): + return _shape(_connector().health()) + + +@router.get('/proxy-hosts') +def proxy_hosts(): + return _shape(_connector().proxy_hosts()) + + +@router.get('/certificates') +def certificates(): + return _shape(_connector().certificates()) + + +@router.get('/certificates/expiring') +def expiring_certificates(days: int = 30): + return _shape(_connector().expiring_certificates(days)) + + +@router.get('/access-lists') +def access_lists(): + return _shape(_connector().access_lists()) + + +@router.get('/exposure-summary') +def exposure_summary(): + return _shape(_connector().exposure_summary()) + + +@router.get('/host-reachability') +def host_reachability(): + return _shape(_connector().host_reachability()) + + +@router.get('/security-warnings') +def security_warnings(): + return _shape(_connector().security_warnings()) + + +@alias_router.get('/health') +def alias_health(): + return health() + + +@alias_router.get('/proxy-hosts') +def alias_proxy_hosts(): + return proxy_hosts() + + +@alias_router.get('/certificates') +def alias_certificates(): + return certificates() + + +@alias_router.get('/certificates/expiring') +def alias_expiring_certificates(days: int = 30): + return expiring_certificates(days) + + +@alias_router.get('/access-lists') +def alias_access_lists(): + return access_lists() + + +@alias_router.get('/exposure-summary') +def alias_exposure_summary(): + return exposure_summary() + + +@alias_router.get('/host-reachability') +def alias_host_reachability(): + return host_reachability() + + +@alias_router.get('/security-warnings') +def alias_security_warnings(): + return security_warnings() diff --git a/backend/app/api/routes_notifications.py b/backend/app/api/routes_notifications.py new file mode 100644 index 0000000..08fd7c9 --- /dev/null +++ b/backend/app/api/routes_notifications.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session + +from ..db.session import get_session +from ..services import notification_service + +router = APIRouter(prefix='/api/notifications') + + +@router.get('/rules') +def rules(session: Session = Depends(get_session)): + return notification_service.list_rules(session) + + +@router.post('/rules') +def create_rule(payload: dict, session: Session = Depends(get_session)): + return notification_service.create_rule(session, payload) + + +@router.patch('/rules/{rule_id}') +def update_rule(rule_id: str, payload: dict, session: Session = Depends(get_session)): + result = notification_service.update_rule(session, rule_id, payload) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='rule_not_found') + return result + + +@router.delete('/rules/{rule_id}') +def delete_rule(rule_id: str, session: Session = Depends(get_session)): + result = notification_service.delete_rule(session, rule_id) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='rule_not_found') + return result + + +@router.get('/events') +def events( + limit: int = Query(default=250, ge=1, le=500), + offset: int = Query(default=0, ge=0), + include_dismissed: bool = Query(default=True), + session: Session = Depends(get_session), +): + return notification_service.list_events( + session, + include_dismissed=include_dismissed, + limit=limit, + offset=offset, + ) + + +@router.get('/events/unread') +def unread_events( + limit: int = Query(default=250, ge=1, le=500), + offset: int = Query(default=0, ge=0), + session: Session = Depends(get_session), +): + return notification_service.unread_events(session, limit=limit, offset=offset) + + +@router.post('/events/{event_id}/dismiss') +def dismiss_event(event_id: str, session: Session = Depends(get_session)): + result = notification_service.dismiss_event(session, event_id) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='event_not_found') + return result + + +@router.post('/events/{event_id}/mark-read') +def mark_read(event_id: str, session: Session = Depends(get_session)): + result = notification_service.mark_read(session, event_id) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='event_not_found') + return result + + +@router.post('/events/mark-all-read') +def mark_all_read(session: Session = Depends(get_session)): + return notification_service.mark_all_read(session) + + +@router.get('/channels') +def channels(session: Session = Depends(get_session)): + return notification_service.list_channels(session) + + +@router.post('/channels') +def create_channel(payload: dict, session: Session = Depends(get_session)): + return notification_service.create_channel(session, payload) + + +@router.patch('/channels/{channel_id}') +def update_channel(channel_id: str, payload: dict, session: Session = Depends(get_session)): + result = notification_service.update_channel(session, channel_id, payload) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='channel_not_found') + return result + + +@router.post('/channels/{channel_id}/test') +def test_channel(channel_id: str, session: Session = Depends(get_session)): + result = notification_service.test_channel(session, channel_id) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='channel_not_found') + return result + + +@router.post('/channels/{channel_id}/test-connection') +def test_channel_connection(channel_id: str, session: Session = Depends(get_session)): + result = notification_service.test_channel_connection(session, channel_id) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='channel_not_found') + return result + + +@router.post('/evaluate') +def evaluate(session: Session = Depends(get_session)): + return notification_service.evaluate_source(session, 'all') + + +@router.post('/evaluate/safety') +def evaluate_safety(session: Session = Depends(get_session)): + return notification_service.evaluate_source(session, 'safety') + + +@router.post('/evaluate/media') +def evaluate_media(session: Session = Depends(get_session)): + return notification_service.evaluate_source(session, 'media') + + +@router.post('/evaluate/connectors') +def evaluate_connectors(session: Session = Depends(get_session)): + return notification_service.evaluate_source(session, 'connectors') + + +@router.get('/summary') +def summary(session: Session = Depends(get_session)): + return notification_service.summary(session) diff --git a/backend/app/api/routes_observability.py b/backend/app/api/routes_observability.py new file mode 100644 index 0000000..bcc8e76 --- /dev/null +++ b/backend/app/api/routes_observability.py @@ -0,0 +1,633 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +import hashlib +import json +import os +from typing import Any + +from fastapi import APIRouter, Depends +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.portainer import PortainerConnector +from ..connectors.unraid import UnraidConnector +from ..core.config import settings +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import AppSetting, AuditEvent, Connector, JobRun, Policy, Secret +from ..db.session import get_session +from ..services.incident_service import list_incidents as list_live_incidents, update_incident_status as update_live_incident_status +from ..services.app_registry import list_active_apps +from ..services.setup_service import apply_stored_connector_settings + +router = APIRouter(prefix='/api') + +NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION = 1 +NETWORK_EXPOSURE_CACHE_KEY = 'network_exposure:cache' +NETWORK_EXPOSURE_CACHE_TTL_SECONDS = 60 +NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS = 300 +STATISTICS_HISTORY_MAX_POINTS = 48 +_STATISTICS_HISTORY: list[dict[str, Any]] = [] + + +@router.get('/incidents') +def list_incidents(session: Session = Depends(get_session)): + return list_live_incidents(session) + + +@router.post('/incidents/{incident_id}/status') +def update_incident_status(incident_id: str, payload: dict | None = None, session: Session = Depends(get_session)): + status = str(payload.get('status') or 'acknowledged') if isinstance(payload, dict) else 'acknowledged' + return update_live_incident_status(session, incident_id, status) + + +@router.get('/operations-map') +def operations_map(session: Session = Depends(get_session)): + apps = {app.id: app for app in list_active_apps(session, order_by='id')} + connectors = session.exec(select(Connector).order_by(Connector.id)).all() + policies = session.exec(select(Policy).order_by(Policy.id)).all() + secrets_count = len(session.exec(select(Secret.id)).all()) + generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + nodes: set[str] = {'Connector Registry', 'Policy Engine', 'Secret Store'} + edges: set[tuple[str, str]] = {('Secret Store', 'Connector Registry')} + + for connector in connectors: + app = apps.get(connector.id) + if app is None: + continue + nodes.add(app.name) + edges.add(('Connector Registry', app.name)) + if connector.secret_ref: + edges.add(('Secret Store', app.name)) + + for policy in policies: + if policy.target == '*': + continue + app = apps.get(policy.target) + if app is None: + continue + nodes.add(app.name) + edges.add(('Policy Engine', app.name)) + + return { + 'generatedAt': generated_at, + 'sources': { + 'apps': {'status': 'ok', 'count': len(apps)}, + 'connectors': {'status': 'ok', 'count': len(connectors)}, + 'policies': {'status': 'ok', 'count': len(policies)}, + 'secrets': {'status': 'metadata_only', 'count': secrets_count}, + }, + 'nodes': sorted(nodes), + 'edges': [ + {'from': source, 'to': target} + for source, target in sorted(edges) + if source in nodes and target in nodes + ], + } + + +def _nullable_int(value: Any) -> int | None: + if value is None or isinstance(value, bool): + return None + if isinstance(value, int | float): + return int(round(float(value))) + if isinstance(value, str): + stripped = value.strip().strip('%') + if not stripped: + return None + try: + return int(round(float(stripped))) + except ValueError: + return None + return None + + +def _record_statistics_sample(generated_at: str, cpu_usage: int | None, ram_usage: int | None) -> list[dict[str, Any]]: + if cpu_usage is None and ram_usage is None: + return list(_STATISTICS_HISTORY) + + _STATISTICS_HISTORY.append({'time': generated_at, 'cpu': cpu_usage, 'ram': ram_usage}) + if len(_STATISTICS_HISTORY) > STATISTICS_HISTORY_MAX_POINTS: + del _STATISTICS_HISTORY[:-STATISTICS_HISTORY_MAX_POINTS] + return list(_STATISTICS_HISTORY) + + +@router.get('/statistics/summary') +def statistics_summary(session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + generated_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + metrics = UnraidConnector().get_system_metrics() + cpu_usage = _nullable_int(metrics.get('cpuUsage')) + ram_usage = _nullable_int(metrics.get('ramUsage')) + samples = _record_statistics_sample(generated_at, cpu_usage, ram_usage) + source_warnings = [] + if metrics.get('status') != 'ok': + source_warnings.append({ + 'source': 'unraid', + 'surface': 'metrics', + 'status': metrics.get('status', 'unknown'), + 'reason': redact_message(str(metrics.get('reason', 'metrics_unavailable')))[:240], + }) + + audit_events = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all() + jobs = session.exec(select(JobRun).order_by(JobRun.started_at.desc())).all() + restarts: dict[str, int] = {} + durations_ms: list[int] = [] + for job in jobs: + if job.action_id == 'container.restart': + restarts[job.target] = restarts.get(job.target, 0) + 1 + if job.status == 'succeeded' and job.duration_ms is not None: + durations_ms.append(int(job.duration_ms)) + + blocked_actions = sum( + 1 + for event in audit_events + if event.policy_decision == 'blocked' or event.result == 'blocked' + ) + voice_action_count = sum(1 for event in audit_events if event.source == 'voice' or event.identity == 'voice') + mttr_hours = None + if durations_ms: + mttr_hours = round((sum(durations_ms) / len(durations_ms)) / 1000 / 60 / 60, 2) + + return { + 'generatedAt': generated_at, + 'sources': {'unraid': metrics.get('status', 'unknown'), 'audit': 'ok', 'jobs': 'ok'}, + 'sourceWarnings': source_warnings, + 'cpuHistory': [{'time': sample['time'], 'value': sample.get('cpu')} for sample in samples], + 'ramHistory': [{'time': sample['time'], 'value': sample.get('ram')} for sample in samples], + 'restartsByApp': [ + {'appId': app_id, 'count': count} + for app_id, count in sorted(restarts.items(), key=lambda item: (-item[1], item[0])) + ], + 'blockedActions': blocked_actions, + 'voiceActionCount': voice_action_count, + 'mttrHours': mttr_hours, + } + + +@router.get('/storage/summary') +def storage_summary(session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + unraid = UnraidConnector() + portainer = PortainerConnector() + cache_usage = 0 + storage_pools: list[dict] = [] + shares: list[dict] = [] + disks: list[dict] = [] + app_data_usage: list[dict] = [] + source_warnings: list[dict] = [] + + cache_result = unraid.get_cache_status() + if cache_result.get('status') == 'ok': + cache_usage = int(cache_result.get('cacheUsage', 0) or 0) + storage_pools = [ + { + 'name': str(pool.get('name', 'cache')), + 'status': str(pool.get('status', 'unknown')), + 'healthy': pool.get('healthy'), + 'type': pool.get('type'), + 'usedGiB': pool.get('usedGiB'), + 'sizeGiB': pool.get('sizeGiB'), + 'freeGiB': pool.get('freeGiB'), + 'usagePercent': pool.get('usagePercent'), + } + for pool in cache_result.get('pools', []) + if isinstance(pool, dict) + ] + elif cache_result.get('status') not in {None, 'ok'}: + source_warnings.append({ + 'source': 'unraid', + 'surface': 'cache', + 'status': cache_result.get('status', 'error'), + 'reason': redact_message(str(cache_result.get('reason', 'cache_unavailable')))[:240], + }) + + share_result = unraid.get_share_summary() + if share_result.get('status') == 'ok': + shares = [ + { + 'name': str(share.get('name', 'unknown')), + 'usedGiB': float(share.get('usedGiB', 0) or 0), + 'sizeGiB': float(share.get('sizeGiB', 0) or 0), + 'critical': bool(share.get('critical', False)), + } + for share in share_result.get('shares', []) + if isinstance(share, dict) + ] + elif share_result.get('status') not in {None, 'ok'}: + source_warnings.append({ + 'source': 'unraid', + 'surface': 'shares', + 'status': share_result.get('status', 'error'), + 'reason': redact_message(str(share_result.get('reason', 'shares_unavailable')))[:240], + }) + + disk_result = unraid.get_disk_warnings() + if disk_result.get('status') == 'ok': + disks = [ + { + 'name': str(disk.get('name', 'unknown')), + 'status': str(disk.get('status', 'unknown')), + 'healthy': disk.get('healthy'), + 'type': disk.get('type'), + 'usedGiB': disk.get('usedGiB'), + 'sizeGiB': disk.get('sizeGiB'), + 'freeGiB': disk.get('freeGiB'), + 'temperature': disk.get('temperature'), + } + for disk in disk_result.get('disks', []) + if isinstance(disk, dict) + ] + elif disk_result.get('status') not in {None, 'ok'}: + source_warnings.append({ + 'source': 'unraid', + 'surface': 'disks', + 'status': disk_result.get('status', 'error'), + 'reason': redact_message(str(disk_result.get('reason', 'disks_unavailable')))[:240], + }) + + share_names = { + str(share.get('name', '')).strip('/').lower(): share + for share in shares + if str(share.get('name', '')).strip() + } + + containers_result = portainer.list_containers() + if containers_result.get('status') == 'ok': + seen: set[str] = set() + for container in containers_result.get('containers', []): + if not isinstance(container, dict): + continue + app_id = str(container.get('appId') or container.get('name') or '').strip() + if not app_id: + continue + container_name = str(container.get('name') or app_id).strip() + mounts = container.get('mounts', []) + if not isinstance(mounts, list) or not mounts: + key = f'{app_id}:{container_name}:container' + if key in seen: + continue + seen.add(key) + app_data_usage.append({ + 'appId': app_id, + 'container': container_name, + 'usedGiB': None, + 'volume': container_name, + 'source': None, + 'destination': None, + 'type': 'container', + 'share': None, + 'measurement': 'not_available', + }) + continue + + if isinstance(mounts, list): + for mount in mounts: + if not isinstance(mount, dict): + continue + source = str(mount.get('source') or '').strip() + destination = str(mount.get('destination') or '').strip() + mount_type = str(mount.get('type') or '').strip() or 'unknown' + if not source and not destination: + continue + key = f'{app_id}:{container_name}:{source}:{destination}' + if key in seen: + continue + seen.add(key) + parts = [part for part in source.replace('\\', '/').split('/') if part] + share_name = parts[2] if len(parts) >= 3 and parts[0] == 'mnt' and parts[1] in {'user', 'cache'} else None + share = share_names.get(str(share_name or '').lower()) + app_data_usage.append({ + 'appId': app_id, + 'container': container_name, + 'usedGiB': None, + 'volume': f'{source or mount_type} -> {destination or "container"}', + 'source': source or None, + 'destination': destination or None, + 'type': mount_type, + 'share': share_name, + 'shareUsagePercent': round((float(share.get('usedGiB', 0) or 0) / float(share.get('sizeGiB', 0) or 1)) * 100) if share and float(share.get('sizeGiB', 0) or 0) > 0 else None, + 'measurement': 'mount_discovered', + }) + elif containers_result.get('status') not in {None, 'ok'}: + source_warnings.append({ + 'source': 'portainer', + 'surface': 'containers', + 'status': containers_result.get('status', 'error'), + 'reason': redact_message(str(containers_result.get('reason', 'containers_unavailable')))[:240], + }) + + total_size = sum(float(share.get('sizeGiB', 0) or 0) for share in shares) + total_used = sum(float(share.get('usedGiB', 0) or 0) for share in shares) + array_usage = round((total_used / total_size) * 100) if total_size > 0 else 0 + + return { + 'cacheUsage': cache_usage, + 'arrayUsage': array_usage, + 'disks': disks, + 'storagePools': storage_pools, + 'shares': shares, + 'appDataUsage': app_data_usage, + 'orphanedVolumes': [], + 'forecastDays': None, + 'sources': { + 'unraid': share_result.get('status', cache_result.get('status', 'unknown')), + 'unraidDisks': disk_result.get('status', 'unknown'), + 'portainer': containers_result.get('status', 'unknown'), + }, + 'sourceWarnings': source_warnings, + } + + +def _parse_utc(value: str) -> datetime: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + + +def _network_cache_ttl_seconds() -> int: + raw = os.getenv('NETWORK_EXPOSURE_CACHE_TTL_SECONDS', str(NETWORK_EXPOSURE_CACHE_TTL_SECONDS)) + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return NETWORK_EXPOSURE_CACHE_TTL_SECONDS + + +def _network_cache_max_stale_seconds() -> int: + raw = os.getenv('NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS', str(NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS)) + try: + return max(0, int(raw)) + except (TypeError, ValueError): + return NETWORK_EXPOSURE_CACHE_MAX_STALE_SECONDS + + +def _redact(value): + if isinstance(value, str): + return redact_message(value) + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, dict): + return { + str(key): _redact(item) + for key, item in value.items() + if 'secret' not in str(key).lower() and 'token' not in str(key).lower() and 'password' not in str(key).lower() + } + return value + + +def _network_exposure_signature(session: Session) -> str: + connectors = [ + { + 'id': item.id, + 'status': item.status, + 'lastTest': item.last_test, + 'secretRef': item.secret_ref, + 'capabilities': item.capabilities, + } + for item in session.exec(select(Connector)).all() + if item.id in {'nginx_proxy_manager', 'authentik'} + ] + secret_rows = [ + { + 'id': item.id, + 'name': item.name, + 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, + 'usedBy': item.used_by_json, + } + for item in session.exec(select(Secret)).all() + if any(connector_id in item.used_by_json for connector_id in ('nginx_proxy_manager', 'authentik')) + ] + signature_payload = { + 'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION, + 'connectors': sorted(connectors, key=lambda item: item['id']), + 'secrets': sorted(secret_rows, key=lambda item: item['id']), + } + serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':')) + return hashlib.sha256(serialized.encode('utf-8')).hexdigest() + + +def _persistent_network_exposure(session: Session, signature: str) -> list[dict] | None: + row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY) + if not row or not isinstance(row.value_json, dict): + return None + value = row.value_json + if value.get('schemaVersion') != NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION or value.get('signature') != signature: + return None + payload = value.get('payload') + expires_at_raw = value.get('expiresAt') + if not isinstance(payload, list) or not isinstance(expires_at_raw, str): + return None + expires_at = _parse_utc(expires_at_raw) + now_dt = datetime.now(UTC) + stale_until = expires_at.timestamp() + _network_cache_max_stale_seconds() + if now_dt >= expires_at and now_dt.timestamp() >= stale_until: + return None + return [item for item in payload if isinstance(item, dict)] + + +def _store_persistent_network_exposure(session: Session, signature: str, payload: list[dict]) -> None: + ttl_seconds = _network_cache_ttl_seconds() + expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z') + row = session.get(AppSetting, NETWORK_EXPOSURE_CACHE_KEY) + if not row: + row = AppSetting(key=NETWORK_EXPOSURE_CACHE_KEY, is_secret=False) + row.value_json = { + 'schemaVersion': NETWORK_EXPOSURE_CACHE_SCHEMA_VERSION, + 'signature': signature, + 'payload': _redact(payload), + 'generatedAt': datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z'), + 'expiresAt': expires_at, + } + row.updated_at = utc_now() + session.add(row) + session.commit() + + +def _build_network_exposure() -> list[dict]: + with ThreadPoolExecutor(max_workers=3) as executor: + hosts_future = executor.submit(lambda: NginxProxyManagerConnector().proxy_hosts()) + certs_future = executor.submit(lambda: NginxProxyManagerConnector().certificates()) + coverage_future = executor.submit(_auth_coverage_by_host) + hosts_payload = hosts_future.result() + certs_payload = certs_future.result() + coverage_by_host = coverage_future.result() + + if not hosts_payload.get('success'): + return [] + + certs = certs_payload.get('items', []) if certs_payload.get('success') else [] + cert_by_id = {item.get('id'): item for item in certs if isinstance(item, dict)} + exposures = [] + for host in hosts_payload.get('items', []): + if not isinstance(host, dict): + continue + domains = host.get('domainNames') if isinstance(host.get('domainNames'), list) else [] + label = ', '.join(str(item) for item in domains if str(item).strip()) or f"proxy-host-{host.get('id', 'unknown')}" + certificate = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None + has_certificate = bool(host.get('certificateId')) + certificate_expired = bool(certificate and certificate.get('isExpired')) + certificate_expiring = bool(certificate and certificate.get('isExpiringSoon')) + coverage = coverage_by_host.get(str(host.get('id'))) + auth_status = str((coverage or {}).get('coverageStatus') or 'unknown') + auth_protected = auth_status in {'protected', 'likely_protected'} + npm_protected = bool(host.get('accessListId')) + protected = npm_protected or auth_protected + protection_source = 'authentik' if auth_protected else ('npm_access_list' if npm_protected else 'none') + enabled = bool(host.get('enabled')) + ssl_forced = bool(host.get('sslForced')) + risk_reasons: list[str] = [] + if enabled and not protected: + risk_reasons.append('No NPM access list or Authentik match') + if enabled and not has_certificate: + risk_reasons.append('No certificate assigned') + if certificate_expired: + risk_reasons.append('Certificate expired') + elif certificate_expiring: + risk_reasons.append('Certificate expiring soon') + if enabled and has_certificate and not ssl_forced: + risk_reasons.append('SSL is not forced') + + if not enabled: + risk = 'low' + elif certificate_expired: + risk = 'critical' + elif not protected or not has_certificate: + risk = 'high' + elif not ssl_forced or certificate_expiring: + risk = 'medium' + else: + risk = 'low' + + exposures.append({ + 'source': 'reverse_proxy', + 'host': label, + 'port': int(host.get('forwardPort') or 0), + 'protocol': str(host.get('forwardScheme') or 'http'), + 'protected': protected, + 'protectionSource': protection_source, + 'authCoverageStatus': auth_status, + 'authentikMatches': (coverage or {}).get('authentikMatches') or [], + 'appId': str(host.get('forwardHost') or ''), + 'risk': risk, + 'riskReasons': risk_reasons, + 'externalChangeRequired': bool(risk_reasons), + }) + return exposures + + +@router.get('/network/exposure') +def network_exposure(refresh: bool = False, session: Session = Depends(get_session)): + apply_stored_connector_settings(session, {'nginx_proxy_manager', 'authentik'}) + signature = _network_exposure_signature(session) + if not refresh: + persisted = _persistent_network_exposure(session, signature) + if persisted is not None: + return persisted + exposures = _build_network_exposure() + _store_persistent_network_exposure(session, signature, exposures) + return exposures + + +def _auth_coverage_by_host() -> dict[str, dict]: + try: + payload = AuthentikConnector().auth_coverage() + except Exception: + return {} + if not payload.get('success'): + return {} + result: dict[str, dict] = {} + for item in payload.get('publicHosts', []): + if not isinstance(item, dict): + continue + host_id = item.get('hostId') + if host_id is None: + continue + result[str(host_id)] = item + return result + + +@router.get('/healthchecks') +def healthchecks(session: Session = Depends(get_session)): + checked_at = datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + connectors = {connector.id: connector for connector in session.exec(select(Connector)).all()} + + return [ + {'service': 'API Gateway', 'status': 'pass', 'checkedAt': checked_at, 'details': 'DB en route registratie OK'}, + _connector_healthcheck('Portainer', connectors.get('portainer'), checked_at), + _connector_healthcheck('Unraid', connectors.get('unraid'), checked_at), + _voice_bridge_healthcheck(connectors.get('home_assistant'), checked_at), + ] + + +def _connector_healthcheck(service: str, connector: Connector | None, checked_at: str) -> dict: + if connector is None: + return { + 'service': service, + 'status': 'warn', + 'checkedAt': checked_at, + 'details': 'Niet aanwezig in connector registry.', + } + + status = str(connector.status or 'unknown').lower() + if status == 'connected': + return { + 'service': service, + 'status': 'pass', + 'checkedAt': checked_at, + 'details': 'Connector registry meldt verbonden.', + } + if status in {'missing_config', 'disconnected', 'unknown'}: + return { + 'service': service, + 'status': 'warn', + 'checkedAt': checked_at, + 'details': f'Connector registry meldt {status}.', + } + return { + 'service': service, + 'status': 'fail', + 'checkedAt': checked_at, + 'details': f'Connector registry meldt {status}.', + } + + +def _voice_bridge_healthcheck(home_assistant: Connector | None, checked_at: str) -> dict: + if not settings.voice_token: + return { + 'service': 'Voice Bridge', + 'status': 'warn', + 'checkedAt': checked_at, + 'details': 'Voice Bridge is niet geactiveerd.', + } + + if home_assistant is None: + return { + 'service': 'Voice Bridge', + 'status': 'warn', + 'checkedAt': checked_at, + 'details': 'Home Assistant connector ontbreekt.', + } + + status = str(home_assistant.status or 'unknown').lower() + if status == 'connected': + return { + 'service': 'Voice Bridge', + 'status': 'pass', + 'checkedAt': checked_at, + 'details': 'Home Assistant connector beschikbaar voor voice exports.', + } + if status in {'missing_config', 'disconnected', 'unknown'}: + return { + 'service': 'Voice Bridge', + 'status': 'warn', + 'checkedAt': checked_at, + 'details': f'Home Assistant connector meldt {status}.', + } + return { + 'service': 'Voice Bridge', + 'status': 'fail', + 'checkedAt': checked_at, + 'details': f'Home Assistant connector meldt {status}.', + } diff --git a/backend/app/api/routes_playbooks.py b/backend/app/api/routes_playbooks.py new file mode 100644 index 0000000..faa9600 --- /dev/null +++ b/backend/app/api/routes_playbooks.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session, select + +from ..db.models import Connector, Policy +from ..db.session import get_session + +router = APIRouter(prefix='/api') + + +def _connector_names(connectors: list[Connector], ids: set[str]) -> list[str]: + return [connector.name for connector in connectors if connector.id in ids] + + +def _policy_risk(session: Session, action: str, target: str, fallback: str = 'medium') -> str: + policy = session.exec(select(Policy).where(Policy.action == action, Policy.target == target)).first() + if policy and policy.risk in {'low', 'medium', 'high', 'critical'}: + return policy.risk + return fallback + + +def _connector_labels(connectors: list[Connector], ids: set[str]) -> list[str]: + labels = _connector_names(connectors, ids) + known = {connector.id for connector in connectors} + for connector_id in sorted(ids - known): + labels.append(connector_id.replace('_', ' ').title()) + return labels + + +def _success_rate(connectors: dict[str, Connector], ids: set[str], fallback: int = 80) -> int: + if not ids: + return fallback + present = [connectors.get(connector_id) for connector_id in ids] + connected = sum(1 for connector in present if connector and connector.status == 'connected') + return round((connected / len(ids)) * 100) + + +@router.get('/playbooks') +def list_playbooks(session: Session = Depends(get_session)): + connectors = session.exec(select(Connector).order_by(Connector.id)).all() + degraded = [ + connector + for connector in connectors + if connector.status in {'missing_config', 'disconnected', 'error', 'auth_failed', 'unreachable'} + ] + connector_by_id = {connector.id: connector for connector in connectors} + + playbooks: list[dict] = [] + playbooks.append({ + 'id': 'storage-pressure-review', + 'name': 'Storage Pressure Review', + 'description': 'Brengt array-, cache-, disk-, share- en container-mountdruk samen zonder storage-mutaties te starten.', + 'risk': 'medium', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': _connector_labels(connectors, {'unraid', 'portainer'}), + 'steps': [ + 'Open Storage & Volumes and review array/cache pressure', + 'Inspect disks and critical shares above threshold', + 'Compare Portainer mount hints with Unraid share usage', + 'Open Safety or Remediation Planner for non-executing follow-up notes', + 'Keep mover, prune and storage mutations outside this playbook', + ], + 'successRate': _success_rate(connector_by_id, {'unraid', 'portainer'}), + }) + + playbooks.append({ + 'id': 'exposure-auth-coverage-review', + 'name': 'Exposure & Auth Coverage Review', + 'description': 'Controleert NPM public hosts, SSL-force, certificaten en Authentik coverage als één securityroute.', + 'risk': 'high', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': _connector_labels(connectors, {'nginx_proxy_manager', 'authentik'}), + 'steps': [ + 'Open Exposure Center and sort high-risk public hosts first', + 'Check SSL-force, certificate status and access-list evidence', + 'Open matching Authentik coverage details for unprotected hosts', + 'Create or review a remediation draft before touching external systems', + 'Re-run Safety refresh after operator-side NPM/Auth changes', + ], + 'successRate': _success_rate(connector_by_id, {'nginx_proxy_manager', 'authentik'}), + }) + + playbooks.append({ + 'id': 'media-stalled-download-review', + 'name': 'Media Pipeline Stalled Download Review', + 'description': 'Volgt Sonarr/Radarr wanted items, Prowlarr health en Deluge stalled downloads zonder data te verwijderen.', + 'risk': 'medium', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': _connector_labels(connectors, {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}), + 'steps': [ + 'Open Media Pipeline summary and review stalled/wanted counters', + 'Check Prowlarr indexers and Sonarr/Radarr queue status', + 'Inspect Deluge stalled downloads and pause/resume policy guidance', + 'Review Plex/Tautulli activity before starting scans', + 'Use approval-gated actions only for non-destructive connector operations', + ], + 'successRate': _success_rate(connector_by_id, {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}), + }) + + playbooks.append({ + 'id': 'unhealthy-container-triage', + 'name': 'Unhealthy Container Triage', + 'description': 'Richt de operator op ongezonde of ongemapte containers met logs, Portainer deep-link en policy-aware restartpad.', + 'risk': 'medium', + 'allowedSources': ['manual'], + 'requiredConnectors': _connector_labels(connectors, {'portainer'}), + 'steps': [ + 'Open Containers with attention filter', + 'Inspect health, import status, app mapping and redacted logs', + 'Open Portainer deep-link only when live context is available', + 'Request approval before restarting protected apps', + 'Verify the container and app detail after any approved action', + ], + 'successRate': _success_rate(connector_by_id, {'portainer'}), + }) + + playbooks.append({ + 'id': 'app-inventory-normalization', + 'name': 'App Inventory Normalization', + 'description': 'Werkt Unknown, needs-review en no-connector apps systematisch weg zodat Apps minder ruis tonen.', + 'risk': 'low', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': _connector_labels(connectors, {'portainer'}), + 'steps': [ + 'Open Apps with attention filter', + 'Review Unknown and needs-review app cards first', + 'Map discovered containers to canonical apps where the match is clear', + 'Bind connector candidates only after connector config has tested connected', + 'Re-run inventory discovery and compare drift after review', + ], + 'successRate': _success_rate(connector_by_id, {'portainer'}), + }) + + playbooks.append({ + 'id': 'backup-readiness-check', + 'name': 'Backup Readiness Check', + 'description': 'Controleert SQLite backup posture, restore-notes en release hygiene zonder restore uit te voeren.', + 'risk': 'low', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': ['AppOps DB'], + 'steps': [ + 'Open Settings backups section', + 'Review latest backup metadata and integrity status', + 'Confirm restore remains manual stop-container flow', + 'Open Audit for recent backup events', + 'Update release notes after verified production changes', + ], + 'successRate': 100, + }) + + playbooks.append({ + 'id': 'notification-delivery-review', + 'name': 'Notification Delivery Review', + 'description': 'Controleert dashboard- en Home Assistant notificatiebereik zonder automatisch testberichten te verzenden.', + 'risk': 'low', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': _connector_labels(connectors, {'home_assistant'}), + 'steps': [ + 'Open Notifications and review active rules, channels and unread events', + 'Validate Home Assistant readiness only when the operator enabled the channel', + 'Run explicit evaluation for safety/media events when stale notifications appear', + 'Keep external delivery opt-in and audited', + ], + 'successRate': _success_rate(connector_by_id, {'home_assistant'}, fallback=70), + }) + + if degraded: + degraded_names = [connector.name for connector in degraded] + playbooks.append({ + 'id': 'connector-readiness-review', + 'name': 'Connector Readiness Review', + 'description': 'Controleert welke connectors configuratie, binding of health-herstel nodig hebben zonder wijzigingen uit te voeren.', + 'risk': 'medium', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': degraded_names, + 'steps': [ + f"Review {len(degraded)} degraded connector(s): {', '.join(degraded_names[:8])}", + 'Open connector setup for missing_config entries', + 'Run read-only connector tests', + 'Review policy and audit status before any follow-up action', + ], + 'successRate': max(0, 100 - (len(degraded) * 10)), + }) + + if {'portainer', 'widefrog'} <= set(connector_by_id): + widefrog_ready = connector_by_id['widefrog'].status == 'connected' + portainer_ready = connector_by_id['portainer'].status == 'connected' + playbooks.append({ + 'id': 'fix-widefrog', + 'name': 'Fix Widefrog', + 'description': 'Bereidt een veilige Widefrog herstelprocedure voor met read-only checks en policy-gated vervolgstappen.', + 'risk': _policy_risk(session, 'widefrog.retry_failed', 'widefrog', 'medium'), + 'allowedSources': ['manual', 'voice'], + 'requiredConnectors': _connector_names(connectors, {'portainer', 'widefrog'}), + 'steps': [ + 'Read Portainer container status', + 'Read Widefrog health, queue and failed jobs', + 'Inspect recent redacted logs when available', + 'Draft follow-up action only when policy allows it', + 'Require approval for retry or restart when policy demands it', + 'Write audit result after an approved execution path', + ], + 'successRate': 90 if widefrog_ready and portainer_ready else 50, + }) + + if 'unraid' in connector_by_id: + unraid_ready = connector_by_id['unraid'].status == 'connected' + playbooks.append({ + 'id': 'unraid-health-scan', + 'name': 'Unraid Health Scan', + 'description': 'Leest array, disk, cache en docker-status uit zonder muterende acties te starten.', + 'risk': 'low', + 'allowedSources': ['manual', 'automation'], + 'requiredConnectors': _connector_names(connectors, {'unraid'}), + 'steps': [ + 'Read array status', + 'Read parity status', + 'Check disk warning count', + 'Check cache usage', + 'Check docker service status', + 'Write read-only audit summary', + ], + 'successRate': 100 if unraid_ready else 60, + }) + + return playbooks diff --git a/backend/app/api/routes_plex.py b/backend/app/api/routes_plex.py new file mode 100644 index 0000000..31a2d96 --- /dev/null +++ b/backend/app/api/routes_plex.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.plex import PlexConnector +from ..db.session import get_session +from .connector_runtime import load_connector_runtime +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.job_runner import complete_job, create_job, set_job_progress, start_job +from ..services.policy_engine import evaluate_policy + +router = APIRouter(prefix='/api/apps/plex', dependencies=[Depends(load_connector_runtime)]) + + +_SCAN_LIBRARY_STEPS = [ + 'Validate library id', + 'Evaluate policy', + 'Check Plex health', + 'Fetch library status', + 'Trigger scan library', + 'Verify accepted or return status', + 'Complete audit/job', +] + +_SCAN_ALL_STEPS = [ + 'Evaluate policy', + 'Check Plex health', + 'Fetch libraries', + 'Trigger scan for each library', + 'Complete audit/job', +] + + +@router.get('/health') +def health(): + return PlexConnector().health() + + +@router.get('/server') +def server(): + return PlexConnector().server_status() + + +@router.get('/libraries') +def libraries(): + return PlexConnector().library_status() + + +@router.get('/sessions') +def sessions(): + return PlexConnector().active_sessions() + + +@router.get('/recent') +def recent(): + return PlexConnector().recent_media() + + +@router.get('/streams') +def streams(): + return PlexConnector().stream_summary() + + +@router.post('/libraries/{library_id}/scan') +def scan_library(library_id: str, payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_plex_action( + session=session, + payload=payload or {}, + action='plex.scan_library', + steps=_SCAN_LIBRARY_STEPS, + connector_call=lambda connector: connector.scan_library(library_id), + default_success_message='scan_started', + metadata={'libraryId': library_id}, + ) + + +@router.post('/libraries/scan-all') +def scan_all_libraries(payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_plex_action( + session=session, + payload=payload or {}, + action='plex.scan_all_libraries', + steps=_SCAN_ALL_STEPS, + connector_call=lambda connector: connector.scan_all_libraries(), + default_success_message='scan_all_started', + metadata={'scope': 'all'}, + ) + + +def _execute_plex_action( + *, + session: Session, + payload: dict[str, Any], + action: str, + steps: list[str], + connector_call: Callable[[PlexConnector], dict[str, Any]], + default_success_message: str, + metadata: dict[str, Any], +) -> dict[str, Any]: + target = 'plex' + source = str(payload.get('source', 'manual')).strip() or 'manual' + policy = evaluate_policy(session=session, action=action, target=target, source=source) + risk = str(policy.get('risk', 'medium')) + + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + execution_plan={'plan': steps, 'policyReason': policy['reason']}, + redacted_metadata=metadata, + ) + return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id} + + if policy['decision'] == 'approval_required': + approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + execution_plan={'plan': steps, 'approvalId': approval.id}, + redacted_metadata={**metadata, 'approval_id': approval.id}, + ) + return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id} + + job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps) + start_job(session, job) + set_job_progress(session, job, 35, 'Policy allowed; calling Plex connector.') + + connector_result = connector_call(PlexConnector()) + success = bool(connector_result.get('success')) + message = str(connector_result.get('message', default_success_message if success else 'plex_failed')) + completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if success else 'failed', + execution_plan={'plan': steps, 'jobId': completed.id}, + after_state={'status': connector_result.get('status'), 'message': message}, + redacted_metadata={**metadata, 'connector': 'plex'}, + ) + + if not success: + return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')} + + return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})} diff --git a/backend/app/api/routes_policies.py b/backend/app/api/routes_policies.py new file mode 100644 index 0000000..9bd3eef --- /dev/null +++ b/backend/app/api/routes_policies.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlmodel import Session, select + +from ..db.session import get_session +from ..db.models import Policy +from ..services.policy_engine import evaluate_policy +from ..services.action_registry import action_capability +from ..services.audit_logger import log_event + +router = APIRouter(prefix='/api/policies') +DECISIONS = {'allowed', 'approval_required', 'blocked'} +RISKS = {'low', 'medium', 'high', 'critical'} + + +class PolicyUpdate(BaseModel): + voice: str + manual: str + risk: str + reason: str + + +@router.get('') +def list_policies(session: Session = Depends(get_session)): + return session.exec(select(Policy).order_by(Policy.action, Policy.target)).all() + + +@router.patch('/{policy_id}') +def update_policy(policy_id: str, payload: PolicyUpdate, session: Session = Depends(get_session)): + policy = session.get(Policy, policy_id) + if not policy: + raise HTTPException(status_code=404, detail='Policy not found.') + if payload.voice not in DECISIONS or payload.manual not in DECISIONS or payload.risk not in RISKS: + raise HTTPException(status_code=422, detail='Invalid policy decision or risk value.') + if not payload.reason.strip(): + raise HTTPException(status_code=422, detail='A policy reason is required.') + + capability = action_capability(policy.action) + if capability == 'destructive' and (payload.voice != 'blocked' or payload.manual != 'blocked'): + raise HTTPException(status_code=409, detail='Destructive actions remain blocked by the platform guardrail.') + + before = {'voice': policy.voice, 'manual': policy.manual, 'risk': policy.risk, 'reason': policy.reason} + policy.voice = payload.voice + policy.manual = payload.manual + policy.risk = 'critical' if capability == 'destructive' else payload.risk + policy.reason = payload.reason.strip() + session.add(policy) + session.commit() + session.refresh(policy) + after = {'voice': policy.voice, 'manual': policy.manual, 'risk': policy.risk, 'reason': policy.reason} + log_event( + session, + source='manual', + identity='operator', + action_id='policy.update', + target=policy.id, + target_type='policy', + risk='medium', + policy_decision='allowed', + reason='Operator updated policy decisions within enforced platform guardrails.', + result='success', + before_state=before, + after_state=after, + redacted_metadata={'protectedGuardrail': capability == 'destructive', 'policyAction': policy.action}, + ) + return { + 'id': policy.id, + 'action': policy.action, + 'target': policy.target, + 'voice': policy.voice, + 'manual': policy.manual, + 'risk': policy.risk, + 'reason': policy.reason, + } + + +@router.post('/evaluate') +def evaluate(payload: dict, session: Session = Depends(get_session)): + action = str(payload.get('action', '')).strip() + target = str(payload.get('target', '')).strip() + source = str(payload.get('source', 'manual')).strip() or 'manual' + result = evaluate_policy(session, action=action, target=target, source=source) + + return { + 'decision': result['decision'], + 'reason': result['reason'], + 'risk': result['risk'], + 'policyId': result['policy_id'], + } diff --git a/backend/app/api/routes_privatemuse_artifacts.py b/backend/app/api/routes_privatemuse_artifacts.py new file mode 100644 index 0000000..5107543 --- /dev/null +++ b/backend/app/api/routes_privatemuse_artifacts.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session + +from ..db.session import get_session +from ..services.audit_logger import log_event +from ..services.policy_engine import evaluate_policy +from ..services.privatemuse_artifact_installer import ( + ArtifactInstallError, + install, + validate_request, +) + +router = APIRouter(prefix="/api/privatemuse/artifacts") + + +class ArtifactInstallRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + artifact_type: str = Field(pattern="^(lora|checkpoint|unet|vae|clip|gguf)$") + source_url: str = Field(min_length=9, max_length=2048) + sha256: str = Field(pattern="^[0-9a-f]{64}$") + filename: str = Field(min_length=13, max_length=192) + source_id: str = Field(min_length=1, max_length=240) + license: str = Field(min_length=1, max_length=240) + reason: str = Field(min_length=1, max_length=1000) + + +@router.post("/preflight") +def preflight( + payload: ArtifactInstallRequest, + session: Session = Depends(get_session), +): + action = "privatemuse.comfyui.install_artifact" + target = "privatemuse/comfyui" + decision = evaluate_policy( + session, + action=action, + target=target, + source="manual", + ) + try: + normalized = validate_request(payload.model_dump()) + except ArtifactInstallError as exc: + raise HTTPException(422, str(exc)) from exc + return { + "ready": decision["decision"] != "blocked", + "policy": decision, + "artifact": { + "artifact_type": normalized["artifact_type"], + "filename": normalized["filename"], + "sha256": normalized["sha256"], + "source_id": normalized["source_id"], + "source_host": normalized["source_url"].split("/")[2], + "license": normalized["license"], + }, + } + + +@router.post("/install") +def install_artifact( + payload: ArtifactInstallRequest, + session: Session = Depends(get_session), +): + action = "privatemuse.comfyui.install_artifact" + target = "privatemuse/comfyui" + decision = evaluate_policy( + session, + action=action, + target=target, + source="manual", + ) + if decision["decision"] != "allowed": + raise HTTPException( + 409, + "Artifact installation requires an explicitly allowed policy/approved execution context.", + ) + model_root = Path( + os.environ.get( + "APPOPS_PRIVATEMUSE_COMFYUI_MODEL_ROOT", + "/mnt/privatemuse-comfyui-models", + ) + ) + receipt_root = Path( + os.environ.get( + "APPOPS_PRIVATEMUSE_ARTIFACT_RECEIPT_ROOT", + "/data/privatemuse-artifact-receipts", + ) + ) + try: + result = install( + payload.model_dump(), + model_root=model_root, + receipt_root=receipt_root, + ) + except ArtifactInstallError as exc: + log_event( + session, + source="action", + identity="manual", + action_id=action, + target=target, + risk="high", + policy_decision="allowed", + reason=payload.reason, + result="failed", + redacted_metadata={ + "filename": payload.filename, + "sha256": payload.sha256, + "source_id": payload.source_id, + }, + ) + raise HTTPException(409, str(exc)) from exc + audit = log_event( + session, + source="action", + identity="manual", + action_id=action, + target=target, + risk="high", + policy_decision="allowed", + reason=payload.reason, + result="success", + after_state={ + "status": result["status"], + "sha256": result["sha256"], + "bytes": result["bytes"], + }, + redacted_metadata={ + "filename": payload.filename, + "source_id": payload.source_id, + "license": payload.license, + }, + ) + return {**result, "auditId": audit.id} diff --git a/backend/app/api/routes_prowlarr.py b/backend/app/api/routes_prowlarr.py new file mode 100644 index 0000000..8a71f9f --- /dev/null +++ b/backend/app/api/routes_prowlarr.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.prowlarr import ProwlarrConnector +from ..db.session import get_session +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.policy_engine import evaluate_policy +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/prowlarr', dependencies=[Depends(load_connector_runtime)]) + + +@router.get('/health') +def health(): + return ProwlarrConnector().health() + + +@router.get('/indexers') +def indexers(): + return ProwlarrConnector().indexer_status() + + +@router.get('/applications') +def applications(): + return ProwlarrConnector().application_status() + + +@router.get('/indexer-health') +def indexer_health(): + return ProwlarrConnector().indexer_health() + + +@router.get('/sync-status') +def sync_status(): + return ProwlarrConnector().sync_status() + + +@router.get('/search-test') +def search_test(q: str = '', source: str = 'manual', session: Session = Depends(get_session)): + query = q.strip() + if not query: + return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []} + + identity = source.strip() or 'manual' + policy = evaluate_policy(session=session, action='prowlarr.search_test', target='prowlarr', source=identity) + risk = str(policy.get('risk', 'medium')) + + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=identity, + action_id='prowlarr.search_test', + target='prowlarr', + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + redacted_metadata={'query': query, 'downloadAllowed': False}, + ) + return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id} + + if policy['decision'] == 'approval_required': + approval = create_approval(session, action='prowlarr.search_test', target='prowlarr', source=identity, risk=risk, reason=policy['reason'], requester=identity) + audit = log_event( + session, + source='action', + identity=identity, + action_id='prowlarr.search_test', + target='prowlarr', + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + redacted_metadata={'query': query, 'approval_id': approval.id, 'downloadAllowed': False}, + ) + return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id} + + result = ProwlarrConnector().search_test(query) + success = bool(result.get('success')) + audit = log_event( + session, + source='action', + identity=identity, + action_id='prowlarr.search_test', + target='prowlarr', + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if success else 'failed', + redacted_metadata={'query': query, 'resultCount': int(result.get('total', 0)), 'downloadAllowed': False}, + ) + result['auditId'] = audit.id + result['downloadAllowed'] = False + return result diff --git a/backend/app/api/routes_radarr.py b/backend/app/api/routes_radarr.py new file mode 100644 index 0000000..acff47a --- /dev/null +++ b/backend/app/api/routes_radarr.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.radarr import RadarrConnector +from ..db.session import get_session +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.job_runner import complete_job, create_job, set_job_progress, start_job +from ..services.policy_engine import evaluate_policy +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/radarr', dependencies=[Depends(load_connector_runtime)]) + + +_ADD_MOVIE_STEPS = [ + 'Validate payload', + 'Evaluate policy', + 'Resolve root folder / quality profile defaults', + 'Add movie in Radarr', + 'Optional search movie', + 'Verify movie exists', + 'Complete audit/job', +] + + +@router.get('/health') +def health(): + return RadarrConnector().health() + + +@router.get('/queue') +def queue(): + return RadarrConnector().queue_status() + + +@router.get('/wanted') +def wanted(): + return RadarrConnector().wanted_missing() + + +@router.get('/search') +def search(q: str = ''): + query = q.strip() + if not query: + return {'success': False, 'status': 'error', 'message': 'query_required', 'items': []} + return RadarrConnector().search_movie(query) + + +@router.post('/movie') +def add_movie(payload: dict, session: Session = Depends(get_session)): + action = 'radarr.add_movie' + target = 'radarr' + source = str(payload.get('source', 'manual')).strip() or 'manual' + policy = evaluate_policy(session=session, action=action, target=target, source=source) + risk = str(policy.get('risk', 'low')) + + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + execution_plan={'plan': _ADD_MOVIE_STEPS, 'policyReason': policy['reason']}, + ) + return { + 'success': False, + 'decision': 'blocked', + 'status': 'failed', + 'policy_decision': 'blocked', + 'result': 'blocked', + 'message': policy['reason'], + 'auditId': audit.id, + } + + if policy['decision'] == 'approval_required': + approval = create_approval( + session, + action=action, + target=target, + source=source, + risk=risk, + reason=policy['reason'], + requester=source, + ) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + execution_plan={'plan': _ADD_MOVIE_STEPS, 'approvalId': approval.id}, + redacted_metadata={'approval_id': approval.id}, + ) + return { + 'success': False, + 'decision': 'approval_required', + 'status': 'failed', + 'policy_decision': 'approval_required', + 'result': 'approval_required', + 'message': 'Action requires approval before execution.', + 'approvalId': approval.id, + 'auditId': audit.id, + } + + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision='allowed', + steps=_ADD_MOVIE_STEPS, + ) + start_job(session, job) + set_job_progress(session, job, 35, 'Payload validated and policy allowed.') + + connector_result = RadarrConnector().add_movie(payload) + success = bool(connector_result.get('success')) + result_status = 'succeeded' if success else 'failed' + message = str(connector_result.get('message', 'movie_added' if success else 'radarr_failed')) + + completed = complete_job( + session, + job, + status=result_status, + result_summary=message, + ) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if success else 'failed', + execution_plan={'plan': _ADD_MOVIE_STEPS, 'jobId': completed.id}, + after_state={'status': connector_result.get('status'), 'message': message}, + redacted_metadata={'connector': 'radarr'}, + ) + + if not success: + return { + 'success': False, + 'decision': 'failed', + 'status': 'failed', + 'policy_decision': 'allowed', + 'result': 'failed', + 'message': message, + 'jobId': completed.id, + 'auditId': audit.id, + 'data': connector_result.get('data'), + } + + return { + 'success': True, + 'decision': 'allowed', + 'status': 'success', + 'policy_decision': 'allowed', + 'result': 'success', + 'message': message, + 'jobId': completed.id, + 'auditId': audit.id, + 'data': connector_result.get('data', {}), + } diff --git a/backend/app/api/routes_runtime.py b/backend/app/api/routes_runtime.py new file mode 100644 index 0000000..cf5acb3 --- /dev/null +++ b/backend/app/api/routes_runtime.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from pydantic import BaseModel +from fastapi.responses import FileResponse, PlainTextResponse +from sqlmodel import Session + +from ..core.security import ( + OPERATOR_SESSION_COOKIE, + issue_operator_session, + operator_session_status, + require_operator_request, + revoke_operator_session, +) +from ..db.session import get_session +from ..services.audit_logger import log_event +from ..services.deployment_target_registry import DeploymentTargetRegistry +from ..services.maintenance_scheduler import maintenance_status +from ..services.request_metrics import ( + request_metrics_openmetrics, + request_metrics_snapshot, +) +from ..services.runtime_service import ( + checkpoint_sqlite, + create_sqlite_backup, + deep_health, + export_audit_summary, + export_config, + export_inventory, + export_policies, + get_sqlite_backup_path, + list_sqlite_backups, + operator_auth_policy, + runtime_info, + sqlite_maintenance_status, + verify_sqlite_backup, + update_operator_auth_policy, +) + +router = APIRouter(prefix='/api') + + +class OperatorAuthPolicyRequest(BaseModel): + required: bool + + +class DeploymentTargetRegistrationRequest(BaseModel): + repository: str + revision: str + + +@router.get('/runtime/info') +def get_runtime_info(session: Session = Depends(get_session)): + return runtime_info(session) + + +@router.get('/operator/session') +def get_operator_session(request: Request): + return operator_session_status(request) + + +@router.get('/runtime/operator-auth') +def get_operator_auth_policy(): + return operator_auth_policy() + + +@router.patch('/runtime/operator-auth') +def patch_operator_auth_policy(payload: OperatorAuthPolicyRequest, session: Session = Depends(get_session)): + return update_operator_auth_policy(session, payload.required) + + +@router.post('/admin/deployment-targets/single-container') +def admin_register_single_container_deployment_target( + payload: DeploymentTargetRegistrationRequest, + request: Request, + session: Session = Depends(get_session), +): + """Register one bounded legacy Actions deployment target. + + This is deliberately not a generic Docker exec or host-file API. AppOps + derives the exact-source path, Compose filename/project and container name + from a canonical Jens repository identifier. The controller-scoped bridge + validates the immutable revision and Compose file directly against the bare + Gitea repository before it updates the deployment registry. Webhook secrets + and modern runnerless webhook onboarding are explicitly out of scope here. + """ + require_operator_request(request) + result = DeploymentTargetRegistry().register_single_container( + payload.repository, + payload.revision, + ) + status = str(result.get('status') or 'error') + succeeded = status == 'registered' + audit = log_event( + session, + source='operator', + identity='operator', + action_id='deployment.target.register', + target=str(payload.repository).strip().lower(), + target_type='deployment-target', + risk='high', + policy_decision='allowed', + reason='Operator requested bounded single-container deployment target onboarding.', + result='success' if succeeded else 'failed', + execution_plan={ + 'steps': [ + 'validate canonical Jens repository and immutable revision', + 'derive controller-owned exact-source and Compose topology', + 'verify the exact revision is on main and contains docker-compose.yml', + 'atomically update deployment controller projects.json', + 'leave the registry unchanged on any source or validation failure', + ] + }, + after_state={ + 'registered': succeeded, + 'repository': str(result.get('repository') or str(payload.repository).strip().lower()), + 'revision': str(result.get('revision') or payload.revision), + 'slug': str(result.get('slug') or ''), + }, + redacted_metadata={ + 'scope': 'legacy-actions-single-container', + 'webhookSecretsCreated': False, + }, + ) + if not succeeded: + code = 400 if status == 'invalid' else 409 if status == 'conflict' else 502 + raise HTTPException( + status_code=code, + detail={ + 'status': status, + 'reason': str(result.get('reason') or 'deployment target registration failed'), + 'auditId': audit.id, + }, + ) + return { + 'success': True, + 'status': status, + 'repository': result.get('repository'), + 'revision': result.get('revision'), + 'slug': result.get('slug'), + 'composeProject': result.get('compose_project'), + 'container': result.get('container'), + 'auditId': audit.id, + } + + +def _session_cookie_secure(request: Request) -> bool: + forwarded_proto = request.headers.get('X-Forwarded-Proto', '').split(',', 1)[0].strip().lower() + return request.url.scheme == 'https' or forwarded_proto == 'https' + + +@router.post('/operator/session') +def create_operator_session(request: Request, response: Response): + session_id = issue_operator_session(request) + response.set_cookie( + OPERATOR_SESSION_COOKIE, + session_id, + httponly=True, + secure=_session_cookie_secure(request), + samesite='strict', + path='/', + ) + return {'authorized': True, 'mode': 'session', 'identity': None} + + +@router.delete('/operator/session') +def delete_operator_session(request: Request, response: Response): + require_operator_request(request) + revoke_operator_session(request) + response.delete_cookie( + OPERATOR_SESSION_COOKIE, + httponly=True, + secure=_session_cookie_secure(request), + samesite='strict', + path='/', + ) + return {'authorized': False, 'mode': 'locked', 'identity': None} + + +@router.get('/health/deep') +def get_deep_health(session: Session = Depends(get_session)): + return deep_health(session) + + +@router.get('/admin/observability/requests') +def admin_request_metrics(): + return request_metrics_snapshot() + + +@router.get('/admin/observability/metrics', response_class=PlainTextResponse) +def admin_openmetrics(): + return PlainTextResponse(request_metrics_openmetrics(), media_type='text/plain; version=0.0.4') + + +@router.get('/admin/maintenance/status') +def admin_maintenance_status(): + return maintenance_status() + + +@router.get('/admin/export/config') +def admin_export_config(session: Session = Depends(get_session)): + return export_config(session) + + +@router.get('/admin/export/policies') +def admin_export_policies(session: Session = Depends(get_session)): + return export_policies(session) + + +@router.get('/admin/export/inventory') +def admin_export_inventory(session: Session = Depends(get_session)): + return export_inventory(session) + + +@router.get('/admin/export/audit-summary') +def admin_export_audit_summary(session: Session = Depends(get_session)): + return export_audit_summary(session) + + +@router.get('/admin/backups/sqlite') +def admin_list_sqlite_backups(): + return list_sqlite_backups() + + +@router.get('/admin/database/status') +def admin_database_status(): + return sqlite_maintenance_status() + + +@router.post('/admin/database/checkpoint') +def admin_database_checkpoint(session: Session = Depends(get_session)): + return checkpoint_sqlite(session) + + +@router.post('/admin/backups/sqlite') +def admin_create_sqlite_backup(session: Session = Depends(get_session)): + return create_sqlite_backup(session) + + +@router.post('/admin/backups/sqlite/{name}/verify') +def admin_verify_sqlite_backup(name: str, session: Session = Depends(get_session)): + result = verify_sqlite_backup(session, name) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='SQLite backup not found.') + return result + + +@router.get('/admin/backups/sqlite/{name}/download') +def admin_download_sqlite_backup(name: str): + path = get_sqlite_backup_path(name) + if not path: + raise HTTPException(status_code=404, detail='SQLite backup not found.') + return FileResponse( + path=path, + filename=path.name, + media_type='application/vnd.sqlite3', + headers={'Cache-Control': 'no-store'}, + ) diff --git a/backend/app/api/routes_safety_center.py b/backend/app/api/routes_safety_center.py new file mode 100644 index 0000000..59e1d77 --- /dev/null +++ b/backend/app/api/routes_safety_center.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from ..db.session import get_session +from ..services import safety_center_service + +router = APIRouter(prefix='/api/safety') + + +@router.get('/dashboard') +def dashboard(refresh: bool = False, session: Session = Depends(get_session)): + return safety_center_service.get_dashboard(session, force_refresh=refresh) + + +@router.get('/summary') +def summary(session: Session = Depends(get_session)): + return safety_center_service.get_summary(session) + + +@router.get('/sections') +def sections(session: Session = Depends(get_session)): + return safety_center_service.get_sections(session) + + +@router.get('/findings') +def findings(session: Session = Depends(get_session)): + return safety_center_service.get_findings(session) + + +@router.get('/recommendations') +def recommendations(session: Session = Depends(get_session)): + return safety_center_service.get_recommendations(session) + + +@router.get('/score-breakdown') +def score_breakdown(session: Session = Depends(get_session)): + return safety_center_service.get_score_breakdown(session) + + +@router.post('/refresh') +def refresh(session: Session = Depends(get_session)): + return safety_center_service.refresh(session) + + +@router.post('/findings/{finding_id}/acknowledge') +def acknowledge_finding(finding_id: str, session: Session = Depends(get_session)): + return safety_center_service.acknowledge_finding(session, finding_id) + + +@router.post('/findings/{finding_id}/dismiss') +def dismiss_finding(finding_id: str, session: Session = Depends(get_session)): + return safety_center_service.dismiss_finding(session, finding_id) + + +@router.post('/recommendations/{recommendation_id}/dismiss') +def dismiss_recommendation(recommendation_id: str, session: Session = Depends(get_session)): + return safety_center_service.dismiss_recommendation(session, recommendation_id) + + +@router.post('/recommendations/{recommendation_id}/create-remediation-plan') +def create_remediation_plan(recommendation_id: str, session: Session = Depends(get_session)): + result = safety_center_service.create_remediation_plan(session, recommendation_id) + if result.get('status') == 'not_found': + raise HTTPException(status_code=404, detail='recommendation_not_found') + return result diff --git a/backend/app/api/routes_setup.py b/backend/app/api/routes_setup.py new file mode 100644 index 0000000..b21bde6 --- /dev/null +++ b/backend/app/api/routes_setup.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, ConfigDict, Field +from sqlmodel import Session + +from ..connectors.deluge import DelugeConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.authentik import AuthentikConnector +from ..connectors.plex import PlexConnector +from ..connectors.prowlarr import ProwlarrConnector +from ..connectors.radarr import RadarrConnector +from ..connectors.sonarr import SonarrConnector +from ..connectors.tautulli import TautulliConnector +from ..connectors.widefrog import WidefrogConnector +from ..core.redaction import redact_message +from ..db.session import get_session +from ..services.setup_service import ( + complete_setup, + get_setup_status, + list_connector_settings, + mark_setup_complete, + mark_voice_configured, + reconcile_setup, + save_connector_settings, + serialize_connector_settings, + test_portainer_setup, + test_unraid_setup, +) +from ..services.secret_store import list_secret_status, secret_storage_status +from ..core.network_security import UnsafeConnectorUrl + +router = APIRouter(prefix='/api') + + +class ConnectorSettingsRequest(BaseModel): + model_config = ConfigDict(extra='forbid') + url: str | None = Field(default=None, max_length=2048) + endpointId: str | None = Field(default=None, max_length=80) + verifyTls: bool | None = None + apiKey: str | None = Field(default=None, max_length=8192) + token: str | None = Field(default=None, max_length=8192) + password: str | None = Field(default=None, max_length=8192) + secret: str | None = Field(default=None, max_length=8192) + identity: str | None = Field(default=None, max_length=320) + outpostUrl: str | None = Field(default=None, max_length=2048) + expectEmbeddedOutpost: bool | None = None + + +def _connector_payload(payload: ConnectorSettingsRequest) -> dict: + return payload.model_dump(exclude_none=True) + + +def _save_connector_settings(session: Session, connector_id: str, payload: dict): + try: + return save_connector_settings(session, connector_id, payload) + except UnsafeConnectorUrl as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +def _authentik_setup_result(result: dict, settings: dict) -> dict: + status = str(result.get('status', 'error')) + message = str(result.get('reason') or status) + required_fields: list[str] = [] + if status == 'unreachable' and 'HTTP 404' in message: + status = 'url_error' + required_fields = ['AUTHENTIK_URL'] + message = 'Authentik API gaf HTTP 404 terug. Controleer dat de Authentik URL de root/base URL is, niet een app-, flow- of outpost-pad.' + if status == 'auth_failed': + required_fields = ['AUTHENTIK_TOKEN'] + message = 'Authentik heeft de token geweigerd: token ongeldig of verlopen. Gebruik de waarde van een Authentik API Token, niet de tokennaam, identifier of een App password, en sla die opnieuw op.' + return { + 'success': status == 'connected', + 'status': status, + 'connected': status == 'connected', + 'message': message, + 'requiredFields': required_fields, + 'settings': settings, + } + + +def _normalized_url(settings: dict) -> str: + safe_config = settings.get('safeConfig') if isinstance(settings.get('safeConfig'), dict) else {} + return str(safe_config.get('url') or '').strip().rstrip('/').lower() + + +def _connector_setup_result(connector_id: str, result: dict, settings: dict, session: Session) -> dict: + status = str(result.get('status', 'error')) + message = str(result.get('reason') or status) + required_fields = list(settings.get('missing') or []) + if not required_fields and status in {'error', 'unreachable', 'auth_failed'}: + required_fields = list(_CONNECTOR_REQUIRED_FIELDS.get(connector_id, [])) + + if connector_id == 'prowlarr' and status in {'error', 'unreachable', 'auth_failed'} and message == 'unauthorized': + prowlarr_url = _normalized_url(settings) + for sibling_id in ('radarr', 'sonarr'): + sibling = serialize_connector_settings(session, sibling_id) + if prowlarr_url and prowlarr_url == _normalized_url(sibling): + label = 'Radarr' if sibling_id == 'radarr' else 'Sonarr' + return { + 'success': False, + 'status': 'url_mismatch', + 'connected': False, + 'message': f'Prowlarr geeft unauthorized terug en gebruikt dezelfde URL als {label}. Controleer PROWLARR_URL en PROWLARR_API_KEY; Prowlarr draait meestal op poort 9696.', + 'requiredFields': ['PROWLARR_URL', 'PROWLARR_API_KEY'], + 'settings': settings, + } + + return { + 'success': status == 'connected', + 'status': status, + 'connected': status == 'connected', + 'message': redact_message(message), + 'requiredFields': required_fields, + 'settings': settings, + } + + +_CONNECTOR_TESTS = { + 'sonarr': SonarrConnector, + 'radarr': RadarrConnector, + 'prowlarr': ProwlarrConnector, + 'deluge': DelugeConnector, + 'plex': PlexConnector, + 'tautulli': TautulliConnector, + 'widefrog': WidefrogConnector, +} + + +_CONNECTOR_REQUIRED_FIELDS = { + 'sonarr': ['SONARR_URL', 'SONARR_API_KEY'], + 'radarr': ['RADARR_URL', 'RADARR_API_KEY'], + 'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'], + 'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'], + 'plex': ['PLEX_URL', 'PLEX_TOKEN'], + 'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'], + 'widefrog': ['WIDEFROG_URL'], +} + + +@router.get('/setup/status') +def setup_status(session: Session = Depends(get_session)): + return get_setup_status(session) + + +@router.post('/setup/complete') +def setup_complete(session: Session = Depends(get_session)): + return complete_setup(session) + + +@router.post('/setup/reconcile') +def setup_reconcile(session: Session = Depends(get_session)): + return reconcile_setup(session) + + +@router.post('/setup/mark-complete') +def setup_mark_complete(payload: dict, session: Session = Depends(get_session)): + return mark_setup_complete(session, payload) + + +@router.post('/setup/voice/mark-configured') +def setup_voice_configured(session: Session = Depends(get_session)): + return mark_voice_configured(session) + + +@router.post('/setup/test/portainer') +def setup_test_portainer(session: Session = Depends(get_session)): + return test_portainer_setup(session) + + +@router.post('/setup/test/unraid') +def setup_test_unraid(session: Session = Depends(get_session)): + return test_unraid_setup(session) + + +@router.post('/setup/test/connector/{connector_id}') +def setup_test_connector(connector_id: str, session: Session = Depends(get_session)): + if connector_id == 'portainer': + return test_portainer_setup(session) + if connector_id == 'unraid': + return test_unraid_setup(session) + if connector_id == 'nginx_proxy_manager': + settings = serialize_connector_settings(session, connector_id) + if settings['missing']: + return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings} + result = NginxProxyManagerConnector().test_connection() + status = str(result.get('status', 'error')) + return {'success': status == 'connected', 'status': status, 'connected': status == 'connected', 'message': str(result.get('reason') or status), 'settings': settings} + if connector_id == 'authentik': + settings = serialize_connector_settings(session, connector_id) + if settings['missing']: + return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings} + result = AuthentikConnector().test_connection() + return _authentik_setup_result(result, settings) + connector_cls = _CONNECTOR_TESTS.get(connector_id) + if connector_cls: + settings = serialize_connector_settings(session, connector_id) + if settings['missing']: + return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': settings['missing'], 'message': 'missing_config', 'settings': settings} + result = connector_cls().test_connection() + return _connector_setup_result(connector_id, result, settings, session) + return { + 'success': False, + 'status': 'unsupported', + 'connected': False, + 'message': f'setup_test_not_implemented_for_{connector_id}', + 'settings': serialize_connector_settings(session, connector_id), + } + + +@router.get('/settings/connectors') +def settings_connectors(session: Session = Depends(get_session)): + return list_connector_settings(session) + + +@router.get('/settings/secrets') +def settings_secrets(session: Session = Depends(get_session)): + return {'storage': secret_storage_status(), 'secrets': list_secret_status(session)} + + +@router.patch('/settings/connectors/portainer') +def update_portainer_settings(payload: ConnectorSettingsRequest, session: Session = Depends(get_session)): + return _save_connector_settings(session, 'portainer', _connector_payload(payload)) + + +@router.patch('/settings/connectors/unraid') +def update_unraid_settings(payload: ConnectorSettingsRequest, session: Session = Depends(get_session)): + return _save_connector_settings(session, 'unraid', _connector_payload(payload)) + + +@router.patch('/settings/connectors/{connector_id}') +def update_connector_settings(connector_id: str, payload: ConnectorSettingsRequest, session: Session = Depends(get_session)): + return _save_connector_settings(session, connector_id, _connector_payload(payload)) diff --git a/backend/app/api/routes_sonarr.py b/backend/app/api/routes_sonarr.py new file mode 100644 index 0000000..dcdf2bb --- /dev/null +++ b/backend/app/api/routes_sonarr.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.sonarr import SonarrConnector +from ..db.session import get_session +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.job_runner import complete_job, create_job, set_job_progress, start_job +from ..services.policy_engine import evaluate_policy +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/sonarr', dependencies=[Depends(load_connector_runtime)]) + + +_ADD_SERIES_STEPS = [ + 'Validate payload', + 'Evaluate policy', + 'Resolve root folder / quality profile defaults', + 'Add series in Sonarr', + 'Optional search missing episodes', + 'Verify series exists', + 'Complete audit/job', +] + + +@router.get('/health') +def health(): + return SonarrConnector().health() + + +@router.get('/queue') +def queue(): + return SonarrConnector().queue_status() + + +@router.get('/wanted') +def wanted(): + return SonarrConnector().wanted_missing() + + +@router.get('/search') +def search(q: str = ''): + query = q.strip() + if not query: + return {'success': False, 'status': 'error', 'message': 'query_required', 'items': []} + return SonarrConnector().search_series(query) + + +@router.post('/series') +def add_series(payload: dict, session: Session = Depends(get_session)): + action = 'sonarr.add_series' + target = 'sonarr' + source = str(payload.get('source', 'manual')).strip() or 'manual' + policy = evaluate_policy(session=session, action=action, target=target, source=source) + risk = str(policy.get('risk', 'low')) + + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + execution_plan={'plan': _ADD_SERIES_STEPS, 'policyReason': policy['reason']}, + ) + return { + 'success': False, + 'decision': 'blocked', + 'status': 'failed', + 'policy_decision': 'blocked', + 'result': 'blocked', + 'message': policy['reason'], + 'auditId': audit.id, + } + + if policy['decision'] == 'approval_required': + approval = create_approval( + session, + action=action, + target=target, + source=source, + risk=risk, + reason=policy['reason'], + requester=source, + ) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + execution_plan={'plan': _ADD_SERIES_STEPS, 'approvalId': approval.id}, + redacted_metadata={'approval_id': approval.id}, + ) + return { + 'success': False, + 'decision': 'approval_required', + 'status': 'failed', + 'policy_decision': 'approval_required', + 'result': 'approval_required', + 'message': 'Action requires approval before execution.', + 'approvalId': approval.id, + 'auditId': audit.id, + } + + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision='allowed', + steps=_ADD_SERIES_STEPS, + ) + start_job(session, job) + set_job_progress(session, job, 35, 'Payload validated and policy allowed.') + + connector_result = SonarrConnector().add_series(payload) + success = bool(connector_result.get('success')) + result_status = 'succeeded' if success else 'failed' + message = str(connector_result.get('message', 'series_added' if success else 'sonarr_failed')) + + completed = complete_job( + session, + job, + status=result_status, + result_summary=message, + ) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if success else 'failed', + execution_plan={'plan': _ADD_SERIES_STEPS, 'jobId': completed.id}, + after_state={'status': connector_result.get('status'), 'message': message}, + redacted_metadata={'connector': 'sonarr'}, + ) + + if not success: + return { + 'success': False, + 'decision': 'failed', + 'status': 'failed', + 'policy_decision': 'allowed', + 'result': 'failed', + 'message': message, + 'jobId': completed.id, + 'auditId': audit.id, + 'data': connector_result.get('data'), + } + + return { + 'success': True, + 'decision': 'allowed', + 'status': 'success', + 'policy_decision': 'allowed', + 'result': 'success', + 'message': message, + 'jobId': completed.id, + 'auditId': audit.id, + 'data': connector_result.get('data', {}), + } diff --git a/backend/app/api/routes_stacks.py b/backend/app/api/routes_stacks.py new file mode 100644 index 0000000..b405b88 --- /dev/null +++ b/backend/app/api/routes_stacks.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +router = APIRouter(prefix='/api/stacks') + + +def _raise_connector_error(response: dict): + status = str(response.get('status', 'error')) + reason = redact_message(str(response.get('reason', 'connection_failed'))) + status_code = 503 if status == 'missing_config' else 502 + raise HTTPException( + status_code=status_code, + detail={ + 'status': status, + 'reason': reason, + 'connector': 'portainer', + }, + ) + + +@router.get('') +def list_stacks(): + response = PortainerConnector().list_stacks() + if response.get('status') == 'ok': + return response.get('stacks', []) + _raise_connector_error(response) + + +@router.get('/{stack_id}') +def get_stack(stack_id: str): + response = PortainerConnector().get_stack_status(stack_id) + if response.get('status') == 'ok': + return response.get('stack') + if response.get('status') == 'not_found': + return None + _raise_connector_error(response) diff --git a/backend/app/api/routes_system.py b/backend/app/api/routes_system.py new file mode 100644 index 0000000..4fef053 --- /dev/null +++ b/backend/app/api/routes_system.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session, select + +from ..connectors.unraid import UnraidConnector +from ..core.redaction import redact_message +from ..db.models import AuditEvent, Connector +from ..db.session import get_session +from ..services.incident_service import count_open_incidents +from ..services.setup_service import apply_stored_connector_settings, list_connector_settings + +router = APIRouter(prefix='/api/system') + + +def _gateway_health(unraid_status: str, portainer_status: str, open_incidents: int) -> str: + core_statuses = {unraid_status, portainer_status} + if core_statuses & {'missing_config', 'error', 'disconnected'}: + return 'critical' + if open_incidents > 0: + return 'warning' + return 'healthy' + + +def _latest_action_label(session: Session) -> str | None: + event = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).first() + if event is None: + return None + action = str(event.action_id or '').strip() + target = str(event.target or '').strip() + if not action: + return None + label = f'{action} -> {target}' if target else action + return redact_message(label)[:120] + + +@router.get('/summary') +def system_summary(session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + connectors = session.exec(select(Connector)).all() + status_map = {connector.id: connector.status for connector in connectors} + config_map = {item["connectorId"]: item for item in list_connector_settings(session)} + + portainer_config = config_map.get('portainer', {}) + portainer_status = 'missing_config' if portainer_config.get('missing') else status_map.get('portainer', 'disconnected') + if portainer_status not in {'connected', 'missing_config', 'error', 'disconnected'}: + portainer_status = 'disconnected' + + unraid_config = config_map.get('unraid', {}) + unraid_status = 'missing_config' if unraid_config.get('missing') else status_map.get('unraid', 'disconnected') + if unraid_status not in {'connected', 'missing_config', 'error', 'disconnected'}: + unraid_status = 'disconnected' + + unraid = UnraidConnector() + server_info = unraid.get_server_info() if unraid.is_configured() else {'status': 'missing_config'} + metrics = unraid.get_system_metrics() if unraid.is_configured() else {'status': 'missing_config'} + cache = unraid.get_cache_status() if unraid.is_configured() else {'status': 'missing_config'} + server = server_info.get('info', {}) if server_info.get('status') == 'ok' else {} + cpu_usage = metrics.get('cpuUsage') if isinstance(metrics.get('cpuUsage'), int | float) else 0 + ram_usage = metrics.get('ramUsage') if isinstance(metrics.get('ramUsage'), int | float) else 0 + cache_usage = cache.get('cacheUsage') if cache.get('status') == 'ok' and isinstance(cache.get('cacheUsage'), int | float) else 0 + open_incidents = count_open_incidents(session) + last_action = _latest_action_label(session) + + return { + 'gatewayHealth': _gateway_health(unraid_status, portainer_status, open_incidents), + 'unraidStatus': unraid_status, + 'portainerStatus': portainer_status, + 'maintenanceMode': False, + 'openIncidents': open_incidents, + 'lastAction': last_action, + 'uptime': str(server.get('uptime') or 'unknown'), + 'cpuUsage': int(cpu_usage), + 'ramUsage': int(ram_usage), + 'cacheUsage': int(cache_usage), + } diff --git a/backend/app/api/routes_tautulli.py b/backend/app/api/routes_tautulli.py new file mode 100644 index 0000000..a117fc7 --- /dev/null +++ b/backend/app/api/routes_tautulli.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.tautulli import TautulliConnector +from ..db.session import get_session +from ..services.audit_logger import log_event +from ..services.policy_engine import evaluate_policy +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/tautulli', dependencies=[Depends(load_connector_runtime)]) + + +def _read( + session: Session, + *, + action: str, + source: str, + risk_default: str, + call: Callable[[TautulliConnector], dict[str, Any]], + audit_read: bool = False, +) -> dict[str, Any]: + identity = source.strip() or 'manual' + policy = evaluate_policy(session=session, action=action, target='tautulli', source=identity) + risk = str(policy.get('risk', risk_default)) + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='read', + identity=identity, + action_id=action, + target='tautulli', + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + redacted_metadata={'privacySensitive': audit_read}, + ) + return {'success': False, 'decision': 'blocked', 'status': 'failed', 'message': policy['reason'], 'auditId': audit.id} + if policy['decision'] == 'approval_required': + audit = log_event( + session, + source='read', + identity=identity, + action_id=action, + target='tautulli', + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_required', + redacted_metadata={'privacySensitive': audit_read}, + ) + return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'message': 'Action requires approval before execution.', 'auditId': audit.id} + result = call(TautulliConnector()) + if audit_read: + log_event( + session, + source='read', + identity=identity, + action_id=action, + target='tautulli', + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if result.get('success') else 'failed', + redacted_metadata={'privacySensitive': True, 'status': result.get('status')}, + ) + return result + + +@router.get('/health') +def health(): + return TautulliConnector().health() + + +@router.get('/status') +def status(): + return TautulliConnector().status() + + +@router.get('/activity') +def activity(): + return TautulliConnector().activity() + + +@router.get('/history') +def history(limit: int = 25, source: str = 'manual', session: Session = Depends(get_session)): + return _read(session, action='tautulli.history', source=source, risk_default='medium', call=lambda connector: connector.history(limit), audit_read=True) + + +@router.get('/users') +def users(source: str = 'manual', session: Session = Depends(get_session)): + return _read(session, action='tautulli.users', source=source, risk_default='medium', call=lambda connector: connector.users(), audit_read=True) + + +@router.get('/libraries') +def libraries(): + return TautulliConnector().libraries() + + +@router.get('/recently-added') +def recently_added(): + return TautulliConnector().recently_added() + + +@router.get('/watch-stats') +def watch_stats(source: str = 'manual', session: Session = Depends(get_session)): + return _read(session, action='tautulli.watch_stats', source=source, risk_default='medium', call=lambda connector: connector.watch_stats(), audit_read=True) + + +@router.get('/streams') +def streams(): + return TautulliConnector().stream_summary() + + +@router.get('/plex-info') +def plex_info(): + return TautulliConnector().plex_info() diff --git a/backend/app/api/routes_unraid.py b/backend/app/api/routes_unraid.py new file mode 100644 index 0000000..0d084c5 --- /dev/null +++ b/backend/app/api/routes_unraid.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.unraid import UnraidConnector +from ..db.session import get_session +from ..services.unraid_vm_service import request_vm_action, vm_capabilities +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/unraid', dependencies=[Depends(load_connector_runtime)]) + + +def _default_summary(): + return { + 'arrayStatus': 'stopped', + 'parityStatus': 'invalid', + 'cacheUsage': 0, + 'diskWarnings': 0, + 'uptime': 'unknown', + 'dockerService': False, + 'cpuUsage': 0, + 'ramUsage': 0, + } + + +@router.get('/summary') +def summary(): + connector = UnraidConnector() + data = _default_summary() + if not connector.is_configured(): + return data + + server_info = connector.get_server_info() + if server_info.get('status') == 'ok': + info = server_info.get('info', {}) + data['uptime'] = str(info.get('uptime', 'unknown')) + + metrics = connector.get_system_metrics() + if metrics.get('status') in {'ok', 'partial'}: + if isinstance(metrics.get('cpuUsage'), int | float): + data['cpuUsage'] = int(metrics.get('cpuUsage', 0)) + if isinstance(metrics.get('ramUsage'), int | float): + data['ramUsage'] = int(metrics.get('ramUsage', 0)) + + array_status = connector.get_array_status() + if array_status.get('status') == 'ok': + data['arrayStatus'] = str(array_status.get('arrayStatus', 'stopped')) + + disk_summary = connector.get_disk_warnings() + if disk_summary.get('status') == 'ok': + data['diskWarnings'] = int(disk_summary.get('diskWarnings', 0)) + + cache_summary = connector.get_cache_status() + if cache_summary.get('status') == 'ok': + data['cacheUsage'] = int(cache_summary.get('cacheUsage', 0)) + + parity_summary = connector.get_parity_status() + if parity_summary.get('status') == 'ok': + data['parityStatus'] = str(parity_summary.get('parityStatus', 'invalid')) + + docker_summary = connector.get_docker_service_status() + if docker_summary.get('status') == 'ok': + data['dockerService'] = bool(docker_summary.get('running', False)) + + return data + + +@router.get('/array') +def array(): + result = UnraidConnector().get_array_status() + if result.get('status') == 'ok': + return {'status': result.get('arrayStatus', 'stopped')} + if result.get('status') == 'missing_config': + return {'status': 'missing_config', 'reason': result.get('reason', 'missing_config')} + return {'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')} + + +@router.get('/disks') +def disks(): + result = UnraidConnector().get_disk_warnings() + if result.get('status') == 'ok': + return {'disks': result.get('disks', []), 'diskWarnings': result.get('diskWarnings', 0)} + if result.get('status') == 'missing_config': + return {'disks': [], 'diskWarnings': 0, 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')} + return {'disks': [], 'diskWarnings': 0, 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')} + + +@router.get('/cache') +def cache(): + result = UnraidConnector().get_cache_status() + if result.get('status') == 'ok': + return {'cacheUsage': result.get('cacheUsage', 0), 'pools': result.get('pools', [])} + if result.get('status') == 'missing_config': + return {'cacheUsage': 0, 'pools': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')} + return {'cacheUsage': 0, 'pools': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')} + + +@router.get('/parity') +def parity(): + result = UnraidConnector().get_parity_status() + if result.get('status') == 'ok': + return { + 'status': result.get('parityStatus', 'invalid'), + 'checkStatus': result.get('checkStatus', 'idle'), + 'running': bool(result.get('running', False)), + 'paused': bool(result.get('paused', False)), + 'correcting': bool(result.get('correcting', False)), + 'progress': result.get('progress'), + 'speed': result.get('speed'), + 'errors': int(result.get('errors', 0)), + } + if result.get('status') == 'missing_config': + return {'status': 'missing_config', 'reason': result.get('reason', 'missing_config')} + return {'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')} + + +@router.get('/docker') +def docker(): + result = UnraidConnector().get_docker_service_status() + if result.get('status') == 'ok': + return { + 'status': result.get('serviceStatus', 'stopped'), + 'running': result.get('running', False), + } + if result.get('status') == 'missing_config': + return {'status': 'missing_config', 'running': False, 'reason': result.get('reason', 'missing_config')} + return {'status': 'disconnected', 'running': False, 'reason': result.get('reason', 'connection_failed')} + + +@router.get('/vms') +def vms(): + result = UnraidConnector().get_vm_summary() + if result.get('status') == 'ok': + return {'vms': result.get('vms', [])} + if result.get('status') == 'missing_config': + return {'vms': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')} + if result.get('status') in {'unavailable', 'api_unavailable'}: + return {'vms': [], 'status': result.get('status'), 'reason': result.get('reason', 'VM API unavailable')} + return {'vms': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')} + + +@router.get('/vms/capabilities') +def vms_capabilities(session: Session = Depends(get_session)): + return vm_capabilities(session) + + +@router.post('/vms/{vm_id}/actions/{action}') +def run_vm_action(vm_id: str, action: str, payload: dict, session: Session = Depends(get_session)): + return request_vm_action( + session, + vm_id=vm_id, + action=action, + source=str(payload.get('source') or 'manual'), + confirmed=payload.get('confirm') is True, + ) + + +@router.get('/shares') +def shares(): + result = UnraidConnector().get_share_summary() + if result.get('status') == 'ok': + return {'shares': result.get('shares', [])} + if result.get('status') == 'missing_config': + return {'shares': [], 'status': 'missing_config', 'reason': result.get('reason', 'missing_config')} + return {'shares': [], 'status': 'disconnected', 'reason': result.get('reason', 'connection_failed')} diff --git a/backend/app/api/routes_voice.py b/backend/app/api/routes_voice.py new file mode 100644 index 0000000..2322f2d --- /dev/null +++ b/backend/app/api/routes_voice.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session + +from ..connectors.authentik import AuthentikConnector +from ..connectors.deluge import DelugeConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.plex import PlexConnector +from ..connectors.prowlarr import ProwlarrConnector +from ..connectors.radarr import RadarrConnector +from ..connectors.sonarr import SonarrConnector +from ..connectors.tautulli import TautulliConnector +from ..connectors.unraid import UnraidConnector +from ..connectors.widefrog import WidefrogConnector +from ..core.redaction import redact_message +from ..db.session import get_session +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.container_restart_service import execute_container_restart +from ..services.policy_engine import evaluate_policy +from ..services import media_pipeline_service, notification_service, safety_center_service +from ..services.setup_service import apply_stored_connector_settings, get_setup_status +from ..services import voice_catalog + +router = APIRouter(prefix='/api/voice') + + +def _handle_voice_action( + session: Session, + action: str, + target: str, + risk: str, + steps: list[str] | None = None, + reader: Callable[[], Any] | None = None, +): + policy = evaluate_policy(session=session, action=action, target=target, source='voice') + decision = policy['decision'] + risk_level = policy.get('risk', risk) + normalized_steps = steps or [] + + if decision == 'blocked': + audit = log_event( + session, + source='voice', + identity='voice', + action_id=action, + target=target, + risk=risk_level, + policy_decision=decision, + reason=policy['reason'], + result='blocked', + execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']}, + redacted_metadata={'policyId': policy.get('policy_id')}, + ) + return { + 'success': False, + 'decision': 'blocked', + 'status': 'failed', + 'policy_decision': decision, + 'result': 'blocked', + 'message': policy['reason'], + 'auditId': audit.id, + } + + if decision == 'approval_required': + approval = create_approval( + session, + action=action, + target=target, + source='voice', + risk=risk_level, + reason=policy['reason'], + requester='voice', + ) + audit = log_event( + session, + source='voice', + identity='voice', + action_id=action, + target=target, + risk=risk_level, + policy_decision=decision, + reason=policy['reason'], + result='approval_requested', + execution_plan={'plan': normalized_steps, 'approvalId': approval.id}, + redacted_metadata={'approval_id': approval.id}, + ) + return { + 'success': False, + 'decision': 'approval_required', + 'status': 'failed', + 'policy_decision': decision, + 'result': 'approval_requested', + 'message': 'Action requires approval before execution.', + 'approvalId': approval.id, + 'auditId': audit.id, + } + + if decision == 'allowed': + try: + data = reader() if reader else None + except Exception as exc: # noqa: BLE001 - voice must return a governed, redacted failure. + message = redact_message(str(exc)) + audit = log_event( + session, source='voice', identity='voice', action_id=action, target=target, + risk=risk_level, policy_decision=decision, reason=policy['reason'], result='failed', + execution_plan={'plan': normalized_steps}, redacted_metadata={'error': message[:200]}, + ) + return { + 'success': False, 'decision': decision, 'status': 'failed', + 'policy_decision': decision, 'result': 'failed', 'message': message, 'auditId': audit.id, + } + audit = log_event( + session, + source='voice', + identity='voice', + action_id=action, + target=target, + risk=risk_level, + policy_decision=decision, + reason=policy['reason'], + result='success', + execution_plan={'plan': normalized_steps, 'policyReason': policy['reason']}, + ) + response = { + 'success': True, + 'decision': 'allowed', + 'status': 'success', + 'policy_decision': decision, + 'result': 'success', + 'message': f'Voice action {action} allowed for {target}.', + 'auditId': audit.id, + } + if data is not None: + response['data'] = data + return response + + return { + 'success': False, + 'decision': 'blocked', + 'status': 'failed', + 'policy_decision': 'blocked', + 'result': 'blocked', + 'message': 'Action policy outcome unsupported.', + } + + +@router.get('/commands') +def voice_commands(session: Session = Depends(get_session)): + return voice_catalog.list_commands(session) + + +@router.get('/home-assistant/export') +def voice_export(request: Request, session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/')) + + +@router.get('/home-assistant/export/rest-commands') +def voice_export_rest_commands(request: Request, session: Session = Depends(get_session)): + return {'yaml': voice_catalog.rest_commands_yaml(voice_catalog.list_commands(session), str(request.base_url).rstrip('/'))} + + +@router.get('/home-assistant/export/scripts') +def voice_export_scripts(session: Session = Depends(get_session)): + return {'yaml': voice_catalog.scripts_yaml(voice_catalog.list_commands(session))} + + +@router.get('/home-assistant/export/dashboard') +def voice_export_dashboard(session: Session = Depends(get_session)): + return {'yaml': voice_catalog.dashboard_yaml(voice_catalog.list_commands(session))} + + +@router.post('/test-intent') +def test_intent(payload: dict | None = None, session: Session = Depends(get_session)): + text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip() + return voice_catalog.test_intent(session, text) + + +@router.post('/commands/{command_id}/dry-run') +def dry_run_command(command_id: str, session: Session = Depends(get_session)): + return voice_catalog.dry_run(session, command_id) + + +@router.post('/commands/{command_id}/request-approval') +def request_command_approval(command_id: str, session: Session = Depends(get_session)): + return voice_catalog.request_approval(session, command_id) + + +@router.post('/actions/container/{app_id}/restart') +def restart_container(app_id: str, session: Session = Depends(get_session)): + return execute_container_restart( + session=session, + action='container.restart', + target=app_id, + source='voice', + risk='medium', + steps=['Resolve container', 'Restart container', 'Voice post-check'], + ) + + +@router.post('/actions/unraid/health') +def unraid_health(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='unraid.health_summary', + target='unraid', + risk='low', + steps=['Read unraid health summary'], + reader=lambda: { + 'array': UnraidConnector().get_array_status(), + 'metrics': UnraidConnector().get_system_metrics(), + 'docker': UnraidConnector().get_docker_service_status(), + }, + ) + + +@router.post('/actions/setup/status') +def setup_status(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='setup.status', + target='appops', + risk='low', + steps=['Read AppOps setup status'], + reader=lambda: get_setup_status(session), + ) + + +@router.post('/actions/safety/summary') +def safety_summary(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='safety.summary', + target='safety', + risk='low', + steps=['Read Safety Center summary'], + reader=lambda: safety_center_service.get_summary(session), + ) + + +@router.post('/actions/media-pipeline/check') +def media_pipeline_check(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='media_pipeline.check', + target='media-pipeline', + risk='low', + steps=['Run read-only media pipeline check'], + reader=lambda: media_pipeline_service.summary(session), + ) + + +@router.post('/actions/notifications/summary') +def notifications_summary(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='notifications.summary', + target='notifications', + risk='low', + steps=['Read notification summary'], + reader=lambda: notification_service.summary(session), + ) + + +@router.post('/actions/notifications/evaluate/safety') +def evaluate_safety_notifications(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='notifications.evaluate_safety', + target='notifications', + risk='medium', + steps=['Evaluate safety notification rules'], + ) + + +@router.post('/actions/apps/widefrog/health') +def widefrog_health(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='widefrog.health', + target='widefrog', + risk='low', + steps=['Read Widefrog health'], + reader=lambda: WidefrogConnector().health(), + ) + + +@router.post('/actions/apps/deluge/status') +def deluge_status(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='deluge.session_status', + target='deluge', + risk='low', + steps=['Read Deluge session status'], + reader=lambda: DelugeConnector().session_status(), + ) + + +@router.post('/actions/apps/plex/activity') +def plex_activity(session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='plex.active_sessions', + target='plex', + risk='low', + steps=['Read Plex active sessions'], + reader=lambda: PlexConnector().active_sessions(), + ) + + +@router.post('/actions/unraid/storage') +def unraid_storage(session: Session = Depends(get_session)): + connector = UnraidConnector() + return _handle_voice_action( + session, 'unraid.health_summary', 'unraid', 'low', ['Read storage health'], + reader=lambda: {'disks': connector.get_disk_warnings(), 'cache': connector.get_cache_status(), 'parity': connector.get_parity_status()}, + ) + + +@router.post('/actions/unraid/vms') +def unraid_vms(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'unraid.health_summary', 'unraid', 'low', ['Read VM inventory'], reader=lambda: UnraidConnector().get_vm_summary()) + + +@router.post('/actions/apps/sonarr/health') +def sonarr_health(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'sonarr.health', 'sonarr', 'low', ['Read Sonarr health'], reader=lambda: SonarrConnector().health()) + + +@router.post('/actions/apps/sonarr/queue') +def sonarr_queue(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'sonarr.queue_status', 'sonarr', 'low', ['Read Sonarr queue'], reader=lambda: SonarrConnector().queue_status()) + + +@router.post('/actions/apps/radarr/health') +def radarr_health(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'radarr.health', 'radarr', 'low', ['Read Radarr health'], reader=lambda: RadarrConnector().health()) + + +@router.post('/actions/apps/radarr/queue') +def radarr_queue(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'radarr.queue_status', 'radarr', 'low', ['Read Radarr queue'], reader=lambda: RadarrConnector().queue_status()) + + +@router.post('/actions/apps/prowlarr/indexers') +def prowlarr_indexers(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'prowlarr.indexer_health', 'prowlarr', 'low', ['Read Prowlarr indexer health'], reader=lambda: ProwlarrConnector().indexer_health()) + + +@router.post('/actions/apps/deluge/stalled') +def deluge_stalled(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'deluge.stalled_downloads', 'deluge', 'low', ['Read stalled downloads'], reader=lambda: DelugeConnector().stalled_downloads()) + + +@router.post('/actions/apps/plex/recent') +def plex_recent(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'plex.recent_media', 'plex', 'low', ['Read recent Plex media'], reader=lambda: PlexConnector().recent_media()) + + +@router.post('/actions/apps/tautulli/activity') +def tautulli_activity(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'tautulli.activity', 'tautulli', 'low', ['Read Tautulli activity'], reader=lambda: TautulliConnector().activity()) + + +@router.post('/actions/security/exposure') +def exposure_summary(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'npm.exposure_summary', 'nginx_proxy_manager', 'low', ['Read public exposure'], reader=lambda: NginxProxyManagerConnector().exposure_summary()) + + +@router.post('/actions/security/authentik') +def authentik_health(session: Session = Depends(get_session)): + return _handle_voice_action(session, 'authentik.health', 'authentik', 'low', ['Read Authentik health'], reader=lambda: AuthentikConnector().health()) + + +@router.post('/actions/playbooks/{playbook_id}/run') +def run_playbook(playbook_id: str, session: Session = Depends(get_session)): + return _handle_voice_action( + session=session, + action='playbook.run', + target=playbook_id, + risk='medium', + steps=['Run playbook'], + ) diff --git a/backend/app/api/routes_voice_operator.py b/backend/app/api/routes_voice_operator.py new file mode 100644 index 0000000..d622dda --- /dev/null +++ b/backend/app/api/routes_voice_operator.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session + +from ..db.session import get_session +from ..services import voice_catalog +from ..services.home_assistant_voice_readiness import build_readiness +from ..services.setup_service import apply_stored_connector_settings + + +router = APIRouter(prefix='/api/operator/voice') + + +@router.get('/commands') +def commands(session: Session = Depends(get_session)): + return voice_catalog.list_commands(session) + + +@router.get('/home-assistant/export') +def home_assistant_export(request: Request, session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + return voice_catalog.home_assistant_export(session, str(request.base_url).rstrip('/')) + + +@router.get('/home-assistant/readiness') +def home_assistant_readiness(session: Session = Depends(get_session)): + apply_stored_connector_settings(session) + return build_readiness(session) + + +@router.post('/test-intent') +def test_intent(payload: dict | None = None, session: Session = Depends(get_session)): + text = str((payload or {}).get('input') or (payload or {}).get('phrase') or '').strip() + return voice_catalog.test_intent(session, text) + + +@router.post('/commands/{command_id}/dry-run') +def dry_run(command_id: str, session: Session = Depends(get_session)): + return voice_catalog.dry_run(session, command_id) diff --git a/backend/app/api/routes_widefrog.py b/backend/app/api/routes_widefrog.py new file mode 100644 index 0000000..42c5162 --- /dev/null +++ b/backend/app/api/routes_widefrog.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from ..connectors.widefrog import WidefrogConnector +from ..db.session import get_session +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.job_runner import complete_job, create_job, set_job_progress, start_job +from ..services.policy_engine import evaluate_policy +from .connector_runtime import load_connector_runtime + +router = APIRouter(prefix='/api/apps/widefrog', dependencies=[Depends(load_connector_runtime)]) + + +_RETRY_STEPS = [ + 'Validate connector config', + 'Evaluate policy', + 'Fetch failed jobs summary', + 'Call retry endpoint', + 'Fetch queue/activity summary', + 'Complete audit/job', +] + +_SCAN_STEPS = [ + 'Validate connector config', + 'Evaluate policy', + 'Check app health', + 'Call scan endpoint', + 'Verify accepted or status', + 'Complete audit/job', +] + + +@router.get('/health') +def health(): + return WidefrogConnector().health() + + +@router.get('/queue') +def queue(): + return WidefrogConnector().queue_status() + + +@router.get('/failed') +def failed(): + return WidefrogConnector().failed_jobs() + + +@router.get('/activity') +def activity(): + return WidefrogConnector().activity() + + +@router.get('/download-status') +def download_status(): + return WidefrogConnector().download_status() + + +@router.post('/retry-failed') +def retry_failed(payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_widefrog_action( + session=session, + payload=payload or {}, + action='widefrog.retry_failed', + steps=_RETRY_STEPS, + connector_call=lambda connector, body: connector.retry_failed(body), + default_success_message='retry_started', + ) + + +@router.post('/scan-library') +def scan_library(payload: dict | None = None, session: Session = Depends(get_session)): + return _execute_widefrog_action( + session=session, + payload=payload or {}, + action='widefrog.scan_library', + steps=_SCAN_STEPS, + connector_call=lambda connector, body: connector.scan_library(body), + default_success_message='scan_started', + ) + + +def _execute_widefrog_action( + *, + session: Session, + payload: dict[str, Any], + action: str, + steps: list[str], + connector_call: Callable[[WidefrogConnector, dict[str, Any]], dict[str, Any]], + default_success_message: str, +) -> dict[str, Any]: + target = 'widefrog' + source = str(payload.get('source', 'manual')).strip() or 'manual' + policy = evaluate_policy(session=session, action=action, target=target, source=source) + risk = str(policy.get('risk', 'medium')) + + if policy['decision'] == 'blocked': + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='blocked', + reason=policy['reason'], + result='blocked', + execution_plan={'plan': steps, 'policyReason': policy['reason']}, + ) + return {'success': False, 'decision': 'blocked', 'status': 'failed', 'policy_decision': 'blocked', 'result': 'blocked', 'message': policy['reason'], 'auditId': audit.id} + + if policy['decision'] == 'approval_required': + approval = create_approval(session, action=action, target=target, source=source, risk=risk, reason=policy['reason'], requester=source) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + execution_plan={'plan': steps, 'approvalId': approval.id}, + redacted_metadata={'approval_id': approval.id}, + ) + return {'success': False, 'decision': 'approval_required', 'status': 'failed', 'policy_decision': 'approval_required', 'result': 'approval_required', 'message': 'Action requires approval before execution.', 'approvalId': approval.id, 'auditId': audit.id} + + job = create_job(session, action_id=action, target=target, source=source, risk=risk, policy_decision='allowed', steps=steps) + start_job(session, job) + set_job_progress(session, job, 35, 'Policy allowed; calling Widefrog connector.') + + connector_result = connector_call(WidefrogConnector(), payload) + success = bool(connector_result.get('success')) + message = str(connector_result.get('message', default_success_message if success else 'widefrog_failed')) + completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message) + audit = log_event( + session, + source='action', + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision='allowed', + reason=policy['reason'], + result='success' if success else 'failed', + execution_plan={'plan': steps, 'jobId': completed.id}, + after_state={'status': connector_result.get('status'), 'message': message}, + redacted_metadata={'connector': 'widefrog'}, + ) + + if not success: + return {'success': False, 'decision': 'failed', 'status': 'failed', 'policy_decision': 'allowed', 'result': 'failed', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data')} + + return {'success': True, 'decision': 'allowed', 'status': 'success', 'policy_decision': 'allowed', 'result': 'success', 'message': message, 'jobId': completed.id, 'auditId': audit.id, 'data': connector_result.get('data', {})} diff --git a/backend/app/connectors/authentik.py b/backend/app/connectors/authentik.py new file mode 100644 index 0000000..0065cbd --- /dev/null +++ b/backend/app/connectors/authentik.py @@ -0,0 +1,817 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin, urlparse + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _AuthentikConfig: + url: str + token: str + verify_tls: bool + timeout_seconds: float + outpost_url: str | None + expect_embedded_outpost: bool + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _token_value(value: Any) -> str: + token = str(value or '').strip() + if token.lower().startswith('bearer '): + return token[7:].strip() + return token + + +def _as_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if value in {None, ''}: + return [] + return [value] + + +def _safe_url(value: Any = None) -> str: + raw = str(value or settings.apis.get('AUTHENTIK_URL') or '').strip().rstrip('/') + if not raw: + return '' + parsed = urlparse(raw) + if not parsed.username and not parsed.password: + return raw + host = parsed.hostname or '' + port = f':{parsed.port}' if parsed.port else '' + return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/') + + +def _domain(value: Any) -> str: + text = str(value or '').strip() + if not text: + return '' + parsed = urlparse(text if '://' in text else f'https://{text}') + return (parsed.hostname or text).lower().strip() + + +def _response_items(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + data = payload.get('data') + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(payload.get('results'), list): + return [item for item in payload['results'] if isinstance(item, dict)] + if isinstance(payload.get('items'), list): + return [item for item in payload['items'] if isinstance(item, dict)] + return [] + + +def _pagination_next(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + pagination = payload.get('pagination') + if isinstance(pagination, dict) and pagination.get('next'): + return str(pagination['next']) + if payload.get('next'): + return str(payload['next']) + return None + + +class AuthentikConnector(BaseConnector): + name = 'Authentik' + + def __init__(self): + super().__init__() + self._read_cache: dict[str, dict[str, Any]] = {} + + def _memo(self, key: str, factory: Any) -> dict[str, Any]: + if key not in self._read_cache: + self._read_cache[key] = factory() + return self._read_cache[key] + + def is_configured(self) -> bool: + return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN')) + + def _config(self) -> _AuthentikConfig | None: + if not self.is_configured(): + return None + return _AuthentikConfig( + url=str(settings.apis['AUTHENTIK_URL']).rstrip('/'), + token=_token_value(settings.apis['AUTHENTIK_TOKEN']), + verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False), + timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0), + outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None, + expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False), + ) + + def _request(self, path: str, params: dict[str, Any] | None = None) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + url = path if path.startswith(('http://', 'https://')) else f'{config.url}/api/v3{path}' + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.get( + url, + params=params, + headers={'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'}, + ) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + try: + return response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + def _write_request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: + """Use the existing Authentik API identity for one typed mutation. + + Callers in this repository pass only a DeploymentPlan-derived payload; + this method is private so routes cannot turn it into a generic Authentik + proxy. + """ + config = self._config() + if not config: + raise RuntimeError('missing_config') + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + request_kwargs: dict[str, Any] = { + 'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'}, + } + if payload is not None: + request_kwargs['json'] = payload + response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + if not response.content: + return {} + try: + return response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + def _paged(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: + config = self._config() + if not config: + raise RuntimeError('missing_config') + items: list[dict[str, Any]] = [] + base_path = path + base_params = dict(params or {}) + next_path: str | None = path + next_params = params + for _ in range(20): + if not next_path: + break + payload = self._request(next_path, next_params) + items.extend(_response_items(payload)) + next_value = _pagination_next(payload) + if not next_value: + break + if next_value.isdigit(): + next_path = base_path + next_params = {**base_params, 'page': int(next_value)} + continue + if next_value.startswith(('http://', 'https://')): + parsed_base = urlparse(config.url) + parsed_next = urlparse(next_value) + if parsed_next.netloc and parsed_next.netloc != parsed_base.netloc: + break + next_path = next_value + else: + next_path = urljoin('/api/v3/', next_value).replace('/api/v3', '', 1) + next_params = None + return items + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + try: + root = self._request('/root/config/') + except RuntimeError as exc: + reason = redact_message(str(exc)) + if reason.startswith('auth_failed'): + return {'status': 'auth_failed', 'reason': reason} + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable', 'reason': reason} + return {'status': 'connected', 'version': self._version(root)} + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(applicationCount=0, outpostAvailable=False) + try: + result = self.test_connection() + if result.get('status') != 'connected': + return self._error_payload(RuntimeError(str(result.get('reason', result.get('status')))), applicationCount=0, outpostAvailable=False) + apps = self.applications() + outpost = self.outpost_health() + except RuntimeError as exc: + return self._error_payload(exc, applicationCount=0, outpostAvailable=False) + return { + 'success': True, + 'status': 'connected', + 'message': 'Authentik connector ready.', + 'url': _safe_url(), + 'version': str(result.get('version') or 'unknown'), + 'applicationCount': len(apps.get('items', [])), + 'outpostAvailable': bool(outpost.get('available')), + 'data': {}, + } + + def system_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing() + try: + root = self._request('/root/config/') + except RuntimeError as exc: + return self._error_payload(exc) + data = { + 'version': self._version(root), + 'brand': root.get('brand') if isinstance(root, dict) else None, + 'url': _safe_url(), + } + return {'success': True, 'status': 'ok', 'message': 'system_status_ok', **data, 'data': data} + + def applications(self) -> dict[str, Any]: + return self._memo('applications', lambda: self._collection('/core/applications/', self._map_applications, 'applications_ok')) + + def providers(self) -> dict[str, Any]: + return self._memo('providers', lambda: self._collection('/core/providers/', self._map_providers, 'providers_ok')) + + def proxy_providers(self) -> dict[str, Any]: + return self._memo('proxy_providers', lambda: self._collection('/providers/proxy/', self._map_proxy_providers, 'proxy_providers_ok')) + + def oauth2_providers(self) -> dict[str, Any]: + return self._memo('oauth2_providers', lambda: self._collection('/providers/oauth2/', self._map_oauth2_providers, 'oauth2_providers_ok')) + + def groups(self) -> dict[str, Any]: + """Read role-group identities for enrollment takeover-safe preflight.""" + return self._memo( + 'groups', + lambda: self._collection( + '/core/groups/', + lambda items: [ + {'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or '')} + for item in items + ], + 'groups_ok', + ), + ) + + @staticmethod + def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]: + """Report only whether the server-owned OIDC template is configured.""" + required = ( + 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', + 'AUTHENTIK_OIDC_INVALIDATION_FLOW', + 'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING', + ) + missing = [name for name in required if not str(settings.apis.get(name) or '').strip()] + configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip() + if configured_claim != role_claim: + missing.append('AUTHENTIK_OIDC_ROLE_CLAIM') + return { + 'status': 'ready' if not missing else 'missing_config', + 'missing': missing, + } + + def ensure_deployment_oidc_application( + self, + *, + application_slug: str, + launch_url: str, + redirect_uri: str, + client_secret: str, + administrator_group: str, + support_agent_group: str, + read_only_group: str, + ) -> dict[str, Any]: + """Provision one Authentik OIDC app from the reviewed deployment template. + + The method refuses tenant-wide group/flow creation and never returns the + supplied/generated client secret. Existing applications are accepted + only when their slug and launch URL already identify this deployment. + """ + if not self.is_configured(): + return self._missing(reason='missing_config') + if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug): + return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'} + if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url): + return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'} + if not redirect_uri.startswith(f'{launch_url}/') or '?' in redirect_uri or '#' in redirect_uri: + return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'} + authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() + invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() + group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip() + if not authorization_flow or not invalidation_flow or not group_claim_mapping: + return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'} + try: + groups = self._paged('/core/groups/') + group_names = {str(item.get('name') or '') for item in groups} + required_groups = {administrator_group, support_agent_group, read_only_group} + if not required_groups.issubset(group_names): + return {'success': False, 'status': 'blocked', 'message': 'required_role_groups_are_missing'} + applications = self._paged('/core/applications/') + matches = [item for item in applications if str(item.get('slug') or '') == application_slug] + if len(matches) > 1: + return {'success': False, 'status': 'conflict', 'message': 'multiple_authentik_applications_with_slug'} + if matches: + existing = matches[0] + existing_url = str(existing.get('meta_launch_url') or existing.get('launch_url') or '') + if existing_url.rstrip('/') != launch_url.rstrip('/'): + return {'success': False, 'status': 'conflict', 'message': 'application_slug_owned_by_different_launch_url'} + provider_id = existing.get('provider') or existing.get('provider_id') + providers = self._paged('/providers/oauth2/') + provider = next( + (item for item in providers if str(item.get('pk') or item.get('id')) == str(provider_id)), + None, + ) + provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None + if isinstance(provider_redirects, str): + provider_redirects = [item for item in provider_redirects.splitlines() if item.strip()] + provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None + if ( + not isinstance(provider, dict) + or str(provider.get('client_id') or '') != application_slug + or str(provider.get('client_type') or '') != 'confidential' + or provider_redirects != [redirect_uri] + or group_claim_mapping not in (provider_mappings or []) + or str(provider.get('authorization_flow') or '') != authorization_flow + or str(provider.get('invalidation_flow') or '') != invalidation_flow + ): + return {'success': False, 'status': 'conflict', 'message': 'existing_oidc_provider_does_not_match_managed_contract'} + return { + 'success': True, + 'status': 'already_managed', + 'message': 'oidc_application_already_matches_typed_plan', + 'applicationId': existing.get('pk') or existing.get('id'), + 'clientSecretCreated': False, + } + + provider = self._write_request( + 'POST', + '/providers/oauth2/', + { + 'name': f'{application_slug} OIDC', + 'authorization_flow': authorization_flow, + 'invalidation_flow': invalidation_flow, + 'client_type': 'confidential', + 'client_id': application_slug, + 'client_secret': client_secret, + 'redirect_uris': [redirect_uri], + 'property_mappings': [group_claim_mapping], + 'include_claims_in_id_token': True, + 'sub_mode': 'hashed_user_id', + 'issuer_mode': 'per_provider', + }, + ) + provider_id = provider.get('pk') or provider.get('id') if isinstance(provider, dict) else None + if not provider_id: + return {'success': False, 'status': 'failed', 'message': 'oidc_provider_response_missing_identifier'} + try: + application = self._write_request( + 'POST', + '/core/applications/', + { + 'name': application_slug, + 'slug': application_slug, + 'provider': provider_id, + 'meta_launch_url': launch_url, + }, + ) + except RuntimeError: + # A generated client secret must never appear in an API error, + # audit record or browser response. Compensate only the provider + # this method just created, and fail closed if that cleanup is + # not acknowledged by Authentik. + try: + self._write_request('DELETE', f'/providers/oauth2/{provider_id}/') + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_failed_provider_cleanup_failed'} + return {'success': False, 'status': 'failed', 'message': 'oidc_application_creation_failed_provider_removed'} + except RuntimeError: + # The provider request carries the confidential client secret. A + # remote error body is not a safe channel for returning it. + return {'success': False, 'status': 'failed', 'message': 'oidc_provider_creation_failed'} + application_id = application.get('pk') or application.get('id') if isinstance(application, dict) else None + if not application_id: + try: + self._write_request('DELETE', f'/providers/oauth2/{provider_id}/') + except RuntimeError: + return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_response_missing_identifier_provider_cleanup_failed'} + return {'success': False, 'status': 'failed', 'message': 'oidc_application_response_missing_identifier_provider_removed'} + return { + 'success': True, + 'status': 'created', + 'message': 'oidc_application_created_from_typed_template', + 'applicationId': application_id, + 'providerId': provider_id, + 'clientSecretCreated': True, + } + + def remove_deployment_oidc_application( + self, + *, + application_id: str | int, + provider_id: str | int, + application_slug: str, + launch_url: str, + ) -> dict[str, Any]: + """Compensate only an OIDC pair created during this enrollment call. + + This is intentionally private to the enrollment orchestrator. It first + re-reads both resources and refuses to delete if the identity is no + longer exactly the bounded application/provider pair it created. + """ + if not self.is_configured(): + return self._missing(reason='missing_config') + if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug): + return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'} + if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url): + return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'} + try: + applications = self._paged('/core/applications/') + matches = [ + item for item in applications + if str(item.get('pk') or item.get('id')) == str(application_id) + ] + if len(matches) != 1: + return {'success': False, 'status': 'not_found', 'message': 'managed_oidc_application_not_found'} + application = matches[0] + if ( + application.get('slug') != application_slug + or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') != launch_url.rstrip('/') + or str(application.get('provider') or application.get('provider_id') or '') != str(provider_id) + ): + return {'success': False, 'status': 'conflict', 'message': 'oidc_application_is_not_the_created_managed_pair'} + provider = self._write_request('GET', f'/providers/oauth2/{provider_id}/') + if not isinstance(provider, dict) or str(provider.get('client_id') or '') != application_slug: + return {'success': False, 'status': 'conflict', 'message': 'oidc_provider_is_not_the_created_managed_pair'} + self._write_request('DELETE', f'/core/applications/{application_id}/') + self._write_request('DELETE', f'/providers/oauth2/{provider_id}/') + except RuntimeError as exc: + return self._error_payload(exc) + return { + 'success': True, + 'status': 'removed', + 'applicationId': application_id, + 'providerId': provider_id, + } + + def outposts(self) -> dict[str, Any]: + return self._memo('outposts', lambda: self._collection('/outposts/instances/', self._map_outposts, 'outposts_ok')) + + def outpost_health(self) -> dict[str, Any]: + return self._memo('outpost_health', self._outpost_health_uncached) + + def _outpost_health_uncached(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0, available=False, embeddedExpected=False) + outposts = self.outposts() + if not outposts.get('success'): + return {**outposts, 'available': False} + items = outposts.get('items', []) + available = any(str(item.get('health') or '').lower() in {'healthy', 'ok', 'connected'} for item in items) + config = self._config() + embedded_expected = bool(config and config.expect_embedded_outpost) + reachability = {'status': 'reported_healthy', 'reachable': True, 'statusCode': None} if available else self._probe_outpost(config) if config and config.outpost_url else { + 'status': 'not_configured', + 'reachable': None, + 'statusCode': None, + } + reachable = reachability.get('reachable') + status = 'ok' if available else ('reachable_unverified' if reachable else ('warning' if embedded_expected else 'unknown')) + if available: + message = 'outpost_health_ok' + elif reachable: + message = 'outpost_reachable_health_unverified' + else: + message = 'outpost_health_unknown' + data = { + 'items': items, + 'available': available, + 'embeddedExpected': embedded_expected, + 'outpostUrl': _safe_url(config.outpost_url) if config and config.outpost_url else None, + 'reachable': reachable, + 'reachabilityStatus': reachability.get('status'), + 'reachabilityStatusCode': reachability.get('statusCode'), + 'telemetryStatus': 'reported' if available else 'unverified', + } + return {'success': True, 'status': status, 'message': message, 'items': items, 'total': len(items), **data, 'data': data} + + @staticmethod + def _probe_outpost(config: _AuthentikConfig) -> dict[str, Any]: + if not config.outpost_url: + return {'status': 'not_configured', 'reachable': None, 'statusCode': None} + try: + # Never forward the Authentik API bearer token to a separately + # configured outpost origin. This probe verifies reachability only. + with httpx.Client( + timeout=min(config.timeout_seconds, 8.0), + verify=config.verify_tls, + follow_redirects=False, + ) as client: + response = client.get( + config.outpost_url, + headers={'Accept': 'text/html,application/json;q=0.9,*/*;q=0.8'}, + ) + except httpx.RequestError: + return {'status': 'unreachable', 'reachable': False, 'statusCode': None} + reachable = response.status_code < 500 + return { + 'status': 'reachable' if reachable else 'degraded', + 'reachable': reachable, + 'statusCode': response.status_code, + } + + def flows_summary(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0, byDesignation={}) + try: + items = self._paged('/flows/instances/') + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0, byDesignation={}) + mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or item.get('slug') or ''), 'designation': str(item.get('designation') or 'unknown')} for item in items] + by_designation: dict[str, int] = {} + for item in mapped: + by_designation[item['designation']] = by_designation.get(item['designation'], 0) + 1 + return {'success': True, 'status': 'ok', 'message': 'flows_summary_ok', 'total': len(mapped), 'items': mapped, 'byDesignation': by_designation, 'data': {'items': mapped, 'byDesignation': by_designation}} + + def policies_summary(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0, enabled=0, disabled=0) + try: + items = self._paged('/policies/all/') + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0, enabled=0, disabled=0) + mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'enabled': bool(item.get('enabled', True))} for item in items] + enabled = sum(1 for item in mapped if item['enabled']) + data = {'items': mapped, 'enabled': enabled, 'disabled': len(mapped) - enabled} + return {'success': True, 'status': 'ok', 'message': 'policies_summary_ok', 'total': len(mapped), **data, 'data': data} + + def events(self, limit: int = 25) -> dict[str, Any]: + cache_key = f'events:{max(1, min(limit, 100))}' + return self._memo(cache_key, lambda: self._events_uncached(limit)) + + def _events_uncached(self, limit: int = 25) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0) + try: + raw = self._paged('/events/events/', {'page_size': max(1, min(limit, 100))}) + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0) + items = [self._map_event(item) for item in raw[:limit]] + return {'success': True, 'status': 'ok', 'message': 'events_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def auth_coverage(self) -> dict[str, Any]: + return self._memo('auth_coverage', self._auth_coverage_uncached) + + def _auth_coverage_uncached(self) -> dict[str, Any]: + apps = self.applications() + providers = self.providers() + proxy = self.proxy_providers() + if not apps.get('success'): + return {**apps, 'summary': self._empty_coverage_summary(), 'publicHosts': [], 'data': {'publicHosts': [], 'summary': self._empty_coverage_summary()}} + npm_hosts = self._npm_hosts() + hosts = npm_hosts.get('items', []) if npm_hosts.get('success') else [] + proxy_domains = {_domain(item.get('externalHost')): item for item in proxy.get('items', []) if _domain(item.get('externalHost'))} if proxy.get('success') else {} + app_domains = {_domain(item.get('launchUrl')): item for item in apps.get('items', []) if _domain(item.get('launchUrl'))} + public_hosts: list[dict[str, Any]] = [] + for host in hosts: + if not host.get('enabled', True): + continue + domains = [str(item) for item in _as_list(host.get('domainNames'))] + matches = [domain for domain in domains if _domain(domain) in proxy_domains or _domain(domain) in app_domains] + status = 'protected' if matches else ('likely_protected' if host.get('accessListId') else 'unprotected') + public_hosts.append({ + 'hostId': host.get('id'), + 'domains': domains, + 'target': f"{host.get('forwardScheme', 'http')}://{host.get('forwardHost', '')}:{host.get('forwardPort', '')}", + 'coverageStatus': status, + 'authentikMatches': matches, + 'accessListId': host.get('accessListId'), + }) + summary = { + 'totalApplications': len(apps.get('items', [])), + 'totalProviders': len(providers.get('items', [])) if providers.get('success') else 0, + 'totalProxyProviders': len(proxy.get('items', [])) if proxy.get('success') else 0, + 'totalPublicHosts': len(public_hosts), + 'protectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'protected'), + 'likelyProtectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'likely_protected'), + 'unprotectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'unprotected'), + 'npmStatus': npm_hosts.get('status', 'unknown'), + } + data = {'summary': summary, 'publicHosts': public_hosts} + return {'success': True, 'status': 'ok', 'message': 'auth_coverage_ok', 'summary': summary, 'publicHosts': public_hosts, 'data': data} + + def security_warnings(self) -> dict[str, Any]: + return self._memo('security_warnings', self._security_warnings_uncached) + + def _security_warnings_uncached(self) -> dict[str, Any]: + warnings: list[dict[str, Any]] = [] + if not self.is_configured(): + npm_hosts = self._npm_hosts() + if npm_hosts.get('success') and npm_hosts.get('items'): + warnings.append({'type': 'authentik_missing_config_with_public_hosts', 'severity': 'high', 'title': 'Authentik connector is missing while NPM has public hosts', 'evidence': [str(len(npm_hosts.get('items', [])))], 'suggestedAction': 'configure_authentik_connector'}) + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': warnings, 'total': len(warnings), 'data': {'items': warnings}} + coverage = self.auth_coverage() + outpost = self.outpost_health() + apps = self.applications() + proxy = self.proxy_providers() + oauth = self.oauth2_providers() + events = self.events() + if not outpost.get('items'): + warnings.append({'type': 'no_outposts', 'severity': 'high', 'title': 'No Authentik outposts reported', 'evidence': [], 'suggestedAction': 'review_outpost_health'}) + if outpost.get('embeddedExpected') and not outpost.get('available'): + outpost_status = str(outpost.get('status') or 'unknown') + if outpost.get('reachable'): + warnings.append({ + 'type': 'embedded_outpost_health_unverified', + 'severity': 'info', + 'title': 'Embedded outpost is reachable but health telemetry is not reported', + 'evidence': [outpost_status, str(outpost.get('reachabilityStatusCode') or '')], + 'suggestedAction': 'review_embedded_outpost_telemetry', + }) + else: + warnings.append({'type': 'embedded_outpost_unavailable', 'severity': 'warning', 'title': 'Expected embedded outpost is unavailable or unknown', 'evidence': [outpost_status], 'suggestedAction': 'review_embedded_outpost'}) + for host in coverage.get('publicHosts', []): + if host.get('coverageStatus') == 'unprotected': + warnings.append({'type': 'public_host_without_authentik', 'severity': 'high', 'title': f"{', '.join(host.get('domains') or [])} has no Authentik mapping", 'evidence': host.get('domains') or [], 'suggestedAction': 'map_host_to_authentik'}) + app_slugs = {str(item.get('slug')) for item in apps.get('items', []) if item.get('slug')} + provider_app_slugs = {str(item.get('assignedApplicationSlug')) for item in proxy.get('items', []) if item.get('assignedApplicationSlug')} + for provider in proxy.get('items', []): + if not provider.get('assignedApplicationSlug'): + warnings.append({'type': 'proxy_provider_without_app', 'severity': 'warning', 'title': f"{provider.get('name')} is not assigned to an application", 'evidence': [str(provider.get('externalHost') or provider.get('id'))], 'suggestedAction': 'assign_proxy_provider'}) + for app in apps.get('items', []): + slug = str(app.get('slug') or '') + if slug and slug not in provider_app_slugs and not app.get('providerId'): + warnings.append({'type': 'application_without_provider', 'severity': 'warning', 'title': f"{app.get('name')} has no provider mapping", 'evidence': [slug], 'suggestedAction': 'review_application_provider'}) + for provider in oauth.get('items', []): + if _as_int(provider.get('redirectUriCount')) >= 4: + warnings.append({'type': 'oauth_many_redirect_uris', 'severity': 'warning', 'title': f"{provider.get('name')} has many redirect URIs", 'evidence': [str(provider.get('redirectUriCount'))], 'suggestedAction': 'review_oauth_redirect_uris'}) + if not events.get('items'): + warnings.append({'type': 'no_recent_events', 'severity': 'info', 'title': 'No recent Authentik events returned', 'evidence': [], 'suggestedAction': 'review_event_retention_or_permissions'}) + warnings = [item for item in warnings if item.get('evidence') is not app_slugs] + severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0} + items = sorted(warnings, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True) + status = 'warning' if items else 'ok' + return {'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'items': items, 'total': len(items), 'data': {'items': items}} + + def _collection(self, path: str, mapper: Any, message: str) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0) + try: + items = mapper(self._paged(path)) + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0) + return {'success': True, 'status': 'ok', 'message': message, 'total': len(items), 'items': items, 'data': {'items': items}} + + @staticmethod + def _version(payload: Any) -> str: + if not isinstance(payload, dict): + return 'unknown' + return str(payload.get('version') or payload.get('build') or payload.get('authentik_version') or 'unknown') + + @staticmethod + def _map_applications(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [{ + 'id': item.get('pk') or item.get('id'), + 'name': str(item.get('name') or ''), + 'slug': str(item.get('slug') or ''), + 'launchUrl': str(item.get('launch_url') or item.get('launchUrl') or item.get('meta_launch_url') or ''), + 'providerId': item.get('provider') or item.get('providerId'), + } for item in raw] + + @staticmethod + def _map_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: + result = [] + for item in raw: + type_text = str(item.get('type') or item.get('component') or '').lower() + provider_type = 'proxy' if 'proxy' in type_text else ('oauth2' if 'oauth' in type_text or 'oidc' in type_text else 'unknown') + result.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'providerType': provider_type, 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug')}) + return result + + @staticmethod + def _map_proxy_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [{ + 'id': item.get('pk') or item.get('id'), + 'name': str(item.get('name') or ''), + 'externalHost': str(item.get('external_host') or item.get('externalHost') or ''), + 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), + 'mode': str(item.get('mode') or ''), + } for item in raw] + + @staticmethod + def _map_oauth2_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: + mapped = [] + for item in raw: + redirect_uris = item.get('redirect_uris') or item.get('redirectUris') or [] + if isinstance(redirect_uris, str): + uris = [line for line in redirect_uris.splitlines() if line.strip()] + else: + uris = [str(uri) for uri in _as_list(redirect_uris)] + mapped.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), 'redirectUriCount': len(uris), 'redirectUris': uris[:10]}) + return mapped + + @staticmethod + def _map_outposts(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [{ + 'id': item.get('pk') or item.get('id'), + 'name': str(item.get('name') or ''), + 'type': str(item.get('type') or item.get('outpost_type') or 'unknown'), + 'health': str(item.get('health') or item.get('health_status') or item.get('status') or 'unknown'), + 'providerCount': len(_as_list(item.get('providers'))), + } for item in raw] + + @staticmethod + def _map_event(raw: dict[str, Any]) -> dict[str, Any]: + user = raw.get('user') if isinstance(raw.get('user'), dict) else {} + return { + 'id': raw.get('pk') or raw.get('id'), + 'action': str(raw.get('action') or raw.get('event') or ''), + 'created': raw.get('created') or raw.get('timestamp'), + 'username': str(user.get('username') or raw.get('username') or ''), + 'clientIp': AuthentikConnector._mask_ip(raw.get('client_ip') or raw.get('clientIp')), + } + + @staticmethod + def _mask_ip(value: Any) -> str: + text = str(value or '') + parts = text.split('.') + if len(parts) == 4: + return '.'.join(parts[:3] + ['x']) + return 'masked' if text else '' + + @staticmethod + def _empty_coverage_summary() -> dict[str, Any]: + return {'totalApplications': 0, 'totalProviders': 0, 'totalProxyProviders': 0, 'totalPublicHosts': 0, 'protectedHosts': 0, 'likelyProtectedHosts': 0, 'unprotectedHosts': 0, 'npmStatus': 'unknown'} + + @staticmethod + def _missing(**extra: Any) -> dict[str, Any]: + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], 'data': {}, **extra} + + @staticmethod + def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: + reason = redact_message(str(exc)) + status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable') + return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra} + + @staticmethod + def _npm_hosts() -> dict[str, Any]: + try: + from .nginx_proxy_manager import NginxProxyManagerConnector + + return NginxProxyManagerConnector().proxy_hosts() + except Exception as exc: + return {'success': False, 'status': 'unavailable', 'message': redact_message(str(exc)), 'items': []} diff --git a/backend/app/connectors/base.py b/backend/app/connectors/base.py new file mode 100644 index 0000000..adc5284 --- /dev/null +++ b/backend/app/connectors/base.py @@ -0,0 +1,14 @@ +class BaseConnector: + name: str = 'base' + + def __init__(self): + self.last_error: str | None = None + + def is_configured(self): + return False + + def set_error(self, message: str): + self.last_error = message + + def clear_error(self): + self.last_error = None diff --git a/backend/app/connectors/deluge.py b/backend/app/connectors/deluge.py new file mode 100644 index 0000000..8b6fbee --- /dev/null +++ b/backend/app/connectors/deluge.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +_TORRENT_FIELDS = [ + 'name', + 'state', + 'progress', + 'download_payload_rate', + 'upload_payload_rate', + 'eta', + 'ratio', + 'total_size', + 'tracker_host', + 'label', + 'save_path', + 'error', + 'is_finished', +] + + +@dataclass +class _DelugeConfig: + url: str + rpc_url: str + password: str + verify_tls: bool + timeout_seconds: float + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_number(value: Any, default: float = 0) -> float: + if value is None or value == '': + return default + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_path(value: Any) -> str: + text = str(value or '').replace('\\', '/').rstrip('/') + if not text: + return '' + parts = [part for part in text.split('/') if part] + if not parts: + return '' + return f'.../{parts[-1]}' + + +def _safe_url() -> str: + value = settings.apis.get('DELUGE_URL') + return str(value).rstrip('/') if value else '' + + +class DelugeConnector(BaseConnector): + name = 'Deluge' + + def __init__(self) -> None: + self._client: httpx.Client | None = None + self._rpc_id = 0 + + def is_configured(self) -> bool: + return bool(settings.apis.get('DELUGE_URL') and settings.apis.get('DELUGE_PASSWORD')) + + def _config(self) -> _DelugeConfig | None: + if not self.is_configured(): + return None + base_url = str(settings.apis['DELUGE_URL']).rstrip('/') + web_path = str(settings.apis.get('DELUGE_WEB_PATH') or '/json').strip() or '/json' + if not web_path.startswith('/'): + web_path = f'/{web_path}' + rpc_url = base_url if base_url.endswith('/json') else f'{base_url}{web_path}' + return _DelugeConfig( + url=base_url, + rpc_url=rpc_url, + password=str(settings.apis['DELUGE_PASSWORD']), + verify_tls=_as_bool(settings.apis.get('DELUGE_VERIFY_TLS'), False), + timeout_seconds=_as_float(settings.apis.get('DELUGE_TIMEOUT_SECONDS'), 10.0), + ) + + def _http(self) -> httpx.Client: + config = self._config() + if not config: + raise RuntimeError('missing_config') + if self._client is None: + self._client = httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) + return self._client + + def _rpc(self, method: str, params: list[Any] | None = None, authed: bool = True) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + if authed: + self._login() + self._rpc_id += 1 + body = {'method': method, 'params': params or [], 'id': self._rpc_id} + try: + response = self._http().post(config.rpc_url, json=body) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'Deluge Web JSON-RPC error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + try: + payload = response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if isinstance(payload, dict) and payload.get('error'): + error = payload['error'] + message = redact_message(str(error.get('message') if isinstance(error, dict) else error)) + if 'password' in message.lower() or 'auth' in message.lower(): + raise RuntimeError(f'auth_failed {message}') + raise RuntimeError(message) + return payload.get('result') if isinstance(payload, dict) else payload + + def _login(self) -> bool: + config = self._config() + if not config: + raise RuntimeError('missing_config') + result = self._rpc('auth.login', [config.password], authed=False) + if result is not True: + raise RuntimeError('auth_failed password=[REDACTED]') + return True + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + optional_warnings: list[dict[str, str]] = [] + + def optional_rpc(method: str, params: list[Any] | None = None) -> Any: + try: + return self._rpc(method, params) + except RuntimeError as exc: + reason = redact_message(str(exc)) + if reason.startswith('auth_failed'): + raise + optional_warnings.append({'method': method, 'reason': reason}) + return None + + try: + self._login() + web_connected = optional_rpc('web.connected') + daemon_connected = optional_rpc('daemon.connected') + version = optional_rpc('daemon.get_version') + session_payload = optional_rpc('core.get_session_status', [['num_torrents']]) + if web_connected is None and daemon_connected is None and version is None and session_payload is None: + reason = optional_warnings[0]['reason'] if optional_warnings else 'connection_failed' + return {'status': 'unreachable', 'reason': reason, 'diagnostics': {'optionalMethodWarnings': optional_warnings}} + except RuntimeError as exc: + reason = redact_message(str(exc)) + if reason.startswith('auth_failed'): + return {'status': 'auth_failed', 'reason': reason} + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable', 'reason': reason} + fallback = 'core.get_session_status' if session_payload is not None and optional_warnings else None + return { + 'status': 'connected', + 'webConnected': bool(web_connected) if web_connected is not None else None, + 'daemonConnected': bool(daemon_connected) if daemon_connected is not None else None, + 'version': str(version or 'unknown'), + 'diagnostics': { + 'healthFallback': fallback, + 'optionalMethodWarnings': optional_warnings, + }, + } + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return { + 'success': False, + 'status': 'missing_config', + 'message': 'missing_config', + 'url': '', + 'webConnected': None, + 'daemonConnected': None, + 'version': 'unknown', + 'required_env_vars': ['DELUGE_URL', 'DELUGE_PASSWORD'], + } + result = self.test_connection() + status = str(result.get('status', 'error')) + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Deluge connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'url': _safe_url(), + 'webConnected': result.get('webConnected'), + 'daemonConnected': result.get('daemonConnected'), + 'version': str(result.get('version', 'unknown')), + 'diagnostics': result.get('diagnostics', {'healthFallback': None, 'optionalMethodWarnings': []}), + } + + def session_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._empty_session('missing_config') + try: + connected = bool(self._rpc('web.connected')) + session_payload = self._rpc('core.get_session_status', [['download_rate', 'upload_rate', 'num_torrents']]) + torrents = self.torrent_status() + except RuntimeError as exc: + return self._empty_session('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc))) + items = torrents.get('items', []) if isinstance(torrents, dict) else [] + return { + 'success': True, + 'status': 'ok', + 'connected': connected, + 'downloadRate': _as_number((session_payload or {}).get('download_rate')), + 'uploadRate': _as_number((session_payload or {}).get('upload_rate')), + 'numTorrents': int(_as_number((session_payload or {}).get('num_torrents'), len(items))), + 'paused': sum(1 for item in items if item.get('paused')), + 'active': sum(1 for item in items if item.get('active')), + 'stalled': sum(1 for item in items if item.get('stalled')), + 'errors': sum(1 for item in items if str(item.get('state', '')).lower() == 'error'), + } + + def torrent_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._empty_torrents('missing_config') + try: + payload = self._rpc('web.update_ui', [_TORRENT_FIELDS, {}]) + except RuntimeError as exc: + return self._empty_torrents('auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', redact_message(str(exc))) + records = (payload or {}).get('torrents') if isinstance(payload, dict) else {} + items = [self._map_torrent(torrent_id, raw) for torrent_id, raw in (records or {}).items() if isinstance(raw, dict)] + return {'success': True, 'status': 'ok', 'message': 'torrents_ok', 'total': len(items), 'items': items} + + def active_downloads(self) -> dict[str, Any]: + return self._filtered('active', lambda item: bool(item.get('active'))) + + def stalled_downloads(self) -> dict[str, Any]: + return self._filtered('stalled', lambda item: bool(item.get('stalled'))) + + def paused_downloads(self) -> dict[str, Any]: + return self._filtered('paused', lambda item: bool(item.get('paused'))) + + def speed_status(self) -> dict[str, Any]: + session = self.session_status() + return { + 'success': bool(session.get('success')), + 'status': session.get('status', 'unreachable'), + 'connected': bool(session.get('connected')), + 'downloadRate': session.get('downloadRate', 0), + 'uploadRate': session.get('uploadRate', 0), + 'active': session.get('active', 0), + 'paused': session.get('paused', 0), + 'stalled': session.get('stalled', 0), + 'errors': session.get('errors', 0), + } + + def pause_torrent(self, torrent_id: str) -> dict[str, Any]: + return self._torrent_mutation('core.pause_torrent', torrent_id, 'torrent_paused') + + def resume_torrent(self, torrent_id: str) -> dict[str, Any]: + return self._torrent_mutation('core.resume_torrent', torrent_id, 'torrent_resumed') + + def pause_all(self) -> dict[str, Any]: + return self._bulk_mutation('core.pause_all_torrents', 'all_torrents_paused') + + def resume_all(self) -> dict[str, Any]: + return self._bulk_mutation('core.resume_all_torrents', 'all_torrents_resumed') + + def _torrent_mutation(self, method: str, torrent_id: str, message: str) -> dict[str, Any]: + clean_id = torrent_id.strip() + if not clean_id: + return {'success': False, 'status': 'error', 'message': 'torrent_id_required'} + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'torrentId': clean_id}} + try: + self._rpc(method, [[clean_id]]) + except RuntimeError as exc: + status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable' + return {'success': False, 'status': status, 'message': redact_message(str(exc)), 'data': {'torrentId': clean_id}} + return {'success': True, 'status': 'ok', 'message': message, 'data': {'torrentId': clean_id}} + + def _bulk_mutation(self, method: str, message: str) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + try: + self._rpc(method) + except RuntimeError as exc: + status = 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable' + return {'success': False, 'status': status, 'message': redact_message(str(exc))} + return {'success': True, 'status': 'ok', 'message': message, 'data': {'accepted': True}} + + def _filtered(self, name: str, predicate: Any) -> dict[str, Any]: + payload = self.torrent_status() + if not payload.get('success'): + return {'success': False, 'status': payload.get('status'), 'message': payload.get('message'), 'total': 0, 'items': []} + items = [item for item in payload.get('items', []) if predicate(item)] + return {'success': True, 'status': 'ok', 'message': f'{name}_downloads_ok', 'total': len(items), 'items': items} + + @staticmethod + def _map_torrent(torrent_id: str, raw: dict[str, Any]) -> dict[str, Any]: + state = str(raw.get('state') or 'unknown') + state_lower = state.lower() + download_rate = _as_number(raw.get('download_payload_rate')) + error = str(raw.get('error') or '') + paused = state_lower == 'paused' + active = state_lower in {'downloading', 'allocating', 'checking', 'moving'} and download_rate > 0 + stalled = state_lower in {'error', 'queued'} or (state_lower == 'downloading' and download_rate <= 0) + return { + 'id': str(torrent_id), + 'name': str(raw.get('name') or torrent_id), + 'state': state, + 'progress': _as_number(raw.get('progress')), + 'downloadPayloadRate': download_rate, + 'uploadPayloadRate': _as_number(raw.get('upload_payload_rate')), + 'eta': _as_number(raw.get('eta')), + 'ratio': _as_number(raw.get('ratio')), + 'totalSize': _as_number(raw.get('total_size')), + 'trackerHost': str(raw.get('tracker_host') or ''), + 'category': str(raw.get('label') or raw.get('category') or ''), + 'savePath': _safe_path(raw.get('save_path')), + 'error': error, + 'isFinished': bool(raw.get('is_finished')), + 'paused': paused, + 'stalled': stalled, + 'active': active, + } + + @staticmethod + def _empty_session(status: str, message: str | None = None) -> dict[str, Any]: + return { + 'success': False, + 'status': status, + 'message': message or status, + 'connected': False, + 'downloadRate': 0, + 'uploadRate': 0, + 'numTorrents': 0, + 'paused': 0, + 'active': 0, + 'stalled': 0, + 'errors': 0, + } + + @staticmethod + def _empty_torrents(status: str, message: str | None = None) -> dict[str, Any]: + return {'success': False, 'status': status, 'message': message or status, 'total': 0, 'items': []} diff --git a/backend/app/connectors/gitea_actions.py b/backend/app/connectors/gitea_actions.py new file mode 100644 index 0000000..c731af2 --- /dev/null +++ b/backend/app/connectors/gitea_actions.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +import copy +import json +from datetime import UTC, datetime +from threading import Lock +from time import monotonic +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + +_CACHE_SECONDS = 15 +_WARNING_AGE_SECONDS = 180 +_CRITICAL_AGE_SECONDS = 600 +_ACTIVE_WARNING_AGE_SECONDS = 1_800 +_ACTIVE_CRITICAL_AGE_SECONDS = 7_200 +_QUEUE_SAMPLE_LIMIT = 50 +_CACHE_LOCK = Lock() +_CACHE: tuple[float, dict[str, Any]] | None = None +_DEFAULT_RUNNER_TARGETS = { + "linux-validation": "itworx-validation-runner-2", + "docker-validation": "itworx-docker-runner", + "browser-validation": "itworx-browser-runner", + "security-validation": "itworx-security-runner", + "mcp-validation": "itworx-security-runner", + "unraid-deploy": "itworx-security-runner", +} +_DEFAULT_RUNNER_TARGET_CANDIDATES = { + "linux-validation": ("itworx-validation-runner-2", "itworx-validation-runner-3"), + "docker-validation": ( + "itworx-docker-runner", + "itworx-validation-runner-2", + "itworx-security-runner", + ), + "browser-validation": ("itworx-browser-runner",), + "security-validation": ("itworx-security-runner",), + "mcp-validation": ("itworx-security-runner",), + "unraid-deploy": ("itworx-security-runner",), +} + + +class GiteaActionsConnector(BaseConnector): + """Read a bounded Gitea Actions control-plane snapshot; never execute jobs.""" + + name = "Gitea Actions" + + def is_configured(self) -> bool: + return bool( + settings.apis.get("GITEA_URL") and settings.apis.get("GITEA_ADMIN_TOKEN") + ) + + def _base_url(self) -> str: + raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/") + parsed = urlsplit(raw) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.netloc + or parsed.username + ): + raise RuntimeError("invalid_gitea_url") + return urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "") + ) + + def _request( + self, path: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + if not self.is_configured(): + raise RuntimeError("missing_config") + token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "") + timeout = max( + 2.0, + min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8)), + ) + try: + with httpx.Client( + timeout=timeout, + verify=bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls)), + ) as client: + response = client.get( + f"{self._base_url()}{path}", + params=params, + headers={ + "Authorization": f"token {token}", + "Accept": "application/json", + }, + ) + except (httpx.RequestError, ValueError) as exc: + raise RuntimeError(redact_message(str(exc))) from exc + if response.status_code in {401, 403}: + raise RuntimeError("gitea_admin_access_denied") + if response.status_code >= 400: + raise RuntimeError(f"gitea_actions_http_{response.status_code}") + try: + payload = response.json() + except ValueError as exc: + raise RuntimeError("gitea_actions_invalid_response") from exc + if not isinstance(payload, dict): + raise RuntimeError("gitea_actions_invalid_response") # noqa: TRY004 + return payload + + @staticmethod + def _timestamp(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + @staticmethod + def _labels(value: Any) -> list[str]: + if not isinstance(value, list): + return [] + labels = [] + for item in value: + label = item.get("name") if isinstance(item, dict) else item + text = str(label or "").strip() + if text: + labels.append(text[:120]) + return labels + + @staticmethod + def _configured_restart_targets() -> dict[str, str]: + raw = str(settings.apis.get("GITEA_RUNNER_TARGETS_JSON") or "{}") + try: + payload = json.loads(raw) + except json.JSONDecodeError: + payload = {} + return ( + { + str(label)[:120]: str(target)[:240] + for label, target in payload.items() + if str(label).strip() and str(target).strip() + } + if isinstance(payload, dict) + else {} + ) + + @classmethod + def _restart_targets(cls) -> dict[str, str]: + return {**_DEFAULT_RUNNER_TARGETS, **cls._configured_restart_targets()} + + @classmethod + def _target_for_label( + cls, + label: str, + runners: list[dict[str, Any]], + targets: dict[str, str], + ) -> str | None: + """Keep explicit mappings authoritative; prove only built-in defaults.""" + configured = cls._configured_restart_targets() + if label in configured: + return configured[label] + + compatible = { + str(runner.get("name") or "") + for runner in runners + if label in runner.get("labels", []) and str(runner.get("name") or "") + } + for candidate in _DEFAULT_RUNNER_TARGET_CANDIDATES.get( + label, (targets.get(label, ""),) + ): + if candidate and candidate in compatible: + return candidate + return None + + def snapshot(self, *, refresh: bool = False) -> dict[str, Any]: + global _CACHE + now_monotonic = monotonic() + with _CACHE_LOCK: + if ( + not refresh + and _CACHE is not None + and now_monotonic - _CACHE[0] < _CACHE_SECONDS + ): + cached = copy.deepcopy(_CACHE[1]) + cached["cache"] = {"hit": True, "ttlSeconds": _CACHE_SECONDS} + return cached + + if not self.is_configured(): + return { + "status": "missing_config", + "severity": "unknown", + "generatedAt": datetime.now(UTC).isoformat(), + "runners": [], + "queue": { + "queued": 0, + "inProgress": 0, + "oldestQueuedAgeSeconds": None, + "oldestInProgressAgeSeconds": None, + "sampleTruncated": False, + "blockedByRunnerLabels": [], + "staleAssignmentRunnerLabels": [], + }, + "recommendedAction": None, + "cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS}, + } + + try: + runner_payload = self._request( + "/api/v1/admin/actions/runners", {"disabled": "false"} + ) + queued_payload = self._request( + "/api/v1/admin/actions/jobs", + { + "status": "queued", + "page": 1, + "limit": 100, + "sort": "id", + "order": "asc", + }, + ) + pending_payload = self._request( + "/api/v1/admin/actions/jobs", + { + "status": "pending", + "page": 1, + "limit": 100, + "sort": "id", + "order": "asc", + }, + ) + active_payload = self._request( + "/api/v1/admin/actions/jobs", + {"status": "in_progress", "page": 1, "limit": 100}, + ) + except RuntimeError as exc: + return { + "status": "error", + "severity": "warning", + "reason": redact_message(str(exc))[:240], + "generatedAt": datetime.now(UTC).isoformat(), + "runners": [], + "queue": { + "queued": 0, + "inProgress": 0, + "oldestQueuedAgeSeconds": None, + "oldestInProgressAgeSeconds": None, + "sampleTruncated": False, + "blockedByRunnerLabels": [], + "staleAssignmentRunnerLabels": [], + }, + "recommendedAction": None, + "cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS}, + } + + raw_runners = runner_payload.get("runners") + runner_items = raw_runners if isinstance(raw_runners, list) else [] + runners = [ + { + "id": int(item.get("id", 0) or 0), + "name": str(item.get("name") or "unknown")[:160], + "status": str(item.get("status") or "unknown")[:32], + "busy": bool(item.get("busy")), + "disabled": bool(item.get("disabled")), + "labels": self._labels(item.get("labels")), + } + for item in runner_items + if isinstance(item, dict) + ] + queued_jobs = [ + item + for payload in (queued_payload, pending_payload) + for item in ( + payload.get("jobs") if isinstance(payload.get("jobs"), list) else [] + ) + if isinstance(item, dict) + ] + active_jobs = ( + active_payload.get("jobs") + if isinstance(active_payload.get("jobs"), list) + else [] + ) + now = datetime.now(UTC) + queued_times = [self._timestamp(item.get("created_at")) for item in queued_jobs] + queued_times = [item for item in queued_times if item is not None] + oldest_age = max( + (int((now - item).total_seconds()) for item in queued_times), default=None + ) + active_times = [ + self._timestamp(item.get("started_at")) + or self._timestamp(item.get("created_at")) + for item in active_jobs + ] + active_times = [item for item in active_times if item is not None] + oldest_active_age = max( + (int((now - item).total_seconds()) for item in active_times), default=None + ) + stale_active_jobs = [] + for job in active_jobs: + created_at = self._timestamp(job.get("started_at")) or self._timestamp( + job.get("created_at") + ) + if ( + created_at is not None + and int((now - created_at).total_seconds()) + >= _ACTIVE_CRITICAL_AGE_SECONDS + ): + stale_active_jobs.append(job) + + online_runners = [ + item + for item in runners + if item["status"] == "online" and not item["disabled"] + ] + blocked_jobs = [] + stale_assignment_jobs: list[dict[str, Any]] = [] + queue_items = [] + for job in queued_jobs: + required = set(self._labels(job.get("labels"))) + matching = [ + runner + for runner in online_runners + if required.issubset(set(runner["labels"])) + ] + if required and not matching: + blocked_jobs.append(job) + created = self._timestamp(job.get("created_at")) + age = int((now - created).total_seconds()) if created else None + all_matching_busy = bool(matching) and all( + bool(runner["busy"]) for runner in matching + ) + reason = ( + "no_online_runner_matches_required_capability" + if required and not matching + else "all_matching_runners_are_busy" + if all_matching_busy + else "awaiting_scheduler_assignment" + ) + if ( + required + and matching + and not all_matching_busy + and age is not None + and age >= _CRITICAL_AGE_SECONDS + ): + stale_assignment_jobs.append( + { + "job": job, + "required": sorted(required), + "matching": matching, + "ageSeconds": age, + } + ) + if len(queue_items) < _QUEUE_SAMPLE_LIMIT: + queue_items.append( + { + "id": int(job.get("id", 0) or 0), + "name": str(job.get("name") or "job")[:160], + "labels": sorted(required)[:20], + "ageSeconds": age, + "matchingRunners": [runner["name"] for runner in matching][:20], + "reason": reason, + } + ) + + severity = "healthy" + if ( + (oldest_age or 0) >= _CRITICAL_AGE_SECONDS + or (oldest_active_age or 0) >= _ACTIVE_CRITICAL_AGE_SECONDS + or blocked_jobs + ): + severity = "critical" + elif ( + (oldest_age or 0) >= _WARNING_AGE_SECONDS + or (oldest_active_age or 0) >= _ACTIVE_WARNING_AGE_SECONDS + or len(online_runners) < len(runners) + ): + severity = "warning" + + targets = self._restart_targets() + blocked_labels = [ + label for job in blocked_jobs for label in self._labels(job.get("labels")) + ] + stale_active_labels = [ + label + for job in stale_active_jobs + for label in self._labels(job.get("labels")) + ] + stale_assignment_labels = sorted( + { + label + for candidate in stale_assignment_jobs + for label in candidate["required"] + } + ) + + recommended_action: dict[str, Any] | None = None + for candidate in sorted( + stale_assignment_jobs, + key=lambda item: int(item["ageSeconds"]), + reverse=True, + ): + job = candidate["job"] + matching_by_name = { + str(runner.get("name") or ""): runner for runner in candidate["matching"] + } + for label in candidate["required"]: + target = self._target_for_label(label, candidate["matching"], targets) + if not target: + continue + target_runner = matching_by_name.get(target) + if target_runner is not None and bool(target_runner.get("busy")): + continue + recommended_action = { + "intent": "runner.recover", + "target": target, + "requiresApproval": False, + "autoExecute": True, + "reason": "Gitea Actions scheduler left a compatible idle runner unassigned for at least ten minutes.", + "evidence": { + "jobId": int(job.get("id", 0) or 0), + "label": label, + "minimumAgeSeconds": _CRITICAL_AGE_SECONDS, + }, + } + break + if recommended_action is not None: + break + + if recommended_action is None: + restart_target = None + restart_reason = "" + for label in [*blocked_labels, *stale_active_labels]: + target = self._target_for_label(label, runners, targets) + if target: + restart_target = target + restart_reason = ( + "Gitea Actions queue is stalled without a matching online runner." + if label in blocked_labels + else "A Gitea Actions job exceeded the two-hour guardrail on an explicitly mapped runner." + ) + break + recommended_action = ( + { + "intent": "container.restart", + "target": restart_target, + "requiresApproval": True, + "autoExecute": False, + "reason": restart_reason, + } + if severity == "critical" and restart_target + else None + ) + + result = { + "status": "ok", + "severity": severity, + "generatedAt": now.isoformat(), + "runners": runners, + "summary": { + "total": len(runners), + "online": len(online_runners), + "busy": sum(bool(item["busy"]) for item in runners), + "offline": sum(item["status"] != "online" for item in runners), + }, + "queue": { + "queued": int(queued_payload.get("total_count", 0) or 0) + + int(pending_payload.get("total_count", 0) or 0), + "inProgress": int( + active_payload.get("total_count", len(active_jobs)) or 0 + ), + "oldestQueuedAgeSeconds": oldest_age, + "oldestInProgressAgeSeconds": oldest_active_age, + "sampleTruncated": ( + int(queued_payload.get("total_count", 0) or 0) + > len( + queued_payload.get("jobs") + if isinstance(queued_payload.get("jobs"), list) + else [] + ) + or int(pending_payload.get("total_count", 0) or 0) + > len( + pending_payload.get("jobs") + if isinstance(pending_payload.get("jobs"), list) + else [] + ) + ), + "blockedByRunnerLabels": sorted(set(blocked_labels))[:20], + "staleActiveRunnerLabels": sorted(set(stale_active_labels))[:20], + "staleAssignmentRunnerLabels": stale_assignment_labels[:20], + "items": queue_items, + }, + "recommendedAction": recommended_action, + "cache": {"hit": False, "ttlSeconds": _CACHE_SECONDS}, + } + with _CACHE_LOCK: + _CACHE = (now_monotonic, copy.deepcopy(result)) + return result diff --git a/backend/app/connectors/nginx_proxy_manager.py b/backend/app/connectors/nginx_proxy_manager.py new file mode 100644 index 0000000..b09ddf6 --- /dev/null +++ b/backend/app/connectors/nginx_proxy_manager.py @@ -0,0 +1,645 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _NpmConfig: + url: str + identity: str + secret: str + verify_tls: bool + timeout_seconds: float + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _as_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if value in {None, ''}: + return [] + return [value] + + +def _safe_url(value: Any = None) -> str: + raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/') + if not raw: + return '' + parsed = urlparse(raw) + if not parsed.username and not parsed.password: + return raw + host = parsed.hostname or '' + port = f':{parsed.port}' if parsed.port else '' + return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/') + + +def _parse_date(value: Any) -> datetime | None: + if not value: + return None + text = str(value).strip() + if not text: + return None + for candidate in (text, text.replace('Z', '+00:00')): + try: + parsed = datetime.fromisoformat(candidate) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + except ValueError: + continue + for fmt in ('%Y-%m-%d', '%Y-%m-%d %H:%M:%S'): + try: + return datetime.strptime(text, fmt).replace(tzinfo=UTC) + except ValueError: + continue + return None + + +def _days_until(value: Any) -> int | None: + parsed = _parse_date(value) + if not parsed: + return None + return (parsed - datetime.now(UTC)).days + + +def _response_items(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + data = payload.get('data') + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(payload.get('items'), list): + return [item for item in payload['items'] if isinstance(item, dict)] + return [] + + +class NginxProxyManagerConnector(BaseConnector): + name = 'Nginx Proxy Manager' + + def __init__(self): + super().__init__() + self._runtime_token: str | None = None + self._read_cache: dict[str, dict[str, Any]] = {} + + def _memo(self, key: str, factory: Any) -> dict[str, Any]: + if key not in self._read_cache: + self._read_cache[key] = factory() + return self._read_cache[key] + + def is_configured(self) -> bool: + return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET')) + + def _config(self) -> _NpmConfig | None: + if not self.is_configured(): + return None + return _NpmConfig( + url=str(settings.apis['NPM_URL']).rstrip('/'), + identity=str(settings.apis['NPM_IDENTITY']), + secret=str(settings.apis['NPM_SECRET']), + verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False), + timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0), + ) + + def _login(self) -> str: + if self._runtime_token: + return self._runtime_token + config = self._config() + if not config: + raise RuntimeError('missing_config') + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.post( + f'{config.url}/api/tokens', + json={'identity': config.identity, 'secret': config.secret}, + headers={'Accept': 'application/json'}, + ) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {400, 401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + try: + payload = response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + token = payload.get('token') if isinstance(payload, dict) else None + if not token: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + self._runtime_token = str(token) + return self._runtime_token + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + token = self._login() + try: + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.request( + method, + f'{config.url}/api{path}', + headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'}, + **kwargs, + ) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + try: + return response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + try: + self._login() + hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100})) + except RuntimeError as exc: + reason = redact_message(str(exc)) + if reason.startswith('auth_failed'): + return {'status': 'auth_failed', 'reason': reason} + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable', 'reason': reason} + return {'status': 'connected', 'proxyHostCount': len(hosts)} + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(proxyHostCount=0, certificateCount=0) + result = self.test_connection() + status = str(result.get('status', 'error')) + certs = self.certificates() if status == 'connected' else {'items': []} + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Nginx Proxy Manager connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'url': _safe_url(), + 'proxyHostCount': _as_int(result.get('proxyHostCount')), + 'certificateCount': len(certs.get('items', [])) if isinstance(certs, dict) else 0, + 'data': {}, + } + + def proxy_hosts(self) -> dict[str, Any]: + return self._memo('proxy_hosts', self._proxy_hosts_uncached) + + def _proxy_hosts_uncached(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0) + try: + items = [self._map_proxy_host(item) for item in _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))] + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0) + return {'success': True, 'status': 'ok', 'message': 'proxy_hosts_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def certificates(self) -> dict[str, Any]: + return self._memo('certificates', self._certificates_uncached) + + def _certificates_uncached(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0) + try: + items = [self._map_certificate(item) for item in _response_items(self._request('GET', '/nginx/certificates', params={'limit': 100}))] + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0) + return {'success': True, 'status': 'ok', 'message': 'certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def expiring_certificates(self, days: int = 30) -> dict[str, Any]: + certs = self.certificates() + items = [ + item for item in certs.get('items', []) + if item.get('isExpired') or (item.get('expiresInDays') is not None and int(item.get('expiresInDays')) <= days) + ] + return {**certs, 'message': 'expiring_certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items, 'days': days}} + + def access_lists(self) -> dict[str, Any]: + return self._memo('access_lists', self._access_lists_uncached) + + def _access_lists_uncached(self) -> dict[str, Any]: + if not self.is_configured(): + return self._missing(items=[], total=0) + try: + items = [self._map_access_list(item) for item in _response_items(self._request('GET', '/nginx/access-lists', params={'limit': 100}))] + except RuntimeError as exc: + return self._error_payload(exc, items=[], total=0) + return {'success': True, 'status': 'ok', 'message': 'access_lists_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def validate_managed_access_list(self, access_list_id: int) -> dict[str, Any]: + """Read-only verification for a trusted managed-host access list. + + Expanded authorization entries are inspected in memory, then discarded; + the result contains only the trusted numeric identifier and status. + """ + if not self.is_configured(): + return self._missing(reason='missing_config') + if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 1 <= access_list_id <= 2_147_483_647: + return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'} + try: + access_lists = _response_items(self._request( + 'GET', '/nginx/access-lists', + params={'limit': 1000, 'expand': 'items,clients'}, + )) + except RuntimeError as exc: + return self._error_payload(exc) + matches = [item for item in access_lists if _as_int(item.get('id')) == access_list_id] + if ( + len(matches) != 1 + or matches[0].get('satisfy_any', matches[0].get('satisfyAny')) is not False + or not isinstance(matches[0].get('items'), list) + or not matches[0]['items'] + ): + return {'success': False, 'status': 'blocked', 'message': 'required_access_list_unavailable'} + return { + 'success': True, + 'status': 'verified', + 'message': 'required_access_list_verified', + 'accessListId': access_list_id, + } + + def exposure_summary(self) -> dict[str, Any]: + hosts = self.proxy_hosts() + certs = self.certificates() + access_lists = self.access_lists() + if not hosts.get('success'): + return {**hosts, 'data': {'warnings': []}} + host_items = hosts.get('items', []) + cert_items = certs.get('items', []) if certs.get('success') else [] + cert_by_id = {item.get('id'): item for item in cert_items} + warnings = self._build_warnings(host_items, cert_by_id) + summary = { + 'totalProxyHosts': len(host_items), + 'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')), + 'publicHosts': sum(1 for item in host_items if item.get('enabled')), + 'hostsWithoutCertificate': sum(1 for item in host_items if item.get('enabled') and not item.get('certificateId')), + 'hostsWithExpiredCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpired')), + 'hostsWithExpiringCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpiringSoon')), + 'hostsWithoutAccessList': sum(1 for item in host_items if item.get('enabled') and not item.get('accessListId')), + 'hostsWithoutSslForce': sum(1 for item in host_items if item.get('enabled') and not item.get('sslForced')), + 'hostsWithWebsocket': sum(1 for item in host_items if item.get('websocketSupport')), + 'accessLists': access_lists.get('total', 0), + 'warnings': warnings, + } + return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary} + + def host_reachability(self) -> dict[str, Any]: + hosts = self.proxy_hosts() + if not hosts.get('success'): + return {**hosts, 'data': {'checks': []}} + checks = [ + { + 'hostId': item.get('id'), + 'domains': item.get('domainNames', []), + 'target': f"{item.get('forwardScheme')}://{item.get('forwardHost')}:{item.get('forwardPort')}", + 'status': 'metadata_only', + 'message': 'V1 does not perform active target scans by default.', + } + for item in hosts.get('items', []) + ] + return {'success': True, 'status': 'metadata_only', 'message': 'host_reachability_metadata_only', 'checks': checks, 'data': {'checks': checks}} + + def security_warnings(self) -> dict[str, Any]: + hosts = self.proxy_hosts() + certs = self.certificates() + if not hosts.get('success'): + return {**hosts, 'items': [], 'total': 0, 'data': {'items': []}} + cert_by_id = {item.get('id'): item for item in certs.get('items', [])} if certs.get('success') else {} + items = self._build_warnings(hosts.get('items', []), cert_by_id) + severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0} + status = 'warning' if items else 'ok' + return { + 'success': True, + 'status': status, + 'message': 'security_warnings_detected' if items else 'security_warnings_ok', + 'total': len(items), + 'items': sorted(items, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True), + 'data': {'items': items}, + } + + def ensure_managed_proxy_host( + self, + *, + hostname: str, + forward_host: str, + forward_port: int, + certificate_mode: str, + force_ssl: bool, + http2: bool, + upload_limit_mb: int, + read_timeout_seconds: int, + ownership_id: str | None = None, + access_list_id: int = 0, + ) -> dict[str, Any]: + """Create or verify one deployment-owned proxy host. + + This is intentionally not a general NPM mutation method. Every value + is derived from DeploymentPlanV1 and this refuses to take over a host + that does not already point at the exact managed container. + """ + if not self.is_configured(): + return self._missing(reason='missing_config') + if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?', hostname): + return {'success': False, 'status': 'invalid', 'message': 'invalid_hostname'} + if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}', forward_host): + return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_host'} + if not 1 <= int(forward_port) <= 65535: + return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_port'} + if certificate_mode not in {'existing', 'managed'}: + return {'success': False, 'status': 'invalid', 'message': 'invalid_certificate_mode'} + if not force_ssl: + return {'success': False, 'status': 'invalid', 'message': 'managed_proxy_requires_force_ssl'} + if ownership_id is not None and not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id): + return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'} + if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 0 <= access_list_id <= 2_147_483_647: + return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'} + try: + if access_list_id: + access_list = self.validate_managed_access_list(access_list_id) + if not access_list.get('success'): + return access_list + hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})) + exact = [ + item for item in hosts + if hostname in {str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames'))} + ] + if len(exact) > 1: + return {'success': False, 'status': 'conflict', 'message': 'multiple_hostname_owners'} + if exact: + existing = exact[0] + same_target = ( + str(existing.get('forward_host') or existing.get('forwardHost') or '') == forward_host + and _as_int(existing.get('forward_port') or existing.get('forwardPort')) == int(forward_port) + ) + if not same_target: + return {'success': False, 'status': 'conflict', 'message': 'hostname_owned_by_different_target'} + metadata = existing.get('meta') if isinstance(existing.get('meta'), dict) else {} + if ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id: + return {'success': False, 'status': 'conflict', 'message': 'hostname_is_not_owned_by_this_enrollment'} + hardened = ( + bool(existing.get('ssl_forced') or existing.get('sslForced')) + and bool(existing.get('http2_support') or existing.get('http2Support')) + and bool(existing.get('block_exploits') or existing.get('blockExploits')) + and not bool(existing.get('caching_enabled') or existing.get('cachingEnabled')) + and not bool(existing.get('allow_websocket_upgrade') or existing.get('websocketSupport')) + and bool(existing.get('certificate_id') or existing.get('certificateId')) + and _as_int(existing.get('access_list_id') or existing.get('accessListId')) == access_list_id + ) + if not hardened: + return {'success': False, 'status': 'conflict', 'message': 'existing_proxy_host_does_not_meet_managed_security_contract'} + return { + 'success': True, + 'status': 'already_managed', + 'message': 'proxy_host_already_matches_typed_plan', + 'hostId': existing.get('id'), + 'hostname': hostname, + } + + certificate_id: Any = 0 + metadata: dict[str, Any] = {} + if certificate_mode == 'existing': + certificates = _response_items(self._request('GET', '/nginx/certificates', params={'limit': 1000})) + matching_certificate = next( + ( + item for item in certificates + if hostname in { + str(value).strip().lower() + for value in _as_list(item.get('domain_names') or item.get('domainNames')) + } + ), + None, + ) + if not matching_certificate: + return {'success': False, 'status': 'blocked', 'message': 'existing_certificate_not_found'} + certificate_id = matching_certificate.get('id') + else: + email = str(settings.apis.get('NPM_LETSENCRYPT_EMAIL') or '').strip() + if not email or '@' not in email or len(email) > 254: + return {'success': False, 'status': 'blocked', 'message': 'certificate_automation_not_configured'} + certificate_id = 'new' + metadata = {'letsencrypt_email': email, 'letsencrypt_agree': True} + if ownership_id is not None: + metadata = {**metadata, 'appops_enrollment_id': ownership_id} + + advanced = ( + f'client_max_body_size {int(upload_limit_mb)}m;\n' + f'proxy_read_timeout {int(read_timeout_seconds)}s;\n' + f'proxy_send_timeout {int(read_timeout_seconds)}s;' + ) + created = self._request( + 'POST', + '/nginx/proxy-hosts', + json={ + 'domain_names': [hostname], + 'forward_scheme': 'http', + 'forward_host': forward_host, + 'forward_port': int(forward_port), + 'access_list_id': access_list_id, + 'certificate_id': certificate_id, + 'ssl_forced': bool(force_ssl), + 'http2_support': bool(http2), + 'hsts_enabled': False, + 'hsts_subdomains': False, + 'caching_enabled': False, + 'block_exploits': True, + 'allow_websocket_upgrade': False, + 'meta': metadata, + 'advanced_config': advanced, + }, + ) + except RuntimeError as exc: + return self._error_payload(exc) + host_id = created.get('id') if isinstance(created, dict) else None + if ownership_id is not None or access_list_id: + if host_id is None: + return { + 'success': False, + 'status': 'ownership_unverified', + 'message': 'managed_proxy_creation_response_missing_identifier', + } + try: + after_create = _response_items( + self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}) + ) + except RuntimeError as exc: + return { + 'success': False, + 'status': 'ownership_unverified', + 'message': redact_message(str(exc)), + 'hostId': host_id, + } + verified = [item for item in after_create if str(item.get('id')) == str(host_id)] + metadata = verified[0].get('meta') if len(verified) == 1 and isinstance(verified[0].get('meta'), dict) else {} + same_target = bool( + len(verified) == 1 + and str(verified[0].get('forward_host') or verified[0].get('forwardHost') or '') == forward_host + and _as_int(verified[0].get('forward_port') or verified[0].get('forwardPort')) == int(forward_port) + and _as_int(verified[0].get('access_list_id') or verified[0].get('accessListId')) == access_list_id + ) + if not same_target or (ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id): + return { + 'success': False, + 'status': 'ownership_unverified', + 'message': 'managed_proxy_creation_ownership_not_verified', + 'hostId': host_id, + } + result = { + 'success': True, + 'status': 'created', + 'message': 'proxy_host_created_from_typed_plan', + 'hostId': host_id, + 'hostname': hostname, + } + if ownership_id is not None: + result['enrollmentId'] = ownership_id + return result + + def remove_managed_proxy_host(self, *, host_id: int | str, ownership_id: str) -> dict[str, Any]: + """Compensate only a proxy host created by this exact enrollment.""" + if not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id): + return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'} + if not self.is_configured(): + return self._missing(reason='missing_config') + try: + hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})) + matching = [item for item in hosts if str(item.get('id')) == str(host_id)] + if len(matching) != 1: + return {'success': False, 'status': 'not_found', 'message': 'managed_proxy_host_not_found'} + metadata = matching[0].get('meta') if isinstance(matching[0].get('meta'), dict) else {} + if metadata.get('appops_enrollment_id') != ownership_id: + return {'success': False, 'status': 'conflict', 'message': 'proxy_host_is_not_owned_by_this_enrollment'} + self._request('DELETE', f'/nginx/proxy-hosts/{host_id}') + except RuntimeError as exc: + return self._error_payload(exc) + return {'success': True, 'status': 'removed', 'hostId': host_id, 'enrollmentId': ownership_id} + + @staticmethod + def _map_proxy_host(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'id': raw.get('id'), + 'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))], + 'forwardHost': str(raw.get('forward_host') or raw.get('forwardHost') or ''), + 'forwardPort': _as_int(raw.get('forward_port') or raw.get('forwardPort')), + 'forwardScheme': str(raw.get('forward_scheme') or raw.get('forwardScheme') or 'http'), + 'enabled': bool(raw.get('enabled')), + 'sslForced': bool(raw.get('ssl_forced') or raw.get('sslForced')), + 'http2Support': bool(raw.get('http2_support') or raw.get('http2Support')), + 'hstsEnabled': bool(raw.get('hsts_enabled') or raw.get('hstsEnabled')), + 'certificateId': raw.get('certificate_id') or raw.get('certificateId') or None, + 'accessListId': raw.get('access_list_id') or raw.get('accessListId') or None, + 'blockExploits': bool(raw.get('block_exploits') or raw.get('blockExploits')), + 'cachingEnabled': bool(raw.get('caching_enabled') or raw.get('cachingEnabled')), + 'websocketSupport': bool(raw.get('allow_websocket_upgrade') or raw.get('websocket_support') or raw.get('websocketSupport')), + 'locationsCount': len(_as_list(raw.get('locations'))), + 'meta': raw.get('meta') if isinstance(raw.get('meta'), dict) else {}, + } + + @staticmethod + def _map_certificate(raw: dict[str, Any]) -> dict[str, Any]: + expires = raw.get('expires_on') or raw.get('expiresOn') or raw.get('expires') + days = _days_until(expires) + return { + 'id': raw.get('id'), + 'niceName': str(raw.get('nice_name') or raw.get('niceName') or raw.get('name') or ''), + 'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))], + 'provider': str(raw.get('provider') or raw.get('type') or 'unknown').lower(), + 'expiresOn': expires, + 'expiresInDays': days, + 'isExpired': days is not None and days < 0, + 'isExpiringSoon': days is not None and days <= 30, + } + + @staticmethod + def _map_access_list(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'id': raw.get('id'), + 'name': str(raw.get('name') or ''), + 'authorizationType': str(raw.get('auth_type') or raw.get('authorizationType') or raw.get('authorization_type') or 'unknown'), + 'satisfyAny': bool(raw.get('satisfy_any') or raw.get('satisfyAny')), + 'itemsCount': len(_as_list(raw.get('items'))), + 'clientsCount': len(_as_list(raw.get('clients'))), + } + + @staticmethod + def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]]) -> list[dict[str, Any]]: + warnings: list[dict[str, Any]] = [] + for host in hosts: + if not host.get('enabled'): + continue + domains = host.get('domainNames') or [] + label = ', '.join(domains) or f"host-{host.get('id')}" + cert = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None + target = f"{host.get('forwardScheme')}://{host.get('forwardHost')}:{host.get('forwardPort')}" + if not host.get('certificateId'): + warnings.append({'type': 'host_without_certificate', 'severity': 'high', 'title': f'{label} has no certificate', 'evidence': [label, target], 'suggestedAction': 'review_certificate'}) + elif cert and cert.get('isExpired'): + warnings.append({'type': 'certificate_expired', 'severity': 'critical', 'title': f'{label} certificate is expired', 'evidence': [label, str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'}) + elif cert and cert.get('isExpiringSoon'): + warnings.append({'type': 'certificate_expiring_soon', 'severity': 'warning', 'title': f'{label} certificate expires soon', 'evidence': [label, f"{cert.get('expiresInDays')} days", str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'}) + if not host.get('accessListId'): + warnings.append({'type': 'public_host_without_access_list', 'severity': 'high', 'title': f'{label} has no access list', 'evidence': [label, target], 'suggestedAction': 'review_access_protection'}) + if not host.get('sslForced'): + warnings.append({'type': 'ssl_not_forced', 'severity': 'warning', 'title': f'{label} does not force SSL', 'evidence': [label], 'suggestedAction': 'review_ssl_policy'}) + if not host.get('hstsEnabled'): + warnings.append({'type': 'hsts_disabled', 'severity': 'info', 'title': f'{label} has HSTS disabled', 'evidence': [label], 'suggestedAction': 'review_hsts_policy'}) + if str(host.get('forwardHost') or '').lower() in {'unknown-app', 'unknown', ''}: + warnings.append({'type': 'unknown_proxy_target', 'severity': 'warning', 'title': f'{label} forwards to an unknown target', 'evidence': [target], 'suggestedAction': 'map_proxy_host_target'}) + return warnings + + @staticmethod + def _missing(**extra: Any) -> dict[str, Any]: + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], 'data': {}, **extra} + + @staticmethod + def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: + reason = redact_message(str(exc)) + status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable') + return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra} diff --git a/backend/app/connectors/plex.py b/backend/app/connectors/plex.py new file mode 100644 index 0000000..ba4cd89 --- /dev/null +++ b/backend/app/connectors/plex.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from xml.etree import ElementTree + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _PlexConfig: + url: str + token: str + verify_tls: bool + timeout_seconds: float + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _safe_path(value: Any) -> str: + text = str(value or '').replace('\\', '/').rstrip('/') + parts = [part for part in text.split('/') if part] + return f'.../{parts[-1]}' if parts else '' + + +def _mask_ip(value: Any) -> str: + text = str(value or '') + if not text: + return '' + parts = text.split('.') + if len(parts) == 4: + return f'{parts[0]}.{parts[1]}.{parts[2]}.x' + return '[masked]' + + +def _safe_url() -> str: + value = settings.apis.get('PLEX_URL') + return str(value).rstrip('/') if value else '' + + +class PlexConnector(BaseConnector): + name = 'Plex' + + def is_configured(self) -> bool: + return bool(settings.apis.get('PLEX_URL') and settings.apis.get('PLEX_TOKEN')) + + def _config(self) -> _PlexConfig | None: + if not self.is_configured(): + return None + return _PlexConfig( + url=str(settings.apis['PLEX_URL']).rstrip('/'), + token=str(settings.apis['PLEX_TOKEN']), + verify_tls=_as_bool(settings.apis.get('PLEX_VERIFY_TLS'), False), + timeout_seconds=_as_float(settings.apis.get('PLEX_TIMEOUT_SECONDS'), 10.0), + ) + + def _request(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + config = self._config() + if not config: + raise RuntimeError('missing_config') + request_params = dict(params or {}) + request_params['X-Plex-Token'] = config.token + url = f'{config.url}{path if path.startswith("/") else f"/{path}"}' + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.get(url, headers={'Accept': 'application/json'}, params=request_params) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'Plex API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + try: + return response.json() + except ValueError: + return self._xml_to_container(response.text) + + def _xml_to_container(self, text: str) -> dict[str, Any]: + try: + root = ElementTree.fromstring(text) + except ElementTree.ParseError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + container: dict[str, Any] = {'MediaContainer': dict(root.attrib)} + children = [] + for child in root: + item = dict(child.attrib) + item['tag'] = child.tag + children.append(item) + if children: + container['MediaContainer']['Directory' if children[0].get('tag') == 'Directory' else 'Metadata'] = children + return container + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + try: + container = self._media_container(self._request('/')) + except RuntimeError as exc: + reason = redact_message(str(exc)) + if reason.startswith('auth_failed'): + return {'status': 'auth_failed', 'reason': reason} + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable', 'reason': reason} + return { + 'status': 'connected', + 'friendlyName': str(container.get('friendlyName') or ''), + 'version': str(container.get('version') or 'unknown'), + 'machineIdentifier': str(container.get('machineIdentifier') or ''), + } + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return { + 'success': False, + 'status': 'missing_config', + 'message': 'missing_config', + 'url': '', + 'version': 'unknown', + 'machineIdentifier': '', + 'friendlyName': '', + 'required_env_vars': ['PLEX_URL', 'PLEX_TOKEN'], + } + result = self.test_connection() + status = str(result.get('status', 'error')) + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Plex connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'url': _safe_url(), + 'version': str(result.get('version', 'unknown')), + 'machineIdentifier': str(result.get('machineIdentifier', '')), + 'friendlyName': str(result.get('friendlyName', '')), + } + + def server_status(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + try: + container = self._media_container(self._request('/')) + except RuntimeError as exc: + return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc))} + return { + 'success': True, + 'status': 'ok', + 'friendlyName': str(container.get('friendlyName') or ''), + 'version': str(container.get('version') or 'unknown'), + 'platform': str(container.get('platform') or ''), + 'updatedAt': container.get('updatedAt'), + 'myPlex': container.get('myPlex'), + 'machineIdentifier': str(container.get('machineIdentifier') or ''), + 'libraryCount': _as_int(container.get('size')), + } + + def library_status(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + container = self._media_container(self._request('/library/sections')) + except RuntimeError as exc: + return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + records = container.get('Directory') if isinstance(container.get('Directory'), list) else [] + items = [self._map_library(item) for item in records if isinstance(item, dict)] + return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items} + + def active_sessions(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + container = self._media_container(self._request('/status/sessions')) + except RuntimeError as exc: + return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else [] + items = [self._map_session(item) for item in records if isinstance(item, dict)] + return {'success': True, 'status': 'ok', 'message': 'sessions_ok', 'total': len(items), 'items': items, 'sessions': items} + + def recent_media(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + container = self._media_container(self._request('/library/recentlyAdded', {'X-Plex-Container-Size': 25})) + except RuntimeError as exc: + return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + records = container.get('Metadata') if isinstance(container.get('Metadata'), list) else [] + items = [self._map_recent(item) for item in records if isinstance(item, dict)] + return {'success': True, 'status': 'ok', 'message': 'recent_media_ok', 'total': len(items), 'items': items} + + def stream_summary(self) -> dict[str, Any]: + sessions = self.active_sessions() + items = sessions.get('items', []) if isinstance(sessions, dict) else [] + return { + 'success': bool(sessions.get('success')), + 'status': sessions.get('status', 'unreachable'), + 'activeStreams': len(items), + 'total': len(items), + 'directPlay': sum(1 for item in items if item.get('transcodeDecision') == 'directplay'), + 'directStream': sum(1 for item in items if item.get('transcodeDecision') == 'directstream'), + 'transcoding': sum(1 for item in items if item.get('transcodeDecision') == 'transcode'), + 'bandwidth': sum(_as_int(item.get('bandwidth')) for item in items), + 'users': len({item.get('user') for item in items if item.get('user')}), + } + + def scan_library(self, library_id: str) -> dict[str, Any]: + clean_id = library_id.strip() + if not clean_id: + return {'success': False, 'status': 'error', 'message': 'library_id_required'} + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {'libraryId': clean_id}} + try: + self._request(f'/library/sections/{clean_id}/refresh') + except RuntimeError as exc: + return {'success': False, 'status': 'auth_failed' if str(exc).startswith('auth_failed') else 'unreachable', 'message': redact_message(str(exc)), 'data': {'libraryId': clean_id}} + return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': {'libraryId': clean_id}} + + def scan_all_libraries(self) -> dict[str, Any]: + libraries = self.library_status() + if not libraries.get('success'): + return {'success': False, 'status': libraries.get('status', 'error'), 'message': libraries.get('message', 'library_status_failed')} + accepted: list[str] = [] + for item in libraries.get('items', []): + library_id = str(item.get('id') or '') + if library_id and self.scan_library(library_id).get('success'): + accepted.append(library_id) + return {'success': True, 'status': 'ok', 'message': 'scan_all_started', 'data': {'libraryIds': accepted}} + + def _map_library(self, raw: dict[str, Any]) -> dict[str, Any]: + library_id = str(raw.get('key') or raw.get('id') or '') + count = 0 + if library_id: + try: + count = _as_int(self._media_container(self._request(f'/library/sections/{library_id}/all')).get('size')) + except RuntimeError: + count = 0 + locations_raw = raw.get('Location') if isinstance(raw.get('Location'), list) else [] + return { + 'id': library_id, + 'key': library_id, + 'title': str(raw.get('title') or ''), + 'type': str(raw.get('type') or ''), + 'agent': str(raw.get('agent') or ''), + 'scanner': str(raw.get('scanner') or ''), + 'count': count, + 'itemCount': count, + 'refreshStatus': raw.get('refreshing'), + 'locations': [_safe_path(item.get('path')) for item in locations_raw if isinstance(item, dict)], + } + + @staticmethod + def _map_session(raw: dict[str, Any]) -> dict[str, Any]: + user = raw.get('User') if isinstance(raw.get('User'), dict) else {} + player = raw.get('Player') if isinstance(raw.get('Player'), dict) else {} + session = raw.get('Session') if isinstance(raw.get('Session'), dict) else {} + transcode = raw.get('TranscodeSession') if isinstance(raw.get('TranscodeSession'), dict) else {} + media = raw.get('Media')[0] if isinstance(raw.get('Media'), list) and raw.get('Media') else {} + decision = str(transcode.get('videoDecision') or media.get('videoDecision') or (media.get('Part', [{}])[0].get('decision') if isinstance(media.get('Part'), list) and media.get('Part') else '') or 'directplay').lower() + duration = _as_int(raw.get('duration')) + offset = _as_int(raw.get('viewOffset')) + return { + 'sessionKey': str(raw.get('sessionKey') or ''), + 'id': str(raw.get('sessionKey') or ''), + 'user': str(user.get('title') or 'unknown'), + 'title': str(raw.get('grandparentTitle') or raw.get('title') or 'unknown'), + 'libraryType': str(raw.get('type') or ''), + 'type': str(raw.get('type') or ''), + 'player': str(player.get('title') or ''), + 'state': str(player.get('state') or ''), + 'product': str(player.get('product') or ''), + 'platform': str(player.get('platform') or ''), + 'ip': _mask_ip(player.get('address')), + 'ipAddress': _mask_ip(player.get('address')), + 'transcodeDecision': decision, + 'videoDecision': str(transcode.get('videoDecision') or media.get('videoDecision') or decision), + 'audioDecision': str(transcode.get('audioDecision') or media.get('audioDecision') or ''), + 'bandwidth': _as_int(session.get('bandwidth')), + 'progress': round((offset / duration) * 100, 1) if duration else 0, + } + + @staticmethod + def _map_recent(raw: dict[str, Any]) -> dict[str, Any]: + title = str(raw.get('title') or 'unknown') + if raw.get('grandparentTitle'): + title = f"{raw.get('grandparentTitle')} - {title}" + return { + 'id': str(raw.get('ratingKey') or raw.get('key') or raw.get('title') or ''), + 'title': title, + 'type': str(raw.get('type') or ''), + 'year': raw.get('year'), + 'library': str(raw.get('librarySectionTitle') or ''), + 'addedAt': raw.get('addedAt'), + 'thumb': raw.get('thumb'), + 'summary': raw.get('summary'), + } + + @staticmethod + def _media_container(payload: dict[str, Any]) -> dict[str, Any]: + container = payload.get('MediaContainer') if isinstance(payload, dict) else {} + return container if isinstance(container, dict) else {} diff --git a/backend/app/connectors/portainer.py b/backend/app/connectors/portainer.py new file mode 100644 index 0000000..e6ab66f --- /dev/null +++ b/backend/app/connectors/portainer.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote, urlsplit, urlunsplit + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +_PORTAINER_DEFAULT_TIMEOUT_SECONDS = 8.0 + + +@dataclass +class _ConnectorConfig: + url: str + api_key: str + endpoint_id: str + verify_tls: bool + + +class PortainerConnector(BaseConnector): + name = 'Portainer' + default_headers = { + 'Content-Type': 'application/json', + } + + def __init__(self): + super().__init__() + + def is_configured(self): + return bool(settings.apis.get('PORTAINER_URL') and settings.apis.get('PORTAINER_API_KEY') and settings.apis.get('PORTAINER_ENDPOINT_ID')) + + def _config(self) -> _ConnectorConfig | None: + if not self.is_configured(): + return None + + return _ConnectorConfig( + url=str(settings.apis['PORTAINER_URL']).strip().rstrip('/'), + api_key=str(settings.apis['PORTAINER_API_KEY']).strip(), + endpoint_id=str(settings.apis['PORTAINER_ENDPOINT_ID']).strip(), + verify_tls=bool(settings.apis.get('PORTAINER_VERIFY_TLS', settings.verify_tls)), + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + config = self._config() + if not config: + raise RuntimeError('missing_config') + + headers = dict(self.default_headers) + headers['X-API-Key'] = config.api_key + url = f'{config.url}{path}' + + try: + self.clear_error() + with httpx.Client(timeout=_PORTAINER_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client: + response = client.request(method=method, url=url, headers=headers, **kwargs) + except (httpx.RequestError, ValueError) as exc: + message = str(exc) + redacted = redact_message(message) + self.set_error(redacted) + raise RuntimeError(redacted) + + if response.status_code == 401: + message = 'unauthorized' + self.set_error(message) + raise RuntimeError(message) + + if response.status_code >= 500: + message = f'Portainer server error: HTTP {response.status_code}' + self.set_error(message) + raise RuntimeError(message) + + if response.status_code >= 400: + response_text = response.text or '' + message = f'Portainer API error: HTTP {response.status_code} {response_text[:256]}' + redacted = redact_message(message) + self.set_error(redacted) + raise RuntimeError(redacted) + + return response + + def test_connection(self): + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + + try: + self._request('GET', '/api/status') + self._request('GET', f'/api/endpoints/{self._config().endpoint_id}/docker/version') + except RuntimeError as exc: + reason = str(exc) + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + if 'endpoints/' in reason or 'HTTP 404' in reason or 'HTTP 403' in reason: + return {'status': 'endpoint_error', 'reason': reason} + return {'status': 'error', 'reason': reason} + + return {'status': 'connected', 'endpointReachable': True} + + def list_containers(self, include_stats: bool = False, stats_limit: int = 200): + config = self._config() + if not config: + return {'status': 'missing_config', 'reason': 'missing_config'} + + try: + path = f'/api/endpoints/{config.endpoint_id}/docker/containers/json?all=1' + response = self._request('GET', path) + payload = response.json() + except (RuntimeError, ValueError) as exc: + return {'status': 'error', 'reason': str(exc), 'containers': []} + + if not isinstance(payload, list): + return {'status': 'error', 'reason': 'unexpected_payload', 'containers': []} + + containers = [self._map_container(container, config) for container in payload] + if include_stats: + self._attach_container_stats(containers, max(1, min(int(stats_limit or 200), 500))) + + return {'status': 'ok', 'containers': containers} + + def _attach_container_stats(self, containers: list[dict[str, Any]], stats_limit: int) -> None: + candidates = [ + container + for container in containers + if container.get('state') == 'running' and container.get('id') and container.get('id') != 'unknown' + ] + selected = candidates[:stats_limit] + selected_ids = {str(container.get('id')) for container in selected} + + for container in containers: + if container.get('state') != 'running': + container['metricsStatus'] = 'not_running' + elif str(container.get('id')) not in selected_ids: + container['metricsStatus'] = 'not_requested' + + if not selected: + return + + max_workers = min(12, len(selected)) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit(self.get_container_stats, str(container.get('id'))): container + for container in selected + } + for future in as_completed(futures): + container = futures[future] + try: + stats = future.result() + except Exception as exc: # pragma: no cover - get_container_stats normalizes expected failures. + stats = {'metricsStatus': 'error', 'metricsError': redact_message(str(exc))} + container.update(stats) + + def get_container_stats(self, container_id: str) -> dict[str, Any]: + if not self.is_configured(): + return {'metricsStatus': 'missing_config', 'metricsError': 'missing_config'} + + try: + safe_container_id = quote(str(container_id).strip(), safe='') + response = self._request( + 'GET', + f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{safe_container_id}/stats', + params={'stream': 'false'}, + ) + payload = response.json() + except (RuntimeError, ValueError) as exc: + return { + 'metricsStatus': 'error', + 'metricsError': redact_message(str(exc)), + } + + metrics = _map_container_stats(payload) + metrics['metricsSource'] = 'portainer.docker.stats' + return metrics + + def get_container_status(self, container_name: str): + containers = self.list_containers() + if containers.get('status') != 'ok': + return containers + + entry = next((c for c in containers.get('containers', []) if c['name'] == container_name), None) + if entry: + return {'status': 'ok', 'container': entry} + + return {'status': 'not_found', 'reason': f'Container {container_name} not found'} + + def restart_container(self, target: str): + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config', 'container': target} + + containers = self.list_containers() + if containers.get('status') != 'ok': + return containers + + target_normalized = str(target).strip().lower() + selected = None + for candidate in containers.get('containers', []): + name = str(candidate.get('name', '')).lower() + container_id = str(candidate.get('id', '')).lower() + app_id = str(candidate.get('appId', '')).lower() + if ( + name == target_normalized + or container_id == target_normalized + or app_id == target_normalized + or (target_normalized and (target_normalized in name or target_normalized in container_id)) + ): + selected = candidate + break + + if not selected: + return {'status': 'not_found', 'reason': f'Container {target} not found'} + + container_name = selected.get('id') or selected.get('name') + if not container_name: + return {'status': 'error', 'reason': 'Unable to resolve container identifier'} + + endpoint_id = self._config().endpoint_id + if not endpoint_id: + return {'status': 'error', 'reason': 'invalid_connector_config'} + + try: + self._request( + 'POST', + f'/api/endpoints/{endpoint_id}/docker/containers/{container_name}/restart', + timeout=30.0, + ) + except (RuntimeError, ValueError) as exc: + return {'status': 'error', 'reason': str(exc)} + + return { + 'status': 'ok', + 'container': str(container_name), + 'target': target, + 'action': 'container.restart', + } + + def get_container_logs(self, container_name: str, tail: int = 100): + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config', 'container': container_name, 'logs': []} + + try: + response = self._request( + 'GET', + f'/api/endpoints/{self._config().endpoint_id}/docker/containers/{container_name}/logs', + params={'stdout': 1, 'stderr': 1, 'tail': max(1, int(tail))}, + ) + except (RuntimeError, ValueError) as exc: + return {'status': 'error', 'reason': str(exc), 'container': container_name, 'logs': []} + + output = (response.text or '').strip() + logs = [line for line in output.splitlines() if line.strip()] + return {'status': 'ok', 'container': container_name, 'logs': logs} + + def list_stacks(self): + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config', 'stacks': []} + + try: + payload = self._request('GET', '/api/stacks').json() + except (RuntimeError, ValueError) as exc: + return {'status': 'error', 'reason': str(exc), 'stacks': []} + + if isinstance(payload, dict) and isinstance(payload.get('stacks'), list): + payload = payload.get('stacks', []) + + if not isinstance(payload, list): + return {'status': 'error', 'reason': 'unexpected_payload', 'stacks': []} + + return {'status': 'ok', 'stacks': [self._map_stack(stack) for stack in payload]} + + def get_stack_status(self, stack_id: str): + stacks = self.list_stacks() + if stacks.get('status') != 'ok': + return stacks + + entry = next((stack for stack in stacks.get('stacks', []) if stack['id'] == stack_id), None) + if entry: + return {'status': 'ok', 'stack': entry} + + return {'status': 'not_found', 'reason': f'Stack {stack_id} not found'} + + @staticmethod + def _map_container(raw: dict[str, Any], config: _ConnectorConfig | None = None) -> dict[str, Any]: + names = raw.get('Names') or [] + raw_name = names[0] if names else raw.get('Name', '') + name = str(raw_name).strip('/').strip() + image = str(raw.get('Image', '')).strip() + state = str(raw.get('State', 'unknown')).lower() + state_map = { + 'running': 'running', + 'exited': 'exited', + 'restarting': 'restarting', + 'paused': 'paused', + } + mapped_state = state_map.get(state, 'exited') + + health_raw = raw.get('State') + health = 'healthy' + if health_raw in {'exited', 'stopped', 'dead'}: + health = 'unhealthy' + + ports_raw = raw.get('Ports', []) if isinstance(raw.get('Ports'), list) else [] + ports = [] + for port in ports_raw: + if not isinstance(port, dict): + continue + public_port = port.get('PublicPort') + private_port = port.get('PrivatePort') + if public_port is None or private_port is None: + continue + ports.append(f"{public_port}:{private_port}") + + if not ports: + ports = [] + + container_id = str(raw.get('Id', name or 'unknown')) + mounts = [] + for mount in raw.get('Mounts', []) if isinstance(raw.get('Mounts'), list) else []: + if not isinstance(mount, dict): + continue + source = str(mount.get('Source') or mount.get('Name') or '').strip() + destination = str(mount.get('Destination') or '').strip() + if not source and not destination: + continue + mounts.append({ + 'source': source, + 'destination': destination, + 'type': str(mount.get('Type') or '').strip(), + }) + return { + 'id': container_id, + 'name': name or str(raw.get('Id', 'unknown')), + 'appId': _infer_app_id(name), + 'state': mapped_state, + 'health': health, + 'image': image or 'unknown', + 'ports': ports, + 'mounts': mounts, + 'cpu': 'n/a', + 'ram': 'n/a', + 'metricsStatus': 'not_requested', + 'restartCount': int(raw.get('RestartCount', 0) or 0), + 'criticality': 'medium', + 'voiceEnabled': name in {'widefrog', 'sonarr', 'jellyfin'}, + 'protected': _is_protected_app(name), + 'portainerUrl': _container_deep_link(config, container_id), + } + + @staticmethod + def _map_stack(raw: dict[str, Any]) -> dict[str, Any]: + stack_id = raw.get('Id') or raw.get('id') or raw.get('name') + status = str(raw.get('Status', 'unknown')).lower() + status_map = { + 'stopped': 'critical', + 'active': 'healthy', + 'running': 'healthy', + 'error': 'error', + 'restarting': 'warning', + } + + return { + 'id': str(stack_id), + 'name': str(raw.get('Name', stack_id or 'stack')), + 'apps': [raw.get('EntryPoint')] if raw.get('EntryPoint') else [], + 'containers': int(raw.get('ContainersCount', 1) or 0), + 'status': status_map.get(status, 'healthy'), + 'lastRedeploy': str(raw.get('UpdatedAt') or raw.get('created', 'unknown')), + 'updatePolicy': str(raw.get('UpdateStrategy', 'Manual')), + 'risk': 'medium', + } + + +def _infer_app_id(name: str) -> str: + normalized = str(name).lower() + if not normalized: + return '' + + known = { + 'widefrog': 'widefrog', + 'sonarr': 'sonarr', + 'radarr': 'radarr', + 'jellyfin': 'jellyfin', + 'authentik-server': 'authentik', + 'authentik': 'authentik', + 'postgres': 'postgres', + 'redis': 'redis', + 'portainer': 'portainer', + 'home-assistant': 'home_assistant', + } + + return known.get(normalized.split('/')[-1], normalized.split('/')[0].strip() if normalized else '') + + +def _is_protected_app(name: str) -> bool: + return _infer_app_id(name) in {'authentik', 'portainer', 'postgres', 'redis', 'home_assistant'} + + +def _nested_number(payload: dict[str, Any], path: tuple[str, ...]) -> float | None: + current: Any = payload + for key in path: + if not isinstance(current, dict) or key not in current: + return None + current = current[key] + try: + return float(current) + except (TypeError, ValueError): + return None + + +def _format_percent(value: float | None) -> str: + if value is None: + return 'n/a' + return f'{max(value, 0.0):.1f}%' + + +def _format_bytes(value: float | None) -> str: + if value is None or value < 0: + return 'n/a' + units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'] + amount = float(value) + unit = units[0] + for unit in units: + if amount < 1024 or unit == units[-1]: + break + amount /= 1024 + if unit == 'B' or amount >= 10 or amount.is_integer(): + return f'{amount:.0f} {unit}' + return f'{amount:.1f} {unit}' + + +def _map_container_stats(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + return { + 'cpu': 'n/a', + 'ram': 'n/a', + 'metricsStatus': 'partial', + } + + cpu_total = _nested_number(payload, ('cpu_stats', 'cpu_usage', 'total_usage')) + cpu_previous = _nested_number(payload, ('precpu_stats', 'cpu_usage', 'total_usage')) + system_total = _nested_number(payload, ('cpu_stats', 'system_cpu_usage')) + system_previous = _nested_number(payload, ('precpu_stats', 'system_cpu_usage')) + online_cpus = _nested_number(payload, ('cpu_stats', 'online_cpus')) + if not online_cpus: + cpu_stats = payload.get('cpu_stats') if isinstance(payload.get('cpu_stats'), dict) else {} + cpu_usage = cpu_stats.get('cpu_usage') if isinstance(cpu_stats.get('cpu_usage'), dict) else {} + per_cpu = cpu_usage.get('percpu_usage') + online_cpus = float(len(per_cpu)) if isinstance(per_cpu, list) and per_cpu else 1.0 + + cpu_percent: float | None = None + if cpu_total is not None and cpu_previous is not None and system_total is not None and system_previous is not None: + cpu_delta = cpu_total - cpu_previous + system_delta = system_total - system_previous + if cpu_delta >= 0 and system_delta > 0: + cpu_percent = (cpu_delta / system_delta) * float(online_cpus or 1.0) * 100.0 + + memory_usage = _nested_number(payload, ('memory_stats', 'usage')) + memory_limit = _nested_number(payload, ('memory_stats', 'limit')) + ram = _format_bytes(memory_usage) + if memory_usage is not None and memory_limit: + ram = f'{_format_bytes(memory_usage)} / {_format_bytes(memory_limit)}' + + return { + 'cpu': _format_percent(cpu_percent), + 'ram': ram, + 'metricsStatus': 'measured' if cpu_percent is not None and memory_usage is not None else 'partial', + } + + +def _safe_portainer_base_url(raw_url: str) -> str | None: + parsed = urlsplit(str(raw_url or '').strip()) + if parsed.scheme not in {'http', 'https'} or not parsed.netloc: + return None + + path = parsed.path.rstrip('/') + return urlunsplit((parsed.scheme, parsed.netloc, path, '', '')).rstrip('/') + + +def _container_deep_link(config: _ConnectorConfig | None, container_id: str) -> str | None: + if not config or not config.endpoint_id or not container_id or container_id == 'unknown': + return None + + base_url = _safe_portainer_base_url(config.url) + if not base_url: + return None + + endpoint_id = quote(str(config.endpoint_id).strip(), safe='') + safe_container_id = quote(str(container_id).strip(), safe='') + return f'{base_url}/#!/{endpoint_id}/docker/containers/{safe_container_id}' diff --git a/backend/app/connectors/prowlarr.py b/backend/app/connectors/prowlarr.py new file mode 100644 index 0000000..5b971e4 --- /dev/null +++ b/backend/app/connectors/prowlarr.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _ProwlarrConfig: + url: str + api_key: str + verify_tls: bool + timeout_seconds: float + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: Any, default: int = 0) -> int: + if value is None or value == '': + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, int | float): + return int(value) + try: + return int(str(value).strip()) + except ValueError: + return default + + +def _safe_url() -> str: + value = settings.apis.get('PROWLARR_URL') + return str(value).rstrip('/') if value else '' + + +class ProwlarrConnector(BaseConnector): + name = 'Prowlarr' + + def is_configured(self) -> bool: + return bool(settings.apis.get('PROWLARR_URL') and settings.apis.get('PROWLARR_API_KEY')) + + def _config(self) -> _ProwlarrConfig | None: + if not self.is_configured(): + return None + return _ProwlarrConfig( + url=str(settings.apis['PROWLARR_URL']).rstrip('/'), + api_key=str(settings.apis['PROWLARR_API_KEY']), + verify_tls=_as_bool(settings.apis.get('PROWLARR_VERIFY_TLS'), False), + timeout_seconds=_as_float(settings.apis.get('PROWLARR_TIMEOUT_SECONDS'), 10.0), + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + + headers = dict(kwargs.pop('headers', {}) or {}) + headers['X-Api-Key'] = config.api_key + url = f'{config.url}{path}' + + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.request(method=method, url=url, headers=headers, **kwargs) + except (httpx.RequestError, ValueError) as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + if response.status_code in {401, 403}: + self.set_error('unauthorized') + raise RuntimeError('unauthorized') + if response.status_code == 404: + self.set_error('unsupported_endpoint') + raise RuntimeError('unsupported_endpoint') + if response.status_code >= 500: + message = f'Prowlarr server error: HTTP {response.status_code}' + self.set_error(message) + raise RuntimeError(message) + if response.status_code >= 400: + message = redact_message(f'Prowlarr API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + if response.status_code == 204: + return None + + try: + return response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + try: + payload = self._request('GET', '/api/v1/system/status') + except RuntimeError as exc: + reason = str(exc) + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable' if reason not in {'unauthorized'} else 'error', 'reason': redact_message(reason)} + version = payload.get('version') if isinstance(payload, dict) else None + return {'status': 'connected', 'version': str(version or 'unknown')} + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return { + 'success': False, + 'status': 'missing_config', + 'message': 'missing_config', + 'version': 'unknown', + 'url': '', + 'required_env_vars': ['PROWLARR_URL', 'PROWLARR_API_KEY'], + } + result = self.test_connection() + status = str(result.get('status', 'error')) + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Prowlarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'version': str(result.get('version', 'unknown')), + 'url': _safe_url(), + } + + def indexer_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._empty_indexers('missing_config') + try: + payload = self._request('GET', '/api/v1/indexer') + except RuntimeError as exc: + result = self._empty_indexers('unavailable' if str(exc) == 'unsupported_endpoint' else 'error') + result['message'] = redact_message(str(exc)) + return result + items_raw = payload if isinstance(payload, list) else [] + items = [self._map_indexer(item) for item in items_raw if isinstance(item, dict)] + return { + 'success': True, + 'status': 'ok', + 'message': 'indexers_ok', + 'total': len(items), + 'enabled': sum(1 for item in items if item['enabled']), + 'disabled': sum(1 for item in items if not item['enabled']), + 'failing': sum(1 for item in items if item['status'] in {'warning', 'error'}), + 'protocols': { + 'torrent': sum(1 for item in items if item['protocol'] == 'torrent'), + 'usenet': sum(1 for item in items if item['protocol'] == 'usenet'), + 'unknown': sum(1 for item in items if item['protocol'] == 'unknown'), + }, + 'items': items, + } + + def application_status(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + payload = self._request('GET', '/api/v1/applications') + except RuntimeError as exc: + return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + items_raw = payload if isinstance(payload, list) else [] + items = [self._map_application(item) for item in items_raw if isinstance(item, dict)] + return {'success': True, 'status': 'ok', 'message': 'applications_ok', 'total': len(items), 'items': items} + + def indexer_health(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'warnings': [], 'errors': []} + try: + payload = self._request('GET', '/api/v1/health') + except RuntimeError as exc: + return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'warnings': [], 'errors': []} + records = payload if isinstance(payload, list) else [] + warnings = [str(item.get('message') or item.get('source') or 'warning') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() != 'error'] + errors = [str(item.get('message') or item.get('source') or 'error') for item in records if isinstance(item, dict) and str(item.get('type', '')).lower() == 'error'] + return { + 'success': True, + 'status': 'ok' if not warnings and not errors else 'warning', + 'message': 'healthy' if not warnings and not errors else 'health_issues_detected', + 'warnings': warnings, + 'errors': errors, + } + + def search_test(self, query: str) -> dict[str, Any]: + clean_query = query.strip() + if not clean_query: + return {'success': False, 'status': 'error', 'message': 'query_required', 'total': 0, 'items': []} + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + payload = self._request('GET', '/api/v1/search', params={'query': clean_query}) + except RuntimeError as exc: + return {'success': False, 'status': 'unavailable' if str(exc) == 'unsupported_endpoint' else 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + records = payload if isinstance(payload, list) else [] + items = [self._map_search_result(item) for item in records if isinstance(item, dict)] + return {'success': True, 'status': 'ok', 'message': 'search_ok', 'total': len(items), 'items': items} + + def sync_status(self) -> dict[str, Any]: + indexers = self.indexer_status() + applications = self.application_status() + health = self.indexer_health() + success = bool(indexers.get('success')) and bool(applications.get('success')) and bool(health.get('success')) + return { + 'success': success, + 'status': 'ok' if success else 'unavailable', + 'message': 'sync_status_ok' if success else 'sync_status_unavailable', + 'applicationsConfigured': int(applications.get('total', 0)), + 'indexersConfigured': int(indexers.get('total', 0)), + 'indexersEnabled': int(indexers.get('enabled', 0)), + 'indexersFailing': int(indexers.get('failing', 0)), + 'healthWarnings': len(health.get('warnings', [])), + 'healthErrors': len(health.get('errors', [])), + 'lastSync': None, + } + + @staticmethod + def _map_indexer(raw: dict[str, Any]) -> dict[str, Any]: + protocol = str(raw.get('protocol') or raw.get('protocolName') or 'unknown').lower() + if protocol not in {'torrent', 'usenet'}: + protocol = 'unknown' + enabled = bool(raw.get('enable', raw.get('enabled', False))) + status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower() + if status in {'ok', 'available'}: + status = 'healthy' + if status not in {'healthy', 'warning', 'error', 'unknown'}: + status = 'unknown' + categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else [] + categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item] + return { + 'id': raw.get('id'), + 'name': str(raw.get('name') or raw.get('definitionName') or 'unknown'), + 'protocol': protocol, + 'enabled': enabled, + 'status': status, + 'categories': categories, + 'privacy': str(raw.get('privacy') or 'unknown').lower(), + } + + @staticmethod + def _map_application(raw: dict[str, Any]) -> dict[str, Any]: + enabled = bool(raw.get('enable', raw.get('enabled', False))) + status = str(raw.get('status') or ('healthy' if enabled else 'unknown')).lower() + if status not in {'healthy', 'warning', 'error', 'unknown'}: + status = 'unknown' + return { + 'id': raw.get('id'), + 'name': str(raw.get('name') or raw.get('implementation') or 'unknown'), + 'implementation': str(raw.get('implementation') or 'unknown'), + 'syncLevel': str(raw.get('syncLevel') or raw.get('sync_level') or 'unknown'), + 'enabled': enabled, + 'status': status, + } + + @staticmethod + def _map_search_result(raw: dict[str, Any]) -> dict[str, Any]: + categories_raw = raw.get('categories') if isinstance(raw.get('categories'), list) else [] + categories = [str(item.get('name') if isinstance(item, dict) else item) for item in categories_raw if item] + return { + 'title': str(raw.get('title') or 'unknown'), + 'indexer': str(raw.get('indexer') or raw.get('indexerName') or 'unknown'), + 'protocol': str(raw.get('protocol') or 'unknown').lower(), + 'size': raw.get('size'), + 'age': raw.get('age'), + 'seeders': _as_int(raw.get('seeders'), 0), + 'leechers': _as_int(raw.get('leechers'), 0), + 'category': categories[0] if categories else str(raw.get('category') or 'unknown'), + 'downloadAllowed': False, + } + + @staticmethod + def _empty_indexers(status: str) -> dict[str, Any]: + return { + 'success': False, + 'status': status, + 'message': status, + 'total': 0, + 'enabled': 0, + 'disabled': 0, + 'failing': 0, + 'protocols': {'torrent': 0, 'usenet': 0, 'unknown': 0}, + 'items': [], + } diff --git a/backend/app/connectors/radarr.py b/backend/app/connectors/radarr.py new file mode 100644 index 0000000..79746c8 --- /dev/null +++ b/backend/app/connectors/radarr.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _RadarrConfig: + url: str + api_key: str + verify_tls: bool + default_root_folder: str | None + default_quality_profile: int | None + default_minimum_availability: str + search_on_add: bool + timeout_seconds: float + + +def _as_int(value: Any, default: int = 0) -> int: + if value is None or value == '': + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, int | float): + return int(value) + try: + return int(str(value).strip()) + except ValueError: + return default + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _safe_url() -> str: + value = settings.apis.get('RADARR_URL') + if not value: + return '' + return str(value).rstrip('/') + + +class RadarrConnector(BaseConnector): + name = 'Radarr' + + def is_configured(self) -> bool: + return bool(settings.apis.get('RADARR_URL') and settings.apis.get('RADARR_API_KEY')) + + def _config(self) -> _RadarrConfig | None: + if not self.is_configured(): + return None + + return _RadarrConfig( + url=str(settings.apis['RADARR_URL']).rstrip('/'), + api_key=str(settings.apis['RADARR_API_KEY']), + verify_tls=_as_bool(settings.apis.get('RADARR_VERIFY_TLS'), False), + default_root_folder=( + str(settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER')) + if settings.apis.get('RADARR_DEFAULT_ROOT_FOLDER') + else None + ), + default_quality_profile=( + _as_int(settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE')) + if settings.apis.get('RADARR_DEFAULT_QUALITY_PROFILE') + else None + ), + default_minimum_availability=str(settings.apis.get('RADARR_DEFAULT_MINIMUM_AVAILABILITY') or 'released'), + search_on_add=_as_bool(settings.apis.get('RADARR_SEARCH_ON_ADD'), True), + timeout_seconds=_as_float(settings.apis.get('RADARR_TIMEOUT_SECONDS'), 10.0), + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + + headers = dict(kwargs.pop('headers', {}) or {}) + headers['X-Api-Key'] = config.api_key + url = f'{config.url}{path}' + + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.request(method=method, url=url, headers=headers, **kwargs) + except (httpx.RequestError, ValueError) as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + if response.status_code in {401, 403}: + self.set_error('unauthorized') + raise RuntimeError('unauthorized') + if response.status_code >= 500: + message = f'Radarr server error: HTTP {response.status_code}' + self.set_error(message) + raise RuntimeError(message) + if response.status_code >= 400: + message = redact_message(f'Radarr API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + + if response.status_code == 204: + return None + + try: + return response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + + try: + payload = self._request('GET', '/api/v3/system/status') + except RuntimeError as exc: + reason = str(exc) + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable', 'reason': redact_message(reason)} + + version = payload.get('version') if isinstance(payload, dict) else None + return {'status': 'connected', 'version': str(version or 'unknown')} + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return { + 'success': False, + 'status': 'missing_config', + 'message': 'missing_config', + 'version': 'unknown', + 'url': '', + 'required_env_vars': ['RADARR_URL', 'RADARR_API_KEY'], + } + + result = self.test_connection() + status = str(result.get('status', 'error')) + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Radarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'version': str(result.get('version', 'unknown')), + 'url': _safe_url(), + } + + def search_movie(self, query: str) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []} + + try: + payload = self._request('GET', '/api/v3/movie/lookup', params={'term': query}) + except RuntimeError as exc: + return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []} + + if not isinstance(payload, list): + return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []} + + return { + 'success': True, + 'status': 'connected', + 'items': [self._map_movie(item) for item in payload if isinstance(item, dict)], + } + + def queue_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._empty_queue('missing_config') + + try: + payload = self._request('GET', '/api/v3/queue') + except RuntimeError as exc: + result = self._empty_queue('unavailable') + result['message'] = redact_message(str(exc)) + return result + + records = payload.get('records', []) if isinstance(payload, dict) else [] + if not isinstance(records, list): + records = [] + + items = [self._map_queue_item(item) for item in records if isinstance(item, dict)] + return { + 'success': True, + 'status': 'ok', + 'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items), + 'downloading': sum(1 for item in items if item['status'] == 'downloading'), + 'queued': sum(1 for item in items if item['status'] == 'queued'), + 'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'), + 'stalled': sum(1 for item in items if item['status'] == 'stalled'), + 'items': items, + } + + def wanted_missing(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + + try: + payload = self._request('GET', '/api/v3/wanted/missing', params={'page': 1, 'pageSize': 20}) + except RuntimeError as exc: + return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + + records = payload.get('records', []) if isinstance(payload, dict) else [] + if not isinstance(records, list): + records = [] + return { + 'success': True, + 'status': 'connected', + 'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records), + 'items': records[:20], + } + + def add_movie(self, payload: dict[str, Any]) -> dict[str, Any]: + config = self._config() + if not config: + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + + title = str(payload.get('title') or '').strip() + tmdb_id = _as_int(payload.get('tmdbId') or payload.get('tmdb_id')) + if not title or not tmdb_id: + return {'success': False, 'status': 'error', 'message': 'title_and_tmdbId_required'} + + root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip() + quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0) + minimum_availability = str( + payload.get('minimumAvailability') or config.default_minimum_availability or 'released' + ).strip() + if not root_folder or not quality_profile_id: + return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'} + + search_on_add = _as_bool(payload.get('searchForMovie'), config.search_on_add) + request_payload = { + **payload, + 'title': title, + 'tmdbId': tmdb_id, + 'rootFolderPath': root_folder, + 'qualityProfileId': quality_profile_id, + 'monitored': _as_bool(payload.get('monitored'), True), + 'minimumAvailability': minimum_availability, + 'searchForMovie': search_on_add, + 'addOptions': { + 'searchForMovie': search_on_add, + }, + } + + try: + result = self._request('POST', '/api/v3/movie', json=request_payload) + except RuntimeError as exc: + return {'success': False, 'status': 'error', 'message': redact_message(str(exc))} + + return { + 'success': True, + 'status': 'connected', + 'message': 'movie_added', + 'data': result if isinstance(result, dict) else {}, + } + + @staticmethod + def _map_movie(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'id': _as_int(raw.get('id')), + 'tmdbId': _as_int(raw.get('tmdbId')), + 'imdbId': str(raw.get('imdbId', '')), + 'title': str(raw.get('title', '')), + 'year': _as_int(raw.get('year')), + 'overview': str(raw.get('overview', '')), + 'status': str(raw.get('status', 'unknown')), + 'remotePoster': str(raw.get('remotePoster', '')), + 'existing': bool(raw.get('id') and raw.get('path')), + 'monitored': _as_bool(raw.get('monitored'), False), + 'minimumAvailability': str(raw.get('minimumAvailability', 'unknown')), + } + + @staticmethod + def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]: + movie = raw.get('movie') if isinstance(raw.get('movie'), dict) else {} + return { + 'id': raw.get('id'), + 'title': str(raw.get('title') or movie.get('title') or 'unknown'), + 'status': str(raw.get('status', 'unknown')).lower(), + 'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(), + 'size': raw.get('size'), + 'sizeleft': raw.get('sizeleft'), + } + + @staticmethod + def _empty_queue(status: str) -> dict[str, Any]: + return { + 'success': False, + 'status': status, + 'message': status, + 'total': 0, + 'downloading': 0, + 'queued': 0, + 'failed': 0, + 'stalled': 0, + 'items': [], + } diff --git a/backend/app/connectors/sonarr.py b/backend/app/connectors/sonarr.py new file mode 100644 index 0000000..53ce3ee --- /dev/null +++ b/backend/app/connectors/sonarr.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +_SONARR_DEFAULT_TIMEOUT_SECONDS = 8.0 + + +@dataclass +class _SonarrConfig: + url: str + api_key: str + verify_tls: bool + default_root_folder: str | None + default_quality_profile: int | None + search_on_add: bool + + +def _as_int(value: Any, default: int = 0) -> int: + if value is None or value == '': + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, int | float): + return int(value) + try: + return int(str(value).strip()) + except ValueError: + return default + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _safe_url() -> str: + value = settings.apis.get('SONARR_URL') + if not value: + return '' + return str(value).rstrip('/') + + +class SonarrConnector(BaseConnector): + name = 'Sonarr' + + def is_configured(self) -> bool: + return bool(settings.apis.get('SONARR_URL') and settings.apis.get('SONARR_API_KEY')) + + def _config(self) -> _SonarrConfig | None: + if not self.is_configured(): + return None + + return _SonarrConfig( + url=str(settings.apis['SONARR_URL']).rstrip('/'), + api_key=str(settings.apis['SONARR_API_KEY']), + verify_tls=bool(settings.verify_tls), + default_root_folder=( + str(settings.apis.get('SONARR_DEFAULT_ROOT_FOLDER')) + if settings.apis.get('SONARR_DEFAULT_ROOT_FOLDER') + else None + ), + default_quality_profile=( + _as_int(settings.apis.get('SONARR_DEFAULT_QUALITY_PROFILE')) + if settings.apis.get('SONARR_DEFAULT_QUALITY_PROFILE') + else None + ), + search_on_add=_as_bool(settings.apis.get('SONARR_SEARCH_ON_ADD'), True), + ) + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + + headers = dict(kwargs.pop('headers', {}) or {}) + headers['X-Api-Key'] = config.api_key + url = f'{config.url}{path}' + + try: + self.clear_error() + with httpx.Client(timeout=_SONARR_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client: + response = client.request(method=method, url=url, headers=headers, **kwargs) + except (httpx.RequestError, ValueError) as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + if response.status_code in {401, 403}: + self.set_error('unauthorized') + raise RuntimeError('unauthorized') + if response.status_code >= 500: + message = f'Sonarr server error: HTTP {response.status_code}' + self.set_error(message) + raise RuntimeError(message) + if response.status_code >= 400: + message = redact_message(f'Sonarr API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + + if response.status_code == 204: + return None + + try: + return response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + + try: + payload = self._request('GET', '/api/v3/system/status') + except RuntimeError as exc: + reason = str(exc) + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'error', 'reason': redact_message(reason)} + + version = payload.get('version') if isinstance(payload, dict) else None + return {'status': 'connected', 'version': str(version or 'unknown')} + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return { + 'success': False, + 'status': 'missing_config', + 'message': 'missing_config', + 'version': 'unknown', + 'url': '', + 'required_env_vars': ['SONARR_URL', 'SONARR_API_KEY'], + } + + result = self.test_connection() + status = str(result.get('status', 'error')) + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Sonarr connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'version': str(result.get('version', 'unknown')), + 'url': _safe_url(), + } + + def search_series(self, query: str) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': []} + + try: + payload = self._request('GET', '/api/v3/series/lookup', params={'term': query}) + except RuntimeError as exc: + return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'items': []} + + if not isinstance(payload, list): + return {'success': False, 'status': 'error', 'message': 'unexpected_payload', 'items': []} + + return { + 'success': True, + 'status': 'connected', + 'items': [self._map_series(item) for item in payload if isinstance(item, dict)], + } + + def queue_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._empty_queue('missing_config') + + try: + payload = self._request('GET', '/api/v3/queue') + except RuntimeError as exc: + result = self._empty_queue('error') + result['message'] = redact_message(str(exc)) + return result + + records = payload.get('records', []) if isinstance(payload, dict) else [] + if not isinstance(records, list): + records = [] + + items = [self._map_queue_item(item) for item in records if isinstance(item, dict)] + return { + 'success': True, + 'status': 'connected', + 'total': int(payload.get('totalRecords', len(items))) if isinstance(payload, dict) else len(items), + 'downloading': sum(1 for item in items if item['status'] == 'downloading'), + 'queued': sum(1 for item in items if item['status'] == 'queued'), + 'failed': sum(1 for item in items if item['trackedDownloadStatus'] == 'error' or item['status'] == 'failed'), + 'stalled': sum(1 for item in items if item['status'] == 'stalled'), + 'items': items, + } + + def wanted_missing(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + + try: + payload = self._request('GET', '/api/v3/wanted/missing', params={'page': 1, 'pageSize': 20}) + except RuntimeError as exc: + return {'success': False, 'status': 'error', 'message': redact_message(str(exc)), 'total': 0, 'items': []} + + records = payload.get('records', []) if isinstance(payload, dict) else [] + if not isinstance(records, list): + records = [] + return { + 'success': True, + 'status': 'connected', + 'total': int(payload.get('totalRecords', len(records))) if isinstance(payload, dict) else len(records), + 'items': records[:20], + } + + def add_series(self, payload: dict[str, Any]) -> dict[str, Any]: + config = self._config() + if not config: + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + + title = str(payload.get('title') or '').strip() + tvdb_id = _as_int(payload.get('tvdbId') or payload.get('tvdb_id')) + if not title or not tvdb_id: + return {'success': False, 'status': 'error', 'message': 'title_and_tvdbId_required'} + + root_folder = str(payload.get('rootFolderPath') or config.default_root_folder or '').strip() + quality_profile_id = _as_int(payload.get('qualityProfileId'), config.default_quality_profile or 0) + if not root_folder or not quality_profile_id: + return {'success': False, 'status': 'missing_config', 'message': 'missing_defaults'} + + request_payload = { + **payload, + 'title': title, + 'tvdbId': tvdb_id, + 'rootFolderPath': root_folder, + 'qualityProfileId': quality_profile_id, + 'monitored': _as_bool(payload.get('monitored'), True), + 'seasonFolder': _as_bool(payload.get('seasonFolder'), True), + 'searchForMissingEpisodes': _as_bool(payload.get('searchForMissingEpisodes'), config.search_on_add), + 'addOptions': { + 'searchForMissingEpisodes': _as_bool(payload.get('searchForMissingEpisodes'), config.search_on_add), + }, + } + + try: + result = self._request('POST', '/api/v3/series', json=request_payload) + except RuntimeError as exc: + return {'success': False, 'status': 'error', 'message': redact_message(str(exc))} + + return { + 'success': True, + 'status': 'connected', + 'message': 'series_added', + 'data': result if isinstance(result, dict) else {}, + } + + @staticmethod + def _map_series(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'id': _as_int(raw.get('id')), + 'tvdbId': _as_int(raw.get('tvdbId')), + 'title': str(raw.get('title', '')), + 'year': _as_int(raw.get('year')), + 'overview': str(raw.get('overview', '')), + 'status': str(raw.get('status', 'unknown')), + 'remotePoster': str(raw.get('remotePoster', '')), + 'existing': bool(raw.get('id') and raw.get('path')), + 'monitored': _as_bool(raw.get('monitored'), False), + 'seasons': raw.get('seasons') if isinstance(raw.get('seasons'), list) else [], + } + + @staticmethod + def _map_queue_item(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'id': raw.get('id'), + 'title': str(raw.get('title', raw.get('series', {}).get('title', 'unknown'))), + 'status': str(raw.get('status', 'unknown')).lower(), + 'trackedDownloadStatus': str(raw.get('trackedDownloadStatus', 'unknown')).lower(), + 'size': raw.get('size'), + 'sizeleft': raw.get('sizeleft'), + } + + @staticmethod + def _empty_queue(status: str) -> dict[str, Any]: + return { + 'success': False, + 'status': status, + 'message': status, + 'total': 0, + 'downloading': 0, + 'queued': 0, + 'failed': 0, + 'stalled': 0, + 'items': [], + } diff --git a/backend/app/connectors/tautulli.py b/backend/app/connectors/tautulli.py new file mode 100644 index 0000000..eb269e3 --- /dev/null +++ b/backend/app/connectors/tautulli.py @@ -0,0 +1,384 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _TautulliConfig: + url: str + api_key: str + verify_tls: bool + timeout_seconds: float + + +def _as_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _safe_url() -> str: + value = settings.apis.get('TAUTULLI_URL') + return str(value).rstrip('/') if value else '' + + +def _mask_ip(value: Any) -> str: + text = str(value or '') + parts = text.split('.') + if len(parts) == 4: + return f'{parts[0]}.{parts[1]}.{parts[2]}.x' + return '[masked]' if text else '' + + +def _list_from_data(data: Any, *keys: str) -> list[dict[str, Any]]: + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + value = data.get('data') + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _title(raw: dict[str, Any]) -> str: + title = str(raw.get('full_title') or raw.get('title') or raw.get('media_title') or 'unknown') + parent = raw.get('grandparent_title') or raw.get('grandparentTitle') + return f'{parent} - {title}' if parent and str(parent) not in title else title + + +class TautulliConnector(BaseConnector): + name = 'Tautulli' + + def is_configured(self) -> bool: + return bool(settings.apis.get('TAUTULLI_URL') and settings.apis.get('TAUTULLI_API_KEY')) + + def _config(self) -> _TautulliConfig | None: + if not self.is_configured(): + return None + return _TautulliConfig( + url=str(settings.apis['TAUTULLI_URL']).rstrip('/'), + api_key=str(settings.apis['TAUTULLI_API_KEY']), + verify_tls=_as_bool(settings.apis.get('TAUTULLI_VERIFY_TLS'), False), + timeout_seconds=_as_float(settings.apis.get('TAUTULLI_TIMEOUT_SECONDS'), 10.0), + ) + + def _command(self, cmd: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + config = self._config() + if not config: + raise RuntimeError('missing_config') + query = {'apikey': config.api_key, 'cmd': cmd, **(params or {})} + try: + self.clear_error() + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.get(f'{config.url}/api/v2', params=query, headers={'Accept': 'application/json'}) + except httpx.RequestError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + if response.status_code in {401, 403}: + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + if response.status_code >= 400: + message = redact_message(f'Tautulli API error: HTTP {response.status_code} {(response.text or "")[:256]}') + self.set_error(message) + raise RuntimeError(message) + try: + payload = response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + response_meta = payload.get('response') if isinstance(payload, dict) else {} + if isinstance(response_meta, dict) and str(response_meta.get('result', '')).lower() == 'error': + message = redact_message(str(response_meta.get('message') or 'tautulli_error')) + if 'api' in message.lower() and 'key' in message.lower(): + self.set_error('auth_failed') + raise RuntimeError('auth_failed') + self.set_error(message) + raise RuntimeError(message) + return payload + + @staticmethod + def _data(payload: dict[str, Any]) -> Any: + response = payload.get('response') if isinstance(payload, dict) else {} + return response.get('data') if isinstance(response, dict) else None + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + try: + data = self._data(self._command('get_server_info')) + except RuntimeError as exc: + reason = redact_message(str(exc)) + if reason.startswith('auth_failed'): + return {'status': 'auth_failed', 'reason': reason} + if reason == 'missing_config': + return {'status': 'missing_config', 'reason': reason} + return {'status': 'unreachable', 'reason': reason} + return { + 'status': 'connected', + 'tautulliVersion': str((data or {}).get('tautulli_version') or 'unknown') if isinstance(data, dict) else 'unknown', + 'plexServerName': str((data or {}).get('pms_name') or '') if isinstance(data, dict) else '', + } + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'required_env_vars': ['TAUTULLI_URL', 'TAUTULLI_API_KEY']} + result = self.test_connection() + status = str(result.get('status', 'error')) + return { + 'success': status == 'connected', + 'status': status, + 'message': 'Tautulli connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), + 'url': _safe_url(), + 'tautulliVersion': str(result.get('tautulliVersion', 'unknown')), + 'plexServerName': str(result.get('plexServerName', '')), + } + + def status(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'data': {}} + try: + data = self._data(self._command('status')) + except RuntimeError as exc: + return self._error_payload(exc, data={}) + return {'success': True, 'status': 'ok', 'message': 'status_ok', 'data': data or {}} + + def activity(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'streamCount': 0, 'sessions': []} + try: + data = self._data(self._command('get_activity')) + except RuntimeError as exc: + return self._error_payload(exc, streamCount=0, sessions=[]) + records = _list_from_data(data, 'sessions') + sessions = [self._map_session(item) for item in records] + return { + 'success': True, + 'status': 'ok', + 'message': 'activity_ok', + 'streamCount': _as_int((data or {}).get('stream_count'), len(sessions)) if isinstance(data, dict) else len(sessions), + 'lanBandwidth': _as_int((data or {}).get('lan_bandwidth')) if isinstance(data, dict) else 0, + 'wanBandwidth': _as_int((data or {}).get('wan_bandwidth')) if isinstance(data, dict) else 0, + 'totalBandwidth': _as_int((data or {}).get('total_bandwidth')) if isinstance(data, dict) else sum(item['bandwidth'] for item in sessions), + 'sessions': sessions, + 'data': {'sessions': sessions}, + } + + def history(self, limit: int = 25) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + data = self._data(self._command('get_history', {'length': limit})) + except RuntimeError as exc: + return self._error_payload(exc, total=0, items=[]) + items = [self._map_history(item) for item in _list_from_data(data, 'data', 'history')] + return {'success': True, 'status': 'ok', 'message': 'history_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def users(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + data = self._data(self._command('get_users')) + except RuntimeError as exc: + return self._error_payload(exc, total=0, items=[]) + items = [self._map_user(item) for item in _list_from_data(data, 'users')] + return {'success': True, 'status': 'ok', 'message': 'users_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def libraries(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + data = self._data(self._command('get_libraries')) + except RuntimeError as exc: + return self._error_payload(exc, total=0, items=[]) + items = [self._map_library(item) for item in _list_from_data(data, 'libraries')] + return {'success': True, 'status': 'ok', 'message': 'libraries_ok', 'total': len(items), 'items': items, 'libraries': items, 'data': {'items': items}} + + def recently_added(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'total': 0, 'items': []} + try: + data = self._data(self._command('get_recently_added', {'count': 25})) + except RuntimeError as exc: + return self._error_payload(exc, total=0, items=[]) + items = [self._map_recent(item) for item in _list_from_data(data, 'recently_added', 'items')] + return {'success': True, 'status': 'ok', 'message': 'recently_added_ok', 'total': len(items), 'items': items, 'data': {'items': items}} + + def watch_stats(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'totalPlays': 0, 'totalDuration': 0, 'topUsers': [], 'topLibraries': [], 'topPlatforms': [], 'playsByDay': []} + try: + by_day = [self._map_day(item) for item in _list_from_data(self._data(self._command('get_plays_by_date')), 'data')] + top_users = [self._map_top_user(item) for item in _list_from_data(self._data(self._command('get_plays_by_top_10_users')), 'data')] + top_platforms = [self._map_top_platform(item) for item in _list_from_data(self._data(self._command('get_plays_by_top_10_platforms')), 'data')] + except RuntimeError as exc: + return self._error_payload(exc, totalPlays=0, totalDuration=0, topUsers=[], topLibraries=[], topPlatforms=[], playsByDay=[]) + total_plays = sum(_as_int(item.get('plays')) for item in by_day) or sum(_as_int(item.get('plays')) for item in top_users) + total_duration = sum(_as_int(item.get('duration')) for item in by_day) or sum(_as_int(item.get('duration')) for item in top_users) + return { + 'success': True, + 'status': 'ok', + 'message': 'watch_stats_ok', + 'totalPlays': total_plays, + 'totalDuration': total_duration, + 'topUsers': top_users, + 'topLibraries': [], + 'topPlatforms': top_platforms, + 'playsByDay': by_day, + 'data': {'topUsers': top_users, 'topPlatforms': top_platforms, 'playsByDay': by_day}, + } + + def stream_summary(self) -> dict[str, Any]: + activity = self.activity() + sessions = activity.get('sessions', []) if isinstance(activity, dict) else [] + return { + 'success': bool(activity.get('success')), + 'status': activity.get('status', 'unreachable'), + 'message': activity.get('message', 'stream_summary'), + 'activeStreams': len(sessions), + 'directPlay': sum(1 for item in sessions if item.get('transcodeDecision') == 'directplay'), + 'directStream': sum(1 for item in sessions if item.get('transcodeDecision') == 'directstream'), + 'transcoding': sum(1 for item in sessions if item.get('transcodeDecision') == 'transcode'), + 'lanStreams': sum(1 for item in sessions if item.get('location') == 'lan'), + 'wanStreams': sum(1 for item in sessions if item.get('location') == 'wan'), + 'bandwidth': sum(_as_int(item.get('bandwidth')) for item in sessions), + 'data': {'sessions': sessions}, + } + + def plex_info(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + try: + data = self._data(self._command('get_server_info')) + except RuntimeError as exc: + return self._error_payload(exc) + data = data if isinstance(data, dict) else {} + machine = str(data.get('pms_identifier') or data.get('machine_identifier') or '') + return { + 'success': True, + 'status': 'ok', + 'message': 'plex_info_ok', + 'name': str(data.get('pms_name') or data.get('name') or ''), + 'version': str(data.get('pms_version') or data.get('version') or 'unknown'), + 'platform': str(data.get('pms_platform') or data.get('platform') or ''), + 'machineIdentifier': f'{machine[:8]}...' if machine else '', + 'plexPass': bool(data.get('pms_is_plexpass', data.get('plexpass', False))), + 'data': {}, + } + + @staticmethod + def _map_session(raw: dict[str, Any]) -> dict[str, Any]: + decision = str(raw.get('transcode_decision') or raw.get('transcodeDecision') or 'direct play').lower().replace(' ', '') + if decision == 'copy': + decision = 'directstream' + return { + 'sessionKey': str(raw.get('session_key') or raw.get('sessionKey') or ''), + 'user': str(raw.get('friendly_name') or raw.get('username') or raw.get('user') or 'unknown'), + 'title': _title(raw), + 'mediaType': str(raw.get('media_type') or raw.get('type') or ''), + 'player': str(raw.get('player') or ''), + 'platform': str(raw.get('platform') or ''), + 'state': str(raw.get('state') or ''), + 'progress': _as_int(raw.get('progress_percent') or raw.get('progress')), + 'qualityProfile': str(raw.get('quality_profile') or raw.get('qualityProfile') or ''), + 'transcodeDecision': decision, + 'videoDecision': str(raw.get('video_decision') or raw.get('videoDecision') or decision).lower().replace(' ', ''), + 'audioDecision': str(raw.get('audio_decision') or raw.get('audioDecision') or '').lower().replace(' ', ''), + 'bandwidth': _as_int(raw.get('bandwidth')), + 'ipAddress': _mask_ip(raw.get('ip_address') or raw.get('ipAddress')), + 'location': str(raw.get('location') or '').lower(), + } + + @staticmethod + def _map_history(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'rowId': raw.get('row_id') or raw.get('rowId'), + 'user': str(raw.get('friendly_name') or raw.get('username') or 'unknown'), + 'title': _title(raw), + 'mediaType': str(raw.get('media_type') or ''), + 'watchedStatus': raw.get('watched_status'), + 'date': raw.get('date'), + 'duration': _as_int(raw.get('duration')), + 'platform': str(raw.get('platform') or ''), + 'transcodeDecision': str(raw.get('transcode_decision') or raw.get('transcodeDecision') or '').lower().replace(' ', ''), + } + + @staticmethod + def _map_user(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'userId': str(raw.get('user_id') or raw.get('userId') or ''), + 'username': str(raw.get('username') or ''), + 'friendlyName': str(raw.get('friendly_name') or raw.get('friendlyName') or raw.get('username') or ''), + 'lastSeen': raw.get('last_seen') or raw.get('lastSeen'), + 'plays': _as_int(raw.get('plays')), + 'duration': _as_int(raw.get('duration')), + } + + @staticmethod + def _map_library(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'sectionId': str(raw.get('section_id') or raw.get('sectionId') or ''), + 'name': str(raw.get('section_name') or raw.get('name') or ''), + 'type': str(raw.get('section_type') or raw.get('type') or ''), + 'count': _as_int(raw.get('count')), + 'parentCount': _as_int(raw.get('parent_count') or raw.get('parentCount')), + 'childCount': _as_int(raw.get('child_count') or raw.get('childCount')), + 'lastAccessed': raw.get('last_accessed') or raw.get('lastAccessed'), + } + + @staticmethod + def _map_recent(raw: dict[str, Any]) -> dict[str, Any]: + return { + 'title': _title(raw), + 'mediaType': str(raw.get('media_type') or raw.get('type') or ''), + 'library': str(raw.get('section_name') or raw.get('library') or ''), + 'addedAt': raw.get('added_at') or raw.get('addedAt'), + 'year': raw.get('year'), + 'thumb': raw.get('thumb'), + } + + @staticmethod + def _map_day(raw: dict[str, Any]) -> dict[str, Any]: + return {'date': raw.get('date'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))} + + @staticmethod + def _map_top_user(raw: dict[str, Any]) -> dict[str, Any]: + return {'name': str(raw.get('friendly_name') or raw.get('username') or raw.get('user') or 'unknown'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))} + + @staticmethod + def _map_top_platform(raw: dict[str, Any]) -> dict[str, Any]: + return {'platform': str(raw.get('platform') or 'unknown'), 'plays': _as_int(raw.get('plays')), 'duration': _as_int(raw.get('duration'))} + + @staticmethod + def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: + reason = redact_message(str(exc)) + status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable') + return {'success': False, 'status': status, 'message': reason, **extra} diff --git a/backend/app/connectors/unraid.py b/backend/app/connectors/unraid.py new file mode 100644 index 0000000..f0c3d53 --- /dev/null +++ b/backend/app/connectors/unraid.py @@ -0,0 +1,742 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +_UNRAID_DEFAULT_TIMEOUT_SECONDS = 8.0 +_UNRAID_REDIRECT_STATUSES = {301, 302, 303, 307, 308} + + +@dataclass +class _ConnectorConfig: + url: str + api_key: str + verify_tls: bool + + +def _as_bool(value: Any) -> bool | None: + if isinstance(value, bool): + return value + + if value is None: + return None + + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on', 'running', 'started'}: + return True + if normalized in {'false', '0', 'off', 'stopped'}: + return False + + return None + + +def _as_int(value: Any, default: int = 0) -> int: + if value is None: + return default + + if isinstance(value, bool): + return int(value) + + if isinstance(value, int | float): + return int(value) + + if isinstance(value, str): + cleaned = value.strip().strip('%') + if not cleaned: + return default + numeric = re.sub(r'[^0-9.+-]', '', cleaned) + if not numeric: + return default + try: + return int(float(numeric)) + except ValueError: + return default + + return default + + +def _as_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + + if isinstance(value, int | float): + return float(value) + + if isinstance(value, str): + cleaned = value.strip().strip('%') + if not cleaned: + return None + numeric = re.sub(r'[^0-9.+-]', '', cleaned) + if not numeric: + return None + try: + return float(numeric) + except ValueError: + return None + + return None + + +def _clamp_percent(value: float) -> int: + return int(round(max(0.0, min(100.0, value)))) + + +def _percent_from_values(used: Any, total: Any) -> int | None: + used_value = _as_float(used) + total_value = _as_float(total) + if used_value is None or total_value is None or total_value <= 0: + return None + return _clamp_percent((used_value / total_value) * 100) + + +def _average_cpu_usage(cpus: Any) -> int | None: + if not isinstance(cpus, list) or not cpus: + return None + + idle_values: list[float] = [] + active_values: list[float] = [] + active_fields = ('percentUser', 'percentSystem', 'percentNice', 'percentIrq') + + for raw_cpu in cpus: + if not isinstance(raw_cpu, dict): + continue + idle = _as_float(raw_cpu.get('percentIdle')) + if idle is not None: + idle_values.append(max(0.0, min(100.0, idle))) + continue + + active_total = 0.0 + has_active = False + for field in active_fields: + value = _as_float(raw_cpu.get(field)) + if value is None: + continue + active_total += value + has_active = True + if has_active: + active_values.append(active_total) + + if idle_values: + return _clamp_percent(100 - (sum(idle_values) / len(idle_values))) + if active_values: + return _clamp_percent(sum(active_values) / len(active_values)) + return None + + +def _storage_value_to_gib(value: Any) -> int: + raw = _as_int(value, 0) + if raw <= 0: + return 0 + if raw >= 1_048_576: + return int(raw / 1024 / 1024) + return raw + + +def _get_nested(data: dict[str, Any], path: str) -> Any: + cursor: Any = data + for part in path.split('.'): + if not isinstance(cursor, dict): + return None + cursor = cursor.get(part) + if cursor is None: + return None + return cursor + + +def _first_value(data: dict[str, Any], paths: tuple[str, ...]) -> Any: + for path in paths: + value = _get_nested(data, path) + if value is not None: + return value + return None + + +def _normalize_status(value: Any, allowed: tuple[str, ...], default: str = "stopped") -> str: + if value is None: + return default + + candidate = str(value).strip().lower() + if not candidate: + return default + + if candidate in {"1", "true", "started", "running", "online", "healthy"}: + return allowed[0] + if candidate in {"0", "false", "stopped", "offline", "unhealthy", "critical", "invalid"}: + return allowed[1] if len(allowed) > 1 else default + return candidate if candidate in allowed else default + + +def _safe_disk_warning_count(disks: list[dict[str, Any]]) -> int: + total = 0 + for raw_disk in disks: + if not isinstance(raw_disk, dict): + continue + warning = raw_disk.get('warning') + if warning is True: + total += 1 + continue + + if raw_disk.get('critical') is not None: + total += 1 + continue + + status = str(raw_disk.get('status', raw_disk.get('state', ''))).strip().lower() + if status in {'warning', 'degraded', 'failed', 'offline', 'faulty', 'disk_dsbl', 'disk_invalid'}: + total += 1 + + if _as_bool(raw_disk.get('healthy')) is False: + total += 1 + + return total + + +def _trusted_https_redirect(response: httpx.Response) -> httpx.URL | None: + if response.status_code not in _UNRAID_REDIRECT_STATUSES: + return None + + location = response.headers.get('location') + if not location: + return None + + redirected = response.url.join(location) + if redirected.scheme != 'https' or redirected.host != response.url.host: + return None + return redirected + + +class UnraidConnector(BaseConnector): + name = 'Unraid' + default_headers = { + 'Content-Type': 'application/json', + 'x-api-key': '', + } + + def __init__(self): + super().__init__() + + def is_configured(self): + return bool(settings.apis.get('UNRAID_URL') and settings.apis.get('UNRAID_API_KEY')) + + def _config(self) -> _ConnectorConfig | None: + if not self.is_configured(): + return None + + return _ConnectorConfig( + url=str(settings.apis['UNRAID_URL']).rstrip('/'), + api_key=str(settings.apis['UNRAID_API_KEY']), + verify_tls=bool(settings.apis.get('UNRAID_VERIFY_TLS', settings.verify_tls)), + ) + + def _request(self, query: str) -> dict[str, Any]: + config = self._config() + if not config: + raise RuntimeError('missing_config') + + headers = dict(self.default_headers) + headers['x-api-key'] = config.api_key + url = f'{config.url}/graphql' + + try: + self.clear_error() + with httpx.Client(timeout=_UNRAID_DEFAULT_TIMEOUT_SECONDS, verify=config.verify_tls) as client: + response = client.post(url, headers=headers, json={'query': query}) + if response.status_code in _UNRAID_REDIRECT_STATUSES: + redirected = _trusted_https_redirect(response) + if redirected is None: + raise RuntimeError('Unraid API refused an untrusted redirect') + response = client.post(str(redirected), headers=headers, json={'query': query}) + except (httpx.RequestError, ValueError) as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + if response.status_code in {401, 403}: + message = 'unauthorized' + self.set_error(message) + raise RuntimeError(message) + + if response.status_code >= 500: + message = f'Unraid server error: HTTP {response.status_code}' + self.set_error(message) + raise RuntimeError(message) + + if response.status_code >= 400: + payload = response.text or '' + message = f'Unraid API error: HTTP {response.status_code} {payload[:256]}' + redacted = redact_message(message) + self.set_error(redacted) + raise RuntimeError(redacted) + + try: + payload = response.json() + except ValueError as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + if not isinstance(payload, dict): + message = 'Invalid GraphQL payload' + self.set_error(message) + raise RuntimeError(message) + + if payload.get('errors'): + error_messages = [ + str(err.get('message', 'unknown')) + for err in payload.get('errors') if isinstance(err, dict) + ] + message = f'GraphQL error: {", ".join(error_messages[:2])}' + redacted = redact_message(message) + self.set_error(redacted) + raise RuntimeError(redacted) + + data = payload.get('data') + if data is None: + message = 'No GraphQL data field' + self.set_error(message) + raise RuntimeError(message) + + if not isinstance(data, dict): + message = 'Invalid GraphQL data' + self.set_error(message) + raise RuntimeError(message) + + return data + + def _query(self, queries: list[str]) -> tuple[dict[str, Any], str]: + last_error = '' + for query in queries: + try: + return self._request(query), 'ok' + except RuntimeError as exc: + last_error = str(exc) + if last_error == 'missing_config': + return {'error': 'missing_config'}, 'missing_config' + if 'VMs are not available' in last_error: + return {'error': last_error}, 'unavailable' + continue + + return {'error': last_error or 'query_failed'}, 'error' + + def test_connection(self): + data, status = self._query(['query { __typename }']) + if status == 'ok': + return {'status': 'connected'} + if status == 'missing_config': + return {'status': 'missing_config', 'reason': 'missing_config'} + reason = str(data.get('error', 'connection_failed')) + return {'status': 'error', 'reason': reason} + + def get_server_info(self): + payload, status = self._query([ + 'query { info { os { platform distro release uptime } cpu { manufacturer brand cores threads } } }', + 'query { serverInfo { hostname version uptime cpuPercent ramPercent } }', + 'query { system { name version uptime cpuPercent ramPercent } }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))} + + uptime = _first_value(payload, ('info.os.uptime', 'serverInfo.uptime', 'system.uptime', 'info.uptime')) + hostname = _first_value(payload, ('serverInfo.hostname', 'system.hostname', 'info.hostname', 'serverInfo.name')) + version = _first_value(payload, ('info.os.release', 'serverInfo.version', 'system.version', 'info.version')) + cpu_brand = _first_value(payload, ('info.cpu.brand',)) + cpu_cores = _first_value(payload, ('info.cpu.cores',)) + cpu_threads = _first_value(payload, ('info.cpu.threads',)) + + return { + 'status': 'ok', + 'info': { + 'hostname': str(hostname or 'unknown'), + 'version': str(version or 'unknown'), + 'uptime': str(uptime or 'unknown'), + 'name': str(hostname or 'unraid'), + 'cpu': { + 'brand': cpu_brand, + 'cores': _as_int(cpu_cores, 0) if cpu_cores is not None else None, + 'threads': _as_int(cpu_threads, 0) if cpu_threads is not None else None, + }, + }, + } + + def get_system_metrics(self): + payload, status = self._query([ + ( + 'query { metrics { ' + 'cpu { cpus { percentIdle percentUser percentSystem percentNice percentIrq } } ' + 'memory { total used free available active } ' + '} }' + ), + ]) + if status != 'ok': + return { + 'status': status, + 'reason': str(payload.get('error', 'metrics_unavailable')), + 'cpuUsage': None, + 'ramUsage': None, + } + + cpu_usage = _average_cpu_usage(_first_value(payload, ('metrics.cpu.cpus',))) + memory_total = _first_value(payload, ('metrics.memory.total',)) + memory_available = _first_value(payload, ('metrics.memory.available',)) + memory_used = _first_value(payload, ('metrics.memory.used',)) + # Linux includes reclaimable cache (and often ZFS ARC) in ``used``. + # Prefer total - available so the dashboard reflects actual memory + # pressure rather than useful filesystem cache. + ram_uses_available = ( + isinstance(memory_total, (int, float)) + and isinstance(memory_available, (int, float)) + and 0 <= memory_available <= memory_total + ) + if ram_uses_available: + memory_used = memory_total - memory_available + ram_usage = _percent_from_values(memory_used, memory_total) + missing = [] + if cpu_usage is None: + missing.append('cpu_unavailable') + if ram_usage is None: + missing.append('ram_unavailable') + + memory = _first_value(payload, ('metrics.memory',)) + return { + 'status': 'ok' if not missing else 'partial', + 'reason': ','.join(missing) if missing else None, + 'cpuUsage': cpu_usage, + 'ramUsage': ram_usage, + 'sources': { + 'cpu': 'metrics.cpu.cpus.percentIdle' if cpu_usage is not None else 'unavailable', + 'ram': 'metrics.memory.available_total' if ram_usage is not None and ram_uses_available else ('metrics.memory.used_total' if ram_usage is not None else 'unavailable'), + }, + 'memory': memory if isinstance(memory, dict) else {}, + } + + def get_array_status(self): + payload, status = self._query([ + 'query { array { state } }', + 'query { array { status started } }', + 'query { system { array { status started } } }', + 'query { arrayStatus started }', + 'query { system { arrayStatus started } }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed'))} + + raw_status = _first_value(payload, ('array.state', 'array.status', 'arrayStatus', 'system.array.status', 'system.arrayStatus', 'state', 'status')) + started = _first_value(payload, ('array.started', 'system.array.started', 'started')) + if isinstance(started, bool): + raw_status = 'started' if started else 'stopped' + elif isinstance(started, str): + if started.lower() in {'true', '1', 'yes', 'running', 'started', 'online'}: + raw_status = 'started' + elif started.lower() in {'false', '0', 'no', 'stopped', 'offline'}: + raw_status = 'stopped' + + normalized = _normalize_status(raw_status, ('started', 'stopped', 'maintenance'), default='maintenance') + return {'status': 'ok', 'arrayStatus': normalized} + + def get_disk_warnings(self): + payload, status = self._query([ + 'query { array { disks { name status warning critical fsSize fsUsed fsFree type } } }', + 'query { disks { name smartStatus temperature isSpinning size } }', + 'query { array { disks { name status healthy warning } } }', + 'query { system { disks { name status healthy warning } } }', + 'query { disks { name status healthy warning } }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'disks': [], 'diskWarnings': 0} + + disks_raw = _first_value(payload, ('array.disks', 'system.disks', 'disks')) or [] + if not isinstance(disks_raw, list): + disks_raw = [] + disks: list[dict[str, Any]] = [] + for raw_disk in disks_raw: + if not isinstance(raw_disk, dict): + continue + status_value = str(raw_disk.get('status') or raw_disk.get('smartStatus') or 'unknown') + warning = raw_disk.get('warning') is not None or raw_disk.get('critical') is not None + healthy = _as_bool(raw_disk.get('healthy', None)) + if healthy is None: + healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'unknown'} + used_gib = _storage_value_to_gib(raw_disk.get('fsUsed', raw_disk.get('used'))) + size_gib = _storage_value_to_gib(raw_disk.get('fsSize', raw_disk.get('size'))) + free_gib = _storage_value_to_gib(raw_disk.get('fsFree', raw_disk.get('free'))) + if size_gib <= 0 and used_gib > 0 and free_gib > 0: + size_gib = used_gib + free_gib + disks.append({ + 'name': str(raw_disk.get('name', 'unknown')), + 'status': status_value, + 'healthy': healthy, + 'type': raw_disk.get('type'), + 'usedGiB': used_gib if used_gib > 0 else None, + 'sizeGiB': size_gib if size_gib > 0 else None, + 'freeGiB': free_gib if free_gib > 0 else None, + 'temperature': raw_disk.get('temperature'), + }) + return {'status': 'ok', 'disks': disks, 'diskWarnings': _safe_disk_warning_count(disks_raw)} + + def get_cache_status(self): + payload, status = self._query([ + 'query { array { caches { name fsSize fsUsed fsFree size } } }', + 'query { cache { usagePercent usedGiB sizeGiB } }', + 'query { cache { used size } }', + 'query { system { cache { usagePercent usedGiB sizeGiB used size } } }', + 'query { cacheUsage }', + 'query { cachePercent }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'cacheUsage': 0, 'pools': []} + + cache_percent = _first_value(payload, ('cache.usagePercent', 'system.cache.usagePercent', 'cacheUsage', 'cachePercent')) + pools: list[dict[str, Any]] = [] + caches = _first_value(payload, ('array.caches',)) + if isinstance(caches, list): + for cache in caches: + if not isinstance(cache, dict): + continue + used_gib = _storage_value_to_gib(cache.get('fsUsed', cache.get('used', cache.get('usedGiB')))) + size_gib = _storage_value_to_gib(cache.get('fsSize', cache.get('size', cache.get('sizeGiB')))) + free_gib = _storage_value_to_gib(cache.get('fsFree', cache.get('free', cache.get('freeGiB')))) + if size_gib <= 0 and used_gib > 0 and free_gib > 0: + size_gib = used_gib + free_gib + status_value = str(cache.get('status') or cache.get('smartStatus') or 'unknown') + warning = cache.get('warning') is not None or cache.get('critical') is not None + healthy = _as_bool(cache.get('healthy', None)) + if healthy is None: + healthy = not warning and status_value.lower() in {'ok', 'disk_ok', 'healthy', 'unknown'} + usage_percent = _as_int(cache.get('usagePercent'), -1) + if usage_percent < 0: + usage_percent = _percent_from_values(used_gib, size_gib) + pools.append({ + 'name': str(cache.get('name', 'cache')), + 'status': status_value, + 'healthy': healthy, + 'type': cache.get('type'), + 'usedGiB': used_gib if used_gib > 0 else None, + 'sizeGiB': size_gib if size_gib > 0 else None, + 'freeGiB': free_gib if free_gib > 0 else None, + 'usagePercent': usage_percent, + }) + if cache_percent is None: + if isinstance(caches, list): + used_total = 0 + size_total = 0 + for cache in caches: + if not isinstance(cache, dict): + continue + used_total += _as_int(cache.get('fsUsed', cache.get('used')), 0) + size_total += _as_int(cache.get('fsSize', cache.get('size')), 0) + if size_total > 0: + cache_percent = int((used_total / size_total) * 100) + if cache_percent is None: + used = _first_value(payload, ('cache.used', 'cache.usedGiB', 'system.cache.used', 'system.cache.usedGiB')) + total = _first_value(payload, ('cache.size', 'cache.sizeGiB', 'system.cache.size', 'system.cache.sizeGiB')) + if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0: + cache_percent = int((float(used) / float(total)) * 100) + + if isinstance(cache_percent, bool): + cache_percent = 100 if cache_percent else 0 + usage = _as_int(cache_percent, 0) + return {'status': 'ok', 'cacheUsage': usage, 'pools': pools} + + def get_parity_status(self): + payload, status = self._query([ + ( + 'query { array { ' + 'parityCheckStatus { progress speed errors status paused running correcting } ' + 'parities { name status warning critical } ' + '} }' + ), + 'query { array { parities { name status warning critical } } }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'parityStatus': 'invalid'} + + check = _first_value(payload, ('array.parityCheckStatus',)) + check = check if isinstance(check, dict) else {} + check_status = str(check.get('status') or '').strip().lower() + paused = _as_bool(check.get('paused')) is True + running = ( + not paused + and ( + _as_bool(check.get('running')) is True + or check_status in {'running', 'checking', 'resyncing'} + ) + ) + if running or paused or check_status in {'running', 'checking', 'paused', 'resyncing'}: + parity_status = 'checking' + else: + parities = _first_value(payload, ('array.parities',)) + parities = parities if isinstance(parities, list) else [] + healthy_statuses = {'disk_ok', 'ok', 'valid', 'healthy', 'online'} + parity_status = 'valid' if parities and all( + isinstance(disk, dict) + and not disk.get('warning') + and not disk.get('critical') + and str(disk.get('status') or '').strip().lower() in healthy_statuses + for disk in parities + ) else 'invalid' + + return { + 'status': 'ok', + 'parityStatus': parity_status, + 'checkStatus': check_status or 'idle', + 'running': running, + 'paused': paused, + 'correcting': _as_bool(check.get('correcting')) is True, + 'progress': check.get('progress'), + 'speed': check.get('speed'), + 'errors': _as_int(check.get('errors'), 0), + } + + def get_docker_service_status(self): + payload, status = self._query([ + 'query { docker { containers { id names image state status } networks { id name } } }', + 'query { docker { running status } }', + 'query { dockerService { running status } }', + 'query { system { docker { running status } } }', + 'query { dockerStatus }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'running': False} + + docker_containers = _first_value(payload, ('docker.containers',)) + raw_running = _first_value(payload, ('docker.running', 'dockerService.running', 'system.docker.running')) + if raw_running is None and isinstance(docker_containers, list): + raw_running = True + if isinstance(raw_running, str) and raw_running.lower() in {'running', 'started', 'online'}: + raw_running = True + running = bool(_as_bool(raw_running) or str(raw_running).lower() in {'running', 'started', 'online'}) + return { + 'status': 'ok', + 'running': running, + 'serviceStatus': str(_first_value(payload, ('docker.status', 'dockerService.status', 'system.docker.status', 'status')) or ('running' if running else 'stopped')), + 'containers': len(docker_containers) if isinstance(docker_containers, list) else None, + } + + def get_vm_summary(self): + payload, status = self._query([ + 'query { vms { domains { id uuid name state } } }', + 'query { vms { id name state status } }', + 'query { virtualMachines { id name state status } }', + 'query { system { vms { id name state status } } }', + ]) + if status != 'ok': + reason = str(payload.get('error', 'connection_failed')) + if 'VMs are not available' in reason: + return { + 'status': 'api_unavailable', + 'reason': ( + 'De Unraid API gaf geen VM-toegang terug. VM Manager kan nog steeds actief zijn; ' + 'controleer of de ingestelde API-sleutel vm:read (of vm:*) heeft en of deze ' + 'Unraid API-versie het VM-schema beschikbaar stelt.' + ), + 'vms': [], + } + return {'status': status, 'reason': reason, 'vms': []} + + raw_vms = _first_value(payload, ('vms.domains', 'vms', 'virtualMachines', 'system.vms')) + if not isinstance(raw_vms, list): + raw_vms = [] + + vms = [] + for raw_vm in raw_vms: + if not isinstance(raw_vm, dict): + continue + vms.append({ + 'id': str(raw_vm.get('id', raw_vm.get('uuid', raw_vm.get('name', '')))), + 'name': str(raw_vm.get('name', 'unknown')), + 'state': str(raw_vm.get('state', raw_vm.get('status', 'unknown'))), + }) + return {'status': 'ok', 'vms': vms} + + def mutate_vm(self, vm_id: str, action: str) -> dict[str, Any]: + supported_actions = {'start', 'pause', 'resume'} + normalized_action = str(action).strip().lower() + normalized_vm_id = str(vm_id).strip() + if normalized_action not in supported_actions: + return { + 'success': False, + 'status': 'blocked', + 'message': 'unsupported_or_blocked_vm_action', + 'action': normalized_action, + } + if not normalized_vm_id: + return { + 'success': False, + 'status': 'invalid_target', + 'message': 'vm_id_required', + 'action': normalized_action, + } + if not self.is_configured(): + return { + 'success': False, + 'status': 'missing_config', + 'message': 'missing_config', + 'action': normalized_action, + } + + # json.dumps produces a valid escaped GraphQL string literal without + # interpolating an operator-controlled identifier as GraphQL syntax. + vm_id_literal = json.dumps(normalized_vm_id) + query = f'mutation AppOpsVmAction {{ vm {{ {normalized_action}(id: {vm_id_literal}) }} }}' + try: + payload = self._request(query) + except RuntimeError as exc: + reason = str(exc) + if reason == 'unauthorized': + reason = 'Unraid API key needs vm:update (or vm:*) permission.' + return { + 'success': False, + 'status': 'permission_denied' if 'unauthorized' in reason.lower() else 'error', + 'message': reason, + 'action': normalized_action, + } + + changed = _first_value(payload, (f'vm.{normalized_action}',)) + return { + 'success': changed is True, + 'status': 'ok' if changed is True else 'failed', + 'message': f'vm_{normalized_action}_accepted' if changed is True else f'vm_{normalized_action}_rejected', + 'action': normalized_action, + } + + def get_share_summary(self): + payload, status = self._query([ + 'query { shares { name free used size } }', + 'query { shares { name usedGiB sizeGiB critical } }', + 'query { system { shares { name usedGiB sizeGiB critical } } }', + 'query { shareSummary { shares { name usedGiB sizeGiB critical } } }', + ]) + if status != 'ok': + return {'status': status, 'reason': str(payload.get('error', 'connection_failed')), 'shares': []} + + raw_shares = _first_value(payload, ('shares', 'system.shares', 'shareSummary.shares')) + if not isinstance(raw_shares, list): + raw_shares = [] + shares = [] + for share in raw_shares: + if not isinstance(share, dict): + continue + used = _as_int(share.get('usedGiB'), -1) + size = _as_int(share.get('sizeGiB'), -1) + if used < 0: + used = _storage_value_to_gib(share.get('used')) + if size < 0: + size = _storage_value_to_gib(share.get('size')) + if size <= 0 and share.get('free') is not None: + size = used + _storage_value_to_gib(share.get('free')) + shares.append({ + 'name': str(share.get('name', 'unknown')), + 'usedGiB': used, + 'sizeGiB': size, + 'critical': bool(_as_bool(share.get('critical', False))), + }) + return {'status': 'ok', 'shares': shares} diff --git a/backend/app/connectors/widefrog.py b/backend/app/connectors/widefrog.py new file mode 100644 index 0000000..9f60ab5 --- /dev/null +++ b/backend/app/connectors/widefrog.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from .base import BaseConnector + + +@dataclass +class _WidefrogConfig: + url: str + api_key: str | None + verify_tls: bool + timeout_seconds: float + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_url() -> str: + value = settings.apis.get('WIDEFROG_URL') + if not value: + return '' + parsed = urlparse(str(value).rstrip('/')) + if not parsed.scheme or not parsed.netloc: + return str(value).rstrip('/') + return f'{parsed.scheme}://{parsed.netloc}' + + +def _port() -> int: + parsed = urlparse(_safe_url()) + return parsed.port or 1207 + + +class WidefrogConnector(BaseConnector): + name = 'Widefrog' + + def is_configured(self) -> bool: + return bool(settings.apis.get('WIDEFROG_URL')) + + def _config(self) -> _WidefrogConfig | None: + if not self.is_configured(): + return None + return _WidefrogConfig( + url=str(settings.apis['WIDEFROG_URL']).rstrip('/'), + api_key=str(settings.apis['WIDEFROG_API_KEY']) if settings.apis.get('WIDEFROG_API_KEY') else None, + verify_tls=bool(settings.apis.get('WIDEFROG_VERIFY_TLS', False)), + timeout_seconds=_as_float(settings.apis.get('WIDEFROG_TIMEOUT_SECONDS'), 10.0), + ) + + def _request_json(self, method: str, path: str, **kwargs: Any) -> Any: + config = self._config() + if not config: + raise RuntimeError('missing_config') + + headers = dict(kwargs.pop('headers', {}) or {}) + if config.api_key: + headers['X-Api-Key'] = config.api_key + try: + with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: + response = client.request(method=method, url=f'{config.url}{path}', headers=headers, **kwargs) + except (httpx.TimeoutException, httpx.ConnectError, httpx.RequestError, ValueError) as exc: + message = redact_message(str(exc)) + self.set_error(message) + raise RuntimeError(message) + + if response.status_code == 404: + raise RuntimeError('unsupported_endpoint') + if response.status_code in {401, 403}: + raise RuntimeError('unauthorized') + if response.status_code >= 500: + raise RuntimeError(f'Widefrog server error: HTTP {response.status_code}') + if response.status_code >= 400: + raise RuntimeError(redact_message(f'Widefrog API error: HTTP {response.status_code} {(response.text or "")[:256]}')) + if response.status_code == 204: + return {} + + content_type = response.headers.get('content-type', '') + if 'json' not in content_type and response.text: + return {'message': response.text[:256], 'status': 'ok'} + + try: + return response.json() if response.text else {} + except ValueError as exc: + raise RuntimeError(redact_message(str(exc))) + + def _first_supported(self, method: str, paths: list[str], **kwargs: Any) -> tuple[Any | None, str | None, str | None]: + last_error: str | None = None + for path in paths: + try: + return self._request_json(method, path, **kwargs), path, None + except RuntimeError as exc: + reason = str(exc) + if reason == 'unsupported_endpoint': + last_error = reason + continue + return None, path, redact_message(reason) + return None, None, last_error or 'unsupported_endpoint' + + def test_connection(self) -> dict[str, Any]: + if not self.is_configured(): + return {'status': 'missing_config', 'reason': 'missing_config'} + payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/']) + if error: + return {'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'reason': redact_message(error)} + version = payload.get('version') if isinstance(payload, dict) else None + return {'status': 'connected', 'version': str(version or 'unknown')} + + def health(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': '', 'port': 1207, 'version': 'unknown', 'checks': [], 'required_env_vars': ['WIDEFROG_URL']} + payload, _path, error = self._first_supported('GET', ['/api/health', '/health', '/']) + if error: + return {'success': False, 'status': 'unreachable' if error != 'unsupported_endpoint' else 'error', 'message': redact_message(error), 'url': _safe_url(), 'port': _port(), 'version': 'unknown', 'checks': []} + data = payload if isinstance(payload, dict) else {} + return {'success': True, 'status': 'connected', 'message': 'Widefrog connector ready.', 'url': _safe_url(), 'port': _port(), 'version': str(data.get('version', 'unknown')), 'checks': data.get('checks', []) if isinstance(data.get('checks'), list) else []} + + def queue_status(self) -> dict[str, Any]: + if not self.is_configured(): + return self._empty_queue('missing_config') + payload, _path, error = self._first_supported('GET', ['/api/queue', '/api/jobs']) + if error: + result = self._empty_queue('unavailable') + result['message'] = redact_message(error) + return result + items = _extract_items(payload) + return _queue_summary(items) + + def failed_jobs(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'failed': 0, 'items': []} + payload, _path, error = self._first_supported('GET', ['/api/failed', '/api/jobs/failed', '/api/queue/failed', '/api/jobs']) + if error: + return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'failed': 0, 'items': []} + items = [item for item in _extract_items(payload) if str(item.get('status', '')).lower() == 'failed'] + if not items: + items = _extract_items(payload) + return {'success': True, 'status': 'ok', 'failed': len(items), 'items': items} + + def activity(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'recent': [], 'last_event': None} + payload, _path, error = self._first_supported('GET', ['/api/activity', '/api/events', '/api/history']) + if error: + return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'recent': [], 'last_event': None} + recent = _extract_items(payload, key='recent') + return {'success': True, 'status': 'ok', 'recent': recent, 'last_event': recent[0] if recent else None} + + def download_status(self) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'active': 0, 'speed': None, 'processing': 0, 'items': []} + payload, _path, error = self._first_supported('GET', ['/api/download-status', '/api/downloads', '/api/queue/downloads']) + if error: + return {'success': False, 'status': 'unavailable', 'message': redact_message(error), 'active': 0, 'speed': None, 'processing': 0, 'items': []} + data = payload if isinstance(payload, dict) else {} + items = _extract_items(payload) + return {'success': True, 'status': 'ok', 'active': int(data.get('active', len(items)) or 0), 'speed': data.get('speed'), 'processing': int(data.get('processing', 0) or 0), 'items': items} + + def retry_failed(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + result, _path, error = self._first_supported('POST', ['/api/retry-failed', '/api/jobs/retry-failed', '/api/failed/retry'], json=payload or {}) + if error: + return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)} + return {'success': True, 'status': 'ok', 'message': 'retry_started', 'data': result if isinstance(result, dict) else {}} + + def scan_library(self, payload: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.is_configured(): + return {'success': False, 'status': 'missing_config', 'message': 'missing_config'} + result, _path, error = self._first_supported('POST', ['/api/scan-library', '/api/library/scan', '/api/scan'], json=payload or {}) + if error: + return {'success': False, 'status': 'unsupported' if error == 'unsupported_endpoint' else 'error', 'message': redact_message(error)} + return {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': result if isinstance(result, dict) else {}} + + @staticmethod + def _empty_queue(status: str) -> dict[str, Any]: + return {'success': False, 'status': status, 'message': status, 'total': 0, 'active': 0, 'queued': 0, 'completed': 0, 'failed': 0, 'items': []} + + +def _extract_items(payload: Any, key: str = 'items') -> list[dict[str, Any]]: + if isinstance(payload, list): + return [item for item in payload if isinstance(item, dict)] + if isinstance(payload, dict): + value = payload.get(key) or payload.get('jobs') or payload.get('queue') or [] + if isinstance(value, list): + return [item for item in value if isinstance(item, dict)] + return [] + + +def _queue_summary(items: list[dict[str, Any]]) -> dict[str, Any]: + normalized = [{**item, 'status': str(item.get('status', 'unknown')).lower()} for item in items] + return { + 'success': True, + 'status': 'ok', + 'total': len(normalized), + 'active': sum(1 for item in normalized if item['status'] in {'active', 'running', 'processing'}), + 'queued': sum(1 for item in normalized if item['status'] in {'queued', 'pending'}), + 'completed': sum(1 for item in normalized if item['status'] in {'completed', 'done', 'success'}), + 'failed': sum(1 for item in normalized if item['status'] in {'failed', 'error'}), + 'items': normalized, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..9bdcb0d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + + +def _env_value(name: str, default: str | None) -> str | None: + value = os.getenv(name) + if value is None: + return default + value = value.strip() + return value or default + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _secret_value(name: str, file_name: str) -> str | None: + value = _env_value(name, None) + if value: + return value + path = _env_value(file_name, None) + if not path: + return None + try: + with open(path, encoding="utf-8") as secret_file: + return secret_file.read().strip() or None + except OSError: + return None + + +def _env_list(name: str, default: list[str] | None = None) -> list[str]: + value = os.getenv(name) + if value is None: + return list(default or []) + return [item.strip() for item in value.split(",") if item.strip()] + + +@dataclass +class Settings: + app_title: str = "Unraid AppOps Gateway" + bind_host: str = "0.0.0.0" + port: int = 1216 + db_path: str = "/data/appops.db" + data_dir: str = "/data" + environment: str = "development" + log_level: str = "info" + static_dir: str = "dist" + frontend_origin: str = "http://localhost:5173" + voice_token: str | None = None + voice_token_file: str | None = None + operator_token: str | None = None + operator_token_file: str | None = None + operator_auth_required: bool = False + operator_proxy_auth_enabled: bool = False + operator_proxy_trusted_networks: list[str] | None = None + operator_proxy_users: list[str] | None = None + operator_proxy_groups: list[str] | None = None + deployment_mode: str = "private" + verify_tls: bool = True + secret_key: str | None = None + secret_key_file: str | None = None + secret_key_source: str = "missing" + secret_rotation_enabled: bool = False + discovery_interval_seconds: int = 0 + discovery_history_runs: int = 0 + notification_interval_seconds: int = 0 + connector_health_interval_seconds: int = 0 + observability_persist_interval_seconds: int = 0 + restore_drill_interval_seconds: int = 0 + mcp_canary_interval_seconds: int = 0 + backup_retention: int = 20 + mutation_rate_limit_per_minute: int = 120 + mcp_enabled: bool = False + mcp_token: str | None = None + mcp_token_file: str | None = None + mcp_allowed_hosts: list[str] | None = None + n8n_enabled: bool = False + n8n_token: str | None = None + n8n_token_file: str | None = None + + apis: dict[str, str | bool | None] = None # populated in __post_init__ + + def __post_init__(self) -> None: + self.app_title = _env_value("APP_OPS_APP_TITLE", self.app_title) or self.app_title + self.bind_host = _env_value("APPOPS_BIND_HOST", self.bind_host) or self.bind_host + self.port = int(_env_value("APPOPS_PORT", str(self.port)) or str(self.port)) + self.db_path = _env_value("APPOPS_DB_PATH", self.db_path) or self.db_path + self.data_dir = _env_value("APPOPS_DATA_DIR", self.data_dir) or self.data_dir + self.environment = _env_value("APPOPS_ENV", self.environment) or self.environment + default_discovery_interval = 120 if self.environment == "production" else self.discovery_interval_seconds + self.discovery_interval_seconds = max(0, int(_env_value("APPOPS_DISCOVERY_INTERVAL_SECONDS", str(default_discovery_interval)) or "0")) + default_discovery_history = 48 if self.environment == "production" else self.discovery_history_runs + self.discovery_history_runs = max(0, int(_env_value("APPOPS_DISCOVERY_HISTORY_RUNS", str(default_discovery_history)) or "0")) + production_default = self.environment == "production" + self.notification_interval_seconds = max(0, int(_env_value("APPOPS_NOTIFICATION_INTERVAL_SECONDS", "300" if production_default else str(self.notification_interval_seconds)) or "0")) + self.connector_health_interval_seconds = max(0, int(_env_value("APPOPS_CONNECTOR_HEALTH_INTERVAL_SECONDS", "300" if production_default else str(self.connector_health_interval_seconds)) or "0")) + self.observability_persist_interval_seconds = max(0, int(_env_value("APPOPS_OBSERVABILITY_PERSIST_INTERVAL_SECONDS", "60" if production_default else str(self.observability_persist_interval_seconds)) or "0")) + self.restore_drill_interval_seconds = max(0, int(_env_value("APPOPS_RESTORE_DRILL_INTERVAL_SECONDS", "86400" if production_default else str(self.restore_drill_interval_seconds)) or "0")) + self.mcp_canary_interval_seconds = max(0, int(_env_value("APPOPS_MCP_CANARY_INTERVAL_SECONDS", "300" if production_default else str(self.mcp_canary_interval_seconds)) or "0")) + self.backup_retention = max(1, min(200, int(_env_value("APPOPS_BACKUP_RETENTION", str(self.backup_retention)) or "20"))) + self.mutation_rate_limit_per_minute = max(10, min(1000, int(_env_value("APPOPS_MUTATION_RATE_LIMIT", str(self.mutation_rate_limit_per_minute)) or "120"))) + self.mcp_enabled = _env_bool("APPOPS_ENABLE_MCP", self.mcp_enabled) + self.mcp_token = _env_value("APPOPS_MCP_TOKEN", self.mcp_token) + self.mcp_token_file = _env_value("APPOPS_MCP_TOKEN_FILE", self.mcp_token_file) + if not self.mcp_token and self.mcp_token_file: + try: + with open(self.mcp_token_file, encoding="utf-8") as token_file: + self.mcp_token = token_file.read().strip() or None + except OSError: + self.mcp_token = None + allowed_hosts = _env_value("APPOPS_MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*,198.18.10.150:*") or "" + self.mcp_allowed_hosts = [item.strip() for item in allowed_hosts.split(",") if item.strip()] + self.n8n_enabled = _env_bool("APPOPS_ENABLE_N8N", self.n8n_enabled) + self.n8n_token = _env_value("APPOPS_N8N_TOKEN", self.n8n_token) + self.n8n_token_file = _env_value("APPOPS_N8N_TOKEN_FILE", self.n8n_token_file) + if not self.n8n_token and self.n8n_token_file: + try: + with open(self.n8n_token_file, encoding="utf-8") as token_file: + self.n8n_token = token_file.read().strip() or None + except OSError: + self.n8n_token = None + self.log_level = (_env_value("APPOPS_LOG_LEVEL", self.log_level) or self.log_level).lower() + self.static_dir = _env_value("APPOPS_STATIC_DIR", self.static_dir) or self.static_dir + self.frontend_origin = _env_value("APPOPS_FRONTEND_ORIGIN", None) or ( + f"http://localhost:{self.port}" if self.environment == "production" else self.frontend_origin + ) + self.voice_token_file = _env_value("APP_OPS_VOICE_TOKEN_FILE", self.voice_token_file) + self.voice_token = _secret_value("APP_OPS_VOICE_TOKEN", "APP_OPS_VOICE_TOKEN_FILE") or self.voice_token + self.operator_token = _env_value("APPOPS_OPERATOR_TOKEN", self.operator_token) + self.operator_token_file = _env_value("APPOPS_OPERATOR_TOKEN_FILE", self.operator_token_file) + if not self.operator_token and self.operator_token_file: + try: + with open(self.operator_token_file, encoding="utf-8") as token_file: + self.operator_token = token_file.read().strip() or None + except OSError: + self.operator_token = None + self.operator_auth_required = _env_bool("APPOPS_REQUIRE_OPERATOR_AUTH", self.operator_auth_required) + self.operator_proxy_auth_enabled = _env_bool("APPOPS_OPERATOR_PROXY_AUTH", self.operator_proxy_auth_enabled) + self.operator_proxy_trusted_networks = _env_list("APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS", self.operator_proxy_trusted_networks) + self.operator_proxy_users = _env_list("APPOPS_OPERATOR_PROXY_USERS", self.operator_proxy_users) + self.operator_proxy_groups = _env_list("APPOPS_OPERATOR_PROXY_GROUPS", self.operator_proxy_groups) + self.deployment_mode = (_env_value("APPOPS_DEPLOYMENT_MODE", self.deployment_mode) or self.deployment_mode).lower() + self.verify_tls = _env_bool("APPOPS_VERIFY_TLS", self.verify_tls) + self.secret_key_file = _env_value("APPOPS_SECRET_KEY_FILE", self.secret_key_file) + self.secret_key = _env_value("APPOPS_SECRET_KEY", self.secret_key) + self.secret_key_source = "env" if self.secret_key else "missing" + if not self.secret_key and self.secret_key_file: + try: + with open(self.secret_key_file, encoding="utf-8") as key_file: + self.secret_key = key_file.read().strip() or None + self.secret_key_source = "file" if self.secret_key else "missing" + except OSError: + self.secret_key = None + self.secret_key_source = "file_unreadable" + self.secret_rotation_enabled = _env_bool("APPOPS_SECRET_ROTATION_ENABLED", self.secret_rotation_enabled) + gitea_admin_token = _secret_value("GITEA_ADMIN_TOKEN", "GITEA_ADMIN_TOKEN_FILE") + self.apis = { + "PORTAINER_URL": _env_value("PORTAINER_URL", None), + "PORTAINER_API_KEY": _env_value("PORTAINER_API_KEY", None), + "PORTAINER_ENDPOINT_ID": _env_value("PORTAINER_ENDPOINT_ID", None), + "PORTAINER_VERIFY_TLS": _env_bool("PORTAINER_VERIFY_TLS", self.verify_tls), + "UNRAID_URL": _env_value("UNRAID_URL", None), + "UNRAID_API_KEY": _env_value("UNRAID_API_KEY", None), + "UNRAID_VERIFY_TLS": _env_bool("UNRAID_VERIFY_TLS", self.verify_tls), + "GITEA_URL": _env_value("GITEA_URL", None), + "GITEA_ADMIN_TOKEN": gitea_admin_token, + "GITEA_VERIFY_TLS": _env_bool("GITEA_VERIFY_TLS", self.verify_tls), + "GITEA_TIMEOUT_SECONDS": _env_value("GITEA_TIMEOUT_SECONDS", "8"), + "GITEA_RUNNER_TARGETS_JSON": _env_value( + "GITEA_RUNNER_TARGETS_JSON", + '{}', + ), + # Deployment Control Plane V1 uses the private broker's dedicated + # typed endpoint. The HMAC material is always resolved from the + # encrypted Secret store by reference, never from this environment + # preview or a deployment plan. + "DEPLOYMENT_BROKER_URL": _env_value("DEPLOYMENT_BROKER_URL", None), + "DEPLOYMENT_BROKER_VERIFY_TLS": _env_bool( + "DEPLOYMENT_BROKER_VERIFY_TLS", self.verify_tls + ), + "DEPLOYMENT_BROKER_CA_FILE": _env_value( + "DEPLOYMENT_BROKER_CA_FILE", None + ), + "DEPLOYMENT_BROKER_TIMEOUT_SECONDS": _env_value( + "DEPLOYMENT_BROKER_TIMEOUT_SECONDS", "10" + ), + "SONARR_URL": _env_value("SONARR_URL", None), + "SONARR_API_KEY": _env_value("SONARR_API_KEY", None), + "SONARR_DEFAULT_ROOT_FOLDER": _env_value("SONARR_DEFAULT_ROOT_FOLDER", None), + "SONARR_DEFAULT_QUALITY_PROFILE": _env_value("SONARR_DEFAULT_QUALITY_PROFILE", None), + "SONARR_SEARCH_ON_ADD": _env_bool("SONARR_SEARCH_ON_ADD", True), + "RADARR_URL": _env_value("RADARR_URL", None), + "RADARR_API_KEY": _env_value("RADARR_API_KEY", None), + "RADARR_DEFAULT_ROOT_FOLDER": _env_value("RADARR_DEFAULT_ROOT_FOLDER", None), + "RADARR_DEFAULT_QUALITY_PROFILE": _env_value("RADARR_DEFAULT_QUALITY_PROFILE", None), + "RADARR_DEFAULT_MINIMUM_AVAILABILITY": _env_value("RADARR_DEFAULT_MINIMUM_AVAILABILITY", "released"), + "RADARR_SEARCH_ON_ADD": _env_bool("RADARR_SEARCH_ON_ADD", True), + "RADARR_VERIFY_TLS": _env_bool("RADARR_VERIFY_TLS", self.verify_tls), + "RADARR_TIMEOUT_SECONDS": _env_value("RADARR_TIMEOUT_SECONDS", "10"), + "PROWLARR_URL": _env_value("PROWLARR_URL", None), + "PROWLARR_API_KEY": _env_value("PROWLARR_API_KEY", None), + "PROWLARR_VERIFY_TLS": _env_bool("PROWLARR_VERIFY_TLS", self.verify_tls), + "PROWLARR_TIMEOUT_SECONDS": _env_value("PROWLARR_TIMEOUT_SECONDS", "10"), + "DELUGE_URL": _env_value("DELUGE_URL", None), + "DELUGE_PASSWORD": _env_value("DELUGE_PASSWORD", None), + "DELUGE_VERIFY_TLS": _env_bool("DELUGE_VERIFY_TLS", self.verify_tls), + "DELUGE_TIMEOUT_SECONDS": _env_value("DELUGE_TIMEOUT_SECONDS", "10"), + "DELUGE_DEFAULT_HOST_ID": _env_value("DELUGE_DEFAULT_HOST_ID", None), + "DELUGE_WEB_PATH": _env_value("DELUGE_WEB_PATH", "/json"), + "PLEX_URL": _env_value("PLEX_URL", None), + "PLEX_TOKEN": _env_value("PLEX_TOKEN", None), + "PLEX_VERIFY_TLS": _env_bool("PLEX_VERIFY_TLS", self.verify_tls), + "PLEX_TIMEOUT_SECONDS": _env_value("PLEX_TIMEOUT_SECONDS", "10"), + "PLEX_DEFAULT_LIBRARY_ID": _env_value("PLEX_DEFAULT_LIBRARY_ID", None), + "PLEX_MACHINE_IDENTIFIER": _env_value("PLEX_MACHINE_IDENTIFIER", None), + "TAUTULLI_URL": _env_value("TAUTULLI_URL", None), + "TAUTULLI_API_KEY": _env_value("TAUTULLI_API_KEY", None), + "TAUTULLI_VERIFY_TLS": _env_bool("TAUTULLI_VERIFY_TLS", self.verify_tls), + "TAUTULLI_TIMEOUT_SECONDS": _env_value("TAUTULLI_TIMEOUT_SECONDS", "10"), + "NPM_URL": _env_value("NPM_URL", None), + "NPM_IDENTITY": _env_value("NPM_IDENTITY", None), + "NPM_SECRET": _env_value("NPM_SECRET", None), + "NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", self.verify_tls), + "NPM_TIMEOUT_SECONDS": _env_value("NPM_TIMEOUT_SECONDS", "10"), + "NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None), + "AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None), + "AUTHENTIK_TOKEN": _env_value("AUTHENTIK_TOKEN", None), + "AUTHENTIK_VERIFY_TLS": _env_bool("AUTHENTIK_VERIFY_TLS", self.verify_tls), + "AUTHENTIK_TIMEOUT_SECONDS": _env_value("AUTHENTIK_TIMEOUT_SECONDS", "10"), + "AUTHENTIK_OUTPOST_URL": _env_value("AUTHENTIK_OUTPOST_URL", None), + "AUTHENTIK_EXPECT_EMBEDDED_OUTPOST": _env_bool("AUTHENTIK_EXPECT_EMBEDDED_OUTPOST", True), + # OIDC provisioning never guesses tenant-wide flow/mapping IDs. + # Operators bind these once to existing, reviewed Authentik objects. + "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None), + "AUTHENTIK_OIDC_INVALIDATION_FLOW": _env_value("AUTHENTIK_OIDC_INVALIDATION_FLOW", None), + "AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING", None), + # The reviewed group mapping must emit this exact claim. The + # enrollment schema only accepts its bounded role contract. + "AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None), + "JELLYFIN_URL": _env_value("JELLYFIN_URL", None), + "JELLYFIN_API_KEY": _env_value("JELLYFIN_API_KEY", None), + "WIDEFROG_URL": _env_value("WIDEFROG_URL", None), + "WIDEFROG_API_KEY": _env_value("WIDEFROG_API_KEY", None), + "WIDEFROG_VERIFY_TLS": _env_bool("WIDEFROG_VERIFY_TLS", self.verify_tls), + "WIDEFROG_TIMEOUT_SECONDS": _env_value("WIDEFROG_TIMEOUT_SECONDS", "10"), + "HOME_ASSISTANT_URL": _env_value("HOME_ASSISTANT_URL", None), + "HOME_ASSISTANT_TOKEN": _env_value("HOME_ASSISTANT_TOKEN", None), + "HOME_ASSISTANT_VERIFY_TLS": _env_bool("HOME_ASSISTANT_VERIFY_TLS", self.verify_tls), + "HOME_ASSISTANT_TIMEOUT_SECONDS": _env_value("HOME_ASSISTANT_TIMEOUT_SECONDS", "10"), + "HOME_ASSISTANT_NOTIFY_DOMAIN": _env_value("HOME_ASSISTANT_NOTIFY_DOMAIN", "notify"), + "HOME_ASSISTANT_NOTIFY_SERVICE": _env_value("HOME_ASSISTANT_NOTIFY_SERVICE", "notify"), + "HOME_ASSISTANT_NOTIFY_TARGET": _env_value("HOME_ASSISTANT_NOTIFY_TARGET", None), + "HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX": _env_value("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX", "AppOps"), + "HOME_ASSISTANT_DRY_RUN": _env_bool("HOME_ASSISTANT_DRY_RUN", False), + "APPOPS_VERIFY_TLS": self.verify_tls, + } + + def redact_env_preview(self) -> dict[str, Any]: + return { + "APPOPS_BIND_HOST": self.bind_host, + "APPOPS_PORT": self.port, + "APPOPS_DB_PATH": self.db_path, + "APPOPS_DATA_DIR": self.data_dir, + "APPOPS_ENV": self.environment, + "APPOPS_LOG_LEVEL": self.log_level, + "APPOPS_STATIC_DIR": self.static_dir, + "APPOPS_FRONTEND_ORIGIN": self.frontend_origin, + "voice_token": bool(self.voice_token), + "operator_token": bool(self.operator_token), + "APPOPS_OPERATOR_TOKEN_FILE": bool(self.operator_token_file), + "APPOPS_REQUIRE_OPERATOR_AUTH": self.operator_auth_required, + "APPOPS_OPERATOR_PROXY_AUTH": self.operator_proxy_auth_enabled, + "APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS_CONFIGURED": bool(self.operator_proxy_trusted_networks), + "APPOPS_OPERATOR_PROXY_USER_COUNT": len(self.operator_proxy_users or []), + "APPOPS_OPERATOR_PROXY_GROUP_COUNT": len(self.operator_proxy_groups or []), + "APPOPS_DEPLOYMENT_MODE": self.deployment_mode, + "APPOPS_BACKUP_RETENTION": self.backup_retention, + "APPOPS_ENABLE_MCP": self.mcp_enabled, + "APPOPS_MCP_TOKEN_FILE": bool(self.mcp_token_file), + "APPOPS_MCP_TOKEN_CONFIGURED": bool(self.mcp_token), + "APPOPS_MCP_ALLOWED_HOSTS": list(self.mcp_allowed_hosts or []), + "APPOPS_ENABLE_N8N": self.n8n_enabled, + "APPOPS_N8N_TOKEN_FILE": bool(self.n8n_token_file), + "APPOPS_N8N_TOKEN_CONFIGURED": bool(self.n8n_token), + "APPOPS_VERIFY_TLS": self.verify_tls, + "APPOPS_SECRET_KEY": bool(self.secret_key), + "APPOPS_SECRET_KEY_FILE": bool(self.secret_key_file), + "APPOPS_SECRET_KEY_SOURCE": self.secret_key_source, + "APPOPS_SECRET_ROTATION_ENABLED": self.secret_rotation_enabled, + "PORTAINER_URL": bool(self.apis["PORTAINER_URL"]), + "PORTAINER_VERIFY_TLS": bool(self.apis["PORTAINER_VERIFY_TLS"]), + "UNRAID_URL": bool(self.apis["UNRAID_URL"]), + "UNRAID_VERIFY_TLS": bool(self.apis["UNRAID_VERIFY_TLS"]), + "GITEA_URL": bool(self.apis["GITEA_URL"]), + "GITEA_ADMIN_TOKEN_CONFIGURED": bool(self.apis["GITEA_ADMIN_TOKEN"]), + "GITEA_VERIFY_TLS": bool(self.apis["GITEA_VERIFY_TLS"]), + "DEPLOYMENT_BROKER_URL": bool(self.apis["DEPLOYMENT_BROKER_URL"]), + "DEPLOYMENT_BROKER_VERIFY_TLS": bool(self.apis["DEPLOYMENT_BROKER_VERIFY_TLS"]), + "DEPLOYMENT_BROKER_CA_FILE": bool(self.apis["DEPLOYMENT_BROKER_CA_FILE"]), + "DEPLOYMENT_BROKER_TIMEOUT_SECONDS": bool(self.apis["DEPLOYMENT_BROKER_TIMEOUT_SECONDS"]), + "SONARR_URL": bool(self.apis["SONARR_URL"]), + "SONARR_DEFAULT_ROOT_FOLDER": bool(self.apis["SONARR_DEFAULT_ROOT_FOLDER"]), + "SONARR_DEFAULT_QUALITY_PROFILE": bool(self.apis["SONARR_DEFAULT_QUALITY_PROFILE"]), + "SONARR_SEARCH_ON_ADD": bool(self.apis["SONARR_SEARCH_ON_ADD"]), + "RADARR_URL": bool(self.apis["RADARR_URL"]), + "RADARR_DEFAULT_ROOT_FOLDER": bool(self.apis["RADARR_DEFAULT_ROOT_FOLDER"]), + "RADARR_DEFAULT_QUALITY_PROFILE": bool(self.apis["RADARR_DEFAULT_QUALITY_PROFILE"]), + "RADARR_DEFAULT_MINIMUM_AVAILABILITY": bool(self.apis["RADARR_DEFAULT_MINIMUM_AVAILABILITY"]), + "RADARR_SEARCH_ON_ADD": bool(self.apis["RADARR_SEARCH_ON_ADD"]), + "RADARR_VERIFY_TLS": bool(self.apis["RADARR_VERIFY_TLS"]), + "RADARR_TIMEOUT_SECONDS": bool(self.apis["RADARR_TIMEOUT_SECONDS"]), + "PROWLARR_URL": bool(self.apis["PROWLARR_URL"]), + "PROWLARR_VERIFY_TLS": bool(self.apis["PROWLARR_VERIFY_TLS"]), + "PROWLARR_TIMEOUT_SECONDS": bool(self.apis["PROWLARR_TIMEOUT_SECONDS"]), + "DELUGE_URL": bool(self.apis["DELUGE_URL"]), + "DELUGE_VERIFY_TLS": bool(self.apis["DELUGE_VERIFY_TLS"]), + "DELUGE_TIMEOUT_SECONDS": bool(self.apis["DELUGE_TIMEOUT_SECONDS"]), + "DELUGE_DEFAULT_HOST_ID": bool(self.apis["DELUGE_DEFAULT_HOST_ID"]), + "DELUGE_WEB_PATH": bool(self.apis["DELUGE_WEB_PATH"]), + "PLEX_URL": bool(self.apis["PLEX_URL"]), + "PLEX_VERIFY_TLS": bool(self.apis["PLEX_VERIFY_TLS"]), + "PLEX_TIMEOUT_SECONDS": bool(self.apis["PLEX_TIMEOUT_SECONDS"]), + "PLEX_DEFAULT_LIBRARY_ID": bool(self.apis["PLEX_DEFAULT_LIBRARY_ID"]), + "PLEX_MACHINE_IDENTIFIER": bool(self.apis["PLEX_MACHINE_IDENTIFIER"]), + "TAUTULLI_URL": bool(self.apis["TAUTULLI_URL"]), + "TAUTULLI_VERIFY_TLS": bool(self.apis["TAUTULLI_VERIFY_TLS"]), + "TAUTULLI_TIMEOUT_SECONDS": bool(self.apis["TAUTULLI_TIMEOUT_SECONDS"]), + "NPM_URL": bool(self.apis["NPM_URL"]), + "NPM_VERIFY_TLS": bool(self.apis["NPM_VERIFY_TLS"]), + "NPM_TIMEOUT_SECONDS": bool(self.apis["NPM_TIMEOUT_SECONDS"]), + "NPM_LETSENCRYPT_EMAIL": bool(self.apis["NPM_LETSENCRYPT_EMAIL"]), + "AUTHENTIK_URL": bool(self.apis["AUTHENTIK_URL"]), + "AUTHENTIK_VERIFY_TLS": bool(self.apis["AUTHENTIK_VERIFY_TLS"]), + "AUTHENTIK_TIMEOUT_SECONDS": bool(self.apis["AUTHENTIK_TIMEOUT_SECONDS"]), + "AUTHENTIK_OUTPOST_URL": bool(self.apis["AUTHENTIK_OUTPOST_URL"]), + "AUTHENTIK_EXPECT_EMBEDDED_OUTPOST": bool(self.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"]), + "AUTHENTIK_OIDC_AUTHORIZATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"]), + "AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"]), + "AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING"]), + "AUTHENTIK_OIDC_ROLE_CLAIM": bool(self.apis["AUTHENTIK_OIDC_ROLE_CLAIM"]), + "JELLYFIN_URL": bool(self.apis["JELLYFIN_URL"]), + "WIDEFROG_URL": bool(self.apis["WIDEFROG_URL"]), + "WIDEFROG_VERIFY_TLS": bool(self.apis["WIDEFROG_VERIFY_TLS"]), + "WIDEFROG_TIMEOUT_SECONDS": bool(self.apis["WIDEFROG_TIMEOUT_SECONDS"]), + "HOME_ASSISTANT_URL": bool(self.apis["HOME_ASSISTANT_URL"]), + "HOME_ASSISTANT_VERIFY_TLS": bool(self.apis["HOME_ASSISTANT_VERIFY_TLS"]), + "HOME_ASSISTANT_TIMEOUT_SECONDS": bool(self.apis["HOME_ASSISTANT_TIMEOUT_SECONDS"]), + "HOME_ASSISTANT_NOTIFY_DOMAIN": bool(self.apis["HOME_ASSISTANT_NOTIFY_DOMAIN"]), + "HOME_ASSISTANT_NOTIFY_SERVICE": bool(self.apis["HOME_ASSISTANT_NOTIFY_SERVICE"]), + "HOME_ASSISTANT_NOTIFY_TARGET": bool(self.apis["HOME_ASSISTANT_NOTIFY_TARGET"]), + "HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX": bool(self.apis["HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX"]), + "HOME_ASSISTANT_DRY_RUN": bool(self.apis["HOME_ASSISTANT_DRY_RUN"]), + } + + +settings = Settings() diff --git a/backend/app/core/network_security.py b/backend/app/core/network_security.py new file mode 100644 index 0000000..bd73055 --- /dev/null +++ b/backend/app/core/network_security.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import ipaddress +import socket +from urllib.parse import urlsplit, urlunsplit + + +class UnsafeConnectorUrl(ValueError): + pass + + +_BLOCKED_HOSTS = {"localhost", "localhost.localdomain", "metadata.google.internal"} +_BLOCKED_IPS = { + ipaddress.ip_address("169.254.169.254"), + ipaddress.ip_address("100.100.100.200"), +} + + +def _address_is_forbidden(address: ipaddress.IPv4Address | ipaddress.IPv6Address, *, allow_loopback: bool) -> bool: + return ( + address in _BLOCKED_IPS + or address.is_link_local + or address.is_multicast + or address.is_unspecified + or (address.is_loopback and not allow_loopback) + ) + + +def validate_connector_url(value: str, *, allow_loopback: bool = False) -> str: + """Validate a configured LAN connector URL and return a normalized value.""" + raw = str(value or "").strip() + if not raw: + return "" + parsed = urlsplit(raw) + if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: + raise UnsafeConnectorUrl("connector_url_requires_http_or_https") + if parsed.username or parsed.password or parsed.fragment: + raise UnsafeConnectorUrl("connector_url_contains_forbidden_components") + try: + port = parsed.port + except ValueError as exc: + raise UnsafeConnectorUrl("connector_url_has_invalid_port") from exc + if port is not None and not 1 <= port <= 65535: + raise UnsafeConnectorUrl("connector_url_has_invalid_port") + + hostname = parsed.hostname.rstrip(".").lower() + if hostname in _BLOCKED_HOSTS and not allow_loopback: + raise UnsafeConnectorUrl("connector_url_loopback_blocked") + try: + address = ipaddress.ip_address(hostname) + except ValueError: + address = None + if address is not None: + if _address_is_forbidden(address, allow_loopback=allow_loopback): + raise UnsafeConnectorUrl("connector_url_reserved_address_blocked") + else: + # Inspect currently resolvable names as well as literal IPs. Unresolved LAN + # names remain configurable, but DNS names resolving to local-only system + # addresses cannot bypass the literal-address checks. + try: + resolved = {item[4][0] for item in socket.getaddrinfo(hostname, port or 0, type=socket.SOCK_STREAM)} + except socket.gaierror: + resolved = set() + for resolved_value in resolved: + resolved_address = ipaddress.ip_address(resolved_value) + if _address_is_forbidden(resolved_address, allow_loopback=allow_loopback): + raise UnsafeConnectorUrl("connector_url_resolves_to_reserved_address") + + normalized_path = parsed.path.rstrip("/") + return urlunsplit((parsed.scheme.lower(), parsed.netloc, normalized_path, parsed.query, "")) diff --git a/backend/app/core/redaction.py b/backend/app/core/redaction.py new file mode 100644 index 0000000..7ed69f0 --- /dev/null +++ b/backend/app/core/redaction.py @@ -0,0 +1,34 @@ +import re +from typing import Any + +REDACTION_PATTERNS = [ + re.compile(r"(Bearer\s+)([A-Za-z0-9._~+/=-]{10,})", re.IGNORECASE), + re.compile(r"(api[-_]?(?:key|token)\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE), + re.compile(r"(apiKey\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE), + re.compile(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE), + re.compile(r"(token\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE), + re.compile(r"(password\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE), + re.compile(r"(access[_-]?token\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE), +] + +_SENSITIVE_KEY_PARTS = ("api_key", "apikey", "token", "password", "secret", "authorization", "cookie") + + +def redact_message(message: str) -> str: + redacted = message + for pattern in REDACTION_PATTERNS: + redacted = pattern.sub(r"\1[REDACTED]", redacted) + return redacted + + +def redact_value(value: Any, key: str = "") -> Any: + """Recursively redact values crossing log, AI, MCP or automation boundaries.""" + if any(part in key.casefold() for part in _SENSITIVE_KEY_PARTS): + return "[REDACTED]" + if isinstance(value, dict): + return {str(nested_key): redact_value(nested, str(nested_key)) for nested_key, nested in value.items()} + if isinstance(value, (list, tuple, set)): + return [redact_value(item) for item in value] + if isinstance(value, str): + return redact_message(value) + return value diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..143266a --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,255 @@ +import hashlib +import hmac +import os +import re +import secrets +import threading +from ipaddress import ip_address, ip_network +from time import monotonic + +from fastapi import Header, HTTPException, Request + +from .config import settings + + +OPERATOR_SESSION_COOKIE = "appops_operator_session" +OPERATOR_SESSION_CSRF_HEADER = "X-AppOps-Session-CSRF" +_OPERATOR_SESSION_TTL_SECONDS = 8 * 60 * 60 +_operator_sessions: dict[str, tuple[float, str]] = {} +_operator_sessions_lock = threading.Lock() + + +def _operator_token() -> str | None: + expected = settings.operator_token or os.getenv("APPOPS_OPERATOR_TOKEN") + token_file = settings.operator_token_file or os.getenv("APPOPS_OPERATOR_TOKEN_FILE") + if expected or not token_file: + return expected + try: + with open(token_file, encoding="utf-8") as handle: + return handle.read().strip() or None + except OSError: + return None + + +def token_is_strong(value: str | None, *, minimum_length: int = 24) -> bool: + if not value or len(value) < minimum_length: + return False + return value.strip().lower() not in {"change-me", "changeme", "dev", "test", "dev-voice-token"} + + +def _extract_bearer_token(authorization: str | None) -> str: + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header missing") + + prefix = "Bearer " + if not authorization.startswith(prefix): + raise HTTPException(status_code=401, detail="Authorization header missing") + + return authorization[len(prefix):] + + +def require_auth_token(provided_token: str | None, expected_token: str | None, scope: str): + if expected_token: + token = _extract_bearer_token(provided_token) + if not hmac.compare_digest(token, expected_token): + raise HTTPException(status_code=403, detail=f"Invalid {scope} token") + + +def _operator_token_from_request(request: Request) -> str | None: + explicit = request.headers.get("X-AppOps-Operator-Token") + if explicit: + return explicit.strip() + authorization = request.headers.get("Authorization") + if authorization and authorization.startswith("Bearer "): + return authorization[len("Bearer "):].strip() + return None + + +def _operator_token_fingerprint(value: str | None) -> str: + return hashlib.sha256((value or "").encode("utf-8")).hexdigest() + + +def _remove_expired_operator_sessions(now: float) -> None: + expired = [session_id for session_id, (expires_at, _) in _operator_sessions.items() if expires_at <= now] + for session_id in expired: + _operator_sessions.pop(session_id, None) + + +def issue_operator_session(request: Request) -> str: + """Exchange the explicit operator token for an opaque, process-local browser session.""" + expected = _operator_token() + provided = _operator_token_from_request(request) + auth_required = settings.operator_auth_required + if not auth_required: + raise HTTPException(status_code=409, detail="Operator sessions are disabled") + if not provided: + raise HTTPException(status_code=401, detail="Operator token missing") + if not expected: + if auth_required: + raise HTTPException(status_code=503, detail="Operator authentication is not configured") + elif settings.environment == "production" and not token_is_strong(expected): + raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token") + elif not hmac.compare_digest(provided, expected): + raise HTTPException(status_code=403, detail="Invalid operator token") + + session_id = secrets.token_urlsafe(32) + now = monotonic() + with _operator_sessions_lock: + _remove_expired_operator_sessions(now) + _operator_sessions[session_id] = ( + now + _OPERATOR_SESSION_TTL_SECONDS, + _operator_token_fingerprint(expected), + ) + return session_id + + +def revoke_operator_session(request: Request) -> None: + session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "") + if not session_id: + return + with _operator_sessions_lock: + _operator_sessions.pop(session_id, None) + + +def _operator_session_identity(request: Request, expected: str | None) -> str | None: + session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "") + if not session_id: + return None + now = monotonic() + with _operator_sessions_lock: + _remove_expired_operator_sessions(now) + record = _operator_sessions.get(session_id) + if record and not hmac.compare_digest(record[1], _operator_token_fingerprint(expected)): + _operator_sessions.pop(session_id, None) + record = None + if not record: + return None + if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}: + csrf = request.headers.get(OPERATOR_SESSION_CSRF_HEADER, "") + if not hmac.compare_digest(csrf, "1"): + raise HTTPException(status_code=403, detail="Operator session CSRF validation failed") + return "session" + + +def _normalized_set(values: list[str] | None) -> set[str]: + return {value.strip().casefold() for value in values or [] if value.strip()} + + +def _request_from_trusted_operator_proxy(request: Request) -> bool: + if not settings.operator_proxy_auth_enabled or not settings.operator_proxy_trusted_networks: + return False + client_host = request.client.host if request.client else "" + try: + client_ip = ip_address(client_host) + except ValueError: + return False + for configured_network in settings.operator_proxy_trusted_networks: + try: + if client_ip in ip_network(configured_network, strict=False): + return True + except ValueError: + continue + return False + + +def _trusted_proxy_operator_identity(request: Request) -> str | None: + """Resolve an Authentik identity only across an explicitly trusted proxy boundary.""" + if not _request_from_trusted_operator_proxy(request): + return None + + allowed_users = _normalized_set(settings.operator_proxy_users) + allowed_groups = _normalized_set(settings.operator_proxy_groups) + if not allowed_users and not allowed_groups: + return None + + username = request.headers.get("X-authentik-username", "").strip() + groups_header = request.headers.get("X-authentik-groups", "") + groups = { + item.strip().casefold() + for item in re.split(r"[|,;]", groups_header) + if item.strip() + } + if username and (username.casefold() in allowed_users or groups.intersection(allowed_groups)): + return username + return None + + +def require_operator_request(request: Request) -> str: + """Validate the local operator boundary without leaking token material.""" + expected = _operator_token() + auth_required = settings.operator_auth_required + if not auth_required: + return "private_unlocked" + proxy_identity = _trusted_proxy_operator_identity(request) + if proxy_identity: + return f"authentik:{proxy_identity}" + + provided = _operator_token_from_request(request) + if provided: + if not expected: + raise HTTPException(status_code=503, detail="Operator authentication is not configured") + if settings.environment == "production" and not token_is_strong(expected): + raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token") + if not hmac.compare_digest(provided, expected): + raise HTTPException(status_code=403, detail="Invalid operator token") + return "operator" + + session_identity = _operator_session_identity(request, expected) + if session_identity: + return session_identity + + if not expected: + raise HTTPException(status_code=503, detail="Operator authentication is not configured") + if settings.environment == "production" and not token_is_strong(expected): + raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token") + raise HTTPException(status_code=401, detail="Operator authorization missing") + + +def operator_session_status(request: Request) -> dict[str, str | bool | None]: + """Return the current request's operator state without exposing credentials.""" + try: + identity = require_operator_request(request) + except HTTPException: + return {"authorized": False, "mode": "locked", "identity": None} + if identity.startswith("authentik:"): + return {"authorized": True, "mode": "account", "identity": identity.split(":", 1)[1]} + if identity == "session": + return {"authorized": True, "mode": "session", "identity": None} + return {"authorized": True, "mode": "token" if identity == "operator" else "private", "identity": None} + + +def require_service_request(request: Request, service: str) -> str: + """Authenticate a narrowly scoped machine integration without reusing operator credentials.""" + if service == "mcp": + enabled, expected = settings.mcp_enabled, settings.mcp_token + elif service == "n8n": + enabled, expected = settings.n8n_enabled, settings.n8n_token + else: + raise HTTPException(status_code=403, detail="Unknown service identity") + if not enabled: + raise HTTPException(status_code=404, detail=f"{service} integration is disabled") + if not token_is_strong(expected): + raise HTTPException(status_code=503, detail=f"{service} authentication is not safely configured") + provided = _operator_token_from_request(request) + if not provided: + raise HTTPException(status_code=401, detail=f"{service} authorization missing") + if not hmac.compare_digest(provided, expected): + raise HTTPException(status_code=403, detail=f"Invalid {service} token") + return service + + +def n8n_auth(request: Request) -> str: + return require_service_request(request, "n8n") + + +def operator_auth(request: Request) -> str: + return require_operator_request(request) + + +def voice_auth(authorization: str | None = Header(default=None, alias='Authorization')): + expected = settings.voice_token or os.getenv("APP_OPS_VOICE_TOKEN") + if not expected: + return True + + require_auth_token(authorization, expected, "voice") + return True diff --git a/backend/app/core/time.py b/backend/app/core/time.py new file mode 100644 index 0000000..6387443 --- /dev/null +++ b/backend/app/core/time.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from datetime import UTC, datetime + + +def utc_now() -> datetime: + """Return current UTC as a naive datetime for existing SQLite columns.""" + return datetime.now(UTC).replace(tzinfo=None) diff --git a/backend/app/core/version.py b/backend/app/core/version.py new file mode 100644 index 0000000..a4edc89 --- /dev/null +++ b/backend/app/core/version.py @@ -0,0 +1 @@ +APP_VERSION = "1.8.0" diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py new file mode 100644 index 0000000..476af16 --- /dev/null +++ b/backend/app/db/init_db.py @@ -0,0 +1,944 @@ +from datetime import UTC, datetime + +from sqlalchemy import inspect, text +from sqlmodel import SQLModel, select +from . import session as db_session +from .session import get_session +from ..core.config import Settings +from ..services.app_classifier import classify_container +from ..services.app_registry import category_label +from .models import App, AppContainerMapping, Connector, DiscoveredContainer, NotificationChannel, NotificationEvent, NotificationRule, Policy, WorkMeshPromotion + + +settings = Settings() + + +def init_db() -> None: + SQLModel.metadata.create_all(db_session.engine) + _apply_lightweight_schema_upgrades() + + with next(get_session()) as session: + changed = False + for app in _seed_apps(): + if session.get(App, app.id) is None: + session.add(app) + changed = True + for connector in _seed_connectors(): + if session.get(Connector, connector.id) is None: + session.add(connector) + changed = True + for policy in _seed_policies(): + if session.get(Policy, policy.id) is None: + session.add(policy) + changed = True + for channel in _seed_notification_channels(): + if session.get(NotificationChannel, channel.id) is None: + session.add(channel) + changed = True + for rule in _seed_notification_rules(): + if session.get(NotificationRule, rule.id) is None: + session.add(rule) + changed = True + changed = _upgrade_unknown_app_classifications(session) or changed + changed = _upgrade_default_notification_rule_channels(session) or changed + changed = _upgrade_duplicate_notification_events(session) or changed + changed = _upgrade_suppressed_notification_events(session) or changed + if changed: + session.commit() + from ..services.setup_service import apply_stored_connector_settings + from ..services.runtime_service import apply_stored_operator_auth_setting + + apply_stored_connector_settings(session) + apply_stored_operator_auth_setting(session) + _ensure_notification_suppression_index() + + +def _apply_lightweight_schema_upgrades() -> None: + inspector = inspect(db_session.engine) + table_names = set(inspector.get_table_names()) + with db_session.engine.begin() as connection: + if 'notification_channels' in table_names: + columns = {column['name'] for column in inspector.get_columns('notification_channels')} + if 'last_test_at' not in columns: + connection.execute(text('ALTER TABLE notification_channels ADD COLUMN last_test_at DATETIME')) + if 'approval' in table_names: + columns = {column['name'] for column in inspector.get_columns('approval')} + approval_columns = { + 'decided_at': 'DATETIME', + 'executed_at': 'DATETIME', + 'dismissed_at': 'DATETIME', + 'job_id': 'VARCHAR', + 'audit_event_id': 'VARCHAR', + 'approved_by': 'VARCHAR', + 'executed_by': 'VARCHAR', + 'action_fingerprint': 'VARCHAR', + 'request_context_json': "JSON DEFAULT '{}'", + } + for name, sql_type in approval_columns.items(): + if name not in columns: + connection.execute(text(f'ALTER TABLE approval ADD COLUMN {name} {sql_type}')) + if 'policy' in table_names: + policy_columns = {column['name'] for column in inspector.get_columns('policy')} + if 'provenance_json' not in policy_columns: + connection.execute(text("ALTER TABLE policy ADD COLUMN provenance_json JSON DEFAULT '{}'")) + if 'discovery_runs' in table_names: + connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovery_runs_started_at ON discovery_runs (started_at)')) + if 'discovered_containers' in table_names: + connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_discovery_run_id ON discovered_containers (discovery_run_id)')) + connection.execute(text('CREATE INDEX IF NOT EXISTS ix_discovered_containers_container_id ON discovered_containers (container_id)')) + if 'app_container_mappings' in table_names: + connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_app_id ON app_container_mappings (app_id)')) + connection.execute(text('CREATE INDEX IF NOT EXISTS ix_app_container_mappings_container_id ON app_container_mappings (container_id)')) + + +def _upgrade_unknown_app_classifications(session) -> bool: + changed = False + risk_rank = {'low': 0, 'medium': 1, 'high': 2, 'critical': 3} + unknown_apps = [ + app + for app in session.exec(select(App)).all() + if str(app.category or '').strip().lower() in {'', 'unknown', 'generic'} + ] + if not unknown_apps: + return False + + mappings_by_app: dict[str, list[AppContainerMapping]] = {} + for mapping in session.exec(select(AppContainerMapping)).all(): + mappings_by_app.setdefault(mapping.app_id, []).append(mapping) + discovered_by_container: dict[str, list[DiscoveredContainer]] = {} + for item in session.exec(select(DiscoveredContainer)).all(): + discovered_by_container.setdefault(item.container_id, []).append(item) + + for app in unknown_apps: + mappings = mappings_by_app.get(app.id, []) + evidence = [ + item + for mapping in mappings + for item in discovered_by_container.get(mapping.container_id, []) + ] + classification = classify_container({ + 'name': ' '.join([app.id, app.name, *(mapping.container_name for mapping in mappings)]), + 'image': ' '.join(item.image for item in evidence), + 'ports': [port for item in evidence for port in (item.ports_json or [])], + }) + if classification.get('app_type') == 'unknown' or float(classification.get('confidence', 0)) < 0.8: + continue + app.category = category_label(str(classification['category'])) + suggested_criticality = str(classification.get('criticality') or 'low') + if risk_rank.get(suggested_criticality, 0) > risk_rank.get(app.criticality, 0): + app.criticality = suggested_criticality + app.protected = app.protected or bool(classification.get('protected')) + session.add(app) + changed = True + return changed + + +def _upgrade_duplicate_notification_events(session) -> bool: + rules = {rule.id: rule for rule in session.exec(select(NotificationRule)).all()} + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.status != 'dismissed') + .order_by(NotificationEvent.created_at.desc()) + ).all() + grouped: dict[tuple[str, ...], list[NotificationEvent]] = {} + for row in rows: + key = (row.source, row.event_type, row.dedupe_key, row.title, row.message) + grouped.setdefault(key, []).append(row) + + changed = False + severity_rank = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4} + + def specificity(row: NotificationEvent) -> tuple[int, object]: + rule = rules.get(row.rule_id) + if not rule: + return 0, row.created_at + conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {} + constrained = sum((bool(rule.categories_json), bool(rule.targets_json), bool(conditions))) + return constrained * 100 + severity_rank.get(rule.severity_min, 0), row.created_at + + specific_media_rows = [ + row for row in rows + if row.rule_id in {'notify-deluge-stalled-downloads', 'notify-widefrog-failed-jobs'} + ] + for row in rows: + if row.rule_id != 'notify-media-pipeline-issue': + continue + message = str(row.message or '').lower() + expected_rule = ( + 'notify-deluge-stalled-downloads' + if 'deluge' in message and 'stalled' in message + else 'notify-widefrog-failed-jobs' + if 'widefrog' in message and 'fail' in message + else None + ) + if not expected_rule: + continue + overlap = next(( + specific for specific in specific_media_rows + if specific.rule_id == expected_rule + and abs((specific.created_at - row.created_at).total_seconds()) <= 5 + ), None) + if not overlap: + continue + row.status = 'dismissed' + row.reason = 'superseded_by_specific_rule' + row.read = True + session.add(row) + changed = True + + for duplicates in grouped.values(): + if len(duplicates) < 2: + continue + timestamps = [row.created_at for row in duplicates] + if (max(timestamps) - min(timestamps)).total_seconds() > 5: + continue + keep = max(duplicates, key=specificity) + for row in duplicates: + if row.id == keep.id: + continue + row.status = 'dismissed' + row.reason = 'superseded_by_specific_rule' + row.read = True + session.add(row) + changed = True + return changed + + +def _upgrade_suppressed_notification_events(session) -> bool: + """Collapse legacy scheduler spam while retaining one read suppression record per identity.""" + def suppression_count(row: NotificationEvent) -> int: + payload = row.payload_json if isinstance(row.payload_json, dict) else {} + try: + return max(1, int(payload.get('suppressionCount') or 1)) + except (TypeError, ValueError): + return 1 + + def suppression_timestamp(row: NotificationEvent, key: str) -> datetime: + payload = row.payload_json if isinstance(row.payload_json, dict) else {} + raw_value = payload.get(key) + if isinstance(raw_value, str): + try: + parsed = datetime.fromisoformat(raw_value.replace('Z', '+00:00')) + if parsed.tzinfo is not None: + parsed = parsed.astimezone(UTC).replace(tzinfo=None) + return parsed + except ValueError: + pass + return row.created_at + + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.status == 'suppressed') + .order_by(NotificationEvent.created_at.desc()) + ).all() + grouped: dict[tuple[str, str, str], list[NotificationEvent]] = {} + changed = False + for row in rows: + if not row.read: + row.read = True + session.add(row) + changed = True + if row.reason not in {'cooldown', 'quiet_hours'}: + continue + key = (row.rule_id, row.dedupe_key, row.reason or 'unspecified') + grouped.setdefault(key, []).append(row) + + for duplicates in grouped.values(): + keeper = duplicates[0] + payload = keeper.payload_json if isinstance(keeper.payload_json, dict) else {} + total = sum(suppression_count(row) for row in duplicates) + first_suppressed = min(suppression_timestamp(row, 'firstSuppressedAt') for row in duplicates) + last_suppressed = max(suppression_timestamp(row, 'lastSuppressedAt') for row in duplicates) + normalized_payload = { + **payload, + 'suppressionCount': total, + 'firstSuppressedAt': first_suppressed.isoformat(), + 'lastSuppressedAt': last_suppressed.isoformat(), + } + if not keeper.read or keeper.payload_json != normalized_payload: + keeper.read = True + keeper.payload_json = normalized_payload + session.add(keeper) + changed = True + for duplicate in duplicates[1:]: + session.delete(duplicate) + changed = True + return changed + + +def _ensure_notification_suppression_index() -> None: + """Enforce one live routine-suppression row per rule identity after legacy compaction.""" + with db_session.engine.begin() as connection: + connection.execute(text(""" + CREATE UNIQUE INDEX IF NOT EXISTS uq_notification_routine_suppression + ON notification_events (rule_id, dedupe_key, reason) + WHERE status = 'suppressed' AND reason IN ('cooldown', 'quiet_hours') + """)) + + +def _seed_apps() -> list[App]: + return [ + App( + id="widefrog", + name="Widefrog", + category="Media", + status="healthy", + criticality="medium", + connector_status="connected", + container_status="running", + voice_enabled=True, + ), + App( + id="sonarr", + name="Sonarr", + category="Media", + status="healthy", + criticality="low", + connector_status="connected", + container_status="running", + voice_enabled=True, + ), + App( + id="radarr", + name="Radarr", + category="Media", + status="healthy", + criticality="low", + connector_status="connected", + container_status="running", + voice_enabled=True, + ), + App( + id="prowlarr", + name="Prowlarr", + category="Media", + status="healthy", + criticality="medium", + connector_status="connected" if (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="deluge", + name="Deluge", + category="Downloads", + status="healthy", + criticality="medium", + connector_status="connected" if (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="plex", + name="Plex", + category="Media", + status="healthy", + criticality="medium", + connector_status="connected" if (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="tautulli", + name="Tautulli", + category="Media", + status="healthy", + criticality="medium", + connector_status="connected" if (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="jellyfin", + name="Jellyfin", + category="Media", + status="healthy", + criticality="low", + connector_status="connected", + container_status="running", + voice_enabled=True, + ), + App( + id="gitea", + name="Gitea", + category="Dev", + status="healthy", + criticality="medium", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="projectbrain", + name="ProjectBrain", + category="Dev", + status="healthy", + criticality="medium", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="psforge", + name="PSForge", + category="Dev", + status="healthy", + criticality="low", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="spoortracker", + name="SpoorTracker", + category="Monitoring", + status="healthy", + criticality="low", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + ), + App( + id="authentik", + name="Authentik", + category="Auth", + status="healthy", + criticality="critical", + connector_status="connected" if (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "disconnected", + container_status="running", + voice_enabled=False, + protected=True, + ), + App( + id="nginx_proxy_manager", + name="Nginx Proxy Manager", + category="Network", + status="healthy", + criticality="critical", + connector_status="connected" if (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "disconnected", + container_status="running", + voice_enabled=False, + protected=True, + ), + App( + id="zabbix", + name="Zabbix", + category="Monitoring", + status="healthy", + criticality="high", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + protected=True, + ), + App( + id="home_assistant", + name="Home Assistant", + category="Infrastructure", + status="healthy", + criticality="medium", + connector_status="connected" if settings.apis.get("HOME_ASSISTANT_URL") else "disconnected", + container_status="running", + voice_enabled=True, + ), + App( + id="portainer", + name="Portainer", + category="Infrastructure", + status="healthy", + criticality="high", + connector_status="connected" if settings.apis.get("PORTAINER_URL") else "disconnected", + container_status="running", + voice_enabled=False, + protected=True, + ), + App( + id="postgres", + name="PostgreSQL", + category="Database", + status="healthy", + criticality="critical", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + protected=True, + ), + App( + id="redis", + name="Redis", + category="Database", + status="healthy", + criticality="high", + connector_status="disconnected", + container_status="running", + voice_enabled=False, + protected=True, + ), + ] + + +def _seed_notification_channels() -> list[NotificationChannel]: + return [ + NotificationChannel( + id="dashboard", + type="dashboard", + name="Dashboard", + enabled=True, + config_json={"delivery": "in_app"}, + secret_refs_json=[], + ), + NotificationChannel( + id="home_assistant", + type="home_assistant", + name="Home Assistant", + enabled=False, + config_json={ + "url": settings.apis.get("HOME_ASSISTANT_URL"), + "notifyDomain": settings.apis.get("HOME_ASSISTANT_NOTIFY_DOMAIN") or "notify", + "notifyService": settings.apis.get("HOME_ASSISTANT_NOTIFY_SERVICE") or "notify", + "target": settings.apis.get("HOME_ASSISTANT_NOTIFY_TARGET"), + "verifyTls": bool(settings.apis.get("HOME_ASSISTANT_VERIFY_TLS")), + "timeoutSeconds": int(settings.apis.get("HOME_ASSISTANT_TIMEOUT_SECONDS") or 10), + "titlePrefix": settings.apis.get("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX") or "AppOps", + "dryRun": bool(settings.apis.get("HOME_ASSISTANT_DRY_RUN")), + }, + secret_refs_json=["HOME_ASSISTANT_TOKEN"], + ), + NotificationChannel(id="discord", type="discord", name="Discord", enabled=False, config_json={"username": "AppOps", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_DISCORD_WEBHOOK"]), + NotificationChannel(id="telegram", type="telegram", name="Telegram", enabled=False, config_json={"chatId": "", "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_TELEGRAM_BOT_TOKEN"]), + NotificationChannel(id="email", type="email", name="E-mail", enabled=False, config_json={"host": "", "port": 465, "username": "", "fromAddress": "", "toAddress": "", "useTls": True, "timeoutSeconds": 10}, secret_refs_json=["NOTIFICATION_SMTP_PASSWORD"]), + ] + + +def _upgrade_default_notification_rule_channels(session) -> bool: + home_assistant_defaults = { + "notify-critical-safety-finding", + "notify-high-safety-finding", + "notify-npm-certificate-expiring", + "notify-public-host-without-auth", + "notify-blocked-dangerous-action", + } + changed = False + for rule_id in home_assistant_defaults: + rule = session.get(NotificationRule, rule_id) + if rule and "home_assistant" not in (rule.channels_json or []): + rule.channels_json = [*(rule.channels_json or ["dashboard"]), "home_assistant"] + changed = True + session.add(rule) + return changed + + +def _seed_notification_rules() -> list[NotificationRule]: + return [ + NotificationRule(id="notify-critical-safety-finding", name="Critical Safety Finding", source="safety", event_type="safety.finding", severity_min="critical", cooldown_seconds=900, quiet_hours_json={"enabled": False, "allowCriticalOverride": True}, channels_json=["dashboard", "home_assistant"]), + NotificationRule(id="notify-high-safety-finding", name="High Safety Finding", source="safety", event_type="safety.finding", severity_min="high", cooldown_seconds=3600, channels_json=["dashboard", "home_assistant"]), + NotificationRule(id="notify-coverage-incomplete", name="Coverage Incomplete", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["coverage", "connector_config", "setup", "connectors"], cooldown_seconds=86400, channels_json=["dashboard"]), + NotificationRule(id="notify-connector-missing-config", name="Connector Missing Config", source="connector", event_type="connector.missing_config", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]), + NotificationRule(id="notify-connector-failed", name="Connector Failure After Previously Connected", source="connector", event_type="connector.failed", severity_min="medium", cooldown_seconds=1800, channels_json=["dashboard"]), + NotificationRule(id="notify-media-pipeline-issue", name="Media Pipeline Issue", source="media_pipeline", event_type="media.issue", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]), + NotificationRule(id="notify-deluge-stalled-downloads", name="Deluge Stalled Downloads", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["downloads"], cooldown_seconds=3600, channels_json=["dashboard"]), + NotificationRule(id="notify-widefrog-failed-jobs", name="Widefrog Failed Jobs", source="media_pipeline", event_type="media.issue", severity_min="medium", categories_json=["widefrog"], cooldown_seconds=3600, channels_json=["dashboard"]), + NotificationRule(id="notify-npm-certificate-expiring", name="NPM Certificate Expiring", source="safety", event_type="safety.finding", severity_min="medium", categories_json=["exposure"], cooldown_seconds=86400, channels_json=["dashboard", "home_assistant"], conditions_json={"titleContains": ["certificate", "certificaat"]}), + NotificationRule(id="notify-public-host-without-auth", name="Public Host Without Auth Coverage", source="safety", event_type="safety.finding", severity_min="high", categories_json=["auth", "exposure"], cooldown_seconds=43200, channels_json=["dashboard", "home_assistant"]), + NotificationRule(id="notify-blocked-dangerous-action", name="Blocked Dangerous Action Attempt", source="audit", event_type="audit.blocked_action", severity_min="high", cooldown_seconds=1800, channels_json=["dashboard", "home_assistant"]), + NotificationRule(id="notify-voice-auth-failure", name="Voice Auth Failure", source="audit", event_type="audit.voice_denied", severity_min="medium", cooldown_seconds=3600, channels_json=["dashboard"]), + NotificationRule(id="notify-discovery-needs-review", name="Discovery Needs Review", source="inventory", event_type="inventory.needs_review", severity_min="medium", cooldown_seconds=86400, channels_json=["dashboard"]), + ] + + +def _seed_voice_export_policies() -> list[Policy]: + return [ + Policy(id="policy-unraid-health-summary", action="unraid.health_summary", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading Unraid health, storage and VM inventory is read-only; mover, format and power mutations use separate actions."), + Policy(id="policy-unraid-vms", action="unraid.vms", target="unraid", voice="allowed", manual="allowed", risk="low", reason="Reading the Unraid VM inventory is read-only."), + Policy(id="policy-unraid-vm-start", action="unraid.vm.start", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Starting a VM changes host load; manual execution is confirmed and audited, while voice requires approval."), + Policy(id="policy-unraid-vm-resume", action="unraid.vm.resume", target="*", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a paused VM changes host load; manual execution is confirmed and audited, while voice requires approval."), + Policy(id="policy-unraid-vm-pause", action="unraid.vm.pause", target="*", voice="blocked", manual="approval_required", risk="high", reason="Pausing a running VM interrupts its workload and requires explicit operator approval."), + Policy(id="policy-unraid-vm-stop", action="unraid.vm.stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Unraid stop can force-stop after timeout and remains blocked."), + Policy(id="policy-unraid-vm-reboot", action="unraid.vm.reboot", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reboot remains blocked until shutdown and recovery semantics are safely verifiable."), + Policy(id="policy-unraid-vm-force-stop", action="unraid.vm.force_stop", target="*", voice="blocked", manual="blocked", risk="critical", reason="Force-stop is destructive and blocked."), + Policy(id="policy-unraid-vm-reset", action="unraid.vm.reset", target="*", voice="blocked", manual="blocked", risk="critical", reason="VM reset is destructive and blocked."), + Policy(id="policy-setup-status", action="setup.status", target="appops", voice="allowed", manual="allowed", risk="low", reason="Reading AppOps setup status is read-only."), + Policy(id="policy-safety-summary", action="safety.summary", target="safety", voice="allowed", manual="allowed", risk="low", reason="Reading Safety Center summary is read-only."), + Policy(id="policy-media-pipeline-check", action="media_pipeline.check", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline voice check is read-only in the voice export path."), + Policy(id="policy-media-pipeline-diagnose", action="media_pipeline.diagnose", target="media-pipeline", voice="allowed", manual="allowed", risk="low", reason="Media pipeline diagnosis is a read-only evidence summary and does not execute connector actions."), + Policy(id="policy-notifications-summary", action="notifications.summary", target="notifications", voice="allowed", manual="allowed", risk="low", reason="Reading notification summary is read-only and never returns notification tokens."), + Policy(id="policy-notifications-evaluate-safety", action="notifications.evaluate_safety", target="notifications", voice="approval_required", manual="allowed", risk="medium", reason="Evaluating safety notifications can create notification events and requires voice approval."), + ] + + +def _seed_connectors() -> list[Connector]: + return [ + Connector( + id="portainer", + name="Portainer", + status="disconnected" if not settings.apis.get("PORTAINER_URL") else "connected", + last_test="never", + secret_ref="PORTAINER_API_KEY", + capabilities=[ + "container.status", + "container.logs", + "container.restart", + "container.stop", + "stack.status", + "stack.redeploy", + ], + type="Infrastructure", + ), + Connector( + id="unraid", + name="Unraid", + status="disconnected" if not settings.apis.get("UNRAID_URL") else "connected", + last_test="never", + secret_ref="UNRAID_API_KEY", + capabilities=[ + "unraid.health_summary", + "unraid.array", + "unraid.disks", + "unraid.cache", + "unraid.parity", + "unraid.docker", + "unraid.vms", + "unraid.shares", + ], + type="Infrastructure", + ), + Connector( + id="sonarr", + name="Sonarr", + status="disconnected" if not settings.apis.get("SONARR_URL") else "connected", + last_test="never", + secret_ref="SONARR_API_KEY", + capabilities=[ + "sonarr.health", + "sonarr.search_series", + "sonarr.add_series", + "sonarr.queue_status", + "sonarr.wanted_missing", + ], + type="Media", + ), + Connector( + id="radarr", + name="Radarr", + status="disconnected" if not (settings.apis.get("RADARR_URL") and settings.apis.get("RADARR_API_KEY")) else "connected", + last_test="never", + secret_ref="RADARR_API_KEY", + capabilities=[ + "radarr.health", + "radarr.search_movie", + "radarr.add_movie", + "radarr.queue_status", + "radarr.wanted_missing", + ], + type="Media", + ), + Connector( + id="prowlarr", + name="Prowlarr", + status="disconnected" if not (settings.apis.get("PROWLARR_URL") and settings.apis.get("PROWLARR_API_KEY")) else "connected", + last_test="never", + secret_ref="PROWLARR_API_KEY", + capabilities=[ + "prowlarr.health", + "prowlarr.indexer_status", + "prowlarr.application_status", + "prowlarr.indexer_health", + "prowlarr.search_test", + "prowlarr.sync_status", + ], + type="Media", + ), + Connector( + id="deluge", + name="Deluge", + status="disconnected" if not (settings.apis.get("DELUGE_URL") and settings.apis.get("DELUGE_PASSWORD")) else "connected", + last_test="never", + secret_ref="DELUGE_PASSWORD", + capabilities=[ + "deluge.health", + "deluge.session_status", + "deluge.torrent_status", + "deluge.active_downloads", + "deluge.stalled_downloads", + "deluge.paused_downloads", + "deluge.speed_status", + "deluge.pause_torrent", + "deluge.resume_torrent", + "deluge.pause_all", + "deluge.resume_all", + ], + type="Downloads", + ), + Connector( + id="plex", + name="Plex", + status="disconnected" if not (settings.apis.get("PLEX_URL") and settings.apis.get("PLEX_TOKEN")) else "connected", + last_test="never", + secret_ref="PLEX_TOKEN", + capabilities=[ + "plex.health", + "plex.server_status", + "plex.library_status", + "plex.active_sessions", + "plex.recent_media", + "plex.scan_library", + "plex.scan_all_libraries", + "plex.stream_summary", + ], + type="Media", + ), + Connector( + id="tautulli", + name="Tautulli", + status="disconnected" if not (settings.apis.get("TAUTULLI_URL") and settings.apis.get("TAUTULLI_API_KEY")) else "connected", + last_test="never", + secret_ref="TAUTULLI_API_KEY", + capabilities=[ + "tautulli.health", + "tautulli.status", + "tautulli.activity", + "tautulli.history", + "tautulli.users", + "tautulli.libraries", + "tautulli.recently_added", + "tautulli.watch_stats", + "tautulli.stream_summary", + "tautulli.plex_info", + ], + type="Media", + ), + Connector( + id="authentik", + name="Authentik", + status="disconnected" if not (settings.apis.get("AUTHENTIK_URL") and settings.apis.get("AUTHENTIK_TOKEN")) else "connected", + last_test="never", + secret_ref="AUTHENTIK_TOKEN", + capabilities=[ + "authentik.health", + "authentik.system_status", + "authentik.applications", + "authentik.providers", + "authentik.proxy_providers", + "authentik.oauth2_providers", + "authentik.outposts", + "authentik.outpost_health", + "authentik.flows_summary", + "authentik.policies_summary", + "authentik.events", + "authentik.auth_coverage", + "authentik.security_warnings", + ], + type="Auth", + ), + Connector( + id="nginx_proxy_manager", + name="Nginx Proxy Manager", + status="disconnected" if not (settings.apis.get("NPM_URL") and settings.apis.get("NPM_IDENTITY") and settings.apis.get("NPM_SECRET")) else "connected", + last_test="never", + secret_ref="NPM_SECRET", + capabilities=[ + "npm.health", + "npm.proxy_hosts", + "npm.certificates", + "npm.expiring_certificates", + "npm.access_lists", + "npm.exposure_summary", + "npm.host_reachability", + "npm.security_warnings", + ], + type="Network", + ), + Connector( + id="widefrog", + name="Widefrog", + status="disconnected" if not settings.apis.get("WIDEFROG_URL") else "connected", + last_test="never", + secret_ref="WIDEFROG_API_KEY", + capabilities=[ + "widefrog.health", + "widefrog.queue_status", + "widefrog.failed_jobs", + "widefrog.activity", + "widefrog.retry_failed", + "widefrog.scan_library", + "widefrog.download_status", + ], + type="Media", + ), + Connector( + id="home_assistant", + name="Home Assistant", + status="disconnected" if not settings.apis.get("HOME_ASSISTANT_URL") else "connected", + last_test="never", + secret_ref="HOME_ASSISTANT_TOKEN", + capabilities=["read_status", "trigger_action"], + type="Automation", + ), + ] + + +def _seed_policies() -> list[Policy]: + return [ + Policy(id="policy-volume-delete", action="volume.delete", target="*", voice="blocked", manual="blocked", risk="critical", reason="Dangerous operation."), + Policy(id="policy-container-stop", action="container.stop", target="*", voice="blocked", manual="blocked", risk="high", reason="Container stop is outside AppOps Gateway V1; use Portainer or Unraid directly after manual review."), + Policy(id="policy-container-clear-logs", action="container.clear_logs", target="*", voice="blocked", manual="blocked", risk="high", reason="Clearing container logs is outside AppOps Gateway V1; log viewing and local download remain read-only."), + Policy(id="policy-gateway-clear-cache", action="gateway.clear_cache", target="gateway", voice="blocked", manual="blocked", risk="high", reason="Clearing gateway cache is outside AppOps Gateway V1; use explicit refresh or restart procedures after review."), + Policy(id="policy-container-restart-authentik", action="container.restart", target="authentik", voice="approval_required", manual="approval_required", risk="critical", reason="Authenticator restart is high-risk."), + Policy(id="policy-container-restart-widefrog", action="container.restart", target="widefrog", voice="allowed", manual="allowed", risk="medium", reason="Widefrog restart is a safe first-line media action."), + Policy(id="policy-unraid-mover", action="unraid.mover.start", target="unraid", voice="blocked", manual="approval_required", risk="high", reason="Mover start requires manual approval except read-only voice flows."), + Policy(id="policy-stack-redeploy", action="stack.redeploy", target="*", voice="approval_required", manual="approval_required", risk="high", reason="Stack redeploy needs approval in this safe policy."), + *_seed_voice_export_policies(), + Policy(id="policy-sonarr-search", action="sonarr.search_series", target="sonarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Sonarr lookup."), + Policy(id="policy-sonarr-add", action="sonarr.add_series", target="sonarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored series is a reversible app-level action and does not affect infrastructure."), + Policy(id="policy-sonarr-delete", action="sonarr.delete_series", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting series can remove metadata/files and is not supported by AppOps Gateway V1."), + Policy(id="policy-sonarr-mass-edit", action="sonarr.mass_edit", target="sonarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."), + Policy(id="policy-radarr-search", action="radarr.search_movie", target="radarr", voice="allowed", manual="allowed", risk="low", reason="Read-only Radarr lookup."), + Policy(id="policy-radarr-add", action="radarr.add_movie", target="radarr", voice="approval_required", manual="allowed", risk="low", reason="Adding a monitored movie is a reversible app-level action and does not affect infrastructure."), + Policy(id="policy-radarr-delete", action="radarr.delete_movie", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movies can remove metadata/files and is not supported by AppOps Gateway V1."), + Policy(id="policy-radarr-delete-files", action="radarr.delete_files", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting movie files is destructive and is blocked by AppOps Gateway V1."), + Policy(id="policy-radarr-mass-edit", action="radarr.mass_edit", target="radarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."), + Policy(id="policy-widefrog-health", action="widefrog.health", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog health check."), + Policy(id="policy-widefrog-queue", action="widefrog.queue_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog queue status."), + Policy(id="policy-widefrog-failed", action="widefrog.failed_jobs", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog failed jobs status."), + Policy(id="policy-widefrog-activity", action="widefrog.activity", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog activity status."), + Policy(id="policy-widefrog-download-status", action="widefrog.download_status", target="widefrog", voice="allowed", manual="allowed", risk="low", reason="Read-only Widefrog download status."), + Policy(id="policy-widefrog-scan-library", action="widefrog.scan_library", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Scanning the media library is a non-destructive app-level action but may create load."), + Policy(id="policy-widefrog-retry-failed", action="widefrog.retry_failed", target="widefrog", voice="approval_required", manual="allowed", risk="medium", reason="Retrying failed jobs may start downloads or processing and should be operator-controlled."), + Policy(id="policy-widefrog-delete-job", action="widefrog.delete_job", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting Widefrog jobs is not supported by AppOps Gateway V1."), + Policy(id="policy-widefrog-delete-media", action="widefrog.delete_media", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting media is blocked."), + Policy(id="policy-widefrog-clear-library", action="widefrog.clear_library", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing library data is blocked."), + Policy(id="policy-widefrog-clear-queue", action="widefrog.clear_queue", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Clearing queue data is blocked."), + Policy(id="policy-widefrog-reset-database", action="widefrog.reset_database", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Database reset is blocked."), + Policy(id="policy-widefrog-delete-downloads", action="widefrog.delete_downloads", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloads is blocked."), + Policy(id="policy-widefrog-prune", action="widefrog.prune", target="widefrog", voice="blocked", manual="blocked", risk="critical", reason="Prune actions are blocked."), + Policy(id="policy-prowlarr-health", action="prowlarr.health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr health is read-only."), + Policy(id="policy-prowlarr-indexers", action="prowlarr.indexer_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer status is read-only."), + Policy(id="policy-prowlarr-applications", action="prowlarr.application_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr app sync status is read-only."), + Policy(id="policy-prowlarr-indexer-health", action="prowlarr.indexer_health", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr indexer health is read-only."), + Policy(id="policy-prowlarr-sync-status", action="prowlarr.sync_status", target="prowlarr", voice="allowed", manual="allowed", risk="low", reason="Reading Prowlarr sync status is read-only."), + Policy(id="policy-prowlarr-search-test", action="prowlarr.search_test", target="prowlarr", voice="approval_required", manual="allowed", risk="medium", reason="Search test consumes indexer API quota and is read-only controlled access."), + Policy(id="policy-prowlarr-delete-indexer", action="prowlarr.delete_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting indexers is destructive and not supported by AppOps Gateway V1."), + Policy(id="policy-prowlarr-delete-application", action="prowlarr.delete_application", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Deleting Prowlarr applications is not supported by AppOps Gateway V1."), + Policy(id="policy-prowlarr-update-indexer", action="prowlarr.update_indexer", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating indexers is not supported by AppOps Gateway V1."), + Policy(id="policy-prowlarr-update-application", action="prowlarr.update_application", target="prowlarr", voice="blocked", manual="blocked", risk="high", reason="Updating Prowlarr applications is not supported by AppOps Gateway V1."), + Policy(id="policy-prowlarr-grab-release", action="prowlarr.grab_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release grab/download is blocked in Prowlarr Connector V1."), + Policy(id="policy-prowlarr-push-release", action="prowlarr.push_release", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Release push is blocked in Prowlarr Connector V1."), + Policy(id="policy-prowlarr-mass-edit", action="prowlarr.mass_edit", target="prowlarr", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are not supported by AppOps Gateway V1."), + Policy(id="policy-prowlarr-test-all-indexers", action="prowlarr.test_all_indexers", target="prowlarr", voice="blocked", manual="blocked", risk="medium", reason="Testing all indexers may consume API quota and is disabled in V1."), + Policy(id="policy-deluge-health", action="deluge.health", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge health is read-only."), + Policy(id="policy-deluge-session", action="deluge.session_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge session status is read-only."), + Policy(id="policy-deluge-torrents", action="deluge.torrent_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge torrent status is read-only."), + Policy(id="policy-deluge-active", action="deluge.active_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading active downloads is read-only."), + Policy(id="policy-deluge-stalled", action="deluge.stalled_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading stalled downloads is read-only."), + Policy(id="policy-deluge-paused", action="deluge.paused_downloads", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading paused downloads is read-only."), + Policy(id="policy-deluge-speeds", action="deluge.speed_status", target="deluge", voice="allowed", manual="allowed", risk="low", reason="Reading Deluge speed status is read-only."), + Policy(id="policy-deluge-pause-torrent", action="deluge.pause_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Pausing a torrent changes download state and should be operator-controlled."), + Policy(id="policy-deluge-resume-torrent", action="deluge.resume_torrent", target="deluge", voice="approval_required", manual="allowed", risk="medium", reason="Resuming a torrent changes download state and should be operator-controlled."), + Policy(id="policy-deluge-pause-all", action="deluge.pause_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Pausing all downloads affects the media pipeline."), + Policy(id="policy-deluge-resume-all", action="deluge.resume_all", target="deluge", voice="blocked", manual="approval_required", risk="high", reason="Resuming all downloads affects the media pipeline."), + Policy(id="policy-deluge-remove-torrent", action="deluge.remove_torrent", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing torrents is destructive and blocked in V1."), + Policy(id="policy-deluge-remove-data", action="deluge.remove_torrent_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Removing downloaded data is blocked."), + Policy(id="policy-deluge-delete-data", action="deluge.delete_data", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Deleting downloaded data is blocked."), + Policy(id="policy-deluge-force-recheck", action="deluge.force_recheck", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Force recheck is unsupported in V1."), + Policy(id="policy-deluge-move-storage", action="deluge.move_storage", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Moving torrent storage is unsupported in V1."), + Policy(id="policy-deluge-add-torrent", action="deluge.add_torrent", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding torrents is unsupported in V1."), + Policy(id="policy-deluge-add-magnet", action="deluge.add_magnet", target="deluge", voice="blocked", manual="blocked", risk="high", reason="Adding magnets is unsupported in V1."), + Policy(id="policy-deluge-mass-edit", action="deluge.mass_edit", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Mass edits are blocked in V1."), + Policy(id="policy-deluge-set-options", action="deluge.set_options", target="deluge", voice="blocked", manual="blocked", risk="critical", reason="Changing torrent options is blocked in V1."), + Policy(id="policy-plex-health", action="plex.health", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex health is read-only."), + Policy(id="policy-plex-server-status", action="plex.server_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server status is read-only."), + Policy(id="policy-plex-library-status", action="plex.library_status", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex library status is read-only."), + Policy(id="policy-plex-active-sessions", action="plex.active_sessions", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex active sessions is read-only."), + Policy(id="policy-plex-recent-media", action="plex.recent_media", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex recent media is read-only."), + Policy(id="policy-plex-stream-summary", action="plex.stream_summary", target="plex", voice="allowed", manual="allowed", risk="low", reason="Reading Plex stream summary is read-only."), + Policy(id="policy-plex-scan-library", action="plex.scan_library", target="plex", voice="approval_required", manual="allowed", risk="medium", reason="Scanning a Plex library can create server load and should be operator-controlled."), + Policy(id="policy-plex-scan-all", action="plex.scan_all_libraries", target="plex", voice="blocked", manual="approval_required", risk="high", reason="Scanning all Plex libraries can create significant server load."), + Policy(id="policy-plex-delete-media", action="plex.delete_media", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex media is blocked."), + Policy(id="policy-plex-delete-library", action="plex.delete_library", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Deleting Plex libraries is blocked."), + Policy(id="policy-plex-empty-trash", action="plex.empty_trash", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Empty trash can remove media references and is blocked."), + Policy(id="policy-plex-clean-bundles", action="plex.clean_bundles", target="plex", voice="blocked", manual="blocked", risk="high", reason="Cleaning bundles is blocked in V1."), + Policy(id="policy-plex-optimize-db", action="plex.optimize_database", target="plex", voice="blocked", manual="blocked", risk="high", reason="Database optimization is blocked in V1."), + Policy(id="policy-plex-kill-session", action="plex.kill_session", target="plex", voice="blocked", manual="blocked", risk="high", reason="Killing user sessions is blocked in V1."), + Policy(id="policy-plex-user-management", action="plex.user_management", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex user management is blocked."), + Policy(id="policy-plex-force-refresh-all", action="plex.force_refresh_all_metadata", target="plex", voice="blocked", manual="blocked", risk="high", reason="Force refreshing all metadata is blocked in V1."), + Policy(id="policy-plex-mass-edit", action="plex.mass_edit", target="plex", voice="blocked", manual="blocked", risk="critical", reason="Plex mass edits are blocked in V1."), + Policy(id="policy-tautulli-health", action="tautulli.health", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli health is read-only."), + Policy(id="policy-tautulli-status", action="tautulli.status", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli status is read-only."), + Policy(id="policy-tautulli-activity", action="tautulli.activity", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading current Tautulli activity is read-only."), + Policy(id="policy-tautulli-history", action="tautulli.history", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Viewing watch history may expose user activity."), + Policy(id="policy-tautulli-users", action="tautulli.users", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="User statistics may expose personal viewing behavior."), + Policy(id="policy-tautulli-libraries", action="tautulli.libraries", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Tautulli libraries is read-only."), + Policy(id="policy-tautulli-recently-added", action="tautulli.recently_added", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading recently added media is read-only."), + Policy(id="policy-tautulli-watch-stats", action="tautulli.watch_stats", target="tautulli", voice="approval_required", manual="allowed", risk="medium", reason="Watch statistics may expose personal viewing behavior."), + Policy(id="policy-tautulli-stream-summary", action="tautulli.stream_summary", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading stream summary is read-only."), + Policy(id="policy-tautulli-plex-info", action="tautulli.plex_info", target="tautulli", voice="allowed", manual="allowed", risk="low", reason="Reading Plex server info through Tautulli is read-only."), + Policy(id="policy-tautulli-terminate-session", action="tautulli.terminate_session", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Terminating sessions is blocked in V1."), + Policy(id="policy-tautulli-delete-newsletter", action="tautulli.delete_newsletter", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting newsletters is blocked in V1."), + Policy(id="policy-tautulli-delete-notification-log", action="tautulli.delete_notification_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notification logs is blocked in V1."), + Policy(id="policy-tautulli-delete-notifier", action="tautulli.delete_notifier", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting notifiers is blocked in V1."), + Policy(id="policy-tautulli-delete-recently-added", action="tautulli.delete_recently_added", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Deleting recently-added records is blocked in V1."), + Policy(id="policy-tautulli-download-config", action="tautulli.download_config", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli config is blocked in V1."), + Policy(id="policy-tautulli-download-database", action="tautulli.download_database", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli database is blocked in V1."), + Policy(id="policy-tautulli-download-export", action="tautulli.download_export", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli exports is blocked in V1."), + Policy(id="policy-tautulli-download-log", action="tautulli.download_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Tautulli logs is blocked in V1."), + Policy(id="policy-tautulli-download-plex-log", action="tautulli.download_plex_log", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Downloading Plex logs through Tautulli is blocked in V1."), + Policy(id="policy-tautulli-notify", action="tautulli.notify", target="tautulli", voice="blocked", manual="blocked", risk="high", reason="Sending notifications through Tautulli is blocked in V1."), + Policy(id="policy-tautulli-user-management", action="tautulli.user_management", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli user management is blocked in V1."), + Policy(id="policy-tautulli-mass-edit", action="tautulli.mass_edit", target="tautulli", voice="blocked", manual="blocked", risk="critical", reason="Tautulli mass edits are blocked in V1."), + Policy(id="policy-npm-health", action="npm.health", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM health is read-only."), + Policy(id="policy-npm-proxy-hosts", action="npm.proxy_hosts", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Proxy hosts may reveal external exposure."), + Policy(id="policy-npm-certificates", action="npm.certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading certificate metadata is read-only."), + Policy(id="policy-npm-expiring-certificates", action="npm.expiring_certificates", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading expiring certificate metadata is read-only."), + Policy(id="policy-npm-access-lists", action="npm.access_lists", target="nginx_proxy_manager", voice="approval_required", manual="allowed", risk="medium", reason="Access list information may reveal security structure."), + Policy(id="policy-npm-exposure-summary", action="npm.exposure_summary", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="medium", reason="Exposure summary is a read-only security overview."), + Policy(id="policy-npm-host-reachability", action="npm.host_reachability", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="V1 host reachability is metadata-only."), + Policy(id="policy-npm-security-warnings", action="npm.security_warnings", target="nginx_proxy_manager", voice="allowed", manual="allowed", risk="low", reason="Reading NPM security warnings is read-only."), + Policy(id="policy-npm-create-proxy-host", action="npm.create_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Creating proxy hosts is not supported by AppOps Gateway V1."), + Policy(id="policy-npm-update-proxy-host", action="npm.update_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Updating proxy hosts is not supported by AppOps Gateway V1."), + Policy(id="policy-npm-delete-proxy-host", action="npm.delete_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting proxy hosts is destructive and blocked."), + Policy(id="policy-npm-enable-proxy-host", action="npm.enable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Enabling proxy hosts changes external exposure and is blocked in V1."), + Policy(id="policy-npm-disable-proxy-host", action="npm.disable_proxy_host", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Disabling proxy hosts changes external exposure and is blocked in V1."), + Policy(id="policy-npm-renew-certificate", action="npm.renew_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="high", reason="Certificate renewal is not executed by AppOps Gateway V1."), + Policy(id="policy-npm-delete-certificate", action="npm.delete_certificate", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Deleting certificates is blocked."), + Policy(id="policy-npm-modify-access-list", action="npm.modify_access_list", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Access list mutations are blocked in V1."), + Policy(id="policy-npm-create-user", action="npm.create_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."), + Policy(id="policy-npm-update-user", action="npm.update_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."), + Policy(id="policy-npm-delete-user", action="npm.delete_user", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM user management is blocked."), + Policy(id="policy-npm-mass-edit", action="npm.mass_edit", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="NPM bulk mutations are blocked."), + Policy(id="policy-npm-unknown-mutation", action="npm.unknown_mutation", target="nginx_proxy_manager", voice="blocked", manual="blocked", risk="critical", reason="Unknown NPM mutations fail closed."), + Policy(id="policy-authentik-health", action="authentik.health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik health is read-only."), + Policy(id="policy-authentik-system", action="authentik.system_status", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik system status is read-only."), + Policy(id="policy-authentik-applications", action="authentik.applications", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Applications reveal SSO coverage and are operator-visible."), + Policy(id="policy-authentik-providers", action="authentik.providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Provider metadata can reveal security topology."), + Policy(id="policy-authentik-proxy-providers", action="authentik.proxy_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Proxy provider metadata can reveal exposure topology."), + Policy(id="policy-authentik-oauth2-providers", action="authentik.oauth2_providers", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="OAuth provider metadata can reveal application integrations."), + Policy(id="policy-authentik-outposts", action="authentik.outposts", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Outpost metadata is security-topology information."), + Policy(id="policy-authentik-outpost-health", action="authentik.outpost_health", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Outpost health summary is read-only."), + Policy(id="policy-authentik-flows", action="authentik.flows_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Flow summaries reveal authentication design."), + Policy(id="policy-authentik-policies", action="authentik.policies_summary", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Policy summaries reveal security controls."), + Policy(id="policy-authentik-events", action="authentik.events", target="authentik", voice="approval_required", manual="allowed", risk="medium", reason="Events may expose user activity and require operator context."), + Policy(id="policy-authentik-auth-coverage", action="authentik.auth_coverage", target="authentik", voice="allowed", manual="allowed", risk="medium", reason="Auth coverage is a read-only security overview."), + Policy(id="policy-authentik-security-warnings", action="authentik.security_warnings", target="authentik", voice="allowed", manual="allowed", risk="low", reason="Reading Authentik security warnings is read-only."), + Policy(id="policy-authentik-create-application", action="authentik.create_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."), + Policy(id="policy-authentik-update-application", action="authentik.update_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."), + Policy(id="policy-authentik-delete-application", action="authentik.delete_application", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik mutations are blocked in Connector V1."), + Policy(id="policy-authentik-create-provider", action="authentik.create_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."), + Policy(id="policy-authentik-update-provider", action="authentik.update_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."), + Policy(id="policy-authentik-delete-provider", action="authentik.delete_provider", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik provider mutations are blocked in Connector V1."), + Policy(id="policy-authentik-create-outpost", action="authentik.create_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."), + Policy(id="policy-authentik-update-outpost", action="authentik.update_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."), + Policy(id="policy-authentik-delete-outpost", action="authentik.delete_outpost", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik outpost mutations are blocked in Connector V1."), + Policy(id="policy-authentik-create-policy", action="authentik.create_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."), + Policy(id="policy-authentik-update-policy", action="authentik.update_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."), + Policy(id="policy-authentik-delete-policy", action="authentik.delete_policy", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik policy mutations are blocked in Connector V1."), + Policy(id="policy-authentik-create-user", action="authentik.create_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."), + Policy(id="policy-authentik-update-user", action="authentik.update_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."), + Policy(id="policy-authentik-delete-user", action="authentik.delete_user", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik user management is blocked."), + Policy(id="policy-authentik-create-group", action="authentik.create_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."), + Policy(id="policy-authentik-update-group", action="authentik.update_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."), + Policy(id="policy-authentik-delete-group", action="authentik.delete_group", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Authentik group management is blocked."), + Policy(id="policy-authentik-rotate-token", action="authentik.rotate_token", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Token rotation is never automated by Connector V1."), + Policy(id="policy-authentik-force-sync", action="authentik.force_sync", target="authentik", voice="blocked", manual="blocked", risk="high", reason="Force sync is a mutating operation and blocked in Connector V1."), + Policy(id="policy-authentik-mass-edit", action="authentik.mass_edit", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Bulk Authentik mutations are blocked."), + Policy(id="policy-authentik-unknown-mutation", action="authentik.unknown_mutation", target="authentik", voice="blocked", manual="blocked", risk="critical", reason="Unknown Authentik mutations fail closed."), + ] diff --git a/backend/app/db/models.py b/backend/app/db/models.py new file mode 100644 index 0000000..7dcd0d8 --- /dev/null +++ b/backend/app/db/models.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, CheckConstraint +from sqlmodel import Column, Field, SQLModel + +from ..core.time import utc_now + + +class App(SQLModel, table=True): + id: str = Field(primary_key=True) + name: str + category: str + status: str = Field(default="healthy") + criticality: str = Field(default="low") + connector_status: str = Field(default="disconnected") + container_status: str = Field(default="stopped") + voice_enabled: bool = Field(default=False) + protected: bool = Field(default=False) + last_action: str | None = None + discovery_status: str | None = None + discovery_source: str | None = None + needs_review: bool = Field(default=False) + created_at: datetime = Field(default_factory=utc_now) + + +class Connector(SQLModel, table=True): + id: str = Field(primary_key=True) + name: str + status: str = Field(default="disconnected") + last_test: str = Field(default="never") + secret_ref: str | None = None + capabilities: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + type: str = Field(default="Infrastructure") + + +class NotificationRule(SQLModel, table=True): + __tablename__ = "notification_rules" + + id: str = Field(primary_key=True) + name: str + enabled: bool = Field(default=True) + source: str + event_type: str + severity_min: str = Field(default="medium") + categories_json: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + targets_json: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + channels_json: list[str] = Field(default_factory=lambda: ["dashboard"], sa_column=Column(JSON)) + cooldown_seconds: int = Field(default=3600) + quiet_hours_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + conditions_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + dedupe_key_template: str = Field(default="{source}:{event_type}:{target}:{category}") + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class NotificationEvent(SQLModel, table=True): + __tablename__ = "notification_events" + + id: str = Field(primary_key=True) + rule_id: str + source: str + event_type: str + severity: str + title: str + message: str + category: str | None = None + target: str | None = None + dedupe_key: str + channels_json: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + status: str = Field(default="queued") + reason: str | None = None + payload_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + read: bool = Field(default=False) + created_at: datetime = Field(default_factory=utc_now) + sent_at: datetime | None = None + suppressed_until: datetime | None = None + + +class NotificationChannel(SQLModel, table=True): + __tablename__ = "notification_channels" + + id: str = Field(primary_key=True) + type: str = Field(default="dashboard") + name: str + enabled: bool = Field(default=True) + config_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + secret_refs_json: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + last_test_at: datetime | None = None + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class AppSetting(SQLModel, table=True): + __tablename__ = "app_settings" + + key: str = Field(primary_key=True) + value_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + is_secret: bool = Field(default=False) + updated_at: datetime = Field(default_factory=utc_now) + + +class Secret(SQLModel, table=True): + __tablename__ = "secrets" + + id: str = Field(primary_key=True) + name: str = Field(index=True) + encrypted_value: str + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + last_used_at: datetime | None = None + used_by_json: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + +class Policy(SQLModel, table=True): + id: str = Field(primary_key=True) + action: str + target: str + voice: str + manual: str + risk: str + reason: str | None = None + provenance_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + +class AuditEvent(SQLModel, table=True): + id: str = Field(primary_key=True) + timestamp: datetime = Field(default_factory=utc_now) + source: str + identity: str = Field(default="manual") + action_id: str + target: str + target_type: str = Field(default="app") + risk: str + policy_decision: str + reason: str | None = None + execution_plan: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + result: str = Field(default="pending") + before_state: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + after_state: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + post_checks: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + redacted_metadata: dict[str, Any] | None = Field(default=None, sa_column=Column(JSON)) + + +class Approval(SQLModel, table=True): + id: str = Field(primary_key=True) + action: str + target: str + source: str = Field(default="manual") + risk: str + status: str = Field(default="pending") + requester: str = Field(default="system") + approved_by: str | None = None + executed_by: str | None = None + action_fingerprint: str | None = None + request_context_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + reason: str | None = None + expires_at: datetime | None = None + created_at: datetime = Field(default_factory=utc_now) + decided_at: datetime | None = None + executed_at: datetime | None = None + dismissed_at: datetime | None = None + job_id: str | None = None + audit_event_id: str | None = None + + +class JobRun(SQLModel, table=True): + id: str = Field(primary_key=True) + type: str + status: str = Field(default="queued") + source: str = Field(default="manual") + action_id: str + target: str + risk: str + policy_decision: str + steps_json: str = Field(default="[]") + progress: int = Field(default=0) + started_at: datetime | None = Field(default=None) + finished_at: datetime | None = Field(default=None) + duration_ms: int | None = None + audit_event_id: str | None = None + result_summary: str | None = None + + +class DeploymentJob(SQLModel, table=True): + """Durable, redacted state for one Deployment Control Plane request. + + The plan is immutable and excludes secret values. Secrets remain in the + encrypted Secret store and are resolved only at the bounded controller + request boundary. + """ + + __tablename__ = "deployment_jobs" + + id: str = Field(primary_key=True) + application_id: str = Field(index=True) + repository: str = Field(index=True) + revision: str = Field(index=True) + plan_sha256: str = Field(index=True) + idempotency_sha256: str = Field(index=True) + status: str = Field(default="queued", index=True) + stage: str = Field(default="accepted") + plan_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + broker_request_id: str | None = Field(default=None, index=True) + result_summary: str | None = None + created_at: datetime = Field(default_factory=utc_now, index=True) + updated_at: datetime = Field(default_factory=utc_now) + completed_at: datetime | None = None + + +class WorkMeshPromotion(SQLModel, table=True): + """Durable approval and reconciliation state for fixed WorkMesh promotion.""" + + __tablename__ = "workmesh_promotions" + + id: str = Field(primary_key=True) + revision: str = Field(index=True) + preflight_sha256: str = Field(index=True) + idempotency_sha256: str = Field(index=True, unique=True) + status: str = Field(default="approval_required", index=True) + stage: str = Field(default="preflight_accepted") + preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + approval_id: str | None = Field(default=None, index=True) + broker_request_id: str | None = Field(default=None, index=True) + result_summary: str | None = None + audit_event_id: str | None = None + created_at: datetime = Field(default_factory=utc_now, index=True) + updated_at: datetime = Field(default_factory=utc_now) + completed_at: datetime | None = None + + +class Enrollment(SQLModel, table=True): + """One immutable application-management boundary established by AppOps.""" + + __tablename__ = "enrollments" + + id: str = Field(primary_key=True) + application_id: str = Field(index=True) + repository: str = Field(index=True) + revision: str = Field(index=True) + profile: str = Field(index=True) + controller_identity: str = Field(index=True) + plan_sha256: str = Field(index=True) + status: str = Field(default="planned", index=True) + target_secret_ref: str = Field(index=True) + oidc_secret_ref: str | None = Field(default=None, index=True) + plan_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + resources_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + provenance_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + last_verified_at: datetime | None = None + created_at: datetime = Field(default_factory=utc_now, index=True) + updated_at: datetime = Field(default_factory=utc_now) + completed_at: datetime | None = None + + +class EnrollmentJob(SQLModel, table=True): + """Restart-safe, redacted evidence for enrollment orchestration.""" + + __tablename__ = "enrollment_jobs" + + id: str = Field(primary_key=True) + enrollment_id: str = Field(index=True) + application_id: str = Field(index=True) + operation: str = Field(default="enroll", index=True) + idempotency_sha256: str = Field(index=True) + status: str = Field(default="queued", index=True) + stage: str = Field(default="planned") + preflight_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + evidence_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + result_summary: str | None = None + created_at: datetime = Field(default_factory=utc_now, index=True) + updated_at: datetime = Field(default_factory=utc_now) + completed_at: datetime | None = None + + +class RepoPulseAdoptionJournal(SQLModel, table=True): + """One durable protocol intent for the fixed RepoPulse legacy adoption.""" + + __tablename__ = "repopulse_adoption_journal" + __table_args__ = ( + CheckConstraint( + "slot = 'repopulse' AND repository = 'jens/repopulse' " + "AND application_id = 'repopulse'", + name="ck_repopulse_adoption_fixed_identity", + ), + ) + + slot: str = Field(default="repopulse", primary_key=True) + repository: str + application_id: str + intent_id: str = Field(index=True) + idempotency_sha256: str + preflight_sha256: str + phase: str + status: str + revision: int = Field(default=1, ge=1) + intent_json: dict[str, Any] = Field(sa_column=Column(JSON, nullable=False)) + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class HealthCheck(SQLModel, table=True): + id: str = Field(primary_key=True) + service: str + status: str + checked_at: datetime = Field(default_factory=utc_now) + details: str | None = None + + +class AiMemoryItem(SQLModel, table=True): + id: str = Field(primary_key=True) + key: str + value: str + category: str = Field(default="app_note") + pinned: bool = Field(default=False) + updated_at: datetime = Field(default_factory=utc_now) + + +class AiRecommendation(SQLModel, table=True): + id: str = Field(primary_key=True) + action: str + title: str + reasoning: str + target: str + severity: str + suggested_actions: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + + +class KnowledgeChunk(SQLModel, table=True): + __tablename__ = "knowledge_chunks" + + id: str = Field(primary_key=True) + source_uri: str = Field(index=True) + source_type: str = Field(default="documentation", index=True) + title: str + section: str | None = None + content: str + content_hash: str = Field(index=True) + updated_at: datetime = Field(default_factory=utc_now) + + +class DiscoveryRun(SQLModel, table=True): + __tablename__ = "discovery_runs" + + id: str = Field(primary_key=True) + source: str = Field(default="portainer") + started_at: datetime = Field(default_factory=utc_now, index=True) + finished_at: datetime | None = None + status: str = Field(default="running") + containers_found: int = Field(default=0) + apps_matched: int = Field(default=0) + new_items: int = Field(default=0) + changed_items: int = Field(default=0) + errors_json: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON)) + + +class DiscoveredContainer(SQLModel, table=True): + __tablename__ = "discovered_containers" + + id: str = Field(primary_key=True) + discovery_run_id: str = Field(index=True) + container_id: str = Field(index=True) + name: str + image: str + state: str = Field(default="unknown") + status: str = Field(default="unknown") + ports_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON)) + mounts_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON)) + networks_json: list[Any] = Field(default_factory=list, sa_column=Column(JSON)) + labels_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + suggested_app_type: str = Field(default="unknown") + suggested_category: str = Field(default="unknown") + suggested_criticality: str = Field(default="low") + suggested_connector: str = Field(default="none") + suggested_policy_profile: str = Field(default="default") + import_status: str = Field(default="discovered") + matched_app_id: str | None = None + raw_metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + + +class AppContainerMapping(SQLModel, table=True): + __tablename__ = "app_container_mappings" + + id: str = Field(primary_key=True) + app_id: str = Field(index=True) + container_id: str = Field(index=True) + container_name: str + role: str = Field(default="primary") + source: str = Field(default="discovery") + is_primary: bool = Field(default=True) + last_seen_run_id: str | None = None + last_seen_at: datetime = Field(default_factory=utc_now) diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..f8c3155 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,44 @@ +from pathlib import Path + +from sqlalchemy import event +from sqlalchemy.engine import Engine +from sqlmodel import create_engine, Session + +from ..core.config import settings + + +def _configure_sqlite_connection(dbapi_connection, _connection_record) -> None: + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA busy_timeout=30000") + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + finally: + cursor.close() + + +def create_app_engine(db_path: Path | str) -> Engine: + resolved_path = Path(db_path) + if resolved_path.parent != Path('.'): + resolved_path.parent.mkdir(parents=True, exist_ok=True) + + app_engine = create_engine( + f"sqlite:///{resolved_path}", + echo=False, + connect_args={"check_same_thread": False, "timeout": 30}, + pool_size=20, + max_overflow=20, + pool_timeout=10, + ) + event.listen(app_engine, "connect", _configure_sqlite_connection) + return app_engine + + +_db_path = Path(settings.db_path) +DATABASE_URL = f"sqlite:///{_db_path}" +engine = create_app_engine(_db_path) + + +def get_session(): + with Session(engine) as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2b95bd9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from collections import defaultdict, deque +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from time import monotonic +from uuid import uuid4 + +from fastapi import Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from sqlmodel import Session +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from .api.routes_actions import router as actions_router +from .api.routes_ai import router as ai_router +from .api.routes_ai_remediation import router as ai_remediation_router +from .api.routes_ai_security import router as ai_security_router +from .api.routes_approvals import router as approvals_router +from .api.routes_apps import router as apps_router +from .api.routes_audit import router as audit_router +from .api.routes_authentik import router as authentik_router +from .api.routes_connectors import router as connectors_router +from .api.routes_containers import router as containers_router +from .api.routes_deluge import router as deluge_router +from .api.routes_deployments import router as deployments_router +from .api.routes_enrollments import router as enrollments_router +from .api.routes_gitea_actions import router as gitea_actions_router +from .api.routes_health import router as health_router +from .api.routes_integrations import n8n_router +from .api.routes_integrations import router as integrations_router +from .api.routes_inventory import router as inventory_router +from .api.routes_jobs import router as jobs_router +from .api.routes_knowledge import router as knowledge_router +from .api.routes_media_pipeline import router as media_pipeline_router +from .api.routes_nginx_proxy_manager import alias_router as npm_alias_router +from .api.routes_nginx_proxy_manager import router as npm_router +from .api.routes_notifications import router as notifications_router +from .api.routes_observability import router as observability_router +from .api.routes_playbooks import router as playbooks_router +from .api.routes_plex import router as plex_router +from .api.routes_policies import router as policies_router +from .api.routes_prowlarr import router as prowlarr_router +from .api.routes_privatemuse_artifacts import router as privatemuse_artifacts_router +from .api.routes_radarr import router as radarr_router +from .api.routes_runtime import router as runtime_router +from .api.routes_safety_center import router as safety_center_router +from .api.routes_setup import router as setup_router +from .api.routes_sonarr import router as sonarr_router +from .api.routes_stacks import router as stacks_router +from .api.routes_system import router as system_router +from .api.routes_tautulli import router as tautulli_router +from .api.routes_unraid import router as unraid_router +from .api.routes_voice import router as voice_router +from .api.routes_voice_operator import router as voice_operator_router +from .api.routes_widefrog import router as widefrog_router +from .core.config import settings +from .core.security import require_operator_request, require_service_request, voice_auth +from .core.version import APP_VERSION +from .db import session as db_session +from .db.init_db import init_db +from .mcp_server import ( + appops_mcp, + mcp_http_app, + restore_tool_metrics, + set_session_running, +) +from .services import knowledge_service +from .services.discovery_scheduler import run_periodic_discovery +from .services.maintenance_scheduler import ( + persist_observability, + run_periodic_maintenance, +) +from .services.request_metrics import record_request, restore_request_metrics + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + try: + with Session(db_session.engine) as session: + restore_request_metrics(session) + restore_tool_metrics(session) + knowledge_service.ensure_index_current(session) + except Exception: # noqa: BLE001 - RAG must degrade without taking down the control plane. + logger.exception("Knowledge index startup refresh failed") + # A zero interval is an explicit opt-out. In particular, development and + # test instances must not run background reconciliation merely because the + # ASGI lifespan has started. + discovery_task = ( + asyncio.create_task(run_periodic_discovery(settings.discovery_interval_seconds)) + if settings.discovery_interval_seconds > 0 + else None + ) + maintenance_enabled = any( + ( + settings.notification_interval_seconds, + settings.connector_health_interval_seconds, + settings.observability_persist_interval_seconds, + settings.restore_drill_interval_seconds, + settings.mcp_canary_interval_seconds if settings.mcp_enabled else 0, + ) + ) + maintenance_task = asyncio.create_task(run_periodic_maintenance()) if maintenance_enabled else None + async with AsyncExitStack() as stack: + if settings.mcp_enabled: + await stack.enter_async_context(appops_mcp.session_manager.run()) + set_session_running(True) + try: + yield + finally: + set_session_running(False) + if discovery_task: + discovery_task.cancel() + if maintenance_task: + maintenance_task.cancel() + if discovery_task: + try: + await discovery_task + except asyncio.CancelledError: + pass + if maintenance_task: + try: + await maintenance_task + except asyncio.CancelledError: + pass + await asyncio.to_thread(persist_observability) + + +app = FastAPI(title=settings.app_title, version=APP_VERSION, lifespan=lifespan) + +_OPERATOR_GET_PREFIXES = ( + "/api/admin/", + "/api/operator/", + "/api/settings/secrets", +) +_MUTATION_WINDOWS: dict[str, deque[float]] = defaultdict(deque) +_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} +_MAX_MACHINE_BODY_BYTES = 1_048_576 +_REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{8,64}$") + + +def _consume_mutation_budget(identity: str) -> bool: + now = monotonic() + window = _MUTATION_WINDOWS[identity] + while window and now - window[0] >= 60: + window.popleft() + if len(window) >= settings.mutation_rate_limit_per_minute: + return False + window.append(now) + return True + + +class MachineBodyLimitMiddleware: + """Apply a real streaming cap before machine-interface body parsing.""" + + def __init__(self, app: ASGIApp, max_body_bytes: int = _MAX_MACHINE_BODY_BYTES): + self.app = app + self.max_body_bytes = max_body_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + path = str(scope.get("path", "")) + method = str(scope.get("method", "GET")).upper() + is_machine_post = method not in _SAFE_METHODS and ( + path == "/mcp" or path.startswith("/mcp/") + or path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/") + ) + if scope["type"] != "http" or not is_machine_post: + await self.app(scope, receive, send) + return + + body = bytearray() + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + chunk = message.get("body", b"") + body.extend(chunk) + if len(body) > self.max_body_bytes: + response = JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"}) + await response(scope, receive, send) + return + if not message.get("more_body", False): + break + + replayed = False + + async def replay_receive() -> Message: + nonlocal replayed + if replayed: + return {"type": "http.disconnect"} + replayed = True + return {"type": "http.request", "body": bytes(body), "more_body": False} + + await self.app(scope, replay_receive, send) + + +@app.middleware("http") +async def private_operator_boundary(request, call_next): + request_started = monotonic() + supplied_request_id = request.headers.get("X-Request-ID", "") + request_id = supplied_request_id if _REQUEST_ID_PATTERN.fullmatch(supplied_request_id) else uuid4().hex + path = request.url.path + + def finalize_response(response): + route = request.scope.get("route") + route_path = getattr(route, "path", path) + duration_ms = (monotonic() - request_started) * 1000 + record_request(method=request.method, path=str(route_path), status=response.status_code, duration_ms=duration_ms) + response.headers.setdefault("X-Request-ID", request_id) + response.headers.setdefault("Server-Timing", f"app;dur={duration_ms:.2f}") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; object-src 'none'; " + "img-src 'self' data:; font-src 'self'; style-src 'self' 'unsafe-inline'; " + "script-src 'self'; connect-src 'self' http: https: ws: wss:", + ) + return response + + is_api = path == "/api" or path.startswith("/api/") + is_voice = path.startswith("/api/voice/") + is_mcp = path == "/mcp" or path.startswith("/mcp/") + is_n8n = path == "/api/integrations/n8n" or path.startswith("/api/integrations/n8n/") + is_operator_session_status = path == "/api/operator/session" and request.method in _SAFE_METHODS + if is_mcp or is_n8n: + try: + require_service_request(request, "mcp" if is_mcp else "n8n") + except HTTPException as exc: + return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})) + if is_mcp or is_n8n: + content_length = request.headers.get("content-length") + if content_length and content_length.isdigit() and int(content_length) > 1_048_576: + return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"})) + needs_operator = is_api and not is_voice and not is_n8n and not is_operator_session_status and ( + request.method not in _SAFE_METHODS + or any(path.startswith(prefix) for prefix in _OPERATOR_GET_PREFIXES) + ) + if needs_operator: + content_length = request.headers.get("content-length") + if content_length and content_length.isdigit() and int(content_length) > 1_048_576: + return finalize_response(JSONResponse(status_code=413, content={"detail": "Request body exceeds 1 MiB limit"})) + try: + require_operator_request(request) + except HTTPException as exc: + return finalize_response(JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})) + is_read_only_n8n_query = is_n8n and path == "/api/integrations/n8n/knowledge/query" + should_rate_limit = request.method not in _SAFE_METHODS and not is_read_only_n8n_query and ( + is_n8n or (needs_operator and settings.operator_auth_required) + ) + if should_rate_limit: + client_host = request.client.host if request.client else "unknown" + identity = f"n8n:{client_host}" if is_n8n else f"operator:{client_host}" + if not _consume_mutation_budget(identity): + return finalize_response(JSONResponse( + status_code=429, + content={"detail": "Mutation rate limit exceeded"}, + headers={"Retry-After": "60"}, + )) + response = await call_next(request) + return finalize_response(response) + +app.add_middleware( + CORSMiddleware, + allow_origins=[settings.frontend_origin, "http://localhost:5173", "http://127.0.0.1:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(MachineBodyLimitMiddleware) + +app.include_router(health_router) +app.include_router(system_router) +app.include_router(apps_router) +app.include_router(containers_router) +app.include_router(stacks_router) +app.include_router(unraid_router) +app.include_router(connectors_router) +app.include_router(inventory_router) +app.include_router(gitea_actions_router) +app.include_router(playbooks_router) +app.include_router(policies_router) +app.include_router(sonarr_router) +app.include_router(radarr_router) +app.include_router(prowlarr_router) +app.include_router(privatemuse_artifacts_router) +app.include_router(deluge_router) +app.include_router(plex_router) +app.include_router(tautulli_router) +app.include_router(media_pipeline_router) +app.include_router(authentik_router) +app.include_router(npm_router) +app.include_router(npm_alias_router) +app.include_router(widefrog_router) +app.include_router(actions_router) +app.include_router(approvals_router) +app.include_router(audit_router) +app.include_router(jobs_router) +app.include_router(observability_router) +app.include_router(safety_center_router) +app.include_router(notifications_router) +app.include_router(voice_router, dependencies=[Depends(voice_auth)]) +app.include_router(voice_operator_router) +app.include_router(ai_router) +app.include_router(ai_security_router) +app.include_router(ai_remediation_router) +app.include_router(setup_router) +app.include_router(runtime_router) +app.include_router(deployments_router) +app.include_router(enrollments_router) +app.include_router(integrations_router) +app.include_router(n8n_router) +app.include_router(knowledge_router) +app.mount("/mcp", mcp_http_app) + + +@app.get('/api') +def index(): + return {'name': 'unraid-appops-gateway', 'status': 'ok'} + + +def _static_root() -> Path: + root = Path(settings.static_dir) + if not root.is_absolute(): + root = Path.cwd() / root + return root + + +static_root = _static_root() +assets_root = static_root / 'assets' +if assets_root.exists(): + app.mount('/assets', StaticFiles(directory=str(assets_root)), name='assets') + + +def _index_response(index_file: Path) -> FileResponse: + return FileResponse( + index_file, + headers={ + 'Cache-Control': 'no-store', + }, + ) + + +@app.get('/{full_path:path}', include_in_schema=False) +def serve_frontend(full_path: str): + if full_path.startswith('api/'): + raise HTTPException(status_code=404, detail='api_route_not_found') + index_file = static_root / 'index.html' + requested = static_root / full_path + if full_path and requested.exists() and requested.is_file(): + return FileResponse(requested) + if index_file.exists(): + return _index_response(index_file) + return {'name': 'unraid-appops-gateway', 'status': 'api_only', 'frontend': 'not_built'} diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py new file mode 100644 index 0000000..f6f1e1f --- /dev/null +++ b/backend/app/mcp_server.py @@ -0,0 +1,839 @@ +from __future__ import annotations + +import hashlib +import json +import os +from collections import defaultdict +from datetime import UTC, datetime +from functools import wraps +from pathlib import Path +from threading import Lock +from time import perf_counter +from typing import Any + +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings +from mcp.types import ToolAnnotations +from sqlmodel import Session, select + +from .api.routes_actions import action_registry as build_action_registry +from .api.routes_system import system_summary as build_system_summary +from .connectors.gitea_actions import GiteaActionsConnector +from .core.config import settings +from .core.redaction import redact_message, redact_value +from .core.time import utc_now +from .core.version import APP_VERSION +from .db.models import AppSetting, AuditEvent +from .db.session import engine +from .schemas.deployment import DeploymentPlanV1 +from .schemas.enrollment import EnrollmentPlanV1 +from .services import knowledge_service, mcp_operations, safety_center_service +from .services.action_execution import mcp_execution_supported +from .services.action_registry import ACTION_RISK, ACTIONS, action_capability +from .services.app_registry import list_active_apps +from .services.audit_logger import log_event +from .services.deployment_broker import DeploymentBrokerClient +from .services.deployment_control_plane import ( + DeploymentControlError, + DeploymentControlPlane, +) +from .services.enrollment_plane import EnrollmentError, EnrollmentPlane +from .services.gitea_project_auto_registration import GiteaProjectAutoRegistration +from .services.inventory_discovery import list_inventory_containers +from .services.policy_engine import evaluate_policy +from .services.privatemuse_artifact_installer import ArtifactInstallError +from .services.privatemuse_artifact_installer import install as install_artifact_file +from .services.privatemuse_artifact_installer import preflight as artifact_preflight +from .services.runtime_service import runtime_info + +_SESSION_RUNNING = False +_SESSION_VERIFIED_AT: str | None = None +_TOOL_METRICS: dict[str, dict[str, float | int | str | None]] = defaultdict( + lambda: { + "calls": 0, + "errors": 0, + "totalMs": 0.0, + "maxMs": 0.0, + "lastCalledAt": None, + } +) +_TOOL_METRICS_LOCK = Lock() +_TOOL_METRICS_SETTING = "observability:mcp_tool_metrics" +_CANARY_STATUS: dict[str, Any] = { + "status": "never", + "lastCheckedAt": None, + "durationMs": None, +} +_MCP_EXECUTION_LOCK = Lock() +_ARTIFACT_INSTALL_LOCK = Lock() +_SELF_UPGRADE_LOCK = Lock() + + +def _artifact_roots() -> tuple[Path, Path]: + return (Path(os.environ.get("APPOPS_PRIVATEMUSE_COMFYUI_MODEL_ROOT", "/mnt/privatemuse-comfyui-models")), + Path(os.environ.get("APPOPS_PRIVATEMUSE_ARTIFACT_RECEIPT_ROOT", "/data/privatemuse-artifact-receipts"))) + +_READ_ONLY_TOOL = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) +_OPERATION_RUN_TOOL = ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, +) +_LEGACY_APPROVAL_EXECUTION_TOOL = ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, +) +_CANCEL_TOOL = ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) + + +def _tracked(tool_name: str): + def decorate(function): + @wraps(function) + def wrapped(*args, **kwargs): + started = perf_counter() + failed = False + try: + return function(*args, **kwargs) + except Exception: + failed = True + raise + finally: + duration_ms = (perf_counter() - started) * 1000 + with _TOOL_METRICS_LOCK: + metrics = _TOOL_METRICS[tool_name] + metrics["calls"] = int(metrics["calls"]) + 1 + metrics["errors"] = int(metrics["errors"]) + int(failed) + metrics["totalMs"] = float(metrics["totalMs"]) + duration_ms + metrics["maxMs"] = max(float(metrics["maxMs"]), duration_ms) + metrics["lastCalledAt"] = datetime.now(UTC).isoformat() + + return wrapped + + return decorate + + +def set_session_running(value: bool) -> None: + global _SESSION_RUNNING, _SESSION_VERIFIED_AT + _SESSION_RUNNING = value + _SESSION_VERIFIED_AT = datetime.now(UTC).isoformat() if value else None + + +def runtime_status() -> dict[str, Any]: + with _TOOL_METRICS_LOCK: + tool_metrics = { + name: { + "calls": int(values["calls"]), + "errors": int(values["errors"]), + "averageMs": round( + float(values["totalMs"]) / max(1, int(values["calls"])), 2 + ), + "maxMs": round(float(values["maxMs"]), 2), + "lastCalledAt": values["lastCalledAt"], + } + for name, values in _TOOL_METRICS.items() + } + return { + "state": "listening" + if settings.mcp_enabled and _SESSION_RUNNING + else "configured" + if settings.mcp_enabled + else "disabled", + "lastVerifiedAt": _SESSION_VERIFIED_AT, + "toolCount": 25, + "toolMetrics": tool_metrics, + "canary": dict(_CANARY_STATUS), + } + + +def restore_tool_metrics(session: Session) -> int: + row = session.get(AppSetting, _TOOL_METRICS_SETTING) + payload = (row.value_json or {}).get("tools", {}) if row else {} + restored = 0 + with _TOOL_METRICS_LOCK: + if _TOOL_METRICS: + return 0 + for name, values in payload.items(): + if not isinstance(values, dict): + continue + _TOOL_METRICS[str(name)[:120]] = { + "calls": max(0, int(values.get("calls", 0))), + "errors": max(0, int(values.get("errors", 0))), + "totalMs": max(0.0, float(values.get("totalMs", 0.0))), + "maxMs": max(0.0, float(values.get("maxMs", 0.0))), + "lastCalledAt": values.get("lastCalledAt"), + } + restored += 1 + return restored + + +def persist_tool_metrics(session: Session) -> int: + with _TOOL_METRICS_LOCK: + tools = {name: dict(values) for name, values in _TOOL_METRICS.items()} + row = session.get(AppSetting, _TOOL_METRICS_SETTING) or AppSetting( + key=_TOOL_METRICS_SETTING + ) + row.value_json = {"tools": tools, "persistedAt": datetime.now(UTC).isoformat()} + row.is_secret = False + row.updated_at = utc_now() + session.add(row) + session.commit() + return len(tools) + + +def run_read_only_canary() -> dict[str, Any]: + started = perf_counter() + try: + result = list_apps(limit=1) + status = "ok" if isinstance(result, dict) and "items" in result else "failed" + except Exception: # noqa: BLE001 - a canary must degrade safely on any read failure + status = "failed" + _CANARY_STATUS.update( + { + "status": status, + "lastCheckedAt": datetime.now(UTC).isoformat(), + "durationMs": round((perf_counter() - started) * 1000, 2), + } + ) + return dict(_CANARY_STATUS) + + +appops_mcp = MCPServer( + "Unraid AppOps Gateway", + version=APP_VERSION, + instructions=( + "Private, governed AppOps context. Use read-only tools for inspection. " + "Authenticated MCP mutations are evaluated against central AppOps policy and execute immediately " + "through an allowlisted typed executor when possible. MCP never asks the operator for a human approval: " + "blocked actions stay blocked and actions without a typed executor fail closed. Never invent actions, " + "targets, policy state or execution results. Legacy approval status/execute tools exist only for approval " + "records created before this no-human-approval MCP contract." + ), +) + + +@appops_mcp.tool(name="gitea.actions.runners.status", annotations=_READ_ONLY_TOOL) +@_tracked("gitea.actions.runners.status") +def gitea_actions_runners_status() -> dict[str, Any]: + """Inspect Gitea Actions runners and queue age; this never restarts a runner.""" + + return _json(GiteaActionsConnector().snapshot()) + + +def _json(value: Any) -> Any: + if hasattr(value, "model_dump"): + return _json(value.model_dump(mode="json")) + if isinstance(value, list): + return [_json(item) for item in value] + if isinstance(value, dict): + return redact_value({str(key): _json(item) for key, item in value.items()}) + return redact_value(value) + + +@appops_mcp.tool(name="gitea.projects.catalog", annotations=_READ_ONLY_TOOL) +@_tracked("gitea.projects.catalog") +def gitea_projects_catalog() -> dict[str, Any]: + """List the persisted owned Gitea project catalog and deployment registration state.""" + + with Session(engine) as session: + return _json(GiteaProjectAutoRegistration.catalog(session)) + + +@appops_mcp.tool(name="gitea.projects.reconcile", annotations=_OPERATION_RUN_TOOL) +@_tracked("gitea.projects.reconcile") +def gitea_projects_reconcile() -> dict[str, Any]: + """Reconcile all owned Gitea projects through the bounded registration plane.""" + + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + with Session(engine) as session: + return _json(GiteaProjectAutoRegistration().sync(session, force=True)) + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("get_system_summary") +def get_system_summary() -> dict[str, Any]: + """Get the current AppOps, Unraid and Portainer summary without changing state.""" + with Session(engine) as session: + return _json(build_system_summary(session)) + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("list_apps") +def list_apps(limit: int = 100) -> dict[str, Any]: + """List managed applications and their current policy-safe metadata.""" + with Session(engine) as session: + items = list_active_apps(session)[: max(1, min(limit, 250))] + return {"items": [_json(item) for item in items], "count": len(items)} + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("list_containers") +def list_containers(limit: int = 100) -> dict[str, Any]: + """List the latest inventory container view; no Docker socket or shell access is used.""" + with Session(engine) as session: + items = list_inventory_containers(session, include_ignored=False)[ + : max(1, min(limit, 250)) + ] + return {"items": _json(items), "count": len(items)} + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("get_safety_findings") +def get_safety_findings() -> dict[str, Any]: + """Get current safety summary and redacted findings.""" + with Session(engine) as session: + return { + "summary": _json(safety_center_service.get_summary(session)), + "findings": _json(safety_center_service.get_findings(session)), + } + + +def _mcp_action_registry(session: Session) -> dict[str, Any]: + """Add the MCP execution posture without changing manual/voice registry semantics.""" + + registry = build_action_registry(session) + direct = 0 + blocked_mutations = 0 + read_only = 0 + items: list[dict[str, Any]] = [] + for raw in registry.get("items", []): + item = dict(raw) + action = str(item.get("id") or "") + target = str(item.get("target") or "") + capability = str(item.get("capability") or "operator") + mcp_policy = evaluate_policy( + session, action=action, target=target, source="automation" + ) + original_decision = str(mcp_policy.get("decision") or "blocked") + + if capability == "read_only": + mcp_mode = "read_only" + mcp_supported = False + mcp_decision = original_decision + read_only += 1 + elif original_decision == "blocked": + mcp_mode = "blocked" + mcp_supported = False + mcp_decision = "blocked" + blocked_mutations += 1 + elif mcp_execution_supported(action): + mcp_mode = "direct_typed" + mcp_supported = True + mcp_decision = "allowed" + direct += 1 + else: + mcp_mode = "blocked_no_typed_executor" + mcp_supported = False + mcp_decision = "blocked" + blocked_mutations += 1 + + items.append( + { + **item, + "manualPolicyDecision": item.get("policyDecision"), + "manualExecutionMode": item.get("executionMode"), + "mcpOriginalPolicyDecision": original_decision, + "mcpPolicyDecision": mcp_decision, + "mcpExecutionMode": mcp_mode, + "mcpExecutionSupported": mcp_supported, + "mcpApprovalRequired": False, + } + ) + + summary = { + **dict(registry.get("summary", {})), + "mcpDirectExecutable": direct, + "mcpBlockedMutations": blocked_mutations, + "mcpReadOnly": read_only, + "mcpApprovalRequired": 0, + } + return {"summary": summary, "items": items} + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("get_action_registry") +def get_action_registry() -> dict[str, Any]: + """List typed AppOps actions plus the no-human-approval MCP execution posture.""" + with Session(engine) as session: + return _json(_mcp_action_registry(session)) + + +def _mcp_preview_mode(action: str, decision: str) -> tuple[str, bool]: + capability = action_capability(action) + if capability == "read_only": + return "read_only", False + if decision == "blocked": + return "blocked", False + if mcp_execution_supported(action): + return "direct_typed", True + return "blocked_no_typed_executor", False + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("preview_action") +def preview_action(action: str, target: str) -> dict[str, Any]: + """Preview policy for an action. This never executes anything or creates an approval.""" + safe_action = action[:160] + with Session(engine) as session: + decision = evaluate_policy( + session, action=safe_action, target=target[:240], source="automation" + ) + mcp_mode, mcp_supported = _mcp_preview_mode( + safe_action, str(decision["decision"]) + ) + return { + "action": safe_action, + "target": target[:240], + "knownAction": safe_action in ACTIONS, + "risk": decision.get("risk", ACTION_RISK.get(safe_action, "critical")), + "policyDecision": decision["decision"], + "reason": redact_message(str(decision["reason"])), + "executed": False, + "mcpApprovalRequired": False, + "mcpExecutionSupported": mcp_supported, + "mcpExecutionMode": mcp_mode, + } + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("search_audit") +def search_audit(query: str = "", limit: int = 30) -> dict[str, Any]: + """Search recent redacted audit metadata.""" + needle = query.casefold().strip()[:200] + with Session(engine) as session: + rows = session.exec( + select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(500) + ).all() + matches = [] + for row in rows: + value = f"{row.action_id} {row.target} {row.source} {row.result} {row.reason or ''}".casefold() + if needle and needle not in value: + continue + matches.append( + { + "id": row.id, + "timestamp": row.timestamp.isoformat(), + "actionId": row.action_id, + "target": row.target, + "source": row.source, + "risk": row.risk, + "policyDecision": row.policy_decision, + "result": row.result, + "reason": redact_message(str(row.reason or "")), + } + ) + if len(matches) >= max(1, min(limit, 100)): + break + return _json({"items": matches, "count": len(matches)}) + + +@appops_mcp.tool(annotations=_READ_ONLY_TOOL) +@_tracked("query_knowledge") +def query_knowledge(question: str, limit: int = 6) -> dict[str, Any]: + """Retrieve grounded AppOps documentation and live context with citations.""" + with Session(engine) as session: + return _json( + knowledge_service.query(session, question, limit=max(1, min(limit, 10))) + ) + + +@appops_mcp.tool(name="server.operation.request", annotations=_OPERATION_RUN_TOOL) +@_tracked("server.operation.request") +def request_operation( + action: str, + target: str, + reason: str, + idempotencyKey: str, +) -> dict[str, Any]: + """Run an exact governed server operation immediately when policy permits; MCP never creates a human approval.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + with Session(engine) as session: + return _json( + mcp_operations.request_operation( + session, + action=action, + target=target, + reason=reason, + idempotency_key=idempotencyKey, + ) + ) + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.tool(name="privatemuse.artifact.install.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("privatemuse.artifact.install.preflight") +def privatemuse_artifact_install_preflight( + artifact_type: str, source_url: str, sha256: str, filename: str, + source_id: str, license: str, reason: str, +) -> dict[str, Any]: + """Check one pinned ComfyUI artifact against the fixed persistent model mount.""" + payload = {"artifact_type": artifact_type, "source_url": source_url, "sha256": sha256, + "filename": filename, "source_id": source_id, "license": license} + if not reason.strip() or len(reason) > 1000: + raise ArtifactInstallError("a bounded reason is required") + model_root, _ = _artifact_roots() + return _json({**artifact_preflight(payload, model_root=model_root), "executed": False}) + + +@appops_mcp.tool(name="privatemuse.artifact.install", annotations=_OPERATION_RUN_TOOL) +@_tracked("privatemuse.artifact.install") +def privatemuse_artifact_install( + artifact_type: str, source_url: str, sha256: str, filename: str, + source_id: str, license: str, reason: str, expected_preflight_fingerprint: str, + idempotency_key: str, +) -> dict[str, Any]: + """Install one immutable SHA-pinned artifact into its server-selected ComfyUI directory.""" + if not _ARTIFACT_INSTALL_LOCK.acquire(blocking=False): + raise ArtifactInstallError("artifact installation already active") + try: + if not reason.strip() or len(reason) > 1000 or not idempotency_key.strip() or len(idempotency_key) > 200: + raise ArtifactInstallError("bounded reason and idempotency key are required") + payload = {"artifact_type": artifact_type, "source_url": source_url, "sha256": sha256, + "filename": filename, "source_id": source_id, "license": license} + model_root, receipt_root = _artifact_roots() + key_hash = hashlib.sha256(idempotency_key.encode()).hexdigest() + request_fingerprint = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest() + request_dir = receipt_root / "mcp-requests" + request_path = request_dir / f"{key_hash}.json" + if request_path.exists(): + previous = json.loads(request_path.read_text(encoding="utf-8")) + if previous.get("request_fingerprint") != request_fingerprint: + raise ArtifactInstallError("idempotency key already used for different artifact") + return _json(previous["result"]) + checked = artifact_preflight(payload, model_root=model_root) + if checked["fingerprint"] != expected_preflight_fingerprint: + raise ArtifactInstallError("preflight changed; run preflight again") + result = install_artifact_file(payload, model_root=model_root, receipt_root=receipt_root) + with Session(engine) as session: + audit = log_event(session, source="action", identity="mcp", action_id="privatemuse.comfyui.install_artifact", + target="privatemuse/comfyui", risk="high", policy_decision="typed_contract_allowed", + reason=reason, result="success", after_state={"status": result["status"], "sha256": result["sha256"], "bytes": result["bytes"]}, + redacted_metadata={"filename": filename, "source_id": source_id}) + result["auditId"] = audit.id + request_dir.mkdir(parents=True, exist_ok=True) + temp_path = request_dir / f".{key_hash}.{os.getpid()}.tmp" + with temp_path.open("w", encoding="utf-8") as handle: + json.dump({"request_fingerprint": request_fingerprint, "result": result}, handle, sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, request_path) + return _json(result) + finally: + _ARTIFACT_INSTALL_LOCK.release() + + +@appops_mcp.tool(name="server.operation.status", annotations=_READ_ONLY_TOOL) +@_tracked("server.operation.status") +def operation_status(approvalId: str) -> dict[str, Any]: + """Legacy: read an approval record created before MCP no-human-approval mode.""" + with Session(engine) as session: + return _json(mcp_operations.operation_status(session, approvalId)) + + +@appops_mcp.tool(name="appops.deployment.status", annotations=_READ_ONLY_TOOL) +@_tracked("appops.deployment.status") +def appops_deployment_status() -> dict[str, Any]: + """Report the exact OCI source revision baked into this AppOps image.""" + with Session(engine) as session: + info = runtime_info(session) + return _json({"repository": info["sourceRepository"], "revision": info["sourceRevision"], + "sourceTree": info["sourceTree"], "health": "ok" if info["dbStatus"] == "ok" and info["liveReady"] else "degraded"}) + + +@appops_mcp.tool(name="appops.self_upgrade.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("appops.self_upgrade.preflight") +def appops_self_upgrade_preflight( + repository: str, revision: str, reason: str, + expected_current_revision: str | None = None, +) -> dict[str, Any]: + """Check a fixed protected-main AppOps revision in the private controller.""" + if repository != "jens/unraid-appops-gateway": + raise ValueError("repository must be jens/unraid-appops-gateway") + return _json(DeploymentBrokerClient().appops_self_upgrade_preflight( + revision, reason, expected_current_revision, + )) + + +@appops_mcp.tool(name="appops.self_upgrade.apply", annotations=_OPERATION_RUN_TOOL) +@_tracked("appops.self_upgrade.apply") +def appops_self_upgrade_apply( + repository: str, revision: str, reason: str, + expected_preflight_fingerprint: str, idempotency_key: str, + expected_current_revision: str | None = None, +) -> dict[str, Any]: + """Queue one exact revision through the controller's validated single-instance executor.""" + if repository != "jens/unraid-appops-gateway": + raise ValueError("repository must be jens/unraid-appops-gateway") + if not _SELF_UPGRADE_LOCK.acquire(blocking=False): + raise RuntimeError("appops self-upgrade already active") + try: + with Session(engine) as session: + audit = log_event( + session, source="action", identity="mcp", action_id="appops.self_upgrade", + target="Unraid-AppOps-Gateway", risk="high", + policy_decision="typed_contract_requested", + reason="Typed self-upgrade reason SHA-256 " + hashlib.sha256(reason.encode("utf-8")).hexdigest(), + result="requested", + after_state={"revision": revision}, + redacted_metadata={"repository": repository}, + ) + result = DeploymentBrokerClient().appops_self_upgrade_apply( + revision, reason, expected_current_revision, + expected_preflight_fingerprint, idempotency_key, + ) + result["auditId"] = audit.id + return _json(result) + finally: + _SELF_UPGRADE_LOCK.release() + + +@appops_mcp.tool(name="appops.self_upgrade.status", annotations=_READ_ONLY_TOOL) +@_tracked("appops.self_upgrade.status") +def appops_self_upgrade_status(operation_id: str) -> dict[str, Any]: + """Read the controller's durable progress and exact deployment receipt.""" + return _json(DeploymentBrokerClient().appops_self_upgrade_status(operation_id)) + + +@appops_mcp.tool(name="server.operation.execute", annotations=_LEGACY_APPROVAL_EXECUTION_TOOL) +@_tracked("server.operation.execute") +def execute_operation( + approvalId: str, expectedActionFingerprint: str +) -> dict[str, Any]: + """Legacy: execute an already-approved pre-existing approval record; new MCP calls never create one.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + with Session(engine) as session: + return _json( + mcp_operations.execute_operation( + session, + approval_id=approvalId, + expected_action_fingerprint=expectedActionFingerprint, + ) + ) + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.tool(name="server.job.status", annotations=_READ_ONLY_TOOL) +@_tracked("server.job.status") +def job_status(jobId: str) -> dict[str, Any]: + """Read bounded progress and the redacted result of one server-side AppOps job.""" + with Session(engine) as session: + return _json(mcp_operations.job_status(session, jobId)) + + +@appops_mcp.tool(name="server.job.cancel", annotations=_CANCEL_TOOL) +@_tracked("server.job.cancel") +def cancel_job(jobId: str) -> dict[str, Any]: + """Cancel a queued or running AppOps job record; completed jobs remain immutable.""" + with Session(engine) as session: + return _json(mcp_operations.cancel_operation_job(session, jobId)) + + +@appops_mcp.tool(name="deployment.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("deployment.preflight") +def deployment_preflight(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: + """Validate one strict DeploymentPlanV1 without touching application runtime state.""" + try: + typed_plan = DeploymentPlanV1.model_validate(plan) + with Session(engine) as session: + return _json( + DeploymentControlPlane().execute( + session, + plan=typed_plan, + idempotency_key=idempotencyKey, + operation="preflight", + ) + ) + except (ValueError, DeploymentControlError) as exc: + return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} + + +@appops_mcp.tool(name="deployment.capabilities", annotations=_READ_ONLY_TOOL) +@_tracked("deployment.capabilities") +def deployment_capabilities() -> dict[str, Any]: + """Read machine-actionable enrollment and deployment readiness without mutations.""" + with Session(engine) as session: + return _json(EnrollmentPlane().capabilities(session)) + + +@appops_mcp.tool(name="enrollment.preflight", annotations=_READ_ONLY_TOOL) +@_tracked("enrollment.preflight") +def enrollment_preflight(plan: dict[str, Any]) -> dict[str, Any]: + """Read-only takeover-safe preflight for one strict EnrollmentPlanV1.""" + try: + typed_plan = EnrollmentPlanV1.model_validate(plan) + with Session(engine) as session: + return _json(EnrollmentPlane().preflight(session, typed_plan)) + except (ValueError, EnrollmentError) as exc: + return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} + + +@appops_mcp.tool(name="target.enroll", annotations=_OPERATION_RUN_TOOL) +@_tracked("target.enroll") +def target_enroll(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: + """Enroll exactly one clean-profile application identity; no arbitrary target is accepted.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + typed_plan = EnrollmentPlanV1.model_validate(plan) + with Session(engine) as session: + return _json(EnrollmentPlane().enroll(session, typed_plan, idempotency_key=idempotencyKey)) + except (ValueError, EnrollmentError) as exc: + return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.tool(name="enrollment.status", annotations=_READ_ONLY_TOOL) +@_tracked("enrollment.status") +def enrollment_status(enrollmentId: str) -> dict[str, Any]: + """Read a redacted durable enrollment state and its restart-safe job evidence.""" + with Session(engine) as session: + try: + return _json(EnrollmentPlane().get_enrollment(session, enrollmentId)) + except EnrollmentError as exc: + return {"status": "not_found", "reason": redact_message(str(exc))} + + +@appops_mcp.tool(name="deployment.apply", annotations=_OPERATION_RUN_TOOL) +@_tracked("deployment.apply") +def deployment_apply(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: + """Submit one preflighted typed deployment to the private controller.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + typed_plan = DeploymentPlanV1.model_validate(plan) + with Session(engine) as session: + return _json( + DeploymentControlPlane().execute( + session, + plan=typed_plan, + idempotency_key=idempotencyKey, + operation="apply", + ) + ) + except (ValueError, DeploymentControlError) as exc: + return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.tool(name="deployment.status", annotations=_READ_ONLY_TOOL) +@_tracked("deployment.status") +def deployment_status(jobId: str) -> dict[str, Any]: + """Read durable deployment state and refresh a bounded broker request.""" + with Session(engine) as session: + try: + return _json(DeploymentControlPlane().refresh(session, jobId)) + except DeploymentControlError as exc: + return {"status": "not_found", "reason": redact_message(str(exc))} + + +@appops_mcp.tool(name="deployment.verify", annotations=_READ_ONLY_TOOL) +@_tracked("deployment.verify") +def deployment_verify(jobId: str) -> dict[str, Any]: + """Refresh the controller receipt of an exact previously submitted deployment.""" + return deployment_status(jobId) + + +@appops_mcp.tool(name="deployment.rollback", annotations=_OPERATION_RUN_TOOL) +@_tracked("deployment.rollback") +def deployment_rollback(plan: dict[str, Any], idempotencyKey: str) -> dict[str, Any]: + """Ask the controller to run its receipt-bound rollback for one typed plan.""" + if not _MCP_EXECUTION_LOCK.acquire(blocking=False): + raise RuntimeError("server_operation_capacity_busy") + try: + typed_plan = DeploymentPlanV1.model_validate(plan) + if not typed_plan.rollback_enabled: + return {"status": "blocked", "executed": False, "reason": "rollback_disabled_by_typed_plan"} + with Session(engine) as session: + return _json( + DeploymentControlPlane().execute( + session, + plan=typed_plan, + idempotency_key=idempotencyKey, + operation="rollback", + ) + ) + except (ValueError, DeploymentControlError) as exc: + return {"status": "invalid", "executed": False, "reason": redact_message(str(exc))} + finally: + _MCP_EXECUTION_LOCK.release() + + +@appops_mcp.resource("appops://capabilities", mime_type="application/json") +def capabilities() -> str: + """Describe the immutable security boundary of this MCP server.""" + return json.dumps( + { + "scope": "governed_operations", + "transport": "streamable_http", + "mutationTools": True, + "directShell": False, + "dockerSocket": False, + "secrets": "excluded", + "execution": "typed AppOps executors immediately after central policy evaluation", + "approvalRequiredForMcpMutations": False, + "humanApprovalWorkflowExposedByMcp": False, + "blockedActionsRemainBlocked": True, + "unsupportedActionsFailClosed": True, + "legacyApprovalCompatibility": True, + "maxConcurrentExecutions": 1, + "deploymentControlPlane": { + "version": "v1", + "typedPlanOnly": True, + "rawCompose": False, + "directShell": False, + "brokerEvidenceRequired": True, + }, + "enrollmentProvisioningPlane": { + "version": "v1", + "typedPlanOnly": True, + "profileControlled": True, + "rawCompose": False, + "bootstrapCredentials": "write_only", + "wildcardPolicies": False, + }, + "giteaProjectRegistration": { + "version": "v1", + "automatic": True, + "catalogTool": "gitea.projects.catalog", + "reconcileTool": "gitea.projects.reconcile", + "wildcardDeploymentPolicy": False, + }, + } + ) + + +mcp_http_app = appops_mcp.streamable_http_app( + streamable_http_path="/", + stateless_http=True, + json_response=True, + max_request_body_size=1_048_576, + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=list(settings.mcp_allowed_hosts or []), + allowed_origins=[settings.frontend_origin], + ), +) diff --git a/backend/app/schemas/ai_remediation.py b/backend/app/schemas/ai_remediation.py new file mode 100644 index 0000000..2fea658 --- /dev/null +++ b/backend/app/schemas/ai_remediation.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +from .ai_security import Confidence, Severity + + +RemediationPlanStatus = Literal['draft', 'reviewed', 'approved_for_manual_execution', 'dismissed'] +RemediationSourceType = Literal['security_finding', 'security_recommendation', 'media_issue', 'inventory', 'connector', 'policy', 'secret', 'unknown'] +RemediationStepType = Literal[ + 'open_page', + 'configure_secret', + 'bind_connector', + 'review_policy', + 'request_approval', + 'run_readonly_check', + 'manual_external_change', + 'blocked_action', + 'documentation', +] +DryRunStatus = Literal['pass', 'warning', 'blocked'] + + +class RemediationEvidence(BaseModel): + source: str + label: str + value: Any = None + redacted: bool = False + + +class RemediationApprovalRequirement(BaseModel): + actionId: str + target: str + reason: str + risk: str + policyDecision: str + + +class RemediationVerificationCheck(BaseModel): + label: str + endpoint: str + expectedResult: str + source: str + readOnly: bool = True + + +class RemediationStep(BaseModel): + id: str + label: str + description: str + type: RemediationStepType + actionId: str | None = None + target: str | None = None + href: str | None = None + policyDecision: str = 'allowed' + risk: str = 'low' + requiresApproval: bool = False + canExecute: bool = False + evidence: list[RemediationEvidence] = Field(default_factory=list) + + +class RemediationPlan(BaseModel): + id: str + title: str + description: str + sourceType: RemediationSourceType + sourceId: str + severity: Severity + confidence: Confidence + status: RemediationPlanStatus = 'draft' + affectedResources: list[str] = Field(default_factory=list) + prerequisites: list[str] = Field(default_factory=list) + steps: list[RemediationStep] = Field(default_factory=list) + verificationChecks: list[RemediationVerificationCheck] = Field(default_factory=list) + blockedActions: list[RemediationStep] = Field(default_factory=list) + approvalRequirements: list[RemediationApprovalRequirement] = Field(default_factory=list) + riskAssessment: str + estimatedImpact: str + rollbackNotes: str + auditPreview: str + createdAt: str + updatedAt: str + executable: bool = False + + +class RemediationPlanCollection(BaseModel): + status: str + generatedAt: str + total: int + plans: list[RemediationPlan] + + +class RemediationDryRunStepResult(BaseModel): + stepId: str + label: str + actionId: str | None = None + target: str | None = None + policyDecision: str + risk: str + status: DryRunStatus + reason: str + canExecute: bool = False + + +class RemediationDryRunResult(BaseModel): + planId: str + status: DryRunStatus + executed: bool = False + reasons: list[str] = Field(default_factory=list) + prerequisites: list[str] = Field(default_factory=list) + unresolvedBlockers: list[str] = Field(default_factory=list) + blockedActions: list[RemediationApprovalRequirement] = Field(default_factory=list) + approvalRequirements: list[RemediationApprovalRequirement] = Field(default_factory=list) + verificationChecks: list[RemediationVerificationCheck] = Field(default_factory=list) + stepResults: list[RemediationDryRunStepResult] = Field(default_factory=list) + + +class RemediationApprovalProposal(BaseModel): + planId: str + created: bool + executed: bool = False + approvalId: str | None = None + proposals: list[RemediationApprovalRequirement] = Field(default_factory=list) + message: str diff --git a/backend/app/schemas/ai_security.py b/backend/app/schemas/ai_security.py new file mode 100644 index 0000000..678c015 --- /dev/null +++ b/backend/app/schemas/ai_security.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +Severity = Literal['info', 'low', 'medium', 'high', 'critical'] +Confidence = Literal['low', 'medium', 'high'] +ReviewStatus = Literal['healthy', 'warning', 'critical', 'partial', 'unknown'] +FindingStatus = Literal['open', 'acknowledged', 'dismissed', 'resolved'] +RecommendationStatus = Literal['open', 'snoozed', 'dismissed', 'accepted'] +SecurityReviewScope = Literal['all', 'exposure', 'auth', 'secrets', 'policies', 'connectors', 'audit', 'unraid', 'media'] + + +class SecurityEvidence(BaseModel): + source: str + label: str + value: Any = None + observed: Any = None + expected: Any = None + referenceId: str | None = None + redacted: bool = False + + +class SecuritySuggestedAction(BaseModel): + label: str + type: Literal['link', 'policy_draft', 'approval_request', 'open_page', 'run_readonly_check'] + actionId: str | None = None + target: str | None = None + policyDecision: str = 'allowed' + risk: str = 'low' + href: str | None = None + executable: bool = False + + +class SecurityFinding(BaseModel): + id: str + title: str + description: str + severity: Severity + confidence: Confidence + category: Literal['exposure', 'auth_coverage', 'coverage', 'connector_config', 'secrets', 'policies', 'connectors', 'audit', 'inventory', 'unraid', 'media'] + affectedResources: list[str] = Field(default_factory=list) + evidence: list[SecurityEvidence] = Field(default_factory=list) + suggestedActions: list[SecuritySuggestedAction] = Field(default_factory=list) + status: FindingStatus = 'open' + createdAt: str + riskReason: str + links: list[dict[str, Any]] = Field(default_factory=list) + + +class SecurityRecommendation(BaseModel): + id: str + title: str + description: str + severity: Severity + confidence: Confidence + evidence: list[SecurityEvidence] = Field(default_factory=list) + affectedResources: list[str] = Field(default_factory=list) + suggestedAction: str + suggestedActions: list[SecuritySuggestedAction] = Field(default_factory=list) + policyDecision: str = 'allowed' + actionTarget: str | None = None + canAutoApply: bool = False + status: RecommendationStatus = 'open' + + +class SecurityReviewSummary(BaseModel): + status: ReviewStatus + score: int + generatedAt: str + dataCompleteness: dict[str, Any] + criticalFindings: int + highFindings: int = 0 + mediumFindings: int = 0 + warningFindings: int + infoFindings: int + recommendationsCount: int + connectedSources: list[str] + scoreBreakdown: dict[str, Any] = Field(default_factory=dict) + + +class SecurityReviewResult(BaseModel): + summary: SecurityReviewSummary + findings: list[SecurityFinding] + recommendations: list[SecurityRecommendation] diff --git a/backend/app/schemas/deployment.py b/backend/app/schemas/deployment.py new file mode 100644 index 0000000..87ebcc9 --- /dev/null +++ b/backend/app/schemas/deployment.py @@ -0,0 +1,372 @@ +"""Strict, secret-free contracts for managed Unraid deployments. + +This module intentionally models a small deployment vocabulary. It is not a +Compose parser, a Docker API wrapper, or a generic remote-command interface. +The controller derives filesystem paths and runtime arguments from this plan +after it has accepted the immutable repository revision. +""" + +from __future__ import annotations + +import posixpath +import re +from typing import Any, Literal + +import yaml +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + +_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") +_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$") +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_VERSION = re.compile(r"^v?(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){2}(?:-[0-9A-Za-z.-]+)?$") +_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$") +_DOMAIN = re.compile( + r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$" +) +_SECRET_REFERENCE = re.compile(r"^[A-Z][A-Z0-9_]{2,120}$") +_SAFE_PATH = re.compile( + r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))(?:[A-Za-z0-9]|\.[A-Za-z0-9])[A-Za-z0-9_./-]{0,239}$" +) + +TRUSTED_APPCONFIG_ROOT = "/srv/example/appdata" + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True) + + +class DeploymentTopologyV1(_StrictModel): + """The only runtime shape accepted by V1. + + V1 is deliberately compose-only and always private behind the existing + proxy network. A public host binding, Docker socket or host networking is + outside this contract. + """ + + kind: Literal["compose"] = "compose" + compose_file: str = ".itworx/deployment.yml" + compose_project: str = Field(min_length=2, max_length=63) + containers: tuple[str, ...] = Field(min_length=1, max_length=8) + proxy_network: str = Field(min_length=2, max_length=63) + internal_port: int = Field(ge=1, le=65535) + restart_policy: Literal["unless-stopped"] = "unless-stopped" + build: RepositoryBuildV1 | None = None + + @field_validator("compose_file") + @classmethod + def safe_compose_file(cls, value: str) -> str: + if not _SAFE_PATH.fullmatch(value) or not value.endswith((".yml", ".yaml")): + raise ValueError("compose_file must be a safe repository-relative YAML path") + return value + + @field_validator("compose_project", "proxy_network", "containers") + @classmethod + def safe_runtime_name(cls, value: str | tuple[str, ...]) -> str | tuple[str, ...]: + values = value if isinstance(value, tuple) else (value,) + if any(not _NAME.fullmatch(item) for item in values): + raise ValueError("runtime names must use the bounded deployment identifier format") + if len(set(values)) != len(values): + raise ValueError("container names must be unique") + return value + + +class RepositoryBuildV1(_StrictModel): + """A controller-revalidated repository build, never a caller shell step.""" + + context: str = "." + dockerfile: str = "Dockerfile" + + @field_validator("context") + @classmethod + def safe_context(cls, value: str) -> str: + if value != "." and not _SAFE_PATH.fullmatch(value): + raise ValueError("build context must be a safe repository-relative path") + return value + + @field_validator("dockerfile") + @classmethod + def safe_dockerfile(cls, value: str) -> str: + if not _SAFE_PATH.fullmatch(value) or not value.endswith("Dockerfile"): + raise ValueError("dockerfile must be a safe repository-relative Dockerfile path") + return value + + +DeploymentTopologyV1.model_rebuild() + + +class ProxyHostV1(_StrictModel): + hostname: str + certificate_mode: Literal["existing", "managed"] = "managed" + force_ssl: bool = True + http2: bool = True + upload_limit_mb: int = Field(default=256, ge=1, le=2048) + read_timeout_seconds: int = Field(default=180, ge=15, le=900) + + @field_validator("hostname") + @classmethod + def safe_hostname(cls, value: str) -> str: + normalized = value.lower().rstrip(".") + if not _DOMAIN.fullmatch(normalized) or normalized.startswith("-"): + raise ValueError("hostname must be a canonical DNS hostname") + return normalized + + @model_validator(mode="after") + def require_https_redirect(self) -> ProxyHostV1: + if not self.force_ssl: + raise ValueError("managed proxy hosts must force HTTPS") + return self + + +class OidcRoleMappingV1(_StrictModel): + administrator_group: str = Field(min_length=1, max_length=120) + support_agent_group: str = Field(min_length=1, max_length=120) + read_only_group: str = Field(min_length=1, max_length=120) + + @field_validator("administrator_group", "support_agent_group", "read_only_group") + @classmethod + def safe_group_name(cls, value: str) -> str: + if any(character in value for character in "\r\n\x00"): + raise ValueError("role group names may not contain control characters") + return value + + +class OidcProvisioningV1(_StrictModel): + provider: Literal["authentik"] = "authentik" + application_slug: str = Field(min_length=2, max_length=63) + redirect_path: str = "/signin-oidc" + role_claim: str = Field(default="groups", min_length=1, max_length=80) + client_secret_ref: str + roles: OidcRoleMappingV1 + + @field_validator("application_slug") + @classmethod + def safe_application_slug(cls, value: str) -> str: + if not _APP_ID.fullmatch(value): + raise ValueError("application_slug must be a bounded lower-case identifier") + return value + + @field_validator("client_secret_ref") + @classmethod + def secret_reference_only(cls, value: str) -> str: + if not _SECRET_REFERENCE.fullmatch(value): + raise ValueError("client_secret_ref must reference a stored AppOps secret, never a value") + return value + + @field_validator("redirect_path") + @classmethod + def safe_redirect_path(cls, value: str) -> str: + if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"): + raise ValueError("redirect_path must be a relative HTTPS callback path") + return value + + +class DeploymentPlanV1(_StrictModel): + """A validated, immutable deployment intent for one managed application.""" + + api_version: Literal["itworx.deployment/v1"] = "itworx.deployment/v1" + application_id: str + repository: str + revision: str + release_version: str + image_repository: str + image_tag: str + topology: DeploymentTopologyV1 + proxy: ProxyHostV1 | None = None + oidc: OidcProvisioningV1 | None = None + health_path: str = "/healthz" + health_timeout_seconds: int = Field(default=180, ge=15, le=900) + broker_secret_ref: str + rollback_enabled: bool = True + + @field_validator("application_id") + @classmethod + def safe_app_id(cls, value: str) -> str: + if not _APP_ID.fullmatch(value): + raise ValueError("application_id must be a bounded lower-case identifier") + return value + + @field_validator("repository") + @classmethod + def safe_repository(cls, value: str) -> str: + normalized = value.lower() + if not _REPOSITORY.fullmatch(normalized): + raise ValueError("repository must be a canonical Jens repository") + return normalized + + @field_validator("revision") + @classmethod + def immutable_revision(cls, value: str) -> str: + normalized = value.lower() + if not _REVISION.fullmatch(normalized): + raise ValueError("revision must be a full lower-case 40-character Git SHA") + return normalized + + @field_validator("release_version") + @classmethod + def semantic_version(cls, value: str) -> str: + if not _VERSION.fullmatch(value): + raise ValueError("release_version must be a semantic version") + return value.removeprefix("v") + + @field_validator("image_repository") + @classmethod + def safe_image_repository(cls, value: str) -> str: + normalized = value.lower() + if not re.fullmatch(r"[a-z0-9][a-z0-9._/-]{1,180}", normalized): + raise ValueError("image_repository is not a safe image identifier") + return normalized + + @field_validator("image_tag") + @classmethod + def immutable_image_tag(cls, value: str) -> str: + if value.lower() == "latest" or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value): + raise ValueError("image_tag must be an explicit non-latest tag") + return value + + @field_validator("broker_secret_ref") + @classmethod + def broker_secret_reference_only(cls, value: str) -> str: + if not _SECRET_REFERENCE.fullmatch(value): + raise ValueError("broker_secret_ref must reference a stored AppOps secret, never a value") + return value + + @field_validator("health_path") + @classmethod + def safe_health_path(cls, value: str) -> str: + if not value.startswith("/") or "//" in value or any(character in value for character in "?#\\\r\n"): + raise ValueError("health_path must be a relative path") + return value + + @model_validator(mode="after") + def validate_cross_field_contract(self) -> DeploymentPlanV1: + if self.application_id not in self.topology.containers: + raise ValueError("application_id must be one of the managed container names") + if self.topology.compose_project != self.application_id: + raise ValueError("compose_project must equal application_id in DeploymentPlanV1") + if self.image_tag not in {self.release_version, self.revision[:12]}: + raise ValueError("image_tag must be the release version or immutable revision prefix") + if self.oidc and not self.proxy: + raise ValueError("OIDC provisioning requires a managed HTTPS proxy host") + return self + + @property + def appdata_path(self) -> str: + return f"{TRUSTED_APPCONFIG_ROOT}/{self.application_id}" + + @property + def public_url(self) -> str | None: + return f"https://{self.proxy.hostname}" if self.proxy else None + + +def validate_compose_runtime_contract(plan: DeploymentPlanV1, raw_yaml: str) -> dict[str, Any]: + """Validate a repository compose artifact against the narrow V1 policy. + + The result is suitable for evidence/audit storage and deliberately contains + no Compose environment values. It rejects mounts, privileged flags and + runtime capabilities that would turn a product deployment into a host + control interface. + """ + + try: + document = yaml.safe_load(raw_yaml) + except yaml.YAMLError as exc: + raise ValueError("deployment compose YAML could not be parsed") from exc + if not isinstance(document, dict) or set(document) - {"services", "networks", "name"}: + raise ValueError("deployment compose only permits name, services and networks") + services = document.get("services") + if not isinstance(services, dict) or not services: + raise ValueError("deployment compose must define services") + + expected = set(plan.topology.containers) + observed = set(services) + if observed != expected: + raise ValueError("deployment compose services must exactly match the typed plan containers") + + for service_name, service in services.items(): + if not isinstance(service, dict): + raise TypeError("deployment service must be an object") + forbidden = { + "privileged", + "devices", + "pid", + "ipc", + "cap_add", + "dockerfile", + } + present = forbidden.intersection(service) + if present: + raise ValueError(f"deployment service {service_name} uses forbidden runtime fields") + build = service.get("build") + if plan.topology.build is None: + if build: + raise ValueError("deployment service build is not declared by the typed plan") + else: + if isinstance(build, str): + observed_build = {"context": build, "dockerfile": "Dockerfile"} + elif isinstance(build, dict): + observed_build = { + "context": str(build.get("context") or "."), + "dockerfile": str(build.get("dockerfile") or "Dockerfile"), + } + if set(build) - {"context", "dockerfile"}: + raise ValueError("deployment service build contains unsupported fields") + else: + raise ValueError("deployment service build must be declared by the typed plan") + expected_build = plan.topology.build.model_dump() + if observed_build != expected_build: + raise ValueError("deployment service build does not match the typed plan") + if service.get("network_mode") in {"host", "service:host"}: + raise ValueError("host networking is forbidden") + if service.get("ports"): + raise ValueError("published host ports are forbidden for managed proxy deployments") + security_options = service.get("security_opt") or [] + if set(security_options) - {"no-new-privileges:true"}: + raise ValueError("deployment service uses unsupported security options") + if service.get("read_only") is not True: + raise ValueError("deployment services must use a read-only root filesystem") + if service.get("cap_drop") != ["ALL"]: + raise ValueError("deployment services must drop every Linux capability") + if service.get("restart") != "unless-stopped": + raise ValueError("deployment services must use restart: unless-stopped") + image = str(service.get("image") or "") + if image != f"{plan.image_repository}:{plan.image_tag}": + raise ValueError("deployment service image must equal the immutable plan image") + owned_bind_mounts = 0 + for mount in service.get("volumes", []) or []: + if isinstance(mount, dict) and mount.get("type") not in {None, "bind"}: + raise ValueError("deployment services permit only application-owned bind mounts") + source = mount.get("source") if isinstance(mount, dict) else str(mount).split(":", 1)[0] + source_text = str(source or "") + normalized_source = posixpath.normpath(source_text) + if "docker.sock" in source_text: + raise ValueError("docker socket and host-control mounts are forbidden") + if ( + not source_text.startswith("/") + or normalized_source != source_text + or source_text in {"/", "/proc", "/sys"} + ): + raise ValueError("only normalized application-owned bind mounts are permitted") + if source_text != plan.appdata_path and not source_text.startswith(plan.appdata_path + "/"): + raise ValueError("bind mounts must stay below the plan's owned appdata path") + owned_bind_mounts += 1 + if not owned_bind_mounts: + raise ValueError("deployment services require an application-owned persistent bind mount") + networks = service.get("networks") + if networks != [plan.topology.proxy_network] and networks != {plan.topology.proxy_network: None}: + raise ValueError("deployment services must use only the typed proxy network") + + networks = document.get("networks") + if networks != {plan.topology.proxy_network: {"external": True}}: + raise ValueError("deployment compose must reference exactly one existing proxy network") + return { + "services": sorted(observed), + "proxyNetwork": plan.topology.proxy_network, + "appdataPath": plan.appdata_path, + "image": f"{plan.image_repository}:{plan.image_tag}", + } diff --git a/backend/app/schemas/enrollment.py b/backend/app/schemas/enrollment.py new file mode 100644 index 0000000..ec678a5 --- /dev/null +++ b/backend/app/schemas/enrollment.py @@ -0,0 +1,375 @@ +"""Strict, secret-free contract for managed application enrollment. + +Enrollment establishes an application identity and its operating boundaries. It +is deliberately separate from a deployment: the caller cannot submit Compose, +host paths, shell commands, callback origins, proxy snippets, or secret values. +""" + +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") +_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9._-]{1,80}$") +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_HOSTNAME = re.compile(r"^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$") +_RELATIVE_PATH = re.compile(r"^/[a-z0-9][a-z0-9/_-]{0,120}$") +_SUBPATH = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +_IMAGE = re.compile(r"^[a-z0-9][a-z0-9._/-]{1,180}$") +_IMAGE_TAG = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") +_VERSION = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$") + +DeploymentProfileName = Literal[ + "internal-webapp", + "internal-webapp-oidc", + "internal-service", + "repopulse-authenticated", +] + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class EnrollmentTargetV1(_StrictModel): + target_type: Literal["gitea-compose-controller"] = "gitea-compose-controller" + target_identity: str = Field(min_length=3, max_length=80) + container_name: str = Field(min_length=2, max_length=63) + # A small reviewed manifest allow-list keeps source locations explicit while + # allowing the established AppOps artifact layout as well as the default + # contract for future projects. It is intentionally not a free path. + compose_file: Literal[".itworx/deployment.yml", "deploy/appops/compose.yml"] = ".itworx/deployment.yml" + ownership_label: Literal["tech.itworx.enrollment"] = "tech.itworx.enrollment" + + @field_validator("target_identity") + @classmethod + def safe_target_identity(cls, value: str) -> str: + if not re.fullmatch(r"[a-z][a-z0-9-]{2,79}", value): + raise ValueError("target_identity must be a bounded lower-case identifier") + return value + + @field_validator("container_name") + @classmethod + def safe_container_name(cls, value: str) -> str: + if not _APP_ID.fullmatch(value): + raise ValueError("container_name must be a bounded lower-case identifier") + return value + + +class EnrollmentStorageV1(_StrictModel): + # The v1 profiles own the data layout. Future profiles may add reviewed + # alternatives, but enrollment must not turn this into a free host path. + appdata_subpath: Literal["data"] = "data" + runtime_uid: int = Field(ge=1, le=65535) + runtime_gid: int = Field(ge=1, le=65535) + mount_role: Literal["application-data"] = "application-data" + + @field_validator("appdata_subpath") + @classmethod + def safe_subpath(cls, value: str) -> str: + if not _SUBPATH.fullmatch(value) or value in {".", ".."}: + raise ValueError("appdata_subpath must be one bounded relative directory") + return value + + +class EnrollmentNetworkV1(_StrictModel): + docker_network: str = Field(min_length=2, max_length=80) + internal_port: int = Field(ge=1, le=65535) + host_publish: Literal[False] = False + + @field_validator("docker_network") + @classmethod + def safe_network(cls, value: str) -> str: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", value): + raise ValueError("docker_network must be a bounded Docker network name") + return value + + +class EnrollmentExposureV1(_StrictModel): + hostname: str + tls_required: Literal[True] = True + upstream_service: str + websocket: bool = False + upload_profile: Literal["standard", "large-upload"] = "standard" + timeout_profile: Literal["standard", "long-running"] = "standard" + + @field_validator("hostname") + @classmethod + def safe_hostname(cls, value: str) -> str: + normalized = value.lower().rstrip(".") + if not _HOSTNAME.fullmatch(normalized) or "*" in normalized: + raise ValueError("hostname must be a concrete DNS hostname") + return normalized + + @field_validator("upstream_service") + @classmethod + def safe_upstream(cls, value: str) -> str: + if not _APP_ID.fullmatch(value): + raise ValueError("upstream_service must be a bounded application identifier") + return value + + +class EnrollmentRolesV1(_StrictModel): + administrator: str = "Administrator" + support_agent: str = "SupportAgent" + read_only: str = "ReadOnly" + + @field_validator("administrator", "support_agent", "read_only") + @classmethod + def safe_role(cls, value: str) -> str: + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{1,63}", value): + raise ValueError("role names must be bounded identifiers") + return value + + +class EnrollmentAuthenticationV1(_StrictModel): + mode: Literal["none", "native-oidc"] = "none" + redirect_path: str | None = None + logout_path: str | None = None + roles: EnrollmentRolesV1 | None = None + + @field_validator("redirect_path", "logout_path") + @classmethod + def safe_relative_path(cls, value: str | None) -> str | None: + if value is None: + return value + if not _RELATIVE_PATH.fullmatch(value) or "//" in value or any(item in value for item in ("?", "#", "\\")): + raise ValueError("OIDC paths must be relative HTTPS application paths") + return value + + @model_validator(mode="after") + def validate_mode(self) -> EnrollmentAuthenticationV1: + if self.mode == "native-oidc" and (not self.redirect_path or self.roles is None): + raise ValueError("native-oidc requires a redirect_path and a complete role model") + if self.mode == "none" and any((self.redirect_path, self.logout_path, self.roles)): + raise ValueError("authentication details are only valid for native-oidc") + return self + + +class EnrollmentArtifactV1(_StrictModel): + release_version: str + image_repository: str + image_tag: str + health_path: str = "/health" + + @field_validator("release_version") + @classmethod + def valid_version(cls, value: str) -> str: + if not _VERSION.fullmatch(value): + raise ValueError("release_version must be a semantic version") + return value.removeprefix("v") + + @field_validator("image_repository") + @classmethod + def valid_image(cls, value: str) -> str: + normalized = value.lower() + if not _IMAGE.fullmatch(normalized): + raise ValueError("image_repository must be a bounded image name") + return normalized + + @field_validator("image_tag") + @classmethod + def valid_tag(cls, value: str) -> str: + if value.lower() == "latest" or not _IMAGE_TAG.fullmatch(value): + raise ValueError("image_tag must be explicit and cannot be latest") + return value + + @field_validator("health_path") + @classmethod + def valid_health_path(cls, value: str) -> str: + if not _RELATIVE_PATH.fullmatch(value) or "//" in value or any(item in value for item in ("?", "#", "\\")): + raise ValueError("health_path must be a bounded relative path") + return value + + +class EnrollmentSecretsV1(_StrictModel): + oidc_client_secret_required: bool = False + additional_secret_refs: tuple[str, ...] = () + + @field_validator("additional_secret_refs") + @classmethod + def references_only(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if len(value) > 8 or len(set(value)) != len(value): + raise ValueError("additional_secret_refs must contain up to eight unique references") + for reference in value: + if not re.fullmatch(r"[A-Z][A-Z0-9_]{2,95}", reference): + raise ValueError("secret requirements must be references, never values") + return value + + +class EnrollmentPolicyV1(_StrictModel): + deployment_apply: Literal[True] = True + deployment_rollback: Literal[True] = True + autonomous_profile: Literal[True] = True + + +class EnrollmentPlanV1(_StrictModel): + api_version: Literal["itworx.enrollment/v1"] = "itworx.enrollment/v1" + application_id: str + display_name: str = Field(min_length=2, max_length=100) + environment: Literal["production", "staging"] = "production" + repository: str + revision: str + profile: DeploymentProfileName + target: EnrollmentTargetV1 + storage: EnrollmentStorageV1 + network: EnrollmentNetworkV1 + artifact: EnrollmentArtifactV1 + exposure: EnrollmentExposureV1 | None = None + authentication: EnrollmentAuthenticationV1 = EnrollmentAuthenticationV1() + secrets: EnrollmentSecretsV1 = EnrollmentSecretsV1() + policy: EnrollmentPolicyV1 = EnrollmentPolicyV1() + + @field_validator("application_id") + @classmethod + def safe_application_id(cls, value: str) -> str: + if not _APP_ID.fullmatch(value): + raise ValueError("application_id must be a bounded lower-case identifier") + return value + + @field_validator("display_name") + @classmethod + def safe_display_name(cls, value: str) -> str: + normalized = value.strip() + if not normalized or any(character in normalized for character in "\r\n<>"): + raise ValueError("display_name contains unsupported characters") + return normalized + + @field_validator("repository") + @classmethod + def safe_repository(cls, value: str) -> str: + normalized = value.lower() + if not _REPOSITORY.fullmatch(normalized): + raise ValueError("repository must be a canonical Jens repository") + return normalized + + @field_validator("revision") + @classmethod + def immutable_revision(cls, value: str) -> str: + normalized = value.lower() + if not _REVISION.fullmatch(normalized): + raise ValueError("revision must be a full lower-case 40-character Git SHA") + return normalized + + @model_validator(mode="after") + def validate_profile_contract(self) -> EnrollmentPlanV1: + if self.target.container_name != self.application_id: + raise ValueError("target.container_name must equal application_id") + if self.profile == "internal-service": + if self.exposure is not None or self.authentication.mode != "none": + raise ValueError("internal-service cannot request public exposure or OIDC") + elif self.exposure is None: + raise ValueError("web application profiles require managed HTTPS exposure") + if self.exposure and self.exposure.upstream_service != self.application_id: + raise ValueError("exposure.upstream_service must equal application_id") + expects_oidc = self.profile == "internal-webapp-oidc" + if expects_oidc != (self.authentication.mode == "native-oidc"): + raise ValueError("profile and authentication mode must match") + if expects_oidc != self.secrets.oidc_client_secret_required: + raise ValueError("OIDC secret requirement must match the selected profile") + if self.profile == "repopulse-authenticated" and ( + self.application_id != "repopulse" + or self.repository != "jens/repopulse" + or self.environment != "production" + or self.target.container_name != "repopulse" + or self.target.compose_file != ".itworx/deployment.yml" + or self.storage.appdata_subpath != "data" + or self.storage.runtime_uid != 10001 + or self.storage.runtime_gid != 10001 + or self.network.docker_network != "itworx-mcp-hub-npm" + or self.network.internal_port != 8000 + or self.artifact.image_repository != "repopulse-managed" + or self.artifact.image_tag != "production" + or self.artifact.health_path != "/health" + or self.exposure is None + or self.exposure.hostname != "repopulse.example.com" + or self.exposure.upstream_service != "repopulse" + or self.authentication.mode != "none" + or self.secrets.additional_secret_refs != ("REPOPULSE_GITEA_TOKEN",) + ): + raise ValueError("repopulse-authenticated must use the fixed RepoPulse managed target contract") + return self + + @property + def appdata_path(self) -> str: + return f"/srv/example/appdata/{self.application_id}" + + @property + def data_path(self) -> str: + return f"{self.appdata_path}/{self.storage.appdata_subpath}" + + @property + def public_url(self) -> str | None: + return f"https://{self.exposure.hostname}" if self.exposure else None + + +class EnrollmentTrustConfigV1(_StrictModel): + """Root-owned boundaries; secret values remain in the encrypted store.""" + + controller_identity: str + bootstrap_secret_ref: str + trusted_appdata_root: Literal["/srv/example/appdata"] = "/srv/example/appdata" + approved_networks: tuple[str, ...] = () + allowed_domain_suffixes: tuple[str, ...] = () + autonomous_profiles: tuple[DeploymentProfileName, ...] = () + repopulse_proxy_access_list_id: int | None = Field(default=None, strict=True, ge=1, le=2_147_483_647) + oidc_issuer: str | None = None + oidc_role_claim: Literal["roles"] = "roles" + administrator_group: str | None = None + support_agent_group: str | None = None + read_only_group: str | None = None + + @field_validator("controller_identity") + @classmethod + def trusted_controller(cls, value: str) -> str: + if not re.fullmatch(r"[a-z][a-z0-9-]{2,79}", value): + raise ValueError("controller_identity must be a bounded lower-case identifier") + return value + + @field_validator("bootstrap_secret_ref") + @classmethod + def trusted_secret_ref(cls, value: str) -> str: + if not re.fullmatch(r"[A-Z][A-Z0-9_]{2,95}", value): + raise ValueError("bootstrap_secret_ref must be a secret reference") + return value + + @field_validator("approved_networks") + @classmethod + def trusted_networks(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not value or len(value) > 8 or len(set(value)) != len(value): + raise ValueError("approved_networks must contain one to eight unique networks") + for item in value: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{1,79}", item): + raise ValueError("approved network name is invalid") + return value + + @field_validator("allowed_domain_suffixes") + @classmethod + def trusted_suffixes(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if len(value) > 16 or len(set(value)) != len(value): + raise ValueError("allowed_domain_suffixes must be unique") + for item in value: + normalized = item.lower().lstrip(".") + if not _HOSTNAME.fullmatch(normalized) or "*" in normalized: + raise ValueError("allowed domain suffix must be a concrete DNS suffix") + return tuple(item.lower().lstrip(".") for item in value) + + @field_validator("oidc_issuer") + @classmethod + def issuer_is_https(cls, value: str | None) -> str | None: + if value is not None and not re.fullmatch(r"https://[a-z0-9.-]+(?:/[a-z0-9._~/-]*)?", value): + raise ValueError("oidc_issuer must be an HTTPS URL without query or fragment") + return value.rstrip("/") if value else value + + @model_validator(mode="after") + def validate_oidc_groups(self) -> EnrollmentTrustConfigV1: + groups = (self.administrator_group, self.support_agent_group, self.read_only_group) + if any(groups) and not all(groups): + raise ValueError("all three configured OIDC role groups are required together") + for group in groups: + if group is not None and not re.fullmatch(r"[A-Za-z0-9 _.-]{2,100}", group): + raise ValueError("OIDC role group name is invalid") + return self diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/action_execution.py b/backend/app/services/action_execution.py new file mode 100644 index 0000000..27c5c29 --- /dev/null +++ b/backend/app/services/action_execution.py @@ -0,0 +1,2264 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from sqlmodel import Session, select + +from ..connectors.deluge import DelugeConnector +from ..connectors.plex import PlexConnector +from ..db.models import Approval, JobRun +from .audit_logger import log_event +from .job_runner import ( + complete_job, + create_job, + prepare_job, + set_job_progress, + start_job, +) +from .setup_service import apply_stored_connector_settings + +GENERIC_EXECUTOR_ACTIONS = { + "container.restart", + "controller.artifact.promote", + "deployment.targets.sync", + "deployment.runnerless.provision", + "deployment.target.deploy_workmesh", + "deployment.target.register_portfolio", + "deployment.target.register_privatemuse", + "deployment.target.reconcile_media_codec_dashboard", + "deployment.webhook.repair_privatemuse", + "privatemuse.comfyui.install_artifact", +} + +DEDICATED_ENDPOINT_ACTIONS = { + "deluge.pause_torrent", + "deluge.resume_torrent", + "media_pipeline.check", + "media_pipeline.diagnose", + "notifications.evaluate_safety", + "plex.scan_library", + "prowlarr.search_test", + "radarr.add_movie", + "sonarr.add_series", + "unraid.vm.start", + "unraid.vm.resume", + "unraid.vm.pause", + "widefrog.retry_failed", + "widefrog.scan_library", +} + +APPROVAL_EXECUTOR_ACTIONS = { + "container.restart", + "controller.artifact.promote", + "runner.fleet.upgrade_v2", + "deployment.targets.sync", + "deployment.runnerless.provision", + "deployment.target.deploy_workmesh", + "deployment.target.promote_workmesh", + "deployment.target.register_portfolio", + "deployment.target.register_privatemuse", + "deployment.target.reconcile_media_codec_dashboard", + "deployment.webhook.repair_privatemuse", + "privatemuse.comfyui.install_artifact", + "deluge.pause_all", + "deluge.resume_all", + "plex.scan_all_libraries", + "unraid.vm.start", + "unraid.vm.resume", + "unraid.vm.pause", +} + +HUMAN_APPROVAL_ONLY_ACTIONS = {"deployment.target.promote_workmesh", "privatemuse.comfyui.install_artifact"} + +_APPROVAL_STEPS = { + "deluge.pause_all": [ + "Load approved request", + "Reload connector settings", + "Pause all torrents", + "Record result", + ], + "deluge.resume_all": [ + "Load approved request", + "Reload connector settings", + "Resume all torrents", + "Record result", + ], + "plex.scan_all_libraries": [ + "Load approved request", + "Reload connector settings", + "Scan all Plex libraries", + "Record result", + ], +} + +_DIRECT_STEPS = { + "deluge.pause_all": [ + "Validate MCP policy", + "Reload connector settings", + "Pause all torrents", + "Record result", + ], + "deluge.resume_all": [ + "Validate MCP policy", + "Reload connector settings", + "Resume all torrents", + "Record result", + ], + "plex.scan_all_libraries": [ + "Validate MCP policy", + "Reload connector settings", + "Scan all Plex libraries", + "Record result", + ], +} + + +def action_execution_profile( + action: str, policy_decision: str, capability: str +) -> dict[str, Any]: + if policy_decision == "blocked": + mode = "blocked" + surface = None + elif action in GENERIC_EXECUTOR_ACTIONS: + mode = "command_center" + surface = "/command-center" + elif action in APPROVAL_EXECUTOR_ACTIONS and policy_decision == "approval_required": + mode = "approval_then_execute" + surface = "/approvals" + elif action in DEDICATED_ENDPOINT_ACTIONS or capability == "read_only": + mode = "dedicated_surface" + surface = None + else: + mode = "governance_only" + surface = None + + return { + "executionMode": mode, + "executionSupported": mode + in {"command_center", "approval_then_execute", "dedicated_surface"}, + "commandExecutionSupported": mode == "command_center", + "executionSurface": surface, + } + + +def approval_execution_supported(action: str) -> bool: + return action in APPROVAL_EXECUTOR_ACTIONS + + +def mcp_execution_supported(action: str) -> bool: + """MCP is a no-human-approval surface; never admit human-only executors.""" + return approval_execution_supported(action) and action not in HUMAN_APPROVAL_ONLY_ACTIONS + + +def _execute_deployment_target_sync( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .gitea_project_auto_registration import GiteaProjectAutoRegistration + + action = "deployment.targets.sync" + target = "jens/*" + steps = [ + "Read owned Gitea repositories through the bounded catalog client", + "Validate repository deployment descriptors and Compose topology", + "Register exact-SHA controller targets where contracts are valid", + "Install exact-target deployment policies without deploying applications", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Reconciling bounded Gitea deployment targets.") + try: + result = GiteaProjectAutoRegistration().sync(session, force=True) + status = str(result.get("status") or "error") + summary = result.get("summary") if isinstance(result.get("summary"), dict) else {} + success = status == "ok" + message = ( + "Deployment target catalog reconciled." + if success + else str(result.get("reason") or "Deployment target catalog reconciliation failed.") + ) + except Exception as exc: + success = False + status = "error" + summary = {} + message = f"{type(exc).__name__}: deployment target catalog reconciliation failed" + result = {"status": status} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-project-catalog", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "total": summary.get("total", 0), + "registered": summary.get("registered", 0), + "knownUnconfigured": summary.get("knownUnconfigured", 0), + "conflicts": summary.get("conflicts", 0), + "errors": summary.get("errors", 0), + "changed": summary.get("changedThisSync", 0), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + "summary": { + "total": summary.get("total", 0), + "registered": summary.get("registered", 0), + "knownUnconfigured": summary.get("knownUnconfigured", 0), + "conflicts": summary.get("conflicts", 0), + "errors": summary.get("errors", 0), + "changed": summary.get("changedThisSync", 0), + }, + } + + +def _execute_workmesh_first_deployment( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .workmesh_first_deployment import WorkMeshFirstDeployment + + action = "deployment.target.deploy_workmesh" + target = "jens/workmesh" + steps = [ + "Verify the fixed WorkMesh v0.1.0 protected-main release contract", + "Create or validate the external mode-0600 WorkMesh runtime environment", + "Reserve the fixed loopback port set and reconcile the exact-source controller target", + "Run the official deployment-controller preflight and transactional first deploy", + "Verify the controller-owned deployment receipt without exposing secret values", + ] + service = WorkMeshFirstDeployment() + tracked_job_id = service.tracked_job_id(session) + job = session.get(JobRun, tracked_job_id) if tracked_job_id else None + orphan_running = False + if job is None: + job = session.exec( + select(JobRun) + .where(JobRun.action_id == action, JobRun.target == target, JobRun.status == "running") + .order_by(JobRun.started_at.desc()) + ).first() + if job is not None: + orphan_running = True + else: + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + + try: + deployment = ( + { + "status": "outcome_unknown", + "reason": "An earlier WorkMesh deployment job is running without matching durable execution state; reconcile it before another deploy", + } + if orphan_running + else service.run(session, job=job) + ) + status = str(deployment.get("status") or "error") + execution_id = str(deployment.get("execution_id") or "") + if status == "deployed": + success = True + message = "WorkMesh v0.1.0 first deployment completed with controller receipt evidence." + elif status == "in_progress": + success = False + message = "WorkMesh v0.1.0 first deployment is still running." + elif status == "outcome_unknown": + success = False + message = str(deployment.get("reason") or "WorkMesh deployment outcome requires reconciliation.") + else: + success = False + message = str(deployment.get("reason") or "WorkMesh v0.1.0 first deployment failed closed.") + except Exception as exc: + status = "error" + execution_id = "" + success = False + deployment = {"status": status} + message = f"{type(exc).__name__}: WorkMesh first-deploy reconciliation failed" + + if status in {"in_progress", "outcome_unknown"}: + if job.status not in {"succeeded", "failed", "cancelled"}: + completed = set_job_progress( + session, + job, + max(job.progress, 90 if status == "outcome_unknown" else 25), + message, + ) + else: + completed = job + elif job.status in {"succeeded", "failed", "cancelled"}: + completed = job + else: + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + + outward_status = ( + status + if status in {"in_progress", "outcome_unknown"} + else "success" + if success + else "failed" + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result=outward_status, + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": deployment.get("repository", target), + "revision": deployment.get("revision"), + "composeProject": deployment.get("compose_project"), + "containers": deployment.get("containers"), + "ports": deployment.get("ports"), + "executionId": execution_id or None, + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "secretsReturned": False, + }, + ) + return { + "success": success, + "status": outward_status, + "result": outward_status, + "message": message, + "jobId": completed.id, + "auditId": audit.id, + **({"executionId": execution_id} if execution_id else {}), + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_portfolio_target_enrollment( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .portfolio_deployment_recovery import PortfolioDeploymentRecovery + + action = "deployment.target.register_portfolio" + target = "jens/portfolio" + steps = [ + "Validate the fixed Portfolio deployment contract", + "Verify or re-attest the proven live Portfolio web/API pair", + "Atomically reconcile the paired controller target state", + "Preserve exact-source deployment provenance", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Validating fixed Portfolio deployment target.") + try: + enrollment = PortfolioDeploymentRecovery().run() + status = str(enrollment.get("status") or "error") + success = status in {"enrolled", "already-enrolled", "reconciled"} + message = str( + enrollment.get("reason") + or ( + "Portfolio deployment target state verified." + if success + else "Portfolio deployment target enrollment failed." + ) + ) + except Exception as exc: + success = False + status = "error" + message = f"{type(exc).__name__}: Portfolio deployment target enrollment failed" + enrollment = {"status": status} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": enrollment.get("repository"), + "composeProject": enrollment.get("compose_project"), + "containers": enrollment.get("containers"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_privatemuse_target_enrollment( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .privatemuse_deployment_recovery import PrivateMuseDeploymentRecovery + + action = "deployment.target.register_privatemuse" + target = "jens/privatemuse" + steps = [ + "Validate the fixed PrivateMuse deployment contract", + "Verify or re-attest the proven live PrivateMuse production trio", + "Atomically reconcile only the bounded controller receipt state", + "Preserve immutable images, media, models and exact-source provenance", + ] + recovery = PrivateMuseDeploymentRecovery() + candidate_job = prepare_job( + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + tracked, admitted = recovery.admit_job(session, candidate_job) + tracked_job_id = str(tracked.get("jobId") or "") + job = session.get(JobRun, tracked_job_id) + if job is None: + raise RuntimeError("PrivateMuse recovery owner job is unavailable") + if admitted: + start_job(session, job) + set_job_progress( + session, job, 20, "Validating fixed PrivateMuse deployment target." + ) + try: + enrollment = recovery.run( + session=session, + job_id=job.id if admitted else candidate_job.id, + ) + status = str(enrollment.get("status") or "error") + success = status in {"enrolled", "already-enrolled", "reconciled"} + message = str( + enrollment.get("reason") + or ( + "PrivateMuse deployment target state verified." + if success + else "PrivateMuse deployment target enrollment failed." + ) + ) + except Exception as exc: + guarded = recovery.guarded_unknown_result( + session, + reason=( + "PrivateMuse recovery crossed its durable execution boundary but " + "local result handling failed; reconcile the recorded execution and " + "do not retry" + ), + ) + if guarded is not None: + success = False + status = "outcome_unknown" + message = str(guarded["reason"]) + enrollment = guarded + else: + success = False + status = "error" + message = ( + f"{type(exc).__name__}: PrivateMuse deployment target enrollment failed" + ) + enrollment = {"status": status} + + execution_id = str(enrollment.get("execution_id") or "") + original_job_id = str(enrollment.get("original_job_id") or job.id) + original_job = session.get(JobRun, original_job_id) + result_job = original_job or job + evidence_summary = ( + f"{status}; executionId={execution_id}; {message}" + if execution_id + else f"{status}; {message}" + ) + if status in {"in_progress", "outcome_unknown"}: + if result_job.status not in {"succeeded", "failed", "cancelled"}: + completed = set_job_progress( + session, + result_job, + max(result_job.progress, 90 if status == "outcome_unknown" else 5), + evidence_summary, + ) + else: + completed = result_job + elif result_job.status in {"succeeded", "failed", "cancelled"}: + completed = result_job + else: + completed = complete_job( + session, + result_job, + status="succeeded" if success else "failed", + result_summary=evidence_summary, + ) + outward_status = ( + status + if status in {"in_progress", "outcome_unknown"} + else "success" + if success + else "failed" + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result=outward_status, + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": enrollment.get("repository"), + "composeProject": enrollment.get("compose_project"), + "containers": enrollment.get("containers"), + "executionId": execution_id or None, + "originalJobId": original_job_id, + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": outward_status, + "result": outward_status, + "message": message, + "jobId": completed.id, + "auditId": audit.id, + **({"executionId": execution_id} if execution_id else {}), + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + + +def _execute_media_codec_dashboard_reconciliation( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .media_codec_dashboard_deployment_reconciliation import ( + MediaCodecDashboardDeploymentReconciliation, + ) + + action = "deployment.target.reconcile_media_codec_dashboard" + target = "jens/media-codec-dashboard" + steps = [ + "Validate the fixed Media Codec Dashboard deployment contract", + "Verify the live immutable dashboard runtime", + "Reconcile only the bounded controller deployment state", + "Preserve containers, images, mounts and exact-source provenance", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress( + session, + job, + 20, + "Validating fixed Media Codec Dashboard deployment state.", + ) + try: + reconciliation = MediaCodecDashboardDeploymentReconciliation().run() + status = str(reconciliation.get("status") or "error") + success = status == "reconciled" + message = str( + reconciliation.get("reason") + or ( + "Media Codec Dashboard deployment state reconciled." + if success + else "Media Codec Dashboard deployment-state reconciliation failed." + ) + ) + except Exception as exc: + success = False + status = "error" + reconciliation = {"status": status} + message = ( + f"{type(exc).__name__}: " + "Media Codec Dashboard deployment-state reconciliation failed" + ) + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": reconciliation.get("repository"), + "container": reconciliation.get("container"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_privatemuse_webhook_repair( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .privatemuse_webhook_repair import PrivateMuseWebhookRepair + + action = "deployment.webhook.repair_privatemuse" + target = "jens/privatemuse" + steps = [ + "Validate the fixed PrivateMuse runnerless webhook contract", + "Verify controller-owned webhook secret and Gitea repository identity", + "Create or repair only the exact PrivateMuse push hook", + "Re-read the hook and verify active main-only push delivery metadata", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Validating fixed PrivateMuse webhook state.") + try: + repair = PrivateMuseWebhookRepair().run() + status = str(repair.get("status") or "error") + success = status == "repaired" + message = str( + repair.get("reason") + or ( + "PrivateMuse runnerless webhook verified or repaired." + if success + else "PrivateMuse runnerless webhook repair failed." + ) + ) + except Exception as exc: + success = False + status = "error" + message = f"{type(exc).__name__}: PrivateMuse runnerless webhook repair failed" + repair = {"status": status} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-webhook", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": repair.get("repository"), + "endpoint": repair.get("endpoint"), + "branch": repair.get("branch"), + "event": repair.get("event"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "secretMaterialReturned": False, + }, + ) + return { + "success": success, + "status": "success" if success else status, + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_runnerless_target_provisioning( + session: Session, + *, + target: str, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .gitea_project_auto_registration import GiteaProjectAutoRegistration + from .runnerless_target_provisioning import RunnerlessTargetProvisioning + + action = "deployment.runnerless.provision" + application_id = target.strip().lower() + catalog = GiteaProjectAutoRegistration.catalog(session) + projects = catalog.get("projects") if isinstance(catalog, dict) else None + matches = [ + item + for item in projects or [] + if isinstance(item, dict) + and str(item.get("applicationId") or "").strip().lower() == application_id + and bool(item.get("deploymentRegistered")) + and str(item.get("state") or "") in {"registered", "registered_existing"} + ] + if len(matches) != 1: + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": ( + "Runnerless provisioning requires exactly one auto-registered " + "deployment target." + ), + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + + project = matches[0] + repository = str(project.get("repository") or "").strip().lower() + branch = str(project.get("defaultBranch") or "").strip() + revision = str(project.get("revision") or "").strip().lower() + if ( + not repository.startswith("jens/") + or not branch + or len(revision) != 40 + or any(character not in "0123456789abcdef" for character in revision) + ): + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Auto-registered deployment target metadata is incomplete.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + + steps = [ + "Resolve the exact auto-registered application target", + "Verify controller target, repository identity and private Gitea reachability", + "Create or verify one repository-specific runnerless webhook secret", + "Install the exact main-push validation triplet and signed Gitea hook", + "Verify the hook without returning credentials", + ] + job = create_job( + session, + action_id=action, + target=application_id, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress( + session, + job, + 20, + "Provisioning exact-target runnerless delivery metadata.", + ) + try: + provisioned = RunnerlessTargetProvisioning().run( + repository=repository, + branch=branch, + ) + status = str(provisioned.get("status") or "error") + success = status == "ready" + message = str( + provisioned.get("reason") + or ( + "Runnerless delivery is ready for the registered target." + if success + else "Runnerless delivery provisioning failed." + ) + ) + except Exception as exc: + provisioned = {"status": "error"} + status = "error" + success = False + message = ( + f"{type(exc).__name__}: runnerless target provisioning failed" + ) + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=application_id, + target_type="deployment-webhook", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "applicationId": application_id, + "repository": repository, + "revision": revision, + "branch": provisioned.get("branch"), + "endpoint": provisioned.get("endpoint"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "secretMaterialReturned": False, + "repositoryCallerSelectable": False, + "branchCallerSelectable": False, + }, + ) + return { + "success": success, + "status": "success" if success else status, + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + "applicationId": application_id, + "repository": repository, + "revision": revision, + } + + +def _execute_workmesh_first_deployment( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .workmesh_first_deployment import WorkMeshFirstDeployment + + action = "deployment.target.deploy_workmesh" + target = "jens/workmesh" + steps = [ + "Verify the fixed WorkMesh v0.1.0 protected-main release contract", + "Create or validate the external mode-0600 WorkMesh runtime environment", + "Reserve the fixed loopback port set and reconcile the exact-source controller target", + "Run the official deployment-controller preflight and transactional first deploy", + "Verify the controller-owned deployment receipt without exposing secret values", + ] + service = WorkMeshFirstDeployment() + tracked_job_id = service.tracked_job_id(session) + job = session.get(JobRun, tracked_job_id) if tracked_job_id else None + orphan_running = False + if job is None: + job = session.exec( + select(JobRun) + .where(JobRun.action_id == action, JobRun.target == target, JobRun.status == "running") + .order_by(JobRun.started_at.desc()) + ).first() + if job is not None: + orphan_running = True + else: + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + + try: + deployment = ( + { + "status": "outcome_unknown", + "reason": "An earlier WorkMesh deployment job is running without matching durable execution state; reconcile it before another deploy", + } + if orphan_running + else service.run(session, job=job) + ) + status = str(deployment.get("status") or "error") + execution_id = str(deployment.get("execution_id") or "") + if status == "deployed": + success = True + message = "WorkMesh v0.1.0 first deployment completed with controller receipt evidence." + elif status == "in_progress": + success = False + message = "WorkMesh v0.1.0 first deployment is still running." + elif status == "outcome_unknown": + success = False + message = str(deployment.get("reason") or "WorkMesh deployment outcome requires reconciliation.") + else: + success = False + message = str(deployment.get("reason") or "WorkMesh v0.1.0 first deployment failed closed.") + except Exception as exc: + status = "error" + execution_id = "" + success = False + deployment = {"status": status} + message = f"{type(exc).__name__}: WorkMesh first-deploy reconciliation failed" + + if status in {"in_progress", "outcome_unknown"}: + if job.status not in {"succeeded", "failed", "cancelled"}: + completed = set_job_progress( + session, + job, + max(job.progress, 90 if status == "outcome_unknown" else 25), + message, + ) + else: + completed = job + elif job.status in {"succeeded", "failed", "cancelled"}: + completed = job + else: + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + + outward_status = ( + status + if status in {"in_progress", "outcome_unknown"} + else "success" + if success + else "failed" + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result=outward_status, + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": deployment.get("repository", target), + "revision": deployment.get("revision"), + "composeProject": deployment.get("compose_project"), + "containers": deployment.get("containers"), + "ports": deployment.get("ports"), + "executionId": execution_id or None, + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "secretsReturned": False, + }, + ) + return { + "success": success, + "status": outward_status, + "result": outward_status, + "message": message, + "jobId": completed.id, + "auditId": audit.id, + **({"executionId": execution_id} if execution_id else {}), + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_portfolio_target_enrollment( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .portfolio_deployment_recovery import PortfolioDeploymentRecovery + + action = "deployment.target.register_portfolio" + target = "jens/portfolio" + steps = [ + "Validate the fixed Portfolio deployment contract", + "Verify or re-attest the proven live Portfolio web/API pair", + "Atomically reconcile the paired controller target state", + "Preserve exact-source deployment provenance", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Validating fixed Portfolio deployment target.") + try: + enrollment = PortfolioDeploymentRecovery().run() + status = str(enrollment.get("status") or "error") + success = status in {"enrolled", "already-enrolled", "reconciled"} + message = str( + enrollment.get("reason") + or ( + "Portfolio deployment target state verified." + if success + else "Portfolio deployment target enrollment failed." + ) + ) + except Exception as exc: + success = False + status = "error" + message = f"{type(exc).__name__}: Portfolio deployment target enrollment failed" + enrollment = {"status": status} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": enrollment.get("repository"), + "composeProject": enrollment.get("compose_project"), + "containers": enrollment.get("containers"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_privatemuse_target_enrollment( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .privatemuse_deployment_recovery import PrivateMuseDeploymentRecovery + + action = "deployment.target.register_privatemuse" + target = "jens/privatemuse" + steps = [ + "Validate the fixed PrivateMuse deployment contract", + "Verify or re-attest the proven live PrivateMuse production trio", + "Atomically reconcile only the bounded controller receipt state", + "Preserve immutable images, media, models and exact-source provenance", + ] + recovery = PrivateMuseDeploymentRecovery() + candidate_job = prepare_job( + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + tracked, admitted = recovery.admit_job(session, candidate_job) + tracked_job_id = str(tracked.get("jobId") or "") + job = session.get(JobRun, tracked_job_id) + if job is None: + raise RuntimeError("PrivateMuse recovery owner job is unavailable") + if admitted: + start_job(session, job) + set_job_progress( + session, job, 20, "Validating fixed PrivateMuse deployment target." + ) + try: + enrollment = recovery.run( + session=session, + job_id=job.id if admitted else candidate_job.id, + ) + status = str(enrollment.get("status") or "error") + success = status in {"enrolled", "already-enrolled", "reconciled"} + message = str( + enrollment.get("reason") + or ( + "PrivateMuse deployment target state verified." + if success + else "PrivateMuse deployment target enrollment failed." + ) + ) + except Exception as exc: + guarded = recovery.guarded_unknown_result( + session, + reason=( + "PrivateMuse recovery crossed its durable execution boundary but " + "local result handling failed; reconcile the recorded execution and " + "do not retry" + ), + ) + if guarded is not None: + success = False + status = "outcome_unknown" + message = str(guarded["reason"]) + enrollment = guarded + else: + success = False + status = "error" + message = ( + f"{type(exc).__name__}: PrivateMuse deployment target enrollment failed" + ) + enrollment = {"status": status} + + execution_id = str(enrollment.get("execution_id") or "") + original_job_id = str(enrollment.get("original_job_id") or job.id) + original_job = session.get(JobRun, original_job_id) + result_job = original_job or job + evidence_summary = ( + f"{status}; executionId={execution_id}; {message}" + if execution_id + else f"{status}; {message}" + ) + if status in {"in_progress", "outcome_unknown"}: + if result_job.status not in {"succeeded", "failed", "cancelled"}: + completed = set_job_progress( + session, + result_job, + max(result_job.progress, 90 if status == "outcome_unknown" else 5), + evidence_summary, + ) + else: + completed = result_job + elif result_job.status in {"succeeded", "failed", "cancelled"}: + completed = result_job + else: + completed = complete_job( + session, + result_job, + status="succeeded" if success else "failed", + result_summary=evidence_summary, + ) + outward_status = ( + status + if status in {"in_progress", "outcome_unknown"} + else "success" + if success + else "failed" + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result=outward_status, + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": enrollment.get("repository"), + "composeProject": enrollment.get("compose_project"), + "containers": enrollment.get("containers"), + "executionId": execution_id or None, + "originalJobId": original_job_id, + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": outward_status, + "result": outward_status, + "message": message, + "jobId": completed.id, + "auditId": audit.id, + **({"executionId": execution_id} if execution_id else {}), + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + + +def _execute_media_codec_dashboard_reconciliation( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .media_codec_dashboard_deployment_reconciliation import ( + MediaCodecDashboardDeploymentReconciliation, + ) + + action = "deployment.target.reconcile_media_codec_dashboard" + target = "jens/media-codec-dashboard" + steps = [ + "Validate the fixed Media Codec Dashboard deployment contract", + "Verify the live immutable dashboard runtime", + "Reconcile only the bounded controller deployment state", + "Preserve containers, images, mounts and exact-source provenance", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress( + session, + job, + 20, + "Validating fixed Media Codec Dashboard deployment state.", + ) + try: + reconciliation = MediaCodecDashboardDeploymentReconciliation().run() + status = str(reconciliation.get("status") or "error") + success = status == "reconciled" + message = str( + reconciliation.get("reason") + or ( + "Media Codec Dashboard deployment state reconciled." + if success + else "Media Codec Dashboard deployment-state reconciliation failed." + ) + ) + except Exception as exc: + success = False + status = "error" + reconciliation = {"status": status} + message = ( + f"{type(exc).__name__}: " + "Media Codec Dashboard deployment-state reconciliation failed" + ) + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-target", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": reconciliation.get("repository"), + "container": reconciliation.get("container"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def _execute_privatemuse_webhook_repair( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .privatemuse_webhook_repair import PrivateMuseWebhookRepair + + action = "deployment.webhook.repair_privatemuse" + target = "jens/privatemuse" + steps = [ + "Validate the fixed PrivateMuse runnerless webhook contract", + "Verify controller-owned webhook secret and Gitea repository identity", + "Create or repair only the exact PrivateMuse push hook", + "Re-read the hook and verify active main-only push delivery metadata", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Validating fixed PrivateMuse webhook state.") + try: + repair = PrivateMuseWebhookRepair().run() + status = str(repair.get("status") or "error") + success = status == "repaired" + message = str( + repair.get("reason") + or ( + "PrivateMuse runnerless webhook verified or repaired." + if success + else "PrivateMuse runnerless webhook repair failed." + ) + ) + except Exception as exc: + success = False + status = "error" + message = f"{type(exc).__name__}: PrivateMuse runnerless webhook repair failed" + repair = {"status": status} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-webhook", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "repository": repair.get("repository"), + "endpoint": repair.get("endpoint"), + "branch": repair.get("branch"), + "event": repair.get("event"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "secretMaterialReturned": False, + }, + ) + return { + "success": success, + "status": "success" if success else status, + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + + +def _execute_controller_artifact_promote( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .controller_artifact_promoter import ControllerArtifactPromoter + + action = "controller.artifact.promote" + target = "tower/deployment-controller" + steps = [ + "Resolve protected current main and successful push validation", + "Reuse the immutable image of the live deployment controller", + "Run one fixed ephemeral controller-promoter helper", + "Execute the existing rollbackable root controller upgrade procedure", + "Verify the installed controller release identity", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 20, "Promoting fixed root deployment-controller artifact.") + try: + result = ControllerArtifactPromoter().run() + status = str(result.get("status") or "error") + success = status in {"promoted", "already_applied"} + message = str( + result.get("reason") + or ( + "Root deployment-controller artifact verified." + if success + else "Root deployment-controller promotion failed." + ) + ) + except Exception as exc: + result = {"status": "error"} + status = "error" + success = False + message = f"{type(exc).__name__}: controller artifact promotion failed" + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="deployment-controller", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "revision": result.get("revision"), + "controllerVersion": result.get("controllerVersion"), + "artifactSha256": result.get("artifactSha256"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "callerRevisionSelectable": False, + "callerCommandSelectable": False, + "callerMountSelectable": False, + "helperTargetSelectable": False, + }, + ) + return { + "success": success, + "status": "success" if success else status, + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + "revision": result.get("revision"), + "controllerVersion": result.get("controllerVersion"), + "artifactSha256": result.get("artifactSha256"), + } + + +def _execute_runner_fleet_upgrade_v2( + session: Session, + *, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + from .deployment_broker import DeploymentBrokerClient + + action = "runner.fleet.upgrade_v2" + target = "tower/runner-fleet-v2" + steps = [ + "Validate exact fixed runner-fleet target", + "Call HMAC-signed root controller endpoint", + "Prove protected main and successful push validation", + "Execute exact-revision fleet-v2 migration script", + "Verify specialized runner topology", + ] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 25, "Requesting fixed Tower runner-fleet v2 migration.") + try: + result = DeploymentBrokerClient().runner_fleet_upgrade_v2(reason=reason) + status = str(result.get("status") or "error") + success = status in {"upgraded", "already_applied"} + message = str( + result.get("reason") + or ( + "Tower runner-fleet v2 migration verified." + if success + else "Tower runner-fleet v2 migration was rejected or failed." + ) + ) + except Exception as exc: + result = {"status": "error"} + status = "error" + success = False + message = f"{type(exc).__name__}: runner fleet v2 migration failed" + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="runner-fleet", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={ + "status": status, + "revision": result.get("revision"), + "verifiedRunners": result.get("verifiedRunners"), + }, + redacted_metadata={ + "executor": action, + "integration": "mcp" if source == "chatgpt-mcp" else "operator", + "originalPolicyDecision": original_policy_decision, + "scriptPathCallerSelectable": False, + "runnerTargetCallerSelectable": False, + }, + ) + return { + "success": success, + "status": "success" if success else status, + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + "controllerStatus": status, + "revision": result.get("revision"), + "verifiedRunners": result.get("verifiedRunners"), + } + + +def _approved_connector_call(action: str) -> tuple[Callable[[], dict[str, Any]], str]: + if action == "deluge.pause_all": + return lambda: DelugeConnector().pause_all(), "all_torrents_paused" + if action == "deluge.resume_all": + return lambda: DelugeConnector().resume_all(), "all_torrents_resumed" + if action == "plex.scan_all_libraries": + return lambda: PlexConnector().scan_all_libraries(), "scan_all_started" + raise ValueError("No approved executor is registered for this action.") + + +def execute_governed_action( + session: Session, + *, + action: str, + target: str, + source: str, + risk: str, + reason: str, + original_policy_decision: str, +) -> dict[str, Any]: + """Execute a typed action after the trusted MCP boundary has evaluated policy. + + This path deliberately does not create or consume an Approval row. It is used by + authenticated MCP automation in no-human-approval mode. The central policy still + fails closed for ``blocked`` actions before this function is called, and only the + small allowlist of typed rollback/post-check capable executors below is reachable. + """ + + if not approval_execution_supported(action): + return { + "success": False, + "status": "failed", + "result": "unsupported", + "message": "No typed MCP executor is registered for this action.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + + if action == "controller.artifact.promote": + if target.strip().lower() != "tower/deployment-controller": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Controller artifact promotion accepts only tower/deployment-controller.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_controller_artifact_promote( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + + if action == "runner.fleet.upgrade_v2": + if target.strip().lower() != "tower/runner-fleet-v2": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Runner fleet v2 upgrade accepts only tower/runner-fleet-v2.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_runner_fleet_upgrade_v2( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + + if action == "deployment.runnerless.provision": + return _execute_runnerless_target_provisioning( + session, + target=target, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + + if action == "deployment.target.deploy_workmesh": + if target.strip().lower() != "jens/workmesh": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "WorkMesh first deploy accepts only the fixed jens/workmesh target.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_workmesh_first_deployment( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + + if action == "deployment.target.promote_workmesh": + return { + "success": False, + "status": "failed", + "result": "approval_required", + "message": ( + "WorkMesh promotion requires the exact-revision preflight and human approval flow." + ), + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + + if action == "deployment.targets.sync": + if target.strip().lower() != "jens/*": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Deployment target sync accepts only the fixed jens/* catalog target.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_deployment_target_sync( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + + if action in {"unraid.vm.start", "unraid.vm.resume", "unraid.vm.pause"}: + from .unraid_vm_service import VM_ACTIONS, execute_vm_action + + vm_action = next( + ( + name + for name, definition in VM_ACTIONS.items() + if definition["action_id"] == action + ), + None, + ) + if vm_action not in {"start", "resume", "pause"}: + return { + "success": False, + "status": "failed", + "result": "unsupported", + "message": "No safe VM executor is registered for this action.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + result = execute_vm_action( + session, + vm_id=target, + action=vm_action, + source=source, + policy_decision="allowed", + reason=reason, + ) + return { + **result, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + if action == "deployment.target.register_portfolio": + if target.strip().lower() != "jens/portfolio": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Portfolio target registration accepts only jens/portfolio.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_portfolio_target_enrollment( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + if action == "deployment.target.register_privatemuse": + if target.strip().lower() != "jens/privatemuse": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "PrivateMuse target registration accepts only jens/privatemuse.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_privatemuse_target_enrollment( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + if action == "deployment.target.reconcile_media_codec_dashboard": + if target.strip().lower() != "jens/media-codec-dashboard": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": ( + "Media Codec Dashboard reconciliation accepts only " + "jens/media-codec-dashboard." + ), + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_media_codec_dashboard_reconciliation( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + if action == "deployment.webhook.repair_privatemuse": + if target.strip().lower() != "jens/privatemuse": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "PrivateMuse webhook repair accepts only jens/privatemuse.", + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + } + return _execute_privatemuse_webhook_repair( + session, + source=source, + risk=risk, + reason=reason, + original_policy_decision=original_policy_decision, + ) + if action == "container.restart": + from .container_restart_service import execute_container_restart + + result = execute_container_restart( + session=session, + action=action, + target=target, + source=source, + risk=risk, + steps=[ + "Validate MCP policy", + "Resolve container", + "Restart container", + "Post-check health", + ], + policy_override={ + "decision": "allowed", + "reason": reason, + "risk": risk, + }, + ) + return { + **result, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + apply_stored_connector_settings(session) + connector_call, default_message = _approved_connector_call(action) + steps = _DIRECT_STEPS[action] + job = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision="allowed", + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 35, "MCP policy authorized; calling typed connector.") + + try: + connector_result = connector_call() + success = bool(connector_result.get("success")) + message = str( + connector_result.get("message") + or (default_message if success else "connector_action_failed") + ) + except Exception as exc: # Connector errors must become an audited failed job. + success = False + message = f"{type(exc).__name__}: connector action failed" + connector_result = {"status": "error"} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + target_type="approval", + risk=risk, + policy_decision="allowed", + reason=reason, + result="success" if success else "failed", + execution_plan={"steps": steps, "jobId": completed.id}, + after_state={"status": connector_result.get("status"), "message": message}, + redacted_metadata={ + "executor": action, + "integration": "mcp", + "originalPolicyDecision": original_policy_decision, + }, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + "policyDecision": "allowed", + "originalPolicyDecision": original_policy_decision, + } + + +def execute_approved_action(session: Session, approval: Approval) -> dict[str, Any]: + if not approval_execution_supported(approval.action): + return { + "success": False, + "status": "failed", + "result": "unsupported", + "message": "Deze goedgekeurde actie heeft nog geen rollback-veilige executor.", + } + + if approval.action == "controller.artifact.promote": + if approval.target.strip().lower() != "tower/deployment-controller": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Controller artifact promotion accepts only tower/deployment-controller.", + } + return _execute_controller_artifact_promote( + session, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + + if approval.action == "deployment.runnerless.provision": + return _execute_runnerless_target_provisioning( + session, + target=approval.target, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + + if approval.action == "runner.fleet.upgrade_v2": + if approval.target.strip().lower() != "tower/runner-fleet-v2": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Runner fleet v2 upgrade accepts only tower/runner-fleet-v2.", + } + return _execute_runner_fleet_upgrade_v2( + session, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + + if approval.action in {"unraid.vm.start", "unraid.vm.resume", "unraid.vm.pause"}: + from .unraid_vm_service import execute_approved_vm_action + + return execute_approved_vm_action(session, approval) + + if approval.action == "deployment.target.promote_workmesh": + from .workmesh_promotion import WorkMeshPromotionService + + return WorkMeshPromotionService().execute_approved(session, approval) + + if approval.action == "deployment.target.register_portfolio": + if approval.target.strip().lower() != "jens/portfolio": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Portfolio target registration accepts only jens/portfolio.", + } + return _execute_portfolio_target_enrollment( + session, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + if approval.action == "deployment.target.register_privatemuse": + if approval.target.strip().lower() != "jens/privatemuse": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "PrivateMuse target registration accepts only jens/privatemuse.", + } + return _execute_privatemuse_target_enrollment( + session, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + if approval.action == "deployment.target.reconcile_media_codec_dashboard": + if approval.target.strip().lower() != "jens/media-codec-dashboard": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": ( + "Media Codec Dashboard reconciliation accepts only " + "jens/media-codec-dashboard." + ), + } + return _execute_media_codec_dashboard_reconciliation( + session, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + if approval.action == "deployment.webhook.repair_privatemuse": + if approval.target.strip().lower() != "jens/privatemuse": + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "PrivateMuse webhook repair accepts only jens/privatemuse.", + } + return _execute_privatemuse_webhook_repair( + session, + source="approved_operator", + risk=approval.risk, + reason=approval.reason or f"Approved through {approval.id}.", + original_policy_decision="approval_required", + ) + if approval.action == "container.restart": + from .container_restart_service import execute_container_restart + + return execute_container_restart( + session=session, + action=approval.action, + target=approval.target, + source="approved_operator", + risk=approval.risk, + steps=[ + "Load approved request", + "Resolve container", + "Restart container", + "Post-check health", + ], + approved=True, + ) + + apply_stored_connector_settings(session) + connector_call, default_message = _approved_connector_call(approval.action) + steps = _APPROVAL_STEPS[approval.action] + job = create_job( + session, + action_id=approval.action, + target=approval.target, + source="approved_operator", + risk=approval.risk, + policy_decision="approval_required", + steps=steps, + ) + start_job(session, job) + set_job_progress( + session, job, 35, f"Approval {approval.id} validated; calling connector." + ) + + try: + connector_result = connector_call() + success = bool(connector_result.get("success")) + message = str( + connector_result.get("message") + or (default_message if success else "connector_action_failed") + ) + except Exception as exc: # Connector errors must become an audited failed job. + success = False + message = f"{type(exc).__name__}: connector action failed" + connector_result = {"status": "error"} + + completed = complete_job( + session, + job, + status="succeeded" if success else "failed", + result_summary=message, + ) + audit = log_event( + session, + source="approval", + identity="operator", + action_id=approval.action, + target=approval.target, + target_type="approval", + risk=approval.risk, + policy_decision="approval_required", + reason=approval.reason or f"Approved through {approval.id}.", + result="success" if success else "failed", + execution_plan={ + "steps": steps, + "approvalId": approval.id, + "jobId": completed.id, + }, + after_state={"status": connector_result.get("status"), "message": message}, + redacted_metadata={"approvalId": approval.id, "executor": approval.action}, + ) + return { + "success": success, + "status": "success" if success else "failed", + "result": "success" if success else "failed", + "message": message, + "jobId": completed.id, + "auditId": audit.id, + } diff --git a/backend/app/services/action_registry.py b/backend/app/services/action_registry.py new file mode 100644 index 0000000..820e0b4 --- /dev/null +++ b/backend/app/services/action_registry.py @@ -0,0 +1,614 @@ +from __future__ import annotations + +ACTIONS = [ + 'container.status', + 'container.logs', + 'container.clear_logs', + 'container.restart', + 'container.stop', + 'runner.fleet.upgrade_v2', + 'controller.artifact.promote', + 'gateway.clear_cache', + 'stack.status', + 'stack.redeploy', + 'deployment.preflight', + 'deployment.targets.sync', + 'deployment.runnerless.provision', + 'deployment.target.deploy_workmesh', + 'deployment.target.promote_workmesh', + 'deployment.target.register_portfolio', + 'deployment.target.register_privatemuse', + 'deployment.target.reconcile_media_codec_dashboard', + 'deployment.webhook.repair_privatemuse', + 'privatemuse.comfyui.install_artifact', + 'deployment.apply', + 'deployment.rollback', + 'volume.delete', + 'unraid.health_summary', + 'unraid.vms', + 'unraid.vm.start', + 'unraid.vm.resume', + 'unraid.vm.pause', + 'unraid.vm.stop', + 'unraid.vm.reboot', + 'unraid.vm.force_stop', + 'unraid.vm.reset', + 'unraid.mover.start', + 'setup.status', + 'safety.summary', + 'media_pipeline.check', + 'media_pipeline.diagnose', + 'notifications.summary', + 'notifications.evaluate_safety', + 'sonarr.search_series', + 'sonarr.add_series', + 'sonarr.health', + 'sonarr.queue_status', + 'sonarr.wanted_missing', + 'sonarr.delete_series', + 'sonarr.mass_edit', + 'radarr.search_movie', + 'radarr.add_movie', + 'radarr.health', + 'radarr.queue_status', + 'radarr.wanted_missing', + 'radarr.delete_movie', + 'radarr.delete_files', + 'radarr.mass_edit', + 'jellyfin.scan_library', + 'widefrog.health', + 'widefrog.queue_status', + 'widefrog.failed_jobs', + 'widefrog.activity', + 'widefrog.retry_failed', + 'widefrog.scan_library', + 'widefrog.download_status', + 'prowlarr.health', + 'prowlarr.indexer_status', + 'prowlarr.application_status', + 'prowlarr.indexer_health', + 'prowlarr.search_test', + 'prowlarr.sync_status', + 'prowlarr.delete_indexer', + 'prowlarr.delete_application', + 'prowlarr.update_indexer', + 'prowlarr.update_application', + 'prowlarr.grab_release', + 'prowlarr.push_release', + 'prowlarr.mass_edit', + 'prowlarr.test_all_indexers', + 'deluge.health', + 'deluge.session_status', + 'deluge.torrent_status', + 'deluge.active_downloads', + 'deluge.stalled_downloads', + 'deluge.paused_downloads', + 'deluge.speed_status', + 'deluge.pause_torrent', + 'deluge.resume_torrent', + 'deluge.pause_all', + 'deluge.resume_all', + 'deluge.remove_torrent', + 'deluge.remove_torrent_data', + 'deluge.delete_data', + 'deluge.force_recheck', + 'deluge.move_storage', + 'deluge.add_torrent', + 'deluge.add_magnet', + 'deluge.mass_edit', + 'deluge.set_options', + 'deluge.unknown_mutation', + 'plex.health', + 'plex.server_status', + 'plex.library_status', + 'plex.active_sessions', + 'plex.recent_media', + 'plex.scan_library', + 'plex.scan_all_libraries', + 'plex.stream_summary', + 'tautulli.health', + 'tautulli.status', + 'tautulli.activity', + 'tautulli.history', + 'tautulli.users', + 'tautulli.libraries', + 'tautulli.recently_added', + 'tautulli.watch_stats', + 'tautulli.stream_summary', + 'tautulli.plex_info', + 'npm.health', + 'npm.proxy_hosts', + 'npm.certificates', + 'npm.expiring_certificates', + 'npm.access_lists', + 'npm.exposure_summary', + 'npm.host_reachability', + 'npm.security_warnings', + 'npm.create_proxy_host', + 'npm.update_proxy_host', + 'npm.delete_proxy_host', + 'npm.enable_proxy_host', + 'npm.disable_proxy_host', + 'npm.renew_certificate', + 'npm.delete_certificate', + 'npm.modify_access_list', + 'npm.create_user', + 'npm.update_user', + 'npm.delete_user', + 'npm.mass_edit', + 'npm.unknown_mutation', + 'authentik.health', + 'authentik.system_status', + 'authentik.applications', + 'authentik.providers', + 'authentik.proxy_providers', + 'authentik.oauth2_providers', + 'authentik.outposts', + 'authentik.outpost_health', + 'authentik.flows_summary', + 'authentik.policies_summary', + 'authentik.events', + 'authentik.auth_coverage', + 'authentik.security_warnings', + 'authentik.create_application', + 'authentik.update_application', + 'authentik.delete_application', + 'authentik.create_provider', + 'authentik.update_provider', + 'authentik.delete_provider', + 'authentik.create_outpost', + 'authentik.update_outpost', + 'authentik.delete_outpost', + 'authentik.create_policy', + 'authentik.update_policy', + 'authentik.delete_policy', + 'authentik.create_user', + 'authentik.update_user', + 'authentik.delete_user', + 'authentik.create_group', + 'authentik.update_group', + 'authentik.delete_group', + 'authentik.rotate_token', + 'authentik.force_sync', + 'authentik.mass_edit', + 'authentik.unknown_mutation', + 'tautulli.terminate_session', + 'tautulli.delete_newsletter', + 'tautulli.delete_notification_log', + 'tautulli.delete_notifier', + 'tautulli.delete_recently_added', + 'tautulli.download_config', + 'tautulli.download_database', + 'tautulli.download_export', + 'tautulli.download_log', + 'tautulli.download_plex_log', + 'tautulli.notify', + 'tautulli.user_management', + 'tautulli.mass_edit', + 'tautulli.unknown_mutation', + 'plex.delete_media', + 'plex.delete_library', + 'plex.empty_trash', + 'plex.clean_bundles', + 'plex.optimize_database', + 'plex.kill_session', + 'plex.user_management', + 'plex.force_refresh_all_metadata', + 'plex.mass_edit', + 'plex.unknown_mutation', + 'widefrog.delete_job', + 'widefrog.delete_media', + 'widefrog.clear_library', + 'widefrog.clear_queue', + 'widefrog.reset_database', + 'widefrog.delete_downloads', + 'widefrog.prune', + 'playbook.run', +] + +ACTION_RISK = { + 'container.status': 'low', + 'container.logs': 'low', + 'container.clear_logs': 'high', + 'container.restart': 'medium', + 'container.stop': 'high', + 'runner.fleet.upgrade_v2': 'high', + 'controller.artifact.promote': 'high', + 'gateway.clear_cache': 'high', + 'stack.status': 'low', + 'stack.redeploy': 'high', + 'deployment.preflight': 'low', + 'deployment.targets.sync': 'high', + 'deployment.runnerless.provision': 'high', + 'deployment.target.deploy_workmesh': 'high', + 'deployment.target.promote_workmesh': 'high', + 'deployment.target.register_portfolio': 'high', + 'deployment.target.register_privatemuse': 'high', + 'deployment.target.reconcile_media_codec_dashboard': 'high', + 'deployment.webhook.repair_privatemuse': 'high', + 'privatemuse.comfyui.install_artifact': 'high', + 'deployment.apply': 'high', + 'deployment.rollback': 'high', + 'volume.delete': 'critical', + 'unraid.health_summary': 'low', + 'unraid.vms': 'low', + 'unraid.vm.start': 'medium', + 'unraid.vm.resume': 'medium', + 'unraid.vm.pause': 'high', + 'unraid.vm.stop': 'critical', + 'unraid.vm.reboot': 'critical', + 'unraid.vm.force_stop': 'critical', + 'unraid.vm.reset': 'critical', + 'unraid.mover.start': 'high', + 'setup.status': 'low', + 'safety.summary': 'low', + 'media_pipeline.check': 'low', + 'media_pipeline.diagnose': 'low', + 'notifications.summary': 'low', + 'notifications.evaluate_safety': 'medium', + 'sonarr.search_series': 'low', + 'sonarr.add_series': 'low', + 'sonarr.health': 'low', + 'sonarr.queue_status': 'low', + 'sonarr.wanted_missing': 'low', + 'sonarr.delete_series': 'critical', + 'sonarr.mass_edit': 'critical', + 'radarr.search_movie': 'low', + 'radarr.add_movie': 'low', + 'radarr.health': 'low', + 'radarr.queue_status': 'low', + 'radarr.wanted_missing': 'low', + 'radarr.delete_movie': 'critical', + 'radarr.delete_files': 'critical', + 'radarr.mass_edit': 'critical', + 'jellyfin.scan_library': 'medium', + 'widefrog.health': 'low', + 'widefrog.queue_status': 'low', + 'widefrog.failed_jobs': 'low', + 'widefrog.activity': 'low', + 'widefrog.retry_failed': 'medium', + 'widefrog.scan_library': 'medium', + 'widefrog.download_status': 'low', + 'prowlarr.health': 'low', + 'prowlarr.indexer_status': 'low', + 'prowlarr.application_status': 'low', + 'prowlarr.indexer_health': 'low', + 'prowlarr.search_test': 'medium', + 'prowlarr.sync_status': 'low', + 'prowlarr.delete_indexer': 'critical', + 'prowlarr.delete_application': 'critical', + 'prowlarr.update_indexer': 'high', + 'prowlarr.update_application': 'high', + 'prowlarr.grab_release': 'critical', + 'prowlarr.push_release': 'critical', + 'prowlarr.mass_edit': 'critical', + 'prowlarr.test_all_indexers': 'medium', + 'deluge.health': 'low', + 'deluge.session_status': 'low', + 'deluge.torrent_status': 'low', + 'deluge.active_downloads': 'low', + 'deluge.stalled_downloads': 'low', + 'deluge.paused_downloads': 'low', + 'deluge.speed_status': 'low', + 'deluge.pause_torrent': 'medium', + 'deluge.resume_torrent': 'medium', + 'deluge.pause_all': 'high', + 'deluge.resume_all': 'high', + 'deluge.remove_torrent': 'critical', + 'deluge.remove_torrent_data': 'critical', + 'deluge.delete_data': 'critical', + 'deluge.force_recheck': 'high', + 'deluge.move_storage': 'high', + 'deluge.add_torrent': 'high', + 'deluge.add_magnet': 'high', + 'deluge.mass_edit': 'critical', + 'deluge.set_options': 'critical', + 'deluge.unknown_mutation': 'critical', + 'plex.health': 'low', + 'plex.server_status': 'low', + 'plex.library_status': 'low', + 'plex.active_sessions': 'low', + 'plex.recent_media': 'low', + 'plex.scan_library': 'medium', + 'plex.scan_all_libraries': 'high', + 'plex.stream_summary': 'low', + 'tautulli.health': 'low', + 'tautulli.status': 'low', + 'tautulli.activity': 'low', + 'tautulli.history': 'medium', + 'tautulli.users': 'medium', + 'tautulli.libraries': 'low', + 'tautulli.recently_added': 'low', + 'tautulli.watch_stats': 'medium', + 'tautulli.stream_summary': 'low', + 'tautulli.plex_info': 'low', + 'npm.health': 'low', + 'npm.proxy_hosts': 'medium', + 'npm.certificates': 'low', + 'npm.expiring_certificates': 'low', + 'npm.access_lists': 'medium', + 'npm.exposure_summary': 'medium', + 'npm.host_reachability': 'low', + 'npm.security_warnings': 'low', + 'npm.create_proxy_host': 'critical', + 'npm.update_proxy_host': 'critical', + 'npm.delete_proxy_host': 'critical', + 'npm.enable_proxy_host': 'high', + 'npm.disable_proxy_host': 'high', + 'npm.renew_certificate': 'high', + 'npm.delete_certificate': 'critical', + 'npm.modify_access_list': 'critical', + 'npm.create_user': 'critical', + 'npm.update_user': 'critical', + 'npm.delete_user': 'critical', + 'npm.mass_edit': 'critical', + 'npm.unknown_mutation': 'critical', + 'authentik.health': 'low', + 'authentik.system_status': 'low', + 'authentik.applications': 'medium', + 'authentik.providers': 'medium', + 'authentik.proxy_providers': 'medium', + 'authentik.oauth2_providers': 'medium', + 'authentik.outposts': 'medium', + 'authentik.outpost_health': 'low', + 'authentik.flows_summary': 'medium', + 'authentik.policies_summary': 'medium', + 'authentik.events': 'medium', + 'authentik.auth_coverage': 'medium', + 'authentik.security_warnings': 'low', + 'authentik.create_application': 'critical', + 'authentik.update_application': 'critical', + 'authentik.delete_application': 'critical', + 'authentik.create_provider': 'critical', + 'authentik.update_provider': 'critical', + 'authentik.delete_provider': 'critical', + 'authentik.create_outpost': 'critical', + 'authentik.update_outpost': 'critical', + 'authentik.delete_outpost': 'critical', + 'authentik.create_policy': 'critical', + 'authentik.update_policy': 'critical', + 'authentik.delete_policy': 'critical', + 'authentik.create_user': 'critical', + 'authentik.update_user': 'critical', + 'authentik.delete_user': 'critical', + 'authentik.create_group': 'critical', + 'authentik.update_group': 'critical', + 'authentik.delete_group': 'critical', + 'authentik.rotate_token': 'critical', + 'authentik.force_sync': 'high', + 'authentik.mass_edit': 'critical', + 'authentik.unknown_mutation': 'critical', + 'tautulli.terminate_session': 'high', + 'tautulli.delete_newsletter': 'critical', + 'tautulli.delete_notification_log': 'critical', + 'tautulli.delete_notifier': 'critical', + 'tautulli.delete_recently_added': 'critical', + 'tautulli.download_config': 'critical', + 'tautulli.download_database': 'critical', + 'tautulli.download_export': 'critical', + 'tautulli.download_log': 'critical', + 'tautulli.download_plex_log': 'critical', + 'tautulli.notify': 'high', + 'tautulli.user_management': 'critical', + 'tautulli.mass_edit': 'critical', + 'tautulli.unknown_mutation': 'critical', + 'plex.delete_media': 'critical', + 'plex.delete_library': 'critical', + 'plex.empty_trash': 'critical', + 'plex.clean_bundles': 'critical', + 'plex.optimize_database': 'critical', + 'plex.kill_session': 'high', + 'plex.user_management': 'critical', + 'plex.force_refresh_all_metadata': 'critical', + 'plex.mass_edit': 'critical', + 'plex.unknown_mutation': 'critical', + 'widefrog.delete_job': 'critical', + 'widefrog.delete_media': 'critical', + 'widefrog.clear_library': 'critical', + 'widefrog.clear_queue': 'critical', + 'widefrog.reset_database': 'critical', + 'widefrog.delete_downloads': 'critical', + 'widefrog.prune': 'critical', + 'playbook.run': 'medium', +} + +READ_ONLY_VERBS = { + 'activity', + 'active_downloads', + 'active_sessions', + 'application_status', + 'applications', + 'auth_coverage', + 'certificates', + 'check', + 'download_status', + 'diagnose', + 'events', + 'expiring_certificates', + 'exposure_summary', + 'failed_jobs', + 'flows_summary', + 'health', + 'health_summary', + 'history', + 'host_reachability', + 'indexer_health', + 'indexer_status', + 'libraries', + 'library_status', + 'logs', + 'oauth2_providers', + 'outpost_health', + 'outposts', + 'paused_downloads', + 'plex_info', + 'preflight', + 'policies_summary', + 'providers', + 'proxy_hosts', + 'proxy_providers', + 'queue_status', + 'recent_media', + 'recently_added', + 'search_movie', + 'search_series', + 'security_warnings', + 'server_status', + 'session_status', + 'speed_status', + 'stalled_downloads', + 'status', + 'stream_summary', + 'summary', + 'sync_status', + 'system_status', + 'torrent_status', + 'users', + 'wanted_missing', + 'watch_stats', + 'vms', +} + +DESTRUCTIVE_VERBS = { + 'clear_cache', + 'clear_library', + 'clear_logs', + 'clear_queue', + 'clean_bundles', + 'delete_application', + 'delete_certificate', + 'delete_data', + 'delete_downloads', + 'delete_files', + 'delete_group', + 'delete_indexer', + 'delete_job', + 'delete_library', + 'delete_media', + 'delete_movie', + 'delete_newsletter', + 'delete_notification_log', + 'delete_notifier', + 'delete_outpost', + 'delete_policy', + 'delete_provider', + 'delete_proxy_host', + 'delete_recently_added', + 'delete_series', + 'delete_user', + 'download_config', + 'download_database', + 'download_export', + 'download_log', + 'download_plex_log', + 'empty_trash', + 'force_refresh_all_metadata', + 'grab_release', + 'mass_edit', + 'modify_access_list', + 'prune', + 'push_release', + 'remove_torrent', + 'remove_torrent_data', + 'reset_database', + 'rotate_token', + 'set_options', + 'stop', + 'unknown_mutation', +} + + +def action_category(action: str) -> str: + return action.split('.', 1)[0] if '.' in action else 'general' + + +def action_default_target(action: str) -> str: + if action == 'controller.artifact.promote': + return 'tower/deployment-controller' + if action == 'runner.fleet.upgrade_v2': + return 'tower/runner-fleet-v2' + if action == 'deployment.targets.sync': + return 'jens/*' + if action == 'deployment.target.deploy_workmesh': + return 'jens/workmesh' + if action == 'deployment.target.promote_workmesh': + return 'jens/workmesh' + if action == 'deployment.target.register_portfolio': + return 'jens/portfolio' + if action in { + 'deployment.target.register_privatemuse', + 'deployment.webhook.repair_privatemuse', + }: + return 'jens/privatemuse' + if action == 'deployment.target.reconcile_media_codec_dashboard': + return 'jens/media-codec-dashboard' + category = action_category(action) + if category == 'container': + return 'container' + if category == 'volume': + return 'volume' + if category == 'media_pipeline': + return 'media-pipeline' + return category + + +def action_label(action: str) -> str: + return action.replace('_', ' ').replace('.', ' / ').title() + + +def action_capability(action: str) -> str: + verb = action.split('.', 1)[1] if '.' in action else action + if action in {'unraid.vm.stop', 'unraid.vm.reboot', 'unraid.vm.force_stop', 'unraid.vm.reset'}: + return 'destructive' + if verb in DESTRUCTIVE_VERBS or ACTION_RISK.get(action) == 'critical': + return 'destructive' + if verb in READ_ONLY_VERBS: + return 'read_only' + return 'operator' + + +def action_blast_radius(action: str, capability: str) -> list[str]: + category = action_category(action) + if capability == 'read_only': + return [f'Read-only visibility for {category}; no state changes are performed.'] + if category == 'volume': + return ['Storage volume data and dependent apps could be affected if execution were ever enabled.'] + if category == 'container': + return ['Target container availability and dependent routes may be affected.'] + if category == 'stack': + return ['Multiple containers in the stack may restart or change state.'] + if category == 'gateway': + return ['AppOps runtime visibility, cached connector state or operator context may be affected if execution were ever enabled.'] + if category in {'authentik', 'npm'}: + return ['Authentication, exposure, routing or access-control behavior may be affected.'] + if category in {'sonarr', 'radarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog'}: + return ['Media pipeline state, queues or user-facing media availability may be affected.'] + if category == 'unraid': + return ['Unraid host or array operations may be affected.'] + return [f'{category} operational state may be affected.'] + + +def action_safeguards(action: str, decision: str, capability: str) -> list[str]: + safeguards = ['Registry detail is read-only and cannot execute this action.'] + if capability == 'destructive': + safeguards.append('Destructive or critical actions stay blocked unless an explicit future policy path is designed.') + if decision == 'blocked': + safeguards.append('AppOps Gateway V1 exposes this only as a blocked boundary; no executor is registered.') + safeguards.append('Current policy decision is blocked.') + elif decision == 'approval_required': + safeguards.append('Current policy decision requires operator approval before execution.') + else: + safeguards.append('Allowed policy preview does not bypass preflight, audit or runtime checks.') + if 'token' in action or 'secret' in action: + safeguards.append('Secret-like values are never returned by the registry.') + return safeguards + + +def action_operator_guidance(action: str, decision: str, capability: str) -> str: + if decision == 'blocked': + return 'AppOps Gateway V1 blocks this action; treat it as governance visibility only and use external/manual tooling until a separate safe execution design exists.' + if decision == 'approval_required': + return 'Use approved operational flows only after reviewing impact and maintenance context.' + if capability == 'read_only': + return 'Safe to inspect for operational awareness.' + return 'Use the existing preflight and audited action flow before any live operation.' diff --git a/backend/app/services/ai_remediation_planner.py b/backend/app/services/ai_remediation_planner.py new file mode 100644 index 0000000..cbe2bdc --- /dev/null +++ b/backend/app/services/ai_remediation_planner.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlmodel import Session, select + +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import AppSetting, Connector +from ..schemas.ai_remediation import ( + RemediationApprovalProposal, + RemediationApprovalRequirement, + RemediationDryRunResult, + RemediationDryRunStepResult, + RemediationEvidence, + RemediationPlan, + RemediationPlanCollection, + RemediationStep, + RemediationVerificationCheck, +) +from . import ai_security_reviewer +from .audit_logger import log_event +from .policy_engine import evaluate_policy +from .secret_store import list_secret_status, secret_storage_status + + +SOURCE_KEY = 'ai_remediation' +MUTATION_TOKENS = ('delete', 'remove', 'prune', 'format', 'renew', 'create', 'update', 'rotate', 'redeploy', 'restart', 'resume', 'pause', 'retry') + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _redact(value: Any) -> Any: + if isinstance(value, str): + lower = value.lower() + if any(token in lower for token in ('token_value', 'secret_value', 'api_key=', 'password=', 'bearer ')): + return '[REDACTED]' + return redact_message(value) + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, dict): + return {str(key): _redact(item) for key, item in value.items() if 'secret' not in str(key).lower() and 'token' not in str(key).lower()} + return value + + +def _setting_key(kind: str, plan_id: str) -> str: + return f'{SOURCE_KEY}:{kind}:{plan_id}' + + +def _get_status(session: Session, plan_id: str) -> str: + row = session.get(AppSetting, _setting_key('plan', plan_id)) + if not row: + return 'draft' + return str(row.value_json.get('status') or 'draft') + + +def _set_status(session: Session, plan_id: str, status: str) -> dict[str, Any]: + row = session.get(AppSetting, _setting_key('plan', plan_id)) + if not row: + row = AppSetting(key=_setting_key('plan', plan_id), is_secret=False) + row.value_json = {'status': status, 'updatedAt': _now()} + row.updated_at = utc_now() + session.add(row) + session.commit() + _audit(session, f'ai.remediation.plan.{status}', plan_id, {'status': status}) + plan = get_plan(session, plan_id) + return {'success': True, 'id': plan_id, 'status': status, 'plan': plan.model_dump() if plan else None} + + +def _audit(session: Session, action_id: str, target: str, metadata: dict[str, Any] | None = None) -> None: + log_event( + session, + source='manual', + identity='ai_remediation_planner', + action_id=action_id, + target=target, + target_type='ai_remediation', + risk='low', + policy_decision='allowed', + reason='AI Remediation Planner V1 non-executing workflow.', + result='success', + redacted_metadata=_redact(metadata or {}), + ) + + +def _evidence(source: str, label: str, value: Any = None) -> RemediationEvidence: + redacted = _redact(value) + return RemediationEvidence(source=source, label=label, value=redacted, redacted=redacted != value) + + +def _check(label: str, endpoint: str, expected: str, source: str) -> RemediationVerificationCheck: + return RemediationVerificationCheck(label=label, endpoint=endpoint, expectedResult=expected, source=source, readOnly=True) + + +def _resource_sample(resources: list[str], limit: int = 5) -> str: + sample = resources[:limit] + if not sample: + return 'unknown resource' + suffix = f' (+{len(resources) - limit} more)' if len(resources) > limit else '' + return ', '.join(sample) + suffix + + +def _connector_setup_href_for_secret(session: Session, secret_ref: str) -> str: + connector = session.exec(select(Connector).where(Connector.secret_ref == secret_ref)).first() + if connector: + return f'/setup?step=connectors&connector={connector.id}' + return '/secrets' + + +def _approval(session: Session, action_id: str, target: str, source: str = 'manual') -> RemediationApprovalRequirement: + policy = evaluate_policy(session, action=action_id, target=target, source=source) + return RemediationApprovalRequirement( + actionId=action_id, + target=target, + reason=str(policy.get('reason') or 'Policy evaluation required.'), + risk=str(policy.get('risk') or 'medium'), + policyDecision=str(policy.get('decision') or 'blocked'), + ) + + +def _step( + session: Session, + step_id: str, + label: str, + description: str, + step_type: str, + *, + href: str | None = None, + action_id: str | None = None, + target: str | None = None, + source: str = 'manual', + evidence: list[RemediationEvidence] | None = None, +) -> RemediationStep: + policy = {'decision': 'allowed', 'risk': 'low'} + if action_id and target: + policy = evaluate_policy(session, action=action_id, target=target, source=source) + return RemediationStep( + id=step_id, + label=label, + description=description, + type=step_type, # type: ignore[arg-type] + actionId=action_id, + target=target, + href=href, + policyDecision=str(policy.get('decision') or 'allowed'), + risk=str(policy.get('risk') or 'low'), + requiresApproval=policy.get('decision') == 'approval_required', + canExecute=False, + evidence=evidence or [], + ) + + +def _blocked(session: Session, step_id: str, label: str, action_id: str, target: str, description: str) -> RemediationStep: + return _step(session, step_id, label, description, 'blocked_action', action_id=action_id, target=target) + + +def _base_plan( + session: Session, + *, + plan_id: str, + title: str, + description: str, + source_type: str, + source_id: str, + severity: str, + confidence: str, + affected: list[str], + prerequisites: list[str], + steps: list[RemediationStep], + verification: list[RemediationVerificationCheck], + blocked: list[RemediationStep], + approvals: list[RemediationApprovalRequirement], + risk: str, + impact: str, + rollback: str, +) -> RemediationPlan: + now = _now() + return RemediationPlan( + id=plan_id, + title=str(_redact(title)), + description=str(_redact(description)), + sourceType=source_type, # type: ignore[arg-type] + sourceId=source_id, + severity=severity, # type: ignore[arg-type] + confidence=confidence, # type: ignore[arg-type] + status=_get_status(session, plan_id), # type: ignore[arg-type] + affectedResources=[str(_redact(item)) for item in affected], + prerequisites=[str(_redact(item)) for item in prerequisites], + steps=steps, + verificationChecks=verification, + blockedActions=blocked, + approvalRequirements=approvals, + riskAssessment=str(_redact(risk)), + estimatedImpact=str(_redact(impact)), + rollbackNotes=str(_redact(rollback)), + auditPreview=f'Plan {plan_id} can be reviewed or dry-run only; no remediation execution is available in V1.', + createdAt=now, + updatedAt=now, + executable=False, + ) + + +def _generic_finding(finding_id: str) -> dict[str, Any]: + title_map = { + 'ai-sec-auth-public-host-without-authentik': ('Public host without Authentik mapping', 'high', 'auth_coverage', ['public_host']), + 'ai-sec-npm-certificate-expiring-soon': ('NPM certificate expiring soon', 'medium', 'exposure', ['certificate']), + 'ai-sec-npm-host-without-certificate': ('NPM public host without certificate', 'high', 'exposure', ['public_host']), + 'ai-sec-npm-host-without-ssl-force': ('NPM host does not force SSL', 'medium', 'exposure', ['public_host']), + 'ai-sec-auth-outpost-unavailable': ('Expected Authentik outpost unavailable', 'medium', 'auth_coverage', ['authentik']), + 'ai-sec-policy-unraid-mover-voice-allowed': ('Unraid mover voice action is allowed', 'high', 'policies', ['unraid.mover.start']), + 'ai-sec-media-pipeline-issue': ('Media pipeline needs review', 'medium', 'media', ['media_pipeline']), + } + title, severity, category, affected = title_map.get(finding_id, ('Security finding needs remediation plan', 'medium', 'unknown', ['unknown'])) + return { + 'id': finding_id, + 'title': title, + 'description': title, + 'severity': severity, + 'confidence': 'medium', + 'category': category, + 'affectedResources': affected, + 'evidence': [], + } + + +def _plan_for_finding(session: Session, finding: dict[str, Any]) -> RemediationPlan: + finding_id = str(finding.get('id') or 'unknown') + title = str(_redact(finding.get('title') or finding_id)) + severity = str(finding.get('severity') or 'medium') + confidence = str(finding.get('confidence') or 'medium') + affected = [str(item) for item in finding.get('affectedResources', [])] or ['unknown'] + evidence = [_evidence(str(item.get('source', 'security')), str(item.get('label', 'evidence')), item.get('value', item.get('observed'))) for item in finding.get('evidence', []) if isinstance(item, dict)] + + if 'missing-secret' in finding_id or finding.get('category') == 'secrets': + secret_ref = affected[0] + setup_href = _connector_setup_href_for_secret(session, secret_ref) + steps = [ + _step(session, 'open-secrets', 'Open connector setup', 'Open the write-only connector configuration page.', 'open_page', href=setup_href, evidence=evidence), + _step(session, 'configure-secret', 'Configure required secret', 'Enter the connector secret in setup. The planner never reads or stores the value.', 'configure_secret', href=setup_href), + _step(session, 'test-connector', 'Test connector', 'Run the existing connector test after the secret is configured.', 'run_readonly_check', href=setup_href), + _step(session, 'rerun-review', 'Re-run security review', 'Confirm the finding is resolved after connector status updates.', 'run_readonly_check', href='/ai/security-reviewer'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Remediate missing secret: {secret_ref}', description='Configure the missing connector secret through the write-only vault and validate with read-only checks.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator has the secret value outside AppOps.', 'Secret storage key is configured.'], steps=steps, verification=[_check('Connector status connected', '/api/connectors/{id}/test', 'connected or configured', 'connectors'), _check('Security finding resolved', '/api/ai/security/findings?scope=secrets', 'finding absent or acknowledged', 'ai_security')], blocked=[], approvals=[], risk='Low operational risk; primary risk is entering the wrong secret.', impact='Connector read operations become available after configuration.', rollback='Replace the secret with the previous value in the vault if validation fails.') + + if 'auth-public-host-without-authentik' in finding_id or 'authentik-missing-public-hosts' in finding_id: + host_count = len(affected) + host_label = 'public host' if host_count == 1 else 'public hosts' + host_sample = _resource_sample(affected) + host_evidence = evidence + [_evidence('authentik', 'affectedHosts', affected)] + blocked = [ + _blocked(session, 'blocked-npm-update', 'Automatic NPM modification blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'NPM host changes are manual in V1.'), + _blocked(session, 'blocked-auth-create', 'Automatic Authentik application/provider creation blocked', 'authentik.create_application', 'authentik', 'Identity-provider changes are manual in V1.'), + ] + approvals = [_approval(session, 'authentik.create_application', 'authentik'), _approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')] + steps = [ + _step(session, 'open-network', 'Open Exposure Center', f'Review the affected public host list host-by-host: {host_sample}.', 'open_page', href='/network', evidence=host_evidence), + _step(session, 'review-npm-host', 'Review NPM proxy host', 'Confirm host-by-host whether each route should stay public and which access list or external auth applies.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'), + _step(session, 'open-authentik', 'Open Authentik applications', 'Review existing Authentik applications/providers/outposts.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/apps/authentik'), + _step(session, 'manual-auth-change', 'Manually create or verify Authentik coverage', 'Make required Authentik/NPM changes outside this planner for every affected host that should be identity-protected.', 'manual_external_change', href='/apps/authentik'), + _step(session, 'rerun-coverage', 'Re-run Auth Coverage', 'Confirm every affected host is protected or intentionally reviewed.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/network'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Add identity coverage for {host_count} {host_label}', description=f'{title}: {host_sample}', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM and Authentik connectors are configured.', 'Operator can administer Authentik/NPM directly.', 'Each affected public host is reviewed host-by-host before external changes.'], steps=steps, verification=[_check('Auth coverage protected', '/api/apps/authentik/auth-coverage', 'each affected host is protected, likely_protected, or explicitly reviewed', 'authentik')], blocked=blocked, approvals=approvals, risk='High if left unresolved because public routes may bypass identity controls.', impact='Manual external changes may affect public access to one or more hosts.', rollback='Revert external Authentik/NPM changes in their native UIs if access breaks.') + + if 'certificate-expiring' in finding_id or 'without-certificate' in finding_id: + blocked = [_blocked(session, 'blocked-cert-renew', 'Automatic certificate renew blocked', 'npm.renew_certificate', 'nginx_proxy_manager', 'Certificate renewal remains manual in NPM for V1.')] + steps = [ + _step(session, 'open-npm', 'Open NPM AppDetail certificate panel', 'Inspect certificate metadata and affected domains.', 'open_page', href='/apps/nginx_proxy_manager', evidence=evidence), + _step(session, 'review-cert', 'Review certificate expiry', 'Validate expiry date, provider and covered domains.', 'run_readonly_check', action_id='npm.certificates', target='nginx_proxy_manager', href='/network'), + _step(session, 'manual-renew', 'Manually renew in NPM if needed', 'Perform renewal in NPM directly; this planner will not call renew.', 'manual_external_change', href='/apps/nginx_proxy_manager'), + _step(session, 'rerun-cert-check', 'Re-run NPM cert check', 'Refresh certificate warning data.', 'run_readonly_check', action_id='npm.expiring_certificates', target='nginx_proxy_manager', href='/network'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review expiring NPM certificate', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read certificate metadata.', 'Operator has NPM admin access.'], steps=steps, verification=[_check('Certificate no longer expiring', '/api/apps/nginx-proxy-manager/certificates/expiring?days=30', 'affected certificate absent', 'npm')], blocked=blocked, approvals=[_approval(session, 'npm.renew_certificate', 'nginx_proxy_manager')], risk='Medium; access can fail when TLS expires.', impact='Manual renewal may briefly affect the public host.', rollback='Restore previous certificate state in NPM if renewal introduces errors.') + + if 'without-ssl-force' in finding_id or 'ssl force' in title.lower() or 'force ssl' in title.lower(): + steps = [ + _step(session, 'open-network', 'Open Exposure Center', 'Inspect the affected NPM public host and its SSL policy.', 'open_page', href='/network', evidence=evidence), + _step(session, 'review-proxy-host', 'Review proxy host SSL settings', 'Confirm Force SSL is disabled for the affected host and whether that is intentional.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'), + _step(session, 'manual-ssl-change', 'Manually enable Force SSL in NPM if appropriate', 'Change Force SSL in NPM directly after checking the app still works over HTTPS.', 'manual_external_change', href='/apps/nginx_proxy_manager'), + _step(session, 'rerun-exposure', 'Re-run exposure review', 'Confirm the SSL-force finding is resolved or explicitly acknowledged.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review NPM SSL enforcement', description=f'Force SSL is not enabled for: {_resource_sample(affected)}.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read proxy-host metadata.', 'Operator has NPM admin access.', 'HTTPS certificate is valid for the affected host.'], steps=steps, verification=[_check('Proxy host forces SSL', '/api/apps/nginx-proxy-manager/proxy-hosts', 'affected host has sslForced=true or finding acknowledged', 'npm')], blocked=[_blocked(session, 'blocked-npm-update', 'Automatic NPM proxy-host update blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'Planner V1 does not mutate NPM proxy hosts.')], approvals=[_approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')], risk='Medium; users may reach the host over plain HTTP if Force SSL is disabled.', impact='Manual Force SSL changes can affect clients using HTTP-only URLs.', rollback='Disable Force SSL again in NPM if the host breaks, then review certificate and upstream settings.') + + if 'outpost-unavailable' in finding_id or 'outpost' in title.lower(): + steps = [ + _step(session, 'open-authentik', 'Open Authentik AppDetail', 'Inspect Authentik connector health and outpost metadata.', 'open_page', href='/apps/authentik', evidence=evidence), + _step(session, 'review-outpost-health', 'Review outpost health', 'Read the Authentik outpost health summary and provider count.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/apps/authentik'), + _step(session, 'manual-outpost-review', 'Verify outpost in Authentik', 'Use Authentik directly to check the embedded outpost, providers and integration health.', 'manual_external_change', href='/apps/authentik'), + _step(session, 'rerun-auth-review', 'Re-run Authentik security review', 'Confirm outpost health is healthy or intentionally documented.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/ai/security-reviewer'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review Authentik outpost health', description='Authentik reports the expected outpost as unavailable or unknown.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Authentik connector is configured.', 'Operator can inspect Authentik outposts directly.'], steps=steps, verification=[_check('Outpost health is available', '/api/apps/authentik/outposts/health', 'available=true or explicitly reviewed', 'authentik')], blocked=[_blocked(session, 'blocked-auth-update', 'Automatic Authentik outpost mutation blocked', 'authentik.update_policy', 'authentik', 'Planner V1 does not mutate Authentik topology.')], approvals=[_approval(session, 'authentik.update_policy', 'authentik')], risk='Medium; unknown outpost health can make external auth coverage harder to trust.', impact='Manual Authentik changes can affect public authentication flows.', rollback='Revert outpost/provider changes in Authentik if application access is affected.') + + if finding.get('category') == 'media': + steps = [ + _step(session, 'open-media', 'Open Media Pipeline', 'Review connector status, stalled downloads and pipeline issues.', 'open_page', href='/media-pipeline', evidence=evidence), + _step(session, 'run-diagnose', 'Run media pipeline diagnose', 'Use the existing read-only diagnose flow to collect current media pipeline state.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/media-pipeline'), + _step(session, 'review-connectors', 'Review missing media connectors', 'Configure only the connectors you intend to use; missing optional connectors should remain visible as partial coverage.', 'bind_connector', href='/setup?step=connectors'), + _step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm media finding is resolved, reduced to known partial coverage or acknowledged.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/safety'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review media pipeline issue', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Media Pipeline route is reachable.', 'Operator knows which media connectors should be active in this deployment.'], steps=steps, verification=[_check('Media pipeline summary reviewed', '/api/media-pipeline/summary', 'status healthy, warning accepted, or partial coverage documented', 'media_pipeline'), _check('Media pipeline issues reviewed', '/api/media-pipeline/issues', 'no unexpected critical issues', 'media_pipeline')], blocked=[_blocked(session, 'blocked-deluge-delete-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')], approvals=[_approval(session, 'media_pipeline.diagnose', 'media-pipeline')], risk='Medium; media connector gaps can hide stalled downloads or processing failures.', impact='Read-only review has no runtime impact; any connector setup is manual.', rollback='Remove or replace connector settings manually if a configured connector is wrong.') + + if finding.get('category') == 'policies' or 'policy-' in finding_id or 'voice' in title.lower(): + action_id = affected[0] if affected else 'unraid.mover.start' + steps = [ + _step(session, 'open-policies', 'Open Policies page', 'Inspect the risky policy rule.', 'open_page', href='/policies', evidence=evidence), + _step(session, 'simulate-voice', 'Simulate voice policy decision', 'Use policy simulation for the affected action and target.', 'review_policy', action_id=action_id, target='unraid', source='voice', href='/policies'), + _step(session, 'draft-policy', 'Create policy draft suggestion', 'Draft a safer blocked or approval-required setting for operator review.', 'documentation', href='/policies'), + _step(session, 'manual-apply', 'Manual policy apply outside V1', 'Apply the policy change through an explicit future policy workflow, not this planner.', 'manual_external_change', href='/policies'), + ] + blocked = [_blocked(session, 'blocked-policy-update', 'Automatic policy apply blocked', 'authentik.update_policy', 'policy', 'Planner V1 does not mutate policies.')] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Reduce risky voice policy', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator understands desired policy posture.', 'Policy changes are reviewed manually.'], steps=steps, verification=[_check('Policy no longer voice-allowed', '/api/policies/evaluate', 'blocked or approval_required for voice', 'policies')], blocked=blocked, approvals=[_approval(session, action_id, 'unraid', 'voice')], risk='High when voice can trigger state-changing operations.', impact='Safer policy can require more manual approvals.', rollback='Restore previous policy value from audit/config backup if needed.') + + if finding.get('category') == 'inventory' or 'critical-app-unprotected' in finding_id or 'needs review' in title.lower(): + app_id = affected[0] + steps = [ + _step(session, 'open-inventory', 'Open Inventory/AppDetail', 'Review app import, category and criticality.', 'open_page', href='/inventory', evidence=evidence), + _step(session, 'mark-protected', 'Mark protected manually or review import', 'Use existing inventory workflows to classify the app.', 'manual_external_change', href=f'/apps/{app_id}'), + _step(session, 'review-policy-profile', 'Review policy profile', 'Confirm policy profile matches app criticality.', 'review_policy', href='/policies'), + _step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm protection finding is gone.', 'run_readonly_check', href='/ai/security-reviewer'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review critical app protection', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Inventory source is current.', 'Operator has enough context to classify the app.'], steps=steps, verification=[_check('App managed/protected', '/api/inventory/apps', 'protected=true or reviewed', 'inventory')], blocked=[_blocked(session, 'blocked-policy-mutation', 'Automatic policy mutation blocked', 'stack.redeploy', app_id, 'Planner does not mutate app policy profile.')], approvals=[], risk='High for critical apps without explicit protection.', impact='Manual classification affects future policy decisions.', rollback='Change classification back manually if misclassified.') + + if finding.get('category') == 'audit' or 'blocked-attempts' in finding_id: + steps = [ + _step(session, 'open-audit', 'Open Audit Log', 'Inspect repeated blocked/failed attempts.', 'open_page', href='/audit', evidence=evidence), + _step(session, 'review-automation', 'Review automation or voice source', 'Identify whether attempts are expected or noisy.', 'documentation', href='/audit'), + _step(session, 'review-policy', 'Review related policy', 'Confirm blocked behavior is intentional.', 'review_policy', href='/policies'), + ] + return _base_plan(session, plan_id=f'rem-{finding_id}', title='Triage repeated blocked attempts', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Recent audit events are available.'], steps=steps, verification=[_check('Blocked attempts decrease', '/api/audit', 'no repeated attempts in next window', 'audit')], blocked=[], approvals=[], risk='Medium; repeated denials can indicate noisy automation or probing.', impact='May require changing external automation or voice phrasing.', rollback='Re-enable previous automation only after policy review.') + + return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Review finding: {title}', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Review source finding evidence.'], steps=[_step(session, 'open-reviewer', 'Open Security Reviewer', 'Inspect finding evidence and links.', 'open_page', href='/ai/security-reviewer', evidence=evidence)], verification=[_check('Finding reviewed', '/api/ai/security/findings', 'status acknowledged or resolved', 'ai_security')], blocked=[], approvals=[], risk='Depends on source finding severity.', impact='No direct impact; V1 is plan-only.', rollback='Dismiss plan if not actionable.') + + +def _plan_for_media_issue(session: Session, issue: dict[str, Any]) -> RemediationPlan: + issue_id = str(issue.get('id') or issue.get('title') or 'media-issue').lower().replace(' ', '-') + title = str(_redact(issue.get('title') or 'Media pipeline issue')) + affected = [str(item) for item in issue.get('affectedConnectors', issue.get('affected', []))] or ['media_pipeline'] + evidence = [_evidence('media_pipeline', 'issue', issue.get('evidence', []))] + if 'widefrog' in title.lower() or 'widefrog' in affected: + steps = [ + _step(session, 'open-widefrog', 'Open Widefrog AppDetail', 'Review failed jobs and queue state.', 'open_page', href='/apps/widefrog', evidence=evidence), + _step(session, 'review-failed-jobs', 'Review failed jobs', 'Inspect failure reason before retry.', 'run_readonly_check', action_id='widefrog.failed_jobs', target='widefrog', href='/apps/widefrog'), + _step(session, 'retry-if-approved', 'Plan retry_failed through policy flow', 'Retry can only run through existing policy/approval flow outside this planner.', 'request_approval', action_id='widefrog.retry_failed', target='widefrog', href='/apps/widefrog'), + _step(session, 'verify-queue', 'Verify queue/activity', 'Confirm failed count decreases after manual execution.', 'run_readonly_check', action_id='widefrog.queue_status', target='widefrog', href='/apps/widefrog'), + ] + blocked = [_blocked(session, 'blocked-delete-jobs', 'Delete jobs/media blocked', 'widefrog.delete_job', 'widefrog', 'Delete operations are forbidden.')] + return _base_plan(session, plan_id='rem-media-widefrog-failed', title='Plan Widefrog failed jobs recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Widefrog connector configured.', 'Operator confirms retry is safe.'], steps=steps, verification=[_check('Failed jobs decrease', '/api/apps/widefrog/failed', 'failed count lower', 'widefrog')], blocked=blocked, approvals=[_approval(session, 'widefrog.retry_failed', 'widefrog')], risk='Medium; retry can repeat failed processing.', impact='May increase load on media processing.', rollback='Stop external retry workflow if failures repeat.') + steps = [ + _step(session, 'open-media', 'Open Media Pipeline', 'Review pipeline issue context.', 'open_page', href='/media-pipeline', evidence=evidence), + _step(session, 'open-deluge', 'Open Deluge detail', 'Review stalled torrents without deleting data.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/apps/deluge'), + _step(session, 'resume-specific', 'Plan resume specific torrent if policy allows', 'Resume must go through existing policy/approval flow outside this planner.', 'request_approval', action_id='deluge.resume_torrent', target='deluge', href='/apps/deluge'), + _step(session, 'verify-stalled', 'Verify stalled count decreases', 'Re-run download checks after manual action.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/media-pipeline'), + ] + blocked = [_blocked(session, 'blocked-remove-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')] + return _base_plan(session, plan_id='rem-media-deluge-stalled', title='Plan Deluge stalled downloads recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Deluge connector configured.', 'Operator identifies the specific stalled torrent.'], steps=steps, verification=[_check('Stalled downloads decrease', '/api/apps/deluge/stalled', 'stalled count lower', 'deluge')], blocked=blocked, approvals=[_approval(session, 'deluge.resume_torrent', 'deluge')], risk='Medium; resuming downloads can affect bandwidth and storage.', impact='Download queue may resume network/storage activity.', rollback='Pause the specific torrent through normal policy flow if needed.') + + +def _inventory_plans(session: Session) -> list[RemediationPlan]: + from .app_registry import list_active_apps + + plans: list[RemediationPlan] = [] + for app in list_active_apps(session): + if app.needs_review: + finding = {'id': f'inventory-needs-review-{app.id}', 'title': f'Discovered app needs review: {app.name}', 'severity': app.criticality if app.criticality in {'low', 'medium', 'high', 'critical'} else 'medium', 'confidence': 'medium', 'category': 'inventory', 'affectedResources': [app.id], 'evidence': []} + plans.append(_plan_for_finding(session, finding)) + break + return plans + + +def _connector_plans(session: Session) -> list[RemediationPlan]: + plans: list[RemediationPlan] = [] + for connector in session.exec(select(Connector)).all(): + if connector.status == 'missing_config': + finding = {'id': f'connector-missing-config-{connector.id}', 'title': f'Connector missing config: {connector.name}', 'severity': 'medium', 'confidence': 'high', 'category': 'connectors', 'affectedResources': [connector.id], 'evidence': [{'source': 'connectors', 'label': 'status', 'value': connector.status}]} + plans.append(_plan_for_finding(session, finding)) + break + return plans + + +def _secret_storage_plan(session: Session) -> list[RemediationPlan]: + status = secret_storage_status() + if status.get('status') != 'warning' and status.get('productionReady') is not False: + return [] + plan = _base_plan( + session, + plan_id='rem-secret-storage-key-warning', + title='Configure production secret storage key', + description='Configure APPOPS_SECRET_KEY or APPOPS_SECRET_KEY_FILE and back it up with the database.', + source_type='secret', + source_id='secret-storage-key-warning', + severity='medium', + confidence='high', + affected=['secret_storage'], + prerequisites=['Operator can set deployment environment variables.', 'Backup process is available.'], + steps=[ + _step(session, 'open-settings', 'Open settings/secrets status', 'Review key source and production readiness.', 'open_page', href='/secrets'), + _step(session, 'read-docs', 'Open Secret Storage docs', 'Follow production key setup guidance.', 'documentation', href='/docs/SECRET_STORAGE.md'), + _step(session, 'configure-key', 'Configure APPOPS_SECRET_KEY', 'Set the key in deployment environment outside this planner.', 'manual_external_change'), + _step(session, 'backup-db-key', 'Back up database with key', 'Back up /data/appops.db and the key material together.', 'documentation'), + _step(session, 'runtime-health', 'Re-run runtime health', 'Confirm production readiness.', 'run_readonly_check', href='/settings'), + ], + verification=[_check('Runtime health production ready', '/api/runtime/info', 'secretStorage.productionReady=true', 'runtime')], + blocked=[_blocked(session, 'blocked-key-rotation', 'Automated key rotation blocked', 'secrets.show', 'secret_storage', 'Key automation is not implemented in planner V1.')], + approvals=[], + risk='Medium; missing production key affects encrypted secret durability.', + impact='Deployment environment change and backup process update.', + rollback='Restore previous environment and database backup if startup fails.', + ) + return [plan] + + +def generate_plans(session: Session, audit_run: bool = False, include_media_issues: bool | None = None) -> RemediationPlanCollection: + if include_media_issues is None: + include_media_issues = audit_run + + review = ai_security_reviewer.generate_review(session) + findings = [finding.model_dump() for finding in review.findings] + recommendations = [recommendation.model_dump() for recommendation in review.recommendations] + finding_ids = {str(finding.get('id')) for finding in findings} + plans = [_plan_for_finding(session, finding) for finding in findings if finding.get('status') != 'dismissed'] + plans.extend( + _plan_for_recommendation(session, recommendation) + for recommendation in recommendations + if recommendation.get('status') != 'dismissed' and str(recommendation.get('id') or '').removeprefix('rec-') not in finding_ids + ) + if include_media_issues: + try: + from . import media_pipeline_service + + issues = media_pipeline_service.issues(session).get('issues', []) + except Exception as exc: # noqa: BLE001 + issues = [{'id': 'media-unavailable', 'title': redact_message(str(exc)), 'affectedConnectors': ['media_pipeline']}] + plans.extend(_plan_for_media_issue(session, issue) for issue in issues if isinstance(issue, dict)) + plans.extend(_inventory_plans(session)) + plans.extend(_connector_plans(session)) + plans.extend(_secret_storage_plan(session)) + deduped = list({plan.id: plan for plan in plans}.values()) + if audit_run: + _audit(session, 'ai.remediation.generate', 'all', {'plans': len(deduped)}) + return RemediationPlanCollection(status='ok', generatedAt=_now(), total=len(deduped), plans=deduped) + + +def _plan_for_recommendation(session: Session, recommendation: dict[str, Any]) -> RemediationPlan: + source_id = str(recommendation.get('id') or 'recommendation') + finding_id = source_id.removeprefix('rec-') + finding = { + 'id': finding_id, + 'title': recommendation.get('title') or source_id, + 'description': recommendation.get('description') or recommendation.get('suggestedAction') or source_id, + 'severity': recommendation.get('severity') or 'medium', + 'confidence': recommendation.get('confidence') or 'medium', + 'category': 'unknown', + 'affectedResources': recommendation.get('affectedResources', []), + 'evidence': recommendation.get('evidence', []), + } + plan = _plan_for_finding(session, finding) + return plan.model_copy(update={'id': f'rem-{source_id}', 'sourceType': 'security_recommendation', 'sourceId': source_id, 'status': _get_status(session, f'rem-{source_id}')}) + + +def get_plans(session: Session) -> RemediationPlanCollection: + return generate_plans(session) + + +def get_plan(session: Session, plan_id: str) -> RemediationPlan | None: + plan = next((item for item in generate_plans(session).plans if item.id == plan_id), None) + if plan: + return plan + if plan_id.startswith('rem-ai-sec-'): + return _plan_for_finding(session, _generic_finding(plan_id.removeprefix('rem-'))) + return None + + +def generate_from_finding(session: Session, finding_id: str) -> RemediationPlan: + finding = ai_security_reviewer.get_finding(session, finding_id) or _generic_finding(finding_id) + plan = _plan_for_finding(session, finding) + _audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_finding', 'sourceId': finding_id}) + return plan + + +def generate_from_recommendation(session: Session, recommendation_id: str) -> RemediationPlan: + recommendation = next((item for item in ai_security_reviewer.get_recommendations(session) if item['id'] == recommendation_id), {'id': recommendation_id, 'title': recommendation_id, 'severity': 'medium', 'confidence': 'medium', 'affectedResources': []}) + plan = _plan_for_recommendation(session, recommendation) + _audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_recommendation', 'sourceId': recommendation_id}) + return plan + + +def review_plan(session: Session, plan_id: str) -> dict[str, Any]: + return _set_status(session, plan_id, 'reviewed') + + +def dismiss_plan(session: Session, plan_id: str) -> dict[str, Any]: + return _set_status(session, plan_id, 'dismissed') + + +def dry_run(session: Session, plan_id: str) -> RemediationDryRunResult | None: + plan = get_plan(session, plan_id) + if not plan: + return None + step_results: list[RemediationDryRunStepResult] = [] + approvals: list[RemediationApprovalRequirement] = [] + blocked: list[RemediationApprovalRequirement] = [] + reasons: list[str] = ['Planner V1 dry-run only; no steps were executed.'] + for step in plan.steps + plan.blockedActions: + decision = step.policyDecision + risk = step.risk + reason = 'No executable action attached.' + if step.actionId and step.target: + policy = evaluate_policy(session, action=step.actionId, target=step.target, source='manual') + decision = str(policy.get('decision') or 'blocked') + risk = str(policy.get('risk') or step.risk) + reason = str(policy.get('reason') or 'Policy evaluated.') + status = 'blocked' if decision == 'blocked' or step.type == 'blocked_action' else ('warning' if decision == 'approval_required' else 'pass') + result = RemediationDryRunStepResult(stepId=step.id, label=step.label, actionId=step.actionId, target=step.target, policyDecision=decision, risk=risk, status=status, reason=reason, canExecute=False) + step_results.append(result) + if step.actionId and step.target and decision == 'approval_required': + approvals.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision)) + if step.actionId and step.target and status == 'blocked': + blocked.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision)) + missing_secret_names = [item.get('secretRef') for item in list_secret_status(session) if not item.get('hasSecret')] + unresolved = [f'Missing secret: {name}' for name in missing_secret_names if name] + if blocked: + status = 'blocked' + elif approvals or unresolved: + status = 'warning' + else: + status = 'pass' + result = RemediationDryRunResult(planId=plan.id, status=status, executed=False, reasons=reasons, prerequisites=plan.prerequisites, unresolvedBlockers=unresolved, blockedActions=blocked, approvalRequirements=approvals, verificationChecks=plan.verificationChecks, stepResults=step_results) + _audit(session, 'ai.remediation.dry_run', plan_id, {'status': status, 'blocked': len(blocked), 'approvals': len(approvals)}) + return result + + +def create_approval_proposal(session: Session, plan_id: str) -> RemediationApprovalProposal | None: + plan = get_plan(session, plan_id) + if not plan: + return None + proposals = plan.approvalRequirements or [ + RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason='Manual approval may be required by policy.', risk=step.risk, policyDecision=step.policyDecision) + for step in plan.steps + if step.actionId and step.target and step.policyDecision == 'approval_required' + ] + row = session.get(AppSetting, _setting_key('proposal', plan_id)) + if not row: + row = AppSetting(key=_setting_key('proposal', plan_id), is_secret=False) + row.value_json = {'createdAt': _now(), 'proposals': [proposal.model_dump() for proposal in proposals], 'executed': False, 'approvalId': None} + row.updated_at = utc_now() + session.add(row) + session.commit() + _audit(session, 'ai.remediation.approval_proposal', plan_id, {'proposals': len(proposals), 'executed': False}) + return RemediationApprovalProposal(planId=plan_id, created=True, executed=False, approvalId=None, proposals=proposals, message='Approval proposal created as planner draft only. No approval or action was executed.') diff --git a/backend/app/services/ai_security_reviewer.py b/backend/app/services/ai_security_reviewer.py new file mode 100644 index 0000000..4d6c455 --- /dev/null +++ b/backend/app/services/ai_security_reviewer.py @@ -0,0 +1,1017 @@ +from __future__ import annotations + +from collections import Counter +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +import hashlib +import json +import os +from time import monotonic +from typing import Any, Callable + +from sqlalchemy import and_, func, not_ +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.unraid import UnraidConnector +from ..core.config import settings +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import App, AppSetting, AuditEvent, Connector, DiscoveredContainer, DiscoveryRun, Policy, Secret +from ..schemas.ai_security import ( + SecurityEvidence, + SecurityFinding, + SecurityRecommendation, + SecurityReviewResult, + SecurityReviewScope, + SecurityReviewSummary, + SecuritySuggestedAction, +) +from .audit_logger import log_event +from .policy_engine import evaluate_policy +from .secret_store import list_secret_status, secret_storage_status + + +SOURCE_NAMES = ('inventory', 'portainer', 'unraid', 'npm', 'authentik', 'secret_storage', 'policies', 'audit', 'media_pipeline') +SEVERITY_PENALTY = {'critical': 20, 'high': 12, 'medium': 6, 'low': 2, 'info': 0} +MUTATING_TOKENS = ('delete', 'remove', 'prune', 'format', 'stop', 'redeploy', 'create', 'update', 'rotate', 'sync', 'mass_edit') +COVERAGE_CATEGORIES = {'coverage', 'connector_config'} +_REVIEW_CACHE_TTL_SECONDS = 30 +_SNAPSHOT_MAX_WORKERS = 10 +_PERSISTENT_CACHE_SCHEMA_VERSION = 2 +_PERSISTENT_CACHE_PREFIX = 'ai_security:review_cache' +_SOURCE_SIGNATURE_CACHE_KEYS = {'media_pipeline:summary_cache', 'network_exposure:cache', 'safety_center:dashboard_cache'} +_PERSISTENT_CACHE_MAX_STALE_SECONDS = 300 +_REVIEW_CACHE: dict[str, tuple[float, SecurityReviewResult]] = {} +OPTIONAL_CONNECTED_SECRET_REFS = { + 'widefrog': {'WIDEFROG_API_KEY'}, +} + + +def _now() -> str: + return datetime.now(UTC).isoformat() + + +def _review_cache_key(session: Session, scope: SecurityReviewScope) -> str: + try: + bind = session.get_bind() + db_key = str(getattr(bind, 'url', 'default')) + except Exception: # noqa: BLE001 + db_key = 'default' + return f'{db_key}:{scope}' + + +def _parse_utc(value: str) -> datetime: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + + +def _persistent_cache_ttl_seconds() -> int: + raw = os.getenv('AI_SECURITY_CACHE_TTL_SECONDS', str(_REVIEW_CACHE_TTL_SECONDS)) + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return _REVIEW_CACHE_TTL_SECONDS + + +def _persistent_cache_max_stale_seconds() -> int: + raw = os.getenv('AI_SECURITY_PERSISTENT_CACHE_MAX_STALE_SECONDS', str(_PERSISTENT_CACHE_MAX_STALE_SECONDS)) + try: + return max(0, int(raw)) + except (TypeError, ValueError): + return _PERSISTENT_CACHE_MAX_STALE_SECONDS + + +def _source_signature(session: Session, scope: SecurityReviewScope) -> str: + from .app_registry import list_active_apps + + policies = [ + {'id': item.id, 'action': item.action, 'target': item.target, 'voice': item.voice, 'manual': item.manual, 'risk': item.risk} + for item in session.exec(select(Policy)).all() + ] + connectors = [ + {'id': item.id, 'status': item.status, 'lastTest': item.last_test, 'secretRef': item.secret_ref, 'capabilities': item.capabilities} + for item in session.exec(select(Connector)).all() + ] + apps = [ + { + 'id': item.id, + 'status': item.status, + 'connectorStatus': item.connector_status, + 'containerStatus': item.container_status, + 'needsReview': item.needs_review, + 'discoveryStatus': item.discovery_status, + 'protected': item.protected, + } + for item in list_active_apps(session) + ] + settings = [ + { + 'key': item.key, + 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, + 'isSecret': item.is_secret, + 'status': item.value_json.get('status') if isinstance(item.value_json, dict) else None, + } + for item in session.exec(select(AppSetting)).all() + if not _is_source_signature_cache_key(item.key) and (not item.is_secret or item.key.startswith('ai_security:')) + ] + secret_rows = [ + {'id': item.id, 'name': item.name, 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, 'usedBy': item.used_by_json} + for item in session.exec(select(Secret)).all() + ] + discovered_count = session.exec(select(func.count(DiscoveredContainer.id))).one() + # Failed automatic startup reconciliation is operational bookkeeping, not + # a changed security source. Keeping it in the signature invalidates the + # durable review on every service restart even when nothing was discovered. + failed_startup_reconciliation = and_( + AuditEvent.source == 'system', + AuditEvent.identity == 'appops-startup', + AuditEvent.action_id.in_({'deployment.target.enroll', 'deployment.state.reconcile'}), + AuditEvent.result == 'failed', + ) + non_ai_audit = and_( + AuditEvent.action_id.not_like('ai.security.%'), + not_(failed_startup_reconciliation), + ) + audit_count = session.exec(select(func.count(AuditEvent.id)).where(non_ai_audit)).one() + latest_audit_timestamp = session.exec(select(func.max(AuditEvent.timestamp)).where(non_ai_audit)).one() + signature_payload = { + 'cacheSchemaVersion': _PERSISTENT_CACHE_SCHEMA_VERSION, + 'scope': scope, + 'policies': sorted(policies, key=lambda item: item['id']), + 'connectors': sorted(connectors, key=lambda item: item['id']), + 'apps': sorted(apps, key=lambda item: item['id']), + 'settings': sorted(settings, key=lambda item: item['key']), + 'secrets': sorted(secret_rows, key=lambda item: item['id']), + 'counts': { + 'discovered': discovered_count, + 'audit': audit_count, + 'latestAuditTimestamp': latest_audit_timestamp.isoformat() if latest_audit_timestamp else None, + }, + } + serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':')) + return hashlib.sha256(serialized.encode('utf-8')).hexdigest() + + +def _persistent_cache_key(scope: SecurityReviewScope) -> str: + return f'{_PERSISTENT_CACHE_PREFIX}:{scope}' + + +def _is_source_signature_cache_key(key: str) -> bool: + return ( + key.startswith(_PERSISTENT_CACHE_PREFIX) + or key.startswith('observability:') + or key in _SOURCE_SIGNATURE_CACHE_KEYS + or key.endswith(':cache') + or key.endswith('_cache') + ) + + +def _persistent_review(session: Session, scope: SecurityReviewScope, signature: str) -> SecurityReviewResult | None: + row = session.get(AppSetting, _persistent_cache_key(scope)) + if not row or not isinstance(row.value_json, dict): + return None + value = row.value_json + if value.get('schemaVersion') != _PERSISTENT_CACHE_SCHEMA_VERSION or value.get('signature') != signature: + return None + payload = value.get('payload') + expires_at_raw = value.get('expiresAt') + if not isinstance(payload, dict) or not isinstance(expires_at_raw, str): + return None + expires_at = _parse_utc(expires_at_raw) + now_dt = datetime.now(UTC) + stale_until = expires_at.timestamp() + _persistent_cache_max_stale_seconds() + if now_dt >= expires_at and now_dt.timestamp() >= stale_until: + return None + try: + return SecurityReviewResult.model_validate(payload).model_copy(deep=True) + except Exception: # noqa: BLE001 + return None + + +def _store_persistent_review(session: Session, scope: SecurityReviewScope, signature: str, result: SecurityReviewResult) -> None: + ttl_seconds = _persistent_cache_ttl_seconds() + expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z') + row = session.get(AppSetting, _persistent_cache_key(scope)) + if not row: + row = AppSetting(key=_persistent_cache_key(scope), is_secret=False) + row.value_json = { + 'schemaVersion': _PERSISTENT_CACHE_SCHEMA_VERSION, + 'signature': signature, + 'payload': _redact(result.model_dump()), + 'generatedAt': result.summary.generatedAt, + 'expiresAt': expires_at, + } + row.updated_at = utc_now() + session.add(row) + session.commit() + + +def _cached_review(session: Session, scope: SecurityReviewScope) -> SecurityReviewResult | None: + key = _review_cache_key(session, scope) + entry = _REVIEW_CACHE.get(key) + if entry: + created_at, result = entry + if monotonic() - created_at <= _REVIEW_CACHE_TTL_SECONDS: + return result.model_copy(deep=True) + _REVIEW_CACHE.pop(key, None) + + signature = _source_signature(session, scope) + persisted = _persistent_review(session, scope, signature) + if persisted: + _REVIEW_CACHE[key] = (monotonic(), persisted.model_copy(deep=True)) + return persisted + return None + + +def _store_review(session: Session, scope: SecurityReviewScope, result: SecurityReviewResult) -> SecurityReviewResult: + _REVIEW_CACHE[_review_cache_key(session, scope)] = (monotonic(), result.model_copy(deep=True)) + _store_persistent_review(session, scope, _source_signature(session, scope), result) + return result + + +def _clear_review_cache() -> None: + _REVIEW_CACHE.clear() + + +def _safe(call: Callable[[], Any], status: str = 'unavailable') -> dict[str, Any]: + try: + payload = call() + if isinstance(payload, dict): + return payload + return {'success': True, 'status': 'ok', 'items': payload if isinstance(payload, list) else []} + except Exception as exc: # noqa: BLE001 - reviewer must degrade instead of crash. + return {'success': False, 'status': status, 'message': redact_message(str(exc))} + + +def _redact(value: Any) -> Any: + if isinstance(value, str): + lower_value = value.lower() + if any(token in lower_value for token in ('token_value', 'secret_value', 'api_key=', 'password=')): + return '[REDACTED]' + return redact_message(value) + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, dict): + return {str(key): _redact(item) for key, item in value.items() if 'secret' not in str(key).lower() and 'token' not in str(key).lower()} + return value + + +def _redact_mapping_values(value: dict[str, Any]) -> dict[str, Any]: + return {str(key): _redact(item) for key, item in value.items()} + + +def _setting_key(kind: str, item_id: str) -> str: + return f'ai_security:{kind}:{item_id}:status' + + +def _connector_setup_href(connector_id: str) -> str: + return f'/setup?step=connectors&connector={connector_id}' + + +def _connector_setup_href_for_secret(session: Session, secret_ref: str) -> str: + connector = session.exec(select(Connector).where(Connector.secret_ref == secret_ref)).first() + if connector: + return _connector_setup_href(str(connector.id)) + return '/secrets' + + +def _npm_snapshot() -> dict[str, Any]: + connector = NginxProxyManagerConnector() + return { + 'npmExposure': connector.exposure_summary(), + 'npmHosts': connector.proxy_hosts(), + } + + +def _authentik_snapshot() -> dict[str, Any]: + connector = AuthentikConnector() + return { + 'authCoverage': connector.auth_coverage(), + 'authWarnings': connector.security_warnings(), + 'authOutpost': connector.outpost_health(), + } + + +def _get_state(session: Session, kind: str, item_id: str, default: str = 'open') -> str: + row = session.get(AppSetting, _setting_key(kind, item_id)) + if not row: + return default + return str(row.value_json.get('status') or default) + + +def _set_state(session: Session, kind: str, item_id: str, status: str) -> dict[str, Any]: + key = _setting_key(kind, item_id) + row = session.get(AppSetting, key) + if not row: + row = AppSetting(key=key, is_secret=False) + row.value_json = {'status': status, 'updatedAt': _now()} + row.updated_at = utc_now() + session.add(row) + session.commit() + log_event( + session, + source='manual', + identity='ai_security_reviewer', + action_id=f'ai.security.{kind}.{status}', + target=item_id, + target_type=kind, + risk='low', + policy_decision='allowed', + reason=f'AI Security Reviewer {kind} marked {status}.', + result='success', + redacted_metadata={'kind': kind, 'id': item_id, 'status': status}, + ) + return {'success': True, 'id': item_id, 'status': status} + + +def _action( + session: Session, + label: str, + *, + action_type: str = 'open_page', + href: str | None = None, + action_id: str | None = None, + target: str | None = None, + source: str = 'manual', +) -> SecuritySuggestedAction: + policy = {'decision': 'allowed', 'risk': 'low'} + if action_id and target: + policy = evaluate_policy(session, action=action_id, target=target, source=source) + return SecuritySuggestedAction( + label=label, + type=action_type, # type: ignore[arg-type] + actionId=action_id, + target=target, + policyDecision=str(policy.get('decision', 'allowed')), + risk=str(policy.get('risk', 'low')), + href=href, + executable=False, + ) + + +def _evidence(source: str, label: str, value: Any = None, *, observed: Any = None, expected: Any = None, reference_id: str | None = None) -> SecurityEvidence: + redacted = _redact(value) + redacted_observed = _redact(observed) + return SecurityEvidence( + source=source, + label=label, + value=redacted, + observed=redacted_observed, + expected=_redact(expected), + referenceId=reference_id, + redacted=redacted != value or redacted_observed != observed, + ) + + +def _finding( + session: Session, + finding_id: str, + *, + title: str, + description: str, + severity: str, + confidence: str, + category: str, + affected: list[str], + evidence: list[SecurityEvidence], + risk_reason: str, + actions: list[SecuritySuggestedAction], +) -> SecurityFinding: + return SecurityFinding( + id=finding_id, + title=str(_redact(title)), + description=str(_redact(description)), + severity=severity, # type: ignore[arg-type] + confidence=confidence, # type: ignore[arg-type] + category=category, # type: ignore[arg-type] + affectedResources=[str(_redact(item)) for item in affected], + evidence=evidence, + suggestedActions=actions, + status=_get_state(session, 'finding', finding_id), # type: ignore[arg-type] + createdAt=_now(), + riskReason=str(_redact(risk_reason)), + links=[{'label': action.label, 'href': action.href} for action in actions if action.href], + ) + + +def _recommendation(session: Session, rec_id: str, finding: SecurityFinding, label: str) -> SecurityRecommendation: + return SecurityRecommendation( + id=rec_id, + title=f'Review: {finding.title}', + description=finding.description, + severity=finding.severity, + confidence=finding.confidence, + evidence=finding.evidence, + affectedResources=finding.affectedResources, + suggestedAction=label, + suggestedActions=finding.suggestedActions, + policyDecision=finding.suggestedActions[0].policyDecision if finding.suggestedActions else 'allowed', + actionTarget=finding.affectedResources[0] if finding.affectedResources else None, + canAutoApply=False, + status=_get_state(session, 'recommendation', rec_id), # type: ignore[arg-type] + ) + + +def _latest_discovered_containers(session: Session) -> list[DiscoveredContainer]: + from .app_registry import is_appops_deployment_container_name + + runs = {run.id: run for run in session.exec(select(DiscoveryRun)).all()} + latest: dict[str, tuple[datetime, DiscoveredContainer]] = {} + for item in session.exec(select(DiscoveredContainer)).all(): + run = runs.get(item.discovery_run_id) + observed_at = run.started_at if run else datetime.min + current = latest.get(item.container_id) + if not current or observed_at >= current[0]: + latest[item.container_id] = (observed_at, item) + return [ + item + for _, item in latest.values() + if not is_appops_deployment_container_name(item.name) + ] + + +def _canonical_app_for_discovery(item: DiscoveredContainer, apps_by_id: dict[str, App]) -> App | None: + candidate_ids = [ + str(item.matched_app_id or ''), + str(item.suggested_app_type or ''), + str(item.suggested_connector or ''), + str(item.suggested_app_type or '').replace('-', '_'), + str(item.suggested_connector or '').replace('-', '_'), + str(item.name or '').strip().lower().replace(' ', '_').replace('-', '_'), + ] + for candidate_id in candidate_ids: + if candidate_id and candidate_id in apps_by_id: + return apps_by_id[candidate_id] + normalized_name = str(item.name or '').strip().lower().replace(' ', '').replace('-', '').replace('_', '') + for app in apps_by_id.values(): + app_name = str(app.name or '').strip().lower().replace(' ', '').replace('-', '').replace('_', '') + if normalized_name and app_name == normalized_name: + return app + return None + + +def _discovery_row_needs_review(item: DiscoveredContainer, apps_by_id: dict[str, App]) -> bool: + canonical = _canonical_app_for_discovery(item, apps_by_id) + if canonical and not canonical.needs_review: + return False + return True + + +def _snapshot(session: Session) -> dict[str, Any]: + from .app_registry import list_active_apps + + try: + from . import media_pipeline_service + + def media_summary() -> dict[str, Any]: + try: + return media_pipeline_service.summary(session) + except TypeError: + return media_pipeline_service.summary() + except Exception as exc: # noqa: BLE001 + media_error = redact_message(str(exc)) + + def media_summary() -> dict[str, Any]: + return {'success': False, 'status': 'unavailable', 'message': media_error} + + external_calls: dict[str, Callable[[], Any]] = { + 'npmSnapshot': _npm_snapshot, + 'authentikSnapshot': _authentik_snapshot, + 'unraidConnection': lambda: UnraidConnector().test_connection(), + 'unraidArray': lambda: UnraidConnector().get_array_status(), + 'unraidCache': lambda: UnraidConnector().get_cache_status(), + 'unraidDisks': lambda: UnraidConnector().get_disk_warnings(), + 'unraidDocker': lambda: UnraidConnector().get_docker_service_status(), + 'mediaPipeline': media_summary, + } + with ThreadPoolExecutor(max_workers=min(_SNAPSHOT_MAX_WORKERS, len(external_calls))) as executor: + futures = {key: executor.submit(_safe, call) for key, call in external_calls.items()} + external = {key: future.result() for key, future in futures.items()} + + npm_snapshot = external.pop('npmSnapshot') + if {'npmExposure', 'npmHosts'}.issubset(npm_snapshot.keys()): + external.update(npm_snapshot) + else: + external['npmExposure'] = npm_snapshot + external['npmHosts'] = npm_snapshot + + authentik_snapshot = external.pop('authentikSnapshot') + if {'authCoverage', 'authWarnings', 'authOutpost'}.issubset(authentik_snapshot.keys()): + external.update(authentik_snapshot) + else: + external['authCoverage'] = authentik_snapshot + external['authWarnings'] = authentik_snapshot + external['authOutpost'] = authentik_snapshot + + return { + 'apps': list_active_apps(session), + 'connectors': session.exec(select(Connector)).all(), + 'discovered': _latest_discovered_containers(session), + 'policies': session.exec(select(Policy)).all(), + 'audit': session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all(), + 'secretStorage': _safe(lambda: secret_storage_status()), + 'secrets': _safe(lambda: {'success': True, 'status': 'ok', 'items': list_secret_status(session)}), + **external, + } + + +def _source_connected(payload: Any) -> bool: + if isinstance(payload, dict): + if payload.get('status') in {'missing_config', 'unavailable', 'unsupported', 'error'}: + return False + return bool(payload.get('success', True)) + return bool(payload) + + +def _payload_status(payload: Any) -> str: + if not isinstance(payload, dict): + return 'connected' if payload else 'unavailable' + status = str(payload.get('status') or ('connected' if payload.get('success', True) else 'unavailable')) + if status in {'ok', 'success'}: + return 'connected' + if status in {'connected', 'missing_config', 'partial', 'unavailable', 'unsupported', 'error', 'warning', 'critical'}: + return status + if payload.get('success') is False: + return 'unavailable' + return 'connected' + + +def _connector_status(data: dict[str, Any], connector_id: str) -> str: + for connector in data.get('connectors', []): + if getattr(connector, 'id', None) == connector_id: + return str(getattr(connector, 'status', 'unavailable') or 'unavailable') + return 'unavailable' + + +def _coverage_source(status: str, *, effect: str) -> dict[str, Any]: + return {'status': status, 'effectOnConfidence': effect} + + +def _coverage_sources(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + npm_statuses = {_payload_status(data['npmExposure']), _payload_status(data['npmHosts'])} + auth_statuses = {_payload_status(data['authCoverage']), _payload_status(data['authWarnings'])} + unraid_status = _payload_status(data['unraidConnection']) + media_status = _payload_status(data['mediaPipeline']) + secret_status = _payload_status(data['secretStorage']) + portainer_status = _connector_status(data, 'portainer') + + def combined(statuses: set[str]) -> str: + if 'connected' in statuses: + return 'connected' + if 'partial' in statuses: + return 'partial' + if 'missing_config' in statuses: + return 'missing_config' + if 'warning' in statuses: + return 'partial' + return 'unavailable' + + return { + 'inventory': _coverage_source('connected' if data['apps'] or data['discovered'] else 'unavailable', effect='high'), + 'portainer': _coverage_source('connected' if portainer_status == 'connected' else _connector_status(data, 'portainer'), effect='high'), + 'unraid': _coverage_source(unraid_status, effect='medium'), + 'npm': _coverage_source(combined(npm_statuses), effect='high'), + 'authentik': _coverage_source(combined(auth_statuses), effect='high'), + 'secret_storage': _coverage_source('connected' if _source_connected(data['secretStorage']) else secret_status, effect='high'), + 'policies': _coverage_source('connected' if data['policies'] else 'unavailable', effect='high'), + 'audit': _coverage_source('connected' if data['audit'] else 'partial', effect='medium'), + 'media_pipeline': _coverage_source('connected' if media_status == 'connected' else media_status, effect='medium'), + } + + +def _data_completeness(data: dict[str, Any]) -> dict[str, Any]: + coverage = _coverage_sources(data) + connected = [name for name, detail in coverage.items() if detail['status'] == 'connected'] + missing = [name for name, detail in coverage.items() if detail['status'] in {'missing_config', 'unavailable', 'unsupported', 'error'}] + return {'percentage': round((len(connected) / len(SOURCE_NAMES)) * 100), 'connectedSources': connected, 'missingSources': missing} + + +def _coverage_status(completeness: dict[str, Any], *, high: int, critical: int, proven_high_risk: bool) -> str: + if critical or proven_high_risk: + return 'critical' + if completeness['missingSources']: + return 'warning' if high else 'partial' + if completeness['percentage'] < 100: + return 'partial' + return 'healthy' + + +def _exposure_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + payload = data['npmExposure'] + findings: list[SecurityFinding] = [] + if payload.get('status') == 'missing_config': + findings.append(_finding(session, 'ai-sec-npm-missing-config', title='Coverage incomplete: NPM connector is not configured', description='Security review is incomplete because NPM is not configured; public exposure cannot be concluded from missing coverage alone.', severity='medium', confidence='high', category='coverage', affected=['nginx_proxy_manager'], evidence=[_evidence('npm', 'status', payload.get('status'))], risk_reason='Coverage incomplete; no public exposure finding is raised without NPM host evidence.', actions=[_action(session, 'Configure NPM connector', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + return findings + warnings = payload.get('warnings') if isinstance(payload.get('warnings'), list) else [] + mapping = { + 'host_without_certificate': ('ai-sec-npm-host-without-certificate', 'NPM public host without certificate', 'high'), + 'certificate_expired': ('ai-sec-npm-certificate-expired', 'NPM certificate expired', 'critical'), + 'certificate_expiring_soon': ('ai-sec-npm-certificate-expiring-soon', 'NPM certificate expiring soon', 'medium'), + 'public_host_without_access_list': ('ai-sec-npm-host-without-access-list', 'NPM public host without access list', 'high'), + 'ssl_not_forced': ('ai-sec-npm-host-without-ssl-force', 'NPM host does not force SSL', 'medium'), + 'unknown_proxy_target': ('ai-sec-npm-unknown-target', 'NPM host forwards to unknown target', 'medium'), + } + seen: set[str] = set() + auth_coverage = data.get('authCoverage') if isinstance(data.get('authCoverage'), dict) else {} + unprotected_auth_domains = _unprotected_auth_domains(auth_coverage) + for warning in warnings: + if not isinstance(warning, dict): + continue + warning_type = str(warning.get('type')) + if warning_type not in mapping or warning_type in seen: + continue + if warning_type == 'public_host_without_access_list' and unprotected_auth_domains is not None: + continue + seen.add(warning_type) + finding_id, title, severity = mapping[warning_type] + findings.append(_finding(session, finding_id, title=title, description=str(warning.get('title') or title), severity=severity, confidence='high', category='exposure', affected=_npm_warning_affected_resources(warning_type, warning), evidence=[_evidence('npm', warning_type, warning.get('evidence'), reference_id=warning_type)], risk_reason='NPM exposure warning reported by connector.', actions=[_action(session, 'Open Exposure Center', href='/network'), _action(session, 'Create review task', action_type='policy_draft', target='nginx_proxy_manager')])) + return findings + + +def _npm_warning_affected_resources(warning_type: str, warning: dict[str, Any]) -> list[str]: + evidence = [str(item) for item in warning.get('evidence', []) if str(item).strip()] + if warning_type in {'certificate_expired', 'certificate_expiring_soon', 'ssl_not_forced'} and evidence: + return [evidence[0]] + return evidence[:3] + + +def _warning_domains(warning: dict[str, Any]) -> set[str]: + domains: set[str] = set() + for item in warning.get('evidence', []): + for candidate in str(item).replace(',', ' ').split(): + value = candidate.strip().lower() + if '.' in value: + domains.add(value) + return domains + + +def _unprotected_auth_domains(coverage: dict[str, Any]) -> set[str] | None: + if coverage.get('status') != 'ok': + return None + public_hosts = coverage.get('publicHosts') + if not isinstance(public_hosts, list): + return None + domains: set[str] = set() + for host in public_hosts: + if not isinstance(host, dict) or host.get('coverageStatus') != 'unprotected': + continue + for domain in host.get('domains', []): + value = str(domain).strip().lower() + if value: + domains.add(value) + return domains + + +def _auth_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + findings: list[SecurityFinding] = [] + coverage = data['authCoverage'] + npm_hosts_payload = data['npmHosts'] + hosts = npm_hosts_payload.get('items') if isinstance(npm_hosts_payload.get('items'), list) else [] + if coverage.get('status') == 'missing_config' and hosts: + findings.append(_finding(session, 'ai-sec-authentik-missing-public-hosts', title='Authentik coverage unavailable for public NPM hosts', description='NPM reports public hosts, but Authentik coverage cannot be reviewed because Authentik is missing configuration.', severity='high', confidence='high', category='auth_coverage', affected=['authentik', 'nginx_proxy_manager'], evidence=[_evidence('authentik', 'status', coverage.get('status')), _evidence('npm', 'publicHosts', len(hosts), expected='0 or mapped')], risk_reason='Public host evidence exists, but identity coverage is unavailable.', actions=[_action(session, 'Configure Authentik connector', href='/connectors'), _action(session, 'Open Exposure Center', href='/network'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + elif coverage.get('status') == 'missing_config' and _payload_status(npm_hosts_payload) == 'missing_config': + findings.append(_finding(session, 'ai-sec-authentik-coverage-missing-config', title='Coverage incomplete: Authentik connector is not configured', description='Security review is incomplete because Authentik and NPM coverage are unavailable; unprotected public hosts are not inferred.', severity='medium', confidence='high', category='coverage', affected=['authentik'], evidence=[_evidence('authentik', 'status', coverage.get('status')), _evidence('npm', 'status', npm_hosts_payload.get('status'))], risk_reason='Auth coverage unavailable; no public-host protection conclusion is made without NPM host evidence.', actions=[_action(session, 'Configure Authentik connector', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + unprotected_hosts = [ + host for host in coverage.get('publicHosts', []) + if isinstance(host, dict) and host.get('coverageStatus') == 'unprotected' + ] if isinstance(coverage.get('publicHosts'), list) else [] + if unprotected_hosts: + domains = [ + str(domain) + for host in unprotected_hosts + for domain in host.get('domains', []) + if str(domain).strip() + ] + sample = domains[:10] + findings.append(_finding(session, 'ai-sec-auth-public-host-without-authentik', title='Public hosts without Authentik mapping', description=f"{len(domains) or len(unprotected_hosts)} public host(s) have no Authentik mapping.", severity='high', confidence='high', category='auth_coverage', affected=sample, evidence=[_evidence('authentik', 'unprotectedHosts', len(unprotected_hosts), expected='0', reference_id='auth_coverage')], risk_reason='One or more public hosts appear unprotected by identity provider.', actions=[_action(session, 'Open Exposure Center', href='/network'), _action(session, 'Open Authentik details', href='/apps/authentik')])) + outpost = data['authOutpost'] + reported_outposts = outpost.get('items') if isinstance(outpost.get('items'), list) else [] + # Authentik does not expose a reliable health value for every embedded + # outpost version. A reported outpost with unknown telemetry is therefore + # not evidence that the outpost is unavailable. + if outpost.get('embeddedExpected') and not outpost.get('available') and not reported_outposts: + outpost_status = str(outpost.get('status') or 'unknown') + severity = 'medium' if outpost_status in {'warning', 'unknown'} else 'high' + findings.append(_finding(session, 'ai-sec-auth-outpost-unavailable', title='Expected Authentik outpost unavailable', description='Embedded outpost is expected but unavailable or unknown.', severity=severity, confidence='medium', category='auth_coverage', affected=['authentik'], evidence=[_evidence('authentik', 'outpostAvailable', outpost.get('available'), expected=True), _evidence('authentik', 'outpostStatus', outpost_status)], risk_reason='Auth proxy enforcement may need review.', actions=[_action(session, 'Open Authentik details', href='/apps/authentik')])) + for warning in data['authWarnings'].get('items', []) if isinstance(data['authWarnings'].get('items'), list) else []: + if 'TOKEN_VALUE' in str(warning) or 'SECRET' in str(warning): + findings.append(_finding(session, 'ai-sec-auth-warning-redacted', title=str(warning.get('title', 'Authentik warning')), description='Authentik warning included sensitive-looking material and was redacted.', severity=str(warning.get('severity', 'medium')), confidence='medium', category='auth_coverage', affected=['authentik'], evidence=[_evidence('authentik', 'warning', warning.get('evidence'))], risk_reason='Connector warning needs review.', actions=[_action(session, 'Open Authentik details', href='/apps/authentik')])) + return findings + + +def _secret_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + findings: list[SecurityFinding] = [] + storage = data['secretStorage'] + if settings.environment == 'production' and not settings.secret_key: + findings.append(_finding(session, 'ai-sec-secret-key-missing-production', title='Production secret key missing', description='Production should set APPOPS_SECRET_KEY or APPOPS_SECRET_KEY_FILE.', severity='critical', confidence='high', category='secrets', affected=['secret_storage'], evidence=[_evidence('secrets', 'keySource', settings.secret_key_source, expected='env or file')], risk_reason='Encrypted connector secrets need stable production key.', actions=[_action(session, 'Open Secrets Vault', href='/secrets')])) + if storage.get('encryptedAtRest') is False: + findings.append(_finding(session, 'ai-sec-secret-storage-not-encrypted', title='Secret storage not encrypted', description='Secret storage reports encryptedAtRest=false.', severity='critical', confidence='high', category='secrets', affected=['secret_storage'], evidence=[_evidence('secrets', 'encryptedAtRest', False, expected=True)], risk_reason='Connector secrets must be encrypted at rest.', actions=[_action(session, 'Open Secrets Vault', href='/secrets')])) + secret_items = data['secrets'].get('items', []) if isinstance(data['secrets'].get('items'), list) else [] + connected_optional_refs = { + secret_ref + for connector in data['connectors'] + if getattr(connector, 'status', None) == 'connected' + for secret_ref in OPTIONAL_CONNECTED_SECRET_REFS.get(str(connector.id), set()) + } + for item in secret_items: + if not item.get('hasSecret'): + ref = str(item.get('secretRef') or item.get('name') or 'connector_secret') + if ref in connected_optional_refs: + continue + setup_href = _connector_setup_href_for_secret(session, ref) + findings.append(_finding(session, f'ai-sec-missing-secret-{ref.lower()}', title=f'Coverage incomplete: missing connector secret {ref}', description='A connector secret reference is missing, so connector coverage is incomplete.', severity='medium', confidence='high', category='connector_config', affected=[ref], evidence=[_evidence('secret_storage', 'secretStatus', item.get('status'), expected='configured', reference_id=ref)], risk_reason='Connector cannot be fully validated without its secret.', actions=[_action(session, 'Open connector setup', href=setup_href), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + break + if item.get('status') == 'env_fallback': + ref = str(item.get('secretRef') or item.get('name') or 'connector_secret') + findings.append(_finding(session, f'ai-sec-secret-env-fallback-{ref.lower()}', title=f'Secret uses env fallback: {ref}', description='A connector secret is available through environment fallback instead of stored secret metadata.', severity='info', confidence='medium', category='secrets', affected=[ref], evidence=[_evidence('secrets', 'status', item.get('status'), expected='configured', reference_id=ref)], risk_reason='Env fallback is supported but less visible to setup lifecycle.', actions=[_action(session, 'Open Secrets Vault', href='/secrets')])) + break + if not any(finding.id.startswith('ai-sec-missing-secret-') for finding in findings): + known = {str(item.get('secretRef') or item.get('name') or ''): item for item in secret_items} + for connector in data['connectors']: + secret_ref = getattr(connector, 'secret_ref', None) + if not secret_ref: + continue + if ( + getattr(connector, 'status', None) == 'connected' + and secret_ref in OPTIONAL_CONNECTED_SECRET_REFS.get(str(connector.id), set()) + ): + continue + status = known.get(secret_ref) + if status and status.get('hasSecret'): + continue + if not settings.apis.get(secret_ref): + setup_href = _connector_setup_href(str(connector.id)) + findings.append(_finding(session, f'ai-sec-missing-secret-{secret_ref.lower()}', title=f'Coverage incomplete: missing connector secret {secret_ref}', description='A connector secret reference is configured but no stored or environment-backed value is available.', severity='medium', confidence='high', category='connector_config', affected=[secret_ref], evidence=[_evidence('secret_storage', 'connectorSecretRef', secret_ref, observed='missing', expected='configured')], risk_reason='Connector cannot be fully validated without its secret.', actions=[_action(session, 'Open connector setup', href=setup_href), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + break + return findings + + +def _policy_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + findings: list[SecurityFinding] = [] + policy_by_action = {policy.action: policy for policy in data['policies']} + destructive = ['volume.delete', 'docker.prune', 'unraid.disk.format'] + for action_id in destructive: + policy = policy_by_action.get(action_id) + if policy and (policy.voice != 'blocked' or policy.manual != 'blocked'): + findings.append(_finding(session, f'ai-sec-policy-{action_id}-not-blocked'.replace('.', '-'), title=f'Destructive action not blocked: {action_id}', description='A destructive action is not blocked for all sources.', severity='critical', confidence='high', category='policies', affected=[action_id], evidence=[_evidence('policies', action_id, {'voice': policy.voice, 'manual': policy.manual}, expected='blocked')], risk_reason='Destructive action should fail closed.', actions=[_action(session, 'Open Policies', href='/policies')])) + mover = policy_by_action.get('unraid.mover.start') + if mover and mover.voice == 'allowed': + findings.append(_finding(session, 'ai-sec-policy-unraid-mover-voice-allowed', title='Unraid mover voice action is allowed', description='unraid.mover.start should not be voice-allowed by default.', severity='high', confidence='high', category='policies', affected=['unraid.mover.start'], evidence=[_evidence('policies', 'voice', mover.voice, expected='blocked or approval_required', reference_id=mover.id)], risk_reason='Mover can affect storage state and should be operator-controlled.', actions=[_action(session, 'Open Policies', href='/policies'), _action(session, 'Create policy draft', action_type='policy_draft', action_id='unraid.mover.start', target='unraid')])) + for policy in data['policies']: + if policy.voice == 'allowed' and policy.risk in {'high', 'critical'}: + findings.append(_finding(session, f'ai-sec-policy-high-risk-voice-{policy.id}', title=f'High-risk voice action allowed: {policy.action}', description='High-risk actions should require approval or be blocked for voice.', severity='high', confidence='medium', category='policies', affected=[policy.action, policy.target], evidence=[_evidence('policies', 'voice', policy.voice, expected='approval_required or blocked', reference_id=policy.id)], risk_reason='Voice action could bypass operator review.', actions=[_action(session, 'Open Policies', href='/policies')])) + break + return findings + + +def _connector_inventory_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + findings: list[SecurityFinding] = [] + apps_by_id = {str(app.id): app for app in data['apps']} + for connector in data['connectors']: + if connector.id in {'portainer', 'unraid', 'nginx_proxy_manager', 'authentik'} and connector.status in {'missing_config', 'disconnected', 'error'}: + findings.append(_finding(session, f'ai-sec-connector-{connector.id}-not-ready', title=f'Coverage incomplete: {connector.name} connector not ready', description='An important connector is not connected or configured.', severity='medium', confidence='high', category='connector_config', affected=[connector.id], evidence=[_evidence('connectors', 'status', connector.status, expected='connected', reference_id=connector.id)], risk_reason='Reviewer coverage is incomplete while connector is not ready.', actions=[_action(session, 'Open Connector Settings', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + break + for item in data['discovered']: + matched_app = apps_by_id.get(str(item.matched_app_id or '')) + if item.import_status == 'changed' and matched_app and matched_app.protected and not matched_app.needs_review: + continue + if item.import_status in {'discovered', 'unmapped', 'changed'} and item.suggested_criticality in {'high', 'critical'} and _discovery_row_needs_review(item, apps_by_id): + href = f'/apps/{item.matched_app_id}' if item.matched_app_id else '/inventory' + findings.append(_finding(session, f'ai-sec-inventory-critical-review-{item.id}', title=f'Critical discovered app needs review: {item.name}', description='A critical-looking discovered app still needs review or mapping.', severity='high', confidence='medium', category='inventory', affected=[item.name], evidence=[_evidence('inventory', 'importStatus', item.import_status, expected='managed', reference_id=item.id)], risk_reason='Critical app should be reviewed before relying on automation.', actions=[_action(session, 'Open App detail' if item.matched_app_id else 'Open Inventory', href=href)])) + break + for app in data['apps']: + if app.criticality == 'critical' and not app.protected: + findings.append(_finding(session, f'ai-sec-critical-app-unprotected-{app.id}', title=f'Critical app is not protected: {app.name}', description='Critical app registry entry is not marked protected.', severity='high', confidence='medium', category='inventory', affected=[app.id], evidence=[_evidence('inventory', 'protected', app.protected, expected=True, reference_id=app.id)], risk_reason='Critical apps should default to protected controls.', actions=[_action(session, 'Open App detail', href=f'/apps/{app.id}')])) + break + if app.needs_review and app.voice_enabled: + findings.append(_finding(session, f'ai-sec-discovered-voice-before-review-{app.id}', title=f'Voice enabled before review: {app.name}', description='Discovered apps should not have voice enabled before review.', severity='critical', confidence='high', category='inventory', affected=[app.id], evidence=[_evidence('inventory', 'voiceEnabled', app.voice_enabled, expected=False, reference_id=app.id)], risk_reason='Voice automation before review violates safety defaults.', actions=[_action(session, 'Open App detail', href=f'/apps/{app.id}')])) + break + return findings + + +def _audit_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + events = data['audit'] + if not events: + return [_finding(session, 'ai-sec-audit-no-recent-events', title='No recent audit events', description='No audit events are available for reviewer analysis.', severity='low', confidence='medium', category='audit', affected=['audit'], evidence=[_evidence('audit', 'events', 0, expected='>0')], risk_reason='Audit history improves security review confidence.', actions=[_action(session, 'Open Audit Log', href='/audit')])] + resolved_setup_checks: set[str] = set() + blocked: list[AuditEvent] = [] + for event in events: + action_target = f'{event.action_id}:{event.target}' + if event.action_id.startswith('setup.test.') and event.result == 'success' and event.policy_decision != 'blocked': + resolved_setup_checks.add(action_target) + continue + if event.policy_decision == 'blocked' or event.result == 'failed': + if event.action_id.startswith('setup.test.') and action_target in resolved_setup_checks: + continue + blocked.append(event) + if len(blocked) >= 3: + counter = Counter(f'{event.action_id}:{event.target}' for event in blocked) + action_target, count = counter.most_common(1)[0] + return [_finding(session, 'ai-sec-audit-repeated-blocked-attempts', title='Repeated blocked or failed attempts', description='Repeated blocked/failed audit events were detected.', severity='medium', confidence='high', category='audit', affected=[action_target], evidence=[_evidence('audit', 'blockedOrFailedCount', count, expected='<3')], risk_reason='Repeated blocked actions may indicate noisy automation or probing.', actions=[_action(session, 'Open Audit Log', href='/audit')])] + return [] + + +def _unraid_media_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: + findings: list[SecurityFinding] = [] + if data['unraidConnection'].get('status') == 'missing_config': + findings.append(_finding(session, 'ai-sec-unraid-missing-config', title='Coverage incomplete: Unraid connector is not configured', description='Unraid safety data is unavailable because the connector is missing configuration.', severity='medium', confidence='high', category='coverage', affected=['unraid'], evidence=[_evidence('unraid', 'status', 'missing_config', expected='connected')], risk_reason='Server safety checks are incomplete; no Unraid risk is concluded from missing coverage alone.', actions=[_action(session, 'Configure Unraid connector', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) + if data['unraidCache'].get('cacheUsage', 0) and int(data['unraidCache'].get('cacheUsage', 0)) >= 85: + findings.append(_finding(session, 'ai-sec-unraid-cache-high', title='Unraid cache usage high', description='Cache usage is above recommended threshold.', severity='medium', confidence='medium', category='unraid', affected=['unraid-cache'], evidence=[_evidence('unraid', 'cacheUsage', data['unraidCache'].get('cacheUsage'), expected='<85')], risk_reason='High cache usage can affect Docker/appdata stability.', actions=[_action(session, 'Open Storage', href='/storage')])) + media = data['mediaPipeline'] + if media.get('status') in {'partial', 'warning', 'critical'}: + issues = media.get('issues') if isinstance(media.get('issues'), list) else [] + issue_titles = [str(issue.get('title')) for issue in issues if isinstance(issue, dict) and issue.get('title')] + affected = [ + str(item) + for issue in issues + if isinstance(issue, dict) + for item in (issue.get('affected') if isinstance(issue.get('affected'), list) else []) + if str(item).strip() + ] + if not affected: + counts = media.get('counts') if isinstance(media.get('counts'), dict) else {} + risk = media.get('risk') if isinstance(media.get('risk'), dict) else {} + reasons = ' '.join(str(item) for item in risk.get('reasons', []) if item) + if int(counts.get('stalledDownloads') or 0) > 0 or 'deluge' in reasons.lower(): + affected.append('deluge') + if int(counts.get('failedWidefrogJobs') or 0) > 0 or 'widefrog' in reasons.lower(): + affected.append('widefrog') + if int(counts.get('failingIndexers') or 0) > 0 or 'prowlarr' in reasons.lower(): + affected.append('prowlarr') + if int(counts.get('transcodes') or 0) >= 3 or 'plex' in reasons.lower(): + affected.append('plex') + affected = list(dict.fromkeys(affected))[:6] or ['media_pipeline'] + issue_evidence = issue_titles[:5] + if not issue_evidence: + counts = media.get('counts') if isinstance(media.get('counts'), dict) else {} + risk = media.get('risk') if isinstance(media.get('risk'), dict) else {} + issue_evidence = [str(item) for item in risk.get('reasons', []) if item] + if not issue_evidence: + issue_evidence = [f'{key}={value}' for key, value in counts.items() if value] + title = 'Media pipeline needs review' + if len(issue_titles) == 1: + title = f'{title}: {issue_titles[0]}' + findings.append(_finding(session, 'ai-sec-media-pipeline-issue', title=title, description=str(media.get('message') or '; '.join(issue_titles[:3]) or 'Media pipeline reported a warning or partial state.'), severity='medium', confidence='medium', category='media', affected=affected, evidence=[_evidence('media_pipeline', 'status', media.get('status'), expected='healthy'), _evidence('media_pipeline', 'issues', issue_evidence or None)], risk_reason='Media connector issues can hide operational failures.', actions=[_action(session, 'Open Media Pipeline', href='/media-pipeline'), _action(session, 'Run media pipeline diagnose', action_type='run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline')])) + return findings + + +def _finding_has_proven_high_risk(finding: SecurityFinding) -> bool: + if finding.status != 'open': + return False + if finding.severity == 'critical': + return True + if finding.severity != 'high': + return False + if finding.category in COVERAGE_CATEGORIES: + return False + high_risk_tokens = ( + 'without-certificate', + 'certificate-expired', + 'without-access-list', + 'public-host', + 'not-blocked', + 'voice-allowed', + 'high-risk-voice', + 'secret-key-missing', + 'secret-storage-not-encrypted', + 'critical-app-unprotected', + 'voice-before-review', + ) + return any(token in finding.id for token in high_risk_tokens) + + +def _build_score_breakdown(data: dict[str, Any], findings: list[SecurityFinding], score: int, counts: dict[str, int], coverage_status: str) -> dict[str, Any]: + completeness = _data_completeness(data) + coverage_sources = _coverage_sources(data) + penalties: list[dict[str, Any]] = [] + for finding in findings: + if finding.status != 'open': + continue + points = SEVERITY_PENALTY.get(finding.severity, 0) + if points <= 0: + continue + source = finding.category + evidence_source = finding.evidence[0].source if finding.evidence else source + penalties.append({ + 'source': evidence_source, + 'reason': finding.title, + 'severity': finding.severity, + 'points': points, + 'findingId': finding.id, + }) + return { + 'baseScore': 100, + 'finalScore': score, + 'penalties': _redact(penalties), + 'missingSources': completeness['missingSources'], + 'dataCompleteness': completeness, + 'criticalFindingsCount': counts['critical'], + 'highFindingsCount': counts['high'], + 'mediumFindingsCount': counts['medium'], + 'coverageStatus': coverage_status, + 'coverageSources': _redact_mapping_values(coverage_sources), + } + + +def generate_review(session: Session, scope: SecurityReviewScope = 'all', audit_run: bool = False) -> SecurityReviewResult: + if not audit_run: + cached = _cached_review(session, scope) + if cached: + return cached + + data = _snapshot(session) + rules = { + 'exposure': _exposure_findings, + 'auth': _auth_findings, + 'secrets': _secret_findings, + 'policies': _policy_findings, + 'connectors': _connector_inventory_findings, + 'audit': _audit_findings, + 'unraid': _unraid_media_findings, + 'media': _unraid_media_findings, + } + selected = list(rules) if scope == 'all' else [scope] + findings: list[SecurityFinding] = [] + for key in selected: + rule = rules.get(key) + if rule: + findings.extend(rule(session, data)) + deduped = list({finding.id: finding for finding in findings}.values()) + recommendations = [_recommendation(session, f'rec-{finding.id}', finding, finding.suggestedActions[0].label if finding.suggestedActions else 'Review finding') for finding in deduped if finding.severity in {'critical', 'high', 'medium'}] + score = max(0, min(100, 100 - sum(SEVERITY_PENALTY.get(finding.severity, 0) for finding in deduped if finding.status == 'open'))) + critical = sum(1 for finding in deduped if finding.severity == 'critical' and finding.status == 'open') + high = sum(1 for finding in deduped if finding.severity == 'high' and finding.status == 'open') + medium = sum(1 for finding in deduped if finding.severity == 'medium' and finding.status == 'open') + warnings = sum(1 for finding in deduped if finding.severity in {'low', 'medium', 'high'} and finding.status == 'open') + info = sum(1 for finding in deduped if finding.severity == 'info' and finding.status == 'open') + completeness = _data_completeness(data) + proven_high_risk = any(_finding_has_proven_high_risk(finding) for finding in deduped) + real_high = any(finding.status == 'open' and finding.severity == 'high' and finding.category not in COVERAGE_CATEGORIES for finding in deduped) + if critical > 0 or (score < 65 and (real_high or proven_high_risk)): + status = 'critical' + elif completeness['missingSources']: + status = 'warning' if high or proven_high_risk else 'partial' + elif score < 85 or high or medium: + status = 'warning' + else: + status = 'healthy' + coverage_status = _coverage_status(completeness, high=high, critical=critical, proven_high_risk=proven_high_risk) + score_breakdown = _build_score_breakdown( + data, + deduped, + score, + {'critical': critical, 'high': high, 'medium': medium}, + coverage_status, + ) + summary = SecurityReviewSummary( + status=status, # type: ignore[arg-type] + score=score, + generatedAt=_now(), + dataCompleteness=completeness, + criticalFindings=critical, + highFindings=high, + mediumFindings=medium, + warningFindings=warnings, + infoFindings=info, + recommendationsCount=len(recommendations), + connectedSources=completeness['connectedSources'], + scoreBreakdown=score_breakdown, + ) + if audit_run: + log_event(session, source='manual', identity='ai_security_reviewer', action_id='ai.security.review', target=str(scope), target_type='ai_security', risk='low', policy_decision='allowed', reason='AI Security Reviewer read-only review generated.', result='success', redacted_metadata={'scope': scope, 'score': score, 'findings': len(deduped)}) + result = SecurityReviewResult(summary=summary, findings=deduped, recommendations=recommendations) + return _store_review(session, scope, result) + + +def get_summary(session: Session) -> dict[str, Any]: + return generate_review(session).summary.model_dump() + + +def get_findings(session: Session, scope: SecurityReviewScope = 'all') -> list[dict[str, Any]]: + return [finding.model_dump() for finding in generate_review(session, scope).findings] + + +def get_finding(session: Session, finding_id: str) -> dict[str, Any] | None: + return next((finding for finding in get_findings(session) if finding['id'] == finding_id), None) + + +def get_recommendations(session: Session) -> list[dict[str, Any]]: + return [recommendation.model_dump() for recommendation in generate_review(session).recommendations] + + +def set_finding_status(session: Session, finding_id: str, status: str) -> dict[str, Any]: + _clear_review_cache() + result = _set_state(session, 'finding', finding_id, status) + finding = get_finding(session, finding_id) + result['finding'] = finding + return result + + +def set_recommendation_status(session: Session, recommendation_id: str, status: str) -> dict[str, Any]: + _clear_review_cache() + result = _set_state(session, 'recommendation', recommendation_id, status) + recommendation = next((item for item in get_recommendations(session) if item['id'] == recommendation_id), None) + result['recommendation'] = recommendation + result['applied'] = False + return result diff --git a/backend/app/services/app_classifier.py b/backend/app/services/app_classifier.py new file mode 100644 index 0000000..d1a6c31 --- /dev/null +++ b/backend/app/services/app_classifier.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import re +from typing import Any + + +def _text(raw: dict[str, Any]) -> str: + labels = raw.get('labels') if isinstance(raw.get('labels'), dict) else {} + label_text = ' '.join(f'{key} {value}' for key, value in labels.items()) + ports = ' '.join(str(port) for port in raw.get('ports', []) if port is not None) + text = f"{raw.get('name', '')} {raw.get('image', '')} {label_text} {ports}".lower() + # Preserve the source while also matching humanized names such as "Gpu Hot" + # against the same conservative patterns used for container IDs and images. + return ' '.join((text, re.sub(r'[\s_]+', '-', text), re.sub(r'[\s-]+', '_', text))) + + +def _match(text: str, *patterns: str) -> bool: + return any(re.search(pattern, text) for pattern in patterns) + + +def _base(raw: dict[str, Any]) -> dict[str, Any]: + name = str(raw.get('name') or raw.get('container_id') or 'unknown').strip('/') or 'unknown' + safe_id = re.sub(r'[^a-z0-9_]+', '-', name.lower()).strip('-') or 'unknown' + return { + 'suggested_app_id': safe_id.replace('-', '_'), + 'display_name': name.replace('-', ' ').replace('_', ' ').title(), + 'app_type': 'unknown', + 'category': 'unknown', + 'criticality': 'low', + 'connector': 'none', + 'policy_profile': 'generic_unknown', + 'protected': False, + 'healthcheck_suggestion': None, + 'voice_suggestion': 'disabled', + 'confidence': 0.2, + 'reasons': ['No known pattern matched.'], + 'import_status': 'unmapped', + } + + +def classify_container(raw: dict[str, Any]) -> dict[str, Any]: + text = _text(raw) + result = _base(raw) + + def apply( + *, + app_type: str, + category: str, + criticality: str, + connector: str = 'none', + policy_profile: str | None = None, + subcategory: str | None = None, + protected: bool = False, + privacy_sensitive: bool = False, + confidence: float = 0.85, + reason: str, + default_port: int | None = None, + ) -> dict[str, Any]: + result.update( + { + 'suggested_app_id': app_type, + 'display_name': app_type.replace('_', ' ').title(), + 'app_type': app_type, + 'category': category, + 'subcategory': subcategory, + 'criticality': criticality, + 'connector': connector, + 'policy_profile': policy_profile or category, + 'protected': protected, + 'privacy_sensitive': privacy_sensitive, + 'healthcheck_suggestion': {'port': default_port} if default_port else {'type': 'http'}, + 'voice_suggestion': 'blocked' if protected else 'disabled', + 'confidence': confidence, + 'reasons': [reason], + 'import_status': 'discovered', + } + ) + return result + + if _match(text, r'\b(binhex-)?sonarr\b', r'arch-sonarr'): + return apply(app_type='sonarr', category='media', criticality='medium', connector='sonarr', policy_profile='media_app', reason='Matched Sonarr name/image pattern.') + if _match(text, r'\b(binhex-)?radarr\b', r'arch-radarr'): + return apply(app_type='radarr', category='media', criticality='medium', connector='radarr', policy_profile='media_app', reason='Matched Radarr name/image pattern.') + if _match(text, r'\bprowlarr\b'): + return apply(app_type='prowlarr', category='media', criticality='medium', connector='prowlarr', policy_profile='media_indexer', reason='Matched Prowlarr pattern.', default_port=9696) + if _match(text, r'\b(binhex-)?deluge(vpn)?\b', r'arch-delugevpn', r'linuxserver/deluge'): + return apply(app_type='deluge', category='downloads', subcategory='torrent_client', criticality='medium', connector='deluge', policy_profile='download_client', reason='Matched Deluge download client pattern.', default_port=8112) + if _match(text, r'\b(binhex-)?plex(pass)?\b', r'arch-plexpass', r'linuxserver/plex', r'plexinc/pms-docker'): + return apply(app_type='plex', category='media', subcategory='media_server', criticality='medium', connector='plex', policy_profile='media_server', reason='Matched Plex media server pattern.', default_port=32400) + if _match(text, r'\bjellyfin\b'): + return apply(app_type='jellyfin', category='media', criticality='medium', connector='none', policy_profile='media_app', reason='Matched Jellyfin media server pattern; no AppOps Jellyfin connector is registered.') + if _match(text, r'\b(binhex-)?tautulli\b', r'linuxserver/tautulli', r'tautulli/tautulli'): + return apply(app_type='tautulli', category='media', subcategory='plex_analytics', criticality='medium', connector='tautulli', policy_profile='media_analytics', privacy_sensitive=True, reason='Matched Tautulli Plex analytics pattern.', default_port=8181) + if _match(text, r'\b(qbittorrent|deluge|sabnzbd|nzbget)\b'): + app_type = next((name for name in ['qbittorrent', 'deluge', 'sabnzbd', 'nzbget'] if name in text), 'downloads') + return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download client pattern.') + if _match(text, r'\bauthentik\b', r'\bauthentik-(server|worker)\b', r'ghcr\.io/goauthentik/(server|proxy)', r'beryju/authentik', r'\bgoauthentik\b'): + return apply( + app_type='authentik', + category='auth', + subcategory='identity_provider', + criticality='critical', + connector='authentik', + protected=True, + policy_profile='auth_provider', + reason='Matched Authentik identity provider pattern.', + default_port=9000, + ) + if _match(text, r'\b(postgres|postgresql|mariadb|mysql)\b'): + app_type = next((name for name in ['postgres', 'postgresql', 'mariadb', 'mysql'] if name in text), 'database') + return apply(app_type=app_type, category='database', criticality='critical', protected=True, policy_profile='database', reason='Matched database pattern.') + if _match(text, r'\bredis\b'): + return apply(app_type='redis', category='database', criticality='high', protected=True, policy_profile='database', reason='Matched Redis pattern.') + if _match(text, r'\b(nginxproxymanager|nginx-proxy-manager|jc21/nginx-proxy-manager|npm)\b'): + return apply( + app_type='nginx_proxy_manager', + category='network', + subcategory='reverse_proxy', + criticality='critical', + connector='nginx_proxy_manager', + protected=True, + policy_profile='reverse_proxy', + reason='Matched Nginx Proxy Manager reverse proxy pattern.', + default_port=81, + ) + if _match(text, r'\bportainer\b'): + return apply(app_type='portainer', category='infrastructure', criticality='critical', protected=True, policy_profile='protected_service', reason='Matched Portainer pattern.') + if _match(text, r'\b(homeassistant|home-assistant|home_assistant)\b'): + return apply(app_type='home_assistant', category='home_automation', criticality='high', protected=True, policy_profile='protected_service', reason='Matched Home Assistant pattern.') + if _match(text, r'\bgitea\b'): + return apply(app_type='gitea', category='dev', criticality='medium', policy_profile='dev_tool', reason='Matched Gitea pattern.') + if _match(text, r'\bwidefrog\b'): + return apply(app_type='widefrog', category='media', criticality='medium', connector='widefrog', policy_profile='media_custom_app', reason='Matched Widefrog pattern.', default_port=1207) + if _match(text, r'\b(adguard|adguardhome|adguard-home)\b'): + return apply(app_type='adguard_home', category='network', criticality='high', protected=True, policy_profile='network_service', reason='Matched AdGuard network service pattern.') + if _match(text, r'\b(vaultwarden|bitwarden)\b'): + return apply(app_type='vaultwarden', category='auth', criticality='critical', protected=True, privacy_sensitive=True, policy_profile='auth_provider', reason='Matched credential vault pattern.') + if _match(text, r'\b(adminer|phpmyadmin)\b'): + app_type = 'adminer' if 'adminer' in text else 'phpmyadmin' + return apply(app_type=app_type, category='database', criticality='high', protected=True, policy_profile='database_admin', reason='Matched database administration pattern.') + if _match(text, r'\b(audiobookshelf|audiodeck|bazarr|seerr|komga|lidarr|mylar3?|immich|peertube|romm|tdarr|grimmory|listenarr|readmeabook)\b'): + app_type = next((name for name in ['audiobookshelf', 'audiodeck', 'bazarr', 'seerr', 'komga', 'lidarr', 'mylar3', 'immich', 'peertube', 'romm', 'tdarr', 'grimmory', 'listenarr', 'readmeabook'] if name.replace('_', '-') in text or name in text), 'media_app') + protected = app_type == 'immich' + return apply(app_type=app_type, category='media', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='media_app', reason='Matched media library or companion app pattern.') + if _match(text, r'\b(paperless-ngx|paperless_ngx|stirlingpdf|stirling-pdf|leafwiki|infrabinder-local|infrabinder_local)\b'): + app_type = next((name for name in ['paperless_ngx', 'stirlingpdf', 'leafwiki', 'infrabinder_local'] if name.replace('_', '-') in text or name in text), 'document_service') + protected = app_type in {'paperless_ngx', 'infrabinder_local'} + return apply(app_type=app_type, category='productivity', subcategory='documents', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=True, policy_profile='document_service', reason='Matched document or knowledge-management service pattern.') + if _match(text, r'\b(jdownloader2?|metube|slskd|unpackerr)\b'): + app_type = next((name for name in ['jdownloader2', 'metube', 'slskd', 'unpackerr'] if name in text), 'download_tool') + return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download workflow pattern.') + if _match(text, r'\b(ollama|anythingllm|litellm|chromadb|hivekeep|odysseus-aio|odysseus_aio)\b'): + app_type = next((name for name in ['ollama', 'anythingllm', 'litellm', 'chromadb', 'hivekeep', 'odysseus_aio'] if name.replace('_', '-') in text or name in text), 'ai_service') + category = 'database' if app_type == 'chromadb' else 'infrastructure' + protected = app_type in {'chromadb', 'hivekeep', 'odysseus_aio'} + return apply(app_type=app_type, category=category, criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='database' if app_type == 'chromadb' else 'ai_service', reason='Matched local AI platform pattern.') + if _match(text, r'\b(searxng|flaresolverr|postfix-relay|postfix_relay)\b'): + app_type = next((name for name in ['searxng', 'flaresolverr', 'postfix_relay'] if name.replace('_', '-') in text or name in text), 'network_service') + return apply(app_type=app_type, category='network', criticality='medium', protected=app_type == 'postfix_relay', policy_profile='network_service', reason='Matched network support service pattern.') + if _match(text, r'\b(unraid-appops-gateway|unraid_appops_gateway|watchtower)\b'): + app_type = 'unraid_appops_gateway' if 'appops' in text else 'watchtower' + return apply(app_type=app_type, category='infrastructure', criticality='critical' if app_type == 'unraid_appops_gateway' else 'high', protected=True, policy_profile='protected_service', reason='Matched infrastructure control-plane service pattern.') + if _match(text, r'\b(organizrv?2?|organizr|unraid-progressive-web-app|unraid_progressive_web_app|ntfy)\b'): + app_type = next((name for name in ['organizrv2', 'unraid_progressive_web_app', 'ntfy'] if name.replace('_', '-') in text or name in text), 'infrastructure_service') + return apply(app_type=app_type, category='infrastructure', criticality='medium', policy_profile='infrastructure_service', reason='Matched self-hosted infrastructure support service pattern.') + if _match(text, r'\b(retroarch|rickroll)\b'): + app_type = 'retroarch' if 'retroarch' in text else 'rickroll' + return apply(app_type=app_type, category='media', criticality='low', policy_profile='media_app', reason='Matched media or entertainment service pattern.') + if _match(text, r'\bdockdeck\b', r'dockdeck:local'): + return apply(app_type='dockdeck', category='infrastructure', subcategory='service_portal', criticality='medium', privacy_sensitive=True, policy_profile='internal_service', reason='Matched the local DockDeck self-hosted start dashboard pattern.', default_port=1218) + if _match(text, r'\blaunchpad\b', r'launchpad:local'): + return apply(app_type='launchpad', category='infrastructure', subcategory='service_portal', criticality='medium', policy_profile='internal_service', reason='Matched the local Launchpad service portal pattern.') + if _match(text, r'\bmedia-codec-dashboard\b', r'\bmedia_codec_dashboard\b'): + return apply(app_type='media_codec_dashboard', category='media', subcategory='media_observability', criticality='medium', privacy_sensitive=True, policy_profile='media_app', reason='Matched the local media codec observability dashboard pattern.') + if _match(text, r'\bgeointel\b'): + return apply(app_type='geointel', category='ai', subcategory='geospatial_workbench', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local GeoIntel GeoAI workbench pattern.') + if _match(text, r'\bopscenter-it-tools\b', r'\bopscenter_it_tools\b'): + return apply(app_type='opscenter_it_tools', category='dev', subcategory='operations_tooling', criticality='high', protected=True, privacy_sensitive=True, policy_profile='dev_tool', reason='Matched the local OpsCenter diagnostics workbench pattern.') + if _match(text, r'\bprojectbrain\b'): + return apply(app_type='projectbrain', category='productivity', subcategory='project_operations', criticality='high', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local ProjectBrain project operating system pattern.') + if _match(text, r'\bpsforge\b'): + return apply(app_type='psforge', category='productivity', subcategory='project_planning', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local PSForge planning and documentation workbench pattern.') + if _match(text, r'\bquizmonitor\b', r'\bquiz-monitor\b'): + return apply(app_type='quizmonitor', category='ai', subcategory='knowledge_assistant', criticality='high', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local QuizMonitor AI knowledge platform pattern.') + if _match(text, r'\bspoortracker\b'): + return apply(app_type='spoortracker', category='monitoring', subcategory='transport_monitoring', criticality='low', policy_profile='monitoring', reason='Matched the local SpoorTracker live transport monitoring pattern.') + if _match(text, r'\bcertscope\b'): + return apply(app_type='certscope', category='monitoring', subcategory='domain_inspection', criticality='low', policy_profile='monitoring', reason='Matched the local CertScope domain posture inspector pattern.') + if _match(text, r'\bps4-http\b', r'\bps4_http\b'): + return apply(app_type='ps4_http', category='infrastructure', subcategory='http_service', criticality='low', policy_profile='infrastructure_service', reason='Matched the local PS4 HTTP support service pattern.') + if _match(text, r'\b(zabbix|grafana|uptime-kuma|glances|netdata|netronome|unraid-stats|unraid_stats|system-monitor|system_monitor|gpu-hot|gpu_hot)\b'): + app_type = next((name for name in ['zabbix', 'grafana', 'uptime-kuma', 'glances', 'netdata', 'netronome', 'unraid_stats', 'system_monitor', 'gpu_hot'] if name.replace('_', '-') in text or name in text), 'monitoring') + return apply(app_type=app_type.replace('-', '_'), category='monitoring', criticality='medium', policy_profile='monitoring', reason='Matched monitoring pattern.') + + return result diff --git a/backend/app/services/app_registry.py b/backend/app/services/app_registry.py new file mode 100644 index 0000000..fd59c83 --- /dev/null +++ b/backend/app/services/app_registry.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import re + +from sqlmodel import Session, select + +from ..db.models import App + + +_APPOPS_ROLLBACK_ID = re.compile( + r'^unraid_appops_gateway(?:_1216)?_pre_\d{8}t\d{6}z$', + re.IGNORECASE, +) + + +def is_appops_deployment_container_name(value: str | None) -> bool: + key = str(value or '').strip().lower().replace('_', '-') + return key in {'unraid-appops-gateway', 'unraid-appops-gateway-1216'} or bool( + re.fullmatch(r'unraid-appops-gateway(?:-1216)?-pre-\d{8}t\d{6}z', key) + ) + + +def is_historical_app_record(app: App) -> bool: + """Keep deployment history out of active product views without deleting it.""" + return bool(_APPOPS_ROLLBACK_ID.fullmatch(str(app.id or '').strip())) + + +def category_label(value: str | None) -> str: + words = str(value or 'Unknown').strip().replace('_', ' ').replace('-', ' ').title() + replacements = {'Ai': 'AI', 'Api': 'API', 'It': 'IT'} + return ' '.join(replacements.get(word, word) for word in words.split()) or 'Unknown' + + +def list_active_apps(session: Session, *, order_by: str = 'name') -> list[App]: + apps = [app for app in session.exec(select(App)).all() if not is_historical_app_record(app)] + + # A port-specific canonical record supersedes the old unqualified 1211 record. + if any(app.id == 'unraid_appops_gateway_1216' for app in apps): + apps = [app for app in apps if app.id != 'unraid_appops_gateway'] + + key = (lambda app: app.id.lower()) if order_by == 'id' else (lambda app: app.name.lower()) + return sorted(apps, key=key) diff --git a/backend/app/services/approval_service.py b/backend/app/services/approval_service.py new file mode 100644 index 0000000..8136f35 --- /dev/null +++ b/backend/app/services/approval_service.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import timedelta + +from sqlmodel import Session, select + +from ..db.models import Approval +from ..core.time import utc_now + + +VALID_DECISIONS = {'approved', 'rejected'} + + +class ApprovalStateError(ValueError): + pass + + +def approval_fingerprint(action: str, target: str, source: str, risk: str, context: dict | None = None) -> str: + canonical_context = json.dumps(context or {}, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + canonical = "\x1f".join([*(part.strip() for part in (action, target, source, risk)), canonical_context]) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def get_approval(session: Session, approval_id: str) -> Approval | None: + approval = session.get(Approval, approval_id) + if approval and not approval.action_fingerprint: + approval.action_fingerprint = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json) + session.add(approval) + session.commit() + session.refresh(approval) + if approval and _expire_if_needed(session, approval): + session.refresh(approval) + return approval + + +def list_approvals(session: Session, *, include_dismissed: bool = False): + statement = select(Approval).order_by(Approval.created_at.desc()) + if not include_dismissed: + statement = statement.where(Approval.dismissed_at.is_(None)) + approvals = session.exec(statement).all() + changed = any(_expire_if_needed(session, approval, commit=False) for approval in approvals) + if changed: + session.commit() + for approval in approvals: + session.refresh(approval) + return approvals + + +def dismiss_expired_approvals(session: Session) -> list[Approval]: + """Archive only terminal expired approvals while retaining their audit evidence.""" + approvals = list_approvals(session) + dismissed_at = utc_now() + dismissed = [approval for approval in approvals if approval.status == 'expired'] + for approval in dismissed: + approval.dismissed_at = dismissed_at + session.add(approval) + if dismissed: + session.commit() + for approval in dismissed: + session.refresh(approval) + return dismissed + + +def _expire_if_needed(session: Session, approval: Approval, *, commit: bool = True) -> bool: + if approval.status != 'pending' or approval.expires_at is None or approval.expires_at > utc_now(): + return False + approval.status = 'expired' + approval.decided_at = utc_now() + session.add(approval) + if commit: + session.commit() + return True + + +def create_approval( + session: Session, + *, + action: str, + target: str, + source: str, + risk: str, + reason: str | None = None, + requester: str = 'manual', + expires_in_minutes: int = 30, + context: dict | None = None, +) -> Approval: + approval = Approval( + id=f'appr-{uuid.uuid4().hex[:12]}', + action=action, + target=target, + source=source, + risk=risk, + status='pending', + requester=requester, + reason=reason, + expires_at=(utc_now() + timedelta(minutes=expires_in_minutes)), + request_context_json=context or {}, + action_fingerprint=approval_fingerprint(action, target, source, risk, context), + ) + session.add(approval) + session.commit() + session.refresh(approval) + return approval + + +def update_approval_status(session: Session, approval_id: str, status: str, *, actor: str = "operator") -> Approval | None: + approval = get_approval(session, approval_id) + if not approval: + return None + + if status not in VALID_DECISIONS: + raise ApprovalStateError('Approval status must be approved or rejected.') + if approval.status == 'expired': + raise ApprovalStateError('Approval has expired and can no longer be decided.') + if approval.status != 'pending': + raise ApprovalStateError(f'Approval is already {approval.status}.') + + approval.status = status + approval.decided_at = utc_now() + approval.approved_by = actor if status == 'approved' else None + session.add(approval) + session.commit() + session.refresh(approval) + return approval + + +def mark_approval_executed( + session: Session, + approval: Approval, + *, + job_id: str, + audit_event_id: str, + actor: str = "operator", +) -> Approval: + if approval.status != 'approved': + raise ApprovalStateError('Only an approved request can be executed.') + expected = approval_fingerprint(approval.action, approval.target, approval.source, approval.risk, approval.request_context_json) + if not approval.action_fingerprint or approval.action_fingerprint != expected: + raise ApprovalStateError('Approval action context changed after review.') + approval.status = 'executed' + approval.executed_at = utc_now() + approval.job_id = job_id + approval.audit_event_id = audit_event_id + approval.executed_by = actor + session.add(approval) + session.commit() + session.refresh(approval) + return approval diff --git a/backend/app/services/audit_logger.py b/backend/app/services/audit_logger.py new file mode 100644 index 0000000..64f464f --- /dev/null +++ b/backend/app/services/audit_logger.py @@ -0,0 +1,66 @@ +from __future__ import annotations +import uuid +from typing import Any + +from sqlmodel import Session, select + +from ..db.models import AuditEvent +from ..core.time import utc_now +from ..core.redaction import redact_message, redact_value + + +def _redact_mapping(value: dict[str, Any] | None) -> dict[str, Any] | None: + return redact_value(value) if value else None + + +def log_event( + session: Session, + *, + source: str, + identity: str, + action_id: str, + target: str, + risk: str, + policy_decision: str, + reason: str, + result: str, + target_type: str = 'app', + execution_plan: dict[str, Any] | None = None, + before_state: dict[str, Any] | None = None, + after_state: dict[str, Any] | None = None, + post_checks: dict[str, Any] | None = None, + redacted_metadata: dict[str, Any] | None = None, +) -> AuditEvent: + event = AuditEvent( + id=f'audit-{uuid.uuid4().hex[:12]}', + timestamp=utc_now(), + source=source, + identity=identity, + action_id=redact_message(action_id), + target=redact_message(target), + target_type=redact_message(target_type), + risk=risk, + policy_decision=policy_decision, + reason=redact_message(reason), + execution_plan=_redact_mapping(execution_plan) or {}, + result=result, + before_state=_redact_mapping(before_state), + after_state=_redact_mapping(after_state), + post_checks=_redact_mapping(post_checks), + redacted_metadata=_redact_mapping(redacted_metadata), + ) + + session.add(event) + session.commit() + session.refresh(event) + return event + + +def list_events(session: Session, *, limit: int = 200, offset: int = 0): + statement = ( + select(AuditEvent) + .order_by(AuditEvent.timestamp.desc(), AuditEvent.id.desc()) + .offset(max(0, offset)) + .limit(max(1, min(limit, 500))) + ) + return session.exec(statement).all() diff --git a/backend/app/services/connector_health.py b/backend/app/services/connector_health.py new file mode 100644 index 0000000..6f2ce0c --- /dev/null +++ b/backend/app/services/connector_health.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..connectors.deluge import DelugeConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.plex import PlexConnector +from ..connectors.portainer import PortainerConnector +from ..connectors.prowlarr import ProwlarrConnector +from ..connectors.radarr import RadarrConnector +from ..connectors.sonarr import SonarrConnector +from ..connectors.tautulli import TautulliConnector +from ..connectors.unraid import UnraidConnector +from ..connectors.widefrog import WidefrogConnector +from ..core.time import utc_now +from ..db.models import App, Connector, NotificationChannel +from . import notification_service, safety_center_service +from .setup_service import apply_stored_connector_settings + + +_TESTERS: dict[str, Callable[[], Any]] = { + 'authentik': AuthentikConnector, + 'deluge': DelugeConnector, + 'nginx_proxy_manager': NginxProxyManagerConnector, + 'plex': PlexConnector, + 'portainer': PortainerConnector, + 'prowlarr': ProwlarrConnector, + 'radarr': RadarrConnector, + 'sonarr': SonarrConnector, + 'tautulli': TautulliConnector, + 'unraid': UnraidConnector, + 'widefrog': WidefrogConnector, +} + + +def _stored_status(status: str) -> str: + return 'error' if status == 'unreachable' else status + + +def test_and_persist_connector(session: Session, connector_id: str) -> dict[str, Any]: + connector = session.get(Connector, connector_id) + if not connector: + return {'status': 'disconnected', 'reason': 'connector_not_found'} + + apply_stored_connector_settings(session, {connector_id}) + if connector_id == 'home_assistant': + channel = session.get(NotificationChannel, 'home_assistant') + result = ( + notification_service.test_channel_connection(session, 'home_assistant') + if channel + else {'status': 'missing_config', 'reason': 'notification_channel_not_found'} + ) + else: + factory = _TESTERS.get(connector_id) + result = factory().test_connection() if factory else {'status': 'error', 'reason': 'test_not_implemented'} + + status = str(result.get('status') or 'disconnected') + persisted_status = _stored_status(status) + connector.status = persisted_status + connector.last_test = utc_now().isoformat() + session.add(connector) + + app = session.get(App, connector_id) + if app and app.connector_status != persisted_status: + app.connector_status = persisted_status + session.add(app) + + session.commit() + safety_center_service.invalidate_cache() + return {**result, 'status': status} + + +def refresh_connector_statuses(session: Session) -> dict[str, Any]: + connector_ids = list(session.exec(select(Connector.id).order_by(Connector.id)).all()) + results: dict[str, str] = {} + for connector_id in connector_ids: + if connector_id not in _TESTERS and connector_id != 'home_assistant': + continue + result = test_and_persist_connector(session, connector_id) + results[connector_id] = str(result.get('status') or 'disconnected') + return { + 'success': True, + 'status': 'ok', + 'tested': len(results), + 'connected': sum(status == 'connected' for status in results.values()), + 'results': results, + } diff --git a/backend/app/services/container_restart_service.py b/backend/app/services/container_restart_service.py new file mode 100644 index 0000000..bf9a121 --- /dev/null +++ b/backend/app/services/container_restart_service.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ..connectors.portainer import PortainerConnector +from ..core.config import settings +from ..core.redaction import redact_message +from ..db.models import JobRun +from ..services.approval_service import create_approval +from ..services.audit_logger import log_event +from ..services.job_runner import complete_job, create_job, start_job +from ..services.policy_engine import evaluate_policy + + +def _widefrog_health_endpoint() -> tuple[str | None, str | None]: + raw_url = str(settings.apis.get("WIDEFROG_URL") or "").strip() + if not raw_url: + return None, "missing_config" + + candidate = ( + raw_url if raw_url.startswith(("http://", "https://")) else f"http://{raw_url}" + ) + try: + parsed = urlparse(candidate) + except Exception: + return None, "invalid_widefrog_url" + + host = parsed.hostname + if not host: + return None, "invalid_widefrog_url" + + port = parsed.port or 1207 + return f"{parsed.scheme}://{host}:{port}/", None + + +def _widefrog_health_check() -> dict[str, Any]: + health_url, reason = _widefrog_health_endpoint() + if not health_url: + return {"status": "error", "reason": reason or "missing_config"} + + try: + with httpx.Client(timeout=4.0, verify=settings.verify_tls) as client: + response = client.get(health_url) + except (httpx.RequestError, ValueError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if response.status_code >= 500: + return { + "status": "error", + "reason": f"widefrog health check failed: HTTP {response.status_code}", + } + if response.status_code >= 400: + return { + "status": "warning", + "reason": f"widefrog health returned HTTP {response.status_code}", + } + + return {"status": "ok"} + + +def _format_success_result( + summary: str, + *, + steps: list[str] | None = None, + before_state: dict[str, Any] | None = None, + after_state: dict[str, Any] | None = None, + post_checks: list[str] | None = None, +) -> dict[str, Any]: + return { + "success": True, + "decision": "allowed", + "status": "success", + "policy_decision": "allowed", + "result": "success", + "message": summary, + "plan": steps or [], + "steps": steps or [], + "before_state": before_state or {}, + "after_state": after_state or {}, + "post_checks": post_checks or [], + } + + +def _format_blocked_result(audit_id: str, reason: str) -> dict[str, Any]: + return { + "success": False, + "decision": "blocked", + "status": "failed", + "policy_decision": "blocked", + "result": "blocked", + "message": reason, + "auditId": audit_id, + } + + +def _format_approval_result( + audit_id: str, approval_id: str, reason: str +) -> dict[str, Any]: + return { + "success": False, + "decision": "approval_required", + "status": "failed", + "policy_decision": "approval_required", + "result": "approval_required", + "message": reason, + "approvalId": approval_id, + "auditId": audit_id, + } + + +def execute_container_restart( + *, + session, + action: str, + target: str, + source: str = "manual", + risk: str = "medium", + steps: list[str] | None = None, + approved: bool = False, + policy_override: dict[str, Any] | None = None, +): + plan_steps = steps or [ + "Resolve container", + "Restart container", + "Post-check health", + ] + if policy_override is not None: + decision = { + "decision": str(policy_override.get("decision") or "blocked"), + "reason": str(policy_override.get("reason") or "Policy override."), + "risk": str(policy_override.get("risk") or risk), + } + elif approved: + decision = { + "decision": "allowed", + "reason": "Exact action context was approved by an AppOps operator.", + "risk": risk, + } + else: + decision = evaluate_policy(session, action=action, target=target, source=source) + + if decision["decision"] == "blocked": + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision["decision"], + reason=decision["reason"], + result="blocked", + execution_plan={"plan": plan_steps, "policyReason": decision["reason"]}, + ) + return _format_blocked_result(audit.id, decision["reason"]) + + if decision["decision"] == "approval_required": + approval = create_approval( + session, + action=action, + target=target, + source=source, + risk=risk, + reason=decision["reason"], + requester=source, + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision["decision"], + reason=decision["reason"], + result="approval_requested", + execution_plan={"plan": plan_steps, "approvalId": approval.id}, + redacted_metadata={"approval_id": approval.id}, + ) + return _format_approval_result( + audit.id, approval.id, "Action requires approval before execution." + ) + + # Allowed restart flow + job: JobRun = create_job( + session, + action_id=action, + target=target, + source=source, + risk=risk, + policy_decision=decision["decision"], + steps=plan_steps, + ) + start_job(session, job) + + try: + connector = PortainerConnector() + + precheck = connector.get_container_status(target) + if precheck.get("status") != "ok": + # keep execution context in logs while still allowing best-effort continuation + precheck = { + "status": "warn", + "container": {"name": target}, + "reason": precheck.get("reason"), + } + + restart_result = connector.restart_container(target) + if restart_result.get("status") != "ok": + raise RuntimeError( + str(restart_result.get("reason", "container_restart_failed")) + ) + + post_check = {"container": restart_result.get("container", target)} + if target == "widefrog": + health = _widefrog_health_check() + post_checks = [ + f"widefrog health: {health.get('status')}", + f"widefrog reason: {health.get('reason', '')}".strip(), + ] + post_check["widefrog_health"] = health + else: + post_checks = [] + health = {} + + completed = complete_job( + session, + job, + status="succeeded", + result_summary="Container restart executed through Portainer connector.", + ) + + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision["decision"], + reason=decision["reason"], + result="success", + execution_plan={ + "plan": plan_steps, + "jobId": completed.id, + "precheck": precheck, + }, + before_state=precheck, + after_state=post_check, + post_checks=health if target == "widefrog" else None, + ) + + result = _format_success_result( + "Container restart executed.", + steps=plan_steps, + before_state=precheck, + after_state=post_check, + post_checks=post_checks, + ) + result["jobId"] = completed.id + result["auditId"] = audit.id + return result + + except Exception as exc: + failed = complete_job( + session, + job, + status="failed", + result_summary=f"Container restart failed: {redact_message(str(exc))}", + ) + audit = log_event( + session, + source="action", + identity=source, + action_id=action, + target=target, + risk=risk, + policy_decision=decision["decision"], + reason="Container restart execution failed.", + result="failed", + execution_plan={"plan": plan_steps, "jobId": failed.id}, + after_state={"status": "failed", "reason": redact_message(str(exc))}, + ) + return { + "success": False, + "decision": "failed", + "status": "failed", + "policy_decision": decision["decision"], + "result": "failed", + "message": redact_message(str(exc)), + "jobId": failed.id, + "auditId": audit.id, + } diff --git a/backend/app/services/controller_artifact_promoter.py b/backend/app/services/controller_artifact_promoter.py new file mode 100644 index 0000000..442e6f1 --- /dev/null +++ b/backend/app/services/controller_artifact_promoter.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import json +import re +import time +from datetime import datetime +from typing import Any +from urllib.parse import quote, urlsplit, urlunsplit + +import httpx + +from ..connectors.portainer import PortainerConnector +from ..core.config import settings +from ..core.redaction import redact_message +from .deployment_broker import DeploymentBrokerClient + +SOURCE_REPOSITORY = "jens/unraid-appops-gateway" +GITEA_OWNER = "Jens" +GITEA_REPOSITORY = "Unraid-AppOps-Gateway" +REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)" +HELPER_NAME = "itworx-controller-artifact-promoter" +CONTROLLER_CONTAINER = "gitea-deploy-control" +CONTROLLER_VERSION = "2.1.5" +HELPER_LABEL = "itworx.controller.promoter" +_HELPER_TIMEOUT_SECONDS = 240 +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_IMAGE_ID = re.compile(r"^sha256:[0-9a-f]{64}$") +_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$") + + +class ControllerArtifactPromotionError(RuntimeError): + pass + + +def helper_container_spec(*, revision: str, image_id: str) -> dict[str, Any]: + if not _REVISION.fullmatch(revision): + raise ValueError("controller_promoter_revision_invalid") + if not _IMAGE_ID.fullmatch(image_id): + raise ValueError("controller_promoter_image_invalid") + + command = f"""set -Eeuo pipefail +revision='{revision}' +version='{CONTROLLER_VERSION}' +repo='/gitea-repositories/jens/unraid-appops-gateway.git' +work='/mnt/cache/appdata/gitea-deploy-runners/.controller-promoter-{revision[:12]}' +rm -rf -- "$work" +install -d -m 0700 "$work" +cleanup() {{ rm -rf -- "$work"; }} +trap cleanup EXIT +command -v git >/dev/null +command -v docker >/dev/null +docker compose version >/dev/null +command -v python3 >/dev/null +command -v tar >/dev/null +command -v curl >/dev/null +git --git-dir="$repo" cat-file -e "$revision^{{commit}}" +tree="$(git --git-dir="$repo" rev-parse "$revision^{{tree}}")" +git --git-dir="$repo" archive --format=tar "$revision" \ + ops/gitea-deploy-broker \ + scripts/prepare_controller_artifact.py \ + scripts/reconcile_controller_state.py \ + scripts/run_repopulse_adoption.py > "$work/controller.tar" +archive_sha="$(sha256sum "$work/controller.tar" | cut -d ' ' -f 1)" +git --git-dir="$repo" show "$revision:scripts/upgrade_deployment_controller.sh" > "$work/upgrade.sh" +chmod 0700 "$work/upgrade.sh" +bash "$work/upgrade.sh" \ + --archive "$work/controller.tar" \ + --archive-sha256 "$archive_sha" \ + --source-repository '{SOURCE_REPOSITORY}' \ + --source-revision "$revision" \ + --source-tree "$tree" \ + --controller-version "$version" \ + --confirm UPGRADE_DEPLOYMENT_CONTROLLER_V2 +""" + + return { + "Image": image_id, + "User": "0:0", + "Cmd": ["/bin/bash", "-lc", command], + "Labels": { + HELPER_LABEL: "true", + "itworx.source.repository": SOURCE_REPOSITORY, + "itworx.source.revision": revision, + "itworx.controller.version": CONTROLLER_VERSION, + }, + "HostConfig": { + "Binds": [ + "/mnt/cache:/mnt/cache", + "/mnt/user:/mnt/user", + "/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro", + "/var/run/docker.sock:/var/run/docker.sock", + ], + "NetworkMode": "host", + "Privileged": False, + "SecurityOpt": ["no-new-privileges:true"], + "RestartPolicy": {"Name": "no"}, + }, + } + + +class ControllerArtifactPromoter: + def __init__( + self, + *, + portainer: PortainerConnector | None = None, + broker: DeploymentBrokerClient | None = None, + ) -> None: + self.portainer = portainer or PortainerConnector() + self.broker = broker or DeploymentBrokerClient() + + @staticmethod + def _gitea_base_url() -> str: + raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/") + parsed = urlsplit(raw) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username: + raise ControllerArtifactPromotionError("controller_promoter_gitea_url_invalid") + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) + + @staticmethod + def _gitea_token() -> str: + token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "").strip() + if not token: + raise ControllerArtifactPromotionError("controller_promoter_gitea_token_missing") + return token + + @staticmethod + def _status_key(item: dict[str, Any]) -> tuple[datetime, int] | None: + raw = str(item.get("updated_at") or item.get("created_at") or "") + if not _TIMESTAMP.fullmatch(raw): + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + identifier = item.get("id") + return parsed, identifier if isinstance(identifier, int) and not isinstance(identifier, bool) else -1 + + def _validated_main_revision(self) -> str: + branch = "main" + base = self._gitea_base_url() + headers = { + "Authorization": f"token {self._gitea_token()}", + "Accept": "application/json", + "User-Agent": "itworx-controller-artifact-promoter/1", + } + timeout = max(2.0, min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8))) + verify = bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls)) + owner = quote(GITEA_OWNER, safe="") + repository = quote(GITEA_REPOSITORY, safe="") + try: + with httpx.Client(timeout=timeout, verify=verify, follow_redirects=False) as client: + branch_response = client.get( + f"{base}/api/v1/repos/{owner}/{repository}/branches/{branch}", + headers=headers, + ) + if branch_response.status_code != 200: + raise ControllerArtifactPromotionError("controller_promoter_main_unavailable") + branch_payload = branch_response.json() + commit = branch_payload.get("commit") if isinstance(branch_payload, dict) else None + revision = str(commit.get("id") or "") if isinstance(commit, dict) else "" + if ( + not isinstance(branch_payload, dict) + or branch_payload.get("name") != branch + or branch_payload.get("protected") is not True + or not _REVISION.fullmatch(revision) + ): + raise ControllerArtifactPromotionError("controller_promoter_main_not_protected") + + status_response = client.get( + f"{base}/api/v1/repos/{owner}/{repository}/statuses/{revision}", + params={"limit": 100, "page": 1}, + headers=headers, + ) + if status_response.status_code != 200: + raise ControllerArtifactPromotionError("controller_promoter_status_unavailable") + statuses = status_response.json() + except ControllerArtifactPromotionError: + raise + except (httpx.RequestError, ValueError) as exc: + raise ControllerArtifactPromotionError( + "controller_promoter_gitea_unavailable" + ) from exc + + if not isinstance(statuses, list) or len(statuses) > 100: + raise ControllerArtifactPromotionError("controller_promoter_status_invalid") + latest: tuple[tuple[datetime, int], str] | None = None + for item in statuses: + if not isinstance(item, dict) or item.get("context") != REQUIRED_PUSH_CONTEXT: + continue + state = str(item.get("status") or "") + if state not in {"pending", "success", "failure", "error", "warning", "neutral", "cancelled"}: + continue + key = self._status_key(item) + if key is not None and (latest is None or key > latest[0]): + latest = (key, state) + if latest is None or latest[1] != "success": + raise ControllerArtifactPromotionError("controller_promoter_main_not_validated") + return revision + + def _portainer_endpoint(self) -> str: + config = self.portainer._config() + if config is None or not config.endpoint_id: + raise ControllerArtifactPromotionError("controller_promoter_portainer_unavailable") + return config.endpoint_id + + def _controller_image_id(self) -> str: + listed = self.portainer.list_containers() + containers = listed.get("containers") if isinstance(listed, dict) else None + candidate = next( + ( + item + for item in containers or [] + if isinstance(item, dict) and item.get("name") == CONTROLLER_CONTAINER + ), + None, + ) + if candidate is None or candidate.get("state") != "running": + raise ControllerArtifactPromotionError("controller_promoter_controller_unavailable") + identifier = str(candidate.get("id") or "") + endpoint = self._portainer_endpoint() + try: + response = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/containers/{quote(identifier, safe='')}/json", + ) + payload = response.json() + except (RuntimeError, ValueError) as exc: + raise ControllerArtifactPromotionError( + "controller_promoter_controller_inspect_failed" + ) from exc + image_id = str(payload.get("Image") or "") if isinstance(payload, dict) else "" + if not _IMAGE_ID.fullmatch(image_id): + raise ControllerArtifactPromotionError("controller_promoter_controller_image_unpinned") + return image_id + + def _existing_helper(self) -> dict[str, Any] | None: + listed = self.portainer.list_containers() + containers = listed.get("containers") if isinstance(listed, dict) else None + return next( + ( + item + for item in containers or [] + if isinstance(item, dict) and item.get("name") == HELPER_NAME + ), + None, + ) + + def _remove_helper(self, identifier: str) -> None: + endpoint = self._portainer_endpoint() + self.portainer._request( + "DELETE", + f"/api/endpoints/{endpoint}/docker/containers/{quote(identifier, safe='')}", + params={"v": "1", "force": "0"}, + ) + + def _run_helper(self, *, revision: str, image_id: str) -> dict[str, Any]: + endpoint = self._portainer_endpoint() + existing = self._existing_helper() + if existing is not None: + identifier = str(existing.get("id") or "") + if existing.get("state") == "running": + raise ControllerArtifactPromotionError("controller_promoter_already_running") + if identifier: + try: + inspect = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/containers/{quote(identifier, safe='')}/json", + ).json() + except (RuntimeError, ValueError) as exc: + raise ControllerArtifactPromotionError( + "controller_promoter_helper_inspect_failed" + ) from exc + labels = ((inspect.get("Config") or {}).get("Labels") or {}) if isinstance(inspect, dict) else {} + if not isinstance(labels, dict) or labels.get(HELPER_LABEL) != "true": + raise ControllerArtifactPromotionError("controller_promoter_name_conflict") + self._remove_helper(identifier) + + spec = helper_container_spec(revision=revision, image_id=image_id) + try: + created_response = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/create", + params={"name": HELPER_NAME}, + json=spec, + ) + created = created_response.json() + helper_id = str(created.get("Id") or "") if isinstance(created, dict) else "" + if not helper_id: + raise ControllerArtifactPromotionError("controller_promoter_create_failed") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/start", + ) + except ControllerArtifactPromotionError: + raise + except (RuntimeError, ValueError) as exc: + raise ControllerArtifactPromotionError("controller_promoter_start_failed") from exc + + deadline = time.monotonic() + _HELPER_TIMEOUT_SECONDS + state: dict[str, Any] = {} + while time.monotonic() < deadline: + try: + payload = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/containers/{quote(helper_id, safe='')}/json", + ).json() + except (RuntimeError, ValueError) as exc: + raise ControllerArtifactPromotionError( + "controller_promoter_helper_inspect_failed" + ) from exc + state = payload.get("State") if isinstance(payload, dict) and isinstance(payload.get("State"), dict) else {} + if state.get("Running") is False: + break + time.sleep(2) + else: + return { + "status": "outcome_unknown", + "revision": revision, + "reason": "controller_promoter_timeout", + "helperId": helper_id, + } + + exit_code = state.get("ExitCode") + logs = self.portainer.get_container_logs(helper_id, tail=200) + log_lines = logs.get("logs") if isinstance(logs, dict) and isinstance(logs.get("logs"), list) else [] + tail = "\n".join(str(line) for line in log_lines[-40:])[-4000:] + if exit_code != 0: + try: + self._remove_helper(helper_id) + except RuntimeError: + pass + return { + "status": "failed", + "revision": revision, + "reason": "controller_promoter_helper_failed", + "exitCode": exit_code, + "logTail": redact_message(tail), + } + + try: + self._remove_helper(helper_id) + except RuntimeError: + pass + return {"status": "promoted", "revision": revision, "logTail": redact_message(tail)} + + def run(self) -> dict[str, Any]: + revision = self._validated_main_revision() + current = self.broker.controller_health() + artifact = current.get("artifact") if isinstance(current, dict) else None + if ( + isinstance(artifact, dict) + and artifact.get("sourceRevision") == revision + and artifact.get("controllerVersion") == CONTROLLER_VERSION + ): + return { + "status": "already_applied", + "revision": revision, + "controllerVersion": CONTROLLER_VERSION, + } + + result = self._run_helper(revision=revision, image_id=self._controller_image_id()) + if result.get("status") != "promoted": + return result + + verified = self.broker.controller_health() + artifact = verified.get("artifact") if isinstance(verified, dict) else None + if ( + not isinstance(artifact, dict) + or artifact.get("sourceRevision") != revision + or artifact.get("controllerVersion") != CONTROLLER_VERSION + ): + return { + "status": "outcome_unknown", + "revision": revision, + "reason": "controller_promoter_postcheck_failed", + } + return { + "status": "promoted", + "revision": revision, + "controllerVersion": CONTROLLER_VERSION, + "artifactSha256": artifact.get("artifactSha256"), + } diff --git a/backend/app/services/controller_bootstrap_import.py b/backend/app/services/controller_bootstrap_import.py new file mode 100644 index 0000000..c51bc39 --- /dev/null +++ b/backend/app/services/controller_bootstrap_import.py @@ -0,0 +1,35 @@ +"""Write-only import of the fixed controller enrollment bootstrap credential.""" + +from __future__ import annotations + +import json +import sys + +from sqlmodel import Session + +from ..db.session import engine +from .repopulse_secret_import import ( + BOOTSTRAP_SECRET_REF, + MAX_SECRET_BYTES, + import_controller_bootstrap, +) + + +def main() -> int: + raw = sys.stdin.buffer.read(MAX_SECRET_BYTES + 1) + if len(raw) > MAX_SECRET_BYTES: + print(json.dumps({"status": "blocked", "reason": "credential input too large"})) + return 2 + try: + token = raw.decode("utf-8") + with Session(engine) as session: + outcome = import_controller_bootstrap(session, token) + except (UnicodeDecodeError, ValueError): + print(json.dumps({"status": "blocked", "reason": "credential import rejected"})) + return 2 + print(json.dumps({"status": outcome, "secretRef": BOOTSTRAP_SECRET_REF})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/services/deployment_broker.py b/backend/app/services/deployment_broker.py new file mode 100644 index 0000000..3c7a5c6 --- /dev/null +++ b/backend/app/services/deployment_broker.py @@ -0,0 +1,562 @@ +"""Client for the private, typed deployment broker surface. + +The client only sends a canonical DeploymentPlanV1 to a configured controller. +It neither exposes a command field nor calls a Docker socket/Portainer exec +endpoint. The controller independently validates repository, revision and +runtime topology before it can mutate production. +""" + +from __future__ import annotations + +import hashlib +import hmac +import ipaddress +import json +import os +import re +import stat +import time +import uuid +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ..core.config import settings +from ..core.redaction import redact_message +from ..schemas.deployment import DeploymentPlanV1 + +_REQUEST_ID = re.compile(r"^[0-9a-f]{64}$") +_MAX_DELIVERY_STATUS_BYTES = 512 * 1024 +_MAX_RUNNER_FLEET_STATUS_BYTES = 512 * 1024 +_MAX_CONTROLLER_HEALTH_BYTES = 64 * 1024 +_WORKMESH_REVISION = re.compile(r"^[0-9a-f]{40}$") +_WORKMESH_PREFLIGHT_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_WORKMESH_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") + + +class DeploymentBrokerClient: + """Minimal HMAC client for the controller's versioned deployment API.""" + + def __init__( + self, + *, + base_url: str | None = None, + verify_tls: bool | None = None, + ca_file: str | None = None, + ): + configured_url = base_url or settings.apis.get("DEPLOYMENT_BROKER_URL") + self.base_url = str(configured_url or "").strip().rstrip("/") + self.verify_tls = ( + bool(settings.apis.get("DEPLOYMENT_BROKER_VERIFY_TLS")) + if verify_tls is None + else verify_tls + ) + configured_ca_file = ( + settings.apis.get("DEPLOYMENT_BROKER_CA_FILE") + if ca_file is None + else ca_file + ) + self.ca_file = str(configured_ca_file or "").strip() or None + self.timeout_seconds = max( + 2.0, + min(30.0, float(settings.apis.get("DEPLOYMENT_BROKER_TIMEOUT_SECONDS") or 10)), + ) + + def configuration_error(self) -> str | None: + if not self.base_url: + return "deployment_broker_url_missing" + parsed = urlparse(self.base_url) + loopback_http = False + if parsed.scheme == "http": + hostname = parsed.hostname or "" + try: + loopback_http = ipaddress.ip_address(hostname).is_loopback + except ValueError: + loopback_http = hostname.lower() == "localhost" + if ( + not (parsed.scheme == "https" or loopback_http) + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + return "deployment_broker_url_invalid" + if parsed.scheme == "https": + if not self.verify_tls: + return "deployment_broker_tls_verification_required" + if self.ca_file and not self._trusted_ca_file_is_safe(self.ca_file): + return "deployment_broker_ca_file_invalid" + return None + + @staticmethod + def _trusted_ca_file_is_safe(value: str) -> bool: + """Require an AppOps-owned readable CA bundle that others cannot write.""" + path = Path(value) + if not path.is_absolute(): + return False + try: + info = path.lstat() + except OSError: + return False + if ( + path.is_symlink() + or not stat.S_ISREG(info.st_mode) + or not 1 <= info.st_size <= 1024 * 1024 + ): + return False + if os.name != "nt": + return ( + info.st_uid == os.geteuid() + and not (stat.S_IMODE(info.st_mode) & 0o022) + and os.access(path, os.R_OK) + ) + return True + + def is_configured(self) -> bool: + return self.configuration_error() is None + + def _verify_option(self) -> bool | str: + parsed = urlparse(self.base_url) + if parsed.scheme == "https" and self.ca_file: + return self.ca_file + return self.verify_tls + + @staticmethod + def _canonical_payload(payload: dict[str, Any]) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + @staticmethod + def _signature( + secret: str, + method: str, + path: str, + timestamp: str, + nonce: str, + body: bytes, + ) -> str: + """Bind the HMAC to one versioned HTTP request, not merely its body.""" + body_digest = hashlib.sha256(body).hexdigest() + signed = ( + f"itworx-controller-request/v2\n{method.upper()}\n{path}\n" + f"{timestamp}\n{nonce}\n{body_digest}" + ).encode("ascii") + return hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() + + def _request( + self, + method: str, + path: str, + *, + secret: str, + payload: dict[str, Any] | None = None, + error_response_fields: frozenset[str] = frozenset(), + ) -> dict[str, Any]: + if not self.is_configured(): + return {"status": "missing_config", "reason": self.configuration_error()} + if not secret or len(secret) < 32: + return {"status": "missing_secret", "reason": "deployment_broker_secret_missing_or_weak"} + body = self._canonical_payload(payload) if payload is not None else b"" + timestamp = str(int(time.time())) + nonce = uuid.uuid4().hex + headers = { + "Accept": "application/json", + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": self._signature( + secret, method, path, timestamp, nonce, body + ), + } + if payload is not None: + headers["Content-Type"] = "application/json" + try: + with httpx.Client( + timeout=self.timeout_seconds, + verify=self._verify_option(), + ) as client: + response = client.request(method, f"{self.base_url}{path}", content=body, headers=headers) + except httpx.RequestError as exc: + return {"status": "unreachable", "reason": redact_message(str(exc))} + if response.status_code in {401, 403}: + return {"status": "unauthorized", "reason": "deployment_broker_authentication_failed"} + try: + decoded = response.json() + except ValueError: + decoded = {} + if not isinstance(decoded, dict): + decoded = {} + if response.status_code >= 400: + rejected = { + "status": str(decoded.get("status") or "rejected"), + "reason": redact_message(str(decoded.get("reason") or f"deployment_broker_http_{response.status_code}")), + } + for field in error_response_fields: + value = decoded.get(field) + if isinstance(value, str) and len(value) <= 256: + rejected[field] = value + return rejected + return {"status": str(decoded.get("status") or "ok"), **decoded} + + def controller_health(self) -> dict[str, Any]: + """Read the bounded root-controller health and release identity.""" + if not self.is_configured(): + return {"status": "missing_config", "reason": self.configuration_error()} + try: + with httpx.Client( + timeout=self.timeout_seconds, + verify=self._verify_option(), + follow_redirects=False, + ) as client, client.stream( + "GET", + f"{self.base_url}/health", + headers={"Accept": "application/json"}, + ) as response: + if response.status_code != 200: + return { + "status": "unavailable", + "reason": f"controller_health_http_{response.status_code}", + } + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > _MAX_CONTROLLER_HEALTH_BYTES: + return { + "status": "unavailable", + "reason": "controller_health_response_too_large", + } + except httpx.RequestError as exc: + return {"status": "unreachable", "reason": redact_message(str(exc))} + try: + decoded = json.loads(bytes(body).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {"status": "unavailable", "reason": "controller_health_invalid_response"} + return ( + decoded + if isinstance(decoded, dict) + else {"status": "unavailable", "reason": "controller_health_invalid_response"} + ) + + def preflight(self, plan: DeploymentPlanV1, *, secret: str) -> dict[str, Any]: + return self._request( + "POST", + "/v1/appops/deployments/preflight", + secret=secret, + payload={"plan": plan.model_dump(mode="json")}, + ) + + def appops_self_upgrade_preflight( + self, revision: str, reason: str, expected_current_revision: str | None, + ) -> dict[str, Any]: + """Ask the private controller to validate one fixed protected-main target.""" + return self._request( + "POST", "/v1/appops/self-upgrade/preflight", + secret=settings.mcp_token or "", + payload={"repository": "jens/unraid-appops-gateway", "revision": revision, + "reason": reason, "expectedCurrentRevision": expected_current_revision}, + ) + + def appops_self_upgrade_apply( + self, revision: str, reason: str, expected_current_revision: str | None, + preflight_sha256: str, idempotency_key: str, + ) -> dict[str, Any]: + return self._request( + "POST", "/v1/appops/self-upgrade/apply", + secret=settings.mcp_token or "", + payload={"repository": "jens/unraid-appops-gateway", "revision": revision, + "reason": reason, "expectedCurrentRevision": expected_current_revision, + "preflightSha256": preflight_sha256, "idempotencyKey": idempotency_key}, + ) + + def appops_self_upgrade_status(self, operation_id: str) -> dict[str, Any]: + if not re.fullmatch(r"[0-9a-f]{64}", operation_id): + return {"status": "invalid_operation_id"} + return self._request( + "GET", f"/v1/appops/self-upgrade/status/{operation_id}", + secret=settings.mcp_token or "", + ) + + def apply(self, plan: DeploymentPlanV1, *, secret: str, idempotency_key: str) -> dict[str, Any]: + return self._request( + "POST", + "/v1/appops/deployments/apply", + secret=secret, + payload={"plan": plan.model_dump(mode="json"), "idempotencyKey": idempotency_key}, + ) + + def rollback(self, plan: DeploymentPlanV1, *, secret: str, idempotency_key: str) -> dict[str, Any]: + return self._request( + "POST", + "/v1/appops/deployments/rollback", + secret=secret, + payload={"plan": plan.model_dump(mode="json"), "idempotencyKey": idempotency_key}, + ) + + def enrollment_preflight(self, enrollment: dict[str, Any], *, secret: str) -> dict[str, Any]: + """Validate a bounded target enrollment with the controller, read-only.""" + return self._request( + "POST", + "/v1/appops/enrollments/preflight", + secret=secret, + payload={"enrollment": enrollment}, + ) + + def enrollment_capabilities(self, *, secret: str) -> dict[str, Any]: + """Read the bootstrap-protected enrollment controller capability.""" + return self._request( + "POST", + "/v1/appops/enrollments/capabilities", + secret=secret, + payload={}, + ) + + def repopulse_adoption_capabilities(self, *, secret: str) -> dict[str, Any]: + """Read fixed RepoPulse adoption readiness through bootstrap HMAC v2.""" + return self._request( + "POST", + "/v1/appops/repopulse/adoption/capabilities", + secret=secret, + payload={}, + ) + + def repopulse_adoption_reserve( + self, + *, + secret: str, + intent_id: str, + idempotency_sha256: str, + preflight_sha256: str, + ) -> dict[str, Any]: + """Reserve the fixed controller intent through bootstrap HMAC v2.""" + return self._request( + "POST", + "/v1/appops/repopulse/adoption/reserve", + secret=secret, + payload={ + "intentId": intent_id, + "idempotencySha256": idempotency_sha256, + "preflightSha256": preflight_sha256, + }, + ) + + def repopulse_adoption_status(self, *, secret: str) -> dict[str, Any]: + """Read sanitized fixed adoption state through bootstrap HMAC v2.""" + return self._request( + "GET", + "/v1/appops/repopulse/adoption/status", + secret=secret, + ) + + def repopulse_adoption_execute( + self, + *, + secret: str, + intent_id: str, + idempotency_sha256: str, + preflight_sha256: str, + preflight_projection: dict[str, Any], + target_credential: str, + ) -> dict[str, Any]: + """Request the one fixed migration; the controller owns every effect.""" + if urlparse(self.base_url).scheme != "https" or self.configuration_error() is not None: + return { + "status": "missing_config", + "reason": "repopulse_adoption_private_tls_required", + } + if ( + not isinstance(target_credential, str) + or not 32 <= len(target_credential) <= 256 + or not target_credential.isascii() + or not target_credential.isprintable() + ): + return {"status": "invalid", "reason": "repopulse_target_credential_invalid"} + return self._request( + "POST", + "/v1/appops/repopulse/adoption/execute", + secret=secret, + payload={ + "intentId": intent_id, + "idempotencySha256": idempotency_sha256, + "preflightSha256": preflight_sha256, + "preflightProjection": preflight_projection, + "targetCredential": target_credential, + }, + ) + + def enroll_target( + self, + enrollment: dict[str, Any], + *, + secret: str, + idempotency_key: str, + ) -> dict[str, Any]: + """Create exactly one controller-owned target through bootstrap HMAC.""" + return self._request( + "POST", + "/v1/appops/enrollments", + secret=secret, + payload={"enrollment": enrollment, "idempotencyKey": idempotency_key}, + ) + + def configure_enrollment_runtime( + self, + runtime: dict[str, Any], + *, + secret: str, + ) -> dict[str, Any]: + """Write profile-owned runtime values using that target's own HMAC.""" + return self._request( + "POST", + "/v1/appops/enrollments/runtime", + secret=secret, + payload={"runtime": runtime}, + ) + + def unenroll_target( + self, + target: dict[str, Any], + *, + secret: str, + ) -> dict[str, Any]: + """Compensate an untouched target through the bootstrap boundary only.""" + return self._request( + "POST", + "/v1/appops/enrollments/rollback", + secret=secret, + payload={"target": target}, + ) + + def status(self, request_id: str, *, secret: str) -> dict[str, Any]: + if not _REQUEST_ID.fullmatch(request_id): + return {"status": "invalid", "reason": "invalid_broker_request_id"} + return self._request( + "GET", + f"/v1/appops/deployments/{request_id}", + secret=secret, + ) + + def workmesh_promotion_preflight(self, revision: str, *, secret: str) -> dict[str, Any]: + """Preflight only the fixed WorkMesh product target at one full SHA.""" + if not _WORKMESH_REVISION.fullmatch(revision): + return {"status": "invalid", "reason": "invalid_workmesh_revision"} + return self._request( + "POST", + "/v1/appops/workmesh/promotions/preflight", + secret=secret, + payload={"revision": revision}, + ) + + def workmesh_promotion_apply( + self, + revision: str, + *, + secret: str, + preflight_sha256: str, + idempotency_key: str, + ) -> dict[str, Any]: + """Queue a promotion only when its approved preflight digest still matches.""" + if not _WORKMESH_REVISION.fullmatch(revision): + return {"status": "invalid", "reason": "invalid_workmesh_revision"} + if not _WORKMESH_PREFLIGHT_SHA256.fullmatch(preflight_sha256): + return {"status": "invalid", "reason": "invalid_workmesh_preflight_digest"} + if not _WORKMESH_IDEMPOTENCY.fullmatch(idempotency_key): + return {"status": "invalid", "reason": "invalid_workmesh_idempotency_key"} + return self._request( + "POST", + "/v1/appops/workmesh/promotions/apply", + secret=secret, + payload={ + "revision": revision, + "preflightSha256": preflight_sha256, + "idempotencyKey": idempotency_key, + }, + error_response_fields=frozenset({"request", "repository", "revision", "operation"}), + ) + + def workmesh_promotion_status(self, request_id: str, *, secret: str) -> dict[str, Any]: + """Read one HMAC-authenticated fixed-target promotion receipt projection.""" + return self.status(request_id, secret=secret) + + def delivery_status(self) -> dict[str, Any]: + """Read bounded, secret-free runnerless delivery health from controller. + + This endpoint deliberately sends no deployment credential: the + controller publishes only a private-network, read-only projection of + its durable webhook inbox. All mutations keep using the HMAC-bound + typed request surface above. + """ + if not self.is_configured(): + return {"status": "missing_config", "reason": self.configuration_error()} + try: + with httpx.Client( + timeout=self.timeout_seconds, + verify=self._verify_option(), + follow_redirects=False, + ) as client, client.stream( + "GET", + f"{self.base_url}/v1/gitea/deliveries", + headers={"Accept": "application/json"}, + ) as response: + if response.status_code != 200: + return {"status": "unavailable", "reason": f"delivery_status_http_{response.status_code}"} + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > _MAX_DELIVERY_STATUS_BYTES: + return {"status": "unavailable", "reason": "delivery_status_response_too_large"} + except httpx.RequestError as exc: + return {"status": "unreachable", "reason": redact_message(str(exc))} + try: + decoded = json.loads(bytes(body).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {"status": "unavailable", "reason": "delivery_status_invalid_response"} + return decoded if isinstance(decoded, dict) else {"status": "unavailable", "reason": "delivery_status_invalid_response"} + + def runner_fleet_upgrade_v2(self, *, reason: str) -> dict[str, Any]: + """Request the one fixed Tower runner-fleet v2 migration. + + The root controller derives and verifies the exact protected-main + revision itself. The caller cannot select a script path, runner name, + revision or Docker target. + """ + return self._request( + "POST", + "/v1/appops/runner-fleet/upgrade-v2", + secret=settings.mcp_token or "", + payload={"reason": reason[:1000]}, + ) + + def runner_fleet_status(self) -> dict[str, Any]: + """Read the controller's private, bounded CI sandbox projection. + + This is deliberately read-only and sends no credential. The controller + derives the payload from fixed runner identities and its private DinD + daemon; AppOps never receives a Docker socket or a cleanup selector. + """ + if not self.is_configured(): + return {"status": "missing_config", "reason": self.configuration_error()} + try: + with httpx.Client( + timeout=self.timeout_seconds, + verify=self._verify_option(), + follow_redirects=False, + ) as client, client.stream( + "GET", + f"{self.base_url}/v1/runner-fleet", + headers={"Accept": "application/json"}, + ) as response: + if response.status_code != 200: + return {"status": "unavailable", "reason": f"runner_fleet_status_http_{response.status_code}"} + body = bytearray() + for chunk in response.iter_bytes(): + body.extend(chunk) + if len(body) > _MAX_RUNNER_FLEET_STATUS_BYTES: + return {"status": "unavailable", "reason": "runner_fleet_status_response_too_large"} + except httpx.RequestError as exc: + return {"status": "unreachable", "reason": redact_message(str(exc))} + try: + decoded = json.loads(bytes(body).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {"status": "unavailable", "reason": "runner_fleet_status_invalid_response"} + return decoded if isinstance(decoded, dict) else {"status": "unavailable", "reason": "runner_fleet_status_invalid_response"} diff --git a/backend/app/services/deployment_control_plane.py b/backend/app/services/deployment_control_plane.py new file mode 100644 index 0000000..da58df1 --- /dev/null +++ b/backend/app/services/deployment_control_plane.py @@ -0,0 +1,613 @@ +"""Deployment Control Plane V1 orchestration. + +The service is intentionally a bounded state machine. It validates a typed +plan, records durable idempotent jobs, and talks only to dedicated API clients. +There is no free-form command, Compose content, Docker socket or host path +input anywhere on the public API. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from typing import Any, Literal + +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.portainer import PortainerConnector +from ..connectors.unraid import UnraidConnector +from ..core.time import utc_now +from ..db.models import DeploymentJob, Policy +from ..schemas.deployment import DeploymentPlanV1 +from .audit_logger import log_event +from .deployment_broker import DeploymentBrokerClient +from .policy_engine import evaluate_policy +from .secret_store import get_secret, has_secret, secret_storage_status + +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_TERMINAL = {"preflight_failed", "succeeded", "failed", "rolled_back", "rollback_failed"} +_BROKER_EVIDENCE_FIELDS = {"request", "repository", "revision", "status"} +_OIDC_EVIDENCE_FIELDS = {"success", "status", "applicationId", "providerId", "clientSecretCreated"} +_PROXY_EVIDENCE_FIELDS = {"success", "status", "hostId", "hostname"} +_AUTO_ROLLBACK_REPOSITORY = "jens/itworx_mcp_hub" +_AUTO_ROLLBACK_APPLICATION_ID = "itworx-mcp-hub" + + +class DeploymentControlError(ValueError): + pass + + +def _canonical_plan(plan: DeploymentPlanV1) -> tuple[dict[str, Any], str]: + value = plan.model_dump(mode="json") + digest = hashlib.sha256( + json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + return value, digest + + +def _idempotency_digest(value: str) -> str: + if not _IDEMPOTENCY_KEY.fullmatch(value): + raise DeploymentControlError("idempotency key must contain 8 to 128 safe characters") + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _check(name: str, status: Literal["passed", "blocked", "warning", "unknown"], detail: str) -> dict[str, str]: + return {"name": name, "status": status, "detail": detail} + + +class DeploymentControlPlane: + """Durable orchestrator for strict DeploymentPlanV1 requests.""" + + def __init__( + self, + *, + broker: DeploymentBrokerClient | None = None, + portainer: PortainerConnector | None = None, + npm: NginxProxyManagerConnector | None = None, + authentik: AuthentikConnector | None = None, + unraid: UnraidConnector | None = None, + ): + self.broker = broker or DeploymentBrokerClient() + self.portainer = portainer or PortainerConnector() + self.npm = npm or NginxProxyManagerConnector() + self.authentik = authentik or AuthentikConnector() + self.unraid = unraid or UnraidConnector() + + def preflight( + self, + session: Session, + plan: DeploymentPlanV1, + *, + operation: Literal["preflight", "apply", "rollback"] = "apply", + ) -> dict[str, Any]: + checks: list[dict[str, str]] = [] + policy_action = "deployment.rollback" if operation == "rollback" else "deployment.apply" + policy = evaluate_policy( + session, + action=policy_action, + target=plan.application_id, + source="manual", + ) + exact_policy = session.exec( + select(Policy).where( + (Policy.action == policy_action) & (Policy.target == plan.application_id) + ) + ).first() + if policy.get("decision") == "allowed" and exact_policy is not None: + checks.append(_check("deployment_policy", "passed", "Exact-target deployment policy is present.")) + else: + checks.append(_check("deployment_policy", "blocked", "Deployment requires an explicit allowed exact-target policy.")) + if has_secret(session, plan.broker_secret_ref): + checks.append(_check("broker_secret_reference", "passed", "Stored controller secret is available by reference.")) + else: + checks.append(_check("broker_secret_reference", "blocked", "The referenced deployment-broker secret is not configured.")) + + containers = self.portainer.list_containers() + if containers.get("status") != "ok": + checks.append(_check("portainer_inventory", "blocked", "Portainer inventory is unavailable for collision preflight.")) + else: + names = {str(item.get("name") or "").casefold() for item in containers.get("containers", [])} + collisions = sorted(name for name in plan.topology.containers if name.casefold() in names) + # Existing expected containers are not unsafe by themselves: an exact + # controller receipt decides whether an apply is an upgrade or a + # conflicting takeover. + detail = "No managed container name is currently present." if not collisions else "Existing containers require controller ownership verification: " + ", ".join(collisions) + checks.append(_check("container_ownership", "warning" if collisions else "passed", detail)) + + if plan.proxy: + hosts = self.npm.proxy_hosts() + if not hosts.get("success"): + checks.append(_check("proxy_hostname", "blocked", "Nginx Proxy Manager inventory is unavailable.")) + else: + matches = [ + item + for item in hosts.get("items", []) + if plan.proxy.hostname in {str(value).lower() for value in item.get("domainNames", [])} + ] + if not matches: + checks.append(_check("proxy_hostname", "passed", "Requested hostname is free in the NPM inventory.")) + elif all( + str(item.get("forwardHost") or "") == plan.application_id + and int(item.get("forwardPort") or 0) == plan.topology.internal_port + for item in matches + ): + checks.append(_check("proxy_hostname", "passed", "Existing hostname already targets the planned managed container.")) + else: + checks.append(_check("proxy_hostname", "blocked", "Requested hostname is owned by a different NPM target.")) + + if plan.oidc: + auth_status = self.authentik.test_connection() + if auth_status.get("status") != "connected": + checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable for the requested OIDC plan.")) + else: + apps = self.authentik.applications() + if not apps.get("success"): + checks.append(_check("oidc_provider", "blocked", "Authentik applications cannot be inspected.")) + else: + found = [item for item in apps.get("items", []) if item.get("slug") == plan.oidc.application_slug] + checks.append(_check("oidc_provider", "warning" if found else "passed", "Existing Authentik application will be ownership-checked." if found else "OIDC application can be provisioned from the bounded template.")) + storage = secret_storage_status() + if not (storage.get("productionReady") or storage.get("status") == "ready"): + checks.append(_check("oidc_secret_reference", "blocked", "Encrypted secret storage is not ready for the referenced OIDC client secret.")) + elif has_secret(session, plan.oidc.client_secret_ref): + checks.append(_check("oidc_secret_reference", "passed", "The write-only OIDC client secret reference is available for the bounded provider setup.")) + else: + checks.append(_check("oidc_secret_reference", "blocked", "The referenced OIDC client secret is not configured in encrypted secret storage.")) + + shares = self.unraid.get_share_summary() + if shares.get("status") in {"ok", "connected"}: + checks.append(_check("trusted_appdata_root", "passed", f"Appdata path is derived as {plan.appdata_path}.")) + else: + checks.append(_check("trusted_appdata_root", "warning", f"Appdata is derived as {plan.appdata_path}; Unraid share inventory is currently unavailable.")) + + broker_secret = get_secret(session, plan.broker_secret_ref, touch=False) + if broker_secret: + result = self.broker.preflight(plan, secret=broker_secret) + status = str(result.get("status") or "unknown") + if status in {"ok", "preflight_ok", "already_deployed"}: + checks.append(_check("deployment_controller", "passed", "Controller accepted immutable repository and topology preflight.")) + elif status in {"missing_config", "missing_secret"}: + checks.append(_check("deployment_controller", "blocked", "Deployment controller is not configured for the typed API.")) + else: + checks.append(_check("deployment_controller", "blocked", "Controller rejected typed deployment preflight.")) + else: + checks.append(_check("deployment_controller", "blocked", "Controller authentication secret is unavailable.")) + + blockers = [item["name"] for item in checks if item["status"] == "blocked"] + warnings = [item["name"] for item in checks if item["status"] == "warning"] + return { + "status": "blocked" if blockers else "passed", + "blockers": blockers, + "warnings": warnings, + "checks": checks, + "appdataPath": plan.appdata_path, + "publicUrl": plan.public_url, + } + + def create_job( + self, + session: Session, + *, + plan: DeploymentPlanV1, + idempotency_key: str, + operation: Literal["preflight", "apply", "rollback"], + ) -> DeploymentJob: + plan_json, plan_digest = _canonical_plan(plan) + idempotency_digest = _idempotency_digest(f"{operation}:{idempotency_key}") + existing = session.exec( + select(DeploymentJob).where(DeploymentJob.idempotency_sha256 == idempotency_digest) + ).first() + if existing: + if existing.plan_sha256 != plan_digest: + raise DeploymentControlError("idempotency key was already used for a different deployment plan") + return existing + job = DeploymentJob( + id=f"deploy-{uuid.uuid4().hex[:16]}", + application_id=plan.application_id, + repository=plan.repository, + revision=plan.revision, + plan_sha256=plan_digest, + idempotency_sha256=idempotency_digest, + status="queued", + stage="accepted", + plan_json=plan_json, + evidence_json={"operation": operation}, + ) + session.add(job) + session.commit() + session.refresh(job) + return job + + @staticmethod + def serialize_job(job: DeploymentJob, *, replay: bool = False) -> dict[str, Any]: + return { + "id": job.id, + "applicationId": job.application_id, + "repository": job.repository, + "revision": job.revision, + "status": job.status, + "stage": job.stage, + "preflight": job.preflight_json, + "evidence": job.evidence_json, + "result": job.result_summary, + "brokerRequestId": job.broker_request_id, + "createdAt": job.created_at.isoformat(), + "updatedAt": job.updated_at.isoformat(), + "completedAt": job.completed_at.isoformat() if job.completed_at else None, + "idempotentReplay": replay, + } + + def _complete_external_integrations( + self, + session: Session, + plan: DeploymentPlanV1, + ) -> tuple[bool, dict[str, Any], str]: + """Complete only plan-derived edge/OIDC integration after live proof. + + Application environment secret delivery remains an explicit controller + enrollment responsibility. This code resolves the same write-only + reference only inside the backend to create/verify the bounded OIDC + provider, and never writes it to an API result or audit record. + """ + evidence: dict[str, Any] = {} + if plan.oidc: + client_secret = get_secret(session, plan.oidc.client_secret_ref, touch=False) + if not client_secret: + return False, evidence, "Referenced OIDC client secret is unavailable after deployment." + oidc_result = self.authentik.ensure_deployment_oidc_application( + application_slug=plan.oidc.application_slug, + launch_url=plan.public_url or "", + redirect_uri=f"{plan.public_url}{plan.oidc.redirect_path}", + client_secret=client_secret, + administrator_group=plan.oidc.roles.administrator_group, + support_agent_group=plan.oidc.roles.support_agent_group, + read_only_group=plan.oidc.roles.read_only_group, + ) + evidence["oidc"] = { + key: oidc_result[key] for key in _OIDC_EVIDENCE_FIELDS if key in oidc_result + } + if not oidc_result.get("success") or oidc_result.get("status") not in {"created", "already_managed"}: + return False, evidence, "Typed OIDC provider/application setup was not verified." + if plan.proxy: + proxy_result = self.npm.ensure_managed_proxy_host( + hostname=plan.proxy.hostname, + forward_host=plan.application_id, + forward_port=plan.topology.internal_port, + certificate_mode=plan.proxy.certificate_mode, + force_ssl=plan.proxy.force_ssl, + http2=plan.proxy.http2, + upload_limit_mb=plan.proxy.upload_limit_mb, + read_timeout_seconds=plan.proxy.read_timeout_seconds, + ) + evidence["proxy"] = { + key: proxy_result[key] for key in _PROXY_EVIDENCE_FIELDS if key in proxy_result + } + if not proxy_result.get("success") or proxy_result.get("status") not in {"created", "already_managed"}: + return False, evidence, "Typed reverse-proxy setup was not verified." + return True, evidence, "External integration is verified." + + def _complete_apply_job( + self, + session: Session, + job: DeploymentJob, + plan: DeploymentPlanV1, + ) -> None: + success, integration_evidence, message = self._complete_external_integrations(session, plan) + job.evidence_json = { + **dict(job.evidence_json or {}), + "externalIntegration": integration_evidence, + } + if success: + job.status = "succeeded" + job.stage = "controller_and_edge_verified" + job.result_summary = "Deployment controller and required edge integration returned verified evidence." + job.completed_at = utc_now() + return + + job.evidence_json = { + **dict(job.evidence_json or {}), + "postApplyVerification": {"status": "failed", "detail": message}, + } + if ( + plan.repository.casefold() != _AUTO_ROLLBACK_REPOSITORY + or plan.application_id != _AUTO_ROLLBACK_APPLICATION_ID + ): + job.status = "failed" + job.stage = "external_integration_failed" + job.result_summary = message + job.completed_at = utc_now() + return + self._attempt_auto_rollback(session, job, plan, reason=message) + + def _attempt_auto_rollback( + self, + session: Session, + job: DeploymentJob, + plan: DeploymentPlanV1, + *, + reason: str, + ) -> None: + """Restore only the controller-retained prior receipt after verification failure.""" + policy = evaluate_policy( + session, + action="deployment.rollback", + target=plan.application_id, + source="manual", + ) + exact_policies = session.exec( + select(Policy).where( + (Policy.action == "deployment.rollback") + & (Policy.target == plan.application_id) + ) + ).all() + exact_policy = exact_policies[0] if len(exact_policies) == 1 else None + policy_is_exact_and_strict = ( + exact_policy is not None + and exact_policy.manual == "allowed" + and exact_policy.voice == "blocked" + and exact_policy.risk == "high" + and policy.get("policy_id") == exact_policy.id + ) + if policy.get("decision") != "allowed" or not policy_is_exact_and_strict: + job.evidence_json = { + **dict(job.evidence_json or {}), + "autoRollback": {"status": "blocked", "reason": "exact-target-policy"}, + } + job.status = "failed" + job.stage = "external_integration_failed" + job.result_summary = reason + job.completed_at = utc_now() + return + + secret = get_secret(session, plan.broker_secret_ref) + if not secret: + job.evidence_json = { + **dict(job.evidence_json or {}), + "autoRollback": {"status": "blocked", "reason": "controller-secret"}, + } + job.status = "failed" + job.stage = "external_integration_failed" + job.result_summary = reason + job.completed_at = utc_now() + return + + rollback_key = f"auto-rollback:{job.id}" + rollback = self.broker.rollback(plan, secret=secret, idempotency_key=rollback_key) + rollback_status = str(rollback.get("status") or "failed") + rollback_request = str(rollback.get("request") or "") or None + rollback_evidence = { + key: rollback[key] + for key in _BROKER_EVIDENCE_FIELDS + if key in rollback + } + rollback_evidence["status"] = rollback_status + job.evidence_json = { + **dict(job.evidence_json or {}), + "autoRollback": rollback_evidence, + } + if rollback_request: + job.broker_request_id = rollback_request + + if rollback_status == "rolled_back": + job.status = "rolled_back" + job.stage = "auto_rollback_verified" + job.result_summary = ( + "Post-apply verification failed; the controller restored the prior verified deployment." + ) + job.completed_at = utc_now() + elif rollback_status in {"accepted", "already_queued", "running"}: + if not rollback_request: + job.status = "failed" + job.stage = "auto_rollback_failed" + job.evidence_json = { + **dict(job.evidence_json or {}), + "autoRollback": { + **rollback_evidence, + "reason": "missing-controller-request", + }, + } + job.result_summary = ( + "Post-apply verification failed and automatic rollback " + "returned no pollable controller request." + ) + job.completed_at = utc_now() + return + job.status = "rolling_back" + job.stage = "auto_rollback_queued" + job.result_summary = ( + "Post-apply verification failed; automatic rollback is in progress." + ) + job.completed_at = None + else: + job.status = "failed" + job.stage = "auto_rollback_failed" + job.result_summary = ( + "Post-apply verification failed and the controller did not verify rollback." + ) + job.completed_at = utc_now() + + def execute( + self, + session: Session, + *, + plan: DeploymentPlanV1, + idempotency_key: str, + operation: Literal["preflight", "apply", "rollback"], + ) -> dict[str, Any]: + job = self.create_job(session, plan=plan, idempotency_key=idempotency_key, operation=operation) + if job.status != "queued": + return self.serialize_job(job, replay=True) + + preflight = self.preflight(session, plan, operation=operation) + job.preflight_json = preflight + job.updated_at = utc_now() + if preflight["status"] != "passed": + job.status = "preflight_failed" + job.stage = "preflight" + job.result_summary = "Deployment preflight was blocked." + job.completed_at = utc_now() + session.add(job) + session.commit() + log_event( + session, + source="deployment-control-plane", + identity="operator", + action_id=f"deployment.{operation}", + target=plan.application_id, + target_type="deployment", + risk="high", + policy_decision="blocked", + reason="Typed deployment preflight failed closed.", + result="blocked", + execution_plan={"planSha256": job.plan_sha256, "operation": operation}, + post_checks={"preflight": preflight}, + ) + return self.serialize_job(job) + + if operation == "preflight": + job.status = "preflight_succeeded" + job.stage = "preflight" + job.result_summary = "Typed deployment preflight passed." + job.completed_at = utc_now() + session.add(job) + session.commit() + return self.serialize_job(job) + + secret = get_secret(session, plan.broker_secret_ref) + if not secret: + raise DeploymentControlError("deployment broker secret was unavailable after preflight") + broker_result = ( + self.broker.apply(plan, secret=secret, idempotency_key=idempotency_key) + if operation == "apply" + else self.broker.rollback(plan, secret=secret, idempotency_key=idempotency_key) + ) + broker_status = str(broker_result.get("status") or "failed") + job.evidence_json = { + **dict(job.evidence_json or {}), + "brokerStatus": broker_status, + "brokerEvidence": { + key: broker_result[key] for key in _BROKER_EVIDENCE_FIELDS if key in broker_result + }, + } + job.broker_request_id = str(broker_result.get("request") or "") or None + job.updated_at = utc_now() + if broker_status in {"accepted", "already_queued", "running"}: + job.status = "applying" if operation == "apply" else "rolling_back" + job.stage = "controller_queued" + job.result_summary = "Deployment controller accepted the typed request." + elif operation == "apply" and broker_status in {"deployed", "already_deployed"}: + self._complete_apply_job(session, job, plan) + elif operation == "rollback" and broker_status == "rolled_back": + job.status = "rolled_back" + job.stage = "rollback_verified" + job.result_summary = "Deployment controller returned verified rollback evidence." + job.completed_at = utc_now() + else: + job.status = "failed" if operation == "apply" else "rollback_failed" + job.stage = "controller_rejected" + job.result_summary = "Deployment controller rejected the typed request." + job.completed_at = utc_now() + session.add(job) + session.commit() + log_event( + session, + source="deployment-control-plane", + identity="operator", + action_id=f"deployment.{operation}", + target=plan.application_id, + target_type="deployment", + risk="high", + policy_decision="allowed" if job.status not in {"failed", "rollback_failed"} else "blocked", + reason="Typed deployment request sent to the private controller.", + result="success" if job.status not in {"failed", "rollback_failed"} else "failed", + execution_plan={"planSha256": job.plan_sha256, "operation": operation}, + after_state={"status": job.status, "stage": job.stage, "brokerRequestId": job.broker_request_id}, + redacted_metadata={"brokerStatus": broker_status}, + ) + return self.serialize_job(job) + + def refresh(self, session: Session, job_id: str) -> dict[str, Any]: + job = session.get(DeploymentJob, job_id) + if not job: + raise DeploymentControlError("deployment job was not found") + if job.status in _TERMINAL or not job.broker_request_id: + return self.serialize_job(job) + plan = DeploymentPlanV1.model_validate(job.plan_json) + secret = get_secret(session, plan.broker_secret_ref) + if not secret: + job.status = "failed" + job.stage = "controller_auth_lost" + job.result_summary = "Deployment controller secret is no longer available." + job.completed_at = utc_now() + else: + result = self.broker.status(job.broker_request_id, secret=secret) + status = str(result.get("status") or "unknown") + job.evidence_json = { + **dict(job.evidence_json or {}), + "brokerStatus": status, + "brokerEvidence": { + key: result[key] for key in _BROKER_EVIDENCE_FIELDS if key in result + }, + } + if status in {"deployed", "already_deployed"}: + auto_rollback = dict(job.evidence_json or {}).get("autoRollback") + if job.status == "rolling_back": + job.status = "failed" if isinstance(auto_rollback, dict) else "rollback_failed" + job.stage = ( + "auto_rollback_failed" + if isinstance(auto_rollback, dict) + else "controller_failed" + ) + job.result_summary = ( + "Rollback request returned a deployed state and was not accepted as rollback proof." + ) + job.completed_at = utc_now() + else: + self._complete_apply_job(session, job, plan) + elif status == "rolled_back": + job.status = "rolled_back" + auto_rollback = dict(job.evidence_json or {}).get("autoRollback") + if isinstance(auto_rollback, dict): + job.evidence_json = { + **dict(job.evidence_json or {}), + "autoRollback": {**auto_rollback, "status": "rolled_back"}, + } + job.stage = "auto_rollback_verified" + job.result_summary = ( + "Post-apply verification failed; the controller verified automatic rollback." + ) + else: + job.stage = "rollback_verified" + job.result_summary = "Deployment controller verified rollback evidence." + job.completed_at = utc_now() + elif status in {"failed", "non_forward", "rejected"}: + auto_rollback = dict(job.evidence_json or {}).get("autoRollback") + if isinstance(auto_rollback, dict) and job.status == "rolling_back": + job.status = "failed" + job.stage = "auto_rollback_failed" + job.evidence_json = { + **dict(job.evidence_json or {}), + "autoRollback": {**auto_rollback, "status": status}, + } + job.result_summary = ( + "Post-apply verification failed and automatic rollback was not verified." + ) + else: + job.status = "failed" if job.status == "applying" else "rollback_failed" + job.stage = "controller_failed" + job.result_summary = "Deployment controller reported a typed deployment failure." + job.completed_at = utc_now() + job.updated_at = utc_now() + session.add(job) + session.commit() + return self.serialize_job(job) + + def list_jobs(self, session: Session, *, limit: int = 100) -> list[dict[str, Any]]: + rows = session.exec( + select(DeploymentJob).order_by(DeploymentJob.created_at.desc()).limit(max(1, min(limit, 250))) + ).all() + return [self.serialize_job(row) for row in rows] diff --git a/backend/app/services/deployment_target_registry.py b/backend/app/services/deployment_target_registry.py new file mode 100644 index 0000000..26815ee --- /dev/null +++ b/backend/app/services/deployment_target_registry.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import json +import re +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_REPOSITORY = re.compile(r"^jens/[a-z0-9_.-]+$") +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$") +_SLUG = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$") +_COMPOSE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") +_CONTAINER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") +_CONTROLLER = "gitea-deploy-control" +_MAX_CONTAINERS = 32 + +# Fixed application-owned mutation. Callers never provide a command or host +# path. Repository-derived topology is validated before it reaches this script, +# then re-bound to the exact immutable Git revision inside the controller. +_REGISTER_SCRIPT = r''' +import fcntl +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import time + +path = Path("/opt/gitea-deploy/projects.json") +host_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") +repository, revision, branch, raw_project = sys.argv[1:5] +project = json.loads(raw_project) + +if path.is_symlink() or not path.is_file(): + raise SystemExit(20) +info = path.stat() +if not stat.S_ISREG(info.st_mode): + raise SystemExit(21) + +try: + if host_path.is_symlink() or not host_path.is_file() or not os.path.samefile(path, host_path): + raise SystemExit(36) +except OSError: + raise SystemExit(36) +mutation_path = host_path + +if project.get("branch") != branch: + raise SystemExit(37) +compose_files = project.get("compose_files") +if not isinstance(compose_files, list) or len(compose_files) != 1: + raise SystemExit(38) +compose_file = compose_files[0] +if not isinstance(compose_file, str) or not compose_file: + raise SystemExit(38) + +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +if not bare_repo.is_dir(): + raise SystemExit(24) + +def git_run(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + +try: + commit_result = git_run("rev-parse", f"{revision}^{{commit}}") +except Exception: + raise SystemExit(25) +commit = commit_result.stdout.strip() +if commit_result.returncode != 0 or commit != revision: + raise SystemExit(25) + +try: + branch_result = git_run("rev-parse", f"refs/heads/{branch}") +except Exception: + raise SystemExit(26) +branch_head = branch_result.stdout.strip() +if branch_result.returncode != 0 or not branch_head: + raise SystemExit(26) + +try: + ancestry = git_run("merge-base", "--is-ancestor", revision, branch_head) +except Exception: + raise SystemExit(27) +if ancestry.returncode != 0: + raise SystemExit(27) + +try: + compose = git_run("cat-file", "-e", f"{revision}:{compose_file}") +except Exception: + raise SystemExit(28) +if compose.returncode != 0: + raise SystemExit(28) + +try: + lock_handle = lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(29) + +with lock_handle as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + + try: + projects = json.loads(mutation_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(31) + if not isinstance(projects, dict): + raise SystemExit(22) + + current = projects.get(repository) + changed = current is None + if current is not None: + if not isinstance(current, dict): + raise SystemExit(23) + allowed_extra_keys = { + "env_source", + "environment", + "gitea_repository_id", + "gitea_webhook_secret_file", + "validation", + } + extra_keys = set(current) - set(project) + if not extra_keys.issubset(allowed_extra_keys): + raise SystemExit(23) + canonical_current = {key: current.get(key) for key in project} + if canonical_current != project: + raise SystemExit(23) + env_source = current.get("env_source") + if env_source is not None: + if ( + not isinstance(env_source, str) + or not env_source.startswith("/srv/example/appdata/") + or len(env_source) > 512 + or ".." in Path(env_source).parts + ): + raise SystemExit(23) + environment = current.get("environment") + if environment is not None: + if not isinstance(environment, dict) or len(environment) > 64: + raise SystemExit(23) + for key, value in environment.items(): + if ( + not isinstance(key, str) + or not key + or len(key) > 128 + or not isinstance(value, str) + or len(value) > 4096 + ): + raise SystemExit(23) + runnerless_keys = { + "gitea_repository_id", + "gitea_webhook_secret_file", + "validation", + } + present_runnerless_keys = runnerless_keys.intersection(current) + if present_runnerless_keys and present_runnerless_keys != runnerless_keys: + raise SystemExit(23) + repository_id = current.get("gitea_repository_id") + if repository_id is not None and ( + not isinstance(repository_id, int) + or isinstance(repository_id, bool) + or repository_id <= 0 + ): + raise SystemExit(23) + webhook_secret_file = current.get("gitea_webhook_secret_file") + if webhook_secret_file is not None: + expected_secret_file = repository.replace("/", "__") + if webhook_secret_file != expected_secret_file: + raise SystemExit(23) + validation = current.get("validation") + if validation is not None: + expected_validation = { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 3600 if repository == "jens/mediarr" else 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, + } + if validation != expected_validation: + raise SystemExit(23) + + if changed: + backup = mutation_path.with_name( + f"projects.json.appops-backup-{int(time.time())}-{os.getpid()}" + ) + try: + shutil.copy2(mutation_path, backup) + except Exception: + raise SystemExit(32) + + updated = dict(projects) + updated[repository] = project + temporary = mutation_path.with_name(f".{mutation_path.name}.{os.getpid()}.tmp") + payload = json.dumps(updated, indent=2, sort_keys=True) + "\n" + try: + with temporary.open("w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, stat.S_IMODE(info.st_mode)) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(33) + + try: + os.replace(temporary, mutation_path) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(34) + + directory_fd = None + try: + directory_fd = os.open(str(mutation_path.parent), os.O_RDONLY) + os.fsync(directory_fd) + except Exception: + raise SystemExit(35) + finally: + if directory_fd is not None: + os.close(directory_fd) + + print("registered" if changed else "already-registered", flush=True) +''' + + +def _safe_compose_file(value: str) -> str: + path = str(value or "").strip().replace("\\", "/") + if not path or path.startswith("/") or len(path) > 240: + raise ValueError("Compose file must be a bounded repository-relative path") + parts = path.split("/") + if any(part in {"", ".", ".."} for part in parts): + raise ValueError("Compose file path cannot contain empty or traversal segments") + if not path.lower().endswith((".yml", ".yaml")): + raise ValueError("Compose file must use a .yml or .yaml suffix") + return path + + +class DeploymentTargetRegistry: + """Narrow bridge for exact-SHA Compose deployment target onboarding.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + @staticmethod + def _slug(repository: str) -> str: + return repository.split("/", 1)[1] + + @staticmethod + def default_compose_project(repository: str) -> str: + raw = DeploymentTargetRegistry._slug(repository).lower() + value = re.sub(r"[^a-z0-9_-]+", "-", raw).strip("-_") + if not value: + raise ValueError("Repository name cannot be converted to a Compose project") + return value[:63] + + @staticmethod + def _project( + repository: str, + *, + branch: str, + compose_file: str, + compose_project: str, + containers: list[str], + health_timeout: int, + verify_oci_revision: bool, + ) -> dict[str, object]: + slug = DeploymentTargetRegistry._slug(repository) + return { + "branch": branch, + "slug": slug, + "source_dir": f"/mnt/cache/appdata/gitea-deploy-runners/sources/{slug}", + "exact_source": True, + "compose_files": [compose_file], + "compose_project": compose_project, + "containers": containers, + "verify_oci_revision": verify_oci_revision, + "health_timeout": health_timeout, + } + + @staticmethod + def validate( + repository: str, + revision: str, + *, + branch: str = "main", + compose_file: str = "docker-compose.yml", + compose_project: str | None = None, + containers: list[str] | None = None, + health_timeout: int = 180, + ) -> tuple[str, str, str, str, str, list[str], int]: + normalized_repository = str(repository or "").strip().lower() + normalized_revision = str(revision or "").strip().lower() + normalized_branch = str(branch or "").strip() + if not _REPOSITORY.fullmatch(normalized_repository): + raise ValueError("Repository must be a canonical Jens owner/name identifier") + slug = DeploymentTargetRegistry._slug(normalized_repository) + if not _SLUG.fullmatch(slug): + raise ValueError("Repository name cannot be used as a bounded deployment slug") + if not _REVISION.fullmatch(normalized_revision): + raise ValueError("Revision must be a full lowercase 40-character Git SHA") + if ( + not _BRANCH.fullmatch(normalized_branch) + or ".." in normalized_branch + or "//" in normalized_branch + or normalized_branch.endswith("/") + ): + raise ValueError("Default branch is outside the bounded Git ref contract") + normalized_compose_file = _safe_compose_file(compose_file) + normalized_project = str( + compose_project + or DeploymentTargetRegistry.default_compose_project(normalized_repository) + ).strip().lower() + if not _COMPOSE_PROJECT.fullmatch(normalized_project): + raise ValueError("Compose project is outside the bounded naming contract") + normalized_containers = [str(item or "").strip() for item in (containers or [slug])] + if not normalized_containers or len(normalized_containers) > _MAX_CONTAINERS: + raise ValueError("Deployment target must contain between 1 and 32 containers") + if any(not _CONTAINER.fullmatch(item) for item in normalized_containers): + raise ValueError("Container name is outside the bounded naming contract") + if len(set(normalized_containers)) != len(normalized_containers): + raise ValueError("Container names must be unique") + timeout = int(health_timeout) + if timeout < 30 or timeout > 900: + raise ValueError("Health timeout must be between 30 and 900 seconds") + return ( + normalized_repository, + normalized_revision, + normalized_branch, + normalized_compose_file, + normalized_project, + normalized_containers, + timeout, + ) + + def register_compose_target( + self, + repository: str, + revision: str, + *, + branch: str = "main", + compose_file: str = "docker-compose.yml", + compose_project: str | None = None, + containers: list[str] | None = None, + health_timeout: int = 180, + verify_oci_revision: bool = False, + ) -> dict[str, Any]: + try: + ( + repository, + revision, + branch, + compose_file, + compose_project, + containers, + health_timeout, + ) = self.validate( + repository, + revision, + branch=branch, + compose_file=compose_file, + compose_project=compose_project, + containers=containers, + health_timeout=health_timeout, + ) + except (TypeError, ValueError) as exc: + return {"status": "invalid", "reason": str(exc)} + + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + return { + "status": "controller_unavailable", + "reason": redact_message( + str(status.get("reason") or status.get("status") or "unavailable") + ), + } + controller = dict(status.get("container") or {}) + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return { + "status": "controller_unavailable", + "reason": "deployment controller is not running", + } + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if not config or not container_id: + return { + "status": "connector_unavailable", + "reason": "Portainer deployment control is unavailable", + } + + project = self._project( + repository, + branch=branch, + compose_file=compose_file, + compose_project=compose_project, + containers=containers, + health_timeout=health_timeout, + verify_oci_revision=bool(verify_oci_revision), + ) + command = [ + "python3", + "-c", + _REGISTER_SCRIPT, + repository, + revision, + branch, + json.dumps(project, separators=(",", ":"), sort_keys=True), + ] + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return { + "status": "error", + "reason": "deployment target registration could not start", + } + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + + deadline = time.monotonic() + 25 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return { + "status": "error", + "reason": "deployment target registration timed out", + } + if exit_code == 23: + return { + "status": "conflict", + "reason": "repository already has a different deployment target", + } + preflight_reasons = { + 20: "deployment projects registry is unavailable or unsafe", + 21: "deployment projects registry is not a regular file", + 22: "deployment projects registry does not contain an object", + 24: "mounted bare repository is unavailable", + 25: "exact revision cannot be resolved in the mounted bare repository", + 26: "default branch cannot be resolved in the mounted bare repository", + 27: "exact revision is not an ancestor of the default branch in the mounted bare repository", + 28: "configured Compose file is absent at the exact revision", + 29: "deployment registry lock file cannot be opened", + 30: "deployment registry lock cannot be acquired", + 31: "deployment projects registry cannot be read as JSON", + 32: "deployment projects registry backup cannot be created", + 33: "deployment projects registry replacement file cannot be durably written", + 34: "deployment projects registry replacement cannot be installed atomically", + 35: "deployment projects registry directory durability sync failed after replacement", + 36: "deployment projects registry host alias is unavailable or does not match the controller registry", + 37: "controller project branch does not match the requested default branch", + 38: "controller project Compose contract is invalid", + } + if exit_code in preflight_reasons: + return { + "status": "validation_failed", + "reason": preflight_reasons[exit_code], + } + if exit_code != 0: + return { + "status": "validation_failed", + "reason": "controller rejected the bounded repository/revision preflight; projects.json was not changed", + } + return { + "status": "registered", + "repository": repository, + "revision": revision, + "branch": branch, + "slug": self._slug(repository), + "compose_file": compose_file, + "compose_project": compose_project, + "container": containers[0] if len(containers) == 1 else None, + "containers": containers, + } + + def register_single_container( + self, + repository: str, + revision: str, + *, + branch: str = "main", + compose_file: str = "docker-compose.yml", + ) -> dict[str, Any]: + slug = str(repository or "").strip().lower().split("/", 1)[-1] + return self.register_compose_target( + repository, + revision, + branch=branch, + compose_file=compose_file, + containers=[slug], + ) diff --git a/backend/app/services/discovery_scheduler.py b/backend/app/services/discovery_scheduler.py new file mode 100644 index 0000000..f1c660d --- /dev/null +++ b/backend/app/services/discovery_scheduler.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import asyncio +import logging + +from sqlmodel import Session, select + +from ..db import session as db_session +from ..db.models import Policy +from .audit_logger import log_event +from .gitea_project_auto_registration import GiteaProjectAutoRegistration +from .inventory_discovery import run_discovery +from .mcp_hub_deployment_reconciliation import McpHubDeploymentReconciliation +from .media_codec_dashboard_deployment_reconciliation import ( + MediaCodecDashboardDeploymentReconciliation, +) +from .privatemuse_deployment_enrollment import PrivateMuseDeploymentEnrollment + + +logger = logging.getLogger(__name__) + +_MCP_HUB_APPLICATION_ID = "itworx-mcp-hub" +_MCP_HUB_REPOSITORY = "jens/itworx_mcp_hub" +_MCP_HUB_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback") + + +def _reconcile_mcp_hub_exact_policies(session: Session) -> dict[str, object]: + """Create only the exact MCP Hub deployment policies after state proof.""" + + existing_by_action: dict[str, Policy | None] = {} + for action in _MCP_HUB_DEPLOYMENT_ACTIONS: + matches = session.exec( + select(Policy).where( + (Policy.action == action) & (Policy.target == _MCP_HUB_APPLICATION_ID) + ) + ).all() + if len(matches) > 1: + return { + "status": "conflict", + "action": action, + "created": 0, + "policyIds": [], + } + existing = matches[0] if matches else None + if existing is not None and ( + existing.manual != "allowed" + or existing.voice != "blocked" + or existing.risk != "high" + ): + return { + "status": "conflict", + "action": action, + "created": 0, + "policyIds": [], + } + existing_by_action[action] = existing + + policy_ids: list[str] = [] + created = 0 + for action in _MCP_HUB_DEPLOYMENT_ACTIONS: + existing = existing_by_action[action] + if existing is not None: + policy_ids.append(existing.id) + continue + suffix = action.rsplit(".", 1)[-1] + policy_id = f"mcp-hub-reconciled-{suffix}" + if session.get(Policy, policy_id) is not None: + return { + "status": "conflict", + "action": action, + "created": 0, + "policyIds": [], + } + session.add( + Policy( + id=policy_id, + action=action, + target=_MCP_HUB_APPLICATION_ID, + voice="blocked", + manual="allowed", + risk="high", + reason=( + "Exact MCP Hub deployment policy created only after " + "fail-closed runtime state reconciliation." + ), + provenance_json={ + "createdBy": "mcp-hub-state-reconciliation/v1", + "repository": _MCP_HUB_REPOSITORY, + "policyTemplateVersion": "exact-target/v1", + }, + ) + ) + policy_ids.append(policy_id) + created += 1 + session.commit() + return {"status": "ready", "created": created, "policyIds": policy_ids} + + +def _ensure_mcp_hub_exact_policies() -> dict[str, object]: + with Session(db_session.engine) as session: + return _reconcile_mcp_hub_exact_policies(session) + + +def _audit_privatemuse_enrollment(enrollment: dict[str, object]) -> None: + status = str(enrollment.get("status") or "error") + failure_reason = str(enrollment.get("reason") or "") + reason = "Bounded startup reconciliation for the existing PrivateMuse deployment target." + if failure_reason: + reason = f"{reason} Enrollment result: {status}; {failure_reason}" + with Session(db_session.engine) as session: + log_event( + session, + source="system", + identity="appops-startup", + action_id="deployment.target.enroll", + target="jens/privatemuse", + target_type="deployment-target", + risk="high", + policy_decision="allowed", + reason=reason, + result="success" if status == "enrolled" else "failed", + after_state={ + "status": status, + "repository": enrollment.get("repository", "jens/privatemuse"), + "legacyRevision": enrollment.get("legacy_revision", ""), + "composeProject": enrollment.get("compose_project", ""), + "containers": enrollment.get("containers", []), + }, + redacted_metadata={"failureReason": failure_reason} if failure_reason else None, + ) + + +def _audit_mcp_hub_reconciliation(result: dict[str, object]) -> None: + status = str(result.get("status") or "error") + failure_reason = str(result.get("reason") or "") + reason = "Fail-closed startup reconciliation for the verified MCP Hub deployment state." + if failure_reason: + reason = f"{reason} Reconciliation result: {status}; {failure_reason}" + with Session(db_session.engine) as session: + log_event( + session, + source="system", + identity="appops-startup", + action_id="deployment.state.reconcile", + target="jens/itworx_mcp_hub", + target_type="deployment-target", + risk="high", + policy_decision="allowed", + reason=reason, + result="success" if status == "reconciled" else "failed", + after_state={ + "status": status, + "repository": result.get("repository", "jens/itworx_mcp_hub"), + "composeProject": result.get("composeProject", "itworx-mcp-hub"), + "policyStatus": result.get("policyStatus", "not_checked"), + "policyIds": result.get("policyIds", []), + "policiesCreated": result.get("policiesCreated", 0), + }, + redacted_metadata={"failureReason": failure_reason} if failure_reason else None, + ) + + +def _audit_media_codec_dashboard_reconciliation(result: dict[str, object]) -> None: + status = str(result.get("status") or "error") + failure_reason = str(result.get("reason") or "") + reason = "Fail-closed startup reconciliation for the verified Media Codec Dashboard deployment state." + if failure_reason: + reason = f"{reason} Reconciliation result: {status}; {failure_reason}" + with Session(db_session.engine) as session: + log_event( + session, + source="system", + identity="appops-startup", + action_id="deployment.state.reconcile", + target="jens/media-codec-dashboard", + target_type="deployment-target", + risk="high", + policy_decision="allowed", + reason=reason, + result="success" if status == "reconciled" else "failed", + after_state={ + "status": status, + "repository": result.get("repository", "jens/media-codec-dashboard"), + "liveRevision": result.get("live_revision", ""), + "previousRecordedRevision": result.get("previous_recorded_revision", ""), + "container": result.get("container", ""), + }, + redacted_metadata={"failureReason": failure_reason} if failure_reason else None, + ) + + +def _sync_gitea_projects() -> dict[str, object]: + with Session(db_session.engine) as session: + result = GiteaProjectAutoRegistration().sync(session) + summary = result.get("summary") if isinstance(result.get("summary"), dict) else {} + changed = int(summary.get("changedThisSync", 0) or 0) + if changed: + log_event( + session, + source="system", + identity="appops-project-sync", + action_id="deployment.targets.reconcile", + target="jens/*", + target_type="deployment-project-catalog", + risk="medium", + policy_decision="allowed", + reason="Automatic Gitea project registration reconciled changed owned repositories.", + result="success" if result.get("status") == "ok" else "failed", + after_state={ + "status": result.get("status"), + "total": summary.get("total", 0), + "registered": summary.get("registered", 0), + "knownUnconfigured": summary.get("knownUnconfigured", 0), + "conflicts": summary.get("conflicts", 0), + "errors": summary.get("errors", 0), + "changed": changed, + }, + ) + return result + + +async def _run_gitea_project_sync() -> None: + try: + result = await asyncio.to_thread(_sync_gitea_projects) + summary = result.get("summary") if isinstance(result.get("summary"), dict) else {} + if result.get("status") != "ok": + logger.warning( + "Gitea project registration sync degraded: status=%s reason=%s", + result.get("status"), + result.get("reason", "unspecified"), + ) + else: + logger.info( + "Gitea project registration sync complete: total=%s registered=%s changed=%s", + summary.get("total", 0), + summary.get("registered", 0), + summary.get("changedThisSync", 0), + ) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - catalog sync must never take down AppOps. + logger.exception("Gitea project registration sync failed") + + +async def run_periodic_discovery(interval_seconds: int) -> None: + """Reconcile deployment project catalog, bounded target state and inventory.""" + await _run_gitea_project_sync() + + try: + enrollment = await asyncio.to_thread(PrivateMuseDeploymentEnrollment().run) + await asyncio.to_thread(_audit_privatemuse_enrollment, enrollment) + if enrollment.get("status") != "enrolled": + logger.warning( + "PrivateMuse deployment enrollment did not complete: status=%s reason=%s", + enrollment.get("status"), + enrollment.get("reason", "unspecified"), + ) + else: + logger.info("PrivateMuse deployment target is enrolled") + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - enrollment failure must not take down AppOps. + logger.exception("PrivateMuse deployment enrollment failed") + + try: + reconciliation = await asyncio.to_thread(McpHubDeploymentReconciliation().run) + if reconciliation.get("status") == "reconciled": + policy_result = await asyncio.to_thread(_ensure_mcp_hub_exact_policies) + reconciliation = { + **reconciliation, + "policyStatus": policy_result.get("status", "error"), + "policyIds": policy_result.get("policyIds", []), + "policiesCreated": policy_result.get("created", 0), + } + if policy_result.get("status") != "ready": + reconciliation = { + **reconciliation, + "status": "policy_conflict", + "reason": "exact MCP Hub deployment policy reconciliation failed closed", + } + await asyncio.to_thread(_audit_mcp_hub_reconciliation, reconciliation) + if reconciliation.get("status") != "reconciled": + logger.warning( + "MCP Hub deployment reconciliation did not complete: status=%s reason=%s", + reconciliation.get("status"), + reconciliation.get("reason", "unspecified"), + ) + else: + logger.info("MCP Hub deployment state is reconciled") + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - reconciliation failure must not take down AppOps. + logger.exception("MCP Hub deployment reconciliation failed") + + try: + reconciliation = await asyncio.to_thread( + MediaCodecDashboardDeploymentReconciliation().run + ) + await asyncio.to_thread( + _audit_media_codec_dashboard_reconciliation, reconciliation + ) + if reconciliation.get("status") != "reconciled": + logger.warning( + "Media Codec Dashboard deployment reconciliation did not complete: status=%s reason=%s", + reconciliation.get("status"), + reconciliation.get("reason", "unspecified"), + ) + else: + logger.info("Media Codec Dashboard deployment state is reconciled") + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - reconciliation failure must not take down AppOps. + logger.exception("Media Codec Dashboard deployment reconciliation failed") + + if interval_seconds <= 0: + return + + await asyncio.sleep(min(10, interval_seconds)) + while True: + try: + await asyncio.to_thread(run_discovery) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - discovery must never take down the gateway. + logger.exception("Scheduled inventory discovery failed") + await _run_gitea_project_sync() + await asyncio.sleep(interval_seconds) diff --git a/backend/app/services/enrollment_plane.py b/backend/app/services/enrollment_plane.py new file mode 100644 index 0000000..cc6eedc --- /dev/null +++ b/backend/app/services/enrollment_plane.py @@ -0,0 +1,986 @@ +"""Bounded enrollment orchestration for a new managed application identity. + +The plane is intentionally not a generic infrastructure API. It creates a +single controller target from a trusted profile, generates write-only +credentials, and only then creates exact-target deployment policies. Every +resource identifier and error returned from this module is sanitized. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import secrets +import uuid +from typing import Any, Literal + +from sqlmodel import Session, select + +from ..connectors.authentik import AuthentikConnector +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..connectors.portainer import PortainerConnector +from ..core.time import utc_now +from ..db.models import AppSetting, Enrollment, EnrollmentJob, Policy +from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1 +from .audit_logger import log_event +from .deployment_broker import DeploymentBrokerClient +from .enrollment_profiles import PROFILE_VERSION, profile_contract, profile_summary +from .secret_store import ( + delete_secret, + get_secret, + has_secret, + secret_storage_status, + set_secret, +) + +TRUST_SETTING_KEY = "deployment_enrollment:trust" +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_TERMINAL = {"ready", "failed", "compensation_required", "preflight_failed"} +_CONTROLLER_EVIDENCE = {"status", "targetId", "repository", "applicationId", "appdataPath"} +_PROXY_EVIDENCE = {"success", "status", "hostId", "hostname"} +_OIDC_EVIDENCE = {"success", "status", "applicationId", "providerId", "clientSecretCreated"} + + +class EnrollmentError(ValueError): + pass + + +def _canonical(value: EnrollmentPlanV1) -> tuple[dict[str, Any], str]: + payload = value.model_dump(mode="json") + digest = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() + return payload, digest + + +def _idempotency_digest(operation: str, value: str) -> str: + if not _IDEMPOTENCY_KEY.fullmatch(value): + raise EnrollmentError("idempotency key must contain 8 to 128 safe characters") + return hashlib.sha256(f"{operation}:{value}".encode()).hexdigest() + + +def _check( + code: str, + status: Literal["passed", "blocked", "warning", "unknown"], + detail: str, +) -> dict[str, str]: + return {"code": code, "status": status, "detail": detail} + + +def _secret_ref(application_id: str, kind: str) -> str: + return f"ENROLL_{application_id.upper().replace('-', '_')}_{kind}" + + +def _credential() -> str: + # 64 URL-safe characters provide a high-entropy printable HMAC key while + # remaining suitable for the controller's mode-0600 credential file. + return secrets.token_urlsafe(48) + + +def _fingerprint(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def _enrollment_secret_store_ready(storage: dict[str, Any]) -> bool: + """Enrollment always needs a configured production-grade key. + + The general secret store intentionally supports a development fallback for + offline UI work. Enrollment generates controller and OIDC credentials, so + it must never accept that deterministic fallback as a write target. + """ + return bool(storage.get("productionReady")) + + +class EnrollmentPlane: + def __init__( + self, + *, + broker: DeploymentBrokerClient | None = None, + portainer: PortainerConnector | None = None, + npm: NginxProxyManagerConnector | None = None, + authentik: AuthentikConnector | None = None, + ): + self.broker = broker or DeploymentBrokerClient() + self.portainer = portainer or PortainerConnector() + self.npm = npm or NginxProxyManagerConnector() + self.authentik = authentik or AuthentikConnector() + + @staticmethod + def get_trust_config(session: Session) -> EnrollmentTrustConfigV1 | None: + row = session.get(AppSetting, TRUST_SETTING_KEY) + if not row or row.is_secret: + return None + try: + return EnrollmentTrustConfigV1.model_validate(row.value_json) + except ValueError: + return None + + @staticmethod + def set_trust_config(session: Session, value: EnrollmentTrustConfigV1) -> dict[str, Any]: + row = session.get(AppSetting, TRUST_SETTING_KEY) or AppSetting( + key=TRUST_SETTING_KEY, is_secret=False + ) + row.value_json = value.model_dump(mode="json") + row.is_secret = False + row.updated_at = utc_now() + session.add(row) + session.commit() + return EnrollmentPlane.trust_status(session) + + @staticmethod + def trust_status(session: Session) -> dict[str, Any]: + trust = EnrollmentPlane.get_trust_config(session) + storage = secret_storage_status() + if trust is None: + return { + "status": "needs_setup", + "blockers": ["enrollment_trust_not_configured"], + "configured": False, + "secretStore": storage, + } + return { + "status": "ready" if _enrollment_secret_store_ready(storage) else "blocked", + "blockers": [] if _enrollment_secret_store_ready(storage) else ["secret_store_not_production_ready"], + "configured": True, + "controllerIdentity": trust.controller_identity, + "trustedAppdataRoot": trust.trusted_appdata_root, + "approvedNetworks": list(trust.approved_networks), + "allowedDomainSuffixes": list(trust.allowed_domain_suffixes), + "autonomousProfiles": list(trust.autonomous_profiles), + "repopulseProxyAccessListId": trust.repopulse_proxy_access_list_id, + "oidcConfigured": bool(trust.oidc_issuer and trust.administrator_group), + "bootstrapSecretConfigured": has_secret(session, trust.bootstrap_secret_ref), + "secretStore": storage, + } + + def capabilities(self, session: Session) -> dict[str, Any]: + trust = self.get_trust_config(session) + storage = secret_storage_status() + configured = trust is not None + bootstrap = bool(trust and has_secret(session, trust.bootstrap_secret_ref)) + controller_capability: dict[str, Any] = {} + if bootstrap and self.broker.is_configured() and trust is not None: + bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False) + if bootstrap_secret: + controller_capability = self.broker.enrollment_capabilities(secret=bootstrap_secret) + broker_ready = bool( + bootstrap + and self.broker.is_configured() + and controller_capability.get("status") == "ready" + ) + npm_ready = self.npm.is_configured() + oidc_configured = bool( + trust + and trust.oidc_issuer + and trust.administrator_group + and trust.support_agent_group + and trust.read_only_group + and self.authentik.is_configured() + ) + certificate_ready = bool(npm_ready) + + def item(ready: bool, code: str) -> dict[str, str]: + return {"status": "ready" if ready else "unavailable", "blockerCode": "" if ready else code} + + return { + "apiVersion": "itworx.enrollment-capabilities/v1", + "trust": item(configured, "enrollment_trust_not_configured"), + "targetEnrollment": item(broker_ready, "deployment_executor_unavailable"), + "secretBootstrap": item(_enrollment_secret_store_ready(storage), "secret_store_not_production_ready"), + "runtimeDeployment": item(broker_ready, "deployment_executor_unavailable"), + "reverseProxyProvisioning": item(npm_ready, "npm_credentials_missing"), + "tlsProvisioning": item(certificate_ready, "certificate_provisioning_unavailable"), + "oidcProvisioning": item(oidc_configured, "oidc_profile_unavailable"), + "policyProvisioning": item(configured, "policy_template_unavailable"), + "rollback": item(broker_ready, "deployment_executor_unavailable"), + "verification": item(broker_ready, "deployment_executor_unavailable"), + "controller": { + "status": str(controller_capability.get("status") or "unavailable"), + "reason": str(controller_capability.get("reason") or "deployment_executor_unavailable"), + }, + "profiles": [profile_summary(name) for name in ("internal-webapp", "internal-webapp-oidc", "internal-service", "repopulse-authenticated")], + } + + @staticmethod + def _allowed_hostname(hostname: str, trust: EnrollmentTrustConfigV1) -> bool: + return any(hostname == suffix or hostname.endswith(f".{suffix}") for suffix in trust.allowed_domain_suffixes) + + @staticmethod + def _existing(session: Session, application_id: str, repository: str) -> tuple[Enrollment | None, Enrollment | None]: + by_application = session.exec( + select(Enrollment).where(Enrollment.application_id == application_id) + ).first() + by_repository = session.exec( + select(Enrollment).where(Enrollment.repository == repository) + ).first() + return by_application, by_repository + + def _controller_payload( + self, + plan: EnrollmentPlanV1, + *, + enrollment_id: str, + target_credential: str | None = None, + ) -> dict[str, Any]: + profile = profile_contract(plan.profile) + assert profile is not None # validated before this method is called + payload: dict[str, Any] = { + "api_version": "itworx.controller-enrollment/v1", + "enrollment_id": enrollment_id, + "application_id": plan.application_id, + "repository": plan.repository, + "revision": plan.revision, + "profile": plan.profile, + "container_name": plan.target.container_name, + "compose_file": plan.target.compose_file, + "appdata_path": plan.appdata_path, + "data_path": plan.data_path, + "runtime_uid": plan.storage.runtime_uid, + "runtime_gid": plan.storage.runtime_gid, + "network": plan.network.docker_network, + "internal_port": plan.network.internal_port, + "proxy_hostname": plan.exposure.hostname if plan.exposure else None, + "image_repository": plan.artifact.image_repository, + "image_tag": plan.artifact.image_tag, + "health_path": plan.artifact.health_path, + "runtime_environment_keys": list(profile["runtimeEnvironmentKeys"]), + } + if target_credential is not None: + payload["target_credential"] = target_credential + return payload + + def preflight(self, session: Session, plan: EnrollmentPlanV1) -> dict[str, Any]: + checks: list[dict[str, str]] = [] + trust = self.get_trust_config(session) + profile = profile_contract(plan.profile) + if trust is None: + checks.append(_check("enrollment_trust_not_configured", "blocked", "Root enrollment trust boundaries are not configured.")) + else: + checks.append(_check("enrollment_trust", "passed", "Root-owned enrollment boundaries are configured.")) + if plan.target.target_identity == trust.controller_identity: + checks.append(_check("controller_identity", "passed", "Requested controller matches the trusted enrollment target.")) + else: + checks.append(_check("deployment_executor_unavailable", "blocked", "Requested controller is not the configured enrollment target.")) + if plan.network.docker_network in trust.approved_networks: + checks.append(_check("network", "passed", "Requested Docker network is within the trusted profile boundary.")) + else: + checks.append(_check("network_unavailable", "blocked", "Requested Docker network is not approved for enrollment.")) + if plan.profile in trust.autonomous_profiles: + checks.append(_check("autonomous_profile", "passed", "Deployment profile is approved for autonomous enrollment.")) + else: + checks.append(_check("policy_template_unavailable", "blocked", "Deployment profile is not approved for autonomous enrollment.")) + + if profile is None: + checks.append(_check("policy_template_unavailable", "blocked", "Requested server-side deployment profile is unavailable.")) + else: + checks.append(_check("deployment_profile", "passed", "Server-side deployment profile is available.")) + + if plan.profile == "repopulse-authenticated": + gitea_token = get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False) + checks.append(_check( + "repopulse_gitea_token_unavailable", + "passed" if gitea_token else "blocked", + "The existing encrypted RepoPulse Gitea token is available by reference." + if gitea_token else "The existing encrypted RepoPulse Gitea token is unavailable.", + )) + if has_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN")): + checks.append(_check( + "repopulse_maintenance_token_collision", "blocked", + "An existing write-only RepoPulse maintenance token cannot be adopted by a new enrollment.", + )) + else: + checks.append(_check( + "repopulse_maintenance_token", "passed", + "A new write-only RepoPulse maintenance token can be generated for this enrollment.", + )) + if trust is None or trust.repopulse_proxy_access_list_id is None: + checks.append(_check( + "repopulse_access_list_unavailable", "blocked", + "A positive root-owned RepoPulse NPM access-list ID is not configured.", + )) + else: + verify_access_list = getattr(self.npm, "validate_managed_access_list", None) + access_list = verify_access_list(trust.repopulse_proxy_access_list_id) if callable(verify_access_list) else {} + verified = ( + access_list.get("success") is True + and access_list.get("status") == "verified" + and access_list.get("accessListId") == trust.repopulse_proxy_access_list_id + ) + checks.append(_check( + "repopulse_access_list_unavailable", + "passed" if verified else "blocked", + "The root-owned RepoPulse NPM access list has active authorization entries and requires all checks." + if verified else "The root-owned RepoPulse NPM access list could not be verified.", + )) + + by_application, by_repository = self._existing(session, plan.application_id, plan.repository) + if by_application: + status = "warning" if by_application.plan_sha256 == _canonical(plan)[1] else "blocked" + code = "enrollment_replay" if status == "warning" else "target_already_exists" + checks.append(_check(code, status, "This application identity is already enrolled.")) + else: + checks.append(_check("application_identity", "passed", "Application identity is not yet enrolled.")) + if by_repository and (not by_application or by_repository.id != by_application.id): + checks.append(_check("target_already_exists", "blocked", "Repository is already bound to a different managed application.")) + + containers = self.portainer.list_containers() + if containers.get("status") != "ok": + checks.append(_check("container_inventory_unavailable", "blocked", "Portainer inventory is unavailable for collision protection.")) + else: + names = {str(item.get("name") or "").casefold() for item in containers.get("containers", [])} + if plan.target.container_name.casefold() in names: + checks.append(_check("unmanaged_resource_collision", "blocked", "Container identity already exists and cannot be claimed by enrollment.")) + else: + checks.append(_check("container_identity", "passed", "Container identity is free.")) + + if plan.exposure: + if trust and self._allowed_hostname(plan.exposure.hostname, trust): + checks.append(_check("hostname_suffix", "passed", "Hostname is inside an allowed domain suffix.")) + else: + checks.append(_check("hostname_not_allowed", "blocked", "Hostname is outside the configured allowed suffixes.")) + hosts = self.npm.proxy_hosts() + if not hosts.get("success"): + checks.append(_check("npm_credentials_missing" if not self.npm.is_configured() else "npm_executor_unavailable", "blocked", "NPM inventory is unavailable for takeover protection.")) + else: + existing = [item for item in hosts.get("items", []) if plan.exposure.hostname in {str(value).lower() for value in item.get("domainNames", [])}] + checks.append(_check("hostname_collision", "blocked" if existing else "passed", "Hostname is already owned and cannot be taken over." if existing else "Hostname is free in NPM.")) + + if plan.authentication.mode == "native-oidc": + groups_ready = bool(trust and trust.administrator_group and trust.support_agent_group and trust.read_only_group and trust.oidc_issuer) + if not groups_ready or not self.authentik.is_configured(): + checks.append(_check("oidc_profile_unavailable", "blocked", "Configured OIDC profile or Authentik role groups are unavailable.")) + else: + assert trust is not None and plan.authentication.roles is not None + expected_roles = { + "administrator": trust.administrator_group, + "support_agent": trust.support_agent_group, + "read_only": trust.read_only_group, + } + requested_roles = plan.authentication.roles.model_dump(mode="json") + checks.append(_check( + "oidc_role_model", + "passed" if requested_roles == expected_roles else "blocked", + "Requested application roles match the root-owned Authentik role model." + if requested_roles == expected_roles + else "Requested application roles do not match the root-owned Authentik role model.", + )) + auth_status = self.authentik.test_connection() + if auth_status.get("status") != "connected": + checks.append(_check("oidc_provider", "blocked", "Authentik is unavailable.")) + else: + template = self.authentik.deployment_oidc_template_status( + role_claim=trust.oidc_role_claim + ) + if template.get("status") != "ready": + checks.append(_check("oidc_profile_unavailable", "blocked", "The server-owned Authentik OIDC template is incomplete.")) + applications = self.authentik.applications() + groups = self.authentik.groups() + if not applications.get("success") or not groups.get("success"): + checks.append(_check("oidc_provider", "blocked", "Authentik applications cannot be inspected for takeover protection.")) + elif any(str(item.get("slug") or "") == plan.application_id for item in applications.get("items", [])): + checks.append(_check("unmanaged_resource_collision", "blocked", "An existing Authentik application cannot be claimed by a new enrollment.")) + else: + checks.append(_check("oidc_provider", "passed", "Authentik is available and the application identity is free.")) + required_groups = { + trust.administrator_group if trust else "", + trust.support_agent_group if trust else "", + trust.read_only_group if trust else "", + } + found_groups = {str(item.get("name") or "") for item in groups.get("items", [])} + checks.append(_check( + "oidc_role_groups", + "passed" if required_groups.issubset(found_groups) else "blocked", + "Configured Authentik role groups are available." if required_groups.issubset(found_groups) else "One or more configured Authentik role groups are missing.", + )) + + storage = secret_storage_status() + storage_ready = _enrollment_secret_store_ready(storage) + checks.append(_check("secret_store", "passed" if storage_ready else "blocked", "Production-grade write-only secret storage is ready." if storage_ready else "Enrollment requires APPOPS_SECRET_KEY from a protected environment variable or file; the development fallback is not permitted.")) + if trust: + checks.append(_check("controller_bootstrap_secret", "passed" if has_secret(session, trust.bootstrap_secret_ref) else "blocked", "Controller bootstrap secret is available by reference." if has_secret(session, trust.bootstrap_secret_ref) else "Controller bootstrap secret reference is not configured.")) + if has_secret(session, trust.bootstrap_secret_ref): + controller = self.broker.enrollment_preflight( + self._controller_payload(plan, enrollment_id="enr-00000000000000000000"), + secret=get_secret(session, trust.bootstrap_secret_ref, touch=False) or "", + ) + code = str(controller.get("reason") or "deployment_executor_unavailable") + checks.append(_check("controller_target", "passed" if controller.get("status") in {"preflight_ok", "ready"} else "blocked", "Controller accepted the bounded enrollment preflight." if controller.get("status") in {"preflight_ok", "ready"} else f"Controller enrollment preflight is unavailable ({code}).")) + else: + checks.append(_check("controller_target", "blocked", "Controller bootstrap credential is unavailable.")) + + blockers = [item["code"] for item in checks if item["status"] == "blocked"] + warnings = [item["code"] for item in checks if item["status"] == "warning"] + planned_resources = { + "applicationId": plan.application_id, + "controllerTarget": plan.target.target_identity, + "appdataPath": plan.appdata_path, + "dataPath": plan.data_path, + "network": plan.network.docker_network, + "hostname": plan.exposure.hostname if plan.exposure else None, + "oidc": plan.authentication.mode == "native-oidc", + } + return { + "status": "ready" if not blockers else "blocked", + "blockers": blockers, + "warnings": warnings, + "checks": checks, + "plannedResources": planned_resources, + "plannedMutations": [] if blockers else ["controller_target_register", "secret_generate", *( ["reverse_proxy_ensure"] if plan.exposure else []), *( ["oidc_application_ensure"] if plan.authentication.mode == "native-oidc" else []), *( ["runtime_environment_seal"] if profile and profile["runtimeEnvironmentKeys"] else []), "exact_policy_install"], + "plannedSecretRefs": [ + _secret_ref(plan.application_id, "TARGET_HMAC"), + *( [_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET")] if plan.authentication.mode == "native-oidc" else []), + *( ["REPOPULSE_GITEA_TOKEN", _secret_ref("repopulse", "MAINTENANCE_TOKEN")] if plan.profile == "repopulse-authenticated" else []), + ], + "plannedPolicies": [f"enrollment-{plan.application_id}-deployment-apply", f"enrollment-{plan.application_id}-deployment-rollback"], + } + + def _create_enrollment(self, session: Session, plan: EnrollmentPlanV1, plan_sha256: str, trust: EnrollmentTrustConfigV1) -> Enrollment: + enrollment = Enrollment( + id=f"enr-{uuid.uuid4().hex[:20]}", + application_id=plan.application_id, + repository=plan.repository, + revision=plan.revision, + profile=plan.profile, + controller_identity=trust.controller_identity, + plan_sha256=plan_sha256, + status="planned", + target_secret_ref=_secret_ref(plan.application_id, "TARGET_HMAC"), + oidc_secret_ref=_secret_ref(plan.application_id, "OIDC_CLIENT_SECRET") if plan.authentication.mode == "native-oidc" else None, + plan_json=plan.model_dump(mode="json"), + provenance_json={"profileVersion": PROFILE_VERSION, "createdBy": "enrollment-plane/v1", "sourceRevision": plan.revision}, + ) + session.add(enrollment) + session.commit() + session.refresh(enrollment) + return enrollment + + def _create_job(self, session: Session, enrollment: Enrollment, idempotency_key: str) -> EnrollmentJob: + digest = _idempotency_digest("enroll", idempotency_key) + existing = session.exec(select(EnrollmentJob).where(EnrollmentJob.idempotency_sha256 == digest)).first() + if existing: + if existing.enrollment_id != enrollment.id: + raise EnrollmentError("idempotency key was already used for a different enrollment") + return existing + job = EnrollmentJob( + id=f"enroll-{uuid.uuid4().hex[:16]}", + enrollment_id=enrollment.id, + application_id=enrollment.application_id, + idempotency_sha256=digest, + status="queued", + stage="planned", + ) + session.add(job) + session.commit() + session.refresh(job) + return job + + @staticmethod + def serialize_enrollment(enrollment: Enrollment) -> dict[str, Any]: + plan = dict(enrollment.plan_json or {}) + exposure = plan.get("exposure") if isinstance(plan.get("exposure"), dict) else {} + authentication = plan.get("authentication") if isinstance(plan.get("authentication"), dict) else {} + return { + "id": enrollment.id, + "applicationId": enrollment.application_id, + "repository": enrollment.repository, + "revision": enrollment.revision, + "profile": enrollment.profile, + "controllerIdentity": enrollment.controller_identity, + "status": enrollment.status, + "publicUrl": f"https://{exposure.get('hostname')}" if exposure.get("hostname") else None, + "authentication": authentication.get("mode", "none"), + "resources": enrollment.resources_json or {}, + "targetSecretRef": enrollment.target_secret_ref, + "oidcSecretRef": enrollment.oidc_secret_ref, + "maintenanceSecretRef": ( + _secret_ref("repopulse", "MAINTENANCE_TOKEN") + if enrollment.profile == "repopulse-authenticated" else None + ), + "lastVerifiedAt": enrollment.last_verified_at.isoformat() if enrollment.last_verified_at else None, + "createdAt": enrollment.created_at.isoformat(), + "updatedAt": enrollment.updated_at.isoformat(), + "completedAt": enrollment.completed_at.isoformat() if enrollment.completed_at else None, + } + + @staticmethod + def serialize_job(job: EnrollmentJob, *, replay: bool = False) -> dict[str, Any]: + return { + "id": job.id, + "enrollmentId": job.enrollment_id, + "applicationId": job.application_id, + "operation": job.operation, + "status": job.status, + "stage": job.stage, + "preflight": job.preflight_json or {}, + "evidence": job.evidence_json or {}, + "result": job.result_summary, + "createdAt": job.created_at.isoformat(), + "updatedAt": job.updated_at.isoformat(), + "completedAt": job.completed_at.isoformat() if job.completed_at else None, + "idempotentReplay": replay, + } + + @staticmethod + def _checkpoint( + session: Session, + enrollment: Enrollment, + job: EnrollmentJob, + *, + stage: str, + evidence: dict[str, Any], + ) -> None: + """Durably record intent and receipts around every external boundary. + + This is deliberately conservative: after an interrupted external call, + a same-plan request is surfaced as recovery-required instead of being + replayed against NPM, Authentik or the controller without proof of its + prior effect. Values in ``evidence`` are identifiers/status only. + """ + now = utc_now() + enrollment.status = "provisioning" + enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence} + enrollment.updated_at = now + job.status = "running" + job.stage = stage + job.evidence_json = {**dict(job.evidence_json or {}), **evidence} + job.updated_at = now + session.add(enrollment) + session.add(job) + session.commit() + + def _install_policies(self, session: Session, enrollment: Enrollment, plan: EnrollmentPlanV1) -> list[str]: + provenance = { + "enrollmentId": enrollment.id, + "profileVersion": PROFILE_VERSION, + "createdBy": "enrollment-plane/v1", + "policyTemplateVersion": "exact-target/v1", + "target": plan.application_id, + "sourceRevision": plan.revision, + "createdAt": utc_now().isoformat(), + } + identifiers: list[str] = [] + for action, suffix in (("deployment.apply", "deployment-apply"), ("deployment.rollback", "deployment-rollback")): + policy_id = f"enrollment-{plan.application_id}-{suffix}" + existing = session.get(Policy, policy_id) + if existing is not None: + # Enrollment never adopts or repurposes a pre-existing policy, + # even where its visible action/target happen to match. A + # replay is handled before this method; this branch is a + # collision, not a safe recovery path. + raise EnrollmentError("exact policy identifier is already owned and cannot be adopted") + existing = Policy( + id=policy_id, + action=action, + target=plan.application_id, + voice="blocked", + manual="allowed", + risk="high", + reason="Enrollment-generated exact-target deployment policy.", + ) + existing.provenance_json = provenance + session.add(existing) + identifiers.append(policy_id) + session.commit() + return identifiers + + def _compensate( + self, + session: Session, + enrollment: Enrollment, + evidence: dict[str, Any], + ) -> dict[str, Any]: + """Reverse only resources this execution proved it created. + + A failed compensation stays visible as ``compensation_required``. It + never attempts to adopt, overwrite or delete an earlier resource. + """ + plan = EnrollmentPlanV1.model_validate(enrollment.plan_json) + outcomes: dict[str, str] = {} + oidc = evidence.get("oidc") if isinstance(evidence.get("oidc"), dict) else {} + if oidc.get("status") == "created": + remove = getattr(self.authentik, "remove_deployment_oidc_application", None) + result = remove( + application_id=oidc.get("applicationId"), + provider_id=oidc.get("providerId"), + application_slug=plan.application_id, + launch_url=plan.public_url or "", + ) if callable(remove) else {"success": False, "status": "unsupported"} + outcomes["oidc"] = str(result.get("status") or "failed") + elif oidc.get("status") == "partial_failure": + outcomes["oidc"] = "ownership_unverified" + + proxy = evidence.get("proxy") if isinstance(evidence.get("proxy"), dict) else {} + if proxy.get("status") == "created": + remove = getattr(self.npm, "remove_managed_proxy_host", None) + result = remove(host_id=proxy.get("hostId"), ownership_id=enrollment.id) if callable(remove) else {"success": False, "status": "unsupported"} + outcomes["proxy"] = str(result.get("status") or "failed") + elif proxy.get("status") == "ownership_unverified": + outcomes["proxy"] = "ownership_unverified" + + controller = evidence.get("controller") if isinstance(evidence.get("controller"), dict) else {} + if controller.get("status") in {"enrolled", "already_enrolled"}: + trust = self.get_trust_config(session) + bootstrap = get_secret(session, trust.bootstrap_secret_ref, touch=False) if trust else None + if bootstrap: + result = self.broker.unenroll_target( + {"enrollment_id": enrollment.id, "application_id": enrollment.application_id, "repository": enrollment.repository}, + secret=bootstrap, + ) + outcomes["controller"] = str(result.get("status") or "failed") + else: + outcomes["controller"] = "bootstrap_secret_unavailable" + elif controller.get("status") == "partial_failure": + outcomes["controller"] = "ownership_unverified" + + completed = all(value in {"removed", "rolled_back"} for value in outcomes.values()) + if completed and outcomes: + delete_secret(session, enrollment.target_secret_ref) + if enrollment.oidc_secret_ref: + delete_secret(session, enrollment.oidc_secret_ref) + if plan.profile == "repopulse-authenticated": + delete_secret(session, _secret_ref("repopulse", "MAINTENANCE_TOKEN")) + return {"completed": completed, "outcomes": outcomes} + + def _fail( + self, + session: Session, + enrollment: Enrollment, + job: EnrollmentJob, + *, + stage: str, + message: str, + evidence: dict[str, Any], + ) -> dict[str, Any]: + compensation = ( + self._compensate(session, enrollment, evidence) + if evidence.get("controller") + else {"completed": True, "outcomes": {}} + ) + compensation_required = not bool(compensation.get("completed")) + enrollment.status = "compensation_required" if compensation_required else "failed" + enrollment.resources_json = {**dict(enrollment.resources_json or {}), **evidence, "compensation": compensation} + enrollment.updated_at = utc_now() + enrollment.completed_at = utc_now() + job.status = "compensation_required" if compensation_required else "failed" + job.stage = stage + job.evidence_json = {**dict(job.evidence_json or {}), **evidence, "compensation": compensation} + job.result_summary = message + job.updated_at = utc_now() + job.completed_at = utc_now() + session.add(enrollment) + session.add(job) + session.commit() + log_event( + session, + source="enrollment-plane", + identity="operator", + action_id="target.enroll", + target=enrollment.application_id, + target_type="enrollment", + risk="high", + policy_decision="blocked", + reason=message, + result="failed", + execution_plan={"enrollmentId": enrollment.id, "planSha256": enrollment.plan_sha256}, + after_state={"stage": stage, "status": enrollment.status}, + ) + return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)} + + def enroll(self, session: Session, plan: EnrollmentPlanV1, *, idempotency_key: str) -> dict[str, Any]: + _, plan_digest = _canonical(plan) + existing, repository_existing = self._existing(session, plan.application_id, plan.repository) + if existing and existing.plan_sha256 == plan_digest: + prior = session.exec( + select(EnrollmentJob).where(EnrollmentJob.enrollment_id == existing.id).order_by(EnrollmentJob.created_at.desc()) + ).first() + if prior is None or prior.status in _TERMINAL: + return { + "enrollment": self.serialize_enrollment(existing), + "job": self.serialize_job(prior, replay=True) if prior else None, + "idempotentReplay": True, + } + # A process may have stopped after an external request was sent but + # before its response was durably checkpointed. Do not infer the + # effect and do not send the request again: retain the exact intent + # and require an explicit reconciliation flow. + existing.status = "recovery_required" + existing.updated_at = utc_now() + prior.status = "recovery_required" + prior.result_summary = "Enrollment stopped during an external stage; reconcile recorded intent and receipts before retrying." + prior.updated_at = utc_now() + session.add(existing) + session.add(prior) + session.commit() + return { + "enrollment": self.serialize_enrollment(existing), + "job": self.serialize_job(prior), + "recoveryRequired": True, + } + if existing or repository_existing: + raise EnrollmentError("application identity or repository is already enrolled") + + trust = self.get_trust_config(session) + if trust is None: + raise EnrollmentError("enrollment trust boundaries are not configured") + enrollment = self._create_enrollment(session, plan, plan_digest, trust) + job = self._create_job(session, enrollment, idempotency_key) + if job.status != "queued": + return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job, replay=True)} + + preflight = self.preflight(session, plan) + job.preflight_json = preflight + job.updated_at = utc_now() + session.add(job) + session.commit() + if preflight["status"] != "ready": + enrollment.status = "preflight_failed" + enrollment.completed_at = utc_now() + enrollment.updated_at = utc_now() + job.status = "preflight_failed" + job.stage = "preflight" + job.result_summary = "Enrollment preflight was blocked." + job.completed_at = utc_now() + session.add(enrollment) + session.add(job) + session.commit() + return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)} + + repopulse_gitea_token = ( + get_secret(session, "REPOPULSE_GITEA_TOKEN", touch=False) + if plan.profile == "repopulse-authenticated" else None + ) + if plan.profile == "repopulse-authenticated" and not repopulse_gitea_token: + return self._fail( + session, enrollment, job, stage="preflight", + message="The existing encrypted RepoPulse Gitea token became unavailable after preflight.", + evidence={}, + ) + + target_secret = _credential() + set_secret( + session, + enrollment.target_secret_ref, + target_secret, + used_by=[f"enrollment:{enrollment.id}", "deployment-controller"], + metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(target_secret), "rotationState": "active"}, + ) + oidc_secret: str | None = None + if enrollment.oidc_secret_ref: + oidc_secret = _credential() + set_secret( + session, + enrollment.oidc_secret_ref, + oidc_secret, + used_by=[f"enrollment:{enrollment.id}", "authentik", "deployment-controller"], + metadata={"enrollmentId": enrollment.id, "credentialFingerprint": _fingerprint(oidc_secret), "rotationState": "active"}, + ) + repopulse_maintenance_token: str | None = None + if plan.profile == "repopulse-authenticated": + repopulse_maintenance_token = _credential() + set_secret( + session, + _secret_ref("repopulse", "MAINTENANCE_TOKEN"), + repopulse_maintenance_token, + used_by=[f"enrollment:{enrollment.id}", "repopulse-maintenance", "deployment-controller"], + metadata={ + "enrollmentId": enrollment.id, + "credentialFingerprint": _fingerprint(repopulse_maintenance_token), + "rotationState": "active", + }, + ) + bootstrap_secret = get_secret(session, trust.bootstrap_secret_ref, touch=False) + if not bootstrap_secret: + return self._fail(session, enrollment, job, stage="creating_secrets", message="Controller bootstrap secret disappeared after preflight.", evidence={}) + evidence: dict[str, Any] = { + "intent": { + "controller": { + "enrollmentId": enrollment.id, + "applicationId": plan.application_id, + "repository": plan.repository, + } + } + } + self._checkpoint( + session, enrollment, job, stage="registering_target", evidence=evidence + ) + controller_result = self.broker.enroll_target( + self._controller_payload(plan, enrollment_id=enrollment.id, target_credential=target_secret), + secret=bootstrap_secret, + idempotency_key=idempotency_key, + ) + evidence["controller"] = { + key: controller_result[key] for key in _CONTROLLER_EVIDENCE if key in controller_result + } + self._checkpoint( + session, enrollment, job, stage="registering_target", evidence=evidence + ) + if controller_result.get("status") not in {"enrolled", "already_enrolled"}: + return self._fail(session, enrollment, job, stage="registering_target", message="Controller rejected the bounded target enrollment.", evidence=evidence) + + if plan.exposure: + evidence["intent"] = { + **dict(evidence.get("intent") or {}), + "proxy": {"hostname": plan.exposure.hostname, "ownershipId": enrollment.id}, + } + self._checkpoint( + session, enrollment, job, stage="provisioning_proxy", evidence=evidence + ) + proxy_arguments = { + "hostname": plan.exposure.hostname, + "forward_host": plan.application_id, + "forward_port": plan.network.internal_port, + "certificate_mode": "managed", + "force_ssl": True, + "http2": True, + "upload_limit_mb": 1024 if plan.exposure.upload_profile == "large-upload" else 128, + "read_timeout_seconds": 900 if plan.exposure.timeout_profile == "long-running" else 120, + "ownership_id": enrollment.id, + } + if plan.profile == "repopulse-authenticated": + access_list_id = trust.repopulse_proxy_access_list_id if trust else None + if not access_list_id or access_list_id <= 0: + return self._fail( + session, enrollment, job, + stage="provisioning_proxy", + message="RepoPulse requires a verified, positive NPM access-list ID.", + evidence=evidence, + ) + proxy_arguments["access_list_id"] = access_list_id + proxy_result = self.npm.ensure_managed_proxy_host( + **proxy_arguments, + ) + evidence["proxy"] = {key: proxy_result[key] for key in _PROXY_EVIDENCE if key in proxy_result} + self._checkpoint( + session, enrollment, job, stage="provisioning_proxy", evidence=evidence + ) + if not proxy_result.get("success") or proxy_result.get("status") != "created": + return self._fail(session, enrollment, job, stage="provisioning_proxy", message="Managed reverse-proxy provisioning was not verified.", evidence=evidence) + + profile = profile_contract(plan.profile) + runtime_values: dict[str, str] = {} + if plan.public_url and profile and "APPOPS_PUBLIC_URL" in profile["runtimeEnvironmentKeys"]: + runtime_values["APPOPS_PUBLIC_URL"] = plan.public_url + if plan.authentication.mode == "native-oidc": + assert oidc_secret is not None and enrollment.oidc_secret_ref is not None + evidence["intent"] = { + **dict(evidence.get("intent") or {}), + "oidc": {"applicationSlug": plan.application_id, "launchUrl": plan.public_url}, + } + self._checkpoint( + session, enrollment, job, stage="provisioning_identity", evidence=evidence + ) + oidc_result = self.authentik.ensure_deployment_oidc_application( + application_slug=plan.application_id, + launch_url=plan.public_url or "", + redirect_uri=f"{plan.public_url}{plan.authentication.redirect_path}", + client_secret=oidc_secret, + administrator_group=trust.administrator_group or "", + support_agent_group=trust.support_agent_group or "", + read_only_group=trust.read_only_group or "", + ) + evidence["oidc"] = {key: oidc_result[key] for key in _OIDC_EVIDENCE if key in oidc_result} + self._checkpoint( + session, enrollment, job, stage="provisioning_identity", evidence=evidence + ) + if not oidc_result.get("success") or oidc_result.get("status") != "created": + return self._fail(session, enrollment, job, stage="provisioning_identity", message="Bounded OIDC application provisioning was not verified.", evidence=evidence) + runtime_values.update({ + "OIDC_CLIENT_ID": plan.application_id, + "OIDC_CLIENT_SECRET": oidc_secret, + "OIDC_ISSUER": trust.oidc_issuer or "", + "OIDC_ROLE_CLAIM": trust.oidc_role_claim, + }) + if plan.profile == "repopulse-authenticated": + assert repopulse_gitea_token is not None and repopulse_maintenance_token is not None + runtime_values.update({ + "REPOPULSE_GITEA_URL": "https://gitea.example.com", + "REPOPULSE_GITEA_TOKEN": repopulse_gitea_token, + "REPOPULSE_MAINTENANCE_TOKEN": repopulse_maintenance_token, + }) + + if profile and profile["runtimeEnvironmentKeys"]: + evidence["intent"] = { + **dict(evidence.get("intent") or {}), + "runtime": {"applicationId": plan.application_id, "keys": list(runtime_values)}, + } + self._checkpoint( + session, enrollment, job, stage="sealing_runtime", evidence=evidence + ) + runtime_result = self.broker.configure_enrollment_runtime( + { + "enrollment_id": enrollment.id, + "application_id": plan.application_id, + "repository": plan.repository, + "values": runtime_values, + }, + secret=target_secret, + ) + evidence["runtime"] = {key: runtime_result[key] for key in ("status", "enrollmentId", "applicationId") if key in runtime_result} + self._checkpoint( + session, enrollment, job, stage="sealing_runtime", evidence=evidence + ) + if runtime_result.get("status") not in {"configured", "already_configured"}: + return self._fail(session, enrollment, job, stage="sealing_runtime", message="Controller could not seal profile-owned runtime values.", evidence=evidence) + + evidence["intent"] = { + **dict(evidence.get("intent") or {}), + "policies": [ + f"enrollment-{plan.application_id}-deployment-apply", + f"enrollment-{plan.application_id}-deployment-rollback", + ], + } + self._checkpoint( + session, enrollment, job, stage="installing_policy", evidence=evidence + ) + try: + policy_ids = self._install_policies(session, enrollment, plan) + except EnrollmentError as exc: + return self._fail(session, enrollment, job, stage="installing_policy", message=str(exc), evidence=evidence) + evidence["policies"] = policy_ids + enrollment.status = "ready" + enrollment.resources_json = { + "appdataPath": plan.appdata_path, + "dataPath": plan.data_path, + "network": plan.network.docker_network, + "hostname": plan.exposure.hostname if plan.exposure else None, + **evidence, + } + enrollment.last_verified_at = utc_now() + enrollment.updated_at = utc_now() + enrollment.completed_at = utc_now() + job.status = "ready" + job.stage = "ready" + job.evidence_json = evidence + job.result_summary = "Bounded target, profile resources and exact policies are ready for deployment." + job.updated_at = utc_now() + job.completed_at = utc_now() + session.add(enrollment) + session.add(job) + session.commit() + log_event( + session, + source="enrollment-plane", + identity="operator", + action_id="target.enroll", + target=plan.application_id, + target_type="enrollment", + risk="high", + policy_decision="allowed", + reason="Bounded application identity was enrolled from an autonomous profile.", + result="success", + execution_plan={"enrollmentId": enrollment.id, "planSha256": plan_digest, "profile": plan.profile}, + after_state={"status": "ready", "policyIds": policy_ids}, + ) + return {"enrollment": self.serialize_enrollment(enrollment), "job": self.serialize_job(job)} + + def list_enrollments(self, session: Session, *, limit: int = 100) -> list[dict[str, Any]]: + rows = session.exec(select(Enrollment).order_by(Enrollment.created_at.desc()).limit(max(1, min(limit, 250)))).all() + return [self.serialize_enrollment(row) for row in rows] + + def get_enrollment(self, session: Session, enrollment_id: str) -> dict[str, Any]: + row = session.get(Enrollment, enrollment_id) + if row is None: + raise EnrollmentError("enrollment was not found") + jobs = session.exec( + select(EnrollmentJob).where(EnrollmentJob.enrollment_id == enrollment_id).order_by(EnrollmentJob.created_at.desc()).limit(20) + ).all() + return {"enrollment": self.serialize_enrollment(row), "jobs": [self.serialize_job(job) for job in jobs]} diff --git a/backend/app/services/enrollment_profiles.py b/backend/app/services/enrollment_profiles.py new file mode 100644 index 0000000..950beae --- /dev/null +++ b/backend/app/services/enrollment_profiles.py @@ -0,0 +1,60 @@ +"""Server-owned enrollment profiles and bounded runtime environment contracts.""" + +from __future__ import annotations + +from typing import Any + +PROFILE_VERSION = "enrollment-profile/v1" + +PROFILES: dict[str, dict[str, Any]] = { + "internal-webapp": { + "public": True, + "authentication": "none", + "runtimeEnvironmentKeys": ("APPOPS_PUBLIC_URL",), + "requiresNpm": True, + "requiresOidc": False, + }, + "internal-webapp-oidc": { + "public": True, + "authentication": "native-oidc", + "runtimeEnvironmentKeys": ( + "APPOPS_PUBLIC_URL", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_ISSUER", + "OIDC_ROLE_CLAIM", + ), + "requiresNpm": True, + "requiresOidc": True, + }, + "internal-service": { + "public": False, + "authentication": "none", + "runtimeEnvironmentKeys": (), + "requiresNpm": False, + "requiresOidc": False, + }, + "repopulse-authenticated": { + "public": True, + "authentication": "npm-access-list+maintenance-token", + "runtimeEnvironmentKeys": ( + "REPOPULSE_GITEA_URL", + "REPOPULSE_GITEA_TOKEN", + "REPOPULSE_MAINTENANCE_TOKEN", + ), + "requiresNpm": True, + "requiresOidc": False, + }, +} + + +def profile_contract(name: str) -> dict[str, Any] | None: + profile = PROFILES.get(name) + return dict(profile) if profile else None + + +def profile_summary(name: str) -> dict[str, Any] | None: + profile = profile_contract(name) + if profile is None: + return None + return {"name": name, "version": PROFILE_VERSION, **profile} diff --git a/backend/app/services/gitea_project_auto_registration.py b/backend/app/services/gitea_project_auto_registration.py new file mode 100644 index 0000000..7c797b2 --- /dev/null +++ b/backend/app/services/gitea_project_auto_registration.py @@ -0,0 +1,611 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from typing import Any +from urllib.parse import quote, urlsplit, urlunsplit + +import httpx +import yaml +from sqlmodel import Session, select + +from ..core.config import settings +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import AppSetting, Policy +from .deployment_target_registry import DeploymentTargetRegistry + +_CATALOG_KEY = "deployment:gitea-project-catalog:v1" +_OWNER = "Jens" +_DESCRIPTOR = ".itworx/deployment-target.json" +_COMPOSE_CANDIDATES = ( + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +) +# Must remain identical to DeploymentPlanV1.application_id. +_APP_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$") +_TRANSIENT_STATES = {"gitea_unavailable", "controller_unavailable", "registration_error"} +_RETRY_STATES = _TRANSIENT_STATES | {"controller_conflict", "policy_conflict"} +_DEPLOYMENT_ACTIONS = ("deployment.apply", "deployment.rollback", "deployment.runnerless.provision") + + +class GiteaProjectAutoRegistration: + """Keep AppOps' project catalog aligned with the owned Gitea portfolio. + + Every repository is recorded in the catalog. A repository becomes an + executable deployment target only when an exact revision exposes a bounded + Compose contract compatible with DeploymentPlanV1. Exact deployment + policies are created only after the controller target has been registered + successfully. Existing targets and policies are never overwritten. + """ + + def __init__( + self, + *, + target_registry: DeploymentTargetRegistry | None = None, + owner: str = _OWNER, + ) -> None: + self.target_registry = target_registry or DeploymentTargetRegistry() + self.owner = owner + + @staticmethod + def _base_url() -> str: + raw = str(settings.apis.get("GITEA_URL") or "").strip().rstrip("/") + parsed = urlsplit(raw) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username: + raise RuntimeError("invalid_gitea_url") + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) + + @staticmethod + def _token() -> str: + token = str(settings.apis.get("GITEA_ADMIN_TOKEN") or "").strip() + if not token: + raise RuntimeError("missing_gitea_admin_token") + return token + + def _client(self) -> httpx.Client: + timeout = max(2.0, min(20.0, float(settings.apis.get("GITEA_TIMEOUT_SECONDS") or 8))) + return httpx.Client( + timeout=timeout, + verify=bool(settings.apis.get("GITEA_VERIFY_TLS", settings.verify_tls)), + headers={ + "Authorization": f"token {self._token()}", + "Accept": "application/json", + }, + ) + + def _get( + self, + client: httpx.Client, + path: str, + *, + params: dict[str, Any] | None = None, + allow_not_found: bool = False, + ) -> Any: + try: + response = client.get(f"{self._base_url()}{path}", params=params) + except (httpx.RequestError, ValueError) as exc: + raise RuntimeError(redact_message(str(exc))) from exc + if allow_not_found and response.status_code == 404: + return None + if response.status_code in {401, 403}: + raise RuntimeError("gitea_admin_access_denied") + if response.status_code >= 400: + raise RuntimeError(f"gitea_project_catalog_http_{response.status_code}") + try: + return response.json() + except ValueError as exc: + raise RuntimeError("gitea_project_catalog_invalid_response") from exc + + def _repositories(self, client: httpx.Client) -> list[dict[str, Any]]: + owner = quote(self.owner, safe="") + repositories: list[dict[str, Any]] = [] + for page in range(1, 11): + payload = self._get( + client, + f"/api/v1/users/{owner}/repos", + params={"page": page, "limit": 50}, + ) + if not isinstance(payload, list): + raise RuntimeError("gitea_project_catalog_invalid_repository_list") + items = [item for item in payload if isinstance(item, dict)] + repositories.extend(items) + if len(items) < 50: + break + return repositories[:500] + + def _branch_head( + self, + client: httpx.Client, + *, + repository_name: str, + branch: str, + ) -> str: + owner = quote(self.owner, safe="") + repository = quote(repository_name, safe="") + branch_ref = quote(branch, safe="") + payload = self._get( + client, + f"/api/v1/repos/{owner}/{repository}/branches/{branch_ref}", + ) + commit = payload.get("commit") if isinstance(payload, dict) else None + revision = "" + if isinstance(commit, dict): + revision = str(commit.get("id") or commit.get("sha") or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise RuntimeError("gitea_default_branch_head_unavailable") + return revision + + def _contents( + self, + client: httpx.Client, + *, + repository_name: str, + revision: str, + path: str = "", + allow_not_found: bool = False, + ) -> Any: + owner = quote(self.owner, safe="") + repository = quote(repository_name, safe="") + suffix = f"/{quote(path, safe='/')}" if path else "" + return self._get( + client, + f"/api/v1/repos/{owner}/{repository}/contents{suffix}", + params={"ref": revision}, + allow_not_found=allow_not_found, + ) + + def _read_text( + self, + client: httpx.Client, + *, + repository_name: str, + revision: str, + path: str, + allow_not_found: bool = False, + ) -> str | None: + payload = self._contents( + client, + repository_name=repository_name, + revision=revision, + path=path, + allow_not_found=allow_not_found, + ) + if payload is None: + return None + if not isinstance(payload, dict): + raise RuntimeError("gitea_project_file_invalid_response") + if str(payload.get("encoding") or "").lower() != "base64": + raise RuntimeError("gitea_project_file_encoding_unsupported") + content = str(payload.get("content") or "").replace("\n", "") + try: + raw = base64.b64decode(content, validate=True) + return raw.decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise RuntimeError("gitea_project_file_decode_failed") from exc + + @staticmethod + def _application_id(repository_name: str) -> str: + raw = repository_name.lower() + value = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")[:63] + if not _APP_ID.fullmatch(value): + raise ValueError("repository name cannot be converted to a DeploymentPlanV1 application id") + return value + + @staticmethod + def _safe_descriptor(payload: Any, repository_name: str) -> dict[str, Any]: + if payload is None: + return {} + if not isinstance(payload, dict): + raise ValueError("deployment descriptor must contain a JSON object") + allowed = { + "apiVersion", + "applicationId", + "composeFile", + "composeProject", + "healthTimeout", + "verifyOciRevision", + } + unknown = set(payload) - allowed + if unknown: + raise ValueError("deployment descriptor contains unsupported fields") + if payload.get("apiVersion") not in {None, "itworx.deployment-target/v1"}: + raise ValueError("deployment descriptor apiVersion is unsupported") + application_id = str( + payload.get("applicationId") + or GiteaProjectAutoRegistration._application_id(repository_name) + ).strip().lower() + if not _APP_ID.fullmatch(application_id): + raise ValueError("deployment descriptor applicationId is invalid") + compose_file = str(payload.get("composeFile") or "").strip() + compose_project = str(payload.get("composeProject") or application_id).strip().lower() + if compose_project != application_id: + raise ValueError("deployment descriptor composeProject must equal applicationId") + timeout = int(payload.get("healthTimeout") or 180) + return { + "applicationId": application_id, + "composeFile": compose_file, + "composeProject": compose_project, + "healthTimeout": timeout, + "verifyOciRevision": bool(payload.get("verifyOciRevision", False)), + } + + def _compose_contract( + self, + client: httpx.Client, + *, + repository_name: str, + revision: str, + ) -> dict[str, Any] | None: + root = self._contents( + client, + repository_name=repository_name, + revision=revision, + ) + if not isinstance(root, list): + raise RuntimeError("gitea_project_root_invalid_response") + names = { + str(item.get("name") or "") + for item in root + if isinstance(item, dict) + } + + descriptor: dict[str, Any] = {} + descriptor_present = False + if ".itworx" in names: + raw_descriptor = self._read_text( + client, + repository_name=repository_name, + revision=revision, + path=_DESCRIPTOR, + allow_not_found=True, + ) + if raw_descriptor is not None: + descriptor_present = True + try: + descriptor = self._safe_descriptor(json.loads(raw_descriptor), repository_name) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise ValueError(f"invalid deployment descriptor: {exc}") from exc + + compose_file = str(descriptor.get("composeFile") or "") + if not compose_file: + compose_file = next((candidate for candidate in _COMPOSE_CANDIDATES if candidate in names), "") + if not compose_file: + return None + + raw_compose = self._read_text( + client, + repository_name=repository_name, + revision=revision, + path=compose_file, + ) + if raw_compose is None: + raise ValueError("configured Compose file is unavailable") + try: + compose = yaml.safe_load(raw_compose) + except yaml.YAMLError as exc: + raise ValueError("Compose file is not valid YAML") from exc + services = compose.get("services") if isinstance(compose, dict) else None + if not isinstance(services, dict) or not services: + raise ValueError("Compose file does not define services") + if len(services) > 8: + if descriptor_present: + raise ValueError("deployment descriptor exceeds DeploymentPlanV1's eight-service bound") + return None + + service_names = [str(name).strip() for name in services] + if any(not name for name in service_names): + raise ValueError("Compose service name is invalid") + + if descriptor_present: + application_id = str(descriptor["applicationId"]) + if application_id not in service_names: + raise ValueError("deployment descriptor applicationId must name a Compose service") + else: + repository_id = self._application_id(repository_name) + compose_name = str(compose.get("name") or "").strip().lower() if isinstance(compose, dict) else "" + candidates: list[str] = [] + for candidate in (repository_id, compose_name): + if candidate and _APP_ID.fullmatch(candidate) and candidate in service_names: + if candidate not in candidates: + candidates.append(candidate) + if len(service_names) == 1 and _APP_ID.fullmatch(service_names[0]): + if service_names[0] not in candidates: + candidates.append(service_names[0]) + if len(candidates) != 1: + # The repository remains cataloged, but deployment requires an + # explicit bounded descriptor to select its primary service. + return None + application_id = candidates[0] + + compose_project = application_id + containers: list[str] = [] + for service_name, raw_service in services.items(): + service = raw_service if isinstance(raw_service, dict) else {} + explicit_name = str(service.get("container_name") or "").strip() + container = explicit_name or f"{compose_project}-{service_name}-1" + containers.append(container) + + return { + "applicationId": application_id, + "composeFile": compose_file, + "composeProject": compose_project, + "containers": containers, + "serviceNames": service_names, + "healthTimeout": int(descriptor.get("healthTimeout") or 180), + "verifyOciRevision": bool(descriptor.get("verifyOciRevision", False)), + } + + @staticmethod + def _policy_id(application_id: str, action: str) -> str: + digest = hashlib.sha256(f"{action}:{application_id}".encode("utf-8")).hexdigest()[:20] + suffix = action.rsplit(".", 1)[-1] + return f"gitea-auto-{application_id[:32]}-{suffix}-{digest}" + + @staticmethod + def _existing_exact_policy_ids( + session: Session, + *, + application_id: str, + ) -> tuple[bool, list[str]]: + identifiers: list[str] = [] + for action in _DEPLOYMENT_ACTIONS: + existing = session.exec( + select(Policy).where( + (Policy.action == action) & (Policy.target == application_id) + ) + ).first() + if existing is None or existing.manual != "allowed": + return False, [] + identifiers.append(existing.id) + return True, identifiers + + @staticmethod + def _ensure_exact_policies( + session: Session, + *, + application_id: str, + repository: str, + revision: str, + ) -> tuple[bool, list[str], int]: + existing_by_action: dict[str, Policy | None] = {} + for action in _DEPLOYMENT_ACTIONS: + existing = session.exec( + select(Policy).where( + (Policy.action == action) & (Policy.target == application_id) + ) + ).first() + if existing is not None and existing.manual != "allowed": + return False, [], 0 + existing_by_action[action] = existing + + identifiers: list[str] = [] + created = 0 + for action in _DEPLOYMENT_ACTIONS: + existing = existing_by_action[action] + if existing is not None: + identifiers.append(existing.id) + continue + policy_id = GiteaProjectAutoRegistration._policy_id(application_id, action) + row = Policy( + id=policy_id, + action=action, + target=application_id, + voice="blocked", + manual="allowed", + risk="high", + reason="Gitea auto-registration generated exact-target deployment policy.", + provenance_json={ + "createdBy": "gitea-project-auto-registration/v1", + "repository": repository, + "sourceRevision": revision, + "createdAt": utc_now().isoformat(), + "policyTemplateVersion": "exact-target/v1", + }, + ) + session.add(row) + identifiers.append(policy_id) + created += 1 + session.commit() + return True, identifiers, created + + @staticmethod + def catalog(session: Session) -> dict[str, Any]: + row = session.get(AppSetting, _CATALOG_KEY) + if row is None or row.is_secret: + return { + "status": "never_synced", + "owner": _OWNER, + "projects": [], + "summary": {"total": 0}, + } + return dict(row.value_json or {}) + + @staticmethod + def _marker(repo: dict[str, Any]) -> dict[str, Any]: + return { + "updatedAt": str(repo.get("updated_at") or ""), + "defaultBranch": str(repo.get("default_branch") or ""), + "archived": bool(repo.get("archived")), + "empty": bool(repo.get("empty")), + "private": bool(repo.get("private")), + } + + def sync(self, session: Session, *, force: bool = False) -> dict[str, Any]: + previous = self.catalog(session) + previous_items = { + str(item.get("repository") or ""): item + for item in previous.get("projects", []) + if isinstance(item, dict) + } + try: + client = self._client() + with client: + repositories = self._repositories(client) + projects: list[dict[str, Any]] = [] + controller_registered = 0 + policies_created = 0 + changed = 0 + for repo in sorted( + repositories, + key=lambda item: str(item.get("full_name") or item.get("name") or "").casefold(), + ): + name = str(repo.get("name") or "").strip() + if not name: + continue + repository = f"jens/{name.lower()}" + marker = self._marker(repo) + prior = previous_items.get(repository) + if ( + not force + and prior is not None + and all(prior.get(key) == value for key, value in marker.items()) + and str(prior.get("state") or "") not in _RETRY_STATES + ): + projects.append(dict(prior)) + continue + + changed += 1 + item: dict[str, Any] = { + "repository": repository, + "name": name, + **marker, + "state": "known", + "deploymentRegistered": False, + "policyIds": [], + } + if marker["archived"]: + item["state"] = "archived" + projects.append(item) + continue + branch = str(marker["defaultBranch"] or "").strip() + if marker["empty"] or not branch: + item["state"] = "empty" + projects.append(item) + continue + + try: + revision = self._branch_head( + client, + repository_name=name, + branch=branch, + ) + item["revision"] = revision + contract = self._compose_contract( + client, + repository_name=name, + revision=revision, + ) + if contract is None: + item["state"] = "known_unconfigured" + projects.append(item) + continue + application_id = str(contract["applicationId"]) + item.update( + { + "applicationId": application_id, + "composeFile": contract["composeFile"], + "composeProject": contract["composeProject"], + "containers": list(contract["containers"]), + "serviceNames": list(contract["serviceNames"]), + } + ) + result = self.target_registry.register_compose_target( + repository, + revision, + branch=branch, + compose_file=str(contract["composeFile"]), + compose_project=str(contract["composeProject"]), + containers=list(contract["containers"]), + health_timeout=int(contract["healthTimeout"]), + verify_oci_revision=bool(contract["verifyOciRevision"]), + ) + result_status = str(result.get("status") or "error") + if result_status == "registered": + policy_ok, policy_ids, created = self._ensure_exact_policies( + session, + application_id=application_id, + repository=repository, + revision=revision, + ) + policies_created += created + if policy_ok: + item["state"] = "registered" + item["deploymentRegistered"] = True + item["policyIds"] = policy_ids + controller_registered += 1 + else: + item["state"] = "policy_conflict" + elif result_status == "conflict": + policy_ok, policy_ids = self._existing_exact_policy_ids( + session, + application_id=application_id, + ) + if policy_ok: + item["state"] = "registered_existing" + item["deploymentRegistered"] = True + item["policyIds"] = policy_ids + else: + item["state"] = "controller_conflict" + item["reason"] = str(result.get("reason") or "")[:240] + elif result_status in {"controller_unavailable", "connector_unavailable"}: + item["state"] = "controller_unavailable" + item["reason"] = str(result.get("reason") or "")[:240] + else: + item["state"] = "registration_error" + item["reason"] = str(result.get("reason") or result_status)[:240] + except (RuntimeError, TypeError, ValueError, yaml.YAMLError) as exc: + item["state"] = "registration_error" + item["reason"] = redact_message(str(exc))[:240] + projects.append(item) + except (RuntimeError, ValueError) as exc: + return { + **previous, + "status": "gitea_unavailable", + "reason": redact_message(str(exc))[:240], + } + + summary = { + "total": len(projects), + "registered": sum( + str(item.get("state")) in {"registered", "registered_existing"} + for item in projects + ), + "knownUnconfigured": sum( + str(item.get("state")) == "known_unconfigured" for item in projects + ), + "archived": sum(str(item.get("state")) == "archived" for item in projects), + "empty": sum(str(item.get("state")) == "empty" for item in projects), + "conflicts": sum( + str(item.get("state")) in {"controller_conflict", "policy_conflict"} + for item in projects + ), + "errors": sum( + str(item.get("state")) in _TRANSIENT_STATES for item in projects + ), + "changedThisSync": changed, + "controllerTargetsRegisteredThisSync": controller_registered, + "policiesCreatedThisSync": policies_created, + } + value = { + "status": "ok", + "owner": self.owner, + "syncedAt": utc_now().isoformat(), + "automatic": True, + "projects": projects, + "summary": summary, + } + row = session.get(AppSetting, _CATALOG_KEY) or AppSetting( + key=_CATALOG_KEY, + is_secret=False, + ) + row.value_json = value + row.is_secret = False + row.updated_at = utc_now() + session.add(row) + session.commit() + return value diff --git a/backend/app/services/healthcheck_service.py b/backend/app/services/healthcheck_service.py new file mode 100644 index 0000000..0e07e2e --- /dev/null +++ b/backend/app/services/healthcheck_service.py @@ -0,0 +1,3 @@ + +def run_healthcheck(target): + return {'target': target, 'healthy': True} diff --git a/backend/app/services/home_assistant_voice_readiness.py b/backend/app/services/home_assistant_voice_readiness.py new file mode 100644 index 0000000..0355ea1 --- /dev/null +++ b/backend/app/services/home_assistant_voice_readiness.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import Any + +import httpx +from sqlmodel import Session + +from ..core.config import settings +from ..core.time import utc_now +from .secret_store import get_secret +from .voice_catalog import list_commands + + +def _timeout_seconds() -> float: + try: + return max(2.0, min(20.0, float(settings.apis.get('HOME_ASSISTANT_TIMEOUT_SECONDS') or 8))) + except (TypeError, ValueError): + return 8.0 + + +def _get_json(url: str, token: str, path: str, verify_tls: bool) -> tuple[Any, str, int | None]: + try: + with httpx.Client(timeout=_timeout_seconds(), verify=verify_tls) as client: + response = client.get( + f'{url.rstrip("/")}{path}', + headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json', + }, + ) + except httpx.RequestError: + return None, 'unreachable', None + + if response.status_code in {401, 403}: + return None, 'auth_failed', response.status_code + if response.status_code >= 400: + return None, 'error', response.status_code + try: + return response.json(), 'ok', response.status_code + except ValueError: + return None, 'invalid_response', response.status_code + + +def _step(step_id: str, label: str, status: str, detail: str) -> dict[str, str]: + return {'id': step_id, 'label': label, 'status': status, 'detail': detail} + + +def build_readiness(session: Session) -> dict[str, Any]: + url = str(settings.apis.get('HOME_ASSISTANT_URL') or '').strip().rstrip('/') + token = get_secret(session, 'HOME_ASSISTANT_TOKEN', touch=False) or str(settings.apis.get('HOME_ASSISTANT_TOKEN') or '').strip() + verify_tls = bool(settings.apis.get('HOME_ASSISTANT_VERIFY_TLS', False)) + commands = list_commands(session) + expected_scripts = sorted({ + f'script.{command["homeAssistantScriptName"]}' + for command in commands + if command.get('homeAssistantScriptName') + }) + core_script = 'script.appops_check_setup' + + if not url or not token: + missing = [ + name for name, value in ( + ('HOME_ASSISTANT_URL', url), + ('HOME_ASSISTANT_TOKEN', token), + ) + if not value + ] + return { + 'status': 'missing_config', + 'operational': False, + 'apiReachable': False, + 'homeAssistantUrl': url, + 'missing': missing, + 'expectedScriptCount': len(expected_scripts), + 'detectedScriptCount': 0, + 'detectedScripts': [], + 'missingScripts': expected_scripts, + 'conversationAvailable': False, + 'geminiConversationAvailable': False, + 'llmExposureStatus': 'manual_verification_required', + 'steps': [ + _step('connector', 'Home Assistant connector', 'warning', f'Missing: {", ".join(missing)}'), + ], + 'nextActions': [ + { + 'id': 'configure_connector', + 'label': 'Configure Home Assistant connector', + 'href': '/setup?step=connectors&connector=home_assistant', + }, + ], + 'lastCheckedAt': utc_now().isoformat(), + } + + config_payload, config_status, config_code = _get_json(url, token, '/api/config', verify_tls) + states_payload, states_status, states_code = _get_json(url, token, '/api/states', verify_tls) + api_status = config_status if config_status != 'ok' else states_status + if api_status != 'ok': + return { + 'status': api_status, + 'operational': False, + 'apiReachable': False, + 'homeAssistantUrl': url, + 'missing': [], + 'statusCode': config_code or states_code, + 'expectedScriptCount': len(expected_scripts), + 'detectedScriptCount': 0, + 'detectedScripts': [], + 'missingScripts': expected_scripts, + 'conversationAvailable': False, + 'geminiConversationAvailable': False, + 'llmExposureStatus': 'manual_verification_required', + 'steps': [ + _step('connector', 'Home Assistant connector', 'complete', 'URL and token are stored.'), + _step('api', 'Home Assistant API', 'warning', f'Live API check returned {api_status}.'), + ], + 'nextActions': [ + { + 'id': 'test_connector', + 'label': 'Review Home Assistant connector', + 'href': '/setup?step=connectors&connector=home_assistant', + }, + ], + 'lastCheckedAt': utc_now().isoformat(), + } + + components = set() + if isinstance(config_payload, dict) and isinstance(config_payload.get('components'), list): + components = {str(item) for item in config_payload['components']} + entity_ids = { + str(item.get('entity_id')) + for item in states_payload + if isinstance(states_payload, list) and isinstance(item, dict) and item.get('entity_id') + } + detected_scripts = sorted(entity_id for entity_id in expected_scripts if entity_id in entity_ids) + missing_scripts = sorted(set(expected_scripts) - set(detected_scripts)) + conversation_available = 'conversation' in components + gemini_available = 'google_generative_ai_conversation' in components + core_script_available = core_script in entity_ids + operational = core_script_available and conversation_available and gemini_available + coverage = int((len(detected_scripts) / len(expected_scripts)) * 100) if expected_scripts else 100 + + steps = [ + _step('connector', 'Home Assistant connector', 'complete', 'Stored configuration is available.'), + _step('api', 'Home Assistant API', 'complete', 'Authenticated API requests succeeded.'), + _step( + 'scripts', + 'AppOps scripts', + 'complete' if not missing_scripts else ('partial' if detected_scripts else 'warning'), + f'{len(detected_scripts)} of {len(expected_scripts)} allowlisted scripts detected.', + ), + _step( + 'conversation', + 'Home Assistant Conversation', + 'complete' if conversation_available else 'warning', + 'Conversation component is loaded.' if conversation_available else 'Conversation component was not reported by Home Assistant.', + ), + _step( + 'gemini', + 'Google Gemini Conversation', + 'complete' if gemini_available else 'warning', + 'Google Gemini Conversation integration is loaded.' if gemini_available else 'Google Gemini Conversation integration was not reported by Home Assistant.', + ), + _step( + 'exposure', + 'Scripts exposed to Assist', + 'manual', + 'Home Assistant does not expose reliable LLM script-exposure state through this API check; verify this in Assist settings.', + ), + ] + next_actions = [] + if missing_scripts: + next_actions.append({'id': 'install_scripts', 'label': 'Install or reload AppOps scripts', 'href': '/voice?tab=ha'}) + if not gemini_available: + next_actions.append({'id': 'configure_gemini', 'label': 'Configure Gemini Conversation', 'href': f'{url}/config/integrations'}) + if not conversation_available: + next_actions.append({'id': 'configure_assist', 'label': 'Configure an Assist pipeline', 'href': f'{url}/config/voice-assistants/assistants'}) + next_actions.append({'id': 'verify_exposure', 'label': 'Verify script exposure to Assist', 'href': f'{url}/config/voice-assistants/expose'}) + + return { + 'status': 'ready' if operational else 'partial', + 'operational': operational, + 'apiReachable': True, + 'homeAssistantUrl': url, + 'missing': [], + 'expectedScriptCount': len(expected_scripts), + 'detectedScriptCount': len(detected_scripts), + 'scriptCoveragePercent': coverage, + 'detectedScripts': detected_scripts, + 'missingScripts': missing_scripts, + 'coreScriptAvailable': core_script_available, + 'conversationAvailable': conversation_available, + 'geminiConversationAvailable': gemini_available, + 'llmExposureStatus': 'manual_verification_required', + 'steps': steps, + 'nextActions': next_actions, + 'lastCheckedAt': utc_now().isoformat(), + } diff --git a/backend/app/services/incident_service.py b/backend/app/services/incident_service.py new file mode 100644 index 0000000..d2cdcf9 --- /dev/null +++ b/backend/app/services/incident_service.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlmodel import Session, select + +from ..db.models import AppSetting, Connector, Policy +from ..core.time import utc_now +from .inventory_discovery import list_drift_incidents, update_drift_incident_status +from .secret_store import secret_storage_status + +_INCIDENT_STATES_SETTING = 'incidents:states' + + +def _now_iso() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + +def _incident_states(session: Session) -> dict[str, dict[str, Any]]: + row = session.get(AppSetting, _INCIDENT_STATES_SETTING) + value = row.value_json if row else {} + items = value.get('items', {}) if isinstance(value, dict) else {} + return {str(key): value for key, value in items.items() if isinstance(value, dict)} + + +def _set_incident_states(session: Session, states: dict[str, dict[str, Any]]) -> None: + row = session.get(AppSetting, _INCIDENT_STATES_SETTING) + value = {'items': states} + if not row: + row = AppSetting(key=_INCIDENT_STATES_SETTING, value_json=value) + else: + row.value_json = value + row.updated_at = utc_now() + session.add(row) + + +def _generated_incidents(session: Session) -> list[dict[str, Any]]: + timestamp = _now_iso() + incidents: list[dict[str, Any]] = [] + + connectors = session.exec(select(Connector).order_by(Connector.id)).all() + degraded_connectors = [ + connector.id + for connector in connectors + if connector.status in {'missing_config', 'disconnected', 'error', 'auth_failed', 'unreachable'} + ] + if degraded_connectors: + incidents.append({ + 'id': 'incident-connectors-config', + 'severity': 'high' if len(degraded_connectors) >= 5 else 'medium', + 'status': 'open', + 'title': 'Connector configuratie of health vraagt aandacht', + 'detectedTime': timestamp, + 'evidence': { + 'source': 'Connector Registry', + 'check': 'Connector readiness', + 'observed': f"{len(degraded_connectors)} degraded connector(s): {', '.join(degraded_connectors[:8])}", + 'expected': 'Alle bekende connectors connected of bewust optioneel.', + 'timestamp': timestamp, + }, + 'suggestedActions': ['connector.configure', 'connector.test'], + }) + + volume_policy = session.get(Policy, 'policy-volume-delete') + if not (volume_policy and volume_policy.voice == 'blocked' and volume_policy.manual == 'blocked'): + incidents.append({ + 'id': 'incident-policy-volume-delete', + 'severity': 'critical', + 'status': 'open', + 'title': 'Destructive storage policy niet fail-closed', + 'detectedTime': timestamp, + 'evidence': { + 'source': 'Policy Engine', + 'check': 'volume.delete guardrail', + 'observed': 'volume.delete is niet volledig blocked', + 'expected': 'voice=blocked en manual=blocked', + 'timestamp': timestamp, + }, + 'suggestedActions': ['policy.review'], + }) + + storage = secret_storage_status() + if not storage.get('productionReady'): + incidents.append({ + 'id': 'incident-secret-storage', + 'severity': 'high', + 'status': 'open', + 'title': 'Secret storage niet production-ready', + 'detectedTime': timestamp, + 'evidence': { + 'source': 'Secret Store', + 'check': 'Encrypted-at-rest readiness', + 'observed': 'Secret storage mist production-ready configuratie.', + 'expected': 'Encrypted secret storage met persistente sleutel.', + 'timestamp': timestamp, + }, + 'suggestedActions': ['secrets.health'], + }) + + return incidents + + +def list_incidents(session: Session) -> list[dict[str, Any]]: + states = _incident_states(session) + incidents: list[dict[str, Any]] = [] + for incident in _generated_incidents(session): + state = states.get(incident['id'], {}) + status = str(state.get('status') or 'open') + if status == 'resolved': + continue + incident['status'] = status if status in {'open', 'acknowledged', 'muted'} else 'open' + incidents.append(incident) + incidents.extend(list_drift_incidents(session)) + return incidents + + +def update_incident_status(session: Session, incident_id: str, status: str) -> dict[str, Any]: + if status not in {'open', 'acknowledged', 'muted', 'resolved'}: + status = 'acknowledged' + + drift_update = update_drift_incident_status(session, incident_id, status) + if drift_update: + return drift_update + + current_ids = {incident['id'] for incident in _generated_incidents(session)} + states = _incident_states(session) + if incident_id not in current_ids and incident_id not in states: + return {'success': False, 'message': 'incident_not_found', 'result': 'failed', 'status': 'failed'} + + states[incident_id] = {'status': status, 'updatedAt': _now_iso()} + _set_incident_states(session, states) + session.commit() + return { + 'success': True, + 'message': f'Incident {incident_id} status updated', + 'result': 'success', + 'status': 'success', + } + + +def count_open_incidents(session: Session) -> int: + return len([incident for incident in list_incidents(session) if incident.get('status') == 'open']) diff --git a/backend/app/services/inventory_discovery.py b/backend/app/services/inventory_discovery.py new file mode 100644 index 0000000..b1bca8f --- /dev/null +++ b/backend/app/services/inventory_discovery.py @@ -0,0 +1,1715 @@ +from __future__ import annotations + +import hashlib +import json +import re +import threading +import time +from datetime import datetime +from typing import Any +from uuid import uuid4 +from weakref import WeakKeyDictionary + +from sqlalchemy import func +from sqlmodel import Session, delete, select + +from ..connectors.portainer import PortainerConnector +from ..core.config import settings +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import App, AppContainerMapping, AppSetting, DiscoveredContainer, DiscoveryRun +from ..db.session import get_session +from .audit_logger import log_event +from . import safety_center_service +from .app_classifier import classify_container + + +_SECRET_KEYS = {'env', 'Env', 'environment', 'Config', 'Secrets'} +_SENSITIVE_KEY_TOKENS = {'key', 'token', 'password', 'secret'} +_DRIFT_ACK_SETTING = 'inventory:drift_acknowledged' +_DRIFT_INCIDENTS_SETTING = 'inventory:drift_incidents' +_DRIFT_BASELINE_SETTING = 'inventory:drift_baseline' +_IGNORE_RULES_SETTING = 'inventory:ignore_rules' +_LATEST_BY_CONTAINER_CACHE_TTL_SECONDS = 5.0 +_latest_by_container_cache_lock = threading.Lock() +_latest_by_container_cache: WeakKeyDictionary[Session, tuple[float, dict[str, DiscoveredContainer]]] = WeakKeyDictionary() +_CONNECTOR_REQUIRED_SECRETS = { + 'portainer': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'], + 'unraid': ['UNRAID_URL', 'UNRAID_API_KEY'], + 'sonarr': ['SONARR_URL', 'SONARR_API_KEY'], + 'radarr': ['RADARR_URL', 'RADARR_API_KEY'], + 'widefrog': ['WIDEFROG_URL'], + 'plex': ['PLEX_URL', 'PLEX_TOKEN'], + 'tautulli': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'], + 'deluge': ['DELUGE_URL', 'DELUGE_PASSWORD'], + 'prowlarr': ['PROWLARR_URL', 'PROWLARR_API_KEY'], + 'nginx_proxy_manager': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], + 'authentik': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], + 'home_assistant': ['HOME_ASSISTANT_URL'], +} +_GENERIC_ACTIONS = [ + 'container.status', + 'container.logs', + 'container.restart', + 'health.basic', + 'portainer.open', + 'container.mounts', + 'container.ports', + 'container.networks', + 'container.image', + 'audit.recent', + 'inventory.import', + 'inventory.ignore', + 'inventory.map', +] + + +def _image_repository(image: str) -> str: + value = str(image or '').strip().casefold().split('@', 1)[0] + last_slash = value.rfind('/') + last_colon = value.rfind(':') + return value[:last_colon] if last_colon > last_slash else value + + +def _ignore_signature(name: str, image: str) -> dict[str, str]: + return {'name': str(name or '').strip().casefold(), 'imageRepository': _image_repository(image)} + + +def _is_ephemeral_container(name: str, image: str) -> bool: + value = f'{name} {image}'.casefold() + if image.casefold().startswith('mcr.microsoft.com/dotnet/sdk:'): + return True + return bool(re.search( + r'(^|\s)(gitea-actions-task-\d+-workflow-|buildx_buildkit_|testcontainers[-_ ]ryuk)' + r'|(?:^|[-_ ])candidate[-_]\d+' + r'|(?:^|[-_ ])phase\d+[a-z]?(?:[-_ ]|$)', + value, + )) + + +def _ignore_signatures(session: Session) -> set[tuple[str, str]]: + signatures: set[tuple[str, str]] = set() + row = session.get(AppSetting, _IGNORE_RULES_SETTING) + rules = row.value_json.get('rules', []) if row and isinstance(row.value_json, dict) else [] + for rule in rules: + if isinstance(rule, dict): + signatures.add((str(rule.get('name') or '').casefold(), str(rule.get('imageRepository') or '').casefold())) + ignored = session.exec(select(DiscoveredContainer).where(DiscoveredContainer.import_status == 'ignored')).all() + for item in ignored: + signature = _ignore_signature(item.name, item.image) + signatures.add((signature['name'], signature['imageRepository'])) + return signatures + + +def _store_ignore_signature(session: Session, item: DiscoveredContainer) -> None: + signature = _ignore_signature(item.name, item.image) + row = session.get(AppSetting, _IGNORE_RULES_SETTING) or AppSetting(key=_IGNORE_RULES_SETTING, is_secret=False) + existing = row.value_json.get('rules', []) if isinstance(row.value_json, dict) else [] + rules = [rule for rule in existing if isinstance(rule, dict)] + if signature not in rules: + rules.append(signature) + row.value_json = {'version': 1, 'rules': rules[-500:]} + row.updated_at = utc_now() + session.add(row) +_DEFAULT_SETUP_CHECKLIST = [ + 'Confirm app type', + 'Confirm category', + 'Confirm criticality', + 'Confirm container mapping', + 'Select connector if available', + 'Configure secret if needed', + 'Add healthcheck', + 'Review policies', + 'Enable voice actions, optional', +] + + +def _id(prefix: str) -> str: + return f'{prefix}-{uuid4().hex[:12]}' + + +def _redact_value(value: Any) -> Any: + if isinstance(value, dict): + return { + str(key): '[REDACTED]' if any(token in str(key).lower() for token in _SENSITIVE_KEY_TOKENS) else _redact_value(nested) + for key, nested in value.items() + if str(key) not in _SECRET_KEYS and 'env' not in str(key).lower() + } + if isinstance(value, list): + return [_redact_value(item) for item in value if not (isinstance(item, str) and '=' in item and any(token in item.lower() for token in ['key', 'token', 'password', 'secret']))] + if isinstance(value, str): + return redact_message(value) + return value + + +def _json_clone(value: Any, fallback: Any) -> Any: + try: + return json.loads(json.dumps(_redact_value(value))) + except (TypeError, ValueError): + return fallback + + +def _canonical_list(value: Any) -> list[Any]: + cloned = _json_clone(value, []) + if not isinstance(cloned, list): + return [] + return sorted( + cloned, + key=lambda item: json.dumps(item, sort_keys=True, separators=(',', ':'), default=str), + ) + + +def _normalize_container(raw: dict[str, Any]) -> dict[str, Any]: + container_id = str(raw.get('container_id') or raw.get('id') or raw.get('Id') or raw.get('name') or 'unknown') + name = str(raw.get('name') or raw.get('Name') or container_id).strip('/').strip() or container_id + labels = raw.get('labels') or raw.get('Labels') or {} + ports = raw.get('ports') or raw.get('Ports') or [] + mounts = raw.get('mounts') or raw.get('Mounts') or [] + networks = raw.get('networks') or raw.get('Networks') or [] + + if isinstance(networks, dict): + networks = list(networks.keys()) + + return { + 'container_id': container_id, + 'name': name, + 'image': str(raw.get('image') or raw.get('Image') or 'unknown'), + 'state': str(raw.get('state') or raw.get('State') or 'unknown').lower(), + 'status': str(raw.get('status') or raw.get('Status') or raw.get('health') or 'unknown'), + 'ports': _canonical_list(ports), + 'mounts': _canonical_list(mounts), + 'networks': _canonical_list(networks), + 'labels': _json_clone(labels if isinstance(labels, dict) else {}, {}), + 'created': raw.get('created') or raw.get('Created'), + 'restart_count': int(raw.get('restartCount') or raw.get('RestartCount') or 0), + 'raw_metadata': _json_clone(raw, {}), + } + + +def _serialize_run(run: DiscoveryRun) -> dict[str, Any]: + return { + 'id': run.id, + 'source': run.source, + 'startedAt': run.started_at.isoformat(), + 'finishedAt': run.finished_at.isoformat() if run.finished_at else None, + 'status': run.status, + 'containersFound': run.containers_found, + 'appsMatched': run.apps_matched, + 'newItems': run.new_items, + 'changedItems': run.changed_items, + 'errors': run.errors_json, + } + + +def serialize_discovered(item: DiscoveredContainer) -> dict[str, Any]: + raw = item.raw_metadata_json or {} + classifier = raw.get('classification') if isinstance(raw.get('classification'), dict) else {} + return { + 'id': item.id, + 'discoveryRunId': item.discovery_run_id, + 'containerId': item.container_id, + 'name': item.name, + 'image': item.image, + 'state': item.state, + 'status': item.status, + 'ports': item.ports_json, + 'mounts': item.mounts_json, + 'networks': item.networks_json, + 'labels': item.labels_json, + 'suggestedAppId': classifier.get('suggested_app_id') or item.suggested_app_type, + 'displayName': classifier.get('display_name') or item.name, + 'suggestedAppType': item.suggested_app_type, + 'suggestedCategory': item.suggested_category, + 'suggestedCriticality': item.suggested_criticality, + 'suggestedConnector': item.suggested_connector, + 'suggestedPolicyProfile': item.suggested_policy_profile, + 'protected': bool(classifier.get('protected', False)), + 'confidence': float(classifier.get('confidence', 0)), + 'reasons': classifier.get('reasons', []), + 'importStatus': item.import_status, + 'matchedAppId': item.matched_app_id, + 'source': 'portainer', + 'lifecycleState': item.import_status, + 'appType': item.suggested_app_type, + 'connectorType': _connector_type(item), + 'connectorStatus': _connector_status(item), + 'connectorRequiredSecrets': _connector_required_secrets(item), + 'isManaged': item.import_status == 'managed', + 'isImported': item.import_status == 'managed', + 'isProtected': bool(classifier.get('protected', False)), + 'policyProfile': item.suggested_policy_profile, + 'privacySensitive': bool(classifier.get('privacy_sensitive', False)), + 'actionsAvailable': _actions_available(item), + 'setupChecklist': _setup_checklist(item), + } + + +def _connector_type(item: DiscoveredContainer) -> str: + connector = str(item.suggested_connector or 'none') + return 'none' if connector in {'', 'none', 'prowlarr_future'} else connector + + +def _connector_required_secrets(item: DiscoveredContainer) -> list[str]: + return _CONNECTOR_REQUIRED_SECRETS.get(_connector_type(item), []) + + +def connector_required_secrets(connector_id: str) -> list[str]: + return _CONNECTOR_REQUIRED_SECRETS.get(connector_id, []) + + +def connector_missing_secrets(connector_id: str) -> list[str]: + return [name for name in connector_required_secrets(connector_id) if not settings.apis.get(name)] + + +def connector_runtime_status(session: Session | None, connector_id: str) -> str: + connector = str(connector_id or 'none') + if connector in {'', 'none', 'prowlarr_future'}: + return 'none' + if session is not None: + from .setup_service import apply_stored_connector_settings + + apply_stored_connector_settings(session) + return 'missing_config' if connector_missing_secrets(connector) else 'connected' + + +def _connector_status(item: DiscoveredContainer) -> str: + connector = _connector_type(item) + if connector == 'none': + return 'none' + missing = connector_missing_secrets(connector) + return 'missing_config' if missing else 'connected' + + +def _actions_available(item: DiscoveredContainer) -> list[str]: + actions = list(_GENERIC_ACTIONS) + connector = _connector_type(item) + if connector != 'none': + actions.append(f'{connector}.capabilities') + return actions + + +def _setup_checklist(item: DiscoveredContainer) -> list[str]: + connector = _connector_type(item) + if connector == 'none': + return list(_DEFAULT_SETUP_CHECKLIST) + if connector == 'nginx_proxy_manager': + return [ + 'Confirm NPM URL', + 'Configure NPM_IDENTITY', + 'Configure NPM_SECRET', + 'Test connector', + 'Review exposure policies', + 'Bind connector', + ] + if connector == 'authentik': + return [ + 'Confirm Authentik URL', + 'Configure AUTHENTIK_TOKEN', + 'Test connector', + 'Review outpost health', + 'Review auth coverage', + 'Bind connector', + ] + return [ + 'Confirm app type', + 'Confirm category', + 'Confirm criticality', + 'Confirm container mapping', + f'Select {connector} connector', + 'Configure connector secret if needed', + 'Test connector', + 'Review policies', + 'Enable voice actions, optional', + ] + + +def serialize_inventory_app(item: DiscoveredContainer) -> dict[str, Any]: + discovered = serialize_discovered(item) + display_name = str(discovered['displayName']) + connector = str(discovered['connectorType']) + lifecycle = str(discovered['lifecycleState']) + health = 'missing' if lifecycle == 'missing' else ('healthy' if item.state == 'running' else 'warning') + return { + 'id': f'discovered-{item.id}', + 'display_name': display_name, + 'name': display_name, + 'app_type': item.suggested_app_type, + 'category': item.suggested_category, + 'criticality': item.suggested_criticality, + 'lifecycle_state': lifecycle, + 'container_names': [item.name], + 'primary_container': item.name, + 'connector_type': connector, + 'connector_status': discovered['connectorStatus'], + 'connector_required_secrets': discovered['connectorRequiredSecrets'], + 'is_protected': discovered['isProtected'], + 'policy_profile': item.suggested_policy_profile, + 'health_status': health, + 'source': 'portainer', + 'actions_available': discovered['actionsAvailable'], + 'setup_checklist': discovered['setupChecklist'], + 'discovery_confidence': discovered['confidence'], + 'discovery_reasons': discovered['reasons'], + 'discovery': discovered, + # Legacy frontend-compatible fields. + 'status': 'warning' if lifecycle in {'unmapped', 'changed'} else health, + 'connectorStatus': discovered['connectorStatus'], + 'containerStatus': item.state, + 'voiceEnabled': False, + 'protected': discovered['isProtected'], + 'discoveryStatus': lifecycle, + 'discoverySource': 'portainer', + 'needsReview': lifecycle != 'managed', + 'suggestedAppId': discovered['suggestedAppId'], + 'containerId': item.container_id, + } + + +def _discovery_run_order(session: Session) -> dict[str, datetime]: + return {run.id: run.started_at for run in session.exec(select(DiscoveryRun)).all()} + + +def invalidate_discovery_cache() -> None: + with _latest_by_container_cache_lock: + _latest_by_container_cache.clear() + + +def _latest_by_container(session: Session) -> dict[str, DiscoveredContainer]: + now = time.monotonic() + with _latest_by_container_cache_lock: + cached = _latest_by_container_cache.get(session) + if cached is not None and now < cached[0]: + return dict(cached[1]) + + ranked = ( + select( + DiscoveredContainer.id.label('discovered_id'), + func.row_number().over( + partition_by=DiscoveredContainer.container_id, + order_by=(DiscoveryRun.started_at.desc(), DiscoveredContainer.id.desc()), + ).label('latest_rank'), + ) + .join(DiscoveryRun, DiscoveryRun.id == DiscoveredContainer.discovery_run_id, isouter=True) + .subquery() + ) + rows = session.exec( + select(DiscoveredContainer) + .join(ranked, DiscoveredContainer.id == ranked.c.discovered_id) + .where(ranked.c.latest_rank == 1) + ).all() + latest = {row.container_id: row for row in rows} + with _latest_by_container_cache_lock: + _latest_by_container_cache[session] = ( + time.monotonic() + _LATEST_BY_CONTAINER_CACHE_TTL_SECONDS, + dict(latest), + ) + return latest + + +def latest_discovered_by_container(session: Session) -> dict[str, DiscoveredContainer]: + """Return the current inventory snapshot without exposing history to consumers.""" + return _latest_by_container(session) + + +def _recent_by_container(session: Session, limit: int = 2) -> dict[str, list[DiscoveredContainer]]: + ranked = ( + select( + DiscoveredContainer.id.label('discovered_id'), + DiscoveredContainer.container_id.label('container_id'), + func.row_number().over( + partition_by=DiscoveredContainer.container_id, + order_by=(DiscoveryRun.started_at.desc(), DiscoveredContainer.id.desc()), + ).label('recent_rank'), + ) + .join(DiscoveryRun, DiscoveryRun.id == DiscoveredContainer.discovery_run_id, isouter=True) + .subquery() + ) + rows = session.exec( + select(DiscoveredContainer) + .join(ranked, DiscoveredContainer.id == ranked.c.discovered_id) + .where(ranked.c.recent_rank <= max(1, limit)) + .order_by(ranked.c.container_id, ranked.c.recent_rank.desc()) + ).all() + grouped: dict[str, list[DiscoveredContainer]] = {} + for row in rows: + grouped.setdefault(row.container_id, []).append(row) + return grouped + + +def _has_changed(previous: DiscoveredContainer | None, normalized: dict[str, Any]) -> bool: + if not previous: + return False + return ( + previous.image != normalized['image'] + or _canonical_list(previous.ports_json) != _canonical_list(normalized['ports']) + or _canonical_list(previous.mounts_json) != _canonical_list(normalized['mounts']) + or _canonical_list(previous.networks_json) != _canonical_list(normalized['networks']) + or previous.status != normalized['status'] + ) + + +def run_discovery(source: str = 'portainer', session: Session | None = None) -> dict[str, Any]: + owns_session = session is None + if session is None: + session = next(get_session()) + + run = DiscoveryRun(id=_id('disc'), source=source, status='running') + session.add(run) + session.commit() + session.refresh(run) + + connector = PortainerConnector() + connection = connector.test_connection() + if connection.get('status') == 'missing_config': + run.status = 'missing_config' + run.finished_at = utc_now() + run.errors_json = [{'status': 'missing_config', 'reason': 'missing_config'}] + session.add(run) + session.commit() + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + result = {'success': False, 'runId': run.id, 'status': 'missing_config', 'containersFound': 0, 'appsMatched': 0, 'newItems': 0, 'changedItems': 0, 'errors': run.errors_json} + if owns_session: + session.close() + return result + + containers_result = connector.list_containers() + if containers_result.get('status') != 'ok': + run.status = 'failed' + run.finished_at = utc_now() + run.errors_json = [{'status': containers_result.get('status'), 'reason': redact_message(str(containers_result.get('reason', 'discovery_failed')))}] + session.add(run) + session.commit() + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + result = {'success': False, 'runId': run.id, 'status': 'failed', 'containersFound': 0, 'appsMatched': 0, 'newItems': 0, 'changedItems': 0, 'errors': run.errors_json} + if owns_session: + session.close() + return result + + history_retention = {'removed': 0, 'retainedRuns': None} + if settings.discovery_history_runs > 0: + history_retention = prune_discovery_history(session, settings.discovery_history_runs) + + previous = _latest_by_container(session) + existing_mappings = session.exec(select(AppContainerMapping)).all() + mappings_by_container_id = {mapping.container_id: mapping for mapping in existing_mappings} + mappings_by_name_and_app: dict[tuple[str, str], list[AppContainerMapping]] = {} + for mapping in existing_mappings: + key = (mapping.container_name.strip().casefold(), mapping.app_id) + mappings_by_name_and_app.setdefault(key, []).append(mapping) + current_ids: set[str] = set() + apps_matched = 0 + new_items = 0 + changed_items = 0 + missing_items = 0 + reconciled_mappings = 0 + duplicate_mappings_removed = 0 + ignored_signatures = _ignore_signatures(session) + + for raw in containers_result.get('containers', []): + if not isinstance(raw, dict): + continue + normalized = _normalize_container(raw) + current_ids.add(normalized['container_id']) + classification = classify_container(normalized) + matched_app = session.get(App, classification['suggested_app_id']) + mapped = mappings_by_container_id.get(normalized['container_id']) + mapping_candidates = mappings_by_name_and_app.get( + (normalized['name'].strip().casefold(), matched_app.id), + [], + ) if matched_app else [] + if not mapped and mapping_candidates: + mapped = max(mapping_candidates, key=lambda item: (item.last_seen_at, item.id)) + mappings_by_container_id.pop(mapped.container_id, None) + mapped.container_id = normalized['container_id'] + reconciled_mappings += 1 + if mapped: + for duplicate in mapping_candidates: + if duplicate.id == mapped.id: + continue + mappings_by_container_id.pop(duplicate.container_id, None) + session.delete(duplicate) + duplicate_mappings_removed += 1 + mapped.container_name = normalized['name'] + mapped.last_seen_run_id = run.id + mapped.last_seen_at = utc_now() + session.add(mapped) + mappings_by_container_id[normalized['container_id']] = mapped + changed = _has_changed(previous.get(normalized['container_id']), normalized) + if matched_app: + apps_matched += 1 + if normalized['container_id'] not in previous: + new_items += 1 + if changed: + changed_items += 1 + + import_status = classification['import_status'] + signature = _ignore_signature(normalized['name'], normalized['image']) + is_ignored = _is_ephemeral_container(normalized['name'], normalized['image']) or ( + signature['name'], signature['imageRepository'] + ) in ignored_signatures + if is_ignored: + import_status = 'ignored' + elif changed: + import_status = 'changed' + elif mapped: + import_status = 'managed' + + item = DiscoveredContainer( + id=_id('dc'), + discovery_run_id=run.id, + container_id=normalized['container_id'], + name=normalized['name'], + image=normalized['image'], + state=normalized['state'], + status=normalized['status'], + ports_json=normalized['ports'], + mounts_json=normalized['mounts'], + networks_json=normalized['networks'], + labels_json=normalized['labels'], + suggested_app_type=classification['app_type'], + suggested_category=classification['category'], + suggested_criticality=classification['criticality'], + suggested_connector=classification['connector'], + suggested_policy_profile=classification['policy_profile'], + import_status=import_status, + matched_app_id=matched_app.id if matched_app else None, + raw_metadata_json={**normalized['raw_metadata'], 'classification': classification}, + ) + session.add(item) + + for container_id, old in previous.items(): + if container_id not in current_ids and old.import_status not in {'ignored', 'missing'}: + missing = DiscoveredContainer( + id=_id('dc'), + discovery_run_id=run.id, + container_id=old.container_id, + name=old.name, + image=old.image, + state='missing', + status='missing', + ports_json=old.ports_json, + mounts_json=old.mounts_json, + networks_json=old.networks_json, + labels_json=old.labels_json, + suggested_app_type=old.suggested_app_type, + suggested_category=old.suggested_category, + suggested_criticality=old.suggested_criticality, + suggested_connector=old.suggested_connector, + suggested_policy_profile=old.suggested_policy_profile, + import_status='missing', + matched_app_id=old.matched_app_id, + raw_metadata_json=old.raw_metadata_json, + ) + session.add(missing) + missing_items += 1 + + run.status = 'success' + run.finished_at = utc_now() + run.containers_found = len(current_ids) + run.apps_matched = apps_matched + run.new_items = new_items + run.changed_items = changed_items + session.add(run) + session.commit() + invalidate_discovery_cache() + sync_app_connector_statuses(session) + if new_items or changed_items or missing_items or reconciled_mappings or duplicate_mappings_removed: + safety_center_service.invalidate_cache() + + if reconciled_mappings or duplicate_mappings_removed: + log_event( + session, + source='inventory', + identity='system', + action_id='inventory.mapping.reconcile', + target=run.id, + target_type='discovery_run', + risk='low', + policy_decision='allowed', + reason='Reconciled AppOps metadata for same-name container recreations; no external system was mutated.', + result='success', + after_state={ + 'reconciledMappings': reconciled_mappings, + 'duplicateMappingsRemoved': duplicate_mappings_removed, + }, + redacted_metadata={'runId': run.id, 'metadataOnly': True}, + ) + + result = { + 'success': True, + 'runId': run.id, + 'status': 'success', + 'containersFound': run.containers_found, + 'appsMatched': run.apps_matched, + 'newItems': run.new_items, + 'changedItems': run.changed_items, + 'reconciledMappings': reconciled_mappings, + 'duplicateMappingsRemoved': duplicate_mappings_removed, + 'historyRowsRemoved': history_retention['removed'], + 'historyRunsRetained': history_retention['retainedRuns'], + 'errors': [], + } + if owns_session: + session.close() + return result + + +def import_discovered_container(session: Session, discovered_container_id: str) -> dict[str, Any]: + from .app_registry import category_label + + item = session.get(DiscoveredContainer, discovered_container_id) + if not item: + return {'success': False, 'message': 'discovered_container_not_found'} + + raw = item.raw_metadata_json or {} + classification = raw.get('classification') if isinstance(raw.get('classification'), dict) else classify_container({'name': item.name, 'image': item.image, 'ports': item.ports_json}) + app_id = str(classification.get('suggested_app_id') or item.suggested_app_type or item.name).replace('-', '_') + protected = bool(classification.get('protected', False)) + connector = str(classification.get('connector') or item.suggested_connector or 'none') + connector_status = connector_runtime_status(session, connector) + + app = session.get(App, app_id) + if not app: + app = App( + id=app_id, + name=str(classification.get('display_name') or item.name), + category=category_label(item.suggested_category), + status='healthy' if item.state == 'running' else 'warning', + criticality=item.suggested_criticality, + connector_status=connector_status, + container_status=item.state if item.state else 'running', + voice_enabled=False, + protected=protected, + discovery_status='managed', + discovery_source='portainer', + needs_review=True, + ) + else: + app.category = category_label(item.suggested_category) + app.criticality = item.suggested_criticality + app.protected = app.protected or protected + app.voice_enabled = False + app.discovery_status = 'managed' + app.discovery_source = 'portainer' + app.needs_review = True + session.add(app) + + mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == item.container_id)).first() + if not mapping: + mapping = AppContainerMapping(id=_id('map'), app_id=app_id, container_id=item.container_id, container_name=item.name, role='primary', source='discovery', is_primary=True, last_seen_run_id=item.discovery_run_id) + else: + mapping.app_id = app_id + mapping.container_name = item.name + mapping.last_seen_run_id = item.discovery_run_id + mapping.last_seen_at = utc_now() + session.add(mapping) + + item.import_status = 'managed' + item.matched_app_id = app_id + session.add(item) + session.commit() + session.refresh(app) + session.refresh(mapping) + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + + return { + 'success': True, + 'message': 'imported', + 'app': _serialize_app(app), + 'mapping': serialize_mapping(mapping), + } + + +def update_discovered_review_metadata(session: Session, discovered_container_id: str, updates: dict[str, Any]) -> dict[str, Any]: + item = session.get(DiscoveredContainer, discovered_container_id) + if not item: + return {'success': False, 'message': 'discovered_container_not_found'} + if item.import_status in {'managed', 'ignored', 'missing'}: + return {'success': False, 'message': 'discovered_container_not_editable'} + + raw = dict(item.raw_metadata_json) if isinstance(item.raw_metadata_json, dict) else {} + classification = raw.get('classification') if isinstance(raw.get('classification'), dict) else {} + classification = dict(classification) + + def text_value(key: str, limit: int = 80) -> str | None: + value = updates.get(key) + if not isinstance(value, str): + return None + value = value.strip() + return value[:limit] if value else None + + display_name = text_value('displayName') + if display_name: + classification['display_name'] = display_name + + category = text_value('category', 40) + if category: + item.suggested_category = re.sub(r'[^a-zA-Z0-9_\- ]', '', category).strip().lower().replace(' ', '_') or item.suggested_category + + criticality = text_value('criticality', 20) + if criticality in {'low', 'medium', 'high', 'critical'}: + item.suggested_criticality = criticality + classification['criticality'] = criticality + + connector = text_value('connectorType', 40) + if connector: + normalized_connector = re.sub(r'[^a-zA-Z0-9_\-]', '', connector).lower() + item.suggested_connector = normalized_connector or 'none' + classification['connector'] = item.suggested_connector + + policy_profile = text_value('policyProfile', 40) + if policy_profile: + item.suggested_policy_profile = re.sub(r'[^a-zA-Z0-9_\-]', '', policy_profile).lower() or item.suggested_policy_profile + + raw['classification'] = classification + item.raw_metadata_json = raw + session.add(item) + session.commit() + session.refresh(item) + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + log_event( + session, + source='manual', + identity='operator', + action_id='inventory.review.update_metadata', + target=item.name, + target_type='inventory', + risk='low', + policy_decision='allowed', + reason='Operator updated AppOps discovery metadata only; no container or external service was mutated.', + result='success', + redacted_metadata={'discoveredContainerId': item.id, 'fields': sorted(updates.keys())}, + ) + return {'success': True, 'message': 'review_metadata_updated', 'discovered': serialize_discovered(item)} + + +def map_discovered_to_existing_app(session: Session, discovered_container_id: str, app_id: str) -> dict[str, Any]: + item = session.get(DiscoveredContainer, discovered_container_id) + if not item: + return {'success': False, 'message': 'discovered_container_not_found'} + app = session.get(App, app_id) + if not app: + return {'success': False, 'message': 'app_not_found'} + if item.import_status in {'ignored', 'missing'}: + return {'success': False, 'message': 'discovered_container_not_mappable'} + + mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == item.container_id)).first() + if not mapping: + mapping = AppContainerMapping( + id=_id('map'), + app_id=app.id, + container_id=item.container_id, + container_name=item.name, + role='primary', + source='manual_review', + is_primary=True, + last_seen_run_id=item.discovery_run_id, + ) + else: + mapping.app_id = app.id + mapping.container_name = item.name + mapping.source = 'manual_review' + mapping.last_seen_run_id = item.discovery_run_id + mapping.last_seen_at = utc_now() + session.add(mapping) + + app.container_status = item.state if item.state else app.container_status + app.discovery_status = 'managed' + app.discovery_source = 'portainer' + app.voice_enabled = False + app.needs_review = True + app.connector_status = connector_runtime_status(session, _connector_type(item)) + session.add(app) + + item.import_status = 'managed' + item.matched_app_id = app.id + session.add(item) + session.commit() + session.refresh(app) + session.refresh(mapping) + session.refresh(item) + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + log_event( + session, + source='manual', + identity='operator', + action_id='inventory.review.map_existing', + target=app.id, + target_type='app', + risk='low', + policy_decision='allowed', + reason='Operator mapped a discovered container to an existing AppOps app; no container or external service was mutated.', + result='success', + redacted_metadata={'discoveredContainerId': item.id, 'appId': app.id, 'mappingId': mapping.id}, + ) + return { + 'success': True, + 'message': 'mapped_existing_app', + 'app': _serialize_app(app), + 'mapping': serialize_mapping(mapping), + 'discovered': serialize_discovered(item), + } + + +def ignore_discovered_container(session: Session, discovered_container_id: str) -> dict[str, Any]: + item = session.get(DiscoveredContainer, discovered_container_id) + if not item: + return {'success': False, 'message': 'discovered_container_not_found'} + item.import_status = 'ignored' + _store_ignore_signature(session, item) + session.add(item) + session.commit() + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + return {'success': True, 'message': 'ignored', 'id': item.id} + + +def complete_app_review(session: Session, *, app_ids: list[str] | None = None) -> dict[str, Any]: + query = select(App).where(App.discovery_status == 'managed') + if app_ids: + query = query.where(App.id.in_(app_ids)) + apps = session.exec(query).all() + reviewed = 0 + for app in apps: + if app.needs_review: + app.needs_review = False + session.add(app) + reviewed += 1 + latest = _latest_by_container(session) + mapped_container_ids = {mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all()} + drift_reviewed = 0 + for item in latest.values(): + if item.import_status == 'changed' and item.container_id in mapped_container_ids: + item.import_status = 'managed' + session.add(item) + drift_reviewed += 1 + session.commit() + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + log_event( + session, + source='manual', + identity='setup', + action_id='inventory.review.complete', + target='inventory', + target_type='inventory', + risk='low', + policy_decision='allowed', + reason='Operator completed discovery import review; no containers were mutated and voice remains disabled.', + result='success', + redacted_metadata={'reviewed': reviewed, 'driftReviewed': drift_reviewed, 'scope': 'selected' if app_ids else 'all_managed'}, + ) + return { + 'success': True, + 'status': 'success', + 'message': 'review_completed', + 'reviewed': reviewed, + 'driftReviewed': drift_reviewed, + 'scope': 'selected' if app_ids else 'all_managed', + } + + +def prune_historical_discovery_rows(session: Session) -> dict[str, Any]: + latest_ids = {item.id for item in _latest_by_container(session).values()} + all_rows = session.exec(select(DiscoveredContainer)).all() + stale_ids = [row.id for row in all_rows if row.id not in latest_ids] + if stale_ids: + session.exec(delete(DiscoveredContainer).where(DiscoveredContainer.id.in_(stale_ids))) + session.commit() + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + retention = settings.discovery_history_runs if settings.discovery_history_runs > 0 else 48 + history = prune_discovery_history(session, retention) + return { + 'success': True, + 'status': 'success', + 'removed': len(stale_ids) + int(history['removed']), + 'runsRemoved': int(history['runsRemoved']), + 'kept': len(latest_ids), + 'retainedRuns': int(history['retainedRuns']), + } + + +def prune_discovery_history(session: Session, keep_runs: int) -> dict[str, Any]: + """Bound detailed discovery history and its summary ledger.""" + retention = max(1, int(keep_runs)) + retained_run_ids = list(session.exec( + select(DiscoveryRun.id) + .where(DiscoveryRun.status == 'success') + .order_by(DiscoveryRun.started_at.desc()) + .limit(retention) + ).all()) + removed = 0 + if retained_run_ids: + result = session.exec( + delete(DiscoveredContainer).where(DiscoveredContainer.discovery_run_id.notin_(retained_run_ids)) + ) + removed = max(0, int(getattr(result, 'rowcount', 0) or 0)) + summary_run_ids = list(session.exec( + select(DiscoveryRun.id) + .order_by(DiscoveryRun.started_at.desc()) + .limit(retention) + ).all()) + referenced_run_ids = select(DiscoveredContainer.discovery_run_id).distinct() + run_result = session.exec( + delete(DiscoveryRun).where( + DiscoveryRun.id.notin_(summary_run_ids), + DiscoveryRun.id.notin_(referenced_run_ids), + ) + ) + runs_removed = max(0, int(getattr(run_result, 'rowcount', 0) or 0)) + if removed or runs_removed: + session.commit() + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + return { + 'success': True, + 'status': 'success', + 'removed': removed, + 'runsRemoved': runs_removed, + 'retainedRuns': len(retained_run_ids), + } + + +def sync_app_connector_statuses(session: Session) -> dict[str, Any]: + from .setup_service import apply_stored_connector_settings + + apply_stored_connector_settings(session) + latest_by_container = _latest_by_container(session) + mappings = session.exec(select(AppContainerMapping)).all() + connector_by_app: dict[str, str] = {} + containers_by_app: dict[str, list[DiscoveredContainer]] = {} + for mapping in mappings: + discovered = latest_by_container.get(mapping.container_id) + if discovered: + connector_by_app[mapping.app_id] = _connector_type(discovered) + containers_by_app.setdefault(mapping.app_id, []).append(discovered) + + updated = 0 + for app in session.exec(select(App)).all(): + connector = connector_by_app.get(app.id) + if (connector is None or connector == 'none') and app.id in _CONNECTOR_REQUIRED_SECRETS: + connector = app.id + if connector is None: + connector = 'none' + next_status = connector_runtime_status(None, connector) + if app.connector_status != next_status: + app.connector_status = next_status + updated += 1 + mapped_containers = containers_by_app.get(app.id, []) + active_containers = [ + item for item in mapped_containers if item.import_status != 'ignored' + ] + if active_containers: + app_name_keys = { + _container_name_key(app.id.replace('_', '-')), + _container_name_key(app.name), + } + canonical_containers = [ + item + for item in active_containers + if _container_name_key(item.name) in app_name_keys + ] + health_containers = canonical_containers or active_containers + unhealthy_states = { + 'dead', + 'error', + 'exited', + 'missing', + 'stopped', + 'unhealthy', + 'warning', + } + unhealthy = [ + item + for item in health_containers + if str(item.state or '').lower() in unhealthy_states + or str(item.status or '').lower() in unhealthy_states + ] + next_health = 'warning' if unhealthy else 'healthy' + next_container_status = ( + str(unhealthy[0].state or unhealthy[0].status or 'unknown') + if unhealthy + else 'running' + ) + elif mapped_containers: + # Ignored one-shot migration and rollback containers are complete, + # not unhealthy long-running services. + next_health = 'healthy' + next_container_status = 'ignored' + else: + next_health = app.status + next_container_status = app.container_status + if app.status != next_health: + app.status = next_health + updated += 1 + if app.container_status != next_container_status: + app.container_status = next_container_status + updated += 1 + session.add(app) + if updated: + session.commit() + safety_center_service.invalidate_cache() + return {'success': True, 'status': 'success', 'updated': updated} + + +def serialize_mapping(mapping: AppContainerMapping) -> dict[str, Any]: + return { + 'id': mapping.id, + 'appId': mapping.app_id, + 'containerId': mapping.container_id, + 'containerName': mapping.container_name, + 'role': mapping.role, + 'source': mapping.source, + 'isPrimary': mapping.is_primary, + 'lastSeenRunId': mapping.last_seen_run_id, + 'lastSeenAt': mapping.last_seen_at.isoformat() if mapping.last_seen_at else None, + } + + +def _serialize_app(app: App) -> dict[str, Any]: + return { + 'id': app.id, + 'name': app.name, + 'category': app.category, + 'status': app.status, + 'criticality': app.criticality, + 'connectorStatus': app.connector_status, + 'containerStatus': app.container_status, + 'voiceEnabled': app.voice_enabled, + 'lastAction': app.last_action, + 'protected': app.protected, + 'discoveryStatus': app.discovery_status, + 'discoverySource': app.discovery_source, + 'needsReview': app.needs_review, + } + + +def list_runs(session: Session, *, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]: + bounded_limit = max(1, min(int(limit), 500)) + bounded_offset = max(0, int(offset)) + query = ( + select(DiscoveryRun) + .order_by(DiscoveryRun.started_at.desc()) + .offset(bounded_offset) + .limit(bounded_limit) + ) + return [_serialize_run(run) for run in session.exec(query).all()] + + +def get_run_detail(session: Session, run_id: str) -> dict[str, Any] | None: + run = session.get(DiscoveryRun, run_id) + if not run: + return None + containers = session.exec(select(DiscoveredContainer).where(DiscoveredContainer.discovery_run_id == run_id)).all() + return {**_serialize_run(run), 'containers': [serialize_discovered(item) for item in containers]} + + +def list_discovered(session: Session) -> list[dict[str, Any]]: + return [serialize_discovered(item) for item in _latest_discovered_items(session)] + + +def _latest_discovered_items(session: Session) -> list[DiscoveredContainer]: + run_order = _discovery_run_order(session) + latest_by_name: dict[str, DiscoveredContainer] = {} + for item in _latest_by_container(session).values(): + if _is_appops_deploy_artifact_name(item.name): + continue + name_key = _container_name_key(item.name) or item.container_id + current = latest_by_name.get(name_key) + item_rank = ( + item.import_status != 'missing', + run_order.get(item.discovery_run_id, datetime.min), + item.id, + ) + current_rank = ( + current.import_status != 'missing', + run_order.get(current.discovery_run_id, datetime.min), + current.id, + ) if current else None + if current_rank is None or item_rank > current_rank: + latest_by_name[name_key] = item + return sorted( + latest_by_name.values(), + key=lambda item: item.name.lower(), + ) + + +def serialize_connector_candidate(item: DiscoveredContainer, connector_id: str) -> dict[str, Any]: + discovered = serialize_discovered(item) + return { + 'connectorId': connector_id, + 'discoveredContainerId': item.id, + 'containerId': item.container_id, + 'containerName': item.name, + 'displayName': discovered['displayName'], + 'appType': item.suggested_app_type, + 'lifecycleState': item.import_status, + 'suggestedAppId': discovered['suggestedAppId'], + 'connectorStatus': _connector_status(item), + 'missingSecrets': connector_missing_secrets(connector_id), + 'requiredSecrets': connector_required_secrets(connector_id), + 'confidence': discovered['confidence'], + 'reasons': discovered['reasons'], + 'ports': item.ports_json, + } + + +def list_connector_candidates(session: Session, connector_id: str) -> list[dict[str, Any]]: + return [ + serialize_connector_candidate(item, connector_id) + for item in _latest_discovered_items(session) + if _connector_type(item) == connector_id and item.import_status not in {'ignored', 'missing'} + ] + + +def _find_latest_discovered_for_container(session: Session, container_id: str) -> DiscoveredContainer | None: + return _latest_by_container(session).get(container_id) + + +def serialize_connector_binding( + session: Session, + mapping: AppContainerMapping, + connector_id: str, + *, + app: App | None = None, + discovered: DiscoveredContainer | None = None, +) -> dict[str, Any]: + app = app if app is not None else session.get(App, mapping.app_id) + discovered = discovered if discovered is not None else _find_latest_discovered_for_container(session, mapping.container_id) + return { + 'connectorId': connector_id, + 'appId': mapping.app_id, + 'appName': app.name if app else mapping.app_id, + 'containerId': mapping.container_id, + 'containerName': mapping.container_name, + 'mappingId': mapping.id, + 'connectorStatus': app.connector_status if app else 'disconnected', + 'lifecycleState': discovered.import_status if discovered else 'managed', + 'missingSecrets': connector_missing_secrets(connector_id), + 'requiredSecrets': connector_required_secrets(connector_id), + 'needsReview': bool(app.needs_review) if app else True, + } + + +def list_connector_bindings(session: Session, connector_id: str) -> list[dict[str, Any]]: + bindings: list[dict[str, Any]] = [] + latest_by_container = _latest_by_container(session) + mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.container_name)).all() + apps = {app.id: app for app in session.exec(select(App)).all()} + for mapping in mappings: + discovered = latest_by_container.get(mapping.container_id) + if not discovered or _connector_type(discovered) != connector_id: + continue + bindings.append(serialize_connector_binding(session, mapping, connector_id, app=apps.get(mapping.app_id), discovered=discovered)) + return bindings + + +def bind_connector_to_discovered(session: Session, connector_id: str, discovered_container_id: str) -> dict[str, Any]: + item = session.get(DiscoveredContainer, discovered_container_id) + if not item: + return {'success': False, 'message': 'discovered_container_not_found'} + if _connector_type(item) != connector_id: + return {'success': False, 'message': 'connector_candidate_mismatch'} + if item.import_status in {'ignored', 'missing'}: + return {'success': False, 'message': 'connector_candidate_not_bindable'} + + result = import_discovered_container(session, discovered_container_id) + if not result.get('success'): + return result + + app_id = str(result['app']['id']) + app = session.get(App, app_id) + mapping = session.exec(select(AppContainerMapping).where(AppContainerMapping.container_id == item.container_id)).first() + if not app or not mapping: + return {'success': False, 'message': 'connector_binding_failed'} + + app.connector_status = _connector_status(item) + app.voice_enabled = False + app.needs_review = True + session.add(app) + session.commit() + session.refresh(app) + session.refresh(mapping) + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + + return { + 'success': True, + 'message': 'connector_bound', + 'app': _serialize_app(app), + 'mapping': serialize_mapping(mapping), + 'binding': serialize_connector_binding(session, mapping, connector_id), + } + + +def unbind_connector_from_app(session: Session, connector_id: str, app_id: str) -> dict[str, Any]: + app = session.get(App, app_id) + if not app: + return {'success': False, 'message': 'app_not_found'} + + mappings = session.exec(select(AppContainerMapping).where(AppContainerMapping.app_id == app_id)).all() + has_connector_mapping = False + for mapping in mappings: + discovered = _find_latest_discovered_for_container(session, mapping.container_id) + if discovered and _connector_type(discovered) == connector_id: + has_connector_mapping = True + break + if not has_connector_mapping: + return {'success': False, 'message': 'connector_binding_not_found'} + + app.connector_status = 'disconnected' + app.voice_enabled = False + app.needs_review = True + session.add(app) + session.commit() + session.refresh(app) + invalidate_discovery_cache() + safety_center_service.invalidate_cache() + return {'success': True, 'message': 'connector_unbound', 'app': _serialize_app(app)} + + +def list_inventory_containers(session: Session, include_ignored: bool = True) -> list[dict[str, Any]]: + items = _latest_discovered_items(session) + if not include_ignored: + items = [item for item in items if item.import_status != 'ignored'] + return [serialize_discovered(item) for item in items] + + +def list_inventory_apps(session: Session, include_unmanaged: bool = True, include_ignored: bool = False) -> list[dict[str, Any]]: + from .app_registry import list_active_apps + + apps = [ + { + 'id': app.id, + 'display_name': app.name, + 'name': app.name, + 'app_type': app.id, + 'category': str(app.category).lower(), + 'criticality': app.criticality, + 'lifecycle_state': app.discovery_status or 'managed', + 'container_names': [], + 'primary_container': None, + 'connector_type': 'none', + 'connector_status': app.connector_status, + 'connector_required_secrets': [], + 'is_protected': app.protected, + 'policy_profile': 'managed', + 'health_status': app.status, + 'source': app.discovery_source or 'manual', + 'actions_available': list(_GENERIC_ACTIONS), + 'setup_checklist': [], + 'discovery_confidence': 1, + 'discovery_reasons': ['Managed app registry entry.'], + } + for app in list_active_apps(session) + ] + if not include_unmanaged: + return apps + managed_container_ids = {mapping.container_id for mapping in session.exec(select(AppContainerMapping)).all()} + for item in _latest_discovered_items(session): + if item.container_id in managed_container_ids or item.import_status == 'managed': + continue + if item.import_status == 'ignored' and not include_ignored: + continue + apps.append(serialize_inventory_app(item)) + return apps + + +def list_unmapped(session: Session, include_ignored: bool = False) -> list[dict[str, Any]]: + return [ + serialize_discovered(item) + for item in _latest_discovered_items(session) + if item.import_status == 'unmapped' or (include_ignored and item.import_status == 'ignored') + ] + + +def list_unknown(session: Session, include_ignored: bool = False) -> list[dict[str, Any]]: + return [ + serialize_discovered(item) + for item in _latest_discovered_items(session) + if item.suggested_app_type == 'unknown' and (include_ignored or item.import_status != 'ignored') + ] + + +def list_suggestions(session: Session) -> list[dict[str, Any]]: + return [item for item in list_discovered(session) if item['importStatus'] not in {'ignored', 'missing'}] + + +def list_mappings(session: Session) -> list[dict[str, Any]]: + return [serialize_mapping(mapping) for mapping in session.exec(select(AppContainerMapping)).all()] + + +def _stable_sequence(values: Any) -> list[str]: + if not isinstance(values, list): + return [] + return sorted(str(value) for value in values if value is not None) + + +def _container_name_key(value: str | None) -> str: + return (value or '').strip().lower() + + +def _is_appops_deploy_artifact_name(value: str | None) -> bool: + from .app_registry import is_appops_deployment_container_name + + return is_appops_deployment_container_name(value) + + +def _setting_json(session: Session, key: str, fallback: Any) -> Any: + row = session.get(AppSetting, key) + if not row or row.value_json is None: + return fallback + return row.value_json + + +def _set_setting_json(session: Session, key: str, value: Any) -> None: + row = session.get(AppSetting, key) + if not row: + row = AppSetting(key=key, value_json=value) + else: + row.value_json = value + row.updated_at = utc_now() + session.add(row) + + +def _drift_id(item: dict[str, Any]) -> str: + material = json.dumps( + { + 'type': item.get('type'), + 'containerId': item.get('containerId'), + 'before': item.get('before'), + 'after': item.get('after'), + }, + sort_keys=True, + default=str, + ) + return f"drift-{hashlib.sha256(material.encode('utf-8')).hexdigest()[:16]}" + + +def _drift_item( + *, + type: str, + severity: str, + container_id: str, + container_name: str, + before: Any, + after: Any, + suggested_action: str, +) -> dict[str, Any]: + item = { + 'type': type, + 'severity': severity, + 'containerId': container_id, + 'containerName': container_name, + 'before': before, + 'after': after, + 'suggestedAction': suggested_action, + 'status': 'open', + } + item['driftId'] = _drift_id(item) + return item + + +def _baseline_container(item: DiscoveredContainer) -> dict[str, Any]: + return { + 'containerId': item.container_id, + 'name': item.name, + 'image': item.image, + 'status': item.status, + 'ports': _stable_sequence(item.ports_json), + 'mounts': _json_clone(item.mounts_json, []), + 'networks': _json_clone(item.networks_json, []), + 'criticality': item.suggested_criticality, + } + + +def update_inventory_baseline(session: Session) -> dict[str, Any]: + current = [ + item + for item in _latest_discovered_items(session) + if item.import_status not in {'ignored', 'missing'} + ] + if not current: + return { + 'success': False, + 'status': 'failed', + 'message': 'Geen actuele inventory beschikbaar. Draai eerst discovery.', + } + + captured_at = utc_now().isoformat() + containers = { + item.container_id: _baseline_container(item) + for item in current + } + previous = _setting_json(session, _DRIFT_BASELINE_SETTING, {}) + snapshot = { + 'version': 1, + 'capturedAt': captured_at, + 'containerCount': len(containers), + 'containers': containers, + } + _set_setting_json(session, _DRIFT_BASELINE_SETTING, snapshot) + _set_setting_json(session, _DRIFT_ACK_SETTING, {'ids': []}) + session.commit() + audit = log_event( + session, + source='manual', + identity='operator', + action_id='inventory.baseline.update', + target='inventory', + target_type='inventory_baseline', + risk='low', + policy_decision='allowed', + reason='Operator stored the current AppOps inventory ledger as drift baseline.', + result='success', + before_state={ + 'capturedAt': previous.get('capturedAt') if isinstance(previous, dict) else None, + 'containerCount': previous.get('containerCount', 0) if isinstance(previous, dict) else 0, + }, + after_state={'capturedAt': captured_at, 'containerCount': len(containers)}, + redacted_metadata={'containerCount': len(containers), 'version': 1}, + ) + return { + 'success': True, + 'status': 'success', + 'message': f'Baseline bijgewerkt voor {len(containers)} containers.', + 'auditId': audit.id, + 'data': {'capturedAt': captured_at, 'containerCount': len(containers)}, + } + + +def _calculate_baseline_drift(session: Session, baseline: dict[str, Any]) -> list[dict[str, Any]]: + raw_baseline = baseline.get('containers') + if not isinstance(raw_baseline, dict): + return [] + + latest = { + item.container_id: item + for item in _latest_discovered_items(session) + } + active_by_name = { + _container_name_key(item.name): item + for item in latest.values() + if item.import_status != 'missing' + } + matched_current_ids: set[str] = set() + drift: list[dict[str, Any]] = [] + + for baseline_id, raw in raw_baseline.items(): + if not isinstance(raw, dict): + continue + current = latest.get(str(baseline_id)) + if current is None or current.import_status == 'missing': + recreated = active_by_name.get(_container_name_key(str(raw.get('name') or ''))) + if recreated is not None: + current = recreated + if current is None or current.import_status == 'missing': + criticality = str(raw.get('criticality') or 'medium') + drift.append(_drift_item( + type='missing_container', + severity='high' if criticality in {'high', 'critical'} else 'medium', + container_id=str(baseline_id), + container_name=str(raw.get('name') or baseline_id), + before=raw.get('status') or 'baseline', + after='missing', + suggested_action='investigate', + )) + continue + + matched_current_ids.add(current.container_id) + comparisons = [ + ('image_changed', 'medium', raw.get('image'), current.image, 'review_baseline'), + ('port_changed', 'medium', _stable_sequence(raw.get('ports')), _stable_sequence(current.ports_json), 'review_exposure'), + ('mount_changed', 'high', raw.get('mounts') or [], current.mounts_json, 'review_storage'), + ('network_changed', 'medium', raw.get('networks') or [], current.networks_json, 'review_network'), + ( + 'status_changed', + 'high' if current.suggested_criticality in {'high', 'critical'} and current.status != 'running' else 'medium', + raw.get('status'), + current.status, + 'review_health', + ), + ] + for drift_type, severity, before, after, suggested_action in comparisons: + if before != after: + drift.append(_drift_item( + type=drift_type, + severity=severity, + container_id=current.container_id, + container_name=current.name, + before=before, + after=after, + suggested_action=suggested_action, + )) + + for current in latest.values(): + if current.container_id in matched_current_ids or current.import_status in {'ignored', 'missing'}: + continue + drift.append(_drift_item( + type='new_container', + severity='low', + container_id=current.container_id, + container_name=current.name, + before=None, + after=current.image, + suggested_action='review_import', + )) + return drift + + +def calculate_drift(session: Session) -> list[dict[str, Any]]: + acknowledged = set(_setting_json(session, _DRIFT_ACK_SETTING, {}).get('ids', [])) + baseline = _setting_json(session, _DRIFT_BASELINE_SETTING, {}) + if isinstance(baseline, dict) and isinstance(baseline.get('containers'), dict): + return [item for item in _calculate_baseline_drift(session, baseline) if item['driftId'] not in acknowledged] + + grouped = _recent_by_container(session) + + latest_by_container: dict[str, DiscoveredContainer] = {} + for container_id, items in grouped.items(): + latest_by_container[container_id] = items[-1] + active_latest_names = { + _container_name_key(item.name) + for item in latest_by_container.values() + if item.import_status != 'missing' and not _is_appops_deploy_artifact_name(item.name) + } + missing_latest_names = { + _container_name_key(item.name) + for item in latest_by_container.values() + if item.import_status == 'missing' and not _is_appops_deploy_artifact_name(item.name) + } + recreated_names = active_latest_names & missing_latest_names + + drift: list[dict[str, Any]] = [] + for container_id, items in grouped.items(): + if _is_appops_deploy_artifact_name(items[-1].name): + continue + if len(items) == 1: + item = items[-1] + if item.import_status in {'discovered', 'unmapped'} and _container_name_key(item.name) not in recreated_names: + drift.append(_drift_item(type='new_container', severity='low', container_id=container_id, container_name=item.name, before=None, after=item.image, suggested_action='review_import')) + continue + previous, current = items[-2], items[-1] + if current.import_status == 'missing': + if _container_name_key(current.name) in recreated_names: + continue + drift.append(_drift_item(type='missing_container', severity='high' if previous.suggested_criticality in {'high', 'critical'} else 'medium', container_id=container_id, container_name=current.name, before=previous.status, after='missing', suggested_action='investigate')) + continue + if previous.image != current.image: + drift.append(_drift_item(type='image_changed', severity='medium', container_id=container_id, container_name=current.name, before=previous.image, after=current.image, suggested_action='review_baseline')) + if _stable_sequence(previous.ports_json) != _stable_sequence(current.ports_json): + drift.append(_drift_item(type='port_changed', severity='medium', container_id=container_id, container_name=current.name, before=previous.ports_json, after=current.ports_json, suggested_action='review_exposure')) + if previous.mounts_json != current.mounts_json: + drift.append(_drift_item(type='mount_changed', severity='high', container_id=container_id, container_name=current.name, before=previous.mounts_json, after=current.mounts_json, suggested_action='review_storage')) + if previous.networks_json != current.networks_json: + drift.append(_drift_item(type='network_changed', severity='medium', container_id=container_id, container_name=current.name, before=previous.networks_json, after=current.networks_json, suggested_action='review_network')) + if previous.status != current.status: + severity = 'high' if current.suggested_criticality in {'high', 'critical'} and current.status != 'running' else 'medium' + drift.append(_drift_item(type='status_changed', severity=severity, container_id=container_id, container_name=current.name, before=previous.status, after=current.status, suggested_action='review_health')) + + known_latest = {items[-1].container_id for items in grouped.values()} + for mapping in session.exec(select(AppContainerMapping)).all(): + if mapping.container_id not in known_latest: + drift.append(_drift_item(type='missing_container', severity='medium', container_id=mapping.container_id, container_name=mapping.container_name, before='mapped', after='missing', suggested_action='investigate')) + + return [item for item in drift if item['driftId'] not in acknowledged] + + +def acknowledge_drift(session: Session, drift_id: str) -> dict[str, Any]: + current = _setting_json(session, _DRIFT_ACK_SETTING, {}) + ids = set(current.get('ids', [])) if isinstance(current, dict) else set() + ids.add(drift_id) + _set_setting_json(session, _DRIFT_ACK_SETTING, {'ids': sorted(ids)}) + session.commit() + log_event( + session, + source='manual', + identity='operator', + action_id='inventory.drift.acknowledge', + target=drift_id, + target_type='inventory_drift', + risk='low', + policy_decision='allowed', + reason='Operator acknowledged inventory drift in AppOps metadata only.', + result='success', + redacted_metadata={'driftId': drift_id}, + ) + return {'success': True, 'message': 'drift_acknowledged', 'status': 'success', 'driftId': drift_id} + + +def _drift_incidents(session: Session) -> list[dict[str, Any]]: + data = _setting_json(session, _DRIFT_INCIDENTS_SETTING, {'items': []}) + items = data.get('items', []) if isinstance(data, dict) else [] + return [item for item in items if isinstance(item, dict)] + + +def list_drift_incidents(session: Session) -> list[dict[str, Any]]: + return [item for item in _drift_incidents(session) if item.get('status') != 'resolved'] + + +def create_drift_incident(session: Session, drift_id: str) -> dict[str, Any]: + drift = next((item for item in calculate_drift(session) if item['driftId'] == drift_id), None) + if not drift: + return {'success': False, 'message': 'drift_not_found_or_acknowledged', 'status': 'failed'} + + incidents = _drift_incidents(session) + existing = next((item for item in incidents if item.get('driftId') == drift_id and item.get('status') != 'resolved'), None) + if existing: + return {'success': True, 'message': 'drift_incident_exists', 'status': 'success', 'incident': existing} + + incident = { + 'id': f"incident-{drift_id}", + 'driftId': drift_id, + 'severity': drift['severity'], + 'status': 'open', + 'title': f"Inventory drift: {str(drift['type']).replace('_', ' ')}", + 'detectedTime': utc_now().isoformat(), + 'evidence': { + 'source': 'Inventory Drift', + 'check': drift['type'], + 'observed': f"{drift['containerName']}: {drift['after']}", + 'expected': str(drift['before']), + 'timestamp': utc_now().isoformat(), + }, + 'suggestedActions': [drift['suggestedAction'], 'inventory.review'], + } + incidents.append(incident) + _set_setting_json(session, _DRIFT_INCIDENTS_SETTING, {'items': incidents}) + session.commit() + log_event( + session, + source='manual', + identity='operator', + action_id='inventory.drift.create_incident', + target=drift_id, + target_type='inventory_drift', + risk='low', + policy_decision='allowed', + reason='Operator created an AppOps incident from inventory drift metadata.', + result='success', + redacted_metadata={'driftId': drift_id, 'incidentId': incident['id']}, + ) + return {'success': True, 'message': 'drift_incident_created', 'status': 'success', 'incident': incident} + + +def update_drift_incident_status(session: Session, incident_id: str, status: str) -> dict[str, Any] | None: + if status not in {'open', 'acknowledged', 'muted', 'resolved'}: + status = 'acknowledged' + incidents = _drift_incidents(session) + updated = False + for incident in incidents: + if incident.get('id') == incident_id: + incident['status'] = status + updated = True + break + if not updated: + return None + _set_setting_json(session, _DRIFT_INCIDENTS_SETTING, {'items': incidents}) + session.commit() + return {'success': True, 'message': f'Incident {incident_id} status updated', 'result': 'success', 'status': 'success'} diff --git a/backend/app/services/job_runner.py b/backend/app/services/job_runner.py new file mode 100644 index 0000000..1074b93 --- /dev/null +++ b/backend/app/services/job_runner.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +import uuid + +from sqlmodel import Session, select + +from ..core.time import utc_now +from ..db.models import JobRun + + +def list_jobs(session: Session): + return session.exec(select(JobRun).order_by(JobRun.started_at.desc())).all() + + +def get_job(session: Session, job_id: str) -> JobRun | None: + return session.get(JobRun, job_id) + + +def create_job( + session: Session, + *, + action_id: str, + target: str, + source: str, + risk: str, + policy_decision: str, + steps: list[str], + audit_event_id: str | None = None, +) -> JobRun: + job = prepare_job( + action_id=action_id, + target=target, + source=source, + risk=risk, + policy_decision=policy_decision, + steps=steps, + audit_event_id=audit_event_id, + ) + session.add(job) + session.commit() + session.refresh(job) + return job + + +def prepare_job( + *, + action_id: str, + target: str, + source: str, + risk: str, + policy_decision: str, + steps: list[str], + audit_event_id: str | None = None, +) -> JobRun: + """Build a job without committing it so admission can be atomic.""" + now = utc_now() + return JobRun( + id=f'job-{uuid.uuid4().hex[:12]}', + type=action_id, + status='queued', + source=source, + action_id=action_id, + target=target, + risk=risk, + policy_decision=policy_decision, + steps_json=json.dumps(steps), + progress=0, + started_at=now, + audit_event_id=audit_event_id, + ) + + +def start_job(session: Session, job: JobRun) -> JobRun: + job.status = 'running' + job.started_at = job.started_at or utc_now() + job.progress = 5 + session.add(job) + session.commit() + session.refresh(job) + return job + + +def complete_job(session: Session, job: JobRun, *, status: str, result_summary: str) -> JobRun: + job.status = status + job.finished_at = utc_now() + job.result_summary = result_summary + job.progress = 100 if status == 'succeeded' else job.progress + if job.started_at: + job.duration_ms = int((job.finished_at - job.started_at).total_seconds() * 1000) + session.add(job) + session.commit() + session.refresh(job) + return job + + +def cancel_job(session: Session, job: JobRun, *, result_summary: str = 'Cancelled by operator.') -> JobRun: + job.status = 'cancelled' + job.finished_at = utc_now() + job.result_summary = result_summary + if job.started_at: + job.duration_ms = int((job.finished_at - job.started_at).total_seconds() * 1000) + session.add(job) + session.commit() + session.refresh(job) + return job + + +def set_job_progress(session: Session, job: JobRun, progress: int, result_summary: str | None = None) -> JobRun: + job.progress = max(0, min(100, int(progress))) + if result_summary: + job.result_summary = result_summary + session.add(job) + session.commit() + session.refresh(job) + return job diff --git a/backend/app/services/knowledge_service.py b/backend/app/services/knowledge_service.py new file mode 100644 index 0000000..5954ed5 --- /dev/null +++ b/backend/app/services/knowledge_service.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +from collections import Counter +from datetime import UTC, datetime +import hashlib +import math +from pathlib import Path +import re +from threading import RLock +from typing import Any, Iterable + +from sqlalchemy import delete, distinct, func +from sqlmodel import Session, select + +from ..core.redaction import REDACTION_PATTERNS, redact_message +from ..core.time import utc_now +from ..db.models import App, AppSetting, AuditEvent, KnowledgeChunk, NotificationEvent, Policy + + +_WORD = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,}") +_HEADING = re.compile(r"^(#{1,4})\s+(.+?)\s*$", re.MULTILINE) +_BLOCKED_FILES: set[str] = set() +_INDEX_MANIFEST_KEY = "knowledge:index_manifest" +_MANIFEST_CACHE: dict[str, Any] = {} +_PERSISTED_CACHE: dict[str, Any] = {} +_INDEX_LOCK = RLock() +_STOPWORDS = { + "aan", "als", "bij", "dat", "de", "den", "der", "die", "dit", "een", "en", "for", "het", + "hoe", "in", "is", "met", "naar", "of", "om", "op", "te", "the", "to", "van", "voor", "wat", +} + + +def _tokens(value: str) -> list[str]: + return [token.casefold() for token in _WORD.findall(value) if token.casefold() not in _STOPWORDS] + + +def _semantic_features(value: str) -> set[str]: + normalized = " ".join(_tokens(value)) + words = normalized.split() + features = {f"word:{word}" for word in words} + features.update(f"pair:{words[index]}_{words[index + 1]}" for index in range(len(words) - 1)) + for word in words: + if len(word) >= 5: + features.update(f"tri:{word[index:index + 3]}" for index in range(len(word) - 2)) + return features + + +def _expanded_query(value: str) -> str: + normalized = value.casefold() + expansions: list[str] = [] + if "authentic" in normalized: + expansions.append("operator authentication private deployment security beveiliging") + if "destruct" in normalized or "geblokkeerd" in normalized: + expansions.append("destructive blocked policy guardrail blokkeren acties") + if ("secret" in normalized or "geheim" in normalized) and ("encrypt" in normalized or "opgeslagen" in normalized): + expansions.append("secret storage encrypted-at-rest Fernet SQLite") + return " ".join([value, *expansions]) + + +def _safe_text(value: str) -> str: + value = re.sub(r"]*>.*?", " ", value, flags=re.IGNORECASE | re.DOTALL) + value = redact_message(value) + return re.sub(r"\s+", " ", value).strip() + + +def _split_markdown(source_uri: str, raw: str) -> Iterable[tuple[str, str, str]]: + matches = list(_HEADING.finditer(raw)) + title = _safe_text(Path(source_uri).stem.replace("_", " ").replace("-", " ").title())[:200] + if matches: + title = _safe_text(matches[0].group(2))[:200] + boundaries = matches or [re.match(r"", raw)] + for index, match in enumerate(boundaries): + start = match.end() if match else 0 + end = boundaries[index + 1].start() if index + 1 < len(boundaries) else len(raw) + section = _safe_text(match.group(2))[:200] if match and match.lastindex else title + body = _safe_text(raw[start:end]) + if not body: + continue + for offset in range(0, len(body), 1800): + content = body[offset:offset + 2200].strip() + if len(content) >= 40: + yield title, section, content + + +def _knowledge_files(root: Path) -> list[Path]: + candidates = [root / "README.md", root / "CHANGELOG.md"] + docs = root / "docs" + if docs.is_dir(): + candidates.extend(sorted(docs.glob("*.md"))) + return [ + path for path in candidates[:160] + if path.is_file() and path.name.casefold() not in _BLOCKED_FILES and path.stat().st_size <= 1_000_000 + ] + + +def _source_manifest(root: Path) -> dict[str, Any]: + files = _knowledge_files(root) + stat_signature = tuple( + (path.relative_to(root).as_posix(), path.stat().st_size, path.stat().st_mtime_ns) + for path in files + ) + cached = _MANIFEST_CACHE.get(str(root)) + if cached and cached.get("statSignature") == stat_signature: + return dict(cached["manifest"]) + digest = hashlib.sha256() + for path in files: + digest.update(path.relative_to(root).as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + manifest = {"hash": digest.hexdigest(), "sourceCount": len(files)} + _MANIFEST_CACHE[str(root)] = {"statSignature": stat_signature, "manifest": manifest} + return dict(manifest) + + +def _redaction_match_count(value: str) -> int: + return sum(len(pattern.findall(value)) for pattern in REDACTION_PATTERNS) + + +def _compact_excerpt(value: str, limit: int = 360) -> str: + sentences = re.split(r"(?<=[.!?])\s+", value.strip()) + summary = " ".join(sentences[:2]).strip() + candidate = summary or value.strip() + return candidate if len(candidate) <= limit else f"{candidate[:limit - 1].rstrip()}…" + + +def _rebuild_index(session: Session, root: Path | None = None) -> dict[str, Any]: + root = (root or Path.cwd()).resolve() + manifest = _source_manifest(root) + rows: list[KnowledgeChunk] = [] + sources: set[str] = set() + redacted_matches = 0 + for path in _knowledge_files(root): + relative = path.relative_to(root).as_posix() + source_uri = f"appops-doc://{relative}" + raw = path.read_text(encoding="utf-8", errors="replace") + redacted_matches += _redaction_match_count(raw) + source_has_chunks = False + for ordinal, (title, section, content) in enumerate(_split_markdown(relative, raw)): + digest = hashlib.sha256(content.encode("utf-8")).hexdigest() + rows.append(KnowledgeChunk( + id=f"kc-{hashlib.sha256(f'{source_uri}:{ordinal}:{digest}'.encode()).hexdigest()[:20]}", + source_uri=source_uri, + source_type="documentation", + title=title, + section=section, + content=content, + content_hash=digest, + )) + source_has_chunks = True + if source_has_chunks: + sources.add(source_uri) + session.exec(delete(KnowledgeChunk)) + session.add_all(rows) + setting = session.get(AppSetting, _INDEX_MANIFEST_KEY) + if not setting: + setting = AppSetting(key=_INDEX_MANIFEST_KEY, is_secret=False) + generated_at = datetime.now(UTC).isoformat() + setting.value_json = { + "manifestHash": manifest["hash"], + "sourceCount": len(sources), + "chunkCount": len(rows), + "generatedAt": generated_at, + "redactedMatches": redacted_matches, + "secretScanStatus": "passed_with_redactions" if redacted_matches else "passed", + } + setting.updated_at = utc_now() + session.add(setting) + session.commit() + _PERSISTED_CACHE.clear() + return { + "status": "ready", + "engine": "local_hybrid_lexical_semantic", + "chunks": len(rows), + "sources": len(sources), + "generatedAt": generated_at, + "manifestHash": manifest["hash"], + "redactedMatches": redacted_matches, + "secretScanStatus": setting.value_json["secretScanStatus"], + } + + +def rebuild_index(session: Session, root: Path | None = None) -> dict[str, Any]: + with _INDEX_LOCK: + return _rebuild_index(session, root) + + +def index_status(session: Session) -> dict[str, Any]: + chunks, sources, last_indexed = session.exec( + select( + func.count(KnowledgeChunk.id), + func.count(distinct(KnowledgeChunk.source_uri)), + func.max(KnowledgeChunk.updated_at), + ) + ).one() + manifest = session.get(AppSetting, _INDEX_MANIFEST_KEY) + metadata = manifest.value_json if manifest and isinstance(manifest.value_json, dict) else {} + return { + "status": "ready" if chunks else "empty", + "engine": "local_hybrid_lexical_semantic", + "chunks": chunks, + "sources": sources, + "lastIndexedAt": metadata.get("generatedAt") or last_indexed, + "secretsIndexed": False, + "secretScanStatus": metadata.get("secretScanStatus", "unknown"), + "redactedMatches": int(metadata.get("redactedMatches", 0)), + "manifestHash": metadata.get("manifestHash"), + "generationMode": "extractive_grounded", + } + + +def ensure_index_current(session: Session, root: Path | None = None) -> dict[str, Any]: + with _INDEX_LOCK: + root = (root or Path.cwd()).resolve() + current = _source_manifest(root) + stored = session.get(AppSetting, _INDEX_MANIFEST_KEY) + stored_hash = stored.value_json.get("manifestHash") if stored and isinstance(stored.value_json, dict) else None + has_chunks = session.exec(select(KnowledgeChunk.id).limit(1)).first() is not None + if not has_chunks or stored_hash != current["hash"]: + return _rebuild_index(session, root) + return index_status(session) + + +def _runtime_chunks(session: Session) -> list[dict[str, str]]: + chunks: list[dict[str, str]] = [] + for app in session.exec(select(App).order_by(App.name)).all(): + chunks.append({ + "id": f"runtime-app-{app.id}", "source_uri": f"appops://apps/{app.id}", "source_type": "live_app", + "title": app.name, "section": "Actuele appstatus", + "content": _safe_text( + f"App {app.name}; categorie {app.category}; status {app.status}; criticality {app.criticality}; " + f"connector {app.connector_status}; container {app.container_status}; protected {app.protected}; review {app.needs_review}." + ), + }) + for policy in session.exec(select(Policy).order_by(Policy.action)).all(): + chunks.append({ + "id": f"runtime-policy-{policy.id}", "source_uri": f"appops://policies/{policy.id}", "source_type": "live_policy", + "title": policy.action, "section": "Actuele policy", + "content": _safe_text( + f"Actie {policy.action}; target {policy.target}; manual {policy.manual}; voice {policy.voice}; " + f"risk {policy.risk}; reden {policy.reason or 'geen'}" + ), + }) + for event in session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(80)).all(): + chunks.append({ + "id": f"runtime-audit-{event.id}", "source_uri": f"appops://audit/{event.id}", "source_type": "live_audit", + "title": event.action_id, "section": "Recent audit-event", + "content": _safe_text( + f"{event.timestamp.isoformat()} actie {event.action_id}; target {event.target}; bron {event.source}; " + f"policy {event.policy_decision}; resultaat {event.result}; reden {event.reason or 'geen'}" + ), + }) + for event in session.exec(select(NotificationEvent).order_by(NotificationEvent.created_at.desc()).limit(40)).all(): + chunks.append({ + "id": f"runtime-notification-{event.id}", "source_uri": f"appops://notifications/{event.id}", "source_type": "live_notification", + "title": _safe_text(event.title), "section": "Recente notificatie", + "content": _safe_text(f"{event.severity} {event.source} {event.category or ''}: {event.message}; status {event.status}"), + }) + return chunks + + +def query(session: Session, question: str, *, limit: int = 6, root: Path | None = None) -> dict[str, Any]: + question = _safe_text(question)[:500] + if len(question) < 2: + raise ValueError("question_required") + status = ensure_index_current(session, root) + cache_key = str(status.get("manifestHash") or status.get("lastIndexedAt") or "empty") + if _PERSISTED_CACHE.get("key") != cache_key: + persisted = [ + { + "id": row.id, "source_uri": row.source_uri, "source_type": row.source_type, + "title": row.title, "section": row.section or row.title, "content": row.content, + } + for row in session.exec(select(KnowledgeChunk)).all() + ] + _PERSISTED_CACHE.update({ + "key": cache_key, + "items": persisted, + "terms": [Counter(_tokens(f"{item['title']} {item['section']} {item['content']}")) for item in persisted], + "features": [_semantic_features(f"{item['title']} {item['section']} {item['content']}") for item in persisted], + }) + persisted = list(_PERSISTED_CACHE.get("items", [])) + persisted_terms = list(_PERSISTED_CACHE.get("terms", [])) + persisted_features = list(_PERSISTED_CACHE.get("features", [])) + candidates = persisted + _runtime_chunks(session) + expanded_question = _expanded_query(question) + query_terms = Counter(_tokens(expanded_question)) + if not query_terms: + raise ValueError("question_has_no_search_terms") + document_frequency: Counter[str] = Counter() + tokenized: list[tuple[dict[str, str], Counter[str], set[str]]] = [] + for index, item in enumerate(candidates): + terms = persisted_terms[index] if index < len(persisted_terms) else Counter( + _tokens(f"{item['title']} {item['section']} {item['content']}") + ) + features = persisted_features[index] if index < len(persisted_features) else _semantic_features( + f"{item['title']} {item['section']} {item['content']}" + ) + tokenized.append((item, terms, features)) + document_frequency.update(terms.keys()) + scored: list[tuple[float, dict[str, str]]] = [] + total = max(1, len(tokenized)) + query_features = _semantic_features(expanded_question) + for item, terms, features in tokenized: + score = 0.0 + title_terms = set(_tokens(f"{item['title']} {item['section']}")) + for term, query_count in query_terms.items(): + if not terms[term]: + continue + idf = math.log((total + 1) / (document_frequency[term] + 1)) + 1 + score += query_count * (1 + math.log(terms[term])) * idf + if term in title_terms: + score += 1.5 * idf + overlap = len(query_features.intersection(features)) + semantic_score = overlap / max(1, math.sqrt(len(query_features) * len(features))) + score += semantic_score * 4.0 + if score > 0.15: + scored.append((score, item)) + selected: list[tuple[float, dict[str, str]]] = [] + source_counts: Counter[str] = Counter() + requested_limit = max(1, min(limit, 10)) + for score, item in sorted(scored, key=lambda value: value[0], reverse=True): + source_uri = item["source_uri"] + if source_counts[source_uri] >= 2: + continue + selected.append((score, item)) + source_counts[source_uri] += 1 + if len(selected) >= requested_limit: + break + results = [] + for rank, (score, item) in enumerate(selected, start=1): + results.append({ + "id": item["id"], "rank": rank, "title": item["title"], "section": item["section"], + "uri": item["source_uri"], "sourceType": item["source_type"], "score": round(score, 3), + "excerpt": item["content"][:700], + }) + if not results: + answer = "Er is geen voldoende relevante, brongebonden context gevonden. Er wordt geen antwoord gegokt." + else: + evidence = "\n".join( + f"[{result['rank']}] {result['title']}: {_compact_excerpt(result['excerpt'])}" + for result in results[:3] + ) + answer = f"Gevonden broncontext:\n{evidence}" + return { + "question": question, + "answer": answer, + "confidence": "high" if len(results) >= 3 else "medium" if results else "none", + "citations": results, + "guardrails": { + "readOnly": True, + "secretsExcluded": True, + "instructionsFromSourcesIgnored": True, + "executionViaPolicyOnly": True, + }, + "retrieval": { + "engine": "local_hybrid_lexical_semantic", + "vectorSpace": "normalized_binary_word_bigram_char_trigram", + "externalEmbeddingProvider": False, + }, + } + + +_RETRIEVAL_EVALUATION_CASES = ( + ("Hoe werkt private operator authenticatie?", ("PRIVATE_DEPLOYMENT_SECURITY", "API_CONTRACT")), + ("Hoe controleer en herstel ik een SQLite backup?", ("BACKUP_RESTORE", "RELEASE_CHECKLIST")), + ("Hoe zijn destructieve acties geblokkeerd?", ("API_CONTRACT", "HARDENING")), + ("Hoe koppelen MCP, RAG en n8n?", ("MCP_RAG_N8N_INTEGRATION",)), + ("Hoe worden connector secrets encrypted-at-rest opgeslagen?", ("SECRET_STORAGE",)), +) + + +def evaluate_retrieval(session: Session) -> dict[str, Any]: + cases = [] + for question, expected_sources in _RETRIEVAL_EVALUATION_CASES: + result = query(session, question, limit=5) + uris = [str(item.get("uri") or "") for item in result.get("citations", [])] + passed = any(expected.lower() in uri.lower() for expected in expected_sources for uri in uris) + cases.append({ + "question": question, + "passed": passed, + "expectedSourceHints": list(expected_sources), + "topSourceUris": uris[:3], + "citationCount": len(uris), + }) + passed_count = sum(1 for case in cases if case["passed"]) + return { + "status": "passed" if passed_count == len(cases) else "needs_tuning", + "evaluationVersion": 2, + "engine": "local_hybrid_lexical_semantic", + "vectorSpace": "normalized_binary_word_bigram_char_trigram", + "passed": passed_count, + "total": len(cases), + "passRate": round(passed_count / max(1, len(cases)), 3), + "cases": cases, + } diff --git a/backend/app/services/maintenance_scheduler.py b/backend/app/services/maintenance_scheduler.py new file mode 100644 index 0000000..726ca8a --- /dev/null +++ b/backend/app/services/maintenance_scheduler.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from threading import Lock +from time import monotonic +from time import perf_counter +from typing import Callable + +from sqlmodel import Session + +from ..api.routes_observability import network_exposure +from ..core.config import settings +from ..db import session as db_session +from ..mcp_server import persist_tool_metrics, run_read_only_canary +from . import media_pipeline_service, notification_service, safety_center_service +from .connector_health import refresh_connector_statuses +from .request_metrics import persist_request_metrics +from .runtime_service import verify_latest_backup_drill + + +logger = logging.getLogger(__name__) +_STATUS_LOCK = Lock() +_TASK_STATUS: dict[str, dict[str, object]] = {} + + +def maintenance_status() -> dict[str, object]: + with _STATUS_LOCK: + tasks = {name: dict(status) for name, status in _TASK_STATUS.items()} + return { + "status": "running", + "tasks": tasks, + "intervalsSeconds": { + "notificationEvaluation": settings.notification_interval_seconds, + "connectorHealth": settings.connector_health_interval_seconds, + "observabilityPersistence": settings.observability_persist_interval_seconds, + "backupRestoreDrill": settings.restore_drill_interval_seconds, + "mcpCanary": settings.mcp_canary_interval_seconds if settings.mcp_enabled else 0, + }, + } + + +def warm_operational_caches() -> None: + with Session(db_session.engine) as session: + safety_center_service.get_dashboard(session) + media_pipeline_service.summary(session) + network_exposure(False, session) + + +def evaluate_notifications_and_retries() -> None: + with Session(db_session.engine) as session: + notification_service.retry_failed_deliveries(session) + notification_service.evaluate_source(session, "all") + + +def reconcile_connector_health() -> None: + with Session(db_session.engine) as session: + refresh_connector_statuses(session) + + +def persist_observability() -> None: + with Session(db_session.engine) as session: + persist_request_metrics(session) + persist_tool_metrics(session) + + +def run_restore_drill() -> None: + with Session(db_session.engine) as session: + verify_latest_backup_drill(session) + + +async def _safe_thread(label: str, function: Callable[[], object]) -> None: + started = perf_counter() + try: + await asyncio.to_thread(function) + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 - maintenance failures must not stop the API. + logger.exception("Scheduled %s failed", label) + state = {"status": "failed", "error": "scheduled_task_failed"} + else: + state = {"status": "ok"} + state.update( + { + "lastRunAt": datetime.now(UTC).isoformat(), + "durationMs": round((perf_counter() - started) * 1000, 2), + } + ) + with _STATUS_LOCK: + _TASK_STATUS[label] = state + + +async def run_periodic_maintenance() -> None: + await asyncio.sleep(2) + if settings.connector_health_interval_seconds: + await _safe_thread("connector health reconciliation", reconcile_connector_health) + await _safe_thread("operational cache warmup", warm_operational_caches) + + now = monotonic() + next_notification = now + min(15, settings.notification_interval_seconds) if settings.notification_interval_seconds else float("inf") + next_connector_health = now + settings.connector_health_interval_seconds if settings.connector_health_interval_seconds else float("inf") + next_observability = now + min(10, settings.observability_persist_interval_seconds) if settings.observability_persist_interval_seconds else float("inf") + next_restore = now + min(60, settings.restore_drill_interval_seconds) if settings.restore_drill_interval_seconds else float("inf") + next_canary = now + min(10, settings.mcp_canary_interval_seconds) if settings.mcp_enabled and settings.mcp_canary_interval_seconds else float("inf") + + while True: + now = monotonic() + if now >= next_notification: + await _safe_thread("notification evaluation", evaluate_notifications_and_retries) + next_notification = monotonic() + settings.notification_interval_seconds + if now >= next_connector_health: + await _safe_thread("connector health reconciliation", reconcile_connector_health) + next_connector_health = monotonic() + settings.connector_health_interval_seconds + if now >= next_canary: + await _safe_thread("MCP read-only canary", run_read_only_canary) + next_canary = monotonic() + settings.mcp_canary_interval_seconds + if now >= next_observability: + await _safe_thread("observability persistence", persist_observability) + next_observability = monotonic() + settings.observability_persist_interval_seconds + if now >= next_restore: + await _safe_thread("backup restore drill", run_restore_drill) + next_restore = monotonic() + settings.restore_drill_interval_seconds + await asyncio.sleep(5) diff --git a/backend/app/services/mcp_hub_deployment_reconciliation.py b/backend/app/services/mcp_hub_deployment_reconciliation.py new file mode 100644 index 0000000..6cb3ff8 --- /dev/null +++ b/backend/app/services/mcp_hub_deployment_reconciliation.py @@ -0,0 +1,691 @@ +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/itworx_mcp_hub" +_COMPOSE_PROJECT = "itworx-mcp-hub" + +# One-time fail-closed re-attestation for the existing MCP Hub runtime. The +# script never builds, restarts, recreates, stops or deploys a container. It +# repairs only controller state after proving the exact target, Git ancestry, +# complete Compose topology and every currently running image identity. +_RECONCILE_SCRIPT = r''' +import fcntl +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import time + +repository, expected_compose_project = sys.argv[1:3] +registry_path = Path("/opt/gitea-deploy/projects.json") +host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") +global_lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") +owned_image_source = "https://gitea.example.com/jens/itworx-mcp-hub" + +if repository != "jens/itworx_mcp_hub" or expected_compose_project != "itworx-mcp-hub": + raise SystemExit(40) +if registry_path.is_symlink() or not registry_path.is_file(): + raise SystemExit(20) +try: + if host_registry_path.is_symlink() or not host_registry_path.is_file() or not os.path.samefile(registry_path, host_registry_path): + raise SystemExit(36) +except OSError: + raise SystemExit(36) + +try: + projects = json.loads(registry_path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(31) +project = projects.get(repository) if isinstance(projects, dict) else None +if not isinstance(project, dict): + raise SystemExit(22) +expected_containers = project.get("containers") +expected_fixed = { + "itworx-mcp-hub-connector-appops-1", + "itworx-mcp-hub-connector-gitea-1", + "itworx-mcp-hub-connector-knowledge-1", + "itworx-mcp-hub-connector-mobilityops-1", + "itworx-mcp-hub-connector-projectbrain-1", + "itworx-mcp-hub-connector-stitch-1", + "itworx-mcp-hub-connector-unraid-1", + "itworx-mcp-hub-contextforge-1", + "itworx-mcp-hub-control-api-1", + "itworx-mcp-hub-dataplane-router-1", + "itworx-mcp-hub-docker-read-broker-1", + "itworx-mcp-hub-postgres-1", + "itworx-mcp-hub-redis-1", + "itworx-mcp-hub-web-1", + "itworx-mcp-hub-worker-1", +} +if ( + project.get("branch") != "main" + or project.get("slug") != expected_compose_project + or project.get("compose_project") != expected_compose_project + or not isinstance(expected_containers, list) + or set(expected_containers) != expected_fixed +): + raise SystemExit(23) + +state_path = state_root / f"{expected_compose_project}.json" +project_lock_path = state_root / "locks" / f"{expected_compose_project}.lock" +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +canonical_repository_root = Path("/mnt/cache/appdata/gitea/git/repositories") +canonical_bare_repo = canonical_repository_root / owner / f"{name}.git" +if not bare_repo.is_dir() or bare_repo.is_symlink(): + raise SystemExit(24) + + +def git_run(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + + +def canonical_git_run(*args): + return subprocess.run( + ["git", f"--git-dir={canonical_bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + + +def canonical_image_ancestry_is_proven(recorded_revision, image_revision, expected_main): + if not canonical_bare_repo.is_dir() or canonical_bare_repo.is_symlink(): + return False + resolved_recorded = canonical_git_run("rev-parse", f"{recorded_revision}^{{commit}}") + resolved_image = canonical_git_run("rev-parse", f"{image_revision}^{{commit}}") + resolved_main = canonical_git_run("rev-parse", "refs/heads/main") + if not ( + resolved_recorded.returncode == 0 + and resolved_recorded.stdout.strip() == recorded_revision + and resolved_image.returncode == 0 + and resolved_image.stdout.strip() == image_revision + and resolved_main.returncode == 0 + and resolved_main.stdout.strip() == expected_main + ): + return False + return ( + canonical_git_run( + "merge-base", "--is-ancestor", recorded_revision, image_revision + ).returncode + == 0 + and canonical_git_run( + "merge-base", "--is-ancestor", image_revision, expected_main + ).returncode + == 0 + ) + + +main = git_run("rev-parse", "refs/heads/main") +main_revision = main.stdout.strip() +if main.returncode != 0 or re.fullmatch(r"[0-9a-f]{40}", main_revision) is None: + raise SystemExit(26) + + +def main_declares_external_image(image_ref): + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/:@-]{1,300}@sha256:[0-9a-f]{64}", image_ref) is None: + return False + for compose_path in ( + "infrastructure/compose/docker-compose.yml", + "infrastructure/compose/docker-compose.prod.yml", + ): + source = git_run("show", f"{main_revision}:{compose_path}") + if source.returncode != 0: + return False + for line in source.stdout.splitlines(): + if line.strip() in {f"image: {image_ref}", f'image: "{image_ref}"', f"image: '{image_ref}'"}: + return True + return False + + +def main_declares_sessionless_dataplane_image(container_name, image_ref): + if ( + container_name != "itworx-mcp-hub-dataplane-router-1" + or image_ref != "itworx-mcp-hub/dataplane:sessionless-final" + ): + return False + sessionless_commit = "b519a8c4cf17cd9df00a575acfe096838aab7789" + resolved = git_run("rev-parse", f"{sessionless_commit}^{{commit}}") + if resolved.returncode != 0 or resolved.stdout.strip() != sessionless_commit: + return False + if git_run("merge-base", "--is-ancestor", sessionless_commit, main_revision).returncode != 0: + return False + + router = git_run( + "show", + f"{sessionless_commit}:services/dataplane_router/router.py", + ) + tests = git_run( + "show", + f"{sessionless_commit}:tests/test_dataplane_session_headers.py", + ) + evidence = git_run( + "show", + f"{main_revision}:evidence/mcp-session-boundary-20260916/SUMMARY.md", + ) + if router.returncode != 0 or tests.returncode != 0 or evidence.returncode != 0: + return False + + router_contract = ( + 'MCP_TRANSPORT_STATE_HEADERS = frozenset({b"mcp-session-id"})', + 'if "mcp-session-id" in response.headers:', + 'del response.headers["mcp-session-id"]', + ) + test_contract = ( + "2025-11-25", + "2026-07-28", + "mcp-session-id", + ) + evidence_contract = ( + sessionless_commit, + "itworx-mcp-hub/dataplane:sessionless-final", + "permanently enforce stateless public transport", + ) + return ( + all(fragment in router.stdout for fragment in router_contract) + and all(fragment in tests.stdout for fragment in test_contract) + and all(fragment in evidence.stdout for fragment in evidence_contract) + ) + + +def main_declares_contextforge_compat_image(container_name, image_ref): + if container_name != "itworx-mcp-hub-contextforge-1": + return False + manifest = git_run( + "show", + f"{main_revision}:infrastructure/images/contextforge/compat-image.env", + ) + builder = git_run( + "show", + f"{main_revision}:scripts/build_contextforge_compat_image.sh", + ) + if manifest.returncode != 0 or builder.returncode != 0: + return False + + values = {} + for raw_line in manifest.stdout.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + return False + key, value = line.split("=", 1) + if key in values: + return False + values[key] = value.strip().strip('"').strip("'") + + expected_keys = { + "CONTEXTFORGE_UPSTREAM_COMMIT", + "CONTEXTFORGE_ALEMBIC_HEAD", + "CONTEXTFORGE_MCP_SDK", + "CONTEXTFORGE_LOCAL_IMAGE", + } + upstream_commit = str(values.get("CONTEXTFORGE_UPSTREAM_COMMIT") or "") + alembic_head = str(values.get("CONTEXTFORGE_ALEMBIC_HEAD") or "") + mcp_sdk = str(values.get("CONTEXTFORGE_MCP_SDK") or "") + local_image = str(values.get("CONTEXTFORGE_LOCAL_IMAGE") or "") + if ( + set(values) != expected_keys + or re.fullmatch(r"[0-9a-f]{40}", upstream_commit) is None + or re.fullmatch(r"[0-9a-f]{12}", alembic_head) is None + or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", mcp_sdk) is None + or local_image != image_ref + or local_image != f"itworx-mcp-hub/contextforge-schema:{alembic_head}" + ): + return False + + required_builder_contract = ( + 'git -C "$SRC" fetch --depth 1 origin "$CONTEXTFORGE_UPSTREAM_COMMIT"', + 'git -C "$SRC" checkout --detach FETCH_HEAD', + 'MCP_REQUIREMENT="mcp==${CONTEXTFORGE_MCP_SDK}"', + 'alembic -c mcpgateway/alembic.ini heads', + 'docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"', + ) + return all(fragment in builder.stdout for fragment in required_builder_contract) + + +state_root.mkdir(parents=True, exist_ok=True) +project_lock_path.parent.mkdir(parents=True, exist_ok=True) +try: + project_lock = project_lock_path.open("a+", encoding="utf-8") + global_lock = global_lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(29) + +with project_lock: + try: + fcntl.flock(project_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + with global_lock: + try: + fcntl.flock(global_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + + try: + current_projects = json.loads(registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(31) + if current_projects.get(repository) != project: + raise SystemExit(37) + + if state_path.is_symlink() or not state_path.is_file(): + raise SystemExit(42) + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(42) + if not isinstance(state, dict) or state.get("schema_version") != 3 or state.get("repository") != repository: + raise SystemExit(43) + + recorded_live = str(state.get("live_revision") or state.get("revision") or "") + recorded_status = str(state.get("status") or "") + recorded_live_status = str(state.get("live_status") or "") + last_attempt = dict(state.get("last_attempt") or {}) + attempt_revision = str(last_attempt.get("revision") or "") + attempt_status = str(last_attempt.get("status") or "") + last_attempt_message = str(last_attempt.get("message") or "") + receipt = state.get("live_receipt") + recorded_artifacts = state.get("live_artifacts") + + already_reconciled = ( + state.get("live_legacy_provenance") is True + and recorded_status == "success" + and recorded_live_status == "healthy" + and re.fullmatch(r"[0-9a-f]{40}", recorded_live) is not None + and isinstance(recorded_artifacts, dict) + ) + if not already_reconciled and ( + recorded_status != "failed" + or recorded_live_status not in {"healthy", "unknown"} + or state.get("live_legacy_provenance") is not False + or attempt_status != "failed" + or re.fullmatch(r"[0-9a-f]{40}", recorded_live) is None + or re.fullmatch(r"[0-9a-f]{40}", attempt_revision) is None + or attempt_revision == recorded_live + or not isinstance(receipt, dict) + or receipt.get("schema") != "gitea-deploy-receipt.v1" + or receipt.get("repository") != repository + or receipt.get("revision") != recorded_live + or not isinstance(recorded_artifacts, dict) + ): + raise SystemExit(52) + + # deploy.py deliberately downgrades the prior live status to unknown + # when capture_compose_rollback() fails before production mutation. + # Accept that state only when the durable attempt message is one of the + # bounded snapshot-stage failure classes and no rollback/source-restore + # error was recorded. The runtime is still re-proven container by + # container below before any controller state is changed. + snapshot_failure_prefixes = ( + "Refusing to mutate a Compose project with unrecorded containers:", + "Deployment state claims a live revision but all production containers are missing", + "Cannot capture a complete rollback snapshot; containers are missing:", + "Existing production containers require a valid healthy live deployment state", + "Container ", + "Existing deployment state is not bound to its live revision", + "Recorded deployment receipt ", + "Existing deployment no longer matches its verified live receipt", + "Expected containers are not healthy/running:", + "Compose project container set does not match the allowlisted runtime:", + "Containers are linked to the wrong Compose project:", + "Running container image IDs do not match the verified artifacts:", + "Immutable image provenance does not match the requested deployment:", + ) + if ( + not already_reconciled + and recorded_live_status == "unknown" + and ( + not last_attempt_message.startswith(snapshot_failure_prefixes) + or "; rollback error:" in last_attempt_message + or "; source rollback error:" in last_attempt_message + ) + ): + raise SystemExit(55) + + resolved_live = git_run("rev-parse", f"{recorded_live}^{{commit}}") + if resolved_live.returncode != 0 or resolved_live.stdout.strip() != recorded_live: + raise SystemExit(25) + if git_run("merge-base", "--is-ancestor", recorded_live, main_revision).returncode != 0: + raise SystemExit(52) + if not already_reconciled: + resolved_attempt = git_run("rev-parse", f"{attempt_revision}^{{commit}}") + if resolved_attempt.returncode != 0 or resolved_attempt.stdout.strip() != attempt_revision: + raise SystemExit(25) + if git_run("merge-base", "--is-ancestor", recorded_live, attempt_revision).returncode != 0: + raise SystemExit(52) + if git_run("merge-base", "--is-ancestor", attempt_revision, main_revision).returncode != 0: + raise SystemExit(52) + + listed = subprocess.run( + [ + "docker", + "ps", + "--all", + "--filter", + f"label=com.docker.compose.project={expected_compose_project}", + "--format", + "{{.Names}}", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + project_names = [line.strip() for line in listed.stdout.splitlines() if line.strip()] + if listed.returncode != 0 or set(project_names) != set(expected_containers): + raise SystemExit(47) + + old_containers = recorded_artifacts.get("containers") + if not isinstance(old_containers, dict) or set(old_containers) != set(expected_containers): + raise SystemExit(52) + + container_artifacts = {} + drift_found = False + for name in expected_containers: + inspected = subprocess.run( + ["docker", "inspect", name], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if inspected.returncode != 0: + raise SystemExit(44) + try: + container = json.loads(inspected.stdout)[0] + except Exception: + raise SystemExit(44) + runtime_state = dict(container.get("State") or {}) + health = str(dict(runtime_state.get("Health") or {}).get("Status") or "none") + labels = dict(dict(container.get("Config") or {}).get("Labels") or {}) + config = dict(container.get("Config") or {}) + image_id = str(container.get("Image") or "") + image_ref = str(config.get("Image") or "") + if not runtime_state.get("Running") or health in {"starting", "unhealthy"}: + raise SystemExit(45) + if labels.get("com.docker.compose.project") != expected_compose_project: + raise SystemExit(47) + if re.fullmatch(r"sha256:[0-9a-f]{64}", image_id) is None or not image_ref: + raise SystemExit(46) + + image_result = subprocess.run( + ["docker", "image", "inspect", image_id], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if image_result.returncode != 0: + raise SystemExit(50) + try: + image = json.loads(image_result.stdout)[0] + except Exception: + raise SystemExit(50) + image_labels = dict(dict(image.get("Config") or {}).get("Labels") or {}) + image_source = str(image_labels.get("org.opencontainers.image.source") or "") + image_revision = str(image_labels.get("org.opencontainers.image.revision") or "") + + recorded = old_containers.get(name) + if not isinstance(recorded, dict): + raise SystemExit(52) + recorded_image_id = str(recorded.get("image_id") or "") + recorded_container_id = str(recorded.get("container_id") or "") + if re.fullmatch(r"sha256:[0-9a-f]{64}", recorded_image_id) is None: + raise SystemExit(52) + + image_changed = image_id != recorded_image_id + if image_changed: + contextforge_compat_image = main_declares_contextforge_compat_image(name, image_ref) + sessionless_dataplane_image = ( + not already_reconciled + and main_declares_sessionless_dataplane_image(name, image_ref) + ) + if contextforge_compat_image or sessionless_dataplane_image: + pass + elif image_source == owned_image_source: + if re.fullmatch(r"[0-9a-f]{40}", image_revision) is None: + raise SystemExit(51) + resolved_image = git_run("rev-parse", f"{image_revision}^{{commit}}") + mounted_recorded_to_image = git_run("merge-base", "--is-ancestor", recorded_live, image_revision) + mounted_image_to_main = git_run("merge-base", "--is-ancestor", image_revision, main_revision) + mounted_ancestry_proven = ( + resolved_image.returncode == 0 + and resolved_image.stdout.strip() == image_revision + and mounted_recorded_to_image.returncode == 0 + and mounted_image_to_main.returncode == 0 + ) + if not mounted_ancestry_proven and not canonical_image_ancestry_is_proven( + recorded_live, image_revision, main_revision + ): + raise SystemExit(51) + elif not main_declares_external_image(image_ref): + raise SystemExit(51) + + container_id = str(container.get("Id") or "") + if not container_id: + raise SystemExit(46) + if image_id != recorded_image_id or container_id != recorded_container_id: + drift_found = True + + container_artifacts[name] = { + "container_id": container_id, + "image_id": image_id, + "image_ref": image_ref, + "container_revision": str(labels.get("org.opencontainers.image.revision") or ""), + "image_revision": image_revision, + "image_source": image_source, + "image_tree": str(image_labels.get("org.opencontainers.image.source-tree") or ""), + "image_version": str(image_labels.get("org.opencontainers.image.version") or ""), + "image_created": str(image_labels.get("org.opencontainers.image.created") or ""), + } + + if already_reconciled: + if drift_found: + raise SystemExit(52) + print("already-reconciled", flush=True) + raise SystemExit(0) + if not drift_found: + raise SystemExit(52) + + artifacts = {"containers": container_artifacts, "compose_sha256": ""} + now = int(time.time()) + payload = { + "schema_version": 3, + "repository": repository, + "slug": expected_compose_project, + "compose_project": expected_compose_project, + "revision": recorded_live, + "live_revision": recorded_live, + "live_status": "healthy", + "live_legacy_provenance": True, + "live_artifacts": artifacts, + "live_receipt": {}, + "rollback_snapshot": {}, + "status": "success", + "message": "Verified mixed MCP Hub runtime reconciled as a legacy provenance baseline", + "updated_at_epoch": now, + "artifacts": artifacts, + "last_attempt": { + "revision": recorded_live, + "status": "success", + "message": "Verified MCP Hub runtime state reconciliation", + "updated_at_epoch": now, + "metadata": {"reconciled_failed_revision": attempt_revision}, + }, + "previous_revision": recorded_live, + "rollback_restored_revision": "", + } + + backup = state_path.with_name(f"{state_path.name}.mcp-hub-reconcile-backup-{now}-{os.getpid()}") + temporary = state_path.with_name(f".{state_path.name}.{os.getpid()}.mcp-hub-reconcile.tmp") + try: + mode = stat.S_IMODE(state_path.stat().st_mode) + shutil.copy2(state_path, backup) + with temporary.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(53) + + try: + verify = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(54) + verified_containers = dict(dict(verify.get("live_artifacts") or {}).get("containers") or {}) + if not ( + verify.get("repository") == repository + and verify.get("live_revision") == recorded_live + and verify.get("live_status") == "healthy" + and verify.get("live_legacy_provenance") is True + and verify.get("status") == "success" + and set(verified_containers) == set(expected_containers) + and all(str(dict(verified_containers[name]).get("image_id") or "") == str(container_artifacts[name]["image_id"]) for name in expected_containers) + ): + raise SystemExit(54) + +print("reconciled", flush=True) +''' + + +class McpHubDeploymentReconciliation: + """Re-attest only the exact proven MCP Hub state drift, without touching runtime.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + return { + "status": "controller_unavailable", + "reason": redact_message( + str(status.get("reason") or status.get("status") or "unavailable") + ), + } + controller = dict(status.get("container") or {}) + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return { + "status": "controller_unavailable", + "reason": "deployment controller is not running", + } + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if not config or not container_id: + return { + "status": "connector_unavailable", + "reason": "Portainer deployment control is unavailable", + } + + command = ["python3", "-c", _RECONCILE_SCRIPT, _REPOSITORY, _COMPOSE_PROJECT] + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return {"status": "error", "reason": "MCP Hub state reconciliation could not start"} + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + deadline = time.monotonic() + 60 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return {"status": "error", "reason": "MCP Hub state reconciliation timed out"} + reasons = { + 20: "deployment projects registry is unavailable or unsafe", + 22: "MCP Hub deployment target is missing", + 23: "MCP Hub deployment target differs from the bounded production topology", + 24: "mounted MCP Hub bare repository is unavailable", + 25: "MCP Hub deployment revisions cannot be resolved", + 26: "MCP Hub main cannot be resolved", + 29: "deployment lock files cannot be opened", + 30: "deployment locks cannot be acquired", + 31: "deployment projects registry cannot be read", + 36: "deployment projects registry host alias is unavailable or mismatched", + 37: "MCP Hub deployment target changed while reconciliation was waiting", + 40: "fixed MCP Hub reconciliation contract was altered", + 42: "MCP Hub deployment state file is unavailable or invalid", + 43: "MCP Hub deployment state is not a modern controller state", + 44: "a live MCP Hub container cannot be inspected", + 45: "a live MCP Hub container is not running and healthy", + 46: "a live MCP Hub container has an invalid immutable image identity", + 47: "live MCP Hub Compose project differs from the controller target", + 50: "a live MCP Hub image cannot be inspected", + 51: "live MCP Hub image revision or external immutable image is not proven by the recorded-live to main chain", + 52: "controller state drift is not the exact known recoverable MCP Hub state", + 53: "reconciled MCP Hub deployment state could not be written durably", + 54: "reconciled MCP Hub deployment state did not verify after write", + 55: "unknown MCP Hub live status was not produced by a proven recoverable snapshot-stage failure", + } + if exit_code != 0: + return { + "status": "validation_failed", + "reason": reasons.get(exit_code, "MCP Hub deployment reconciliation failed closed"), + } + return { + "status": "reconciled", + "repository": _REPOSITORY, + "composeProject": _COMPOSE_PROJECT, + } diff --git a/backend/app/services/mcp_operations.py b/backend/app/services/mcp_operations.py new file mode 100644 index 0000000..99b77ec --- /dev/null +++ b/backend/app/services/mcp_operations.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import hashlib +import json +import re +from threading import Lock +from typing import Any + +from sqlmodel import Session + +from ..core.redaction import redact_message, redact_value +from ..core.time import utc_now +from ..db.models import AppSetting +from .action_execution import ( + execute_approved_action, + execute_governed_action, + mcp_execution_supported, +) +from .action_registry import ACTION_RISK, ACTIONS, action_capability +from .approval_service import ( + approval_fingerprint, + get_approval, + mark_approval_executed, +) +from .audit_logger import log_event +from .job_runner import cancel_job, get_job +from .policy_engine import evaluate_policy + +_IDEMPOTENCY_LOCK = Lock() +_IDEMPOTENCY_PREFIX = "mcp:operation:idempotency:" +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") + + +def _reconciliation_required_response( + *, + action: str, + target: str, + risk: str, + original_policy_decision: str | None, +) -> dict[str, Any]: + return { + "action": action, + "target": target, + "risk": risk, + "policyDecision": "blocked", + "originalPolicyDecision": original_policy_decision, + "created": False, + "executed": False, + "executionSupported": mcp_execution_supported(action), + "executionState": "reconciliation_required", + "reason": ( + "A previous MCP attempt crossed the durable execution boundary but did not " + "persist a terminal response. Re-execution is refused to prevent a duplicate mutation." + ), + "nextStep": ( + "Inspect the target, audit trail and any job state to reconcile the prior attempt; " + "do not retry this idempotency key blindly." + ), + } + + +def request_operation( + session: Session, + *, + action: str, + target: str, + reason: str, + idempotency_key: str, +) -> dict[str, Any]: + """Execute an MCP mutation immediately when central policy permits it. + + MCP is intentionally a no-human-approval surface. A policy decision of + ``approval_required`` is accepted only when AppOps has a typed executor for the + exact action; it is then executed directly through that executor and audited with + the original decision preserved as metadata. ``blocked`` actions stay blocked. + Actions without a typed executor fail closed instead of creating an Approval row. + + A durable idempotency reservation is committed before any side effect. Once the + reservation enters ``executing``, a replay without a terminal response is refused + as ``reconciliation_required`` rather than risking a second mutation. + """ + + safe_action = action.strip()[:160] + safe_target = target.strip()[:240] + safe_reason = redact_message(reason.strip())[:1000] + if safe_action not in ACTIONS: + raise ValueError("unknown_action") + if not safe_target: + raise ValueError("target_required") + if not safe_reason: + raise ValueError("reason_required") + if not _IDEMPOTENCY_KEY.fullmatch(idempotency_key): + raise ValueError("invalid_idempotency_key") + + capability = action_capability(safe_action) + policy = evaluate_policy( + session, + action=safe_action, + target=safe_target, + source="automation", + ) + risk = str(policy.get("risk") or ACTION_RISK.get(safe_action, "critical")) + if capability == "read_only": + return { + "action": safe_action, + "target": safe_target, + "risk": risk, + "policyDecision": policy["decision"], + "created": False, + "executed": False, + "executionSupported": False, + "nextStep": "Use the corresponding read-only MCP tool.", + } + + digest = hashlib.sha256( + json.dumps( + { + "action": safe_action, + "target": safe_target, + "reason": safe_reason, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + row_key = ( + f"{_IDEMPOTENCY_PREFIX}{hashlib.sha256(idempotency_key.encode()).hexdigest()}" + ) + + with _IDEMPOTENCY_LOCK: + row = session.get(AppSetting, row_key) + if row: + if row.value_json.get("requestDigest") != digest: + raise ValueError("idempotency_key_reused_with_different_request") + if row.value_json.get("response") is not None: + return {**row.value_json.get("response", {}), "idempotentReplay": True} + if row.value_json.get("state") == "executing": + response = _reconciliation_required_response( + action=safe_action, + target=safe_target, + risk=risk, + original_policy_decision=row.value_json.get( + "originalPolicyDecision" + ), + ) + return {**response, "idempotentReplay": True} + else: + row = AppSetting( + key=row_key, + value_json={ + "requestDigest": digest, + "state": "reserved", + "reservedAt": utc_now().isoformat(), + "action": safe_action, + "target": safe_target, + }, + is_secret=False, + ) + session.add(row) + session.commit() + + if policy["decision"] == "blocked": + audit = log_event( + session, + source="automation", + identity="chatgpt-mcp", + action_id=safe_action, + target=safe_target, + risk=risk, + policy_decision="blocked", + reason=policy["reason"], + result="blocked", + redacted_metadata={"integration": "mcp", "request": "operation"}, + ) + response = { + "action": safe_action, + "target": safe_target, + "risk": risk, + "policyDecision": "blocked", + "reason": policy["reason"], + "created": False, + "executed": False, + "executionSupported": False, + "auditId": audit.id, + "nextStep": "Blocked by AppOps policy. MCP does not create approvals.", + } + elif not mcp_execution_supported(safe_action): + reason_text = ( + "MCP no-human-approval mode has no typed executor for this action; " + "the operation is blocked instead of creating an approval request." + ) + audit = log_event( + session, + source="automation", + identity="chatgpt-mcp", + action_id=safe_action, + target=safe_target, + risk=risk, + policy_decision="blocked", + reason=reason_text, + result="blocked", + redacted_metadata={ + "integration": "mcp", + "request": "operation", + "originalPolicyDecision": policy["decision"], + }, + ) + response = { + "action": safe_action, + "target": safe_target, + "risk": risk, + "policyDecision": "blocked", + "originalPolicyDecision": policy["decision"], + "reason": reason_text, + "created": False, + "executed": False, + "executionSupported": False, + "auditId": audit.id, + "nextStep": "No MCP approval will be requested; add a typed executor if this action should become autonomous.", + } + else: + original_decision = str(policy["decision"]) + authorization_reason = safe_reason + if original_decision == "approval_required": + authorization_reason = redact_message( + f"{safe_reason} MCP autonomous execution accepted the policy's approval-required posture without a human approval step: {policy['reason']}" + )[:1000] + + # This commit is the side-effect boundary. If the process dies after this + # point and before a terminal response is stored, a replay sees + # state=executing and refuses to mutate again. + row.value_json = { + **row.value_json, + "state": "executing", + "executingAt": utc_now().isoformat(), + "originalPolicyDecision": original_decision, + } + row.updated_at = utc_now() + session.add(row) + session.commit() + + result = execute_governed_action( + session, + action=safe_action, + target=safe_target, + source="chatgpt-mcp", + risk=risk, + reason=authorization_reason, + original_policy_decision=original_decision, + ) + response = { + **result, + "action": safe_action, + "target": safe_target, + "risk": risk, + "policyDecision": str(result.get("policyDecision") or "allowed"), + "originalPolicyDecision": original_decision, + "created": False, + "executed": True, + "executionSupported": True, + "nextStep": ( + "Use server.job.status for the returned job if further progress inspection is needed." + if result.get("jobId") + else "Operation completed through the typed MCP executor." + ), + } + + row.value_json = { + **row.value_json, + "state": "completed", + "completedAt": utc_now().isoformat(), + "response": response, + } + row.updated_at = utc_now() + session.add(row) + session.commit() + return {**response, "idempotentReplay": False} + + +def operation_status(session: Session, approval_id: str) -> dict[str, Any]: + """Legacy status reader for approval records created before MCP no-approval mode.""" + + approval = get_approval(session, approval_id.strip()) + if not approval: + raise ValueError("approval_not_found") + return redact_value( + { + "approvalId": approval.id, + "action": approval.action, + "target": approval.target, + "risk": approval.risk, + "status": approval.status, + "actionFingerprint": approval.action_fingerprint, + "expiresAt": approval.expires_at.isoformat() + if approval.expires_at + else None, + "executionSupported": mcp_execution_supported(approval.action), + "jobId": approval.job_id, + "auditId": approval.audit_event_id, + } + ) + + +def execute_operation( + session: Session, + *, + approval_id: str, + expected_action_fingerprint: str, +) -> dict[str, Any]: + """Legacy executor for approval records created before MCP no-approval mode.""" + + approval = get_approval(session, approval_id.strip()) + if not approval: + raise ValueError("approval_not_found") + if approval.status == "executed": + return operation_status(session, approval.id) + if approval.status != "approved": + raise ValueError("approval_not_approved") + if approval.expires_at is not None and approval.expires_at <= utc_now(): + raise ValueError("approval_expired") + expected = approval_fingerprint( + approval.action, + approval.target, + approval.source, + approval.risk, + approval.request_context_json, + ) + supplied = expected_action_fingerprint.strip() + if not supplied or supplied != expected or approval.action_fingerprint != expected: + raise ValueError("approval_context_mismatch") + if not mcp_execution_supported(approval.action): + raise ValueError("operation_executor_unavailable") + + result = execute_approved_action(session, approval) + if result.get("success"): + mark_approval_executed( + session, + approval, + job_id=str(result["jobId"]), + audit_event_id=str(result["auditId"]), + ) + return redact_value({**result, "approvalId": approval.id}) + + +def job_status(session: Session, job_id: str) -> dict[str, Any]: + job = get_job(session, job_id.strip()) + if not job: + raise ValueError("job_not_found") + return redact_value(job.model_dump(mode="json")) + + +def cancel_operation_job(session: Session, job_id: str) -> dict[str, Any]: + job = get_job(session, job_id.strip()) + if not job: + raise ValueError("job_not_found") + from .privatemuse_deployment_recovery import ( + privatemuse_recovery_job_cancellation_reason, + ) + + cancellation_reason = privatemuse_recovery_job_cancellation_reason(session, job) + if not cancellation_reason: + from .workmesh_first_deployment import ( + workmesh_first_deploy_job_cancellation_reason, + ) + + cancellation_reason = workmesh_first_deploy_job_cancellation_reason(session, job) + if cancellation_reason: + return { + "jobId": job.id, + "status": job.status, + "cancelled": False, + "changed": False, + "reason": cancellation_reason, + } + if job.status in {"succeeded", "failed", "cancelled"}: + return { + "jobId": job.id, + "status": job.status, + "cancelled": job.status == "cancelled", + "changed": False, + } + previous = job.status + updated = cancel_job(session, job, result_summary="Cancelled through governed MCP.") + audit = log_event( + session, + source="automation", + identity="chatgpt-mcp", + action_id="job.cancel", + target=updated.id, + target_type="job", + risk="low", + policy_decision="allowed", + reason="A governed MCP client cancelled a queued or running AppOps job.", + result="success", + before_state={"status": previous}, + after_state={"status": updated.status}, + redacted_metadata={"jobId": updated.id, "actionId": updated.action_id}, + ) + return { + "jobId": updated.id, + "status": updated.status, + "cancelled": True, + "changed": True, + "auditId": audit.id, + } diff --git a/backend/app/services/media_codec_dashboard_deployment_reconciliation.py b/backend/app/services/media_codec_dashboard_deployment_reconciliation.py new file mode 100644 index 0000000..fa8380d --- /dev/null +++ b/backend/app/services/media_codec_dashboard_deployment_reconciliation.py @@ -0,0 +1,768 @@ +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/media-codec-dashboard" +_CONTAINER = "Media-Codec-Dashboard" + +# Fail-closed reconciliation for the verified production runtime. +# It does not deploy, restart, recreate or stop any container. It only repairs +# controller state after proving the exact live container/image/mount topology +# and immutable runtime revision from independent OCI labels plus Gitea ancestry. +_RECONCILE_SCRIPT = r''' +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import time + +repository, expected_container = sys.argv[1:3] +registry_path = Path("/opt/gitea-deploy/projects.json") +host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") +state_path = state_root / "media-codec-dashboard.json" +project_lock_path = state_root / "locks" / "media-codec-dashboard.lock" +global_lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") +controller_path = Path("/opt/gitea-deploy/deploy.py") + +if repository != "jens/media-codec-dashboard": + raise SystemExit(40) +if expected_container != "Media-Codec-Dashboard": + raise SystemExit(40) + +if registry_path.is_symlink() or not registry_path.is_file(): + raise SystemExit(20) +try: + if host_registry_path.is_symlink() or not host_registry_path.is_file() or not os.path.samefile(registry_path, host_registry_path): + raise SystemExit(36) +except OSError: + raise SystemExit(36) + +try: + projects = json.loads(registry_path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(31) +project = projects.get(repository) if isinstance(projects, dict) else None +if not isinstance(project, dict): + raise SystemExit(22) +if project.get("branch") != "main" or project.get("slug") != "media-codec-dashboard": + raise SystemExit(23) +if project.get("compose_project") != "media-codec-dashboard" or project.get("containers") != [expected_container]: + raise SystemExit(23) +compose_files = project.get("compose_files") +if not isinstance(compose_files, list) or "docker-compose.yml" not in compose_files: + raise SystemExit(23) +source_dir = Path(str(project.get("source_dir") or "")) +allowed_roots = (Path("/mnt/cache/appdata/gitea-deploy-runners/sources"), Path("/srv/example/appdata")) +if not source_dir.is_absolute() or not any(root == source_dir or root in source_dir.parents for root in allowed_roots): + raise SystemExit(23) + +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +canonical_repository_root = Path("/mnt/cache/appdata/gitea/git/repositories") +canonical_bare_repo = canonical_repository_root / owner / f"{name}.git" +if not bare_repo.is_dir(): + raise SystemExit(24) + +def git_run(*args): + return subprocess.run(["git", f"--git-dir={bare_repo}", *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False) + +def canonical_git_run(*args): + return subprocess.run(["git", f"--git-dir={canonical_bare_repo}", *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False) + +def _is_current_verified_receipt_snapshot_baseline(state, repository, expected_container, live_revision, attempt_revision): + attempt = state.get("last_attempt") + artifacts = state.get("live_artifacts") + receipt = state.get("live_receipt") + if not isinstance(attempt, dict) or not isinstance(artifacts, dict) or not isinstance(receipt, dict): + return False + containers = artifacts.get("containers") + receipt_containers = receipt.get("containers") + return ( + state.get("schema_version") == 3 + and state.get("repository") == repository + and state.get("revision") == live_revision + and state.get("live_revision") == live_revision + and state.get("status") == "failed" + and state.get("live_status") == "unknown" + and state.get("live_legacy_provenance") is False + and attempt.get("revision") == attempt_revision + and attempt.get("status") == "failed" + and attempt.get("message") == "Existing production containers require a valid healthy live deployment state" + and attempt.get("updated_at_epoch") == state.get("updated_at_epoch") + and re.fullmatch(r"[0-9a-f]{64}", str(artifacts.get("compose_sha256") or "")) is not None + and isinstance(containers, dict) + and set(containers) == {expected_container} + and receipt.get("schema") == "gitea-deploy-receipt.v1" + and receipt.get("repository") == repository + and receipt.get("revision") == live_revision + and receipt.get("live") is None + and isinstance(receipt_containers, dict) + and set(receipt_containers) == {expected_container} + and receipt_containers == containers + ) + +def _is_exact_legacy_state_ahead_baseline( + state, + repository, + expected_container, + recorded_live, + live_revision, + container_artifact, + git_facts, +): + attempt = state.get("last_attempt") + artifacts = state.get("live_artifacts") + receipt = state.get("live_receipt") + if not isinstance(attempt, dict) or not isinstance(artifacts, dict) or not isinstance(receipt, dict): + return False + metadata = attempt.get("metadata") + build = receipt.get("build") + if not isinstance(metadata, dict) or not isinstance(build, dict): + return False + artifact_containers = artifacts.get("containers") + receipt_containers = receipt.get("containers") + if not isinstance(artifact_containers, dict) or not isinstance(receipt_containers, dict): + return False + stored_artifact = artifact_containers.get(expected_container) + if not isinstance(stored_artifact, dict) or receipt_containers != artifact_containers: + return False + + def _same_immutable_artifact_after_container_recreate(stored, current): + expected_fields = { + "container_id", + "image_id", + "image_ref", + "container_revision", + "image_revision", + "image_source", + "image_tree", + "image_version", + "image_created", + } + immutable_fields = expected_fields - {"container_id"} + return ( + isinstance(current, dict) + and set(stored) == expected_fields + and set(current) == expected_fields + and re.fullmatch(r"[0-9a-f]{64}", str(stored.get("container_id") or "")) is not None + and re.fullmatch(r"[0-9a-f]{64}", str(current.get("container_id") or "")) is not None + and all(stored.get(field) == current.get(field) for field in immutable_fields) + ) + + attempt_revision = str(attempt.get("revision") or "") + expected_facts = { + "recorded_to_live": 1, + "canonical_recorded_to_live": 1, + "live_to_recorded": 0, + "canonical_live_to_recorded": 0, + "mounted_recorded_main": 0, + "canonical_recorded_main": 0, + "canonical_live_main": 0, + "recorded_to_attempt": 0, + "canonical_recorded_to_attempt": 0, + "attempt_to_main": 0, + "canonical_attempt_to_main": 0, + } + return ( + state.get("schema_version") == 3 + and state.get("repository") == repository + and state.get("revision") == recorded_live + and state.get("live_revision") == recorded_live + and state.get("status") == "failed" + and state.get("live_status") == "unknown" + and state.get("live_legacy_provenance") is False + and re.fullmatch(r"[0-9a-f]{40}", recorded_live) is not None + and re.fullmatch(r"[0-9a-f]{40}", live_revision) is not None + and recorded_live != live_revision + and re.fullmatch(r"[0-9a-f]{40}", attempt_revision) is not None + and attempt_revision != recorded_live + and attempt.get("status") == "failed" + and attempt.get("message") == "Existing production containers require a valid healthy live deployment state" + and attempt.get("updated_at_epoch") == state.get("updated_at_epoch") + and set(metadata) == {"builtAt", "id", "repository", "revision", "tree", "treeDigest"} + and metadata.get("id") == attempt_revision[:12] + and metadata.get("repository") == repository + and metadata.get("revision") == attempt_revision + and re.fullmatch(r"[0-9a-f]{40}", str(metadata.get("tree") or "")) is not None + and re.fullmatch(r"[0-9a-f]{64}", str(metadata.get("treeDigest") or "")) is not None + and isinstance(metadata.get("builtAt"), str) + and bool(metadata.get("builtAt")) + and set(artifacts) == {"compose_sha256", "containers"} + and re.fullmatch(r"[0-9a-f]{64}", str(artifacts.get("compose_sha256") or "")) is not None + and set(artifact_containers) == {expected_container} + and _same_immutable_artifact_after_container_recreate(stored_artifact, container_artifact) + and set(receipt) == {"schema", "repository", "revision", "tree", "treeDigest", "build", "containers", "live"} + and receipt.get("schema") == "gitea-deploy-receipt.v1" + and receipt.get("repository") == repository + and receipt.get("revision") == recorded_live + and receipt.get("live") is None + and re.fullmatch(r"[0-9a-f]{40}", str(receipt.get("tree") or "")) is not None + and re.fullmatch(r"[0-9a-f]{64}", str(receipt.get("treeDigest") or "")) is not None + and set(receipt_containers) == {expected_container} + and set(build) == {"builtAt", "id", "repository", "revision", "tree", "treeDigest"} + and build.get("id") == recorded_live[:12] + and build.get("repository") == repository + and build.get("revision") == recorded_live + and build.get("tree") == receipt.get("tree") + and build.get("treeDigest") == receipt.get("treeDigest") + and isinstance(build.get("builtAt"), str) + and bool(build.get("builtAt")) + and container_artifact.get("container_revision") == live_revision + and container_artifact.get("image_revision") == live_revision + and re.fullmatch(r"[0-9a-f]{64}", str(container_artifact.get("container_id") or "")) is not None + and re.fullmatch(r"sha256:[0-9a-f]{64}", str(container_artifact.get("image_id") or "")) is not None + and git_facts == expected_facts + ) + +main = git_run("rev-parse", "refs/heads/main") +main_revision = main.stdout.strip() +if main.returncode != 0 or re.fullmatch(r"[0-9a-f]{40}", main_revision) is None: + raise SystemExit(26) + +state_root.mkdir(parents=True, exist_ok=True) +project_lock_path.parent.mkdir(parents=True, exist_ok=True) +try: + project_lock = project_lock_path.open("a+", encoding="utf-8") + global_lock = global_lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(29) + +with project_lock: + try: + fcntl.flock(project_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + with global_lock: + try: + fcntl.flock(global_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + + try: + current_projects = json.loads(registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(31) + if current_projects.get(repository) != project: + raise SystemExit(37) + + if state_path.is_symlink() or not state_path.is_file(): + raise SystemExit(42) + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(42) + if not isinstance(state, dict) or state.get("schema_version") != 3 or state.get("repository") != repository: + raise SystemExit(43) + + inspected = subprocess.run(["docker", "inspect", expected_container], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False) + if inspected.returncode != 0: + raise SystemExit(44) + try: + container = json.loads(inspected.stdout)[0] + except Exception: + raise SystemExit(44) + runtime_state = dict(container.get("State") or {}) + labels = dict(dict(container.get("Config") or {}).get("Labels") or {}) + image_ref = str(dict(container.get("Config") or {}).get("Image") or "") + image_id = str(container.get("Image") or "") + container_revision = str(labels.get("org.opencontainers.image.revision") or "") + if not runtime_state.get("Running") or str(dict(runtime_state.get("Health") or {}).get("Status") or "") != "healthy": + raise SystemExit(45) + if labels.get("com.docker.compose.project") != "media-codec-dashboard": + raise SystemExit(45) + if not image_id.startswith("sha256:") or re.fullmatch(r"[0-9a-f]{40}", container_revision) is None: + raise SystemExit(46) + + project_names = subprocess.run(["docker", "ps", "--all", "--filter", "label=com.docker.compose.project=media-codec-dashboard", "--format", "{{.Names}}"], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False) + if project_names.returncode != 0 or {line.strip() for line in project_names.stdout.splitlines() if line.strip()} != {expected_container}: + raise SystemExit(47) + + mounts = {str(item.get("Destination") or ""): str(item.get("Source") or "") for item in list(container.get("Mounts") or []) if isinstance(item, dict)} + required_mounts = { + "/app/data": "/srv/example/appdata/media-codec-dashboard/data", + "/app/data/quality-runtime": "/srv/example/appdata/tdarr/logs/media-codec-dashboard-quality-audit", + "/run/secrets": "/srv/example/appdata/media-codec-dashboard/secrets", + "/tdarr-output": "/srv/example/Media", + } + if any(mounts.get(destination) != source for destination, source in required_mounts.items()): + raise SystemExit(48) + + bindings = dict(dict(container.get("HostConfig") or {}).get("PortBindings") or {}) + host_ports = {str(item.get("HostPort") or "") for item in list(bindings.get("1211/tcp") or []) if isinstance(item, dict)} + if "1211" not in host_ports: + raise SystemExit(49) + + image_result = subprocess.run(["docker", "image", "inspect", image_id], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=10, check=False) + if image_result.returncode != 0: + raise SystemExit(50) + try: + image = json.loads(image_result.stdout)[0] + except Exception: + raise SystemExit(50) + image_labels = dict(dict(image.get("Config") or {}).get("Labels") or {}) + image_revision = str(image_labels.get("org.opencontainers.image.revision") or "") + live_revision = image_revision + if re.fullmatch(r"[0-9a-f]{40}", live_revision) is None or container_revision != image_revision: + raise SystemExit(51) + container_artifact = { + "container_id": str(container.get("Id") or ""), + "image_id": image_id, + "image_ref": image_ref, + "container_revision": container_revision, + "image_revision": image_revision, + "image_source": str(image_labels.get("org.opencontainers.image.source") or ""), + "image_tree": str(image_labels.get("org.opencontainers.image.source-tree") or ""), + "image_version": str(image_labels.get("org.opencontainers.image.version") or ""), + "image_created": str(image_labels.get("org.opencontainers.image.created") or ""), + } + resolved = git_run("rev-parse", f"{live_revision}^{{commit}}") + if resolved.returncode != 0 or resolved.stdout.strip() != live_revision: + raise SystemExit(25) + if git_run("merge-base", "--is-ancestor", live_revision, main_revision).returncode != 0: + raise SystemExit(27) + + recorded_live = str(state.get("live_revision") or state.get("revision") or "") + recorded_status = str(state.get("status") or "") + recorded_live_status = str(state.get("live_status") or "") + last_attempt = dict(state.get("last_attempt") or {}) + attempt_revision = str(last_attempt.get("revision") or "") + attempt_status = str(last_attempt.get("status") or "") + reconciled_from = recorded_live + if recorded_live == live_revision: + if recorded_status == "success" and state.get("live_legacy_provenance") is True: + print("already-reconciled", flush=True) + raise SystemExit(0) + + current_artifacts = state.get("live_artifacts") + current_receipt = state.get("live_receipt") + current_artifact_containers = ( + current_artifacts.get("containers") + if isinstance(current_artifacts, dict) + else None + ) + current_stored_artifact = ( + current_artifact_containers.get(expected_container) + if isinstance(current_artifact_containers, dict) + else None + ) + current_attempt_metadata = ( + last_attempt.get("metadata") + if isinstance(last_attempt, dict) + else None + ) + current_artifact_fields = { + "container_id", + "image_id", + "image_ref", + "container_revision", + "image_revision", + "image_source", + "image_tree", + "image_version", + "image_created", + } + current_immutable_fields = current_artifact_fields - {"container_id"} + current_live_unknown_snapshot_baseline = ( + recorded_status == "failed" + and recorded_live_status == "unknown" + and state.get("live_legacy_provenance") is True + and isinstance(current_artifacts, dict) + and set(current_artifacts) == {"compose_sha256", "containers"} + and current_artifacts.get("compose_sha256") == "" + and isinstance(current_artifact_containers, dict) + and set(current_artifact_containers) == {expected_container} + and isinstance(current_stored_artifact, dict) + and set(current_stored_artifact) == current_artifact_fields + and set(container_artifact) == current_artifact_fields + and re.fullmatch(r"[0-9a-f]{64}", str(current_stored_artifact.get("container_id") or "")) is not None + and re.fullmatch(r"[0-9a-f]{64}", str(container_artifact.get("container_id") or "")) is not None + and all(current_stored_artifact.get(field) == container_artifact.get(field) for field in current_immutable_fields) + and isinstance(current_receipt, dict) + and current_receipt == {} + and attempt_status == "failed" + # Do not bind recovery to one historical error string. The proof + # is the preserved immutable artifact plus the currently healthy + # runtime, exact candidate metadata/timestamp, and Git ancestry. + and attempt.get("updated_at_epoch") == state.get("updated_at_epoch") + and isinstance(current_attempt_metadata, dict) + and set(current_attempt_metadata) == {"builtAt", "id", "repository", "revision", "tree", "treeDigest"} + and current_attempt_metadata.get("id") == attempt_revision[:12] + and current_attempt_metadata.get("repository") == repository + and current_attempt_metadata.get("revision") == attempt_revision + and re.fullmatch(r"[0-9a-f]{40}", str(current_attempt_metadata.get("tree") or "")) is not None + and re.fullmatch(r"[0-9a-f]{64}", str(current_attempt_metadata.get("treeDigest") or "")) is not None + and isinstance(current_attempt_metadata.get("builtAt"), str) + and bool(current_attempt_metadata.get("builtAt")) + ) + current_verified_receipt_snapshot_baseline = _is_current_verified_receipt_snapshot_baseline( + state, repository, expected_container, live_revision, attempt_revision + ) + current_live_failed_baseline = ( + recorded_status == "failed" + and (recorded_live_status == "healthy" or current_live_unknown_snapshot_baseline or current_verified_receipt_snapshot_baseline) + and attempt_status == "failed" + and re.fullmatch(r"[0-9a-f]{40}", attempt_revision) is not None + and attempt_revision != live_revision + ) + if not current_live_failed_baseline: + raise SystemExit(55) + resolved_attempt = git_run("rev-parse", f"{attempt_revision}^{{commit}}") + if resolved_attempt.returncode != 0 or resolved_attempt.stdout.strip() != attempt_revision: + raise SystemExit(25) + if git_run("merge-base", "--is-ancestor", live_revision, attempt_revision).returncode != 0: + raise SystemExit(56) + if git_run("merge-base", "--is-ancestor", attempt_revision, main_revision).returncode != 0: + raise SystemExit(57) + if current_verified_receipt_snapshot_baseline: + if controller_path.is_symlink() or not controller_path.is_file(): + raise SystemExit(62) + try: + spec = importlib.util.spec_from_file_location("dashboard_deploy_controller", controller_path) + controller = importlib.util.module_from_spec(spec) if spec and spec.loader else None + if controller is None or spec is None or spec.loader is None: + raise RuntimeError("controller import unavailable") + spec.loader.exec_module(controller) + verified_project = dict(project) + verified_project["_repository_name"] = repository + verified_project["_repository_path"] = str(bare_repo) + build = controller.validate_recorded_receipt( + verified_project, current_receipt, repository, live_revision + ) + observed_receipt = controller.verify_deployment( + verified_project, + build, + expected_image_ids={ + expected_container: current_receipt["containers"][expected_container]["image_id"] + }, + ) + observed_artifacts = controller.deployment_artifacts(verified_project) + except Exception: + raise SystemExit(63) + if observed_receipt != current_receipt or observed_artifacts != current_artifacts: + raise SystemExit(64) + reconciled_from = attempt_revision + else: + if re.fullmatch(r"[0-9a-f]{40}", recorded_live) is None: + raise SystemExit(58) + stale_success_baseline = ( + recorded_status == "success" and recorded_live_status == "healthy" + and attempt_status == "success" and attempt_revision == recorded_live + ) + stale_failed_attempt = recorded_status == "failed" + if not (stale_success_baseline or stale_failed_attempt): + raise SystemExit(59) + resolved_recorded = git_run("rev-parse", f"{recorded_live}^{{commit}}") + if resolved_recorded.returncode != 0 or resolved_recorded.stdout.strip() != recorded_live: + raise SystemExit(25) + recorded_to_live = git_run("merge-base", "--is-ancestor", recorded_live, live_revision) + if recorded_to_live.returncode not in {0, 1}: + raise SystemExit(61) + if recorded_to_live.returncode != 0: + canonical_recorded = canonical_git_run("rev-parse", f"{recorded_live}^{{commit}}") + canonical_live = canonical_git_run("rev-parse", f"{live_revision}^{{commit}}") + canonical_main = canonical_git_run("rev-parse", "refs/heads/main") + canonical_recorded_to_live = canonical_git_run("merge-base", "--is-ancestor", recorded_live, live_revision) + canonical_recorded_main = canonical_git_run("merge-base", "--is-ancestor", recorded_live, main_revision) + canonical_live_main = canonical_git_run("merge-base", "--is-ancestor", live_revision, main_revision) + canonical_revisions_available = ( + canonical_bare_repo.is_dir() + and not canonical_bare_repo.is_symlink() + and canonical_recorded.returncode == 0 + and canonical_recorded.stdout.strip() == recorded_live + and canonical_live.returncode == 0 + and canonical_live.stdout.strip() == live_revision + and canonical_main.returncode == 0 + and canonical_main.stdout.strip() == main_revision + ) + canonical_ancestry_fallback = ( + canonical_revisions_available + and canonical_recorded_to_live.returncode == 0 + and canonical_recorded_main.returncode == 0 + and canonical_live_main.returncode == 0 + ) + if not canonical_ancestry_fallback: + if not canonical_revisions_available: + raise SystemExit(61) + if canonical_recorded_to_live.returncode not in {0, 1}: + raise SystemExit(61) + if canonical_recorded_main.returncode not in {0, 1}: + raise SystemExit(61) + if canonical_live_main.returncode not in {0, 1}: + raise SystemExit(61) + + live_to_recorded = git_run("merge-base", "--is-ancestor", live_revision, recorded_live) + canonical_live_to_recorded = canonical_git_run("merge-base", "--is-ancestor", live_revision, recorded_live) + mounted_recorded_main = git_run("merge-base", "--is-ancestor", recorded_live, main_revision) + mounted_merge_base = git_run("merge-base", recorded_live, live_revision) + canonical_merge_base = canonical_git_run("merge-base", recorded_live, live_revision) + mounted_recorded_time = git_run("show", "-s", "--format=%ct", recorded_live) + mounted_live_time = git_run("show", "-s", "--format=%ct", live_revision) + canonical_recorded_time = canonical_git_run("show", "-s", "--format=%ct", recorded_live) + canonical_live_time = canonical_git_run("show", "-s", "--format=%ct", live_revision) + mounted_recorded_distance = git_run("rev-list", "--count", f"{recorded_live}..{main_revision}") + mounted_live_distance = git_run("rev-list", "--count", f"{live_revision}..{main_revision}") + canonical_recorded_distance = canonical_git_run("rev-list", "--count", f"{recorded_live}..{main_revision}") + canonical_live_distance = canonical_git_run("rev-list", "--count", f"{live_revision}..{main_revision}") + resolved_attempt = git_run("rev-parse", f"{attempt_revision}^{{commit}}") + canonical_resolved_attempt = canonical_git_run("rev-parse", f"{attempt_revision}^{{commit}}") + recorded_to_attempt = git_run("merge-base", "--is-ancestor", recorded_live, attempt_revision) + canonical_recorded_to_attempt = canonical_git_run("merge-base", "--is-ancestor", recorded_live, attempt_revision) + attempt_to_main = git_run("merge-base", "--is-ancestor", attempt_revision, main_revision) + canonical_attempt_to_main = canonical_git_run("merge-base", "--is-ancestor", attempt_revision, main_revision) + + def git_nonnegative_int(result): + if result.returncode != 0: + return None + value = result.stdout.strip() + if not value.isdigit(): + return None + return int(value) + + recorded_commit_epoch = git_nonnegative_int(mounted_recorded_time) + live_commit_epoch = git_nonnegative_int(mounted_live_time) + canonical_recorded_commit_epoch = git_nonnegative_int(canonical_recorded_time) + canonical_live_commit_epoch = git_nonnegative_int(canonical_live_time) + recorded_distance = git_nonnegative_int(mounted_recorded_distance) + live_distance = git_nonnegative_int(mounted_live_distance) + canonical_recorded_distance_value = git_nonnegative_int(canonical_recorded_distance) + canonical_live_distance_value = git_nonnegative_int(canonical_live_distance) + try: + recorded_updated_at = int(state.get("updated_at_epoch") or 0) + except (TypeError, ValueError): + recorded_updated_at = 0 + + mounted_merge_base_value = mounted_merge_base.stdout.strip() + canonical_merge_base_value = canonical_merge_base.stdout.strip() + history_transition_baseline = ( + stale_success_baseline + and recorded_to_live.returncode == 1 + and canonical_recorded_to_live.returncode == 1 + and live_to_recorded.returncode == 1 + and canonical_live_to_recorded.returncode == 1 + and mounted_recorded_main.returncode == 0 + and canonical_recorded_main.returncode == 0 + and canonical_live_main.returncode == 0 + and mounted_merge_base.returncode == 0 + and canonical_merge_base.returncode == 0 + and re.fullmatch(r"[0-9a-f]{40}", mounted_merge_base_value) is not None + and canonical_merge_base_value == mounted_merge_base_value + and mounted_merge_base_value not in {recorded_live, live_revision} + and recorded_commit_epoch is not None + and live_commit_epoch is not None + and canonical_recorded_commit_epoch == recorded_commit_epoch + and canonical_live_commit_epoch == live_commit_epoch + and 0 < recorded_updated_at + and recorded_commit_epoch <= recorded_updated_at + and recorded_updated_at < live_commit_epoch + and recorded_commit_epoch < live_commit_epoch + and recorded_distance is not None + and live_distance is not None + and canonical_recorded_distance_value == recorded_distance + and canonical_live_distance_value == live_distance + and 0 <= live_distance < recorded_distance + ) + exact_legacy_state_ahead_baseline = ( + resolved_attempt.returncode == 0 + and resolved_attempt.stdout.strip() == attempt_revision + and canonical_resolved_attempt.returncode == 0 + and canonical_resolved_attempt.stdout.strip() == attempt_revision + and _is_exact_legacy_state_ahead_baseline( + state, + repository, + expected_container, + recorded_live, + live_revision, + container_artifact, + { + "recorded_to_live": recorded_to_live.returncode, + "canonical_recorded_to_live": canonical_recorded_to_live.returncode, + "live_to_recorded": live_to_recorded.returncode, + "canonical_live_to_recorded": canonical_live_to_recorded.returncode, + "mounted_recorded_main": mounted_recorded_main.returncode, + "canonical_recorded_main": canonical_recorded_main.returncode, + "canonical_live_main": canonical_live_main.returncode, + "recorded_to_attempt": recorded_to_attempt.returncode, + "canonical_recorded_to_attempt": canonical_recorded_to_attempt.returncode, + "attempt_to_main": attempt_to_main.returncode, + "canonical_attempt_to_main": canonical_attempt_to_main.returncode, + }, + ) + ) + if not (history_transition_baseline or exact_legacy_state_ahead_baseline): + if canonical_recorded_to_live.returncode == 1: + raise SystemExit(60) + raise SystemExit(61) + + artifacts = {"containers": {expected_container: container_artifact}, "compose_sha256": ""} + now = int(time.time()) + payload = { + "schema_version": 3, + "repository": repository, + "slug": "media-codec-dashboard", + "compose_project": "media-codec-dashboard", + "revision": live_revision, + "live_revision": live_revision, + "live_status": "healthy", + "live_legacy_provenance": True, + "live_artifacts": artifacts, + "live_receipt": {}, + "status": "success", + "message": "Verified existing Media Codec Dashboard runtime reconciled to actual immutable live image", + "updated_at_epoch": now, + "artifacts": artifacts, + "last_attempt": { + "revision": live_revision, + "status": "success", + "message": "Verified runtime state reconciliation", + "updated_at_epoch": now, + "metadata": {"reconciled_from": reconciled_from}, + }, + "previous_revision": recorded_live, + "rollback_restored_revision": "", + } + if recorded_live == live_revision and current_verified_receipt_snapshot_baseline: + payload = dict(state) + payload.update({ + "live_status": "healthy", + "status": "success", + "message": "Verified Media Codec Dashboard receipt re-attested after failed forward deployment", + "updated_at_epoch": now, + "artifacts": current_artifacts, + "last_attempt": { + "revision": live_revision, + "status": "success", + "message": "Verified existing runtime receipt re-attestation", + "updated_at_epoch": now, + "metadata": {"reconciled_failed_revision": reconciled_from}, + }, + }) + + backup = state_path.with_name(f"{state_path.name}.reconcile-backup-{now}-{os.getpid()}") + try: + shutil.copy2(state_path, backup) + temporary = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") + with temporary.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, stat.S_IMODE(state_path.stat().st_mode)) + os.replace(temporary, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + raise SystemExit(53) + + verify = json.loads(state_path.read_text(encoding="utf-8")) + expected_legacy = not (recorded_live == live_revision and current_verified_receipt_snapshot_baseline) + if not (verify.get("live_revision") == live_revision and verify.get("live_status") == "healthy" and verify.get("live_legacy_provenance") is expected_legacy and dict(dict(verify.get("live_artifacts") or {}).get("containers") or {}).get(expected_container, {}).get("image_id") == image_id): + raise SystemExit(54) + if not expected_legacy and (verify.get("live_receipt") != current_receipt or verify.get("live_artifacts") != current_artifacts): + raise SystemExit(64) + +print("reconciled", flush=True) +''' + + +class MediaCodecDashboardDeploymentReconciliation: + """Repair only stale controller state proven against the live immutable dashboard runtime.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + return {"status": "controller_unavailable", "reason": redact_message(str(status.get("reason") or status.get("status") or "unavailable"))} + controller = dict(status.get("container") or {}) + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return {"status": "controller_unavailable", "reason": "deployment controller is not running"} + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if not config or not container_id: + return {"status": "connector_unavailable", "reason": "Portainer deployment control is unavailable"} + + command = ["python3", "-c", _RECONCILE_SCRIPT, _REPOSITORY, _CONTAINER] + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request("POST", f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", json={"AttachStdout": False, "AttachStderr": False, "Tty": False, "Cmd": command}).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return {"status": "error", "reason": "dashboard state reconciliation could not start"} + safe_exec = quote(exec_id, safe="") + self.portainer._request("POST", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", json={"Detach": True, "Tty": False}) + deadline = time.monotonic() + 45 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request("GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json").json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return {"status": "error", "reason": "dashboard state reconciliation timed out"} + reasons = { + 20: "deployment projects registry is unavailable or unsafe", + 22: "Media Codec Dashboard deployment target is missing", + 23: "Media Codec Dashboard deployment target topology differs from the bounded contract", + 24: "mounted Media Codec Dashboard bare repository is unavailable", + 25: "deployment revisions cannot be resolved", + 26: "Media Codec Dashboard main cannot be resolved", + 27: "live Media Codec Dashboard revision is not an ancestor of main", + 29: "deployment lock files cannot be opened", + 30: "deployment locks cannot be acquired", + 31: "deployment projects registry cannot be read", + 36: "deployment projects registry host alias is unavailable or mismatched", + 37: "deployment target changed while reconciliation was waiting", + 40: "fixed Media Codec Dashboard reconciliation contract was altered", + 42: "deployment state file is unavailable or invalid", + 43: "deployment state is not a modern Media Codec Dashboard state", + 44: "live Media Codec Dashboard container cannot be inspected", + 45: "live Media Codec Dashboard container is not healthy in the expected Compose project", + 46: "live Media Codec Dashboard immutable image identity is invalid", + 47: "Media Codec Dashboard Compose project contains unexpected containers", + 48: "live Media Codec Dashboard mount topology differs from the verified runtime", + 49: "live Media Codec Dashboard port mapping differs from the verified runtime", + 50: "live Media Codec Dashboard image cannot be inspected", + 51: "live Media Codec Dashboard image revision label does not match the verified runtime", + 52: "controller state drift is not the exact known recoverable state", + 53: "reconciled deployment state could not be written durably", + 54: "reconciled deployment state did not verify after write", + 55: "controller state for the current live revision is internally inconsistent", + 56: "failed deployment attempt is not a descendant of the live revision", + 57: "failed deployment attempt is not an ancestor of main", + 58: "recorded live deployment revision is invalid", + 59: "stale controller state metadata is not a supported recoverable baseline", + 60: "recorded live deployment revision is not an ancestor of the verified runtime", + 61: "mounted deployment repository ancestry differs from canonical Gitea repository", + 62: "deployment controller verifier is unavailable or unsafe", + 63: "recorded dashboard receipt cannot be reverified against the live runtime", + 64: "verified dashboard receipt or artifacts differ from the recorded state", + } + if exit_code != 0: + return {"status": "validation_failed", "reason": reasons.get(exit_code, "Media Codec Dashboard deployment reconciliation failed closed")} + return { + "status": "reconciled", + "repository": _REPOSITORY, + "container": _CONTAINER, + } diff --git a/backend/app/services/media_pipeline_service.py b/backend/app/services/media_pipeline_service.py new file mode 100644 index 0000000..f10ab21 --- /dev/null +++ b/backend/app/services/media_pipeline_service.py @@ -0,0 +1,637 @@ +from __future__ import annotations + +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +import hashlib +import json +import os +from threading import Lock +from time import monotonic +from typing import Any + +from sqlalchemy import func +from sqlmodel import Session, select + +from ..connectors.deluge import DelugeConnector +from ..connectors.plex import PlexConnector +from ..connectors.prowlarr import ProwlarrConnector +from ..connectors.sonarr import SonarrConnector +from ..connectors.tautulli import TautulliConnector +from ..connectors.widefrog import WidefrogConnector +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import App, AppSetting, Connector, Secret +from .audit_logger import log_event +from .job_runner import complete_job, create_job, set_job_progress, start_job +from .policy_engine import evaluate_policy + + +CONNECTOR_IDS = ('sonarr', 'prowlarr', 'deluge', 'plex', 'tautulli', 'widefrog') +BLOCKED_ACTION_TOKENS = ('delete', 'remove', 'kill_session', 'terminate', 'empty_trash', 'download_database', 'download_config') +SNAPSHOT_CACHE_TTL_SECONDS = 15.0 +SNAPSHOT_MAX_WORKERS = 8 +SUMMARY_CACHE_SCHEMA_VERSION = 1 +SUMMARY_CACHE_SETTING_KEY = 'media_pipeline:summary_cache' +SUMMARY_CACHE_TTL_SECONDS = 60 +SUMMARY_CACHE_MAX_STALE_SECONDS = 300 +_SNAPSHOT_CACHE: dict[tuple[int, ...], tuple[float, dict[str, Any]]] = {} +_SNAPSHOT_CACHE_LOCK = Lock() + + +def _now() -> str: + return utc_now().isoformat() + + +def _parse_utc(value: str) -> datetime: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + + +def _summary_cache_ttl_seconds() -> int: + raw = os.getenv('MEDIA_PIPELINE_SUMMARY_CACHE_TTL_SECONDS', str(SUMMARY_CACHE_TTL_SECONDS)) + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return SUMMARY_CACHE_TTL_SECONDS + + +def _summary_cache_max_stale_seconds() -> int: + raw = os.getenv('MEDIA_PIPELINE_SUMMARY_CACHE_MAX_STALE_SECONDS', str(SUMMARY_CACHE_MAX_STALE_SECONDS)) + try: + return max(0, int(raw)) + except (TypeError, ValueError): + return SUMMARY_CACHE_MAX_STALE_SECONDS + + +def _redact(value: Any) -> Any: + if isinstance(value, str): + return redact_message(value) + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, dict): + return { + str(key): _redact(item) + for key, item in value.items() + if 'secret' not in str(key).lower() and 'token' not in str(key).lower() and 'password' not in str(key).lower() + } + return value + + +def _int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_call(call: Callable[[], dict[str, Any]], fallback_status: str = 'error') -> dict[str, Any]: + try: + payload = call() + return payload if isinstance(payload, dict) else {'success': False, 'status': fallback_status, 'message': 'unexpected_payload'} + except Exception as exc: # noqa: BLE001 - connector failures must not crash the dashboard. + return {'success': False, 'status': fallback_status, 'message': str(exc)} + + +def _snapshot_cache_key() -> tuple[int, ...]: + return ( + id(SonarrConnector.health), + id(SonarrConnector.wanted_missing), + id(SonarrConnector.queue_status), + id(ProwlarrConnector.health), + id(ProwlarrConnector.indexer_status), + id(ProwlarrConnector.application_status), + id(ProwlarrConnector.sync_status), + id(DelugeConnector.health), + id(DelugeConnector.session_status), + id(DelugeConnector.active_downloads), + id(DelugeConnector.stalled_downloads), + id(DelugeConnector.paused_downloads), + id(DelugeConnector.speed_status), + id(PlexConnector.health), + id(PlexConnector.library_status), + id(PlexConnector.active_sessions), + id(PlexConnector.recent_media), + id(PlexConnector.stream_summary), + id(TautulliConnector.health), + id(TautulliConnector.activity), + id(TautulliConnector.history), + id(TautulliConnector.watch_stats), + id(TautulliConnector.stream_summary), + id(TautulliConnector.libraries), + id(WidefrogConnector.health), + id(WidefrogConnector.queue_status), + id(WidefrogConnector.failed_jobs), + id(WidefrogConnector.activity), + id(WidefrogConnector.download_status), + ) + + +def _summary_signature(session: Session) -> str: + connectors = [ + { + 'id': item.id, + 'status': item.status, + 'lastTest': item.last_test, + 'secretRef': item.secret_ref, + 'capabilities': item.capabilities, + } + for item in session.exec(select(Connector)).all() + if item.id in CONNECTOR_IDS + ] + secret_rows = [ + { + 'id': item.id, + 'name': item.name, + 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, + 'usedBy': item.used_by_json, + } + for item in session.exec(select(Secret)).all() + if any(connector_id in item.used_by_json for connector_id in CONNECTOR_IDS) + ] + media_app_count = session.exec(select(func.count(App.id)).where(App.category.in_(['media', 'downloads']))).one() + signature_payload = { + 'schemaVersion': SUMMARY_CACHE_SCHEMA_VERSION, + 'connectors': sorted(connectors, key=lambda item: item['id']), + 'secrets': sorted(secret_rows, key=lambda item: item['id']), + 'mediaAppCount': media_app_count, + } + serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':')) + return hashlib.sha256(serialized.encode('utf-8')).hexdigest() + + +def _persistent_summary(session: Session, signature: str) -> dict[str, Any] | None: + row = session.get(AppSetting, SUMMARY_CACHE_SETTING_KEY) + if not row or not isinstance(row.value_json, dict): + return None + value = row.value_json + if value.get('schemaVersion') != SUMMARY_CACHE_SCHEMA_VERSION or value.get('signature') != signature: + return None + payload = value.get('payload') + expires_at_raw = value.get('expiresAt') + if not isinstance(payload, dict) or not isinstance(expires_at_raw, str): + return None + expires_at = _parse_utc(expires_at_raw) + now_dt = datetime.now(UTC) + stale_until = expires_at.timestamp() + _summary_cache_max_stale_seconds() + if now_dt >= expires_at and now_dt.timestamp() >= stale_until: + return None + return dict(payload) + + +def _store_persistent_summary(session: Session, signature: str, payload: dict[str, Any]) -> None: + ttl_seconds = _summary_cache_ttl_seconds() + expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z') + row = session.get(AppSetting, SUMMARY_CACHE_SETTING_KEY) + if not row: + row = AppSetting(key=SUMMARY_CACHE_SETTING_KEY, is_secret=False) + row.value_json = { + 'schemaVersion': SUMMARY_CACHE_SCHEMA_VERSION, + 'signature': signature, + 'payload': _redact(payload), + 'generatedAt': payload.get('lastUpdated'), + 'expiresAt': expires_at, + } + row.updated_at = utc_now() + session.add(row) + session.commit() + + +def _snapshot_cached() -> dict[str, Any]: + key = _snapshot_cache_key() + now = monotonic() + cached = _SNAPSHOT_CACHE.get(key) + if cached and now - cached[0] <= SNAPSHOT_CACHE_TTL_SECONDS: + return cached[1] + with _SNAPSHOT_CACHE_LOCK: + now = monotonic() + cached = _SNAPSHOT_CACHE.get(key) + if cached and now - cached[0] <= SNAPSHOT_CACHE_TTL_SECONDS: + return cached[1] + data = _build_snapshot() + _SNAPSHOT_CACHE.clear() + _SNAPSHOT_CACHE[key] = (now, data) + return data + + +def _connector_status(payload: dict[str, Any]) -> str: + status = str(payload.get('status') or ('connected' if payload.get('success') else 'error')).lower() + if status in {'ok', 'healthy'}: + return 'connected' + if status in {'connected', 'missing_config', 'auth_failed', 'unreachable', 'unsupported', 'unavailable', 'error'}: + return status + return 'error' + + +def _ui_status(connector_status: str, warning: bool = False, critical: bool = False) -> str: + if connector_status == 'missing_config': + return 'missing_config' + if connector_status in {'auth_failed', 'unreachable', 'error'} or critical: + return 'critical' + if connector_status in {'unsupported', 'unavailable'} or warning: + return 'warning' + return 'healthy' + + +def _policy_action(session: Session, *, label: str, action_id: str, target: str, source: str = 'manual') -> dict[str, Any]: + policy = evaluate_policy(session=session, action=action_id, target=target, source=source) + return { + 'label': label, + 'actionId': action_id, + 'target': target, + 'policyDecision': policy['decision'], + 'risk': policy.get('risk', 'medium'), + 'reason': policy.get('reason'), + 'link': _action_link(action_id, target), + } + + +def _action_link(action_id: str, target: str) -> str | None: + if action_id == 'media_pipeline.diagnose': + return '/media-pipeline' + if target in CONNECTOR_IDS: + return f'/apps/{target}' + return None + + +def _filter_safe_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [action for action in actions if not any(token in action.get('actionId', '') for token in BLOCKED_ACTION_TOKENS)] + + +def _build_snapshot() -> dict[str, Any]: + calls: dict[str, Callable[[], dict[str, Any]]] = { + 'sonarrHealth': lambda: SonarrConnector().health(), + 'sonarrWanted': lambda: SonarrConnector().wanted_missing(), + 'sonarrQueue': lambda: SonarrConnector().queue_status(), + 'prowlarrHealth': lambda: ProwlarrConnector().health(), + 'prowlarrIndexers': lambda: ProwlarrConnector().indexer_status(), + 'prowlarrApplications': lambda: ProwlarrConnector().application_status(), + 'prowlarrSync': lambda: ProwlarrConnector().sync_status(), + 'delugeHealth': lambda: DelugeConnector().health(), + 'delugeSession': lambda: DelugeConnector().session_status(), + 'delugeActive': lambda: DelugeConnector().active_downloads(), + 'delugeStalled': lambda: DelugeConnector().stalled_downloads(), + 'delugePaused': lambda: DelugeConnector().paused_downloads(), + 'delugeSpeeds': lambda: DelugeConnector().speed_status(), + 'plexHealth': lambda: PlexConnector().health(), + 'plexLibraries': lambda: PlexConnector().library_status(), + 'plexSessions': lambda: PlexConnector().active_sessions(), + 'plexRecent': lambda: PlexConnector().recent_media(), + 'plexStreams': lambda: PlexConnector().stream_summary(), + 'tautulliHealth': lambda: TautulliConnector().health(), + 'tautulliActivity': lambda: TautulliConnector().activity(), + 'tautulliHistory': lambda: TautulliConnector().history(), + 'tautulliStats': lambda: TautulliConnector().watch_stats(), + 'tautulliStreams': lambda: TautulliConnector().stream_summary(), + 'tautulliLibraries': lambda: TautulliConnector().libraries(), + 'widefrogHealth': lambda: WidefrogConnector().health(), + 'widefrogQueue': lambda: WidefrogConnector().queue_status(), + 'widefrogFailed': lambda: WidefrogConnector().failed_jobs(), + 'widefrogActivity': lambda: WidefrogConnector().activity(), + 'widefrogDownloads': lambda: WidefrogConnector().download_status(), + } + + with ThreadPoolExecutor(max_workers=min(SNAPSHOT_MAX_WORKERS, len(calls))) as executor: + futures = {key: executor.submit(_safe_call, call) for key, call in calls.items()} + return {key: future.result() for key, future in futures.items()} + + +def _connector_map(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + 'sonarr': {'status': _connector_status(data['sonarrHealth']), 'enabled': True}, + 'prowlarr': {'status': _connector_status(data['prowlarrHealth']), 'enabled': True}, + 'deluge': {'status': _connector_status(data['delugeHealth']), 'enabled': True}, + 'plex': {'status': _connector_status(data['plexHealth']), 'enabled': True}, + 'tautulli': {'status': _connector_status(data['tautulliHealth']), 'enabled': True}, + 'widefrog': {'status': _connector_status(data['widefrogHealth']), 'enabled': True}, + } + + +def _counts(data: dict[str, Any]) -> dict[str, int]: + return { + 'wantedSeries': _int(data['sonarrWanted'].get('total')), + 'missingEpisodes': _int(data['sonarrWanted'].get('total')), + 'activeDownloads': _int(data['delugeActive'].get('total', data['delugeSession'].get('active'))), + 'stalledDownloads': _int(data['delugeStalled'].get('total', data['delugeSession'].get('stalled'))), + 'failedWidefrogJobs': _int(data['widefrogFailed'].get('failed')), + 'plexLibraries': _int(data['plexLibraries'].get('total')), + 'activeStreams': max(_int(data['plexStreams'].get('total')), _int(data['tautulliStreams'].get('activeStreams')), _int(data['tautulliActivity'].get('streamCount'))), + 'transcodes': max(_int(data['plexStreams'].get('transcoding')), _int(data['tautulliStreams'].get('transcoding'))), + 'indexers': _int(data['prowlarrIndexers'].get('total')), + 'failingIndexers': _int(data['prowlarrIndexers'].get('failing'), _int(data['prowlarrSync'].get('indexersFailing'))), + } + + +def _summary_from_snapshot(data: dict[str, Any]) -> dict[str, Any]: + connectors = _connector_map(data) + counts = _counts(data) + missing = [key for key, value in connectors.items() if value['status'] == 'missing_config'] + hard_failures = [key for key, value in connectors.items() if value['status'] in {'auth_failed', 'unreachable', 'error'}] + warnings: list[str] = [] + warnings.extend(f'{name} missing_config' for name in missing) + warnings.extend(f'{name} {connectors[name]["status"]}' for name in hard_failures) + if counts['failingIndexers']: + warnings.append('Prowlarr has failing indexers') + if counts['stalledDownloads']: + warnings.append('Deluge has stalled downloads') + if counts['failedWidefrogJobs']: + warnings.append('Widefrog has failed jobs') + if counts['transcodes'] >= 3: + warnings.append('Plex has high active transcodes') + + if hard_failures: + status = 'critical' + risk_level = 'high' + elif missing: + status = 'partial' if len(missing) < len(CONNECTOR_IDS) else 'missing_config' + risk_level = 'medium' + elif warnings: + status = 'warning' + risk_level = 'medium' + else: + status = 'healthy' + risk_level = 'low' + + return { + 'success': True, + 'status': status, + 'message': 'Media pipeline ready.' if status == 'healthy' else 'Media pipeline has items requiring attention.', + 'connectors': connectors, + 'counts': counts, + 'risk': {'level': risk_level, 'reasons': warnings}, + 'lastUpdated': _now(), + } + + +def summary(session: Session | None = None, *, force_refresh: bool = False) -> dict[str, Any]: + signature = _summary_signature(session) if session else None + if session and signature and not force_refresh: + persisted = _persistent_summary(session, signature) + if persisted: + return persisted + + data = _snapshot_cached() + payload = _summary_from_snapshot(data) + if session and signature: + _store_persistent_summary(session, signature, payload) + return payload + + +def flow(session: Session) -> dict[str, Any]: + data = _snapshot_cached() + connectors = _connector_map(data) + counts = _counts(data) + nodes = [ + {'id': 'sonarr', 'label': 'Sonarr', 'type': 'wanted', 'status': _ui_status(connectors['sonarr']['status'], counts['wantedSeries'] > 0), 'metrics': {'wanted': counts['wantedSeries'], 'queue': _int(data['sonarrQueue'].get('total'))}, 'actions': []}, + {'id': 'prowlarr', 'label': 'Prowlarr', 'type': 'indexers', 'status': _ui_status(connectors['prowlarr']['status'], counts['failingIndexers'] > 0), 'metrics': {'indexers': counts['indexers'], 'failing': counts['failingIndexers'], 'apps': _int(data['prowlarrApplications'].get('total'))}, 'actions': [_policy_action(session, label='Run search test', action_id='prowlarr.search_test', target='prowlarr')]}, + {'id': 'deluge', 'label': 'Deluge', 'type': 'downloads', 'status': _ui_status(connectors['deluge']['status'], counts['stalledDownloads'] > 0), 'metrics': {'active': counts['activeDownloads'], 'stalled': counts['stalledDownloads'], 'paused': _int(data['delugePaused'].get('total'))}, 'actions': [_policy_action(session, label='Pause all', action_id='deluge.pause_all', target='deluge')]}, + {'id': 'widefrog', 'label': 'Widefrog', 'type': 'processing', 'status': _ui_status(connectors['widefrog']['status'], counts['failedWidefrogJobs'] > 0), 'metrics': {'queue': _int(data['widefrogQueue'].get('total')), 'failed': counts['failedWidefrogJobs']}, 'actions': [_policy_action(session, label='Retry failed', action_id='widefrog.retry_failed', target='widefrog')]}, + {'id': 'plex', 'label': 'Plex', 'type': 'library', 'status': _ui_status(connectors['plex']['status'], counts['transcodes'] >= 3), 'metrics': {'libraries': counts['plexLibraries'], 'streams': counts['activeStreams'], 'transcodes': counts['transcodes']}, 'actions': [_policy_action(session, label='Scan all libraries', action_id='plex.scan_all_libraries', target='plex')]}, + {'id': 'tautulli', 'label': 'Tautulli', 'type': 'analytics', 'status': _ui_status(connectors['tautulli']['status']), 'metrics': {'streams': _int(data['tautulliStreams'].get('activeStreams')), 'plays': _int(data['tautulliStats'].get('totalPlays'))}, 'actions': []}, + ] + edges = [ + {'from': 'sonarr', 'to': 'prowlarr', 'status': 'broken' if connectors['prowlarr']['status'] in {'missing_config', 'error'} else 'healthy', 'label': 'indexer sync'}, + {'from': 'prowlarr', 'to': 'deluge', 'status': 'warning' if counts['failingIndexers'] else 'healthy', 'label': 'release search'}, + {'from': 'widefrog', 'to': 'plex', 'status': 'warning' if counts['failedWidefrogJobs'] else 'healthy', 'label': 'media processing'}, + {'from': 'plex', 'to': 'tautulli', 'status': 'broken' if connectors['tautulli']['status'] == 'missing_config' else 'healthy', 'label': 'playback analytics'}, + ] + return {'success': True, 'nodes': nodes, 'edges': edges, 'lastUpdated': _now()} + + +def issues(session: Session) -> dict[str, Any]: + data = _snapshot_cached() + connectors = _connector_map(data) + counts = _counts(data) + result: list[dict[str, Any]] = [] + observed_at = _now() + + for name, connector in connectors.items(): + if connector['status'] == 'missing_config': + result.append(_issue('connector-missing-' + name, 'warning', f'{name.title()} missing_config', f'{name} connector configuration is incomplete.', [name], [connector['status']], [_configure_action(name)])) + elif connector['status'] in {'auth_failed', 'unreachable', 'error'}: + result.append(_issue('connector-error-' + name, 'critical', f'{name.title()} connector unavailable', f'{name} returned {connector["status"]}.', [name], [connector['status']], [_configure_action(name)])) + + if connectors['sonarr']['status'] == 'connected' and counts['failingIndexers']: + result.append(_issue('prowlarr-failing-indexers', 'warning', 'Prowlarr failing indexers', 'Sonarr is connected but one or more indexers are failing.', ['sonarr', 'prowlarr'], [f'{counts["failingIndexers"]} failing indexers'], [_policy_action(session, label='Run search test', action_id='prowlarr.search_test', target='prowlarr')])) + if counts['wantedSeries'] and counts['activeDownloads'] == 0: + result.append(_issue('wanted-no-downloads', 'info', 'Wanted items without active downloads', 'Sonarr has wanted items but Deluge has no active downloads.', ['sonarr', 'deluge'], [f'{counts["wantedSeries"]} wanted', '0 active downloads'], [_policy_action(session, label='Run Prowlarr search test', action_id='prowlarr.search_test', target='prowlarr')])) + if counts['stalledDownloads']: + source = 'deluge.stalled_downloads' + result.append(_issue( + 'deluge-stalled', + 'warning', + 'Deluge stalled downloads', + 'One or more Deluge downloads are stalled.', + ['deluge'], + [f'{counts["stalledDownloads"]} stalled downloads', f'source={source}', f'observedAt={observed_at}'], + [_policy_action(session, label='Resume stalled torrent', action_id='deluge.resume_torrent', target='deluge')], + source=source, + observed_at=observed_at, + )) + if connectors['plex']['status'] == 'connected' and connectors['tautulli']['status'] == 'missing_config': + result.append(_issue('tautulli-missing-with-plex', 'warning', 'Tautulli analytics incomplete', 'Plex is connected but Tautulli is missing configuration.', ['plex', 'tautulli'], ['Plex connected', 'Tautulli missing_config'], [_configure_action('tautulli')])) + if counts['transcodes'] >= 3: + result.append(_issue('plex-high-transcodes', 'warning', 'Plex transcodes active', 'Multiple active transcodes may indicate client or server load.', ['plex', 'tautulli'], [f'{counts["transcodes"]} active transcodes'], [])) + if counts['failedWidefrogJobs']: + result.append(_issue('widefrog-failed-jobs', 'warning', 'Widefrog failed jobs', 'Widefrog reports failed processing jobs.', ['widefrog'], [f'{counts["failedWidefrogJobs"]} failed jobs'], [_policy_action(session, label='Retry Widefrog failed jobs', action_id='widefrog.retry_failed', target='widefrog')])) + + result.extend(_needs_review_issues(session)) + for issue in result: + issue['suggestedActions'] = _filter_safe_actions(issue['suggestedActions']) + return {'success': True, 'issues': result, 'lastUpdated': _now()} + + +def _issue(issue_id: str, severity: str, title: str, description: str, affected: list[str], evidence: list[str], actions: list[dict[str, Any]], *, source: str = 'media_pipeline', observed_at: str | None = None) -> dict[str, Any]: + return {'id': issue_id, 'severity': severity, 'title': title, 'description': description, 'affectedConnectors': affected, 'evidence': evidence, 'suggestedActions': actions, 'source': source, 'observedAt': observed_at or _now()} + + +def _configure_action(connector: str) -> dict[str, Any]: + link = f'/setup?step=connectors&connector={connector}' if connector in CONNECTOR_IDS else f'/apps/{connector}' + return {'label': f'Configure {connector}', 'actionId': 'connector.configure', 'target': connector, 'policyDecision': 'allowed', 'risk': 'low', 'link': link} + + +def _needs_review_issues(session: Session) -> list[dict[str, Any]]: + apps = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712 + result: list[dict[str, Any]] = [] + for app in apps: + if (app.category or '').lower() not in {'media', 'downloads'}: + continue + target = str(getattr(app, 'connector_type', None) or app.id) + result.append( + _issue( + f'app-needs-review-{app.id}', + 'info', + f'{app.name} needs review', + 'Imported media app needs operator review before enabling broader actions.', + [target], + ['needsReview=true'], + [_configure_action(target)], + ) + ) + return result + + +def downloads(session: Session) -> dict[str, Any]: + data = _snapshot_cached() + actions = [ + {'label': 'Open Deluge detail', 'actionId': 'app.open', 'target': 'deluge', 'policyDecision': 'allowed', 'risk': 'low', 'link': '/apps/deluge'}, + _policy_action(session, label='Resume stalled torrent', action_id='deluge.resume_torrent', target='deluge'), + _policy_action(session, label='Retry Widefrog failed', action_id='widefrog.retry_failed', target='widefrog'), + _policy_action(session, label='Pause all Deluge', action_id='deluge.pause_all', target='deluge'), + ] + warnings = [] + if _int(data['delugeStalled'].get('total')): + warnings.append('Deluge has stalled downloads') + if _int(data['widefrogFailed'].get('failed')): + warnings.append('Widefrog has failed jobs') + return { + 'success': True, + 'active': data['delugeActive'], + 'stalled': data['delugeStalled'], + 'paused': data['delugePaused'], + 'failedWidefrogJobs': data['widefrogFailed'], + 'speedSummary': data['delugeSpeeds'], + 'queueSummary': data['widefrogQueue'], + 'warnings': warnings, + 'suggestedActions': _filter_safe_actions(actions), + 'lastUpdated': _now(), + } + + +def library(session: Session) -> dict[str, Any]: + data = _snapshot_cached() + actions = [ + _policy_action(session, label='Scan Plex library', action_id='plex.scan_library', target='plex'), + _policy_action(session, label='Scan all Plex libraries', action_id='plex.scan_all_libraries', target='plex'), + _policy_action(session, label='Scan Widefrog library', action_id='widefrog.scan_library', target='widefrog'), + ] + return { + 'success': True, + 'plexLibraries': data['plexLibraries'], + 'recentMedia': data['plexRecent'], + 'activeSessions': data['plexSessions'], + 'transcodeSummary': data['plexStreams'], + 'tautulliStats': data['tautulliStats'], + 'tautulliLibraries': data['tautulliLibraries'], + 'widefrogQueue': data['widefrogQueue'], + 'scanSuggestions': _filter_safe_actions(actions), + 'suggestedActions': _filter_safe_actions(actions), + 'lastUpdated': _now(), + } + + +def activity() -> dict[str, Any]: + data = _snapshot_cached() + return { + 'success': True, + 'tautulliActivity': data['tautulliActivity'], + 'plexSessions': data['plexSessions'], + 'tautulliHistory': data['tautulliHistory'], + 'plexRecent': data['plexRecent'], + 'streamSummary': data['tautulliStreams'], + 'transcodeSummary': data['plexStreams'], + 'privacy': {'sensitive': True, 'actions': ['tautulli.history', 'tautulli.users', 'tautulli.watch_stats'], 'voicePolicy': 'approval_required'}, + 'lastUpdated': _now(), + } + + +def recommendations(session: Session) -> dict[str, Any]: + issue_payload = issues(session) + items = [ + { + 'id': f'rec-{issue["id"]}', + 'severity': issue['severity'], + 'confidence': 0.86 if issue['severity'] == 'warning' else 0.72, + 'title': issue['title'], + 'evidence': issue['evidence'], + 'affectedConnectors': issue['affectedConnectors'], + 'action': issue['suggestedActions'][0] if issue['suggestedActions'] else {'label': 'Review', 'actionId': 'dashboard.review', 'target': 'media-pipeline', 'policyDecision': 'allowed', 'risk': 'low'}, + } + for issue in issue_payload['issues'] + ] + if not items: + items.append({'id': 'rec-healthy', 'severity': 'info', 'confidence': 0.7, 'title': 'Media pipeline healthy', 'evidence': ['No active cross-connector issues detected'], 'affectedConnectors': list(CONNECTOR_IDS), 'action': {'label': 'Review dashboard', 'actionId': 'dashboard.review', 'target': 'media-pipeline', 'policyDecision': 'allowed', 'risk': 'low'}}) + return {'success': True, 'recommendations': items, 'lastUpdated': _now()} + + +def check(session: Session) -> dict[str, Any]: + steps = ['Check connector statuses', 'Pull summaries', 'Detect issues', 'Write audit event', 'Return media pipeline summary'] + job = create_job(session, action_id='media_pipeline.check', target='media-pipeline', source='manual', risk='low', policy_decision='allowed', steps=steps) + start_job(session, job) + set_job_progress(session, job, 45, 'Collected connector summaries.') + summary_payload = summary(session, force_refresh=True) + issue_payload = issues(session) + completed = complete_job(session, job, status='succeeded', result_summary=summary_payload['status']) + audit = log_event( + session, + source='action', + identity='manual', + action_id='media_pipeline.check', + target='media-pipeline', + risk='low', + policy_decision='allowed', + reason='Read-only media pipeline check.', + result='success', + execution_plan={'plan': steps, 'jobId': completed.id}, + redacted_metadata={'connectors': summary_payload['connectors'], 'issueCount': len(issue_payload['issues'])}, + ) + return {'success': True, 'summary': summary_payload, 'issues': issue_payload['issues'], 'jobId': completed.id, 'auditId': audit.id} + + +def diagnose(session: Session, payload: dict[str, Any]) -> dict[str, Any]: + query = str(payload.get('query') or '').strip() + scope = str(payload.get('scope') or 'all').strip() or 'all' + data = _snapshot_cached() + counts = _counts(data) + likely_causes: list[str] = [] + evidence: list[str] = [] + actions: list[dict[str, Any]] = [] + + if counts['wantedSeries'] and counts['activeDownloads'] == 0: + likely_causes.append('Wanted media exists but no download is currently active.') + evidence.extend([f'{counts["wantedSeries"]} wanted items', '0 active Deluge downloads']) + actions.append(_policy_action(session, label='Run Prowlarr search test', action_id='prowlarr.search_test', target='prowlarr')) + if counts['failingIndexers']: + likely_causes.append('One or more Prowlarr indexers are failing.') + evidence.append(f'{counts["failingIndexers"]} failing indexers') + actions.append(_policy_action(session, label='Run Prowlarr search test', action_id='prowlarr.search_test', target='prowlarr')) + if counts['stalledDownloads']: + likely_causes.append('Deluge has stalled downloads.') + evidence.append(f'{counts["stalledDownloads"]} stalled downloads') + actions.append(_policy_action(session, label='Resume stalled torrent', action_id='deluge.resume_torrent', target='deluge')) + if counts['failedWidefrogJobs']: + likely_causes.append('Widefrog has failed processing jobs.') + evidence.append(f'{counts["failedWidefrogJobs"]} failed Widefrog jobs') + actions.append(_policy_action(session, label='Retry Widefrog failed', action_id='widefrog.retry_failed', target='widefrog')) + if counts['transcodes'] >= 3: + likely_causes.append('Plex has several active transcodes.') + evidence.append(f'{counts["transcodes"]} transcodes') + + if not likely_causes: + likely_causes.append('No obvious rule-based issue found.') + evidence.append('Connector summaries did not cross configured thresholds') + + audit = log_event( + session, + source='action', + identity='manual', + action_id='media_pipeline.diagnose', + target='media-pipeline', + risk='low', + policy_decision='allowed', + reason='Read-only media pipeline diagnosis.', + result='success', + execution_plan={'query': query, 'scope': scope}, + redacted_metadata={'scope': scope}, + ) + return { + 'success': True, + 'diagnosis': { + 'query': query, + 'scope': scope, + 'likelyCauses': likely_causes, + 'evidence': evidence, + 'suggestedActions': _filter_safe_actions(actions), + 'executedActions': [], + 'auditId': audit.id, + }, + } diff --git a/backend/app/services/n8n_integration.py b/backend/app/services/n8n_integration.py new file mode 100644 index 0000000..e672243 --- /dev/null +++ b/backend/app/services/n8n_integration.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import base64 +from datetime import UTC, datetime +import hashlib +import json +from threading import Lock +from typing import Any + +from sqlalchemy import and_, or_ +from sqlmodel import Session, select + +from ..core.redaction import redact_value +from ..core.time import utc_now +from ..db.models import AppSetting, AuditEvent, JobRun, NotificationEvent +from .action_registry import ACTIONS, ACTION_RISK +from .approval_service import create_approval, get_approval +from .audit_logger import log_event +from .policy_engine import evaluate_policy + + +_IDEMPOTENCY_LOCK = Lock() +_IDEMPOTENCY_PREFIX = "n8n:idempotency:" +_IDEMPOTENCY_MAX_RECORDS = 5_000 +_HEARTBEAT_KEY = "n8n:workflow_heartbeat" + + +def _redact(value: Any) -> Any: + return redact_value(value) + + +def _encode_cursor(timestamp: datetime, event_id: str) -> str: + raw = json.dumps({"at": timestamp.astimezone(UTC).isoformat(), "id": event_id}, separators=(",", ":")) + return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=") + + +def _decode_cursor(value: str | None) -> tuple[datetime, str]: + if not value: + return datetime.fromtimestamp(0, UTC), "" + try: + padded = value + "=" * (-len(value) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded).decode()) + timestamp = datetime.fromisoformat(str(payload["at"]).replace("Z", "+00:00")) + return timestamp.astimezone(UTC), str(payload["id"]) + except (ValueError, KeyError, TypeError, json.JSONDecodeError) as exc: + raise ValueError("invalid_cursor") from exc + + +def _as_utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + +def event_feed(session: Session, *, cursor: str | None, limit: int) -> dict[str, Any]: + after_at, after_id = _decode_cursor(cursor) + after_db = after_at.replace(tzinfo=None) + events: list[dict[str, Any]] = [] + audit_query = ( + select(AuditEvent) + .where(or_( + AuditEvent.timestamp > after_db, + and_(AuditEvent.timestamp == after_db, AuditEvent.id > after_id), + )) + .order_by(AuditEvent.timestamp, AuditEvent.id) + .limit(limit + 1) + ) + for row in session.exec(audit_query).all(): + events.append(_redact({ + "id": row.id, + "occurredAt": _as_utc(row.timestamp), + "type": "audit.action", + "severity": row.risk, + "source": row.source, + "target": row.target, + "payload": { + "actionId": row.action_id, + "policyDecision": row.policy_decision, + "result": row.result, + "reason": row.reason, + "metadata": row.redacted_metadata or {}, + }, + })) + notification_query = ( + select(NotificationEvent) + .where(or_( + NotificationEvent.created_at > after_db, + and_(NotificationEvent.created_at == after_db, NotificationEvent.id > after_id), + )) + .order_by(NotificationEvent.created_at, NotificationEvent.id) + .limit(limit + 1) + ) + for row in session.exec(notification_query).all(): + events.append(_redact({ + "id": row.id, + "occurredAt": _as_utc(row.created_at), + "type": "notification.created", + "severity": row.severity, + "source": row.source, + "target": row.target, + "payload": { + "title": row.title, + "message": row.message, + "category": row.category, + "status": row.status, + }, + })) + events.sort(key=lambda item: (item["occurredAt"], item["id"])) + page = events[:limit] + for item in page: + item["occurredAt"] = _as_utc(item["occurredAt"]).isoformat() + next_cursor = cursor + if page: + last = page[-1] + next_cursor = _encode_cursor(datetime.fromisoformat(last["occurredAt"]), last["id"]) + return {"items": page, "nextCursor": next_cursor, "hasMore": len(events) > len(page)} + + +def record_heartbeat(session: Session, payload: dict[str, Any]) -> dict[str, Any]: + now = utc_now() + setting = session.get(AppSetting, _HEARTBEAT_KEY) + if not setting: + setting = AppSetting(key=_HEARTBEAT_KEY, is_secret=False) + previous = setting.value_json if isinstance(setting.value_json, dict) else {} + branch = str(payload.get("branch", "unknown"))[:40] + branch_status = { + "branch": branch, + "processedEvents": max(0, min(int(payload.get("processedEvents", 0) or 0), 100_000)), + "backlogRemaining": bool(payload.get("backlogRemaining", False)), + "confidence": str(payload.get("confidence", ""))[:20], + "receivedAt": now.isoformat(), + } + branches = previous.get("branches") if isinstance(previous.get("branches"), dict) else {} + branches = {**branches, branch: branch_status} + event_status = branch_status if branch == "events" else branches.get("events", {}) + setting.value_json = { + "workflowId": str(payload.get("workflowId", ""))[:120], + "workflowVersion": str(payload.get("workflowVersion", ""))[:40], + "branch": branch, + "processedEvents": int(event_status.get("processedEvents", 0) or 0), + "backlogRemaining": bool(event_status.get("backlogRemaining", False)), + "confidence": branch_status["confidence"], + "receivedAt": now.isoformat(), + "branches": branches, + } + setting.updated_at = now + session.add(setting) + session.commit() + return {"accepted": True, "receivedAt": now.isoformat(), "canExecute": False} + + +def heartbeat_status(session: Session) -> dict[str, Any]: + setting = session.get(AppSetting, _HEARTBEAT_KEY) + payload = setting.value_json if setting and isinstance(setting.value_json, dict) else {} + received_at = payload.get("receivedAt") + fresh = False + if received_at: + try: + observed = _as_utc(datetime.fromisoformat(str(received_at).replace("Z", "+00:00"))) + fresh = (datetime.now(UTC) - observed).total_seconds() <= 30 * 60 + except ValueError: + fresh = False + return { + "state": "reachable" if fresh else "configured", + "lastVerifiedAt": received_at, + "workflow": payload or None, + } + + +def _trim_idempotency_records(session: Session) -> None: + stale = session.exec( + select(AppSetting) + .where(AppSetting.key.startswith(_IDEMPOTENCY_PREFIX)) + .order_by(AppSetting.updated_at.desc()) + .offset(_IDEMPOTENCY_MAX_RECORDS - 1) + ).all() + for row in stale: + session.delete(row) + + +def preflight(session: Session, action: str, target: str) -> dict[str, Any]: + decision = evaluate_policy(session, action=action, target=target, source="automation") + return { + "action": action, + "target": target, + "source": "automation", + "risk": decision.get("risk", ACTION_RISK.get(action, "critical")), + "policyDecision": decision["decision"], + "reason": decision["reason"], + "knownAction": action in ACTIONS, + "executionAvailable": False, + "nextStep": "create_approval" if decision["decision"] == "approval_required" else "manual_review" if decision["decision"] == "blocked" else "use_typed_appops_surface", + } + + +def request_approval( + session: Session, + *, + action: str, + target: str, + reason: str, + plan: list[str], + idempotency_key: str, +) -> dict[str, Any]: + with _IDEMPOTENCY_LOCK: + return _request_approval_locked( + session, + action=action, + target=target, + reason=reason, + plan=plan, + idempotency_key=idempotency_key, + ) + + +def _request_approval_locked( + session: Session, + *, + action: str, + target: str, + reason: str, + plan: list[str], + idempotency_key: str, +) -> dict[str, Any]: + request_digest = hashlib.sha256(json.dumps({ + "action": action, "target": target, "reason": reason, "plan": plan, + }, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + setting_key = f"{_IDEMPOTENCY_PREFIX}{hashlib.sha256(idempotency_key.encode()).hexdigest()}" + existing = session.get(AppSetting, setting_key) + if existing: + if existing.value_json.get("requestDigest") != request_digest: + raise ValueError("idempotency_key_reused_with_different_request") + return {**existing.value_json.get("response", {}), "idempotentReplay": True} + + preview = preflight(session, action, target) + if preview["policyDecision"] == "blocked": + log_event( + session, source="automation", identity="n8n", action_id=action, target=target, + risk=preview["risk"], policy_decision="blocked", reason=preview["reason"], result="blocked", + redacted_metadata={"integration": "n8n", "request": "approval"}, + ) + raise PermissionError("action_blocked_by_policy") + if preview["policyDecision"] != "approval_required": + response = {**preview, "created": False, "message": "Deze actie vereist volgens de huidige policy geen approval; n8n voert haar niet uit."} + else: + safe_reason = str(_redact(reason))[:1000] + approval = create_approval( + session, + action=action, + target=target, + source="automation", + risk=preview["risk"], + reason=safe_reason, + requester="n8n", + context={"plan": [_redact(item)[:300] for item in plan[:30]], "integration": "n8n"}, + ) + audit = log_event( + session, source="automation", identity="n8n", action_id="approval.request", target=target, + target_type="approval", risk=preview["risk"], policy_decision="approval_required", + reason=safe_reason, result="approval_requested", + redacted_metadata={"approvalId": approval.id, "requestedAction": action, "integration": "n8n"}, + ) + response = { + **preview, + "created": True, + "approvalId": approval.id, + "approvalStatus": approval.status, + "auditId": audit.id, + "expiresAt": approval.expires_at.isoformat(), + } + _trim_idempotency_records(session) + session.add(AppSetting(key=setting_key, value_json={"requestDigest": request_digest, "response": response})) + session.commit() + return {**response, "idempotentReplay": False} + + +def approval_status(session: Session, approval_id: str) -> dict[str, Any] | None: + approval = get_approval(session, approval_id) + if not approval: + return None + return { + "id": approval.id, + "action": approval.action, + "target": approval.target, + "risk": approval.risk, + "status": approval.status, + "expiresAt": approval.expires_at, + "jobId": approval.job_id, + "auditEventId": approval.audit_event_id, + } + + +def job_status(session: Session, job_id: str) -> dict[str, Any] | None: + job = session.get(JobRun, job_id) + if not job: + return None + return _redact(job.model_dump()) diff --git a/backend/app/services/notification_channels/__init__.py b/backend/app/services/notification_channels/__init__.py new file mode 100644 index 0000000..6c696bb --- /dev/null +++ b/backend/app/services/notification_channels/__init__.py @@ -0,0 +1,2 @@ +"""Notification channel delivery helpers.""" + diff --git a/backend/app/services/notification_channels/external.py b/backend/app/services/notification_channels/external.py new file mode 100644 index 0000000..130f94c --- /dev/null +++ b/backend/app/services/notification_channels/external.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from email.message import EmailMessage +import json +import smtplib +import ssl +import time +from typing import Any +from urllib import error, request + +from ...core.redaction import redact_message +from ...db.models import NotificationChannel +from ..secret_store import get_secret + + +SECRET_REFS = { + "discord": "NOTIFICATION_DISCORD_WEBHOOK", + "telegram": "NOTIFICATION_TELEGRAM_BOT_TOKEN", + "email": "NOTIFICATION_SMTP_PASSWORD", +} + + +def safe_config(channel_type: str, config: dict[str, Any]) -> dict[str, Any]: + allowed = { + "discord": {"username", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"}, + "telegram": {"chatId", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"}, + "email": {"host", "port", "username", "fromAddress", "toAddress", "useTls", "smtpMode", "timeoutSeconds", "maxAttempts", "retryBackoffSeconds"}, + }.get(channel_type, set()) + return {key: value for key, value in config.items() if key in allowed} + + +def config_status(session, channel: NotificationChannel) -> dict[str, Any]: + config = safe_config(channel.type, channel.config_json or {}) + secret_ref = SECRET_REFS.get(channel.type) + has_secret = bool(secret_ref and get_secret(session, secret_ref, touch=False)) + missing: list[str] = [] + if channel.type == "telegram" and not str(config.get("chatId") or "").strip(): + missing.append("chatId") + if channel.type == "email": + for field in ("host", "fromAddress", "toAddress"): + if not str(config.get(field) or "").strip(): + missing.append(field) + if not has_secret: + missing.append("credential") + elif channel.type == "discord": + webhook = get_secret(session, secret_ref, touch=False) or "" + if not webhook.startswith(("https://discord.com/api/webhooks/", "https://discordapp.com/api/webhooks/")): + missing.append("validDiscordWebhook") + return { + "status": "disabled" if not channel.enabled else ("configured" if not missing else "missing_config"), + "hasSecret": has_secret, + "missing": missing, + "safeConfig": config, + } + + +def _http_json(url: str, payload: dict[str, Any], timeout: int) -> dict[str, Any]: + try: + body = json.dumps(payload).encode() + req = request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST") + with request.urlopen(req, timeout=timeout) as response: + status = int(response.status) + return {"success": 200 <= status < 300, "status": "sent" if 200 <= status < 300 else "failed", "httpStatus": status} + except error.HTTPError as exc: + return {"success": False, "status": "failed", "httpStatus": int(exc.code), "reason": "remote_http_error"} + except (error.URLError, TimeoutError, OSError, ValueError): + return {"success": False, "status": "failed", "reason": "connection_failed"} + + +def _send_once(session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]: + status = config_status(session, channel) + if not channel.enabled: + return {"success": True, "status": "disabled"} + if status["missing"]: + return {"success": False, "status": "missing_config", "missing": status["missing"]} + config = status["safeConfig"] + timeout = max(2, min(int(config.get("timeoutSeconds") or 10), 30)) + title = redact_message(str(event.get("title") or "AppOps notification"))[:200] + message = redact_message(str(event.get("message") or ""))[:1500] + secret = get_secret(session, SECRET_REFS[channel.type], touch=True) or "" + + if channel.type == "discord": + return _http_json(secret, {"username": str(config.get("username") or "AppOps"), "content": f"**{title}**\n{message}"}, timeout) + if channel.type == "telegram": + url = f"https://api.telegram.org/bot{secret}/sendMessage" + return _http_json(url, {"chat_id": str(config["chatId"]), "text": f"{title}\n{message}"}, timeout) + if channel.type == "email": + email = EmailMessage() + email["Subject"] = title + email["From"] = str(config["fromAddress"]) + email["To"] = str(config["toAddress"]) + email.set_content(message) + try: + smtp_mode = str(config.get("smtpMode") or ("implicit_tls" if config.get("useTls", True) else "plain")) + if smtp_mode not in {"implicit_tls", "starttls", "plain"}: + return {"success": False, "status": "missing_config", "missing": ["validSmtpMode"]} + port = int(config.get("port") or (465 if smtp_mode == "implicit_tls" else 587 if smtp_mode == "starttls" else 25)) + if smtp_mode == "implicit_tls": + with smtplib.SMTP_SSL(str(config["host"]), port, timeout=timeout, context=ssl.create_default_context()) as smtp: + if config.get("username"): + smtp.login(str(config["username"]), secret) + smtp.send_message(email) + else: + with smtplib.SMTP(str(config["host"]), port, timeout=timeout) as smtp: + if smtp_mode == "starttls": + smtp.ehlo() + smtp.starttls(context=ssl.create_default_context()) + smtp.ehlo() + if config.get("username"): + smtp.login(str(config["username"]), secret) + smtp.send_message(email) + return {"success": True, "status": "sent"} + except (OSError, smtplib.SMTPException, ValueError): + return {"success": False, "status": "failed", "reason": "smtp_delivery_failed"} + return {"success": False, "status": "unsupported_channel"} + + +def send_notification(session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]: + config = safe_config(channel.type, channel.config_json or {}) + max_attempts = max(1, min(int(config.get("maxAttempts") or 3), 3)) + backoff = max(0.0, min(float(config.get("retryBackoffSeconds") or 0.25), 2.0)) + result: dict[str, Any] = {"success": False, "status": "failed", "reason": "delivery_failed"} + for attempt in range(1, max_attempts + 1): + result = _send_once(session, channel, event) + result["attempts"] = attempt + if result.get("success") or result.get("status") in {"disabled", "missing_config", "unsupported_channel"}: + return result + retryable = result.get("reason") in {"connection_failed", "smtp_delivery_failed"} or int(result.get("httpStatus") or 0) >= 500 or int(result.get("httpStatus") or 0) == 429 + if not retryable: + return result + if attempt < max_attempts and backoff: + time.sleep(backoff * (2 ** (attempt - 1))) + result["deadLetter"] = True + return result diff --git a/backend/app/services/notification_channels/home_assistant.py b/backend/app/services/notification_channels/home_assistant.py new file mode 100644 index 0000000..9e9293f --- /dev/null +++ b/backend/app/services/notification_channels/home_assistant.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import json +import ssl +import urllib.error +import urllib.request +from typing import Any + +from sqlmodel import Session + +from ...core.config import settings +from ...db.models import NotificationChannel +from ..secret_store import get_secret + +SECRET_REF = "HOME_ASSISTANT_TOKEN" + + +def _api_value(key: str, default: Any = None) -> Any: + value = settings.apis.get(key) + return default if value is None else value + + +def _bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _int(value: Any, default: int) -> int: + try: + return max(1, int(value)) + except (TypeError, ValueError): + return default + + +def _clean_url(value: Any) -> str | None: + text = str(value or "").strip().rstrip("/") + return text or None + + +def normalize_config(raw: dict[str, Any] | None) -> dict[str, Any]: + config = dict(raw or {}) + return { + "url": _clean_url(config.get("url") or _api_value("HOME_ASSISTANT_URL")), + "notifyDomain": str(config.get("notifyDomain") or _api_value("HOME_ASSISTANT_NOTIFY_DOMAIN", "notify") or "notify").strip() or "notify", + "notifyService": str(config.get("notifyService") or _api_value("HOME_ASSISTANT_NOTIFY_SERVICE", "notify") or "notify").strip() or "notify", + "target": str(config.get("target") or _api_value("HOME_ASSISTANT_NOTIFY_TARGET") or "").strip() or None, + "verifyTls": _bool(config.get("verifyTls"), _bool(_api_value("HOME_ASSISTANT_VERIFY_TLS"), False)), + "timeoutSeconds": _int(config.get("timeoutSeconds") or _api_value("HOME_ASSISTANT_TIMEOUT_SECONDS"), 10), + "titlePrefix": str(config.get("titlePrefix") or _api_value("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX", "AppOps") or "AppOps").strip() or "AppOps", + "dryRun": _bool(config.get("dryRun"), _bool(_api_value("HOME_ASSISTANT_DRY_RUN"), False)), + } + + +def safe_config(config: dict[str, Any] | None) -> dict[str, Any]: + normalized = normalize_config(config) + return { + "url": normalized["url"], + "notifyDomain": normalized["notifyDomain"], + "notifyService": normalized["notifyService"], + "target": normalized["target"], + "verifyTls": normalized["verifyTls"], + "timeoutSeconds": normalized["timeoutSeconds"], + "titlePrefix": normalized["titlePrefix"], + "dryRun": normalized["dryRun"], + } + + +def _token(session: Session) -> str | None: + return get_secret(session, SECRET_REF) or (str(_api_value(SECRET_REF) or "").strip() or None) + + +def config_status(session: Session, channel: NotificationChannel) -> dict[str, Any]: + config = normalize_config(channel.config_json) + missing: list[str] = [] + token = _token(session) + if not config["url"]: + missing.append("HOME_ASSISTANT_URL") + if not token: + missing.append("HOME_ASSISTANT_TOKEN") + if missing: + status = "missing_config" + elif not channel.enabled: + status = "disabled" + else: + status = "connected" + return { + "status": status, + "missing": missing, + "hasSecret": bool(token), + "safeConfig": safe_config(channel.config_json), + } + + +def format_message(event: dict[str, Any]) -> str: + severity = str(event.get("severity") or "info").upper() + source = str(event.get("source") or "appops") + target = str(event.get("target") or "global") + message = str(event.get("message") or event.get("title") or "Notification") + return f"{severity} - {source} - {target}\n{message}" + + +def _title(config: dict[str, Any], event: dict[str, Any]) -> str: + prefix = str(config.get("titlePrefix") or "AppOps") + title = str(event.get("title") or "Notification") + return f"{prefix}: {title}" if prefix else title + + +def _request_context(config: dict[str, Any]) -> ssl.SSLContext | None: + if config["verifyTls"]: + return None + return ssl._create_unverified_context() + + +def _call_home_assistant(config: dict[str, Any], token: str, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + body = json.dumps(payload).encode("utf-8") if payload is not None else None + request = urllib.request.Request( + f"{config['url']}{path}", + data=body, + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=config["timeoutSeconds"], context=_request_context(config)) as response: + return {"ok": True, "statusCode": response.status} + except urllib.error.HTTPError as exc: + if exc.code in {401, 403}: + return {"ok": False, "status": "auth_failed", "statusCode": exc.code} + return {"ok": False, "status": "error", "statusCode": exc.code} + except urllib.error.URLError: + return {"ok": False, "status": "unreachable"} + except OSError: + return {"ok": False, "status": "unreachable"} + + +def test_connection(session: Session, channel: NotificationChannel) -> dict[str, Any]: + status = config_status(session, channel) + if status["missing"]: + return {"success": False, "status": "missing_config", "missing": status["missing"]} + config = normalize_config(channel.config_json) + token = _token(session) or "" + if config["dryRun"]: + return {"success": True, "status": "connected", "dryRun": True} + result = _call_home_assistant(config, token, "GET", "/api/") + return { + "success": bool(result["ok"]), + "status": "connected" if result["ok"] else result.get("status", "error"), + "statusCode": result.get("statusCode"), + } + + +def _notify_payload(config: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]: + payload: dict[str, Any] = { + "title": _title(config, event), + "message": format_message(event), + "data": { + "source": event.get("source"), + "severity": event.get("severity"), + "category": event.get("category"), + "target": event.get("target"), + "ruleId": event.get("ruleId"), + "eventId": event.get("id"), + }, + } + if config["target"]: + payload["target"] = config["target"] + return payload + + +def send_notification(session: Session, channel: NotificationChannel, event: dict[str, Any]) -> dict[str, Any]: + if not channel.enabled: + return {"status": "suppressed", "reason": "disabled"} + status = config_status(session, channel) + if status["missing"]: + return {"status": "failed", "reason": "missing_config", "missing": status["missing"]} + config = normalize_config(channel.config_json) + if config["dryRun"]: + return {"status": "sent", "reason": "dry_run"} + token = _token(session) or "" + path = f"/api/services/{config['notifyDomain']}/{config['notifyService']}" + result = _call_home_assistant(config, token, "POST", path, _notify_payload(config, event)) + if result["ok"]: + return {"status": "sent"} + return {"status": "failed", "reason": result.get("status", "error"), "statusCode": result.get("statusCode")} + + +def send_test_notification(session: Session, channel: NotificationChannel) -> dict[str, Any]: + event = { + "id": "test", + "ruleId": "notification.channel.test", + "source": "notification", + "event_type": "notification.channel.test", + "severity": "info", + "title": "Home Assistant test notification", + "message": "AppOps test notification.", + "category": "test", + "target": "home_assistant", + } + result = send_notification(session, channel, event) + return { + "success": result["status"] == "sent", + "status": result["status"], + "reason": result.get("reason"), + "missing": result.get("missing"), + } diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..0ca9970 --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,1050 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +import json +import re +from threading import RLock +from typing import Any +from uuid import uuid4 + +from sqlalchemy import case, func, or_, update +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import App, AuditEvent, Connector, DiscoveryRun, NotificationChannel, NotificationEvent, NotificationRule +from . import media_pipeline_service, safety_center_service +from .audit_logger import log_event +from .notification_channels import external, home_assistant +from .secret_store import set_secret + +SEVERITY_RANK = {'info': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4} +SECRET_KEYS = {'token', 'password', 'secret', 'api_key', 'apikey', 'authorization', 'bearer'} +_EVALUATION_LOCK = RLock() + + +def _now() -> datetime: + return utc_now() + + +def _redact(value: Any) -> Any: + if isinstance(value, str): + redacted = redact_message(value) + if re.search(r'(api[_-]?key|token|password|bearer)', redacted, flags=re.IGNORECASE): + return '[REDACTED]' + return redacted + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, item in value.items(): + lowered = str(key).lower() + if any(secret_key in lowered for secret_key in SECRET_KEYS): + continue + result[str(key)] = _redact(item) + return result + return value + + +def _rule_to_dict(rule: NotificationRule) -> dict[str, Any]: + return { + 'id': rule.id, + 'name': rule.name, + 'enabled': rule.enabled, + 'source': rule.source, + 'eventType': rule.event_type, + 'severityMin': rule.severity_min, + 'categories': rule.categories_json, + 'targets': rule.targets_json, + 'channels': rule.channels_json, + 'cooldownSeconds': rule.cooldown_seconds, + 'quietHours': rule.quiet_hours_json, + 'conditions': rule.conditions_json, + 'dedupeKeyTemplate': rule.dedupe_key_template, + 'createdAt': rule.created_at.isoformat(), + 'updatedAt': rule.updated_at.isoformat(), + } + + +def _event_to_dict(event: NotificationEvent) -> dict[str, Any]: + payload = _redact(event.payload_json) + channel_results = payload.get('channelResults', {}) if isinstance(payload, dict) else {} + return { + 'id': event.id, + 'ruleId': event.rule_id, + 'source': event.source, + 'eventType': event.event_type, + 'severity': event.severity, + 'title': event.title, + 'message': event.message, + 'category': event.category, + 'target': event.target, + 'dedupeKey': event.dedupe_key, + 'channels': event.channels_json, + 'status': event.status, + 'reason': event.reason, + 'payload': payload, + 'channelResults': channel_results, + 'read': event.read, + 'createdAt': event.created_at.isoformat(), + 'sentAt': event.sent_at.isoformat() if event.sent_at else None, + 'suppressedUntil': event.suppressed_until.isoformat() if event.suppressed_until else None, + } + + +def _channel_to_dict(channel: NotificationChannel, session: Session | None = None) -> dict[str, Any]: + safe_config = { + key: value + for key, value in channel.config_json.items() + if not any(secret_key in str(key).lower() for secret_key in SECRET_KEYS) + } + status = 'connected' if channel.enabled and channel.type == 'dashboard' else ('configured' if channel.enabled else 'disabled') + has_secret = bool(channel.secret_refs_json) + missing: list[str] = [] + safe_config_alias = _redact(safe_config) + if channel.type == 'home_assistant' and session is not None: + ha_status = home_assistant.config_status(session, channel) + status = ha_status['status'] + has_secret = ha_status['hasSecret'] + missing = ha_status['missing'] + safe_config_alias = _redact(ha_status['safeConfig']) + elif channel.type in external.SECRET_REFS and session is not None: + external_status = external.config_status(session, channel) + status = external_status['status'] + has_secret = external_status['hasSecret'] + missing = external_status['missing'] + safe_config_alias = _redact(external_status['safeConfig']) + return { + 'id': channel.id, + 'type': channel.type, + 'name': channel.name, + 'enabled': channel.enabled, + 'config': safe_config_alias, + 'safeConfig': safe_config_alias, + 'secretRefs': channel.secret_refs_json, + 'status': status, + 'hasSecret': has_secret, + 'missing': missing, + 'lastError': safe_config.get('lastError'), + 'lastSentAt': safe_config.get('lastSentAt'), + 'lastTestAt': channel.last_test_at.isoformat() if channel.last_test_at else None, + 'createdAt': channel.created_at.isoformat(), + 'updatedAt': channel.updated_at.isoformat(), + } + + +def list_rules(session: Session) -> list[dict[str, Any]]: + return [_rule_to_dict(rule) for rule in session.exec(select(NotificationRule).order_by(NotificationRule.name)).all()] + + +def create_rule(session: Session, payload: dict[str, Any]) -> dict[str, Any]: + rule = NotificationRule( + id=str(payload.get('id') or f"notify-{uuid4().hex[:12]}"), + name=str(payload.get('name') or 'Notification Rule'), + enabled=bool(payload.get('enabled', True)), + source=str(payload.get('source') or 'safety'), + event_type=str(payload.get('eventType') or payload.get('event_type') or 'safety.finding'), + severity_min=str(payload.get('severityMin') or payload.get('severity_min') or 'medium'), + categories_json=list(payload.get('categories') or payload.get('categories_json') or []), + targets_json=list(payload.get('targets') or payload.get('targets_json') or []), + channels_json=list(payload.get('channels') or payload.get('channels_json') or ['dashboard']), + cooldown_seconds=int(payload.get('cooldownSeconds') or payload.get('cooldown_seconds') or 3600), + quiet_hours_json=dict(payload.get('quietHours') or payload.get('quiet_hours_json') or {}), + conditions_json=dict(payload.get('conditions') or payload.get('conditions_json') or {}), + dedupe_key_template=str(payload.get('dedupeKeyTemplate') or payload.get('dedupe_key_template') or '{source}:{event_type}:{target}:{category}'), + ) + session.add(rule) + session.commit() + session.refresh(rule) + safety_center_service.invalidate_cache() + return _rule_to_dict(rule) + + +def update_rule(session: Session, rule_id: str, payload: dict[str, Any]) -> dict[str, Any]: + rule = session.get(NotificationRule, rule_id) + if not rule: + return {'success': False, 'status': 'not_found', 'id': rule_id} + mapping = { + 'name': 'name', + 'enabled': 'enabled', + 'source': 'source', + 'eventType': 'event_type', + 'severityMin': 'severity_min', + 'cooldownSeconds': 'cooldown_seconds', + 'dedupeKeyTemplate': 'dedupe_key_template', + } + for input_key, attr in mapping.items(): + if input_key in payload: + setattr(rule, attr, payload[input_key]) + if 'categories' in payload: + rule.categories_json = list(payload['categories']) + if 'targets' in payload: + rule.targets_json = list(payload['targets']) + if 'channels' in payload: + rule.channels_json = list(payload['channels']) + if 'quietHours' in payload: + rule.quiet_hours_json = dict(payload['quietHours']) + if 'conditions' in payload: + rule.conditions_json = dict(payload['conditions']) + rule.updated_at = _now() + session.add(rule) + session.commit() + session.refresh(rule) + safety_center_service.invalidate_cache() + return _rule_to_dict(rule) + + +def delete_rule(session: Session, rule_id: str) -> dict[str, Any]: + rule = session.get(NotificationRule, rule_id) + if not rule: + return {'success': False, 'status': 'not_found', 'id': rule_id} + session.delete(rule) + session.commit() + safety_center_service.invalidate_cache() + return {'success': True, 'status': 'deleted', 'id': rule_id} + + +def list_channels(session: Session) -> list[dict[str, Any]]: + return [_channel_to_dict(channel, session) for channel in session.exec(select(NotificationChannel).order_by(NotificationChannel.name)).all()] + + +def create_channel(session: Session, payload: dict[str, Any]) -> dict[str, Any]: + channel_type = str(payload.get('type') or 'dashboard') + config = dict(payload.get('config') or payload.get('config_json') or {}) + token = str(payload.get('token') or config.pop('token', '') or '').strip() + if channel_type == 'home_assistant': + config = home_assistant.safe_config(config) + elif channel_type in external.SECRET_REFS: + config = external.safe_config(channel_type, config) + channel = NotificationChannel( + id=str(payload.get('id') or f"channel-{uuid4().hex[:12]}"), + type=channel_type, + name=str(payload.get('name') or payload.get('type') or 'Dashboard'), + enabled=bool(payload.get('enabled', True)), + config_json=_redact(config), + secret_refs_json=list(payload.get('secretRefs') or payload.get('secret_refs_json') or ([home_assistant.SECRET_REF] if channel_type == 'home_assistant' else ([external.SECRET_REFS[channel_type]] if channel_type in external.SECRET_REFS else []))), + ) + if channel_type == 'home_assistant' and token: + set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False) + elif channel_type in external.SECRET_REFS and token: + set_secret(session, external.SECRET_REFS[channel_type], token, used_by=[f'notification_channel:{channel_type}'], commit=False) + session.add(channel) + session.commit() + session.refresh(channel) + safety_center_service.invalidate_cache() + return _channel_to_dict(channel, session) + + +def update_channel(session: Session, channel_id: str, payload: dict[str, Any]) -> dict[str, Any]: + channel = session.get(NotificationChannel, channel_id) + if not channel: + return {'success': False, 'status': 'not_found', 'id': channel_id} + if 'name' in payload: + channel.name = str(payload['name']) + if 'enabled' in payload: + channel.enabled = bool(payload['enabled']) + if 'type' in payload: + channel.type = str(payload['type']) + if 'config' in payload: + config = dict(payload['config']) + token = str(payload.get('token') or config.pop('token', '') or '').strip() + if channel.type == 'home_assistant': + channel.config_json = _redact(home_assistant.safe_config(config)) + if token: + set_secret(session, home_assistant.SECRET_REF, token, used_by=['notification_channel:home_assistant'], commit=False) + channel.secret_refs_json = [home_assistant.SECRET_REF] + elif channel.type in external.SECRET_REFS: + channel.config_json = _redact(external.safe_config(channel.type, config)) + if token: + secret_ref = external.SECRET_REFS[channel.type] + set_secret(session, secret_ref, token, used_by=[f'notification_channel:{channel.type}'], commit=False) + channel.secret_refs_json = [secret_ref] + else: + channel.config_json = _redact(config) + elif channel.type == 'home_assistant' and payload.get('token'): + set_secret(session, home_assistant.SECRET_REF, str(payload['token']), used_by=['notification_channel:home_assistant'], commit=False) + channel.secret_refs_json = [home_assistant.SECRET_REF] + elif channel.type in external.SECRET_REFS and payload.get('token'): + secret_ref = external.SECRET_REFS[channel.type] + set_secret(session, secret_ref, str(payload['token']), used_by=[f'notification_channel:{channel.type}'], commit=False) + channel.secret_refs_json = [secret_ref] + if 'secretRefs' in payload: + channel.secret_refs_json = list(payload['secretRefs']) + channel.updated_at = _now() + session.add(channel) + session.commit() + session.refresh(channel) + safety_center_service.invalidate_cache() + return _channel_to_dict(channel, session) + + +def test_channel(session: Session, channel_id: str) -> dict[str, Any]: + channel = session.get(NotificationChannel, channel_id) + if not channel: + return {'success': False, 'status': 'not_found', 'id': channel_id} + channel.last_test_at = _now() + channel.updated_at = channel.last_test_at + session.add(channel) + session.commit() + session.refresh(channel) + if channel.type == 'home_assistant': + result = home_assistant.send_test_notification(session, channel) + channel.config_json = { + **(channel.config_json or {}), + 'lastError': result.get('reason') if not result.get('success') else None, + 'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'), + } + session.add(channel) + session.commit() + session.refresh(channel) + log_event( + session, + source='notification', + identity='system', + action_id='notification.channel.test', + target=channel.id, + target_type='notification_channel', + risk='low', + policy_decision='allowed', + reason=str(result.get('reason') or result.get('status') or 'test_notification'), + result='success' if result.get('success') else 'failed', + redacted_metadata={'status': result.get('status'), 'missing': result.get('missing')}, + ) + result['channel'] = _channel_to_dict(channel, session) + return result + if channel.type in external.SECRET_REFS: + result = external.send_notification(session, channel, {"title": "AppOps test", "message": "Dit is een gecontroleerde testmelding vanuit AppOps."}) + channel.config_json = { + **(channel.config_json or {}), + 'lastError': result.get('reason') if not result.get('success') else None, + 'lastSentAt': channel.last_test_at.isoformat() if result.get('success') else channel.config_json.get('lastSentAt'), + } + session.add(channel) + session.commit() + result['channel'] = _channel_to_dict(channel, session) + return result + safe = _channel_to_dict(channel, session) + if not channel.enabled: + return {'success': True, 'status': 'disabled', 'channel': safe} + if channel.type == 'dashboard': + return {'success': True, 'status': 'sent', 'channel': safe} + if channel.type in {'home_assistant', 'webhook'}: + configured = bool(channel.config_json.get('url') or channel.secret_refs_json) + return {'success': True, 'status': 'prepared' if configured else 'missing_config', 'channel': safe} + return {'success': True, 'status': 'prepared', 'channel': safe} + + +def test_channel_connection(session: Session, channel_id: str) -> dict[str, Any]: + channel = session.get(NotificationChannel, channel_id) + if not channel: + return {'success': False, 'status': 'not_found', 'id': channel_id} + channel.last_test_at = _now() + channel.updated_at = channel.last_test_at + session.add(channel) + session.commit() + session.refresh(channel) + if channel.type == 'home_assistant': + result = home_assistant.test_connection(session, channel) + channel.config_json = { + **(channel.config_json or {}), + 'lastError': result.get('status') if not result.get('success') else None, + } + session.add(channel) + session.commit() + session.refresh(channel) + result['channel'] = _channel_to_dict(channel, session) + return result + return test_channel(session, channel_id) + + +def list_events( + session: Session, + include_dismissed: bool = True, + limit: int = 250, + offset: int = 0, +) -> list[dict[str, Any]]: + bounded_limit = max(1, min(limit, 500)) + bounded_offset = max(0, offset) + statement = select(NotificationEvent) + if not include_dismissed: + statement = statement.where(NotificationEvent.status != 'dismissed') + inbox_priority = case( + ( + (NotificationEvent.read == False) # noqa: E712 + & NotificationEvent.status.not_in(['dismissed', 'suppressed']), + 0, + ), + (NotificationEvent.status.not_in(['dismissed', 'suppressed']), 1), + (NotificationEvent.status == 'suppressed', 2), + else_=3, + ) + statement = ( + statement + .order_by(inbox_priority.asc(), NotificationEvent.created_at.desc(), NotificationEvent.id.desc()) + .offset(bounded_offset) + .limit(bounded_limit) + ) + events = session.exec(statement).all() + return [_event_to_dict(event) for event in events] + + +def unread_events(session: Session, limit: int = 250, offset: int = 0) -> list[dict[str, Any]]: + bounded_limit = max(1, min(limit, 500)) + bounded_offset = max(0, offset) + events = session.exec( + select(NotificationEvent) + .where(NotificationEvent.read == False) # noqa: E712 + .where(NotificationEvent.status != 'dismissed') + .where(NotificationEvent.status != 'suppressed') + .order_by(NotificationEvent.created_at.desc(), NotificationEvent.id.desc()) + .offset(bounded_offset) + .limit(bounded_limit) + ).all() + return [_event_to_dict(event) for event in events] + + +def mark_read(session: Session, event_id: str) -> dict[str, Any]: + event = session.get(NotificationEvent, event_id) + if not event: + return {'success': False, 'status': 'not_found', 'id': event_id} + event.read = True + if event.status == 'queued': + event.status = 'sent' + session.add(event) + session.commit() + session.refresh(event) + result = _event_to_dict(event) + result['success'] = True + return result + + +def dismiss_event(session: Session, event_id: str) -> dict[str, Any]: + event = session.get(NotificationEvent, event_id) + if not event: + return {'success': False, 'status': 'not_found', 'id': event_id} + event.status = 'dismissed' + event.read = True + session.add(event) + session.commit() + session.refresh(event) + result = _event_to_dict(event) + result['success'] = True + return result + + +def mark_all_read(session: Session) -> dict[str, Any]: + events = session.exec(select(NotificationEvent).where(NotificationEvent.read == False)).all() # noqa: E712 + for event in events: + event.read = True + if event.status == 'queued': + event.status = 'sent' + session.add(event) + session.commit() + return {'success': True, 'updated': len(events)} + + +def _severity_matches(rule: NotificationRule, event: dict[str, Any]) -> bool: + return SEVERITY_RANK.get(str(event.get('severity', 'info')), 0) >= SEVERITY_RANK.get(rule.severity_min, 0) + + +def _condition_matches(rule: NotificationRule, event: dict[str, Any]) -> bool: + if rule.source != event.get('source') or rule.event_type != event.get('event_type'): + return False + if not _severity_matches(rule, event): + return False + category = str(event.get('category') or '') + target = str(event.get('target') or '') + if rule.categories_json and category not in rule.categories_json: + return False + if rule.targets_json and target not in rule.targets_json: + return False + title_contains = rule.conditions_json.get('titleContains') if isinstance(rule.conditions_json, dict) else None + if title_contains: + title = str(event.get('title') or '').lower() + if not any(str(item).lower() in title for item in title_contains): + return False + return True + + +def _rule_specificity(rule: NotificationRule) -> int: + conditions = rule.conditions_json if isinstance(rule.conditions_json, dict) else {} + constrained_fields = sum(( + bool(rule.categories_json), + bool(rule.targets_json), + bool(conditions), + )) + return constrained_fields * 100 + SEVERITY_RANK.get(rule.severity_min, 0) + + +def _dedupe_key(rule: NotificationRule, event: dict[str, Any]) -> str: + fallback = str(event.get('dedupe_key') or f"{event.get('source')}:{event.get('event_type')}:{event.get('target')}:{event.get('category')}") + try: + return rule.dedupe_key_template.format( + source=event.get('source') or '', + event_type=event.get('event_type') or '', + severity=event.get('severity') or '', + category=event.get('category') or '', + target=event.get('target') or '', + id=event.get('id') or event.get('findingId') or '', + ) or fallback + except Exception: + return fallback + + +def _cooldown_event(session: Session, rule: NotificationRule, dedupe_key: str) -> NotificationEvent | None: + cutoff = _now() - timedelta(seconds=rule.cooldown_seconds) + statement = ( + select(NotificationEvent) + .where(NotificationEvent.rule_id == rule.id) + .where(NotificationEvent.dedupe_key == dedupe_key) + .where(or_( + NotificationEvent.reason.is_(None), + NotificationEvent.reason.not_in(['cooldown', 'quiet_hours']), + )) + .where(NotificationEvent.created_at >= cutoff) + .order_by(NotificationEvent.created_at.desc()) + ) + return session.exec(statement).first() + + +def _quiet_hours_until( + rule: NotificationRule, + event: dict[str, Any], + now: datetime | None = None, +) -> datetime | None: + quiet = rule.quiet_hours_json or {} + if not quiet.get('enabled'): + return None + if event.get('severity') == 'critical' and quiet.get('allowCriticalOverride', True): + return None + try: + start = int(quiet.get('startHour', 22)) % 24 + end = int(quiet.get('endHour', 7)) % 24 + except (TypeError, ValueError): + start, end = 22, 7 + current = now or _now() + hour = current.hour + suppressed = hour >= start or hour < end if start > end else start <= hour < end + if not suppressed: + return None + until = current.replace(hour=end, minute=0, second=0, microsecond=0) + if until <= current: + until += timedelta(days=1) + return until + + +def _quiet_hours_suppressed(rule: NotificationRule, event: dict[str, Any]) -> bool: + return _quiet_hours_until(rule, event) is not None + + +def _persist_event(session: Session, rule: NotificationRule, event: dict[str, Any], status: str, reason: str | None = None, suppressed_until: datetime | None = None) -> NotificationEvent: + row = NotificationEvent( + id=f"notif-{uuid4().hex[:12]}", + rule_id=rule.id, + source=str(event.get('source') or rule.source), + event_type=str(event.get('event_type') or rule.event_type), + severity=str(event.get('severity') or rule.severity_min), + title=_redact(str(event.get('title') or 'Notification')), + message=_redact(str(event.get('message') or event.get('title') or 'Notification')), + category=str(event.get('category') or '') or None, + target=str(event.get('target') or '') or None, + dedupe_key=_dedupe_key(rule, event), + channels_json=list(rule.channels_json or ['dashboard']), + status=status, + reason=reason, + payload_json=_redact(dict(event.get('payload') or {})), + read=status == 'suppressed', + sent_at=_now() if status == 'sent' else None, + suppressed_until=suppressed_until, + ) + session.add(row) + session.commit() + session.refresh(row) + return row + + +def _suppressed_channel_results(channels: list[str], reason: str) -> dict[str, dict[str, Any]]: + return {channel_id: {'status': 'suppressed', 'reason': reason} for channel_id in channels} + + +def _record_suppression( + session: Session, + rule: NotificationRule, + event: dict[str, Any], + reason: str, + suppressed_until: datetime, +) -> NotificationEvent: + """Atomically aggregate one read-only evidence row per routine suppression identity.""" + dedupe_key = _dedupe_key(rule, event) + now = _now() + source_payload = event.get('payload') if isinstance(event.get('payload'), dict) else {} + channels = list(rule.channels_json or ['dashboard']) + payload_patch = _redact({ + **source_payload, + 'channelResults': _suppressed_channel_results(channels, reason), + }) + values = { + 'severity': str(event.get('severity') or rule.severity_min), + 'title': _redact(str(event.get('title') or 'Notification')), + 'message': _redact(str(event.get('message') or event.get('title') or 'Notification')), + 'category': str(event.get('category') or '') or None, + 'target': str(event.get('target') or '') or None, + 'channels_json': channels, + 'read': True, + 'suppressed_until': suppressed_until, + } + + def update_existing() -> bool: + payload = func.json_set( + func.json_patch(NotificationEvent.payload_json, json.dumps(payload_patch)), + '$.suppressionCount', + func.coalesce(func.json_extract(NotificationEvent.payload_json, '$.suppressionCount'), 0) + 1, + '$.lastSuppressedAt', + now.isoformat(), + ) + result = session.execute( + update(NotificationEvent) + .where(NotificationEvent.rule_id == rule.id) + .where(NotificationEvent.dedupe_key == dedupe_key) + .where(NotificationEvent.status == 'suppressed') + .where(NotificationEvent.reason == reason) + .values(**values, payload_json=payload) + ) + return bool(result.rowcount) + + if not update_existing(): + row = NotificationEvent( + id=f'notif-{uuid4()}', + rule_id=rule.id, + source=str(event.get('source') or rule.source), + event_type=str(event.get('event_type') or rule.event_type), + dedupe_key=dedupe_key, + status='suppressed', + reason=reason, + created_at=now, + payload_json={ + **payload_patch, + 'suppressionCount': 1, + 'firstSuppressedAt': now.isoformat(), + 'lastSuppressedAt': now.isoformat(), + }, + **values, + ) + session.add(row) + try: + session.commit() + except IntegrityError: + session.rollback() + if not update_existing(): + raise + session.commit() + row = session.exec( + select(NotificationEvent) + .where(NotificationEvent.rule_id == rule.id) + .where(NotificationEvent.dedupe_key == dedupe_key) + .where(NotificationEvent.status == 'suppressed') + .where(NotificationEvent.reason == reason) + ).one() + return row + + +def _event_payload(event: NotificationEvent) -> dict[str, Any]: + return { + 'id': event.id, + 'ruleId': event.rule_id, + 'source': event.source, + 'event_type': event.event_type, + 'severity': event.severity, + 'title': event.title, + 'message': event.message, + 'category': event.category, + 'target': event.target, + 'dedupe_key': event.dedupe_key, + } + + +def _deliver_event(session: Session, row: NotificationEvent) -> NotificationEvent: + results: dict[str, dict[str, Any]] = {} + for channel_id in row.channels_json or ['dashboard']: + if channel_id == 'dashboard': + results[channel_id] = {'status': 'sent'} + continue + channel = session.get(NotificationChannel, channel_id) + if not channel: + results[channel_id] = {'status': 'failed', 'reason': 'channel_not_found'} + continue + if channel.type == 'home_assistant': + result = home_assistant.send_notification(session, channel, _event_payload(row)) + results[channel_id] = _redact(result) + if result.get('status') == 'sent': + channel.config_json = {**(channel.config_json or {}), 'lastError': None, 'lastSentAt': _now().isoformat()} + elif result.get('status') == 'failed': + channel.config_json = {**(channel.config_json or {}), 'lastError': result.get('reason') or 'error'} + channel.updated_at = _now() + session.add(channel) + continue + if channel.type in external.SECRET_REFS: + result = external.send_notification(session, channel, _event_payload(row)) + results[channel_id] = _redact(result) + channel.config_json = { + **(channel.config_json or {}), + 'lastError': result.get('reason') if not result.get('success') else None, + 'lastSentAt': _now().isoformat() if result.get('success') else channel.config_json.get('lastSentAt'), + } + channel.updated_at = _now() + session.add(channel) + continue + results[channel_id] = {'status': 'suppressed', 'reason': 'unsupported_channel'} + previous_retry_count = int((row.payload_json or {}).get('deliveryRetryCount') or 0) + retry_count = previous_retry_count + 1 + failed_channels = [channel_id for channel_id, result in results.items() if result.get('status') in {'failed', 'missing_config', 'auth_failed', 'unreachable'}] + delivered_channels = [channel_id for channel_id, result in results.items() if result.get('status') == 'sent'] + dead_letter = bool(failed_channels and retry_count >= 3) + row.status = 'sent' if delivered_channels else ('failed' if failed_channels else 'suppressed') + row.read = row.read or row.status == 'suppressed' + row.reason = 'delivery_dead_letter' if dead_letter else 'partial_delivery_failed' if failed_channels and delivered_channels else 'delivery_failed' if failed_channels else None + row.sent_at = _now() if delivered_channels else None + row.payload_json = _redact({ + **(row.payload_json or {}), + 'channelResults': results, + 'deliveryRetryCount': retry_count, + 'deliveryPending': bool(failed_channels and not dead_letter), + 'deadLetter': dead_letter, + }) + session.add(row) + session.commit() + session.refresh(row) + return row + + +def retry_failed_deliveries(session: Session, limit: int = 20) -> dict[str, Any]: + bounded_limit = max(1, min(limit, 100)) + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.status != 'dismissed') + .where(NotificationEvent.payload_json['deliveryPending'].as_boolean() == True) # noqa: E712 + .order_by(NotificationEvent.created_at.asc()) + .limit(bounded_limit) + ).all() + retried = 0 + recovered = 0 + dead_lettered = 0 + for row in rows: + payload = row.payload_json or {} + if bool(payload.get('deadLetter')) or int(payload.get('deliveryRetryCount') or 0) >= 3: + dead_lettered += 1 + continue + retried += 1 + _deliver_event(session, row) + if not bool((row.payload_json or {}).get('deliveryPending')) and not bool((row.payload_json or {}).get('deadLetter')): + recovered += 1 + elif bool((row.payload_json or {}).get('deadLetter')): + dead_lettered += 1 + return {'retried': retried, 'recovered': recovered, 'deadLettered': dead_lettered} + + +def evaluate_rules(session: Session, events: list[dict[str, Any]]) -> dict[str, Any]: + rules = session.exec(select(NotificationRule).where(NotificationRule.enabled == True)).all() # noqa: E712 + created: list[dict[str, Any]] = [] + suppressed: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + for event in events: + if event.get('event_type') == 'safety.score': + continue + matching_rules = [rule for rule in rules if _condition_matches(rule, event)] + if not matching_rules: + continue + highest_specificity = max(_rule_specificity(rule) for rule in matching_rules) + for rule in matching_rules: + if _rule_specificity(rule) != highest_specificity: + continue + dedupe = _dedupe_key(rule, event) + previous = _cooldown_event(session, rule, dedupe) + if previous: + until = previous.created_at + timedelta(seconds=rule.cooldown_seconds) + row = _record_suppression(session, rule, event, 'cooldown', until) + suppressed.append(_event_to_dict(row)) + continue + quiet_hours_until = _quiet_hours_until(rule, event) + if quiet_hours_until: + row = _record_suppression(session, rule, event, 'quiet_hours', quiet_hours_until) + suppressed.append(_event_to_dict(row)) + continue + try: + row = _persist_event(session, rule, event, 'sent') + row = _deliver_event(session, row) + created.append(_event_to_dict(row)) + except Exception as exc: + failed.append({'ruleId': rule.id, 'reason': _redact(str(exc))}) + return {'created': len(created), 'suppressed': len(suppressed), 'failed': len(failed), 'events': created, 'suppressedEvents': suppressed, 'failedEvents': failed} + + +def _auto_resolve_stale_connector_events(session: Session, current_events: list[dict[str, Any]]) -> int: + current_targets = { + str(event.get('target')) + for event in current_events + if event.get('source') == 'connector' and event.get('event_type') in {'connector.missing_config', 'connector.failed'} + } + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.source == 'connector') + .where(NotificationEvent.event_type.in_(['connector.missing_config', 'connector.failed'])) + .where(NotificationEvent.status != 'dismissed') + ).all() + resolved = 0 + for row in rows: + if str(row.target) in current_targets: + continue + row.status = 'dismissed' + row.reason = 'auto_resolved' + row.read = True + session.add(row) + resolved += 1 + if resolved: + session.commit() + return resolved + + +def _auto_resolve_stale_safety_events(session: Session, current_events: list[dict[str, Any]]) -> int: + def event_identity(event: dict[str, Any]) -> str: + payload = event.get('payload') if isinstance(event.get('payload'), dict) else {} + return str(payload.get('findingId') or event.get('dedupe_key') or '') + + current_ids = { + event_identity(event) + for event in current_events + if event.get('source') == 'safety' and event.get('event_type') == 'safety.finding' + } + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.source == 'safety') + .where(NotificationEvent.event_type == 'safety.finding') + .where(NotificationEvent.status != 'dismissed') + ).all() + resolved = 0 + for row in rows: + payload = row.payload_json if isinstance(row.payload_json, dict) else {} + row_identity = str(payload.get('findingId') or row.dedupe_key) + if row_identity in current_ids: + continue + row.status = 'dismissed' + row.reason = 'auto_resolved' + row.read = True + session.add(row) + resolved += 1 + if resolved: + session.commit() + return resolved + + +def _auto_resolve_stale_media_events(session: Session, current_events: list[dict[str, Any]]) -> int: + current_keys = { + str(event.get('dedupe_key') or '') + for event in current_events + if event.get('source') == 'media_pipeline' and event.get('event_type') == 'media.issue' + } + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.source == 'media_pipeline') + .where(NotificationEvent.event_type == 'media.issue') + .where(NotificationEvent.status != 'dismissed') + ).all() + resolved = 0 + for row in rows: + if str(row.dedupe_key or '') in current_keys: + continue + row.status = 'dismissed' + row.reason = 'auto_resolved' + row.read = True + session.add(row) + resolved += 1 + if resolved: + session.commit() + return resolved + + +def _reconcile_current_state(session: Session) -> int: + current_events = [*_connector_events(session), *_safety_events(session), *_media_events(session)] + return ( + _auto_resolve_stale_connector_events(session, current_events) + + _auto_resolve_stale_safety_events(session, current_events) + + _auto_resolve_stale_media_events(session, current_events) + ) + + +def _safety_events(session: Session) -> list[dict[str, Any]]: + dashboard = safety_center_service.get_dashboard(session) + events: list[dict[str, Any]] = [] + for finding in dashboard.get('findings', []): + if finding.get('status') != 'open': + continue + events.append({ + 'source': 'safety', + 'event_type': 'safety.finding', + 'severity': finding.get('severity', 'medium'), + 'title': finding.get('title', 'Safety finding'), + 'message': finding.get('description') or finding.get('title') or 'Safety finding', + 'category': finding.get('category') or 'safety', + 'target': ','.join(finding.get('affectedResources') or [])[:160] or 'safety-center', + 'dedupe_key': f"safety.finding:{finding.get('id')}", + 'payload': {'findingId': finding.get('id'), 'source': finding.get('source'), 'category': finding.get('category')}, + }) + return events + + +def _connector_events(session: Session) -> list[dict[str, Any]]: + result = [] + for connector in session.exec(select(Connector)).all(): + status = str(connector.status or 'unknown') + if status in {'missing_config', 'disconnected', 'error'}: + event_type = 'connector.failed' if status == 'error' else 'connector.missing_config' + result.append({ + 'source': 'connector', + 'event_type': event_type, + 'severity': 'medium' if event_type == 'connector.missing_config' else 'high', + 'title': f'{connector.name} connector vraagt aandacht', + 'message': f'{connector.name} connector status is {status}.', + 'category': 'connector_config', + 'target': connector.id, + 'dedupe_key': f'{event_type}:{connector.id}', + 'payload': {'connectorId': connector.id, 'status': status, 'secretRef': connector.secret_ref}, + }) + return result + + +def _media_events(session: Session) -> list[dict[str, Any]]: + summary = media_pipeline_service.summary(session) + result: list[dict[str, Any]] = [] + counts = summary.get('counts', {}) + for reason in summary.get('risk', {}).get('reasons', []): + normalized_reason = str(reason).lower() + if counts.get('stalledDownloads', 0) > 0 and 'deluge' in normalized_reason and 'stalled' in normalized_reason: + continue + if counts.get('failedWidefrogJobs', 0) > 0 and 'widefrog' in normalized_reason and 'fail' in normalized_reason: + continue + result.append({ + 'source': 'media_pipeline', + 'event_type': 'media.issue', + 'severity': 'medium', + 'title': 'Media Pipeline issue', + 'message': str(reason), + 'category': 'media', + 'target': 'media-pipeline', + 'dedupe_key': f"media.issue:{str(reason).lower()}", + 'payload': {'reason': reason}, + }) + if counts.get('stalledDownloads', 0) > 0: + result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Deluge stalled downloads', 'message': f"{counts['stalledDownloads']} stalled download(s).", 'category': 'downloads', 'target': 'deluge', 'dedupe_key': 'media.issue:deluge:stalled', 'payload': {'count': counts['stalledDownloads']}}) + if counts.get('failedWidefrogJobs', 0) > 0: + result.append({'source': 'media_pipeline', 'event_type': 'media.issue', 'severity': 'medium', 'title': 'Widefrog failed jobs', 'message': f"{counts['failedWidefrogJobs']} failed Widefrog job(s).", 'category': 'widefrog', 'target': 'widefrog', 'dedupe_key': 'media.issue:widefrog:failed', 'payload': {'count': counts['failedWidefrogJobs']}}) + return result + + +def _audit_events(session: Session) -> list[dict[str, Any]]: + rows = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all() + result = [] + for row in rows[:50]: + if row.policy_decision == 'blocked' or row.result == 'blocked': + event_type = 'audit.blocked_action' if row.risk in {'high', 'critical'} else ('audit.voice_denied' if row.source == 'voice' else 'audit.blocked_action') + result.append({ + 'source': 'audit', + 'event_type': event_type, + 'severity': 'high' if row.risk in {'high', 'critical'} else 'medium', + 'title': 'Blocked dangerous action attempt' if event_type == 'audit.blocked_action' else 'Voice action denied', + 'message': f'{row.action_id} on {row.target} was blocked.', + 'category': 'policy', + 'target': row.target, + 'dedupe_key': f'{event_type}:{row.action_id}:{row.target}', + 'payload': {'auditId': row.id, 'action': row.action_id, 'risk': row.risk}, + }) + return result + + +def _inventory_events(session: Session) -> list[dict[str, Any]]: + needs_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712 + if not needs_review: + return [] + return [{ + 'source': 'inventory', + 'event_type': 'inventory.needs_review', + 'severity': 'medium', + 'title': 'Inventory review nodig', + 'message': f'{len(needs_review)} app(s) vragen review.', + 'category': 'inventory', + 'target': 'inventory', + 'dedupe_key': 'inventory.needs_review', + 'payload': {'count': len(needs_review)}, + }] + + +def _setup_events(session: Session) -> list[dict[str, Any]]: + latest = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first() + if latest: + return [] + return [{ + 'source': 'setup', + 'event_type': 'setup.incomplete', + 'severity': 'medium', + 'title': 'Setup incomplete', + 'message': 'Discovery is nog niet uitgevoerd.', + 'category': 'setup', + 'target': 'setup', + 'dedupe_key': 'setup.incomplete', + 'payload': {'missing': 'discovery'}, + }] + + +def evaluate_source(session: Session, source: str = 'all') -> dict[str, Any]: + with _EVALUATION_LOCK: + return _evaluate_source_locked(session, source) + + +def _evaluate_source_locked(session: Session, source: str = 'all') -> dict[str, Any]: + source_map = { + 'safety': _safety_events, + 'connectors': _connector_events, + 'connector': _connector_events, + 'media': _media_events, + 'media_pipeline': _media_events, + 'audit': _audit_events, + 'inventory': _inventory_events, + 'setup': _setup_events, + } + if source == 'all': + events: list[dict[str, Any]] = [] + for key in ['safety', 'connectors', 'media', 'audit', 'inventory', 'setup']: + events.extend(source_map[key](session)) + else: + events = source_map.get(source, lambda _: [])(session) + resolved = 0 + if source in {'all', 'connectors', 'connector'}: + resolved += _auto_resolve_stale_connector_events(session, events) + if source in {'all', 'safety'}: + resolved += _auto_resolve_stale_safety_events(session, events) + if source in {'all', 'media', 'media_pipeline'}: + resolved += _auto_resolve_stale_media_events(session, events) + result = evaluate_rules(session, events) + result['resolved'] = resolved + result['evaluated'] = len(events) + result['source'] = source + return result + + +def summary(session: Session) -> dict[str, Any]: + events = session.exec(select(NotificationEvent)).all() + rules = session.exec(select(NotificationRule)).all() + channels = session.exec(select(NotificationChannel)).all() + unread = [event for event in events if not event.read and event.status not in {'dismissed', 'suppressed'}] + suppressed = [event for event in events if event.status == 'suppressed'] + failed = [event for event in events if event.status == 'failed'] + delivered = [event for event in events if event.status not in {'dismissed', 'suppressed'}] + last = max(delivered, key=lambda event: event.created_at) if delivered else None + return { + 'unreadCount': len(unread), + 'activeRulesCount': len([rule for rule in rules if rule.enabled]), + 'suppressedCount': len(suppressed), + 'failedCount': len(failed), + 'lastNotification': _event_to_dict(last) if last else None, + 'channels': [_channel_to_dict(channel, session) for channel in channels], + } diff --git a/backend/app/services/policy_engine.py b/backend/app/services/policy_engine.py new file mode 100644 index 0000000..4c7d50f --- /dev/null +++ b/backend/app/services/policy_engine.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from sqlmodel import Session, select + +from ..db.models import Policy +from .action_registry import ACTION_RISK, ACTIONS + +_DEFAULT_DECISIONS = { + 'privatemuse.comfyui.install_artifact': 'approval_required', + 'deployment.target.deploy_workmesh': 'approval_required', + 'deployment.target.promote_workmesh': 'approval_required', + 'container.clear_logs': 'blocked', + 'container.stop': 'blocked', + 'gateway.clear_cache': 'blocked', + 'jellyfin.scan_library': 'blocked', + 'volume.delete': 'blocked', + 'docker.prune': 'blocked', + 'secrets.show': 'blocked', + 'unraid.disk.format': 'blocked', + 'unraid.vm.stop': 'blocked', + 'unraid.vm.reboot': 'blocked', + 'unraid.vm.force_stop': 'blocked', + 'unraid.vm.reset': 'blocked', + 'sonarr.delete_series': 'blocked', + 'sonarr.mass_edit': 'blocked', + 'radarr.delete_movie': 'blocked', + 'radarr.delete_files': 'blocked', + 'radarr.mass_edit': 'blocked', + 'widefrog.delete_job': 'blocked', + 'widefrog.delete_media': 'blocked', + 'widefrog.clear_library': 'blocked', + 'widefrog.clear_queue': 'blocked', + 'widefrog.reset_database': 'blocked', + 'widefrog.delete_downloads': 'blocked', + 'widefrog.prune': 'blocked', + 'prowlarr.delete_indexer': 'blocked', + 'prowlarr.delete_application': 'blocked', + 'prowlarr.update_indexer': 'blocked', + 'prowlarr.update_application': 'blocked', + 'prowlarr.grab_release': 'blocked', + 'prowlarr.push_release': 'blocked', + 'prowlarr.mass_edit': 'blocked', + 'prowlarr.test_all_indexers': 'blocked', + 'deluge.remove_torrent': 'blocked', + 'deluge.remove_torrent_data': 'blocked', + 'deluge.delete_data': 'blocked', + 'deluge.force_recheck': 'blocked', + 'deluge.move_storage': 'blocked', + 'deluge.add_torrent': 'blocked', + 'deluge.add_magnet': 'blocked', + 'deluge.mass_edit': 'blocked', + 'deluge.set_options': 'blocked', + 'deluge.unknown_mutation': 'blocked', + 'plex.delete_media': 'blocked', + 'plex.delete_library': 'blocked', + 'plex.empty_trash': 'blocked', + 'plex.clean_bundles': 'blocked', + 'plex.optimize_database': 'blocked', + 'plex.kill_session': 'blocked', + 'plex.user_management': 'blocked', + 'plex.force_refresh_all_metadata': 'blocked', + 'plex.mass_edit': 'blocked', + 'plex.unknown_mutation': 'blocked', + 'tautulli.terminate_session': 'blocked', + 'tautulli.delete_newsletter': 'blocked', + 'tautulli.delete_notification_log': 'blocked', + 'tautulli.delete_notifier': 'blocked', + 'tautulli.delete_recently_added': 'blocked', + 'tautulli.download_config': 'blocked', + 'tautulli.download_database': 'blocked', + 'tautulli.download_export': 'blocked', + 'tautulli.download_log': 'blocked', + 'tautulli.download_plex_log': 'blocked', + 'tautulli.notify': 'blocked', + 'tautulli.user_management': 'blocked', + 'tautulli.mass_edit': 'blocked', + 'tautulli.unknown_mutation': 'blocked', + 'npm.create_proxy_host': 'blocked', + 'npm.update_proxy_host': 'blocked', + 'npm.delete_proxy_host': 'blocked', + 'npm.enable_proxy_host': 'blocked', + 'npm.disable_proxy_host': 'blocked', + 'npm.renew_certificate': 'blocked', + 'npm.delete_certificate': 'blocked', + 'npm.modify_access_list': 'blocked', + 'npm.create_user': 'blocked', + 'npm.update_user': 'blocked', + 'npm.delete_user': 'blocked', + 'npm.mass_edit': 'blocked', + 'npm.unknown_mutation': 'blocked', + 'authentik.create_application': 'blocked', + 'authentik.update_application': 'blocked', + 'authentik.delete_application': 'blocked', + 'authentik.create_provider': 'blocked', + 'authentik.update_provider': 'blocked', + 'authentik.delete_provider': 'blocked', + 'authentik.create_outpost': 'blocked', + 'authentik.update_outpost': 'blocked', + 'authentik.delete_outpost': 'blocked', + 'authentik.create_policy': 'blocked', + 'authentik.update_policy': 'blocked', + 'authentik.delete_policy': 'blocked', + 'authentik.create_user': 'blocked', + 'authentik.update_user': 'blocked', + 'authentik.delete_user': 'blocked', + 'authentik.create_group': 'blocked', + 'authentik.update_group': 'blocked', + 'authentik.delete_group': 'blocked', + 'authentik.rotate_token': 'blocked', + 'authentik.force_sync': 'blocked', + 'authentik.mass_edit': 'blocked', + 'authentik.unknown_mutation': 'blocked', +} + +_DEFAULT_REASONS = { + 'privatemuse.comfyui.install_artifact': 'Installing a model artifact mutates the production ComfyUI model store and requires an explicit approved operator flow with pinned SHA-256 provenance.', + 'deployment.target.deploy_workmesh': 'WorkMesh first deployment is a high-impact production mutation and requires an explicit approved operator flow outside typed MCP automation.', + 'deployment.target.promote_workmesh': 'WorkMesh product promotion requires exact-revision controller preflight and a human approval bound to its digest.', + 'container.clear_logs': 'Clearing container logs is outside AppOps Gateway V1; log viewing and local download remain read-only.', + 'container.stop': 'Container stop is outside AppOps Gateway V1; use Portainer or Unraid directly after manual review.', + 'gateway.clear_cache': 'Clearing gateway cache is outside AppOps Gateway V1; use explicit refresh or restart procedures after review.', + 'jellyfin.scan_library': 'No live Jellyfin connector or executor is registered. Use the Jellyfin UI until a typed, audited connector is implemented.', + 'unraid.vm.stop': 'Unraid stop may force-stop after its graceful timeout; AppOps blocks that destructive fallback.', + 'unraid.vm.reboot': 'VM reboot remains blocked until guest-shutdown and recovery behavior is safely verifiable.', + 'unraid.vm.force_stop': 'Force-stop is destructive and blocked by default.', + 'unraid.vm.reset': 'VM reset is destructive and blocked by default.', +} + + +def evaluate_policy( + session: Session, + action: str, + target: str, + source: str, +) -> dict: + if action not in ACTIONS: + return { + 'decision': 'blocked', + 'reason': 'Unknown action id.', + 'risk': ACTION_RISK.get(action, 'critical'), + 'policy_id': None, + 'scope_decision': None, + } + + if action in _DEFAULT_DECISIONS: + reason = _DEFAULT_REASONS.get(action, 'Action uses default governance policy.') + return { + 'decision': _DEFAULT_DECISIONS[action], + 'reason': reason, + 'risk': ACTION_RISK.get(action, 'critical'), + 'policy_id': None, + 'scope_decision': None, + } + + if action == 'container.restart' and source == 'voice' and target == 'widefrog': + return { + 'decision': 'allowed', + 'reason': 'Widefrog restart is explicitly allow-listed for voice actions.', + 'risk': ACTION_RISK.get(action, 'medium'), + 'policy_id': None, + 'scope_decision': None, + } + + records = session.exec( + select(Policy).where( + (Policy.action == action) & ( + (Policy.target == target) | (Policy.target == '*') + ) + ) + ).all() + + # Prefer exact target over wildcard. + exact_match = next((r for r in records if r.target == target), None) + wildcard_match = next((r for r in records if r.target == '*'), None) + policy = exact_match or wildcard_match + + if not policy: + # Safety-first defaults by scope. + if action in {'deployment.apply', 'deployment.rollback'}: + return { + 'decision': 'blocked', + 'reason': 'Production deployment requires an explicit exact-target deployment policy.', + 'risk': ACTION_RISK.get(action, 'high'), + 'policy_id': None, + 'scope_decision': None, + } + + if source == 'voice' and action.startswith('container.') and target in {'authentik', 'portainer'}: + return { + 'decision': 'approval_required', + 'reason': 'High-risk service action via voice requires explicit approval by policy.', + 'risk': ACTION_RISK.get(action, 'high'), + 'policy_id': None, + 'scope_decision': None, + } + + if source == 'voice' and action.startswith('container.') and target in {'postgres', 'redis', 'database'}: + return { + 'decision': 'blocked', + 'reason': 'Datastore/container restarts via voice are blocked by default.', + 'risk': ACTION_RISK.get(action, 'high'), + 'policy_id': None, + 'scope_decision': None, + } + + if source == 'voice' and action in {'unraid.mover.start'}: + return { + 'decision': 'blocked', + 'reason': 'High-impact maintenance action is blocked for voice unless explicitly approved.', + 'risk': ACTION_RISK.get(action, 'high'), + 'policy_id': None, + 'scope_decision': None, + } + + if action == 'stack.redeploy': + return { + 'decision': 'approval_required', + 'reason': 'Default safe policy for stack redeploy requires approval.', + 'risk': ACTION_RISK.get(action, 'high'), + 'policy_id': None, + 'scope_decision': None, + } + + return { + 'decision': 'approval_required' if source == 'voice' else 'allowed', + 'reason': 'No explicit policy found; using default safe policy.', + 'risk': ACTION_RISK.get(action, 'medium'), + 'policy_id': None, + 'scope_decision': None, + } + + decision = policy.voice if source == 'voice' else policy.manual + return { + 'decision': decision, + 'reason': policy.reason or 'Policy match from database.', + 'risk': policy.risk, + 'policy_id': policy.id, + 'scope_decision': decision, + } diff --git a/backend/app/services/portfolio_deployment_enrollment.py b/backend/app/services/portfolio_deployment_enrollment.py new file mode 100644 index 0000000..be69085 --- /dev/null +++ b/backend/app/services/portfolio_deployment_enrollment.py @@ -0,0 +1,815 @@ +from __future__ import annotations + +import json +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/portfolio" +_LEGACY_REVISION = "e59bfb3484531883e5486ee9e10b663ed0f6764f" +_RECOVERY_LIVE_REVISION = "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e" +_RECOVERY_FAILED_REVISION = "b7955d90b63860ffff8e0bd84be123aaba632043" +_RECOVERY_FAILURE_MESSAGE = "Existing production containers require a valid healthy live deployment state" + +_PROJECT: dict[str, object] = { + "branch": "main", + "slug": "portfolio", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio", + "exact_source": True, + "env_source": "/srv/example/appdata/Portfolio/.env", + "compose_files": ["docker-compose.yml"], + "compose_project": "portfolio", + "containers": ["Portfolio", "Portfolio-api"], + "verify_oci_revision": False, + "health_timeout": 180, + "environment": { + "GIT_COMMIT": "{revision}", + "BUILD_ID": "build-{short_revision}", + }, +} + +_LEGACY_SINGLE_PROJECT: dict[str, object] = { + "branch": "main", + "slug": "portfolio", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio", + "exact_source": True, + "compose_files": ["docker-compose.yml"], + "compose_project": "portfolio", + "containers": ["portfolio"], + "verify_oci_revision": False, + "health_timeout": 180, +} + +_INSPECT_SCRIPT = r''' +import json +from pathlib import Path +import sys + +repository = sys.argv[1] +path = Path("/opt/gitea-deploy/projects.json") +try: + projects = json.loads(path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(20) +current = projects.get(repository) +if current is None: + print(json.dumps({"present": False}, sort_keys=True, separators=(",", ":"))) + raise SystemExit(0) +if not isinstance(current, dict): + print(json.dumps({"present": True, "type": type(current).__name__}, sort_keys=True, separators=(",", ":"))) + raise SystemExit(0) + +safe = {"present": True} +for key in ( + "branch", + "slug", + "source_dir", + "exact_source", + "env_source", + "compose_files", + "compose_project", + "containers", + "verify_oci_revision", + "health_timeout", + "deploy_script", + "archive_deploy_script", + "manual", +): + if key in current: + safe[key] = current[key] +environment = current.get("environment") +if isinstance(environment, dict): + safe["environment_keys"] = sorted(str(key) for key in environment.keys()) +live_probe = current.get("live_probe") +if isinstance(live_probe, dict): + safe["live_probe"] = { + key: live_probe[key] + for key in ("type", "container", "port", "path") + if key in live_probe + } + +state_path = Path("/mnt/cache/appdata/gitea-deploy-runners/state/portfolio.json") +if state_path.is_file() and not state_path.is_symlink(): + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + state = None + if isinstance(state, dict): + safe_state = { + key: state[key] + for key in ( + "schema_version", + "repository", + "revision", + "live_revision", + "live_status", + "live_legacy_provenance", + "status", + "updated_at_epoch", + ) + if key in state + } + last_attempt = state.get("last_attempt") + if isinstance(last_attempt, dict): + safe_attempt = { + key: last_attempt[key] + for key in ("revision", "status", "updated_at_epoch") + if key in last_attempt + } + message = last_attempt.get("message") + if isinstance(message, str): + safe_attempt["message"] = message.replace("\r", " ").replace("\n", " ")[:768] + safe_state["last_attempt"] = safe_attempt + live_artifacts = state.get("live_artifacts") + if isinstance(live_artifacts, dict): + containers = live_artifacts.get("containers") + if isinstance(containers, dict): + safe_state["live_artifact_containers"] = sorted(str(key) for key in containers.keys()) + safe["state"] = safe_state +print(json.dumps(safe, sort_keys=True, separators=(",", ":"))) +''' + +_ENROLL_SCRIPT = r''' +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import time + +registry_path = Path("/opt/gitea-deploy/projects.json") +host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") +state_path = state_root / "portfolio.json" +project_lock_path = state_root / "locks" / "portfolio.lock" +lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") +( + repository, + legacy_revision, + recovery_live_revision, + recovery_failed_revision, + recovery_failure_message, + raw_project, + raw_legacy_project, +) = sys.argv[1:8] +project = json.loads(raw_project) +legacy_project = json.loads(raw_legacy_project) + +expected_project = { + "branch": "main", + "slug": "portfolio", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio", + "exact_source": True, + "env_source": "/srv/example/appdata/Portfolio/.env", + "compose_files": ["docker-compose.yml"], + "compose_project": "portfolio", + "containers": ["Portfolio", "Portfolio-api"], + "verify_oci_revision": False, + "health_timeout": 180, + "environment": { + "GIT_COMMIT": "{revision}", + "BUILD_ID": "build-{short_revision}", + }, +} +expected_legacy_project = { + "branch": "main", + "slug": "portfolio", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/portfolio", + "exact_source": True, + "compose_files": ["docker-compose.yml"], + "compose_project": "portfolio", + "containers": ["portfolio"], + "verify_oci_revision": False, + "health_timeout": 180, +} +expected_legacy_revision = "e59bfb3484531883e5486ee9e10b663ed0f6764f" +expected_recovery_live_revision = "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e" +expected_recovery_failed_revision = "b7955d90b63860ffff8e0bd84be123aaba632043" +expected_recovery_failure_message = "Existing production containers require a valid healthy live deployment state" + +if ( + repository != "jens/portfolio" + or legacy_revision != expected_legacy_revision + or recovery_live_revision != expected_recovery_live_revision + or recovery_failed_revision != expected_recovery_failed_revision + or recovery_failure_message != expected_recovery_failure_message + or project != expected_project + or legacy_project != expected_legacy_project +): + raise SystemExit(40) +if registry_path.is_symlink() or not registry_path.is_file(): + raise SystemExit(20) +registry_stat = registry_path.stat() +if not stat.S_ISREG(registry_stat.st_mode): + raise SystemExit(21) +try: + if ( + host_registry_path.is_symlink() + or not host_registry_path.is_file() + or not os.path.samefile(registry_path, host_registry_path) + ): + raise SystemExit(36) +except OSError: + raise SystemExit(36) + +env_source = Path(str(project["env_source"])) +if env_source.is_symlink() or not env_source.is_file(): + raise SystemExit(41) + +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +if not bare_repo.is_dir(): + raise SystemExit(24) + +def git_run(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + +for revision in (legacy_revision, recovery_live_revision, recovery_failed_revision): + resolved = git_run("rev-parse", f"{revision}^{{commit}}") + if resolved.returncode != 0 or resolved.stdout.strip() != revision: + raise SystemExit(25) +main = git_run("rev-parse", "refs/heads/main") +main_revision = main.stdout.strip() +if main.returncode != 0 or len(main_revision) != 40: + raise SystemExit(26) +if git_run("merge-base", "--is-ancestor", legacy_revision, main_revision).returncode != 0: + raise SystemExit(27) +if git_run("merge-base", "--is-ancestor", recovery_live_revision, main_revision).returncode != 0: + raise SystemExit(27) +if main_revision != recovery_failed_revision: + raise SystemExit(58) +for compose_file in project["compose_files"]: + if git_run("cat-file", "-e", f"{main_revision}:{compose_file}").returncode != 0: + raise SystemExit(28) + +try: + if state_root.is_symlink() or not state_root.is_dir(): + raise SystemExit(50) + project_lock_path.parent.mkdir(parents=True, exist_ok=True) + if project_lock_path.parent.is_symlink() or project_lock_path.is_symlink(): + raise SystemExit(29) + project_lock_handle = project_lock_path.open("a+", encoding="utf-8") + lock_handle = lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(29) + +with project_lock_handle as project_lock, lock_handle as lock: + try: + fcntl.flock(project_lock, fcntl.LOCK_EX) + fcntl.flock(lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + + try: + projects = json.loads(host_registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(31) + if not isinstance(projects, dict): + raise SystemExit(22) + + current = projects.get(repository) + + def legacy_script_target_matches(value): + if not isinstance(value, dict): + return False + expected_keys = { + "branch", + "slug", + "source_dir", + "env_source", + "compose_files", + "compose_project", + "containers", + "deploy_script", + "environment", + } + if set(value) != expected_keys: + return False + environment = value.get("environment") + return ( + value.get("branch") == "main" + and value.get("slug") == "portfolio" + and value.get("source_dir") == "/srv/example/appdata/Portfolio/source" + and value.get("env_source") == "/srv/example/appdata/Portfolio/.env" + and value.get("compose_files") == ["docker-compose.yml"] + and value.get("compose_project") == "portfolio" + and value.get("containers") == ["Portfolio", "Portfolio-api"] + and value.get("deploy_script") == "scripts/deploy-unraid.sh" + and isinstance(environment, dict) + and set(environment) == {"PORTFOLIO_APP_DIR"} + and environment.get("PORTFOLIO_APP_DIR") + in {"/srv/example/appdata/Portfolio", "/srv/example/appdata/portfolio"} + ) + + if current not in (None, project, legacy_project) and not legacy_script_target_matches(current): + raise SystemExit(23) + + state_requires_reconcile = False + runtime_state_requires_reconcile = False + existing_state = None + if state_path.exists(): + if state_path.is_symlink() or not state_path.is_file(): + raise SystemExit(42) + try: + existing_state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(42) + if not isinstance(existing_state, dict): + raise SystemExit(42) + if existing_state.get("schema_version") == 3: + if existing_state.get("repository") != repository: + raise SystemExit(43) + if ( + current == project + and existing_state.get("live_status") == "healthy" + and isinstance(existing_state.get("live_revision"), str) + and len(existing_state.get("live_revision")) == 40 + and ( + existing_state.get("live_legacy_provenance") is True + or isinstance(existing_state.get("live_receipt"), dict) + ) + ): + print("already-enrolled", flush=True) + raise SystemExit(0) + + last_attempt = existing_state.get("last_attempt") + live_artifacts = existing_state.get("live_artifacts") + live_receipt = existing_state.get("live_receipt") + artifact_containers = ( + live_artifacts.get("containers") + if isinstance(live_artifacts, dict) + else None + ) + if ( + current == project + and existing_state.get("revision") == recovery_live_revision + and existing_state.get("live_revision") == recovery_live_revision + and existing_state.get("live_status") == "unknown" + and existing_state.get("live_legacy_provenance") is False + and existing_state.get("status") == "failed" + and isinstance(last_attempt, dict) + and last_attempt.get("revision") == recovery_failed_revision + and last_attempt.get("status") == "failed" + and last_attempt.get("message") == recovery_failure_message + and isinstance(live_receipt, dict) + and bool(live_receipt) + and isinstance(artifact_containers, dict) + and set(artifact_containers) == set(project["containers"]) + ): + runtime_state_requires_reconcile = True + elif legacy_script_target_matches(current): + state_requires_reconcile = True + else: + raise SystemExit(43) + elif not ( + existing_state.get("repository") == repository + and existing_state.get("status") == "success" + and existing_state.get("revision") == legacy_revision + and "schema_version" not in existing_state + and "live_receipt" not in existing_state + and "live_legacy_provenance" not in existing_state + ): + if legacy_script_target_matches(current): + state_requires_reconcile = True + else: + raise SystemExit(43) + + expected_names = set(project["containers"]) + observed_result = subprocess.run( + [ + "docker", "ps", "--all", "--filter", + f"label=com.docker.compose.project={project['compose_project']}", + "--format", "{{.Names}}", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + if observed_result.returncode != 0: + raise SystemExit(44) + observed_names = {line.strip() for line in observed_result.stdout.splitlines() if line.strip()} + if observed_names != expected_names: + raise SystemExit(45) + + for required in sorted(expected_names): + inspected = subprocess.run( + ["docker", "inspect", required], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if inspected.returncode != 0: + raise SystemExit(46) + try: + container = json.loads(inspected.stdout)[0] + except Exception: + raise SystemExit(46) + runtime = dict(container.get("State") or {}) + health = str(dict(runtime.get("Health") or {}).get("Status") or "none") + config = dict(container.get("Config") or {}) + labels = dict(config.get("Labels") or {}) + revision_label = str(labels.get("tech.itworx.commit") or "") + build_label = str(labels.get("tech.itworx.build-id") or "") + if ( + not runtime.get("Running") + or health in {"starting", "unhealthy"} + or labels.get("com.docker.compose.project") != "portfolio" + ): + raise SystemExit(46) + if legacy_script_target_matches(current) and revision_label != legacy_revision: + raise SystemExit(47) + if runtime_state_requires_reconcile: + if ( + revision_label != recovery_live_revision + or build_label != f"build-{recovery_live_revision[:12]}" + ): + raise SystemExit(55) + artifacts = dict(dict(existing_state.get("live_artifacts") or {}).get("containers") or {}) + artifact = artifacts.get(required) + if not isinstance(artifact, dict): + raise SystemExit(55) + if ( + str(artifact.get("image_id") or "") != str(container.get("Image") or "") + or str(artifact.get("container_id") or "") != str(container.get("Id") or "") + ): + raise SystemExit(55) + + if runtime_state_requires_reconcile: + if state_root.is_symlink() or not state_root.is_dir(): + raise SystemExit(50) + now = int(time.time()) + state_backup = state_path.with_name( + f"portfolio.json.runtime-reconcile-backup-{now}-{os.getpid()}" + ) + temporary_state = state_path.with_name( + f".{state_path.name}.{os.getpid()}.runtime-reconcile.tmp" + ) + reconciled_state = dict(existing_state) + reconciled_state["live_status"] = "healthy" + reconciled_state["status"] = "success" + reconciled_state["message"] = ( + "Verified Portfolio runtime matches recorded live artifacts; controller state reconciled" + ) + reconciled_state["updated_at_epoch"] = now + reconciled_state["last_attempt"] = { + "revision": recovery_live_revision, + "status": "success", + "message": "Verified live Portfolio runtime state reconciliation", + "updated_at_epoch": now, + "metadata": {"reconciled_failed_revision": recovery_failed_revision}, + } + try: + mode = stat.S_IMODE(state_path.stat().st_mode) + shutil.copy2(state_path, state_backup) + with temporary_state.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(reconciled_state, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary_state, mode) + os.replace(temporary_state, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary_state.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(56) + try: + verify = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(57) + if not ( + isinstance(verify, dict) + and verify.get("live_revision") == recovery_live_revision + and verify.get("live_status") == "healthy" + and verify.get("live_legacy_provenance") is False + and isinstance(verify.get("live_receipt"), dict) + and bool(verify.get("live_receipt")) + and verify.get("status") == "success" + ): + raise SystemExit(57) + print("runtime-state-reconciled", flush=True) + raise SystemExit(0) + + if state_requires_reconcile: + if state_root.is_symlink() or not state_root.is_dir(): + raise SystemExit(50) + state_backup = state_path.with_name( + f"portfolio.json.verified-live-backup-{int(time.time())}-{os.getpid()}" + ) + temporary_state = state_path.with_name(f".{state_path.name}.{os.getpid()}.reconcile.tmp") + try: + shutil.copy2(state_path, state_backup) + reconciled_state = { + "repository": repository, + "revision": legacy_revision, + "status": "success", + } + with temporary_state.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(reconciled_state, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary_state, 0o600) + os.replace(temporary_state, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + existing_state = reconciled_state + except Exception: + try: + temporary_state.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(54) + + if current != project: + backup = host_registry_path.with_name( + f"projects.json.portfolio-backup-{int(time.time())}-{os.getpid()}" + ) + try: + shutil.copy2(host_registry_path, backup) + except Exception: + raise SystemExit(32) + updated = dict(projects) + updated[repository] = project + temporary = host_registry_path.with_name(f".{host_registry_path.name}.{os.getpid()}.tmp") + payload = json.dumps(updated, indent=2, sort_keys=True) + "\n" + try: + with temporary.open("w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, stat.S_IMODE(registry_stat.st_mode)) + os.replace(temporary, host_registry_path) + directory_fd = os.open(str(host_registry_path.parent), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(34) + + if existing_state is None: + state_root.mkdir(parents=True, exist_ok=True) + if state_root.is_symlink() or not state_root.is_dir(): + raise SystemExit(50) + temporary_state = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") + payload = {"repository": repository, "revision": legacy_revision, "status": "success"} + try: + with temporary_state.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary_state, 0o600) + os.replace(temporary_state, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary_state.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(51) + + try: + controller_path = Path("/opt/gitea-deploy/deploy.py") + if controller_path.is_symlink() or not controller_path.is_file(): + raise RuntimeError("controller module is unavailable") + controller_spec = importlib.util.spec_from_file_location( + "appops_locked_portfolio_bootstrap", controller_path + ) + if controller_spec is None or controller_spec.loader is None: + raise RuntimeError("controller module cannot be loaded") + controller = importlib.util.module_from_spec(controller_spec) + controller_spec.loader.exec_module(controller) + controller.bootstrap_legacy_state(repository, project, legacy_revision) + except Exception: + raise SystemExit(52) + + try: + final_state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(53) + if not ( + isinstance(final_state, dict) + and final_state.get("schema_version") == 3 + and final_state.get("repository") == repository + and final_state.get("live_revision") == legacy_revision + and final_state.get("live_status") == "healthy" + and final_state.get("live_legacy_provenance") is True + ): + raise SystemExit(53) + +print("enrolled", flush=True) +''' + + +class PortfolioDeploymentEnrollment: + """Enroll or reconcile the fixed paired Portfolio exact-source controller target.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def _inspect_current_target(self, *, endpoint: str, container_id: str) -> dict[str, Any] | None: + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": True, + "AttachStderr": False, + "Tty": True, + "Cmd": ["python3", "-c", _INSPECT_SCRIPT, _REPOSITORY], + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return None + safe_exec = quote(exec_id, safe="") + response = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": False, "Tty": True}, + ) + raw = (response.text or "").strip() + if not raw or len(raw) > 4096: + return None + decoded = json.loads(raw) + return decoded if isinstance(decoded, dict) else None + except (RuntimeError, ValueError, TypeError, json.JSONDecodeError): + return None + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + return { + "status": "controller_unavailable", + "reason": redact_message( + str(status.get("reason") or status.get("status") or "unavailable") + ), + } + controller = dict(status.get("container") or {}) + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return { + "status": "controller_unavailable", + "reason": "deployment controller is not running", + } + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if not config or not container_id: + return { + "status": "connector_unavailable", + "reason": "Portainer deployment control is unavailable", + } + + endpoint = quote(str(config.endpoint_id), safe="") + current_target = self._inspect_current_target(endpoint=endpoint, container_id=container_id) + if current_target is not None: + container_revisions: dict[str, str | None] = {} + for required in ("Portfolio", "Portfolio-api"): + try: + inspected_container = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/containers/{quote(required, safe='')}/json", + ).json() + config_payload = inspected_container.get("Config") if isinstance(inspected_container, dict) else None + labels = config_payload.get("Labels") if isinstance(config_payload, dict) else None + revision = labels.get("tech.itworx.commit") if isinstance(labels, dict) else None + container_revisions[required] = str(revision) if revision else None + except (RuntimeError, ValueError, TypeError): + container_revisions[required] = None + current_target["container_revisions"] = container_revisions + command = [ + "python3", + "-c", + _ENROLL_SCRIPT, + _REPOSITORY, + _LEGACY_REVISION, + _RECOVERY_LIVE_REVISION, + _RECOVERY_FAILED_REVISION, + _RECOVERY_FAILURE_MESSAGE, + json.dumps(_PROJECT, separators=(",", ":"), sort_keys=True), + json.dumps(_LEGACY_SINGLE_PROJECT, separators=(",", ":"), sort_keys=True), + ] + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return {"status": "error", "reason": "Portfolio enrollment could not start"} + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + + deadline = time.monotonic() + 120 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return {"status": "error", "reason": "Portfolio enrollment timed out"} + reasons = { + 20: "deployment projects registry is unavailable or unsafe", + 21: "deployment projects registry is not a regular file", + 22: "deployment projects registry does not contain an object", + 23: "Portfolio already has an unexpected deployment target", + 24: "mounted Portfolio bare repository is unavailable", + 25: "known Portfolio deployment revisions cannot be resolved", + 26: "Portfolio main cannot be resolved", + 27: "known Portfolio live revision is not an ancestor of main", + 28: "required Portfolio docker-compose.yml is absent from main", + 29: "deployment registry lock file cannot be opened", + 30: "deployment registry lock cannot be acquired", + 31: "deployment projects registry cannot be read as JSON", + 32: "deployment projects registry backup cannot be created", + 34: "deployment projects registry replacement failed", + 36: "deployment projects registry host alias is unavailable or mismatched", + 40: "Portfolio fixed enrollment contract was altered", + 41: "Portfolio production environment file is unavailable or unsafe", + 42: "Portfolio deployment state file is unavailable or invalid", + 43: "Portfolio deployment state conflicts with the verified live baseline", + 44: "Portfolio Compose project inventory could not be read", + 45: "Portfolio Compose project does not contain exactly the expected web/API containers", + 46: "required Portfolio production container is missing or unhealthy", + 47: "live Portfolio container revision does not match the verified legacy baseline", + 50: "deployment state directory is unavailable or unsafe", + 51: "verified Portfolio legacy state could not be written durably", + 52: "deployment controller rejected Portfolio legacy-state bootstrap", + 53: "Portfolio legacy-state bootstrap did not produce the expected managed state", + 54: "verified stale Portfolio controller state could not be reconciled safely", + 55: "live Portfolio runtime no longer matches the controller-recorded modern artifacts", + 56: "verified Portfolio runtime state could not be reconciled durably", + 57: "reconciled Portfolio runtime state did not verify after write", + 58: "Portfolio main changed after the bounded recovery contract was recorded", + } + if exit_code != 0: + reason = reasons.get( + exit_code, + "Portfolio enrollment failed closed before any application deployment", + ) + if exit_code in {23, 43, 47, 55, 58} and current_target is not None: + reason = redact_message( + f"{reason}: {json.dumps(current_target, sort_keys=True, separators=(',', ':'))}" + ) + return {"status": "validation_failed", "reason": reason} + return { + "status": "enrolled", + "repository": _REPOSITORY, + "legacy_revision": _LEGACY_REVISION, + "compose_project": _PROJECT["compose_project"], + "containers": list(_PROJECT["containers"]), + } diff --git a/backend/app/services/portfolio_deployment_recovery.py b/backend/app/services/portfolio_deployment_recovery.py new file mode 100644 index 0000000..a893e76 --- /dev/null +++ b/backend/app/services/portfolio_deployment_recovery.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import json +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message +from .portfolio_deployment_enrollment import PortfolioDeploymentEnrollment + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/portfolio" +_LIVE_REVISION = "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e" +_FAILED_REVISION = "b7955d90b63860ffff8e0bd84be123aaba632043" +_FAILURE_MESSAGE = "Existing production containers require a valid healthy live deployment state" + +# One-time, fail-closed re-attestation for the exact observed Portfolio state. +# The recovery never builds, restarts, recreates or stops application containers. +# It uses the controller's own receipt/runtime verifier and accepts only a change +# in ephemeral container IDs while immutable image/provenance evidence remains +# exactly equal to the previously recorded verified receipt. +_REATTEST_SCRIPT = r''' +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import time + +repository, live_revision, failed_revision, failure_message = sys.argv[1:5] +expected_repository = "jens/portfolio" +expected_live_revision = "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e" +expected_failed_revision = "b7955d90b63860ffff8e0bd84be123aaba632043" +expected_failure_message = "Existing production containers require a valid healthy live deployment state" +if ( + repository != expected_repository + or live_revision != expected_live_revision + or failed_revision != expected_failed_revision + or failure_message != expected_failure_message +): + raise SystemExit(40) + +controller_path = Path("/opt/gitea-deploy/deploy.py") +registry_path = Path("/opt/gitea-deploy/projects.json") +state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") +state_path = state_root / "portfolio.json" +project_lock_path = state_root / "locks" / "portfolio.lock" +global_lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") + +if controller_path.is_symlink() or not controller_path.is_file(): + raise SystemExit(20) +if registry_path.is_symlink() or not registry_path.is_file(): + raise SystemExit(21) +try: + projects = json.loads(registry_path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(22) +project = projects.get(repository) if isinstance(projects, dict) else None +if not isinstance(project, dict): + raise SystemExit(23) +if ( + project.get("branch") != "main" + or project.get("slug") != "portfolio" + or project.get("compose_project") != "portfolio" + or project.get("containers") != ["Portfolio", "Portfolio-api"] + or project.get("compose_files") != ["docker-compose.yml"] + or project.get("exact_source") is not True + or project.get("verify_oci_revision") is not False +): + raise SystemExit(24) + +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +if not bare_repo.is_dir(): + raise SystemExit(25) + +def git_run(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + +for revision in (live_revision, failed_revision): + resolved = git_run("rev-parse", f"{revision}^{{commit}}") + if resolved.returncode != 0 or resolved.stdout.strip() != revision: + raise SystemExit(26) +main = git_run("rev-parse", "refs/heads/main") +if main.returncode != 0 or main.stdout.strip() != failed_revision: + raise SystemExit(27) +if git_run("merge-base", "--is-ancestor", live_revision, failed_revision).returncode != 0: + raise SystemExit(28) + +try: + spec = importlib.util.spec_from_file_location("itworx_deploy_controller", controller_path) + controller = importlib.util.module_from_spec(spec) if spec and spec.loader else None + if controller is None or spec is None or spec.loader is None: + raise RuntimeError("controller import unavailable") + spec.loader.exec_module(controller) +except Exception: + raise SystemExit(29) + +state_root.mkdir(parents=True, exist_ok=True) +project_lock_path.parent.mkdir(parents=True, exist_ok=True) +try: + project_lock = project_lock_path.open("a+", encoding="utf-8") + global_lock = global_lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(30) + +with project_lock: + try: + fcntl.flock(project_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(31) + with global_lock: + try: + fcntl.flock(global_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(31) + + try: + current_projects = json.loads(registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(32) + if current_projects.get(repository) != project: + raise SystemExit(33) + + if state_path.is_symlink() or not state_path.is_file(): + raise SystemExit(34) + try: + state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(34) + if not isinstance(state, dict): + raise SystemExit(34) + attempt = state.get("last_attempt") + receipt = state.get("live_receipt") + artifacts = state.get("live_artifacts") + if not ( + state.get("schema_version") == 3 + and state.get("repository") == repository + and state.get("revision") == live_revision + and state.get("live_revision") == live_revision + and state.get("live_status") == "unknown" + and state.get("live_legacy_provenance") is False + and state.get("status") == "failed" + and isinstance(attempt, dict) + and attempt.get("revision") == failed_revision + and attempt.get("status") == "failed" + and attempt.get("message") == failure_message + and isinstance(receipt, dict) + and bool(receipt) + and isinstance(artifacts, dict) + ): + raise SystemExit(35) + + project = dict(project) + project["_repository_name"] = repository + project["_repository_path"] = str(bare_repo) + + try: + build = controller.validate_recorded_receipt(project, receipt, repository, live_revision) + recorded_receipt_containers = receipt.get("containers") + if not isinstance(recorded_receipt_containers, dict): + raise RuntimeError("receipt containers missing") + expected_image_ids = { + str(container_name): str(dict(raw).get("image_id") or "") + for container_name, raw in recorded_receipt_containers.items() + if isinstance(raw, dict) + } + if set(expected_image_ids) != {"Portfolio", "Portfolio-api"} or not all(expected_image_ids.values()): + raise RuntimeError("receipt image IDs missing") + observed_receipt = controller.verify_deployment( + project, + build, + expected_image_ids=expected_image_ids, + ) + observed_artifacts = controller.deployment_artifacts(project) + except Exception: + raise SystemExit(36) + + immutable_receipt_keys = ("schema", "repository", "revision", "tree", "treeDigest", "build", "live") + if any(observed_receipt.get(key) != receipt.get(key) for key in immutable_receipt_keys): + raise SystemExit(37) + observed_receipt_containers = observed_receipt.get("containers") + if not isinstance(observed_receipt_containers, dict) or set(observed_receipt_containers) != set(recorded_receipt_containers): + raise SystemExit(37) + + container_id_changed = False + for container_name in sorted(recorded_receipt_containers): + old = dict(recorded_receipt_containers[container_name]) + new = dict(observed_receipt_containers[container_name]) + if old.get("container_id") != new.get("container_id"): + container_id_changed = True + old_without_id = {key: value for key, value in old.items() if key != "container_id"} + new_without_id = {key: value for key, value in new.items() if key != "container_id"} + if old_without_id != new_without_id: + raise SystemExit(38) + if not container_id_changed: + raise SystemExit(39) + + recorded_artifact_containers = artifacts.get("containers") + observed_artifact_containers = observed_artifacts.get("containers") + if ( + not isinstance(recorded_artifact_containers, dict) + or not isinstance(observed_artifact_containers, dict) + or set(recorded_artifact_containers) != set(observed_artifact_containers) + or artifacts.get("compose_sha256") != observed_artifacts.get("compose_sha256") + ): + raise SystemExit(41) + for container_name in sorted(recorded_artifact_containers): + old = dict(recorded_artifact_containers[container_name]) + new = dict(observed_artifact_containers[container_name]) + old_without_id = {key: value for key, value in old.items() if key != "container_id"} + new_without_id = {key: value for key, value in new.items() if key != "container_id"} + if old_without_id != new_without_id: + raise SystemExit(41) + + now = int(time.time()) + repaired = dict(state) + repaired["live_status"] = "healthy" + repaired["live_artifacts"] = observed_artifacts + repaired["artifacts"] = observed_artifacts + repaired["live_receipt"] = observed_receipt + repaired["status"] = "success" + repaired["message"] = "Verified Portfolio runtime re-attested after container recreation" + repaired["updated_at_epoch"] = now + repaired["last_attempt"] = { + "revision": live_revision, + "status": "success", + "message": "Verified live Portfolio runtime receipt re-attestation", + "updated_at_epoch": now, + "metadata": { + "reconciled_failed_revision": failed_revision, + "container_identity_only": True, + }, + } + + backup = state_path.with_name(f"portfolio.json.reattest-backup-{now}-{os.getpid()}") + temporary = state_path.with_name(f".{state_path.name}.{os.getpid()}.reattest.tmp") + try: + mode = stat.S_IMODE(state_path.stat().st_mode) + shutil.copy2(state_path, backup) + with temporary.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(repaired, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, mode) + os.replace(temporary, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(42) + + try: + verified = controller.verified_current_receipt( + repository, + project, + live_revision, + str(receipt.get("tree") or ""), + ) + except Exception: + verified = None + if not isinstance(verified, dict) or verified != observed_receipt: + raise SystemExit(43) + +print("re-attested", flush=True) +''' + + +class PortfolioDeploymentRecovery: + """Recover the exact known Portfolio state drift, otherwise use normal enrollment.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + controller = dict(status.get("container") or {}) if status.get("status") == "ok" else {} + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if controller.get("name") != _CONTROLLER or controller.get("state") != "running" or not container_id or not config: + return PortfolioDeploymentEnrollment(portainer=self.portainer).run() + + command = [ + "python3", + "-c", + _REATTEST_SCRIPT, + _REPOSITORY, + _LIVE_REVISION, + _FAILED_REVISION, + _FAILURE_MESSAGE, + ] + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={"AttachStdout": False, "AttachStderr": False, "Tty": False, "Cmd": command}, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return {"status": "error", "reason": "Portfolio runtime re-attestation could not start"} + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + deadline = time.monotonic() + 45 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return {"status": "error", "reason": "Portfolio runtime re-attestation timed out"} + if exit_code == 0: + return { + "status": "reconciled", + "repository": _REPOSITORY, + "live_revision": _LIVE_REVISION, + "failed_revision": _FAILED_REVISION, + "containers": ["Portfolio", "Portfolio-api"], + "compose_project": "portfolio", + } + + # Exit 35 means the exact recoverable state is no longer present. In + # normal/healthy states the established enrollment routine remains the + # authoritative path. Every other re-attestation failure is fail-closed. + if exit_code == 35: + return PortfolioDeploymentEnrollment(portainer=self.portainer).run() + + reasons = { + 20: "deployment controller executable is unavailable or unsafe", + 21: "deployment projects registry is unavailable or unsafe", + 22: "deployment projects registry cannot be read", + 23: "Portfolio deployment target is missing", + 24: "Portfolio deployment target differs from the bounded paired contract", + 25: "mounted Portfolio bare repository is unavailable", + 26: "known Portfolio recovery revisions cannot be resolved", + 27: "Portfolio main changed after the bounded recovery contract was recorded", + 28: "known Portfolio live revision is not an ancestor of the failed revision", + 29: "deployment controller verifier cannot be loaded", + 30: "deployment lock files cannot be opened", + 31: "deployment locks cannot be acquired", + 32: "deployment projects registry changed or became unreadable while waiting", + 33: "Portfolio deployment target changed while recovery was waiting", + 34: "Portfolio deployment state is unavailable or invalid", + 36: "controller could not re-attest the current Portfolio runtime against recorded immutable image IDs", + 37: "Portfolio receipt identity changed during re-attestation", + 38: "Portfolio immutable container/image provenance differs from the recorded receipt", + 39: "Portfolio container identities did not change; the known recovery cause is absent", + 40: "fixed Portfolio re-attestation contract was altered", + 41: "Portfolio recorded live artifacts differ beyond ephemeral container IDs", + 42: "re-attested Portfolio deployment state could not be written durably", + 43: "controller rejected the repaired Portfolio receipt after write", + } + return { + "status": "validation_failed", + "reason": reasons.get(exit_code, "Portfolio runtime re-attestation failed closed"), + } diff --git a/backend/app/services/preflight_service.py b/backend/app/services/preflight_service.py new file mode 100644 index 0000000..b45f55c --- /dev/null +++ b/backend/app/services/preflight_service.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +NO_ROLLBACK_ACTIONS = { + 'container.clear_logs', + 'container.stop', + 'gateway.clear_cache', + 'unraid.mover.start', + 'volume.delete', +} + + +def build_preflight(action: str, target: str, policy_decision: str, risk: str): + dependencies = ['connector'] + if action.startswith('container.'): + dependencies.append('container runtime') + if action.startswith('sonarr.') or action.startswith('radarr.'): + dependencies.append('media indexer api') + + return { + 'action': action, + 'target': target, + 'risk': risk, + 'policyDecision': policy_decision, + 'affectedApps': [target], + 'dependencies': dependencies, + 'preChecks': ['policy evaluate', 'connector config', 'maintenance status'], + 'postChecks': ['connector state', 'service availability', 'policy audit'], + 'rollbackAvailable': action not in NO_ROLLBACK_ACTIONS, + 'auditPreview': f'Action {action} on {target} is prepared for execution review.', + } diff --git a/backend/app/services/privatemuse_artifact_installer.py b/backend/app/services/privatemuse_artifact_installer.py new file mode 100644 index 0000000..be61653 --- /dev/null +++ b/backend/app/services/privatemuse_artifact_installer.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import ssl +import tempfile +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +_ALLOWED_KINDS = { + "lora": "loras", + "checkpoint": "checkpoints", + "unet": "diffusion_models", + "vae": "vae", + "clip": "text_encoders", + "gguf": "diffusion_models", +} +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_FILENAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+ -]{0,180}\.(?:safetensors|gguf)$") +_MAX_BYTES = 24 * 1024 * 1024 * 1024 + + +class ArtifactInstallError(RuntimeError): + pass + + +def validate_request(payload: dict[str, Any]) -> dict[str, str]: + kind = str(payload.get("artifact_type") or "").strip().lower() + url = str(payload.get("source_url") or "").strip() + sha256 = str(payload.get("sha256") or "").strip().lower() + filename = str(payload.get("filename") or "").strip() + license_name = str(payload.get("license") or "").strip() + source_id = str(payload.get("source_id") or "").strip() + parsed = urllib.parse.urlsplit(url) + if kind not in _ALLOWED_KINDS: + raise ArtifactInstallError("artifact_type is not allow-listed") + if parsed.scheme != "https" or not parsed.hostname: + raise ArtifactInstallError("source_url must be an absolute HTTPS URL") + if parsed.username or parsed.password or parsed.fragment: + raise ArtifactInstallError("source_url credentials/fragments are forbidden") + if not _SHA256.fullmatch(sha256): + raise ArtifactInstallError("sha256 must be a pinned lowercase SHA-256 digest") + expected_extension = ".gguf" if kind == "gguf" else ".safetensors" + if ( + not _FILENAME.fullmatch(filename) + or "/" in filename + or "\\" in filename + or not filename.casefold().endswith(expected_extension) + ): + raise ArtifactInstallError( + f"filename must be a bounded {expected_extension} basename for {kind}" + ) + if not license_name or len(license_name) > 240: + raise ArtifactInstallError("license provenance is required") + if not source_id or len(source_id) > 240: + raise ArtifactInstallError("source_id/version provenance is required") + return { + "artifact_type": kind, + "source_url": url, + "sha256": sha256, + "filename": filename, + "license": license_name, + "source_id": source_id, + } + + +def preflight(payload: dict[str, Any], *, model_root: Path) -> dict[str, Any]: + request = validate_request(payload) + root = model_root.resolve(strict=True) + if not root.is_dir() or not os.path.ismount(root): + raise ArtifactInstallError("model root must be an existing persistent mount") + destination_dir = (root / _ALLOWED_KINDS[request["artifact_type"]]).resolve(strict=True) + if root not in destination_dir.parents: + raise ArtifactInstallError("resolved destination escaped the model root") + if not os.access(destination_dir, os.W_OK | os.X_OK): + raise ArtifactInstallError("artifact destination is not writable by the AppOps service") + destination = destination_dir / request["filename"] + existing_digest = None + if destination.exists(): + if not destination.is_file() or destination.is_symlink(): + raise ArtifactInstallError("destination is not a regular artifact") + existing_digest = _hash_file(destination) + if existing_digest != request["sha256"]: + raise ArtifactInstallError("destination already exists with a different digest") + evidence = {"request": request, "model_root": str(root), "existing_digest": existing_digest} + fingerprint = hashlib.sha256(json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return {"ready": True, "fingerprint": fingerprint, "artifact_type": request["artifact_type"], + "filename": request["filename"], "sha256": request["sha256"], + "model_root": str(root), "already_installed": existing_digest is not None} + + +def _hash_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _write_receipt(request: dict[str, str], destination: Path, size: int, receipt_root: Path) -> Path: + receipt_root.mkdir(parents=True, exist_ok=True) + receipt = { + "schema": "privatemuse-comfyui-artifact.v1", + "artifact_type": request["artifact_type"], + "filename": request["filename"], + "sha256": request["sha256"], + "bytes": size, + "source_id": request["source_id"], + "source_host": urllib.parse.urlsplit(request["source_url"]).hostname, + "license": request["license"], + "destination": str(destination), + } + receipt_path = receipt_root / f'{request["sha256"]}.json' + if receipt_path.exists(): + return receipt_path + with tempfile.NamedTemporaryFile( + mode="w", prefix=".receipt-", suffix=".json", dir=receipt_root, + delete=False, encoding="utf-8", + ) as handle: + json.dump(receipt, handle, sort_keys=True, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(handle.name) + try: + os.link(temporary, receipt_path) + except FileExistsError: + pass + finally: + temporary.unlink(missing_ok=True) + return receipt_path + + +def install( + payload: dict[str, Any], + *, + model_root: Path, + receipt_root: Path, +) -> dict[str, Any]: + request = validate_request(payload) + root = model_root.resolve() + destination_dir = (root / _ALLOWED_KINDS[request["artifact_type"]]).resolve() + if root not in destination_dir.parents: + raise ArtifactInstallError("resolved destination escaped the model root") + destination_dir.mkdir(parents=True, exist_ok=True) + destination = destination_dir / request["filename"] + if destination.is_symlink(): + raise ArtifactInstallError("destination symlinks are forbidden") + if destination.exists(): + digest = _hash_file(destination) + if digest != request["sha256"]: + raise ArtifactInstallError( + "destination already exists with a different digest" + ) + receipt_path = _write_receipt(request, destination, destination.stat().st_size, receipt_root) + return { + "status": "already_installed", + "path": str(destination), + "sha256": digest, + "bytes": destination.stat().st_size, + "receipt": str(receipt_path), + } + + request_obj = urllib.request.Request( + request["source_url"], + headers={"User-Agent": "ITWorx-AppOps/PrivateMuse-artifact-installer"}, + ) + hasher = hashlib.sha256() + total = 0 + tmp_path: Path | None = None + try: + with urllib.request.urlopen( + request_obj, + timeout=30, + context=ssl.create_default_context(), + ) as response: + final = urllib.parse.urlsplit(response.geturl()) + if final.scheme != "https" or not final.hostname: + raise ArtifactInstallError("download redirected outside HTTPS") + declared = response.headers.get("Content-Length") + if declared and int(declared) > _MAX_BYTES: + raise ArtifactInstallError( + "artifact exceeds the 24 GiB bounded installer limit" + ) + with tempfile.NamedTemporaryFile( + prefix=".appops-", + suffix=".part", + dir=destination_dir, + delete=False, + ) as handle: + tmp_path = Path(handle.name) + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > _MAX_BYTES: + raise ArtifactInstallError( + "artifact exceeded the 24 GiB bounded installer limit" + ) + hasher.update(chunk) + handle.write(chunk) + handle.flush() + os.fsync(handle.fileno()) + digest = hasher.hexdigest() + if digest != request["sha256"]: + raise ArtifactInstallError( + "downloaded artifact SHA-256 did not match the pinned digest" + ) + # Link is atomic and fails if another installer created the final name. + # os.replace would silently overwrite a different digest in that race. + try: + os.link(tmp_path, destination) + except FileExistsError as exc: + if destination.is_symlink() or not destination.is_file() or _hash_file(destination) != digest: + raise ArtifactInstallError("destination appeared with a different digest") from exc + tmp_path.unlink() + tmp_path = None + receipt_path = _write_receipt(request, destination, total, receipt_root) + return { + "status": "installed", + "path": str(destination), + "sha256": digest, + "bytes": total, + "receipt": str(receipt_path), + } + finally: + if tmp_path is not None: + tmp_path.unlink(missing_ok=True) diff --git a/backend/app/services/privatemuse_deployment_enrollment.py b/backend/app/services/privatemuse_deployment_enrollment.py new file mode 100644 index 0000000..1b4ea93 --- /dev/null +++ b/backend/app/services/privatemuse_deployment_enrollment.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +import json +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/privatemuse" +_LEGACY_REVISION = "715f7d152d35745521a2e014e334a1509bdce6a2" + +_PROJECT: dict[str, object] = { + "branch": "main", + "slug": "privatemuse", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/privatemuse", + "exact_source": True, + "env_source": "/srv/example/appdata/privatemuse/.env", + "compose_files": ["docker-compose.yml", "docker-compose.unraid.yml"], + "compose_project": "privatemuse", + "containers": [ + "privatemuse-backend-1", + "privatemuse-frontend-1", + "privatemuse-audio-enhancement-1", + ], + "verify_oci_revision": False, + "health_timeout": 180, + "environment": {"PRIVATEMUSE_RELEASE": "{short_revision}"}, +} + +# This is a fixed, application-owned one-time migration. Callers cannot supply +# commands, paths, container names, Compose files or deployment JSON. It uses +# the same controller-owned registry, global production lock and official +# deploy.py bootstrap-legacy path as the deployment broker itself. +_ENROLL_SCRIPT = r''' +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import time + +registry_path = Path("/opt/gitea-deploy/projects.json") +host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") +state_path = state_root / "privatemuse.json" +project_lock_path = state_root / "locks" / "privatemuse.lock" +lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") +repository, legacy_revision, raw_project = sys.argv[1:4] +project = json.loads(raw_project) + +expected_project = { + "branch": "main", + "slug": "privatemuse", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/privatemuse", + "exact_source": True, + "env_source": "/srv/example/appdata/privatemuse/.env", + "compose_files": ["docker-compose.yml", "docker-compose.unraid.yml"], + "compose_project": "privatemuse", + "containers": [ + "privatemuse-backend-1", + "privatemuse-frontend-1", + "privatemuse-audio-enhancement-1", + ], + "verify_oci_revision": False, + "health_timeout": 180, + "environment": {"PRIVATEMUSE_RELEASE": "{short_revision}"}, +} +expected_legacy = "715f7d152d35745521a2e014e334a1509bdce6a2" +stale_audio = { + "privatemuse-audio-runtime-base-1", + "privatemuse-audio-speech-1", + "privatemuse-audio-music-1", + "privatemuse-audio-sound-1", + "privatemuse-audio-transcription-1", + "privatemuse-audio-lipsync-1", +} + +if repository != "jens/privatemuse" or legacy_revision != expected_legacy or project != expected_project: + raise SystemExit(40) +if registry_path.is_symlink() or not registry_path.is_file(): + raise SystemExit(20) +registry_stat = registry_path.stat() +if not stat.S_ISREG(registry_stat.st_mode): + raise SystemExit(21) +try: + if ( + host_registry_path.is_symlink() + or not host_registry_path.is_file() + or not os.path.samefile(registry_path, host_registry_path) + ): + raise SystemExit(36) +except OSError: + raise SystemExit(36) + +env_source = Path(str(project["env_source"])) +if env_source.is_symlink() or not env_source.is_file(): + raise SystemExit(41) + +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +if not bare_repo.is_dir(): + raise SystemExit(24) + +def git_run(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + +legacy = git_run("rev-parse", f"{legacy_revision}^{{commit}}") +if legacy.returncode != 0 or legacy.stdout.strip() != legacy_revision: + raise SystemExit(25) +main = git_run("rev-parse", "refs/heads/main") +main_revision = main.stdout.strip() +if main.returncode != 0 or len(main_revision) != 40: + raise SystemExit(26) +if git_run("merge-base", "--is-ancestor", legacy_revision, main_revision).returncode != 0: + raise SystemExit(27) +for compose_file in project["compose_files"]: + if git_run("cat-file", "-e", f"{main_revision}:{compose_file}").returncode != 0: + raise SystemExit(28) + +try: + if state_root.is_symlink() or not state_root.is_dir(): + raise SystemExit(50) + project_lock_path.parent.mkdir(parents=True, exist_ok=True) + if project_lock_path.parent.is_symlink() or project_lock_path.is_symlink(): + raise SystemExit(29) + project_lock_handle = project_lock_path.open("a+", encoding="utf-8") + lock_handle = lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(29) + +with project_lock_handle as project_lock, lock_handle as lock: + try: + fcntl.flock(project_lock, fcntl.LOCK_EX) + fcntl.flock(lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(30) + + try: + projects = json.loads(host_registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(31) + if not isinstance(projects, dict): + raise SystemExit(22) + + current = projects.get(repository) + canonical_target_match = current == project + if current is not None and current != project: + if not isinstance(current, dict): + raise SystemExit(54) + if current.get("appops_managed_target") is True: + raise SystemExit(55) + target_fields = [ + "branch", + "slug", + "source_dir", + "exact_source", + "env_source", + "compose_files", + "compose_project", + "containers", + "verify_oci_revision", + "health_timeout", + "environment", + ] + for index, key in enumerate(target_fields, start=60): + if key not in current or current.get(key) != project[key]: + raise SystemExit(index) + extra_keys = set(current) - set(project) + allowed_metadata_keys = { + "gitea_repository_id", + "gitea_webhook_secret_file", + "validation", + } + expected_validation = { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, + } + repository_id = current.get("gitea_repository_id") + if ( + extra_keys != allowed_metadata_keys + or not isinstance(repository_id, int) + or isinstance(repository_id, bool) + or repository_id <= 0 + or current.get("gitea_webhook_secret_file") != "jens__privatemuse" + or current.get("validation") != expected_validation + ): + raise SystemExit(71) + canonical_target_match = True + + existing_state = None + if state_path.exists(): + if state_path.is_symlink() or not state_path.is_file(): + raise SystemExit(42) + try: + existing_state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(42) + if not isinstance(existing_state, dict): + raise SystemExit(42) + if existing_state.get("schema_version") == 3: + if existing_state.get("repository") != repository: + raise SystemExit(43) + if not canonical_target_match: + raise SystemExit(23) + print("already-enrolled", flush=True) + raise SystemExit(0) + if not ( + existing_state.get("repository") == repository + and existing_state.get("status") == "success" + and existing_state.get("revision") == legacy_revision + and "schema_version" not in existing_state + and "live_receipt" not in existing_state + and "live_legacy_provenance" not in existing_state + ): + raise SystemExit(43) + + expected_names = set(project["containers"]) + observed_result = subprocess.run( + [ + "docker", + "ps", + "--all", + "--filter", + f"label=com.docker.compose.project={project['compose_project']}", + "--format", + "{{.Names}}", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + if observed_result.returncode != 0: + raise SystemExit(44) + observed_names = {line.strip() for line in observed_result.stdout.splitlines() if line.strip()} + unexpected = observed_names - expected_names - stale_audio + if unexpected: + raise SystemExit(45) + + for required in sorted(expected_names): + inspected = subprocess.run( + [ + "docker", + "inspect", + "--format", + "{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}|{{index .Config.Labels \"com.docker.compose.project\"}}", + required, + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if inspected.returncode != 0: + raise SystemExit(46) + try: + running, health, compose_project = inspected.stdout.strip().split("|", 2) + except ValueError: + raise SystemExit(46) + if running != "true" or health in {"starting", "unhealthy"} or compose_project != "privatemuse": + raise SystemExit(46) + + # Remove only known, stopped provider containers. Images, named volumes, + # model directories and persistent media are deliberately left untouched. + for stale in sorted(observed_names & stale_audio): + inspected = subprocess.run( + [ + "docker", + "inspect", + "--format", + "{{.State.Running}}|{{index .Config.Labels \"com.docker.compose.project\"}}", + stale, + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if inspected.returncode != 0: + raise SystemExit(47) + try: + running, compose_project = inspected.stdout.strip().split("|", 1) + except ValueError: + raise SystemExit(47) + if running == "true" or compose_project != "privatemuse": + raise SystemExit(48) + removed = subprocess.run( + ["docker", "rm", stale], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=20, + check=False, + ) + if removed.returncode != 0: + raise SystemExit(49) + + if current is None: + backup = host_registry_path.with_name( + f"projects.json.privatemuse-backup-{int(time.time())}-{os.getpid()}" + ) + try: + shutil.copy2(host_registry_path, backup) + except Exception: + raise SystemExit(32) + updated = dict(projects) + updated[repository] = project + temporary = host_registry_path.with_name(f".{host_registry_path.name}.{os.getpid()}.tmp") + payload = json.dumps(updated, indent=2, sort_keys=True) + "\n" + try: + with temporary.open("w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, stat.S_IMODE(registry_stat.st_mode)) + os.replace(temporary, host_registry_path) + directory_fd = os.open(str(host_registry_path.parent), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(34) + + if existing_state is None: + state_root.mkdir(parents=True, exist_ok=True) + if state_root.is_symlink() or not state_root.is_dir(): + raise SystemExit(50) + temporary_state = state_path.with_name(f".{state_path.name}.{os.getpid()}.tmp") + payload = { + "repository": repository, + "revision": legacy_revision, + "status": "success", + } + try: + with temporary_state.open("x", encoding="utf-8") as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary_state, 0o600) + os.replace(temporary_state, state_path) + directory_fd = os.open(str(state_root), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + try: + temporary_state.unlink(missing_ok=True) + except Exception: + pass + raise SystemExit(51) + + try: + controller_path = Path("/opt/gitea-deploy/deploy.py") + if controller_path.is_symlink() or not controller_path.is_file(): + raise RuntimeError("controller module is unavailable") + controller_spec = importlib.util.spec_from_file_location( + "appops_locked_privatemuse_bootstrap", controller_path + ) + if controller_spec is None or controller_spec.loader is None: + raise RuntimeError("controller module cannot be loaded") + controller = importlib.util.module_from_spec(controller_spec) + controller_spec.loader.exec_module(controller) + controller.bootstrap_legacy_state(repository, project, legacy_revision) + except Exception: + raise SystemExit(52) + + try: + final_state = json.loads(state_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(53) + if not ( + isinstance(final_state, dict) + and final_state.get("schema_version") == 3 + and final_state.get("repository") == repository + and final_state.get("live_revision") == legacy_revision + and final_state.get("live_status") == "healthy" + and final_state.get("live_legacy_provenance") is True + ): + raise SystemExit(53) + +print("enrolled", flush=True) +''' + + +class PrivateMuseDeploymentEnrollment: + """One-time, fail-closed enrollment of the existing PrivateMuse production runtime.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + return { + "status": "controller_unavailable", + "reason": redact_message( + str(status.get("reason") or status.get("status") or "unavailable") + ), + } + controller = dict(status.get("container") or {}) + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return { + "status": "controller_unavailable", + "reason": "deployment controller is not running", + } + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if not config or not container_id: + return { + "status": "connector_unavailable", + "reason": "Portainer deployment control is unavailable", + } + + command = [ + "python3", + "-c", + _ENROLL_SCRIPT, + _REPOSITORY, + _LEGACY_REVISION, + json.dumps(_PROJECT, separators=(",", ":"), sort_keys=True), + ] + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return {"status": "error", "reason": "PrivateMuse enrollment could not start"} + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + + deadline = time.monotonic() + 120 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return {"status": "error", "reason": "PrivateMuse enrollment timed out"} + reasons = { + 20: "deployment projects registry is unavailable or unsafe", + 21: "deployment projects registry is not a regular file", + 22: "deployment projects registry does not contain an object", + 23: "PrivateMuse already has a different deployment target", + 24: "mounted PrivateMuse bare repository is unavailable", + 25: "verified PrivateMuse legacy revision cannot be resolved", + 26: "PrivateMuse main cannot be resolved", + 27: "verified PrivateMuse legacy revision is not an ancestor of main", + 28: "required PrivateMuse production Compose file is absent from main", + 29: "deployment registry lock file cannot be opened", + 30: "deployment registry lock cannot be acquired", + 31: "deployment projects registry cannot be read as JSON", + 32: "deployment projects registry backup cannot be created", + 34: "deployment projects registry replacement failed", + 36: "deployment projects registry host alias is unavailable or mismatched", + 40: "PrivateMuse fixed enrollment contract was altered", + 41: "PrivateMuse production environment file is unavailable or unsafe", + 42: "PrivateMuse deployment state file is unavailable or invalid", + 43: "PrivateMuse deployment state conflicts with the verified legacy baseline", + 44: "PrivateMuse Compose project inventory could not be read", + 45: "PrivateMuse Compose project contains an unexpected container", + 46: "required PrivateMuse production container is missing or unhealthy", + 47: "stale PrivateMuse audio container could not be inspected", + 48: "a stale PrivateMuse audio provider is still running and was not removed", + 49: "a verified stopped PrivateMuse audio container could not be removed", + 50: "deployment state directory is unavailable or unsafe", + 51: "verified PrivateMuse legacy state could not be written durably", + 52: "deployment controller rejected PrivateMuse legacy-state bootstrap", + 53: "PrivateMuse legacy-state bootstrap did not produce the expected managed state", + 54: "PrivateMuse deployment target is not an object", + 55: "PrivateMuse deployment target is owned by the controller v2 managed-target contract", + 60: "PrivateMuse deployment target differs in branch", + 61: "PrivateMuse deployment target differs in slug", + 62: "PrivateMuse deployment target differs in source_dir", + 63: "PrivateMuse deployment target differs in exact_source", + 64: "PrivateMuse deployment target differs in env_source", + 65: "PrivateMuse deployment target differs in compose_files", + 66: "PrivateMuse deployment target differs in compose_project", + 67: "PrivateMuse deployment target differs in containers", + 68: "PrivateMuse deployment target differs in verify_oci_revision", + 69: "PrivateMuse deployment target differs in health_timeout", + 70: "PrivateMuse deployment target differs in environment", + 71: "PrivateMuse deployment target matches canonical fields but has additional or invalid controller metadata", + } + if exit_code != 0: + return { + "status": "validation_failed", + "reason": reasons.get( + exit_code, + "PrivateMuse enrollment failed closed before a production application deploy", + ), + } + return { + "status": "enrolled", + "repository": _REPOSITORY, + "legacy_revision": _LEGACY_REVISION, + "compose_project": _PROJECT["compose_project"], + "containers": list(_PROJECT["containers"]), + } diff --git a/backend/app/services/privatemuse_deployment_recovery.py b/backend/app/services/privatemuse_deployment_recovery.py new file mode 100644 index 0000000..9c6905d --- /dev/null +++ b/backend/app/services/privatemuse_deployment_recovery.py @@ -0,0 +1,1228 @@ +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote +from uuid import uuid4 + +from sqlalchemy import func, update +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import AppSetting, JobRun + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/privatemuse" +_LIVE_REVISION = "2103d5795d151885edeeb964466b643b8fd4a0eb" +_FAILED_REVISION = "6e4c959eea2e7f3bf2c6b3560a2c1658865bfc38" +_FAILURE_MESSAGE = ( + "Existing production containers require a valid healthy live deployment state" +) +_CONTAINERS = [ + "privatemuse-backend-1", + "privatemuse-frontend-1", + "privatemuse-audio-enhancement-1", +] +_RECOVERY_STATE_SETTING = "deployment:privatemuse:receipt-recovery:v1" +_ACTIVE_RECOVERY_STATES = {"reserved", "created", "started", "outcome_unknown"} +_STATE_TRANSITIONS = { + "reserved": {"reserved", "created", "outcome_unknown", "completed"}, + "created": {"started", "outcome_unknown", "completed"}, + "started": {"outcome_unknown", "completed"}, + "outcome_unknown": {"outcome_unknown", "completed"}, +} + +# One-time, fail-closed re-attestation for the exact observed PrivateMuse state. +# It never builds, restarts, recreates or stops application containers. The +# controller's own receipt/runtime verifiers prove that only the two container +# IDs recreated by the prior rollback differ; all immutable evidence must match. +_REATTEST_SCRIPT = r""" +import fcntl +import importlib.util +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import time + +repository, live_revision, failed_revision, failure_message = sys.argv[1:5] +expected_repository = "jens/privatemuse" +expected_live_revision = "2103d5795d151885edeeb964466b643b8fd4a0eb" +expected_failed_revision = "6e4c959eea2e7f3bf2c6b3560a2c1658865bfc38" +expected_failure_message = "Existing production containers require a valid healthy live deployment state" +if ( + repository != expected_repository + or live_revision != expected_live_revision + or failed_revision != expected_failed_revision + or failure_message != expected_failure_message +): + raise SystemExit(40) + +controller_path = Path("/opt/gitea-deploy/deploy.py") +registry_path = Path("/opt/gitea-deploy/projects.json") +state_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") +state_path = state_root / "privatemuse.json" +project_lock_path = state_root / "locks" / "privatemuse.lock" +global_lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") + +def fsync_directory(directory): + flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + directory_fd = os.open(str(directory), flags) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + +def write_exclusive(path, payload, metadata): + try: + with path.open("xb") as handle: + handle.write(payload) + handle.flush() + os.fchmod(handle.fileno(), stat.S_IMODE(metadata.st_mode)) + os.fchown(handle.fileno(), metadata.st_uid, metadata.st_gid) + os.fsync(handle.fileno()) + except Exception: + try: + path.unlink(missing_ok=True) + except Exception: + pass + raise + +if controller_path.is_symlink() or not controller_path.is_file(): + raise SystemExit(20) +if registry_path.is_symlink() or not registry_path.is_file(): + raise SystemExit(21) +try: + projects = json.loads(registry_path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(22) +project = projects.get(repository) if isinstance(projects, dict) else None +if not isinstance(project, dict): + raise SystemExit(23) + +expected_target = { + "branch": "main", + "slug": "privatemuse", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/privatemuse", + "exact_source": True, + "env_source": "/srv/example/appdata/privatemuse/.env", + "compose_files": ["docker-compose.yml", "docker-compose.unraid.yml"], + "compose_project": "privatemuse", + "containers": [ + "privatemuse-backend-1", + "privatemuse-frontend-1", + "privatemuse-audio-enhancement-1", + ], + "verify_oci_revision": False, + "health_timeout": 180, + "environment": {"PRIVATEMUSE_RELEASE": "{short_revision}"}, +} +for key, expected in expected_target.items(): + if project.get(key) != expected: + raise SystemExit(24) +allowed_metadata_keys = { + "gitea_repository_id", + "gitea_webhook_secret_file", + "validation", +} +expected_validation = { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, +} +repository_id = project.get("gitea_repository_id") +if ( + set(project) - set(expected_target) != allowed_metadata_keys + or not isinstance(repository_id, int) + or isinstance(repository_id, bool) + or repository_id <= 0 + or project.get("gitea_webhook_secret_file") != "jens__privatemuse" + or project.get("validation") != expected_validation +): + raise SystemExit(24) + +owner, name = repository.split("/", 1) +bare_repo = Path("/gitea-repositories") / owner / f"{name}.git" +if not bare_repo.is_dir(): + raise SystemExit(25) + +def git_run(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + +for revision in (live_revision, failed_revision): + resolved = git_run("rev-parse", f"{revision}^{{commit}}") + if resolved.returncode != 0 or resolved.stdout.strip() != revision: + raise SystemExit(26) +main = git_run("rev-parse", "refs/heads/main") +if main.returncode != 0 or main.stdout.strip() != failed_revision: + raise SystemExit(27) +if git_run("merge-base", "--is-ancestor", live_revision, failed_revision).returncode != 0: + raise SystemExit(28) + +try: + spec = importlib.util.spec_from_file_location("itworx_deploy_controller", controller_path) + controller = importlib.util.module_from_spec(spec) if spec and spec.loader else None + if controller is None or spec is None or spec.loader is None: + raise RuntimeError("controller import unavailable") + spec.loader.exec_module(controller) +except Exception: + raise SystemExit(29) + +state_root.mkdir(parents=True, exist_ok=True) +project_lock_path.parent.mkdir(parents=True, exist_ok=True) +try: + project_lock = project_lock_path.open("a+", encoding="utf-8") + global_lock = global_lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(30) + +with project_lock: + try: + fcntl.flock(project_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(31) + with global_lock: + try: + fcntl.flock(global_lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(31) + + try: + current_projects = json.loads(registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(32) + if current_projects.get(repository) != project: + raise SystemExit(33) + + if state_path.is_symlink() or not state_path.is_file(): + raise SystemExit(34) + try: + state_metadata = state_path.stat() + original_state = state_path.read_bytes() + state = json.loads(original_state.decode("utf-8")) + except Exception: + raise SystemExit(34) + if not isinstance(state, dict): + raise SystemExit(34) + + project = dict(project) + project["_repository_name"] = repository + project["_repository_path"] = str(bare_repo) + + if ( + state.get("schema_version") == 3 + and state.get("repository") == repository + and state.get("live_status") == "healthy" + ): + healthy_revision = str(state.get("live_revision") or "") + healthy_receipt = state.get("live_receipt") + healthy_tree = ( + str(healthy_receipt.get("tree") or "") + if isinstance(healthy_receipt, dict) + else "" + ) + verified_healthy = None + if ( + healthy_revision in {live_revision, failed_revision} + and state.get("revision") == healthy_revision + and state.get("status") == "success" + and state.get("live_legacy_provenance") is False + and isinstance(healthy_receipt, dict) + and healthy_receipt.get("revision") == healthy_revision + and healthy_tree + ): + try: + verified_healthy = controller.verified_current_receipt( + repository, + project, + healthy_revision, + healthy_tree, + ) + except Exception: + verified_healthy = None + if verified_healthy == healthy_receipt: + raise SystemExit(35) + raise SystemExit(44) + + attempt = state.get("last_attempt") + receipt = state.get("live_receipt") + artifacts = state.get("live_artifacts") + expected_attempt_metadata = { + "id": "6e4c959eea2e", + "repository": repository, + "revision": failed_revision, + "tree": "daa0e757f4b3edac2c2830572a05bfdf271e8bba", + "treeDigest": "e763339aca7139a29b3d4fc8a8f856557881ef177f15115d0c7fd52b093b3654", + "builtAt": "2026-09-19T23:07:14.894Z", + } + expected_receipt_keys = { + "build", + "containers", + "live", + "repository", + "revision", + "schema", + "tree", + "treeDigest", + } + expected_artifact_keys = {"compose_sha256", "containers"} + if not ( + state.get("schema_version") == 3 + and state.get("repository") == repository + and state.get("revision") == live_revision + and state.get("live_revision") == live_revision + and state.get("live_status") == "unknown" + and state.get("live_legacy_provenance") is False + and state.get("status") == "failed" + and state.get("message") == failure_message + and isinstance(attempt, dict) + and attempt.get("revision") == failed_revision + and attempt.get("status") == "failed" + and attempt.get("message") == failure_message + and attempt.get("metadata") == expected_attempt_metadata + and isinstance(receipt, dict) + and set(receipt) == expected_receipt_keys + and isinstance(artifacts, dict) + and set(artifacts) == expected_artifact_keys + ): + raise SystemExit(44) + + expected_containers = { + "privatemuse-audio-enhancement-1", + "privatemuse-backend-1", + "privatemuse-frontend-1", + } + expected_container_keys = { + "container_id", + "container_revision", + "image_created", + "image_id", + "image_ref", + "image_revision", + "image_source", + "image_tree", + "image_version", + } + try: + build = controller.validate_recorded_receipt(project, receipt, repository, live_revision) + recorded_receipt_containers = receipt.get("containers") + if ( + not isinstance(recorded_receipt_containers, dict) + or set(recorded_receipt_containers) != expected_containers + ): + raise RuntimeError("receipt containers differ") + expected_image_ids = { + str(container_name): str(dict(raw).get("image_id") or "") + for container_name, raw in recorded_receipt_containers.items() + if isinstance(raw, dict) and set(raw) == expected_container_keys + } + if set(expected_image_ids) != expected_containers or not all(expected_image_ids.values()): + raise RuntimeError("receipt image IDs missing") + observed_receipt = controller.verify_deployment( + project, + build, + expected_image_ids=expected_image_ids, + ) + observed_artifacts = controller.deployment_artifacts(project) + except Exception: + raise SystemExit(36) + + immutable_receipt_keys = ( + "schema", + "repository", + "revision", + "tree", + "treeDigest", + "build", + "live", + ) + if any(observed_receipt.get(key) != receipt.get(key) for key in immutable_receipt_keys): + raise SystemExit(37) + observed_receipt_containers = observed_receipt.get("containers") + if ( + not isinstance(observed_receipt_containers, dict) + or set(observed_receipt_containers) != expected_containers + ): + raise SystemExit(37) + + allowed_changed_ids = {"privatemuse-backend-1", "privatemuse-frontend-1"} + changed_receipt_ids = set() + for container_name in sorted(expected_containers): + old = dict(recorded_receipt_containers[container_name]) + new = dict(observed_receipt_containers[container_name]) + if set(old) != expected_container_keys or set(new) != expected_container_keys: + raise SystemExit(38) + if old.get("container_id") != new.get("container_id"): + changed_receipt_ids.add(container_name) + old_without_id = {key: value for key, value in old.items() if key != "container_id"} + new_without_id = {key: value for key, value in new.items() if key != "container_id"} + if old_without_id != new_without_id: + raise SystemExit(38) + if changed_receipt_ids != allowed_changed_ids: + raise SystemExit(39) + + recorded_artifact_containers = artifacts.get("containers") + observed_artifact_containers = observed_artifacts.get("containers") + if ( + set(observed_artifacts) != expected_artifact_keys + or not isinstance(recorded_artifact_containers, dict) + or not isinstance(observed_artifact_containers, dict) + or set(recorded_artifact_containers) != expected_containers + or set(observed_artifact_containers) != expected_containers + or artifacts.get("compose_sha256") != observed_artifacts.get("compose_sha256") + ): + raise SystemExit(41) + changed_artifact_ids = set() + for container_name in sorted(expected_containers): + old = dict(recorded_artifact_containers[container_name]) + new = dict(observed_artifact_containers[container_name]) + if set(old) != expected_container_keys or set(new) != expected_container_keys: + raise SystemExit(41) + if old.get("container_id") != new.get("container_id"): + changed_artifact_ids.add(container_name) + old_without_id = {key: value for key, value in old.items() if key != "container_id"} + new_without_id = {key: value for key, value in new.items() if key != "container_id"} + if ( + old_without_id != new_without_id + or new.get("container_id") + != dict(observed_receipt_containers[container_name]).get("container_id") + ): + raise SystemExit(41) + if changed_artifact_ids != allowed_changed_ids: + raise SystemExit(41) + + now = int(time.time()) + repaired = dict(state) + repaired["live_status"] = "healthy" + repaired["live_artifacts"] = observed_artifacts + repaired["artifacts"] = observed_artifacts + repaired["live_receipt"] = observed_receipt + repaired["status"] = "success" + repaired["message"] = "Verified PrivateMuse runtime re-attested after rollback recreation" + repaired["updated_at_epoch"] = now + repaired["last_attempt"] = { + "revision": live_revision, + "status": "success", + "message": "Verified live PrivateMuse runtime receipt re-attestation", + "updated_at_epoch": now, + "metadata": { + "reconciled_failed_revision": failed_revision, + "container_identity_only": True, + "changed_containers": sorted(allowed_changed_ids), + }, + } + + backup = state_path.with_name(f"privatemuse.json.reattest-backup-{now}-{os.getpid()}") + temporary = state_path.with_name(f".{state_path.name}.{os.getpid()}.reattest.tmp") + restore_temporary = state_path.with_name( + f".{state_path.name}.{os.getpid()}.reattest-restore.tmp" + ) + + def restore_original_state(): + try: + write_exclusive(restore_temporary, original_state, state_metadata) + os.replace(restore_temporary, state_path) + fsync_directory(state_root) + return state_path.read_bytes() == original_state + except Exception: + try: + restore_temporary.unlink(missing_ok=True) + except Exception: + pass + return False + + state_replaced = False + try: + write_exclusive(backup, original_state, state_metadata) + fsync_directory(state_root) + repaired_state = ( + json.dumps(repaired, indent=2, sort_keys=True) + "\n" + ).encode("utf-8") + write_exclusive(temporary, repaired_state, state_metadata) + os.replace(temporary, state_path) + state_replaced = True + fsync_directory(state_root) + except Exception: + try: + temporary.unlink(missing_ok=True) + except Exception: + pass + if state_replaced and not restore_original_state(): + raise SystemExit(45) + raise SystemExit(42) + + try: + written = json.loads(state_path.read_text(encoding="utf-8")) + verified = controller.verified_current_receipt( + repository, + project, + live_revision, + str(receipt.get("tree") or ""), + ) + final_artifacts = controller.deployment_artifacts(project) + except Exception: + written = None + verified = None + final_artifacts = None + if ( + written != repaired + or not isinstance(verified, dict) + or verified != observed_receipt + or final_artifacts != observed_artifacts + ): + if not restore_original_state(): + raise SystemExit(45) + raise SystemExit(43) + +print("re-attested", flush=True) +""" + + +class PrivateMuseDeploymentRecovery: + """Recover only the exact known PrivateMuse receipt drift, otherwise fail closed.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + @staticmethod + def tracked_job_id(session: Session) -> str | None: + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + if not row: + return None + state = dict(row.value_json or {}) + job_id = str(state.get("jobId") or "") + return job_id or None + + @staticmethod + def guarded_unknown_result( + session: Session, *, reason: str + ) -> dict[str, Any] | None: + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + state = dict(row.value_json or {}) if row else {} + if state.get("state") not in _ACTIVE_RECOVERY_STATES: + return None + execution_id = str(state.get("executionId") or "") + return { + "status": "outcome_unknown", + "reason": reason, + **({"execution_id": execution_id} if execution_id else {}), + "original_job_id": str(state.get("jobId") or ""), + } + + @staticmethod + def _ensure_state_row(session: Session) -> AppSetting: + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + if row: + return row + try: + row = AppSetting( + key=_RECOVERY_STATE_SETTING, + value_json={"state": "idle", "version": 0}, + is_secret=False, + ) + session.add(row) + session.commit() + except IntegrityError: + session.rollback() + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + if not row: + raise RuntimeError("PrivateMuse recovery state could not be initialized") + return row + + @classmethod + def admit_job( + cls, + session: Session, + job: JobRun, + ) -> tuple[dict[str, Any], bool]: + """Atomically reserve recovery ownership and create its sole JobRun.""" + cls._ensure_state_row(session) + for _ in range(5): + session.expire_all() + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + if not row: + raise RuntimeError("PrivateMuse recovery state disappeared") + current = dict(row.value_json or {}) + if current.get("state") in _ACTIVE_RECOVERY_STATES or current.get( + "state" + ) == "completed": + return current, False + + now = utc_now() + current_version = int(current.get("version") or 0) + claimed = { + "state": "reserved", + "claimId": uuid4().hex, + "jobId": job.id, + "version": current_version + 1, + "reservedAt": now.isoformat(), + "updatedAt": now.isoformat(), + } + statement = ( + update(AppSetting) + .where( + AppSetting.key == _RECOVERY_STATE_SETTING, + AppSetting.updated_at == row.updated_at, + func.coalesce( + AppSetting.value_json["version"].as_integer(), 0 + ) + == current_version, + ) + .values(value_json=claimed, updated_at=now) + ) + result = session.exec(statement) + if result.rowcount == 1: + session.add(job) + session.commit() + session.refresh(job) + return claimed, True + session.rollback() + raise RuntimeError("PrivateMuse recovery state is busy") + + @classmethod + def _persist_state( + cls, + session: Session, + tracked: dict[str, Any], + *, + state: str, + execution_id: str = "", + controller_id: str = "", + result: dict[str, Any] | None = None, + ) -> dict[str, Any]: + for _ in range(5): + session.expire_all() + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + if not row: + raise RuntimeError("PrivateMuse recovery state is unavailable") + current = dict(row.value_json or {}) + if ( + current.get("claimId") != tracked.get("claimId") + or current.get("jobId") != tracked.get("jobId") + ): + raise RuntimeError("PrivateMuse recovery ownership changed") + if current.get("state") == "completed": + tracked.clear() + tracked.update(current) + return current + current_state = str(current.get("state") or "") + if state not in _STATE_TRANSITIONS.get(current_state, set()): + raise RuntimeError( + f"PrivateMuse recovery cannot transition from {current_state} to {state}" + ) + current_execution_id = str(current.get("executionId") or "") + if ( + current_execution_id + and execution_id + and current_execution_id != execution_id + ): + raise RuntimeError("PrivateMuse recovery execution identity changed") + current_controller_id = str(current.get("controllerId") or "") + if ( + current_controller_id + and controller_id + and current_controller_id != controller_id + ): + raise RuntimeError("PrivateMuse recovery controller identity changed") + + now = utc_now() + current_version = int(current.get("version") or 0) + updated = { + **current, + "state": state, + "version": current_version + 1, + "updatedAt": now.isoformat(), + } + if execution_id: + updated["executionId"] = execution_id + if controller_id: + updated["controllerId"] = controller_id + if result is not None: + updated["result"] = result + statement = ( + update(AppSetting) + .where( + AppSetting.key == _RECOVERY_STATE_SETTING, + AppSetting.updated_at == row.updated_at, + func.coalesce( + AppSetting.value_json["version"].as_integer(), 0 + ) + == current_version, + ) + .values(value_json=updated, updated_at=now) + ) + update_result = session.exec(statement) + if update_result.rowcount == 1: + session.commit() + tracked.clear() + tracked.update(updated) + return updated + session.rollback() + raise RuntimeError("PrivateMuse recovery state changed concurrently") + + @staticmethod + def _require_owner_state( + session: Session, + tracked: dict[str, Any], + *, + expected_state: str, + execution_id: str = "", + ) -> dict[str, Any]: + session.expire_all() + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + current = dict(row.value_json or {}) if row else {} + if ( + current.get("claimId") != tracked.get("claimId") + or current.get("jobId") != tracked.get("jobId") + or current.get("state") != expected_state + or ( + execution_id + and str(current.get("executionId") or "") != execution_id + ) + ): + raise RuntimeError("PrivateMuse recovery ownership or phase changed") + return current + + @staticmethod + def _with_tracking( + result: dict[str, Any], + tracked: dict[str, Any] | None, + execution_id: str, + ) -> dict[str, Any]: + if tracked is None: + return result + return { + **result, + **({"execution_id": execution_id} if execution_id else {}), + "original_job_id": str(tracked.get("jobId") or ""), + } + + def _record_unknown( + self, + *, + reason: str, + execution_id: str, + session: Session | None, + tracked: dict[str, Any] | None, + ) -> dict[str, Any]: + result = self._with_tracking( + { + "status": "outcome_unknown", + "reason": reason, + **({"execution_id": execution_id} if execution_id else {}), + }, + tracked, + execution_id, + ) + if session is not None and tracked is not None: + persisted = self._persist_state( + session, + tracked, + state="outcome_unknown", + execution_id=execution_id, + result=result, + ) + if persisted.get("state") == "completed" and isinstance( + persisted.get("result"), dict + ): + return dict(persisted["result"]) + return result + + def _record_terminal( + self, + result: dict[str, Any], + *, + execution_id: str, + session: Session | None, + tracked: dict[str, Any] | None, + ) -> dict[str, Any]: + result = self._with_tracking(result, tracked, execution_id) + if session is not None and tracked is not None: + persisted = self._persist_state( + session, + tracked, + state="completed", + execution_id=execution_id, + result=result, + ) + if isinstance(persisted.get("result"), dict): + return dict(persisted["result"]) + return result + + @staticmethod + def _exit_result(exit_code: int) -> dict[str, Any]: + if exit_code == 0: + return { + "status": "reconciled", + "repository": _REPOSITORY, + "live_revision": _LIVE_REVISION, + "failed_revision": _FAILED_REVISION, + "containers": list(_CONTAINERS), + "compose_project": "privatemuse", + } + if exit_code == 35: + return {"status": "already-enrolled", "repository": _REPOSITORY} + + reasons = { + 20: "deployment controller executable is unavailable or unsafe", + 21: "deployment projects registry is unavailable or unsafe", + 22: "deployment projects registry cannot be read", + 23: "PrivateMuse deployment target is missing", + 24: "PrivateMuse deployment target differs from the bounded contract", + 25: "mounted PrivateMuse bare repository is unavailable", + 26: "known PrivateMuse recovery revisions cannot be resolved", + 27: "PrivateMuse main changed after the bounded recovery contract was recorded", + 28: "known PrivateMuse live revision is not an ancestor of the failed revision", + 29: "deployment controller verifier cannot be loaded", + 30: "deployment lock files cannot be opened", + 31: "deployment locks cannot be acquired", + 32: "deployment projects registry changed or became unreadable while waiting", + 33: "PrivateMuse deployment target changed while recovery was waiting", + 34: "PrivateMuse deployment state is unavailable or invalid", + 36: ( + "controller could not re-attest the current PrivateMuse runtime " + "against recorded immutable image IDs" + ), + 37: "PrivateMuse receipt identity changed during re-attestation", + 38: ( + "PrivateMuse immutable container/image provenance differs from " + "the recorded receipt" + ), + 39: "PrivateMuse container identity drift differs from the known rollback incident", + 40: "fixed PrivateMuse re-attestation contract was altered", + 41: "PrivateMuse recorded live artifacts differ from the known rollback incident", + 42: "re-attested PrivateMuse deployment state could not be written durably", + 43: "controller rejected the repaired PrivateMuse receipt after write", + 44: "PrivateMuse deployment state differs from the exact recoverable incident", + 45: ( + "controller rejected the repaired PrivateMuse receipt and the " + "original state could not be restored" + ), + } + return { + "status": "validation_failed", + "reason": reasons.get( + exit_code, "PrivateMuse runtime re-attestation failed closed" + ), + } + + def _inspect_tracked_execution( + self, + *, + config: Any, + current_controller_id: str, + session: Session, + tracked: dict[str, Any], + ) -> dict[str, Any]: + execution_id = str(tracked.get("executionId") or "") + original_controller_id = str(tracked.get("controllerId") or "") + if not execution_id: + return self._record_unknown( + reason=( + "PrivateMuse recovery crossed its durable reservation boundary " + "without a recorded execution ID; do not retry" + ), + execution_id="", + session=session, + tracked=tracked, + ) + if original_controller_id != current_controller_id: + return self._record_unknown( + reason=( + "PrivateMuse recovery controller identity changed while the " + "existing execution outcome remained unknown; do not retry" + ), + execution_id=execution_id, + session=session, + tracked=tracked, + ) + endpoint = quote(str(config.endpoint_id), safe="") + safe_exec = quote(execution_id, safe="") + try: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + except (RuntimeError, ValueError, TypeError) as exc: + return self._record_unknown( + reason=( + "PrivateMuse runtime re-attestation outcome remains unknown: " + f"{redact_message(str(exc))}; inspect the existing execution and do not retry" + ), + execution_id=execution_id, + session=session, + tracked=tracked, + ) + if ( + str(inspected.get("ID") or "") != execution_id + or str(inspected.get("ContainerID") or "") != original_controller_id + ): + return self._record_unknown( + reason=( + "PrivateMuse recovery execution identity could not be verified; " + "do not retry" + ), + execution_id=execution_id, + session=session, + tracked=tracked, + ) + if bool(inspected.get("Running")): + return self._record_unknown( + reason=( + "PrivateMuse runtime re-attestation is still running; inspect the " + "existing execution and do not retry" + ), + execution_id=execution_id, + session=session, + tracked=tracked, + ) + if tracked.get("state") == "created": + return self._record_unknown( + reason=( + "PrivateMuse recovery execution was created but its start boundary " + "was not durably confirmed; do not retry" + ), + execution_id=execution_id, + session=session, + tracked=tracked, + ) + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + return self._record_terminal( + self._exit_result(exit_code), + execution_id=execution_id, + session=session, + tracked=tracked, + ) + + def run( + self, + *, + session: Session | None = None, + job_id: str | None = None, + ) -> dict[str, Any]: + preexisting: dict[str, Any] | None = None + tracked: dict[str, Any] | None = None + owns_reservation = False + if session is not None: + if not job_id: + raise ValueError("job_id is required for durable recovery execution") + session.expire_all() + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + state = dict(row.value_json or {}) if row else {} + if state.get("state") == "completed": + stored_result = state.get("result") + if isinstance(stored_result, dict): + return dict(stored_result) + raise RuntimeError( + "PrivateMuse recovery has a terminal guard without a durable result" + ) + if state.get("state") not in _ACTIVE_RECOVERY_STATES: + raise RuntimeError( + "PrivateMuse recovery requires durable job admission before execution" + ) + tracked = state + owns_reservation = ( + state.get("state") == "reserved" and state.get("jobId") == job_id + ) + if state.get("state") == "reserved" and not owns_reservation: + return self._with_tracking( + { + "status": "in_progress", + "reason": ( + "PrivateMuse recovery is durably admitted and awaiting its " + "owning worker; no second execution was started" + ), + }, + tracked, + "", + ) + if not owns_reservation: + preexisting = state + + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + if session is not None and preexisting is not None: + return self._record_unknown( + reason=( + "PrivateMuse recovery outcome remains unknown because the " + "deployment controller is unavailable; do not retry" + ), + execution_id=str(preexisting.get("executionId") or ""), + session=session, + tracked=preexisting, + ) + unavailable = { + "status": "controller_unavailable", + "reason": redact_message( + str(status.get("reason") or status.get("status") or "unavailable") + ), + } + return ( + self._record_terminal( + unavailable, + execution_id="", + session=session, + tracked=tracked, + ) + if tracked is not None + else unavailable + ) + controller = dict(status.get("container") or {}) + if ( + controller.get("name") != _CONTROLLER + or controller.get("state") != "running" + ): + if session is not None and preexisting is not None: + return self._record_unknown( + reason=( + "PrivateMuse recovery outcome remains unknown because the " + "deployment controller is not running; do not retry" + ), + execution_id=str(preexisting.get("executionId") or ""), + session=session, + tracked=preexisting, + ) + unavailable = { + "status": "controller_unavailable", + "reason": "deployment controller is not running", + } + return ( + self._record_terminal( + unavailable, + execution_id="", + session=session, + tracked=tracked, + ) + if tracked is not None + else unavailable + ) + container_id = str(controller.get("id") or "") + if not container_id: + if session is not None and preexisting is not None: + return self._record_unknown( + reason=( + "PrivateMuse recovery outcome remains unknown because the " + "deployment controller identity is unavailable; do not retry" + ), + execution_id=str(preexisting.get("executionId") or ""), + session=session, + tracked=preexisting, + ) + unavailable = { + "status": "controller_unavailable", + "reason": "deployment controller identity is unavailable", + } + return ( + self._record_terminal( + unavailable, + execution_id="", + session=session, + tracked=tracked, + ) + if tracked is not None + else unavailable + ) + config = self.portainer._config() + if not config: + if session is not None and preexisting is not None: + return self._record_unknown( + reason=( + "PrivateMuse recovery outcome remains unknown because " + "Portainer deployment control is unavailable; do not retry" + ), + execution_id=str(preexisting.get("executionId") or ""), + session=session, + tracked=preexisting, + ) + unavailable = { + "status": "connector_unavailable", + "reason": "Portainer deployment control is unavailable", + } + return ( + self._record_terminal( + unavailable, + execution_id="", + session=session, + tracked=tracked, + ) + if tracked is not None + else unavailable + ) + + if session is not None: + if preexisting is not None: + return self._inspect_tracked_execution( + config=config, + current_controller_id=container_id, + session=session, + tracked=tracked, + ) + if tracked is None: + raise RuntimeError("PrivateMuse recovery admission state is unavailable") + tracked = self._persist_state( + session, + tracked, + state="reserved", + controller_id=container_id, + ) + + command = [ + "python3", + "-c", + _REATTEST_SCRIPT, + _REPOSITORY, + _LIVE_REVISION, + _FAILED_REVISION, + _FAILURE_MESSAGE, + ] + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + exec_id = "" + start_requested = False + try: + if session is not None and tracked is not None: + self._require_owner_state( + session, + tracked, + expected_state="reserved", + ) + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return self._record_terminal( + { + "status": "error", + "reason": "PrivateMuse runtime re-attestation could not start", + }, + execution_id="", + session=session, + tracked=tracked, + ) + if session is not None and tracked is not None: + self._persist_state( + session, + tracked, + state="created", + execution_id=exec_id, + ) + safe_exec = quote(exec_id, safe="") + if session is not None and tracked is not None: + self._require_owner_state( + session, + tracked, + expected_state="created", + execution_id=exec_id, + ) + start_requested = True + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + if session is not None and tracked is not None: + self._persist_state( + session, + tracked, + state="started", + execution_id=exec_id, + ) + deadline = time.monotonic() + 300 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json" + ).json() + if ( + str(inspected.get("ID") or "") != exec_id + or str(inspected.get("ContainerID") or "") != container_id + ): + return self._record_unknown( + reason=( + "PrivateMuse recovery execution identity could not be " + "verified; do not retry" + ), + execution_id=exec_id, + session=session, + tracked=tracked, + ) + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(1.0) + except (RuntimeError, ValueError, TypeError) as exc: + if exec_id and start_requested: + return self._record_unknown( + reason=( + "PrivateMuse runtime re-attestation outcome is unknown after " + "execution start; inspect the existing execution and do not retry" + ), + execution_id=exec_id, + session=session, + tracked=tracked, + ) + return self._record_terminal( + {"status": "error", "reason": redact_message(str(exc))}, + execution_id=exec_id, + session=session, + tracked=tracked, + ) + + if exit_code is None: + return self._record_unknown( + reason=( + "PrivateMuse runtime re-attestation is still running; inspect the " + "existing execution and do not retry" + ), + execution_id=exec_id, + session=session, + tracked=tracked, + ) + return self._record_terminal( + self._exit_result(exit_code), + execution_id=exec_id, + session=session, + tracked=tracked, + ) + + +def privatemuse_recovery_job_cancellation_reason( + session: Session, job: JobRun +) -> str | None: + if job.action_id != "deployment.target.register_privatemuse": + return None + row = session.get(AppSetting, _RECOVERY_STATE_SETTING) + state = dict(row.value_json or {}) if row else {} + if ( + state.get("state") not in _ACTIVE_RECOVERY_STATES + or state.get("jobId") != job.id + ): + return None + return ( + "This job owns an unresolved detached PrivateMuse recovery execution; " + "reconcile that execution before changing the job record." + ) diff --git a/backend/app/services/privatemuse_webhook_repair.py b/backend/app/services/privatemuse_webhook_repair.py new file mode 100644 index 0000000..6a30b45 --- /dev/null +++ b/backend/app/services/privatemuse_webhook_repair.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/privatemuse" +_ENDPOINT = "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse" + +# Fixed, one-target repair. The model/operator cannot supply repository, URL, +# secret, token, hook id or command text. Both credentials stay inside the +# existing root-owned deployment-control boundary. +_REPAIR_SCRIPT = r""" +import json +import os +from pathlib import Path +import stat +import subprocess +import sys + +repository, endpoint = sys.argv[1:3] +if repository != "jens/privatemuse" or endpoint != "http://198.19.0.1:1299/v1/gitea/push/jens/privatemuse": + raise SystemExit(40) + +projects_path = Path("/opt/gitea-deploy/projects.json") +secret_root = Path("/opt/gitea-deploy/webhook-secrets") +secret_path = secret_root / "jens__privatemuse" +admin_token_path = Path("/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token") +expected_validation = { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, +} + +def safe_regular(path: Path, *, private: bool = False) -> None: + if path.is_symlink() or not path.is_file(): + raise SystemExit(20) + info = path.stat() + if os.name != "nt" and info.st_uid != os.geteuid(): + raise SystemExit(21) + if private and os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o077: + raise SystemExit(22) + +safe_regular(projects_path) +safe_regular(secret_path, private=True) +safe_regular(admin_token_path, private=True) + +try: + projects = json.loads(projects_path.read_text(encoding="utf-8")) +except Exception: + raise SystemExit(23) +project = projects.get(repository) if isinstance(projects, dict) else None +repository_id = project.get("gitea_repository_id") if isinstance(project, dict) else None +if ( + not isinstance(project, dict) + or project.get("branch") != "main" + or isinstance(repository_id, bool) + or not isinstance(repository_id, int) + or repository_id <= 0 + or project.get("gitea_webhook_secret_file") != "jens__privatemuse" + or project.get("validation") != expected_validation +): + raise SystemExit(24) + +secret = secret_path.read_text(encoding="utf-8").strip() +token = admin_token_path.read_text(encoding="utf-8").strip() +if not 32 <= len(secret) <= 256 or any(ord(ch) < 33 or ord(ch) > 126 for ch in secret): + raise SystemExit(25) +if not 20 <= len(token) <= 512 or any(ch in token for ch in "\r\n"): + raise SystemExit(26) + +# Prove the effective Gitea outbound boundary directly instead of inferring it +# from app.ini syntax. This is stronger than a text parse: the Gitea container +# must actually reach the fixed private controller endpoint and receive the +# expected controller protocol identity. No credential is sent. +health = subprocess.run( + [ + "docker", "exec", "Gitea", + "curl", "--fail", "--silent", "--show-error", "--max-time", "5", + "http://198.19.0.1:1299/health", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, +) +if health.returncode != 0: + raise SystemExit(28) +try: + health_payload = json.loads(health.stdout) +except json.JSONDecodeError: + raise SystemExit(29) +if ( + not isinstance(health_payload, dict) + or health_payload.get("status") != "ok" + or health_payload.get("protocolVersion") != "itworx-controller-request/v2" +): + raise SystemExit(29) + +def request(method: str, path: str, payload=None): + cmd = [ + "docker", "exec", "-i", "Gitea", + "curl", "--fail", "--silent", "--show-error", "--max-time", "15", + "--request", method, + "--header", f"Authorization: token {token}", + "--header", "Accept: application/json", + ] + raw = None + if payload is not None: + cmd.extend(["--header", "Content-Type: application/json", "--data-binary", "@-"]) + raw = json.dumps(payload, separators=(",", ":")) + cmd.append(f"http://127.0.0.1:3000/api/v1{path}") + result = subprocess.run( + cmd, + input=raw, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=20, + check=False, + ) + if result.returncode != 0: + raise SystemExit(30) + if not result.stdout.strip(): + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + raise SystemExit(31) + +repo = request("GET", "/repos/Jens/PrivateMuse") +if ( + not isinstance(repo, dict) + or repo.get("id") != repository_id + or str(repo.get("full_name") or "").lower() != repository + or repo.get("default_branch") != "main" +): + raise SystemExit(32) + +hooks = request("GET", "/repos/Jens/PrivateMuse/hooks") +if not isinstance(hooks, list): + raise SystemExit(33) + +exact = [] +semantic_other = [] +for hook in hooks: + if not isinstance(hook, dict): + continue + hook_config = hook.get("config") + url = str(hook_config.get("url") or "") if isinstance(hook_config, dict) else "" + if url == endpoint: + exact.append(hook) + elif url.endswith("/v1/gitea/push/jens/privatemuse"): + semantic_other.append(hook) +if len(exact) > 1 or semantic_other: + raise SystemExit(34) + +payload = { + "active": True, + "branch_filter": "main", + "events": ["push"], + "config": { + "url": endpoint, + "content_type": "json", + "secret": secret, + "insecure_ssl": "0", + }, +} + +if exact: + hook_id = exact[0].get("id") + if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0: + raise SystemExit(35) + updated = request("PATCH", f"/repos/Jens/PrivateMuse/hooks/{hook_id}", payload) +else: + created = request( + "POST", + "/repos/Jens/PrivateMuse/hooks", + {"type": "gitea", **payload}, + ) + hook_id = created.get("id") if isinstance(created, dict) else None + if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0: + raise SystemExit(36) + updated = created + +if not isinstance(updated, dict): + updated = request("GET", f"/repos/Jens/PrivateMuse/hooks/{hook_id}") +config_out = updated.get("config") if isinstance(updated, dict) else None +events_out = updated.get("events") if isinstance(updated, dict) else None +if ( + not isinstance(updated, dict) + or updated.get("id") != hook_id + or updated.get("active") is not True + or str(updated.get("branch_filter") or "") != "main" + or not isinstance(config_out, dict) + or config_out.get("url") != endpoint + or str(config_out.get("content_type") or "").lower() != "json" + or events_out != ["push"] +): + raise SystemExit(37) + +# Never print the hook payload, token or secret. Hook id is non-sensitive +# evidence that the bounded Gitea object exists. +print(f"privatemuse_runnerless_webhook_ready hook_id={hook_id}", flush=True) +""" + + +class PrivateMuseWebhookRepair: + """Repair only the fixed PrivateMuse runnerless repository webhook.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + controller = ( + dict(status.get("container") or {}) + if status.get("status") == "ok" + else {} + ) + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if ( + controller.get("name") != _CONTROLLER + or controller.get("state") != "running" + or not container_id + or not config + ): + return { + "status": "controller_unavailable", + "reason": "deployment controller is unavailable", + } + + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + command = ["python3", "-c", _REPAIR_SCRIPT, _REPOSITORY, _ENDPOINT] + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return { + "status": "error", + "reason": "PrivateMuse webhook repair could not start", + } + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + deadline = time.monotonic() + 60 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/exec/{safe_exec}/json", + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return { + "status": "outcome_unknown", + "reason": "PrivateMuse webhook repair timed out; inspect before retrying", + } + if exit_code == 0: + return { + "status": "repaired", + "repository": _REPOSITORY, + "endpoint": _ENDPOINT, + "branch": "main", + "event": "push", + } + + reasons = { + 20: "required controller/Gitea file is unavailable or unsafe", + 21: "required repair file is not owned by the controller user", + 22: "required credential file permissions are too broad", + 23: "deployment projects registry is invalid", + 24: "PrivateMuse runnerless target metadata differs from the fixed contract", + 25: "PrivateMuse controller webhook secret is invalid", + 26: "Gitea admin credential is unavailable or invalid", + 27: "reserved legacy Gitea config validation failure", + 28: "Gitea cannot reach the fixed private deployment controller endpoint", + 29: "Private deployment controller health identity is invalid or unavailable", + 30: "bounded Gitea API request failed", + 31: "Gitea returned an invalid JSON response", + 32: "Gitea repository identity or default branch differs from the fixed contract", + 33: "Gitea webhook inventory is invalid", + 34: "PrivateMuse has ambiguous or stale runnerless webhook endpoints", + 35: "existing PrivateMuse runnerless hook id is invalid", + 36: "Gitea did not return a valid hook after creation", + 37: "PrivateMuse runnerless hook verification failed after repair", + 40: "fixed PrivateMuse webhook repair contract was altered", + } + return { + "status": "validation_failed", + "reason": reasons.get( + exit_code, + "PrivateMuse runnerless webhook repair failed closed", + ), + } diff --git a/backend/app/services/repopulse_adoption_journal.py b/backend/app/services/repopulse_adoption_journal.py new file mode 100644 index 0000000..03253e8 --- /dev/null +++ b/backend/app/services/repopulse_adoption_journal.py @@ -0,0 +1,300 @@ +"""Durable AppOps persistence for the one fixed RepoPulse adoption intent. + +This module validates and persists protocol state only. It has no API route, +controller client, product enable flag, or external side effect. A future +adapter must authenticate controller evidence before passing it here. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from sqlalchemy import update +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import SQLAlchemyError +from sqlmodel import Session + +from ..core.time import utc_now +from ..db.models import RepoPulseAdoptionJournal +from . import repopulse_legacy_adoption_protocol as protocol + +_SLOT = "repopulse" + + +class RepoPulseAdoptionJournalError(ValueError): + """Sanitized journal state or persistence error.""" + + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _rollback(session: Session) -> None: + try: + session.rollback() + except SQLAlchemyError: + pass + + +def _raise_protocol_error(session: Session, error: protocol.AdoptionProtocolError) -> None: + _rollback(session) + raise RepoPulseAdoptionJournalError(error.code) from None + + +def _raise_storage_error(session: Session) -> None: + _rollback(session) + raise RepoPulseAdoptionJournalError("journal_storage_unavailable") from None + + +def _load(session: Session) -> tuple[RepoPulseAdoptionJournal | None, dict[str, Any] | None]: + row = session.get(RepoPulseAdoptionJournal, _SLOT, populate_existing=True) + if row is None: + return None, None + + if ( + row.slot != _SLOT + or row.repository != protocol.REPOSITORY + or row.application_id != protocol.APPLICATION_ID + or not isinstance(row.revision, int) + or isinstance(row.revision, bool) + or row.revision < 1 + or not isinstance(row.intent_json, dict) + ): + raise RepoPulseAdoptionJournalError("journal_identity_or_shape_invalid") + + intent = row.intent_json + try: + protocol.intent_binding_sha256(intent) + except protocol.AdoptionProtocolError: + raise RepoPulseAdoptionJournalError("journal_intent_invalid") from None + if ( + row.intent_id != intent.get("intentId") + or row.idempotency_sha256 != intent.get("idempotencySha256") + or row.preflight_sha256 != intent.get("preflightSha256") + or row.phase != intent.get("phase") + or row.status != intent.get("status") + ): + raise RepoPulseAdoptionJournalError("journal_projection_mismatch") + return row, intent + + +def _status( + intent: dict[str, Any] | None, + revision: int = 0, +) -> dict[str, Any]: + recovery = intent.get("recovery") if intent else None + return { + "apiVersion": protocol.PROTOCOL_VERSION, + "repository": protocol.REPOSITORY, + "applicationId": protocol.APPLICATION_ID, + "status": intent["status"] if intent else "unreserved", + "phase": intent["phase"] if intent else None, + "intentId": intent["intentId"] if intent else None, + "revision": revision, + "preflightSha256": intent["preflightSha256"] if intent else None, + "intentBindingSha256": ( + protocol.intent_binding_sha256(intent) if intent else None + ), + "recoveryReason": recovery.get("reason") if isinstance(recovery, dict) else None, + "failureEvidenceSha256": ( + recovery.get("failureEvidenceSha256") if isinstance(recovery, dict) else None + ), + "controllerReconciliationRequired": ( + recovery.get("controllerReconciliationRequired") is True + if isinstance(recovery, dict) + else False + ), + "legacyVolumePreserved": True, + } + + +def get_repopulse_adoption_status(session: Session) -> dict[str, Any]: + """Return fixed identity and safe digests, never stored evidence payloads.""" + try: + row, intent = _load(session) + return _status(intent, row.revision if row else 0) + except RepoPulseAdoptionJournalError: + _rollback(session) + raise + except SQLAlchemyError: + _raise_storage_error(session) + + +def reserve_repopulse_adoption( + session: Session, + preflight_report: object, + idempotency_key: str, +) -> dict[str, Any]: + """Atomically create or replay the sole fixed RepoPulse intent.""" + try: + candidate = protocol.create_intent(preflight_report, idempotency_key) + now = utc_now() + statement = sqlite_insert(RepoPulseAdoptionJournal).values( + slot=_SLOT, + repository=protocol.REPOSITORY, + application_id=protocol.APPLICATION_ID, + intent_id=candidate["intentId"], + idempotency_sha256=candidate["idempotencySha256"], + preflight_sha256=candidate["preflightSha256"], + phase=candidate["phase"], + status=candidate["status"], + revision=1, + intent_json=candidate, + created_at=now, + updated_at=now, + ) + # The insert acquires SQLite's writer reservation before the read. On + # conflict it is a no-op, so concurrent first reservations serialize + # without replacing the persisted intent. + session.execute( + statement.on_conflict_do_nothing( + index_elements=[RepoPulseAdoptionJournal.slot] + ) + ) + persisted_row, persisted_intent = _load(session) + if persisted_row is None or persisted_intent is None: + raise RepoPulseAdoptionJournalError("journal_persistence_failed") + replay = protocol.reserve_or_replay( + persisted_intent, preflight_report, idempotency_key + ) + session.commit() + return _status(replay, persisted_row.revision) + except protocol.AdoptionProtocolError as error: + _raise_protocol_error(session, error) + except RepoPulseAdoptionJournalError: + _rollback(session) + raise + except SQLAlchemyError: + _raise_storage_error(session) + + +def _persist_updated_intent( + session: Session, + row: RepoPulseAdoptionJournal, + current: dict[str, Any], + updated: dict[str, Any], + expected_revision: int, +) -> dict[str, Any]: + if updated == current: + # A protocol-approved replay does not create a new journal revision. + return _status(current, row.revision) + if ( + not isinstance(expected_revision, int) + or isinstance(expected_revision, bool) + or expected_revision < 1 + or expected_revision != row.revision + ): + raise RepoPulseAdoptionJournalError("journal_revision_conflict") + + result = session.execute( + update(RepoPulseAdoptionJournal) + .where( + RepoPulseAdoptionJournal.slot == _SLOT, + RepoPulseAdoptionJournal.revision == expected_revision, + RepoPulseAdoptionJournal.intent_id == current["intentId"], + ) + .values( + intent_id=updated["intentId"], + idempotency_sha256=updated["idempotencySha256"], + preflight_sha256=updated["preflightSha256"], + phase=updated["phase"], + status=updated["status"], + intent_json=updated, + revision=expected_revision + 1, + updated_at=utc_now(), + ) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + raise RepoPulseAdoptionJournalError("journal_revision_conflict") + session.commit() + return _status(updated, expected_revision + 1) + + +def _update_intent( + session: Session, + transform: Callable[[dict[str, Any]], dict[str, Any]], + expected_revision: int, +) -> dict[str, Any]: + try: + row, current = _load(session) + if row is None or current is None: + raise RepoPulseAdoptionJournalError("adoption_intent_not_reserved") + updated = transform(current) + return _persist_updated_intent( + session, row, current, updated, expected_revision + ) + except protocol.AdoptionProtocolError as error: + _raise_protocol_error(session, error) + except RepoPulseAdoptionJournalError: + _rollback(session) + raise + except SQLAlchemyError: + _raise_storage_error(session) + + +def advance_repopulse_adoption( + session: Session, + step: str, + evidence: object, + *, + expected_revision: int, +) -> dict[str, Any]: + """Persist one protocol-validated checkpoint using revision compare-and-swap.""" + return _update_intent( + session, + lambda current: protocol.advance_intent(current, step, evidence), + expected_revision, + ) + + +def confirm_repopulse_controller_intent( + session: Session, + quiescence_evidence: object, + *, + expected_revision: int, +) -> dict[str, Any]: + """Persist controller durability confirmation at the quiescence checkpoint. + + The pure protocol checks exact intent identity and binding digest. This + function does not authenticate or call the controller; a future adapter + must authenticate the supplied controller evidence first. + """ + return advance_repopulse_adoption( + session, + "quiescence", + quiescence_evidence, + expected_revision=expected_revision, + ) + + +def mark_repopulse_adoption_recovery( + session: Session, + reason: str, + failure_evidence: object, + *, + expected_revision: int, +) -> dict[str, Any]: + """Persist only the protocol's failure digest; reject conflicting recovery rewrites.""" + def transform(current: dict[str, Any]) -> dict[str, Any]: + updated = protocol.mark_recovery_required(current, reason, failure_evidence) + if current["status"] == "recovery_required" and updated != current: + raise protocol.AdoptionProtocolError("idempotency_conflict") + return updated + + return _update_intent(session, transform, expected_revision) + + +def resume_repopulse_adoption( + session: Session, + controller_receipt: object, + *, + expected_revision: int, +) -> dict[str, Any]: + """Resume the journal only from a protocol-validated, exactly bound controller receipt.""" + return _update_intent( + session, + lambda current: protocol.resume_intent(current, controller_receipt), + expected_revision, + ) diff --git a/backend/app/services/repopulse_adoption_ownership.py b/backend/app/services/repopulse_adoption_ownership.py new file mode 100644 index 0000000..4ef06e9 --- /dev/null +++ b/backend/app/services/repopulse_adoption_ownership.py @@ -0,0 +1,629 @@ +"""Atomic AppOps ownership commit for the one RepoPulse adoption. + +This is a database-only protocol step. The caller must authenticate the live +controller receipt before supplying the fixed registration evidence. No route, +controller client, secret writer, or generic enrollment orchestration is used. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from sqlalchemy import or_, update +from sqlalchemy.exc import SQLAlchemyError +from sqlmodel import Session, select + +from ..core.time import utc_now +from ..db.models import AppSetting, Enrollment, Policy, RepoPulseAdoptionJournal +from ..schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1 +from . import repopulse_adoption_journal as journal +from . import repopulse_legacy_adoption_protocol as protocol +from .enrollment_profiles import PROFILE_VERSION +from .secret_store import has_secret + +_SLOT = "repopulse" +_TRUST_SETTING_KEY = "deployment_enrollment:trust" +_CONTROLLER_IDENTITY = "unraid-controller" +_TARGET_SECRET_REF = "ENROLL_REPOPULSE_TARGET_HMAC" +_CREATED_BY = "repopulse-legacy-adoption/v1" +_APPLY_POLICY_ID = "enrollment-repopulse-deployment-apply" +_ROLLBACK_POLICY_ID = "enrollment-repopulse-deployment-rollback" +_POLICY_REASON = "Enrollment-generated exact-target deployment policy." + + +class RepoPulseOwnershipError(ValueError): + """Sanitized fixed ownership precondition or persistence error.""" + + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _need(condition: bool, code: str) -> None: + if not condition: + raise RepoPulseOwnershipError(code) + + +def _canonical_plan(plan: EnrollmentPlanV1) -> tuple[dict[str, Any], str]: + payload = plan.model_dump(mode="json") + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return payload, hashlib.sha256(encoded).hexdigest() + + +def _fixed_plan( + session: Session, intent: dict[str, Any] +) -> tuple[EnrollmentPlanV1, dict[str, Any], str]: + preflight = intent["evidence"].get("preflight") + gates = intent["evidence"].get("targetGates") + registration = intent["evidence"].get("controllerRegistration") + _need( + isinstance(preflight, dict) and isinstance(gates, dict), + "adoption_prerequisite_missing", + ) + _need(isinstance(registration, dict), "controller_registration_missing") + + trust_row = session.get(AppSetting, _TRUST_SETTING_KEY) + _need( + trust_row is not None and trust_row.is_secret is False, + "enrollment_trust_unavailable", + ) + try: + trust = EnrollmentTrustConfigV1.model_validate(trust_row.value_json) + except (TypeError, ValueError): + raise RepoPulseOwnershipError("enrollment_trust_invalid") from None + _need( + trust.controller_identity == _CONTROLLER_IDENTITY, + "controller_identity_mismatch", + ) + _need( + "repopulse-authenticated" in trust.autonomous_profiles + and "itworx-mcp-hub-npm" in trust.approved_networks + and "example.com" in trust.allowed_domain_suffixes + and trust.repopulse_proxy_access_list_id is not None, + "repopulse_trust_boundary_mismatch", + ) + + revision = preflight.get("main_revision") + _need( + isinstance(revision, str) and len(revision) == 40, "preflight_revision_invalid" + ) + try: + plan = EnrollmentPlanV1.model_validate( + { + "application_id": protocol.APPLICATION_ID, + "display_name": "RepoPulse", + "environment": "production", + "repository": "Jens/RepoPulse", + "revision": revision, + "profile": "repopulse-authenticated", + "target": { + "target_identity": _CONTROLLER_IDENTITY, + "container_name": "repopulse", + }, + "storage": {"runtime_uid": 10001, "runtime_gid": 10001}, + "network": { + "docker_network": "itworx-mcp-hub-npm", + "internal_port": 8000, + }, + "artifact": { + "release_version": "1.5.0", + "image_repository": "repopulse-managed", + "image_tag": "production", + "health_path": "/health", + }, + "exposure": { + "hostname": "repopulse.example.com", + "upstream_service": "repopulse", + }, + "secrets": {"additional_secret_refs": ["REPOPULSE_GITEA_TOKEN"]}, + } + ) + except (TypeError, ValueError): + raise RepoPulseOwnershipError("fixed_repopulse_plan_invalid") from None + + payload, plan_sha256 = _canonical_plan(plan) + _need( + gates.get("profileId") == "repopulse-authenticated" + and gates.get("profileVersion") == PROFILE_VERSION + and gates.get("planSha256") == plan_sha256 + and registration.get("planSha256") == plan_sha256, + "fixed_repopulse_plan_digest_mismatch", + ) + expected_enrollment_id = f"enr-{intent['intentId'][4:]}" + _need( + registration.get("enrollmentId") == expected_enrollment_id, + "fixed_enrollment_id_mismatch", + ) + return plan, payload, plan_sha256 + + +def _expected_provenance( + intent: dict[str, Any], registration: dict[str, Any], plan_sha256: str +) -> dict[str, Any]: + return { + "createdBy": _CREATED_BY, + "adoptionIntentId": intent["intentId"], + "profileVersion": PROFILE_VERSION, + "policyTemplateVersion": "exact-target/v1", + "sourceRevision": intent["evidence"]["preflight"]["main_revision"], + "planSha256": plan_sha256, + "controllerReceiptSha256": registration["receiptSha256"], + "deploymentReceiptSha256": registration["deploymentReceiptSha256"], + } + + +def _expected_resources(registration: dict[str, Any]) -> dict[str, Any]: + return { + "appdataPath": "/srv/example/appdata/repopulse", + "dataPath": "/srv/example/appdata/repopulse/data", + "network": "itworx-mcp-hub-npm", + "hostname": "repopulse.example.com", + "controllerReceiptSha256": registration["receiptSha256"], + "deploymentReceiptSha256": registration["deploymentReceiptSha256"], + } + + +def _expected_policy_rows( + intent: dict[str, Any], registration: dict[str, Any], plan_sha256: str +) -> tuple[Policy, Policy]: + provenance = _expected_provenance(intent, registration, plan_sha256) + return ( + Policy( + id=_APPLY_POLICY_ID, + action="deployment.apply", + target=protocol.APPLICATION_ID, + voice="blocked", + manual="allowed", + risk="high", + reason=_POLICY_REASON, + provenance_json=provenance, + ), + Policy( + id=_ROLLBACK_POLICY_ID, + action="deployment.rollback", + target=protocol.APPLICATION_ID, + voice="blocked", + manual="allowed", + risk="high", + reason=_POLICY_REASON, + provenance_json=provenance, + ), + ) + + +def _policy_matches(actual: Policy | None, expected: Policy) -> bool: + return bool( + actual is not None + and actual.id == expected.id + and actual.action == expected.action + and actual.target == expected.target + and actual.voice == expected.voice + and actual.manual == expected.manual + and actual.risk == expected.risk + and actual.reason == expected.reason + and actual.provenance_json == expected.provenance_json + ) + + +def _expected_enrollment( + intent: dict[str, Any], + registration: dict[str, Any], + plan: EnrollmentPlanV1, + plan_json: dict[str, Any], + plan_sha256: str, +) -> Enrollment: + return Enrollment( + id=registration["enrollmentId"], + application_id=protocol.APPLICATION_ID, + repository=protocol.REPOSITORY, + revision=plan.revision, + profile="repopulse-authenticated", + controller_identity=_CONTROLLER_IDENTITY, + plan_sha256=plan_sha256, + status="ready", + target_secret_ref=_TARGET_SECRET_REF, + oidc_secret_ref=None, + plan_json=plan_json, + resources_json=_expected_resources(registration), + provenance_json=_expected_provenance(intent, registration, plan_sha256), + ) + + +def _enrollment_matches(actual: Enrollment | None, expected: Enrollment) -> bool: + return bool( + actual is not None + and actual.id == expected.id + and actual.application_id == expected.application_id + and actual.repository == expected.repository + and actual.revision == expected.revision + and actual.profile == expected.profile + and actual.controller_identity == expected.controller_identity + and actual.plan_sha256 == expected.plan_sha256 + and actual.status == expected.status + and actual.target_secret_ref == expected.target_secret_ref + and actual.oidc_secret_ref is None + and actual.plan_json == expected.plan_json + and actual.resources_json == expected.resources_json + and actual.provenance_json == expected.provenance_json + and actual.last_verified_at is not None + and actual.completed_at is not None + ) + + +def _verify_owned_rows( + session: Session, + intent: dict[str, Any], + registration: dict[str, Any], + plan: EnrollmentPlanV1, + plan_json: dict[str, Any], + plan_sha256: str, +) -> tuple[Enrollment, tuple[Policy, Policy]]: + expected_enrollment = _expected_enrollment( + intent, registration, plan, plan_json, plan_sha256 + ) + matching_enrollments = session.exec( + select(Enrollment).where( + or_( + Enrollment.application_id == protocol.APPLICATION_ID, + Enrollment.repository == protocol.REPOSITORY, + ) + ) + ).all() + _need( + len(matching_enrollments) == 1 + and _enrollment_matches(matching_enrollments[0], expected_enrollment), + "ownership_enrollment_readback_mismatch", + ) + expected_policies = _expected_policy_rows(intent, registration, plan_sha256) + _need( + all( + _policy_matches(session.get(Policy, expected.id), expected) + for expected in expected_policies + ), + "ownership_policy_readback_mismatch", + ) + _need( + has_secret(session, _TARGET_SECRET_REF), "target_secret_reference_unavailable" + ) + return matching_enrollments[0], expected_policies + + +def _verify_live_registration( + intent: dict[str, Any], live_registration_evidence: object +) -> dict[str, Any]: + try: + if intent.get("status") == "completed": + checked = protocol.validate_controller_registration_evidence( + intent, live_registration_evidence + ) + replayed_registration = checked + else: + replayed = protocol.advance_intent( + intent, "controller_registration", live_registration_evidence + ) + replayed_registration = replayed["evidence"].get("controllerRegistration") + except protocol.AdoptionProtocolError as error: + raise RepoPulseOwnershipError(error.code) from None + _need( + replayed_registration == intent["evidence"].get("controllerRegistration"), + "idempotency_conflict", + ) + return replayed_registration + + +def _ownership_evidence( + registration: dict[str, Any], plan_sha256: str +) -> dict[str, Any]: + return { + "repository": protocol.REPOSITORY, + "applicationId": protocol.APPLICATION_ID, + "enrollmentId": registration["enrollmentId"], + "profileId": registration["profileId"], + "planSha256": plan_sha256, + "controllerReceiptSha256": registration["receiptSha256"], + "enrollmentPersisted": True, + "exactApplyPolicyPersisted": True, + "exactRollbackPolicyPersisted": True, + "secretReferenceReady": True, + } + + +def _advance_or_verify_ownership( + intent: dict[str, Any], evidence: dict[str, Any] +) -> dict[str, Any]: + try: + if intent.get("status") == "completed": + checked = protocol.validate_appops_ownership_evidence(intent, evidence) + _need( + checked == intent["evidence"].get("appopsOwnership"), + "idempotency_conflict", + ) + return intent + return protocol.advance_intent(intent, "appops_ownership", evidence) + except protocol.AdoptionProtocolError as error: + raise RepoPulseOwnershipError(error.code) from None + + +def _write_journal_update( + session: Session, + row: RepoPulseAdoptionJournal, + current: dict[str, Any], + updated: dict[str, Any], + expected_revision: int, +) -> int: + result = session.execute( + update(RepoPulseAdoptionJournal) + .where( + RepoPulseAdoptionJournal.slot == _SLOT, + RepoPulseAdoptionJournal.revision == expected_revision, + RepoPulseAdoptionJournal.intent_id == current["intentId"], + ) + .values( + idempotency_sha256=updated["idempotencySha256"], + preflight_sha256=updated["preflightSha256"], + phase=updated["phase"], + status=updated["status"], + intent_json=updated, + revision=expected_revision + 1, + updated_at=utc_now(), + ) + .execution_options(synchronize_session=False) + ) + _need(result.rowcount == 1, "journal_revision_conflict") + return expected_revision + 1 + + +def _readback_owned( + session: Session, registration: dict[str, Any] +) -> dict[str, Any] | None: + session.rollback() + session.expire_all() + try: + row, intent = journal._load(session) + if row is None or intent is None: + return None + live = _verify_live_registration(intent, registration) + plan, plan_json, plan_sha256 = _fixed_plan(session, intent) + _verify_owned_rows(session, intent, live, plan, plan_json, plan_sha256) + _advance_or_verify_ownership(intent, _ownership_evidence(live, plan_sha256)) + if intent["phase"] not in {"appops_owned", "completed"}: + return None + if intent["phase"] == "completed": + _assert_completion_binding(intent, live, registration) + return journal.get_repopulse_adoption_status(session) + except ( + RepoPulseOwnershipError, + journal.RepoPulseAdoptionJournalError, + protocol.AdoptionProtocolError, + SQLAlchemyError, + ): + session.rollback() + return None + + +def _record_uncertain_commit( + session: Session, expected_revision: int, receipt_sha256: str +) -> None: + session.rollback() + session.expire_all() + try: + row, current = journal._load(session) + if ( + row is None + or current is None + or row.revision != expected_revision + or current.get("phase") != "target_registered" + or current.get("status") != "in_progress" + ): + return + journal.mark_repopulse_adoption_recovery( + session, + "appops_commit_uncertain", + {"controllerReceiptSha256": receipt_sha256}, + expected_revision=expected_revision, + ) + except ( + RepoPulseOwnershipError, + journal.RepoPulseAdoptionJournalError, + protocol.AdoptionProtocolError, + SQLAlchemyError, + ): + session.rollback() + + +def _assert_completion_binding( + intent: dict[str, Any], + registration: dict[str, Any], + live_registration: dict[str, Any], +) -> None: + stored = intent.get("evidence", {}).get("completion") + _need(isinstance(stored, dict), "completion_evidence_missing") + expected = { + "intentId": intent["intentId"], + "repository": protocol.REPOSITORY, + "applicationId": protocol.APPLICATION_ID, + "enrollmentId": registration["enrollmentId"], + "controllerReceiptSha256": registration["receiptSha256"], + "enrollmentStillPersisted": True, + "exactApplyPolicyStillPersisted": True, + "exactRollbackPolicyStillPersisted": True, + "controllerReceiptStillMatches": ( + live_registration == registration + and live_registration["receiptSha256"] == registration["receiptSha256"] + ), + "legacyVolumeRetained": live_registration["legacyVolumeRetained"], + } + _need(stored == expected, "completion_binding_mismatch") + + +def commit_repopulse_adoption_ownership( + session: Session, + authenticated_controller_registration: object, + *, + expected_revision: int, +) -> dict[str, Any]: + """Atomically install fixed enrollment/policies and advance to `appops_owned`. + + The caller is responsible for authenticating the live controller response. + The protocol validator then binds every field to the persisted fixed intent. + Exact replay reads back the DB rows and validates the fresh receipt again. + """ + try: + row, current = journal._load(session) + _need(row is not None and current is not None, "adoption_intent_not_reserved") + live = _verify_live_registration(current, authenticated_controller_registration) + plan, plan_json, plan_sha256 = _fixed_plan(session, current) + _need( + has_secret(session, _TARGET_SECRET_REF), + "target_secret_reference_unavailable", + ) + + if current["phase"] in {"appops_owned", "completed"}: + _verify_owned_rows(session, current, live, plan, plan_json, plan_sha256) + _advance_or_verify_ownership( + current, _ownership_evidence(live, plan_sha256) + ) + if current["phase"] == "completed": + _assert_completion_binding(current, live, live) + return journal.get_repopulse_adoption_status(session) + + _need( + current["phase"] == "target_registered" + and current["status"] == "in_progress", + "adoption_not_ready_for_ownership", + ) + _need( + isinstance(expected_revision, int) + and not isinstance(expected_revision, bool) + and expected_revision == row.revision, + "journal_revision_conflict", + ) + + conflicting_enrollments = session.exec( + select(Enrollment).where( + or_( + Enrollment.application_id == protocol.APPLICATION_ID, + Enrollment.repository == protocol.REPOSITORY, + ) + ) + ).all() + _need(not conflicting_enrollments, "ownership_resource_conflict") + expected_enrollment = _expected_enrollment( + current, live, plan, plan_json, plan_sha256 + ) + expected_policies = _expected_policy_rows(current, live, plan_sha256) + _need( + all(session.get(Policy, item.id) is None for item in expected_policies), + "ownership_resource_conflict", + ) + + now = utc_now() + expected_enrollment.created_at = now + expected_enrollment.updated_at = now + expected_enrollment.last_verified_at = now + expected_enrollment.completed_at = now + for policy in expected_policies: + session.add(policy) + session.add(expected_enrollment) + session.flush() + + updated = _advance_or_verify_ownership( + current, _ownership_evidence(live, plan_sha256) + ) + next_revision = _write_journal_update( + session, row, current, updated, expected_revision + ) + try: + session.commit() + except (SQLAlchemyError, RuntimeError, OSError): + replay = _readback_owned(session, live) + if replay is not None: + return replay + _record_uncertain_commit(session, expected_revision, live["receiptSha256"]) + raise RepoPulseOwnershipError("appops_commit_uncertain") from None + return journal._status(updated, next_revision) + except RepoPulseOwnershipError: + session.rollback() + raise + except journal.RepoPulseAdoptionJournalError as error: + session.rollback() + raise RepoPulseOwnershipError(error.code) from None + except protocol.AdoptionProtocolError as error: + session.rollback() + raise RepoPulseOwnershipError(error.code) from None + except SQLAlchemyError: + session.rollback() + raise RepoPulseOwnershipError("ownership_storage_unavailable") from None + + +def complete_repopulse_adoption( + session: Session, + authenticated_controller_registration: object, + *, + expected_revision: int, +) -> dict[str, Any]: + """Persist AppOps completion after ownership, with fresh receipt readback. + + Controller authentication and its completed adoption/deployment receipt + verification remain caller responsibilities. This step independently + rechecks the receipt binding and exact owned rows on every replay. + """ + try: + row, current = journal._load(session) + _need(row is not None and current is not None, "adoption_intent_not_reserved") + live = _verify_live_registration(current, authenticated_controller_registration) + plan, plan_json, plan_sha256 = _fixed_plan(session, current) + _verify_owned_rows(session, current, live, plan, plan_json, plan_sha256) + _advance_or_verify_ownership(current, _ownership_evidence(live, plan_sha256)) + _need( + (current["phase"] == "appops_owned" and current["status"] == "in_progress") + or (current["phase"] == "completed" and current["status"] == "completed"), + "adoption_not_ready_to_complete", + ) + completion_evidence = { + "intentId": current["intentId"], + "repository": protocol.REPOSITORY, + "applicationId": protocol.APPLICATION_ID, + "enrollmentId": live["enrollmentId"], + "controllerReceiptSha256": live["receiptSha256"], + "enrollmentStillPersisted": True, + "exactApplyPolicyStillPersisted": True, + "exactRollbackPolicyStillPersisted": True, + "controllerReceiptStillMatches": True, + "legacyVolumeRetained": live["legacyVolumeRetained"], + } + if current["phase"] == "completed": + _assert_completion_binding(current, live, live) + _need( + current["evidence"].get("completion") == completion_evidence, + "completion_binding_mismatch", + ) + return journal.get_repopulse_adoption_status(session) + + _need( + isinstance(expected_revision, int) + and not isinstance(expected_revision, bool) + and expected_revision == row.revision, + "journal_revision_conflict", + ) + try: + updated = protocol.complete_intent(current, completion_evidence) + except protocol.AdoptionProtocolError as error: + raise RepoPulseOwnershipError(error.code) from None + return journal._persist_updated_intent( + session, row, current, updated, expected_revision + ) + except RepoPulseOwnershipError: + session.rollback() + raise + except journal.RepoPulseAdoptionJournalError as error: + session.rollback() + raise RepoPulseOwnershipError(error.code) from None + except protocol.AdoptionProtocolError as error: + session.rollback() + raise RepoPulseOwnershipError(error.code) from None + except SQLAlchemyError: + session.rollback() + raise RepoPulseOwnershipError("ownership_storage_unavailable") from None diff --git a/backend/app/services/repopulse_adoption_preflight.py b/backend/app/services/repopulse_adoption_preflight.py new file mode 100644 index 0000000..07528da --- /dev/null +++ b/backend/app/services/repopulse_adoption_preflight.py @@ -0,0 +1,735 @@ +"""Read-only, fixed-identity assessment of the existing RepoPulse deployment. + +This module is deliberately separate from generic enrollment and contains no +adoption, registry-write, or container-mutation path. Its collector runs only +fixed read operations in the existing deployment controller; its evaluator is +pure so sanitized observations can be covered without a live Unraid host. +""" + +from __future__ import annotations + +import json +import re +import sys +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/repopulse" +_CONTAINER = "repopulse" +_VOLUME = "repopulse_data" +_SOURCE_DIR = "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse" +_APPDATA_ROOT = "/srv/example/appdata/repopulse" +_EXPECTED_TARGET = { + "branch": "main", + "slug": "repopulse", + "source_dir": _SOURCE_DIR, + "exact_source": True, + "env_source": f"{_APPDATA_ROOT}/deploy.env", + "compose_files": ["docker-compose.yml"], + "compose_project": "repopulse", + "containers": [_CONTAINER], + "verify_oci_revision": False, + "health_timeout": 180, + "environment_keys": [], +} +_EXPECTED_REGISTRY_KEYS = { + "branch", + "slug", + "source_dir", + "exact_source", + "env_source", + "compose_files", + "compose_project", + "containers", + "verify_oci_revision", + "health_timeout", + "gitea_repository_id", + "gitea_webhook_secret_file", + "validation", +} +_EXPECTED_VALIDATION = { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, +} +_SHA = re.compile(r"^[0-9a-f]{40}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_PREFLIGHT_SCRIPT = r''' +import http.client +import hashlib +import json +import os +import re +import stat +import subprocess +from pathlib import Path + +REPOSITORY = "jens/repopulse" +CONTAINER = "repopulse" +VOLUME = "repopulse_data" +APPDATA_ROOT = Path("/srv/example/appdata/repopulse") +SCAN_ROOT = Path("/mnt/cache/appdata/gitea-deploy-runners/sources") +REGISTRY = Path("/opt/gitea-deploy/projects.json") +HOST_REGISTRY = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +STATE = Path("/mnt/cache/appdata/gitea-deploy-runners/state/repopulse.json") +BARE_REPO = Path("/gitea-repositories/jens/repopulse.git") +SHA = re.compile(r"^[0-9a-f]{40}$") + +def read_json(path): + try: + if path.is_symlink() or not path.is_file() or path.stat().st_size > 4 * 1024 * 1024: + return None + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + +def run_fixed(argv, timeout=10): + try: + result = subprocess.run( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=timeout, + check=False, + env={ + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "HOME": "/root", + "GIT_OPTIONAL_LOCKS": "0", + "GIT_TERMINAL_PROMPT": "0", + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_CONFIG_GLOBAL": "/dev/null", + }, + ) + return result.returncode, result.stdout.strip() + except Exception: + return -1, "" + +def inspect_json(kind, name): + code, output = run_fixed([ + "docker", "--host", "unix:///var/run/docker.sock", "inspect", "--type", kind, name + ], timeout=12) + if code != 0: + return None + try: + value = json.loads(output) + return value[0] if isinstance(value, list) and len(value) == 1 and isinstance(value[0], dict) else None + except Exception: + return None + +def mode_allows(path, *, uid, gid, required_bits): + """Conservative POSIX mode check for a fixed non-root runtime identity.""" + try: + info = path.lstat() + if path.is_symlink() or not info or not stat.S_ISDIR(info.st_mode): + return False + mode = stat.S_IMODE(info.st_mode) + if info.st_uid == uid: + bits = (mode >> 6) & 0o7 + elif info.st_gid == gid: + bits = (mode >> 3) & 0o7 + else: + bits = mode & 0o7 + return bits & required_bits == required_bits + except Exception: + return False + +def directory_readable_as(path, *, uid, gid): + """Check traversal of every parent and read/traverse rights at the leaf.""" + absolute = path.absolute() + chain = list(reversed(absolute.parents)) + [absolute] + for item in chain[:-1]: + if not mode_allows(item, uid=uid, gid=gid, required_bits=0o1): + return False + return mode_allows(chain[-1], uid=uid, gid=gid, required_bits=0o5) + +def repository_facts(): + projects = read_json(REGISTRY) + entry = projects.get(REPOSITORY) if isinstance(projects, dict) else None + registry = {"present": isinstance(projects, dict) and REPOSITORY in projects} + try: + registry["alias_matches"] = ( + not REGISTRY.is_symlink() + and not HOST_REGISTRY.is_symlink() + and REGISTRY.is_file() + and HOST_REGISTRY.is_file() + and os.path.samefile(REGISTRY, HOST_REGISTRY) + ) + except Exception: + registry["alias_matches"] = False + if isinstance(entry, dict): + registry["entry_type"] = "object" + registry["keys"] = sorted(str(key) for key in entry.keys()) + registry["target"] = { + key: entry.get(key) + for key in ( + "branch", "slug", "source_dir", "exact_source", "env_source", + "compose_files", "compose_project", "containers", + "verify_oci_revision", "health_timeout", + ) + } + environment = entry.get("environment") + registry["environment_keys"] = ( + sorted(str(key) for key in environment) + if isinstance(environment, dict) + else [] if "environment" not in entry else None + ) + validation = entry.get("validation") + registry["validation"] = validation if isinstance(validation, dict) else None + registry["repository_id"] = entry.get("gitea_repository_id") + registry["webhook_secret_file_matches"] = entry.get("gitea_webhook_secret_file") == "jens__repopulse" + else: + registry["entry_type"] = "missing" if entry is None else type(entry).__name__ + + state = read_json(STATE) + if isinstance(state, dict): + state = { + key: state.get(key) + for key in ( + "schema_version", "repository", "revision", "live_revision", + "live_status", "live_legacy_provenance", "status", + ) + } + else: + state = None + + live_revision = state.get("live_revision") if isinstance(state, dict) else None + git = {"bare_repo_present": BARE_REPO.is_dir() and not BARE_REPO.is_symlink()} + main_code, main_sha = run_fixed(["git", f"--git-dir={BARE_REPO}", "rev-parse", "refs/heads/main"]) + git["main_revision"] = main_sha if main_code == 0 and SHA.fullmatch(main_sha) else None + git["live_revision_resolves"] = False + git["live_is_ancestor"] = False + git["compose_present_on_main"] = False + if isinstance(live_revision, str) and SHA.fullmatch(live_revision) and git["main_revision"]: + resolve_code, resolved = run_fixed([ + "git", f"--git-dir={BARE_REPO}", "rev-parse", f"{live_revision}^{{commit}}" + ]) + git["live_revision_resolves"] = resolve_code == 0 and resolved == live_revision + if git["live_revision_resolves"]: + ancestor_code, _ = run_fixed([ + "git", f"--git-dir={BARE_REPO}", "merge-base", "--is-ancestor", + live_revision, git["main_revision"], + ]) + git["live_is_ancestor"] = ancestor_code == 0 + compose_code, _ = run_fixed([ + "git", f"--git-dir={BARE_REPO}", "cat-file", "-e", + f"{git['main_revision']}:docker-compose.yml", + ]) + git["compose_present_on_main"] = compose_code == 0 + git["live_revision"] = live_revision if isinstance(live_revision, str) and SHA.fullmatch(live_revision) else None + + container = inspect_json("container", CONTAINER) + runtime = None + if isinstance(container, dict): + config = container.get("Config") if isinstance(container.get("Config"), dict) else {} + host_config = container.get("HostConfig") if isinstance(container.get("HostConfig"), dict) else {} + state_payload = container.get("State") if isinstance(container.get("State"), dict) else {} + labels = config.get("Labels") if isinstance(config.get("Labels"), dict) else {} + mounts = [] + for mount in container.get("Mounts", []): + if isinstance(mount, dict) and mount.get("Destination") in ("/data", "/scan"): + mounts.append({ + "destination": mount.get("Destination"), + "type": mount.get("Type"), + "name": mount.get("Name"), + "source": mount.get("Source"), + "read_write": mount.get("RW"), + }) + envs = config.get("Env") if isinstance(config.get("Env"), list) else [] + operator_token_configured = any( + isinstance(value, str) and value.startswith("REPOPULSE_OPERATOR_TOKEN=") + and bool(value.split("=", 1)[1]) + for value in envs + ) + bindings = host_config.get("PortBindings") if isinstance(host_config.get("PortBindings"), dict) else {} + host_ports = [] + for _container_port, endpoints in bindings.items(): + if not isinstance(endpoints, list): + continue + for endpoint in endpoints: + if isinstance(endpoint, dict): + ip, port = endpoint.get("HostIp"), endpoint.get("HostPort") + if isinstance(port, str) and port.isdigit(): + host_ports.append({"ip": (ip or "0.0.0.0")[:64] if isinstance(ip, str) else "unknown", "port": int(port)}) + security_options = host_config.get("SecurityOpt") if isinstance(host_config.get("SecurityOpt"), list) else [] + runtime = { + "name_matches": container.get("Name") == "/repopulse", + "compose_project_matches": labels.get("com.docker.compose.project") == "repopulse", + "container_id_sha256": ( + hashlib.sha256(container["Id"].encode("ascii")).hexdigest() + if isinstance(container.get("Id"), str) + and re.fullmatch(r"[0-9a-f]{64}", container["Id"]) + else None + ), + "image_sha256": ( + hashlib.sha256(config["Image"].encode("utf-8")).hexdigest() + if isinstance(config.get("Image"), str) and len(config["Image"]) <= 160 + else None + ), + "state": state_payload.get("Status") if state_payload.get("Status") in ("running", "exited", "created", "restarting", "paused", "dead") else "unknown", + "health": (state_payload.get("Health") or {}).get("Status") if isinstance(state_payload.get("Health"), dict) else "missing", + "image": config.get("Image") if isinstance(config.get("Image"), str) and len(config.get("Image")) <= 160 else None, + "network_mode": host_config.get("NetworkMode") if isinstance(host_config.get("NetworkMode"), str) and len(host_config.get("NetworkMode")) <= 160 else "unknown", + "mounts": mounts, + "read_only_root": host_config.get("ReadonlyRootfs") is True, + "cap_drop_all": "ALL" in (host_config.get("CapDrop") if isinstance(host_config.get("CapDrop"), list) else []), + "no_new_privileges": any(isinstance(value, str) and value.casefold() == "no-new-privileges:true" for value in security_options), + "host_ports": sorted(host_ports, key=lambda item: (item["ip"], item["port"])), + "operator_token_configured": operator_token_configured, + } + + volume = inspect_json("volume", VOLUME) + volume_facts = None + if isinstance(volume, dict): + volume_facts = { + "name_matches": volume.get("Name") == VOLUME, + "mountpoint_matches": volume.get("Mountpoint") == "/var/lib/docker/volumes/repopulse_data/_data", + } + + try: + root_ok = APPDATA_ROOT.is_dir() and not APPDATA_ROOT.is_symlink() + unexpected_entry_count = 0 + if root_ok: + # At most four names are needed to check the expected appdata + # entries without materializing a large directory listing. + with os.scandir(APPDATA_ROOT) as entries: + for index, item in enumerate(entries): + if item.name not in {"deploy.env", "deploy.managed.env", "data", "sources"}: + unexpected_entry_count = 1 + break + if index >= 3: + unexpected_entry_count = 1 + break + env_path = APPDATA_ROOT / "deploy.env" + managed_env_path = APPDATA_ROOT / "deploy.managed.env" + data_path = APPDATA_ROOT / "data" + sources_path = APPDATA_ROOT / "sources" + sources_exists = sources_path.exists() or sources_path.is_symlink() + appdata = { + "root_is_safe_directory": root_ok, + "deploy_env_is_regular_file": env_path.is_file() and not env_path.is_symlink(), + "managed_env_exists": managed_env_path.exists() or managed_env_path.is_symlink(), + "managed_env_is_regular_file": managed_env_path.is_file() and not managed_env_path.is_symlink(), + "managed_env_is_private": ( + managed_env_path.is_file() + and not managed_env_path.is_symlink() + and ( + os.name == "nt" + or ( + managed_env_path.stat().st_uid == os.geteuid() + and stat.S_IMODE(managed_env_path.stat().st_mode) & 0o077 == 0 + ) + ) + ), + "target_data_exists": data_path.exists() or data_path.is_symlink(), + "target_data_is_symlink": data_path.is_symlink(), + "sources_exists": sources_exists, + "sources_is_safe_directory": not sources_exists or (sources_path.is_dir() and not sources_path.is_symlink()), + "unexpected_entry_count": unexpected_entry_count, + } + except Exception: + appdata = None + + scan_access = { + "runtime_uid": 10001, + "runtime_gid": 10001, + "root_readable": directory_readable_as(SCAN_ROOT, uid=10001, gid=10001), + "repopulse_source_readable": directory_readable_as(SCAN_ROOT / "repopulse", uid=10001, gid=10001), + "privatemuse_source_readable": directory_readable_as(SCAN_ROOT / "privatemuse", uid=10001, gid=10001), + } + + health = None + meta = None + try: + connection = http.client.HTTPConnection("127.0.0.1", 1254, timeout=5) + connection.request("GET", "/health", headers={"Connection": "close"}) + response = connection.getresponse() + body = response.read(8192) + health_payload = json.loads(body.decode("utf-8")) if response.status == 200 else None + health = { + "http_ok": response.status == 200, + "status": health_payload.get("status") if isinstance(health_payload, dict) and health_payload.get("status") in ("ok", "degraded") else "unknown", + } + connection.close() + + connection = http.client.HTTPConnection("127.0.0.1", 1254, timeout=5) + connection.request("GET", "/api/v1/meta", headers={"Connection": "close"}) + response = connection.getresponse() + body = response.read(65536) + payload = json.loads(body.decode("utf-8")) if response.status == 200 else None + connection.close() + if isinstance(payload, dict): + def safe_int(value): + return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None + repo_states = payload.get("repository_states") if isinstance(payload.get("repository_states"), dict) else {} + storage = payload.get("storage") if isinstance(payload.get("storage"), dict) else {} + recovery = payload.get("database_recovery") if isinstance(payload.get("database_recovery"), dict) else {} + meta = { + "http_ok": response.status == 200, + "product_matches": payload.get("name") == "RepoPulse", + "repository_count": safe_int(payload.get("repository_count")), + "repository_states": {key: safe_int(repo_states.get(key)) for key in ("current", "stale", "queued", "scanning", "failed", "never_scanned", "archived", "unavailable")}, + "queue": {key: safe_int(payload.get(name)) for key, name in (("queued", "queue_depth"), ("active", "active_jobs"), ("completed", "completed_jobs"), ("failed", "failed_jobs"))}, + "storage": {key: safe_int(storage.get(key)) for key in ("database_bytes", "journal_bytes", "scan_count", "compacted_scan_count", "mirror_count", "mirror_cache_bytes", "worktree_count")}, + "database_recovery_status": recovery.get("status") if recovery.get("status") in ("healthy", "acknowledged", "degraded_recovered", "unknown") else "unknown", + "mutation_authentication": payload.get("mutation_authentication") if payload.get("mutation_authentication") in ("operator_token", "perimeter_required") else "unknown", + } + except Exception: + pass + + safe = { + "registry": registry, + "state": state, + "git": git, + "runtime": runtime, + "volume": volume_facts, + "appdata": appdata, + "scan_access": scan_access, + "health": health, + "meta": meta, + } + print(json.dumps(safe, sort_keys=True, separators=(",", ":"))) + +repository_facts() +''' + + +def _check(code: str, passed: bool | None, detail: str) -> dict[str, str]: + status = "passed" if passed is True else "blocked" if passed is False else "unknown" + return {"code": code, "status": status, "detail": detail} + + +def _unavailable_report(code: str, detail: str) -> dict[str, Any]: + check = _check(code, False, detail) + check["gate"] = "legacy_compatibility" + return { + "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", + "repository": _REPOSITORY, + "status": "unavailable", + "legacyCompatibility": {"status": "unavailable", "checks": [check]}, + "targetAcceptance": { + "status": "not_assessed", + "checks": [], + "externalGates": ["Verify the exact authenticated NPM HTTPS route and access list separately."], + }, + "readOnly": True, + "adoptionAuthorized": False, + "checks": [check], + "evidence": {}, + } + + +def _is_nonnegative_int(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _matches_string_set(value: object, expected: set[str]) -> bool: + return ( + isinstance(value, list) + and all(isinstance(item, str) for item in value) + and len(value) == len(expected) + and set(value) == expected + ) + + +def _same_json(left: object, right: object) -> bool: + try: + return json.dumps(left, sort_keys=True, separators=(",", ":"), allow_nan=False) == json.dumps( + right, sort_keys=True, separators=(",", ":"), allow_nan=False + ) + except (TypeError, ValueError): + return False + + +def evaluate_facts(facts: object) -> dict[str, Any]: + """Evaluate a sanitized snapshot; never trusts caller-provided paths or commands.""" + if not isinstance(facts, dict): + facts = {} + legacy_checks: list[dict[str, str]] = [] + acceptance_checks: list[dict[str, str]] = [] + + def add_check( + target: list[dict[str, str]], gate: str, code: str, passed: bool | None, detail: str + ) -> None: + result = _check(code, passed, detail) + result["gate"] = gate + target.append(result) + + registry = facts.get("registry") if isinstance(facts.get("registry"), dict) else {} + registry_target = registry.get("target") if isinstance(registry.get("target"), dict) else {} + expected_registry_target = { + key: value for key, value in _EXPECTED_TARGET.items() if key != "environment_keys" + } + registry_matches = ( + registry.get("present") is True + and registry.get("alias_matches") is True + and registry.get("entry_type") == "object" + and _matches_string_set(registry.get("keys"), _EXPECTED_REGISTRY_KEYS) + and _same_json(registry_target, expected_registry_target) + and registry.get("environment_keys") == [] + and _same_json(registry.get("validation"), _EXPECTED_VALIDATION) + and registry.get("repository_id") == 64 + and registry.get("webhook_secret_file_matches") is True + ) + add_check(legacy_checks, "legacy_compatibility", "legacy_registry", registry_matches, "The controller registry must match the fixed jens/repopulse target and runnerless validation policy.") + + state = facts.get("state") if isinstance(facts.get("state"), dict) else {} + live_revision = state.get("live_revision") + state_matches = ( + state.get("schema_version") == 3 + and state.get("repository") == _REPOSITORY + and isinstance(state.get("revision"), str) + and state.get("revision") == live_revision + and isinstance(live_revision, str) + and _SHA.fullmatch(live_revision) is not None + and state.get("live_status") == "healthy" + and state.get("status") == "success" + and state.get("live_legacy_provenance") is False + ) + add_check(legacy_checks, "legacy_compatibility", "controller_state", state_matches, "The current controller state must be a healthy schema v3 success for the fixed repository.") + + git = facts.get("git") if isinstance(facts.get("git"), dict) else {} + main_revision = git.get("main_revision") + git_matches = ( + git.get("bare_repo_present") is True + and isinstance(main_revision, str) + and _SHA.fullmatch(main_revision) is not None + and git.get("live_revision") == live_revision + and git.get("live_revision_resolves") is True + and git.get("live_is_ancestor") is True + and git.get("compose_present_on_main") is True + ) + add_check(legacy_checks, "legacy_compatibility", "gitea_ancestry", git_matches, "The recorded live SHA must resolve in Gitea, be an ancestor of the current main SHA, and main must contain the required Compose file.") + + runtime = facts.get("runtime") if isinstance(facts.get("runtime"), dict) else {} + runtime_identity = _check( + "runtime_identity_and_health", + runtime.get("name_matches") is True + and runtime.get("compose_project_matches") is True + and isinstance(runtime.get("container_id_sha256"), str) + and _SHA256.fullmatch(runtime["container_id_sha256"]) is not None + and isinstance(runtime.get("image_sha256"), str) + and _SHA256.fullmatch(runtime["image_sha256"]) is not None + and isinstance(runtime.get("image"), str) + and runtime.get("image").split("@", 1)[0].split(":", 1)[0] == "repopulse-repopulse" + and runtime.get("state") == "running" + and runtime.get("health") == "healthy", + "The exact repopulse container must be running and healthy under the repopulse Compose project.", + ) + runtime_identity["gate"] = "legacy_compatibility" + legacy_checks.append(runtime_identity) + mounts = runtime.get("mounts") if isinstance(runtime.get("mounts"), list) else [] + data_mount = next((mount for mount in mounts if isinstance(mount, dict) and mount.get("destination") == "/data"), {}) + scan_mount = next((mount for mount in mounts if isinstance(mount, dict) and mount.get("destination") == "/scan"), {}) + volume = facts.get("volume") if isinstance(facts.get("volume"), dict) else {} + storage_matches = ( + data_mount.get("type") == "volume" + and data_mount.get("name") == _VOLUME + and data_mount.get("read_write") is True + and scan_mount.get("type") == "bind" + and scan_mount.get("source") == _SOURCE_DIR.replace("/repopulse", "") + and scan_mount.get("read_write") is False + and volume.get("name_matches") is True + and volume.get("mountpoint_matches") is True + ) + add_check(legacy_checks, "legacy_compatibility", "legacy_storage_topology", storage_matches, "The legacy data volume and read-only scan source must match the audited RepoPulse mounts.") + + safe_runtime = ( + runtime.get("read_only_root") is True + and runtime.get("cap_drop_all") is True + and runtime.get("no_new_privileges") is True + ) + add_check(acceptance_checks, "target_acceptance", "runtime_hardening", safe_runtime, "Managed cutover requires a read-only root filesystem, all capabilities dropped, and no-new-privileges; current legacy settings are reported without failing compatibility.") + + host_ports = runtime.get("host_ports") if isinstance(runtime.get("host_ports"), list) else [] + add_check(acceptance_checks, "target_acceptance", "host_exposure", len(host_ports) == 0, "Managed cutover must remove the legacy published host port; current bindings are included in the evidence.") + add_check(acceptance_checks, "target_acceptance", "managed_network", isinstance(runtime.get("network_mode"), str) and runtime.get("network_mode") not in ("host", "unknown"), "Managed cutover requires an inspected private network instead of host networking.") + + appdata = facts.get("appdata") if isinstance(facts.get("appdata"), dict) else {} + sources_safe = ( + isinstance(appdata.get("sources_exists"), bool) + and appdata.get("sources_is_safe_directory") is True + ) + managed_env_exists = appdata.get("managed_env_exists") + managed_env_is_regular_file = appdata.get("managed_env_is_regular_file") + managed_env_is_private = appdata.get("managed_env_is_private") + managed_env_safe = ( + isinstance(managed_env_exists, bool) + and isinstance(managed_env_is_regular_file, bool) + and isinstance(managed_env_is_private, bool) + and ( + ( + managed_env_exists is False + and managed_env_is_regular_file is False + and managed_env_is_private is False + ) + or ( + managed_env_exists is True + and managed_env_is_regular_file is True + and managed_env_is_private is True + ) + ) + ) + legacy_appdata_clean = ( + appdata.get("root_is_safe_directory") is True + and appdata.get("deploy_env_is_regular_file") is True + and sources_safe + and managed_env_safe + and _is_nonnegative_int(appdata.get("unexpected_entry_count")) + and appdata.get("unexpected_entry_count") == 0 + ) + add_check(legacy_checks, "legacy_compatibility", "legacy_appdata", legacy_appdata_clean, "The legacy appdata root must retain its regular deploy.env and contain no unexpected entries; optional sources and a private managed environment file are allowed.") + data_target_clear = appdata.get("target_data_exists") is False and appdata.get("target_data_is_symlink") is False + add_check(acceptance_checks, "target_acceptance", "managed_data_target", data_target_clear, "The managed appdata/data target must be absent before the reviewed storage migration; an existing target is reported as a collision.") + add_check( + acceptance_checks, + "target_acceptance", + "managed_environment_file", + managed_env_exists is True and managed_env_is_regular_file is True and managed_env_is_private is True, + "The fixed deploy.managed.env target file must be a private regular non-symlink file; no environment values are read or returned.", + ) + + scan_access = facts.get("scan_access") if isinstance(facts.get("scan_access"), dict) else {} + scan_access_ready = ( + scan_access.get("runtime_uid") == 10001 + and scan_access.get("runtime_gid") == 10001 + and scan_access.get("root_readable") is True + and scan_access.get("repopulse_source_readable") is True + and scan_access.get("privatemuse_source_readable") is True + ) + add_check( + acceptance_checks, + "target_acceptance", + "scan_access", + scan_access_ready, + "The fixed RepoPulse and PrivateMuse scan directories must be readable and traversable by runtime UID/GID 10001, with the permission policy retained after atomic source synchronization.", + ) + + health = facts.get("health") if isinstance(facts.get("health"), dict) else {} + meta = facts.get("meta") if isinstance(facts.get("meta"), dict) else {} + health_matches = health.get("http_ok") is True and health.get("status") == "ok" and meta.get("http_ok") is True and meta.get("product_matches") is True + add_check(legacy_checks, "legacy_compatibility", "product_api", health_matches, "The loopback product health and metadata endpoints must return healthy RepoPulse facts.") + + recovery_status = meta.get("database_recovery_status") + add_check(legacy_checks, "legacy_compatibility", "database_health", recovery_status in ("healthy", "acknowledged"), "The live product must report a healthy or acknowledged database recovery state.") + queue = meta.get("queue") if isinstance(meta.get("queue"), dict) else {} + queue_facts_present = all(_is_nonnegative_int(queue.get(key)) for key in ("queued", "active", "completed", "failed")) + add_check(legacy_checks, "legacy_compatibility", "queue_quiescence", queue_facts_present and queue.get("queued") == 0 and queue.get("active") == 0, "Queue and active-job counts must be available and both zero for this legacy compatibility snapshot.") + storage = meta.get("storage") if isinstance(meta.get("storage"), dict) else {} + db_facts_present = all(_is_nonnegative_int(storage.get(key)) for key in ("database_bytes", "journal_bytes", "scan_count", "compacted_scan_count")) + add_check(legacy_checks, "legacy_compatibility", "database_baseline", db_facts_present and storage.get("database_bytes", 0) > 0 and storage.get("scan_count", -1) >= storage.get("compacted_scan_count", 0), "The live product must provide safe aggregate SQLite size and scan-history counts.") + + auth_mode = meta.get("mutation_authentication") + operator_token_configured = auth_mode == "operator_token" + add_check(acceptance_checks, "target_acceptance", "mutation_boundary", operator_token_configured, "Managed mutation access requires an operator token or a separately verified authenticated NPM perimeter; this preflight does not inspect NPM.") + + legacy_status = "passed" if legacy_checks and all(item["status"] == "passed" for item in legacy_checks) else "blocked" + acceptance_status = "blocked" if any(item["status"] != "passed" for item in acceptance_checks) else "external_verification_required" + status = "preflight_passed" if legacy_status == "passed" else "blocked" + evidence = { + "main_revision": main_revision if isinstance(main_revision, str) and _SHA.fullmatch(main_revision) else None, + "live_revision": live_revision if isinstance(live_revision, str) and _SHA.fullmatch(live_revision) else None, + "container_state": runtime.get("state") if runtime.get("state") in ("running", "exited", "created", "restarting", "paused", "dead", "unknown") else "unknown", + "container_health": runtime.get("health") if runtime.get("health") in ("healthy", "unhealthy", "starting", "missing", "unknown") else "unknown", + "container_id_sha256": runtime.get("container_id_sha256") if isinstance(runtime.get("container_id_sha256"), str) and _SHA256.fullmatch(runtime["container_id_sha256"]) else None, + "container_image_sha256": runtime.get("image_sha256") if isinstance(runtime.get("image_sha256"), str) and _SHA256.fullmatch(runtime["image_sha256"]) else None, + "host_ports": [item for item in host_ports if isinstance(item, dict) and isinstance(item.get("port"), int) and not isinstance(item.get("port"), bool) and isinstance(item.get("ip"), str)][:16], + "database": { + key: storage.get(key) for key in ("database_bytes", "journal_bytes", "scan_count", "compacted_scan_count", "mirror_count", "mirror_cache_bytes", "worktree_count") if _is_nonnegative_int(storage.get(key)) + }, + "queue": {key: queue.get(key) for key in ("queued", "active", "completed", "failed") if _is_nonnegative_int(queue.get(key))}, + "appdata_target_exists": appdata.get("target_data_exists") if isinstance(appdata.get("target_data_exists"), bool) else None, + "appdata_sources_exists": appdata.get("sources_exists") if isinstance(appdata.get("sources_exists"), bool) else None, + "appdata_sources_safe_directory": appdata.get("sources_is_safe_directory") if isinstance(appdata.get("sources_is_safe_directory"), bool) else None, + "managed_environment_file_ready": managed_env_exists is True and managed_env_is_regular_file is True and managed_env_is_private is True, + "runtime_hardening": { + key: runtime.get(key) if isinstance(runtime.get(key), bool) else None + for key in ("read_only_root", "cap_drop_all", "no_new_privileges") + }, + "network_mode": runtime.get("network_mode") if isinstance(runtime.get("network_mode"), str) else "unknown", + "operator_token_configured": operator_token_configured, + "scan_access_uid_10001": scan_access_ready, + } + checks = legacy_checks + acceptance_checks + return { + "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", + "repository": _REPOSITORY, + "status": status, + "legacyCompatibility": {"status": legacy_status, "checks": legacy_checks}, + "targetAcceptance": { + "status": acceptance_status, + "checks": acceptance_checks, + "externalGates": ["Verify the exact authenticated NPM HTTPS route and access list separately."], + }, + "readOnly": True, + "adoptionAuthorized": False, + "checks": checks, + "evidence": evidence, + } + + +class RepoPulseAdoptionPreflight: + """Collect fixed live facts through the controller and evaluate read-only.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self) -> dict[str, Any]: + status = self.portainer.get_container_status(_CONTROLLER) + if status.get("status") != "ok": + return _unavailable_report("controller", "The fixed deployment controller is unavailable.") + controller = status.get("container") if isinstance(status.get("container"), dict) else {} + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return _unavailable_report("controller", "The fixed deployment controller is not running.") + controller_id = str(controller.get("id") or "") + config = self.portainer._config() + if not controller_id or not config: + return _unavailable_report("controller", "Portainer deployment control is unavailable.") + + endpoint = quote(str(config.endpoint_id), safe="") + safe_container = quote(controller_id, safe="") + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": True, + "AttachStderr": False, + "Tty": True, + "Cmd": ["python3", "-c", _PREFLIGHT_SCRIPT], + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + raise ValueError("exec unavailable") + raw_response = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{quote(exec_id, safe='')}/start", + json={"Detach": False, "Tty": True}, + timeout=60.0, + ) + raw = (raw_response.text or "").strip() + if not raw or len(raw) > 65536: + raise ValueError("preflight output unavailable") + facts = json.loads(raw) + except (RuntimeError, ValueError, TypeError, json.JSONDecodeError): + return _unavailable_report("collector", "The fixed read-only collector failed.") + return evaluate_facts(facts) + + +def main() -> int: + """CLI entry point for an operator-run, read-only preflight report.""" + result = RepoPulseAdoptionPreflight().run() + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 if result.get("status") == "preflight_passed" else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/services/repopulse_adoption_runner.py b/backend/app/services/repopulse_adoption_runner.py new file mode 100644 index 0000000..b8dee9e --- /dev/null +++ b/backend/app/services/repopulse_adoption_runner.py @@ -0,0 +1,1277 @@ +"""Fixed operator-only coordinator for the one RepoPulse adoption. + +AppOps prepares and reserves the fixed intent, then finalizes only evidence +that has been collected by the separately operated root controller. This +module never asks the controller to execute a cutover and exposes no API +route, Portainer operation, caller-selected path, or arbitrary command. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from typing import Any +from urllib.parse import urlparse + +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import SQLAlchemyError +from sqlmodel import Session + +from ..core.time import utc_now +from ..db.models import AppSetting +from ..db.session import engine +from ..schemas.enrollment import EnrollmentTrustConfigV1 +from . import ( + repopulse_adoption_journal as journal, +) +from . import ( + repopulse_adoption_ownership as ownership, +) +from . import ( + repopulse_legacy_adoption_protocol as protocol, +) +from . import ( + secret_store, +) +from .deployment_broker import DeploymentBrokerClient +from .enrollment_plane import TRUST_SETTING_KEY +from .repopulse_adoption_preflight import RepoPulseAdoptionPreflight +from .repopulse_target_credential import ( + RepoPulseTargetCredentialError, + get_or_create_repopulse_target_credential, +) +from .setup_service import apply_stored_connector_settings + +_HANDOFF_KEY = "repopulse_adoption:controller_handoff" +_BOOTSTRAP_SECRET_MINIMUM = 32 +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_CAPABILITIES_VERSION = "itworx.repopulse-adoption-capabilities/v1" +_RESERVE_VERSION = "itworx.repopulse-adoption-reserve/v1" +_STATUS_VERSION = "itworx.repopulse-adoption-status/v1" +_EXECUTION_RESULT_VERSION = "itworx.repopulse-controller-adoption-execution-result/v1" +_REGISTRY_RECEIPT_VERSION = "itworx.repopulse-controller-adoption-store-receipt/v1" +_COMPLETION_VERSION = "itworx.repopulse-controller-adoption-completion/v1" +_LEGACY_VOLUME = "repopulse_data" +_MAX_CONTROLLER_RESULT_BYTES = 256 * 1024 +_RECOVERY_STATUS_PHASES = { + "reserved", + "target_gates_ready", + "quiesced", + "snapshot_verified", +} +_COPY_KEYS = { + "intentId", + "snapshotFileSha256", + "targetLogicalSha256", + "targetScanCount", + "targetCompactedScanCount", + "targetQueueSha256", + "sourceAuxiliaryTreeSha256", + "targetAuxiliaryTreeSha256", + "sourceAuxiliaryFileCount", + "targetAuxiliaryFileCount", + "targetQuickCheck", + "targetIntegrityCheck", + "targetDataUid", + "targetDataGid", + "targetRecoveryStatus", + "managedDataMountMatches", + "copySourceIsVerifiedSnapshot", + "legacyVolumeRetained", + "legacyEnvironmentRetained", + "managedEnvironmentFilePrivate", +} +_DEPLOYMENT_RECEIPT_KEYS = { + "repository", + "revision", + "containerRevision", + "containerIdSha256", + "deploymentReceiptSchema", + "deploymentReceiptSha256", + "deploymentReceiptVerified", + "runtimeHealthy", + "publishedHostPorts", + "managedDataMountMatches", + "targetDataUid", + "targetDataGid", + "targetLogicalSha256", + "targetScanCount", + "targetCompactedScanCount", + "targetQueueSha256", + "targetAuxiliaryTreeSha256", + "targetAuxiliaryFileCount", + "targetQuickCheck", + "targetIntegrityCheck", + "targetRecoveryStatus", + "targetQueuedJobs", + "targetActiveJobs", + "legacyVolume", + "legacyVolumeRetained", + "legacyEnvironmentRetained", + "managedEnvironmentFilePrivate", + "scanSourceMatches", + "scanMountReadOnly", + "scanPermissionPolicyPersistent", + "scanAccessForRuntimeIdentityReady", + "containerName", + "containerImageSha256", + "snapshotFileSha256", +} +_REGISTRY_RECEIPT_KEYS = { + "apiVersion", + "intentId", + "repository", + "applicationId", + "phase", + "intentBindingSha256", + "legacyVolumeRetained", + "intentDurable", + "managedEnrollmentId", + "projectsSha256", + "snapshotFileSha256", + "receiptSha256", +} +_COMPLETION_KEYS = { + "apiVersion", + "intentId", + "intentBindingSha256", + "controllerRegistrationReceiptSha256", + "mainRevision", + "deploymentReceiptSha256", + "snapshotFileSha256", + "snapshotLogicalSha256", + "snapshotQueueSha256", + "auxiliaryTreeSha256", + "containerIdSha256", + "containerImageSha256", + "legacyVolume", + "legacyVolumeRetained", + "completionSha256", +} +_RESULT_KEYS = { + "apiVersion", + "repository", + "applicationId", + "intentId", + "status", + "phase", + "legacyVolume", + "legacyVolumeRetained", + "targetGates", + "exactShaValidation", + "quiescence", + "snapshot", + "copy", + "registryReceipt", + "deploymentReceipt", + "completion", +} +_STATUS_HASH_KEYS = { + "intentSha256", + "intentBindingSha256", + "idempotencySha256", + "preflightSha256", + "expectedProjectsSha256", + "quiescenceSha256", + "planSha256", + "snapshotFileSha256", + "snapshotLogicalSha256", + "snapshotQueueSha256", + "targetProjectsSha256", + "receiptSha256", +} + + +class RepoPulseAdoptionRunnerError(ValueError): + """Sanitized fixed-runner failure.""" + + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _need(condition: bool, code: str) -> None: + if not condition: + raise RepoPulseAdoptionRunnerError(code) + + +def _sha(value: object) -> bool: + return isinstance(value, str) and _SHA256.fullmatch(value) is not None + + +def _count(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _canonical_sha(value: object) -> str: + try: + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError): + raise RepoPulseAdoptionRunnerError("controller_evidence_encoding_invalid") from None + return hashlib.sha256(encoded).hexdigest() + + +def _controller_document_sha(value: object) -> str: + try: + encoded = ( + json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + + "\n" + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError): + raise RepoPulseAdoptionRunnerError("controller_evidence_encoding_invalid") from None + return hashlib.sha256(encoded).hexdigest() + + +def _safe_error_code(value: object, fallback: str) -> str: + code = getattr(value, "code", None) + if isinstance(code, str) and re.fullmatch(r"[a-z0-9_]{1,64}", code): + return code + return fallback + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_json_key") + result[key] = value + return result + + +def _reject_json_constant(_: str) -> None: + raise ValueError("non_finite_json_number") + + +def _read_controller_result_stdin() -> object: + stream = getattr(sys.stdin, "buffer", None) + if stream is None: + raw = sys.stdin.read(_MAX_CONTROLLER_RESULT_BYTES + 1).encode("utf-8") + else: + raw = stream.read(_MAX_CONTROLLER_RESULT_BYTES + 1) + _need( + isinstance(raw, bytes) and 0 < len(raw) <= _MAX_CONTROLLER_RESULT_BYTES, + "controller_result_input_invalid", + ) + try: + return json.loads( + raw.decode("utf-8"), + object_pairs_hook=_unique_json_object, + parse_constant=_reject_json_constant, + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError): + raise RepoPulseAdoptionRunnerError("controller_result_input_invalid") from None + + +def _safe_status(status: dict[str, Any], *, reason: str | None = None) -> dict[str, Any]: + result = { + key: status.get(key) + for key in ("status", "phase", "revision", "intentId") + if key in status + } + if reason is not None: + result["reason"] = reason + elif status.get("status") == "recovery_required" and isinstance( + status.get("recoveryReason"), str + ): + result["reason"] = status["recoveryReason"] + return result + + +def _trust_and_bootstrap(session: Session) -> tuple[EnrollmentTrustConfigV1, str]: + row = session.get(AppSetting, TRUST_SETTING_KEY) + _need(row is not None and row.is_secret is False, "enrollment_trust_unavailable") + try: + trust = EnrollmentTrustConfigV1.model_validate(row.value_json) + except (TypeError, ValueError): + raise RepoPulseAdoptionRunnerError("enrollment_trust_invalid") from None + _need( + trust.controller_identity == "unraid-controller" + and "repopulse-authenticated" in trust.autonomous_profiles + and "itworx-mcp-hub-npm" in trust.approved_networks + and "example.com" in trust.allowed_domain_suffixes + and trust.repopulse_proxy_access_list_id is not None, + "repopulse_trust_boundary_mismatch", + ) + value = secret_store.get_secret(session, trust.bootstrap_secret_ref, touch=False) + _need( + isinstance(value, str) + and len(value) >= _BOOTSTRAP_SECRET_MINIMUM + and value.isascii() + and value.isprintable(), + "controller_bootstrap_secret_unavailable", + ) + return trust, value + + +def _validate_transport(broker: DeploymentBrokerClient) -> None: + parsed = urlparse(str(getattr(broker, "base_url", ""))) + _need( + parsed.scheme == "https" + and bool(parsed.netloc) + and getattr(broker, "verify_tls", False) is True + and broker.configuration_error() is None, + "controller_private_tls_required", + ) + + +def _validate_capabilities(value: object) -> None: + expected = { + "apiVersion", + "status", + "repository", + "adoptionStore", + "privateTls", + "controller", + } + _need(isinstance(value, dict) and set(value) == expected, "controller_capabilities_invalid") + _need( + value.get("apiVersion") == _CAPABILITIES_VERSION + and value.get("status") == "ready" + and value.get("repository") == protocol.REPOSITORY + and value.get("adoptionStore") == {"executionEnabled": True} + and value.get("privateTls") == {"status": "ready"} + and value.get("controller") + == {"aliases": "ready", "registry": "ready", "secretRoot": "ready"}, + "controller_capabilities_not_ready", + ) + + +def _validate_reservation(value: object, intent: dict[str, Any]) -> str: + expected = { + "apiVersion", + "status", + "intentId", + "phase", + "intentSha256", + "intentBindingSha256", + } + _need(isinstance(value, dict) and set(value) == expected, "controller_reservation_invalid") + binding = protocol.intent_binding_sha256(intent) + _need( + value.get("apiVersion") == _RESERVE_VERSION + and value.get("status") == "reserved" + and value.get("intentId") == intent["intentId"] + and value.get("phase") == "reserved" + and _sha(value.get("intentSha256")) + and value.get("intentBindingSha256") == binding, + "controller_reservation_unbound", + ) + return value["intentSha256"] + + +def _read_handoff(session: Session) -> dict[str, Any] | None: + row = session.get(AppSetting, _HANDOFF_KEY, populate_existing=True) + if row is None: + return None + expected = { + "intentId", + "idempotencySha256", + "preflightSha256", + "intentBindingSha256", + "controllerIntentSha256", + "state", + } + value = row.value_json + if ( + row.is_secret is not False + or not isinstance(value, dict) + or set(value) != expected + or value.get("state") + not in { + "reserve_attempted", + "handoff_prepared", + "controller_cli_handoff_issued", + } + or not isinstance(value.get("intentId"), str) + or not _sha(value.get("idempotencySha256")) + or not _sha(value.get("preflightSha256")) + or not _sha(value.get("intentBindingSha256")) + or ( + value.get("state") == "reserve_attempted" + and value.get("controllerIntentSha256") is not None + ) + or ( + value.get("state") in {"handoff_prepared", "controller_cli_handoff_issued"} + and not _sha(value.get("controllerIntentSha256")) + ) + ): + raise RepoPulseAdoptionRunnerError("controller_handoff_marker_invalid") + return value + + +def _handoff_record( + intent: dict[str, Any], state: str, controller_intent_sha256: str | None +) -> dict[str, Any]: + return { + "intentId": intent["intentId"], + "idempotencySha256": intent["idempotencySha256"], + "preflightSha256": intent["preflightSha256"], + "intentBindingSha256": protocol.intent_binding_sha256(intent), + "controllerIntentSha256": controller_intent_sha256, + "state": state, + } + + +def _persist_reserve_attempt(session: Session, intent: dict[str, Any]) -> dict[str, Any]: + """Persist the one fixed reserve attempt before its external POST.""" + value = _handoff_record(intent, "reserve_attempted", None) + statement = sqlite_insert(AppSetting).values( + key=_HANDOFF_KEY, + value_json=value, + is_secret=False, + updated_at=utc_now(), + ) + try: + session.execute(statement.on_conflict_do_nothing(index_elements=[AppSetting.key])) + session.commit() + existing = _read_handoff(session) + except SQLAlchemyError: + session.rollback() + raise RepoPulseAdoptionRunnerError("controller_handoff_marker_unavailable") from None + _need( + existing is not None + and _handoff_matches(existing, intent) + and existing.get("state") + in {"reserve_attempted", "handoff_prepared", "controller_cli_handoff_issued"}, + "controller_handoff_marker_conflict", + ) + return existing + + +def _persist_handoff( + session: Session, intent: dict[str, Any], controller_intent_sha256: str +) -> dict[str, Any]: + value = _handoff_record(intent, "handoff_prepared", controller_intent_sha256) + try: + current = _read_handoff(session) + if current is None: + session.execute( + sqlite_insert(AppSetting) + .values( + key=_HANDOFF_KEY, + value_json=value, + is_secret=False, + updated_at=utc_now(), + ) + .on_conflict_do_nothing(index_elements=[AppSetting.key]) + ) + elif _handoff_matches(current, intent): + _need( + current.get("state") in {"reserve_attempted", "controller_cli_handoff_issued"} + or current.get("controllerIntentSha256") == controller_intent_sha256, + "controller_handoff_marker_conflict", + ) + if current.get("state") == "controller_cli_handoff_issued": + _need( + current.get("controllerIntentSha256") == controller_intent_sha256, + "controller_handoff_marker_conflict", + ) + value = current + else: + session.execute( + AppSetting.__table__.update() + .where(AppSetting.key == _HANDOFF_KEY) + .values(value_json=value, is_secret=False, updated_at=utc_now()) + ) + else: + raise RepoPulseAdoptionRunnerError("controller_handoff_marker_conflict") + session.commit() + existing = _read_handoff(session) + except SQLAlchemyError: + session.rollback() + raise RepoPulseAdoptionRunnerError("controller_handoff_marker_unavailable") from None + _need(existing == value, "controller_handoff_marker_conflict") + return value + + +def _persist_cli_handoff_issued( + session: Session, intent: dict[str, Any], controller_intent_sha256: str +) -> dict[str, Any]: + """Consume the one secret-bearing pipe handoff before returning it.""" + current = _read_handoff(session) + _need( + current is not None + and _handoff_matches(current, intent) + and current.get("state") == "handoff_prepared" + and current.get("controllerIntentSha256") == controller_intent_sha256, + "controller_cli_handoff_replay_requires_recovery", + ) + value = _handoff_record( + intent, "controller_cli_handoff_issued", controller_intent_sha256 + ) + try: + result = session.execute( + AppSetting.__table__.update() + .where( + AppSetting.key == _HANDOFF_KEY, + AppSetting.value_json == current, + ) + .values(value_json=value, is_secret=False, updated_at=utc_now()) + ) + _need(result.rowcount == 1, "controller_cli_handoff_replay_requires_recovery") + session.commit() + existing = _read_handoff(session) + except SQLAlchemyError: + session.rollback() + raise RepoPulseAdoptionRunnerError("controller_cli_handoff_marker_unavailable") from None + _need(existing == value, "controller_cli_handoff_marker_conflict") + return value + + +def _status_projection(value: object) -> dict[str, Any]: + expected = {"apiVersion", "status", "phase", "hashes"} + _need(isinstance(value, dict) and set(value) == expected, "controller_status_invalid") + hashes = value.get("hashes") + _need( + value.get("apiVersion") == _STATUS_VERSION + and isinstance(value.get("status"), str) + and value.get("status") in {"in_progress", "recovery_required", "not_reserved"} + and (value.get("phase") is None or value.get("phase") in _RECOVERY_STATUS_PHASES) + and isinstance(hashes, dict) + and set(hashes).issubset(_STATUS_HASH_KEYS) + and all(item is None or _sha(item) for item in hashes.values()), + "controller_status_invalid", + ) + if value["status"] == "not_reserved": + _need(value.get("phase") is None and hashes == {}, "controller_status_invalid") + else: + _need(value.get("phase") in _RECOVERY_STATUS_PHASES, "controller_status_invalid") + required = { + "intentSha256", + "intentBindingSha256", + "idempotencySha256", + "preflightSha256", + "expectedProjectsSha256", + } + _need(required.issubset(hashes), "controller_status_invalid") + return value + + +def _status_failure_evidence(value: object) -> dict[str, Any]: + try: + checked = _status_projection(value) + except RepoPulseAdoptionRunnerError: + return {"controllerStatus": "unavailable"} + return { + "controllerStatus": checked["status"], + "controllerPhase": checked["phase"], + "statusHashes": checked["hashes"], + } + + +def _validate_registry_receipt(value: object, intent: dict[str, Any]) -> dict[str, Any]: + _need(isinstance(value, dict) and set(value) == _REGISTRY_RECEIPT_KEYS, "controller_registry_receipt_invalid") + receipt = dict(value) + digest = receipt.pop("receiptSha256") + _need( + receipt.get("apiVersion") == _REGISTRY_RECEIPT_VERSION + and receipt.get("intentId") == intent["intentId"] + and receipt.get("repository") == protocol.REPOSITORY + and receipt.get("applicationId") == protocol.APPLICATION_ID + and receipt.get("phase") == "registry_committed" + and receipt.get("intentBindingSha256") == protocol.intent_binding_sha256(intent) + and receipt.get("legacyVolumeRetained") is True + and receipt.get("intentDurable") is True + and receipt.get("managedEnrollmentId") == f"enr-{intent['intentId'][4:]}" + and _sha(receipt.get("projectsSha256")) + and _sha(receipt.get("snapshotFileSha256")) + and _sha(digest) + and digest == _controller_document_sha(receipt), + "controller_registry_receipt_invalid", + ) + receipt["receiptSha256"] = digest + return receipt + + +def _validate_copy(value: object, snapshot: dict[str, Any], intent_id: str) -> dict[str, Any]: + _need(isinstance(value, dict) and set(value) == _COPY_KEYS, "controller_copy_evidence_invalid") + evidence = dict(value) + _need( + evidence.get("intentId") == intent_id + and evidence.get("snapshotFileSha256") == snapshot["snapshotFileSha256"] + and evidence.get("targetLogicalSha256") == snapshot["snapshotLogicalSha256"] + and _count(evidence.get("targetScanCount")) + and evidence.get("targetScanCount") == snapshot["snapshotScanCount"] + and _count(evidence.get("targetCompactedScanCount")) + and evidence.get("targetCompactedScanCount") == snapshot["snapshotCompactedScanCount"] + and evidence.get("targetQueueSha256") == snapshot["snapshotQueueSha256"] + and evidence.get("sourceAuxiliaryTreeSha256") == snapshot["sourceAuxiliaryTreeSha256"] + and evidence.get("targetAuxiliaryTreeSha256") == snapshot["sourceAuxiliaryTreeSha256"] + and _count(evidence.get("sourceAuxiliaryFileCount")) + and evidence.get("sourceAuxiliaryFileCount") == snapshot["sourceAuxiliaryFileCount"] + and _count(evidence.get("targetAuxiliaryFileCount")) + and evidence.get("targetAuxiliaryFileCount") == snapshot["sourceAuxiliaryFileCount"] + and evidence.get("targetQuickCheck") == "ok" + and evidence.get("targetIntegrityCheck") == "ok" + and evidence.get("targetDataUid") == 10001 + and evidence.get("targetDataGid") == 10001 + and evidence.get("targetRecoveryStatus") in {"healthy", "acknowledged"} + and evidence.get("managedDataMountMatches") is True + and evidence.get("copySourceIsVerifiedSnapshot") is True + and evidence.get("legacyVolumeRetained") is True + and evidence.get("legacyEnvironmentRetained") is True + and evidence.get("managedEnvironmentFilePrivate") is True, + "controller_copy_evidence_unverified", + ) + return evidence + + +def _validate_deployment_receipt( + value: object, intent: dict[str, Any], snapshot: dict[str, Any], copied: dict[str, Any] +) -> dict[str, Any]: + _need(isinstance(value, dict) and set(value) == _DEPLOYMENT_RECEIPT_KEYS, "controller_deployment_receipt_invalid") + receipt = dict(value) + revision = intent["evidence"]["preflight"]["main_revision"] + _need( + receipt.get("repository") == protocol.REPOSITORY + and receipt.get("revision") == revision + and receipt.get("containerRevision") == revision + and _sha(receipt.get("containerIdSha256")) + and receipt.get("deploymentReceiptSchema") == "gitea-deploy-receipt.v1" + and _sha(receipt.get("deploymentReceiptSha256")) + and receipt.get("deploymentReceiptVerified") is True + and receipt.get("runtimeHealthy") is True + and receipt.get("publishedHostPorts") == [] + and receipt.get("managedDataMountMatches") is True + and receipt.get("targetDataUid") == copied["targetDataUid"] + and receipt.get("targetDataGid") == copied["targetDataGid"] + and receipt.get("targetLogicalSha256") == copied["targetLogicalSha256"] + and receipt.get("targetScanCount") == copied["targetScanCount"] + and receipt.get("targetCompactedScanCount") == copied["targetCompactedScanCount"] + and receipt.get("targetQueueSha256") == copied["targetQueueSha256"] + and receipt.get("targetAuxiliaryTreeSha256") == copied["targetAuxiliaryTreeSha256"] + and receipt.get("targetAuxiliaryFileCount") == copied["targetAuxiliaryFileCount"] + and receipt.get("targetQuickCheck") == copied["targetQuickCheck"] + and receipt.get("targetIntegrityCheck") == copied["targetIntegrityCheck"] + and receipt.get("targetRecoveryStatus") == copied["targetRecoveryStatus"] + and receipt.get("targetQueuedJobs") == 0 + and receipt.get("targetActiveJobs") == 0 + and receipt.get("legacyVolume") == _LEGACY_VOLUME + and receipt.get("legacyVolumeRetained") is True + and receipt.get("legacyEnvironmentRetained") is True + and receipt.get("managedEnvironmentFilePrivate") is True + and receipt.get("scanSourceMatches") is True + and receipt.get("scanMountReadOnly") is True + and receipt.get("scanPermissionPolicyPersistent") is True + and receipt.get("scanAccessForRuntimeIdentityReady") is True + and receipt.get("containerName") == "repopulse" + and _sha(receipt.get("containerImageSha256")) + and receipt.get("snapshotFileSha256") == snapshot["snapshotFileSha256"], + "controller_deployment_receipt_unverified", + ) + for key in ("targetScanCount", "targetCompactedScanCount", "targetAuxiliaryFileCount", "targetQueuedJobs", "targetActiveJobs"): + _need(_count(receipt.get(key)), "controller_deployment_receipt_invalid") + return receipt + + +def _validate_completion( + value: object, + intent: dict[str, Any], + snapshot: dict[str, Any], + copied: dict[str, Any], + registry: dict[str, Any], + deployment: dict[str, Any], +) -> dict[str, Any]: + _need(isinstance(value, dict) and set(value) == _COMPLETION_KEYS, "controller_completion_invalid") + completion = dict(value) + digest = completion.pop("completionSha256") + _need( + completion.get("apiVersion") == _COMPLETION_VERSION + and completion.get("intentId") == intent["intentId"] + and completion.get("intentBindingSha256") == protocol.intent_binding_sha256(intent) + and completion.get("controllerRegistrationReceiptSha256") == registry["receiptSha256"] + and completion.get("mainRevision") == intent["evidence"]["preflight"]["main_revision"] + and completion.get("deploymentReceiptSha256") == deployment["deploymentReceiptSha256"] + and completion.get("snapshotFileSha256") == snapshot["snapshotFileSha256"] + and completion.get("snapshotLogicalSha256") == snapshot["snapshotLogicalSha256"] + and completion.get("snapshotQueueSha256") == snapshot["snapshotQueueSha256"] + and completion.get("auxiliaryTreeSha256") == copied["targetAuxiliaryTreeSha256"] + and completion.get("containerIdSha256") == deployment["containerIdSha256"] + and completion.get("containerImageSha256") == deployment["containerImageSha256"] + and completion.get("legacyVolume") == _LEGACY_VOLUME + and completion.get("legacyVolumeRetained") is True + and _sha(digest) + and digest == _canonical_sha(completion), + "controller_completion_binding_invalid", + ) + completion["completionSha256"] = digest + return completion + + +def _map_controller_result( + value: object, intent: dict[str, Any] +) -> tuple[dict[str, dict[str, Any]], str, str]: + _need(isinstance(value, dict) and set(value) == _RESULT_KEYS, "controller_result_envelope_invalid") + result = value + _need( + result.get("apiVersion") == _EXECUTION_RESULT_VERSION + and result.get("repository") == protocol.REPOSITORY + and result.get("applicationId") == protocol.APPLICATION_ID + and result.get("intentId") == intent["intentId"] + and result.get("status") == "deployment_verified" + and result.get("phase") == "completion_verified" + and result.get("legacyVolume") == _LEGACY_VOLUME + and result.get("legacyVolumeRetained") is True, + "controller_result_identity_invalid", + ) + gates = result.get("targetGates") + quiescence = result.get("quiescence") + snapshot = result.get("snapshot") + _need(isinstance(gates, dict), "controller_target_gates_invalid") + _need(isinstance(quiescence, dict), "controller_quiescence_invalid") + _need(isinstance(snapshot, dict), "controller_snapshot_invalid") + + validation = result.get("exactShaValidation") + _need( + isinstance(validation, dict) + and set(validation) == {"repository", "revision", "contexts"} + and validation.get("repository") == protocol.REPOSITORY + and validation.get("revision") == intent["evidence"]["preflight"]["main_revision"] + and validation.get("contexts") == {"Managed validation / full (push)": "success"}, + "controller_exact_sha_validation_invalid", + ) + + checked_registry = _validate_registry_receipt(result.get("registryReceipt"), intent) + _need( + checked_registry["snapshotFileSha256"] == snapshot.get("snapshotFileSha256"), + "controller_registry_snapshot_mismatch", + ) + copied = _validate_copy(result.get("copy"), snapshot, intent["intentId"]) + deployment = _validate_deployment_receipt( + result.get("deploymentReceipt"), intent, snapshot, copied + ) + _need( + copied["managedDataMountMatches"] is True + and deployment["managedDataMountMatches"] is True, + "controller_data_mount_mismatch", + ) + completion = _validate_completion( + result.get("completion"), + intent, + snapshot, + copied, + checked_registry, + deployment, + ) + + registration = { + "receiptVersion": protocol.CONTROLLER_RECEIPT_VERSION, + "intentId": intent["intentId"], + # The completion receipt binds the registry, copied data, deployment, + # and live container proof as one immutable controller result. + "receiptSha256": completion["completionSha256"], + "repository": protocol.REPOSITORY, + "applicationId": protocol.APPLICATION_ID, + "enrollmentId": checked_registry["managedEnrollmentId"], + "profileId": gates.get("profileId"), + "planSha256": gates.get("planSha256"), + "snapshotFileSha256": snapshot.get("snapshotFileSha256"), + "targetLogicalSha256": copied["targetLogicalSha256"], + "targetScanCount": copied["targetScanCount"], + "targetCompactedScanCount": copied["targetCompactedScanCount"], + "targetQueueSha256": copied["targetQueueSha256"], + "targetAuxiliaryTreeSha256": copied["targetAuxiliaryTreeSha256"], + "targetAuxiliaryFileCount": copied["targetAuxiliaryFileCount"], + "targetQueuedJobs": deployment["targetQueuedJobs"], + "targetActiveJobs": deployment["targetActiveJobs"], + "targetQuickCheck": copied["targetQuickCheck"], + "targetIntegrityCheck": copied["targetIntegrityCheck"], + "targetRecoveryStatus": copied["targetRecoveryStatus"], + "targetDataUid": copied["targetDataUid"], + "targetDataGid": copied["targetDataGid"], + "managedDataMountMatches": deployment["managedDataMountMatches"], + "copySourceIsVerifiedSnapshot": copied["copySourceIsVerifiedSnapshot"], + "registryTargetMatches": ( + checked_registry["managedEnrollmentId"] == f"enr-{intent['intentId'][4:]}" + and checked_registry["snapshotFileSha256"] == snapshot["snapshotFileSha256"] + ), + "controllerStateReceiptVerified": checked_registry["receiptSha256"] + == completion["controllerRegistrationReceiptSha256"], + "deploymentReceiptSchema": deployment["deploymentReceiptSchema"], + "deploymentReceiptSha256": deployment["deploymentReceiptSha256"], + "deploymentReceiptVerified": deployment["deploymentReceiptVerified"], + "runtimeHealthy": deployment["runtimeHealthy"], + "publishedHostPorts": deployment["publishedHostPorts"], + "globalDeployLockHeld": quiescence.get("globalDeployLockHeld"), + "projectLockHeld": quiescence.get("projectLockHeld"), + "legacyVolume": deployment["legacyVolume"], + "legacyVolumeRetained": deployment["legacyVolumeRetained"], + "legacyEnvironmentRetained": deployment["legacyEnvironmentRetained"], + "managedEnvironmentFilePrivate": deployment["managedEnvironmentFilePrivate"], + "scanSourceMatches": deployment["scanSourceMatches"], + "scanMountReadOnly": deployment["scanMountReadOnly"], + "scanPermissionPolicyPersistent": deployment["scanPermissionPolicyPersistent"], + "scanAccessForRuntimeIdentityReady": deployment[ + "scanAccessForRuntimeIdentityReady" + ], + "controllerIntentDurable": quiescence.get("controllerIntentDurable"), + "controllerIntentBindingSha256": quiescence.get( + "controllerIntentBindingSha256" + ), + } + + # Run the exact pure state machine in memory before committing any + # checkpoints. This also verifies the result against the fresh preflight. + candidate = protocol.advance_intent(intent, "target_gates", gates) + candidate = protocol.advance_intent(candidate, "quiescence", quiescence) + candidate = protocol.advance_intent(candidate, "snapshot", snapshot) + candidate = protocol.advance_intent(candidate, "controller_registration", registration) + return ( + { + "target_gates": gates, + "quiescence": quiescence, + "snapshot": snapshot, + "controller_registration": candidate["evidence"]["controllerRegistration"], + }, + checked_registry["receiptSha256"], + checked_registry["projectsSha256"], + ) + + +def _validate_status_binding( + value: object, + intent: dict[str, Any], + registration: dict[str, Any], + *, + controller_intent_sha256: str, + registry_receipt_sha256: str, + projects_sha256: str, +) -> dict[str, Any]: + status = _status_projection(value) + hashes = status["hashes"] + snapshot = intent["evidence"]["snapshot"] + gates = intent["evidence"]["targetGates"] + _need( + status.get("status") == "in_progress" + and status.get("phase") == "snapshot_verified" + and hashes.get("intentSha256") == controller_intent_sha256 + and hashes.get("intentBindingSha256") == protocol.intent_binding_sha256(intent) + and hashes.get("idempotencySha256") == intent["idempotencySha256"] + and hashes.get("preflightSha256") == intent["preflightSha256"] + and hashes.get("planSha256") == gates.get("planSha256") + and hashes.get("snapshotFileSha256") == snapshot.get("snapshotFileSha256") + and hashes.get("snapshotLogicalSha256") == snapshot.get("snapshotLogicalSha256") + and hashes.get("snapshotQueueSha256") == snapshot.get("snapshotQueueSha256") + and hashes.get("expectedProjectsSha256") == projects_sha256 + and hashes.get("targetProjectsSha256") == projects_sha256 + and hashes.get("receiptSha256") == registry_receipt_sha256 + and registration.get("receiptSha256") is not None, + "controller_status_binding_mismatch", + ) + return status + + +def _failure_evidence(stage: str, status_value: object) -> dict[str, Any]: + return {"stage": stage, **_status_failure_evidence(status_value)} + + +def _mark_recovery( + session: Session, + status: dict[str, Any], + *, + reason: str = "unknown_external_effect", + stage: str, + controller_status: object, +) -> dict[str, Any]: + if status.get("status") == "recovery_required" or status.get("status") == "completed": + return _safe_status(status) + try: + updated = journal.mark_repopulse_adoption_recovery( + session, + reason, + _failure_evidence(stage, controller_status), + expected_revision=status.get("revision"), + ) + except Exception: # noqa: BLE001 - persistence failures must become a safe recovery result. + try: + updated = journal.get_repopulse_adoption_status(session) + except Exception: # noqa: BLE001 - never return database exception text. + return {"status": "recovery_required", "phase": status.get("phase"), "reason": reason} + return _safe_status(updated) + + +class RepoPulseAdoptionRunner: + """Operator-only prepare/finalize service for fixed RepoPulse adoption.""" + + def __init__( + self, + *, + session: Session, + preflight: RepoPulseAdoptionPreflight | None = None, + broker: DeploymentBrokerClient | None = None, + ): + self.session = session + self._runtime_settings_loaded = False + self.preflight = preflight or RepoPulseAdoptionPreflight() + self.broker = broker or DeploymentBrokerClient() + + def _initialize_operator_runtime(self) -> None: + if self._runtime_settings_loaded: + return + # FastAPI routes hydrate connector settings before using Portainer. + # The operator CLI has no lifespan, so perform the same bounded load. + apply_stored_connector_settings(self.session, {"portainer"}) + self._runtime_settings_loaded = True + + def _fresh_intent(self, idempotency_key: str) -> tuple[dict[str, Any], dict[str, Any]]: + _need( + isinstance(idempotency_key, str) + and _IDEMPOTENCY_KEY.fullmatch(idempotency_key) is not None, + "idempotency_key_invalid", + ) + report = self.preflight.run() + candidate = protocol.create_intent(report, idempotency_key) + local = journal.reserve_repopulse_adoption( + self.session, report, idempotency_key + ) + row, intent = journal._load(self.session) + _need(row is not None and intent is not None, "adoption_intent_not_reserved") + _need(intent["intentId"] == candidate["intentId"], "adoption_intent_conflict") + return local, intent + + def _controller_status(self, secret: str) -> dict[str, Any]: + return self.broker.repopulse_adoption_status(secret=secret) + + def prepare(self, idempotency_key: str) -> dict[str, Any]: + """Freshly preflight and reserve; never invoke controller cutover.""" + if protocol.EXECUTION_ENABLED is not True: + return {"status": "disabled", "reason": "repopulse_adoption_execution_disabled"} + local: dict[str, Any] | None = None + bootstrap: str | None = None + reserve_attempted = False + try: + self._initialize_operator_runtime() + local, intent = self._fresh_intent(idempotency_key) + if local.get("status") in {"completed", "recovery_required"}: + return _safe_status(local) + + _validate_transport(self.broker) + _trust, bootstrap = _trust_and_bootstrap(self.session) + + handoff = _read_handoff(self.session) + if handoff is not None: + if not _handoff_matches(handoff, intent): + return _mark_recovery( + self.session, + local, + stage="handoff_conflict", + controller_status={"status": "unavailable"}, + ) + remote = self._controller_status(bootstrap) + try: + remote = _validate_reservation_status(remote, intent) + except RepoPulseAdoptionRunnerError: + return _mark_recovery( + self.session, + local, + stage="prepare_replay", + controller_status=remote, + ) + if handoff.get("state") == "reserve_attempted": + remote_intent_sha = remote["hashes"].get("intentSha256") + if not _sha(remote_intent_sha): + return _mark_recovery( + self.session, + local, + stage="prepare_replay", + controller_status=remote, + ) + _persist_handoff(self.session, intent, remote_intent_sha) + return {**_safe_status(local), "status": "prepared"} + + if local.get("phase") != "reserved": + remote = self._controller_status(bootstrap) + return _mark_recovery( + self.session, + local, + stage="handoff_missing", + controller_status=remote, + ) + + capabilities = self.broker.repopulse_adoption_capabilities( + secret=bootstrap + ) + _validate_capabilities(capabilities) + _persist_reserve_attempt(self.session, intent) + reserve_attempted = True + try: + reservation = self.broker.repopulse_adoption_reserve( + secret=bootstrap, + intent_id=intent["intentId"], + idempotency_sha256=intent["idempotencySha256"], + preflight_sha256=intent["preflightSha256"], + ) + except Exception: # noqa: BLE001 - reserve may have committed remotely. + return self._uncertain_prepare(local, "controller_reserve", bootstrap) + if isinstance(reservation, dict) and reservation.get("status") == "execution_disabled": + return {**_safe_status(local), "status": "blocked", "reason": "controller_execution_disabled"} + try: + controller_intent_sha = _validate_reservation(reservation, intent) + enrollment_id, target_credential = get_or_create_repopulse_target_credential( + self.session, intent_id=intent["intentId"] + ) + del target_credential + _need( + enrollment_id == f"enr-{intent['intentId'][4:]}", + "target_credential_binding_mismatch", + ) + _persist_handoff(self.session, intent, controller_intent_sha) + except Exception: # noqa: BLE001 - the durable reserve may precede local failure. + # The controller may have durably reserved before this process + # failed; reconcile through status and never POST reserve again. + return self._uncertain_prepare(local, "controller_reserve", bootstrap) + return {**_safe_status(local), "status": "prepared"} + except RepoPulseAdoptionRunnerError as error: + if reserve_attempted and local is not None: + return self._uncertain_prepare(local, "controller_reserve", bootstrap) + return {"status": "blocked", "reason": error.code} + except (journal.RepoPulseAdoptionJournalError, protocol.AdoptionProtocolError) as error: + if reserve_attempted and local is not None: + return self._uncertain_prepare(local, "controller_reserve", bootstrap) + return {"status": "blocked", "reason": _safe_error_code(error, "adoption_prepare_failed")} + except RepoPulseTargetCredentialError as error: + if reserve_attempted and local is not None: + return self._uncertain_prepare(local, "controller_reserve", bootstrap) + return {"status": "blocked", "reason": _safe_error_code(error, "target_credential_unavailable")} + except Exception: # noqa: BLE001 - transport and DB exceptions may contain credentials. + # HTTP clients and drivers can include credentials in exception + # text; this CLI boundary deliberately emits only fixed codes. + try: + local = local or journal.get_repopulse_adoption_status(self.session) + except Exception: # noqa: BLE001 - status read failure must not expose exception text. + return {"status": "blocked", "reason": "adoption_prepare_failed"} + if reserve_attempted and local.get("status") == "in_progress": + return self._uncertain_prepare(local, "controller_reserve", bootstrap) + return _safe_status(local, reason="adoption_prepare_failed") + + def _uncertain_prepare( + self, local: dict[str, Any], stage: str, bootstrap: str | None + ) -> dict[str, Any]: + remote: object = {"status": "unavailable"} + if bootstrap is not None: + try: + remote = self._controller_status(bootstrap) + except Exception: # noqa: BLE001 - record unavailable without logging transport details. + remote = {"status": "unavailable"} + return _mark_recovery( + self.session, + local, + stage=stage, + controller_status=remote, + ) + + def prepare_controller_cli_handoff(self, idempotency_key: str) -> dict[str, Any]: + """Return the exact in-memory stdin contract for the fixed root CLI. + + The caller must pipe this object directly to the separately operated + controller CLI. It must not print or persist the target credential. + """ + prepared = self.prepare(idempotency_key) + _need(prepared.get("status") == "prepared", "controller_handoff_not_ready") + local = journal.get_repopulse_adoption_status(self.session) + _need( + local.get("status") == "in_progress" and local.get("phase") == "reserved", + "controller_handoff_phase_invalid", + ) + row, intent = journal._load(self.session) + _need(row is not None and intent is not None, "adoption_intent_not_reserved") + marker = _read_handoff(self.session) + if marker is not None and marker.get("state") == "controller_cli_handoff_issued": + _validate_transport(self.broker) + _trust, bootstrap = _trust_and_bootstrap(self.session) + remote = self._controller_status(bootstrap) + _mark_recovery( + self.session, + local, + stage="controller_cli_handoff_replay", + controller_status=remote, + ) + raise RepoPulseAdoptionRunnerError( + "controller_cli_handoff_replay_requires_recovery" + ) + _need( + marker is not None + and marker.get("state") == "handoff_prepared" + and _handoff_matches(marker, intent), + "controller_handoff_marker_invalid", + ) + enrollment_id, target_credential = get_or_create_repopulse_target_credential( + self.session, intent_id=intent["intentId"] + ) + _need( + enrollment_id == f"enr-{intent['intentId'][4:]}" + and isinstance(target_credential, bytes) + and 32 <= len(target_credential) <= 256, + "target_credential_binding_mismatch", + ) + try: + credential = target_credential.decode("ascii") + except UnicodeDecodeError: + raise RepoPulseAdoptionRunnerError("target_credential_encoding_invalid") from None + _need(credential.isprintable(), "target_credential_encoding_invalid") + projection = intent["evidence"]["preflight"] + _need(isinstance(projection, dict), "controller_handoff_preflight_invalid") + _persist_cli_handoff_issued( + self.session, intent, marker["controllerIntentSha256"] + ) + return { + "intentId": intent["intentId"], + "idempotencySha256": intent["idempotencySha256"], + "preflightSha256": intent["preflightSha256"], + "preflightProjection": dict(projection), + "targetCredential": credential, + } + + def finalize(self, controller_result: object) -> dict[str, Any]: + """Validate root-controller evidence, checkpoint it, and commit ownership. + + The caller must obtain ``controller_result`` from the trusted fixed + controller handoff. This method validates every field and confirms + the matching controller journal over CA-verified HTTPS/HMAC before + AppOps writes ownership. + """ + if protocol.EXECUTION_ENABLED is not True: + return {"status": "disabled", "reason": "repopulse_adoption_execution_disabled"} + try: + local = journal.get_repopulse_adoption_status(self.session) + if local.get("status") == "completed": + return _safe_status(local) + _need(local.get("status") == "in_progress", "adoption_not_ready_to_finalize") + _need(local.get("phase") == "reserved", "adoption_partial_finalize_requires_recovery") + row, intent = journal._load(self.session) + _need(row is not None and intent is not None, "adoption_intent_not_reserved") + handoff = _read_handoff(self.session) + _need( + handoff is not None + and handoff.get("state") == "controller_cli_handoff_issued" + and _handoff_matches(handoff, intent), + "controller_handoff_missing", + ) + _validate_transport(self.broker) + _trust, bootstrap = _trust_and_bootstrap(self.session) + + mapped, registry_receipt_sha256, projects_sha256 = _map_controller_result( + controller_result, intent + ) + candidate = intent + for step in ("target_gates", "quiescence", "snapshot", "controller_registration"): + candidate = protocol.advance_intent(candidate, step, mapped[step]) + registration = candidate["evidence"]["controllerRegistration"] + + remote = self._controller_status(bootstrap) + _validate_status_binding( + remote, + candidate, + registration, + controller_intent_sha256=handoff["controllerIntentSha256"], + registry_receipt_sha256=registry_receipt_sha256, + projects_sha256=projects_sha256, + ) + + current_status = local + for step in ("target_gates", "quiescence", "snapshot", "controller_registration"): + current_status = journal.advance_repopulse_adoption( + self.session, + step, + mapped[step], + expected_revision=current_status["revision"], + ) + current_status = ownership.commit_repopulse_adoption_ownership( + self.session, + registration, + expected_revision=current_status["revision"], + ) + current_status = ownership.complete_repopulse_adoption( + self.session, + registration, + expected_revision=current_status["revision"], + ) + return _safe_status(current_status) + except Exception as error: # noqa: BLE001 - recover any uncertain side effect without echoing it. + try: + local = journal.get_repopulse_adoption_status(self.session) + except Exception: # noqa: BLE001 - never expose database exception text. + return {"status": "recovery_required", "reason": "unknown_external_effect"} + if local.get("status") == "completed": + return _safe_status(local) + try: + _trust, bootstrap = _trust_and_bootstrap(self.session) + _validate_transport(self.broker) + remote = self._controller_status(bootstrap) + except Exception: # noqa: BLE001 - query failure is represented as unavailable. + remote = {"status": "unavailable"} + code = _safe_error_code(error, "controller_result_unverified") + reason = "appops_commit_uncertain" if code == "appops_commit_uncertain" else "unknown_external_effect" + return _mark_recovery( + self.session, + local, + reason=reason, + stage="finalize", + controller_status=remote, + ) + + +def _handoff_matches(handoff: dict[str, Any], intent: dict[str, Any]) -> bool: + return bool( + handoff.get("intentId") == intent.get("intentId") + and handoff.get("idempotencySha256") == intent.get("idempotencySha256") + and handoff.get("preflightSha256") == intent.get("preflightSha256") + and handoff.get("intentBindingSha256") == protocol.intent_binding_sha256(intent) + ) + + +def _validate_reservation_status(value: object, intent: dict[str, Any]) -> dict[str, Any]: + status = _status_projection(value) + hashes = status["hashes"] + _need( + status["status"] == "in_progress" + and status["phase"] in _RECOVERY_STATUS_PHASES + and hashes.get("intentBindingSha256") == protocol.intent_binding_sha256(intent) + and hashes.get("idempotencySha256") == intent["idempotencySha256"] + and hashes.get("preflightSha256") == intent["preflightSha256"], + "controller_status_binding_mismatch", + ) + return status + + +def main(argv: list[str] | None = None) -> int: + """Run fixed operator phases; finalize accepts only bounded JSON stdin.""" + parser = argparse.ArgumentParser(description="Operate the fixed one-time RepoPulse adoption") + commands = parser.add_subparsers(dest="command", required=True) + prepare_parser = commands.add_parser("prepare", help="fresh preflight and reserve") + prepare_parser.add_argument("--idempotency-key", required=True) + commands.add_parser("finalize", help="verify controller result from stdin") + args = parser.parse_args(argv) + try: + with Session(engine) as session: + runner = RepoPulseAdoptionRunner(session=session) + if protocol.EXECUTION_ENABLED is not True: + result = {"status": "disabled", "reason": "repopulse_adoption_execution_disabled"} + elif args.command == "prepare": + result = runner.prepare(args.idempotency_key) + else: + result = runner.finalize(_read_controller_result_stdin()) + except Exception: # noqa: BLE001 - CLI boundary emits only a fixed sanitized reason. + result = {"status": "blocked", "reason": "adoption_operator_command_failed"} + print(json.dumps(result, sort_keys=True, separators=(",", ":"))) + return 0 if result.get("status") in {"prepared", "completed"} else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/app/services/repopulse_legacy_adoption_protocol.py b/backend/app/services/repopulse_legacy_adoption_protocol.py new file mode 100644 index 0000000..85285c7 --- /dev/null +++ b/backend/app/services/repopulse_legacy_adoption_protocol.py @@ -0,0 +1,984 @@ +"""Pure state and evidence rules for the fixed RepoPulse legacy adoption. + +This module deliberately has no Portainer, filesystem, Docker, database, or +controller client. The integrated operator runner uses these rules only after +the fixed managed profile and controller-owned durable intent are verified. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from copy import deepcopy +from typing import Any + +from backend.app.services.enrollment_profiles import PROFILE_VERSION + +PROTOCOL_VERSION = "itworx.repopulse-legacy-adoption/v1" +INTENT_VERSION = "itworx.repopulse-legacy-adoption-intent/v1" +CONTROLLER_RECEIPT_VERSION = "itworx.repopulse-legacy-adoption-receipt/v1" +REPOSITORY = "jens/repopulse" +APPLICATION_ID = "repopulse" +LEGACY_VOLUME = "repopulse_data" +EXECUTION_ENABLED = True + +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_SHA1 = re.compile(r"^[0-9a-f]{40}$") +_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_PROFILE_ID = "repopulse-authenticated" +_ENROLLMENT_ID = re.compile(r"^enr-[0-9a-f]{20}$") +_INTENT_ID = re.compile(r"^rpa-[0-9a-f]{20}$") + +_LEGACY_CHECKS = { + "legacy_registry", + "controller_state", + "gitea_ancestry", + "runtime_identity_and_health", + "legacy_storage_topology", + "legacy_appdata", + "product_api", + "database_health", + "queue_quiescence", + "database_baseline", +} +_PHASES = ( + "reserved", + "target_gates_ready", + "quiesced", + "snapshot_verified", + "target_registered", + "appops_owned", + "completed", +) +_STEP_CONTRACTS: dict[str, tuple[str, str]] = { + "target_gates": ("reserved", "target_gates_ready"), + "quiescence": ("target_gates_ready", "quiesced"), + "snapshot": ("quiesced", "snapshot_verified"), + "controller_registration": ("snapshot_verified", "target_registered"), + "appops_ownership": ("target_registered", "appops_owned"), +} +_RECOVERY_REASONS = { + "controller_unavailable", + "database_backup_failed", + "registry_receipt_uncertain", + "target_health_failed", + "appops_commit_uncertain", + "preflight_drift", + "operator_interruption", + "unknown_external_effect", +} +_STEP_EVIDENCE_KEYS = { + "target_gates": "targetGates", + "quiescence": "quiescence", + "snapshot": "snapshot", + "controller_registration": "controllerRegistration", + "appops_ownership": "appopsOwnership", + "completion": "completion", +} + + +class AdoptionProtocolError(ValueError): + """A fail-closed protocol or evidence validation error.""" + + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +def _canonical(value: object) -> bytes: + try: + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + except (TypeError, ValueError, UnicodeEncodeError) as exc: + raise AdoptionProtocolError("invalid_evidence_encoding") from exc + + +def _digest(value: object) -> str: + return hashlib.sha256(_canonical(value)).hexdigest() + + +def _is_count(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _validate_preflight_projection(value: object) -> dict[str, Any]: + required = { + "repository", + "main_revision", + "live_revision", + "container_id_sha256", + "container_image_sha256", + "queue", + "database", + "container_state", + "container_health", + } + _need(isinstance(value, dict) and set(value) == required, "intent_preflight_invalid") + projection = value + _need( + projection.get("repository") == REPOSITORY + and isinstance(projection.get("main_revision"), str) + and _SHA1.fullmatch(projection["main_revision"]) is not None + and isinstance(projection.get("live_revision"), str) + and _SHA1.fullmatch(projection["live_revision"]) is not None + and isinstance(projection.get("container_id_sha256"), str) + and _SHA256.fullmatch(projection["container_id_sha256"]) is not None + and isinstance(projection.get("container_image_sha256"), str) + and _SHA256.fullmatch(projection["container_image_sha256"]) is not None + and projection.get("container_state") == "running" + and projection.get("container_health") == "healthy", + "intent_preflight_invalid", + ) + queue = projection.get("queue") + database = projection.get("database") + _need( + isinstance(queue, dict) + and set(queue) == {"queued", "active", "completed", "failed"} + and all(_is_count(queue.get(key)) for key in queue) + and queue.get("queued") == 0 + and queue.get("active") == 0, + "intent_preflight_queue_invalid", + ) + _need( + isinstance(database, dict) + and set(database) + == {"database_bytes", "journal_bytes", "scan_count", "compacted_scan_count"} + and all(_is_count(database.get(key)) for key in database) + and database.get("database_bytes", 0) > 0 + and database.get("scan_count", -1) >= database.get("compacted_scan_count", 0), + "intent_preflight_database_invalid", + ) + return projection + + +def _need(condition: bool, code: str) -> None: + if not condition: + raise AdoptionProtocolError(code) + + +def _safe_preflight_projection(report: object) -> dict[str, Any]: + """Accept only the existing read-only preflight's allowlisted identity facts.""" + _need(isinstance(report, dict), "legacy_preflight_missing") + source = report + _need( + source.get("apiVersion") == "itworx.repopulse-legacy-adoption-preflight/v1" + and source.get("repository") == REPOSITORY + and source.get("status") == "preflight_passed" + and source.get("readOnly") is True + and source.get("adoptionAuthorized") is False, + "legacy_preflight_not_compatible", + ) + compatibility = source.get("legacyCompatibility") + _need( + isinstance(compatibility, dict) + and compatibility.get("status") == "passed", + "legacy_preflight_not_compatible", + ) + checks = compatibility.get("checks") + _need(isinstance(checks, list), "legacy_preflight_checks_missing") + observed: dict[str, str] = {} + for check in checks: + _need(isinstance(check, dict), "legacy_preflight_check_invalid") + code = check.get("code") + _need( + isinstance(code, str) + and code in _LEGACY_CHECKS + and check.get("gate") == "legacy_compatibility" + and check.get("status") == "passed" + and code not in observed, + "legacy_preflight_check_failed", + ) + observed[code] = "passed" + _need(set(observed) == _LEGACY_CHECKS, "legacy_preflight_check_incomplete") + + evidence = source.get("evidence") + _need(isinstance(evidence, dict), "legacy_preflight_evidence_missing") + main_revision = evidence.get("main_revision") + live_revision = evidence.get("live_revision") + queue = evidence.get("queue") + database = evidence.get("database") + _need( + isinstance(main_revision, str) + and _SHA1.fullmatch(main_revision) is not None + and isinstance(live_revision, str) + and _SHA1.fullmatch(live_revision) is not None, + "legacy_preflight_revision_invalid", + ) + _need( + isinstance(queue, dict) + and set(queue) == {"queued", "active", "completed", "failed"} + and all(_is_count(queue.get(key)) for key in queue) + and queue.get("queued") == 0 + and queue.get("active") == 0, + "legacy_preflight_queue_not_quiescent", + ) + _need( + isinstance(database, dict) + and all( + _is_count(database.get(key)) + for key in ( + "database_bytes", + "journal_bytes", + "scan_count", + "compacted_scan_count", + ) + ) + and database.get("database_bytes", 0) > 0 + and database.get("scan_count", -1) + >= database.get("compacted_scan_count", 0), + "legacy_preflight_database_facts_invalid", + ) + _need( + evidence.get("container_state") == "running" + and evidence.get("container_health") == "healthy", + "legacy_preflight_runtime_invalid", + ) + return { + "repository": REPOSITORY, + "main_revision": main_revision, + "live_revision": live_revision, + "container_id_sha256": evidence.get("container_id_sha256"), + "container_image_sha256": evidence.get("container_image_sha256"), + "queue": { + key: queue[key] + for key in ("queued", "active", "completed", "failed") + }, + "database": { + key: database[key] + for key in ( + "database_bytes", + "journal_bytes", + "scan_count", + "compacted_scan_count", + ) + }, + "container_state": "running", + "container_health": "healthy", + } + + +def create_intent(report: object, idempotency_key: str) -> dict[str, Any]: + """Create deterministic fixed-identity intent data from a passing preflight. + + Persistence is deliberately left to the future AppOps/controller adapters; + this pure constructor never writes a database or controller state file. + """ + _need( + isinstance(idempotency_key, str) + and _IDEMPOTENCY_KEY.fullmatch(idempotency_key) is not None, + "idempotency_key_invalid", + ) + projection = _safe_preflight_projection(report) + idempotency_sha256 = hashlib.sha256( + f"{PROTOCOL_VERSION}:{REPOSITORY}:{idempotency_key}".encode("ascii") + ).hexdigest() + intent_id = f"rpa-{idempotency_sha256[:20]}" + intent = { + "apiVersion": INTENT_VERSION, + "protocolVersion": PROTOCOL_VERSION, + "intentId": intent_id, + "idempotencySha256": idempotency_sha256, + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "phase": "reserved", + "status": "in_progress", + "preflightSha256": _digest(projection), + "legacyVolume": LEGACY_VOLUME, + "legacyVolumePreserved": True, + "evidence": {"preflight": projection}, + "receipts": {}, + "recovery": None, + } + return _validate_intent(intent) + + +def reserve_or_replay( + existing_intent: object | None, report: object, idempotency_key: str +) -> dict[str, Any]: + """Enforce the single fixed adoption slot and exact retry identity.""" + candidate = create_intent(report, idempotency_key) + if existing_intent is None: + return candidate + existing = _validate_intent(existing_intent) + _need( + existing["idempotencySha256"] == candidate["idempotencySha256"], + "one_time_adoption_already_reserved", + ) + _need( + existing["preflightSha256"] == candidate["preflightSha256"], + "idempotency_conflict", + ) + return deepcopy(existing) + + +def _validate_intent(intent: object) -> dict[str, Any]: + required = { + "apiVersion", + "protocolVersion", + "intentId", + "idempotencySha256", + "repository", + "applicationId", + "phase", + "status", + "preflightSha256", + "legacyVolume", + "legacyVolumePreserved", + "evidence", + "receipts", + "recovery", + } + _need(isinstance(intent, dict) and set(intent) == required, "intent_shape_invalid") + value = intent + _need( + value.get("apiVersion") == INTENT_VERSION + and value.get("protocolVersion") == PROTOCOL_VERSION + and value.get("repository") == REPOSITORY + and value.get("applicationId") == APPLICATION_ID + and value.get("legacyVolume") == LEGACY_VOLUME + and value.get("legacyVolumePreserved") is True + and isinstance(value.get("intentId"), str) + and _INTENT_ID.fullmatch(value["intentId"]) is not None + and isinstance(value.get("idempotencySha256"), str) + and _SHA256.fullmatch(value["idempotencySha256"]) is not None + and value["intentId"] == f"rpa-{value['idempotencySha256'][:20]}" + and isinstance(value.get("preflightSha256"), str) + and _SHA256.fullmatch(value["preflightSha256"]) is not None + and value.get("phase") in _PHASES + and value.get("status") in {"in_progress", "recovery_required", "completed"} + and isinstance(value.get("evidence"), dict) + and isinstance(value.get("receipts"), dict), + "intent_identity_invalid", + ) + _need( + value.get("status") != "completed" or value.get("phase") == "completed", + "intent_terminal_state_invalid", + ) + evidence = value["evidence"] + _need( + set(evidence) <= {"preflight", *_STEP_EVIDENCE_KEYS.values()} + and isinstance(evidence.get("preflight"), dict) + and _digest(evidence["preflight"]) == value["preflightSha256"], + "intent_evidence_invalid", + ) + _validate_preflight_projection(evidence["preflight"]) + receipts = value["receipts"] + phase = value["phase"] + expected_steps = { + "reserved": (), + "target_gates_ready": ("target_gates",), + "quiesced": ("target_gates", "quiescence"), + "snapshot_verified": ("target_gates", "quiescence", "snapshot"), + "target_registered": ("target_gates", "quiescence", "snapshot", "controller_registration"), + "appops_owned": tuple(key for key in _STEP_EVIDENCE_KEYS if key != "completion"), + "completed": tuple(_STEP_EVIDENCE_KEYS), + }[phase] + _need(set(receipts) == set(expected_steps), "intent_receipts_incomplete") + for step, record in receipts.items(): + _need( + isinstance(record, dict) + and set(record) == {"evidenceSha256"} + and isinstance(record.get("evidenceSha256"), str) + and _SHA256.fullmatch(record["evidenceSha256"]) is not None, + "intent_receipt_invalid", + ) + evidence_key = _STEP_EVIDENCE_KEYS[step] + _need( + evidence_key in evidence + and _digest(evidence[evidence_key]) == record["evidenceSha256"], + "intent_receipt_evidence_mismatch", + ) + if value["status"] == "recovery_required": + recovery = value.get("recovery") + _need( + isinstance(recovery, dict) + and set(recovery) + == { + "reason", + "afterPhase", + "failureEvidenceSha256", + "controllerReconciliationRequired", + "legacyVolumePreserved", + } + and recovery.get("reason") in _RECOVERY_REASONS + and recovery.get("afterPhase") == phase + and isinstance(recovery.get("failureEvidenceSha256"), str) + and _SHA256.fullmatch(recovery["failureEvidenceSha256"]) is not None + and recovery.get("controllerReconciliationRequired") is True + and recovery.get("legacyVolumePreserved") is True, + "intent_recovery_marker_invalid", + ) + else: + _need(value.get("recovery") is None, "intent_recovery_marker_unexpected") + return value + + +def _intent_binding(intent: dict[str, Any]) -> str: + return _digest( + { + "intentId": intent["intentId"], + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "preflightSha256": intent["preflightSha256"], + "idempotencySha256": intent["idempotencySha256"], + } + ) + + +def _target_gates(evidence: object) -> dict[str, Any]: + required = { + "profileId", + "profileVersion", + "planSha256", + "profileReady", + "controllerTrustReady", + "controllerBootstrapSecretImported", + "typedEnrollmentTrustReady", + "targetSecretReady", + "exactPolicyReady", + "authenticatedRouteReady", + "accessListReady", + "mutationBoundaryReady", + "managedRuntimeReady", + "scanSourceMatches", + "scanMountReadOnly", + "scanPermissionPolicyPersistent", + "scanAccessForRuntimeIdentityReady", + "ownedDataTargetReady", + "managedEnvironmentReady", + "legacyEnvironmentRetained", + "maintenanceAuthReady", + "controllerAdoptionCapabilityReady", + "controllerRegistryWriterReady", + "controllerSecretWriterReady", + } + _need(isinstance(evidence, dict) and set(evidence) == required, "target_gates_invalid") + value = evidence + _need( + value.get("profileId") == _PROFILE_ID + and value.get("profileVersion") == PROFILE_VERSION + and isinstance(value.get("planSha256"), str) + and _SHA256.fullmatch(value["planSha256"]) is not None + and all(value.get(key) is True for key in required - {"profileId", "profileVersion", "planSha256"}), + "target_gates_not_ready", + ) + return {key: value[key] for key in sorted(required)} + + +def _quiescence_evidence(evidence: object, intent: dict[str, Any]) -> dict[str, Any]: + required = { + "intentId", + "globalDeployLockHeld", + "projectLockHeld", + "maintenanceModeActive", + "queueFrozen", + "queuedJobs", + "activeJobs", + "maintenanceModeAuthenticated", + "containerName", + "containerIdSha256", + "containerImageSha256", + "containerState", + "legacyVolume", + "controllerIntentDurable", + "controllerIntentBindingSha256", + } + _need(isinstance(evidence, dict) and set(evidence) == required, "quiescence_evidence_invalid") + value = evidence + _need( + value.get("intentId") == intent["intentId"] + and value.get("globalDeployLockHeld") is True + and value.get("projectLockHeld") is True + and value.get("maintenanceModeActive") is True + and value.get("queueFrozen") is True + and value.get("maintenanceModeAuthenticated") is True + and _is_count(value.get("queuedJobs")) + and value.get("queuedJobs") == 0 + and _is_count(value.get("activeJobs")) + and value.get("activeJobs") == 0 + and value.get("containerName") == "repopulse" + and isinstance(value.get("containerIdSha256"), str) + and _SHA256.fullmatch(value["containerIdSha256"]) is not None + and value.get("containerIdSha256") == intent["evidence"]["preflight"]["container_id_sha256"] + and isinstance(value.get("containerImageSha256"), str) + and _SHA256.fullmatch(value["containerImageSha256"]) is not None + and value.get("containerImageSha256") == intent["evidence"]["preflight"]["container_image_sha256"] + and value.get("containerState") == "exited" + and value.get("legacyVolume") == LEGACY_VOLUME + and value.get("controllerIntentDurable") is True, + "quiescence_not_proven", + ) + _need( + isinstance(value.get("controllerIntentBindingSha256"), str) + and _SHA256.fullmatch(value["controllerIntentBindingSha256"]) is not None + and value["controllerIntentBindingSha256"] == _intent_binding(intent), + "controller_intent_binding_mismatch", + ) + return {key: value[key] for key in sorted(required)} + + +def _snapshot_evidence(evidence: object, intent: dict[str, Any]) -> dict[str, Any]: + required = { + "intentId", + "globalDeployLockHeld", + "projectLockHeld", + "legacyContainerStopped", + "legacyVolume", + "legacyVolumeRetained", + "sqliteBackupMethod", + "quickCheck", + "integrityCheck", + "sourceLogicalSha256", + "snapshotLogicalSha256", + "snapshotFileSha256", + "sourceScanCount", + "snapshotScanCount", + "sourceCompactedScanCount", + "snapshotCompactedScanCount", + "sourceQueueSha256", + "snapshotQueueSha256", + "sourceAuxiliaryTreeSha256", + "sourceAuxiliaryFileCount", + "targetDataPathWasAbsent", + "snapshotReceiptDurable", + "legacyEnvironmentRetained", + "managedEnvironmentFilePrivate", + "controllerIntentBindingSha256", + } + _need(isinstance(evidence, dict) and set(evidence) == required, "snapshot_evidence_invalid") + value = evidence + _need( + value.get("intentId") == intent["intentId"] + and value.get("globalDeployLockHeld") is True + and value.get("projectLockHeld") is True + and value.get("legacyContainerStopped") is True + and value.get("legacyVolume") == LEGACY_VOLUME + and value.get("legacyVolumeRetained") is True + and value.get("sqliteBackupMethod") == "sqlite_online_backup" + and value.get("quickCheck") == "ok" + and value.get("integrityCheck") == "ok" + and isinstance(value.get("sourceLogicalSha256"), str) + and _SHA256.fullmatch(value["sourceLogicalSha256"]) is not None + and value.get("sourceLogicalSha256") == value.get("snapshotLogicalSha256") + and isinstance(value.get("snapshotFileSha256"), str) + and _SHA256.fullmatch(value["snapshotFileSha256"]) is not None + and _is_count(value.get("sourceScanCount")) + and value.get("sourceScanCount") == value.get("snapshotScanCount") + and _is_count(value.get("sourceCompactedScanCount")) + and value.get("sourceCompactedScanCount") == value.get("snapshotCompactedScanCount") + and isinstance(value.get("sourceQueueSha256"), str) + and _SHA256.fullmatch(value["sourceQueueSha256"]) is not None + and value.get("sourceQueueSha256") == value.get("snapshotQueueSha256") + and isinstance(value.get("sourceAuxiliaryTreeSha256"), str) + and _SHA256.fullmatch(value["sourceAuxiliaryTreeSha256"]) is not None + and _is_count(value.get("sourceAuxiliaryFileCount")) + and value.get("targetDataPathWasAbsent") is True + and value.get("snapshotReceiptDurable") is True + and value.get("legacyEnvironmentRetained") is True + and value.get("managedEnvironmentFilePrivate") is True, + "sqlite_snapshot_not_verified", + ) + _need( + isinstance(value.get("controllerIntentBindingSha256"), str) + and _SHA256.fullmatch(value["controllerIntentBindingSha256"]) is not None + and value["controllerIntentBindingSha256"] == _intent_binding(intent), + "controller_intent_binding_mismatch", + ) + return {key: value[key] for key in sorted(required)} + + +def _controller_registration_evidence( + evidence: object, intent: dict[str, Any] +) -> dict[str, Any]: + required = { + "receiptVersion", + "intentId", + "receiptSha256", + "repository", + "applicationId", + "enrollmentId", + "profileId", + "planSha256", + "snapshotFileSha256", + "targetLogicalSha256", + "targetScanCount", + "targetCompactedScanCount", + "targetQueueSha256", + "targetAuxiliaryTreeSha256", + "targetAuxiliaryFileCount", + "targetQueuedJobs", + "targetActiveJobs", + "targetQuickCheck", + "targetIntegrityCheck", + "targetRecoveryStatus", + "targetDataUid", + "targetDataGid", + "managedDataMountMatches", + "copySourceIsVerifiedSnapshot", + "registryTargetMatches", + "controllerStateReceiptVerified", + "deploymentReceiptSchema", + "deploymentReceiptSha256", + "deploymentReceiptVerified", + "runtimeHealthy", + "publishedHostPorts", + "globalDeployLockHeld", + "projectLockHeld", + "legacyVolume", + "legacyVolumeRetained", + "legacyEnvironmentRetained", + "managedEnvironmentFilePrivate", + "scanSourceMatches", + "scanMountReadOnly", + "scanPermissionPolicyPersistent", + "scanAccessForRuntimeIdentityReady", + "controllerIntentDurable", + "controllerIntentBindingSha256", + } + _need( + isinstance(evidence, dict) and set(evidence) == required, + "controller_receipt_evidence_invalid", + ) + value = evidence + gates = intent["evidence"].get("targetGates") + snapshot = intent["evidence"].get("snapshot") + _need(isinstance(gates, dict) and isinstance(snapshot, dict), "adoption_prerequisite_missing") + _need( + value.get("receiptVersion") == CONTROLLER_RECEIPT_VERSION + and value.get("intentId") == intent["intentId"] + and isinstance(value.get("receiptSha256"), str) + and _SHA256.fullmatch(value["receiptSha256"]) is not None + and value.get("repository") == REPOSITORY + and value.get("applicationId") == APPLICATION_ID + and isinstance(value.get("enrollmentId"), str) + and _ENROLLMENT_ID.fullmatch(value["enrollmentId"]) is not None + and value.get("profileId") == gates["profileId"] + and value.get("planSha256") == gates["planSha256"] + and value.get("snapshotFileSha256") == snapshot["snapshotFileSha256"] + and isinstance(value.get("targetLogicalSha256"), str) + and value.get("targetLogicalSha256") == snapshot["snapshotLogicalSha256"] + and _is_count(value.get("targetScanCount")) + and value.get("targetScanCount") == snapshot["snapshotScanCount"] + and _is_count(value.get("targetCompactedScanCount")) + and value.get("targetCompactedScanCount") == snapshot["snapshotCompactedScanCount"] + and value.get("targetQueueSha256") == snapshot["snapshotQueueSha256"] + and value.get("targetAuxiliaryTreeSha256") == snapshot["sourceAuxiliaryTreeSha256"] + and value.get("targetAuxiliaryFileCount") == snapshot["sourceAuxiliaryFileCount"] + and _is_count(value.get("targetQueuedJobs")) + and value.get("targetQueuedJobs") == 0 + and _is_count(value.get("targetActiveJobs")) + and value.get("targetActiveJobs") == 0 + and value.get("targetQuickCheck") == "ok" + and value.get("targetIntegrityCheck") == "ok" + and value.get("targetRecoveryStatus") in {"healthy", "acknowledged"} + and value.get("targetDataUid") == 10001 + and value.get("targetDataGid") == 10001 + and value.get("managedDataMountMatches") is True + and value.get("copySourceIsVerifiedSnapshot") is True + and value.get("registryTargetMatches") is True + and value.get("controllerStateReceiptVerified") is True + and value.get("deploymentReceiptSchema") == "gitea-deploy-receipt.v1" + and isinstance(value.get("deploymentReceiptSha256"), str) + and _SHA256.fullmatch(value["deploymentReceiptSha256"]) is not None + and value.get("deploymentReceiptVerified") is True + and value.get("runtimeHealthy") is True + and value.get("publishedHostPorts") == [] + and value.get("globalDeployLockHeld") is True + and value.get("projectLockHeld") is True + and value.get("legacyVolume") == LEGACY_VOLUME + and value.get("legacyVolumeRetained") is True + and value.get("legacyEnvironmentRetained") is True + and value.get("managedEnvironmentFilePrivate") is True + and value.get("scanSourceMatches") is True + and value.get("scanMountReadOnly") is True + and value.get("scanPermissionPolicyPersistent") is True + and value.get("scanAccessForRuntimeIdentityReady") is True + and value.get("controllerIntentDurable") is True + and value.get("controllerIntentBindingSha256") == _intent_binding(intent), + "controller_adoption_receipt_not_verified", + ) + return {key: value[key] for key in sorted(required)} + + +def validate_controller_registration_evidence( + intent: object, evidence: object +) -> dict[str, Any]: + """Validate fresh controller registration evidence against the intent. + + This pure projection can validate receipt replays after AppOps reaches its + terminal `completed` phase. Controller response authentication remains the + caller's responsibility. + """ + current = _validate_intent(intent) + return _controller_registration_evidence(evidence, current) + + +def _appops_ownership_evidence( + evidence: object, intent: dict[str, Any] +) -> dict[str, Any]: + required = { + "repository", + "applicationId", + "enrollmentId", + "profileId", + "planSha256", + "controllerReceiptSha256", + "enrollmentPersisted", + "exactApplyPolicyPersisted", + "exactRollbackPolicyPersisted", + "secretReferenceReady", + } + _need(isinstance(evidence, dict) and set(evidence) == required, "appops_ownership_evidence_invalid") + value = evidence + registration = intent["evidence"].get("controllerRegistration") + _need(isinstance(registration, dict), "controller_registration_missing") + gates = intent["evidence"].get("targetGates") + _need(isinstance(gates, dict), "target_gates_missing") + _need( + value.get("repository") == REPOSITORY + and value.get("applicationId") == APPLICATION_ID + and value.get("enrollmentId") == registration["enrollmentId"] + and value.get("profileId") == registration["profileId"] + and value.get("planSha256") == gates["planSha256"] + and value.get("controllerReceiptSha256") == registration["receiptSha256"] + and all( + value.get(key) is True + for key in ( + "enrollmentPersisted", + "exactApplyPolicyPersisted", + "exactRollbackPolicyPersisted", + "secretReferenceReady", + ) + ), + "appops_ownership_not_verified", + ) + return {key: value[key] for key in sorted(required)} + + +def validate_appops_ownership_evidence( + intent: object, evidence: object +) -> dict[str, Any]: + """Validate fixed ownership evidence for an idempotent completed replay.""" + current = _validate_intent(intent) + return _appops_ownership_evidence(evidence, current) + + +def advance_intent( + intent: object, step: str, evidence: object +) -> dict[str, Any]: + """Purely validate and record one forward protocol checkpoint. + + The exact evidence digest makes retries idempotent. Replaying a step with + different evidence is a conflict and must enter recovery review. + """ + current = _validate_intent(intent) + _need(current.get("status") == "in_progress", "intent_requires_recovery") + _need(step in _STEP_CONTRACTS, "adoption_step_unknown") + source_phase, destination_phase = _STEP_CONTRACTS[step] + checked = { + "target_gates": _target_gates, + "quiescence": lambda value: _quiescence_evidence(value, current), + "snapshot": lambda value: _snapshot_evidence(value, current), + "controller_registration": lambda value: _controller_registration_evidence(value, current), + "appops_ownership": lambda value: _appops_ownership_evidence(value, current), + }[step](evidence) + digest = _digest(checked) + receipts = current["receipts"] + previous = receipts.get(step) + if previous is not None: + _need( + current.get("phase") in _PHASES[_PHASES.index(destination_phase):] + and isinstance(previous, dict) + and previous.get("evidenceSha256") == digest, + "idempotency_conflict", + ) + return deepcopy(current) + _need(current.get("phase") == source_phase, "adoption_step_out_of_order") + + updated = deepcopy(current) + updated["phase"] = destination_phase + updated["recovery"] = None + updated["receipts"][step] = {"evidenceSha256": digest} + evidence_key = _STEP_EVIDENCE_KEYS[step] + updated["evidence"][evidence_key] = checked + _validate_intent(updated) + return updated + + +def complete_intent(intent: object, evidence: object) -> dict[str, Any]: + """Finalize only after re-reading both AppOps ownership and controller receipt.""" + current = _validate_intent(intent) + _need( + current.get("status") == "in_progress" + and current.get("phase") == "appops_owned", + "adoption_not_ready_to_complete", + ) + required = { + "intentId", + "repository", + "applicationId", + "enrollmentId", + "controllerReceiptSha256", + "enrollmentStillPersisted", + "exactApplyPolicyStillPersisted", + "exactRollbackPolicyStillPersisted", + "controllerReceiptStillMatches", + "legacyVolumeRetained", + } + _need(isinstance(evidence, dict) and set(evidence) == required, "completion_evidence_invalid") + value = evidence + ownership = current["evidence"].get("appopsOwnership") + registration = current["evidence"].get("controllerRegistration") + _need(isinstance(ownership, dict) and isinstance(registration, dict), "adoption_prerequisite_missing") + _need( + value.get("intentId") == current["intentId"] + and value.get("repository") == REPOSITORY + and value.get("applicationId") == APPLICATION_ID + and value.get("enrollmentId") == registration["enrollmentId"] + and value.get("controllerReceiptSha256") == registration["receiptSha256"] + and value.get("controllerReceiptSha256") == ownership["controllerReceiptSha256"] + and all( + value.get(key) is True + for key in ( + "enrollmentStillPersisted", + "exactApplyPolicyStillPersisted", + "exactRollbackPolicyStillPersisted", + "controllerReceiptStillMatches", + "legacyVolumeRetained", + ) + ), + "completion_not_verified", + ) + checked = {key: value[key] for key in sorted(required)} + updated = deepcopy(current) + updated["phase"] = "completed" + updated["status"] = "completed" + updated["evidence"]["completion"] = checked + updated["receipts"]["completion"] = {"evidenceSha256": _digest(checked)} + return _validate_intent(updated) + + +def mark_recovery_required( + intent: object, reason: str, failure_evidence: object +) -> dict[str, Any]: + """Persist a sanitized recovery marker without compensating external state.""" + current = _validate_intent(intent) + _need(current.get("status") != "completed", "intent_already_completed") + _need(reason in _RECOVERY_REASONS, "recovery_reason_invalid") + _need(isinstance(failure_evidence, dict), "recovery_evidence_invalid") + # Only bind the evidence; arbitrary external messages/paths/secret values + # are never copied into the journal by this pure protocol helper. + updated = deepcopy(current) + updated["status"] = "recovery_required" + updated["recovery"] = { + "reason": reason, + "afterPhase": current["phase"], + "failureEvidenceSha256": _digest(failure_evidence), + "controllerReconciliationRequired": True, + "legacyVolumePreserved": True, + } + return updated + + +def resume_intent(intent: object, controller_receipt: object) -> dict[str, Any]: + """Resume only from an authenticated controller receipt bound to this intent. + + The adapter that calls this function must authenticate the controller + response. This pure helper validates its exact content and only reconciles + the single ambiguous boundary: controller registration committed while the + AppOps intent checkpoint was interrupted. + """ + current = _validate_intent(intent) + _need(current.get("status") == "recovery_required", "intent_not_in_recovery") + required = { + "apiVersion", + "intentId", + "phase", + "receiptSha256", + "repository", + "applicationId", + "legacyVolumeRetained", + "intentDurable", + "intentBindingSha256", + "registrationEvidence", + } + _need( + isinstance(controller_receipt, dict) + and set(controller_receipt) == required, + "controller_recovery_receipt_invalid", + ) + receipt = controller_receipt + _need( + receipt.get("apiVersion") == CONTROLLER_RECEIPT_VERSION + and receipt.get("intentId") == current["intentId"] + and receipt.get("repository") == REPOSITORY + and receipt.get("applicationId") == APPLICATION_ID + and receipt.get("legacyVolumeRetained") is True + and receipt.get("intentDurable") is True + and receipt.get("intentBindingSha256") == _intent_binding(current) + and isinstance(receipt.get("receiptSha256"), str) + and _SHA256.fullmatch(receipt["receiptSha256"]) is not None, + "controller_recovery_receipt_unbound", + ) + phase = receipt.get("phase") + if phase == current["phase"]: + _need( + current["phase"] in {"reserved", "target_gates_ready", "quiesced", "snapshot_verified", "target_registered", "appops_owned"}, + "controller_recovery_phase_invalid", + ) + if phase in {"target_registered", "appops_owned"}: + registration = _controller_registration_evidence( + receipt.get("registrationEvidence"), current + ) + _need( + receipt.get("receiptSha256") == registration["receiptSha256"] + and registration == current["evidence"].get("controllerRegistration"), + "controller_recovery_receipt_conflict", + ) + else: + _need( + receipt.get("registrationEvidence") is None, + "controller_recovery_receipt_conflict", + ) + updated = deepcopy(current) + updated["status"] = "in_progress" + updated["recovery"] = None + return updated + _need( + current.get("phase") == "snapshot_verified" and phase == "target_registered", + "controller_recovery_phase_conflict", + ) + registration = _controller_registration_evidence(receipt.get("registrationEvidence"), current) + registration_digest = _digest(registration) + _need(receipt.get("receiptSha256") == registration["receiptSha256"], "controller_recovery_receipt_digest_mismatch") + updated = deepcopy(current) + updated["phase"] = "target_registered" + updated["status"] = "in_progress" + updated["recovery"] = None + updated["receipts"]["controller_registration"] = {"evidenceSha256": registration_digest} + updated["evidence"]["controllerRegistration"] = registration + _validate_intent(updated) + return updated + + +def intent_binding_sha256(intent: object) -> str: + """Return the immutable root-controller journal binding for an intent.""" + current = _validate_intent(intent) + return _intent_binding(current) + + +def execution_status() -> dict[str, Any]: + """Report the code gate without claiming live target readiness.""" + return { + "apiVersion": PROTOCOL_VERSION, + "repository": REPOSITORY, + "status": "enabled" if EXECUTION_ENABLED is True else "disabled", + "reason": None if EXECUTION_ENABLED is True else "controller_adoption_execution_disabled", + "executionEnabled": EXECUTION_ENABLED, + } diff --git a/backend/app/services/repopulse_proxy_stage.py b/backend/app/services/repopulse_proxy_stage.py new file mode 100644 index 0000000..1438132 --- /dev/null +++ b/backend/app/services/repopulse_proxy_stage.py @@ -0,0 +1,114 @@ +"""Operator-only staging of the single authenticated RepoPulse NPM route.""" + +from __future__ import annotations + +import json +import re + +from sqlmodel import Session + +from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector +from ..core.config import settings +from ..db.session import engine +from . import repopulse_adoption_journal as journal +from .enrollment_plane import EnrollmentPlane +from .setup_service import apply_stored_connector_settings + + +class RepoPulseProxyStageError(ValueError): + """Fixed, non-secret staging failure.""" + + +def _need(condition: bool, reason: str) -> None: + if not condition: + raise RepoPulseProxyStageError(reason) + + +def stage_repopulse_proxy_host( + session: Session, *, connector: NginxProxyManagerConnector | None = None +) -> dict[str, object]: + """Create or verify only the reserved RepoPulse host and ACL1 contract.""" + row, intent = journal._load(session) + _need(row is not None and isinstance(intent, dict), "adoption_intent_unavailable") + intent_id = intent.get("intentId") + _need( + isinstance(intent_id, str) + and re.fullmatch(r"rpa-[0-9a-f]{20}", intent_id) is not None + and intent.get("status") == "in_progress", + "adoption_intent_unavailable", + ) + trust = EnrollmentPlane.get_trust_config(session) + _need( + trust is not None + and trust.controller_identity == "unraid-controller" + and trust.repopulse_proxy_access_list_id == 1 + and "repopulse-authenticated" in trust.autonomous_profiles + and "itworx-mcp-hub-npm" in trust.approved_networks + and "example.com" in trust.allowed_domain_suffixes, + "repopulse_trust_boundary_mismatch", + ) + apply_stored_connector_settings(session, {"nginx_proxy_manager"}) + identity = settings.apis.get("NPM_IDENTITY") + _need( + isinstance(identity, str) + and re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", identity) is not None, + "certificate_automation_unavailable", + ) + settings.apis["NPM_LETSENCRYPT_EMAIL"] = identity + npm = connector or NginxProxyManagerConnector() + _need(npm.is_configured(), "npm_connector_unavailable") + acl = npm.validate_managed_access_list(1) + _need( + isinstance(acl, dict) + and acl.get("success") is True + and acl.get("status") == "verified" + and acl.get("accessListId") == 1, + "repopulse_access_list_unavailable", + ) + ownership_id = f"enr-{intent_id[4:]}" + result = npm.ensure_managed_proxy_host( + hostname="repopulse.example.com", + forward_host="repopulse", + forward_port=8000, + certificate_mode="managed", + force_ssl=True, + http2=True, + upload_limit_mb=128, + read_timeout_seconds=120, + ownership_id=ownership_id, + access_list_id=1, + ) + _need( + isinstance(result, dict) + and result.get("success") is True + and result.get("status") in {"created", "already_managed"} + and result.get("hostname") == "repopulse.example.com", + "repopulse_proxy_host_unavailable", + ) + return { + "status": "ready", + "intentId": intent_id, + "hostname": "repopulse.example.com", + "accessListId": 1, + "hostId": result.get("hostId"), + } + + +def main() -> int: + import sys + + if len(sys.argv) != 1: + print('{"status":"blocked","reason":"invalid_invocation"}') + return 2 + try: + with Session(engine) as session: + result = stage_repopulse_proxy_host(session) + print(json.dumps(result, separators=(",", ":"))) + return 0 + except Exception: # noqa: BLE001 - connector failures can include sensitive response text. + print('{"status":"blocked","reason":"repopulse_proxy_stage_unavailable"}') + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/services/repopulse_secret_import.py b/backend/app/services/repopulse_secret_import.py new file mode 100644 index 0000000..5db0d3c --- /dev/null +++ b/backend/app/services/repopulse_secret_import.py @@ -0,0 +1,88 @@ +"""One-time, write-only import of RepoPulse's existing Gitea credential. + +Run inside the AppOps container as its service account. The credential is read +from stdin so it never appears in argv, environment, logs, or command output. +""" + +from __future__ import annotations + +import hmac +import json +import sys + +from sqlmodel import Session + +from ..db.session import engine +from .secret_store import get_secret, has_secret, secret_storage_status, set_secret + +SECRET_REF = "REPOPULSE_GITEA_TOKEN" +BOOTSTRAP_SECRET_REF = "ENROLLMENT_BOOTSTRAP" +MAX_SECRET_BYTES = 8192 + + +def _import_fixed_secret( + session: Session, token: str, *, secret_ref: str, used_by: str, purpose: str +) -> str: + if not secret_storage_status()["productionReady"]: + raise ValueError("production secret storage is not ready") + if not token or token != token.strip() or "\n" in token or "\r" in token: + raise ValueError("invalid credential input") + if len(token.encode("utf-8")) > MAX_SECRET_BYTES: + raise ValueError("credential input too large") + + existing = get_secret(session, secret_ref, touch=False) + if existing is None and has_secret(session, secret_ref): + raise ValueError("credential reference cannot be decrypted") + if existing is not None: + if hmac.compare_digest(existing, token): + return "already_present" + raise ValueError("credential reference already contains a different value") + + set_secret( + session, + secret_ref, + token, + used_by=[used_by], + metadata={"purpose": purpose}, + ) + return "imported" + + +def import_repopulse_gitea_token(session: Session, token: str) -> str: + return _import_fixed_secret( + session, + token, + secret_ref=SECRET_REF, + used_by="deployment:repopulse", + purpose="repopulse_gitea_access", + ) + + +def import_controller_bootstrap(session: Session, token: str) -> str: + return _import_fixed_secret( + session, + token, + secret_ref=BOOTSTRAP_SECRET_REF, + used_by="deployment:enrollment_controller", + purpose="controller_enrollment_bootstrap", + ) + + +def main() -> int: + raw = sys.stdin.buffer.read(MAX_SECRET_BYTES + 1) + if len(raw) > MAX_SECRET_BYTES: + print(json.dumps({"status": "blocked", "reason": "credential input too large"})) + return 2 + try: + token = raw.decode("utf-8") + with Session(engine) as session: + outcome = import_repopulse_gitea_token(session, token) + except (UnicodeDecodeError, ValueError): + print(json.dumps({"status": "blocked", "reason": "credential import rejected"})) + return 2 + print(json.dumps({"status": outcome, "secretRef": SECRET_REF})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/services/repopulse_target_credential.py b/backend/app/services/repopulse_target_credential.py new file mode 100644 index 0000000..fbf6225 --- /dev/null +++ b/backend/app/services/repopulse_target_credential.py @@ -0,0 +1,96 @@ +"""One durable credential for the fixed RepoPulse legacy adoption target.""" + +from __future__ import annotations + +import hashlib +import re +import secrets + +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.exc import SQLAlchemyError +from sqlmodel import Session + +from ..core.time import utc_now +from ..db.models import Secret +from . import secret_store + +_INTENT_ID = re.compile(r"^rpa-[0-9a-f]{20}$") +_REF = "ENROLL_REPOPULSE_TARGET_HMAC" + + +class RepoPulseTargetCredentialError(RuntimeError): + """A sanitized fixed-target credential error.""" + + +def get_or_create_repopulse_target_credential( + session: Session, *, intent_id: str +) -> tuple[str, bytes]: + """Insert once, then reuse the encrypted credential on every retry. + + The caller must have durably reserved the same adoption intent before this + method is called. The plaintext is returned only to the in-process private + TLS client and must never be added to a receipt, response, or log. + """ + if not isinstance(intent_id, str) or _INTENT_ID.fullmatch(intent_id) is None: + raise RepoPulseTargetCredentialError("adoption_intent_invalid") + if secret_store.secret_storage_status().get("productionReady") is not True: + raise RepoPulseTargetCredentialError("secret_store_not_ready") + + enrollment_id = f"enr-{intent_id.removeprefix('rpa-')}" + candidate = secrets.token_urlsafe(48) + metadata = { + "intentId": intent_id, + "enrollmentId": enrollment_id, + "rotationState": "active", + "credentialFingerprint": hashlib.sha256(candidate.encode("ascii")).hexdigest()[ + :16 + ], + } + now = utc_now() + try: + session.execute( + sqlite_insert(Secret) + .values( + id=_REF, + name=_REF, + encrypted_value=secret_store._fernet() + .encrypt(candidate.encode("ascii")) + .decode("ascii"), + created_at=now, + updated_at=now, + used_by_json=[f"enrollment:{enrollment_id}", "deployment-controller"], + metadata_json=metadata, + ) + .on_conflict_do_nothing(index_elements=[Secret.id]) + ) + session.commit() + row = session.get(Secret, _REF, populate_existing=True) + if ( + row is None + or row.name != _REF + or not isinstance(row.metadata_json, dict) + or row.metadata_json.get("intentId") != intent_id + or row.metadata_json.get("enrollmentId") != enrollment_id + ): + raise RepoPulseTargetCredentialError("target_credential_collision") + value = secret_store.get_secret(session, _REF, touch=False) + if ( + not value + or not 32 <= len(value) <= 256 + or not value.isascii() + or not value.isprintable() + ): + raise RepoPulseTargetCredentialError("target_credential_unavailable") + if ( + row.metadata_json.get("credentialFingerprint") + != hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + ): + raise RepoPulseTargetCredentialError( + "target_credential_fingerprint_mismatch" + ) + return enrollment_id, value.encode("utf-8") + except SQLAlchemyError: + session.rollback() + raise RepoPulseTargetCredentialError( + "target_credential_storage_unavailable" + ) from None diff --git a/backend/app/services/repopulse_trust_stage.py b/backend/app/services/repopulse_trust_stage.py new file mode 100644 index 0000000..622db25 --- /dev/null +++ b/backend/app/services/repopulse_trust_stage.py @@ -0,0 +1,92 @@ +"""One-time fixed AppOps trust staging for the RepoPulse controller route.""" + +from __future__ import annotations + +import json +import ssl +import sys +import urllib.request + +from sqlmodel import Session + +from ..db.session import engine +from ..schemas.enrollment import EnrollmentTrustConfigV1 +from .deployment_broker import DeploymentBrokerClient +from .enrollment_plane import EnrollmentPlane +from .secret_store import get_secret, has_secret, secret_storage_status + +_BROKER_URL = "https://203.0.113.1:1300" +_CA_FILE = "/data/controller-ca.pem" +_BOOTSTRAP_REF = "ENROLLMENT_BOOTSTRAP" + + +def _need(condition: bool) -> None: + if not condition: + raise ValueError("repopulse_trust_not_ready") + + +def stage( + session: Session, *, broker: DeploymentBrokerClient | None = None +) -> dict[str, str]: + """Write only the exact reviewed trust document after TLS and secrets pass.""" + client = broker or DeploymentBrokerClient() + _need( + client.base_url == _BROKER_URL + and client.verify_tls is True + and client.ca_file == _CA_FILE + and client.configuration_error() is None + ) + _need(secret_storage_status().get("productionReady") is True) + bootstrap = get_secret(session, _BOOTSTRAP_REF, touch=False) + _need(isinstance(bootstrap, str) and 32 <= len(bootstrap) <= 256) + _need(has_secret(session, "REPOPULSE_GITEA_TOKEN")) + context = ssl.create_default_context(cafile=_CA_FILE) + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context) + ) + with opener.open(_BROKER_URL + "/health", timeout=8) as response: + _need(response.status == 200) + body = response.read(4097) + _need(len(body) <= 4096) + health = json.loads(body) + _need(isinstance(health, dict) and health.get("status") == "ok") + + expected = EnrollmentTrustConfigV1( + controller_identity="unraid-controller", + bootstrap_secret_ref=_BOOTSTRAP_REF, + approved_networks=("itworx-mcp-hub-npm",), + allowed_domain_suffixes=("example.com",), + autonomous_profiles=("repopulse-authenticated",), + repopulse_proxy_access_list_id=1, + ) + existing = EnrollmentPlane.get_trust_config(session) + if existing is not None: + _need(existing == expected) + return { + "status": "already_configured", + "controllerIdentity": "unraid-controller", + } + result = EnrollmentPlane.set_trust_config(session, expected) + _need( + result.get("status") == "ready" + and result.get("bootstrapSecretConfigured") is True + ) + return {"status": "configured", "controllerIdentity": "unraid-controller"} + + +def main() -> int: + if len(sys.argv) != 1: + print('{"status":"blocked","reason":"invalid_invocation"}') + return 2 + try: + with Session(engine) as session: + result = stage(session) + print(json.dumps(result, separators=(",", ":"))) + return 0 + except Exception: # noqa: BLE001 - never expose TLS or secret-store details. + print('{"status":"blocked","reason":"repopulse_trust_unavailable"}') + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/services/request_metrics.py b/backend/app/services/request_metrics.py new file mode 100644 index 0000000..46ae973 --- /dev/null +++ b/backend/app/services/request_metrics.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from datetime import UTC, datetime +import math +from threading import Lock +from typing import Any + +from sqlmodel import Session + +from ..core.time import utc_now +from ..db.models import AppSetting + + +_MAX_SAMPLES = 2_000 +_PERSISTED_SAMPLES = 500 +_SETTING_KEY = "observability:request_samples" +_samples: deque[dict[str, Any]] = deque(maxlen=_MAX_SAMPLES) +_lock = Lock() +_source_identity: str | None = None + + +def record_request(*, method: str, path: str, status: int, duration_ms: float) -> None: + sample = { + "method": method, + "path": path, + "status": status, + "durationMs": round(max(0.0, duration_ms), 2), + "observedAt": datetime.now(UTC).isoformat(), + } + with _lock: + _samples.append(sample) + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return 0.0 + rank = max(1, math.ceil(max(0.0, min(percentile, 1.0)) * len(values))) + return values[min(len(values) - 1, rank - 1)] + + +def request_metrics_snapshot() -> dict[str, Any]: + with _lock: + samples = list(_samples) + + grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for sample in samples: + grouped[(sample["method"], sample["path"])].append(sample) + + endpoints = [] + for (method, path), items in grouped.items(): + durations = sorted(float(item["durationMs"]) for item in items) + endpoints.append({ + "method": method, + "path": path, + "requests": len(items), + "errors": sum(1 for item in items if int(item["status"]) >= 400), + "averageMs": round(sum(durations) / len(durations), 2), + "p95Ms": round(_percentile(durations, 0.95), 2), + "maxMs": round(durations[-1], 2), + }) + + endpoints.sort(key=lambda item: (item["p95Ms"], item["requests"]), reverse=True) + return { + "window": "process_and_persisted", + "sampleLimit": _MAX_SAMPLES, + "sampleCount": len(samples), + "generatedAt": datetime.now(UTC).isoformat(), + "slowEndpoints": endpoints[:30], + "recentErrors": [sample for sample in samples if int(sample["status"]) >= 400][-20:], + } + + +def restore_request_metrics(session: Session) -> int: + global _source_identity + source_identity = str(session.get_bind().url) + row = session.get(AppSetting, _SETTING_KEY) + persisted = (row.value_json or {}).get("samples", []) if row else [] + restored = 0 + with _lock: + if _source_identity != source_identity: + _samples.clear() + _source_identity = source_identity + if _samples: + return 0 + for sample in persisted[-_PERSISTED_SAMPLES:]: + if not isinstance(sample, dict): + continue + if not {"method", "path", "status", "durationMs", "observedAt"}.issubset(sample): + continue + _samples.append({ + "method": str(sample["method"])[:12], + "path": str(sample["path"])[:240], + "status": int(sample["status"]), + "durationMs": round(max(0.0, float(sample["durationMs"])), 2), + "observedAt": str(sample["observedAt"])[:64], + }) + restored += 1 + return restored + + +def persist_request_metrics(session: Session) -> int: + with _lock: + samples = list(_samples)[-_PERSISTED_SAMPLES:] + row = session.get(AppSetting, _SETTING_KEY) or AppSetting(key=_SETTING_KEY) + row.value_json = {"samples": samples, "persistedAt": datetime.now(UTC).isoformat()} + row.is_secret = False + row.updated_at = utc_now() + session.add(row) + session.commit() + return len(samples) + + +def request_metrics_openmetrics() -> str: + snapshot = request_metrics_snapshot() + lines = [ + "# HELP appops_http_requests_total Requests observed by method and route template.", + "# TYPE appops_http_requests_total counter", + "# HELP appops_http_request_duration_p95_milliseconds Nearest-rank p95 request duration.", + "# TYPE appops_http_request_duration_p95_milliseconds gauge", + ] + for endpoint in snapshot["slowEndpoints"]: + method = str(endpoint["method"]).replace('"', '') + path = str(endpoint["path"]).replace('\\', '\\\\').replace('"', '\\"') + labels = f'method="{method}",path="{path}"' + lines.append(f'appops_http_requests_total{{{labels}}} {endpoint["requests"]}') + lines.append(f'appops_http_request_errors_total{{{labels}}} {endpoint["errors"]}') + lines.append(f'appops_http_request_duration_p95_milliseconds{{{labels}}} {endpoint["p95Ms"]}') + lines.append(f'appops_http_request_samples {snapshot["sampleCount"]}') + return "\n".join(lines) + "\n" diff --git a/backend/app/services/runner_fleet.py b/backend/app/services/runner_fleet.py new file mode 100644 index 0000000..26ff782 --- /dev/null +++ b/backend/app/services/runner_fleet.py @@ -0,0 +1,82 @@ +"""Compose bounded Gitea and controller runner-fleet telemetry. + +AppOps is intentionally a consumer of this projection. The root-owned +controller remains the only component that may perform an allowlisted recovery +or cleanup inside the isolated CI Docker daemon. +""" + +from __future__ import annotations + +from typing import Any + +from ..connectors.gitea_actions import GiteaActionsConnector +from .deployment_broker import DeploymentBrokerClient + + +_CAPABILITIES = ( + "linux-validation", + "docker-validation", + "browser-validation", + "security-validation", + "windows-native", +) +_SEVERITY_RANK = {"healthy": 0, "warning": 1, "critical": 2, "unknown": 3} + + +def _severity(*states: object) -> str: + normalized = [str(state) for state in states] + return max(normalized, key=lambda value: _SEVERITY_RANK.get(value, 3), default="unknown") + + +def _capabilities(runners: list[dict[str, Any]]) -> list[dict[str, object]]: + rows = [] + for capability in _CAPABILITIES: + compatible = [runner for runner in runners if capability in runner.get("labels", [])] + online = [ + runner + for runner in compatible + if runner.get("status") == "online" and not runner.get("disabled") + ] + rows.append( + { + "name": capability, + "total": len(compatible), + "online": len(online), + "busy": sum(bool(runner.get("busy")) for runner in online), + "status": "healthy" if online else "critical", + } + ) + return rows + + +class RunnerFleetService: + """Provide a read-only, explainable CI fleet projection.""" + + def snapshot(self) -> dict[str, object]: + actions = GiteaActionsConnector().snapshot() + controller = DeploymentBrokerClient().runner_fleet_status() + runners = actions.get("runners") if isinstance(actions.get("runners"), list) else [] + typed_runners = [runner for runner in runners if isinstance(runner, dict)] + capabilities = _capabilities(typed_runners) + controller_status = str(controller.get("status") or "unavailable") + controller_severity = ( + controller_status + if controller_status in _SEVERITY_RANK + else "warning" if controller_status != "unavailable" else "unknown" + ) + action_severity = str(actions.get("severity") or "unknown") + capability_severity = _severity(*(row["status"] for row in capabilities)) + return { + "status": _severity(action_severity, controller_severity, capability_severity), + "generatedAt": actions.get("generatedAt"), + "runners": typed_runners, + "capabilities": capabilities, + "queue": actions.get("queue") if isinstance(actions.get("queue"), dict) else {}, + "controller": controller, + "actions": [ + {"intent": "runner.inspect", "mode": "read_only"}, + {"intent": "runner.cleanup.preview", "mode": "read_only"}, + {"intent": "runner.restart", "mode": "root_allowlisted_and_bounded"}, + {"intent": "runner.drain", "mode": "operator_controlled"}, + ], + } diff --git a/backend/app/services/runnerless_target_provisioning.py b/backend/app/services/runnerless_target_provisioning.py new file mode 100644 index 0000000..40f6c83 --- /dev/null +++ b/backend/app/services/runnerless_target_provisioning.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +import re +import time +from typing import Any +from urllib.parse import quote + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$") +_BRANCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$") +_CONTROLLER_ROOT = "http://198.19.0.1:1299" + +_PROVISION_SCRIPT = r""" +import json +import os +from pathlib import Path +import re +import secrets +import stat +import subprocess +import sys + +repository, branch, controller_root = sys.argv[1:4] +repository_pattern = re.compile(r"^jens/[a-z0-9][a-z0-9_.-]{0,96}$") +branch_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,119}$") +if ( + not repository_pattern.fullmatch(repository) + or not branch_pattern.fullmatch(branch) + or ".." in branch + or "//" in branch + or branch.endswith("/") + or controller_root != "http://198.19.0.1:1299" +): + raise SystemExit(40) + +projects_path = Path("/opt/gitea-deploy/projects.json") +secret_root = Path("/opt/gitea-deploy/webhook-secrets") +admin_token_path = Path( + "/mnt/cache/appdata/gitea-deploy-runners/secrets/runner_guard_admin_token" +) +secret_name = repository.replace("/", "__") +secret_path = secret_root / secret_name +endpoint = f"{controller_root}/v1/gitea/push/{repository}" +expected_validation = { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, +} + + +def safe_regular(path: Path, *, private: bool = False) -> None: + if path.is_symlink() or not path.is_file(): + raise SystemExit(20) + info = path.stat() + if os.name != "nt" and info.st_uid != os.geteuid(): + raise SystemExit(21) + if private and os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o077: + raise SystemExit(22) + + +def safe_directory(path: Path) -> None: + if path.is_symlink() or not path.is_dir(): + raise SystemExit(20) + info = path.stat() + if os.name != "nt" and info.st_uid != os.geteuid(): + raise SystemExit(21) + if os.name != "nt" and stat.S_IMODE(info.st_mode) & 0o077: + raise SystemExit(22) + + +safe_regular(projects_path) +safe_regular(admin_token_path, private=True) +safe_directory(secret_root) + +try: + original_projects_bytes = projects_path.read_bytes() + projects = json.loads(original_projects_bytes) +except Exception: + raise SystemExit(23) +project = projects.get(repository) if isinstance(projects, dict) else None +if ( + not isinstance(project, dict) + or project.get("branch") != branch + or project.get("exact_source") is not True + or not isinstance(project.get("compose_files"), list) + or not project.get("compose_files") + or not isinstance(project.get("containers"), list) + or not project.get("containers") +): + raise SystemExit(24) + +triplet = { + "gitea_repository_id", + "gitea_webhook_secret_file", + "validation", +} +present = triplet.intersection(project) +if present and present != triplet: + raise SystemExit(25) +already_configured = present == triplet +if already_configured: + repository_id = project.get("gitea_repository_id") + if ( + isinstance(repository_id, bool) + or not isinstance(repository_id, int) + or repository_id <= 0 + or project.get("gitea_webhook_secret_file") != secret_name + or project.get("validation") != expected_validation + ): + raise SystemExit(26) + safe_regular(secret_path, private=True) + +token = admin_token_path.read_text(encoding="utf-8").strip() +if not 20 <= len(token) <= 512 or any(ch in token for ch in "\r\n"): + raise SystemExit(27) + +health = subprocess.run( + [ + "docker", + "exec", + "Gitea", + "curl", + "--fail", + "--silent", + "--show-error", + "--max-time", + "5", + f"{controller_root}/health", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, +) +if health.returncode != 0: + raise SystemExit(28) +try: + health_payload = json.loads(health.stdout) +except json.JSONDecodeError: + raise SystemExit(29) +if ( + not isinstance(health_payload, dict) + or health_payload.get("status") != "ok" + or health_payload.get("protocolVersion") != "itworx-controller-request/v2" +): + raise SystemExit(29) + + +def request(method: str, path: str, payload=None): + cmd = [ + "docker", + "exec", + "-i", + "Gitea", + "curl", + "--fail", + "--silent", + "--show-error", + "--max-time", + "15", + "--request", + method, + "--header", + f"Authorization: token {token}", + "--header", + "Accept: application/json", + ] + raw = None + if payload is not None: + cmd.extend( + [ + "--header", + "Content-Type: application/json", + "--data-binary", + "@-", + ] + ) + raw = json.dumps(payload, separators=(",", ":")) + cmd.append(f"http://127.0.0.1:3000/api/v1{path}") + result = subprocess.run( + cmd, + input=raw, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=20, + check=False, + ) + if result.returncode != 0: + raise SystemExit(30) + if not result.stdout.strip(): + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + raise SystemExit(31) + + +owner, name = repository.split("/", 1) +repo = request("GET", f"/repos/{owner}/{name}") +if ( + not isinstance(repo, dict) + or not isinstance(repo.get("id"), int) + or isinstance(repo.get("id"), bool) + or str(repo.get("full_name") or "").lower() != repository + or repo.get("default_branch") != branch +): + raise SystemExit(32) +repository_id = int(repo["id"]) +if already_configured and project.get("gitea_repository_id") != repository_id: + raise SystemExit(33) + +hooks = request("GET", f"/repos/{owner}/{name}/hooks") +if not isinstance(hooks, list): + raise SystemExit(34) +exact = [] +semantic_other = [] +for hook in hooks: + if not isinstance(hook, dict): + continue + config = hook.get("config") + url = str(config.get("url") or "") if isinstance(config, dict) else "" + if url == endpoint: + exact.append(hook) + elif url.endswith(f"/v1/gitea/push/{repository}"): + semantic_other.append(hook) +if len(exact) > 1 or semantic_other: + raise SystemExit(35) +if exact and not already_configured: + # Never adopt an unknown existing hook/secret relationship. + raise SystemExit(36) + +created_secret = False +created_hook_id = None +project_replaced = False + +try: + if already_configured: + secret = secret_path.read_text(encoding="utf-8").strip() + else: + secret = secrets.token_hex(32) + temporary_secret = secret_root / f".{secret_name}.{os.getpid()}.tmp" + descriptor = os.open( + temporary_secret, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(secret + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_secret, secret_path) + created_secret = True + finally: + try: + temporary_secret.unlink(missing_ok=True) + except OSError: + pass + + project["gitea_repository_id"] = repository_id + project["gitea_webhook_secret_file"] = secret_name + project["validation"] = expected_validation + temporary_projects = projects_path.with_name( + f".{projects_path.name}.runnerless-{os.getpid()}.tmp" + ) + payload = ( + json.dumps(projects, sort_keys=True, separators=(",", ":")) + "\n" + ) + descriptor = os.open( + temporary_projects, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_projects, projects_path) + project_replaced = True + directory_fd = os.open(str(projects_path.parent), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + temporary_projects.unlink(missing_ok=True) + except OSError: + pass + + if not 32 <= len(secret) <= 256 or any( + ord(ch) < 33 or ord(ch) > 126 for ch in secret + ): + raise SystemExit(37) + + hook_payload = { + "active": True, + "branch_filter": branch, + "events": ["push"], + "config": { + "url": endpoint, + "content_type": "json", + "secret": secret, + "insecure_ssl": "0", + }, + } + if exact: + hook_id = exact[0].get("id") + if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0: + raise SystemExit(38) + updated = request( + "PATCH", + f"/repos/{owner}/{name}/hooks/{hook_id}", + hook_payload, + ) + else: + created = request( + "POST", + f"/repos/{owner}/{name}/hooks", + {"type": "gitea", **hook_payload}, + ) + hook_id = created.get("id") if isinstance(created, dict) else None + if isinstance(hook_id, bool) or not isinstance(hook_id, int) or hook_id <= 0: + raise SystemExit(39) + created_hook_id = hook_id + updated = created + + if not isinstance(updated, dict): + updated = request( + "GET", + f"/repos/{owner}/{name}/hooks/{hook_id}", + ) + config_out = updated.get("config") if isinstance(updated, dict) else None + if ( + not isinstance(updated, dict) + or updated.get("id") != hook_id + or updated.get("active") is not True + or str(updated.get("branch_filter") or "") != branch + or not isinstance(config_out, dict) + or config_out.get("url") != endpoint + or str(config_out.get("content_type") or "").lower() != "json" + or updated.get("events") != ["push"] + ): + raise SystemExit(41) + +except BaseException: + rollback_failed = False + if created_hook_id is not None: + try: + request( + "DELETE", + f"/repos/{owner}/{name}/hooks/{created_hook_id}", + ) + except BaseException: + rollback_failed = True + if project_replaced: + try: + temporary_restore = projects_path.with_name( + f".{projects_path.name}.restore-{os.getpid()}.tmp" + ) + descriptor = os.open( + temporary_restore, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + ) + with os.fdopen(descriptor, "wb") as handle: + handle.write(original_projects_bytes) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_restore, projects_path) + except BaseException: + rollback_failed = True + if created_secret: + try: + secret_path.unlink(missing_ok=True) + except OSError: + rollback_failed = True + if rollback_failed: + raise SystemExit(42) + raise + +print( + f"runnerless_target_ready repository={repository} branch={branch} " + f"hook_id={hook_id} state={'existing' if already_configured else 'provisioned'}", + flush=True, +) +""" + + +class RunnerlessTargetProvisioning: + """Provision runnerless delivery only for an already registered controller target.""" + + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + def run(self, *, repository: str, branch: str) -> dict[str, Any]: + repository = str(repository or "").strip().lower() + branch = str(branch or "").strip() + if ( + not _REPOSITORY.fullmatch(repository) + or not _BRANCH.fullmatch(branch) + or ".." in branch + or "//" in branch + or branch.endswith("/") + ): + return { + "status": "invalid", + "reason": "runnerless target identity is outside the bounded contract", + } + + status = self.portainer.get_container_status(_CONTROLLER) + controller = ( + dict(status.get("container") or {}) + if status.get("status") == "ok" + else {} + ) + container_id = str(controller.get("id") or "") + config = self.portainer._config() + if ( + controller.get("name") != _CONTROLLER + or controller.get("state") != "running" + or not container_id + or not config + ): + return { + "status": "controller_unavailable", + "reason": "deployment controller is unavailable", + } + + endpoint_id = quote(str(config.endpoint_id), safe="") + safe_container = quote(container_id, safe="") + command = [ + "python3", + "-c", + _PROVISION_SCRIPT, + repository, + branch, + _CONTROLLER_ROOT, + ] + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint_id}/docker/containers/{safe_container}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": command, + }, + ).json() + exec_id = str(created.get("Id") or "") + if not exec_id: + return { + "status": "error", + "reason": "runnerless target provisioning could not start", + } + safe_exec = quote(exec_id, safe="") + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint_id}/docker/exec/{safe_exec}/start", + json={"Detach": True, "Tty": False}, + ) + deadline = time.monotonic() + 90 + exit_code: int | None = None + while time.monotonic() < deadline: + inspected = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint_id}/docker/exec/{safe_exec}/json", + ).json() + if not bool(inspected.get("Running")): + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + break + time.sleep(0.25) + except (RuntimeError, ValueError, TypeError) as exc: + return {"status": "error", "reason": redact_message(str(exc))} + + if exit_code is None: + return { + "status": "outcome_unknown", + "reason": "runnerless provisioning timed out; inspect before retrying", + } + if exit_code == 0: + return { + "status": "ready", + "repository": repository, + "branch": branch, + "endpoint": f"{_CONTROLLER_ROOT}/v1/gitea/push/{repository}", + } + + reasons = { + 20: "required controller/Gitea path is unavailable or unsafe", + 21: "required controller/Gitea path ownership is invalid", + 22: "required private path permissions are too broad", + 23: "deployment projects registry is invalid", + 24: "repository is not an already registered exact-source controller target", + 25: "runnerless target has partial controller metadata", + 26: "existing runnerless target metadata differs from the fixed contract", + 27: "Gitea admin credential is unavailable or invalid", + 28: "Gitea cannot reach the private deployment controller", + 29: "private deployment controller identity is invalid", + 30: "bounded Gitea API request failed", + 31: "Gitea returned invalid JSON", + 32: "Gitea repository identity or default branch differs from the target", + 33: "Gitea repository id differs from existing controller metadata", + 34: "Gitea webhook inventory is invalid", + 35: "runnerless target has ambiguous webhook endpoints", + 36: "an unbound runnerless webhook already exists and cannot be adopted", + 37: "runnerless secret material failed validation", + 38: "existing runnerless hook id is invalid", + 39: "Gitea did not return a valid hook after creation", + 41: "runnerless webhook verification failed after provisioning", + 42: "runnerless provisioning compensation failed; operator inspection required", + 40: "runnerless provisioning contract was altered", + } + return { + "status": "validation_failed", + "reason": reasons.get( + exit_code, + "runnerless target provisioning failed closed", + ), + } diff --git a/backend/app/services/runtime_service.py b/backend/app/services/runtime_service.py new file mode 100644 index 0000000..448b93b --- /dev/null +++ b/backend/app/services/runtime_service.py @@ -0,0 +1,603 @@ +from __future__ import annotations + +import hashlib +import json +import shutil +import sqlite3 +import tempfile +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from sqlmodel import Session, select + +from ..core.config import settings +from ..core.version import APP_VERSION +from ..db.models import ( + AppContainerMapping, + AppSetting, + AuditEvent, + DiscoveredContainer, + NotificationChannel, + NotificationRule, + Policy, +) +from .audit_logger import log_event +from .secret_store import secret_storage_status +from .setup_service import get_setup_status, list_connector_settings + +_BACKUP_LOCK = threading.Lock() +_OPERATOR_AUTH_SETTING_KEY = "security.operator_auth" + + +def apply_stored_operator_auth_setting(session: Session) -> None: + stored = session.get(AppSetting, _OPERATOR_AUTH_SETTING_KEY) + if stored and isinstance(stored.value_json, dict) and isinstance(stored.value_json.get("required"), bool): + settings.operator_auth_required = stored.value_json["required"] + + +def operator_auth_policy() -> dict[str, bool]: + return {"required": settings.operator_auth_required} + + +def update_operator_auth_policy(session: Session, required: bool) -> dict[str, bool]: + stored = session.get(AppSetting, _OPERATOR_AUTH_SETTING_KEY) + if stored is None: + stored = AppSetting(key=_OPERATOR_AUTH_SETTING_KEY, is_secret=False) + stored.value_json = {"required": required} + stored.updated_at = datetime.now(timezone.utc) + session.add(stored) + settings.operator_auth_required = required + log_event( + session, + source="manual", + identity="operator", + action_id="security.operator_auth.update", + target="operator_session", + target_type="security_setting", + risk="medium", + policy_decision="allowed", + reason="Operator session requirement changed from Settings.", + result="success", + redacted_metadata={"required": required}, + ) + session.commit() + return operator_auth_policy() + + +def _data_dir() -> Path: + return Path(settings.data_dir) + + +def _static_dir() -> Path: + return Path(settings.static_dir) + + +def _backup_dir() -> Path: + return _data_dir() / "backups" + + +def is_data_dir_writable() -> bool: + try: + _data_dir().mkdir(parents=True, exist_ok=True) + probe = _data_dir() / ".appops-write-test" + probe.write_text("ok", encoding="utf-8") + probe.unlink(missing_ok=True) + return True + except OSError: + return False + + +def db_status(session: Session) -> str: + try: + session.exec(select(Policy).limit(1)).all() + return "ok" + except Exception: + return "error" + + +def frontend_assets_available() -> bool: + return (_static_dir() / "index.html").exists() + + +def production_warnings() -> list[str]: + warnings: list[str] = [] + if settings.environment == "production": + proxy_operator_ready = bool( + settings.operator_proxy_auth_enabled + and settings.operator_proxy_trusted_networks + and (settings.operator_proxy_users or settings.operator_proxy_groups) + ) + if not settings.operator_auth_required: + warnings.append("APPOPS_REQUIRE_OPERATOR_AUTH is disabled; private LAN operator routes are unlocked.") + elif not proxy_operator_ready and (not settings.operator_token or settings.operator_token in {"change-me", "changeme", "dev", "test"}): + warnings.append("APPOPS_OPERATOR_TOKEN is missing or unsafe; protected operator routes fail closed.") + if settings.operator_proxy_auth_enabled and not proxy_operator_ready: + warnings.append("Account-bound operator auth is enabled but trusted proxy networks and an allowed user or group are not fully configured.") + if not settings.voice_token or settings.voice_token in {"change-me", "changeme", "dev", "test"}: + warnings.append("APP_OPS_VOICE_TOKEN is missing or unsafe for production.") + warnings.extend(secret_storage_status()["warnings"]) + return warnings + + +def connector_config_summary(session: Session) -> dict[str, Any]: + return { + item["connectorId"]: { + "configured": item["configured"], + "hasSecret": item["hasSecret"], + "missing": item["missing"], + } + for item in list_connector_settings(session) + } + + +def runtime_info(session: Session) -> dict[str, Any]: + setup_status = get_setup_status(session) + storage_status = secret_storage_status() + notification_rules = session.exec(select(NotificationRule)).all() + notification_channels = session.exec(select(NotificationChannel)).all() + source_metadata = Path("/etc/appops/source-metadata") + source_lines = source_metadata.read_text(encoding="utf-8").splitlines() if source_metadata.is_file() else [] + source_repository, source_revision, source_tree = source_lines if len(source_lines) == 3 else ("unknown", "unknown", "unknown") + return { + "version": APP_VERSION, + "sourceRepository": source_repository, + "sourceRevision": source_revision, + "sourceTree": source_tree, + "environment": settings.environment, + "dataDirConfigured": bool(settings.data_dir), + "dataDir": str(_data_dir()), + "dbPath": settings.db_path, + "dbStatus": db_status(session), + "frontendMode": "static" if frontend_assets_available() else "api_only", + "frontendAssetsAvailable": frontend_assets_available(), + "setupComplete": setup_status["setupComplete"], + "liveReady": setup_status["liveReady"], + "firstRunCompleted": setup_status["firstRunCompleted"], + "coverageComplete": setup_status["coverageComplete"], + "missingOptionalSources": setup_status["missingOptionalSources"], + "canonicalPort": 1216, + "canonicalInstance": settings.port == 1216, + "secretStorageStatus": storage_status, + "notificationRulesStatus": { + "rules": len(notification_rules), + "channels": len(notification_channels), + "homeAssistantConfigured": any(channel.id == "home_assistant" and channel.enabled for channel in notification_channels), + }, + "setupStatus": setup_status, + "connectorConfigSummary": connector_config_summary(session), + "secretStorage": storage_status, + "productionWarnings": production_warnings(), + } + + +def deep_health(session: Session) -> dict[str, Any]: + checks = { + "dbReachable": db_status(session) == "ok", + "dataDirWritable": is_data_dir_writable(), + "frontendAssetsAvailable": frontend_assets_available(), + "secretStorageReady": secret_storage_status()["status"] in {"ready"}, + } + return { + "status": "ok" if all(checks.values()) or (checks["dbReachable"] and checks["dataDirWritable"]) else "degraded", + "checks": checks, + "setup": get_setup_status(session), + "connectors": connector_config_summary(session), + "secretStorage": secret_storage_status(), + "warnings": production_warnings(), + } + + +def _backup_metadata(path: Path) -> dict[str, Any]: + stat = path.stat() + created = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z") + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + manifest_path = path.with_suffix('.manifest.json') + return { + "name": path.name, + "path": str(path), + "sizeBytes": stat.st_size, + "createdAt": created, + "sha256": digest.hexdigest(), + "manifestAvailable": manifest_path.is_file(), + } + + +def _backup_manifest_path(path: Path) -> Path: + return path.with_suffix('.manifest.json') + + +def list_sqlite_backups(limit: int = 20) -> dict[str, Any]: + backup_dir = _backup_dir() + backups = [] + if backup_dir.exists(): + backups = sorted( + (item for item in backup_dir.glob("appops-db-*.db") if item.is_file()), + key=lambda item: item.stat().st_mtime, + reverse=True, + )[:limit] + return { + "dbPath": settings.db_path, + "backupDir": str(backup_dir), + "backups": [_backup_metadata(item) for item in backups], + "restoreMode": "manual_stop_container", + } + + +def sqlite_maintenance_status() -> dict[str, Any]: + db_path = Path(settings.db_path) + wal_path = Path(f"{settings.db_path}-wal") + result: dict[str, Any] = { + "status": "missing" if not db_path.is_file() else "ready", + "databaseBytes": db_path.stat().st_size if db_path.is_file() else 0, + "walBytes": wal_path.stat().st_size if wal_path.is_file() else 0, + "journalMode": "unknown", + "pageCount": 0, + "freePages": 0, + "reclaimableBytes": 0, + "recommendation": "none", + } + if not db_path.is_file(): + return result + try: + connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5) + try: + page_size = int(connection.execute("PRAGMA page_size").fetchone()[0]) + result["pageCount"] = int(connection.execute("PRAGMA page_count").fetchone()[0]) + result["freePages"] = int(connection.execute("PRAGMA freelist_count").fetchone()[0]) + result["journalMode"] = str(connection.execute("PRAGMA journal_mode").fetchone()[0]) + result["reclaimableBytes"] = result["freePages"] * page_size + finally: + connection.close() + except (OSError, sqlite3.Error, TypeError, ValueError): + result["status"] = "error" + return result + + if result["walBytes"] >= 16 * 1024 * 1024: + result["recommendation"] = "checkpoint" + elif result["reclaimableBytes"] >= max(32 * 1024 * 1024, result["databaseBytes"] // 4): + result["recommendation"] = "offline_vacuum_after_backup" + return result + + +def checkpoint_sqlite(session: Session) -> dict[str, Any]: + before = sqlite_maintenance_status() + backup = create_sqlite_backup(session) + if not backup.get("success"): + return {"success": False, "status": "backup_failed", "before": before, "backup": backup} + try: + connection = sqlite3.connect(settings.db_path, timeout=30) + try: + checkpoint = connection.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() + finally: + connection.close() + after = sqlite_maintenance_status() + success = bool(checkpoint) and int(checkpoint[0]) == 0 + except (OSError, sqlite3.Error, TypeError, ValueError): + checkpoint = None + after = sqlite_maintenance_status() + success = False + audit = log_event( + session, + source="manual", + identity="operator", + action_id="runtime.sqlite.checkpoint", + target="appops.db", + target_type="runtime_database", + risk="low", + policy_decision="allowed", + reason="Operator requested a backup-first SQLite WAL checkpoint.", + result="success" if success else "failed", + redacted_metadata={"beforeWalBytes": before["walBytes"], "afterWalBytes": after["walBytes"]}, + ) + return { + "success": success, + "status": "checkpointed" if success else "failed", + "before": before, + "after": after, + "backup": backup.get("backup"), + "auditId": audit.id, + } + + +def _sqlite_backup_path(name: str) -> Path | None: + normalized_name = Path(str(name)).name + if normalized_name != name or not normalized_name.startswith('appops-db-') or not normalized_name.endswith('.db'): + return None + backup_dir = _backup_dir().resolve() + candidate = (backup_dir / normalized_name).resolve() + if candidate.parent != backup_dir or not candidate.is_file(): + return None + return candidate + + +def get_sqlite_backup_path(name: str) -> Path | None: + return _sqlite_backup_path(name) + + +def verify_sqlite_backup( + session: Session, + name: str, + *, + source: str = 'manual', + identity: str = 'operator', + reason: str = 'Existing SQLite backup integrity verification requested from Settings.', +) -> dict[str, Any]: + path = _sqlite_backup_path(name) + if not path: + return { + 'success': False, + 'status': 'not_found', + 'message': 'SQLite backup not found.', + 'restoreMode': 'manual_stop_container', + } + integrity_check = 'unknown' + restore_check = 'unknown' + manifest_check = 'legacy_missing' + try: + metadata = _backup_metadata(path) + manifest_path = _backup_manifest_path(path) + if manifest_path.is_file(): + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + manifest_check = 'ok' if ( + manifest.get('databaseFile') == path.name + and manifest.get('sha256') == metadata['sha256'] + and manifest.get('sizeBytes') == metadata['sizeBytes'] + ) else 'mismatch' + connection = sqlite3.connect(f'file:{path}?mode=ro', uri=True) + try: + result = connection.execute('PRAGMA integrity_check').fetchone() + finally: + connection.close() + integrity_check = str(result[0]) if result else 'unknown' + with tempfile.TemporaryDirectory(prefix="appops-restore-check-") as temp_dir: + restored = Path(temp_dir) / "restored.db" + shutil.copy2(path, restored) + restored_connection = sqlite3.connect(restored) + try: + restored_integrity = restored_connection.execute("PRAGMA integrity_check").fetchone() + restored_connection.execute("SELECT name FROM sqlite_master LIMIT 1").fetchone() + finally: + restored_connection.close() + restore_check = str(restored_integrity[0]) if restored_integrity else 'unknown' + success = integrity_check == 'ok' and restore_check == 'ok' and manifest_check != 'mismatch' + except (OSError, ValueError, json.JSONDecodeError, sqlite3.Error): + success = False + + audit = log_event( + session, + source=source, + identity=identity, + action_id='runtime.sqlite_backup.verify', + target=path.name, + target_type='runtime_backup', + risk='low', + policy_decision='allowed', + reason=reason, + result='success' if success else 'failed', + execution_plan={'steps': ['open backup read-only', 'PRAGMA integrity_check', 'write audit']}, + post_checks={'integrityCheck': integrity_check, 'restoreCheck': restore_check, 'manifestCheck': manifest_check, 'backupUnchanged': True}, + redacted_metadata={'backupName': path.name, 'sizeBytes': path.stat().st_size}, + ) + return { + 'success': success, + 'status': 'verified' if success else 'failed', + 'message': 'SQLite backup integrity is valid.' if success else 'SQLite backup integrity check failed.', + 'backup': _backup_metadata(path), + 'integrityCheck': integrity_check, + 'restoreCheck': restore_check, + 'manifestCheck': manifest_check, + 'auditId': audit.id, + 'restoreMode': 'manual_stop_container', + } + + +def verify_latest_backup_drill(session: Session) -> dict[str, Any]: + backups = list_sqlite_backups(limit=1).get('backups', []) + if not backups: + return {'success': False, 'status': 'no_backup', 'restoreMode': 'manual_stop_container'} + return verify_sqlite_backup( + session, + str(backups[0]['name']), + source='system', + identity='restore-drill', + reason='Scheduled read-only backup integrity and disposable restore-copy drill.', + ) + + +def create_sqlite_backup(session: Session) -> dict[str, Any]: + source = Path(settings.db_path) + destination_dir = _backup_dir() + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + destination = destination_dir / f"appops-db-{timestamp}.db" + + if not _BACKUP_LOCK.acquire(blocking=False): + return {"success": False, "status": "busy", "message": "A SQLite backup is already running.", "restoreMode": "manual_stop_container"} + try: + if not source.exists(): + raise FileNotFoundError("configured SQLite database does not exist") + destination_dir.mkdir(parents=True, exist_ok=True) + free_bytes = shutil.disk_usage(destination_dir).free + required_bytes = max(source.stat().st_size * 2, 16 * 1024 * 1024) + if free_bytes < required_bytes: + raise OSError("insufficient_space_for_backup") + with sqlite3.connect(source) as source_conn, sqlite3.connect(destination) as destination_conn: + source_conn.backup(destination_conn) + integrity = destination_conn.execute("PRAGMA integrity_check").fetchone() + integrity_check = str(integrity[0]) if integrity else "unknown" + success = integrity_check == "ok" + metadata = _backup_metadata(destination) + manifest = { + "schemaVersion": 1, + "appVersion": APP_VERSION, + "databaseFile": destination.name, + "createdAt": metadata["createdAt"], + "sizeBytes": metadata["sizeBytes"], + "sha256": metadata["sha256"], + "integrityCheck": integrity_check, + "restoreMode": "manual_stop_container", + "secretKeySource": settings.secret_key_source, + } + _backup_manifest_path(destination).write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + metadata["manifestAvailable"] = True + retained = sorted( + (item for item in destination_dir.glob("appops-db-*.db") if item.is_file()), + key=lambda item: item.stat().st_mtime, + reverse=True, + ) + for stale in retained[settings.backup_retention:]: + stale.unlink() + _backup_manifest_path(stale).unlink(missing_ok=True) + audit = log_event( + session, + source="manual", + identity="operator", + action_id="runtime.sqlite_backup", + target="appops-db", + target_type="runtime", + risk="low", + policy_decision="allowed", + reason="SQLite online backup requested from Settings.", + result="success" if success else "failed", + execution_plan={"steps": ["sqlite backup api", "integrity_check", "write audit"]}, + post_checks={"integrityCheck": integrity_check, "backupCreated": destination.exists()}, + redacted_metadata={"backupPath": str(destination), "sizeBytes": metadata["sizeBytes"]}, + ) + return { + "success": success, + "status": "created" if success else "failed", + "message": "SQLite backup created." if success else "SQLite backup integrity check failed.", + "backup": metadata, + "integrityCheck": integrity_check, + "auditId": audit.id, + "restoreMode": "manual_stop_container", + } + except (OSError, sqlite3.Error) as exc: + audit = log_event( + session, + source="manual", + identity="operator", + action_id="runtime.sqlite_backup", + target="appops-db", + target_type="runtime", + risk="low", + policy_decision="allowed", + reason="SQLite online backup requested from Settings.", + result="failed", + execution_plan={"steps": ["sqlite backup api", "integrity_check", "write audit"]}, + post_checks={"backupCreated": False}, + redacted_metadata={"errorType": type(exc).__name__}, + ) + return { + "success": False, + "status": "failed", + "message": "SQLite backup failed.", + "reason": type(exc).__name__, + "auditId": audit.id, + "restoreMode": "manual_stop_container", + } + finally: + _BACKUP_LOCK.release() + + +def export_config(session: Session) -> dict[str, Any]: + return { + "runtime": { + "environment": settings.environment, + "dataDir": str(_data_dir()), + "dbPath": settings.db_path, + "frontendOrigin": settings.frontend_origin, + "secretStorage": secret_storage_status(), + }, + "connectors": list_connector_settings(session), + "setup": get_setup_status(session), + "note": "Secret values are intentionally excluded.", + } + + +def export_policies(session: Session) -> dict[str, Any]: + policies = session.exec(select(Policy).order_by(Policy.id)).all() + return { + "policies": [ + { + "id": policy.id, + "action": policy.action, + "target": policy.target, + "voice": policy.voice, + "manual": policy.manual, + "risk": policy.risk, + "reason": policy.reason, + } + for policy in policies + ] + } + + +def export_inventory(session: Session) -> dict[str, Any]: + discovered = session.exec(select(DiscoveredContainer).order_by(DiscoveredContainer.name)).all() + mappings = session.exec(select(AppContainerMapping).order_by(AppContainerMapping.container_name)).all() + return { + "discovered": [ + { + "id": item.id, + "containerId": item.container_id, + "name": item.name, + "image": item.image, + "state": item.state, + "status": item.status, + "suggestedAppType": item.suggested_app_type, + "suggestedCategory": item.suggested_category, + "suggestedCriticality": item.suggested_criticality, + "suggestedConnector": item.suggested_connector, + "importStatus": item.import_status, + "matchedAppId": item.matched_app_id, + } + for item in discovered + ], + "mappings": [ + { + "id": mapping.id, + "appId": mapping.app_id, + "containerId": mapping.container_id, + "containerName": mapping.container_name, + "role": mapping.role, + "source": mapping.source, + "isPrimary": mapping.is_primary, + } + for mapping in mappings + ], + } + + +def export_audit_summary(session: Session) -> dict[str, Any]: + events = session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all() + by_result: dict[str, int] = {} + by_action: dict[str, int] = {} + for event in events: + by_result[event.result] = by_result.get(event.result, 0) + 1 + by_action[event.action_id] = by_action.get(event.action_id, 0) + 1 + return { + "total": len(events), + "byResult": by_result, + "byAction": by_action, + "recent": [ + { + "id": event.id, + "timestamp": event.timestamp.isoformat(), + "source": event.source, + "action": event.action_id, + "target": event.target, + "risk": event.risk, + "policyDecision": event.policy_decision, + "result": event.result, + } + for event in events[:25] + ], + } diff --git a/backend/app/services/safety_center_service.py b/backend/app/services/safety_center_service.py new file mode 100644 index 0000000..fc09db6 --- /dev/null +++ b/backend/app/services/safety_center_service.py @@ -0,0 +1,850 @@ +from __future__ import annotations + +from collections import Counter +from datetime import UTC, datetime +import hashlib +import json +import logging +import os +from threading import Lock, Thread +from time import perf_counter +from typing import Any + +from sqlalchemy import func +from sqlmodel import Session, select + +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import App, AppSetting, AuditEvent, Connector, DiscoveryRun, JobRun, Policy, Secret +from . import ai_remediation_planner, ai_security_reviewer +from .audit_logger import log_event + +logger = logging.getLogger(__name__) + +DEFAULT_CACHE_TTL_SECONDS = 60 +CACHE_KEY = 'safety-dashboard-v1' +CACHE_SCHEMA_VERSION = 2 +CACHE_SETTING_KEY = 'safety_center:dashboard_cache' +DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS = 900 + +_dashboard_cache: dict[str, Any] | None = None +_skip_persistent_cache_once = False +_background_refresh_lock = Lock() +_background_refresh_running = False + + +SECTION_DEFINITIONS = [ + ('setup-coverage', 'Setup & Coverage', '/setup', {'coverage', 'connector_config', 'setup'}), + ('external-exposure', 'External Exposure', '/network', {'exposure'}), + ('authentication-coverage', 'Authentication Coverage', '/apps/authentik', {'auth'}), + ('secret-hygiene', 'Secret Hygiene', '/secrets', {'secrets'}), + ('policy-posture', 'Policy Posture', '/policies', {'policies'}), + ('inventory-app-review', 'Inventory & App Review', '/inventory', {'inventory', 'connectors'}), + ('media-pipeline', 'Media Pipeline', '/media-pipeline', {'media'}), + ('unraid-host-safety', 'Unraid & Host Safety', '/unraid', {'unraid'}), + ('audit-activity', 'Audit & Activity', '/audit', {'audit'}), +] + +SEVERITY_PENALTY = {'critical': 20, 'high': 12, 'medium': 6, 'low': 2, 'info': 0} +CATEGORY_MAP = { + 'auth_coverage': 'auth', + 'coverage': 'setup', + 'connector_config': 'connectors', +} +SOURCE_LABELS = { + 'inventory': 'Inventory', + 'portainer': 'Portainer', + 'unraid': 'Unraid', + 'npm': 'NPM', + 'authentik': 'Authentik', + 'secret_storage': 'Secret Storage', + 'policies': 'Policies', + 'audit': 'Audit', + 'media_pipeline': 'Media Pipeline', +} + + +def _now() -> str: + return datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z') + + +def _parse_utc(value: str) -> datetime: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + + +def _ttl_seconds() -> int: + raw = os.getenv('SAFETY_CACHE_TTL_SECONDS', str(DEFAULT_CACHE_TTL_SECONDS)) + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return DEFAULT_CACHE_TTL_SECONDS + + +def _persistent_cache_max_stale_seconds() -> int: + raw = os.getenv('SAFETY_PERSISTENT_CACHE_MAX_STALE_SECONDS', str(DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS)) + try: + return max(0, int(raw)) + except (TypeError, ValueError): + return DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS + + +def invalidate_cache() -> None: + global _dashboard_cache, _skip_persistent_cache_once + _dashboard_cache = None + _skip_persistent_cache_once = True + + +def _redact(value: Any) -> Any: + if isinstance(value, str): + return redact_message(value) + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, dict): + return { + str(key): _redact(item) + for key, item in value.items() + if 'secret' not in str(key).lower() and 'token' not in str(key).lower() + } + return value + + +def _state_key(kind: str, item_id: str) -> str: + return f'safety_center:{kind}:{item_id}:status' + + +def _get_state(session: Session, kind: str, item_id: str, default: str = 'open') -> str: + row = session.get(AppSetting, _state_key(kind, item_id)) + if not row: + return default + return str(row.value_json.get('status') or default) + + +def _set_state(session: Session, kind: str, item_id: str, status: str) -> dict[str, Any]: + row = session.get(AppSetting, _state_key(kind, item_id)) + if not row: + row = AppSetting(key=_state_key(kind, item_id), is_secret=False) + row.value_json = {'status': status, 'updatedAt': _now()} + row.updated_at = utc_now() + session.add(row) + session.commit() + log_event( + session, + source='manual', + identity='safety_center', + action_id=f'safety.{kind}.{status}', + target=item_id, + target_type=kind, + risk='low', + policy_decision='allowed', + reason=f'Safety Center {kind} marked {status}.', + result='success', + redacted_metadata={'kind': kind, 'id': item_id, 'status': status}, + ) + invalidate_cache() + return {'success': True, 'id': item_id, 'status': status} + + +def _category(category: str) -> str: + return CATEGORY_MAP.get(category, category) + + +def _action_type(raw: str | None) -> str: + allowed = { + 'open_page', + 'configure_connector', + 'configure_secret', + 'run_review', + 'create_remediation_plan', + 'run_readonly_check', + 'review_policy', + } + if raw == 'run_readonly_check': + return raw + if raw in {'policy_draft', 'review_policy'}: + return 'review_policy' + if raw == 'configure_secret': + return raw + if raw == 'configure_connector': + return raw + return 'open_page' if raw in {None, 'link'} else (raw if raw in allowed else 'open_page') + + +def _suggested_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for action in actions: + label = str(action.get('label') or action.get('type') or 'Open details') + href = action.get('href') or action.get('link') + result.append({ + 'label': _redact(label), + 'actionType': _action_type(action.get('type')), + 'href': href or '/safety', + 'policyDecision': action.get('policyDecision'), + 'executable': False, + }) + return result + + +def _findings(session: Session, review: Any) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for finding in review.findings: + source_status = _get_state(session, 'finding', finding.id, finding.status) + category = _category(finding.category) + findings.append({ + 'id': finding.id, + 'title': _redact(finding.title), + 'description': _redact(finding.description), + 'severity': finding.severity, + 'category': category, + 'affectedResources': _redact(finding.affectedResources), + 'evidence': _redact([item.model_dump() for item in finding.evidence]), + 'suggestedActions': _suggested_actions([item.model_dump() for item in finding.suggestedActions]), + 'source': 'AI Security Reviewer', + 'status': source_status, + }) + return findings + + +def _coverage(session: Session, summary: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str], list[str], dict[str, Any]]: + breakdown = summary.get('scoreBreakdown') if isinstance(summary.get('scoreBreakdown'), dict) else {} + sources = breakdown.get('coverageSources') if isinstance(breakdown.get('coverageSources'), dict) else {} + if not sources: + sources = { + 'inventory': {'status': 'connected'}, + 'portainer': {'status': _connector_status(session, 'portainer')}, + 'unraid': {'status': _connector_status(session, 'unraid')}, + 'npm': {'status': _connector_status(session, 'nginx_proxy_manager')}, + 'authentik': {'status': _connector_status(session, 'authentik')}, + 'secret_storage': {'status': 'connected'}, + 'policies': {'status': 'connected'}, + 'audit': {'status': 'partial'}, + 'media_pipeline': {'status': 'partial'}, + } + normalized = { + key: {'label': SOURCE_LABELS.get(key, key), 'status': str(value.get('status', 'unknown')), 'effectOnConfidence': value.get('effectOnConfidence', 'medium')} + for key, value in sources.items() + if isinstance(value, dict) + } + connected = [key for key, value in normalized.items() if value['status'] == 'connected'] + missing = [key for key, value in normalized.items() if value['status'] in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}] + completeness_weight = sum(_coverage_weight(value['status']) for value in normalized.values()) + completeness = {'percentage': round((completeness_weight / max(len(normalized), 1)) * 100), 'connectedSources': connected, 'missingSources': missing} + return normalized, connected, missing, completeness + + +def _connector_status(session: Session, connector_id: str) -> str: + connector = session.get(Connector, connector_id) + return str(connector.status if connector else 'unknown') + + +def _coverage_weight(status: str) -> float: + if status == 'connected': + return 1.0 + if status == 'partial': + return 0.5 + return 0.0 + + +def _counts(findings: list[dict[str, Any]]) -> Counter: + return Counter(finding['severity'] for finding in findings if finding['status'] == 'open') + + +def _has_real_high_or_critical(findings: list[dict[str, Any]]) -> bool: + proven_high_risk_categories = {'auth', 'auth_coverage', 'exposure', 'policies', 'secrets', 'unraid'} + return any( + finding['status'] == 'open' + and finding['severity'] in {'high', 'critical'} + and finding['category'] in proven_high_risk_categories + for finding in findings + ) + + +def _status(score: int, counts: Counter, missing: list[str], findings: list[dict[str, Any]]) -> str: + if counts['critical'] > 0: + return 'critical' + if missing: + return 'warning' if counts['high'] > 0 and _has_real_high_or_critical(findings) else 'partial' + if score < 85 or counts['medium'] or counts['high']: + return 'warning' + return 'healthy' + + +def _section_status(score: int, counts: Counter, missing_only: bool) -> str: + if counts['critical'] > 0: + return 'critical' + if missing_only and counts['high'] == 0: + return 'partial' + if score < 85 or counts['medium'] or counts['high']: + return 'warning' + return 'healthy' + + +def _section_for_category(category: str) -> str: + for section_id, _, _, categories in SECTION_DEFINITIONS: + if category in categories: + return section_id + return 'setup-coverage' + + +def _section_summary(section_id: str, section_findings: list[dict[str, Any]]) -> str: + if not section_findings: + return 'Geen open rule-based findings voor deze sectie.' + top = section_findings[0] + return str(top['title']) + + +def _sections(findings: list[dict[str, Any]], recommendations: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + result = [] + for section_id, title, href, categories in SECTION_DEFINITIONS: + section_findings = [ + finding + for finding in findings + if finding['status'] == 'open' and _section_for_category(finding['category']) == section_id + ] + section_recommendations = [item for item in recommendations if item['sectionId'] == section_id and item['status'] == 'open'] + counts = _counts(section_findings) + score = max(0, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in section_findings)) + related_sources = _related_sources(section_id) + source_weight = sum(_coverage_weight(str(coverage.get(source, {}).get('status', 'unknown'))) for source in related_sources) + missing_only = bool(related_sources) and source_weight < len(related_sources) and not any(finding['severity'] in {'high', 'critical'} for finding in section_findings) + completeness = round((source_weight / max(len(related_sources), 1)) * 100) if related_sources else 100 + result.append({ + 'id': section_id, + 'title': title, + 'status': _section_status(score, counts, missing_only), + 'score': score, + 'summary': _section_summary(section_id, section_findings), + 'findingsCount': len(section_findings), + 'recommendationsCount': len(section_recommendations), + 'dataCompleteness': completeness, + 'href': href, + 'categories': sorted(categories), + }) + return result + + +def _related_sources(section_id: str) -> list[str]: + return { + 'setup-coverage': ['inventory', 'portainer', 'secret_storage', 'policies'], + 'external-exposure': ['npm'], + 'authentication-coverage': ['authentik', 'npm'], + 'secret-hygiene': ['secret_storage'], + 'policy-posture': ['policies'], + 'inventory-app-review': ['inventory', 'portainer'], + 'media-pipeline': ['media_pipeline'], + 'unraid-host-safety': ['unraid', 'portainer'], + 'audit-activity': ['audit'], + }.get(section_id, []) + + +def _base_recommendations(session: Session, findings: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + recommendations: list[dict[str, Any]] = [] + for source, detail in coverage.items(): + if detail['status'] not in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}: + continue + if source in {'npm', 'authentik', 'unraid'}: + recommendations.append(_recommendation( + rec_id=f'safety-rec-configure-{source}', + title=f'Configureer {SOURCE_LABELS.get(source, source)} connector', + description='Vul de connectorconfiguratie en secret in en draai daarna een read-only test.', + severity='medium', + confidence='high', + source='Setup & Coverage', + affected=[source], + action_type='configure_connector', + href='/setup', + section_id='setup-coverage', + )) + for finding in findings: + if finding['severity'] not in {'critical', 'high', 'medium'}: + continue + section_id = _section_for_category(finding['category']) + action = finding['suggestedActions'][0] if finding['suggestedActions'] else {} + recommendations.append(_recommendation( + rec_id=f'safety-rec-{finding["id"]}', + title=f'Review: {finding["title"]}', + description=finding['description'], + severity=finding['severity'], + confidence='high', + source=section_id, + affected=finding['affectedResources'], + action_type=action.get('actionType') or 'open_page', + href=action.get('href') or '/safety', + section_id=section_id, + source_finding_id=finding['id'], + policy_decision=action.get('policyDecision'), + )) + return list({item['id']: item for item in recommendations}.values()) + + +def _recommendation( + *, + rec_id: str, + title: str, + description: str, + severity: str, + confidence: str, + source: str, + affected: list[str], + action_type: str, + href: str, + section_id: str, + source_finding_id: str | None = None, + policy_decision: str | None = None, +) -> dict[str, Any]: + return { + 'id': rec_id, + 'title': _redact(title), + 'description': _redact(description), + 'severity': severity, + 'confidence': confidence, + 'source': source, + 'affectedResources': _redact(affected), + 'actionType': _action_type(action_type), + 'href': href, + 'policyDecision': policy_decision, + 'executable': False, + 'canCreateRemediationPlan': bool(source_finding_id), + 'sourceFindingId': source_finding_id, + 'sectionId': section_id, + 'status': 'open', + } + + +def _add_local_findings(session: Session, findings: list[dict[str, Any]]) -> list[dict[str, Any]]: + result = list(findings) + latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first() + if not latest_run: + result.append(_local_finding('safety-discovery-not-run', 'Container discovery is nog niet gedraaid', 'Discovery coverage ontbreekt; inventory review is daardoor partial.', 'medium', 'setup', ['discovery'], ['No discovery run found'], '/inventory')) + apps_need_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712 + if apps_need_review: + href = f'/apps/{apps_need_review[0].id}' if len(apps_need_review) == 1 else '/inventory' + result.append(_local_finding('safety-apps-need-review', 'Apps wachten op review', f'{len(apps_need_review)} app(s) vragen operator review voordat bredere automation veilig is.', 'medium', 'inventory', [app.id for app in apps_need_review[:6]], ['needs_review=true'], href)) + jobs = session.exec(select(JobRun).where(JobRun.status.in_(['queued', 'running']))).all() + if len(jobs) >= 5: + result.append(_local_finding('safety-many-open-jobs', 'Veel open jobs actief', f'{len(jobs)} jobs staan queued/running.', 'low', 'audit', ['jobs'], ['open jobs >= 5'], '/jobs')) + return list({item['id']: item for item in result}.values()) + + +def _local_finding(finding_id: str, title: str, description: str, severity: str, category: str, affected: list[str], evidence: list[str], href: str) -> dict[str, Any]: + return { + 'id': finding_id, + 'title': title, + 'description': description, + 'severity': severity, + 'category': category, + 'affectedResources': affected, + 'evidence': [{'source': 'safety_center', 'label': title, 'value': evidence, 'redacted': False}], + 'suggestedActions': [{'label': 'Open details', 'actionType': 'open_page', 'href': href, 'policyDecision': 'allowed', 'executable': False}], + 'source': 'Safety Center', + 'status': 'open', + } + + +def _source_signature(session: Session) -> str: + from .app_registry import list_active_apps + from .inventory_discovery import latest_discovered_by_container + + policies = [ + { + 'id': item.id, + 'action': item.action, + 'target': item.target, + 'voice': item.voice, + 'manual': item.manual, + 'risk': item.risk, + } + for item in session.exec(select(Policy)).all() + ] + connectors = [ + { + 'id': item.id, + 'status': item.status, + 'lastTest': item.last_test, + 'secretRef': item.secret_ref, + 'capabilities': item.capabilities, + } + for item in session.exec(select(Connector)).all() + ] + apps = [ + { + 'id': item.id, + 'status': item.status, + 'connectorStatus': item.connector_status, + 'containerStatus': item.container_status, + 'needsReview': item.needs_review, + 'discoveryStatus': item.discovery_status, + 'protected': item.protected, + } + for item in list_active_apps(session) + ] + settings = [ + { + 'key': item.key, + 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, + 'isSecret': item.is_secret, + 'status': item.value_json.get('status') if isinstance(item.value_json, dict) else None, + } + for item in session.exec(select(AppSetting)).all() + if item.key != CACHE_SETTING_KEY + and not item.key.startswith('ai_security:review_cache') + and item.key not in {'media_pipeline:summary_cache', 'network_exposure:cache'} + and (not item.is_secret or item.key.startswith('safety_center:')) + ] + secret_rows = [ + { + 'id': item.id, + 'name': item.name, + 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, + 'usedBy': item.used_by_json, + } + for item in session.exec(select(Secret)).all() + ] + discovered = [ + { + 'containerId': item.container_id, + 'name': item.name, + 'image': item.image, + 'state': item.state, + 'status': item.status, + 'importStatus': item.import_status, + 'matchedAppId': item.matched_app_id, + } + for item in latest_discovered_by_container(session).values() + ] + job_count = session.exec(select(func.count(JobRun.id))).one() + latest_job_started = session.exec(select(func.max(JobRun.started_at))).one() + non_safety_audit = AuditEvent.action_id.not_like('safety.%') + audit_count = session.exec(select(func.count(AuditEvent.id)).where(non_safety_audit)).one() + latest_audit_timestamp = session.exec(select(func.max(AuditEvent.timestamp)).where(non_safety_audit)).one() + signature_payload = { + 'cacheSchemaVersion': CACHE_SCHEMA_VERSION, + 'policies': sorted(policies, key=lambda item: item['id']), + 'connectors': sorted(connectors, key=lambda item: item['id']), + 'apps': sorted(apps, key=lambda item: item['id']), + 'settings': sorted(settings, key=lambda item: item['key']), + 'secrets': sorted(secret_rows, key=lambda item: item['id']), + 'discovered': sorted(discovered, key=lambda item: item['containerId']), + 'counts': { + 'jobs': job_count, + 'latestJobStarted': latest_job_started.isoformat() if latest_job_started else None, + 'audit': audit_count, + 'latestAuditTimestamp': latest_audit_timestamp.isoformat() if latest_audit_timestamp else None, + }, + } + serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':')) + return hashlib.sha256(serialized.encode('utf-8')).hexdigest() + + +def _payload(session: Session) -> dict[str, Any]: + review = ai_security_reviewer.generate_review(session) + security_summary = review.summary.model_dump() + coverage, connected, missing, completeness = _coverage(session, security_summary) + findings = _add_local_findings(session, _findings(session, review)) + counts = _counts(findings) + score = max(0, min(100, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in findings if finding['status'] == 'open'))) + recommendations = _base_recommendations(session, findings, coverage) + sections = _sections(findings, recommendations, coverage) + status = _status(score, counts, missing, findings) + summary = { + 'status': status, + 'score': score, + 'generatedAt': _now(), + 'dataCompleteness': completeness, + 'coverageStatus': coverage, + 'criticalCount': counts['critical'], + 'highCount': counts['high'], + 'mediumCount': counts['medium'], + 'warningCount': counts['low'] + counts['medium'] + counts['high'], + 'recommendationCount': len([item for item in recommendations if item['status'] == 'open']), + 'connectedSources': connected, + 'missingSources': missing, + 'sections': sections, + } + score_breakdown = { + 'baseScore': 100, + 'finalScore': score, + 'penalties': [ + { + 'source': finding['source'], + 'reason': finding['title'], + 'severity': finding['severity'], + 'points': SEVERITY_PENALTY.get(finding['severity'], 0), + 'findingId': finding['id'], + } + for finding in findings + if finding['status'] == 'open' and SEVERITY_PENALTY.get(finding['severity'], 0) > 0 + ], + 'bonuses': [], + 'missingCoverageImpact': 'Missing coverage lowers confidence and data completeness; it is not treated as critical without evidence.', + 'criticalEvidence': [finding['id'] for finding in findings if finding['status'] == 'open' and finding['severity'] == 'critical'], + 'explanation': f'Startscore 100 minus open finding penalties. Current status is {status}.', + 'missingSources': missing, + 'dataCompleteness': completeness, + 'coverageStatus': coverage, + } + return {'summary': _redact(summary), 'sections': _redact(sections), 'findings': _redact(findings), 'recommendations': _redact(recommendations), 'scoreBreakdown': _redact(score_breakdown)} + + +def _coverage_sources(payload: dict[str, Any]) -> list[dict[str, Any]]: + coverage = payload.get('summary', {}).get('coverageStatus', {}) + if not isinstance(coverage, dict): + return [] + return [ + { + 'id': source, + 'label': detail.get('label') or SOURCE_LABELS.get(source, source), + 'status': detail.get('status', 'unknown'), + 'effectOnConfidence': detail.get('effectOnConfidence'), + } + for source, detail in sorted(coverage.items()) + if isinstance(detail, dict) + ] + + +def _with_cache_metadata(payload: dict[str, Any], *, hit: bool, source: str, generated_at: str, expires_at: str, ttl_seconds: int, generation_duration_ms: int, stale_reason: str | None = None) -> dict[str, Any]: + dashboard = dict(payload) + dashboard['generatedAt'] = generated_at + dashboard['coverageSources'] = _coverage_sources(payload) + cache_meta: dict[str, Any] = { + 'hit': hit, + 'ttlSeconds': ttl_seconds, + 'expiresAt': expires_at, + 'source': source, + 'generationDurationMs': generation_duration_ms, + } + if stale_reason: + cache_meta['staleReason'] = _redact(stale_reason) + dashboard['cache'] = cache_meta + return _redact(dashboard) + + +def _persistent_cache(session: Session, signature: str) -> dict[str, Any] | None: + row = session.get(AppSetting, CACHE_SETTING_KEY) + if not row or not isinstance(row.value_json, dict): + return None + value = row.value_json + if value.get('schemaVersion') != CACHE_SCHEMA_VERSION: + return None + if value.get('signature') != signature: + return None + payload = value.get('payload') + if not isinstance(payload, dict): + return None + return { + 'key': CACHE_KEY, + 'signature': signature, + 'payload': payload, + 'generatedAt': value.get('generatedAt'), + 'expiresAt': value.get('expiresAt'), + 'generationDurationMs': value.get('generationDurationMs', 0), + } + + +def _store_persistent_cache(session: Session, cache: dict[str, Any]) -> None: + row = session.get(AppSetting, CACHE_SETTING_KEY) + if not row: + row = AppSetting(key=CACHE_SETTING_KEY, is_secret=False) + row.value_json = { + 'schemaVersion': CACHE_SCHEMA_VERSION, + 'signature': cache.get('signature'), + 'payload': _redact(cache.get('payload')), + 'generatedAt': cache.get('generatedAt'), + 'expiresAt': cache.get('expiresAt'), + 'generationDurationMs': cache.get('generationDurationMs', 0), + } + row.updated_at = utc_now() + session.add(row) + session.commit() + + +def _schedule_background_refresh() -> bool: + global _background_refresh_running + with _background_refresh_lock: + if _background_refresh_running: + return False + _background_refresh_running = True + + def refresh_worker() -> None: + global _background_refresh_running + try: + from ..db.session import engine + + with Session(engine) as refresh_session: + get_dashboard(refresh_session, force_refresh=True) + except Exception: # noqa: BLE001 - stale safety data remains available on refresh failure. + logger.exception('Background Safety dashboard refresh failed') + finally: + with _background_refresh_lock: + _background_refresh_running = False + + Thread(target=refresh_worker, name='appops-safety-refresh', daemon=True).start() + return True + + +def get_dashboard(session: Session, force_refresh: bool = False) -> dict[str, Any]: + global _dashboard_cache, _skip_persistent_cache_once + ttl_seconds = _ttl_seconds() + now_dt = datetime.now(UTC) + signature = _source_signature(session) + if _dashboard_cache and not force_refresh and _dashboard_cache.get('signature') == signature: + expires_at = _parse_utc(str(_dashboard_cache['expiresAt'])) + if now_dt < expires_at: + return _with_cache_metadata( + _dashboard_cache['payload'], + hit=True, + source='cache', + generated_at=str(_dashboard_cache['generatedAt']), + expires_at=str(_dashboard_cache['expiresAt']), + ttl_seconds=ttl_seconds, + generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)), + ) + + skip_persistent = _skip_persistent_cache_once + _skip_persistent_cache_once = False + persisted = None if force_refresh or skip_persistent else _persistent_cache(session, signature) + if persisted: + expires_at = _parse_utc(str(persisted['expiresAt'])) + max_stale_seconds = _persistent_cache_max_stale_seconds() + stale_until = expires_at.timestamp() + max_stale_seconds + if now_dt < expires_at: + _dashboard_cache = persisted + return _with_cache_metadata( + persisted['payload'], + hit=True, + source='cache', + generated_at=str(persisted['generatedAt']), + expires_at=str(persisted['expiresAt']), + ttl_seconds=ttl_seconds, + generation_duration_ms=int(persisted.get('generationDurationMs', 0)), + ) + if max_stale_seconds > 0 and now_dt.timestamp() < stale_until: + _dashboard_cache = persisted + _schedule_background_refresh() + return _with_cache_metadata( + persisted['payload'], + hit=True, + source='stale-while-revalidate', + generated_at=str(persisted['generatedAt']), + expires_at=str(persisted['expiresAt']), + ttl_seconds=ttl_seconds, + generation_duration_ms=int(persisted.get('generationDurationMs', 0)), + ) + + started = perf_counter() + try: + payload = _payload(session) + except Exception as exc: + if _dashboard_cache: + return _with_cache_metadata( + _dashboard_cache['payload'], + hit=True, + source='stale', + generated_at=str(_dashboard_cache['generatedAt']), + expires_at=str(_dashboard_cache['expiresAt']), + ttl_seconds=ttl_seconds, + generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)), + stale_reason=str(exc), + ) + raise + + duration_ms = int((perf_counter() - started) * 1000) + generated_at = str(payload['summary']['generatedAt']) + expires_at = (now_dt.replace(microsecond=0).timestamp() + ttl_seconds) + expires_at_iso = datetime.fromtimestamp(expires_at, UTC).isoformat().replace('+00:00', 'Z') + _dashboard_cache = { + 'key': CACHE_KEY, + 'signature': signature, + 'payload': payload, + 'generatedAt': generated_at, + 'expiresAt': expires_at_iso, + 'generationDurationMs': duration_ms, + } + _store_persistent_cache(session, _dashboard_cache) + logger.info('Safety dashboard generated in %sms with score=%s status=%s', duration_ms, payload['summary'].get('score'), payload['summary'].get('status')) + return _with_cache_metadata( + payload, + hit=False, + source='fresh', + generated_at=generated_at, + expires_at=expires_at_iso, + ttl_seconds=ttl_seconds, + generation_duration_ms=duration_ms, + ) + + +def get_summary(session: Session) -> dict[str, Any]: + return get_dashboard(session)['summary'] + + +def get_sections(session: Session) -> list[dict[str, Any]]: + return get_dashboard(session)['sections'] + + +def get_findings(session: Session) -> list[dict[str, Any]]: + return get_dashboard(session)['findings'] + + +def get_recommendations(session: Session) -> list[dict[str, Any]]: + return get_dashboard(session)['recommendations'] + + +def get_score_breakdown(session: Session) -> dict[str, Any]: + return get_dashboard(session)['scoreBreakdown'] + + +def refresh(session: Session) -> dict[str, Any]: + invalidate_cache() + dashboard = get_dashboard(session, force_refresh=True) + log_event( + session, + source='manual', + identity='safety_center', + action_id='safety.refresh', + target='safety-center', + target_type='safety', + risk='low', + policy_decision='allowed', + reason='Safety Center read-only refresh.', + result='success', + redacted_metadata={'score': dashboard['summary']['score'], 'status': dashboard['summary']['status'], 'findings': len(dashboard['findings']), 'cache': dashboard['cache']['source']}, + ) + return dashboard + + +def acknowledge_finding(session: Session, finding_id: str) -> dict[str, Any]: + return _set_state(session, 'finding', finding_id, 'acknowledged') + + +def dismiss_finding(session: Session, finding_id: str) -> dict[str, Any]: + return _set_state(session, 'finding', finding_id, 'dismissed') + + +def dismiss_recommendation(session: Session, recommendation_id: str) -> dict[str, Any]: + return _set_state(session, 'recommendation', recommendation_id, 'dismissed') + + +def create_remediation_plan(session: Session, recommendation_id: str) -> dict[str, Any]: + recommendation = next((item for item in get_recommendations(session) if item['id'] == recommendation_id), None) + if not recommendation: + return {'success': False, 'status': 'not_found', 'recommendationId': recommendation_id, 'executable': False, 'href': '/ai/remediation-planner'} + finding_id = recommendation.get('sourceFindingId') + plan_id = None + if finding_id: + plan = ai_remediation_planner.generate_from_finding(session, str(finding_id)) + plan_id = plan.id + log_event( + session, + source='manual', + identity='safety_center', + action_id='safety.recommendation.create_remediation_plan', + target=recommendation_id, + target_type='safety_recommendation', + risk='low', + policy_decision='allowed', + reason='Safety Center linked a non-executing remediation plan.', + result='success', + redacted_metadata={'recommendationId': recommendation_id, 'planId': plan_id}, + ) + invalidate_cache() + href = f'/ai/remediation-planner?plan={plan_id}' if plan_id else '/ai/remediation-planner' + return {'success': True, 'status': 'plan_linked', 'recommendationId': recommendation_id, 'planId': plan_id, 'href': href, 'executable': False} diff --git a/backend/app/services/secret_store.py b/backend/app/services/secret_store.py new file mode 100644 index 0000000..107f88c --- /dev/null +++ b/backend/app/services/secret_store.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import base64 +import hashlib +import re +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken +from sqlmodel import Session, select + +from ..core.config import settings +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import Secret + + +SECRET_LIKE_PATTERN = re.compile(r"(secret|token|api[_-]?key|password|x-api-key)", re.IGNORECASE) +REDACTED = "[REDACTED]" + + +def _derive_fernet_key(value: str) -> bytes: + value = value.strip() + try: + Fernet(value.encode("utf-8")) + return value.encode("utf-8") + except (ValueError, TypeError): + digest = hashlib.sha256(value.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest) + + +def _fallback_material() -> str: + return f"appops-development-secret-store:{settings.db_path}:{settings.data_dir}" + + +def _active_key_material() -> str: + if settings.secret_key: + if settings.environment == "production" and len(settings.secret_key) < 32: + raise RuntimeError("APPOPS_SECRET_KEY is too weak for production") + return settings.secret_key + return _fallback_material() + + +def _fernet() -> Fernet: + return Fernet(_derive_fernet_key(_active_key_material())) + + +def secret_storage_status() -> dict[str, Any]: + key_source = settings.secret_key_source if settings.secret_key else "missing" + encrypted = bool(settings.secret_key) or settings.environment != "production" + key_strong = bool(settings.secret_key and len(settings.secret_key) >= 32) + production_ready = key_strong and key_source in {"env", "file"} + warnings: list[str] = [] + if settings.environment == "production" and not settings.secret_key: + warnings.append("APPOPS_SECRET_KEY is required for production encrypted secret storage.") + elif settings.environment == "production" and not key_strong: + warnings.append("APPOPS_SECRET_KEY must contain at least 32 characters in production.") + if settings.secret_key_source == "file_unreadable": + warnings.append("APPOPS_SECRET_KEY_FILE could not be read.") + if settings.environment != "production" and not settings.secret_key: + key_source = "development_fallback" + warnings.append("Development fallback key is active; set APPOPS_SECRET_KEY before production.") + return { + "encryptedAtRest": encrypted, + "enabled": encrypted, + "keySource": key_source, + "rotationEnabled": settings.secret_rotation_enabled, + "productionReady": production_ready, + "status": "ready" if production_ready or settings.environment != "production" else "warning", + "warnings": warnings, + } + + +def set_secret( + session: Session, + name: str, + value: str, + *, + used_by: list[str] | None = None, + metadata: dict[str, Any] | None = None, + commit: bool = True, +) -> Secret: + clean_name = name.strip().upper() + clean_value = value.strip() + now = utc_now() + encrypted = _fernet().encrypt(clean_value.encode("utf-8")).decode("utf-8") + row = session.get(Secret, clean_name) + if not row: + row = Secret(id=clean_name, name=clean_name, encrypted_value=encrypted, created_at=now) + row.name = clean_name + row.encrypted_value = encrypted + row.updated_at = now + row.used_by_json = sorted(set(used_by or row.used_by_json or [])) + row.metadata_json = metadata or row.metadata_json or {} + session.add(row) + if commit: + session.commit() + return row + + +def get_secret(session: Session, name: str, *, touch: bool = True) -> str | None: + row = session.get(Secret, name.strip().upper()) + if not row: + return None + try: + value = _fernet().decrypt(row.encrypted_value.encode("utf-8")).decode("utf-8") + except InvalidToken: + return None + if touch: + row.last_used_at = utc_now() + session.add(row) + session.commit() + return value + + +def has_secret(session: Session, name: str) -> bool: + row = session.get(Secret, name.strip().upper()) + return bool(row and row.encrypted_value) + + +def delete_secret(session: Session, name: str) -> None: + row = session.get(Secret, name.strip().upper()) + if row: + session.delete(row) + session.commit() + + +def rotate_secret(session: Session, name: str, new_value: str, *, used_by: list[str] | None = None) -> Secret: + row = session.get(Secret, name.strip().upper()) + return set_secret(session, name, new_value, used_by=used_by or (row.used_by_json if row else None)) + + +def secret_status(row: Secret) -> dict[str, Any]: + updated_at = row.updated_at or row.created_at + age_days = max(0, (utc_now() - updated_at).days) if updated_at else None + return { + "secretRef": row.id, + "name": row.name, + "hasSecret": bool(row.encrypted_value), + "status": "configured" if row.encrypted_value else "missing", + "usedBy": row.used_by_json or [], + "lastUpdated": row.updated_at.isoformat() if row.updated_at else None, + "lastUsed": row.last_used_at.isoformat() if row.last_used_at else None, + "ageDays": age_days, + "rotationRecommended": bool(age_days is not None and age_days >= 90), + "metadata": { + key: value + for key, value in (row.metadata_json or {}).items() + if not SECRET_LIKE_PATTERN.search(str(key)) + }, + } + + +def empty_secret_status(name: str, *, used_by: list[str] | None = None) -> dict[str, Any]: + return { + "secretRef": name.strip().upper(), + "name": name.strip().upper(), + "hasSecret": False, + "status": "missing", + "usedBy": used_by or [], + "lastUpdated": None, + "lastUsed": None, + "ageDays": None, + "rotationRecommended": False, + "metadata": {}, + } + + +def list_secret_status(session: Session) -> list[dict[str, Any]]: + rows = session.exec(select(Secret).order_by(Secret.name)).all() + return [secret_status(row) for row in rows] + + +def redact_secret_value(value: str | None) -> str | None: + return REDACTED if value else value + + +def _known_secret_values(session: Session | None) -> list[str]: + if session is None: + return [] + values: list[str] = [] + rows = session.exec(select(Secret)).all() + for row in rows: + value = get_secret(session, row.id) + if value: + values.append(value) + return values + + +def redact_text(text: str, *, session: Session | None = None, known_values: list[str] | None = None) -> str: + redacted = redact_message(text) + redacted = re.sub(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE) + redacted = re.sub(r"(apiKey\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE) + redacted = re.sub(r"(token\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE) + for value in [*(known_values or []), *_known_secret_values(session)]: + if value: + redacted = redacted.replace(value, REDACTED) + return redacted diff --git a/backend/app/services/setup_service.py b/backend/app/services/setup_service.py new file mode 100644 index 0000000..c776297 --- /dev/null +++ b/backend/app/services/setup_service.py @@ -0,0 +1,646 @@ +from __future__ import annotations + +from typing import Any + +from sqlmodel import Session, select + +from ..connectors.portainer import PortainerConnector +from ..connectors.unraid import UnraidConnector +from ..core.config import settings +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..core.network_security import validate_connector_url +from ..db.models import AppContainerMapping, AppSetting, AuditEvent, Connector, DiscoveryRun, Secret +from .audit_logger import log_event +from .secret_store import empty_secret_status, get_secret, has_secret, secret_status, secret_storage_status, set_secret +from . import safety_center_service + + +_CONNECTOR_FIELDS: dict[str, dict[str, Any]] = { + 'portainer': { + 'required': ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'], + 'secret_env': 'PORTAINER_API_KEY', + 'field_env': {'url': 'PORTAINER_URL', 'endpointId': 'PORTAINER_ENDPOINT_ID', 'verifyTls': 'PORTAINER_VERIFY_TLS'}, + 'safe_fields': ['url', 'endpointId', 'verifyTls'], + }, + 'unraid': { + 'required': ['UNRAID_URL', 'UNRAID_API_KEY'], + 'secret_env': 'UNRAID_API_KEY', + 'field_env': {'url': 'UNRAID_URL', 'verifyTls': 'UNRAID_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'sonarr': { + 'required': ['SONARR_URL', 'SONARR_API_KEY'], + 'secret_env': 'SONARR_API_KEY', + 'field_env': {'url': 'SONARR_URL'}, + 'safe_fields': ['url'], + }, + 'radarr': { + 'required': ['RADARR_URL', 'RADARR_API_KEY'], + 'secret_env': 'RADARR_API_KEY', + 'field_env': {'url': 'RADARR_URL', 'verifyTls': 'RADARR_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'prowlarr': { + 'required': ['PROWLARR_URL', 'PROWLARR_API_KEY'], + 'secret_env': 'PROWLARR_API_KEY', + 'field_env': {'url': 'PROWLARR_URL', 'verifyTls': 'PROWLARR_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'deluge': { + 'required': ['DELUGE_URL', 'DELUGE_PASSWORD'], + 'secret_env': 'DELUGE_PASSWORD', + 'field_env': {'url': 'DELUGE_URL', 'verifyTls': 'DELUGE_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'plex': { + 'required': ['PLEX_URL', 'PLEX_TOKEN'], + 'secret_env': 'PLEX_TOKEN', + 'field_env': {'url': 'PLEX_URL', 'verifyTls': 'PLEX_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'tautulli': { + 'required': ['TAUTULLI_URL', 'TAUTULLI_API_KEY'], + 'secret_env': 'TAUTULLI_API_KEY', + 'field_env': {'url': 'TAUTULLI_URL', 'verifyTls': 'TAUTULLI_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'nginx_proxy_manager': { + 'required': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], + 'secret_env': 'NPM_SECRET', + 'field_env': {'url': 'NPM_URL', 'identity': 'NPM_IDENTITY', 'verifyTls': 'NPM_VERIFY_TLS'}, + 'safe_fields': ['url', 'identity', 'verifyTls'], + }, + 'authentik': { + 'required': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], + 'secret_env': 'AUTHENTIK_TOKEN', + 'field_env': { + 'url': 'AUTHENTIK_URL', + 'verifyTls': 'AUTHENTIK_VERIFY_TLS', + 'outpostUrl': 'AUTHENTIK_OUTPOST_URL', + 'expectEmbeddedOutpost': 'AUTHENTIK_EXPECT_EMBEDDED_OUTPOST', + }, + 'safe_fields': ['url', 'verifyTls', 'outpostUrl', 'expectEmbeddedOutpost'], + }, + 'widefrog': { + 'required': ['WIDEFROG_URL'], + 'secret_env': 'WIDEFROG_API_KEY', + 'field_env': {'url': 'WIDEFROG_URL', 'verifyTls': 'WIDEFROG_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, + 'home_assistant': { + 'required': ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'], + 'secret_env': 'HOME_ASSISTANT_TOKEN', + 'field_env': {'url': 'HOME_ASSISTANT_URL', 'verifyTls': 'HOME_ASSISTANT_VERIFY_TLS'}, + 'safe_fields': ['url', 'verifyTls'], + }, +} + + +def _setting_key(connector_id: str, suffix: str) -> str: + return f'connector:{connector_id}:{suffix}' + + +def _get_setting(session: Session, key: str) -> AppSetting | None: + return session.get(AppSetting, key) + + +def _set_setting(session: Session, key: str, value: dict[str, Any], is_secret: bool = False) -> AppSetting: + row = _get_setting(session, key) + if not row: + row = AppSetting(key=key, is_secret=is_secret) + row.value_json = value + row.is_secret = is_secret + row.updated_at = utc_now() + session.add(row) + return row + + +def _connector_schema(connector_id: str) -> dict[str, Any]: + return _CONNECTOR_FIELDS.get(connector_id, { + 'required': [f'{connector_id.upper()}_URL'], + 'secret_env': f'{connector_id.upper()}_API_KEY', + 'field_env': {'url': f'{connector_id.upper()}_URL'}, + 'safe_fields': ['url'], + }) + + +def _safe_config_from_row(session: Session, connector_id: str) -> dict[str, Any]: + row = _get_setting(session, _setting_key(connector_id, 'config')) + config = dict(row.value_json) if row else {} + schema = _connector_schema(connector_id) + safe: dict[str, Any] = {} + for field in schema['safe_fields']: + value = config.get(field) + if value is None: + env_name = schema['field_env'].get(field) + value = settings.apis.get(env_name) if env_name else None + if value is not None: + safe[field] = value + if connector_id == 'portainer' and not str(safe.get('endpointId') or '').strip(): + safe['endpointId'] = '1' + return safe + + +def _has_secret(session: Session, connector_id: str) -> bool: + schema = _connector_schema(connector_id) + secret_row = _get_setting(session, _setting_key(connector_id, 'secret')) + secret_env = schema.get('secret_env') + return bool(secret_env and has_secret(session, secret_env)) or bool(secret_row and secret_row.value_json.get('value')) or bool(secret_env and settings.apis.get(secret_env)) + + +def _connector_secret_status(session: Session, connector_id: str) -> dict[str, Any]: + schema = _connector_schema(connector_id) + secret_env = schema.get('secret_env') + if not secret_env: + return empty_secret_status(f'{connector_id.upper()}_API_KEY', used_by=[connector_id]) + row = session.get(Secret, secret_env) + if row: + return secret_status(row) + if settings.apis.get(secret_env): + status = empty_secret_status(secret_env, used_by=[connector_id]) + status['hasSecret'] = True + status['status'] = 'env_fallback' + status['lastUpdated'] = None + return status + return empty_secret_status(secret_env, used_by=[connector_id]) + + +def _migrate_legacy_connector_secret(session: Session, connector_id: str) -> None: + schema = _connector_schema(connector_id) + secret_env = schema.get('secret_env') + if not secret_env: + return + legacy_row = _get_setting(session, _setting_key(connector_id, 'secret')) + legacy_value = legacy_row.value_json.get('value') if legacy_row else None + if not isinstance(legacy_value, str) or not legacy_value.strip(): + return + set_secret( + session, + secret_env, + legacy_value, + used_by=[connector_id], + metadata={'connectorId': connector_id, 'migratedFrom': _setting_key(connector_id, 'secret')}, + commit=False, + ) + legacy_row.value_json = {'secretRef': secret_env, 'hasSecret': True, 'migrated': True} + legacy_row.updated_at = utc_now() + session.add(legacy_row) + log_event( + session, + source='system', + identity='secret_store', + action_id='secret_migrated', + target=secret_env, + target_type='secret', + risk='low', + policy_decision='allowed', + reason='Legacy plaintext connector secret migrated to encrypted secret store.', + result='success', + redacted_metadata={'connector': connector_id, 'secretRef': secret_env}, + ) + session.commit() + + +def _apply_connector_config_to_runtime(session: Session, connector_id: str) -> None: + _migrate_legacy_connector_secret(session, connector_id) + schema = _connector_schema(connector_id) + safe_config = _safe_config_from_row(session, connector_id) + for field, env_name in schema['field_env'].items(): + if field in safe_config: + settings.apis[env_name] = safe_config[field] + if 'verifyTls' in safe_config and connector_id in {'portainer', 'unraid'}: + settings.verify_tls = bool(safe_config['verifyTls']) + secret_row = _get_setting(session, _setting_key(connector_id, 'secret')) + secret_env = schema.get('secret_env') + if secret_env: + stored_secret = get_secret(session, secret_env, touch=False) + if stored_secret: + settings.apis[secret_env] = stored_secret + elif secret_row and secret_row.value_json.get('value'): + settings.apis[secret_env] = secret_row.value_json.get('value') + + +def apply_stored_connector_settings(session: Session, connector_ids: set[str] | None = None) -> None: + selected = connector_ids if connector_ids is not None else set(_CONNECTOR_FIELDS) + for connector_id in _CONNECTOR_FIELDS: + if connector_id not in selected: + continue + _apply_connector_config_to_runtime(session, connector_id) + + +def serialize_connector_settings(session: Session, connector_id: str) -> dict[str, Any]: + _apply_connector_config_to_runtime(session, connector_id) + schema = _connector_schema(connector_id) + missing = [name for name in schema['required'] if not settings.apis.get(name)] + return { + 'connectorId': connector_id, + 'configured': not missing, + 'hasSecret': _has_secret(session, connector_id), + 'missing': missing, + 'safeConfig': _safe_config_from_row(session, connector_id), + 'secretRef': schema.get('secret_env'), + 'secretStatus': _connector_secret_status(session, connector_id), + 'secretStore': 'encrypted_local_db', + 'secretStorage': secret_storage_status(), + } + + +def list_connector_settings(session: Session) -> list[dict[str, Any]]: + connector_ids = [connector.id for connector in session.exec(select(Connector).order_by(Connector.id)).all()] + for connector_id in _CONNECTOR_FIELDS: + if connector_id not in connector_ids: + connector_ids.append(connector_id) + return [serialize_connector_settings(session, connector_id) for connector_id in connector_ids] + + +def _normalize_connector_field(field: str, value: Any) -> Any: + if field == 'verifyTls': + return bool(value) + if not isinstance(value, str): + return value + normalized = value.strip() + if field in {'url', 'outpostUrl'}: + normalized = validate_connector_url(normalized) + return normalized + + +def save_connector_settings(session: Session, connector_id: str, payload: dict[str, Any]) -> dict[str, Any]: + schema = _connector_schema(connector_id) + safe_payload = dict(_safe_config_from_row(session, connector_id)) + for field in schema['safe_fields']: + if field in payload and payload[field] is not None: + safe_payload[field] = _normalize_connector_field(field, payload[field]) + if connector_id == 'portainer' and not str(safe_payload.get('endpointId') or '').strip(): + safe_payload['endpointId'] = '1' + _set_setting(session, _setting_key(connector_id, 'config'), safe_payload, is_secret=False) + + secret_value = payload.get('apiKey') or payload.get('token') or payload.get('password') or payload.get('secret') + if isinstance(secret_value, str) and secret_value.strip(): + secret_env = schema.get('secret_env') + if secret_env: + set_secret( + session, + secret_env, + secret_value, + used_by=[connector_id], + metadata={'connectorId': connector_id}, + commit=False, + ) + _set_setting(session, _setting_key(connector_id, 'secret'), {'secretRef': secret_env, 'hasSecret': True}, is_secret=True) + + session.commit() + _apply_connector_config_to_runtime(session, connector_id) + + connector = session.get(Connector, connector_id) + if connector: + connector.status = 'disconnected' if serialize_connector_settings(session, connector_id)['configured'] else 'missing_config' + session.add(connector) + session.commit() + safety_center_service.invalidate_cache() + return serialize_connector_settings(session, connector_id) + + +def _step(step_id: str, label: str, status: str, required: bool) -> dict[str, Any]: + return {'id': step_id, 'label': label, 'status': status, 'required': required} + + +def _successful_portainer_test_exists(session: Session) -> bool: + event = session.exec( + select(AuditEvent) + .where(AuditEvent.action_id == 'setup.test.portainer') + .where(AuditEvent.result == 'success') + .order_by(AuditEvent.timestamp.desc()) + ).first() + return bool(event) + + +def _setup_flag(session: Session) -> tuple[bool, str | None]: + first_run = _get_setting(session, 'setup:first_run_completed') + completed_at = _get_setting(session, 'setup:completed_at') + return bool(first_run and first_run.value_json.get('value')), completed_at.value_json.get('value') if completed_at else None + + +def _set_setup_completed(session: Session, *, completed_at: str | None = None) -> str: + now = completed_at or utc_now().isoformat() + _set_setting(session, 'setup:first_run_completed', {'value': True}) + _set_setting(session, 'setup:completed_at', {'value': now}) + session.commit() + safety_center_service.invalidate_cache() + return now + + +def _required_blockers( + *, + portainer_ready: bool, + discovery_complete: bool, + managed_apps: int, + review_blockers: int, + secret_storage_ready: bool, +) -> list[str]: + blockers: list[str] = [] + if not portainer_ready: + blockers.append('portainer_not_connected') + if not discovery_complete: + blockers.append('discovery_not_completed') + if managed_apps <= 0: + blockers.append('no_managed_apps') + if review_blockers > 0: + blockers.append('apps_need_review') + if not secret_storage_ready: + blockers.append('secret_storage_not_ready') + return blockers + + +def get_setup_status(session: Session) -> dict[str, Any]: + from .inventory_discovery import _latest_discovered_items + from .app_registry import list_active_apps + + first_run_completed, setup_completed_at = _setup_flag(session) + portainer = serialize_connector_settings(session, 'portainer') + unraid = serialize_connector_settings(session, 'unraid') + portainer_row = session.get(Connector, 'portainer') + unraid_row = session.get(Connector, 'unraid') + portainer_connected = portainer['configured'] and portainer_row is not None and portainer_row.status == 'connected' + portainer_tested = _successful_portainer_test_exists(session) + unraid_ready = unraid['configured'] and (unraid_row is None or unraid_row.status == 'connected') + latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first() + # Setup is an operator-facing current-state summary. Discovery run history + # remains available under Inventory, but recreated Docker IDs must not make + # the setup counters grow forever. + discovered = _latest_discovered_items(session) + mappings = session.exec(select(AppContainerMapping)).all() + apps = list_active_apps(session) + connectors = list_connector_settings(session) + mapped_container_ids = {mapping.container_id for mapping in mappings} + apps_need_review = len([app for app in apps if app.needs_review]) + len([ + item + for item in discovered + if item.import_status in {'discovered', 'unmapped'} or (item.import_status == 'changed' and item.container_id not in mapped_container_ids) + ]) + optional_connectors = [item for item in connectors if item['connectorId'] != 'portainer'] + missing_optional_sources = [item['connectorId'] for item in optional_connectors if item['missing']] + missing_config = len([item for item in connectors if item['missing']]) + mapped_apps = len(mappings) + managed_apps = len(apps) + review_blockers = 0 if first_run_completed else apps_need_review + discovery_status = 'pending' + if latest_run: + discovery_status = 'complete' if latest_run.status == 'success' else 'failed' if latest_run.status == 'failed' else 'warning' + discovery_complete = bool(latest_run and latest_run.status == 'success') or bool(discovered) or mapped_apps > 0 + portainer_ready = portainer_connected or portainer_tested or discovery_complete + + review_status = 'pending' if not latest_run else ('complete' if review_blockers == 0 else 'warning') + connectors_status = 'warning' if missing_optional_sources else 'complete' + home_assistant_configured = 'home_assistant' not in missing_optional_sources + voice_status = 'complete' if settings.voice_token and home_assistant_configured else 'warning' + storage_status = secret_storage_status() + secret_storage_ready = storage_status['status'] == 'ready' + required_blockers = _required_blockers( + portainer_ready=portainer_ready, + discovery_complete=discovery_complete, + managed_apps=mapped_apps, + review_blockers=review_blockers, + secret_storage_ready=secret_storage_ready, + ) + required_steps_complete = not required_blockers + setup_complete = required_steps_complete and first_run_completed + coverage_complete = not missing_optional_sources + live_ready = setup_complete and settings.port == 1216 + warnings: list[str] = [] + if missing_optional_sources: + warnings.append('Optional connector coverage is partial.') + if settings.port != 1216: + warnings.append('Runtime is not on canonical production port 1216.') + next_actions = [ + {'id': blocker, 'required': True} + for blocker in required_blockers + ] + if required_steps_complete and not first_run_completed: + next_actions.append({'id': 'reconcile_setup_state', 'required': True}) + for connector_id in missing_optional_sources: + next_actions.append({'id': f'configure_optional_{connector_id}', 'required': False}) + + steps = [ + _step('portainer', 'Portainer verbinden', 'complete' if portainer_ready else 'pending', True), + _step('unraid', 'Unraid API verbinden', 'complete' if unraid_ready else 'warning', False), + _step('discovery', 'Container discovery draaien', 'complete' if discovery_complete else discovery_status, True), + _step('review', 'Reviewbaseline vastgelegd' if first_run_completed else 'Apps reviewen', review_status, True), + _step('connectors', 'Connectoren binden', connectors_status, False), + _step('voice', 'Voice/Home Assistant instellen', voice_status, False), + ] + return { + 'firstRunCompleted': first_run_completed, + 'setupComplete': setup_complete, + 'liveReady': live_ready, + 'coverageComplete': coverage_complete, + 'securityCoveragePartial': not coverage_complete, + 'setupCompletedAt': setup_completed_at, + 'requiredStepsComplete': required_steps_complete, + 'optionalStepsComplete': coverage_complete, + 'reviewBlockers': review_blockers, + 'managedApps': managed_apps, + 'needsReview': apps_need_review, + 'missingOptionalSources': missing_optional_sources, + 'steps': steps, + 'warnings': warnings, + 'nextActions': next_actions, + 'blockers': required_blockers, + 'summary': { + 'containersDiscovered': len({item.container_id for item in discovered}), + 'appsManaged': managed_apps, + 'appsNeedReview': apps_need_review, + 'connectorsMissingConfig': missing_config, + 'missingOptionalSources': missing_optional_sources, + }, + } + + +def complete_setup(session: Session) -> dict[str, Any]: + from .inventory_discovery import sync_app_connector_statuses + + sync_app_connector_statuses(session) + _set_setup_completed(session) + return {'success': True, 'message': 'setup_completed', 'status': get_setup_status(session)} + + +def reconcile_setup(session: Session) -> dict[str, Any]: + from .inventory_discovery import sync_app_connector_statuses + + sync_app_connector_statuses(session) + before = get_setup_status(session) + blockers = list(before.get('blockers') or []) + if blockers: + log_event( + session, + source='manual', + identity='setup', + action_id='setup.reconcile', + target='setup', + target_type='setup', + risk='low', + policy_decision='allowed', + reason='Setup reconciliation blocked by required criteria.', + result='blocked', + before_state={'firstRunCompleted': before['firstRunCompleted'], 'blockers': blockers}, + after_state={'firstRunCompleted': before['firstRunCompleted']}, + redacted_metadata={'blockers': blockers}, + ) + after = get_setup_status(session) + return {'success': False, 'message': 'setup_reconcile_blocked', 'blockers': blockers, 'before': before, 'after': after} + + if not before['firstRunCompleted']: + _set_setup_completed(session) + after = get_setup_status(session) + log_event( + session, + source='manual', + identity='setup', + action_id='setup.reconcile', + target='setup', + target_type='setup', + risk='low', + policy_decision='allowed', + reason='Setup state reconciled from existing live data.', + result='success', + before_state={'firstRunCompleted': before['firstRunCompleted'], 'setupComplete': before['setupComplete']}, + after_state={'firstRunCompleted': after['firstRunCompleted'], 'setupComplete': after['setupComplete']}, + redacted_metadata={'managedApps': after['managedApps'], 'coverageComplete': after['coverageComplete']}, + ) + return {'success': True, 'message': 'setup_reconciled', 'blockers': [], 'before': before, 'after': after} + + +def mark_setup_complete(session: Session, payload: dict[str, Any]) -> dict[str, Any]: + if payload.get('confirm') != 'MARK_SETUP_COMPLETE': + from fastapi import HTTPException + + raise HTTPException(status_code=400, detail='confirm_required') + before = get_setup_status(session) + _set_setup_completed(session) + after = get_setup_status(session) + log_event( + session, + source='manual', + identity='setup', + action_id='setup.mark_complete', + target='setup', + target_type='setup', + risk='medium', + policy_decision='allowed', + reason='Operator explicitly marked setup complete.', + result='success', + before_state={'firstRunCompleted': before['firstRunCompleted'], 'setupComplete': before['setupComplete']}, + after_state={'firstRunCompleted': after['firstRunCompleted'], 'setupComplete': after['setupComplete']}, + redacted_metadata={'coverageComplete': after['coverageComplete'], 'blockers': after.get('blockers', [])}, + ) + return {'success': True, 'message': 'setup_marked_complete', 'status': after} + + +def mark_voice_configured(session: Session) -> dict[str, Any]: + _set_setting(session, 'setup:voice_configured', {'value': True}) + session.commit() + return {'success': True, 'message': 'voice_configured', 'status': get_setup_status(session)} + + +def test_portainer_setup(session: Session) -> dict[str, Any]: + config = serialize_connector_settings(session, 'portainer') + if config['missing']: + return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': config['missing'], 'message': 'missing_config'} + connector = PortainerConnector() + result = connector.test_connection() + status = str(result.get('status', 'error')) + if status != 'connected': + message = redact_message(str(result.get('reason', status))) + log_setup_test(session, 'portainer', status, message) + response_status = 'unreachable' if status == 'error' else status + required_fields = ['PORTAINER_ENDPOINT_ID'] if response_status == 'endpoint_error' else None + return { + 'success': False, + 'status': response_status, + 'connected': False, + 'message': message, + 'requiredFields': required_fields, + 'safeConfig': config['safeConfig'], + } + containers = connector.list_containers() + stacks = connector.list_stacks() + if containers.get('status') != 'ok': + message = redact_message(str(containers.get('reason', 'endpoint_error'))) + log_setup_test(session, 'portainer', 'endpoint_error', message) + return { + 'success': False, + 'status': 'endpoint_error', + 'connected': False, + 'message': message, + 'requiredFields': ['PORTAINER_ENDPOINT_ID'], + 'summary': {'containers': 0, 'stacks': 0, 'endpointReachable': False}, + 'safeConfig': config['safeConfig'], + } + payload = { + 'success': True, + 'status': 'connected', + 'connected': True, + 'summary': { + 'containers': len(containers.get('containers', [])), + 'stacks': len(stacks.get('stacks', [])) if stacks.get('status') == 'ok' else 0, + 'endpointReachable': True, + }, + 'safeConfig': config['safeConfig'], + } + connector_row = session.get(Connector, 'portainer') + if connector_row: + connector_row.status = 'connected' + session.add(connector_row) + session.commit() + log_setup_test(session, 'portainer', 'connected', 'connected') + return payload + + +def test_unraid_setup(session: Session) -> dict[str, Any]: + config = serialize_connector_settings(session, 'unraid') + if config['missing']: + return {'success': False, 'status': 'missing_config', 'connected': False, 'requiredFields': config['missing'], 'message': 'missing_config'} + connector = UnraidConnector() + result = connector.test_connection() + status = str(result.get('status', 'error')) + if status != 'connected': + message = redact_message(str(result.get('reason', status))) + log_setup_test(session, 'unraid', status, message) + return {'success': False, 'status': 'unreachable' if status == 'error' else status, 'connected': False, 'message': message} + info = connector.get_server_info() + array = connector.get_array_status() + docker = connector.get_docker_service_status() + payload = { + 'success': True, + 'status': 'connected', + 'connected': True, + 'summary': { + 'server': info.get('info', {}), + 'arrayStatus': array.get('arrayStatus'), + 'dockerService': docker.get('serviceStatus'), + }, + 'safeConfig': config['safeConfig'], + } + connector_row = session.get(Connector, 'unraid') + if connector_row: + connector_row.status = 'connected' + session.add(connector_row) + session.commit() + log_setup_test(session, 'unraid', 'connected', 'connected') + return payload + + +def log_setup_test(session: Session, connector_id: str, result: str, reason: str) -> None: + log_event( + session, + source='manual', + identity='setup', + action_id=f'setup.test.{connector_id}', + target=connector_id, + target_type='connector', + risk='low', + policy_decision='allowed', + reason=redact_message(reason), + result='success' if result == 'connected' else 'failed', + redacted_metadata={'connector': connector_id, 'result': result}, + ) diff --git a/backend/app/services/unraid_vm_service.py b/backend/app/services/unraid_vm_service.py new file mode 100644 index 0000000..3ff5714 --- /dev/null +++ b/backend/app/services/unraid_vm_service.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from typing import Any + +from sqlmodel import Session + +from ..connectors.unraid import UnraidConnector +from .approval_service import create_approval +from .audit_logger import log_event +from .job_runner import complete_job, create_job, set_job_progress, start_job +from .policy_engine import evaluate_policy +from .setup_service import apply_stored_connector_settings + + +VM_ACTIONS: dict[str, dict[str, Any]] = { + 'start': { + 'action_id': 'unraid.vm.start', + 'expected_before': {'SHUTOFF', 'STOPPED', 'OFFLINE'}, + 'expected_after': {'RUNNING', 'STARTED', 'ONLINE'}, + 'risk': 'medium', + 'label': 'Start VM', + }, + 'resume': { + 'action_id': 'unraid.vm.resume', + 'expected_before': {'PAUSED', 'PMSUSPENDED'}, + 'expected_after': {'RUNNING', 'STARTED', 'ONLINE'}, + 'risk': 'medium', + 'label': 'Resume VM', + }, + 'pause': { + 'action_id': 'unraid.vm.pause', + 'expected_before': {'RUNNING', 'STARTED', 'ONLINE'}, + 'expected_after': {'PAUSED'}, + 'risk': 'high', + 'label': 'Pause VM', + }, + 'stop': { + 'action_id': 'unraid.vm.stop', + 'risk': 'critical', + 'label': 'Stop VM', + 'blocked_reason': ( + 'The current Unraid stop mutation can force-stop a VM after its graceful-shutdown timeout. ' + 'AppOps keeps this destructive fallback blocked.' + ), + }, + 'reboot': { + 'action_id': 'unraid.vm.reboot', + 'risk': 'critical', + 'label': 'Reboot VM', + 'blocked_reason': 'VM reboot remains blocked until guest-shutdown and recovery semantics can be verified safely.', + }, + 'force_stop': { + 'action_id': 'unraid.vm.force_stop', + 'risk': 'critical', + 'label': 'Force-stop VM', + 'blocked_reason': 'Force-stop is destructive and blocked by default.', + }, + 'reset': { + 'action_id': 'unraid.vm.reset', + 'risk': 'critical', + 'label': 'Reset VM', + 'blocked_reason': 'VM reset is destructive and blocked by default.', + }, +} + + +def _find_vm(connector: UnraidConnector, vm_id: str) -> tuple[dict[str, Any] | None, dict[str, Any]]: + inventory = connector.get_vm_summary() + if inventory.get('status') != 'ok': + return None, inventory + vm = next((item for item in inventory.get('vms', []) if str(item.get('id')) == vm_id), None) + return vm, inventory + + +def vm_capabilities(session: Session) -> dict[str, Any]: + actions = [] + for action, definition in VM_ACTIONS.items(): + action_id = str(definition['action_id']) + policy = evaluate_policy(session, action=action_id, target='*', source='manual') + blocked_reason = definition.get('blocked_reason') + actions.append({ + 'id': action, + 'actionId': action_id, + 'label': definition['label'], + 'risk': definition['risk'], + 'policyDecision': 'blocked' if blocked_reason else policy['decision'], + 'reason': blocked_reason or policy['reason'], + 'executionSupported': action in {'start', 'resume', 'pause'}, + }) + return { + 'source': 'unraid_graphql', + 'writePermission': 'vm:update or vm:*', + 'actions': actions, + } + + +def request_vm_action( + session: Session, + *, + vm_id: str, + action: str, + source: str = 'manual', + confirmed: bool = False, +) -> dict[str, Any]: + normalized_action = str(action).strip().lower() + definition = VM_ACTIONS.get(normalized_action) + if not definition: + return { + 'success': False, + 'status': 'blocked', + 'result': 'blocked', + 'decision': 'blocked', + 'message': 'Unknown VM action.', + } + + action_id = str(definition['action_id']) + risk = str(definition['risk']) + blocked_reason = definition.get('blocked_reason') + if blocked_reason: + audit = log_event( + session, + source=source, + identity='operator', + action_id=action_id, + target=vm_id, + target_type='virtual_machine', + risk=risk, + policy_decision='blocked', + reason=str(blocked_reason), + result='blocked', + execution_plan={'steps': []}, + redacted_metadata={'vmId': vm_id, 'requestedAction': normalized_action}, + ) + return { + 'success': False, + 'status': 'blocked', + 'result': 'blocked', + 'decision': 'blocked', + 'policy_decision': 'blocked', + 'message': blocked_reason, + 'auditId': audit.id, + } + + policy = evaluate_policy(session, action=action_id, target=vm_id, source=source) + if policy['decision'] == 'blocked': + return { + 'success': False, + 'status': 'blocked', + 'result': 'blocked', + 'decision': 'blocked', + 'policy_decision': 'blocked', + 'message': policy['reason'], + } + if policy['decision'] == 'approval_required': + approval = create_approval( + session, + action=action_id, + target=vm_id, + source=source, + risk=risk, + reason=policy['reason'], + requester=source, + ) + audit = log_event( + session, + source=source, + identity='operator', + action_id=action_id, + target=vm_id, + target_type='virtual_machine', + risk=risk, + policy_decision='approval_required', + reason=policy['reason'], + result='approval_requested', + execution_plan={'steps': ['Review VM impact', 'Approve request', 'Execute and verify state']}, + redacted_metadata={'vmId': vm_id, 'approvalId': approval.id}, + ) + return { + 'success': False, + 'status': 'approval_required', + 'result': 'approval_required', + 'decision': 'approval_required', + 'policy_decision': 'approval_required', + 'message': 'VM action requires approval before execution.', + 'approvalId': approval.id, + 'auditId': audit.id, + } + if not confirmed: + return { + 'success': False, + 'status': 'confirm_required', + 'result': 'confirm_required', + 'decision': 'allowed', + 'policy_decision': 'allowed', + 'message': 'Explicit confirmation is required before changing VM state.', + } + return execute_vm_action( + session, + vm_id=vm_id, + action=normalized_action, + source=source, + policy_decision='allowed', + reason=policy['reason'], + ) + + +def execute_vm_action( + session: Session, + *, + vm_id: str, + action: str, + source: str, + policy_decision: str, + reason: str, +) -> dict[str, Any]: + definition = VM_ACTIONS.get(action) + if not definition or action not in {'start', 'resume', 'pause'}: + return { + 'success': False, + 'status': 'blocked', + 'result': 'blocked', + 'message': 'No safe VM executor is registered for this action.', + } + + apply_stored_connector_settings(session) + connector = UnraidConnector() + before_vm, inventory = _find_vm(connector, vm_id) + if not before_vm: + message = str(inventory.get('reason') or 'VM target was not found in live Unraid inventory.') + audit = log_event( + session, + source=source, + identity='operator', + action_id=str(definition['action_id']), + target=vm_id, + target_type='virtual_machine', + risk=str(definition['risk']), + policy_decision=policy_decision, + reason=reason, + result='failed', + before_state={'inventoryStatus': inventory.get('status')}, + redacted_metadata={'vmId': vm_id}, + ) + return { + 'success': False, + 'status': 'failed', + 'result': 'failed', + 'message': message, + 'auditId': audit.id, + } + + before_state = str(before_vm.get('state', 'unknown')).upper() + if before_state not in definition['expected_before']: + message = f'{definition["label"]} is not valid while VM state is {before_state}.' + audit = log_event( + session, + source=source, + identity='operator', + action_id=str(definition['action_id']), + target=vm_id, + target_type='virtual_machine', + risk=str(definition['risk']), + policy_decision=policy_decision, + reason=reason, + result='blocked', + before_state={'name': before_vm.get('name'), 'state': before_state}, + redacted_metadata={'vmId': vm_id}, + ) + return { + 'success': False, + 'status': 'invalid_state', + 'result': 'blocked', + 'message': message, + 'auditId': audit.id, + } + + steps = [ + 'Load live VM inventory', + f'Validate {before_state} state', + f'Run {definition["label"]} through Unraid GraphQL', + 'Reload VM inventory and verify state', + ] + job = create_job( + session, + action_id=str(definition['action_id']), + target=vm_id, + source=source, + risk=str(definition['risk']), + policy_decision=policy_decision, + steps=steps, + ) + start_job(session, job) + set_job_progress(session, job, 35, f'Validated VM {before_vm.get("name", vm_id)} in state {before_state}.') + + mutation = connector.mutate_vm(vm_id, action) + set_job_progress(session, job, 75, str(mutation.get('message') or 'VM mutation returned.')) + after_vm, after_inventory = _find_vm(connector, vm_id) + after_state = str(after_vm.get('state', 'unknown')).upper() if after_vm else 'UNKNOWN' + verified = after_state in definition['expected_after'] + success = bool(mutation.get('success')) and verified + if success: + message = f'{before_vm.get("name", "VM")} is now {after_state.lower()}.' + elif mutation.get('success'): + message = f'Unraid accepted the action, but post-check reported {after_state}.' + else: + message = str(mutation.get('message') or 'VM action failed.') + + completed = complete_job(session, job, status='succeeded' if success else 'failed', result_summary=message) + audit = log_event( + session, + source=source, + identity='operator', + action_id=str(definition['action_id']), + target=vm_id, + target_type='virtual_machine', + risk=str(definition['risk']), + policy_decision=policy_decision, + reason=reason, + result='success' if success else 'failed', + execution_plan={'steps': steps, 'jobId': completed.id}, + before_state={'name': before_vm.get('name'), 'state': before_state}, + after_state={ + 'name': after_vm.get('name') if after_vm else before_vm.get('name'), + 'state': after_state, + 'inventoryStatus': after_inventory.get('status'), + 'verified': verified, + }, + redacted_metadata={'vmId': vm_id, 'executor': definition['action_id']}, + ) + return { + 'success': success, + 'status': 'success' if success else 'failed', + 'result': 'success' if success else 'failed', + 'decision': policy_decision, + 'policy_decision': policy_decision, + 'message': message, + 'jobId': completed.id, + 'auditId': audit.id, + 'before_state': {'name': before_vm.get('name'), 'state': before_state}, + 'after_state': {'state': after_state, 'verified': verified}, + } + + +def execute_approved_vm_action(session: Session, approval) -> dict[str, Any]: + action = next( + (name for name, definition in VM_ACTIONS.items() if definition['action_id'] == approval.action), + None, + ) + if action not in {'start', 'resume', 'pause'}: + return { + 'success': False, + 'status': 'failed', + 'result': 'unsupported', + 'message': 'No safe approved VM executor is registered for this action.', + } + return execute_vm_action( + session, + vm_id=approval.target, + action=action, + source='approved_operator', + policy_decision='approval_required', + reason=approval.reason or f'Approved through {approval.id}.', + ) diff --git a/backend/app/services/voice_catalog.py b/backend/app/services/voice_catalog.py new file mode 100644 index 0000000..d5aaeca --- /dev/null +++ b/backend/app/services/voice_catalog.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlmodel import Session + +from ..core.config import settings +from .policy_engine import evaluate_policy + + +VOICE_COMMANDS: list[dict[str, Any]] = [ + { + "id": "unraid.health", + "label": "Check Unraid", + "spokenPhraseNl": "controleer unraid", + "spokenPhraseEn": "check unraid", + "category": "Infrastructure", + "endpoint": "/api/voice/actions/unraid/health", + "method": "POST", + "payload": {}, + "action": "unraid.health_summary", + "target": "unraid", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_unraid", + "homeAssistantScriptName": "appops_check_unraid", + "description": "Read-only Unraid health summary through the voice policy path.", + "safetyNotes": ["Read-only", "No mover, disk or array mutation"], + }, + { + "id": "appops.setup.check", + "label": "Check AppOps setup", + "spokenPhraseNl": "controleer appops setup", + "spokenPhraseEn": "check appops setup", + "category": "Gateway", + "endpoint": "/api/voice/actions/setup/status", + "method": "POST", + "payload": {}, + "action": "setup.status", + "target": "appops", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_setup", + "homeAssistantScriptName": "appops_check_setup", + "description": "Read-only setup readiness check for the AppOps Gateway.", + "safetyNotes": ["Read-only", "Reports setup state only"], + }, + { + "id": "safety.summary", + "label": "Check Safety", + "spokenPhraseNl": "controleer veiligheid", + "spokenPhraseEn": "check safety", + "category": "Safety", + "endpoint": "/api/voice/actions/safety/summary", + "method": "POST", + "payload": {}, + "action": "safety.summary", + "target": "safety", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_safety", + "homeAssistantScriptName": "appops_check_safety", + "description": "Read-only Safety Center summary.", + "safetyNotes": ["Read-only", "No remediation execution"], + }, + { + "id": "media.pipeline.check", + "label": "Check Media Pipeline", + "spokenPhraseNl": "controleer media pipeline", + "spokenPhraseEn": "check media pipeline", + "category": "Media", + "endpoint": "/api/voice/actions/media-pipeline/check", + "method": "POST", + "payload": {}, + "action": "media_pipeline.check", + "target": "media-pipeline", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_media_pipeline", + "homeAssistantScriptName": "appops_check_media_pipeline", + "description": "Runs the read-only media pipeline diagnostic check path.", + "safetyNotes": ["Read-only connector summaries", "No media add/delete actions"], + }, + { + "id": "apps.widefrog.health", + "label": "Check Widefrog", + "spokenPhraseNl": "controleer widefrog", + "spokenPhraseEn": "check widefrog", + "category": "Apps", + "endpoint": "/api/voice/actions/apps/widefrog/health", + "method": "POST", + "payload": {}, + "action": "widefrog.health", + "target": "widefrog", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_widefrog", + "homeAssistantScriptName": "appops_check_widefrog", + "description": "Read-only Widefrog connector health.", + "safetyNotes": ["Read-only", "No retry or scan"], + }, + { + "id": "apps.deluge.status", + "label": "Check Downloads", + "spokenPhraseNl": "controleer downloads", + "spokenPhraseEn": "check downloads", + "category": "Downloads", + "endpoint": "/api/voice/actions/apps/deluge/status", + "method": "POST", + "payload": {}, + "action": "deluge.session_status", + "target": "deluge", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_downloads", + "homeAssistantScriptName": "appops_check_downloads", + "description": "Read-only Deluge/download status summary.", + "safetyNotes": ["Read-only", "No pause, resume or delete-with-data"], + }, + { + "id": "apps.plex.activity", + "label": "Check Plex Activity", + "spokenPhraseNl": "controleer plex activiteit", + "spokenPhraseEn": "check plex activity", + "category": "Media", + "endpoint": "/api/voice/actions/apps/plex/activity", + "method": "POST", + "payload": {}, + "action": "plex.active_sessions", + "target": "plex", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_plex_activity", + "homeAssistantScriptName": "appops_check_plex_activity", + "description": "Read-only Plex active stream summary.", + "safetyNotes": ["Read-only", "No library scan or session kill"], + }, + { + "id": "notifications.summary", + "label": "Check Notifications", + "spokenPhraseNl": "controleer notificaties", + "spokenPhraseEn": "check notifications", + "category": "Notifications", + "endpoint": "/api/voice/actions/notifications/summary", + "method": "POST", + "payload": {}, + "action": "notifications.summary", + "target": "notifications", + "risk": "low", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_notifications", + "homeAssistantScriptName": "appops_check_notifications", + "description": "Read-only notification rule/channel summary.", + "safetyNotes": ["Read-only", "Does not send Home Assistant notifications"], + }, + { + "id": "unraid.storage", + "label": "Check Storage", + "spokenPhraseNl": "controleer opslag", + "spokenPhraseEn": "check storage", + "category": "Infrastructure", + "endpoint": "/api/voice/actions/unraid/storage", + "method": "POST", "payload": {}, "action": "unraid.health_summary", "target": "unraid", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_storage", "homeAssistantScriptName": "appops_check_storage", + "description": "Returns live disk, cache and parity status without changing storage.", + "safetyNotes": ["Read-only", "No mover, format or array action"], + }, + { + "id": "unraid.vms", + "label": "Check Virtual Machines", + "spokenPhraseNl": "controleer virtuele machines", + "spokenPhraseEn": "check virtual machines", + "category": "Infrastructure", + "endpoint": "/api/voice/actions/unraid/vms", + "method": "POST", "payload": {}, "action": "unraid.health_summary", "target": "unraid", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_vms", "homeAssistantScriptName": "appops_check_vms", + "description": "Returns the VM inventory and power states visible to the Unraid API.", + "safetyNotes": ["Read-only", "Does not start or stop a VM"], + }, + { + "id": "apps.sonarr.health", + "label": "Check Sonarr", + "spokenPhraseNl": "controleer sonarr", + "spokenPhraseEn": "check sonarr", + "category": "Media", + "endpoint": "/api/voice/actions/apps/sonarr/health", + "method": "POST", "payload": {}, "action": "sonarr.health", "target": "sonarr", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_sonarr", "homeAssistantScriptName": "appops_check_sonarr", + "description": "Returns Sonarr health without modifying series.", "safetyNotes": ["Read-only"], + }, + { + "id": "apps.sonarr.queue", + "label": "Check Sonarr Queue", + "spokenPhraseNl": "controleer sonarr wachtrij", + "spokenPhraseEn": "check sonarr queue", + "category": "Media", + "endpoint": "/api/voice/actions/apps/sonarr/queue", + "method": "POST", "payload": {}, "action": "sonarr.queue_status", "target": "sonarr", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_sonarr_queue", "homeAssistantScriptName": "appops_check_sonarr_queue", + "description": "Returns the current Sonarr queue summary.", "safetyNotes": ["Read-only"], + }, + { + "id": "apps.radarr.health", + "label": "Check Radarr", + "spokenPhraseNl": "controleer radarr", + "spokenPhraseEn": "check radarr", + "category": "Media", + "endpoint": "/api/voice/actions/apps/radarr/health", + "method": "POST", "payload": {}, "action": "radarr.health", "target": "radarr", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_radarr", "homeAssistantScriptName": "appops_check_radarr", + "description": "Returns Radarr health without modifying movies.", "safetyNotes": ["Read-only"], + }, + { + "id": "apps.radarr.queue", + "label": "Check Radarr Queue", + "spokenPhraseNl": "controleer radarr wachtrij", + "spokenPhraseEn": "check radarr queue", + "category": "Media", + "endpoint": "/api/voice/actions/apps/radarr/queue", + "method": "POST", "payload": {}, "action": "radarr.queue_status", "target": "radarr", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_radarr_queue", "homeAssistantScriptName": "appops_check_radarr_queue", + "description": "Returns the current Radarr queue summary.", "safetyNotes": ["Read-only"], + }, + { + "id": "apps.prowlarr.indexers", + "label": "Check Prowlarr Indexers", + "spokenPhraseNl": "controleer prowlarr indexers", + "spokenPhraseEn": "check prowlarr indexers", + "category": "Media", + "endpoint": "/api/voice/actions/apps/prowlarr/indexers", + "method": "POST", "payload": {}, "action": "prowlarr.indexer_health", "target": "prowlarr", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_prowlarr", "homeAssistantScriptName": "appops_check_prowlarr", + "description": "Returns Prowlarr indexer health and failures.", "safetyNotes": ["Read-only"], + }, + { + "id": "apps.deluge.stalled", + "label": "Check Stalled Downloads", + "spokenPhraseNl": "controleer vastgelopen downloads", + "spokenPhraseEn": "check stalled downloads", + "category": "Downloads", + "endpoint": "/api/voice/actions/apps/deluge/stalled", + "method": "POST", "payload": {}, "action": "deluge.stalled_downloads", "target": "deluge", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_stalled_downloads", "homeAssistantScriptName": "appops_check_stalled_downloads", + "description": "Returns stalled Deluge downloads without changing them.", "safetyNotes": ["Read-only", "No delete-with-data"], + }, + { + "id": "apps.plex.recent", + "label": "Check Recent Plex Media", + "spokenPhraseNl": "wat is recent toegevoegd aan plex", + "spokenPhraseEn": "check recent plex media", + "category": "Media", + "endpoint": "/api/voice/actions/apps/plex/recent", + "method": "POST", "payload": {}, "action": "plex.recent_media", "target": "plex", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_plex_recent", "homeAssistantScriptName": "appops_check_plex_recent", + "description": "Returns recently added Plex media.", "safetyNotes": ["Read-only"], + }, + { + "id": "apps.tautulli.activity", + "label": "Check Tautulli Activity", + "spokenPhraseNl": "controleer tautulli activiteit", + "spokenPhraseEn": "check tautulli activity", + "category": "Media", + "endpoint": "/api/voice/actions/apps/tautulli/activity", + "method": "POST", "payload": {}, "action": "tautulli.activity", "target": "tautulli", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_tautulli", "homeAssistantScriptName": "appops_check_tautulli", + "description": "Returns current Tautulli stream and user activity.", "safetyNotes": ["Read-only"], + }, + { + "id": "security.exposure", + "label": "Check Public Exposure", + "spokenPhraseNl": "controleer publieke blootstelling", + "spokenPhraseEn": "check public exposure", + "category": "Safety", + "endpoint": "/api/voice/actions/security/exposure", + "method": "POST", "payload": {}, "action": "npm.exposure_summary", "target": "nginx_proxy_manager", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_exposure", "homeAssistantScriptName": "appops_check_exposure", + "description": "Returns Nginx Proxy Manager exposure and certificate posture.", "safetyNotes": ["Read-only", "No proxy mutation"], + }, + { + "id": "security.authentik", + "label": "Check Authentik", + "spokenPhraseNl": "controleer authentik", + "spokenPhraseEn": "check authentik", + "category": "Safety", + "endpoint": "/api/voice/actions/security/authentik", + "method": "POST", "payload": {}, "action": "authentik.health", "target": "authentik", + "risk": "low", "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_check_authentik", "homeAssistantScriptName": "appops_check_authentik", + "description": "Returns Authentik health without changing identity configuration.", "safetyNotes": ["Read-only", "No identity mutation"], + }, + { + "id": "container.widefrog.restart", + "label": "Restart Widefrog", + "spokenPhraseNl": "herstart widefrog", + "spokenPhraseEn": "restart widefrog", + "category": "Apps", + "endpoint": "/api/voice/actions/container/widefrog/restart", + "method": "POST", + "payload": {}, + "action": "container.restart", + "target": "widefrog", + "risk": "medium", + "enabledByDefault": True, + "homeAssistantRestCommandName": "appops_restart_widefrog", + "homeAssistantScriptName": "appops_restart_widefrog", + "description": "Policy-aware Widefrog container restart.", + "safetyNotes": ["Only exported while policy allows voice restart", "Audited", "No stack redeploy"], + }, + { + "id": "playbooks.fix-widefrog.run", + "label": "Run Fix Widefrog Playbook", + "spokenPhraseNl": "voer widefrog herstel uit", + "spokenPhraseEn": "run fix widefrog playbook", + "category": "Playbooks", + "endpoint": "/api/voice/actions/playbooks/fix-widefrog/run", + "method": "POST", + "payload": {}, + "action": "playbook.run", + "target": "fix-widefrog", + "risk": "medium", + "enabledByDefault": False, + "homeAssistantRestCommandName": "appops_run_fix_widefrog_playbook", + "homeAssistantScriptName": "appops_run_fix_widefrog_playbook", + "description": "Requests policy evaluation for the Widefrog recovery playbook.", + "safetyNotes": ["Approval may be required", "No direct connector call before policy"], + }, + { + "id": "notifications.evaluate.safety", + "label": "Evaluate Safety Notifications", + "spokenPhraseNl": "evalueer veiligheidsnotificaties", + "spokenPhraseEn": "evaluate safety notifications", + "category": "Notifications", + "endpoint": "/api/voice/actions/notifications/evaluate/safety", + "method": "POST", + "payload": {}, + "action": "notifications.evaluate_safety", + "target": "notifications", + "risk": "medium", + "enabledByDefault": False, + "homeAssistantRestCommandName": "appops_evaluate_safety_notifications", + "homeAssistantScriptName": "appops_evaluate_safety_notifications", + "description": "Policy-aware safety notification evaluation.", + "safetyNotes": ["May create notification events", "Does not expose HOME_ASSISTANT_TOKEN"], + }, +] + +BLOCKED_VOICE_EXAMPLES = [ + { + "id": "container.authentik.restart", + "label": "Restart Authentik direct command", + "action": "container.restart", + "target": "authentik", + "reason": "Critical identity service restart is not exported as direct voice command.", + }, + { + "id": "bulk-download-pause", + "label": "Pause all downloads", + "target": "deluge", + "reason": "Bulk download actions require explicit operator control.", + }, + { + "id": "stack-redeploy", + "label": "Stack redeploy", + "target": "stack", + "reason": "Redeploy changes runtime topology and is not exported to voice.", + }, + { + "id": "unraid-mover-start", + "label": "Unraid mover", + "target": "unraid", + "reason": "High-impact storage maintenance is blocked for voice.", + }, + { + "id": "volume-delete", + "label": "Delete/prune/volume", + "target": "volume", + "reason": "Data destructive operations are blocked.", + }, +] + + +def list_commands(session: Session) -> list[dict[str, Any]]: + exported_at = datetime.now(UTC).isoformat() + commands = [] + for command in VOICE_COMMANDS: + policy = evaluate_policy(session, action=command["action"], target=command["target"], source="voice") + decision = str(policy["decision"]) + if decision == "blocked": + continue + commands.append( + { + **_public_command(command), + "policyDecision": decision, + "requiresApproval": decision == "approval_required", + "lastExportedAt": exported_at, + } + ) + return commands + + +def command_by_id(session: Session, command_id: str) -> dict[str, Any] | None: + return next((command for command in list_commands(session) if command["id"] == command_id), None) + + +def test_intent(session: Session, text: str) -> dict[str, Any]: + normalized = " ".join(text.lower().strip().split()) + if not normalized: + return {"status": "unknown", "executed": False, "message": "No spoken phrase supplied."} + + for command in list_commands(session): + phrases = [ + str(command.get("spokenPhraseNl", "")).lower(), + str(command.get("spokenPhraseEn", "")).lower(), + str(command.get("label", "")).lower(), + ] + if normalized in phrases or any(phrase and phrase in normalized for phrase in phrases): + return dry_run(session, command["id"]) + + return {"status": "unknown", "executed": False, "message": "No allowlisted voice command matched."} + + +def dry_run(session: Session, command_id: str) -> dict[str, Any]: + command = command_by_id(session, command_id) + if not command: + return {"status": "unknown", "executed": False, "message": "Voice command is not allowlisted or is blocked."} + + policy = evaluate_policy(session, action=command["action"], target=command["target"], source="voice") + return { + "status": "matched", + "executed": False, + "command": command, + "policyPreview": { + "decision": policy["decision"], + "reason": policy["reason"], + "risk": policy.get("risk", command["risk"]), + }, + "request": { + "method": command["method"], + "endpoint": command["endpoint"], + "payload": command["payload"], + }, + } + + +def request_approval(session: Session, command_id: str) -> dict[str, Any]: + command = command_by_id(session, command_id) + if not command: + return {"success": False, "status": "unknown", "message": "Voice command is not allowlisted or is blocked."} + policy = evaluate_policy(session, action=command["action"], target=command["target"], source="voice") + if policy["decision"] == "allowed": + return {"success": False, "status": "not_required", "message": "Command is allowed and does not need approval.", "command": command} + if policy["decision"] == "blocked": + return {"success": False, "status": "blocked", "message": policy["reason"], "command": command} + + from .approval_service import create_approval + + approval = create_approval( + session, + action=command["action"], + target=command["target"], + source="voice", + risk=str(policy.get("risk", command["risk"])), + reason=policy["reason"], + requester="voice", + ) + return {"success": True, "status": "approval_requested", "approvalId": approval.id, "command": command} + + +def home_assistant_export(session: Session, base_url: str = 'http://appops:1216') -> dict[str, Any]: + commands = list_commands(session) + normalized_base_url = base_url.rstrip('/') + return { + "yaml": "\n\n".join([rest_commands_yaml(commands, normalized_base_url), scripts_yaml(commands), dashboard_yaml(commands), secrets_yaml()]), + "restCommandsYaml": rest_commands_yaml(commands, normalized_base_url), + "scriptsYaml": scripts_yaml(commands), + "dashboardYaml": dashboard_yaml(commands), + "secretsYaml": secrets_yaml(), + "commands": commands, + "blocked": BLOCKED_VOICE_EXAMPLES, + "endpoints": [ + { + "name": command["label"], + "path": command["endpoint"], + "method": command["method"], + "policyDecision": command["policyDecision"], + "requiresApproval": command["requiresApproval"], + } + for command in commands + ], + "token": { + "name": "APP_OPS_VOICE_TOKEN", + "status": "required", + "homeAssistantSecret": "appops_voice_authorization", + "note": "HOME_ASSISTANT_TOKEN is only for AppOps-to-Home-Assistant notification delivery.", + }, + "publicBaseUrl": normalized_base_url, + "homeAssistantUrl": str(settings.apis.get('HOME_ASSISTANT_URL') or '').rstrip('/'), + "setupGuide": [ + {"id": "token", "title": "Bewaar de AppOps bearer header", "detail": "Plaats 'Bearer ' plus APP_OPS_VOICE_TOKEN als appops_voice_authorization in Home Assistant secrets.yaml."}, + {"id": "rest", "title": "Installeer rest_command", "detail": "Voeg het rest_command-blok toe aan configuration.yaml of een Home Assistant package."}, + {"id": "scripts", "title": "Installeer de scripts", "detail": "Voeg het script-blok toe en herlaad scripts of herstart Home Assistant."}, + {"id": "test", "title": "Test eerst read-only", "detail": "Voer Check AppOps setup handmatig uit en controleer AppOps Audit."}, + {"id": "gemini", "title": "Koppel Gemini aan Assist", "detail": "Configureer Google Gemini Conversation met Control Home Assistant ingeschakeld en exposeer alleen de gewenste AppOps-scripts aan Assist."}, + {"id": "phone", "title": "Gebruik de Home Assistant Assist-pipeline", "detail": "Selecteer de Gemini conversation agent in je Assist-pipeline en start die pipeline vanuit de Home Assistant Companion-app op je telefoon."}, + ], + } + + +def rest_commands_yaml(commands: list[dict[str, Any]], base_url: str = 'http://appops:1216') -> str: + lines = ["rest_command:"] + for command in commands: + lines.extend( + [ + f" {command['homeAssistantRestCommandName']}:", + f" url: \"{base_url.rstrip('/')}{command['endpoint']}\"", + f" method: {command['method'].lower()}", + " headers:", + " Authorization: !secret appops_voice_authorization", + " Content-Type: \"application/json\"", + " payload: '{}'", + ] + ) + return "\n".join(lines) + + +def scripts_yaml(commands: list[dict[str, Any]]) -> str: + lines = ["script:"] + for command in commands: + lines.extend( + [ + f" {command['homeAssistantScriptName']}:", + f" alias: \"{command['label']}\"", + f" description: \"{command['description']} Tell the user the returned AppOps status and never claim execution beyond the response.\"", + " mode: single", + " sequence:", + " - action: rest_command." + command["homeAssistantRestCommandName"], + " response_variable: appops_result", + " - variables:", + " appops_response:", + " status: \"{{ appops_result.status | default(0) }}\"", + " content: \"{{ appops_result.content | default('{}') }}\"", + " - stop: \"AppOps request completed\"", + " response_variable: appops_response", + ] + ) + return "\n".join(lines) + + +def dashboard_yaml(commands: list[dict[str, Any]]) -> str: + lines = [ + "# Lovelace dashboard snippet", + "type: entities", + "title: AppOps Voice Commands", + "entities:", + ] + for command in commands: + lines.append(f" - entity: script.{command['homeAssistantScriptName']}") + lines.append(f" name: \"{command['label']}\"") + return "\n".join(lines) + + +def secrets_yaml() -> str: + return 'secrets.yaml:\n appops_voice_authorization: "Bearer PASTE_APP_OPS_VOICE_TOKEN_HERE"' + + +def _public_command(command: dict[str, Any]) -> dict[str, Any]: + public = {key: value for key, value in command.items() if key not in {"action", "target"}} + public["action"] = command["action"] + public["target"] = command["target"] + return public diff --git a/backend/app/services/workmesh_first_deployment.py b/backend/app/services/workmesh_first_deployment.py new file mode 100644 index 0000000..0bd324f --- /dev/null +++ b/backend/app/services/workmesh_first_deployment.py @@ -0,0 +1,588 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import quote + +from sqlmodel import Session + +from ..connectors.portainer import PortainerConnector +from ..core.redaction import redact_message +from ..core.time import utc_now +from ..db.models import AppSetting, JobRun +from .job_runner import set_job_progress + +_CONTROLLER = "gitea-deploy-control" +_REPOSITORY = "jens/workmesh" +_STATE_KEY = "deployment:workmesh:first-deploy:v1" +_RELEASE_REVISION = "7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05" +_CONTAINERS = ( + "workmesh-postgres-1", + "workmesh-mock-ats-1", + "workmesh-api-1", + "workmesh-n8n-1", + "workmesh-workmesh-1", +) +_PORTS = (1255, 1256, 1257, 1258) + + +# Fixed first-deploy bootstrap. Callers provide no repository, path, command, +# port, environment value, or secret. Secret material is generated inside the +# root-owned deployment controller and is never attached to exec output. +_FIRST_DEPLOY_SCRIPT = r""" +import fcntl +import json +import os +from pathlib import Path +import secrets +import shutil +import stat +import subprocess +import sys +import time + +repository = "jens/workmesh" +expected_release_revision = "7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05" +slug = "workmesh" +compose_project = "workmesh" +containers = [ + "workmesh-postgres-1", + "workmesh-mock-ats-1", + "workmesh-api-1", + "workmesh-n8n-1", + "workmesh-workmesh-1", +] +ports = {1255, 1256, 1257, 1258} +controller_root = Path("/opt/gitea-deploy") +registry_path = controller_root / "projects.json" +host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json") +lock_path = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") +env_dir = Path("/srv/example/appdata/workmesh") +env_path = env_dir / ".env" +bare_repo = Path("/gitea-repositories/jens/workmesh.git") +source_dir = Path("/mnt/cache/appdata/gitea-deploy-runners/sources/workmesh") +deploy_py = controller_root / "deploy.py" + +base_project = { + "branch": "main", + "slug": slug, + "source_dir": str(source_dir), + "exact_source": True, + "compose_files": ["compose.yaml"], + "compose_project": compose_project, + "containers": containers, + "verify_oci_revision": False, + "health_timeout": 300, +} +project = {**base_project, "env_source": str(env_path)} + +for path in (registry_path, host_registry_path, lock_path, bare_repo, deploy_py): + if path.is_symlink(): + raise SystemExit(20) +if not registry_path.is_file() or not host_registry_path.is_file() or not deploy_py.is_file(): + raise SystemExit(20) +if not bare_repo.is_dir(): + raise SystemExit(21) +try: + if not os.path.samefile(registry_path, host_registry_path): + raise SystemExit(22) +except OSError: + raise SystemExit(22) + + +def git(*args): + return subprocess.run( + ["git", f"--git-dir={bare_repo}", *args], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + + +main = git("rev-parse", "refs/heads/main") +revision = main.stdout.strip() +if main.returncode != 0 or len(revision) != 40 or revision != expected_release_revision: + raise SystemExit(23) +for required in ("compose.yaml", "VERSION", ".itworx/deployment-target.json"): + if git("cat-file", "-e", f"{revision}:{required}").returncode != 0: + raise SystemExit(24) +version = git("show", f"{revision}:VERSION") +if version.returncode != 0 or version.stdout.strip() != "0.1.0": + raise SystemExit(25) +descriptor = git("show", f"{revision}:.itworx/deployment-target.json") +try: + descriptor_payload = json.loads(descriptor.stdout) +except Exception: + raise SystemExit(26) +if descriptor_payload != { + "apiVersion": "itworx.deployment-target/v1", + "applicationId": "workmesh", + "composeFile": "compose.yaml", + "composeProject": "workmesh", + "healthTimeout": 300, + "verifyOciRevision": False, +}: + raise SystemExit(26) + +# Refuse to take host ports from any non-WorkMesh container, including stopped +# containers that would become conflicting if restarted later. +listed = subprocess.run( + ["docker", "ps", "-aq"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=20, + check=False, +) +if listed.returncode != 0: + raise SystemExit(27) +for container_id in [line.strip() for line in listed.stdout.splitlines() if line.strip()]: + inspected = subprocess.run( + ["docker", "inspect", container_id], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if inspected.returncode != 0: + raise SystemExit(27) + try: + item = json.loads(inspected.stdout)[0] + except Exception: + raise SystemExit(27) + labels = dict(dict(item.get("Config") or {}).get("Labels") or {}) + if labels.get("com.docker.compose.project") == compose_project: + continue + bindings = dict(dict(item.get("HostConfig") or {}).get("PortBindings") or {}) + for values in bindings.values(): + for binding in values or []: + raw = str(dict(binding).get("HostPort") or "") + if raw.isdigit() and int(raw) in ports: + raise SystemExit(28) + +required_fixed = { + "WORKMESH_ENV": "production", + "WORKMESH_API_PORT": "1256", + "WORKMESH_WEB_PORT": "1255", + "WORKMESH_BIND_HOST": "127.0.0.1", + "MOCK_ATS_PORT": "1258", + "WORKMESH_POSTGRES_DB": "workmesh", + "WORKMESH_POSTGRES_USER": "workmesh", + "N8N_BASE_URL": "http://n8n:5678", + "WORKMESH_API_BASE_URL": "http://api:8000", + "N8N_PORT": "1257", + "MOCK_ATS_BASE_URL": "http://mock-ats:8010", + "DEMO_CONTROLS_ENABLED": "true", + "AI_PROVIDER": "disabled", + "AI_MODEL": "qwen3.5:9b", + "AI_TIMEOUT_SECONDS": "30", + "OLLAMA_BASE_URL": "http://host.docker.internal:11434", + "OPENAI_COMPATIBLE_BASE_URL": "", + "OPENAI_COMPATIBLE_API_KEY": "", + "KNOWLEDGE_PROVIDER": "fixture", + "RAGCORE_BASE_URL": "", + "RAGCORE_TOKEN": "", + "RAGCORE_SPACE_IDS": "", + "RAGCORE_PROFILE": "hybrid-v1", + "RAGCORE_TIMEOUT_SECONDS": "10", + "NEXT_PUBLIC_WORKMESH_API_URL": "http://127.0.0.1:1256", + "CORS_ORIGINS": "http://localhost:1255,http://127.0.0.1:1255", +} +secret_keys = ( + "WORKMESH_POSTGRES_PASSWORD", + "WORKMESH_N8N_CALLBACK_SECRET", + "N8N_ENCRYPTION_KEY", +) + + +def parse_env(path): + values = {} + for line in path.read_text(encoding="utf-8").splitlines(): + if not line or line.startswith("#"): + continue + if "=" not in line: + raise SystemExit(31) + key, value = line.split("=", 1) + if key in values or not key: + raise SystemExit(31) + values[key] = value + return values + + +if env_path.exists(): + if env_path.is_symlink() or not env_path.is_file(): + raise SystemExit(29) + info = env_path.stat() + if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o077: + raise SystemExit(30) + env = parse_env(env_path) + if set(env) != set(required_fixed) | set(secret_keys): + raise SystemExit(31) + if any(env.get(key) != value for key, value in required_fixed.items()): + raise SystemExit(32) + if any(len(env.get(key, "")) < 32 or "change-me" in env.get(key, "").lower() for key in secret_keys): + raise SystemExit(33) +else: + env_dir.mkdir(parents=True, exist_ok=True) + if env_dir.is_symlink() or not env_dir.is_dir(): + raise SystemExit(29) + generated = {key: secrets.token_urlsafe(48) for key in secret_keys} + env = {**required_fixed, **generated} + temporary_env = env_dir / f".env.{os.getpid()}.tmp" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + descriptor_fd = os.open(temporary_env, flags, 0o600) + try: + with os.fdopen(descriptor_fd, "w", encoding="utf-8") as handle: + for key in sorted(env): + handle.write(f"{key}={env[key]}\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_env, env_path) + os.chmod(env_path, 0o600) + directory_fd = os.open(env_dir, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + finally: + try: + temporary_env.unlink(missing_ok=True) + except Exception: + pass + +try: + lock_handle = lock_path.open("a+", encoding="utf-8") +except Exception: + raise SystemExit(34) +with lock_handle as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX) + except Exception: + raise SystemExit(34) + try: + projects = json.loads(host_registry_path.read_text(encoding="utf-8")) + except Exception: + raise SystemExit(35) + if not isinstance(projects, dict): + raise SystemExit(35) + current = projects.get(repository) + if current is not None: + if not isinstance(current, dict): + raise SystemExit(36) + allowed_metadata = {"gitea_repository_id", "gitea_webhook_secret_file", "validation"} + if set(current) - set(project) - allowed_metadata: + raise SystemExit(36) + for key, value in base_project.items(): + if current.get(key) != value: + raise SystemExit(36) + current_env = current.get("env_source") + if current_env not in {None, str(env_path)}: + raise SystemExit(36) + updated_project = {**current, **project} + else: + updated_project = project + + if current != updated_project: + backup = host_registry_path.with_name( + f"projects.json.workmesh-backup-{int(time.time())}-{os.getpid()}" + ) + shutil.copy2(host_registry_path, backup) + updated = dict(projects) + updated[repository] = updated_project + temporary = host_registry_path.with_name(f".{host_registry_path.name}.{os.getpid()}.tmp") + payload = json.dumps(updated, indent=2, sort_keys=True) + "\n" + with temporary.open("w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, stat.S_IMODE(host_registry_path.stat().st_mode)) + os.replace(temporary, host_registry_path) + directory_fd = os.open(host_registry_path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + +# The official controller is the sole deployment executor. It revalidates the +# exact revision, source tree, Compose topology, health, receipt and rollback. +preflight = subprocess.run( + ["python3", str(deploy_py), "preflight", repository, revision], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=300, + check=False, +) +if preflight.returncode != 0: + raise SystemExit(40) +# Refuse branch drift between preflight and the mutating deployment. +main_again = git("rev-parse", "refs/heads/main") +if main_again.returncode != 0 or main_again.stdout.strip() != revision: + raise SystemExit(41) +deployed = subprocess.run( + ["python3", str(deploy_py), "deploy", repository, revision], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1800, + check=False, +) +if deployed.returncode != 0: + raise SystemExit(42) +raise SystemExit(0) +""" + + +class WorkMeshFirstDeployment: + def __init__(self, portainer: PortainerConnector | None = None): + self.portainer = portainer or PortainerConnector() + + @staticmethod + def _state(session: Session) -> AppSetting: + row = session.get(AppSetting, _STATE_KEY) + if row: + return row + row = AppSetting( + key=_STATE_KEY, + value_json={"state": "idle", "version": 1}, + is_secret=False, + ) + session.add(row) + session.commit() + session.refresh(row) + return row + + @staticmethod + def tracked_job_id(session: Session) -> str | None: + row = session.get(AppSetting, _STATE_KEY) + state = dict(row.value_json or {}) if row else {} + job_id = str(state.get("jobId") or "") + return job_id or None + + @staticmethod + def _result_for_exit(exit_code: int) -> dict[str, Any]: + if exit_code == 0: + return { + "status": "deployed", + "repository": _REPOSITORY, + "revision": _RELEASE_REVISION, + "compose_project": "workmesh", + "containers": list(_CONTAINERS), + "ports": list(_PORTS), + } + reasons = { + 20: "deployment controller registry or executable is unsafe or unavailable", + 21: "mounted WorkMesh bare repository is unavailable", + 22: "deployment registry host alias does not match the controller registry", + 23: "WorkMesh protected main is not the fixed v0.1.0 release revision", + 24: "required WorkMesh deployment artifacts are absent from protected main", + 25: "WorkMesh VERSION is not 0.1.0", + 26: "WorkMesh deployment descriptor differs from the fixed first-deploy contract", + 27: "Docker inventory could not be inspected safely", + 28: "one of the reserved WorkMesh loopback ports is already owned by another container", + 29: "WorkMesh environment directory or file is unsafe", + 30: "WorkMesh environment file permissions are too broad", + 31: "WorkMesh environment file keys are outside the fixed contract", + 32: "WorkMesh environment fixed values differ from the reviewed contract", + 33: "WorkMesh environment secrets are missing, weak, or development placeholders", + 34: "deployment registry lock could not be acquired", + 35: "deployment project registry is invalid", + 36: "an existing WorkMesh deployment target conflicts with the fixed contract", + 40: "official WorkMesh deployment preflight failed", + 41: "WorkMesh protected main changed between preflight and deployment", + 42: "official WorkMesh first deployment failed or rolled back", + } + return { + "status": "failed", + "reason": reasons.get(exit_code, "WorkMesh first deployment failed closed"), + } + + def run(self, session: Session, *, job: JobRun) -> dict[str, Any]: + row = self._state(session) + state = dict(row.value_json or {}) + if state.get("state") == "completed" and isinstance(state.get("result"), dict): + return dict(state["result"]) + + execution_id = str(state.get("executionId") or "") + if state.get("state") == "creating": + return { + "status": "outcome_unknown", + "reason": "WorkMesh deployment exec creation has an unknown outcome; do not retry", + } + if state.get("state") == "created" and execution_id: + return { + "status": "outcome_unknown", + "reason": ( + "WorkMesh deployment execution was created but its start boundary " + "was not durably confirmed; do not retry" + ), + "execution_id": execution_id, + } + + controller_status = self.portainer.get_container_status(_CONTROLLER) + if controller_status.get("status") != "ok": + return { + "status": "outcome_unknown" if state.get("state") == "started" else "error", + "reason": "deployment controller is unavailable", + **({"execution_id": execution_id} if execution_id else {}), + } + controller = dict(controller_status.get("container") or {}) + if controller.get("name") != _CONTROLLER or controller.get("state") != "running": + return { + "status": "outcome_unknown" if state.get("state") == "started" else "error", + "reason": "deployment controller is not running", + **({"execution_id": execution_id} if execution_id else {}), + } + controller_id = str(controller.get("id") or "") + config = self.portainer._config() + if not config or not controller_id: + return { + "status": "outcome_unknown" if state.get("state") == "started" else "error", + "reason": "Portainer deployment control is unavailable", + **({"execution_id": execution_id} if execution_id else {}), + } + + original_controller_id = str(state.get("controllerId") or "") + endpoint = quote(str(config.endpoint_id), safe="") + + if state.get("state") == "started" and execution_id: + if original_controller_id != controller_id: + return { + "status": "outcome_unknown", + "reason": "deployment controller identity changed while first-deploy outcome was unknown", + "execution_id": execution_id, + } + try: + inspected = self.portainer._request( + "GET", + f"/api/endpoints/{endpoint}/docker/exec/{quote(execution_id, safe='')}/json", + ).json() + except (RuntimeError, ValueError, TypeError) as exc: + return { + "status": "outcome_unknown", + "reason": f"existing WorkMesh deployment outcome remains unknown: {redact_message(str(exc))}", + "execution_id": execution_id, + } + if ( + str(inspected.get("ID") or "") != execution_id + or str(inspected.get("ContainerID") or "") != controller_id + ): + return { + "status": "outcome_unknown", + "reason": "WorkMesh deployment execution identity could not be verified", + "execution_id": execution_id, + } + if bool(inspected.get("Running")): + if job.status not in {"succeeded", "failed", "cancelled"}: + set_job_progress(session, job, max(job.progress, 50), "Official WorkMesh first deployment is still running.") + return {"status": "in_progress", "execution_id": execution_id} + raw_exit = inspected.get("ExitCode") + exit_code = int(raw_exit) if raw_exit is not None else -1 + result = self._result_for_exit(exit_code) + row.value_json = { + **state, + "state": "completed", + "result": result, + "completedAt": utc_now().isoformat(), + } + row.updated_at = utc_now() + session.add(row) + session.commit() + return result + + if state.get("state") not in {"idle", None}: + return { + "status": "outcome_unknown", + "reason": "WorkMesh first-deploy state is not safely restartable", + **({"execution_id": execution_id} if execution_id else {}), + } + + safe_controller = quote(controller_id, safe="") + row.value_json = { + "state": "creating", + "version": 1, + "jobId": job.id, + "controllerId": controller_id, + "createdAt": utc_now().isoformat(), + } + row.updated_at = utc_now() + session.add(row) + session.commit() + try: + created = self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/containers/{safe_controller}/exec", + json={ + "AttachStdout": False, + "AttachStderr": False, + "Tty": False, + "Cmd": ["python3", "-c", _FIRST_DEPLOY_SCRIPT], + }, + ).json() + execution_id = str(created.get("Id") or "") + if not execution_id: + return { + "status": "outcome_unknown", + "reason": "WorkMesh deployment exec creation identity is unknown; do not retry", + } + row.value_json = { + "state": "created", + "version": 1, + "jobId": job.id, + "executionId": execution_id, + "controllerId": controller_id, + "createdAt": utc_now().isoformat(), + } + row.updated_at = utc_now() + session.add(row) + session.commit() + self.portainer._request( + "POST", + f"/api/endpoints/{endpoint}/docker/exec/{quote(execution_id, safe='')}/start", + json={"Detach": True, "Tty": False}, + ) + row.value_json = { + **dict(row.value_json or {}), + "state": "started", + "startedAt": utc_now().isoformat(), + } + row.updated_at = utc_now() + session.add(row) + session.commit() + except (RuntimeError, ValueError, TypeError) as exc: + return { + "status": "outcome_unknown", + "reason": f"WorkMesh deployment exec outcome is unknown: {redact_message(str(exc))}", + **({"execution_id": execution_id} if execution_id else {}), + } + set_job_progress(session, job, 25, "Official WorkMesh first deployment started.") + return {"status": "in_progress", "execution_id": execution_id} + + + +def workmesh_first_deploy_job_cancellation_reason( + session: Session, + job: JobRun, +) -> str | None: + # A running fixed-target deployment may have reached the controller even + # when its state row was lost or does not yet name this job. Keep both + # cancellation entry points fail-closed until execution is reconciled. + if ( + job.action_id == "deployment.target.deploy_workmesh" + and job.target == _REPOSITORY + and job.status == "running" + ): + return ( + "WorkMesh first deployment may own an unresolved controller execution and " + "cannot be cancelled until that exact execution is reconciled." + ) + row = session.get(AppSetting, _STATE_KEY) + state = dict(row.value_json or {}) if row else {} + if str(state.get("jobId") or "") != job.id: + return None + if state.get("state") in {"created", "started"}: + return ( + "WorkMesh first deployment owns an active controller execution and " + "cannot be cancelled until that exact execution is reconciled." + ) + return None diff --git a/backend/app/services/workmesh_promotion.py b/backend/app/services/workmesh_promotion.py new file mode 100644 index 0000000..f9600fb --- /dev/null +++ b/backend/app/services/workmesh_promotion.py @@ -0,0 +1,722 @@ +"""Approval-bound promotion for the single registered WorkMesh product target.""" + +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from datetime import timedelta +from typing import Any + +from sqlalchemy import update +from sqlmodel import Session, select + +from ..core.time import utc_now +from ..db.models import Approval, WorkMeshPromotion +from .approval_service import approval_fingerprint +from .audit_logger import log_event +from .deployment_broker import DeploymentBrokerClient +from .secret_store import get_secret + +_ACTION = "deployment.target.promote_workmesh" +_TARGET = "jens/workmesh" +_SECRET_REF = "WORKMESH_DEPLOYMENT_BROKER" +_API_VERSION = "itworx.workmesh-promotion/v2" +_REVISION = re.compile(r"^[0-9a-f]{40}$") +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_IDEMPOTENCY = re.compile(r"^[A-Za-z0-9._:-]{8,128}$") +_CHECKS = {"exactMain", "pushValidation", "controllerPreflight", "dataBackup"} +_SAFE_PREFLIGHT_FIELDS = { + "apiVersion", + "status", + "repository", + "revision", + "preflightSha256", + "backupInputsSha256", + "checks", +} +_TERMINAL = {"succeeded", "failed", "preflight_changed"} + + +class WorkMeshPromotionError(ValueError): + """A fail-closed error in the fixed-target promotion contract.""" + + +def _idempotency_digest(value: str) -> str: + normalized = value.strip() + if not _IDEMPOTENCY.fullmatch(normalized): + raise WorkMeshPromotionError("Idempotency-Key must contain 8 to 128 safe characters.") + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _request_id(revision: str, idempotency_key: str) -> str: + value = json.dumps( + { + "repository": _TARGET, + "revision": revision, + "operation": "workmesh_promote", + "idempotency": idempotency_key, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(value).hexdigest() + + +def _safe_preflight(result: dict[str, Any], revision: str) -> dict[str, Any] | None: + checks = result.get("checks") + digest = str(result.get("preflightSha256") or "") + if ( + result.get("status") != "preflight_ok" + or result.get("apiVersion") != _API_VERSION + or result.get("repository") != _TARGET + or result.get("revision") != revision + or not _SHA256.fullmatch(digest) + or not _SHA256.fullmatch(str(result.get("backupInputsSha256") or "")) + or not isinstance(checks, dict) + or set(checks) != _CHECKS + or any(checks.get(key) != "passed" for key in _CHECKS) + ): + return None + return { + key: result[key] + for key in _SAFE_PREFLIGHT_FIELDS + if key in result + } + + +def _serialize( + row: WorkMeshPromotion, + *, + idempotent_replay: bool = False, + approval_status: str | None = None, +) -> dict[str, Any]: + return { + "promotionId": row.id, + "jobId": row.id, + "revision": row.revision, + "status": row.status, + "stage": row.stage, + "preflight": dict(row.preflight_json or {}), + "approvalId": row.approval_id, + "approvalStatus": approval_status, + "request": row.broker_request_id, + "evidence": dict(row.evidence_json or {}), + "result": row.result_summary, + "auditId": row.audit_event_id, + "createdAt": row.created_at.isoformat(), + "updatedAt": row.updated_at.isoformat(), + "completedAt": row.completed_at.isoformat() if row.completed_at else None, + "idempotentReplay": idempotent_replay, + } + + +def _record_preflight_error(result: dict[str, Any]) -> str: + # Never surface arbitrary controller response text or its transport details. + status = str(result.get("status") or "unavailable") + if status in {"unreachable", "unavailable", "unknown", "missing_config", "missing_secret"}: + return "The fixed WorkMesh controller preflight is unavailable." + if status in {"preflight_failed", "preflight_changed", "deployment_policy_rejected"}: + return "The fixed WorkMesh controller rejected the requested revision preflight." + return "The controller response did not satisfy the WorkMesh promotion contract." + + +class WorkMeshPromotionService: + def __init__(self, *, broker: DeploymentBrokerClient | None = None): + self.broker = broker or DeploymentBrokerClient() + + def preflight( + self, + session: Session, + *, + revision: str, + idempotency_key: str, + ) -> dict[str, Any]: + if not _REVISION.fullmatch(revision): + raise WorkMeshPromotionError("revision must be a full lowercase 40-character Git SHA.") + key_digest = _idempotency_digest(idempotency_key) + existing = session.exec( + select(WorkMeshPromotion).where( + WorkMeshPromotion.idempotency_sha256 == key_digest + ) + ).first() + if existing: + if existing.revision != revision: + raise WorkMeshPromotionError( + "Idempotency-Key was already used for a different WorkMesh revision." + ) + if existing.approval_id: + approval = session.get(Approval, existing.approval_id) + return _serialize( + existing, + idempotent_replay=True, + approval_status=approval.status if approval else None, + ) + raise WorkMeshPromotionError( + "The original preflight request is incomplete and requires operator review." + ) + + try: + secret = get_secret(session, _SECRET_REF, touch=False) + except Exception: + secret = None + if not secret: + raise WorkMeshPromotionError( + "The fixed WorkMesh deployment-controller secret is not configured." + ) + try: + result = self.broker.workmesh_promotion_preflight(revision, secret=secret) + except Exception as exc: + raise WorkMeshPromotionError( + "The fixed WorkMesh controller preflight is unavailable." + ) from exc + if not isinstance(result, dict): + result = {"status": "invalid_response"} + safe = _safe_preflight(result, revision) + if safe is None: + raise WorkMeshPromotionError(_record_preflight_error(result)) + + promotion_id = f"workmesh-prom-{uuid.uuid4().hex}" + context = { + "apiVersion": _API_VERSION, + "promotionId": promotion_id, + "repository": _TARGET, + "revision": revision, + "preflightSha256": safe["preflightSha256"], + "backupInputsSha256": safe["backupInputsSha256"], + "preflight": safe, + } + approval = Approval( + id=f"appr-{uuid.uuid4().hex[:12]}", + action=_ACTION, + target=_TARGET, + source="manual", + risk="high", + status="pending", + requester="operator", + reason=( + f"Promote WorkMesh revision {revision} after preflight digest " + f"{safe['preflightSha256']}." + ), + expires_at=utc_now() + timedelta(minutes=30), + request_context_json=context, + action_fingerprint=approval_fingerprint( + _ACTION, _TARGET, "manual", "high", context + ), + ) + row = WorkMeshPromotion( + id=promotion_id, + revision=revision, + preflight_sha256=safe["preflightSha256"], + idempotency_sha256=key_digest, + status="approval_required", + stage="preflight_accepted", + preflight_json=safe, + approval_id=approval.id, + ) + # The approval and its immutable preflight binding become durable together. + session.add(row) + session.add(approval) + session.commit() + session.refresh(row) + audit = log_event( + session, + source="deployment", + identity="operator", + action_id=_ACTION, + target=_TARGET, + target_type="approval", + risk="high", + policy_decision="approval_required", + reason="WorkMesh promotion preflight passed for the exact revision.", + result="approval_requested", + execution_plan={ + "steps": [ + "Verify exact protected-main revision and push validation", + "Run fixed WorkMesh controller preflight", + "Bind operator approval to the immutable preflight digest", + "Verify a PostgreSQL and n8n backup with isolated restore evidence before apply", + ], + "promotionId": row.id, + "revision": revision, + "preflightSha256": row.preflight_sha256, + "backupInputsSha256": safe["backupInputsSha256"], + "approvalId": approval.id, + }, + redacted_metadata={"promotionId": row.id, "approvalId": approval.id}, + ) + row.audit_event_id = audit.id + row.updated_at = utc_now() + session.add(row) + session.commit() + session.refresh(row) + return _serialize(row, approval_status=approval.status) + + def execute_approved(self, session: Session, approval: Approval) -> dict[str, Any]: + context = dict(approval.request_context_json or {}) + promotion_id = str(context.get("promotionId") or "") + row = session.get(WorkMeshPromotion, promotion_id) + if ( + approval.action != _ACTION + or approval.target != _TARGET + or approval.source != "manual" + or approval.risk != "high" + or approval.status != "approved" + or (approval.expires_at is not None and approval.expires_at <= utc_now()) + or context.get("apiVersion") != _API_VERSION + or not row + or row.approval_id != approval.id + or row.revision != context.get("revision") + or row.preflight_sha256 != context.get("preflightSha256") + or dict(row.preflight_json or {}).get("backupInputsSha256") + != context.get("backupInputsSha256") + ): + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "Approval is not bound to a valid fixed WorkMesh promotion preflight.", + } + + if row.status != "approval_required": + # The apply boundary is one-shot. This also closes the crash window + # after persisting `submitting` but before learning the HTTP outcome. + return self._approval_execution_result(row) + + try: + secret = get_secret(session, _SECRET_REF, touch=False) + except Exception: + secret = None + if not secret: + return { + "success": False, + "status": "failed", + "result": "blocked", + "message": "The fixed WorkMesh deployment-controller secret is unavailable.", + } + try: + current = self.broker.workmesh_promotion_preflight(row.revision, secret=secret) + except Exception: + current = {"status": "unavailable"} + if not isinstance(current, dict): + current = {"status": "invalid_response"} + safe = _safe_preflight(current, row.revision) + if ( + safe is None + or safe["preflightSha256"] != row.preflight_sha256 + or safe["backupInputsSha256"] + != dict(row.preflight_json or {}).get("backupInputsSha256") + ): + now = utc_now() + claim = session.exec( + update(WorkMeshPromotion) + .where( + (WorkMeshPromotion.id == row.id) + & (WorkMeshPromotion.status == "approval_required") + ) + .values( + status="preflight_changed", + stage="approval_invalidated", + evidence_json={"preflightChanged": True}, + result_summary="The controller preflight changed after approval; no promotion was queued.", + completed_at=now, + updated_at=now, + ) + ) + if claim.rowcount != 1: + session.rollback() + session.refresh(row) + return self._approval_execution_result(row) + approval.status = "expired" + approval.decided_at = now + session.add(approval) + session.commit() + session.refresh(row) + audit = log_event( + session, + source="approval", + identity="operator", + action_id=_ACTION, + target=_TARGET, + target_type="approval", + risk="high", + policy_decision="approval_required", + reason="WorkMesh preflight changed after operator approval; promotion was not queued.", + result="blocked", + redacted_metadata={"promotionId": row.id, "revision": row.revision}, + ) + row.audit_event_id = audit.id + row.updated_at = utc_now() + session.add(row) + session.commit() + return { + "success": False, + "status": row.status, + "result": "preflight_changed", + "message": row.result_summary, + "jobId": row.id, + "auditId": audit.id, + } + + idempotency_key = f"workmesh-promotion:{row.id}" + request_id = _request_id(row.revision, idempotency_key) + # This durable marker is committed before the only mutating controller + # request. Re-entry reconciles status and never repeats apply. + now = utc_now() + claim = session.exec( + update(WorkMeshPromotion) + .where( + (WorkMeshPromotion.id == row.id) + & (WorkMeshPromotion.status == "approval_required") + ) + .values( + status="submitting", + stage="controller_request_submitted", + broker_request_id=request_id, + updated_at=now, + ) + ) + session.commit() + if claim.rowcount != 1: + session.refresh(row) + return self._approval_execution_result(row) + session.refresh(row) + audit = log_event( + session, + source="approval", + identity="operator", + action_id=_ACTION, + target=_TARGET, + target_type="approval", + risk="high", + policy_decision="approval_required", + reason="Submitting the exact approved WorkMesh revision to the fixed controller target.", + result="pending", + execution_plan={ + "promotionId": row.id, + "revision": row.revision, + "preflightSha256": row.preflight_sha256, + "backupInputsSha256": dict(row.preflight_json or {}).get("backupInputsSha256"), + "controllerRequest": request_id, + }, + redacted_metadata={"promotionId": row.id, "approvalId": approval.id}, + ) + row.audit_event_id = audit.id + session.add(row) + session.commit() + + try: + result = self.broker.workmesh_promotion_apply( + row.revision, + secret=secret, + preflight_sha256=row.preflight_sha256, + idempotency_key=idempotency_key, + ) + except Exception: + result = {"status": "unavailable"} + if not isinstance(result, dict): + result = {"status": "invalid_response"} + return self._accept_apply_result(session, row, result, request_id) + + def _accept_apply_result( + self, + session: Session, + row: WorkMeshPromotion, + result: dict[str, Any], + request_id: str, + ) -> dict[str, Any]: + identity_matches = ( + result.get("request") == request_id + and result.get("repository") == _TARGET + and result.get("revision") == row.revision + and result.get("operation") == "workmesh_promote" + ) + status = str(result.get("status") or "unknown") + if not identity_matches: + self._mark_outcome_unknown(session, row) + return self._approval_execution_result(row) + + if ( + status == "preflight_changed" + and result.get("reason") == "approved_preflight_no_longer_matches" + ): + now = utc_now() + current_digest = str(result.get("preflightSha256") or "") + row.status = "preflight_changed" + row.stage = "approval_invalidated" + row.evidence_json = { + "preflightChanged": True, + **( + {"currentPreflightSha256": current_digest} + if _SHA256.fullmatch(current_digest) + else {} + ), + } + row.result_summary = ( + "Controller preflight changed before queueing; no deployment was started and approval expired." + ) + row.completed_at = now + row.updated_at = now + approval = session.get(Approval, row.approval_id) if row.approval_id else None + if approval and approval.status == "approved": + approval.status = "expired" + approval.decided_at = now + session.add(approval) + session.add(row) + audit = log_event( + session, + source="approval", + identity="operator", + action_id=_ACTION, + target=_TARGET, + target_type="approval", + risk="high", + policy_decision="approval_required", + reason=( + "Controller preflight changed before queueing; no deployment was started " + "and the approval was expired." + ), + result="blocked", + execution_plan={ + "promotionId": row.id, + "revision": row.revision, + "approvedPreflightSha256": row.preflight_sha256, + "controllerRequest": request_id, + }, + redacted_metadata={"promotionId": row.id, "approvalId": row.approval_id}, + ) + row.audit_event_id = audit.id + row.updated_at = utc_now() + session.add(row) + session.commit() + return self._approval_execution_result(row) + + if ( + status == "deployed" + and _SHA256.fullmatch(str(result.get("receiptSha256") or "")) + and _SHA256.fullmatch(str(result.get("backupReceiptSha256") or "")) + ): + row.status = "succeeded" + row.stage = "receipt_verified" + row.evidence_json = { + "repository": _TARGET, + "revision": row.revision, + "operation": "workmesh_promote", + "receiptSha256": result["receiptSha256"], + "backupReceiptSha256": result["backupReceiptSha256"], + } + row.result_summary = "The exact WorkMesh revision has a verified controller receipt." + row.completed_at = utc_now() + elif status in {"accepted", "already_queued", "pending", "running"}: + row.status = "applying" + row.stage = "controller_accepted" + row.evidence_json = { + "repository": _TARGET, + "revision": row.revision, + "operation": "workmesh_promote", + "backupInputsSha256": dict(row.preflight_json or {}).get("backupInputsSha256"), + } + row.result_summary = "The fixed controller accepted the WorkMesh promotion; reconcile its receipt." + elif status == "failed" and result.get("reason") in { + "controller_rejected_before_mutation", + "workmesh_preflight_changed", + }: + row.status = "preflight_changed" + row.stage = "approval_invalidated" + row.evidence_json = {"preflightChanged": True, "noMutation": True} + row.result_summary = ( + "The controller rejected the approved backup preflight before deployment; approval expired." + ) + approval = session.get(Approval, row.approval_id) if row.approval_id else None + if approval and approval.status == "approved": + approval.status = "expired" + approval.decided_at = utc_now() + session.add(approval) + row.completed_at = utc_now() + audit = log_event( + session, + source="approval", + identity="operator", + action_id=_ACTION, + target=_TARGET, + target_type="approval", + risk="high", + policy_decision="approval_required", + reason="Controller rejected the approved WorkMesh backup preflight before mutation.", + result="blocked", + execution_plan={ + "promotionId": row.id, + "revision": row.revision, + "preflightSha256": row.preflight_sha256, + "backupInputsSha256": dict(row.preflight_json or {}).get("backupInputsSha256"), + "controllerRequest": request_id, + }, + redacted_metadata={"promotionId": row.id, "approvalId": row.approval_id}, + ) + row.audit_event_id = audit.id + elif status == "failed": + row.status = "failed" + row.stage = "controller_failed" + row.evidence_json = {"repository": _TARGET, "revision": row.revision} + row.result_summary = "The fixed controller recorded a failed WorkMesh promotion." + row.completed_at = utc_now() + else: + self._mark_outcome_unknown(session, row) + return self._approval_execution_result(row) + row.updated_at = utc_now() + session.add(row) + session.commit() + return self._approval_execution_result(row) + + @staticmethod + def _mark_outcome_unknown(session: Session, row: WorkMeshPromotion) -> None: + row.status = "outcome_unknown" + row.stage = "reconciliation_required" + row.evidence_json = {"reconciliationRequired": True} + row.result_summary = ( + "Controller acceptance is ambiguous. Reconcile the fixed request status; do not resubmit." + ) + row.updated_at = utc_now() + session.add(row) + session.commit() + + @staticmethod + def _approval_execution_result(row: WorkMeshPromotion) -> dict[str, Any]: + # Once apply might have crossed the network boundary, success here means + # the approval was consumed and cannot be executed a second time. + return { + "success": row.status != "preflight_changed", + "status": row.status, + "result": row.status, + "message": row.result_summary or "WorkMesh promotion request was recorded.", + "jobId": row.id, + "auditId": row.audit_event_id or "", + } + + def refresh(self, session: Session, promotion_id: str) -> dict[str, Any] | None: + row = session.get(WorkMeshPromotion, promotion_id) + if not row: + return None + if row.status in _TERMINAL or not row.broker_request_id: + approval = session.get(Approval, row.approval_id) if row.approval_id else None + return _serialize(row, approval_status=approval.status if approval else None) + try: + secret = get_secret(session, _SECRET_REF, touch=False) + except Exception: + secret = None + if not secret: + approval = session.get(Approval, row.approval_id) if row.approval_id else None + return _serialize(row, approval_status=approval.status if approval else None) + try: + result = self.broker.workmesh_promotion_status( + row.broker_request_id, secret=secret + ) + except Exception: + result = {"status": "unavailable"} + if not isinstance(result, dict): + result = {"status": "unavailable"} + + matches = ( + result.get("request") == row.broker_request_id + and result.get("repository") == _TARGET + and result.get("revision") == row.revision + and result.get("operation") == "workmesh_promote" + ) + observed_status = str(result.get("status") or "unknown") + previous = row.status + if matches and observed_status == "deployed": + receipt_sha256 = str(result.get("receiptSha256") or "") + backup_receipt_sha256 = str(result.get("backupReceiptSha256") or "") + if _SHA256.fullmatch(receipt_sha256) and _SHA256.fullmatch(backup_receipt_sha256): + row.status = "succeeded" + row.stage = "receipt_verified" + row.evidence_json = { + "repository": _TARGET, + "revision": row.revision, + "operation": "workmesh_promote", + "receiptSha256": receipt_sha256, + "backupReceiptSha256": backup_receipt_sha256, + } + row.result_summary = "The exact WorkMesh revision has a verified controller receipt." + row.completed_at = utc_now() + elif matches and observed_status in {"pending", "running", "accepted", "already_queued"}: + row.status = "applying" + row.stage = "controller_running" + row.evidence_json = { + "repository": _TARGET, + "revision": row.revision, + "operation": "workmesh_promote", + "backupInputsSha256": dict(row.preflight_json or {}).get("backupInputsSha256"), + } + elif matches and observed_status == "failed" and result.get("reason") in { + "controller_rejected_before_mutation", + "workmesh_preflight_changed", + }: + row.status = "preflight_changed" + row.stage = "approval_invalidated" + row.evidence_json = {"preflightChanged": True, "noMutation": True} + row.result_summary = ( + "The controller rejected the approved backup preflight before deployment; approval expired." + ) + approval = session.get(Approval, row.approval_id) if row.approval_id else None + if approval and approval.status == "approved": + approval.status = "expired" + approval.decided_at = utc_now() + session.add(approval) + row.completed_at = utc_now() + elif matches and observed_status == "failed": + row.status = "failed" + row.stage = "controller_failed" + row.evidence_json = {"repository": _TARGET, "revision": row.revision} + row.result_summary = "The fixed controller recorded a failed WorkMesh promotion." + row.completed_at = utc_now() + else: + # Not found, transport failure, missing receipt, or identity drift + # cannot prove that it is safe to submit again. + row.status = "outcome_unknown" + row.stage = "reconciliation_required" + row.evidence_json = {"reconciliationRequired": True} + row.result_summary = ( + "Controller acceptance is not verified. Keep reconciling this request; do not resubmit." + ) + row.updated_at = utc_now() + session.add(row) + session.commit() + session.refresh(row) + if row.status != previous: + audit = log_event( + session, + source="deployment", + identity="operator", + action_id=_ACTION, + target=_TARGET, + target_type="deployment", + risk="high", + policy_decision="approval_required", + reason="Reconciled the fixed WorkMesh controller request without resubmission.", + result="success" if row.status == "succeeded" else row.status, + execution_plan={"promotionId": row.id, "revision": row.revision}, + redacted_metadata={"promotionId": row.id, "controllerStatus": observed_status}, + ) + row.audit_event_id = audit.id + row.updated_at = utc_now() + session.add(row) + session.commit() + approval = session.get(Approval, row.approval_id) if row.approval_id else None + return _serialize(row, approval_status=approval.status if approval else None) + + @staticmethod + def list_promotions(session: Session, *, limit: int = 20) -> list[dict[str, Any]]: + rows = session.exec( + select(WorkMeshPromotion) + .order_by(WorkMeshPromotion.created_at.desc()) + .limit(max(1, min(limit, 50))) + ).all() + # Listing is a durable local read. Reconciliation is an explicit GET by + # promotion id so page loads never create an unbounded controller poll. + result = [] + for row in rows: + approval = session.get(Approval, row.approval_id) if row.approval_id else None + result.append(_serialize(row, approval_status=approval.status if approval else None)) + return result diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..4f371e4 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +ruff==0.6.9 +pytest==9.0.3 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3f456c4 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.139.0 +pydantic==2.13.4 +uvicorn[standard]==0.52.4 +SQLAlchemy==2.0.35 +sqlmodel==0.0.22 +httpx==0.27.2 +cryptography==50.0.0 +pyyaml==6.0.2 +mcp==2.0.0 diff --git a/backend/tests/test_ai_remediation_planner.py b/backend/tests/test_ai_remediation_planner.py new file mode 100644 index 0000000..61c16f5 --- /dev/null +++ b/backend/tests/test_ai_remediation_planner.py @@ -0,0 +1,345 @@ +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, Session, create_engine + + +def _reset_db(tmp_path, monkeypatch): + db_path = tmp_path / "ai_remediation_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + + previous = { + "apis": dict(settings.apis), + "environment": settings.environment, + "secret_key": settings.secret_key, + "secret_key_source": settings.secret_key_source, + } + for key in settings.apis: + settings.apis[key] = None if key.endswith(("_URL", "_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD")) else settings.apis[key] + settings.environment = "development" + settings.secret_key = "test-ai-remediation-key" + settings.secret_key_source = "env" + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous["apis"]) + settings.environment = previous["environment"] + settings.secret_key = previous["secret_key"] + settings.secret_key_source = previous["secret_key_source"] + + +def _client(tmp_path, monkeypatch): + previous = _reset_db(tmp_path, monkeypatch) + from backend.app.main import app + + return TestClient(app), previous + + +def _npm_exposure(): + return { + "success": True, + "status": "ok", + "totalProxyHosts": 2, + "enabledProxyHosts": 2, + "publicHosts": 2, + "hostsWithoutCertificate": 1, + "hostsWithExpiredCertificate": 0, + "hostsWithExpiringCertificate": 1, + "hostsWithoutAccessList": 1, + "hostsWithoutSslForce": 1, + "hostsWithWebsocket": 1, + "warnings": [ + {"type": "host_without_certificate", "severity": "high", "title": "open.example.com has no certificate", "evidence": ["open.example.com"], "suggestedAction": "review_certificate"}, + {"type": "certificate_expiring_soon", "severity": "warning", "title": "plex.example.com certificate expires soon", "evidence": ["12"], "suggestedAction": "review_certificate_in_npm"}, + ], + } + + +def _auth_coverage(): + return { + "success": True, + "status": "ok", + "summary": {"totalPublicHosts": 2, "protectedHosts": 1, "likelyProtectedHosts": 0, "unprotectedHosts": 1, "npmStatus": "ok"}, + "publicHosts": [ + {"hostId": 1, "domains": ["plex.example.com"], "target": "http://plex:32400", "coverageStatus": "protected", "authentikMatches": ["plex.example.com"]}, + {"hostId": 2, "domains": ["open.example.com"], "target": "http://unknown:8080", "coverageStatus": "unprotected", "authentikMatches": []}, + ], + } + + +def _seed_signals(monkeypatch): + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: _npm_exposure(), raising=True) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 2, "domainNames": ["open.example.com"], "enabled": True}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: _auth_coverage(), raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": True, "embeddedExpected": True, "items": []}, raising=True) + monkeypatch.setattr( + "backend.app.services.media_pipeline_service.issues", + lambda session: { + "success": True, + "issues": [ + {"id": "deluge-stalled", "severity": "warning", "title": "Deluge stalled downloads", "description": "2 stalled downloads", "affectedConnectors": ["deluge"], "evidence": ["stalled=2"]}, + {"id": "widefrog-failed", "severity": "warning", "title": "Widefrog failed jobs", "description": "1 failed job", "affectedConnectors": ["widefrog"], "evidence": ["failed=1"]}, + ], + }, + raising=False, + ) + + +def test_generate_plans_from_security_findings_and_media(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + + with client: + payload = client.post("/api/ai/remediation/plans/generate").json() + + plan_ids = {plan["id"] for plan in payload["plans"]} + assert "rem-ai-sec-auth-public-host-without-authentik" in plan_ids + assert "rem-ai-sec-npm-certificate-expiring-soon" in plan_ids + assert "rem-media-deluge-stalled" in plan_ids + assert "rem-media-widefrog-failed" in plan_ids + assert all(plan["executable"] is False for plan in payload["plans"]) + assert all(step["canExecute"] is False for plan in payload["plans"] for step in plan["steps"]) + + _restore_config(previous) + + +def test_generated_media_security_plan_uses_media_steps_not_inventory(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + monkeypatch.setattr( + "backend.app.services.media_pipeline_service.summary", + lambda session=None: {"success": True, "status": "partial", "message": "Media pipeline needs review."}, + raising=False, + ) + + with client: + payload = client.post("/api/ai/remediation/plans/generate").json() + + plans = [ + item for item in payload["plans"] + if item["sourceId"] == "ai-sec-media-pipeline-issue" + ] + + assert plans + for plan in plans: + step_ids = {step["id"] for step in plan["steps"]} + assert plan["title"] == "Review media pipeline issue" + assert "run-diagnose" in step_ids + assert "review-connectors" in step_ids + assert "mark-protected" not in step_ids + assert any(check["endpoint"] == "/api/media-pipeline/summary" for check in plan["verificationChecks"]) + + _restore_config(previous) + + +def test_generate_plans_deduplicates_recommendations_covered_by_findings(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + + with client: + payload = client.get("/api/ai/remediation/plans").json() + + source_ids = [plan["sourceId"] for plan in payload["plans"]] + assert "ai-sec-auth-public-host-without-authentik" in source_ids + finding_source_ids = {source_id for source_id in source_ids if source_id.startswith("ai-sec-")} + duplicate_recommendations = [ + source_id + for source_id in source_ids + if source_id.startswith("rec-") and source_id.removeprefix("rec-") in finding_source_ids + ] + assert duplicate_recommendations == [] + + _restore_config(previous) + + +def test_generate_missing_secret_plan_has_no_secret_values(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + secret = "AUTHENTIK_TOKEN_VALUE" + + from backend.app.db.models import Connector + from backend.app.db.session import engine + + with Session(engine) as session: + connector = session.get(Connector, "authentik") + connector.secret_ref = "AUTHENTIK_TOKEN" + session.add(connector) + session.commit() + + monkeypatch.setattr( + "backend.app.connectors.authentik.AuthentikConnector.security_warnings", + lambda self: {"success": True, "items": [{"type": "token_leak", "severity": "critical", "title": f"leaked {secret}", "evidence": [secret]}]}, + raising=True, + ) + + with client: + finding_payload = client.get("/api/ai/security/findings?scope=secrets").json() + finding_id = next(item["id"] for item in finding_payload if item["category"] == "connector_config") + plan = client.post(f"/api/ai/remediation/plans/from-finding/{finding_id}").json() + + assert "secret" in plan["title"].lower() + assert secret not in str(plan) + assert "[REDACTED]" not in plan["affectedResources"] + assert any(step["type"] == "configure_secret" for step in plan["steps"]) + configure_step = next(step for step in plan["steps"] if step["id"] == "configure-secret") + test_step = next(step for step in plan["steps"] if step["id"] == "test-connector") + secret_owner = plan["affectedResources"][0].removesuffix("_API_KEY").removesuffix("_TOKEN").lower() + assert configure_step["href"] == f"/setup?step=connectors&connector={secret_owner}" + assert test_step["href"] == f"/setup?step=connectors&connector={secret_owner}" + assert plan["executable"] is False + + _restore_config(previous) + + +def test_generate_plans_builds_security_review_once(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.services import ai_remediation_planner + + calls = {"count": 0} + original_generate_review = ai_remediation_planner.ai_security_reviewer.generate_review + + def counted_generate_review(session, scope="all"): + calls["count"] += 1 + return original_generate_review(session, scope) + + monkeypatch.setattr(ai_remediation_planner.ai_security_reviewer, "generate_review", counted_generate_review) + monkeypatch.setattr("backend.app.services.media_pipeline_service.issues", lambda session: {"success": True, "issues": []}, raising=False) + + with client: + payload = client.post("/api/ai/remediation/plans/generate").json() + + assert payload["status"] == "ok" + assert calls["count"] == 1 + + _restore_config(previous) + + +def test_dry_run_evaluates_policy_without_execution(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + + with client: + plan = client.post("/api/ai/remediation/plans/from-finding/ai-sec-auth-public-host-without-authentik").json() + dry_run = client.post(f"/api/ai/remediation/plans/{plan['id']}/dry-run").json() + + assert dry_run["executed"] is False + assert dry_run["status"] in {"pass", "warning", "blocked"} + assert any(step["policyDecision"] == "blocked" for step in dry_run["stepResults"]) + assert any(item["actionId"].startswith("authentik.") for item in dry_run["blockedActions"]) + + _restore_config(previous) + + +def test_auth_coverage_plan_is_host_aware_and_non_executing(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + + with client: + plan = client.post("/api/ai/remediation/plans/from-finding/ai-sec-auth-public-host-without-authentik").json() + + assert plan["title"] == "Add identity coverage for 1 public host" + assert "open.example.com" in plan["description"] + assert plan["affectedResources"] == ["open.example.com"] + assert any("host-by-host" in step["description"] for step in plan["steps"]) + assert any(step["id"] == "manual-auth-change" and step["canExecute"] is False for step in plan["steps"]) + assert any(check["endpoint"] == "/api/apps/authentik/auth-coverage" for check in plan["verificationChecks"]) + assert any(item["actionId"] == "authentik.create_application" for item in plan["approvalRequirements"]) + assert plan["executable"] is False + + _restore_config(previous) + + +def test_media_pipeline_security_finding_uses_media_plan_not_inventory(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + + with client: + plan = client.post("/api/ai/remediation/plans/from-finding/ai-sec-media-pipeline-issue").json() + + assert plan["title"] == "Review media pipeline issue" + assert plan["affectedResources"] == ["media_pipeline"] + assert any(step["href"] == "/media-pipeline" for step in plan["steps"]) + assert not any(step["href"] == "/inventory" for step in plan["steps"]) + assert any(check["endpoint"] == "/api/media-pipeline/summary" for check in plan["verificationChecks"]) + assert plan["executable"] is False + + _restore_config(previous) + + +def test_ssl_force_finding_has_specific_npm_review_plan(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + plan = client.post("/api/ai/remediation/plans/from-finding/ai-sec-npm-host-without-ssl-force").json() + + assert plan["title"] == "Review NPM SSL enforcement" + assert "Force SSL" in plan["description"] + assert any(step["actionId"] == "npm.proxy_hosts" for step in plan["steps"]) + assert any(step["id"] == "manual-ssl-change" and step["canExecute"] is False for step in plan["steps"]) + assert any(check["endpoint"] == "/api/apps/nginx-proxy-manager/proxy-hosts" for check in plan["verificationChecks"]) + + _restore_config(previous) + + +def test_authentik_outpost_finding_has_specific_review_plan(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + plan = client.post("/api/ai/remediation/plans/from-finding/ai-sec-auth-outpost-unavailable").json() + + assert plan["title"] == "Review Authentik outpost health" + assert any(step["actionId"] == "authentik.outpost_health" for step in plan["steps"]) + assert any(step["id"] == "manual-outpost-review" and step["canExecute"] is False for step in plan["steps"]) + assert any(check["endpoint"] == "/api/apps/authentik/outposts/health" for check in plan["verificationChecks"]) + + _restore_config(previous) + + +def test_approval_proposal_is_draft_only_and_lifecycle_audited(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + _seed_signals(monkeypatch) + + with client: + plan = client.post("/api/ai/remediation/plans/from-finding/ai-sec-policy-unraid-mover-voice-allowed").json() + reviewed = client.post(f"/api/ai/remediation/plans/{plan['id']}/review").json() + proposal = client.post(f"/api/ai/remediation/plans/{plan['id']}/create-approval-proposal").json() + dismissed = client.post(f"/api/ai/remediation/plans/{plan['id']}/dismiss").json() + approvals = client.get("/api/approvals").json() + audit = client.get("/api/audit").json() + + assert reviewed["status"] == "reviewed" + assert proposal["created"] is True + assert proposal["executed"] is False + assert proposal["approvalId"] is None + assert dismissed["status"] == "dismissed" + assert approvals == [] + assert any(event["action_id"] == "ai.remediation.approval_proposal" for event in audit) + + _restore_config(previous) + + +def test_routes_return_404_for_unknown_plan(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + response = client.get("/api/ai/remediation/plans/rem-missing") + + assert response.status_code == 404 + + _restore_config(previous) diff --git a/backend/tests/test_ai_security_reviewer.py b/backend/tests/test_ai_security_reviewer.py new file mode 100644 index 0000000..d7c0356 --- /dev/null +++ b/backend/tests/test_ai_security_reviewer.py @@ -0,0 +1,1054 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, Session, create_engine + + +def _reset_db(tmp_path, monkeypatch): + db_path = tmp_path / "ai_security_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + + previous = { + "apis": dict(settings.apis), + "environment": settings.environment, + "secret_key": settings.secret_key, + "secret_key_source": settings.secret_key_source, + } + for key in settings.apis: + settings.apis[key] = None if key.endswith(("_URL", "_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD")) else settings.apis[key] + settings.environment = "development" + settings.secret_key = "test-ai-security-key" + settings.secret_key_source = "env" + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous["apis"]) + settings.environment = previous["environment"] + settings.secret_key = previous["secret_key"] + settings.secret_key_source = previous["secret_key_source"] + + +def _client(tmp_path, monkeypatch): + previous = _reset_db(tmp_path, monkeypatch) + from backend.app.main import app + + return TestClient(app), previous + + +def _npm_exposure(): + return { + "success": True, + "status": "ok", + "totalProxyHosts": 2, + "enabledProxyHosts": 2, + "publicHosts": 2, + "hostsWithoutCertificate": 1, + "hostsWithExpiredCertificate": 0, + "hostsWithExpiringCertificate": 1, + "hostsWithoutAccessList": 1, + "hostsWithoutSslForce": 1, + "hostsWithWebsocket": 1, + "warnings": [ + {"type": "host_without_certificate", "severity": "high", "title": "open.example.com has no certificate", "evidence": ["open.example.com"], "suggestedAction": "review_certificate"}, + {"type": "certificate_expiring_soon", "severity": "warning", "title": "plex.example.com certificate expires soon", "evidence": ["12"], "suggestedAction": "review_certificate_in_npm"}, + ], +} + + +def _npm_expiring_certificate_exposure(): + return { + **_npm_exposure(), + "warnings": [ + { + "type": "certificate_expiring_soon", + "severity": "warning", + "title": "auth.example.com certificate expires soon", + "evidence": ["auth.example.com", "30 days", "2026-08-07 07:13:42"], + "suggestedAction": "review_certificate_in_npm", + } + ], + } + + +def _auth_coverage(): + return { + "success": True, + "status": "ok", + "summary": {"totalPublicHosts": 2, "protectedHosts": 1, "likelyProtectedHosts": 0, "unprotectedHosts": 1, "npmStatus": "ok"}, + "publicHosts": [ + {"hostId": 1, "domains": ["plex.example.com"], "target": "http://plex:32400", "coverageStatus": "protected", "authentikMatches": ["plex.example.com"]}, + {"hostId": 2, "domains": ["open.example.com"], "target": "http://unknown:8080", "coverageStatus": "unprotected", "authentikMatches": []}, + ], + } + + +def _media_snapshot_with_stalled_download(): + return { + "sonarrHealth": {"success": True, "status": "connected"}, + "sonarrWanted": {"success": True, "status": "connected", "total": 0, "items": []}, + "sonarrQueue": {"success": True, "status": "connected", "total": 0, "items": []}, + "prowlarrHealth": {"success": True, "status": "connected"}, + "prowlarrIndexers": {"success": True, "status": "ok", "total": 1, "enabled": 1, "failing": 0, "items": []}, + "prowlarrApplications": {"success": True, "status": "ok", "total": 1, "items": []}, + "prowlarrSync": {"success": True, "status": "ok", "indexersFailing": 0}, + "delugeHealth": {"success": True, "status": "connected"}, + "delugeSession": {"success": True, "status": "ok", "active": 0, "paused": 0, "stalled": 2}, + "delugeActive": {"success": True, "status": "ok", "total": 0, "items": []}, + "delugeStalled": {"success": True, "status": "ok", "total": 2, "items": [{"id": "stalled-1", "name": "Example"}]}, + "delugePaused": {"success": True, "status": "ok", "total": 0, "items": []}, + "delugeSpeeds": {"success": True, "status": "ok", "active": 0, "paused": 0, "stalled": 2}, + "plexHealth": {"success": True, "status": "connected"}, + "plexLibraries": {"success": True, "status": "ok", "total": 1, "libraries": []}, + "plexSessions": {"success": True, "status": "ok", "total": 0, "sessions": []}, + "plexRecent": {"success": True, "status": "ok", "total": 0, "items": []}, + "plexStreams": {"success": True, "status": "ok", "total": 0, "transcoding": 0}, + "tautulliHealth": {"success": True, "status": "connected"}, + "tautulliActivity": {"success": True, "status": "ok", "streamCount": 0, "sessions": []}, + "tautulliHistory": {"success": True, "status": "ok", "total": 0, "items": []}, + "tautulliStats": {"success": True, "status": "ok", "totalPlays": 0}, + "tautulliStreams": {"success": True, "status": "ok", "activeStreams": 0, "transcoding": 0}, + "tautulliLibraries": {"success": True, "status": "ok", "total": 1, "items": []}, + "widefrogHealth": {"success": True, "status": "connected"}, + "widefrogQueue": {"success": True, "status": "ok", "total": 0, "items": []}, + "widefrogFailed": {"success": True, "status": "ok", "failed": 0, "items": []}, + "widefrogActivity": {"success": True, "status": "ok", "recent": []}, + "widefrogDownloads": {"success": True, "status": "ok", "active": 0, "items": []}, + } + + +def test_ai_security_summary_generated_with_no_data_is_partial(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + payload = client.get("/api/ai/security/summary").json() + + assert payload["status"] in {"partial", "warning"} + assert 0 <= payload["score"] <= 100 + assert payload["dataCompleteness"]["percentage"] < 100 + assert "secret_storage" in payload["dataCompleteness"]["connectedSources"] + assert payload["criticalFindings"] == 0 + assert payload["scoreBreakdown"]["finalScore"] == payload["score"] + assert set(payload["scoreBreakdown"]["missingSources"]) >= {"npm", "authentik", "unraid"} + assert payload["scoreBreakdown"]["coverageStatus"] in {"partial", "warning"} + assert payload["scoreBreakdown"]["coverageSources"]["secret_storage"]["status"] == "connected" + assert payload["scoreBreakdown"]["coverageSources"]["npm"]["status"] == "missing_config" + assert payload["scoreBreakdown"]["coverageSources"]["authentik"]["status"] == "missing_config" + assert payload["scoreBreakdown"]["coverageSources"]["unraid"]["status"] == "missing_config" + + _restore_config(previous) + + +def test_ai_security_missing_npm_and_auth_does_not_infer_unprotected_public_hosts(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + findings = client.get("/api/ai/security/findings").json() + summary = client.get("/api/ai/security/summary").json() + + finding_ids = {finding["id"] for finding in findings} + assert "ai-sec-authentik-missing-public-hosts" not in finding_ids + assert "ai-sec-auth-public-host-without-authentik" not in finding_ids + assert summary["status"] in {"partial", "warning"} + assert summary["criticalFindings"] == 0 + + _restore_config(previous) + + +def test_ai_security_npm_and_auth_coverage_findings(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: _npm_exposure(), raising=True) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 2, "domainNames": ["open.example.com"], "enabled": True}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: _auth_coverage(), raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "items": []}, raising=True) + + with client: + findings = client.get("/api/ai/security/findings").json() + + types = {finding["id"] for finding in findings} + assert "ai-sec-npm-host-without-certificate" in types + assert "ai-sec-npm-certificate-expiring-soon" in types + assert "ai-sec-auth-public-host-without-authentik" in types + assert all(action["executable"] is False for finding in findings for action in finding["suggestedActions"]) + + _restore_config(previous) + + +def test_ai_security_npm_certificate_context_stays_evidence_not_affected(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: _npm_expiring_certificate_exposure(), raising=True) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 2, "domainNames": ["auth.example.com"], "enabled": True}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {**_auth_coverage(), "publicHosts": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": True, "embeddedExpected": True, "items": []}, raising=True) + + with client: + findings = client.get("/api/ai/security/findings").json() + + finding = next(item for item in findings if item["id"] == "ai-sec-npm-certificate-expiring-soon") + assert finding["affectedResources"] == ["auth.example.com"] + assert finding["evidence"][0]["value"] == ["auth.example.com", "30 days", "2026-08-07 07:13:42"] + + _restore_config(previous) + + +def test_ai_security_snapshot_runs_external_reads_in_parallel(monkeypatch, tmp_path): + from threading import Barrier + + client, previous = _client(tmp_path, monkeypatch) + barrier = Barrier(3, timeout=1) + + def wait_payload(payload): + def _inner(self): + barrier.wait() + return payload + + return _inner + + monkeypatch.setattr("backend.app.services.ai_security_reviewer._REVIEW_CACHE", {}, raising=False) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", wait_payload(_npm_exposure()), raising=True) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", wait_payload(_auth_coverage()), raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "available": True, "embeddedExpected": True, "items": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.unraid.UnraidConnector.test_connection", wait_payload({"success": True, "status": "connected"}), raising=True) + + with client: + payload = client.get("/api/ai/security/summary").json() + + assert payload["scoreBreakdown"]["coverageSources"]["npm"]["status"] == "connected" + assert payload["scoreBreakdown"]["coverageSources"]["authentik"]["status"] == "connected" + assert payload["scoreBreakdown"]["coverageSources"]["unraid"]["status"] == "connected" + + _restore_config(previous) + + +def test_ai_security_snapshot_reuses_npm_and_auth_instances(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.core.config import settings + from backend.app.services import ai_security_reviewer + + settings.apis["NPM_URL"] = "https://npm.example.test" + settings.apis["NPM_IDENTITY"] = "admin@example.test" + settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE" + settings.apis["AUTHENTIK_URL"] = "https://auth.example.test" + settings.apis["AUTHENTIK_TOKEN"] = "AUTHENTIK_TOKEN_VALUE" + settings.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"] = True + + npm_calls = [] + auth_calls = [] + + def npm_request(self, method, path, **kwargs): + npm_calls.append(path) + return { + "/nginx/proxy-hosts": [ + { + "id": 1, + "domain_names": ["media.example.com"], + "forward_host": "plex", + "forward_port": 32400, + "forward_scheme": "http", + "enabled": True, + "ssl_forced": True, + "hsts_enabled": True, + "certificate_id": 10, + "access_list_id": 2, + }, + ], + "/nginx/certificates": [{"id": 10, "domain_names": ["media.example.com"], "expires_on": "2099-01-01T00:00:00Z"}], + "/nginx/access-lists": [{"id": 2, "name": "Authentik"}], + }[path] + + def auth_paged(self, path, params=None): + auth_calls.append(path) + return { + "/core/applications/": [{"pk": "app-media", "name": "Media", "slug": "media", "launch_url": "https://media.example.com", "provider": 101}], + "/core/providers/": [{"pk": 101, "name": "Media proxy", "type": "authentik_providers_proxy.proxyprovider", "assigned_application_slug": "media"}], + "/providers/proxy/": [{"pk": 101, "name": "Media proxy", "external_host": "https://media.example.com", "assigned_application_slug": "media"}], + "/providers/oauth2/": [], + "/outposts/instances/": [{"pk": "outpost-1", "name": "Embedded Outpost", "health": "healthy", "providers": [101]}], + "/events/events/": [{"pk": "evt-1", "action": "login", "created": "2026-01-01T10:00:00Z"}], + }[path] + + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr(NginxProxyManagerConnector, "_request", npm_request, raising=True) + monkeypatch.setattr(AuthentikConnector, "_paged", auth_paged, raising=True) + ai_security_reviewer._clear_review_cache() + + with client: + payload = client.get("/api/ai/security/summary").json() + + assert payload["scoreBreakdown"]["coverageSources"]["npm"]["status"] == "connected" + assert payload["scoreBreakdown"]["coverageSources"]["authentik"]["status"] == "connected" + assert npm_calls.count("/nginx/proxy-hosts") <= 2 + assert npm_calls.count("/nginx/certificates") == 1 + assert npm_calls.count("/nginx/access-lists") == 1 + assert auth_calls.count("/core/applications/") == 1 + assert auth_calls.count("/core/providers/") == 1 + assert auth_calls.count("/providers/proxy/") == 1 + assert auth_calls.count("/outposts/instances/") == 1 + assert auth_calls.count("/events/events/") == 1 + + _restore_config(previous) + + +def test_ai_security_reuses_persistent_review_after_memory_cache_loss(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + with client: + first = client.get("/api/ai/security/summary").json() + + from backend.app.services import ai_security_reviewer + + ai_security_reviewer._clear_review_cache() + + def fail_snapshot(session): + raise AssertionError("persistent AI Security cache should avoid a cold snapshot") + + monkeypatch.setattr(ai_security_reviewer, "_snapshot", fail_snapshot) + + with client: + second = client.get("/api/ai/security/summary").json() + + assert second["generatedAt"] == first["generatedAt"] + assert second["score"] == first["score"] + + _restore_config(previous) + + +def test_ai_security_persistent_review_ignores_other_dashboard_cache_rows(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + with client: + first = client.get("/api/ai/security/summary").json() + safety = client.get("/api/safety/dashboard").json() + + assert safety["cache"]["source"] in {"fresh", "cache"} + + from backend.app.services import ai_security_reviewer + + ai_security_reviewer._clear_review_cache() + + def fail_snapshot(session): + raise AssertionError("dashboard cache rows should not invalidate AI Security persistent cache") + + monkeypatch.setattr(ai_security_reviewer, "_snapshot", fail_snapshot) + + with client: + second = client.get("/api/ai/security/summary").json() + + assert second["generatedAt"] == first["generatedAt"] + assert second["score"] == first["score"] + + _restore_config(previous) + + +def test_ai_security_uses_persistent_media_summary_cache(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.session import engine + from backend.app.services import ai_security_reviewer, media_pipeline_service + + media_pipeline_service._SNAPSHOT_CACHE.clear() + monkeypatch.setattr(media_pipeline_service, "_build_snapshot", lambda: _media_snapshot_with_stalled_download()) + with Session(engine) as session: + cached = media_pipeline_service.summary(session, force_refresh=True) + + assert cached["status"] == "warning" + assert cached["counts"]["stalledDownloads"] == 2 + + media_pipeline_service._SNAPSHOT_CACHE.clear() + ai_security_reviewer._clear_review_cache() + + def fail_media_snapshot(): + raise AssertionError("AI Security should reuse the persistent Media Pipeline summary cache") + + monkeypatch.setattr(media_pipeline_service, "_build_snapshot", fail_media_snapshot) + + with client: + findings = client.get("/api/ai/security/findings?scope=media").json() + + media_finding = next(item for item in findings if item["id"] == "ai-sec-media-pipeline-issue") + assert media_finding["affectedResources"] == ["deluge"] + + _restore_config(previous) + + +def test_ai_security_access_list_warning_honors_authentik_coverage(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", + lambda self: { + "success": True, + "status": "ok", + "warnings": [ + { + "type": "public_host_without_access_list", + "severity": "high", + "title": "protected.example.com has no access list", + "evidence": ["protected.example.com", "http://protected-app:8080"], + "suggestedAction": "review_access_protection", + }, + { + "type": "public_host_without_access_list", + "severity": "high", + "title": "open.example.com has no access list", + "evidence": ["open.example.com", "http://open-app:8081"], + "suggestedAction": "review_access_protection", + }, + ], + }, + raising=True, + ) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.authentik.AuthentikConnector.auth_coverage", + lambda self: { + "success": True, + "status": "ok", + "publicHosts": [ + {"hostId": 1, "domains": ["protected.example.com"], "coverageStatus": "protected"}, + {"hostId": 2, "domains": ["open.example.com"], "coverageStatus": "unprotected"}, + ], + }, + raising=True, + ) + + with client: + findings = client.get("/api/ai/security/findings?scope=exposure").json() + + assert not any(finding["id"] == "ai-sec-npm-host-without-access-list" for finding in findings) + + _restore_config(previous) + + +def test_ai_security_authentik_missing_config_with_public_hosts(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "items": [{"id": 1, "domainNames": ["public.example.com"], "enabled": True}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: {**_npm_exposure(), "warnings": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {"success": False, "status": "missing_config", "publicHosts": []}, raising=True) + + with client: + findings = client.get("/api/ai/security/findings?scope=auth").json() + + assert any(finding["id"] == "ai-sec-authentik-missing-public-hosts" for finding in findings) + + _restore_config(previous) + + +def test_ai_security_connected_npm_public_hosts_with_missing_auth_is_evidence_based(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.proxy_hosts", lambda self: {"success": True, "status": "ok", "items": [{"id": 1, "domainNames": ["public.example.com"], "enabled": True}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", lambda self: {**_npm_exposure(), "warnings": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {"success": False, "status": "missing_config", "publicHosts": []}, raising=True) + + with client: + findings = client.get("/api/ai/security/findings?scope=auth").json() + + finding = next(item for item in findings if item["id"] == "ai-sec-authentik-missing-public-hosts") + assert finding["severity"] in {"medium", "high"} + assert any(evidence["source"] == "npm" and evidence["label"] == "publicHosts" and evidence["value"] == 1 for evidence in finding["evidence"]) + + _restore_config(previous) + + +def test_ai_security_reported_embedded_outpost_with_unknown_health_is_not_unavailable(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.auth_coverage", lambda self: {"success": True, "status": "ok", "publicHosts": []}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.security_warnings", lambda self: {"success": True, "status": "warning", "items": [{"type": "embedded_outpost_unavailable", "severity": "warning", "title": "Expected embedded outpost is unavailable or unknown", "evidence": ["warning"]}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.authentik.AuthentikConnector.outpost_health", lambda self: {"success": True, "status": "warning", "available": False, "embeddedExpected": True, "items": [{"id": "embedded", "health": "unknown"}]}, raising=True) + + with client: + findings = client.get("/api/ai/security/findings?scope=auth").json() + + assert not any(finding["id"] == "ai-sec-auth-outpost-unavailable" for finding in findings) + + _restore_config(previous) + + +def test_ai_security_critical_status_requires_critical_or_high_risk_evidence(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import Policy + from backend.app.db.session import engine + + with Session(engine) as session: + policy = session.get(Policy, "policy-volume-delete") + policy.manual = "allowed" + session.add(policy) + session.commit() + + with client: + payload = client.get("/api/ai/security/summary").json() + + assert payload["status"] == "critical" + assert payload["criticalFindings"] >= 1 + assert any(item.get("findingId") == "ai-sec-policy-volume-delete-not-blocked" for item in payload["scoreBreakdown"]["penalties"]) + + _restore_config(previous) + + +def test_ai_security_ignores_stale_changed_discovery_when_latest_is_managed(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import App, AppContainerMapping, DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + app = session.get(App, "nginx_proxy_manager") + if not app: + app = App(id="nginx_proxy_manager", name="Nginx Proxy Manager", category="Network") + app.criticality = "critical" + app.protected = True + app.needs_review = False + app.discovery_status = "managed" + session.add(app) + old_run = DiscoveryRun( + id="disc-old-npm", + status="complete", + started_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=1), + finished_at=datetime.now(UTC).replace(tzinfo=None) - timedelta(hours=1), + ) + new_run = DiscoveryRun( + id="disc-new-npm", + status="complete", + started_at=datetime.now(UTC).replace(tzinfo=None), + finished_at=datetime.now(UTC).replace(tzinfo=None), + ) + session.add(old_run) + session.add(new_run) + session.add( + DiscoveredContainer( + id="dc-old-npm", + discovery_run_id=old_run.id, + container_id="npm-container", + name="NginxProxyManager", + image="jlesage/nginx-proxy-manager", + state="running", + status="healthy", + suggested_app_type="nginx_proxy_manager", + suggested_category="network", + suggested_criticality="critical", + suggested_connector="nginx_proxy_manager", + suggested_policy_profile="reverse_proxy", + import_status="changed", + matched_app_id="nginx_proxy_manager", + ) + ) + session.add( + DiscoveredContainer( + id="dc-new-npm", + discovery_run_id=new_run.id, + container_id="npm-container", + name="NginxProxyManager", + image="jlesage/nginx-proxy-manager", + state="running", + status="healthy", + suggested_app_type="nginx_proxy_manager", + suggested_category="network", + suggested_criticality="critical", + suggested_connector="nginx_proxy_manager", + suggested_policy_profile="reverse_proxy", + import_status="managed", + matched_app_id="nginx_proxy_manager", + ) + ) + session.add( + AppContainerMapping( + id="map-npm-container", + app_id="nginx_proxy_manager", + container_id="npm-container", + container_name="NginxProxyManager", + source="discovery", + is_primary=True, + last_seen_run_id=new_run.id, + ) + ) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings").json() + + assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-old-npm" for finding in findings) + assert not any( + finding["category"] == "inventory" and "NginxProxyManager" in finding["title"] + for finding in findings + ) + + _restore_config(previous) + + +def test_ai_security_ignores_appops_deployment_discovery_artifacts(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + run = DiscoveryRun( + id="disc-appops-deploy-artifacts", + status="complete", + started_at=datetime.now(UTC).replace(tzinfo=None), + finished_at=datetime.now(UTC).replace(tzinfo=None), + ) + session.add(run) + for suffix, name in ( + ("current", "unraid-appops-gateway-1216"), + ("rollback", "unraid-appops-gateway-1216-pre-20260712T212606Z"), + ): + session.add(DiscoveredContainer( + id=f"dc-appops-{suffix}", + discovery_run_id=run.id, + container_id=f"appops-{suffix}", + name=name, + image="unraid-appops-gateway:test", + state="exited" if suffix == "rollback" else "running", + status="healthy", + suggested_app_type="unraid_appops_gateway", + suggested_category="infrastructure", + suggested_criticality="critical", + suggested_connector="none", + suggested_policy_profile="protected_service", + import_status="discovered", + matched_app_id="unraid_appops_gateway_1216", + )) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings").json() + + assert not any( + finding["category"] == "inventory" and "unraid-appops-gateway" in finding["title"].lower() + for finding in findings + ) + + _restore_config(previous) + + +def test_ai_security_ignores_changed_discovery_for_protected_mapped_app(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + app = session.get(App, "nginx_proxy_manager") + if not app: + app = App(id="nginx_proxy_manager", name="Nginx Proxy Manager", category="Network") + app.criticality = "critical" + app.protected = True + app.needs_review = False + app.discovery_status = "managed" + run = DiscoveryRun(id="disc-current-npm", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None)) + session.add(app) + session.add(run) + session.add( + DiscoveredContainer( + id="dc-current-npm", + discovery_run_id=run.id, + container_id="npm-container", + name="NginxProxyManager", + image="jlesage/nginx-proxy-manager", + state="running", + status="healthy", + suggested_app_type="nginx_proxy_manager", + suggested_category="network", + suggested_criticality="critical", + suggested_connector="nginx_proxy_manager", + suggested_policy_profile="reverse_proxy", + import_status="changed", + matched_app_id="nginx_proxy_manager", + ) + ) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings").json() + + assert not any( + finding["category"] == "inventory" and "NginxProxyManager" in finding["title"] + for finding in findings + ) + + _restore_config(previous) + + +def test_ai_security_critical_discovery_links_to_matched_app_detail(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + app = session.get(App, "homeassistant") + if not app: + app = App(id="homeassistant", name="Home Assistant", category="Automation") + app.criticality = "critical" + app.protected = False + app.needs_review = True + session.add(app) + run = DiscoveryRun(id="disc-ha-review", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None)) + session.add(run) + session.add( + DiscoveredContainer( + id="dc-ha-review", + discovery_run_id=run.id, + container_id="ha-container", + name="homeassistant", + image="ghcr.io/home-assistant/home-assistant", + state="running", + status="healthy", + suggested_app_type="home_assistant", + suggested_category="automation", + suggested_criticality="critical", + suggested_connector="home_assistant", + suggested_policy_profile="home_automation", + import_status="changed", + matched_app_id="homeassistant", + ) + ) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings").json() + + finding = next(item for item in findings if item["id"] == "ai-sec-inventory-critical-review-dc-ha-review") + hrefs = {action["href"] for action in finding["suggestedActions"]} + assert "/apps/homeassistant" in hrefs + + _restore_config(previous) + + +def test_ai_security_ignores_discovered_row_when_reviewed_canonical_app_exists(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + app = session.get(App, "home_assistant") + if not app: + app = App(id="home_assistant", name="Home Assistant", category="Infrastructure") + app.criticality = "medium" + app.protected = False + app.needs_review = False + app.discovery_status = "managed" + app.connector_status = "connected" + session.add(app) + run = DiscoveryRun(id="disc-ha-stale", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None)) + session.add(run) + session.add( + DiscoveredContainer( + id="dc-ha-stale", + discovery_run_id=run.id, + container_id="ha-container", + name="homeassistant", + image="ghcr.io/home-assistant/home-assistant", + state="running", + status="healthy", + suggested_app_type="home_assistant", + suggested_category="automation", + suggested_criticality="critical", + suggested_connector="home_assistant", + suggested_policy_profile="home_automation", + import_status="discovered", + ) + ) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings").json() + + assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-stale" for finding in findings) + assert not any( + finding["category"] == "inventory" and "homeassistant" in finding["title"].lower() + for finding in findings + ) + + _restore_config(previous) + + +def test_ai_security_review_refresh_replaces_cached_findings(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import App, DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + app = session.get(App, "home_assistant") + if not app: + app = App(id="home_assistant", name="Home Assistant", category="Infrastructure") + app.criticality = "medium" + app.needs_review = True + app.discovery_status = "managed" + session.add(app) + run = DiscoveryRun(id="disc-ha-cache", status="complete", started_at=datetime.now(UTC).replace(tzinfo=None), finished_at=datetime.now(UTC).replace(tzinfo=None)) + session.add(run) + session.add( + DiscoveredContainer( + id="dc-ha-cache", + discovery_run_id=run.id, + container_id="ha-cache-container", + name="homeassistant", + image="ghcr.io/home-assistant/home-assistant", + state="running", + status="healthy", + suggested_app_type="home_assistant", + suggested_category="automation", + suggested_criticality="critical", + suggested_connector="home_assistant", + suggested_policy_profile="home_automation", + import_status="discovered", + ) + ) + session.commit() + + with client: + before = client.get("/api/ai/security/findings").json() + + assert any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-cache" for finding in before) + + with Session(engine) as session: + app = session.get(App, "home_assistant") + app.needs_review = False + session.add(app) + session.commit() + + with client: + refreshed = client.post("/api/ai/security/review").json() + after = client.get("/api/ai/security/findings").json() + + assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-cache" for finding in refreshed["findings"]) + assert not any(finding["id"] == "ai-sec-inventory-critical-review-dc-ha-cache" for finding in after) + + _restore_config(previous) + + +def test_ai_security_secret_policy_audit_and_media_findings(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import AuditEvent, Policy + from backend.app.db.session import engine + + with Session(engine) as session: + policy = session.get(Policy, "policy-unraid-mover") + policy.voice = "allowed" + session.add(policy) + session.add(AuditEvent(id="audit-block-1", source="voice", identity="ha", action_id="container.restart", target="authentik", risk="high", policy_decision="blocked", result="failed")) + session.add(AuditEvent(id="audit-block-2", source="voice", identity="ha", action_id="container.restart", target="authentik", risk="high", policy_decision="blocked", result="failed")) + session.add(AuditEvent(id="audit-block-3", source="voice", identity="ha", action_id="container.restart", target="authentik", risk="high", policy_decision="blocked", result="failed")) + session.commit() + + monkeypatch.setattr("backend.app.services.media_pipeline_service.summary", lambda session: {"success": True, "status": "warning", "issues": [{"id": "deluge-stalled", "severity": "warning", "title": "Deluge stalled downloads", "description": "2 stalled downloads", "affected": ["deluge"]}], "connectors": {"deluge": {"status": "connected"}}}, raising=False) + + with client: + findings = client.get("/api/ai/security/findings").json() + recommendations = client.get("/api/ai/security/recommendations").json() + + assert any(finding["id"] == "ai-sec-policy-unraid-mover-voice-allowed" for finding in findings) + assert any(finding["id"] == "ai-sec-audit-repeated-blocked-attempts" for finding in findings) + assert any(finding["category"] == "connector_config" and "missing" in finding["title"].lower() for finding in findings) + media_finding = next(finding for finding in findings if finding["id"] == "ai-sec-media-pipeline-issue") + assert "Deluge stalled downloads" in media_finding["title"] + assert media_finding["affectedResources"] == ["deluge"] + assert any(recommendation["id"] == "rec-ai-sec-media-pipeline-issue" for recommendation in recommendations) + + _restore_config(previous) + + +def test_ai_security_media_finding_uses_summary_counts_when_issues_missing(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + monkeypatch.setattr( + "backend.app.services.media_pipeline_service.summary", + lambda session: { + "success": True, + "status": "warning", + "message": "Media pipeline has items requiring attention.", + "counts": {"stalledDownloads": 4, "failedWidefrogJobs": 0, "failingIndexers": 0, "transcodes": 0}, + "risk": {"level": "medium", "reasons": ["Deluge has stalled downloads"]}, + "connectors": {"deluge": {"status": "connected"}}, + }, + raising=False, + ) + + with client: + findings = client.get("/api/ai/security/findings").json() + + media_finding = next(finding for finding in findings if finding["id"] == "ai-sec-media-pipeline-issue") + assert media_finding["affectedResources"] == ["deluge"] + assert media_finding["evidence"][1]["value"] == ["Deluge has stalled downloads"] + + _restore_config(previous) + + +def test_ai_security_missing_secret_actions_deep_link_to_connector_setup(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + findings = client.get("/api/ai/security/findings?scope=secrets").json() + + finding = next(item for item in findings if item["id"] == "ai-sec-missing-secret-portainer_api_key") + hrefs = {action["href"] for action in finding["suggestedActions"]} + assert "/setup?step=connectors&connector=portainer" in hrefs + assert "/connectors" not in hrefs + + _restore_config(previous) + + +def test_ai_security_ignores_stale_setup_failures_after_later_success(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.db.models import AuditEvent + from backend.app.db.session import engine + + with Session(engine) as session: + base = datetime.now(UTC).replace(tzinfo=None) + for index in range(3): + session.add( + AuditEvent( + id=f"audit-old-portainer-fail-{index}", + source="manual", + identity="setup", + action_id="setup.test.portainer", + target="portainer", + risk="low", + policy_decision="allowed", + result="failed", + timestamp=base - timedelta(minutes=10 - index), + ) + ) + session.add( + AuditEvent( + id="audit-new-portainer-success", + source="manual", + identity="setup", + action_id="setup.test.portainer", + target="portainer", + risk="low", + policy_decision="allowed", + result="success", + timestamp=base, + ) + ) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings?scope=audit").json() + + assert not any(finding["id"] == "ai-sec-audit-repeated-blocked-attempts" for finding in findings) + + _restore_config(previous) + + +def test_ai_security_lifecycle_routes_and_accept_does_not_execute(tmp_path, monkeypatch): + client, previous = _client(tmp_path, monkeypatch) + + with client: + review = client.post("/api/ai/security/review").json() + finding_id = review["findings"][0]["id"] + recommendation_id = review["recommendations"][0]["id"] + acknowledged = client.post(f"/api/ai/security/findings/{finding_id}/acknowledge").json() + dismissed = client.post(f"/api/ai/security/findings/{finding_id}/dismiss").json() + accepted = client.post(f"/api/ai/security/recommendations/{recommendation_id}/accept").json() + + assert acknowledged["status"] == "acknowledged" + assert dismissed["status"] == "dismissed" + assert accepted["status"] == "accepted" + assert accepted["applied"] is False + assert accepted["recommendation"]["canAutoApply"] is False + assert all(action["executable"] is False for action in accepted["recommendation"]["suggestedActions"]) + + _restore_config(previous) + + +def test_ai_security_redacts_secret_values(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + secret = "AUTHENTIK_TOKEN_VALUE" + + monkeypatch.setattr( + "backend.app.connectors.authentik.AuthentikConnector.security_warnings", + lambda self: {"success": True, "items": [{"type": "token_leak", "severity": "critical", "title": f"leaked {secret}", "evidence": [secret], "suggestedAction": "rotate"}]}, + raising=True, + ) + + with client: + payload = client.get("/api/ai/security/findings").json() + + assert secret not in str(payload) + assert "[REDACTED]" in str(payload) + + _restore_config(previous) + + +def test_ai_security_does_not_require_optional_widefrog_api_key_when_connected(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + + from backend.app.core.config import settings + from backend.app.db.models import Connector + from backend.app.db.session import engine + + settings.apis["WIDEFROG_URL"] = "http://widefrog.local" + settings.apis["WIDEFROG_API_KEY"] = None + with Session(engine) as session: + connector = session.get(Connector, "widefrog") + connector.status = "connected" + connector.secret_ref = "WIDEFROG_API_KEY" + session.add(connector) + session.commit() + + with client: + findings = client.get("/api/ai/security/findings?scope=secrets").json() + + assert all( + finding["id"] != "ai-sec-missing-secret-widefrog_api_key" + for finding in findings + ) + assert "WIDEFROG_API_KEY" not in str([finding["title"] for finding in findings]) + + _restore_config(previous) + + +def test_ai_security_score_breakdown_is_secret_safe(monkeypatch, tmp_path): + client, previous = _client(tmp_path, monkeypatch) + secret = "SUPER_SECRET_API_KEY_VALUE" + + monkeypatch.setattr( + "backend.app.connectors.nginx_proxy_manager.NginxProxyManagerConnector.exposure_summary", + lambda self: {"success": False, "status": "missing_config", "message": f"missing {secret}"}, + raising=True, + ) + + with client: + payload = client.get("/api/ai/security/summary").json() + + assert "scoreBreakdown" in payload + assert secret not in str(payload["scoreBreakdown"]) + assert "API_KEY_VALUE" not in str(payload["scoreBreakdown"]) + + _restore_config(previous) diff --git a/backend/tests/test_appops_self_upgrade_controller.py b/backend/tests/test_appops_self_upgrade_controller.py new file mode 100644 index 0000000..df0e645 --- /dev/null +++ b/backend/tests/test_appops_self_upgrade_controller.py @@ -0,0 +1,377 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import subprocess +import sys +import tarfile +import threading +import time +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CONTROLLER = ROOT / "ops" / "gitea-deploy-broker" + + +@pytest.fixture +def controller(monkeypatch, tmp_path): + monkeypatch.syspath_prepend(str(CONTROLLER)) + spec = importlib.util.spec_from_file_location("self_upgrade_controller_test", CONTROLLER / "status_server.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + monkeypatch.setattr(module, "WEBHOOK_INBOX_ROOT", tmp_path / "inbox") + module.WEBHOOK_INBOX_ROOT.mkdir(mode=0o700) + return module + + +@pytest.fixture +def deploy_module(monkeypatch): + if sys.platform == "win32": + pytest.skip("controller deploy.py uses Linux fcntl") + monkeypatch.syspath_prepend(str(CONTROLLER)) + spec = importlib.util.spec_from_file_location("self_upgrade_deploy_test", CONTROLLER / "deploy.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _project(): + return { + "branch": "main", "gitea_repository_id": 9, + "gitea_webhook_secret_file": "jens__unraid-appops-gateway", + "containers": ["Unraid-AppOps-Gateway"], + "compose_project": "unraid-appops-gateway", + "compose_files": ["docker-compose.yml"], + "environment": {"APPOPS_DATA_PATH": "/srv/example/appdata/unraid-appops-gateway-1216"}, + "verify_oci_revision": True, + "validation": { + "validation_gate_version": 1, "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 1800, "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, + }, + } + + +def _ready(controller, monkeypatch): + monkeypatch.setattr(controller, "_load_projects", lambda: {controller.SELF_UPGRADE_REPOSITORY: _project()}) + monkeypatch.setattr(controller, "_self_upgrade_protected_head", lambda revision: True) + monkeypatch.setattr(controller, "_is_current_branch_head", lambda repository, project, revision: True) + monkeypatch.setattr(controller, "_fetch_gitea_validation", lambda repository, revision, policy: ("pass", [])) + monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: { + "revision": "a" * 40, "imageId": "sha256:" + "b" * 64, + "dataPath": "/srv/example/appdata/unraid-appops-gateway-1216", + }) + monkeypatch.setattr(controller.shutil, "disk_usage", lambda path: SimpleNamespace(free=50 * 1024**3)) + + +def test_fixed_self_upgrade_preflight_accepts_exact_validated_head(controller, monkeypatch): + _ready(controller, monkeypatch) + result = controller._self_upgrade_preflight("c" * 40) + assert result["status"] == "preflight_ok" + assert result["currentRevision"] == "a" * 40 + assert len(result["preflightSha256"]) == 64 + assert result["executed"] is False + assert all(check["passed"] for check in result["checks"]) + + +def test_timed_out_delivery_recovers_only_after_exact_protected_push_pass(controller, monkeypatch): + _ready(controller, monkeypatch) + revision = "c" * 40 + digest = controller._webhook_semantic_digest(9, "refs/heads/main", revision) + policy = controller._validation_policy(controller.SELF_UPGRADE_REPOSITORY, _project()) + original = { + "schema": controller._WEBHOOK_SCHEMA, "semantic_sha256": digest, + "body_sha256": "b" * 64, "gitea_repository_id": 9, + "ref": "refs/heads/main", "repository": controller.SELF_UPGRADE_REPOSITORY, + "revision": revision, "status": "validation_timeout", + "validation": controller._validation_snapshot(policy, int(time.time())), + } + marker = controller.WEBHOOK_INBOX_ROOT / f"{digest}.json" + marker.write_text(json.dumps(original), encoding="utf-8") + monkeypatch.setattr(controller, "_fetch_gitea_validation", lambda *args: ("wait", [])) + controller._auto_revalidate_timed_out_appops(marker) + revalidation = controller.WEBHOOK_INBOX_ROOT / f"{digest}.revalidation.json" + assert not revalidation.exists() + controller._appops_revalidation_next_check.clear() + monkeypatch.setattr(controller, "_fetch_gitea_validation", lambda *args: ("pass", [])) + monkeypatch.setattr(controller, "_self_upgrade_protected_head", lambda revision: False) + controller._auto_revalidate_timed_out_appops(marker) + assert not revalidation.exists() + controller._appops_revalidation_next_check.clear() + monkeypatch.setattr(controller, "_self_upgrade_protected_head", lambda revision: True) + controller._auto_revalidate_timed_out_appops(marker) + assert json.loads(revalidation.read_text())["status"] == "awaiting_validation" + assert json.loads(marker.read_text())["status"] == "validation_timeout" + updated = json.loads(revalidation.read_text()) + updated["status"] = "deployed" + revalidation.write_text(json.dumps(updated), encoding="utf-8") + assert controller._self_upgrade_delivery_record(digest)["status"] == "deployed" + + +@pytest.mark.parametrize("gate", ["head", "validation", "runtime", "disk"]) +def test_self_upgrade_preflight_fails_closed_on_gate(controller, monkeypatch, gate): + _ready(controller, monkeypatch) + if gate == "head": + monkeypatch.setattr(controller, "_self_upgrade_protected_head", lambda revision: False) + elif gate == "validation": + monkeypatch.setattr(controller, "_fetch_gitea_validation", lambda *args: ("wait", [])) + elif gate == "runtime": + monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: None) + else: + monkeypatch.setattr(controller.shutil, "disk_usage", lambda path: SimpleNamespace(free=1)) + result = controller._self_upgrade_preflight("c" * 40) + assert result["status"] == "blocked" + assert "preflightSha256" not in result + + +def test_self_upgrade_preflight_rejects_non_sha_and_conflicting_upgrade(controller, monkeypatch): + _ready(controller, monkeypatch) + assert controller._self_upgrade_preflight("main")["status"] == "blocked" + marker = controller.WEBHOOK_INBOX_ROOT / ("e" * 64 + ".json") + marker.write_text(json.dumps({ + "repository": controller.SELF_UPGRADE_REPOSITORY, + "revision": "d" * 40, "status": "deploying", + }), encoding="utf-8") + result = controller._self_upgrade_preflight("c" * 40) + assert result["status"] == "blocked" + assert result["checks"][-1] == {"name": "no_conflicting_upgrade", "passed": False} + + +def test_runtime_requires_exact_container_mount_port_and_health(controller, monkeypatch): + payload = [{ + "State": {"Running": True, "Health": {"Status": "healthy"}}, + "Mounts": [{"Destination": "/data", "Source": "/srv/example/appdata/unraid-appops-gateway-1216", "RW": True}], + "Config": {"Labels": {"org.opencontainers.image.revision": "a" * 40, + "org.opencontainers.image.source": "jens/unraid-appops-gateway"}}, + "NetworkSettings": {"Ports": {"1216/tcp": [{"HostPort": "1216"}]}}, + "Image": "sha256:" + "b" * 64, + }] + names = ["Unraid-AppOps-Gateway"] + + def fake_run(args, **kwargs): + return subprocess.CompletedProcess(args, 0, json.dumps(payload) if args[1] == "inspect" else "\n".join(names), "") + + monkeypatch.setattr(controller.subprocess, "run", fake_run) + assert controller._self_upgrade_runtime()["revision"] == "a" * 40 + payload[0]["Mounts"][0]["Source"] = "/wrong" + assert controller._self_upgrade_runtime() is None + payload[0]["Mounts"][0]["Source"] = "/srv/example/appdata/unraid-appops-gateway-1216" + names.append("Unraid-AppOps-Gateway-copy") + assert controller._self_upgrade_runtime() is None + + +def test_mcp_self_upgrade_rejects_arbitrary_repository(monkeypatch): + from backend.app import mcp_server + + with pytest.raises(ValueError, match="repository must be"): + mcp_server.appops_self_upgrade_preflight("other/repo", "a" * 40, "test reason") + with pytest.raises(ValueError, match="repository must be"): + mcp_server.appops_self_upgrade_apply("other/repo", "a" * 40, "test reason", "b" * 64, "key-12345") + + +def test_self_upgrade_signature_binds_endpoint_and_rejects_replay(controller, monkeypatch, tmp_path): + secret = b"fixed-test-self-upgrade-capability-0123456789" + token_file = tmp_path / "mcp-token" + token_file.write_bytes(secret + b"\n") + token_file.chmod(0o600) + monkeypatch.setattr(controller, "SELF_UPGRADE_TOKEN_FILE", token_file) + monkeypatch.setattr(controller, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + stamp = str(int(time.time())) + nonce = "a" * 32 + body = b'{"revision":"' + b"b" * 40 + b'"}' + path = controller.SELF_UPGRADE_PREFLIGHT_PATH + signature = controller._request_signature( + secret, method="POST", path=path, timestamp=stamp, + nonce=nonce, raw_body=body, + ) + assert not controller._self_upgrade_signature_is_valid( + body, stamp, nonce, signature, method="POST", path=controller.SELF_UPGRADE_APPLY_PATH, + ) + assert controller._self_upgrade_signature_is_valid( + body, stamp, nonce, signature, method="POST", path=path, + ) + assert not controller._self_upgrade_signature_is_valid( + body, stamp, nonce, signature, method="POST", path=path, + ) + + +def test_self_upgrade_http_contract_is_typed_idempotent_and_durable(controller, monkeypatch, tmp_path): + _ready(controller, monkeypatch) + monkeypatch.setattr(controller, "SELF_UPGRADE_REQUEST_ROOT", tmp_path / "requests") + monkeypatch.setattr(controller, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + monkeypatch.setattr(controller, "APPOPS_PROGRESS_ROOT", tmp_path / "progress") + secret = b"fixed-test-self-upgrade-capability-0123456789" + token_file = tmp_path / "mcp-token" + token_file.write_bytes(secret + b"\n") + token_file.chmod(0o600) + monkeypatch.setattr(controller, "SELF_UPGRADE_TOKEN_FILE", token_file) + monkeypatch.setattr(controller.Handler, "log_message", lambda *args: None) + server = ThreadingHTTPServer(("127.0.0.1", 0), controller.Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}" + counter = 0 + + def call(path, payload, *, method="POST"): + nonlocal counter + counter += 1 + body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() if payload is not None else b"" + nonce = f"{counter:032x}" + stamp = str(int(time.time())) + signature = controller._request_signature( + secret, method=method, path=path, timestamp=stamp, + nonce=nonce, raw_body=body, + ) + request = urllib.request.Request( + base + path, data=body if method == "POST" else None, + method=method, + headers={ + "Content-Type": "application/json", "X-ITWorx-Deployment-Timestamp": stamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": signature, + }, + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as error: + return error.code, json.load(error) + + try: + intent = { + "repository": "jens/unraid-appops-gateway", "revision": "c" * 40, + "reason": "Validated production upgrade", "expectedCurrentRevision": "a" * 40, + } + invalid = {**intent, "image": "attacker/image:latest"} + assert call(controller.SELF_UPGRADE_PREFLIGHT_PATH, invalid)[0] == 400 + code, preflight = call(controller.SELF_UPGRADE_PREFLIGHT_PATH, intent) + assert code == 200 and preflight["status"] == "preflight_ok" + apply = {**intent, "preflightSha256": preflight["preflightSha256"], + "idempotencyKey": "fixed-request-12345"} + code, accepted = call(controller.SELF_UPGRADE_APPLY_PATH, apply) + assert code == 202 and accepted["status"] == "accepted", accepted + operation_id = accepted["operationId"] + assert len(operation_id) == 64 + assert (controller.SELF_UPGRADE_REQUEST_ROOT / ( + hashlib.sha256(apply["idempotencyKey"].encode()).hexdigest() + ".json" + )).exists() + code, replay = call(controller.SELF_UPGRADE_APPLY_PATH, apply) + assert code == 200 and replay["status"] == "already_queued" + assert replay["operationId"] == operation_id + assert call(controller.SELF_UPGRADE_APPLY_PATH, {**apply, "revision": "d" * 40})[0] == 409 + code, status = call(controller.SELF_UPGRADE_STATUS_PREFIX + operation_id, None, method="GET") + assert code == 200 and status["state"] == "awaiting_validation" + assert status["operationState"] == "queued" + assert status["revision"] == "c" * 40 + controller.APPOPS_PROGRESS_ROOT.mkdir(mode=0o700) + (controller.APPOPS_PROGRESS_ROOT / ("c" * 40 + ".json")).write_text(json.dumps({ + "schema": "itworx.appops-upgrade-progress/v1", "revision": "c" * 40, + "phase": "building", + }), encoding="utf-8") + delivery_path = controller.WEBHOOK_INBOX_ROOT / f"{operation_id}.json" + delivery_record = json.loads(delivery_path.read_text(encoding="utf-8")) + delivery_record["status"] = "deploying" + delivery_path.write_text(json.dumps(delivery_record), encoding="utf-8") + code, status = call(controller.SELF_UPGRADE_STATUS_PREFIX + operation_id, None, method="GET") + assert code == 200 and status["operationState"] == "building" + delivery_record["status"] = "validation_timeout" + delivery_path.write_text(json.dumps(delivery_record), encoding="utf-8") + recovered = {**delivery_record, "schema": controller._WEBHOOK_REVALIDATION_SCHEMA, + "revalidation_of": operation_id, "status": "deployed"} + (controller.WEBHOOK_INBOX_ROOT / f"{operation_id}.revalidation.json").write_text( + json.dumps(recovered), encoding="utf-8" + ) + monkeypatch.setattr(controller, "_self_upgrade_runtime", lambda: { + "revision": "c" * 40, "imageId": "sha256:" + "b" * 64, + "dataPath": "/srv/example/appdata/unraid-appops-gateway-1216", + }) + monkeypatch.setattr(controller, "_deployment_state", lambda slug: {}) + monkeypatch.setattr(controller, "_verified_receipt", lambda *args: {"revision": "c" * 40}) + code, status = call(controller.SELF_UPGRADE_STATUS_PREFIX + operation_id, None, method="GET") + assert code == 200 and status["operationState"] == "succeeded" + assert status["state"] == "deployed" + code, preflight = call(controller.SELF_UPGRADE_PREFLIGHT_PATH, { + **intent, "expectedCurrentRevision": "c" * 40, + }) + assert code == 200 and preflight["status"] == "already_deployed" + already = {**intent, "expectedCurrentRevision": "c" * 40, + "preflightSha256": preflight["preflightSha256"], + "idempotencyKey": "already-deployed-request-12345"} + code, result = call(controller.SELF_UPGRADE_APPLY_PATH, already) + assert code == 200 and result["status"] == "already_deployed" + assert result["operationId"] == operation_id + code, result = call(controller.SELF_UPGRADE_APPLY_PATH, already) + assert code == 200 and result["status"] == "already_deployed" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def test_appops_deploy_rechecks_exact_main_head_before_mutation(deploy_module, monkeypatch): + revision = "c" * 40 + metadata = {"builtAt": "2026-09-28T00:00:00.000Z"} + monkeypatch.setattr(deploy_module, "validate_commit", lambda repo, branch, value: value) + monkeypatch.setattr(deploy_module, "deployment_metadata", lambda *args, **kwargs: metadata) + monkeypatch.setattr(deploy_module, "output", lambda args: "d" * 40) + with pytest.raises(deploy_module.DeployError, match="exact protected main head"): + deploy_module.revalidate_deployment_candidate( + Path("/repo.git"), {"branch": "main"}, revision, + "jens/unraid-appops-gateway", metadata, + ) + monkeypatch.setattr(deploy_module, "output", lambda args: revision) + deploy_module.revalidate_deployment_candidate( + Path("/repo.git"), {"branch": "main"}, revision, + "jens/unraid-appops-gateway", metadata, + ) + + +def test_appops_backup_is_fixed_and_digest_verified(deploy_module, monkeypatch, tmp_path): + data = tmp_path / "appops-data" + data.mkdir() + (data / "state.txt").write_text("preserved", encoding="utf-8") + monkeypatch.setattr(deploy_module, "APPOPS_DATA_ROOT", data) + monkeypatch.setattr(deploy_module, "APPOPS_BACKUP_ROOT", tmp_path / "backups") + monkeypatch.setattr( + deploy_module.shutil, "disk_usage", lambda path: SimpleNamespace(free=50 * 1024**3) + ) + receipt = deploy_module.capture_appops_data_backup("c" * 40, "a" * 40) + archive = Path(receipt["archive"]) + assert hashlib.sha256(archive.read_bytes()).hexdigest() == receipt["sha256"] + assert receipt["previousRevision"] == "a" * 40 + assert json.loads((archive.parent / "receipt.json").read_text()) == receipt + with tarfile.open(archive, "r:gz") as backup: + assert backup.extractfile("./state.txt").read() == b"preserved" + + +def test_appops_backup_rejects_symlinked_data(deploy_module, monkeypatch, tmp_path): + data = tmp_path / "real-data" + data.mkdir() + link = tmp_path / "link-data" + link.symlink_to(data, target_is_directory=True) + monkeypatch.setattr(deploy_module, "APPOPS_DATA_ROOT", link) + with pytest.raises(deploy_module.DeployError, match="unsafe"): + deploy_module.capture_appops_data_backup("c" * 40, "a" * 40) + + +def test_appops_progress_is_durable_and_bounded(deploy_module, monkeypatch, tmp_path): + monkeypatch.setattr(deploy_module, "APPOPS_PROGRESS_ROOT", tmp_path / "progress") + deploy_module.write_appops_progress("c" * 40, "building") + path = deploy_module.APPOPS_PROGRESS_ROOT / ("c" * 40 + ".json") + assert json.loads(path.read_text())["phase"] == "building" + deploy_module.write_appops_progress("c" * 40, "verifying") + assert json.loads(path.read_text())["phase"] == "verifying" + with pytest.raises(deploy_module.DeployError, match="invalid"): + deploy_module.write_appops_progress("c" * 40, "arbitrary-command") diff --git a/backend/tests/test_authentik_connector.py b/backend/tests/test_authentik_connector.py new file mode 100644 index 0000000..e0c1f80 --- /dev/null +++ b/backend/tests/test_authentik_connector.py @@ -0,0 +1,673 @@ +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +def _reset_db(tmp_path, monkeypatch): + db_path = tmp_path / "authentik_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.db import session + from backend.app.db.init_db import init_db + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + +def _set_authentik_config( + url: str | None = "https://auth.example.test", + token: str | None = "AUTHENTIK_TOKEN_VALUE", + outpost_url: str | None = "https://outpost.example.test", +): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis["AUTHENTIK_URL"] = url + settings.apis["AUTHENTIK_TOKEN"] = token + settings.apis["AUTHENTIK_VERIFY_TLS"] = False + settings.apis["AUTHENTIK_TIMEOUT_SECONDS"] = "10" + settings.apis["AUTHENTIK_OUTPOST_URL"] = outpost_url + settings.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"] = True + return previous + + +def _set_npm_config(): + from backend.app.core.config import settings + + settings.apis["NPM_URL"] = "https://npm.example.test" + settings.apis["NPM_IDENTITY"] = "admin@example.test" + settings.apis["NPM_SECRET"] = "NPM_SECRET_VALUE" + settings.apis["NPM_VERIFY_TLS"] = False + settings.apis["NPM_TIMEOUT_SECONDS"] = "10" + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def _applications(): + return [ + {"pk": "app-media", "name": "Media", "slug": "media", "launch_url": "https://media.example.com", "provider": 101}, + {"pk": "app-orphan", "name": "Orphan", "slug": "orphan", "launch_url": "https://orphan.example.com"}, + ] + + +def _providers(): + return [ + {"pk": 101, "name": "Media proxy", "type": "authentik_providers_proxy.proxyprovider", "assigned_application_slug": "media"}, + {"pk": 202, "name": "Dashboard oauth", "type": "authentik_providers_oauth2.oauth2provider", "assigned_application_slug": "dashboard"}, + ] + + +def _proxy_providers(): + return [ + {"pk": 101, "name": "Media proxy", "external_host": "https://media.example.com", "assigned_application_slug": "media"}, + {"pk": 303, "name": "Unused proxy", "external_host": "https://unused.example.com"}, + ] + + +def _oauth2_providers(): + return [ + { + "pk": 202, + "name": "Dashboard oauth", + "assigned_application_slug": "dashboard", + "redirect_uris": "https://a.example.com/cb\nhttps://b.example.com/cb\nhttps://c.example.com/cb\nhttps://d.example.com/cb", + } + ] + + +def _outposts(): + return [{"pk": "outpost-1", "name": "Embedded Outpost", "type": "proxy", "health": "healthy", "providers": [101]}] + + +def _events(): + return [ + {"pk": "evt-1", "action": "login", "created": "2026-01-01T10:00:00Z", "client_ip": "198.18.10.25", "user": {"username": "jens"}}, + {"pk": "evt-2", "action": "authorize_application", "created": "2026-01-01T10:05:00Z", "client_ip": "198.19.0.2"}, + ] + + +def test_authentik_connector_missing_config_reports_required_env(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_authentik_config(url=None, token=None) + + from backend.app.main import app + + with TestClient(app) as client: + payload = client.post("/api/connectors/authentik/test").json() + + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert payload["missing_config"] is True + assert payload["required_env_vars"] == ["AUTHENTIK_URL", "AUTHENTIK_TOKEN"] + assert payload["secretRef"] == "AUTHENTIK_TOKEN" + + _restore_config(previous) + + +def test_authentik_token_accepts_optional_bearer_prefix(): + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config(token="Bearer AUTHENTIK_TOKEN_VALUE") + + try: + config = AuthentikConnector()._config() + + assert config is not None + assert config.token == "AUTHENTIK_TOKEN_VALUE" + finally: + _restore_config(previous) + + +def test_oidc_template_requires_the_exact_enrolled_role_claim(): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + previous = _set_authentik_config() + settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' + settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' + settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' + settings.apis['AUTHENTIK_OIDC_ROLE_CLAIM'] = 'groups' + try: + assert AuthentikConnector.deployment_oidc_template_status(role_claim='roles') == { + 'status': 'missing_config', + 'missing': ['AUTHENTIK_OIDC_ROLE_CLAIM'], + } + settings.apis['AUTHENTIK_OIDC_ROLE_CLAIM'] = 'roles' + assert AuthentikConnector.deployment_oidc_template_status(role_claim='roles') == { + 'status': 'ready', + 'missing': [], + } + finally: + _restore_config(previous) + + +def test_typed_oidc_provisioning_is_bounded_and_never_returns_client_secret(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + previous = _set_authentik_config() + settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' + settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' + settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' + writes = [] + + def paged(self, path, params=None): + assert params is None + return { + '/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}], + '/core/applications/': [], + }[path] + + def write(self, method, path, payload=None): + writes.append((method, path, payload)) + return {'pk': 'provider-1'} if path == '/providers/oauth2/' else {'pk': 'application-1'} + + monkeypatch.setattr(AuthentikConnector, '_paged', paged, raising=True) + monkeypatch.setattr(AuthentikConnector, '_write_request', write, raising=True) + secret = 'generated-client-secret-value' + result = AuthentikConnector().ensure_deployment_oidc_application( + application_slug='wsbc-portal', + launch_url='https://support.itworx.test', + redirect_uri='https://support.itworx.test/signin-oidc', + client_secret=secret, + administrator_group='Administrator', + support_agent_group='SupportAgent', + read_only_group='ReadOnly', + ) + + assert result == { + 'success': True, + 'status': 'created', + 'message': 'oidc_application_created_from_typed_template', + 'applicationId': 'application-1', + 'providerId': 'provider-1', + 'clientSecretCreated': True, + } + assert writes[0][0:2] == ('POST', '/providers/oauth2/') + assert writes[0][2]['client_secret'] == secret + assert secret not in str(result) + _restore_config(previous) + + +def test_typed_oidc_provisioning_removes_new_provider_when_application_create_fails(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + previous = _set_authentik_config() + settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' + settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' + settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' + calls = [] + monkeypatch.setattr( + AuthentikConnector, + '_paged', + lambda self, path, params=None: ( + [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}] + if path == '/core/groups/' else [] + ), + raising=True, + ) + + def write(self, method, path, payload=None): + calls.append((method, path, payload)) + if path == '/providers/oauth2/': + return {'pk': 'provider-1'} + if path == '/core/applications/': + raise RuntimeError('request failed client_secret=never-return-this') + assert (method, path, payload) == ('DELETE', '/providers/oauth2/provider-1/', None) + return {} + + monkeypatch.setattr(AuthentikConnector, '_write_request', write, raising=True) + result = AuthentikConnector().ensure_deployment_oidc_application( + application_slug='wsbc-portal', + launch_url='https://support.itworx.test', + redirect_uri='https://support.itworx.test/signin-oidc', + client_secret='never-return-this', + administrator_group='Administrator', + support_agent_group='SupportAgent', + read_only_group='ReadOnly', + ) + + assert result == { + 'success': False, + 'status': 'failed', + 'message': 'oidc_application_creation_failed_provider_removed', + } + assert calls[-1] == ('DELETE', '/providers/oauth2/provider-1/', None) + assert 'never-return-this' not in str(result) + _restore_config(previous) + + +def test_typed_oidc_existing_application_requires_matching_provider_contract(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.core.config import settings + + previous = _set_authentik_config() + settings.apis['AUTHENTIK_OIDC_AUTHORIZATION_FLOW'] = 'flow-default-auth' + settings.apis['AUTHENTIK_OIDC_INVALIDATION_FLOW'] = 'flow-default-invalidation' + settings.apis['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING'] = 'mapping-groups' + monkeypatch.setattr( + AuthentikConnector, + '_paged', + lambda self, path, params=None: { + '/core/groups/': [{'name': 'Administrator'}, {'name': 'SupportAgent'}, {'name': 'ReadOnly'}], + '/core/applications/': [{'pk': 'application-1', 'slug': 'wsbc-portal', 'meta_launch_url': 'https://support.itworx.test', 'provider': 'provider-1'}], + '/providers/oauth2/': [{'pk': 'provider-1', 'client_id': 'unexpected-client', 'client_type': 'confidential', 'redirect_uris': ['https://support.itworx.test/signin-oidc'], 'property_mappings': ['mapping-groups'], 'authorization_flow': 'flow-default-auth', 'invalidation_flow': 'flow-default-invalidation'}], + }[path], + raising=True, + ) + try: + result = AuthentikConnector().ensure_deployment_oidc_application( + application_slug='wsbc-portal', + launch_url='https://support.itworx.test', + redirect_uri='https://support.itworx.test/signin-oidc', + client_secret='never-return-this', + administrator_group='Administrator', + support_agent_group='SupportAgent', + read_only_group='ReadOnly', + ) + finally: + _restore_config(previous) + + assert result == { + 'success': False, + 'status': 'conflict', + 'message': 'existing_oidc_provider_does_not_match_managed_contract', + } + + +def test_authentik_health_success_and_auth_failure_redact_token(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config() + requested_paths = [] + + def ok_request(self, path, params=None): + requested_paths.append(path) + return {"version": "2026.1.0"} if path == "/root/config/" else {} + + monkeypatch.setattr(AuthentikConnector, "_request", ok_request, raising=True) + monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": _applications(), "total": 2}, raising=True) + monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "status": "ok", "available": True, "items": []}, raising=True) + + healthy = AuthentikConnector().health() + + assert healthy["success"] is True + assert healthy["status"] == "connected" + assert healthy["version"] == "2026.1.0" + assert healthy["applicationCount"] == 2 + assert healthy["url"] == "https://auth.example.test" + assert "AUTHENTIK_TOKEN_VALUE" not in str(healthy) + assert "/root/config/" in requested_paths + assert "/core/root/" not in requested_paths + + def fail_request(self, path, params=None): + raise RuntimeError("auth_failed token=AUTHENTIK_TOKEN_VALUE") + + monkeypatch.setattr(AuthentikConnector, "_request", fail_request, raising=True) + failed = AuthentikConnector().health() + + assert failed["success"] is False + assert failed["status"] == "auth_failed" + assert "AUTHENTIK_TOKEN_VALUE" not in str(failed) + assert "[REDACTED]" in str(failed) + + _restore_config(previous) + + +def test_authentik_resources_normalize_and_events_mask_ips(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config() + monkeypatch.setattr( + AuthentikConnector, + "_paged", + lambda self, path, params=None: { + "/core/applications/": _applications(), + "/core/providers/": _providers(), + "/providers/proxy/": _proxy_providers(), + "/providers/oauth2/": _oauth2_providers(), + "/outposts/instances/": _outposts(), + "/events/events/": _events(), + "/flows/instances/": [{"pk": "flow-1", "name": "Login", "designation": "authentication"}], + "/policies/all/": [{"pk": "policy-1", "name": "Require MFA", "enabled": True}], + }[path], + raising=True, + ) + + connector = AuthentikConnector() + apps = connector.applications() + providers = connector.providers() + proxy = connector.proxy_providers() + oauth = connector.oauth2_providers() + outposts = connector.outposts() + events = connector.events() + flows = connector.flows_summary() + policies = connector.policies_summary() + + assert apps["items"][0]["slug"] == "media" + assert providers["items"][0]["providerType"] == "proxy" + assert proxy["items"][0]["externalHost"] == "https://media.example.com" + assert oauth["items"][0]["redirectUriCount"] == 4 + assert outposts["items"][0]["providerCount"] == 1 + assert events["items"][0]["clientIp"] == "192.168.10.x" + assert flows["total"] == 1 + assert policies["enabled"] == 1 + + _restore_config(previous) + + +def test_authentik_auth_coverage_and_security_warnings_with_npm(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_authentik_config() + _set_npm_config() + monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": AuthentikConnector._map_applications(_applications())}, raising=True) + monkeypatch.setattr(AuthentikConnector, "providers", lambda self: {"success": True, "items": AuthentikConnector._map_providers(_providers())}, raising=True) + monkeypatch.setattr(AuthentikConnector, "proxy_providers", lambda self: {"success": True, "items": AuthentikConnector._map_proxy_providers(_proxy_providers())}, raising=True) + monkeypatch.setattr(AuthentikConnector, "oauth2_providers", lambda self: {"success": True, "items": AuthentikConnector._map_oauth2_providers(_oauth2_providers())}, raising=True) + monkeypatch.setattr(AuthentikConnector, "outposts", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "status": "warning", "items": []}, raising=True) + monkeypatch.setattr(AuthentikConnector, "events", lambda self, limit=25: {"success": True, "items": []}, raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "proxy_hosts", + lambda self: { + "success": True, + "items": [ + {"id": 1, "domainNames": ["media.example.com"], "enabled": True, "accessListId": 2, "forwardHost": "plex", "forwardPort": 32400, "forwardScheme": "http"}, + {"id": 2, "domainNames": ["open.example.com"], "enabled": True, "accessListId": None, "forwardHost": "app", "forwardPort": 8080, "forwardScheme": "http"}, + ], + }, + raising=True, + ) + + coverage = AuthentikConnector().auth_coverage() + warnings = AuthentikConnector().security_warnings() + + assert coverage["summary"]["totalPublicHosts"] == 2 + assert coverage["summary"]["protectedHosts"] == 1 + assert coverage["summary"]["unprotectedHosts"] == 1 + assert any(item["coverageStatus"] == "protected" for item in coverage["publicHosts"]) + assert any(item["coverageStatus"] == "unprotected" for item in coverage["publicHosts"]) + assert any(item["type"] == "no_outposts" for item in warnings["items"]) + assert any(item["type"] == "public_host_without_authentik" for item in warnings["items"]) + assert any(item["type"] == "oauth_many_redirect_uris" for item in warnings["items"]) + + _restore_config(previous) + + +def test_authentik_reuses_read_results_within_connector_instance(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_authentik_config() + _set_npm_config() + calls = [] + + def paged(self, path, params=None): + calls.append(path) + return { + "/core/applications/": _applications(), + "/core/providers/": _providers(), + "/providers/proxy/": _proxy_providers(), + "/providers/oauth2/": _oauth2_providers(), + "/outposts/instances/": _outposts(), + "/events/events/": _events(), + }[path] + + monkeypatch.setattr(AuthentikConnector, "_paged", paged, raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "proxy_hosts", + lambda self: { + "success": True, + "status": "ok", + "items": [ + {"id": 1, "domainNames": ["media.example.com"], "enabled": True, "accessListId": 2, "forwardHost": "plex", "forwardPort": 32400, "forwardScheme": "http"}, + {"id": 2, "domainNames": ["open.example.com"], "enabled": True, "accessListId": None, "forwardHost": "app", "forwardPort": 8080, "forwardScheme": "http"}, + ], + }, + raising=True, + ) + + connector = AuthentikConnector() + coverage = connector.auth_coverage() + warnings = connector.security_warnings() + outpost = connector.outpost_health() + + assert coverage["summary"]["totalPublicHosts"] == 2 + assert warnings["total"] >= 1 + assert outpost["available"] is True + assert calls.count("/core/applications/") == 1 + assert calls.count("/core/providers/") == 1 + assert calls.count("/providers/proxy/") == 1 + assert calls.count("/providers/oauth2/") == 1 + assert calls.count("/outposts/instances/") == 1 + assert calls.count("/events/events/") == 1 + + _restore_config(previous) + + +def test_authentik_unknown_embedded_outpost_is_warning_not_high(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config(outpost_url=None) + try: + monkeypatch.setattr(AuthentikConnector, "applications", lambda self: {"success": True, "items": AuthentikConnector._map_applications(_applications())}, raising=True) + monkeypatch.setattr(AuthentikConnector, "providers", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr(AuthentikConnector, "proxy_providers", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr(AuthentikConnector, "oauth2_providers", lambda self: {"success": True, "items": []}, raising=True) + monkeypatch.setattr(AuthentikConnector, "outposts", lambda self: {"success": True, "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}]}, raising=True) + monkeypatch.setattr(AuthentikConnector, "outpost_health", lambda self: {"success": True, "available": False, "embeddedExpected": True, "status": "warning", "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}]}, raising=True) + monkeypatch.setattr(AuthentikConnector, "events", lambda self, limit=25: {"success": True, "items": []}, raising=True) + + warnings = AuthentikConnector().security_warnings() + embedded = next(item for item in warnings["items"] if item["type"] == "embedded_outpost_unavailable") + + assert embedded["severity"] == "warning" + finally: + _restore_config(previous) + + +def test_authentik_numeric_pagination_reuses_collection_path(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config() + calls = [] + + def request(self, path, params=None): + calls.append((path, params)) + page = int((params or {}).get("page", 1)) + return { + "results": [{"pk": f"app-{page}"}], + "pagination": {"next": page + 1 if page < 3 else 0}, + } + + monkeypatch.setattr(AuthentikConnector, "_request", request, raising=True) + try: + items = AuthentikConnector()._paged("/core/applications/", {"page_size": 20}) + + assert [item["pk"] for item in items] == ["app-1", "app-2", "app-3"] + assert calls == [ + ("/core/applications/", {"page_size": 20}), + ("/core/applications/", {"page_size": 20, "page": 2}), + ("/core/applications/", {"page_size": 20, "page": 3}), + ] + finally: + _restore_config(previous) + + +def test_authentik_outpost_reachability_is_not_mislabeled_as_health(monkeypatch): + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config(outpost_url="https://outpost.example.test") + try: + monkeypatch.setattr( + AuthentikConnector, + "outposts", + lambda self: { + "success": True, + "items": [{"id": "embedded", "name": "Embedded Outpost", "health": "unknown"}], + }, + raising=True, + ) + monkeypatch.setattr( + AuthentikConnector, + "_probe_outpost", + lambda self, config: {"status": "reachable", "reachable": True, "statusCode": 302}, + raising=True, + ) + + result = AuthentikConnector().outpost_health() + + assert result["available"] is False + assert result["reachable"] is True + assert result["status"] == "reachable_unverified" + assert result["telemetryStatus"] == "unverified" + assert result["message"] == "outpost_reachable_health_unverified" + finally: + _restore_config(previous) + + +def test_authentik_outpost_probe_never_forwards_api_token(monkeypatch): + from backend.app.connectors import authentik + from backend.app.connectors.authentik import AuthentikConnector + + previous = _set_authentik_config(token="SENSITIVE_AUTHENTIK_TOKEN") + captured = {} + + class FakeResponse: + status_code = 401 + + class FakeClient: + def __init__(self, **kwargs): + captured["client"] = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def get(self, url, headers): + captured["url"] = url + captured["headers"] = headers + return FakeResponse() + + monkeypatch.setattr(authentik.httpx, "Client", FakeClient) + try: + config = AuthentikConnector()._config() + result = AuthentikConnector._probe_outpost(config) + finally: + _restore_config(previous) + + assert result == {"status": "reachable", "reachable": True, "statusCode": 401} + assert "Authorization" not in captured["headers"] + assert "SENSITIVE_AUTHENTIK_TOKEN" not in str(captured) + + +def test_authentik_routes_are_read_only_and_shape_is_consistent(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_authentik_config() + + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.main import app + + monkeypatch.setattr(AuthentikConnector, "_request", lambda self, path, params=None: {"version": "2026.1.0"}, raising=True) + monkeypatch.setattr(AuthentikConnector, "_paged", lambda self, path, params=None: [], raising=True) + + with TestClient(app) as client: + for path in [ + "/api/apps/authentik/health", + "/api/apps/authentik/system", + "/api/apps/authentik/applications", + "/api/apps/authentik/providers", + "/api/apps/authentik/providers/proxy", + "/api/apps/authentik/providers/oauth2", + "/api/apps/authentik/outposts", + "/api/apps/authentik/outposts/health", + "/api/apps/authentik/flows", + "/api/apps/authentik/policies", + "/api/apps/authentik/events", + "/api/apps/authentik/auth-coverage", + "/api/apps/authentik/security-warnings", + ]: + response = client.get(path) + assert response.status_code == 200 + payload = response.json() + assert set(["success", "status", "message", "data"]).issubset(payload.keys()) + assert client.post("/api/apps/authentik/applications").status_code == 405 + + _restore_config(previous) + + +def test_authentik_policy_blocks_mutations_and_unknowns_fail_closed(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + from backend.app.main import app + + with TestClient(app) as client: + for action in [ + "authentik.create_application", + "authentik.update_application", + "authentik.delete_application", + "authentik.create_provider", + "authentik.delete_provider", + "authentik.create_outpost", + "authentik.delete_policy", + "authentik.create_user", + "authentik.delete_group", + "authentik.rotate_token", + "authentik.force_sync", + "authentik.mass_edit", + "authentik.unknown_mutation", + ]: + payload = client.post("/api/policies/evaluate", json={"action": action, "target": "authentik", "source": "manual"}).json() + assert payload["decision"] == "blocked" + events_voice = client.post("/api/policies/evaluate", json={"action": "authentik.events", "target": "authentik", "source": "voice"}).json() + assert events_voice["decision"] == "approval_required" + + +def test_authentik_inventory_classification_and_candidate(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + from backend.app.main import app + from backend.app.services.app_classifier import classify_container + + result = classify_container({"name": "authentik-server", "image": "ghcr.io/goauthentik/server:latest", "ports": ["9000:9000"]}) + + assert result["app_type"] == "authentik" + assert result["category"] == "auth" + assert result["subcategory"] == "identity_provider" + assert result["connector"] == "authentik" + assert result["criticality"] == "critical" + assert result["policy_profile"] == "auth_provider" + assert result["protected"] is True + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "ok", "containers": [{"id": "auth-container", "name": "authentik-worker", "image": "ghcr.io/goauthentik/proxy", "state": "running", "status": "running", "ports": ["9000:9000"]}]}, + raising=True, + ) + + with TestClient(app) as client: + client.post("/api/inventory/discover") + candidates = client.get("/api/connectors/authentik/candidates").json() + + assert len(candidates) == 1 + assert candidates[0]["connectorId"] == "authentik" + assert candidates[0]["appType"] == "authentik" + assert candidates[0]["missingSecrets"] == ["AUTHENTIK_URL", "AUTHENTIK_TOKEN"] diff --git a/backend/tests/test_container_restart.py b/backend/tests/test_container_restart.py new file mode 100644 index 0000000..339bcdd --- /dev/null +++ b/backend/tests/test_container_restart.py @@ -0,0 +1,2866 @@ +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "appops_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.main import app + from backend.app.db import session + from backend.app.db.init_db import init_db + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def _with_updated_connectors(portainer_url: str | None = None, portainer_key: str | None = None, portainer_endpoint: str | None = None, unraid_url: str | None = None, unraid_key: str | None = None): + from backend.app.core.config import settings + + return_settings = dict(settings.apis) + if portainer_url is not None: + settings.apis['PORTAINER_URL'] = portainer_url + if portainer_key is not None: + settings.apis['PORTAINER_API_KEY'] = portainer_key + if portainer_endpoint is not None: + settings.apis['PORTAINER_ENDPOINT_ID'] = portainer_endpoint + if unraid_url is not None: + settings.apis['UNRAID_URL'] = unraid_url + if unraid_key is not None: + settings.apis['UNRAID_API_KEY'] = unraid_key + return return_settings + + +def _restore_connectors(previous): + from backend.app.core.config import settings + settings.apis.clear() + settings.apis.update(previous) + + +def test_approval_decision_writes_audit_event(api_client): + created = api_client.post('/api/approvals', json={ + 'action': 'container.restart', + 'target': 'sonarr', + 'source': 'manual', + 'risk': 'medium', + 'reason': 'Operator requested restart approval.', + 'requester': 'operator', + }).json() + + assert created['success'] is True + approval_id = created['id'] + + decided = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'approved'}).json() + + assert decided['success'] is True + assert decided['status'] == 'approved' + assert decided['auditId'].startswith('audit-') + + audit = api_client.get('/api/audit').json() + decision_events = [event for event in audit if event['id'] == decided['auditId']] + assert decision_events + assert decision_events[0]['action_id'] == 'approval.approved' + assert decision_events[0]['target'] == 'sonarr' + + +def test_approval_decision_uses_strict_state_machine(api_client): + created = api_client.post('/api/approvals', json={ + 'action': 'stack.redeploy', + 'target': 'media', + 'source': 'manual', + 'risk': 'high', + }).json() + approval_id = created['id'] + + invalid = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'executed'}) + assert invalid.status_code == 409 + + approved = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'approved'}) + assert approved.status_code == 200 + + repeated = api_client.patch(f'/api/approvals/{approval_id}', json={'status': 'rejected'}) + assert repeated.status_code == 409 + + execution = api_client.post(f'/api/approvals/{approval_id}/execute') + assert execution.status_code == 200 + assert execution.json()['result'] == 'unsupported' + + +def test_expired_approval_is_closed_by_backend_not_only_ui(api_client): + from datetime import timedelta + + from sqlmodel import Session + + from backend.app.core.time import utc_now + from backend.app.db import session as db_session + from backend.app.db.models import Approval + + created = api_client.post('/api/approvals', json={ + 'action': 'deluge.pause_all', + 'target': 'deluge', + 'source': 'manual', + 'risk': 'high', + }).json() + with Session(db_session.engine) as session: + approval = session.get(Approval, created['id']) + approval.expires_at = utc_now() - timedelta(minutes=1) + session.add(approval) + session.commit() + + listed = api_client.get('/api/approvals').json() + expired = next(item for item in listed if item['id'] == created['id']) + assert expired['status'] == 'expired' + + decision = api_client.patch(f"/api/approvals/{created['id']}", json={'status': 'approved'}) + assert decision.status_code == 409 + + +def test_approval_cleanup_archives_only_expired_records(api_client): + from datetime import timedelta + + from sqlmodel import Session + + from backend.app.core.time import utc_now + from backend.app.db import session as db_session + from backend.app.db.models import Approval + + expired = api_client.post('/api/approvals', json={ + 'action': 'deluge.pause_all', + 'target': 'deluge', + 'source': 'manual', + 'risk': 'high', + }).json() + pending = api_client.post('/api/approvals', json={ + 'action': 'plex.scan_all_libraries', + 'target': 'plex', + 'source': 'manual', + 'risk': 'medium', + }).json() + with Session(db_session.engine) as session: + approval = session.get(Approval, expired['id']) + approval.expires_at = utc_now() - timedelta(minutes=1) + session.add(approval) + session.commit() + + cleaned = api_client.post('/api/approvals/cleanup') + + assert cleaned.status_code == 200 + assert cleaned.json()['success'] is True + assert cleaned.json()['dismissedCount'] == 1 + active_ids = {item['id'] for item in api_client.get('/api/approvals').json()} + assert expired['id'] not in active_ids + assert pending['id'] in active_ids + + history = api_client.get('/api/approvals?include_dismissed=true').json() + archived = next(item for item in history if item['id'] == expired['id']) + assert archived['status'] == 'expired' + assert archived['dismissed_at'] is not None + + cleanup_audit = next( + event for event in api_client.get('/api/audit').json() + if event['id'] == cleaned.json()['auditId'] + ) + assert cleanup_audit['action_id'] == 'approval.cleanup' + + +def test_action_registry_reports_real_execution_modes(api_client): + payload = api_client.get('/api/actions/registry').json() + items = {item['id']: item for item in payload['items']} + + assert items['container.restart']['executionMode'] == 'command_center' + assert items['container.restart']['commandExecutionSupported'] is True + assert items['deluge.pause_all']['executionMode'] == 'approval_then_execute' + assert items['unraid.vm.start']['executionMode'] == 'dedicated_surface' + assert items['unraid.vm.pause']['executionMode'] == 'approval_then_execute' + assert items['unraid.vm.stop']['executionMode'] == 'blocked' + assert items['stack.redeploy']['executionMode'] == 'governance_only' + assert items['jellyfin.scan_library']['executionMode'] == 'blocked' + assert items['deployment.target.promote_workmesh']['executionMode'] == 'approval_then_execute' + assert payload['summary']['commandExecutable'] >= 1 + assert payload['summary']['approvalExecutable'] == 5 + + +def test_deployment_target_sync_registry_is_bounded_command_action(api_client): + payload = api_client.get('/api/actions/registry').json() + items = {item['id']: item for item in payload['items']} + + sync = items['deployment.targets.sync'] + assert sync['target'] == 'jens/*' + assert sync['risk'] == 'high' + assert sync['executionMode'] == 'command_center' + assert sync['commandExecutionSupported'] is True + + +def test_deployment_target_sync_executes_existing_bounded_reconciler(api_client, monkeypatch): + from backend.app.services import gitea_project_auto_registration + + calls = [] + + def fake_sync(self, session, *, force=False): + calls.append(force) + return { + 'status': 'ok', + 'summary': { + 'total': 64, + 'registered': 12, + 'knownUnconfigured': 50, + 'conflicts': 0, + 'errors': 0, + 'changedThisSync': 1, + }, + } + + monkeypatch.setattr( + gitea_project_auto_registration.GiteaProjectAutoRegistration, + 'sync', + fake_sync, + raising=True, + ) + + response = api_client.post( + '/api/actions/execute', + json={ + 'intent': 'deployment.targets.sync', + 'target': 'jens/*', + 'source': 'manual', + 'risk': 'high', + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['status'] == 'success' + assert payload['summary']['registered'] == 12 + assert payload['summary']['changed'] == 1 + assert calls == [True] + + audit = api_client.get('/api/audit').json() + event = next(item for item in audit if item['id'] == payload['auditId']) + assert event['action_id'] == 'deployment.targets.sync' + assert event['target'] == 'jens/*' + + +def test_deployment_target_sync_rejects_target_drift(api_client): + response = api_client.post( + '/api/actions/execute', + json={ + 'intent': 'deployment.targets.sync', + 'target': 'jens/workmesh', + 'source': 'manual', + 'risk': 'high', + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['result'] == 'blocked' + + +def test_workmesh_first_deploy_registry_is_fixed_high_risk_command(api_client): + payload = api_client.get('/api/actions/registry').json() + items = {item['id']: item for item in payload['items']} + + deploy = items['deployment.target.deploy_workmesh'] + assert deploy['target'] == 'jens/workmesh' + assert deploy['risk'] == 'high' + assert deploy['policyDecision'] == 'approval_required' + assert deploy['executionMode'] == 'command_center' + assert deploy['commandExecutionSupported'] is True + + +def test_workmesh_first_deploy_contract_is_secret_safe_and_release_pinned(): + from backend.app.services.workmesh_first_deployment import ( + _CONTAINERS, + _FIRST_DEPLOY_SCRIPT, + _PORTS, + _RELEASE_REVISION, + ) + + assert _RELEASE_REVISION == '7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05' + assert _PORTS == (1255, 1256, 1257, 1258) + assert _CONTAINERS == ( + 'workmesh-postgres-1', + 'workmesh-mock-ats-1', + 'workmesh-api-1', + 'workmesh-n8n-1', + 'workmesh-workmesh-1', + ) + assert 'env_path = env_dir / ".env"' in _FIRST_DEPLOY_SCRIPT + assert 'secrets.token_urlsafe(48)' in _FIRST_DEPLOY_SCRIPT + assert 'os.open(temporary_env, flags, 0o600)' in _FIRST_DEPLOY_SCRIPT + assert 'info.st_uid != 0' in _FIRST_DEPLOY_SCRIPT + assert '"WORKMESH_BIND_HOST": "127.0.0.1"' in _FIRST_DEPLOY_SCRIPT + assert '"WORKMESH_WEB_PORT": "1255"' in _FIRST_DEPLOY_SCRIPT + assert '"WORKMESH_API_PORT": "1256"' in _FIRST_DEPLOY_SCRIPT + assert '"N8N_PORT": "1257"' in _FIRST_DEPLOY_SCRIPT + assert '"MOCK_ATS_PORT": "1258"' in _FIRST_DEPLOY_SCRIPT + assert '["python3", str(deploy_py), "preflight", repository, revision]' in _FIRST_DEPLOY_SCRIPT + assert '["python3", str(deploy_py), "deploy", repository, revision]' in _FIRST_DEPLOY_SCRIPT + assert 'timeout=1800' in _FIRST_DEPLOY_SCRIPT + assert 'stdout=subprocess.DEVNULL' in _FIRST_DEPLOY_SCRIPT + assert 'stderr=subprocess.DEVNULL' in _FIRST_DEPLOY_SCRIPT + + +def test_workmesh_first_deploy_script_accepts_the_pinned_release_descriptor(tmp_path, monkeypatch): + from backend.app.services.workmesh_first_deployment import _FIRST_DEPLOY_SCRIPT + + release_revision = '7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05' + descriptor = { + 'apiVersion': 'itworx.deployment-target/v1', + 'applicationId': 'workmesh', + 'composeFile': 'compose.yaml', + 'composeProject': 'workmesh', + 'healthTimeout': 300, + 'verifyOciRevision': False, + } + descriptor_json = json.dumps(descriptor) + controller_root = tmp_path / 'controller' + registry_path = controller_root / 'projects.json' + bare_repo = tmp_path / 'WorkMesh.git' + deploy_py = controller_root / 'deploy.py' + controller_root.mkdir() + registry_path.write_text('{}', encoding='utf-8') + deploy_py.write_text('# test stub\n', encoding='utf-8') + bare_repo.mkdir() + + script = _FIRST_DEPLOY_SCRIPT + for original, replacement in ( + ('Path("/opt/gitea-deploy")', f'Path({json.dumps(str(controller_root))})'), + ( + 'Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")', + f'Path({json.dumps(str(registry_path))})', + ), + ( + 'Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock")', + f'Path({json.dumps(str(tmp_path / "deploy.lock"))})', + ), + ( + 'Path("/gitea-repositories/jens/workmesh.git")', + f'Path({json.dumps(str(bare_repo))})', + ), + ): + script = script.replace(original, replacement) + + artifact_checks = [] + descriptor_reads = [] + + class DockerInventoryReached(Exception): + pass + + def fake_run(command, **_kwargs): + if command[0] == 'git': + operation = command[2] + if operation == 'rev-parse': + return SimpleNamespace(returncode=0, stdout=f'{release_revision}\n') + if operation == 'cat-file': + artifact = command[4].split(':', 1)[1] + artifact_checks.append(artifact) + expected = {'compose.yaml', 'VERSION', '.itworx/deployment-target.json'} + return SimpleNamespace(returncode=0 if artifact in expected else 1, stdout='') + if operation == 'show': + artifact = command[3].split(':', 1)[1] + if artifact == 'VERSION': + return SimpleNamespace(returncode=0, stdout='0.1.0\n') + descriptor_reads.append(artifact) + if artifact == '.itworx/deployment-target.json': + return SimpleNamespace(returncode=0, stdout=descriptor_json) + return SimpleNamespace(returncode=1, stdout='') + raise AssertionError(command) + if command[:3] == ['docker', 'ps', '-aq']: + raise DockerInventoryReached + raise AssertionError(command) + + monkeypatch.setattr(subprocess, 'run', fake_run) + monkeypatch.setitem( + sys.modules, + 'fcntl', + SimpleNamespace(LOCK_EX=1, flock=lambda *_args: None), + ) + + with pytest.raises(DockerInventoryReached): + exec(compile(script, '', 'exec'), {}) # noqa: S102 + + assert artifact_checks == [ + 'compose.yaml', + 'VERSION', + '.itworx/deployment-target.json', + ] + assert descriptor_reads == ['.itworx/deployment-target.json'] + + +def test_workmesh_first_deploy_uses_one_durable_job(api_client, monkeypatch): + from backend.app.db import session as db_session + from backend.app.services import action_execution + from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment + + monkeypatch.setattr( + WorkMeshFirstDeployment, + 'tracked_job_id', + staticmethod(lambda session: None), + raising=True, + ) + monkeypatch.setattr( + WorkMeshFirstDeployment, + 'run', + lambda self, session, *, job: { + 'status': 'in_progress', + 'execution_id': 'exec-workmesh-1', + }, + raising=True, + ) + + with Session(db_session.engine) as session: + result = action_execution.execute_governed_action( + session, + action='deployment.target.deploy_workmesh', + target='jens/workmesh', + source='chatgpt-mcp', + risk='high', + reason='Test the fixed first-deploy boundary.', + original_policy_decision='approval_required', + ) + + assert result['success'] is False + assert result['status'] == 'in_progress' + assert result['executionId'] == 'exec-workmesh-1' + assert result['jobId'].startswith('job-') + assert result['auditId'].startswith('audit-') + + +def test_workmesh_first_deploy_blocks_orphan_running_job(api_client, monkeypatch): + from sqlmodel import select + + from backend.app.db import session as db_session + from backend.app.db.models import JobRun + from backend.app.services import action_execution + from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment + + monkeypatch.setattr( + WorkMeshFirstDeployment, + 'run', + lambda self, session, *, job: pytest.fail('must not start a second controller execution'), + raising=True, + ) + with Session(db_session.engine) as session: + session.add(JobRun( + id='job-orphan-workmesh', + type='deployment.target.deploy_workmesh', + status='running', + action_id='deployment.target.deploy_workmesh', + target='jens/workmesh', + risk='high', + policy_decision='allowed', + )) + session.commit() + result = action_execution.execute_governed_action( + session, + action='deployment.target.deploy_workmesh', + target='jens/workmesh', + source='chatgpt-mcp', + risk='high', + reason='Reconcile the existing first-deploy job.', + original_policy_decision='approval_required', + ) + jobs = session.exec( + select(JobRun).where(JobRun.action_id == 'deployment.target.deploy_workmesh') + ).all() + + assert result['status'] == 'outcome_unknown' + assert result['jobId'] == 'job-orphan-workmesh' + assert len(jobs) == 1 + + +def test_workmesh_first_deploy_reconciles_one_controller_exec(api_client): + from backend.app.db import session as db_session + from backend.app.services.job_runner import create_job, start_job + from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment + + class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + class FakePortainer: + def __init__(self): + self.started = False + self.running = True + self.calls = [] + + def get_container_status(self, name): + assert name == 'gitea-deploy-control' + return { + 'status': 'ok', + 'container': { + 'id': 'controller-1', + 'name': 'gitea-deploy-control', + 'state': 'running', + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id='3') + + def _request(self, method, path, **kwargs): + self.calls.append((method, path)) + if method == 'POST' and path.endswith('/exec'): + return FakeResponse({'Id': 'exec-workmesh-1'}) + if method == 'POST' and path.endswith('/start'): + self.started = True + return FakeResponse({}) + if method == 'GET' and path.endswith('/json'): + return FakeResponse({ + 'ID': 'exec-workmesh-1', + 'ContainerID': 'controller-1', + 'Running': self.running, + 'ExitCode': 0 if not self.running else None, + }) + raise AssertionError((method, path)) + + fake = FakePortainer() + with Session(db_session.engine) as session: + job = create_job( + session, + action_id='deployment.target.deploy_workmesh', + target='jens/workmesh', + source='chatgpt-mcp', + risk='high', + policy_decision='allowed', + steps=['fixed first deploy'], + ) + start_job(session, job) + service = WorkMeshFirstDeployment(portainer=fake) + + first = service.run(session, job=job) + assert first == { + 'status': 'in_progress', + 'execution_id': 'exec-workmesh-1', + } + assert fake.started is True + assert service.tracked_job_id(session) == job.id + + cancelled = api_client.post(f'/api/jobs/{job.id}/cancel').json() + assert cancelled['success'] is False + assert 'cannot be cancelled' in cancelled['message'] + assert cancelled['status'] == 'running' + + second = service.run(session, job=job) + assert second['status'] == 'in_progress' + assert second['execution_id'] == 'exec-workmesh-1' + + fake.running = False + third = service.run(session, job=job) + assert third['status'] == 'deployed' + assert third['revision'] == '7dfbdb7d25e0efd70c53106de1d36d3fff6e3a05' + assert third['ports'] == [1255, 1256, 1257, 1258] + + fourth = service.run(session, job=job) + assert fourth == third + create_calls = [call for call in fake.calls if call[0] == 'POST' and call[1].endswith('/exec')] + assert len(create_calls) == 1 + + +@pytest.mark.parametrize('ambiguous_at', ['create', 'start']) +def test_workmesh_first_deploy_does_not_repeat_ambiguous_exec(api_client, ambiguous_at): + from backend.app.db import session as db_session + from backend.app.services.job_runner import create_job, start_job + from backend.app.services.workmesh_first_deployment import WorkMeshFirstDeployment + + class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + class FakePortainer: + def __init__(self): + self.create_calls = 0 + + def get_container_status(self, _name): + return { + 'status': 'ok', + 'container': { + 'id': 'controller-1', + 'name': 'gitea-deploy-control', + 'state': 'running', + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id='3') + + def _request(self, method, path, **_kwargs): + if method == 'POST' and path.endswith('/exec'): + self.create_calls += 1 + if ambiguous_at == 'create': + raise RuntimeError('provider response lost after exec creation') + return FakeResponse({'Id': 'exec-workmesh-1'}) + if method == 'POST' and path.endswith('/start'): + raise RuntimeError('provider response lost after exec start') + raise AssertionError((method, path)) + + fake = FakePortainer() + with Session(db_session.engine) as session: + job = create_job( + session, + action_id='deployment.target.deploy_workmesh', + target='jens/workmesh', + source='chatgpt-mcp', + risk='high', + policy_decision='allowed', + steps=['fixed first deploy'], + ) + start_job(session, job) + service = WorkMeshFirstDeployment(portainer=fake) + + first = service.run(session, job=job) + second = service.run(session, job=job) + + assert first['status'] == 'outcome_unknown' + assert second['status'] == 'outcome_unknown' + assert service.tracked_job_id(session) == job.id + assert fake.create_calls == 1 + + +def test_workmesh_first_deploy_rejects_target_drift(api_client): + from backend.app.db import session as db_session + from backend.app.services import action_execution + + with Session(db_session.engine) as session: + result = action_execution.execute_governed_action( + session, + action='deployment.target.deploy_workmesh', + target='jens/not-workmesh', + source='chatgpt-mcp', + risk='high', + reason='Reject target drift.', + original_policy_decision='approval_required', + ) + + assert result['success'] is False + assert result['status'] == 'failed' + assert result['result'] == 'blocked' + + +@pytest.mark.parametrize('state_mode', ['missing', 'other_job']) +def test_running_workmesh_first_deploy_cannot_be_cancelled_via_http_or_mcp( + tmp_path, state_mode +): + from backend.app.api import routes_jobs + from backend.app.db.models import AppSetting, JobRun + from backend.app.services import mcp_operations + from backend.app.services.workmesh_first_deployment import _STATE_KEY + + engine = create_engine(f"sqlite:///{tmp_path / 'workmesh-cancel.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + job = JobRun( + id='job-workmesh-active', + type='deployment.target.deploy_workmesh', + status='running', + source='chatgpt-mcp', + action_id='deployment.target.deploy_workmesh', + target='jens/workmesh', + risk='high', + policy_decision='allowed', + ) + session.add(job) + if state_mode == 'other_job': + session.add(AppSetting( + key=_STATE_KEY, + value_json={'state': 'started', 'jobId': 'job-different'}, + is_secret=False, + )) + session.commit() + + http_result = routes_jobs.cancel(job.id, session=session) + mcp_result = mcp_operations.cancel_operation_job(session, job.id) + session.refresh(job) + + assert http_result['success'] is False + assert http_result['status'] == 'running' + assert 'cannot be cancelled' in http_result['message'] + assert mcp_result['cancelled'] is False + assert mcp_result['changed'] is False + assert mcp_result['status'] == 'running' + assert 'cannot be cancelled' in mcp_result['reason'] + assert job.status == 'running' + + +@pytest.fixture +def restart_service(monkeypatch): + from backend.app.services import container_restart_service + + monkeypatch.setattr( + container_restart_service.PortainerConnector, + 'get_container_status', + lambda self, container_name: {'status': 'ok', 'container': {'name': container_name, 'state': 'running'}}, + raising=True, + ) + + monkeypatch.setattr( + container_restart_service.PortainerConnector, + 'restart_container', + lambda self, target: {'status': 'ok', 'container': target, 'action': 'container.restart', 'target': target}, + raising=True, + ) + + monkeypatch.setattr( + container_restart_service, + '_widefrog_health_check', + lambda: {'status': 'ok'}, + raising=True, + ) + + return container_restart_service + + +def test_restart_manual_execute_succeeds(api_client, restart_service): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'widefrog', 'source': 'manual', 'risk': 'medium'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['status'] == 'success' + assert payload['policy_decision'] == 'allowed' + assert payload['result'] == 'success' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert isinstance(payload.get('steps', []), list) + assert isinstance(payload.get('post_checks', []), list) + assert isinstance(payload.get('before_state', {}), dict) + assert isinstance(payload.get('after_state', {}), dict) + assert payload['before_state'] + assert payload['after_state'] + + jobs = api_client.get('/api/jobs').json() + assert any(job['id'] == payload['jobId'] and job['status'] == 'succeeded' for job in jobs) + + +def test_restart_manual_endpoint_succeeds(api_client, restart_service): + response = api_client.post('/api/actions/container/widefrog/restart') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['status'] == 'success' + + +def test_action_plan_recognizes_check_unraid_phrase(api_client): + response = api_client.post( + '/api/actions/plan', + json={'input': 'check unraid', 'source': 'manual'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['intent'] == 'unraid.health_summary' + assert payload['target'] == 'unraid' + assert payload['policy'] == 'allowed' + assert payload['risk'] == 'low' + assert payload['plan'] + + +def test_restart_voice_endpoint_succeeds_with_valid_token(api_client, restart_service): + response = api_client.post( + '/api/voice/actions/container/widefrog/restart', + headers={'Authorization': 'Bearer test-voice-token'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['status'] == 'success' + + +def test_restart_voice_endpoint_rejects_missing_or_invalid_token(api_client, restart_service): + missing = api_client.post('/api/voice/actions/container/widefrog/restart') + assert missing.status_code == 401 + + wrong = api_client.post( + '/api/voice/actions/container/widefrog/restart', + headers={'Authorization': 'Bearer wrong-token'}, + ) + assert wrong.status_code == 403 + + +def test_voice_unraid_health_endpoint_rejects_missing_or_invalid_token(api_client, restart_service): + missing = api_client.post('/api/voice/actions/unraid/health') + assert missing.status_code == 401 + + wrong = api_client.post( + '/api/voice/actions/unraid/health', + headers={'Authorization': 'Bearer wrong-token'}, + ) + assert wrong.status_code == 403 + + +def test_voice_playbook_endpoint_rejects_missing_or_invalid_token(api_client, restart_service): + missing = api_client.post('/api/voice/actions/playbooks/fix-widefrog/run') + assert missing.status_code == 401 + + wrong = api_client.post( + '/api/voice/actions/playbooks/fix-widefrog/run', + headers={'Authorization': 'Bearer wrong-token'}, + ) + assert wrong.status_code == 403 + + +def test_voice_unraid_health_uses_explicit_readonly_policy(api_client, restart_service): + response = api_client.post( + '/api/voice/actions/unraid/health', + headers={'Authorization': 'Bearer test-voice-token'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['policy_decision'] == 'allowed' + assert payload['status'] == 'success' + assert isinstance(payload.get('data'), dict) + + +def test_voice_playbook_run_evaluates_policy(api_client, restart_service): + response = api_client.post( + '/api/voice/actions/playbooks/fix-widefrog/run', + headers={'Authorization': 'Bearer test-voice-token'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] in {'approval_required', 'blocked'} + assert payload['policy_decision'] in {'approval_required', 'blocked'} + assert payload['status'] in {'failed'} + + +def test_restart_requires_approval_for_authentik(api_client, restart_service): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'authentik', 'source': 'manual', 'risk': 'high'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'approval_required' + assert payload['approvalId'].startswith('appr-') + assert payload['auditId'].startswith('audit-') + assert payload['status'] == 'failed' + assert payload['result'] == 'approval_required' + + +def test_restart_fails_when_portainer_missing_config(api_client, monkeypatch): + from backend.app.services import container_restart_service + + monkeypatch.setattr( + container_restart_service.PortainerConnector, + 'get_container_status', + lambda self, container_name: {'status': 'missing_config', 'reason': 'missing_config'}, + raising=True, + ) + monkeypatch.setattr( + container_restart_service.PortainerConnector, + 'restart_container', + lambda self, target: {'status': 'missing_config', 'reason': 'missing_config'}, + raising=True, + ) + + response = api_client.post('/api/actions/container/widefrog/restart') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['message'] == 'missing_config' + assert payload['jobId'].startswith('job-') + assert payload['status'] == 'failed' + + +def test_restart_execute_fails_when_portainer_missing_config(api_client, monkeypatch): + from backend.app.services import container_restart_service + + monkeypatch.setattr( + container_restart_service.PortainerConnector, + 'get_container_status', + lambda self, container_name: {'status': 'missing_config', 'reason': 'missing_config'}, + raising=True, + ) + monkeypatch.setattr( + container_restart_service.PortainerConnector, + 'restart_container', + lambda self, target: {'status': 'missing_config', 'reason': 'missing_config'}, + raising=True, + ) + + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'widefrog', 'source': 'manual', 'risk': 'medium'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['message'] == 'missing_config' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert payload['status'] == 'failed' + assert payload['result'] == 'failed' + + +def test_restart_manual_endpoint_fails_when_portainer_missing_config(api_client): + response = api_client.post('/api/actions/container/widefrog/restart') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['message'] == 'missing_config' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert payload['status'] == 'failed' + + +def test_restart_voice_endpoint_fails_when_portainer_missing_config(api_client): + response = api_client.post( + '/api/voice/actions/container/widefrog/restart', + headers={'Authorization': 'Bearer test-voice-token'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['message'] == 'missing_config' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert payload['status'] == 'failed' + + +def test_security_policy_blocks_volume_delete(api_client, restart_service): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'volume.delete', 'target': 'postgres-data', 'source': 'manual', 'risk': 'critical', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'blocked' + assert payload['message'] == 'Action uses default governance policy.' + + +def test_action_registry_returns_policy_preview_without_secrets(api_client): + response = api_client.get('/api/actions/registry') + + assert response.status_code == 200 + payload = response.json() + assert payload['summary']['total'] >= 2 + assert isinstance(payload['items'], list) + + actions = {item['id']: item for item in payload['items']} + assert actions['safety.summary']['risk'] == 'low' + assert actions['safety.summary']['policyDecision'] == 'allowed' + assert actions['safety.summary']['capability'] == 'read_only' + + assert actions['volume.delete']['risk'] == 'critical' + assert actions['volume.delete']['policyDecision'] == 'blocked' + assert actions['volume.delete']['capability'] == 'destructive' + + serialized = str(payload).lower() + assert 'password' not in serialized + assert 'bearer ' not in serialized + assert 'test-voice-token' not in serialized + + +def test_action_registry_detail_is_read_only_and_policy_aware(api_client): + response = api_client.get('/api/actions/registry/volume.delete') + + assert response.status_code == 200 + payload = response.json() + assert payload['id'] == 'volume.delete' + assert payload['risk'] == 'critical' + assert payload['policyDecision'] == 'blocked' + assert payload['capability'] == 'destructive' + assert payload['executionSupported'] is False + assert any('blocked' in item.lower() for item in payload['safeguards']) + assert any('volume' in item.lower() for item in payload['blastRadius']) + assert 'password' not in str(payload).lower() + assert 'test-voice-token' not in str(payload).lower() + + +def test_action_lifecycle_v1_excluded_actions_are_blocked_and_visible(api_client): + response = api_client.get('/api/actions/registry') + + assert response.status_code == 200 + actions = {item['id']: item for item in response.json()['items']} + + for action_id in ['container.stop', 'container.clear_logs', 'gateway.clear_cache']: + assert action_id in actions + assert actions[action_id]['policyDecision'] == 'blocked' + assert actions[action_id]['capability'] == 'destructive' + + detail = api_client.get(f'/api/actions/registry/{action_id}').json() + assert detail['executionSupported'] is False + assert detail['policyDecision'] == 'blocked' + assert any('v1' in item.lower() or 'blocked' in item.lower() for item in detail['safeguards']) + assert 'v1' in detail['operatorGuidance'].lower() + + preflight = api_client.post('/api/actions/preflight', json={'action': action_id, 'target': actions[action_id]['target']}).json() + assert preflight['policyDecision'] == 'blocked' + assert preflight['rollbackAvailable'] is False + + +def test_action_registry_detail_rejects_unknown_action(api_client): + response = api_client.get('/api/actions/registry/not-a-real-action') + + assert response.status_code == 404 + + +def test_security_voice_blocks_postgres_restart(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'postgres', 'source': 'voice', 'risk': 'critical', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'blocked' + assert payload['status'] == 'failed' + + +def test_security_voice_blocks_redis_restart(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'redis', 'source': 'voice', 'risk': 'critical', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'blocked' + assert payload['status'] == 'failed' + + +def test_security_voice_blocks_unraid_mover_start(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'unraid.mover.start', 'target': 'unraid', 'source': 'voice', 'risk': 'high', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'blocked' + assert payload['status'] == 'failed' + + +def test_security_unraid_mover_start_requires_approval_manually(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'unraid.mover.start', 'target': 'unraid', 'source': 'manual', 'risk': 'high', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'approval_required' + assert payload['approvalId'].startswith('appr-') + assert payload['auditId'].startswith('audit-') + + +def test_security_stack_redeploy_requires_approval(api_client, restart_service): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'stack.redeploy', 'target': 'prod', 'source': 'manual', 'risk': 'high', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'approval_required' + assert payload['approvalId'].startswith('appr-') + + +def test_security_authentik_restart_requires_approval_for_voice(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'authentik', 'source': 'voice', 'risk': 'high', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] in {'approval_required', 'blocked'} + assert payload['status'] in {'failed'} + + +def test_security_portainer_restart_requires_approval_or_blocked_for_voice(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'portainer', 'source': 'voice', 'risk': 'high', 'plan': []}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] in {'approval_required', 'blocked'} + assert payload['status'] == 'failed' + + +def test_connector_test_reports_missing_config_for_portainer(api_client, monkeypatch): + prev = _with_updated_connectors( + portainer_url='', + portainer_key=None, + portainer_endpoint=None, + unraid_url=None, + unraid_key=None, + ) + response = api_client.post('/api/connectors/portainer/test') + payload = response.json() + + assert response.status_code == 200 + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['reason'] == 'missing_config' + assert payload['missing_config'] is True + assert payload['required_env_vars'] == ['PORTAINER_URL', 'PORTAINER_API_KEY', 'PORTAINER_ENDPOINT_ID'] + assert payload['secretRef'] == 'PORTAINER_API_KEY' + _restore_connectors(prev) + + +def test_connector_test_reports_missing_config_for_unraid(api_client): + prev = _with_updated_connectors( + portainer_url=None, + portainer_key=None, + portainer_endpoint=None, + unraid_url='', + unraid_key=None, + ) + response = api_client.post('/api/connectors/unraid/test') + payload = response.json() + + assert response.status_code == 200 + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['reason'] == 'missing_config' + assert payload['missing_config'] is True + assert payload['required_env_vars'] == ['UNRAID_URL', 'UNRAID_API_KEY'] + assert payload['secretRef'] == 'UNRAID_API_KEY' + _restore_connectors(prev) + + +def test_connector_error_redacts_portainer_secret_from_api_error(api_client, monkeypatch): + prev = _with_updated_connectors( + portainer_url='https://localhost:9443', + portainer_key='PORTA_SECRET_TOKEN', + portainer_endpoint='1', + ) + + def fail_request(*_args, **_kwargs): + raise RuntimeError('x-api-key=PORTA_SECRET_TOKEN') + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector._request', fail_request, raising=True) + + response = api_client.post('/api/connectors/portainer/test') + payload = response.json() + + assert response.status_code == 200 + assert payload['status'] in {'error', 'unreachable'} + reason = str(payload.get('reason')) + assert 'PORTA_SECRET_TOKEN' not in reason + assert '[REDACTED]' in reason + + _restore_connectors(prev) + + +def test_containers_endpoint_can_request_readonly_portainer_stats(api_client, monkeypatch): + from backend.app.api import routes_containers + + class FakePortainerConnector: + def list_containers(self, include_stats=False, stats_limit=200): + assert include_stats is True + assert stats_limit == 50 + return { + 'status': 'ok', + 'containers': [{ + 'id': 'abc123', + 'name': 'widefrog', + 'appId': 'widefrog', + 'state': 'running', + 'health': 'healthy', + 'image': 'widefrog:latest', + 'ports': [], + 'cpu': '12.3%', + 'ram': '256 MiB / 1 GiB', + 'metricsStatus': 'measured', + 'restartCount': 0, + 'criticality': 'medium', + 'voiceEnabled': False, + 'protected': False, + }], + } + + monkeypatch.setattr(routes_containers, 'PortainerConnector', FakePortainerConnector, raising=True) + + response = api_client.get('/api/containers?include_stats=true&stats_limit=50') + + assert response.status_code == 200 + payload = response.json() + assert payload[0]['cpu'] == '12.3%' + assert payload[0]['ram'] == '256 MiB / 1 GiB' + assert payload[0]['metricsStatus'] == 'measured' + + +def test_portainer_connector_maps_docker_stats_without_mutation(monkeypatch): + from backend.app.connectors.portainer import PortainerConnector + + prev = _with_updated_connectors( + portainer_url='https://portainer.local', + portainer_key='PORTA_SECRET_TOKEN', + portainer_endpoint='9', + ) + + class FakeResponse: + def __init__(self, payload): + self._payload = payload + self.text = '' + self.status_code = 200 + + def json(self): + return self._payload + + def fake_request(self, method, path, **_kwargs): + assert method == 'GET' + if path.endswith('/docker/containers/json?all=1'): + return FakeResponse([{ + 'Id': 'abc123', + 'Names': ['/widefrog'], + 'Image': 'widefrog:latest', + 'State': 'running', + 'Ports': [], + }]) + if path.endswith('/docker/containers/abc123/stats'): + return FakeResponse({ + 'cpu_stats': { + 'online_cpus': 4, + 'system_cpu_usage': 200_000_000, + 'cpu_usage': {'total_usage': 120_000_000}, + }, + 'precpu_stats': { + 'system_cpu_usage': 100_000_000, + 'cpu_usage': {'total_usage': 100_000_000}, + }, + 'memory_stats': { + 'usage': 268_435_456, + 'limit': 1_073_741_824, + }, + }) + raise AssertionError(f'unexpected path {path}') + + monkeypatch.setattr(PortainerConnector, '_request', fake_request, raising=True) + + result = PortainerConnector().list_containers(include_stats=True, stats_limit=10) + + assert result['status'] == 'ok' + assert result['containers'][0]['cpu'] == '80.0%' + assert result['containers'][0]['ram'] == '256 MiB / 1 GiB' + assert result['containers'][0]['metricsStatus'] == 'measured' + assert result['containers'][0]['metricsSource'] == 'portainer.docker.stats' + + _restore_connectors(prev) + + +def test_connector_error_redacts_unraid_secret_from_api_error(api_client, monkeypatch): + prev = _with_updated_connectors( + unraid_url='https://localhost:9444', + unraid_key='UNRAID_SECRET_TOKEN', + ) + + def fail_request(*_args, **_kwargs): + raise RuntimeError('api_key=UNRAID_SECRET_TOKEN') + + monkeypatch.setattr('backend.app.connectors.unraid.UnraidConnector._request', fail_request, raising=True) + + response = api_client.post('/api/connectors/unraid/test') + payload = response.json() + + assert response.status_code == 200 + assert payload['status'] in {'error', 'unreachable'} + reason = str(payload.get('reason')) + assert 'UNRAID_SECRET_TOKEN' not in reason + assert '[REDACTED]' in reason + + _restore_connectors(prev) + + +def test_action_execute_idempotency_replays_without_duplicate_execution(api_client): + payload = { + 'intent': 'container.restart', + 'target': 'missing-app', + 'source': 'manual', + 'plan': ['Resolve container', 'Restart container'], + } + headers = {'Idempotency-Key': 'restart-missing-app-1'} + + first = api_client.post('/api/actions/execute', json=payload, headers=headers) + replay = api_client.post('/api/actions/execute', json=payload, headers=headers) + + assert first.status_code == 200 + assert replay.status_code == 200 + assert replay.json()['idempotentReplay'] is True + assert replay.json()['auditId'] == first.json()['auditId'] + + conflict = api_client.post( + '/api/actions/execute', + json={**payload, 'target': 'different-app'}, + headers=headers, + ) + assert conflict.status_code == 409 + + +def test_ai_route_does_not_execute_actions(api_client): + response = api_client.post( + '/api/ai/chat', + json={'content': 'Herstart authentik nu meteen'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['id'].startswith('chat-') + assert payload['role'] == 'assistant' + assert 'success' not in payload + assert 'decision' not in payload + assert 'status' not in payload + assert 'policy_decision' not in payload + assert 'result' not in payload + + +def test_ai_route_does_not_create_jobs_or_approvals(api_client): + initial_jobs = api_client.get('/api/jobs').json() + initial_approvals = api_client.get('/api/approvals').json() + + response = api_client.post( + '/api/ai/chat', + json={'content': 'Herstart authentik nu meteen, maak een job aan en stuur approval aan'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['id'].startswith('chat-') + assert payload['role'] == 'assistant' + assert 'jobId' not in payload + assert 'approvalId' not in payload + assert 'auditId' not in payload + + jobs = api_client.get('/api/jobs').json() + approvals = api_client.get('/api/approvals').json() + assert jobs == initial_jobs + assert approvals == initial_approvals + + +def test_ai_history_does_not_return_fake_run_events(api_client): + response = api_client.get('/api/ai/history') + + assert response.status_code == 200 + payload = response.json() + assert payload == [] + + +def test_ai_chat_response_is_advisory_without_action_buttons(api_client): + response = api_client.post( + '/api/ai/chat', + json={'content': 'Herstart widefrog en voer de fix uit'}, + ) + + assert response.status_code == 200 + payload = response.json() + metadata = payload.get('metadata') or {} + assert 'suggestedActions' not in metadata + assert 'actionPlan' not in metadata + assert 'approvalId' not in payload + assert 'jobId' not in payload + + +def test_ai_status_returns_frontend_summary_contract(api_client): + response = api_client.get('/api/ai/status') + + assert response.status_code == 200 + payload = response.json() + assert set(payload) >= { + 'dailySummary', + 'currentRisks', + 'recommendedActions', + 'storageForecast', + 'securityScore', + 'autopilotStatus', + } + assert isinstance(payload['dailySummary'], str) + assert isinstance(payload['currentRisks'], int) + assert isinstance(payload['recommendedActions'], int) + assert isinstance(payload['storageForecast'], str) + assert isinstance(payload['securityScore'], int) + assert payload['autopilotStatus'] in {'active', 'paused', 'off'} + assert 'tools' not in payload + assert 'enabled' not in payload + + +def test_ai_recommendations_return_advisory_frontend_contract(api_client): + response = api_client.get('/api/ai/recommendations') + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + assert payload + + for recommendation in payload: + assert set(recommendation) >= { + 'id', + 'title', + 'severity', + 'confidence', + 'evidence', + 'affectedApps', + 'suggestedAction', + 'status', + } + assert recommendation['severity'] in {'low', 'medium', 'high', 'critical'} + assert isinstance(recommendation['confidence'], float) + assert recommendation['status'] in {'open', 'applied', 'snoozed', 'dismissed'} + assert 'actionPlan' not in recommendation + assert 'jobId' not in recommendation + assert 'approvalId' not in recommendation + assert 'auditId' not in recommendation + + +def test_playbooks_endpoint_returns_live_contract_shape(api_client): + response = api_client.get('/api/playbooks') + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + assert payload + + first = payload[0] + assert set(first) >= { + 'id', + 'name', + 'description', + 'risk', + 'allowedSources', + 'requiredConnectors', + 'steps', + 'successRate', + } + assert first['risk'] in {'low', 'medium', 'high', 'critical'} + assert all(source in {'manual', 'voice', 'automation', 'system'} for source in first['allowedSources']) + assert isinstance(first['requiredConnectors'], list) + assert isinstance(first['steps'], list) + assert isinstance(first['successRate'], int | float) + + +def test_playbooks_catalog_is_live_readonly_and_reflects_connector_readiness(api_client): + payload = api_client.get('/api/playbooks').json() + by_id = {item['id']: item for item in payload} + + expected_operational_playbooks = { + 'storage-pressure-review', + 'exposure-auth-coverage-review', + 'media-stalled-download-review', + 'unhealthy-container-triage', + 'app-inventory-normalization', + 'backup-readiness-check', + } + assert expected_operational_playbooks <= set(by_id) + assert len(payload) >= 8 + + assert 'connector-readiness-review' in by_id + connector_review = by_id['connector-readiness-review'] + assert connector_review['risk'] == 'medium' + assert connector_review['successRate'] < 100 + assert 'Deluge' in connector_review['requiredConnectors'] + assert any('degraded connector' in step.lower() for step in connector_review['steps']) + assert all('lastRuns' not in item for item in payload) + assert all('delete' not in ' '.join(item['steps']).lower() for item in payload) + + +def test_observability_endpoints_keep_frontend_contract_shape(api_client): + routes = { + '/api/incidents': ('id', 'severity', 'status', 'title', 'detectedTime', 'evidence', 'suggestedActions'), + '/api/healthchecks': ('service', 'status', 'checkedAt'), + '/api/network/exposure': ('source', 'host', 'port', 'protocol', 'protected', 'risk'), + } + + for path, required_keys in routes.items(): + response = api_client.get(path) + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + if path != '/api/network/exposure': + assert payload + if payload: + assert set(payload[0]) >= set(required_keys) + + statistics = api_client.get('/api/statistics/summary').json() + assert set(statistics) >= {'cpuHistory', 'ramHistory', 'restartsByApp', 'blockedActions', 'voiceActionCount'} + + storage = api_client.get('/api/storage/summary').json() + assert set(storage) >= {'cacheUsage', 'arrayUsage', 'shares', 'appDataUsage'} + + operations_map = api_client.get('/api/operations-map').json() + assert set(operations_map) >= {'nodes', 'edges'} + assert isinstance(operations_map['nodes'], list) + assert isinstance(operations_map['edges'], list) + + +def test_healthchecks_reflect_connector_readiness(api_client): + from sqlmodel import Session + + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.models import Connector + + settings.voice_token = "test-voice-token" + with Session(db_session.engine) as session: + portainer = session.get(Connector, "portainer") + unraid = session.get(Connector, "unraid") + home_assistant = session.get(Connector, "home_assistant") + assert portainer is not None + assert unraid is not None + assert home_assistant is not None + portainer.status = "connected" + unraid.status = "connected" + home_assistant.status = "missing_config" + session.add(portainer) + session.add(unraid) + session.add(home_assistant) + session.commit() + + response = api_client.get('/api/healthchecks') + + assert response.status_code == 200 + checks = {item['service']: item for item in response.json()} + assert checks['Portainer']['status'] == 'pass' + assert checks['Unraid']['status'] == 'pass' + assert checks['Voice Bridge']['status'] == 'warn' + assert all(not item['checkedAt'].startswith('2026-06-25') for item in checks.values()) + assert 'token' not in str(checks).lower() + + +def test_storage_summary_uses_readonly_connector_sources(api_client, monkeypatch): + monkeypatch.setattr( + 'backend.app.connectors.unraid.UnraidConnector.get_cache_status', + lambda self: {'status': 'ok', 'cacheUsage': 64}, + raising=True, + ) + monkeypatch.setattr( + 'backend.app.connectors.unraid.UnraidConnector.get_share_summary', + lambda self: { + 'status': 'ok', + 'shares': [ + {'name': 'appdata', 'usedGiB': 100, 'sizeGiB': 200, 'critical': False}, + {'name': 'media', 'usedGiB': 900, 'sizeGiB': 1000, 'critical': True}, + ], + }, + raising=True, + ) + monkeypatch.setattr( + 'backend.app.connectors.unraid.UnraidConnector.get_disk_warnings', + lambda self: { + 'status': 'ok', + 'diskWarnings': 0, + 'disks': [ + {'name': 'disk1', 'status': 'DISK_OK', 'healthy': True, 'usedGiB': 50, 'sizeGiB': 100, 'freeGiB': 50}, + ], + }, + raising=True, + ) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: { + 'status': 'ok', + 'containers': [ + {'name': 'widefrog', 'appId': 'widefrog', 'mounts': [{'source': '/srv/example/appdata/widefrog', 'destination': '/data', 'type': 'bind'}]}, + {'name': 'authentik-server', 'appId': 'authentik', 'mounts': [{'source': '/srv/example/appdata/authentik', 'destination': '/media', 'type': 'bind'}]}, + {'name': 'authentik-worker', 'appId': 'authentik', 'mounts': [{'source': '/srv/example/appdata/authentik-worker', 'destination': '/worker', 'type': 'bind'}]}, + ], + }, + raising=True, + ) + + payload = api_client.get('/api/storage/summary').json() + + assert payload['cacheUsage'] == 64 + assert payload['arrayUsage'] == 83 + assert payload['disks'][0]['name'] == 'disk1' + assert payload['shares'][1]['name'] == 'media' + assert len(payload['appDataUsage']) == 3 + assert payload['appDataUsage'][0]['usedGiB'] is None + assert payload['appDataUsage'][0]['share'] == 'appdata' + assert payload['orphanedVolumes'] == [] + assert payload['sources'] == {'unraid': 'ok', 'unraidDisks': 'ok', 'portainer': 'ok'} + + +def test_storage_summary_keeps_portainer_volume_hints_when_unraid_schema_partial(api_client, monkeypatch): + monkeypatch.setattr( + 'backend.app.connectors.unraid.UnraidConnector.get_cache_status', + lambda self: {'status': 'error', 'reason': 'GraphQL validation failed', 'cacheUsage': 0}, + raising=True, + ) + monkeypatch.setattr( + 'backend.app.connectors.unraid.UnraidConnector.get_share_summary', + lambda self: {'status': 'error', 'reason': 'Cannot query field shareSummary', 'shares': []}, + raising=True, + ) + monkeypatch.setattr( + 'backend.app.connectors.unraid.UnraidConnector.get_disk_warnings', + lambda self: {'status': 'error', 'reason': 'Cannot query field disks', 'disks': [], 'diskWarnings': 0}, + raising=True, + ) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: { + 'status': 'ok', + 'containers': [ + {'name': 'plex', 'appId': 'plex', 'mounts': [{'source': '/srv/example/appdata/plex', 'destination': '/config'}]}, + {'name': 'plex-worker', 'appId': 'plex', 'mounts': [{'source': '/srv/example/media', 'destination': '/media'}]}, + {'name': 'radarr', 'appId': 'radarr', 'mounts': [{'source': '/srv/example/appdata/radarr', 'destination': '/config'}]}, + ], + }, + raising=True, + ) + + payload = api_client.get('/api/storage/summary').json() + + assert payload['sources']['unraid'] == 'error' + assert payload['sources']['unraidDisks'] == 'error' + assert payload['sources']['portainer'] == 'ok' + assert payload['sourceWarnings'][0]['source'] == 'unraid' + assert payload['shares'] == [] + assert len(payload['appDataUsage']) == 3 + assert payload['appDataUsage'][0]['volume'] == '/srv/example/appdata/plex -> /config' + assert payload['appDataUsage'][0]['usedGiB'] is None + + +def test_safety_summary_partial_when_key_sources_missing_without_critical_evidence(api_client): + payload = api_client.get('/api/safety/summary').json() + + assert payload['status'] in {'partial', 'warning'} + assert payload['criticalCount'] == 0 + assert payload['score'] >= 0 + assert payload['dataCompleteness']['percentage'] < 100 + assert {'npm', 'authentik', 'unraid'} & set(payload['missingSources']) + assert len(payload['sections']) == 9 + assert all(section['status'] != 'critical' for section in payload['sections'] if section['id'] in {'external-exposure', 'authentication-coverage', 'unraid-host-safety'}) + + +def test_safety_routes_return_center_payloads_and_no_secret_values(api_client): + summary = api_client.get('/api/safety/summary').json() + sections = api_client.get('/api/safety/sections').json() + findings = api_client.get('/api/safety/findings').json() + recommendations = api_client.get('/api/safety/recommendations').json() + breakdown = api_client.get('/api/safety/score-breakdown').json() + + assert summary['recommendationCount'] == len(recommendations) + assert len(sections) == 9 + assert isinstance(findings, list) + assert breakdown['baseScore'] == 100 + assert breakdown['finalScore'] == summary['score'] + assert all(recommendation['executable'] is False for recommendation in recommendations) + + serialized = str({'summary': summary, 'sections': sections, 'findings': findings, 'recommendations': recommendations, 'breakdown': breakdown}) + assert 'Bearer ' not in serialized + assert 'test-voice-token' not in serialized + assert 'TOKEN_VALUE' not in serialized + + +def test_safety_section_completeness_counts_partial_sources(): + from backend.app.services import safety_center_service + + findings = [{ + 'id': 'ai-sec-media-pipeline-issue', + 'title': 'Media pipeline needs review', + 'description': 'Media pipeline has items requiring attention.', + 'severity': 'medium', + 'category': 'media', + 'affectedResources': ['media_pipeline'], + 'evidence': [], + 'suggestedActions': [], + 'source': 'AI Security Reviewer', + 'status': 'open', + }] + coverage = {'media_pipeline': {'status': 'partial', 'label': 'Media Pipeline', 'effectOnConfidence': 'medium'}} + + sections = safety_center_service._sections(findings, [], coverage) + media = next(section for section in sections if section['id'] == 'media-pipeline') + + assert media['status'] == 'partial' + assert media['dataCompleteness'] == 50 + + +def test_safety_dashboard_returns_combined_cached_payload(api_client, monkeypatch): + from backend.app.services import safety_center_service + + safety_center_service.invalidate_cache() + calls = {'count': 0} + original_generate_review = safety_center_service.ai_security_reviewer.generate_review + + def counted_generate_review(session): + calls['count'] += 1 + return original_generate_review(session) + + monkeypatch.setattr(safety_center_service.ai_security_reviewer, 'generate_review', counted_generate_review) + + first = api_client.get('/api/safety/dashboard').json() + second = api_client.get('/api/safety/dashboard').json() + + assert first['summary']['score'] == first['scoreBreakdown']['finalScore'] + assert first['summary']['recommendationCount'] == len(first['recommendations']) + assert len(first['sections']) == 9 + assert isinstance(first['coverageSources'], list) + assert first['cache']['hit'] is False + assert first['cache']['source'] == 'fresh' + assert first['cache']['ttlSeconds'] >= 1 + assert first['cache']['generationDurationMs'] >= 0 + assert second['cache']['hit'] is True + assert second['cache']['source'] == 'cache' + assert second['summary']['score'] == first['summary']['score'] + assert calls['count'] == 1 + + +def test_safety_dashboard_reuses_persistent_cache_after_process_cache_loss(api_client, monkeypatch): + from backend.app.services import safety_center_service + + safety_center_service.invalidate_cache() + first = api_client.get('/api/safety/dashboard').json() + + safety_center_service._dashboard_cache = None + safety_center_service._skip_persistent_cache_once = False + + def fail_if_regenerated(session): + raise AssertionError('persistent safety cache should avoid cold regeneration') + + monkeypatch.setattr(safety_center_service.ai_security_reviewer, 'generate_review', fail_if_regenerated) + + second = api_client.get('/api/safety/dashboard').json() + + assert second['cache']['hit'] is True + assert second['cache']['source'] in {'cache', 'stale'} + assert second['summary']['score'] == first['summary']['score'] + + +def test_safety_dashboard_refreshes_expired_persistent_cache(api_client, monkeypatch): + from datetime import datetime, timedelta, timezone + from backend.app.services import safety_center_service + + safety_center_service.invalidate_cache() + first = api_client.get('/api/safety/dashboard').json() + persisted_payload = { + key: first[key] + for key in ('summary', 'sections', 'findings', 'recommendations', 'scoreBreakdown') + } + expired = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat().replace('+00:00', 'Z') + safety_center_service._dashboard_cache = None + safety_center_service._skip_persistent_cache_once = False + monkeypatch.setattr( + safety_center_service, + '_persistent_cache', + lambda session, signature: { + 'key': safety_center_service.CACHE_KEY, + 'signature': signature, + 'payload': persisted_payload, + 'generatedAt': first['summary']['generatedAt'], + 'expiresAt': expired, + 'generationDurationMs': 12, + }, + ) + calls = {'scheduled': 0} + monkeypatch.setattr( + safety_center_service, + '_schedule_background_refresh', + lambda: calls.__setitem__('scheduled', calls['scheduled'] + 1) or True, + ) + + second = api_client.get('/api/safety/dashboard').json() + + assert calls['scheduled'] == 1 + assert second['cache']['source'] == 'stale-while-revalidate' + assert second['cache']['hit'] is True + + +def test_safety_refresh_invalidates_dashboard_cache(api_client): + from backend.app.services import safety_center_service + + safety_center_service.invalidate_cache() + + first = api_client.get('/api/safety/dashboard').json() + second = api_client.get('/api/safety/dashboard').json() + refreshed = api_client.post('/api/safety/refresh').json() + after_refresh = api_client.get('/api/safety/dashboard').json() + + assert first['cache']['source'] == 'fresh' + assert second['cache']['hit'] is True + assert refreshed['cache']['hit'] is False + assert refreshed['cache']['source'] == 'fresh' + assert after_refresh['cache']['hit'] is True + assert refreshed['summary']['score'] == after_refresh['summary']['score'] + + +def test_setup_connector_save_invalidates_safety_cache(api_client, monkeypatch): + from backend.app.services import safety_center_service + + original_invalidate = safety_center_service.invalidate_cache + calls = {'count': 0} + + def counted_invalidate(): + calls['count'] += 1 + original_invalidate() + + monkeypatch.setattr(safety_center_service, 'invalidate_cache', counted_invalidate) + + payload = api_client.patch( + '/api/settings/connectors/unraid', + json={'url': 'http://198.18.10.150', 'apiKey': 'test-unraid-key'}, + ).json() + + assert payload['connectorId'] == 'unraid' + assert calls['count'] >= 1 + + +def test_app_mapping_invalidates_safety_cache(api_client, monkeypatch): + from backend.app.db.models import App + from backend.app.db.session import engine + from backend.app.services import safety_center_service + from sqlmodel import Session + + with Session(engine) as session: + session.add(App(id='mapping-test', name='Mapping Test', category='Test')) + session.commit() + + original_invalidate = safety_center_service.invalidate_cache + calls = {'count': 0} + + def counted_invalidate(): + calls['count'] += 1 + original_invalidate() + + monkeypatch.setattr(safety_center_service, 'invalidate_cache', counted_invalidate) + + payload = api_client.patch( + '/api/apps/mapping-test/container-mapping', + json={'containerId': 'container-123', 'containerName': 'mapping-test'}, + ).json() + + assert payload['success'] is True + assert payload['message'] == 'mapping_updated' + assert calls['count'] >= 1 + + +def test_safety_legacy_endpoints_share_dashboard_snapshot(api_client): + from backend.app.services import safety_center_service + + safety_center_service.invalidate_cache() + + dashboard = api_client.get('/api/safety/dashboard').json() + summary = api_client.get('/api/safety/summary').json() + sections = api_client.get('/api/safety/sections').json() + findings = api_client.get('/api/safety/findings').json() + recommendations = api_client.get('/api/safety/recommendations').json() + breakdown = api_client.get('/api/safety/score-breakdown').json() + + assert summary == dashboard['summary'] + assert sections == dashboard['sections'] + assert findings == dashboard['findings'] + assert recommendations == dashboard['recommendations'] + assert breakdown == dashboard['scoreBreakdown'] + + +def test_safety_summary_critical_when_destructive_policy_is_not_blocked(api_client): + from backend.app.db.models import Policy + from backend.app.db.session import engine + from sqlmodel import Session + + with Session(engine) as session: + policy = session.get(Policy, 'policy-volume-delete') + policy.manual = 'allowed' + session.add(policy) + session.commit() + + payload = api_client.get('/api/safety/summary').json() + findings = api_client.get('/api/safety/findings').json() + + assert payload['status'] == 'critical' + assert payload['criticalCount'] >= 1 + assert any(finding['id'] == 'ai-sec-policy-volume-delete-not-blocked' and finding['severity'] == 'critical' for finding in findings) + + +def test_safety_summary_high_inventory_review_is_warning_not_global_critical(api_client): + from backend.app.db.models import App + from backend.app.db.session import engine + from backend.app.services import safety_center_service + from sqlmodel import Session + + safety_center_service.invalidate_cache() + with Session(engine) as session: + session.add(App( + id='critical-review-app', + name='Critical Review App', + category='Infrastructure', + criticality='critical', + protected=False, + needs_review=True, + discovery_status='managed', + connector_status='connected', + container_status='running', + )) + session.commit() + + payload = api_client.get('/api/safety/summary').json() + findings = api_client.get('/api/safety/findings').json() + + assert payload['criticalCount'] == 0 + assert payload['highCount'] >= 1 + assert payload['status'] != 'critical' + assert any(finding['category'] == 'inventory' and finding['severity'] == 'high' for finding in findings) + + +def test_safety_status_high_auth_exposure_without_critical_finding_is_warning(): + from backend.app.services import safety_center_service + from collections import Counter + + findings = [ + { + 'id': 'ai-sec-auth-public-host-without-authentik', + 'severity': 'high', + 'category': 'auth', + 'status': 'open', + }, + { + 'id': 'ai-sec-npm-host-without-ssl-force', + 'severity': 'medium', + 'category': 'exposure', + 'status': 'open', + }, + ] + + assert safety_center_service._status(46, Counter({'high': 1, 'medium': 1}), [], findings) == 'warning' + + +def test_safety_single_app_review_recommendation_links_to_app_detail(api_client): + from backend.app.db.models import App + from backend.app.db.session import engine + from backend.app.services import safety_center_service + from sqlmodel import Session, select + + safety_center_service.invalidate_cache() + with Session(engine) as session: + for app in session.exec(select(App)).all(): + app.needs_review = False + session.add(app) + app = session.get(App, 'radarr') + if not app: + app = App(id='radarr', name='Radarr', category='Media') + app.needs_review = True + app.criticality = 'medium' + session.add(app) + session.commit() + + recommendations = api_client.get('/api/safety/recommendations').json() + review = next(item for item in recommendations if item['id'] == 'safety-rec-safety-apps-need-review') + + assert review['href'] == '/apps/radarr' + + +def test_safety_recommendations_are_non_executing_and_remediation_link_creates_no_jobs(api_client): + recommendations = api_client.get('/api/safety/recommendations').json() + assert recommendations + assert all(recommendation['executable'] is False for recommendation in recommendations) + + before_jobs = api_client.get('/api/jobs').json() + response = api_client.post(f"/api/safety/recommendations/{recommendations[0]['id']}/create-remediation-plan") + after_jobs = api_client.get('/api/jobs').json() + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['executable'] is False + assert payload['href'].startswith('/ai/remediation-planner') + assert before_jobs == after_jobs + + +def test_safety_finding_acknowledge_and_dismiss_are_safety_state_only(api_client): + finding = api_client.get('/api/safety/findings').json()[0] + + acknowledged = api_client.post(f"/api/safety/findings/{finding['id']}/acknowledge").json() + dismissed = api_client.post(f"/api/safety/findings/{finding['id']}/dismiss").json() + + assert acknowledged['success'] is True + assert acknowledged['status'] == 'acknowledged' + assert dismissed['success'] is True + assert dismissed['status'] == 'dismissed' + + +def test_incidents_are_derived_from_live_safety_sources(api_client): + payload = api_client.get('/api/incidents').json() + by_id = {item['id']: item for item in payload} + + assert 'incident-connectors-config' in by_id + assert by_id['incident-connectors-config']['status'] == 'open' + assert by_id['incident-connectors-config']['severity'] in {'medium', 'high'} + assert by_id['incident-connectors-config']['evidence']['source'] == 'Connector Registry' + assert 'connector' in by_id['incident-connectors-config']['title'].lower() + assert 'i1' not in by_id + assert all('503 Service Unavailable' not in str(item) for item in payload) + + +def test_incident_status_lifecycle_is_persistent_and_affects_summary(api_client): + incident = api_client.get('/api/incidents').json()[0] + + acknowledged = api_client.post(f"/api/incidents/{incident['id']}/status", json={'status': 'acknowledged'}).json() + after_ack = {item['id']: item for item in api_client.get('/api/incidents').json()} + summary_after_ack = api_client.get('/api/system/summary').json() + + assert acknowledged['success'] is True + assert after_ack[incident['id']]['status'] == 'acknowledged' + assert summary_after_ack['openIncidents'] == len([item for item in after_ack.values() if item['status'] == 'open']) + + resolved = api_client.post(f"/api/incidents/{incident['id']}/status", json={'status': 'resolved'}).json() + after_resolve = {item['id']: item for item in api_client.get('/api/incidents').json()} + + assert resolved['success'] is True + assert incident['id'] not in after_resolve + + +def test_unknown_incident_status_update_fails_closed(api_client): + payload = api_client.post('/api/incidents/not-a-real-incident/status', json={'status': 'resolved'}).json() + + assert payload['success'] is False + assert payload['status'] == 'failed' + assert payload['message'] == 'incident_not_found' + + +def test_operations_map_uses_live_app_connector_and_policy_sources(api_client): + payload = api_client.get('/api/operations-map').json() + + assert 'Connector Registry' in payload['nodes'] + assert 'Policy Engine' in payload['nodes'] + assert 'Secret Store' in payload['nodes'] + assert 'Widefrog' in payload['nodes'] + assert 'Plex' in payload['nodes'] + assert 'Jellyfin' not in payload['nodes'] + + edges = {(edge['from'], edge['to']) for edge in payload['edges']} + assert ('Connector Registry', 'Widefrog') in edges + assert ('Connector Registry', 'Plex') in edges + assert ('Policy Engine', 'Widefrog') in edges + assert ('Secret Store', 'Connector Registry') in edges + assert ('Widefrog', 'Home Assistant') not in edges + + assert payload['generatedAt'].endswith('Z') + assert payload['sources'] == { + 'apps': {'status': 'ok', 'count': len(api_client.get('/api/apps').json())}, + 'connectors': {'status': 'ok', 'count': len(api_client.get('/api/connectors').json())}, + 'policies': {'status': 'ok', 'count': len(api_client.get('/api/policies').json())}, + 'secrets': {'status': 'metadata_only', 'count': 0}, + } + + +def test_system_summary_counts_live_open_incidents(api_client): + incidents = api_client.get('/api/incidents').json() + summary = api_client.get('/api/system/summary').json() + + open_incidents = [incident for incident in incidents if incident['status'] == 'open'] + assert open_incidents + assert summary['openIncidents'] == len(open_incidents) + + +def test_container_logs_are_redacted_before_returning_to_ui(api_client, monkeypatch): + secret = 'PORTAINER_LOG_SECRET' + + def fake_logs(self, container_name): + return { + 'status': 'ok', + 'container': container_name, + 'logs': [ + f'Authorization: Bearer {secret}', + f'api_key={secret}', + 'normal startup line', + ], + } + + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.get_container_logs', + fake_logs, + raising=True, + ) + + response = api_client.get('/api/containers/widefrog/logs') + + assert response.status_code == 200 + payload = response.json() + assert payload['container'] == 'widefrog' + assert secret not in str(payload) + assert '[REDACTED]' in str(payload) + assert 'normal startup line' in payload['logs'] + + +def test_portainer_read_routes_report_missing_config_instead_of_empty_data(api_client, monkeypatch): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis['PORTAINER_URL'] = '' + settings.apis.pop('PORTAINER_API_KEY', None) + settings.apis.pop('PORTAINER_ENDPOINT_ID', None) + + containers = api_client.get('/api/containers') + stacks = api_client.get('/api/stacks') + + assert containers.status_code == 503 + assert containers.json()['detail']['status'] == 'missing_config' + assert containers.json()['detail']['reason'] == 'missing_config' + assert stacks.status_code == 503 + assert stacks.json()['detail']['status'] == 'missing_config' + assert stacks.json()['detail']['reason'] == 'missing_config' + + settings.apis.clear() + settings.apis.update(previous) + + +def test_portainer_container_mapping_adds_safe_ui_deep_link_without_secret(): + from backend.app.connectors.portainer import PortainerConnector, _ConnectorConfig + + config = _ConnectorConfig('https://portainer.example.local/', 'SHOULD_NOT_LEAK', '7', True) + + payload = PortainerConnector._map_container( + { + 'Id': 'abc123', + 'Names': ['/widefrog'], + 'Image': 'widefrog/app:latest', + 'State': 'running', + 'Ports': [], + }, + config, + ) + + assert payload['portainerUrl'] == 'https://portainer.example.local/#!/7/docker/containers/abc123' + assert config.api_key not in str(payload) + + +def test_portainer_container_mapping_omits_deep_link_for_invalid_base_url(): + from backend.app.connectors.portainer import PortainerConnector, _ConnectorConfig + + config = _ConnectorConfig('javascript:alert(1)', 'SHOULD_NOT_LEAK', '7', True) + + payload = PortainerConnector._map_container( + { + 'Id': 'abc123', + 'Names': ['/widefrog'], + 'Image': 'widefrog/app:latest', + 'State': 'running', + 'Ports': [], + }, + config, + ) + + assert payload['portainerUrl'] is None + assert config.api_key not in str(payload) + + +def test_ai_memory_create_redacts_secret_values(api_client): + secret = 'AI_MEMORY_SECRET' + + response = api_client.post( + '/api/ai/memory', + json={ + 'key': 'connector-token-note', + 'value': f'api_key={secret}', + 'category': 'known_issue', + 'pinned': True, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert secret not in str(payload) + assert '[REDACTED]' in str(payload) + + +def test_ai_tools_return_governed_tool_catalog(api_client): + response = api_client.get('/api/ai/tools') + + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + assert payload + + forbidden_tools = {'run_shell', 'delete_volume', 'show_secret', 'docker_prune'} + returned_ids = {tool['id'] for tool in payload} + returned_names = {tool['name'] for tool in payload} + assert forbidden_tools.isdisjoint(returned_ids) + assert forbidden_tools.isdisjoint(returned_names) + + for tool in payload: + assert set(tool) >= { + 'id', + 'name', + 'description', + 'type', + 'permissionLevel', + 'requiresApproval', + 'rateLimit', + 'tool', + 'enabled', + 'allowedByDefault', + 'scope', + } + assert tool['type'] in {'read', 'mutate'} + assert tool['scope'] in {'read', 'mutate'} + if tool['type'] == 'mutate': + assert tool['requiresApproval'] is True + assert tool['allowedByDefault'] is False + + +def test_sonarr_connector_missing_config_reports_required_env(api_client): + response = api_client.post('/api/connectors/sonarr/test') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['missing_config'] is True + assert payload['required_env_vars'] == ['SONARR_URL', 'SONARR_API_KEY'] + assert payload['secretRef'] == 'SONARR_API_KEY' + + +def test_sonarr_connector_error_redacts_api_key(api_client, monkeypatch): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis['SONARR_URL'] = 'https://sonarr.local' + settings.apis['SONARR_API_KEY'] = 'SONARR_SECRET_TOKEN' + + def fail_request(*_args, **_kwargs): + raise RuntimeError('X-Api-Key=SONARR_SECRET_TOKEN') + + monkeypatch.setattr('backend.app.connectors.sonarr.SonarrConnector._request', fail_request, raising=True) + + response = api_client.post('/api/connectors/sonarr/test') + payload = response.json() + + assert response.status_code == 200 + assert payload['status'] == 'error' + assert 'SONARR_SECRET_TOKEN' not in str(payload) + assert '[REDACTED]' in str(payload) + + settings.apis.clear() + settings.apis.update(previous) + + +def test_sonarr_search_series_normalizes_lookup_response(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.sonarr import SonarrConnector + + previous = dict(settings.apis) + settings.apis['SONARR_URL'] = 'https://sonarr.local' + settings.apis['SONARR_API_KEY'] = 'SONARR_SECRET_TOKEN' + + def fake_request(self, method, path, **kwargs): + assert method == 'GET' + assert path == '/api/v3/series/lookup' + assert kwargs['params']['term'] == 'Foundation' + return [ + { + 'id': 12, + 'tvdbId': 366972, + 'title': 'Foundation', + 'year': 2021, + 'overview': 'A sci-fi series.', + 'status': 'continuing', + 'remotePoster': 'https://image.example/poster.jpg', + 'monitored': True, + 'seasons': [{'seasonNumber': 1}], + } + ] + + monkeypatch.setattr(SonarrConnector, '_request', fake_request, raising=True) + + result = SonarrConnector().search_series('Foundation') + + assert result['success'] is True + assert result['status'] == 'connected' + assert result['items'][0] == { + 'id': 12, + 'tvdbId': 366972, + 'title': 'Foundation', + 'year': 2021, + 'overview': 'A sci-fi series.', + 'status': 'continuing', + 'remotePoster': 'https://image.example/poster.jpg', + 'existing': False, + 'monitored': True, + 'seasons': [{'seasonNumber': 1}], + } + + settings.apis.clear() + settings.apis.update(previous) + + +def test_sonarr_queue_status_normalizes_counts(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.sonarr import SonarrConnector + + previous = dict(settings.apis) + settings.apis['SONARR_URL'] = 'https://sonarr.local' + settings.apis['SONARR_API_KEY'] = 'SONARR_SECRET_TOKEN' + + def fake_request(self, method, path, **_kwargs): + assert method == 'GET' + assert path == '/api/v3/queue' + return { + 'records': [ + {'id': 1, 'status': 'downloading', 'trackedDownloadStatus': 'ok', 'title': 'Episode 1'}, + {'id': 2, 'status': 'queued', 'trackedDownloadStatus': 'warning', 'title': 'Episode 2'}, + {'id': 3, 'status': 'completed', 'trackedDownloadStatus': 'error', 'title': 'Episode 3'}, + {'id': 4, 'status': 'stalled', 'trackedDownloadStatus': 'warning', 'title': 'Episode 4'}, + ], + 'totalRecords': 4, + } + + monkeypatch.setattr(SonarrConnector, '_request', fake_request, raising=True) + + result = SonarrConnector().queue_status() + + assert result['success'] is True + assert result['total'] == 4 + assert result['downloading'] == 1 + assert result['queued'] == 1 + assert result['failed'] == 1 + assert result['stalled'] == 1 + assert len(result['items']) == 4 + + settings.apis.clear() + settings.apis.update(previous) + + +def test_sonarr_add_series_policy_allowed_creates_job_and_audit(api_client, monkeypatch): + def fake_add_series(self, payload): + return { + 'success': True, + 'status': 'connected', + 'message': 'series_added', + 'data': {'id': 42, 'title': payload['title'], 'tvdbId': payload['tvdbId']}, + } + + monkeypatch.setattr('backend.app.connectors.sonarr.SonarrConnector.add_series', fake_add_series, raising=True) + + response = api_client.post( + '/api/apps/sonarr/series', + json={'title': 'Foundation', 'tvdbId': 366972, 'rootFolderPath': '/tv', 'qualityProfileId': 1}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['status'] == 'success' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert payload['data']['tvdbId'] == 366972 + + +def test_sonarr_add_series_missing_config_fails_without_fake_success(api_client): + response = api_client.post( + '/api/apps/sonarr/series', + json={'title': 'Foundation', 'tvdbId': 366972}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['status'] == 'failed' + assert payload['message'] == 'missing_config' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + + +def test_sonarr_destructive_actions_are_not_executable(api_client): + delete_response = api_client.post('/api/apps/sonarr/series/delete', json={'id': 42}) + mass_response = api_client.post('/api/apps/sonarr/mass-edit', json={'ids': [42]}) + + assert delete_response.status_code in {404, 405} + assert mass_response.status_code in {404, 405} + + policy_delete = api_client.post( + '/api/policies/evaluate', + json={'action': 'sonarr.delete_series', 'target': 'sonarr', 'source': 'manual'}, + ).json() + policy_mass = api_client.post( + '/api/policies/evaluate', + json={'action': 'sonarr.mass_edit', 'target': 'sonarr', 'source': 'manual'}, + ).json() + assert policy_delete['decision'] == 'blocked' + assert policy_mass['decision'] == 'blocked' + + +def test_radarr_connector_missing_config_reports_required_env(api_client): + response = api_client.post('/api/connectors/radarr/test') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['missing_config'] is True + assert payload['required_env_vars'] == ['RADARR_URL', 'RADARR_API_KEY'] + assert payload['secretRef'] == 'RADARR_API_KEY' + + +def test_radarr_connector_error_redacts_api_key(api_client, monkeypatch): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' + + def fail_request(*_args, **_kwargs): + raise RuntimeError('X-Api-Key=RADARR_SECRET_TOKEN') + + monkeypatch.setattr('backend.app.connectors.radarr.RadarrConnector._request', fail_request, raising=True) + + response = api_client.post('/api/connectors/radarr/test') + payload = response.json() + + assert response.status_code == 200 + assert payload['status'] in {'error', 'unreachable'} + assert 'RADARR_SECRET_TOKEN' not in str(payload) + assert '[REDACTED]' in str(payload) + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_connector_uses_radarr_tls_and_timeout_settings(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.radarr import RadarrConnector + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' + settings.apis['RADARR_VERIFY_TLS'] = True + settings.apis['RADARR_TIMEOUT_SECONDS'] = '12.5' + captured = {} + + class FakeResponse: + status_code = 200 + text = '{"version":"5.0.0"}' + + def json(self): + return {'version': '5.0.0'} + + class FakeClient: + def __init__(self, *, timeout, verify): + captured['timeout'] = timeout + captured['verify'] = verify + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def request(self, **kwargs): + captured['headers'] = kwargs['headers'] + return FakeResponse() + + monkeypatch.setattr('backend.app.connectors.radarr.httpx.Client', FakeClient, raising=True) + + result = RadarrConnector().test_connection() + + assert result['status'] == 'connected' + assert captured['timeout'] == 12.5 + assert captured['verify'] is True + assert captured['headers']['X-Api-Key'] == 'RADARR_SECRET_TOKEN' + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_seed_status_requires_url_and_api_key(monkeypatch): + from backend.app.core.config import settings + from backend.app.db.init_db import _seed_connectors + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = None + + radarr = next(connector for connector in _seed_connectors() if connector.id == 'radarr') + + assert radarr.status == 'disconnected' + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_search_movie_normalizes_lookup_response(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.radarr import RadarrConnector + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' + + def fake_request(self, method, path, **kwargs): + assert method == 'GET' + assert path == '/api/v3/movie/lookup' + assert kwargs['params']['term'] == 'Arrival' + return [ + { + 'id': 18, + 'tmdbId': 329865, + 'imdbId': 'tt2543164', + 'title': 'Arrival', + 'year': 2016, + 'overview': 'A linguist works with an alien language.', + 'status': 'released', + 'remotePoster': 'https://image.example/arrival.jpg', + 'monitored': True, + 'minimumAvailability': 'released', + } + ] + + monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) + + result = RadarrConnector().search_movie('Arrival') + + assert result['success'] is True + assert result['status'] == 'connected' + assert result['items'][0] == { + 'id': 18, + 'tmdbId': 329865, + 'imdbId': 'tt2543164', + 'title': 'Arrival', + 'year': 2016, + 'overview': 'A linguist works with an alien language.', + 'status': 'released', + 'remotePoster': 'https://image.example/arrival.jpg', + 'existing': False, + 'monitored': True, + 'minimumAvailability': 'released', + } + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_queue_status_normalizes_counts(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.radarr import RadarrConnector + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' + + def fake_request(self, method, path, **_kwargs): + assert method == 'GET' + assert path == '/api/v3/queue' + return { + 'records': [ + {'id': 1, 'status': 'downloading', 'trackedDownloadStatus': 'ok', 'title': 'Movie 1'}, + {'id': 2, 'status': 'queued', 'trackedDownloadStatus': 'warning', 'title': 'Movie 2'}, + {'id': 3, 'status': 'completed', 'trackedDownloadStatus': 'error', 'title': 'Movie 3'}, + {'id': 4, 'status': 'stalled', 'trackedDownloadStatus': 'warning', 'title': 'Movie 4'}, + ], + 'totalRecords': 4, + } + + monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) + + result = RadarrConnector().queue_status() + + assert result['success'] is True + assert result['status'] == 'ok' + assert result['total'] == 4 + assert result['downloading'] == 1 + assert result['queued'] == 1 + assert result['failed'] == 1 + assert result['stalled'] == 1 + assert len(result['items']) == 4 + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_queue_unavailable_on_connector_error(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.radarr import RadarrConnector + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' + + def fake_request(self, method, path, **_kwargs): + raise RuntimeError('connection refused') + + monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) + + result = RadarrConnector().queue_status() + + assert result['success'] is False + assert result['status'] == 'unavailable' + assert result['total'] == 0 + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_wanted_missing_normalizes_records(monkeypatch): + from backend.app.core.config import settings + from backend.app.connectors.radarr import RadarrConnector + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = 'https://radarr.local' + settings.apis['RADARR_API_KEY'] = 'RADARR_SECRET_TOKEN' + + def fake_request(self, method, path, **kwargs): + assert method == 'GET' + assert path == '/api/v3/wanted/missing' + assert kwargs['params'] == {'page': 1, 'pageSize': 20} + return {'records': [{'title': 'Missing Movie', 'tmdbId': 123}], 'totalRecords': 1} + + monkeypatch.setattr(RadarrConnector, '_request', fake_request, raising=True) + + result = RadarrConnector().wanted_missing() + + assert result['success'] is True + assert result['status'] == 'connected' + assert result['total'] == 1 + assert result['items'] == [{'title': 'Missing Movie', 'tmdbId': 123}] + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_add_movie_policy_allowed_creates_job_and_audit(api_client, monkeypatch): + def fake_add_movie(self, payload): + return { + 'success': True, + 'status': 'connected', + 'message': 'movie_added', + 'data': {'id': 99, 'title': payload['title'], 'tmdbId': payload['tmdbId']}, + } + + monkeypatch.setattr('backend.app.connectors.radarr.RadarrConnector.add_movie', fake_add_movie, raising=True) + + response = api_client.post( + '/api/apps/radarr/movie', + json={'title': 'Arrival', 'tmdbId': 329865, 'rootFolderPath': '/movies', 'qualityProfileId': 1}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['status'] == 'success' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert payload['data']['tmdbId'] == 329865 + + +def test_radarr_add_movie_missing_config_fails_without_fake_success(api_client): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis['RADARR_URL'] = None + settings.apis['RADARR_API_KEY'] = None + + response = api_client.post('/api/apps/radarr/movie', json={'title': 'Arrival', 'tmdbId': 329865}) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['status'] == 'failed' + assert payload['message'] == 'missing_config' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + + settings.apis.clear() + settings.apis.update(previous) + + +def test_radarr_voice_add_movie_requires_policy_without_connector_call(api_client, monkeypatch): + def fail_add_movie(*_args, **_kwargs): + raise AssertionError('connector should not be called before approval') + + monkeypatch.setattr('backend.app.connectors.radarr.RadarrConnector.add_movie', fail_add_movie, raising=True) + + response = api_client.post( + '/api/apps/radarr/movie', + json={'title': 'Arrival', 'tmdbId': 329865, 'source': 'voice'}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'approval_required' + assert payload['status'] == 'failed' + assert payload['approvalId'].startswith('appr-') + assert payload['auditId'].startswith('audit-') + + +def test_radarr_destructive_actions_are_not_executable(api_client): + delete_response = api_client.post('/api/apps/radarr/movie/delete', json={'id': 42}) + delete_files_response = api_client.post('/api/apps/radarr/movie/delete-files', json={'id': 42}) + mass_response = api_client.post('/api/apps/radarr/mass-edit', json={'ids': [42]}) + + assert delete_response.status_code in {404, 405} + assert delete_files_response.status_code in {404, 405} + assert mass_response.status_code in {404, 405} + + policy_delete = api_client.post( + '/api/policies/evaluate', + json={'action': 'radarr.delete_movie', 'target': 'radarr', 'source': 'manual'}, + ).json() + policy_delete_files = api_client.post( + '/api/policies/evaluate', + json={'action': 'radarr.delete_files', 'target': 'radarr', 'source': 'manual'}, + ).json() + policy_mass = api_client.post( + '/api/policies/evaluate', + json={'action': 'radarr.mass_edit', 'target': 'radarr', 'source': 'manual'}, + ).json() + assert policy_delete['decision'] == 'blocked' + assert policy_delete_files['decision'] == 'blocked' + assert policy_mass['decision'] == 'blocked' + + +def test_execute_blocked_action_is_never_success(api_client): + response = api_client.post( + '/api/actions/execute', + json={'intent': 'volume.delete', 'target': 'postgres-data', 'source': 'manual', 'risk': 'critical', 'plan': []}, + ) + payload = response.json() + + assert response.status_code == 200 + assert payload['success'] is False + assert payload['decision'] == 'blocked' + assert payload['status'] == 'failed' + assert payload.get('result') == 'blocked' + assert 'jobId' not in payload + + +def test_approval_required_does_not_execute_job(api_client): + jobs_before = api_client.get('/api/jobs').json() + + response = api_client.post( + '/api/actions/execute', + json={'intent': 'container.restart', 'target': 'authentik', 'source': 'manual', 'risk': 'high'}, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload['success'] is False + assert payload['decision'] == 'approval_required' + assert payload['status'] == 'failed' + assert payload['result'] == 'approval_required' + assert payload['approvalId'].startswith('appr-') + + jobs_after = api_client.get('/api/jobs').json() + assert jobs_after == jobs_before + + +def test_allowed_action_without_live_executor_is_not_fake_success(api_client): + jobs_before = api_client.get('/api/jobs').json() + + response = api_client.post( + '/api/actions/execute', + json={ + 'intent': 'unraid.health_summary', + 'target': 'unraid', + 'source': 'manual', + 'risk': 'low', + 'plan': ['Read array status'], + }, + ) + + payload = response.json() + assert response.status_code == 200 + assert payload['success'] is False + assert payload['decision'] == 'allowed' + assert payload['status'] == 'failed' + assert payload['result'] == 'unsupported' + assert 'No live executor' in payload['message'] + assert 'jobId' not in payload + + jobs_after = api_client.get('/api/jobs').json() + assert jobs_after == jobs_before + + +def test_policy_update_is_persisted_and_audited(api_client): + response = api_client.patch( + '/api/policies/policy-widefrog-health', + json={ + 'voice': 'approval_required', + 'manual': 'allowed', + 'risk': 'medium', + 'reason': 'Operator requires voice approval during maintenance.', + }, + ) + + assert response.status_code == 200 + response_payload = response.json() + assert response_payload.get('voice') == 'approval_required', response_payload + evaluation = api_client.post( + '/api/policies/evaluate', + json={'action': 'widefrog.health', 'target': 'widefrog', 'source': 'voice'}, + ).json() + assert evaluation['decision'] == 'approval_required' + audit = api_client.get('/api/audit').json() + assert any(event['action_id'] == 'policy.update' and event['target'] == 'policy-widefrog-health' for event in audit) + + +def test_policy_update_cannot_loosen_destructive_guardrail(api_client): + response = api_client.patch( + '/api/policies/policy-volume-delete', + json={ + 'voice': 'blocked', + 'manual': 'allowed', + 'risk': 'critical', + 'reason': 'Attempted override.', + }, + ) + + assert response.status_code == 409 + evaluation = api_client.post( + '/api/policies/evaluate', + json={'action': 'volume.delete', 'target': 'volume', 'source': 'manual'}, + ).json() + assert evaluation['decision'] == 'blocked' diff --git a/backend/tests/test_controller_artifact_promoter.py b/backend/tests/test_controller_artifact_promoter.py new file mode 100644 index 0000000..a541963 --- /dev/null +++ b/backend/tests/test_controller_artifact_promoter.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def test_controller_promoter_action_is_fixed_and_registered(): + registry = (ROOT / "backend/app/services/action_registry.py").read_text(encoding="utf-8") + execution = (ROOT / "backend/app/services/action_execution.py").read_text(encoding="utf-8") + assert "'controller.artifact.promote'" in registry + assert "return 'tower/deployment-controller'" in registry + assert 'action == "controller.artifact.promote"' in execution + assert 'target.strip().lower() != "tower/deployment-controller"' in execution + + +def test_controller_promoter_helper_spec_is_fixed_and_bounded(): + from backend.app.services.controller_artifact_promoter import ( + CONTROLLER_VERSION, + HELPER_NAME, + helper_container_spec, + ) + + revision = "a" * 40 + image_id = "sha256:" + "b" * 64 + spec = helper_container_spec(revision=revision, image_id=image_id) + + assert HELPER_NAME == "itworx-controller-artifact-promoter" + assert re.fullmatch(r"2\.1\.\d+", CONTROLLER_VERSION) + assert spec["Image"] == image_id + assert spec["HostConfig"]["Privileged"] is False + assert spec["HostConfig"]["NetworkMode"] == "host" + assert spec["HostConfig"]["SecurityOpt"] == ["no-new-privileges:true"] + assert set(spec["HostConfig"]["Binds"]) == { + "/mnt/cache:/mnt/cache", + "/mnt/user:/mnt/user", + "/srv/example/appdata/gitea/git/repositories:/gitea-repositories:ro", + "/var/run/docker.sock:/var/run/docker.sock", + } + assert spec["Cmd"][:2] == ["/bin/bash", "-lc"] + command = spec["Cmd"][2] + assert "scripts/upgrade_deployment_controller.sh" in command + assert "UPGRADE_DEPLOYMENT_CONTROLLER_V2" in command + assert revision in command + assert CONTROLLER_VERSION in command + assert "/gitea-repositories/jens/unraid-appops-gateway.git" in command + + +@pytest.mark.parametrize( + "revision,image_id", + [ + ("main", "sha256:" + "b" * 64), + ("a" * 39, "sha256:" + "b" * 64), + ("a" * 40, "ubuntu:latest"), + ("a" * 40, "sha256:" + "z" * 64), + ], +) +def test_controller_promoter_rejects_mutable_or_invalid_identity(revision: str, image_id: str): + from backend.app.services.controller_artifact_promoter import helper_container_spec + + with pytest.raises(ValueError): + helper_container_spec(revision=revision, image_id=image_id) + + +def test_controller_promoter_uses_only_protected_main_and_push_gate(): + source = (ROOT / "backend/app/services/controller_artifact_promoter.py").read_text(encoding="utf-8") + assert 'branch = "main"' in source + assert 'REQUIRED_PUSH_CONTEXT = "Managed validation / full (push)"' in source + assert "controller_promoter_main_not_validated" in source + assert "caller_revision" not in source + + +def test_deployment_broker_exposes_bounded_controller_health(): + source = (ROOT / "backend/app/services/deployment_broker.py").read_text(encoding="utf-8") + assert "def controller_health(self)" in source + assert 'f"{self.base_url}/health"' in source + assert "_MAX_CONTROLLER_HEALTH_BYTES" in source diff --git a/backend/tests/test_controller_bootstrap_import_host.py b/backend/tests/test_controller_bootstrap_import_host.py new file mode 100644 index 0000000..2d85ea3 --- /dev/null +++ b/backend/tests/test_controller_bootstrap_import_host.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "import_controller_bootstrap.py" +SPEC = importlib.util.spec_from_file_location("controller_bootstrap_import_host", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +host_import = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(host_import) + + +def test_reads_root_private_bootstrap_without_newline(tmp_path, monkeypatch): + path = tmp_path / "bootstrap" + path.write_bytes(b"x" * 64 + b"\n") + path.chmod(0o600) + monkeypatch.setattr(host_import.os, "geteuid", lambda: 0, raising=False) + assert host_import.read_bootstrap(path) == b"x" * 64 + path.write_bytes(b"short\n") + with pytest.raises(ValueError): + host_import.read_bootstrap(path) + + +def test_bootstrap_passes_only_on_stdin(monkeypatch): + observed = {} + + def fake_run(argv, *, input, capture_output, check, timeout): + observed.update({"argv": argv, "input": input, "capture_output": capture_output}) + + class Result: + returncode = 0 + stdout = json.dumps({"status": "imported", "secretRef": "ENROLLMENT_BOOTSTRAP"}).encode() + + return Result() + + monkeypatch.setattr(host_import.subprocess, "run", fake_run) + assert host_import.import_bootstrap(b"sensitive-test-value") == "imported" + assert observed["input"] == b"sensitive-test-value" + assert "sensitive-test-value" not in " ".join(observed["argv"]) + assert observed["capture_output"] is True diff --git a/backend/tests/test_controller_state_reconciliation.py b/backend/tests/test_controller_state_reconciliation.py new file mode 100644 index 0000000..6baf48c --- /dev/null +++ b/backend/tests/test_controller_state_reconciliation.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import importlib.util +import os +import stat +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER_PATH = ROOT / "scripts" / "reconcile_controller_state.py" +UPGRADE_PATH = ROOT / "scripts" / "upgrade_deployment_controller.sh" +WRAPPER_PATH = ROOT / "scripts" / "upgrade_deployment_controller.ps1" + +spec = importlib.util.spec_from_file_location("reconcile_controller_state", HELPER_PATH) +assert spec is not None and spec.loader is not None +reconciliation = importlib.util.module_from_spec(spec) +spec.loader.exec_module(reconciliation) + + +def _roots(tmp_path: Path) -> tuple[Path, Path]: + legacy = tmp_path / "legacy-state" + cache = tmp_path / "cache-state" + legacy.mkdir() + cache.mkdir() + return legacy, cache + + +def test_reconciliation_migrates_only_missing_legacy_state_with_metadata(tmp_path: Path): + legacy, cache = _roots(tmp_path) + (legacy / "nested").mkdir(mode=0o750) + source = legacy / "nested" / "receipt.json" + source.write_bytes(b'{"revision":"abc"}\n') + source.chmod(0o640) + identical = legacy / "existing.json" + identical.write_bytes(b'{"status":"healthy"}\n') + (cache / "existing.json").write_bytes(identical.read_bytes()) + + result = reconciliation.reconcile_state_roots(legacy, cache) + + migrated = cache / "nested" / "receipt.json" + assert migrated.read_bytes() == source.read_bytes() + assert stat.S_IMODE(migrated.stat().st_mode) == stat.S_IMODE(source.stat().st_mode) + assert result == {"legacyEntries": 3, "cacheEntries": 1, "migratedDirectories": 1, "migratedFiles": 1} + + +def test_reconciliation_detects_every_conflict_before_copying_any_missing_file(tmp_path: Path): + legacy, cache = _roots(tmp_path) + (legacy / "a-missing.json").write_text("legacy-only", encoding="utf-8") + (legacy / "z-conflict.json").write_text("legacy", encoding="utf-8") + (cache / "z-conflict.json").write_text("cache", encoding="utf-8") + + with pytest.raises(reconciliation.StateConflictError, match="z-conflict.json"): + reconciliation.reconcile_state_roots(legacy, cache) + + assert not (cache / "a-missing.json").exists() + assert (cache / "z-conflict.json").read_text(encoding="utf-8") == "cache" + + +def test_reconciliation_rejects_symlinks_without_mutating_cache(tmp_path: Path): + if not hasattr(os, "symlink"): + pytest.skip("symlinks are unavailable") + legacy, cache = _roots(tmp_path) + target = tmp_path / "outside.json" + target.write_text("outside", encoding="utf-8") + try: + (legacy / "unsafe.json").symlink_to(target) + except OSError: + pytest.skip("the test process cannot create symlinks") + + with pytest.raises(reconciliation.UnsafeStateError, match="unsafe.json"): + reconciliation.reconcile_state_roots(legacy, cache) + + assert list(cache.iterdir()) == [] + + +def test_reconciliation_allows_cache_only_state_and_keeps_it_unchanged(tmp_path: Path): + legacy, cache = _roots(tmp_path) + (legacy / "shared.json").write_text("shared", encoding="utf-8") + (cache / "shared.json").write_text("shared", encoding="utf-8") + cache_only = cache / "new-controller.json" + cache_only.write_text("cache-only", encoding="utf-8") + + result = reconciliation.reconcile_state_roots(legacy, cache) + + assert cache_only.read_text(encoding="utf-8") == "cache-only" + assert result["migratedDirectories"] == 0 + assert result["migratedFiles"] == 0 + + +def test_reconciliation_rejects_metadata_conflict_before_copying_missing_state( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + legacy, cache = _roots(tmp_path) + (legacy / "missing.json").write_text("legacy-only", encoding="utf-8") + (legacy / "shared.json").write_text("shared", encoding="utf-8") + (cache / "shared.json").write_text("shared", encoding="utf-8") + real_inventory = reconciliation._inventory + + def inventory_with_conflicting_cache_metadata(root: Path): + entries = real_inventory(root) + if root == cache: + entries["shared.json"] = { + **entries["shared.json"], + "mode": int(entries["shared.json"]["mode"]) ^ 0o020, + "uid": int(entries["shared.json"]["uid"]) + 1, + "gid": int(entries["shared.json"]["gid"]) + 1, + } + return entries + + monkeypatch.setattr(reconciliation, "_inventory", inventory_with_conflicting_cache_metadata) + + with pytest.raises(reconciliation.StateConflictError, match="shared.json"): + reconciliation.reconcile_state_roots(legacy, cache) + + assert not (cache / "missing.json").exists() + + +def test_reconciliation_rejects_legacy_inode_replacement_and_rolls_back( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + legacy, cache = _roots(tmp_path) + source = legacy / "receipt.json" + source.write_bytes(b'{"revision":"abc"}\n') + real_create_file = reconciliation._create_file + + def replace_source_before_copy(path: Path, payload: bytes, metadata: dict[str, int | str]): + replacement = source.with_suffix(".replacement") + replacement.write_bytes(source.read_bytes()) + os.replace(replacement, source) + real_create_file(path, payload, metadata) + + monkeypatch.setattr(reconciliation, "_create_file", replace_source_before_copy) + + with pytest.raises(reconciliation.UnsafeStateError, match="changed during migration"): + reconciliation.reconcile_state_roots(legacy, cache) + + assert list(cache.iterdir()) == [] + + +def test_reconciliation_rolls_back_every_created_entry_after_partial_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + legacy, cache = _roots(tmp_path) + (legacy / "nested").mkdir() + (legacy / "nested" / "a.json").write_text("first", encoding="utf-8") + (legacy / "nested" / "b.json").write_text("second", encoding="utf-8") + real_create_file = reconciliation._create_file + created_files = 0 + + def fail_after_first_file(path: Path, payload: bytes, metadata: dict[str, int | str]): + nonlocal created_files + if created_files == 1: + raise OSError("injected second-file failure") + real_create_file(path, payload, metadata) + created_files += 1 + + monkeypatch.setattr(reconciliation, "_create_file", fail_after_first_file) + + with pytest.raises(OSError, match="injected second-file failure"): + reconciliation.reconcile_state_roots(legacy, cache) + + assert list(cache.iterdir()) == [] + + +def test_successful_reconciliation_can_be_rolled_back_before_controller_swap(tmp_path: Path): + legacy, cache = _roots(tmp_path) + preexisting = cache / "cache-only.json" + preexisting.write_text("keep", encoding="utf-8") + (legacy / "nested").mkdir() + (legacy / "nested" / "receipt.json").write_text("migrate", encoding="utf-8") + journal = tmp_path / "migration-journal.json" + + reconciliation.reconcile_state_roots(legacy, cache, journal_path=journal) + + assert (cache / "nested" / "receipt.json").read_text(encoding="utf-8") == "migrate" + reconciliation.rollback_reconciliation(cache, journal) + + assert preexisting.read_text(encoding="utf-8") == "keep" + assert not (cache / "nested").exists() + assert not journal.exists() + + +def test_upgrade_stops_old_writer_before_locking_cache_and_reconciles_while_stopped(): + script = UPGRADE_PATH.read_text(encoding="utf-8") + + cache_lock = 'flock -x "$cache_lock_fd"' + stop = 'writers_stopped=1\ndocker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"' + reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root' + backup = 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' + swap = 'mv -- "$CONTROLLER_DIR" "$previous"' + + assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script + assert script.index(stop) < script.index(cache_lock) + assert script.index(stop) < script.index(backup) + assert script.index(cache_lock) < script.index(backup) + assert script.index(backup) < script.index(reconcile) + assert script.index(reconcile) < script.index(swap) + assert 'flock -u "$cache_lock_fd"' not in script + + +def test_upgrade_retains_and_validates_optional_private_tls_before_stopping_controller(): + script = UPGRADE_PATH.read_text(encoding="utf-8") + + stage_copy = 'cp -a -- "$CONTROLLER_DIR" "${stage}/controller"' + tls_gate = ( + 'if [[ -e "${stage}/controller/private-tls.json" ' + '|| -L "${stage}/controller/private-tls.json" ]]; then\n' + ' python3 "${stage}/controller/status_server.py" --check-private-tls-config\n' + 'fi' + ) + stop = ( + 'writers_stopped=1\ndocker compose --project-directory "$ROOT" ' + '-f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"' + ) + + assert stage_copy in script + assert script.index(stage_copy) < script.index(tls_gate) + assert script.index(tls_gate) < script.index(stop) + assert 'cp -a -- "$CONTROLLER_DIR" "$backup/controller"' in script + + +def test_upgrade_rolls_back_state_before_restoring_or_starting_the_old_writer(): + script = UPGRADE_PATH.read_text(encoding="utf-8") + restore = script[script.index("restore_previous() {") : script.index("cleanup() {")] + + stop = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" stop "$COMPOSE_SERVICE"' + rollback = '--rollback' + restore_controller = 'mv -- "$previous" "$CONTROLLER_DIR"' + start = 'docker compose --project-directory "$ROOT" -f "$COMPOSE_FILE" up -d --force-recreate "$COMPOSE_SERVICE"' + + assert restore.index(stop) < restore.index(rollback) + assert restore.index(rollback) < restore.index(restore_controller) + assert restore.index(restore_controller) < restore.index(start) + assert "the old writer will remain stopped" in script + reconcile = 'python3 "${stage}/reconcile_controller_state.py" \\\n --legacy-root' + assert script.index("reconciliation_active=1", script.index("writers_stopped=1")) < script.index(reconcile) + assert '[[ -f "$migration_journal" && ! -L "$migration_journal" ]]' in restore + assert '[[ -e "$migration_journal" || -L "$migration_journal" ]]' in restore + + +def test_wrapper_binds_reconciliation_helper_to_the_immutable_revision(): + wrapper = WRAPPER_PATH.read_text(encoding="utf-8") + + assert "scripts/reconcile_controller_state.py" in wrapper + assert "git archive" in wrapper + + +def test_wrapper_archives_the_fixed_operator_handoff_required_by_root_upgrade(): + wrapper = WRAPPER_PATH.read_text(encoding="utf-8") + upgrade = UPGRADE_PATH.read_text(encoding="utf-8") + archive_line = next(line for line in wrapper.splitlines() if "git archive" in line) + + assert "scripts/run_repopulse_adoption.py" in archive_line + assert '"scripts/run_repopulse_adoption.py"' in upgrade diff --git a/backend/tests/test_deluge_connector.py b/backend/tests/test_deluge_connector.py new file mode 100644 index 0000000..1357c66 --- /dev/null +++ b/backend/tests/test_deluge_connector.py @@ -0,0 +1,405 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "deluge_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + previous_apis = dict(settings.apis) + for key in [ + "DELUGE_URL", + "DELUGE_PASSWORD", + "DELUGE_VERIFY_TLS", + "DELUGE_TIMEOUT_SECONDS", + "DELUGE_DEFAULT_HOST_ID", + "DELUGE_WEB_PATH", + ]: + settings.apis[key] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + settings.apis = previous_apis + + +def _set_deluge_config(url: str | None = "http://deluge:8112", password: str | None = "DELUGE_SECRET_PASSWORD"): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis["DELUGE_URL"] = url + settings.apis["DELUGE_PASSWORD"] = password + settings.apis["DELUGE_VERIFY_TLS"] = False + settings.apis["DELUGE_TIMEOUT_SECONDS"] = "10" + settings.apis["DELUGE_WEB_PATH"] = "/json" + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def _torrent_payload(): + return { + "active-id": { + "name": "Active Linux ISO", + "state": "Downloading", + "progress": 42.5, + "download_payload_rate": 2048, + "upload_payload_rate": 512, + "eta": 120, + "ratio": 0.4, + "total_size": 1000000, + "tracker_host": "tracker.example", + "label": "linux", + "save_path": "/srv/example/downloads/linux", + "is_finished": False, + }, + "paused-id": { + "name": "Paused Media", + "state": "Paused", + "progress": 12, + "download_payload_rate": 0, + "upload_payload_rate": 0, + "eta": 0, + "ratio": 0, + "total_size": 2000000, + "tracker_host": "tracker.example", + "label": "tv", + "save_path": "/srv/example/downloads/tv", + "is_finished": False, + }, + "error-id": { + "name": "Broken Torrent", + "state": "Error", + "progress": 50, + "download_payload_rate": 0, + "upload_payload_rate": 0, + "eta": 0, + "ratio": 0.1, + "total_size": 3000000, + "tracker_host": "bad.example", + "label": "", + "save_path": "/srv/example/downloads/broken", + "error": "Tracker failed", + "is_finished": False, + }, + } + + +def test_deluge_connector_missing_config_reports_required_env(api_client): + response = api_client.post("/api/connectors/deluge/test") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert payload["missing_config"] is True + assert payload["required_env_vars"] == ["DELUGE_URL", "DELUGE_PASSWORD"] + assert payload["secretRef"] == "DELUGE_PASSWORD" + + +def test_deluge_health_success_normalizes_response(monkeypatch): + from backend.app.connectors.deluge import DelugeConnector + + previous = _set_deluge_config() + + monkeypatch.setattr(DelugeConnector, "_login", lambda self: True, raising=True) + monkeypatch.setattr( + DelugeConnector, + "_rpc", + lambda self, method, params=None, authed=True: { + "web.connected": True, + "daemon.connected": True, + }.get(method, "2.1.1"), + raising=True, + ) + + payload = DelugeConnector().health() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["webConnected"] is True + assert payload["daemonConnected"] is True + assert payload["version"] == "2.1.1" + assert payload["url"] == "http://deluge:8112" + assert "DELUGE_SECRET_PASSWORD" not in str(payload) + + _restore_config(previous) + + +def test_deluge_auth_failure_redacts_password(monkeypatch): + from backend.app.connectors.deluge import DelugeConnector + + previous = _set_deluge_config() + + def fail_login(self): + raise RuntimeError("auth_failed password=DELUGE_SECRET_PASSWORD") + + monkeypatch.setattr(DelugeConnector, "_login", fail_login, raising=True) + + payload = DelugeConnector().health() + + assert payload["success"] is False + assert payload["status"] == "auth_failed" + assert "DELUGE_SECRET_PASSWORD" not in str(payload) + assert "[REDACTED]" in str(payload) + + _restore_config(previous) + + +def test_deluge_health_uses_session_fallback_when_optional_methods_unknown(monkeypatch): + from backend.app.connectors.deluge import DelugeConnector + + previous = _set_deluge_config() + monkeypatch.setattr(DelugeConnector, "_login", lambda self: True, raising=True) + + def fake_rpc(self, method, params=None, authed=True): + if method in {"web.connected", "daemon.connected", "daemon.get_version"}: + raise RuntimeError("Unknown method") + if method == "core.get_session_status": + return {"num_torrents": 57} + raise AssertionError(f"unexpected method {method}") + + monkeypatch.setattr(DelugeConnector, "_rpc", fake_rpc, raising=True) + + payload = DelugeConnector().health() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["message"] == "Deluge connector ready." + assert payload["diagnostics"]["healthFallback"] == "core.get_session_status" + assert payload["diagnostics"]["optionalMethodWarnings"] + + _restore_config(previous) + + +def test_deluge_session_status_normalizes_rates_and_counts(monkeypatch): + from backend.app.connectors.deluge import DelugeConnector + + previous = _set_deluge_config() + monkeypatch.setattr(DelugeConnector, "_login", lambda self: True, raising=True) + + def fake_rpc(self, method, params=None, authed=True): + if method == "web.connected": + return True + if method == "core.get_session_status": + return {"download_rate": 4096, "upload_rate": 1024, "num_torrents": 3} + if method == "web.update_ui": + return {"torrents": _torrent_payload()} + return None + + monkeypatch.setattr(DelugeConnector, "_rpc", fake_rpc, raising=True) + + payload = DelugeConnector().session_status() + + assert payload["success"] is True + assert payload["connected"] is True + assert payload["downloadRate"] == 4096 + assert payload["uploadRate"] == 1024 + assert payload["numTorrents"] == 3 + assert payload["active"] == 1 + assert payload["paused"] == 1 + assert payload["stalled"] == 1 + assert payload["errors"] == 1 + + _restore_config(previous) + + +def test_deluge_torrent_status_and_filters(monkeypatch): + from backend.app.connectors.deluge import DelugeConnector + + previous = _set_deluge_config() + monkeypatch.setattr(DelugeConnector, "_login", lambda self: True, raising=True) + monkeypatch.setattr( + DelugeConnector, + "_rpc", + lambda self, method, params=None, authed=True: {"torrents": _torrent_payload()} if method == "web.update_ui" else True, + raising=True, + ) + + torrents = DelugeConnector().torrent_status() + active = DelugeConnector().active_downloads() + stalled = DelugeConnector().stalled_downloads() + paused = DelugeConnector().paused_downloads() + + assert torrents["success"] is True + assert torrents["total"] == 3 + assert torrents["items"][0]["id"] == "active-id" + assert torrents["items"][0]["category"] == "linux" + assert torrents["items"][0]["savePath"] == ".../linux" + assert "DELUGE_SECRET_PASSWORD" not in str(torrents) + assert active["total"] == 1 + assert active["items"][0]["id"] == "active-id" + assert stalled["total"] == 1 + assert stalled["items"][0]["id"] == "error-id" + assert paused["total"] == 1 + assert paused["items"][0]["id"] == "paused-id" + + _restore_config(previous) + + +def test_deluge_speed_status_uses_session_rates(monkeypatch): + from backend.app.connectors.deluge import DelugeConnector + + previous = _set_deluge_config() + monkeypatch.setattr( + DelugeConnector, + "session_status", + lambda self: {"success": True, "connected": True, "downloadRate": 100, "uploadRate": 25, "numTorrents": 2, "active": 1, "paused": 0, "stalled": 0, "errors": 0}, + raising=True, + ) + + payload = DelugeConnector().speed_status() + + assert payload["success"] is True + assert payload["downloadRate"] == 100 + assert payload["uploadRate"] == 25 + assert payload["active"] == 1 + + _restore_config(previous) + + +def test_deluge_pause_torrent_allowed_creates_job_and_audit(api_client, monkeypatch): + monkeypatch.setattr( + "backend.app.connectors.deluge.DelugeConnector.pause_torrent", + lambda self, torrent_id: {"success": True, "status": "ok", "message": "torrent_paused", "data": {"torrentId": torrent_id}}, + raising=True, + ) + + response = api_client.post("/api/apps/deluge/torrents/active-id/pause", json={"source": "manual"}) + payload = response.json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["decision"] == "allowed" + assert payload["jobId"].startswith("job-") + assert payload["auditId"].startswith("audit-") + assert payload["data"]["torrentId"] == "active-id" + + +def test_deluge_resume_torrent_voice_requires_approval_without_connector_call(api_client, monkeypatch): + def fail_resume(*_args, **_kwargs): + raise AssertionError("connector should not run before approval") + + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.resume_torrent", fail_resume, raising=True) + + response = api_client.post("/api/apps/deluge/torrents/paused-id/resume", json={"source": "voice"}) + payload = response.json() + + assert payload["success"] is False + assert payload["decision"] == "approval_required" + assert payload["approvalId"].startswith("appr-") + assert payload["auditId"].startswith("audit-") + + +def test_deluge_pause_all_resume_all_require_approval_or_block_without_connector_call(api_client, monkeypatch): + def fail_bulk(*_args, **_kwargs): + raise AssertionError("bulk connector call should wait for approval") + + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.pause_all", fail_bulk, raising=True) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.resume_all", fail_bulk, raising=True) + + manual_pause = api_client.post("/api/apps/deluge/pause-all", json={"source": "manual"}).json() + manual_resume = api_client.post("/api/apps/deluge/resume-all", json={"source": "manual"}).json() + voice_pause = api_client.post("/api/apps/deluge/pause-all", json={"source": "voice"}).json() + voice_resume = api_client.post("/api/apps/deluge/resume-all", json={"source": "voice"}).json() + + assert manual_pause["decision"] == "approval_required" + assert manual_resume["decision"] == "approval_required" + assert voice_pause["decision"] == "blocked" + assert voice_resume["decision"] == "blocked" + + +def test_deluge_pause_all_executes_only_after_separate_approval_step(api_client, monkeypatch): + calls = [] + + def pause_all(_self): + calls.append('pause') + return {'success': True, 'status': 'ok', 'message': 'all_torrents_paused'} + + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.pause_all", pause_all, raising=True) + + requested = api_client.post("/api/apps/deluge/pause-all", json={"source": "manual"}).json() + assert calls == [] + + approved = api_client.patch(f"/api/approvals/{requested['approvalId']}", json={"status": "approved"}).json() + assert approved['status'] == 'approved' + assert calls == [] + + executed = api_client.post(f"/api/approvals/{requested['approvalId']}/execute").json() + assert executed['success'] is True + assert executed['approvalStatus'] == 'executed' + assert executed['jobId'].startswith('job-') + assert executed['auditId'].startswith('audit-') + assert calls == ['pause'] + + approvals = api_client.get('/api/approvals').json() + approval = next(item for item in approvals if item['id'] == requested['approvalId']) + assert approval['status'] == 'executed' + assert approval['job_id'] == executed['jobId'] + + +def test_deluge_destructive_actions_are_blocked(api_client): + for action in [ + "deluge.remove_torrent", + "deluge.remove_torrent_data", + "deluge.delete_data", + "deluge.force_recheck", + "deluge.move_storage", + "deluge.add_torrent", + "deluge.add_magnet", + "deluge.mass_edit", + "deluge.set_options", + "deluge.unknown_mutation", + ]: + payload = api_client.post("/api/policies/evaluate", json={"action": action, "target": "deluge", "source": "manual"}).json() + assert payload["decision"] == "blocked" + + +def test_deluge_inventory_classification_and_candidate(api_client, monkeypatch): + from backend.app.services.app_classifier import classify_container + + result = classify_container({"name": "binhex-delugevpn", "image": "binhex/arch-delugevpn:latest", "ports": ["8112:8112"]}) + + assert result["app_type"] == "deluge" + assert result["category"] == "downloads" + assert result["subcategory"] == "torrent_client" + assert result["connector"] == "deluge" + assert result["policy_profile"] == "download_client" + assert result["criticality"] == "medium" + assert result["healthcheck_suggestion"] == {"port": 8112} + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "ok", "containers": [{"id": "deluge-container", "name": "binhex-delugevpn", "image": "binhex/arch-delugevpn", "state": "running", "status": "running", "ports": ["8112:8112"]}]}, + raising=True, + ) + + api_client.post("/api/inventory/discover") + candidates = api_client.get("/api/connectors/deluge/candidates").json() + + assert len(candidates) == 1 + assert candidates[0]["connectorId"] == "deluge" + assert candidates[0]["appType"] == "deluge" + assert candidates[0]["missingSecrets"] == ["DELUGE_URL", "DELUGE_PASSWORD"] diff --git a/backend/tests/test_deploy_broker.py b/backend/tests/test_deploy_broker.py new file mode 100644 index 0000000..59ca4af --- /dev/null +++ b/backend/tests/test_deploy_broker.py @@ -0,0 +1,7318 @@ +from __future__ import annotations + +import copy +import hashlib +import hmac +import http.client +import importlib.util +import io +import ipaddress +import json +import os +import socket +import ssl +import stat +import subprocess +import sys +import tarfile +import threading +import types +from contextlib import contextmanager +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BROKER_ROOT = ROOT / "ops" / "gitea-deploy-broker" + + +def _load_module(name: str, path: Path): + if "fcntl" not in sys.modules: + fcntl = types.ModuleType("fcntl") + fcntl.LOCK_EX = 2 + fcntl.LOCK_UN = 8 + fcntl.flock = lambda *_args: None + sys.modules["fcntl"] = fcntl + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def deploy_module(): + return _load_module("appops_deploy_broker_test", BROKER_ROOT / "deploy.py") + + +@pytest.fixture +def status_module(): + return _load_module("appops_deploy_status_test", BROKER_ROOT / "status_server.py") + + +def test_controller_state_uses_the_physical_cache_path(deploy_module, status_module): + expected_root = Path("/mnt/cache/appdata/gitea-deploy-runners/state") + + assert deploy_module.STATE_ROOT == expected_root + assert deploy_module.GLOBAL_DEPLOY_LOCK == expected_root.parent / "deploy.lock" + assert status_module.STATE_ROOT == expected_root + assert status_module.WEBHOOK_INBOX_ROOT == expected_root / "webhook-inbox" + + +def test_normal_deploy_still_takes_its_own_lock(deploy_module, monkeypatch, tmp_path): + project_lock = tmp_path / "repopulse.lock" + other_lock = tmp_path / "other.lock" + monkeypatch.setitem( + sys.modules, + "repopulse_adoption_store", + types.SimpleNamespace(transaction_lock_held=lambda path: path == project_lock), + ) + with deploy_module.exclusive_lock(project_lock, "project repopulse"): + assert project_lock.exists() + with deploy_module.exclusive_lock(other_lock, "other project"): + assert other_lock.exists() + + +def test_adoption_deploy_requires_both_held_locks_before_entering_core( + deploy_module, monkeypatch +): + monkeypatch.setitem( + sys.modules, + "repopulse_adoption_store", + types.SimpleNamespace(transaction_lock_held=lambda _path: False), + ) + monkeypatch.setattr( + deploy_module, + "main", + lambda _argv: pytest.fail("deploy core must not run without the transaction"), + ) + with pytest.raises(deploy_module.DeployError, match="requires both fixed transaction locks"): + deploy_module.deploy_repopulse_under_adoption_transaction("a" * 40) + + +def test_adoption_deploy_rechecks_exact_receipt_after_existing_core( + deploy_module, monkeypatch, tmp_path +): + revision = "a" * 40 + fixed_locks = { + deploy_module.STATE_ROOT / "locks" / "repopulse.lock", + deploy_module.GLOBAL_DEPLOY_LOCK, + } + monkeypatch.setitem( + sys.modules, + "repopulse_adoption_store", + types.SimpleNamespace(transaction_lock_held=lambda path: path in fixed_locks), + ) + observed = [] + project = { + "managed_profile": "repopulse-authenticated", + "branch": "main", + "source_dir": str(tmp_path), + "slug": "repopulse", + "exact_source": True, + } + monkeypatch.setattr( + deploy_module, + "load_projects", + lambda: {"jens/repopulse": project}, + ) + monkeypatch.setattr(deploy_module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(deploy_module, "validate_commit", lambda *_args: revision) + monkeypatch.setattr( + deploy_module, + "deployment_metadata", + lambda *_args: {"tree": "b" * 40}, + ) + for name in ( + "validate_project_config", + "validate_forward_revision", + "sync_source", + "copy_environment", + "revalidate_deployment_candidate", + "validate_managed_v1_runtime", + "ensure_docker_capacity", + "deploy_project", + "write_state", + ): + monkeypatch.setattr( + deploy_module, + name, + lambda *_args, _name=name, **_kwargs: observed.append(_name), + ) + receipt = {"repository": "jens/repopulse", "revision": revision} + monkeypatch.setattr(deploy_module, "verify_deployment", lambda *_args: receipt) + monkeypatch.setattr( + deploy_module, + "verified_current_receipt", + lambda _repo, _project, _revision, _tree: receipt, + ) + assert deploy_module.deploy_repopulse_under_adoption_transaction(revision) == receipt + assert observed == [ + "validate_project_config", + "validate_forward_revision", + "sync_source", + "copy_environment", + "revalidate_deployment_candidate", + "validate_managed_v1_runtime", + "ensure_docker_capacity", + "deploy_project", + "write_state", + ] + + +def test_global_lock_uses_only_the_physical_cache_lock(deploy_module, monkeypatch): + events: list[tuple[str, Path] | tuple[str]] = [] + + @contextmanager + def observed_lock(path: Path, _description: str): + events.append(("enter", path)) + try: + yield + finally: + events.append(("exit", path)) + + monkeypatch.setattr(deploy_module, "exclusive_lock", observed_lock) + + with deploy_module.exclusive_global_deploy_lock(): + events.append(("body",)) + + physical = Path("/mnt/cache/appdata/gitea-deploy-runners/deploy.lock") + assert events == [ + ("enter", physical), + ("body",), + ("exit", physical), + ] + + +def test_static_rollback_environment_accepts_application_database_url(deploy_module, tmp_path): + path = tmp_path / ".env" + path.write_text("DATABASE_URL='postgresql://fixture:fixture@localhost:5432/projectbrain'\n") + assert deploy_module.static_projectbrain_environment(path) == { + "DATABASE_URL": "postgresql://fixture:fixture@localhost:5432/projectbrain" + } + + +@pytest.mark.parametrize("changed", [False, True]) +def test_project_config_is_rechecked_after_lock_before_work(deploy_module, monkeypatch, tmp_path, changed): + repository = "jens/unraid-appops-gateway" + config = {"slug": "unraid-appops-gateway", "branch": "main", + "source_dir": str(tmp_path / "source"), "compose_project": "appops", + "containers": ["Unraid-AppOps-Gateway"]} + locked = False + reads = 0 + + @contextmanager + def lock(*_args): + nonlocal locked + locked = True + try: + yield + finally: + locked = False + + def load(): + nonlocal reads + reads += 1 + if reads == 2: + assert locked, "configuration must be rechecked inside the project lock" + value = copy.deepcopy(config) + if reads == 2 and changed: + value["source_dir"] = str(tmp_path / "migrated-source") + return {repository: value} + + monkeypatch.setattr(sys, "argv", ["deploy.py", "check", repository, "a" * 40]) + monkeypatch.setattr(deploy_module, "load_projects", load) + monkeypatch.setattr(deploy_module, "repository_path", lambda *_: tmp_path) + monkeypatch.setattr(deploy_module, "validate_commit", lambda *_: "a" * 40) + monkeypatch.setattr(deploy_module, "deployment_metadata", lambda *_: {}) + monkeypatch.setattr(deploy_module, "validate_project_config", lambda *_: None) + monkeypatch.setattr(deploy_module, "exclusive_lock", lock) + if changed: + with pytest.raises(deploy_module.DeployError, match="configuration changed"): + deploy_module.main() + else: + assert deploy_module.main() == 0 + assert reads == 2 + + +def _project(tmp_path: Path) -> dict[str, object]: + return { + "slug": "unraid-appops-gateway", + "source_dir": str(tmp_path / "source"), + "compose_project": "unraid-appops-gateway", + "compose_files": ["docker-compose.yml"], + "containers": ["Unraid-AppOps-Gateway"], + "verify_oci_revision": True, + "_repository_name": "jens/unraid-appops-gateway", + } + + +def _workmesh_product_project(tmp_path: Path) -> dict[str, object]: + return { + "slug": "workmesh", + "source_dir": str(tmp_path / "source"), + "compose_project": "workmesh", + "compose_files": ["compose.yaml"], + "containers": ["workmesh-postgres-1", "workmesh-api-1", "workmesh-n8n-1", "workmesh-workmesh-1"], + "retired_compose_containers": ["workmesh-mock-ats-1"], + "_repository_name": "jens/workmesh", + } + + +def _workmesh_legacy_reattest_setup(tmp_path: Path): + revision = "a" * 40 + project = _workmesh_product_project(tmp_path) + project.update({ + "branch": "main", + "containers": [ + "workmesh-postgres-1", + "workmesh-mock-ats-1", + "workmesh-api-1", + "workmesh-n8n-1", + "workmesh-workmesh-1", + ], + "compose_files": ["compose.yaml"], + "exact_source": True, + "verify_oci_revision": False, + }) + project.pop("retired_compose_containers") + receipt = _receipt(project, revision) + for index, name in enumerate(project["containers"], start=1): + receipt["containers"][name]["container_id"] = f"{index:x}" * 64 + receipt["containers"][name]["container_revision"] = "" + for field in ("image_revision", "image_source", "image_tree", "image_version", "image_created"): + receipt["containers"][name][field] = "" + receipt_containers = receipt["containers"] + artifacts = { + "containers": { + name: dict(item) + for name, item in receipt_containers.items() + }, + "compose_sha256": "c" * 64, + } + state = { + "schema_version": 3, + "repository": "jens/workmesh", + "slug": "workmesh", + "compose_project": "workmesh", + "revision": revision, + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": copy.deepcopy(artifacts), + "live_receipt": copy.deepcopy(receipt), + "rollback_snapshot": {}, + "status": "success", + "message": "verified live WorkMesh deployment", + "updated_at_epoch": 100, + "artifacts": copy.deepcopy(artifacts), + "last_attempt": { + "revision": revision, + "status": "success", + "message": "verified live WorkMesh deployment", + "updated_at_epoch": 100, + "metadata": copy.deepcopy(receipt["build"]), + }, + "previous_revision": "", + "rollback_restored_revision": "", + } + configured = copy.deepcopy(project) + configured.pop("_repository_name") + project.update({ + "_repository_name": "jens/workmesh", + "_repository_path": str(tmp_path), + "_deployment_metadata": copy.deepcopy(receipt["build"]), + }) + projects = {"jens/workmesh": configured} + state_root = tmp_path / "state" + state_root.mkdir() + state_path = state_root / "workmesh.json" + original_bytes = (json.dumps(state, indent=2) + "\n").encode("utf-8") + state_path.write_bytes(original_bytes) + return revision, project, projects, receipt, artifacts, state_path, original_bytes + + +def _install_workmesh_reattest_runtime( + deploy_module, monkeypatch, receipt, artifacts, state_path, *, drift=None +): + observed_receipt = copy.deepcopy(receipt) + observed_artifacts = copy.deepcopy(artifacts["containers"]) + observed_receipt["containers"]["workmesh-n8n-1"]["container_id"] = "f" * 64 + observed_artifacts["workmesh-n8n-1"]["container_id"] = "f" * 64 + if drift: + name, field, value = drift + observed_receipt["containers"][name][field] = value + observed_artifacts[name][field] = value + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "_root_owned", lambda _path: True) + monkeypatch.setattr( + deploy_module, "validate_commit", lambda _repo, _branch, revision: revision + ) + monkeypatch.setattr( + deploy_module, + "deployment_metadata", + lambda _repo, _revision, _repository, *, built_at=None: copy.deepcopy(receipt["build"]), + ) + monkeypatch.setattr( + deploy_module, + "git_revision_compose_sha256", + lambda *_args: str(artifacts["compose_sha256"]), + ) + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: set(deploy_module.WORKMESH_LEGACY_CONTAINERS), + ) + monkeypatch.setattr( + deploy_module, + "verify_deployment", + lambda _project, _build, **_kwargs: copy.deepcopy(observed_receipt), + ) + monkeypatch.setattr( + deploy_module, + "deployment_container_artifacts", + lambda _project: copy.deepcopy(observed_artifacts), + ) + monkeypatch.setattr(deploy_module, "_fsync_directory", lambda _path: None) + reverified = [] + + def verify_after_write(repository, _project, revision, tree): + reverified.append((repository, revision, tree)) + return json.loads(state_path.read_text(encoding="utf-8"))["live_receipt"] + + monkeypatch.setattr(deploy_module, "verified_current_receipt", verify_after_write) + return reverified + + +def test_workmesh_n8n_recreation_reattests_only_container_id( + deploy_module, tmp_path, monkeypatch +): + ( + revision, + project, + projects, + receipt, + artifacts, + state_path, + original_bytes, + ) = _workmesh_legacy_reattest_setup(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_path.parent) + reverified = _install_workmesh_reattest_runtime( + deploy_module, monkeypatch, receipt, artifacts, state_path + ) + + result = deploy_module.reattest_workmesh_n8n_receipt( + projects, project, tmp_path, revision + ) + + persisted = json.loads(state_path.read_text(encoding="utf-8")) + expected_receipt = copy.deepcopy(receipt) + expected_receipt["containers"]["workmesh-n8n-1"]["container_id"] = "f" * 64 + expected_artifacts = copy.deepcopy(artifacts) + expected_artifacts["containers"]["workmesh-n8n-1"]["container_id"] = "f" * 64 + assert result == { + "revision": revision, + "container": "workmesh-n8n-1", + "status": "reattested", + } + assert persisted["live_receipt"] == expected_receipt + assert persisted["live_artifacts"] == expected_artifacts + assert persisted["artifacts"] == expected_artifacts + assert reverified == [("jens/workmesh", revision, receipt["tree"])] + backup = state_path.parent / ( + f"workmesh-before-n8n-reattest-{revision[:12]}-{'4' * 12}.json" + ) + assert backup.read_bytes() == original_bytes + if os.name != "nt": + assert stat.S_IMODE(backup.stat().st_mode) == 0o600 + assert persisted["status"] == "success" + assert persisted["last_attempt"] == json.loads(original_bytes)["last_attempt"] + + +@pytest.mark.parametrize( + ("name", "field", "value"), + [ + ("workmesh-api-1", "image_id", "sha256:" + "9" * 64), + ("workmesh-api-1", "image_tree", "d" * 40), + ("workmesh-api-1", "container_id", "e" * 64), + ], +) +def test_workmesh_n8n_reattest_rejects_other_runtime_drift( + deploy_module, tmp_path, monkeypatch, name, field, value +): + ( + revision, + project, + projects, + receipt, + artifacts, + state_path, + original_bytes, + ) = _workmesh_legacy_reattest_setup(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_path.parent) + _install_workmesh_reattest_runtime( + deploy_module, + monkeypatch, + receipt, + artifacts, + state_path, + drift=(name, field, value), + ) + + with pytest.raises(deploy_module.DeployError, match="runtime drift"): + deploy_module.reattest_workmesh_n8n_receipt( + projects, project, tmp_path, revision + ) + + assert state_path.read_bytes() == original_bytes + assert list(state_path.parent.glob("workmesh-before-n8n-reattest-*.json")) == [] + + +def test_workmesh_n8n_reattest_requires_compose_digest_from_live_revision( + deploy_module, tmp_path, monkeypatch +): + ( + revision, + project, + projects, + receipt, + artifacts, + state_path, + original_bytes, + ) = _workmesh_legacy_reattest_setup(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_path.parent) + _install_workmesh_reattest_runtime( + deploy_module, monkeypatch, receipt, artifacts, state_path + ) + monkeypatch.setattr( + deploy_module, + "git_revision_compose_sha256", + lambda *_args: "d" * 64, + ) + + with pytest.raises(deploy_module.DeployError, match="Compose digest"): + deploy_module.reattest_workmesh_n8n_receipt( + projects, project, tmp_path, revision + ) + + assert state_path.read_bytes() == original_bytes + assert list(state_path.parent.glob("workmesh-before-n8n-reattest-*.json")) == [] + + +def test_workmesh_n8n_reattest_rejects_non_root_controller( + deploy_module, tmp_path, monkeypatch +): + revision, project, projects, _receipt_value, _artifacts, state_path, original_bytes = ( + _workmesh_legacy_reattest_setup(tmp_path) + ) + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_path.parent) + monkeypatch.setattr( + deploy_module, + "require_host_root", + lambda: (_ for _ in ()).throw( + deploy_module.DeployError("WorkMesh live receipt reattestation is host-root-only") + ), + ) + + with pytest.raises(deploy_module.DeployError, match="host-root-only"): + deploy_module.reattest_workmesh_n8n_receipt( + projects, project, tmp_path, revision + ) + + assert state_path.read_bytes() == original_bytes + + +def test_workmesh_n8n_mode_dispatches_under_both_controller_locks( + deploy_module, tmp_path, monkeypatch, capsys +): + revision = "a" * 40 + project_config = { + "slug": "workmesh", + "branch": "main", + "source_dir": str(tmp_path / "source"), + "compose_project": "workmesh", + "compose_files": ["compose.yaml"], + "containers": list(deploy_module.WORKMESH_LEGACY_CONTAINERS), + "exact_source": True, + "verify_oci_revision": True, + } + projects = {"jens/workmesh": project_config} + events = [] + + @contextmanager + def project_lock(path, _description): + events.append(("project-lock", path)) + yield + + @contextmanager + def global_lock(): + events.append(("global-lock",)) + yield + + monkeypatch.setattr(sys, "argv", [ + "deploy.py", "reattest-workmesh-n8n", "jens/workmesh", revision, + ]) + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "load_projects", lambda: projects) + monkeypatch.setattr(deploy_module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr( + deploy_module, "validate_commit", lambda _repo, _branch, requested: requested + ) + monkeypatch.setattr( + deploy_module, "deployment_metadata", lambda *_args: {"tree": "b" * 40} + ) + monkeypatch.setattr(deploy_module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(deploy_module, "exclusive_lock", project_lock) + monkeypatch.setattr(deploy_module, "exclusive_global_deploy_lock", global_lock) + + def reattest(configured_projects, project, repo, requested_revision): + assert configured_projects == projects + assert project["_repository_name"] == "jens/workmesh" + assert repo == tmp_path + assert requested_revision == revision + events.append(("reattest",)) + return {"revision": revision, "container": "workmesh-n8n-1", "status": "reattested"} + + monkeypatch.setattr(deploy_module, "reattest_workmesh_n8n_receipt", reattest) + + assert deploy_module.main() == 0 + + assert events == [ + ("project-lock", deploy_module.STATE_ROOT / "locks" / "workmesh.lock"), + ("global-lock",), + ("reattest",), + ] + assert json.loads(capsys.readouterr().out) == { + "repository": "jens/workmesh", + "revision": revision, + "container": "workmesh-n8n-1", + "status": "reattested", + } + + +def _workmesh_product_reattest_setup(deploy_module, tmp_path: Path): + revision = "a" * 40 + project = { + "slug": "workmesh", + "branch": "main", + "source_dir": (deploy_module.EXACT_SOURCE_ROOT / "workmesh").as_posix(), + "env_source": "/srv/example/appdata/workmesh/.env", + "compose_project": "workmesh", + "compose_files": ["compose.yaml"], + "containers": list(deploy_module.WORKMESH_PRODUCT_CONTAINERS), + "retired_compose_containers": [deploy_module.WORKMESH_RETIRED_CONTAINER], + "exact_source": True, + "verify_oci_revision": False, + "health_timeout": 300, + } + receipt = _receipt({**project, "_repository_name": "jens/workmesh"}, revision) + for index, name in enumerate(project["containers"], start=1): + receipt["containers"][name]["container_id"] = f"{index:x}" * 64 + receipt["containers"][name]["container_revision"] = "" + receipt["containers"][name]["image_revision"] = "" + receipt["containers"][name]["image_source"] = "" + receipt["containers"][name]["image_tree"] = "" + receipt["containers"][name]["image_version"] = "" + receipt["containers"][name]["image_created"] = "" + artifacts = { + "containers": copy.deepcopy(receipt["containers"]), + "compose_sha256": "c" * 64, + } + candidate_revision = "d" * 40 + state = { + "schema_version": 3, + "repository": "jens/workmesh", + "slug": "workmesh", + "compose_project": "workmesh", + "revision": revision, + "live_revision": revision, + "live_status": "unknown", + "live_legacy_provenance": False, + "live_artifacts": copy.deepcopy(artifacts), + "live_receipt": copy.deepcopy(receipt), + "rollback_snapshot": {}, + "status": "failed", + "message": "candidate deploy failed; live state needs reconciliation", + "updated_at_epoch": 100, + "artifacts": copy.deepcopy(artifacts), + "last_attempt": { + "revision": candidate_revision, + "status": "failed", + "message": "candidate deploy failed", + "updated_at_epoch": 101, + "metadata": {"revision": candidate_revision}, + }, + "previous_revision": "", + "rollback_restored_revision": "", + } + configured = copy.deepcopy(project) + project.update({ + "_repository_name": "jens/workmesh", + "_repository_path": str(tmp_path), + "_deployment_metadata": copy.deepcopy(receipt["build"]), + }) + state_root = tmp_path / "state" + state_root.mkdir() + state_path = state_root / "workmesh.json" + original_bytes = (json.dumps(state, indent=2) + "\n").encode() + state_path.write_bytes(original_bytes) + return revision, project, {"jens/workmesh": configured}, receipt, artifacts, state_path, original_bytes + + +def test_workmesh_product_n8n_recovery_repairs_only_n8n_id_after_failed_attempt( + deploy_module, tmp_path, monkeypatch +): + ( + revision, + project, + projects, + receipt, + artifacts, + state_path, + original_bytes, + ) = _workmesh_product_reattest_setup(deploy_module, tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_path.parent) + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "_root_owned", lambda _path: True) + monkeypatch.setattr(deploy_module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr( + deploy_module, + "exact_source_matches_revision", + lambda _repo, requested, _source: requested == revision, + ) + monkeypatch.setattr( + deploy_module, + "deployment_metadata", + lambda _repo, _requested, _repository, *, built_at=None: copy.deepcopy(receipt["build"]), + ) + monkeypatch.setattr( + deploy_module, + "git_revision_compose_sha256", + lambda *_args: artifacts["compose_sha256"], + ) + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: set(deploy_module.WORKMESH_PRODUCT_CONTAINERS), + ) + monkeypatch.setattr(deploy_module, "container_state", lambda _name: (True, "healthy", "workmesh")) + observed_receipt = copy.deepcopy(receipt) + observed_artifacts = copy.deepcopy(artifacts["containers"]) + observed_receipt["containers"]["workmesh-n8n-1"]["container_id"] = "f" * 64 + observed_artifacts["workmesh-n8n-1"]["container_id"] = "f" * 64 + monkeypatch.setattr( + deploy_module, + "verify_deployment", + lambda *_args, **_kwargs: copy.deepcopy(observed_receipt), + ) + monkeypatch.setattr( + deploy_module, + "deployment_container_artifacts", + lambda _project: copy.deepcopy(observed_artifacts), + ) + monkeypatch.setattr(deploy_module, "output", lambda *_args, **_kwargs: "n8n " + "e" * 64) + monkeypatch.setattr(deploy_module, "compose_command", lambda _project: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda name: { + "Id": "f" * 64, + "Image": "sha256:" + "1" * 64, + "Config": { + "Image": "workmesh-n8n-1:production", + "Labels": { + "com.docker.compose.project": "workmesh", + "com.docker.compose.service": "n8n", + "com.docker.compose.config-hash": "e" * 64, + } + }, + "State": {"Running": True, "Health": {"Status": "healthy"}}, + "Mounts": [ + { + "Type": "volume", + "Name": "workmesh_workmesh_n8n_data", + "Destination": "/home/node/.n8n", + "RW": True, + } + ], + } if name == "workmesh-n8n-1" else None, + ) + monkeypatch.setattr(deploy_module, "_fsync_directory", lambda _path: None) + monkeypatch.setattr( + deploy_module, + "verified_current_receipt", + lambda _repository, _project, _requested, _tree: json.loads( + state_path.read_text(encoding="utf-8") + )["live_receipt"], + ) + + result = deploy_module.reattest_workmesh_product_n8n_receipt( + projects, project, tmp_path, revision + ) + + persisted = json.loads(state_path.read_text(encoding="utf-8")) + assert result == { + "revision": revision, + "container": "workmesh-n8n-1", + "status": "reattested", + } + assert persisted["live_status"] == "healthy" + assert persisted["status"] == "failed" + assert persisted["last_attempt"] == json.loads(original_bytes)["last_attempt"] + assert persisted["live_receipt"]["containers"]["workmesh-n8n-1"]["container_id"] == "f" * 64 + assert persisted["live_artifacts"]["containers"]["workmesh-n8n-1"]["container_id"] == "f" * 64 + for name in deploy_module.WORKMESH_PRODUCT_CONTAINERS: + if name != "workmesh-n8n-1": + assert persisted["live_receipt"]["containers"][name] == receipt["containers"][name] + backup = state_path.parent / ( + f"workmesh-product-before-n8n-reattest-{revision[:12]}-{'3' * 12}.json" + ) + assert backup.read_bytes() == original_bytes + + +def test_workmesh_product_n8n_recovery_refuses_volume_or_config_drift( + deploy_module, tmp_path, monkeypatch +): + ( + revision, + project, + projects, + receipt, + artifacts, + state_path, + original_bytes, + ) = _workmesh_product_reattest_setup(deploy_module, tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_path.parent) + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "_root_owned", lambda _path: True) + monkeypatch.setattr(deploy_module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr(deploy_module, "exact_source_matches_revision", lambda *_args: True) + monkeypatch.setattr( + deploy_module, + "deployment_metadata", + lambda *_args, **_kwargs: copy.deepcopy(receipt["build"]), + ) + monkeypatch.setattr(deploy_module, "git_revision_compose_sha256", lambda *_args: artifacts["compose_sha256"]) + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda _name: set(deploy_module.WORKMESH_PRODUCT_CONTAINERS)) + monkeypatch.setattr(deploy_module, "container_state", lambda _name: (True, "healthy", "workmesh")) + observed_receipt = copy.deepcopy(receipt) + observed_receipt["containers"]["workmesh-n8n-1"]["container_id"] = "f" * 64 + observed_artifacts = copy.deepcopy(artifacts["containers"]) + observed_artifacts["workmesh-n8n-1"]["container_id"] = "f" * 64 + monkeypatch.setattr(deploy_module, "verify_deployment", lambda *_args, **_kwargs: copy.deepcopy(observed_receipt)) + monkeypatch.setattr(deploy_module, "deployment_container_artifacts", lambda _project: copy.deepcopy(observed_artifacts)) + monkeypatch.setattr(deploy_module, "output", lambda *_args, **_kwargs: "n8n " + "e" * 64) + monkeypatch.setattr(deploy_module, "compose_command", lambda _project: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda _name: { + "Id": "f" * 64, + "Image": "sha256:" + "1" * 64, + "Config": {"Image": "workmesh-n8n-1:production", "Labels": { + "com.docker.compose.project": "workmesh", + "com.docker.compose.service": "n8n", + "com.docker.compose.config-hash": "e" * 64, + }}, + "State": {"Running": True, "Health": {"Status": "healthy"}}, + "Mounts": [{ + "Type": "volume", + "Name": "wrong_workmesh_n8n_data", + "Destination": "/home/node/.n8n", + "RW": True, + }], + }, + ) + + with pytest.raises(deploy_module.DeployError, match="n8n volume"): + deploy_module.reattest_workmesh_product_n8n_receipt(projects, project, tmp_path, revision) + + assert state_path.read_bytes() == original_bytes + assert list(state_path.parent.glob("workmesh-product-before-n8n-reattest-*.json")) == [] + + +def test_workmesh_product_n8n_recovery_dispatches_under_both_locks( + deploy_module, tmp_path, monkeypatch, capsys +): + revision = "a" * 40 + configured = { + "slug": "workmesh", + "branch": "main", + "source_dir": (deploy_module.EXACT_SOURCE_ROOT / "workmesh").as_posix(), + "env_source": "/srv/example/appdata/workmesh/.env", + "compose_project": "workmesh", + "compose_files": ["compose.yaml"], + "containers": list(deploy_module.WORKMESH_PRODUCT_CONTAINERS), + "retired_compose_containers": [deploy_module.WORKMESH_RETIRED_CONTAINER], + "exact_source": True, + "verify_oci_revision": False, + "health_timeout": 300, + } + projects = {"jens/workmesh": configured} + events = [] + + @contextmanager + def project_lock(path, _description): + events.append(("project-lock", path)) + yield + + @contextmanager + def global_lock(): + events.append(("global-lock",)) + yield + + monkeypatch.setattr(sys, "argv", [ + "deploy.py", "reattest-workmesh-product-n8n", "jens/workmesh", revision, + ]) + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "load_projects", lambda: projects) + monkeypatch.setattr(deploy_module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(deploy_module, "validate_commit", lambda _repo, _branch, value: value) + monkeypatch.setattr(deploy_module, "deployment_metadata", lambda *_args: {"tree": "b" * 40}) + monkeypatch.setattr(deploy_module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(deploy_module, "exclusive_lock", project_lock) + monkeypatch.setattr(deploy_module, "exclusive_global_deploy_lock", global_lock) + + def reattest(received_projects, project, repo, requested_revision): + assert received_projects == projects + assert project["_repository_name"] == "jens/workmesh" + assert repo == tmp_path + assert requested_revision == revision + events.append(("reattest-product",)) + return {"revision": revision, "container": "workmesh-n8n-1", "status": "reattested"} + + monkeypatch.setattr(deploy_module, "reattest_workmesh_product_n8n_receipt", reattest) + + assert deploy_module.main() == 0 + + assert events == [ + ("project-lock", deploy_module.STATE_ROOT / "locks" / "workmesh.lock"), + ("global-lock",), + ("reattest-product",), + ] + assert json.loads(capsys.readouterr().out) == { + "repository": "jens/workmesh", + "revision": revision, + "container": "workmesh-n8n-1", + "status": "reattested", + } + + +def test_workmesh_topology_transition_requires_exact_registry_and_recorded_receipt( + deploy_module, tmp_path +): + project = _workmesh_product_project(tmp_path) + previous = {"live_receipt": {"containers": { + name: {"image_id": "sha256:" + "1" * 64} + for name in deploy_module.WORKMESH_LEGACY_CONTAINERS + }}} + assert deploy_module.workmesh_transition_enabled(project) + assert deploy_module.workmesh_rollback_topology(project, previous)["containers"] == deploy_module.WORKMESH_LEGACY_CONTAINERS + assert deploy_module.workmesh_rollback_topology(project, {"live_receipt": {"containers": {}}}) is project + wrong = {**project, "retired_compose_containers": ["some-other-container"]} + assert not deploy_module.workmesh_transition_enabled(wrong) + assert deploy_module.workmesh_rollback_topology(wrong, previous) is wrong + + +def test_workmesh_product_deploy_removes_only_reviewed_compose_orphan( + deploy_module, tmp_path, monkeypatch +): + project = _workmesh_product_project(tmp_path) + commands = [] + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_: {}) + monkeypatch.setattr(deploy_module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(deploy_module, "prepare_compose_migration", lambda *_: []) + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda *_: set(deploy_module.WORKMESH_LEGACY_CONTAINERS)) + monkeypatch.setattr(deploy_module, "run", lambda command, **_: commands.append(command)) + deploy_module.deploy_project(project, "a" * 40, "jens/workmesh") + assert commands[-1] == ["docker", "compose", "up", "--detach", "--no-build", "--remove-orphans"] + other = _project(tmp_path) + deploy_module.deploy_project(other, "a" * 40, "jens/unraid-appops-gateway") + assert commands[-1] == ["docker", "compose", "up", "--detach", "--no-build"] + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda *_: set(deploy_module.WORKMESH_PRODUCT_CONTAINERS)) + deploy_module.deploy_project(project, "a" * 40, "jens/workmesh") + assert commands[-1] == ["docker", "compose", "up", "--detach", "--no-build"] + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda *_: set(deploy_module.WORKMESH_LEGACY_CONTAINERS) | {"unreviewed-sidecar"}) + with pytest.raises(deploy_module.DeployError, match="topology changed"): + deploy_module.deploy_project(project, "a" * 40, "jens/workmesh") + + +def test_workmesh_registry_transition_requires_live_proof_and_fixed_candidate( + deploy_module, tmp_path, monkeypatch +): + configured = _workmesh_product_project(tmp_path) + configured["containers"] = deploy_module.WORKMESH_LEGACY_CONTAINERS + configured.pop("retired_compose_containers") + configured["exact_source"] = True + projects = {"jens/workmesh": configured} + path = tmp_path / "projects.json" + path.write_text(json.dumps(projects), encoding="utf-8") + monkeypatch.setattr(deploy_module, "CONFIG_PATH", path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path) + project = {**configured, "_repository_name": "jens/workmesh"} + live_revision = "a" * 40 + revision = "b" * 40 + monkeypatch.setattr(deploy_module, "read_state", lambda *_: { + "live_revision": live_revision, + "live_status": "healthy", + "live_receipt": {"build": {"tree": "c" * 40}}, + }) + monkeypatch.setattr(deploy_module, "verified_current_receipt", lambda *_: {"verified": True}) + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda *_: set(deploy_module.WORKMESH_LEGACY_CONTAINERS)) + monkeypatch.setattr(deploy_module, "exact_source_matches_revision", lambda *_: True) + monkeypatch.setattr(deploy_module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_: {}) + monkeypatch.setattr(deploy_module, "output", lambda *_ , **__: "postgres\napi\nn8n\nworkmesh") + deploy_module.transition_workmesh_registry(projects, project, tmp_path, revision) + changed = json.loads(path.read_text(encoding="utf-8"))["jens/workmesh"] + assert changed["containers"] == deploy_module.WORKMESH_PRODUCT_CONTAINERS + assert changed["retired_compose_containers"] == ["workmesh-mock-ats-1"] + backup = tmp_path / f"workmesh-registry-before-product-{revision[:12]}.json" + assert json.loads(backup.read_text(encoding="utf-8")) == projects + with pytest.raises(deploy_module.DeployError, match="changed before topology transition"): + deploy_module.transition_workmesh_registry(projects, project, tmp_path, revision) + + +def test_workmesh_registry_transition_rejects_unverified_source_or_service( + deploy_module, tmp_path, monkeypatch +): + configured = _workmesh_product_project(tmp_path) + configured["containers"] = deploy_module.WORKMESH_LEGACY_CONTAINERS + configured.pop("retired_compose_containers") + configured["exact_source"] = True + projects = {"jens/workmesh": configured} + path = tmp_path / "projects.json" + path.write_text(json.dumps(projects), encoding="utf-8") + monkeypatch.setattr(deploy_module, "CONFIG_PATH", path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path) + project = {**configured, "_repository_name": "jens/workmesh"} + monkeypatch.setattr(deploy_module, "read_state", lambda *_: { + "live_revision": "a" * 40, + "live_status": "healthy", + "live_receipt": {"build": {"tree": "c" * 40}}, + }) + monkeypatch.setattr(deploy_module, "verified_current_receipt", lambda *_: None) + with pytest.raises(deploy_module.DeployError, match="live receipt"): + deploy_module.transition_workmesh_registry(projects, project, tmp_path, "b" * 40) + monkeypatch.setattr(deploy_module, "verified_current_receipt", lambda *_: {"verified": True}) + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda *_: set(deploy_module.WORKMESH_LEGACY_CONTAINERS)) + monkeypatch.setattr(deploy_module, "exact_source_matches_revision", lambda *_: True) + monkeypatch.setattr(deploy_module, "compose_command", lambda *_: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_: {}) + monkeypatch.setattr(deploy_module, "output", lambda *_ , **__: "postgres\napi\nn8n\nworkmesh\nmock-ats") + with pytest.raises(deploy_module.DeployError, match="four-service"): + deploy_module.transition_workmesh_registry(projects, project, tmp_path, "b" * 40) + assert json.loads(path.read_text(encoding="utf-8")) == projects + + +def test_failed_workmesh_product_deploy_retains_five_container_artifacts( + deploy_module, tmp_path, monkeypatch +): + project = _workmesh_product_project(tmp_path) + previous = {"live_revision": "a" * 40, "live_artifacts": {"containers": {}}, + "live_receipt": {"containers": { + name: {"image_id": "sha256:" + "1" * 64} + for name in deploy_module.WORKMESH_LEGACY_CONTAINERS + }}} + observed = [] + monkeypatch.setattr(deploy_module, "deployment_artifacts", lambda target: ( + observed.append(list(target["containers"])) or {"containers": { + name: {"image_id": "sha256:" + "1" * 64} for name in target["containers"] + }} + )) + result = deploy_module.resolve_failed_live_state( + previous, + {"revision": "a" * 40, "receipt": previous["live_receipt"]}, + project, + production_started=True, + failure_stage="deployment", + restore_error=None, + ) + assert observed == [deploy_module.WORKMESH_LEGACY_CONTAINERS] + assert set(result["live_artifacts"]["containers"]) == set(deploy_module.WORKMESH_LEGACY_CONTAINERS) + + +def test_workmesh_registry_rollback_accepts_only_exact_saved_baseline( + deploy_module, tmp_path, monkeypatch +): + original = _workmesh_product_project(tmp_path) + original["containers"] = deploy_module.WORKMESH_LEGACY_CONTAINERS + original.pop("retired_compose_containers") + current = _workmesh_product_project(tmp_path) + projects = {"jens/workmesh": current, "jens/other": {"slug": "other"}} + original_projects = {"jens/workmesh": original, "jens/other": {"slug": "other"}} + revision = "b" * 40 + path = tmp_path / "projects.json" + path.write_text(json.dumps(projects), encoding="utf-8") + (tmp_path / f"workmesh-registry-before-product-{revision[:12]}.json").write_text( + json.dumps(original_projects), encoding="utf-8" + ) + monkeypatch.setattr(deploy_module, "CONFIG_PATH", path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path) + backup = deploy_module.workmesh_registry_rollback_backup(projects, revision) + deploy_module.restore_workmesh_registry(projects, backup) + assert json.loads(path.read_text(encoding="utf-8")) == original_projects + path.write_text(json.dumps({**projects, "jens/other": {"slug": "changed"}}), encoding="utf-8") + with pytest.raises(deploy_module.DeployError, match="Registry changed"): + deploy_module.workmesh_registry_rollback_backup(projects, revision) + + +def test_workmesh_product_snapshot_retains_authorized_five_container_rollback( + deploy_module, tmp_path, monkeypatch +): + project = _workmesh_product_project(tmp_path) + previous = {**project, "containers": deploy_module.WORKMESH_LEGACY_CONTAINERS} + old_revision, new_revision = "a" * 40, "b" * 40 + old_receipt = _receipt(previous, old_revision) + snapshot = { + "revision": old_revision, + "receipt": old_receipt, + "containers": [{ + "name": name, + "image_id": "sha256:" + "1" * 64, + "image_ref": name + ":production", + "rollback_ref": f"gitea-deploy-rollback/workmesh-{index}:{old_revision[:12]}", + } for index, name in enumerate(deploy_module.WORKMESH_LEGACY_CONTAINERS)], + } + state_root = tmp_path / "state" + state_root.mkdir() + (state_root / "workmesh.json").write_text(json.dumps({ + "live_revision": new_revision, + "live_status": "healthy", + "rollback_snapshot": snapshot, + }), encoding="utf-8") + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_root) + monkeypatch.setattr(deploy_module, "inspect_image", lambda image_id: {"Id": image_id}) + assert deploy_module.durable_rollback_snapshot(project, "jens/workmesh", new_revision) == snapshot + + +def test_deployment_environment_exports_verified_build_date(deploy_module, tmp_path): + project = _project(tmp_path) + repository = "jens/unraid-appops-gateway" + revision = "a" * 40 + built_at = "2026-09-23T10:15:00.000Z" + metadata = {"repository": repository, "revision": revision, "builtAt": built_at} + + environment = deploy_module.deployment_environment(project, revision, repository, metadata) + + assert environment["APPOPS_BUILD_DATE"] == built_at + + +def test_deployment_environment_binds_workmesh_build_arg_to_exact_revision( + deploy_module, tmp_path, monkeypatch +): + revision = "a" * 40 + monkeypatch.delenv("WORKMESH_BUILD_REVISION", raising=False) + project = _workmesh_product_project(tmp_path) + project["environment"] = {"WORKMESH_BUILD_REVISION": "operator-override"} + + environment = deploy_module.deployment_environment(project, revision, "jens/workmesh") + + assert environment["WORKMESH_BUILD_REVISION"] == revision + other_environment = deploy_module.deployment_environment( + _project(tmp_path), revision, "jens/unraid-appops-gateway" + ) + assert "WORKMESH_BUILD_REVISION" not in other_environment + + +def test_deployment_environment_clears_unverified_build_date(deploy_module, tmp_path, monkeypatch): + project = _project(tmp_path) + repository = "jens/unraid-appops-gateway" + revision = "a" * 40 + monkeypatch.setenv("APPOPS_BUILD_DATE", "ambient-unverified-value") + metadata = { + "repository": "jens/another-repository", + "revision": revision, + "builtAt": "2026-09-23T10:15:00.000Z", + } + + environment = deploy_module.deployment_environment(project, revision, repository, metadata) + + assert environment["APPOPS_BUILD_DATE"] == "" + + +def test_deployment_environment_allows_project_build_date_override(deploy_module, tmp_path): + project = _project(tmp_path) + project["environment"] = {"APPOPS_BUILD_DATE": "operator-configured-value"} + repository = "jens/unraid-appops-gateway" + revision = "a" * 40 + metadata = { + "repository": repository, + "revision": revision, + "builtAt": "2026-09-23T10:15:00.000Z", + } + + environment = deploy_module.deployment_environment(project, revision, repository, metadata) + + assert environment["APPOPS_BUILD_DATE"] == "operator-configured-value" + + +def _receipt( + project: dict[str, object], + revision: str, + *, + tree: str = "b" * 40, +) -> dict[str, object]: + repository = str(project["_repository_name"]) + built_at = "2026-08-30T10:00:00.000Z" + digest = hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest() + build = { + "id": revision[:12], + "repository": repository, + "revision": revision, + "tree": tree, + "treeDigest": digest, + "builtAt": built_at, + } + containers = { + str(name): { + "container_id": "2" * 64, + "image_id": "sha256:" + "1" * 64, + "image_ref": f"{name}:production", + "image_revision": revision, + "image_source": repository, + "image_tree": tree, + "image_version": revision[:12], + "image_created": built_at, + } + for name in project["containers"] + } + live = None + if project.get("live_probe"): + live = { + "status": "live", + "build": { + key: build[key] + for key in ("id", "revision", "tree", "treeDigest", "builtAt") + }, + } + return { + "schema": "gitea-deploy-receipt.v1", + "repository": repository, + "revision": revision, + "tree": tree, + "treeDigest": digest, + "build": build, + "containers": containers, + "live": live, + } + + +def test_failed_attempts_never_replace_the_verified_live_revision( + deploy_module, tmp_path, monkeypatch +): + revision_a = "a" * 40 + revision_b = "b" * 40 + revision_c = "c" * 40 + project = _project(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path / "state") + state_path = deploy_module.STATE_ROOT / "unraid-appops-gateway.json" + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schema_version": 2, + "repository": "jens/unraid-appops-gateway", + "revision": revision_a, + "live_revision": revision_a, + "live_status": "healthy", + "live_artifacts": { + "containers": {"Unraid-AppOps-Gateway": {"image_id": "sha256:a"}} + }, + "status": "success", + } + ), + encoding="utf-8", + ) + + deploy_module.write_state( + "jens/unraid-appops-gateway", project, revision_b, "failed", "B failed" + ) + after_b = json.loads(state_path.read_text(encoding="utf-8")) + assert after_b["live_revision"] == revision_a + assert after_b["revision"] == revision_a + assert after_b["last_attempt"]["revision"] == revision_b + + deploy_module.write_state( + "jens/unraid-appops-gateway", project, revision_c, "failed", "C failed" + ) + after_c = json.loads(state_path.read_text(encoding="utf-8")) + assert after_c["live_revision"] == revision_a + assert after_c["last_attempt"]["revision"] == revision_c + + +def test_snapshot_is_optional_only_for_a_true_first_deploy( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path / "state") + monkeypatch.setattr( + deploy_module, "compose_project_container_names", lambda _name: set() + ) + monkeypatch.setattr(deploy_module, "inspect_container", lambda _name: None) + assert deploy_module.capture_compose_rollback(project) is None + + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda _name: { + "Id": "container-id", + "Image": "sha256:" + "1" * 64, + "Config": { + "Image": "unraid-appops-gateway:production", + "Labels": {"com.docker.compose.project": "unraid-appops-gateway"}, + }, + "State": {"Running": True}, + }, + ) + with pytest.raises( + deploy_module.DeployError, match="valid healthy live deployment state" + ): + deploy_module.capture_compose_rollback(project) + + +def test_snapshot_rejects_partial_container_sets_before_tagging( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + project["containers"] = ["one", "two"] + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path / "state") + monkeypatch.setattr( + deploy_module, "compose_project_container_names", lambda _name: set() + ) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda name: None if name == "two" else {"Id": "one"}, + ) + commands: list[list[str]] = [] + monkeypatch.setattr( + deploy_module, "run", lambda command, **_kwargs: commands.append(command) + ) + + with pytest.raises(deploy_module.DeployError, match="containers are missing"): + deploy_module.capture_compose_rollback(project) + assert commands == [] + + +def test_snapshot_rejects_receipt_for_another_revision_before_tagging( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + live_revision = "a" * 40 + receipt_revision = "b" * 40 + image_id = "sha256:" + "1" * 64 + bad_receipt = _receipt(project, receipt_revision) + monkeypatch.setattr( + deploy_module, + "read_state", + lambda _project: { + "schema_version": 3, + "repository": project["_repository_name"], + "live_revision": live_revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": { + "containers": {"Unraid-AppOps-Gateway": {"image_id": image_id}} + }, + "live_receipt": bad_receipt, + }, + ) + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Unraid-AppOps-Gateway"}, + ) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda _name: { + "Id": "2" * 64, + "Image": image_id, + "Config": { + "Image": "unraid-appops-gateway:production", + "Labels": {"com.docker.compose.project": "unraid-appops-gateway"}, + }, + "State": {"Running": True}, + }, + ) + monkeypatch.setattr( + deploy_module, + "inspect_image", + lambda _image: { + "Id": image_id, + "Config": { + "Labels": { + "org.opencontainers.image.revision": live_revision, + "org.opencontainers.image.source": project["_repository_name"], + } + }, + }, + ) + commands: list[list[str]] = [] + monkeypatch.setattr( + deploy_module, "run", lambda command, **_kwargs: commands.append(command) + ) + + with pytest.raises(deploy_module.DeployError, match="receipt identity"): + deploy_module.capture_compose_rollback(project) + assert commands == [] + + +def test_snapshot_rejects_unrecorded_same_project_containers( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Unraid-AppOps-Gateway", "unrecorded-sidecar"}, + ) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda _name: pytest.fail("container inspection must not start"), + ) + + with pytest.raises(deploy_module.DeployError, match="unrecorded containers"): + deploy_module.capture_compose_rollback(project) + + +def test_oci_verification_reads_the_immutable_image_not_container_override( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + requested = "b" * 40 + image_id = "sha256:" + "1" * 64 + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Unraid-AppOps-Gateway"}, + ) + monkeypatch.setattr( + deploy_module, + "container_state", + lambda _name: (True, "healthy", "unraid-appops-gateway"), + ) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda _name: { + "Image": image_id, + "Config": {"Labels": {"org.opencontainers.image.revision": requested}}, + }, + ) + monkeypatch.setattr( + deploy_module, + "inspect_image", + lambda _image_id: { + "Id": image_id, + "Config": { + "Labels": { + "org.opencontainers.image.revision": "a" * 40, + "org.opencontainers.image.source": "jens/unraid-appops-gateway", + } + }, + }, + ) + + with pytest.raises(deploy_module.DeployError, match="Immutable image provenance"): + deploy_module.verify_containers( + project, timeout=30, expected_revision=requested + ) + + +def test_forward_gate_rejects_same_drifted_or_older_revision( + deploy_module, tmp_path, monkeypatch +): + live = "b" * 40 + project = _project(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path / "state") + state_path = deploy_module.STATE_ROOT / "unraid-appops-gateway.json" + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps({"live_revision": live, "live_status": "healthy"}), + encoding="utf-8", + ) + + with pytest.raises(deploy_module.DeployError, match="runtime verification failed"): + deploy_module.validate_forward_revision(Path("repo.git"), project, live) + + monkeypatch.setattr( + deploy_module.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace(returncode=1), + ) + with pytest.raises(deploy_module.NonForwardDeployError): + deploy_module.validate_forward_revision(Path("repo.git"), project, "a" * 40) + + +def test_candidate_is_revalidated_against_the_current_branch_under_lock( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + project["branch"] = "main" + revision = "a" * 40 + metadata = { + "id": revision[:12], + "repository": project["_repository_name"], + "revision": revision, + "tree": "b" * 40, + "treeDigest": hashlib.sha256(f"git-tree:{'b' * 40}".encode()).hexdigest(), + "builtAt": "2026-08-30T10:00:00.000Z", + } + monkeypatch.setattr( + deploy_module, + "validate_commit", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + deploy_module.DeployError("commit is no longer on the branch") + ), + ) + with pytest.raises(deploy_module.DeployError, match="no longer on the branch"): + deploy_module.revalidate_deployment_candidate( + Path("repo.git"), + project, + revision, + str(project["_repository_name"]), + metadata, + ) + + +def test_bootstrap_legacy_cannot_downgrade_modern_state( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + revision = "a" * 40 + monkeypatch.setattr( + deploy_module, + "read_state", + lambda _project: { + "schema_version": 3, + "repository": project["_repository_name"], + "revision": revision, + "live_revision": revision, + "live_receipt": _receipt(project, revision), + "status": "success", + }, + ) + monkeypatch.setattr( + deploy_module, + "verify_containers", + lambda *_args, **_kwargs: pytest.fail("modern state must not be re-enrolled"), + ) + with pytest.raises(deploy_module.DeployError, match="modern schema"): + deploy_module.bootstrap_legacy_state( + str(project["_repository_name"]), project, revision + ) + + +def test_interrupted_legacy_recovery_requires_recorded_pre_mutation_guard( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + live_revision = "a" * 40 + candidate_revision = "b" * 40 + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path / "state") + state_path = deploy_module.STATE_ROOT / "unraid-appops-gateway.json" + state_path.parent.mkdir(parents=True) + state_path.write_text( + json.dumps( + { + "schema_version": 3, + "repository": project["_repository_name"], + "revision": live_revision, + "live_revision": live_revision, + "live_status": "unknown", + "live_legacy_provenance": False, + "live_artifacts": {"containers": {}}, + "live_receipt": {}, + "status": "failed", + "last_attempt": { + "revision": candidate_revision, + "status": "failed", + "message": ( + "Refusing to mutate a Compose project with unrecorded containers: " + "['unrecorded-sidecar']" + ), + "updated_at_epoch": 123, + }, + } + ), + encoding="utf-8", + ) + verified = [] + monkeypatch.setattr( + deploy_module, + "verify_containers", + lambda value: verified.append(value), + ) + artifacts = {"containers": {"Unraid-AppOps-Gateway": {"image_id": "legacy"}}} + monkeypatch.setattr(deploy_module, "deployment_artifacts", lambda _project: artifacts) + + deploy_module.recover_interrupted_legacy_state( + str(project["_repository_name"]), project, live_revision + ) + + recovered = json.loads(state_path.read_text(encoding="utf-8")) + assert verified == [project] + assert recovered["status"] == "success" + assert recovered["live_revision"] == live_revision + assert recovered["live_status"] == "healthy" + assert recovered["live_legacy_provenance"] is True + assert recovered["live_artifacts"] == artifacts + assert recovered["last_attempt"]["metadata"] == { + "recovery": "interrupted-legacy-pre-mutation-allowlist-guard", + "reconciled_from_attempt_revision": candidate_revision, + "reconciled_from_attempt_updated_at_epoch": 123, + } + + +@pytest.mark.parametrize( + "change", + [ + {"live_status": "healthy"}, + {"live_receipt": {"unverified": True}}, + {"last_attempt": {"status": "failed", "message": "source sync failed"}}, + { + "last_attempt": { + "status": "success", + "message": "Refusing to mutate a Compose project with unrecorded containers: []", + } + }, + ], +) +def test_interrupted_legacy_recovery_rejects_any_other_state( + deploy_module, tmp_path, monkeypatch, change +): + project = _project(tmp_path) + revision = "a" * 40 + state = { + "schema_version": 3, + "repository": project["_repository_name"], + "revision": revision, + "live_revision": revision, + "live_status": "unknown", + "live_legacy_provenance": False, + "live_artifacts": {"containers": {}}, + "live_receipt": {}, + "status": "failed", + "last_attempt": { + "revision": "b" * 40, + "status": "failed", + "message": "Refusing to mutate a Compose project with unrecorded containers: ['sidecar']", + }, + } + state.update(change) + monkeypatch.setattr(deploy_module, "read_state", lambda _project: state) + monkeypatch.setattr( + deploy_module, + "verify_containers", + lambda *_args, **_kwargs: pytest.fail("untrusted state must not inspect containers"), + ) + + with pytest.raises(deploy_module.DeployError, match="exact recorded pre-mutation"): + deploy_module.recover_interrupted_legacy_state( + str(project["_repository_name"]), project, revision + ) + + +def test_pre_mutation_failure_preserves_legacy_and_unknown_live_state( + deploy_module, tmp_path +): + project = _project(tmp_path) + revision = "a" * 40 + artifacts = {"containers": {"Unraid-AppOps-Gateway": {"image_id": "legacy"}}} + previous = { + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": True, + "live_artifacts": artifacts, + "live_receipt": {}, + } + + candidate_failure = deploy_module.resolve_failed_live_state( + previous, + None, + project, + production_started=False, + failure_stage="candidate-revalidation", + restore_error=None, + ) + assert candidate_failure == { + "live_revision": revision, + "live_artifacts": artifacts, + "live_status": "healthy", + "live_legacy_provenance": True, + "live_receipt": {}, + } + + previous["live_status"] = "unknown" + unknown_failure = deploy_module.resolve_failed_live_state( + previous, + None, + project, + production_started=False, + failure_stage="candidate-revalidation", + restore_error=None, + ) + assert unknown_failure["live_status"] == "unknown" + assert unknown_failure["live_legacy_provenance"] is True + + +def test_snapshot_capture_failure_preserves_evidence_but_marks_live_unknown( + deploy_module, tmp_path +): + project = _project(tmp_path) + revision = "a" * 40 + artifacts = {"containers": {"Unraid-AppOps-Gateway": {"image_id": "legacy"}}} + result = deploy_module.resolve_failed_live_state( + { + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": True, + "live_artifacts": artifacts, + "live_receipt": {}, + }, + None, + project, + production_started=False, + failure_stage="snapshot", + restore_error=None, + ) + assert result["live_revision"] == revision + assert result["live_status"] == "unknown" + assert result["live_legacy_provenance"] is True + assert result["live_artifacts"] == artifacts + + +def test_container_without_exact_compose_label_is_rejected( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + monkeypatch.setattr( + deploy_module, + "container_state", + lambda _name: (True, "healthy", ""), + ) + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Unraid-AppOps-Gateway"}, + ) + with pytest.raises(deploy_module.DeployError, match="wrong Compose project"): + deploy_module.verify_containers(project, timeout=30) + + +def test_post_deploy_verification_rejects_extra_compose_services( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + monkeypatch.setattr( + deploy_module, + "container_state", + lambda _name: (True, "healthy", "unraid-appops-gateway"), + ) + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Unraid-AppOps-Gateway", "unreviewed-sidecar"}, + ) + with pytest.raises(deploy_module.DeployError, match="container set"): + deploy_module.verify_containers(project, timeout=30) + + +def test_rollback_removes_only_containers_created_after_snapshot( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + snapshot = { + "compose_containers": ["Unraid-AppOps-Gateway"], + "containers": [{"name": "Unraid-AppOps-Gateway"}], + } + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Unraid-AppOps-Gateway", "failed-revision-sidecar"}, + ) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda name: { + "Config": { + "Labels": {"com.docker.compose.project": "unraid-appops-gateway"} + } + } + if name == "failed-revision-sidecar" + else None, + ) + commands: list[list[str]] = [] + monkeypatch.setattr( + deploy_module, "run", lambda command, **_kwargs: commands.append(command) + ) + + deploy_module.remove_attempt_only_compose_containers(project, snapshot) + assert commands == [["docker", "rm", "--force", "failed-revision-sidecar"]] + + +def _projectbrain_rollback_fixture(tmp_path): + project = {"slug": "projectbrain", "source_dir": str(tmp_path), "deploy_script": "scripts/deploy-unraid-single.sh", + "containers": ["projectbrain"], "compose_project": "projectbrain", + "_repository_name": "jens/projectbrain"} + binding = {"app_dir": str(tmp_path.resolve()), "data_path": (tmp_path / "data").as_posix(), + "container_name": "projectbrain", "image_ref": "projectbrain-unraid:latest", + "image_id": "sha256:" + "a" * 64} + template = tmp_path / "docker/unraid/projectbrain-container.env.example" + template.parent.mkdir(parents=True) + template.write_text("PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES=true\nPROJECTBRAIN_REMOVE_OLD_POSTGRES=true\n") + values = {"PROJECTBRAIN_IMAGE": binding["image_ref"], "PROJECTBRAIN_CONTAINER_NAME": "projectbrain", + "PROJECTBRAIN_APPDATA_PATH": binding["data_path"], + "PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES": "false", "PROJECTBRAIN_REMOVE_OLD_POSTGRES": "false"} + for key in ("POSTGRES_PASSWORD", "PROJECTBRAIN_OWNER_PASSWORD", "PROJECTBRAIN_AUTH_SECRET", + "PROJECTBRAIN_HUB_SERVICE_TOKEN", "PROJECTBRAIN_GITEA_WEBHOOK_SECRET"): + values[key] = "fixture-only-not-live" + (tmp_path / ".env").write_text("".join(f"{key}={value}\n" for key, value in values.items())) + return project, binding + + +def test_single_container_rollback_never_starts_legacy_compose(deploy_module, monkeypatch, tmp_path): + project, binding = _projectbrain_rollback_fixture(tmp_path) + calls = [] + monkeypatch.setattr(deploy_module, "run", lambda command, **kw: calls.append((command, kw))) + monkeypatch.setattr(deploy_module, "compose_command", lambda _: pytest.fail("wrong topology")) + monkeypatch.setattr(deploy_module, "inspect_image", lambda _: {"Id": binding["image_id"]}) + env = {"VITE_PROJECTBRAIN_COMMIT": "a" * 40} + safe = deploy_module.projectbrain_rollback_environment(project, binding, env) + deploy_module.restore_snapshot_runtime(project, "jens/projectbrain", "b" * 40, env, binding) + assert calls == [(["sh", str(tmp_path / "scripts/deploy-unraid-single.sh"), "--skip-pull", "--skip-build"], + {"cwd": tmp_path, "env": safe})] + + +@pytest.mark.parametrize("assignment", [ + "PROJECTBRAIN_DEPLOY_SKIP_BUILD=false", "PROJECTBRAIN_DEPLOY_SKIP_PULL=false", + "PROJECTBRAIN_DEPLOY_REF=main", "PROJECTBRAIN_IMAGE=other:latest", + "PROJECTBRAIN_CONTAINER_NAME=other", "PROJECTBRAIN_APPDATA_PATH=/other", + "PROJECTBRAIN_STOP_COMPOSE_APP_SERVICES=true", "PROJECTBRAIN_REMOVE_OLD_POSTGRES=true", +]) +def test_projectbrain_file_control_drift_fails_before_mutation(deploy_module, tmp_path, monkeypatch, assignment): + project, binding = _projectbrain_rollback_fixture(tmp_path) + key = assignment.split("=", 1)[0] + path = tmp_path / ".env" + lines = [line for line in path.read_text().splitlines() if not line.startswith(key + "=")] + path.write_text("\n".join([*lines, assignment]) + "\n") + monkeypatch.setattr(deploy_module, "run", lambda *args, **kw: pytest.fail("unsafe mutation")) + with pytest.raises(deploy_module.DeployError, match="conflicts"): + deploy_module.restore_snapshot_runtime(project, "jens/projectbrain", "b" * 40, {}, binding) + + +def test_projectbrain_missing_live_key_cannot_import_unsafe_template_default(deploy_module, tmp_path): + project, binding = _projectbrain_rollback_fixture(tmp_path) + path = tmp_path / ".env" + path.write_text(path.read_text().replace("PROJECTBRAIN_REMOVE_OLD_POSTGRES=false\n", "")) + with pytest.raises(deploy_module.DeployError, match="REMOVE_OLD_POSTGRES"): + deploy_module.projectbrain_rollback_environment(project, binding, {}) + + +def test_projectbrain_inherited_rollback_controls_are_forced_without_shell_evaluation(deploy_module, tmp_path): + project, binding = _projectbrain_rollback_fixture(tmp_path) + inherited = {"PROJECTBRAIN_DEPLOY_REF": "main", "PROJECTBRAIN_DEPLOY_SKIP_BUILD": "false", + "PROJECTBRAIN_DEPLOY_SKIP_PULL": "false", "PROJECTBRAIN_REMOVE_OLD_POSTGRES": "true", + "ENV": "/unreviewed/startup", "BASH_ENV": "/unreviewed/startup"} + result = deploy_module.projectbrain_rollback_environment(project, binding, inherited) + assert result["PROJECTBRAIN_DEPLOY_REF"] == "" + assert result["PROJECTBRAIN_DEPLOY_SKIP_BUILD"] == "true" + assert result["PROJECTBRAIN_DEPLOY_SKIP_PULL"] == "true" + assert result["PROJECTBRAIN_REMOVE_OLD_POSTGRES"] == "false" + assert "ENV" not in result and "BASH_ENV" not in result + assert inherited["PROJECTBRAIN_DEPLOY_REF"] == "main" + + +@pytest.mark.parametrize("key", ["PROJECTBRAIN_IMAGE", "PROJECTBRAIN_CONTAINER_NAME", "PROJECTBRAIN_APP_DIR", "PROJECTBRAIN_APPDATA_PATH"]) +def test_projectbrain_inherited_target_drift_fails_closed(deploy_module, tmp_path, key): + project, binding = _projectbrain_rollback_fixture(tmp_path) + with pytest.raises(deploy_module.DeployError, match="inherited"): + deploy_module.projectbrain_rollback_environment(project, binding, {key: "other"}) + + +@pytest.mark.parametrize("line", ["PROJECTBRAIN_TEST=$(id)", "PROJECTBRAIN_TEST=`id`", + "PROJECTBRAIN_TEST=value; true", "APP_DIR=/other", "PATH=/other"]) +def test_projectbrain_environment_never_evaluates_shell_input(deploy_module, tmp_path, line): + project, binding = _projectbrain_rollback_fixture(tmp_path) + path = tmp_path / ".env" + path.write_text(path.read_text() + line + "\n") + with pytest.raises(deploy_module.DeployError): + deploy_module.projectbrain_rollback_environment(project, binding, {}) + + +def test_projectbrain_snapshot_preflight_precedes_image_tag_mutations(deploy_module, tmp_path, monkeypatch): + project, binding = _projectbrain_rollback_fixture(tmp_path) + (tmp_path / ".env").write_text((tmp_path / ".env").read_text().replace("PROJECTBRAIN_REMOVE_OLD_POSTGRES=false", "PROJECTBRAIN_REMOVE_OLD_POSTGRES=true")) + runtime = {"Id": "c" * 64, "Image": binding["image_id"], + "Config": {"Image": binding["image_ref"], "Labels": {"com.docker.compose.project": "projectbrain"}}, + "Mounts": [{"Type": "bind", "Source": binding["data_path"], "Destination": "/var/lib/projectbrain", "RW": True}], + "State": {"Running": True}} + state = {"status": "success", "revision": "a" * 40, "live_legacy_provenance": True, + "artifacts": {"containers": {"projectbrain": {"image_id": binding["image_id"]}}}} + monkeypatch.setattr(deploy_module, "read_state", lambda _: state) + monkeypatch.setattr(deploy_module, "compose_project_container_names", lambda _: {"projectbrain"}) + monkeypatch.setattr(deploy_module, "inspect_container", lambda _: runtime) + monkeypatch.setattr(deploy_module, "inspect_image", lambda _: {"Id": binding["image_id"]}) + monkeypatch.setattr(deploy_module, "run", lambda *args, **kw: pytest.fail("snapshot mutation before preflight")) + with pytest.raises(deploy_module.DeployError, match="REMOVE_OLD_POSTGRES"): + deploy_module.capture_compose_rollback(project) + + +def test_projectbrain_rollback_rechecks_restored_immutable_tag(deploy_module, tmp_path, monkeypatch): + project, binding = _projectbrain_rollback_fixture(tmp_path) + monkeypatch.setattr(deploy_module, "inspect_image", lambda _: {"Id": "wrong"}) + monkeypatch.setattr(deploy_module, "run", lambda *args, **kw: pytest.fail("wrong image mutation")) + with pytest.raises(deploy_module.DeployError, match="immutable snapshot"): + deploy_module.restore_snapshot_runtime(project, "jens/projectbrain", "b" * 40, {}, binding) + + +@pytest.mark.parametrize("key,value", [("DOCKER_HOST", "tcp://elsewhere.invalid:2376"), + ("DOCKER_CONTEXT", "other"), ("DOCKER_TLS_VERIFY", "1")]) +def test_projectbrain_rollback_rejects_inherited_docker_target(deploy_module, tmp_path, key, value): + project, binding = _projectbrain_rollback_fixture(tmp_path) + with pytest.raises(deploy_module.DeployError, match="controller-local Docker"): + deploy_module.projectbrain_rollback_environment(project, binding, {key: value}) + + +def test_projectbrain_rollback_revalidates_before_any_restore_mutation(deploy_module, tmp_path, monkeypatch): + project, binding = _projectbrain_rollback_fixture(tmp_path) + path = tmp_path / ".env" + path.write_text(path.read_text() + "PROJECTBRAIN_DEPLOY_SKIP_BUILD=false\n") + monkeypatch.setattr(deploy_module, "run", lambda *args, **kw: pytest.fail("mutation before guard")) + monkeypatch.setattr(deploy_module, "sync_source", lambda *args, **kw: pytest.fail("source restore before guard")) + snapshot = {"revision": "a" * 40, "containers": [], "script_binding": binding} + with pytest.raises(deploy_module.DeployError, match="DEPLOY_SKIP_BUILD"): + deploy_module.restore_compose_snapshot(project, snapshot, "jens/projectbrain", "b" * 40) + + +def test_projectbrain_mount_binding_rejects_wrong_or_additional_data_targets(deploy_module, tmp_path): + project, binding = _projectbrain_rollback_fixture(tmp_path) + mount = {"Type": "bind", "Source": binding["data_path"], "Destination": "/wrong", "RW": True} + container = {"Config": {"Image": binding["image_ref"]}, "Image": binding["image_id"], "Mounts": [mount]} + with pytest.raises(deploy_module.DeployError, match="data mount"): + deploy_module.projectbrain_snapshot_binding(project, container) + mount["Destination"] = "/var/lib/projectbrain" + container["Mounts"].append(dict(mount)) + with pytest.raises(deploy_module.DeployError, match="data mount"): + deploy_module.projectbrain_snapshot_binding(project, container) + + +def test_projectbrain_static_parse_error_does_not_echo_sensitive_values(deploy_module, tmp_path): + project, binding = _projectbrain_rollback_fixture(tmp_path) + value = "fixture-sensitive-value-with-$-syntax" + path = tmp_path / ".env" + path.write_text(path.read_text() + f"PROJECTBRAIN_TEST={value}\n") + with pytest.raises(deploy_module.DeployError) as raised: + deploy_module.projectbrain_rollback_environment(project, binding, {}) + assert value not in str(raised.value) + + +@pytest.mark.parametrize("change", [ + {"deploy_script": "unknown.sh"}, + {"archive_deploy_script": "archive.sh"}, + {"containers": ["other"]}, +]) +def test_unreviewed_script_rollback_fails_closed(deploy_module, monkeypatch, tmp_path, change): + project = {"source_dir": str(tmp_path), "deploy_script": "scripts/deploy-unraid-single.sh", + "containers": ["projectbrain"], **change} + monkeypatch.setattr(deploy_module, "run", lambda *a, **kw: pytest.fail("unreviewed mutation")) + with pytest.raises(deploy_module.DeployError, match="reviewed immutable rollback"): + deploy_module.restore_snapshot_runtime(project, "jens/projectbrain", "b" * 40, {}) + + +def test_rollback_image_tag_lifecycle_preserves_only_current_snapshot( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + project["slug"] = "chimera" + current = "gitea-deploy-rollback/chimera-0:aaaaaaaaaaaa" + stale = "gitea-deploy-rollback/chimera-0:bbbbbbbbbbbb" + commands: list[list[str]] = [] + + def fake_run(command, **_kwargs): + commands.append(command) + if command[:3] == ["docker", "image", "ls"]: + return types.SimpleNamespace( + returncode=0, + stdout=f"{current}\n{stale}\n", + stderr="", + ) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(deploy_module.subprocess, "run", fake_run) + deploy_module.cleanup_rollback_image_tags(project, {current}) + assert ["docker", "image", "rm", stale] in commands + assert ["docker", "image", "rm", current] not in commands + + +def test_rollback_image_cleanup_is_project_exact_and_never_raises( + deploy_module, tmp_path, monkeypatch +): + project = _project(tmp_path) + project["slug"] = "chimera" + owned = "gitea-deploy-rollback/chimera-12:aaaaaaaaaaaa" + colliding_project = "gitea-deploy-rollback/chimera-preview-0:bbbbbbbbbbbb" + noncanonical_tag = "gitea-deploy-rollback/chimera-0:latest" + commands: list[list[str]] = [] + + def fake_run(command, **_kwargs): + commands.append(command) + if command[:3] == ["docker", "image", "ls"]: + return types.SimpleNamespace( + returncode=0, + stdout=f"{owned}\n{colliding_project}\n{noncanonical_tag}\n", + stderr="", + ) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(deploy_module.subprocess, "run", fake_run) + deploy_module.cleanup_rollback_image_tags(project) + assert ["docker", "image", "rm", owned] in commands + assert ["docker", "image", "rm", colliding_project] not in commands + assert ["docker", "image", "rm", noncanonical_tag] not in commands + + monkeypatch.setattr( + deploy_module.subprocess, + "run", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("docker unavailable")), + ) + deploy_module.cleanup_rollback_image_tags(project) + + +def test_commit_and_tree_metadata_are_controller_derived(deploy_module, monkeypatch): + revision = "a" * 40 + tree = "b" * 40 + calls: list[list[str]] = [] + + def fake_output(command, **_kwargs): + calls.append(command) + if command[-1].endswith("^{commit}"): + return revision + if command[-1].endswith("^{tree}"): + return tree + if command[-1].startswith("refs/heads/"): + return revision + raise AssertionError(command) + + monkeypatch.setattr(deploy_module, "output", fake_output) + monkeypatch.setattr( + deploy_module.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace(returncode=0), + ) + + assert deploy_module.validate_commit(Path("repo.git"), "main", revision) == revision + metadata = deploy_module.deployment_metadata( + Path("repo.git"), + revision, + "jens/chimera", + built_at="2026-08-30T10:00:00Z", + ) + assert metadata == { + "id": revision[:12], + "repository": "jens/chimera", + "revision": revision, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": "2026-08-30T10:00:00.000Z", + } + assert any(command[-1] == f"{revision}^{{tree}}" for command in calls) + + monkeypatch.setattr(deploy_module, "output", lambda *_args, **_kwargs: "c" * 40) + with pytest.raises( + deploy_module.DeployError, match="object itself must be a commit" + ): + deploy_module.validate_commit(Path("repo.git"), "main", revision) + + +def test_exact_source_sync_replaces_dirty_context_from_a_fresh_archive( + deploy_module, tmp_path, monkeypatch +): + exact_root = tmp_path / "managed-sources" + target = exact_root / "chimera" + target.mkdir(parents=True) + (target / "tracked.txt").write_text("old", encoding="utf-8") + (target / "untracked-runtime.js").write_text("dirty", encoding="utf-8") + revision = "a" * 40 + + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", exact_root) + monkeypatch.setattr(deploy_module, "tracked_files", lambda *_args: ["tracked.txt"]) + + def fake_extract(_repo, _revision, destination): + (destination / "tracked.txt").write_text("reviewed", encoding="utf-8") + + monkeypatch.setattr(deploy_module, "extract_revision", fake_extract) + deploy_module.sync_source(Path("repo.git"), revision, target, "chimera", exact=True) + + assert (target / "tracked.txt").read_text(encoding="utf-8") == "reviewed" + assert not (target / "untracked-runtime.js").exists() + assert not (target / ".gitea-deploy").exists() + assert deploy_module.exact_source_matches_revision( + Path("repo.git"), revision, target + ) + + outside = tmp_path / "not-controller-owned" + with pytest.raises(deploy_module.DeployError, match="controller-owned source root"): + deploy_module.sync_source( + Path("repo.git"), revision, outside, "chimera", exact=True + ) + + +def test_exact_repo_sources_apply_group_read_policy_only_to_fixed_slugs( + deploy_module, tmp_path, monkeypatch +): + exact_root = tmp_path / "managed-sources" + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", exact_root) + monkeypatch.setattr(deploy_module, "tracked_files", lambda *_args: ["tracked.txt"]) + + def fake_extract(_repo, _revision, destination): + (destination / "tracked.txt").write_text("reviewed", encoding="utf-8") + + monkeypatch.setattr(deploy_module, "extract_revision", fake_extract) + prepared = [] + monkeypatch.setattr( + deploy_module, + "_prepare_exact_source_group_read", + lambda target, slug: prepared.append((slug, target.parent.resolve() == exact_root.resolve())), + raising=False, + ) + + for slug in ("repopulse", "privatemuse", "other-project"): + deploy_module.sync_source( + Path("repo.git"), "a" * 40, exact_root / slug, slug, exact=True, + ) + + assert prepared == [("repopulse", True), ("privatemuse", True)] + + +@pytest.mark.parametrize("slug", ["repopulse", "privatemuse"]) +def test_exact_source_permission_failure_preserves_the_previous_live_tree( + deploy_module, tmp_path, monkeypatch, slug +): + exact_root = tmp_path / "sources" + target = exact_root / slug + target.mkdir(parents=True) + (target / "tracked.txt").write_text("previous-live-source", encoding="utf-8") + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", exact_root) + monkeypatch.setattr(deploy_module, "tracked_files", lambda *_args: ["tracked.txt"]) + + def fake_extract(_repo, _revision, destination): + (destination / "tracked.txt").write_text("candidate-source", encoding="utf-8") + + def deny_permission_setup(_target, _slug): + raise deploy_module.DeployError("fixed source permission setup failed") + + monkeypatch.setattr(deploy_module, "extract_revision", fake_extract) + monkeypatch.setattr(deploy_module, "_prepare_exact_source_group_read", deny_permission_setup) + + with pytest.raises(deploy_module.DeployError, match="permission setup failed"): + deploy_module.sync_source(Path("repo.git"), "a" * 40, target, slug, exact=True) + + assert (target / "tracked.txt").read_text(encoding="utf-8") == "previous-live-source" + assert sorted(item.name for item in exact_root.iterdir()) == [slug] + + +@pytest.mark.skipif(os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, reason="requires root-owned Linux source paths") +def test_fixed_source_group_policy_is_read_only_and_keeps_other_siblings_private( + deploy_module, tmp_path, monkeypatch +): + exact_root = tmp_path / "sources" + exact_root.mkdir() + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", exact_root) + readable_sources = [] + + for slug in ("repopulse", "privatemuse"): + staging = exact_root / f".{slug}-aabbccddeeff-stage" + staging.mkdir(mode=0o700) + nested = staging / "nested" + nested.mkdir(mode=0o700) + ordinary = nested / "module.py" + ordinary.write_text("print('scan')\n", encoding="utf-8") + ordinary.chmod(0o600) + executable = nested / "tool" + executable.write_text("#!/bin/sh\n", encoding="utf-8") + executable.chmod(0o700) + deploy_module._prepare_exact_source_group_read(staging, slug) + readable_sources.append(staging) + + private_sibling = exact_root / "other-project" + private_sibling.mkdir(mode=0o700) + private_before = private_sibling.stat() + + for staging in readable_sources: + nested = staging / "nested" + assert staging.stat().st_gid == nested.stat().st_gid == 10001 + assert stat.S_IMODE(staging.stat().st_mode) == stat.S_IMODE(nested.stat().st_mode) == 0o2750 + assert (nested / "module.py").stat().st_gid == 10001 + assert stat.S_IMODE((nested / "module.py").stat().st_mode) == 0o640 + assert (nested / "tool").stat().st_gid == 10001 + assert stat.S_IMODE((nested / "tool").stat().st_mode) == 0o750 + + assert private_sibling.stat().st_gid == private_before.st_gid + assert stat.S_IMODE(private_sibling.stat().st_mode) == 0o700 + + +def test_controller_source_mode_contract_rejects_private_mode_700(status_module): + private_dir = os.stat_result((stat.S_IFDIR | 0o700, 10, 1, 1, 0, 10001, 0, 0, 0, 0)) + readable_dir = os.stat_result((stat.S_IFDIR | 0o2750, 11, 1, 1, 0, 10001, 0, 0, 0, 0)) + assert status_module._source_mode_matches_runtime_contract(private_dir) is False + assert status_module._source_mode_matches_runtime_contract(readable_dir) is True + + +def test_managed_repopulse_render_accepts_only_its_read_only_source_mount( + deploy_module, tmp_path, monkeypatch +): + source = tmp_path / "managed-source" / "repopulse" + project = { + "source_dir": str(source), + "managed_plan_version": "itworx.deployment/v1", + "managed_profile": "repopulse-authenticated", + "managed_application_id": "repopulse", + "managed_repository": "jens/repopulse", + "managed_appdata_path": "/srv/example/appdata/repopulse", + "managed_image_repository": "repopulse-managed", + "managed_image_tag": "production", + "managed_proxy_network": "itworx-mcp-hub-npm", + "managed_internal_port": 8000, + "managed_build_context": ".", + "managed_build_dockerfile": "deploy/managed/Dockerfile", + "managed_runtime_environment_keys": [ + "REPOPULSE_GITEA_URL", "REPOPULSE_GITEA_TOKEN", "REPOPULSE_MAINTENANCE_TOKEN", + ], + "managed_runtime_uid": 10001, + "managed_runtime_gid": 10001, + "managed_proxy_hostname": "repopulse.example.com", + "managed_health_path": "/health", + } + rendered = { + "services": { + "repopulse": { + "image": "repopulse-managed:production", + "build": {"context": str(source), "dockerfile": "deploy/managed/Dockerfile"}, + "restart": "unless-stopped", + "read_only": True, + "user": "10001:10001", + "security_opt": ["no-new-privileges:true"], + "cap_drop": ["ALL"], + "networks": ["itworx-mcp-hub-npm"], + "environment": { + "REPOPULSE_GITEA_URL": "https://gitea.example.com", + "REPOPULSE_GITEA_TOKEN": "opaque-gitea-value", + "REPOPULSE_MAINTENANCE_TOKEN": "opaque-maintenance-value", + }, + "volumes": [ + {"type": "bind", "source": "/srv/example/appdata/repopulse/data", "target": "/data", "read_only": False}, + {"type": "bind", "source": "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse", "target": "/scan/repopulse", "read_only": True}, + {"type": "bind", "source": "/mnt/cache/appdata/gitea-deploy-runners/sources/privatemuse", "target": "/scan/privatemuse", "read_only": True}, + ], + } + }, + "networks": {"itworx-mcp-hub-npm": {"external": True}}, + } + monkeypatch.setattr(deploy_module, "compose_command", lambda _project: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_args: {}) + monkeypatch.setattr(deploy_module, "managed_runtime_environment", lambda _project: rendered["services"]["repopulse"]["environment"]) + monkeypatch.setattr(deploy_module, "output", lambda *_args, **_kwargs: json.dumps(rendered)) + + deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/repopulse") + rendered["services"]["repopulse"]["volumes"][2]["source"] = "/srv/example/appdata/repopulse/sources" + with pytest.raises(deploy_module.DeployError): + deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/repopulse") + rendered["services"]["repopulse"]["volumes"][2] = dict(rendered["services"]["repopulse"]["volumes"][1]) + with pytest.raises(deploy_module.DeployError): + deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/repopulse") + + +def test_repopulse_project_registry_requires_its_fixed_build_identity( + deploy_module, monkeypatch +): + source = "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse" + project = { + "branch": "main", + "slug": "repopulse", + "source_dir": str(source), + "exact_source": True, + "compose_dir": str(source), + "compose_files": [".itworx/deployment.yml"], + "compose_project": "repopulse", + "containers": ["repopulse"], + "managed_plan_version": "itworx.deployment/v1", + "managed_profile": "repopulse-authenticated", + "managed_application_id": "repopulse", + "managed_repository": "jens/repopulse", + "managed_appdata_path": "/srv/example/appdata/repopulse", + "managed_image_repository": "repopulse-managed", + "managed_image_tag": "production", + "managed_proxy_network": "itworx-mcp-hub-npm", + "managed_internal_port": 8000, + "managed_build_context": ".", + "managed_build_dockerfile": "deploy/managed/Dockerfile", + "managed_runtime_environment_keys": [ + "REPOPULSE_GITEA_URL", "REPOPULSE_GITEA_TOKEN", "REPOPULSE_MAINTENANCE_TOKEN", + ], + "managed_runtime_uid": 10001, + "managed_runtime_gid": 10001, + "managed_proxy_hostname": "repopulse.example.com", + "managed_health_path": "/health", + "appops_deployment_secret_file": "jens__repopulse", + "appops_managed_target": True, + } + monkeypatch.setattr(deploy_module, "git_path_exists", lambda *_args: True) + + deploy_module.validate_project_config(Path("repo.git"), project, "a" * 40) + for field, value in ( + ("managed_repository", "jens/another-app"), + ("managed_build_dockerfile", "Dockerfile"), + ("managed_runtime_gid", 1000), + ("managed_proxy_network", "itworx-npm"), + ("source_dir", "/mnt/cache/appdata/gitea-deploy-runners/sources/other"), + ): + unsafe = {**project, field: value} + with pytest.raises(deploy_module.DeployError): + deploy_module.validate_project_config(Path("repo.git"), unsafe, "a" * 40) + + +def test_controller_metadata_path_is_reserved(deploy_module, monkeypatch): + monkeypatch.setattr( + deploy_module, + "output", + lambda *_args, **_kwargs: "compose.yaml\n.gitea-deploy/revision", + ) + with pytest.raises(deploy_module.DeployError, match="metadata path"): + deploy_module.tracked_files(Path("repo.git"), "a" * 40) + + +@pytest.mark.parametrize( + ("name", "linkname", "kind"), + [ + ("../outside.txt", "", "file"), + ("/absolute.txt", "", "file"), + ("safe/link", "../../outside.txt", "symlink"), + ("safe/link", "../outside.txt", "hardlink"), + ], +) +def test_git_archive_links_and_paths_cannot_escape_staging( + deploy_module, tmp_path, monkeypatch, name, linkname, kind +): + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as archive: + member = tarfile.TarInfo(name) + if kind == "symlink": + member.type = tarfile.SYMTYPE + member.linkname = linkname + archive.addfile(member) + elif kind == "hardlink": + member.type = tarfile.LNKTYPE + member.linkname = linkname + archive.addfile(member) + else: + payload = b"hostile" + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + archive_bytes = buffer.getvalue() + + def fake_run(_command, **kwargs): + kwargs["stdout"].write(archive_bytes) + return types.SimpleNamespace(returncode=0) + + monkeypatch.setattr(deploy_module.subprocess, "run", fake_run) + target = tmp_path / "staging" + target.mkdir() + with pytest.raises(deploy_module.DeployError): + deploy_module.extract_revision(Path("repo.git"), "a" * 40, target) + assert not (tmp_path / "outside.txt").exists() + + +def test_exact_source_manifest_failure_preserves_previous_tree( + deploy_module, tmp_path, monkeypatch +): + exact_root = tmp_path / "managed-sources" + target = exact_root / "chimera" + target.mkdir(parents=True) + (target / "live.txt").write_text("previous", encoding="utf-8") + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", exact_root) + monkeypatch.setattr(deploy_module, "tracked_files", lambda *_args: ["tracked.txt"]) + + def fake_extract(_repo, _revision, destination): + (destination / "tracked.txt").write_text("reviewed", encoding="utf-8") + (destination / "unexpected.txt").write_text("dirty", encoding="utf-8") + + monkeypatch.setattr(deploy_module, "extract_revision", fake_extract) + with pytest.raises(deploy_module.DeployError, match="file manifest"): + deploy_module.sync_source( + Path("repo.git"), "a" * 40, target, "chimera", exact=True + ) + assert (target / "live.txt").read_text(encoding="utf-8") == "previous" + + +def test_exact_source_atomic_swap_failure_restores_previous_tree( + deploy_module, tmp_path, monkeypatch +): + exact_root = tmp_path / "managed-sources" + target = exact_root / "chimera" + target.mkdir(parents=True) + (target / "tracked.txt").write_text("previous", encoding="utf-8") + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", exact_root) + monkeypatch.setattr(deploy_module, "tracked_files", lambda *_args: ["tracked.txt"]) + + def fake_extract(_repo, _revision, destination): + (destination / "tracked.txt").write_text("reviewed", encoding="utf-8") + + monkeypatch.setattr(deploy_module, "extract_revision", fake_extract) + original_replace = Path.replace + + def fail_staging_swap(self, destination): + if self.name.startswith(".chimera-") and Path(destination) == target: + raise OSError("simulated atomic swap failure") + return original_replace(self, destination) + + monkeypatch.setattr(Path, "replace", fail_staging_swap) + with pytest.raises(OSError, match="simulated atomic swap failure"): + deploy_module.sync_source( + Path("repo.git"), "a" * 40, target, "chimera", exact=True + ) + assert (target / "tracked.txt").read_text(encoding="utf-8") == "previous" + + +def test_exact_source_uses_external_compose_environment(deploy_module, tmp_path): + source = tmp_path / "source" + source.mkdir() + (source / "compose.yaml").write_text("services: {}\n", encoding="utf-8") + env_source = tmp_path / "deploy.env" + env_source.write_text("SECRET=not-copied\n", encoding="utf-8") + project = { + "source_dir": str(source), + "compose_dir": "", + "compose_files": ["compose.yaml"], + "compose_project": "chimera", + "exact_source": True, + "env_source": str(env_source), + } + + deploy_module.copy_environment(project, source) + _, command = deploy_module.compose_command(project) + assert not (source / ".env").exists() + assert command[:7] == [ + "docker", + "compose", + "--project-name", + "chimera", + "--env-file", + str(env_source), + "--file", + ] + + +def test_managed_nested_manifest_uses_exact_source_as_project_directory(deploy_module, tmp_path): + source = tmp_path / "source" + manifest = source / ".itworx" / "deployment.yml" + manifest.parent.mkdir(parents=True) + manifest.write_text("services: {}\n", encoding="utf-8") + project = { + "source_dir": str(source), + "compose_files": [".itworx/deployment.yml"], + "compose_project": "repopulse", + "exact_source": True, + "managed_plan_version": "itworx.deployment/v1", + } + + cwd, command = deploy_module.compose_command(project) + + assert cwd == source + assert command == [ + "docker", "compose", "--project-name", "repopulse", + "--project-directory", str(source), "--file", str(manifest), + ] + + +def test_immutable_image_and_live_probe_must_match_every_build_field( + deploy_module, tmp_path, monkeypatch +): + revision = "a" * 40 + tree = "b" * 40 + metadata = { + "id": revision[:12], + "repository": "jens/chimera", + "revision": revision, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": "2026-08-30T10:00:00.000Z", + } + project = _project(tmp_path) + project.update( + { + "slug": "chimera", + "compose_project": "chimera", + "containers": ["Chimera"], + "_repository_name": "jens/chimera", + "live_probe": { + "type": "chimera-health-live-v1", + "container": "Chimera", + "port": 6967, + "path": "/health/live", + "host": "chimera:6967", + }, + } + ) + image_id = "sha256:" + "1" * 64 + monkeypatch.setattr( + deploy_module, + "compose_project_container_names", + lambda _name: {"Chimera"}, + ) + labels = { + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source": "jens/chimera", + "org.opencontainers.image.source-tree": tree, + "org.opencontainers.image.version": revision[:12], + "org.opencontainers.image.created": metadata["builtAt"], + } + monkeypatch.setattr( + deploy_module, "container_state", lambda _name: (True, "healthy", "chimera") + ) + monkeypatch.setattr( + deploy_module, + "inspect_container", + lambda _name: {"Image": image_id, "Config": {"Labels": {}}}, + ) + monkeypatch.setattr( + deploy_module, + "inspect_image", + lambda _image_id: {"Id": image_id, "Config": {"Labels": dict(labels)}}, + ) + monkeypatch.setattr( + deploy_module.subprocess, + "run", + lambda *_args, **_kwargs: types.SimpleNamespace( + returncode=0, + stdout=json.dumps({"status": "live", "build": metadata}), + stderr="", + ), + ) + + receipt = deploy_module.verify_deployment(project, metadata) + assert receipt["build"] == metadata + assert receipt["containers"]["Chimera"]["image_id"] == image_id + + labels["org.opencontainers.image.source-tree"] = "c" * 40 + with pytest.raises(deploy_module.DeployError, match="Immutable image provenance"): + deploy_module.verify_deployment(project, metadata) + + +def test_live_probe_rejects_every_provenance_drift_and_hostile_response( + deploy_module, tmp_path, monkeypatch +): + revision = "a" * 40 + tree = "b" * 40 + metadata = { + "id": revision[:12], + "repository": "jens/chimera", + "revision": revision, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": "2026-08-30T10:00:00.000Z", + } + project = _project(tmp_path) + project.update( + { + "containers": ["Chimera"], + "live_probe": { + "type": "chimera-health-live-v1", + "container": "Chimera", + "port": 6967, + "path": "/health/live", + "host": "chimera:6967", + }, + } + ) + bad_outputs = ["not-json", "x" * 65537] + bad_outputs.append(json.dumps({"status": "starting", "build": metadata})) + for key in ("id", "revision", "tree", "treeDigest", "builtAt"): + changed = dict(metadata) + changed[key] = "wrong" + bad_outputs.append(json.dumps({"status": "live", "build": changed})) + + for stdout in bad_outputs: + monkeypatch.setattr( + deploy_module.subprocess, + "run", + lambda *_args, _stdout=stdout, **_kwargs: types.SimpleNamespace( + returncode=0, + stdout=_stdout, + stderr="", + ), + ) + with pytest.raises(deploy_module.DeployError): + deploy_module.verify_live_probe(project, metadata) + + monkeypatch.setattr( + deploy_module.subprocess, + "run", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + subprocess.TimeoutExpired("docker", 10) + ), + ) + with pytest.raises(deploy_module.DeployError, match="timed out"): + deploy_module.verify_live_probe(project, metadata) + + +def test_same_revision_revalidates_runtime_receipt_before_success( + deploy_module, tmp_path, monkeypatch +): + revision = "a" * 40 + tree = "b" * 40 + project = _project(tmp_path) + receipt = _receipt(project, revision, tree=tree) + monkeypatch.setattr( + deploy_module, + "read_state", + lambda _project: { + "schema_version": 3, + "repository": "jens/unraid-appops-gateway", + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_receipt": receipt, + }, + ) + calls = [] + monkeypatch.setattr( + deploy_module, + "verify_deployment", + lambda _project, metadata, **_kwargs: calls.append(metadata) or receipt, + ) + + assert ( + deploy_module.verified_current_receipt( + "jens/unraid-appops-gateway", project, revision, tree + ) + == receipt + ) + assert calls == [receipt["build"]] + + monkeypatch.setattr( + deploy_module, + "verify_deployment", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + deploy_module.DeployError("drift") + ), + ) + assert ( + deploy_module.verified_current_receipt( + "jens/unraid-appops-gateway", project, revision, tree + ) + is None + ) + + +def _webhook_project(repository: str = "jens/repo-a") -> dict[str, object]: + return { + "branch": "main", + "gitea_repository_id": 77, + "gitea_webhook_secret_file": repository.replace("/", "__"), + "validation": { + "validation_gate_version": 1, + "required": True, + "contexts": ["Managed validation / full (push)"], + "timeout_seconds": 600, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, + }, + } + + +def _write_webhook_secret(root: Path, name: str, secret: bytes) -> None: + root.mkdir() + root.chmod(0o700) + path = root / name + path.write_bytes(secret + b"\n") + path.chmod(0o600) + + +def _push_payload( + revision: str, *, repository: str = "jens/repo-a", repository_id: int = 77 +) -> dict[str, object]: + return { + "ref": "refs/heads/main", + "before": "b" * 40, + "after": revision, + "repository": { + "id": repository_id, + "full_name": repository, + "default_branch": "main", + "object_format_name": "sha1", + }, + "head_commit": {"id": revision}, + } + + +def test_repository_webhook_secrets_are_name_scoped_and_hmac_bound( + status_module, tmp_path, monkeypatch +): + secret_root = tmp_path / "secrets" + secret = b"repo-a-" + b"x" * 40 + _write_webhook_secret(secret_root, "jens__repo-a", secret) + monkeypatch.setattr(status_module, "WEBHOOK_SECRET_ROOT", secret_root) + project = _webhook_project() + body = b'{"repository":{"full_name":"Jens/repo-a"}}' + signature = hmac.new(secret, body, hashlib.sha256).hexdigest() + + assert status_module._webhook_secret("jens/repo-a", project) == secret + assert status_module._webhook_secret("jens/repo-b", project) is None + assert status_module._valid_webhook_signature( + body, signature, "jens/repo-a", project + ) + assert not status_module._valid_webhook_signature( + body + b" ", signature, "jens/repo-a", project + ) + + +def test_webhook_policy_rejects_legacy_or_mismatched_credentials(status_module): + project = _webhook_project() + assert status_module._webhook_policy("jens/repo-a", project) == ( + 77, + "main", + "jens__repo-a", + ) + for invalid in ( + {}, + {**project, "gitea_repository_id": True}, + {**project, "gitea_repository_id": 0}, + {**project, "gitea_webhook_secret_file": "jens__other"}, + {**project, "broker_token_file": "jens__repo-a"}, + {**project, "legacy_broker_token": True}, + ): + assert status_module._webhook_policy("jens/repo-a", invalid) is None + + +def test_webhook_push_identity_binds_repo_id_branch_revision_and_head(status_module): + revision = "a" * 40 + project = _webhook_project() + assert ( + status_module._webhook_push_identity( + _push_payload(revision), "jens/repo-a", project + ) + == revision + ) + assert ( + status_module._webhook_push_identity( + _push_payload(revision, repository="Jens/Repo-A"), + "jens/repo-a", + project, + ) + == revision + ) + + mutations = ( + ("ref", "refs/heads/other"), + ("before", revision), + ("before", "B" * 40), + ("after", "0" * 40), + ("after", "A" * 40), + ( + "repository", + { + "id": 78, + "full_name": "jens/repo-a", + "default_branch": "main", + "object_format_name": "sha1", + }, + ), + ( + "repository", + { + "id": 77, + "full_name": "jens/other", + "default_branch": "main", + "object_format_name": "sha1", + }, + ), + ( + "repository", + { + "id": 77, + "full_name": "jens/repo-a", + "default_branch": "other", + "object_format_name": "sha1", + }, + ), + ( + "repository", + { + "id": 77, + "full_name": "jens/repo-a", + "default_branch": "main", + "object_format_name": "sha256", + }, + ), + ("head_commit", {"id": "b" * 40}), + ) + for field, value in mutations: + payload = _push_payload(revision) + payload[field] = value + assert ( + status_module._webhook_push_identity(payload, "jens/repo-a", project) + is None + ) + + +def test_webhook_inbox_is_durable_and_replay_keys_the_signed_semantics( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + delivery = "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d" + revision = "a" * 40 + ref = "refs/heads/main" + raw_body = json.dumps( + _push_payload(revision, repository="Jens/repo-a"), + separators=(",", ":"), + ).encode() + body_digest = hashlib.sha256(raw_body).hexdigest() + semantic_digest = status_module._webhook_semantic_digest(77, ref, revision) + policy = status_module._validation_policy("jens/repo-a", _webhook_project()) + assert policy is not None + + assert status_module._enqueue_webhook_delivery( + raw_body, delivery, 77, ref, "jens/repo-a", revision, policy + ) == ("awaiting_validation", semantic_digest) + marker_path = inbox_root / f"{semantic_digest}.json" + marker = json.loads(marker_path.read_text()) + assert marker["schema"] == "gitea-webhook-inbox.v2" + assert marker["semantic_sha256"] == semantic_digest + assert marker["body_sha256"] == body_digest + assert marker["gitea_repository_id"] == 77 + assert marker["ref"] == ref + assert marker["repository"] == "jens/repo-a" + assert marker["revision"] == revision + assert marker["status"] == "awaiting_validation" + assert marker["validation"]["required_contexts"] == ["Managed validation / full (push)"] + + changed_unsigned_delivery = "145440ab-b7b1-4c74-ae84-996d8d48167e" + assert status_module._enqueue_webhook_delivery( + raw_body, + changed_unsigned_delivery, + 77, + ref, + "jens/repo-a", + revision, + policy, + ) == ("awaiting_validation", semantic_digest) + assert json.loads(marker_path.read_text())["delivery"] == delivery + + same_semantics_different_body = json.dumps( + {**_push_payload(revision), "compare_url": "https://example.invalid/compare"}, + separators=(",", ":"), + ).encode() + assert hashlib.sha256(same_semantics_different_body).hexdigest() != body_digest + assert status_module._enqueue_webhook_delivery( + same_semantics_different_body, + changed_unsigned_delivery, + 77, + ref, + "jens/repo-a", + revision, + policy, + ) == ("awaiting_validation", semantic_digest) + assert json.loads(marker_path.read_text())["body_sha256"] == body_digest + + assert status_module._enqueue_webhook_delivery( + raw_body, "../../escape", 77, ref, "jens/repo-a", revision, policy + ) == ("invalid", "") + + +def test_terminal_validation_revalidation_creates_a_separate_idempotent_attempt( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: True) + revision = "a" * 40 + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, semantic_digest = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload(revision), separators=(",", ":")).encode(), + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + policy, + ) + original_marker = inbox_root / f"{semantic_digest}.json" + original = json.loads(original_marker.read_text()) + original["status"] = "validation_failed" + original["reason"] = "validation_failed" + original["validation"]["last_result"] = "reject" + original_marker.write_text( + json.dumps(original, sort_keys=True, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + original_bytes = original_marker.read_bytes() + + assert status_module._enqueue_terminal_revalidation( + "jens/repo-a", revision, project + ) == ("awaiting_validation", semantic_digest) + + revalidation_marker = inbox_root / f"{semantic_digest}.revalidation.json" + attempt = json.loads(revalidation_marker.read_text()) + assert original_marker.read_bytes() == original_bytes + assert attempt["schema"] == "gitea-webhook-revalidation.v1" + assert attempt["semantic_sha256"] == semantic_digest + assert attempt["revalidation_of"] == semantic_digest + assert attempt["gitea_repository_id"] == 77 + assert attempt["ref"] == "refs/heads/main" + assert attempt["repository"] == "jens/repo-a" + assert attempt["revision"] == revision + assert attempt["status"] == "awaiting_validation" + assert attempt["validation"]["attempts"] == 0 + assert attempt["validation"]["observed"] == [] + assert [entry["stage"] for entry in attempt["audit"]] == [ + "revalidation_requested", + "awaiting_validation", + ] + + attempt_bytes = revalidation_marker.read_bytes() + assert status_module._enqueue_terminal_revalidation( + "jens/repo-a", revision, project + ) == ("awaiting_validation", semantic_digest) + assert revalidation_marker.read_bytes() == attempt_bytes + assert len(list(inbox_root.glob("*.json"))) == 2 + + +@pytest.mark.parametrize( + "terminal_status", + ["deployed", "failed", "non_forward", "rejected", "superseded"], +) +def test_revalidation_refuses_non_validation_terminal_states( + status_module, tmp_path, monkeypatch, terminal_status +): + inbox_root = tmp_path / "inbox" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: True) + revision = "b" * 40 + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, semantic_digest = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload(revision), separators=(",", ":")).encode(), + "145440ab-b7b1-4c74-ae84-996d8d48167e", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + policy, + ) + original_marker = inbox_root / f"{semantic_digest}.json" + original = json.loads(original_marker.read_text()) + original["status"] = terminal_status + original_marker.write_text(json.dumps(original), encoding="utf-8") + original_bytes = original_marker.read_bytes() + + assert status_module._enqueue_terminal_revalidation( + "jens/repo-a", revision, project + ) == ("not_revalidatable", semantic_digest) + assert original_marker.read_bytes() == original_bytes + assert not (inbox_root / f"{semantic_digest}.revalidation.json").exists() + + +def test_revalidation_refuses_a_revision_that_is_not_the_current_branch_head( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: False) + revision = "c" * 40 + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, semantic_digest = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload(revision), separators=(",", ":")).encode(), + "766ea001-aa32-49c4-b765-5ea0a39a7831", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + policy, + ) + original_marker = inbox_root / f"{semantic_digest}.json" + original = json.loads(original_marker.read_text()) + original["status"] = "validation_timeout" + original_marker.write_text(json.dumps(original), encoding="utf-8") + original_bytes = original_marker.read_bytes() + + assert status_module._enqueue_terminal_revalidation( + "jens/repo-a", revision, project + ) == ("not_current_head", semantic_digest) + assert original_marker.read_bytes() == original_bytes + assert not (inbox_root / f"{semantic_digest}.revalidation.json").exists() + + +def test_revalidation_attempt_requires_fresh_status_success_and_preserves_original( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + projects_path = tmp_path / "projects.json" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + projects_path.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: True) + revision = "d" * 40 + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, semantic_digest = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload(revision), separators=(",", ":")).encode(), + "92869210-fcd2-47d2-a775-ab3fa47f229f", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + policy, + ) + original_marker = inbox_root / f"{semantic_digest}.json" + original = json.loads(original_marker.read_text()) + original["status"] = "validation_failed" + original["reason"] = "validation_failed" + original_marker.write_text(json.dumps(original), encoding="utf-8") + original_bytes = original_marker.read_bytes() + assert status_module._enqueue_terminal_revalidation( + "jens/repo-a", revision, project + ) == ("awaiting_validation", semantic_digest) + attempt_marker = inbox_root / f"{semantic_digest}.revalidation.json" + deploy_calls: list[list[str]] = [] + + class Result: + returncode = 0 + + monkeypatch.setattr( + status_module.subprocess, + "run", + lambda args, **_kwargs: deploy_calls.append(args) or Result(), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"verified": True}) + monkeypatch.setattr(status_module, "_fetch_gitea_validation", lambda *_args: ("wait", [])) + + status_module._process_webhook_record(attempt_marker) + assert json.loads(attempt_marker.read_text())["status"] == "awaiting_validation" + assert deploy_calls == [] + assert original_marker.read_bytes() == original_bytes + + monkeypatch.setattr( + status_module, + "_fetch_gitea_validation", + lambda *_args: ( + "pass", + [{ + "context": "Managed validation / full (push)", + "status": "success", + "updated_at": "2026-09-21T18:10:15Z", + }], + ), + ) + attempt = json.loads(attempt_marker.read_text()) + attempt["validation"]["next_check_at"] = 0 + attempt_marker.write_text(json.dumps(attempt), encoding="utf-8") + + status_module._process_webhook_record(attempt_marker) + + assert json.loads(attempt_marker.read_text())["status"] == "deployed" + assert deploy_calls == [[ + "python3", + str(status_module.ROOT / "deploy.py"), + "deploy", + "jens/repo-a", + revision, + ]] + assert original_marker.read_bytes() == original_bytes + + +def test_delivery_status_identifies_revalidation_attempts( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + project = _webhook_project() + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: True) + monkeypatch.setattr( + status_module, + "_gitea_validation_settings", + lambda: ("https://gitea.example.test/api/v1", b"x" * 40), + ) + revision = "e" * 40 + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, semantic_digest = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload(revision), separators=(",", ":")).encode(), + "c492f8a1-dd2b-48fe-af98-f071c802b53e", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + policy, + ) + original_marker = inbox_root / f"{semantic_digest}.json" + original = json.loads(original_marker.read_text()) + original["status"] = "validation_failed" + original_marker.write_text(json.dumps(original), encoding="utf-8") + status_module._enqueue_terminal_revalidation("jens/repo-a", revision, project) + + payload = status_module._delivery_status_payload() + revalidation = next(item for item in payload["items"] if item.get("attemptType") == "revalidation") + + assert revalidation["repository"] == "jens/repo-a" + assert revalidation["revision"] == revision + assert revalidation["revalidationOf"] == semantic_digest + + +def test_revalidation_rechecks_exact_branch_head_immediately_before_deploy( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + projects_path = tmp_path / "projects.json" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + projects_path.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + current_head = iter([True, False]) + monkeypatch.setattr( + status_module, "_is_current_branch_head", lambda *_args: next(current_head) + ) + revision = "f" * 40 + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, semantic_digest = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload(revision), separators=(",", ":")).encode(), + "691f58fa-fbd1-49d7-87c3-d7caa947cb10", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + policy, + ) + original_marker = inbox_root / f"{semantic_digest}.json" + original = json.loads(original_marker.read_text()) + original["status"] = "validation_failed" + original_marker.write_text(json.dumps(original), encoding="utf-8") + original_bytes = original_marker.read_bytes() + assert status_module._enqueue_terminal_revalidation( + "jens/repo-a", revision, project + ) == ("awaiting_validation", semantic_digest) + attempt_marker = inbox_root / f"{semantic_digest}.revalidation.json" + monkeypatch.setattr( + status_module, + "_fetch_gitea_validation", + lambda *_args: ( + "pass", + [{ + "context": "Managed validation / full (push)", + "status": "success", + "updated_at": "2026-09-21T18:10:15Z", + }], + ), + ) + monkeypatch.setattr( + status_module.subprocess, + "run", + lambda *_args, **_kwargs: pytest.fail("deployment ran after branch head changed"), + ) + + status_module._process_webhook_record(attempt_marker) + + attempt = json.loads(attempt_marker.read_text()) + assert attempt["status"] == "superseded" + assert attempt["reason"] == "revision_not_current_head" + assert original_marker.read_bytes() == original_bytes + + +def test_revalidation_cli_refuses_non_root_linux_operator(status_module, monkeypatch): + monkeypatch.setattr(status_module.os, "name", "posix") + monkeypatch.setattr(status_module.os, "geteuid", lambda: 1000, raising=False) + monkeypatch.setattr( + status_module.sys, + "argv", + [ + "status_server.py", + "--revalidate-delivery", + "jens/repo-a", + "a" * 40, + ], + ) + + with pytest.raises(PermissionError, match="root"): + status_module.main() + + +def test_revalidation_cli_queues_through_local_supported_surface( + status_module, monkeypatch, capsys +): + project = _webhook_project() + revision = "a" * 40 + digest = "b" * 64 + monkeypatch.setattr(status_module.os, "name", "posix") + monkeypatch.setattr(status_module.os, "geteuid", lambda: 0, raising=False) + monkeypatch.setattr(status_module, "_require_controller_release", dict) + monkeypatch.setattr(status_module, "_load_projects", lambda: {"jens/repo-a": project}) + requests: list[tuple[str, str, dict[str, object]]] = [] + monkeypatch.setattr( + status_module, + "_enqueue_terminal_revalidation", + lambda repository, requested_revision, requested_project: ( + requests.append((repository, requested_revision, requested_project)) + or ("awaiting_validation", digest) + ), + ) + monkeypatch.setattr( + status_module.sys, + "argv", + [ + "status_server.py", + "--revalidate-delivery", + "jens/repo-a", + revision, + ], + ) + + status_module.main() + + assert requests == [("jens/repo-a", revision, project)] + assert json.loads(capsys.readouterr().out) == { + "status": "awaiting_validation", + "repository": "jens/repo-a", + "revision": revision, + "delivery": digest, + } + + +def test_revalidation_has_no_http_mutation_route(status_module): + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + try: + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/revalidate", + body=b"{}", + headers={"Content-Type": "application/json", "Content-Length": "2"}, + ) + response = client.getresponse() + response.read() + client.close() + assert response.status == 404 + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_workmesh_promotion_client_uses_only_fixed_signed_contract(monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + captured = [] + + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"status": "accepted", "operation": "workmesh_promote"} + + class Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def request(self, method, url, *, content, headers): + captured.append((method, url, content, headers)) + return Response() + + monkeypatch.setattr("backend.app.services.deployment_broker.httpx.Client", Client) + client = DeploymentBrokerClient(base_url="https://controller.example", verify_tls=True) + secret = "workmesh-controller-secret-" + "x" * 32 + revision = "a" * 40 + digest = "b" * 64 + key = "workmesh-promotion:workmesh-prom-0123456789abcdef" + + client.workmesh_promotion_preflight(revision, secret=secret) + client.workmesh_promotion_apply( + revision, + secret=secret, + preflight_sha256=digest, + idempotency_key=key, + ) + + expected = [ + ( + "POST", + "https://controller.example/v1/appops/workmesh/promotions/preflight", + {"revision": revision}, + ), + ( + "POST", + "https://controller.example/v1/appops/workmesh/promotions/apply", + {"revision": revision, "preflightSha256": digest, "idempotencyKey": key}, + ), + ] + assert [ + (method, url, json.loads(body)) for method, url, body, _headers in captured + ] == expected + for method, url, body, headers in captured: + path = url.removeprefix("https://controller.example") + assert headers["X-ITWorx-Deployment-Signature"] == DeploymentBrokerClient._signature( + secret, + method, + path, + headers["X-ITWorx-Deployment-Timestamp"], + headers["X-ITWorx-Deployment-Nonce"], + body, + ) + assert client.workmesh_promotion_apply( + "main", + secret=secret, + preflight_sha256=digest, + idempotency_key=key, + ) == {"status": "invalid", "reason": "invalid_workmesh_revision"} + assert len(captured) == 2 + + +def test_workmesh_apply_preserves_rejection_identity_without_extra_fields(monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + request_id = "a" * 64 + + class Response: + status_code = 409 + + @staticmethod + def json(): + return { + "status": "preflight_changed", + "request": request_id, + "repository": "jens/workmesh", + "revision": "b" * 40, + "operation": "workmesh_promote", + "reason": "approved_preflight_no_longer_matches", + "secret": "discard-me", + } + + class Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def request(self, *_args, **_kwargs): + return Response() + + monkeypatch.setattr("backend.app.services.deployment_broker.httpx.Client", Client) + client = DeploymentBrokerClient(base_url="https://controller.example", verify_tls=True) + result = client.workmesh_promotion_apply( + "b" * 40, + secret="workmesh-controller-secret-" + "x" * 32, + preflight_sha256="c" * 64, + idempotency_key="workmesh-promotion:workmesh-prom-0123456789abcdef", + ) + + assert result == { + "status": "preflight_changed", + "reason": "approved_preflight_no_longer_matches", + "request": request_id, + "repository": "jens/workmesh", + "revision": "b" * 40, + "operation": "workmesh_promote", + } + + +def test_health_is_served_while_initial_runner_maintenance_is_blocked( + status_module, monkeypatch +): + maintenance_started = threading.Event() + release_maintenance = threading.Event() + server_created = threading.Event() + serving_started = threading.Event() + server_holder = {} + real_server = status_module.BoundedThreadingHTTPServer + + class BlockingFleet: + def automatic_cycle(self): + maintenance_started.set() + assert release_maintenance.wait(timeout=5) + + class ObservedServer(real_server): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + server_holder["server"] = self + server_created.set() + + def serve_forever(self, *args, **kwargs): + serving_started.set() + return super().serve_forever(*args, **kwargs) + + monkeypatch.setattr(status_module, "RunnerFleetService", BlockingFleet) + monkeypatch.setattr(status_module, "BoundedThreadingHTTPServer", ObservedServer) + monkeypatch.setattr( + status_module, + "_controller_release", + lambda: { + "apiVersion": "itworx-controller-release.v1", + "controllerVersion": "2.1.3", + "sourceRepository": "jens/Unraid-AppOps-Gateway", + "sourceRevision": "a" * 40, + "sourceTree": "b" * 40, + "artifactSha256": "c" * 64, + "builtAt": "2026-09-21T20:58:36Z", + }, + ) + monkeypatch.setattr( + status_module, + "_delivery_status_payload", + lambda: {"status": "ok", "health": "healthy", "summary": {}}, + ) + monkeypatch.setattr( + status_module.sys, + "argv", + ["status_server.py", "--listen", "127.0.0.1", "--port", "0"], + ) + + main_thread = threading.Thread(target=status_module.main, daemon=True) + main_thread.start() + try: + assert maintenance_started.wait(timeout=2) + assert server_created.wait(timeout=1), "server must bind before startup maintenance" + assert serving_started.wait(timeout=1), "server must serve before startup maintenance" + server = server_holder["server"] + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("GET", "/health") + response = client.getresponse() + payload = json.loads(response.read()) + client.close() + + assert response.status == 200 + assert payload["status"] == "ok" + assert payload["transport"] == "http" + assert not release_maintenance.is_set() + finally: + release_maintenance.set() + if serving_started.wait(timeout=2): + server_holder["server"].shutdown() + main_thread.join(timeout=3) + + assert not main_thread.is_alive() + + +def test_webhook_worker_deploys_once_and_requires_verified_receipt( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + projects_path = tmp_path / "projects.json" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + projects_path.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + revision = "a" * 40 + raw_body = json.dumps(_push_payload(revision), separators=(",", ":")).encode() + delivery = "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d" + _, request_digest = status_module._enqueue_webhook_delivery( + raw_body, + delivery, + 77, + "refs/heads/main", + "jens/repo-a", + revision, + status_module._validation_policy("jens/repo-a", project), + ) + marker = inbox_root / f"{request_digest}.json" + calls = [] + + class Result: + returncode = 0 + + monkeypatch.setattr( + status_module.subprocess, + "run", + lambda arguments, **_kwargs: calls.append(arguments) or Result(), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"ok": True}) + monkeypatch.setattr( + status_module, + "_fetch_gitea_validation", + lambda *_args: ("pass", [{"context": "Managed validation / full (push)", "status": "success", "updated_at": "2026-09-14T00:00:00Z"}]), + ) + + status_module._process_webhook_record(marker) + assert json.loads(marker.read_text())["status"] == "deployed" + assert calls == [ + [ + "python3", + str(status_module.ROOT / "deploy.py"), + "deploy", + "jens/repo-a", + revision, + ] + ] + status_module._process_webhook_record(marker) + assert len(calls) == 1 + + second_body = json.dumps(_push_payload("c" * 40), separators=(",", ":")).encode() + _, second_digest = status_module._enqueue_webhook_delivery( + second_body, + "145440ab-b7b1-4c74-ae84-996d8d48167e", + 77, + "refs/heads/main", + "jens/repo-a", + "c" * 40, + status_module._validation_policy("jens/repo-a", project), + ) + second_marker = inbox_root / f"{second_digest}.json" + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: None) + status_module._process_webhook_record(second_marker) + assert json.loads(second_marker.read_text())["status"] == "failed" + + +def test_webhook_worker_recovers_interrupted_deployment_from_verified_receipt( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + projects_path = tmp_path / "projects.json" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + projects_path.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + revision = "a" * 40 + raw_body = json.dumps(_push_payload(revision), separators=(",", ":")).encode() + _, request_digest = status_module._enqueue_webhook_delivery( + raw_body, + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + status_module._validation_policy("jens/repo-a", project), + ) + marker = inbox_root / f"{request_digest}.json" + record = json.loads(marker.read_text()) + record["status"] = "deploying" + marker.write_text(json.dumps(record), encoding="utf-8") + receipt = {"schema": "gitea-deploy-receipt.v1", "revision": revision} + calls = [] + + monkeypatch.setattr( + status_module.subprocess, + "run", + lambda arguments, **_kwargs: calls.append(arguments), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: receipt) + + status_module._process_webhook_record(marker) + + recovered = json.loads(marker.read_text()) + assert recovered["status"] == "deployed" + assert recovered["receipt_sha256"] == hashlib.sha256( + json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + assert recovered["audit"][-1]["stage"] == "deployed" + assert calls == [] + + +def test_webhook_worker_fails_interrupted_deployment_without_verified_receipt( + status_module, tmp_path, monkeypatch +): + inbox_root = tmp_path / "inbox" + projects_path = tmp_path / "projects.json" + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + projects_path.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + revision = "a" * 40 + raw_body = json.dumps(_push_payload(revision), separators=(",", ":")).encode() + _, request_digest = status_module._enqueue_webhook_delivery( + raw_body, + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", + 77, + "refs/heads/main", + "jens/repo-a", + revision, + status_module._validation_policy("jens/repo-a", project), + ) + marker = inbox_root / f"{request_digest}.json" + record = json.loads(marker.read_text()) + record["status"] = "deploying" + marker.write_text(json.dumps(record), encoding="utf-8") + calls = [] + + monkeypatch.setattr( + status_module.subprocess, + "run", + lambda arguments, **_kwargs: calls.append(arguments), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: None) + + status_module._process_webhook_record(marker) + + recovered = json.loads(marker.read_text()) + assert recovered["status"] == "failed" + assert recovered["reason"] == "deployment_interrupted" + assert recovered["audit"][-1] == { + "at": recovered["audit"][-1]["at"], + "stage": "deployment_failed", + "reason": "deployment_interrupted", + } + assert calls == [] + + +def test_gitea_validation_uses_latest_exact_sha_push_context_only(status_module, monkeypatch): + revision = "a" * 40 + policy = status_module._validation_policy("jens/repo-a", _webhook_project()) + assert policy is not None + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _limit): + return json.dumps([ + { + "id": 1, + "context": "Managed validation / full (push)", + "status": "success", + "url": f"https://gitea.example.test/api/v1/repos/Jens/Repo-A/statuses/{revision}", + "target_url": "/Jens/Repo-A/actions/runs/100/jobs/200", + "updated_at": "2026-09-14T00:00:00Z", + }, + { + "id": 2, + "context": "Managed validation / full (push)", + "status": "failure", + "url": f"https://gitea.example.test/api/v1/repos/Jens/Repo-A/statuses/{revision}", + "target_url": "/Jens/Repo-A/actions/runs/100/jobs/200", + "updated_at": "2026-09-14T00:01:00Z", + }, + { + "id": 3, + "context": "Managed validation / full (push)", + "status": "success", + "url": f"https://gitea.example.test/api/v1/repos/Jens/Repo-A/statuses/{'b' * 40}", + "target_url": "/Jens/Repo-A/actions/runs/100/jobs/200", + "updated_at": "2026-09-14T00:02:00Z", + }, + { + "id": 4, + "context": "lint (push)", + "status": "success", + "url": f"https://gitea.example.test/api/v1/repos/Jens/Repo-A/statuses/{revision}", + "target_url": "/Jens/Repo-A/actions/runs/100/jobs/200", + "updated_at": "2026-09-14T00:03:00Z", + }, + { + "id": 5, + "context": "Managed validation / full (push)", + "status": "success", + "url": f"https://gitea.example.test/api/v1/repos/Jens/Repo-A/statuses/{revision}", + "target_url": "/Jens/other-repo/actions/runs/100/jobs/200", + "updated_at": "2026-09-14T00:04:00Z", + }, + ]).encode() + + class Opener: + def open(self, _request, timeout): + assert timeout == status_module.GITEA_STATUS_TIMEOUT_SECONDS + return Response() + + monkeypatch.setattr( + status_module, + "_gitea_validation_settings", + lambda: ("https://gitea.example.test/api/v1", b"x" * 40), + ) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: Opener()) + outcome, observed = status_module._fetch_gitea_validation("jens/repo-a", revision, policy) + assert outcome == "reject" + assert observed == [{ + "context": "Managed validation / full (push)", + "status": "failure", + "updated_at": "2026-09-14T00:01:00Z", + }] + + +def test_gitea_validation_api_probe_uses_only_required_repository_read_scope(status_module, monkeypatch): + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def read(self, _limit): + return b'{"full_name":"Jens/Unraid-AppOps-Gateway"}' + + class Opener: + def open(self, request, timeout): + assert request.full_url == "https://gitea.example.test/api/v1/repos/jens/unraid-appops-gateway" + assert timeout == status_module.GITEA_STATUS_TIMEOUT_SECONDS + return Response() + + monkeypatch.setattr( + status_module, + "_gitea_validation_settings", + lambda: ("https://gitea.example.test/api/v1", b"x" * 40), + ) + monkeypatch.setattr( + status_module, + "_controller_release", + lambda: {"sourceRepository": "jens/unraid-appops-gateway"}, + ) + monkeypatch.setattr(status_module, "build_opener", lambda *_args: Opener()) + + assert status_module._gitea_validation_api_reachable() + + +def test_validation_gate_never_deploys_before_exact_status_success(status_module, tmp_path, monkeypatch): + inbox = tmp_path / "inbox" + projects = tmp_path / "projects.json" + project = {**_webhook_project(), "slug": "repo-a", "containers": ["RepoA"]} + projects.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects) + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, request_id = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload("a" * 40), separators=(",", ":")).encode(), + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", + 77, + "refs/heads/main", + "jens/repo-a", + "a" * 40, + policy, + ) + marker = inbox / f"{request_id}.json" + deploy_calls: list[list[str]] = [] + + class Result: + returncode = 0 + + monkeypatch.setattr(status_module.subprocess, "run", lambda args, **_kwargs: deploy_calls.append(args) or Result()) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"verified": True}) + monkeypatch.setattr(status_module, "_fetch_gitea_validation", lambda *_args: ("wait", [])) + status_module._process_webhook_record(marker) + assert json.loads(marker.read_text())["status"] == "awaiting_validation" + assert deploy_calls == [] + + monkeypatch.setattr( + status_module, + "_fetch_gitea_validation", + lambda *_args: ("pass", [{"context": "Managed validation / full (push)", "status": "success", "updated_at": "2026-09-14T00:00:00Z"}]), + ) + record = json.loads(marker.read_text()) + record["validation"]["next_check_at"] = 0 + marker.write_text(json.dumps(record), encoding="utf-8") + status_module._process_webhook_record(marker) + assert json.loads(marker.read_text())["status"] == "deployed" + assert len(deploy_calls) == 1 + + +@pytest.mark.parametrize( + ("outcome", "expected_state"), + [ + ("reject", "validation_failed"), + ("unavailable", "awaiting_validation"), + ], +) +def test_validation_failure_or_gitea_outage_fails_closed( + status_module, tmp_path, monkeypatch, outcome, expected_state +): + inbox = tmp_path / "inbox" + projects = tmp_path / "projects.json" + project = {**_webhook_project(), "slug": "repo-a", "containers": ["RepoA"]} + projects.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects) + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, request_id = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload("a" * 40), separators=(",", ":")).encode(), + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", + 77, + "refs/heads/main", + "jens/repo-a", + "a" * 40, + policy, + ) + marker = inbox / f"{request_id}.json" + deploy_calls: list[list[str]] = [] + monkeypatch.setattr(status_module.subprocess, "run", lambda args, **_kwargs: deploy_calls.append(args)) + monkeypatch.setattr(status_module, "_fetch_gitea_validation", lambda *_args: (outcome, [])) + status_module._process_webhook_record(marker) + record = json.loads(marker.read_text()) + assert record["status"] == expected_state + assert deploy_calls == [] + if outcome == "unavailable": + assert record["reason"] == "gitea_validation_unavailable" + + +def test_newer_validated_descendant_supersedes_old_undeployed_delivery(status_module, tmp_path, monkeypatch): + inbox = tmp_path / "inbox" + projects = tmp_path / "projects.json" + project = {**_webhook_project(), "slug": "repo-a", "containers": ["RepoA"]} + projects.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects) + policy = status_module._validation_policy("jens/repo-a", project) + assert policy is not None + _, old_id = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload("a" * 40), separators=(",", ":")).encode(), + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", 77, "refs/heads/main", "jens/repo-a", "a" * 40, policy, + ) + _, new_id = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload("b" * 40), separators=(",", ":")).encode(), + "145440ab-b7b1-4c74-ae84-996d8d48167e", 77, "refs/heads/main", "jens/repo-a", "b" * 40, policy, + ) + old_marker = inbox / f"{old_id}.json" + new_marker = inbox / f"{new_id}.json" + monkeypatch.setattr(status_module, "_fetch_gitea_validation", lambda *_args: ("pass", [])) + monkeypatch.setattr(status_module, "_is_forward_descendant", lambda *_args: True) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"verified": True}) + monkeypatch.setattr(status_module.subprocess, "run", lambda *_args, **_kwargs: type("Result", (), {"returncode": 0})()) + status_module._process_webhook_record(new_marker) + assert json.loads(old_marker.read_text())["status"] == "superseded" + assert json.loads(new_marker.read_text())["status"] == "deployed" + + +def test_delivery_status_projection_is_bounded_and_secret_free(status_module, tmp_path, monkeypatch): + inbox = tmp_path / "inbox" + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox) + policy = status_module._validation_policy("jens/repo-a", _webhook_project()) + assert policy is not None + _, request_id = status_module._enqueue_webhook_delivery( + json.dumps(_push_payload("a" * 40), separators=(",", ":")).encode(), + "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", 77, "refs/heads/main", "jens/repo-a", "a" * 40, policy, + ) + marker = inbox / f"{request_id}.json" + record = json.loads(marker.read_text()) + record["webhook_secret"] = "must-not-be-returned" + record["authorization"] = "must-not-be-returned" + marker.write_text(json.dumps(record), encoding="utf-8") + monkeypatch.setattr(status_module, "_gitea_validation_settings", lambda: ("https://gitea.example.test/api/v1", b"x" * 40)) + payload = status_module._delivery_status_payload() + serialized = json.dumps(payload) + assert payload["summary"]["pendingValidation"] == 1 + assert payload["items"][0]["repository"] == "jens/repo-a" + assert "must-not-be-returned" not in serialized + + +def test_delivery_status_marks_an_unwritable_inbox_unhealthy( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "inbox" + inbox.mkdir() + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox) + monkeypatch.setattr( + status_module, + "_gitea_validation_settings", + lambda: ("https://gitea.example.test/api/v1", b"x" * 40), + ) + monkeypatch.setattr( + status_module, + "_controller_release", + lambda: {"sourceRepository": "jens/unraid-appops-gateway"}, + ) + + def no_space_left(*_args, **_kwargs): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(status_module.os, "open", no_space_left) + + payload = status_module._delivery_status_payload() + + assert payload["status"] == "unhealthy" + assert payload["health"]["inbox"] == "unhealthy" + assert list(inbox.iterdir()) == [] + + +def test_inbox_health_probe_cleans_up_when_the_create_directory_fsync_fails( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "inbox" + inbox.mkdir() + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox) + fsync_calls = 0 + + def fail_the_first_directory_fsync(): + nonlocal fsync_calls + fsync_calls += 1 + if fsync_calls == 1: + raise OSError(5, "Directory fsync failed") + + monkeypatch.setattr(status_module, "_fsync_inbox_root", fail_the_first_directory_fsync) + + with pytest.raises(OSError, match="Directory fsync failed"): + status_module._probe_inbox_writable() + + assert fsync_calls == 2 + assert list(inbox.iterdir()) == [] + + +def test_delivery_status_client_is_read_only_and_does_not_send_a_secret(monkeypatch): + from backend.app.core.config import settings + from backend.app.services import deployment_broker + + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_URL", "https://controller.example.test") + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_VERIFY_TLS", True) + captured: dict[str, object] = {} + + class Response: + status_code = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def iter_bytes(self): + return iter([b'{"status":"healthy","items":[]}']) + + class Client: + def __init__(self, **kwargs): + captured["client"] = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def stream(self, method, url, headers): + captured.update({"method": method, "url": url, "headers": headers}) + return Response() + + monkeypatch.setattr(deployment_broker.httpx, "Client", Client) + assert deployment_broker.DeploymentBrokerClient().delivery_status() == {"status": "healthy", "items": []} + assert captured["method"] == "GET" + assert captured["url"] == "https://controller.example.test/v1/gitea/deliveries" + assert captured["headers"] == {"Accept": "application/json"} + assert captured["client"] == {"timeout": 10.0, "verify": True, "follow_redirects": False} + + +def _write_controller_tls_pair(tmp_path: Path) -> tuple[Path, Path]: + from datetime import datetime, timedelta, timezone + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "controller.test")]) + now = datetime.now(timezone.utc).replace(tzinfo=None) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.SubjectAlternativeName( + [x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ), + critical=False, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False + ) + .sign(key, hashes.SHA256()) + ) + certificate_path = tmp_path / "controller-test.crt" + private_key_path = tmp_path / "controller-test.key" + certificate_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + private_key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + private_key_path.chmod(0o600) + return certificate_path, private_key_path + + +def test_private_tls_configuration_requires_a_complete_private_listener_record( + status_module, tmp_path, monkeypatch +): + monkeypatch.setattr(status_module, "os", types.SimpleNamespace(name="nt")) + certificate, private_key = _write_controller_tls_pair(tmp_path) + config_path = tmp_path / "private-tls.json" + monkeypatch.setattr(status_module, "PRIVATE_TLS_CONFIG_PATH", config_path) + + assert status_module._private_tls_configuration() is None + with pytest.raises(RuntimeError, match="missing"): + status_module._private_tls_configuration(required=True) + + payload = { + "schema": "itworx.controller-private-tls/v1", + "listen_ip": "198.18.10.50", + "port": 1300, + "certificate_file": str(certificate), + "private_key_file": str(private_key), + } + config_path.write_text(json.dumps(payload), encoding="utf-8") + config_path.chmod(0o600) + + incomplete = dict(payload) + incomplete.pop("private_key_file") + config_path.write_text(json.dumps(incomplete), encoding="utf-8") + with pytest.raises(RuntimeError, match="configuration is invalid"): + status_module._private_tls_configuration(required=True) + + config_path.write_text(json.dumps(payload), encoding="utf-8") + + configuration = status_module._private_tls_configuration(required=True) + assert configuration is not None + assert configuration["listen_ip"] == "198.18.10.50" + assert configuration["port"] == 1300 + context = status_module._private_tls_context(configuration) + assert context.minimum_version == ssl.TLSVersion.TLSv1_2 + + payload["listen_ip"] = "0.0.0.0" + config_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(RuntimeError, match="explicit private IPv4"): + status_module._private_tls_configuration(required=True) + payload["listen_ip"] = "fd00::1" + config_path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(RuntimeError, match="explicit private IPv4"): + status_module._private_tls_configuration(required=True) + + +def test_private_tls_listener_serves_health_over_verified_tls( + status_module, tmp_path, monkeypatch +): + certificate, private_key = _write_controller_tls_pair(tmp_path) + monkeypatch.setattr( + status_module, + "_controller_release", + lambda: {"controllerVersion": "test"}, + ) + monkeypatch.setattr( + status_module, + "_delivery_status_payload", + lambda: {"status": "ok", "health": "healthy", "summary": {}}, + ) + server = status_module._private_tls_server( + { + "listen_ip": "127.0.0.1", + "port": 0, + "certificate_file": certificate, + "private_key_file": private_key, + } + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + try: + client = http.client.HTTPSConnection( + *server.server_address, + context=ssl.create_default_context(cafile=str(certificate)), + timeout=2, + ) + client.request("GET", "/health") + response = client.getresponse() + payload = json.loads(response.read()) + client.close() + assert response.status == 200 + assert payload["status"] == "ok" + assert payload["transport"] == "https" + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_deployment_broker_uses_private_ca_and_rejects_disabled_https_verification( + monkeypatch, tmp_path +): + from backend.app.core.config import settings + from backend.app.services import deployment_broker + + monkeypatch.setattr(deployment_broker, "os", types.SimpleNamespace(name="nt")) + ca_file = tmp_path / "private-ca.pem" + ca_file.write_text("test CA bundle", encoding="utf-8") + ca_file.chmod(0o644) + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_URL", "https://controller.private") + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_VERIFY_TLS", True) + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_CA_FILE", str(ca_file)) + captured: dict[str, object] = {} + + class Response: + status_code = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def iter_bytes(self): + return iter([b'{"status":"healthy","items":[]}']) + + class Client: + def __init__(self, **kwargs): + captured["client"] = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def stream(self, method, url, headers): + captured.update({"method": method, "url": url, "headers": headers}) + return Response() + + monkeypatch.setattr(deployment_broker.httpx, "Client", Client) + assert deployment_broker.DeploymentBrokerClient().delivery_status() == { + "status": "healthy", + "items": [], + } + assert captured["client"] == { + "timeout": 10.0, + "verify": str(ca_file), + "follow_redirects": False, + } + + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_VERIFY_TLS", False) + insecure = deployment_broker.DeploymentBrokerClient() + assert insecure.delivery_status() == { + "status": "missing_config", + "reason": "deployment_broker_tls_verification_required", + } + assert ( + insecure.configuration_error() + == "deployment_broker_tls_verification_required" + ) + + monkeypatch.setitem(settings.apis, "DEPLOYMENT_BROKER_VERIFY_TLS", True) + monkeypatch.setitem( + settings.apis, + "DEPLOYMENT_BROKER_CA_FILE", + str(tmp_path / "missing-ca.pem"), + ) + invalid_ca = deployment_broker.DeploymentBrokerClient() + assert invalid_ca.delivery_status() == { + "status": "missing_config", + "reason": "deployment_broker_ca_file_invalid", + } + + +def test_deployment_broker_ca_file_must_be_owned_by_the_appops_process( + monkeypatch, tmp_path +): + from backend.app.services import deployment_broker + + ca_file = tmp_path / "private-ca.pem" + owner = 1234 + file_info = types.SimpleNamespace( + st_mode=stat.S_IFREG | 0o640, + st_size=16, + st_uid=owner, + ) + monkeypatch.setattr(deployment_broker.Path, "lstat", lambda _path: file_info) + monkeypatch.setattr( + deployment_broker, + "os", + types.SimpleNamespace( + name="posix", + R_OK=4, + geteuid=lambda: owner, + access=lambda _path, _mode: True, + ), + ) + + assert deployment_broker.DeploymentBrokerClient._trusted_ca_file_is_safe( + str(ca_file) + ) + + monkeypatch.setattr( + deployment_broker, + "os", + types.SimpleNamespace( + name="posix", + R_OK=4, + geteuid=lambda: -1, + access=lambda _path, _mode: True, + ), + ) + assert not deployment_broker.DeploymentBrokerClient._trusted_ca_file_is_safe( + str(ca_file) + ) + + file_info.st_mode = stat.S_IFREG | 0o660 + monkeypatch.setattr( + deployment_broker, + "os", + types.SimpleNamespace( + name="posix", + R_OK=4, + geteuid=lambda: owner, + access=lambda _path, _mode: True, + ), + ) + assert not deployment_broker.DeploymentBrokerClient._trusted_ca_file_is_safe( + str(ca_file) + ) + + +def test_status_receipt_must_match_the_authenticated_request(status_module): + revision = "a" * 40 + project = { + "containers": ["Chimera"], + "verify_oci_revision": True, + "live_probe": { + "type": "chimera-health-live-v1", + }, + } + project["_repository_name"] = "jens/chimera" + receipt = _receipt(project, revision) + state = { + "schema_version": 3, + "repository": "jens/chimera", + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_receipt": receipt, + } + assert ( + status_module._verified_receipt(state, "jens/chimera", revision, project) + == receipt + ) + assert ( + status_module._verified_receipt(state, "jens/chimera", "d" * 40, project) + is None + ) + assert ( + status_module._verified_receipt(state, "jens/other", revision, project) is None + ) + + +def test_status_receipt_rejects_corrupt_state_container_and_live_proof(status_module): + revision = "a" * 40 + project = { + "containers": ["Chimera"], + "verify_oci_revision": True, + "live_probe": {"type": "chimera-health-live-v1"}, + "_repository_name": "jens/chimera", + } + receipt = _receipt(project, revision) + state = { + "schema_version": 3, + "repository": "jens/chimera", + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_receipt": receipt, + } + corruptions = [] + + bad = copy.deepcopy(state) + bad["live_legacy_provenance"] = True + corruptions.append(bad) + + bad = copy.deepcopy(state) + bad["live_receipt"]["treeDigest"] = "0" * 64 + corruptions.append(bad) + + bad = copy.deepcopy(state) + bad["live_receipt"]["build"]["id"] = "wrong" + corruptions.append(bad) + + bad = copy.deepcopy(state) + bad["live_receipt"]["containers"] = {"bogus": {}} + corruptions.append(bad) + + bad = copy.deepcopy(state) + bad["live_receipt"]["containers"]["Chimera"]["image_id"] = "sha256:short" + corruptions.append(bad) + + bad = copy.deepcopy(state) + bad["live_receipt"]["containers"]["Chimera"]["image_source"] = "jens/other" + corruptions.append(bad) + + bad = copy.deepcopy(state) + bad["live_receipt"]["live"]["status"] = "starting" + corruptions.append(bad) + + for corrupt in corruptions: + assert ( + status_module._verified_receipt(corrupt, "jens/chimera", revision, project) + is None + ) + + +def test_repository_webhook_secret_rejects_symlink( + status_module, tmp_path, monkeypatch +): + secret_root = tmp_path / "secrets" + secret_root.mkdir() + real = tmp_path / "real-secret" + real.write_text("x" * 48, encoding="utf-8") + real.chmod(0o600) + link = secret_root / "jens__repo-a" + try: + link.symlink_to(real) + except OSError: + pytest.skip("symbolic links are unavailable on this host") + monkeypatch.setattr(status_module, "WEBHOOK_SECRET_ROOT", secret_root) + assert status_module._webhook_secret("jens/repo-a", _webhook_project()) is None + + +def test_status_server_bounds_threads_and_times_out_partial_bodies( + status_module, monkeypatch +): + monkeypatch.setattr(status_module, "MAX_REQUEST_THREADS", 2) + monkeypatch.setattr(status_module, "REQUEST_TIMEOUT_SECONDS", 0.1) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread( + target=server.serve_forever, + kwargs={"poll_interval": 0.01}, + daemon=True, + ) + serving.start() + try: + assert server._request_slots.acquire(blocking=False) + assert server._request_slots.acquire(blocking=False) + assert not server._request_slots.acquire(blocking=False) + server._request_slots.release() + server._request_slots.release() + + with socket.create_connection(server.server_address, timeout=2) as client: + client.settimeout(2) + client.sendall( + b"POST /v1/gitea/push/jens/repo-a HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Length: 10\r\n" + b"\r\n" + b"{" + ) + chunks: list[bytes] = [] + while chunk := client.recv(4096): + chunks.append(chunk) + response = b"".join(chunks) + assert b" 408 " in response + assert b"request_timeout" in response + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_status_server_accepts_only_exact_signed_repository_push( + status_module, tmp_path, monkeypatch +): + secret = b"webhook-" + b"x" * 48 + secret_root = tmp_path / "secrets" + inbox_root = tmp_path / "inbox" + projects_path = tmp_path / "projects.json" + _write_webhook_secret(secret_root, "jens__repo-a", secret) + project = { + **_webhook_project(), + "slug": "repo-a", + "containers": ["RepoA"], + } + projects_path.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "WEBHOOK_SECRET_ROOT", secret_root) + monkeypatch.setattr(status_module, "WEBHOOK_INBOX_ROOT", inbox_root) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + revision = "a" * 40 + raw_body = json.dumps(_push_payload(revision), separators=(",", ":")).encode() + signature = hmac.new(secret, raw_body, hashlib.sha256).hexdigest() + headers = { + "Content-Type": "application/json", + "X-Gitea-Delivery": "5f6d9b7a-4d42-4ae0-8ab5-36cf7cb6ea6d", + "X-Gitea-Event": "push", + "X-Gitea-Event-Type": "push", + "X-Gitea-Hook-Installation-Target-Type": "repository", + "X-Gitea-Signature": signature, + } + try: + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/push/jens/repo-a", + body=raw_body, + headers=headers, + ) + response = client.getresponse() + body = json.loads(response.read()) + client.close() + assert response.status == 202 + assert body["status"] == "accepted" + assert body["revision"] == revision + request_digest = body["request"] + + replay_body = json.dumps( + { + **_push_payload(revision, repository="Jens/repo-a"), + "compare_url": "https://example.invalid", + }, + separators=(",", ":"), + ).encode() + replay_headers = { + **headers, + "X-Gitea-Delivery": "145440ab-b7b1-4c74-ae84-996d8d48167e", + "X-Gitea-Signature": hmac.new( + secret, replay_body, hashlib.sha256 + ).hexdigest(), + } + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/push/jens/repo-a", + body=replay_body, + headers=replay_headers, + ) + response = client.getresponse() + replay_response = json.loads(response.read()) + client.close() + assert response.status == 202 + assert replay_response["request"] == request_digest + assert len(list(inbox_root.glob("*.json"))) == 1 + + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/push/jens/repo-a", + body=raw_body + b" ", + headers=headers, + ) + response = client.getresponse() + assert response.status == 403 + assert b"webhook_signature_rejected" in response.read() + client.close() + + invalid_json = b"{" + invalid_headers = {**headers, "X-Gitea-Signature": "0" * 64} + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/push/jens/repo-a", + body=invalid_json, + headers=invalid_headers, + ) + response = client.getresponse() + assert response.status == 403 + assert b"webhook_signature_rejected" in response.read() + client.close() + + valid_invalid_headers = { + **headers, + "X-Gitea-Signature": hmac.new( + secret, invalid_json, hashlib.sha256 + ).hexdigest(), + } + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/push/jens/repo-a", + body=invalid_json, + headers=valid_invalid_headers, + ) + response = client.getresponse() + assert response.status == 400 + assert b"invalid_json" in response.read() + client.close() + + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request( + "POST", + "/v1/gitea/push/Jens/repo-a", + body=raw_body, + headers=headers, + ) + response = client.getresponse() + assert response.status == 404 + client.close() + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +@pytest.mark.parametrize( + ("raw_value", "expected"), + [ + ("1", 1), + ("4096", 4096), + ("1048576", 1048576), + ("0", None), + ("1048577", None), + ("00001", None), + ("-1", None), + ("\N{SUPERSCRIPT TWO}", None), + ("9" * 5000, None), + ], +) +def test_status_server_content_length_is_bounded_ascii( + status_module, raw_value, expected +): + assert status_module._bounded_content_length(raw_value) == expected + + +def test_status_server_closes_completed_json_responses(status_module): + handler = status_module.Handler.__new__(status_module.Handler) + headers: list[tuple[str, str]] = [] + statuses: list[int] = [] + handler.close_connection = False + handler.wfile = io.BytesIO() + handler.send_response = statuses.append + handler.send_header = lambda name, value: headers.append((name, value)) + handler.end_headers = lambda: None + + handler._send_json(200, b'{"status":"ok"}\n') + + assert statuses == [200] + assert ("Connection", "close") in headers + assert handler.close_connection is True + assert handler.wfile.getvalue() == b'{"status":"ok"}\n' + + +def _appops_project() -> dict[str, object]: + return { + "slug": "repo-a", + "compose_project": "repo-a", + "compose_files": [".itworx/deployment.yml"], + "containers": ["repo-a"], + "managed_plan_version": "itworx.deployment/v1", + "managed_application_id": "repo-a", + "managed_appdata_path": "/srv/example/appdata/repo-a", + "managed_image_repository": "repo-a", + "managed_image_tag": "1.2.3", + "managed_proxy_network": "itworx-npm", + "managed_internal_port": 8080, + "managed_proxy_hostname": None, + "managed_oidc_application_slug": None, + "appops_deployment_secret_file": "jens__repo-a", + } + + +def _appops_plan(revision: str) -> dict[str, object]: + return { + "api_version": "itworx.deployment/v1", + "application_id": "repo-a", + "repository": "jens/repo-a", + "revision": revision, + "release_version": "1.2.3", + "image_repository": "repo-a", + "image_tag": "1.2.3", + "topology": { + "kind": "compose", + "compose_file": ".itworx/deployment.yml", + "compose_project": "repo-a", + "containers": ["repo-a"], + "proxy_network": "itworx-npm", + "internal_port": 8080, + }, + "broker_secret_ref": "REPO_A_DEPLOYMENT_BROKER", + } + + +def _write_appops_secret(root: Path, name: str, value: bytes) -> None: + root.mkdir(mode=0o700) + root.chmod(0o700) + path = root / name + path.write_bytes(value) + path.chmod(0o600) + + +def test_bootstrap_signatures_bind_endpoint_and_are_single_use(status_module, tmp_path, monkeypatch): + secret = b"bootstrap-" + b"x" * 48 + secret_path = tmp_path / "enrollment-bootstrap" + secret_path.write_bytes(secret) + secret_path.chmod(0o600) + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", secret_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + raw = b'{"enrollment":{}}' + timestamp = str(int(status_module.time.time())) + nonce = "3" * 32 + signature = status_module._request_signature( + secret, + method="POST", + path="/v1/appops/enrollments/preflight", + timestamp=timestamp, + nonce=nonce, + raw_body=raw, + ) + + assert not status_module._bootstrap_signature_is_valid( + raw, timestamp, nonce, signature, + method="POST", path="/v1/appops/enrollments/rollback", + ) + assert status_module._bootstrap_signature_is_valid( + raw, timestamp, nonce, signature, + method="POST", path="/v1/appops/enrollments/preflight", + ) + assert not status_module._bootstrap_signature_is_valid( + raw, timestamp, nonce, signature, + method="POST", path="/v1/appops/enrollments/preflight", + ) + + +def test_repopulse_adoption_capabilities_is_hmac_protected_and_read_only( + status_module, tmp_path, monkeypatch +): + bootstrap_secret = b"adoption-bootstrap-" + b"x" * 48 + bootstrap_path = tmp_path / "bootstrap" + bootstrap_path.write_bytes(bootstrap_secret) + bootstrap_path.chmod(0o600) + nonce_root = tmp_path / "nonces" + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", bootstrap_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root) + monkeypatch.setattr( + status_module._repopulse_adoption_store, + "capability_report", + lambda: { + "repository": "jens/repopulse", + "executionEnabled": True, + "controllerAliasesReady": True, + "registryReady": True, + "secretRootReady": True, + }, + ) + monkeypatch.setattr(status_module, "_private_tls_configuration", lambda **_kwargs: {}) + monkeypatch.setattr(status_module, "_private_tls_context", lambda _configuration: object()) + monkeypatch.setattr(status_module, "_repopulse_adoption_alias_mounts_ready", lambda: True) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + body = b"{}" + timestamp = str(int(status_module.time.time())) + nonce = "5" * 32 + path = "/v1/appops/repopulse/adoption/capabilities" + signature = status_module._request_signature( + bootstrap_secret, + method="POST", + path=path, + timestamp=timestamp, + nonce=nonce, + raw_body=body, + ) + headers = { + "Content-Type": "application/json", + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": signature, + } + try: + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("POST", path, body=body, headers=headers) + response = client.getresponse() + payload = json.loads(response.read()) + client.close() + + assert response.status == 200 + assert payload == { + "apiVersion": "itworx.repopulse-adoption-capabilities/v1", + "status": "ready", + "repository": "jens/repopulse", + "adoptionStore": {"executionEnabled": True}, + "privateTls": {"status": "ready"}, + "controller": { + "aliases": "ready", + "registry": "ready", + "secretRoot": "ready", + }, + } + assert bootstrap_secret.decode("ascii") not in json.dumps(payload) + assert nonce_root.is_dir() + assert len(list(nonce_root.iterdir())) == 1 + assert not (tmp_path / "repopulse-adoption").exists() + + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("POST", path, body=body, headers=headers) + replay = client.getresponse() + replay_payload = json.loads(replay.read()) + client.close() + assert replay.status == 403 + assert replay_payload == {"status": "authentication_rejected"} + assert len(list(nonce_root.iterdir())) == 1 + + for unavailable_path in ( + "/v1/appops/repopulse/adoption/execute", + "/v1/appops/repopulse/adoption/rollback", + ): + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("POST", unavailable_path, body=body) + unsupported = client.getresponse() + unsupported.read() + client.close() + assert unsupported.status == 404 + assert len(list(nonce_root.iterdir())) == 1 + + monkeypatch.setattr( + status_module, + "_private_tls_configuration", + lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("unsafe path detail")), + ) + tls_nonce = "6" * 32 + tls_timestamp = str(int(status_module.time.time())) + tls_signature = status_module._request_signature( + bootstrap_secret, + method="POST", + path=path, + timestamp=tls_timestamp, + nonce=tls_nonce, + raw_body=body, + ) + tls_headers = { + **headers, + "X-ITWorx-Deployment-Timestamp": tls_timestamp, + "X-ITWorx-Deployment-Nonce": tls_nonce, + "X-ITWorx-Deployment-Signature": tls_signature, + } + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("POST", path, body=body, headers=tls_headers) + tls_unavailable = client.getresponse() + tls_payload = json.loads(tls_unavailable.read()) + client.close() + assert tls_unavailable.status == 503 + assert tls_payload["privateTls"] == {"status": "unavailable"} + assert tls_payload["controller"]["aliases"] == "ready" + assert "unsafe path detail" not in json.dumps(tls_payload) + assert len(list(nonce_root.iterdir())) == 2 + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def _configure_repopulse_adoption_store_for_http_test( + status_module, tmp_path, monkeypatch, *, enabled +): + store_module = status_module._repopulse_adoption_store + controller = tmp_path / "controller" + controller.mkdir() + controller.chmod(0o755) + projects = { + "jens/repopulse": store_module._legacy_registry_record(), + "jens/other": {"slug": "other"}, + } + projects_path = controller / "projects.json" + projects_path.write_text(json.dumps(projects, sort_keys=True) + "\n", encoding="utf-8") + projects_path.chmod(0o600) + secrets = controller / "appops-deployment-secrets" + secrets.mkdir() + secrets.chmod(0o700) + state = tmp_path / "state" + state.mkdir() + state.chmod(0o755) + locks = state / "locks" + locks.mkdir() + locks.chmod(0o755) + state_store = state / "repopulse-adoption" + project_lock = locks / "repopulse.lock" + global_lock = tmp_path / "deploy.lock" + + for name, value in { + "CONTROLLER_READONLY_ROOT": controller, + "CONTROLLER_RW_ROOT": controller, + "STATE_ROOT": state, + "GLOBAL_DEPLOY_LOCK": global_lock, + "PROJECT_LOCK": project_lock, + "ADOPTION_STATE_ROOT": state_store, + "EXECUTION_ENABLED": enabled, + "_IS_POSIX_CONTROLLER": True, + }.items(): + monkeypatch.setattr(store_module, name, value) + test_uid = controller.stat().st_uid + monkeypatch.setattr(store_module, "_ROOT_UID", test_uid) + monkeypatch.setattr(store_module, "_effective_uid", lambda: test_uid) + if os.name != "posix": + monkeypatch.setattr( + store_module, + "_mode_bits", + lambda path, info: ( + 0o700 + if path.name in {"appops-deployment-secrets", "repopulse-adoption"} + else 0o755 + if stat.S_ISDIR(info.st_mode) + else 0o600 + ), + ) + monkeypatch.setattr(store_module, "_directory_fsync", lambda _path: None) + return store_module, state_store, project_lock, global_lock, controller + + +def _adoption_bootstrap_headers(status_module, secret, *, method, path, body, nonce): + timestamp = str(int(status_module.time.time())) + signature = status_module._request_signature( + secret, + method=method, + path=path, + timestamp=timestamp, + nonce=nonce, + raw_body=body, + ) + return { + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": signature, + } + + +def _adoption_http_request(server, method, path, *, body=None, headers=None): + client = http.client.HTTPConnection(*server.server_address, timeout=2) + if body is None: + client.request(method, path, headers=headers or {}) + else: + client.request(method, path, body=body, headers=headers or {}) + response = client.getresponse() + status = response.status + payload = json.loads(response.read()) + client.close() + return status, payload + + +def _valid_repopulse_reserve_body(status_module, *, seed="reserve-seed"): + idempotency_sha256 = hashlib.sha256(seed.encode("ascii")).hexdigest() + payload = { + "intentId": f"rpa-{idempotency_sha256[:20]}", + "idempotencySha256": idempotency_sha256, + "preflightSha256": hashlib.sha256(b"fixed-preflight").hexdigest(), + } + return json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"), payload + + +def test_repopulse_reserve_is_hmac_protected_strict_and_disabled_before_store_write( + status_module, tmp_path, monkeypatch +): + store_module, state_store, project_lock, global_lock, _controller = ( + _configure_repopulse_adoption_store_for_http_test( + status_module, tmp_path, monkeypatch, enabled=False + ) + ) + secret = b"reserve-bootstrap-secret-" + b"x" * 40 + bootstrap_path = tmp_path / "bootstrap" + bootstrap_path.write_bytes(secret) + bootstrap_path.chmod(0o600) + nonce_root = tmp_path / "nonces" + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", bootstrap_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root) + transaction_calls = [] + + def forbidden_transaction(*_args, **_kwargs): + transaction_calls.append(True) + raise AssertionError("disabled reserve must not enter store transaction") + + monkeypatch.setattr( + store_module.RepoPulseAdoptionStore, "transaction", forbidden_transaction + ) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + reserve_path = status_module.REPOPULSE_ADOPTION_RESERVE_PATH + raw_body, payload = _valid_repopulse_reserve_body(status_module) + try: + invalid_payloads = [ + {**payload, "path": "/tmp/attacker"}, + {key: value for key, value in payload.items() if key != "preflightSha256"}, + {**payload, "idempotencySha256": True}, + {**payload, "intentId": "rpa-" + "f" * 20}, + {**payload, "preflightSha256": "A" * 64}, + ] + for invalid in invalid_payloads: + body = json.dumps(invalid, separators=(",", ":")).encode("utf-8") + response_status, response = _adoption_http_request( + server, "POST", reserve_path, body=body + ) + assert response_status == 400 + assert response == {"status": "invalid_reserve_request"} + + # Duplicate keys are rejected before auth and never interpreted as a + # different request from the one the caller intended to sign. + duplicate_body = ( + b'{"intentId":"' + + payload["intentId"].encode("ascii") + + b'","intentId":"' + + payload["intentId"].encode("ascii") + + b'","idempotencySha256":"' + + payload["idempotencySha256"].encode("ascii") + + b'","preflightSha256":"' + + payload["preflightSha256"].encode("ascii") + + b'"}' + ) + duplicate_status, duplicate_response = _adoption_http_request( + server, "POST", reserve_path, body=duplicate_body + ) + assert duplicate_status == 400 + assert duplicate_response == {"status": "invalid_json"} + + # Even a valid contract cannot reach the store without the HMAC. + unauthenticated_status, unauthenticated = _adoption_http_request( + server, "POST", reserve_path, body=raw_body + ) + assert unauthenticated_status == 403 + assert unauthenticated == {"status": "authentication_rejected"} + + headers = _adoption_bootstrap_headers( + status_module, + secret, + method="POST", + path=reserve_path, + body=raw_body, + nonce="1" * 32, + ) + disabled_status, disabled = _adoption_http_request( + server, "POST", reserve_path, body=raw_body, headers=headers + ) + assert disabled_status == 503 + assert disabled == { + "apiVersion": "itworx.repopulse-adoption-reserve/v1", + "status": "execution_disabled", + } + replay_status, replay = _adoption_http_request( + server, "POST", reserve_path, body=raw_body, headers=headers + ) + assert replay_status == 403 + assert replay == {"status": "authentication_rejected"} + + status_path = status_module.REPOPULSE_ADOPTION_STATUS_PATH + status_headers = _adoption_bootstrap_headers( + status_module, + secret, + method="GET", + path=status_path, + body=b"", + nonce="2" * 32, + ) + status_status, status_payload = _adoption_http_request( + server, "GET", status_path, headers=status_headers + ) + assert status_status == 200 + assert status_payload == { + "apiVersion": "itworx.repopulse-adoption-status/v1", + "status": "not_reserved", + "phase": None, + "hashes": {}, + } + assert not state_store.exists() + assert not project_lock.exists() + assert not global_lock.exists() + replay_status, replay = _adoption_http_request( + server, "GET", status_path, headers=status_headers + ) + assert replay_status == 403 + assert replay == {"status": "authentication_rejected"} + state_store.write_text("unsafe status state", encoding="utf-8") + unsafe_headers = _adoption_bootstrap_headers( + status_module, + secret, + method="GET", + path=status_path, + body=b"", + nonce="7" * 32, + ) + unsafe_status, unsafe_payload = _adoption_http_request( + server, "GET", status_path, headers=unsafe_headers + ) + assert unsafe_status == 503 + assert unsafe_payload == { + "apiVersion": "itworx.repopulse-adoption-status/v1", + "status": "unavailable", + } + assert state_store.read_text(encoding="utf-8") == "unsafe status state" + assert "unsafe status state" not in json.dumps(unsafe_payload) + assert len(list(nonce_root.iterdir())) == 3 + assert transaction_calls == [] + assert not project_lock.exists() + assert not global_lock.exists() + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_repopulse_reserve_is_idempotent_locked_and_status_redacts_credential( + status_module, tmp_path, monkeypatch +): + store_module, state_store, project_lock, global_lock, controller = ( + _configure_repopulse_adoption_store_for_http_test( + status_module, tmp_path, monkeypatch, enabled=True + ) + ) + secret = b"reserve-bootstrap-secret-" + b"y" * 40 + bootstrap_path = tmp_path / "bootstrap" + bootstrap_path.write_bytes(secret) + bootstrap_path.chmod(0o600) + nonce_root = tmp_path / "nonces" + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", bootstrap_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root) + credential = b"very-high-entropy-existing-controller-credential" * 2 + credential_path = controller / "appops-deployment-secrets" / "jens__repopulse" + credential_path.write_bytes(credential) + credential_path.chmod(0o600) + + lock_events = [] + original_lock = store_module._exclusive_flock + + @contextmanager + def tracked_lock(path): + with original_lock(path): + lock_events.append(path) + yield + + monkeypatch.setattr(store_module, "_exclusive_flock", tracked_lock) + original_reserve = store_module.RepoPulseAdoptionStore.reserve_intent + + def checked_reserve(store, **kwargs): + assert status_module._enrollment_write_lock.locked() + assert lock_events[-2:] == [project_lock, global_lock] + return original_reserve(store, **kwargs) + + monkeypatch.setattr( + store_module.RepoPulseAdoptionStore, "reserve_intent", checked_reserve + ) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + reserve_path = status_module.REPOPULSE_ADOPTION_RESERVE_PATH + raw_body, payload = _valid_repopulse_reserve_body(status_module) + try: + first_headers = _adoption_bootstrap_headers( + status_module, + secret, + method="POST", + path=reserve_path, + body=raw_body, + nonce="3" * 32, + ) + first_status, first = _adoption_http_request( + server, "POST", reserve_path, body=raw_body, headers=first_headers + ) + second_headers = _adoption_bootstrap_headers( + status_module, + secret, + method="POST", + path=reserve_path, + body=raw_body, + nonce="4" * 32, + ) + second_status, second = _adoption_http_request( + server, "POST", reserve_path, body=raw_body, headers=second_headers + ) + assert first_status == second_status == 200 + assert first == second + assert first["apiVersion"] == "itworx.repopulse-adoption-reserve/v1" + assert first["status"] == "reserved" + assert first["phase"] == "reserved" + assert first["intentId"] == payload["intentId"] + assert len(first["intentSha256"]) == 64 + assert lock_events == [project_lock, global_lock, project_lock, global_lock] + assert not status_module._enrollment_write_lock.locked() + assert state_store.is_dir() + assert (state_store / "intent.json").is_file() + + conflict_body, _conflict_payload = _valid_repopulse_reserve_body( + status_module, seed="different-reservation" + ) + conflict_headers = _adoption_bootstrap_headers( + status_module, + secret, + method="POST", + path=reserve_path, + body=conflict_body, + nonce="5" * 32, + ) + conflict_status, conflict = _adoption_http_request( + server, "POST", reserve_path, body=conflict_body, headers=conflict_headers + ) + assert conflict_status == 409 + assert conflict == { + "apiVersion": "itworx.repopulse-adoption-reserve/v1", + "status": "conflict", + } + assert lock_events == [ + project_lock, + global_lock, + project_lock, + global_lock, + project_lock, + global_lock, + ] + + status_path = status_module.REPOPULSE_ADOPTION_STATUS_PATH + status_headers = _adoption_bootstrap_headers( + status_module, + secret, + method="GET", + path=status_path, + body=b"", + nonce="6" * 32, + ) + read_status, status_payload = _adoption_http_request( + server, "GET", status_path, headers=status_headers + ) + assert read_status == 200 + assert status_payload["status"] == "in_progress" + assert status_payload["phase"] == "reserved" + assert status_payload["hashes"]["idempotencySha256"] == payload["idempotencySha256"] + assert status_payload["hashes"]["preflightSha256"] == payload["preflightSha256"] + encoded_status = json.dumps(status_payload) + assert credential.decode("ascii") not in encoded_status + assert hashlib.sha256(credential).hexdigest() not in encoded_status + assert "expectedTargetCredentialSha256" not in encoded_status + assert len(list(nonce_root.iterdir())) == 4 + assert not status_module._enrollment_write_lock.locked() + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_repopulse_enrollment_rollback_reloads_registry_under_project_global_then_write_lock( + status_module, tmp_path, monkeypatch +): + store_module, _state_store, project_lock, global_lock, _controller = ( + _configure_repopulse_adoption_store_for_http_test( + status_module, tmp_path, monkeypatch, enabled=False + ) + ) + secret = b"rollback-bootstrap-secret-" + b"x" * 40 + bootstrap_path = tmp_path / "bootstrap" + bootstrap_path.write_bytes(secret) + bootstrap_path.chmod(0o600) + monkeypatch.setattr(status_module, "APPOPS_ENROLLMENT_BOOTSTRAP_SECRET_FILE", bootstrap_path) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + lock_events = [] + original_lock = store_module._exclusive_flock + project_lock_waiting = threading.Event() + allow_project_lock = threading.Event() + + @contextmanager + def tracked_lock(path): + if path == project_lock: + project_lock_waiting.set() + if not allow_project_lock.wait(timeout=5): + raise TimeoutError("test did not release the project lock gate") + with original_lock(path): + lock_events.append(path) + yield + + monkeypatch.setattr(store_module, "_exclusive_flock", tracked_lock) + + class TrackedEnrollmentLock: + def __init__(self): + self._lock = threading.Lock() + + def __enter__(self): + lock_events.append("enrollment_lock") + self._lock.acquire() + return self + + def __exit__(self, *_args): + self._lock.release() + + def locked(self): + return self._lock.locked() + + monkeypatch.setattr(status_module, "_enrollment_write_lock", TrackedEnrollmentLock()) + load_calls = 0 + + def load_projects(): + nonlocal load_calls + load_calls += 1 + if load_calls == 1: + return {"jens/repopulse": {"revision": "stale"}} + assert lock_events[-3:] == [project_lock, global_lock, "enrollment_lock"] + assert status_module._enrollment_write_lock.locked() + assert ( + store_module._transaction_owner(store_module._lock_identity()) is None + ) + return {"jens/repopulse": {"revision": "fresh"}} + + observed_targets = [] + + def rollback(projects, target): + assert lock_events[-3:] == [project_lock, global_lock, "enrollment_lock"] + assert status_module._enrollment_write_lock.locked() + assert ( + store_module._transaction_owner(store_module._lock_identity()) is None + ) + assert projects["jens/repopulse"]["revision"] == "fresh" + observed_targets.append(target) + return "rolled_back", { + "enrollmentId": target["enrollment_id"], + "applicationId": target["application_id"], + } + + monkeypatch.setattr(status_module, "_load_projects", load_projects) + monkeypatch.setattr(status_module, "_rollback_enrollment_target", rollback) + server = status_module.BoundedThreadingHTTPServer( + ("127.0.0.1", 0), status_module.Handler + ) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + path = "/v1/appops/enrollments/rollback" + target = { + "enrollment_id": "enr-" + "c" * 20, + "application_id": "repopulse", + "repository": "jens/repopulse", + } + raw_body = json.dumps({"target": target}, separators=(",", ":")).encode("utf-8") + headers = { + "Content-Type": "application/json", + **_adoption_bootstrap_headers( + status_module, + secret, + method="POST", + path=path, + body=raw_body, + nonce="8" * 32, + ), + } + response_values = [] + + def send_rollback_request(): + response_values.append( + _adoption_http_request(server, "POST", path, body=raw_body, headers=headers) + ) + + request_thread = threading.Thread(target=send_rollback_request) + try: + request_thread.start() + assert project_lock_waiting.wait(timeout=5) + assert not status_module._enrollment_write_lock.locked(), ( + "rollback must not hold the enrollment lock while waiting for the project flock" + ) + allow_project_lock.set() + request_thread.join(timeout=5) + assert not request_thread.is_alive() + response_status, response = response_values[0] + assert response_status == 200 + assert response == { + "status": "rolled_back", + "enrollmentId": target["enrollment_id"], + "applicationId": target["application_id"], + } + assert load_calls == 2 + assert observed_targets == [target] + assert lock_events == [project_lock, global_lock, "enrollment_lock"] + assert not status_module._enrollment_write_lock.locked() + finally: + allow_project_lock.set() + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_repopulse_adoption_capabilities_client_uses_fixed_bootstrap_hmac( + monkeypatch, +): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + captured = {} + + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"status": "ready", "repository": "jens/repopulse"} + + class Client: + def __init__(self, **kwargs): + captured["client"] = kwargs + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def request(self, method, url, *, content, headers): + captured.update( + {"method": method, "url": url, "body": content, "headers": headers} + ) + return Response() + + monkeypatch.setattr("backend.app.services.deployment_broker.httpx.Client", Client) + secret = "bootstrap-client-secret-value" * 2 + client = DeploymentBrokerClient(base_url="https://controller.example", verify_tls=True) + + assert client.repopulse_adoption_capabilities(secret=secret) == { + "status": "ready", + "repository": "jens/repopulse", + } + assert captured["method"] == "POST" + assert captured["client"]["verify"] is True + assert captured["url"] == ( + "https://controller.example/v1/appops/repopulse/adoption/capabilities" + ) + assert captured["body"] == b"{}" + headers = captured["headers"] + assert headers["X-ITWorx-Deployment-Signature"] == DeploymentBrokerClient._signature( + secret, + "POST", + "/v1/appops/repopulse/adoption/capabilities", + headers["X-ITWorx-Deployment-Timestamp"], + headers["X-ITWorx-Deployment-Nonce"], + b"{}", + ) + + +def test_repopulse_adoption_client_binds_commands_to_fixed_paths(monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + requests = [] + + class Response: + status_code = 200 + + @staticmethod + def json(): + return {"status": "ok"} + + class Client: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def request(self, method, url, *, content, headers): + requests.append((method, url, content, headers)) + return Response() + + monkeypatch.setattr("backend.app.services.deployment_broker.httpx.Client", Client) + secret = "bootstrap-client-secret-value" * 2 + client = DeploymentBrokerClient(base_url="https://controller.example", verify_tls=True) + digest = "a" * 64 + + assert client.repopulse_adoption_reserve( + secret=secret, + intent_id=f"rpa-{digest[:20]}", + idempotency_sha256=digest, + preflight_sha256="b" * 64, + ) == {"status": "ok"} + assert client.repopulse_adoption_status(secret=secret) == {"status": "ok"} + assert client.repopulse_adoption_execute( + secret=secret, + intent_id=f"rpa-{digest[:20]}", + idempotency_sha256=digest, + preflight_sha256="b" * 64, + preflight_projection={"repository": "jens/repopulse"}, + target_credential="target-secret-value" * 4, + ) == {"status": "ok"} + assert [(method, url.rsplit("/", 1)[-1]) for method, url, _, _ in requests] == [ + ("POST", "reserve"), + ("GET", "status"), + ("POST", "execute"), + ] + assert json.loads(requests[0][2]) == { + "idempotencySha256": digest, + "intentId": f"rpa-{digest[:20]}", + "preflightSha256": "b" * 64, + } + assert requests[1][2] == b"" + assert json.loads(requests[2][2]) == { + "idempotencySha256": digest, + "intentId": f"rpa-{digest[:20]}", + "preflightProjection": {"repository": "jens/repopulse"}, + "preflightSha256": "b" * 64, + "targetCredential": "target-secret-value" * 4, + } + for method, url, body, headers in requests: + path = url.removeprefix("https://controller.example") + assert headers["X-ITWorx-Deployment-Signature"] == DeploymentBrokerClient._signature( + secret, + method, + path, + headers["X-ITWorx-Deployment-Timestamp"], + headers["X-ITWorx-Deployment-Nonce"], + body, + ) + + assert DeploymentBrokerClient( + base_url="http://127.0.0.1:1300", verify_tls=False + ).repopulse_adoption_execute( + secret=secret, + intent_id=f"rpa-{digest[:20]}", + idempotency_sha256=digest, + preflight_sha256="b" * 64, + preflight_projection={"repository": "jens/repopulse"}, + target_credential="target-secret-value" * 4, + ) == { + "status": "missing_config", + "reason": "repopulse_adoption_private_tls_required", + } + assert len(requests) == 3 + + +def test_controller_rejects_a_group_writable_projects_registry(status_module, tmp_path, monkeypatch): + if status_module.os.name == "nt": + pytest.skip("POSIX ownership and permissions are enforced by the controller") + projects = tmp_path / "projects.json" + projects.write_text("{}", encoding="utf-8") + projects.chmod(0o666) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects) + + assert status_module._projects_registry_is_safe() is False + with pytest.raises(OSError, match="unsafe"): + status_module._load_projects() + + +def test_repopulse_capability_requires_fixed_readonly_and_rw_mount_aliases(status_module): + readonly_root = status_module._repopulse_adoption_store.CONTROLLER_READONLY_ROOT + rw_root = status_module._repopulse_adoption_store.CONTROLLER_RW_ROOT + valid_mountinfo = ( + f"25 1 0:2 / {readonly_root.as_posix()} ro - ext4 /dev/test ro\n" + f"26 1 0:2 / {rw_root.as_posix()} rw - ext4 /dev/test rw\n" + ) + assert status_module._repopulse_alias_mounts_match_contract(valid_mountinfo) is True + + readonly_changed_to_rw = valid_mountinfo.replace( + f"{readonly_root.as_posix()} ro", f"{readonly_root.as_posix()} rw" + ) + assert status_module._repopulse_alias_mounts_match_contract(readonly_changed_to_rw) is False + + rw_changed_to_readonly = valid_mountinfo.replace( + f"{rw_root.as_posix()} rw", f"{rw_root.as_posix()} ro" + ) + assert status_module._repopulse_alias_mounts_match_contract(rw_changed_to_readonly) is False + assert status_module._repopulse_alias_mounts_match_contract("") is False + + +def test_controller_release_identity_is_content_bound(status_module, tmp_path, monkeypatch, capsys): + renderer_spec = importlib.util.spec_from_file_location( + "prepare_controller_artifact", + Path(__file__).resolve().parents[2] / "scripts" / "prepare_controller_artifact.py", + ) + assert renderer_spec is not None and renderer_spec.loader is not None + renderer = importlib.util.module_from_spec(renderer_spec) + renderer_spec.loader.exec_module(renderer) + assert renderer._ARTIFACT_FILES == status_module._CONTROLLER_ARTIFACT_FILES + artifact_root = tmp_path / "controller" + artifact_root.mkdir() + for filename in status_module._CONTROLLER_ARTIFACT_FILES: + source = ( + Path(__file__).resolve().parents[2] / "scripts" / filename + if filename == "run_repopulse_adoption.py" + else status_module.ROOT / filename + ) + (artifact_root / filename).write_bytes(source.read_bytes()) + monkeypatch.setattr(status_module, "ROOT", artifact_root) + monkeypatch.setattr(status_module, "CONTROLLER_RELEASE_PATH", artifact_root / "controller-release.json") + digest = status_module._controller_artifact_digest() + assert digest is not None + release = { + "apiVersion": "itworx.controller-artifact/v1", + "controllerVersion": "2.0.0", + "sourceRepository": "jens/unraid-appops-gateway", + "sourceRevision": "a" * 40, + "sourceTree": "b" * 40, + "artifactSha256": digest, + "builtAt": "2026-09-14T01:00:00.000Z", + } + status_module.CONTROLLER_RELEASE_PATH.write_text(json.dumps(release), encoding="utf-8") + + assert status_module._controller_release() == release + monkeypatch.setattr(status_module, "_load_projects", lambda: (_ for _ in ()).throw(AssertionError("legacy config must not be read"))) + monkeypatch.setattr(status_module.sys, "argv", ["status_server.py", "--check-artifact"]) + status_module.main() + assert "validated controller artifact 2.0.0" in capsys.readouterr().out + (artifact_root / "deploy.py").write_text("tampered", encoding="utf-8") + assert status_module._controller_release() is None + + +def test_appops_deployment_contract_is_pre_registered_and_hmac_bound(status_module, tmp_path, monkeypatch): + from backend.app.services.deployment_broker import DeploymentBrokerClient + + secret_root = tmp_path / "appops-secrets" + secret = b"appops-" + b"x" * 48 + _write_appops_secret(secret_root, "jens__repo-a", secret) + project = _appops_project() + plan = _appops_plan("a" * 40) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secret_root) + + assert status_module._appops_plan({"jens/repo-a": project}, plan) == ( + "jens/repo-a", + "a" * 40, + project, + ) + raw = json.dumps({"plan": plan}, sort_keys=True, separators=(",", ":")).encode() + timestamp = str(int(status_module.time.time())) + nonce = "1" * 32 + nonce_root = tmp_path / "nonces" + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", nonce_root) + signature = status_module._request_signature( + secret, + method="POST", + path="/v1/appops/deployments/apply", + timestamp=timestamp, + nonce=nonce, + raw_body=raw, + ) + expected = hmac.new( + secret, + ( + "itworx-controller-request/v2\nPOST\n/v1/appops/deployments/apply\n" + f"{timestamp}\n{nonce}\n{hashlib.sha256(raw).hexdigest()}" + ).encode("ascii"), + hashlib.sha256, + ).hexdigest() + assert expected == signature + # A captured apply request cannot be retargeted to rollback before its + # nonce is consumed: the endpoint path is part of the authenticated input. + assert not status_module._appops_signature_is_valid( + raw, timestamp, nonce, signature, "jens/repo-a", project, + method="POST", path="/v1/appops/deployments/rollback", + ) + assert status_module._appops_signature_is_valid( + raw, timestamp, nonce, signature, "jens/repo-a", project, + method="POST", path="/v1/appops/deployments/apply", + ) + # The same authenticated request is rejected after its atomic nonce write. + assert not status_module._appops_signature_is_valid( + raw, timestamp, nonce, signature, "jens/repo-a", project, + method="POST", path="/v1/appops/deployments/apply", + ) + assert not status_module._appops_signature_is_valid( + raw, timestamp, "2" * 32, "0" * 64, "jens/repo-a", project, + method="POST", path="/v1/appops/deployments/apply", + ) + legacy_signature = hmac.new( + secret, + f"{timestamp}\n{'4' * 32}\n{hashlib.sha256(raw).hexdigest()}".encode(), + hashlib.sha256, + ).hexdigest() + assert DeploymentBrokerClient._signature( + secret.decode(), "POST", "/v1/appops/deployments/apply", timestamp, nonce, raw + ) == signature + assert not status_module._appops_signature_is_valid( + raw, timestamp, "4" * 32, legacy_signature, "jens/repo-a", project, + method="POST", path="/v1/appops/deployments/apply", + ) + + tampered = {**plan, "image_repository": "other"} + assert status_module._appops_plan({"jens/repo-a": project}, tampered) is None + + +def test_appops_request_is_idempotent_and_runs_only_fixed_deploy(status_module, tmp_path, monkeypatch): + inbox = tmp_path / "appops-inbox" + projects = tmp_path / "projects.json" + project = _appops_project() + projects.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + plan = _appops_plan("a" * 40) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects) + status, request_id = status_module._enqueue_appops_request( + repository="jens/repo-a", + revision="a" * 40, + operation="apply", + idempotency_key="appops-repo-a-0001", + plan=plan, + ) + assert status == "pending" + assert status_module._enqueue_appops_request( + repository="jens/repo-a", + revision="a" * 40, + operation="apply", + idempotency_key="appops-repo-a-0001", + plan=plan, + ) == ("pending", request_id) + marker = inbox / f"{request_id}.json" + calls = [] + + class Result: + returncode = 0 + + monkeypatch.setattr(status_module.subprocess, "run", lambda args, **_kwargs: calls.append(args) or Result()) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"verified": True}) + status_module._process_appops_record(marker) + + assert json.loads(marker.read_text())["status"] == "deployed" + assert calls == [["python3", str(status_module.ROOT / "deploy.py"), "deploy", "jens/repo-a", "a" * 40]] + + +def test_durable_rollback_snapshot_is_receipt_bound_and_not_caller_selected(deploy_module, tmp_path, monkeypatch): + previous_revision = "a" * 40 + current_revision = "b" * 40 + project = _project(tmp_path) + monkeypatch.setattr(deploy_module, "STATE_ROOT", tmp_path / "state") + previous_receipt = _receipt(project, previous_revision) + current_receipt = _receipt(project, current_revision) + snapshot = { + "revision": previous_revision, + "legacy_provenance": False, + "receipt": previous_receipt, + "compose_containers": ["Unraid-AppOps-Gateway"], + "containers": [{ + "name": "Unraid-AppOps-Gateway", + "container_id": "container-a", + "image_id": "sha256:" + "1" * 64, + "image_ref": "Unraid-AppOps-Gateway:production", + "rollback_ref": "gitea-deploy-rollback/unraid-appops-gateway-0:aaaaaaaaaaaa", + "was_running": True, + }], + } + state = { + "schema_version": 3, + "repository": "jens/unraid-appops-gateway", + "live_revision": current_revision, + "revision": current_revision, + "live_status": "healthy", + "live_receipt": current_receipt, + "rollback_snapshot": snapshot, + } + state_path = deploy_module.STATE_ROOT / "unraid-appops-gateway.json" + state_path.parent.mkdir(parents=True) + state_path.write_text(json.dumps(state), encoding="utf-8") + monkeypatch.setattr( + deploy_module, + "inspect_image", + lambda image_id: {"Id": image_id}, + ) + + assert deploy_module.durable_rollback_snapshot( + project, "jens/unraid-appops-gateway", current_revision + ) == snapshot + with pytest.raises(deploy_module.DeployError, match="verified live deployment"): + deploy_module.durable_rollback_snapshot( + project, "jens/unraid-appops-gateway", "c" * 40 + ) + + +def test_managed_runtime_contract_rejects_published_ports_and_accepts_owned_hardened_compose(deploy_module, tmp_path, monkeypatch): + project = _project(tmp_path) + project.update({ + "managed_plan_version": "itworx.deployment/v1", + "managed_application_id": "unraid-appops-gateway", + "managed_appdata_path": "/srv/example/appdata/unraid-appops-gateway", + "managed_image_repository": "unraid-appops-gateway", + "managed_image_tag": "1.5.0", + "managed_proxy_network": "itworx-npm", + "managed_internal_port": 1216, + "appops_deployment_secret_file": "jens__unraid-appops-gateway", + }) + rendered = { + "services": { + "unraid-appops-gateway": { + "image": "unraid-appops-gateway:1.5.0", + "restart": "unless-stopped", + "read_only": True, + "security_opt": ["no-new-privileges:true"], + "cap_drop": ["ALL"], + "networks": ["itworx-npm"], + "volumes": [{ + "type": "bind", + "source": "/srv/example/appdata/unraid-appops-gateway/data", + "target": "/data", + }], + } + }, + "networks": {"itworx-npm": {"external": True}}, + } + monkeypatch.setattr(deploy_module, "compose_command", lambda _project: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_args: {}) + monkeypatch.setattr(deploy_module, "output", lambda *_args, **_kwargs: json.dumps(rendered)) + + deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/unraid-appops-gateway") + rendered["services"]["unraid-appops-gateway"]["ports"] = [{"published": "1216"}] + with pytest.raises(deploy_module.DeployError, match="published ports"): + deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/unraid-appops-gateway") + + +def test_managed_runtime_contract_rejects_appdata_path_traversal_and_controller_env_inheritance(deploy_module, tmp_path, monkeypatch): + project = _project(tmp_path) + project.update({ + "managed_plan_version": "itworx.deployment/v1", + "managed_application_id": "unraid-appops-gateway", + "managed_appdata_path": "/srv/example/appdata/unraid-appops-gateway", + "managed_image_repository": "unraid-appops-gateway", + "managed_image_tag": "1.5.0", + "managed_proxy_network": "itworx-npm", + "managed_internal_port": 1216, + "appops_deployment_secret_file": "jens__unraid-appops-gateway", + "environment": {"PORTAL_ADMIN_KEY": "managed-secret"}, + }) + monkeypatch.setenv("UNRELATED_CONTROLLER_SECRET", "must-not-reach-compose") + environment = deploy_module.deployment_environment(project, "a" * 40, "jens/unraid-appops-gateway") + assert environment["PORTAL_ADMIN_KEY"] == "managed-secret" + assert "UNRELATED_CONTROLLER_SECRET" not in environment + rendered = { + "services": { + "unraid-appops-gateway": { + "image": "unraid-appops-gateway:1.5.0", + "restart": "unless-stopped", + "read_only": True, + "security_opt": ["no-new-privileges:true"], + "cap_drop": ["ALL"], + "environment": {"PORTAL_ADMIN_KEY": "managed-secret"}, + "networks": ["itworx-npm"], + "volumes": [{ + "type": "bind", + "source": "/srv/example/appdata/unraid-appops-gateway/../../other-app", + "target": "/data", + }], + } + }, + "networks": {"itworx-npm": {"external": True}}, + } + monkeypatch.setattr(deploy_module, "compose_command", lambda _project: (tmp_path, ["docker", "compose"])) + monkeypatch.setattr(deploy_module, "output", lambda *_args, **_kwargs: json.dumps(rendered)) + + with pytest.raises(deploy_module.DeployError, match="appdata root"): + deploy_module.validate_managed_v1_runtime(project, "a" * 40, "jens/unraid-appops-gateway") + + +def test_appops_rollback_runs_only_receipt_bound_controller_mode(status_module, tmp_path, monkeypatch): + inbox = tmp_path / "appops-inbox" + projects = tmp_path / "projects.json" + project = _appops_project() + projects.write_text(json.dumps({"jens/repo-a": project}), encoding="utf-8") + plan = _appops_plan("a" * 40) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects) + status, request_id = status_module._enqueue_appops_request( + repository="jens/repo-a", + revision="a" * 40, + operation="rollback", + idempotency_key="rollback-repo-a-0001", + plan=plan, + ) + assert status == "pending" + marker = inbox / f"{request_id}.json" + calls = [] + + class Result: + returncode = 0 + + monkeypatch.setattr(status_module.subprocess, "run", lambda args, **_kwargs: calls.append(args) or Result()) + monkeypatch.setattr( + status_module, + "_deployment_state", + lambda _slug: {"rollback_restored_revision": "b" * 40}, + ) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"verified": True}) + status_module._process_appops_record(marker) + + assert json.loads(marker.read_text())["status"] == "rolled_back" + assert calls == [["python3", str(status_module.ROOT / "deploy.py"), "rollback", "jens/repo-a", "a" * 40]] + + +def test_webhook_broker_has_no_runner_or_job_secret_artifacts(): + assert not (BROKER_ROOT / "docker-host").exists() + assert not (BROKER_ROOT / "deploy-runner.config.example.yaml").exists() + assert not (BROKER_ROOT / "deploy-runner.compose.example.yaml").exists() + source = (BROKER_ROOT / "status_server.py").read_text(encoding="utf-8") + assert "BROKER_TOKEN_ROOT" not in source + assert "GITEA_DEPLOY_BROKER_TOKEN" not in source + assert "UNRAID_DEPLOY_BROKER_TOKEN" not in source + assert "X-Gitea-Signature" in source + assert "gitea-webhook-inbox.v1" in source + + +def test_appops_uses_push_validation_without_a_deploy_runner_workflow(): + workflow_root = ROOT / ".gitea" / "workflows" + assert not (workflow_root / "unraid-deploy.yml").exists() + managed_validation = (workflow_root / "managed-validation.yml").read_text(encoding="utf-8") + assert "push:" in managed_validation + assert "branches: [main]" in managed_validation + assert "runs-on: linux-validation" in managed_validation + assert "unraid-deploy" not in managed_validation + + +def test_chimera_example_requires_exact_source_webhook_auth_and_live_proof(): + projects = json.loads( + (BROKER_ROOT / "projects.example.json").read_text(encoding="utf-8") + ) + chimera = projects["jens/chimera"] + assert chimera["exact_source"] is True + assert chimera["source_dir"].startswith( + "/mnt/cache/appdata/gitea-deploy-runners/sources/" + ) + assert chimera["env_source"] == "/srv/example/appdata/chimera/deploy.env" + assert chimera["verify_oci_revision"] is True + assert chimera["gitea_repository_id"] == 1234 + assert chimera["gitea_webhook_secret_file"] == "jens__chimera" + assert "broker_token_file" not in chimera + assert "legacy_broker_token" not in chimera + assert chimera["live_probe"] == { + "type": "chimera-health-live-v1", + "container": "Chimera", + "port": 6967, + "path": "/health/live", + "host": "chimera:6967", + } + assert chimera["environment"] == { + "CHIMERA_BUILD_ID": "{build_id}", + "CHIMERA_SOURCE_REPOSITORY": "{repository}", + "CHIMERA_SOURCE_REVISION": "{revision}", + "CHIMERA_SOURCE_TREE": "{tree}", + "CHIMERA_BUILD_DATE": "{build_date}", + } + + +def _controller_enrollment_payload(*, credential: bool = False) -> dict[str, object]: + value: dict[str, object] = { + "api_version": "itworx.controller-enrollment/v1", + "enrollment_id": "enr-" + "a" * 20, + "application_id": "wsbc-portal", + "repository": "jens/windows-support-bundle-collector", + "revision": "b" * 40, + "profile": "internal-webapp-oidc", + "container_name": "wsbc-portal", + "compose_file": ".itworx/deployment.yml", + "appdata_path": "/srv/example/appdata/wsbc-portal", + "data_path": "/srv/example/appdata/wsbc-portal/data", + "runtime_uid": 1654, + "runtime_gid": 1654, + "network": "itworx-npm", + "internal_port": 8080, + "proxy_hostname": "wsbc.example.com", + "image_repository": "wsbc-portal", + "image_tag": "0.23.0", + "health_path": "/health", + "runtime_environment_keys": [ + "APPOPS_PUBLIC_URL", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_ISSUER", + "OIDC_ROLE_CLAIM", + ], + } + if credential: + value["target_credential"] = "x" * 64 + return value + + +def _repopulse_controller_enrollment_payload(*, credential: bool = False) -> dict[str, object]: + value: dict[str, object] = { + "api_version": "itworx.controller-enrollment/v1", + "enrollment_id": "enr-" + "c" * 20, + "application_id": "repopulse", + "repository": "jens/repopulse", + "revision": "d" * 40, + "profile": "repopulse-authenticated", + "container_name": "repopulse", + "compose_file": ".itworx/deployment.yml", + "appdata_path": "/srv/example/appdata/repopulse", + "data_path": "/srv/example/appdata/repopulse/data", + "runtime_uid": 10001, + "runtime_gid": 10001, + "network": "itworx-mcp-hub-npm", + "internal_port": 8000, + "proxy_hostname": "repopulse.example.com", + "image_repository": "repopulse-managed", + "image_tag": "production", + "health_path": "/health", + "runtime_environment_keys": [ + "REPOPULSE_GITEA_URL", + "REPOPULSE_GITEA_TOKEN", + "REPOPULSE_MAINTENANCE_TOKEN", + ], + } + if credential: + value["target_credential"] = "x" * 64 + return value + + +def test_controller_repopulse_profile_is_fixed_and_registers_managed_build(status_module): + payload = _repopulse_controller_enrollment_payload() + enrollment = status_module._controller_enrollment(payload, require_credential=False) + assert enrollment is not None + project = status_module._enrollment_project(enrollment) + + assert project["managed_build_context"] == "." + assert project["managed_build_dockerfile"] == "deploy/managed/Dockerfile" + assert project["managed_repository"] == "jens/repopulse" + assert project["managed_runtime_environment_keys"] == [ + "REPOPULSE_GITEA_URL", + "REPOPULSE_GITEA_TOKEN", + "REPOPULSE_MAINTENANCE_TOKEN", + ] + + for field, value in ( + ("application_id", "another-app"), + ("repository", "jens/another-app"), + ("network", "other-network"), + ("runtime_uid", 1000), + ("internal_port", 8080), + ("image_tag", "1.5.0"), + ("proxy_hostname", "another.example.com"), + ): + unsafe = {**payload, field: value} + assert status_module._controller_enrollment(unsafe, require_credential=False) is None + + +def test_controller_repopulse_runtime_seal_fixes_gitea_url_and_hides_values(status_module, tmp_path, monkeypatch): + appdata_root = tmp_path / "appdata" + app_root = appdata_root / "repopulse" + app_root.mkdir(parents=True) + legacy_env_path = app_root / "deploy.env" + legacy_env_path.write_text("REPOPULSE_OPERATOR_TOKEN=legacy-preserved\n", encoding="utf-8") + env_path = app_root / "deploy.managed.env" + env_path.write_text("", encoding="utf-8") + monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root) + monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources") + payload = _repopulse_controller_enrollment_payload(credential=True) + payload["appdata_path"] = app_root.as_posix() + payload["data_path"] = (app_root / "data").as_posix() + enrollment = status_module._controller_enrollment(payload, require_credential=True) + assert enrollment is not None + project = status_module._enrollment_project(enrollment) + assert project["env_source"] == env_path.as_posix() + projects = {"jens/repopulse": project} + runtime = { + "enrollment_id": enrollment["enrollment_id"], + "application_id": "repopulse", + "repository": "jens/repopulse", + "values": { + "REPOPULSE_GITEA_URL": "https://gitea.example.com", + "REPOPULSE_GITEA_TOKEN": "write-only-gitea-token", + "REPOPULSE_MAINTENANCE_TOKEN": "write-only-maintenance-token", + }, + } + + status, evidence = status_module._configure_enrollment_runtime(projects, runtime) + assert status == "configured" + assert evidence == {"enrollmentId": enrollment["enrollment_id"], "applicationId": "repopulse"} + assert "write-only-gitea-token" in env_path.read_text(encoding="utf-8") + assert "write-only-maintenance-token" in env_path.read_text(encoding="utf-8") + assert legacy_env_path.read_text(encoding="utf-8") == "REPOPULSE_OPERATOR_TOKEN=legacy-preserved\n" + assert "write-only-gitea-token" not in str(evidence) + assert "write-only-maintenance-token" not in str(evidence) + + unsafe = {**runtime, "values": {**runtime["values"], "REPOPULSE_GITEA_URL": "https://attacker.invalid"}} + status, evidence = status_module._configure_enrollment_runtime(projects, unsafe) + assert status == "runtime_environment_rejected" + assert evidence == {} + + +def test_repopulse_preflight_blocks_existing_private_source_tree(status_module, monkeypatch): + payload = _repopulse_controller_enrollment_payload() + enrollment = status_module._controller_enrollment(payload, require_credential=False) + assert enrollment is not None + monkeypatch.setattr(status_module, "_repopulse_controller_storage_ready", lambda: (True, "preflight_ok")) + monkeypatch.setattr(status_module, "_repopulse_sources_readable", lambda: False) + + code, detail = status_module._safe_enrollment_preflight({}, enrollment) + + assert (code, detail) == ( + "source_permissions_unavailable", + "fixed_local_sources_are_not_readable_by_runtime_gid", + ) + + +@pytest.mark.skipif( + os.name == "nt" or not hasattr(os, "geteuid") or os.geteuid() != 0, + reason="requires Linux root-owned source trees", +) +def test_repopulse_preflight_blocks_an_existing_mode_700_live_source_tree( + status_module, deploy_module, tmp_path, monkeypatch +): + sources_root = tmp_path / "sources" + sources_root.mkdir() + for slug in ("repopulse", "privatemuse"): + source = sources_root / slug + source.mkdir(mode=0o700) + os.chown(source, 0, 10001) + source.chmod(0o700) + monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", sources_root) + monkeypatch.setattr(deploy_module, "EXACT_SOURCE_ROOT", sources_root) + monkeypatch.setattr(status_module, "_repopulse_controller_storage_ready", lambda: (True, "preflight_ok")) + payload = _repopulse_controller_enrollment_payload() + enrollment = status_module._controller_enrollment(payload, require_credential=False) + assert enrollment is not None + + code, detail = status_module._safe_enrollment_preflight({}, enrollment) + + assert (code, detail) == ( + "source_permissions_unavailable", + "fixed_local_sources_are_not_readable_by_runtime_gid", + ) + + for slug in ("repopulse", "privatemuse"): + source = sources_root / slug + staging = sources_root / f".{slug}-aabbccddeeff-stage" + source.rename(staging) + deploy_module._prepare_exact_source_group_read(staging, slug) + staging.rename(source) + assert status_module._repopulse_sources_readable() is True + + +def test_repopulse_preflight_blocks_read_only_controller_mount(status_module, monkeypatch): + payload = _repopulse_controller_enrollment_payload() + enrollment = status_module._controller_enrollment(payload, require_credential=False) + assert enrollment is not None + monkeypatch.setattr( + status_module, + "_repopulse_controller_storage_ready", + lambda: (False, "controller_storage_read_only"), + ) + + code, detail = status_module._safe_enrollment_preflight({}, enrollment) + + assert (code, detail) == ( + "controller_storage_read_only", + "controller_storage_not_writable_for_repopulse_profile", + ) + + +def test_controller_mount_read_only_check_uses_the_most_specific_mount(status_module): + mountinfo = ( + "30 20 0:30 / /opt/gitea-deploy ro,relatime - ext4 /dev/root ro\n" + "31 20 0:31 / /mnt/cache/appdata/gitea-deploy-runners/sources rw,relatime - ext4 /dev/cache rw\n" + ) + assert status_module._mount_is_read_only_for("/opt/gitea-deploy/status_server.py", mountinfo) is True + assert status_module._mount_is_read_only_for( + "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse", mountinfo + ) is False + + +def test_controller_repopulse_rollback_refuses_legacy_env_collision(status_module, tmp_path, monkeypatch): + appdata_root = tmp_path / "appdata" + app_root = appdata_root / "repopulse" + data_path = app_root / "data" + data_path.mkdir(parents=True) + managed_env = app_root / "deploy.managed.env" + managed_env.write_text("REPOPULSE_GITEA_URL=https://gitea.example.com\n", encoding="utf-8") + legacy_env = app_root / "deploy.env" + legacy_env.write_text("legacy configuration\n", encoding="utf-8") + state_root = tmp_path / "state" + state_root.mkdir() + secret_root = tmp_path / "target-secrets" + secret_root.mkdir() + secret = secret_root / "jens__repopulse" + secret.write_text("x" * 64, encoding="ascii") + monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root) + monkeypatch.setattr(status_module, "STATE_ROOT", state_root) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secret_root) + writes = [] + monkeypatch.setattr(status_module, "_write_projects", lambda _projects: writes.append("write")) + enrollment_id = "enr-" + "c" * 20 + project = { + "appops_managed_target": True, + "managed_enrollment_id": enrollment_id, + "managed_application_id": "repopulse", + "managed_profile": "repopulse-authenticated", + } + + status, evidence = status_module._rollback_enrollment_target( + {"jens/repopulse": project}, + {"enrollment_id": enrollment_id, "application_id": "repopulse", "repository": "jens/repopulse"}, + ) + + assert status == "rollback_failed" + assert evidence == {} + assert writes == [] + assert managed_env.is_file() and legacy_env.is_file() and secret.is_file() + assert data_path.is_dir() + + +def test_controller_enrollment_contract_rejects_arbitrary_compose_and_paths(status_module): + value = _controller_enrollment_payload() + assert status_module._controller_enrollment(value, require_credential=False) is not None + + unsafe = dict(value) + unsafe["compose"] = "services: {}" + assert status_module._controller_enrollment(unsafe, require_credential=False) is None + + unsafe = dict(value) + unsafe["data_path"] = "/etc" + assert status_module._controller_enrollment(unsafe, require_credential=False) is None + + unsafe = dict(value) + unsafe["proxy_hostname"] = "*.example.com" + assert status_module._controller_enrollment(unsafe, require_credential=False) is None + + unsafe = _controller_enrollment_payload(credential=True) + unsafe["target_credential"] = "short" + assert status_module._controller_enrollment(unsafe, require_credential=True) is None + + +def test_controller_runtime_seal_accepts_only_profile_owned_keys(status_module, tmp_path, monkeypatch): + appdata_root = tmp_path / "appdata" + app_root = appdata_root / "wsbc-portal" + app_root.mkdir(parents=True) + env_path = app_root / "deploy.env" + env_path.write_text("", encoding="utf-8") + monkeypatch.setattr(status_module, "TRUSTED_APPCONFIG_ROOT", appdata_root) + monkeypatch.setattr(status_module, "EXACT_SOURCE_ROOT", tmp_path / "sources") + payload = _controller_enrollment_payload(credential=True) + payload["appdata_path"] = app_root.as_posix() + payload["data_path"] = (app_root / "data").as_posix() + enrollment = status_module._controller_enrollment(payload, require_credential=True) + assert enrollment is not None + project = status_module._enrollment_project(enrollment) + projects = {"jens/windows-support-bundle-collector": project} + runtime = { + "enrollment_id": enrollment["enrollment_id"], + "application_id": "wsbc-portal", + "repository": "jens/windows-support-bundle-collector", + "values": { + "APPOPS_PUBLIC_URL": "https://wsbc.example.com", + "OIDC_CLIENT_ID": "wsbc-portal", + "OIDC_CLIENT_SECRET": "opaque-write-only-value", + "OIDC_ISSUER": "https://auth.example.com/application/o/wsbc-portal", + "OIDC_ROLE_CLAIM": "roles", + }, + } + status, evidence = status_module._configure_enrollment_runtime(projects, runtime) + assert status == "configured" + assert evidence == {"enrollmentId": enrollment["enrollment_id"], "applicationId": "wsbc-portal"} + assert "opaque-write-only-value" in env_path.read_text(encoding="utf-8") + + unsafe = {**runtime, "values": {**runtime["values"], "ARBITRARY_COMMAND": "id"}} + status, _evidence = status_module._configure_enrollment_runtime(projects, unsafe) + assert status == "runtime_environment_rejected" + + +def test_controller_enrollment_replay_requires_the_existing_target_credential(status_module, tmp_path, monkeypatch): + projects_path = tmp_path / "projects.json" + secrets_root = tmp_path / "target-secrets" + credential_path = secrets_root / "jens__windows-support-bundle-collector" + secrets_root.mkdir() + credential_path.write_text("x" * 64, encoding="ascii") + enrollment = _controller_enrollment_payload(credential=True) + project = status_module._enrollment_project(enrollment) + projects_path.write_text(json.dumps({"jens/windows-support-bundle-collector": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secrets_root) + enrollment["target_credential"] = "y" * 64 + + status, _evidence = status_module._enroll_controller_target(enrollment) + assert status == "target_credential_conflict" + + +def _workmesh_promotion_project(status_module) -> dict[str, object]: + return { + "slug": "workmesh", + "branch": "main", + "source_dir": (status_module.EXACT_SOURCE_ROOT / "workmesh").as_posix(), + "exact_source": True, + "env_source": "/srv/example/appdata/workmesh/.env", + "compose_files": ["compose.yaml"], + "compose_project": "workmesh", + "containers": [ + "workmesh-postgres-1", + "workmesh-api-1", + "workmesh-n8n-1", + "workmesh-workmesh-1", + ], + "retired_compose_containers": ["workmesh-mock-ats-1"], + "verify_oci_revision": False, + "health_timeout": 300, + "appops_deployment_secret_file": "jens__workmesh", + "validation": { + "validation_gate_version": 1, + "required": True, + "contexts": ["WorkMesh CI (push)"], + "timeout_seconds": 600, + "poll_interval_seconds": 10, + "max_poll_interval_seconds": 30, + }, + } + + +def test_workmesh_promotion_policy_accepts_only_root_registered_product_topology(status_module): + project = _workmesh_promotion_project(status_module) + assert status_module._workmesh_promotion_policy("jens/workmesh", project) == ( + "workmesh", + "jens__workmesh", + ) + assert status_module._workmesh_promotion_policy("jens/other", project) is None + + unsafe = {**project, "source_dir": "/tmp/workmesh"} + assert status_module._workmesh_promotion_policy("jens/workmesh", unsafe) is None + unsafe = {**project, "containers": [*project["containers"], "sidecar"]} + assert status_module._workmesh_promotion_policy("jens/workmesh", unsafe) is None + unsafe_oci_mode = {**project, "verify_oci_revision": True} + assert status_module._workmesh_promotion_policy("jens/workmesh", unsafe_oci_mode) is None + + +def test_workmesh_preflight_binds_exact_main_validation_and_controller_check( + status_module, monkeypatch +): + project = _workmesh_promotion_project(status_module) + revision = "a" * 40 + validation = [{"context": "WorkMesh CI (push)", "status": "success"}] + calls: list[list[str]] = [] + + class Result: + returncode = 0 + stdout = json.dumps({ + "status": "preflight-ok", + "repository": "jens/workmesh", + "revision": "a" * 40, + "backupInputsSha256": "c" * 64, + }).encode() + + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: True) + monkeypatch.setattr(status_module, "_validation_policy", lambda *_args: {"contexts": ["WorkMesh CI (push)"]}) + monkeypatch.setattr(status_module, "_fetch_gitea_validation", lambda *_args: ("pass", validation)) + monkeypatch.setattr(status_module, "_controller_release", lambda: { + "controllerVersion": "2.1.4", + "sourceRevision": "c" * 40, + "sourceTree": "d" * 40, + "artifactSha256": "e" * 64, + }) + monkeypatch.setattr(status_module.subprocess, "run", lambda args, **_kwargs: calls.append(args) or Result()) + + result = status_module._workmesh_promotion_preflight("jens/workmesh", revision, project) + + assert result["status"] == "preflight_ok" + assert result["repository"] == "jens/workmesh" + assert result["revision"] == revision + assert len(result["preflightSha256"]) == 64 + assert result["backupInputsSha256"] == "c" * 64 + assert result["checks"] == { + "exactMain": "passed", + "pushValidation": "passed", + "controllerPreflight": "passed", + "dataBackup": "passed", + } + assert calls == [["python3", str(status_module.ROOT / "deploy.py"), "workmesh-preflight", "jens/workmesh", revision]] + + +def test_workmesh_preflight_blocks_branch_drift_before_controller_command( + status_module, monkeypatch +): + project = _workmesh_promotion_project(status_module) + monkeypatch.setattr(status_module, "_is_current_branch_head", lambda *_args: False) + monkeypatch.setattr(status_module.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("controller must not run")) + + result = status_module._workmesh_promotion_preflight("jens/workmesh", "b" * 40, project) + + assert result["status"] == "preflight_failed" + assert result["checks"]["exactMain"] == "blocked" + assert result["checks"]["pushValidation"] == "unknown" + assert result["checks"]["controllerPreflight"] == "unknown" + + +def test_workmesh_promotion_queue_deploys_only_after_digest_recheck_and_receipt( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "appops-inbox" + projects_path = tmp_path / "projects.json" + project = _workmesh_promotion_project(status_module) + projects_path.write_text(json.dumps({"jens/workmesh": project}), encoding="utf-8") + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda *_args: { + "status": "preflight_ok", + "repository": "jens/workmesh", + "revision": "a" * 40, + "preflightSha256": "f" * 64, + "backupInputsSha256": "e" * 64, + }) + status, request_id = status_module._enqueue_appops_request( + repository="jens/workmesh", + revision="a" * 40, + operation="workmesh_promote", + idempotency_key="workmesh-approval-001", + plan={"revision": "a" * 40, "preflightSha256": "f" * 64, "backupInputsSha256": "e" * 64}, + ) + assert status == "pending" + assert status_module._enqueue_appops_request( + repository="jens/workmesh", + revision="a" * 40, + operation="workmesh_promote", + idempotency_key="workmesh-approval-001", + plan={"revision": "a" * 40, "preflightSha256": "f" * 64, "backupInputsSha256": "e" * 64}, + ) == ("pending", request_id) + + calls: list[list[str]] = [] + monkeypatch.setattr( + status_module, + "_run_workmesh_promotion_deploy", + lambda args: calls.append(args) or status_module.subprocess.CompletedProcess(args, 0), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {"live_status": "healthy"}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"revision": "a" * 40}) + monkeypatch.setattr(status_module, "_verified_workmesh_backup_receipt", lambda *_args: "b" * 64) + marker = inbox / f"{request_id}.json" + + status_module._process_appops_record(marker) + + record = json.loads(marker.read_text(encoding="utf-8")) + assert record["status"] == "deployed" + assert record["revision"] == "a" * 40 + assert record["receipt_sha256"] == hashlib.sha256( + b'{"revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + ).hexdigest() + assert record["backup_receipt_sha256"] == "b" * 64 + assert calls == [[ + "python3", str(status_module.ROOT / "deploy.py"), "workmesh-promote", + "jens/workmesh", "a" * 40, request_id, "f" * 64, "e" * 64, + ]] + + +@pytest.mark.parametrize( + ("observed_preflight", "observed_backup_inputs"), + [("e" * 64, "e" * 64), ("f" * 64, "9" * 64)], +) +def test_workmesh_promotion_worker_refuses_changed_preflight_without_deploy( + status_module, tmp_path, monkeypatch, observed_preflight, observed_backup_inputs +): + inbox = tmp_path / "appops-inbox" + inbox.mkdir() + marker = inbox / ("a" * 64 + ".json") + marker.write_text(json.dumps({ + "schema": "appops-deployment-request.v1", + "request": "a" * 64, + "repository": "jens/workmesh", + "revision": "b" * 40, + "operation": "workmesh_promote", + "preflight_sha256": "f" * 64, + "backup_inputs_sha256": "e" * 64, + "status": "pending", + }), encoding="utf-8") + project = _workmesh_promotion_project(status_module) + monkeypatch.setattr(status_module, "_load_projects", lambda: {"jens/workmesh": project}) + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda *_args: { + "status": "preflight_ok", + "preflightSha256": observed_preflight, + "backupInputsSha256": observed_backup_inputs, + }) + monkeypatch.setattr( + status_module, + "_run_workmesh_promotion_deploy", + lambda *_args: pytest.fail("changed approval must not deploy"), + ) + + status_module._process_appops_record(marker) + + record = json.loads(marker.read_text(encoding="utf-8")) + assert record["status"] == "failed" + assert record["reason"] == "workmesh_preflight_changed" + + +def test_workmesh_backup_capture_rejects_changed_inputs_before_stopping_services( + deploy_module, monkeypatch, tmp_path +): + inputs = { + "apiVersion": "itworx.workmesh-backup-inputs/v1", + "repository": "jens/workmesh", + "target": "workmesh", + "candidateRevision": "b" * 40, + "priorLiveRevision": "a" * 40, + "priorReceiptSha256": "c" * 64, + "backupToolSha256": "d" * 64, + "environmentSha256": "e" * 64, + "externalRecoveryReceiptSha256": "f" * 64, + "n8nImageId": "sha256:" + "1" * 64, + "n8nImageRefSha256": "2" * 64, + } + calls: list[str] = [] + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr( + deploy_module, "workmesh_backup_preflight_inputs", lambda *_args: inputs + ) + monkeypatch.setattr( + deploy_module, + "_workmesh_docker", + lambda *args, **_kwargs: calls.append(str(args[0])) + or pytest.fail("input drift must be detected before any Docker operation"), + ) + + with pytest.raises(deploy_module.WorkMeshPromotionNoMutation, match="preflight changed"): + deploy_module.capture_workmesh_data_backup( + {"slug": "workmesh"}, + tmp_path, + "b" * 40, + request_id="3" * 64, + preflight_sha256="4" * 64, + backup_inputs_sha256="9" * 64, + ) + + assert calls == [] + + +@pytest.mark.parametrize("failure_point", ["backup_input_drift", "backup_restore_gate"]) +@pytest.mark.parametrize("source_restore_succeeds", [True, False]) +def test_workmesh_promotion_restores_prior_source_before_no_mutation_classification( + deploy_module, monkeypatch, tmp_path, failure_point, source_restore_succeeds +): + live_revision = "a" * 40 + candidate_revision = "b" * 40 + source_dir = tmp_path / "source" + source_dir.mkdir() + (source_dir / "source-revision").write_text("prior", encoding="utf-8") + request_id = "c" * 64 + preflight_sha256 = "d" * 64 + backup_inputs = { + "apiVersion": "itworx.workmesh-backup-inputs/v1", + "repository": "jens/workmesh", + "target": "workmesh", + "candidateRevision": candidate_revision, + "priorLiveRevision": live_revision, + "priorReceiptSha256": "e" * 64, + "backupToolSha256": "f" * 64, + "environmentSha256": "1" * 64, + "externalRecoveryReceiptSha256": "2" * 64, + "n8nImageId": "sha256:" + "3" * 64, + "n8nImageRefSha256": "4" * 64, + } + approved_backup_inputs = ( + deploy_module.workmesh_backup_inputs_sha256(backup_inputs) + if failure_point == "backup_restore_gate" + else "9" * 64 + ) + project = { + "slug": "workmesh", + "branch": "main", + "source_dir": str(source_dir), + "env_source": "/srv/example/appdata/workmesh/.env", + "compose_project": "workmesh", + "compose_files": ["compose.yaml"], + "containers": list(deploy_module.WORKMESH_PRODUCT_CONTAINERS), + "retired_compose_containers": [deploy_module.WORKMESH_RETIRED_CONTAINER], + "exact_source": True, + "health_timeout": 300, + "verify_oci_revision": False, + } + projects = {"jens/workmesh": project} + previous_state = { + "schema_version": 3, + "repository": "jens/workmesh", + "live_revision": live_revision, + "live_status": "healthy", + "live_receipt": {"containers": {}}, + } + state_writes: list[dict[str, object]] = [] + backup_calls: list[str] = [] + + @contextmanager + def no_lock(*_args): + yield + + monkeypatch.setattr(sys, "argv", [ + "deploy.py", "workmesh-promote", "jens/workmesh", candidate_revision, + request_id, preflight_sha256, approved_backup_inputs, + ]) + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "load_projects", lambda: projects) + monkeypatch.setattr(deploy_module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(deploy_module, "read_state", lambda _project: previous_state) + monkeypatch.setattr( + deploy_module, "validate_commit", lambda _repo, _branch, value: value + ) + monkeypatch.setattr( + deploy_module, "deployment_metadata", lambda *_args: {"tree": "5" * 40} + ) + monkeypatch.setattr(deploy_module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(deploy_module, "_workmesh_require_product_target", lambda *_args: None) + monkeypatch.setattr(deploy_module, "exclusive_lock", no_lock) + monkeypatch.setattr(deploy_module, "exclusive_global_deploy_lock", no_lock) + monkeypatch.setattr( + deploy_module, + "_workmesh_current_runtime", + lambda *_args: (previous_state, previous_state["live_receipt"], {}), + ) + + def sync_source(*_args, **_kwargs): + (source_dir / "source-revision").write_text("candidate", encoding="utf-8") + + def restore_source(_project, _repository, requested_revision): + assert requested_revision == live_revision + if source_restore_succeeds: + (source_dir / "source-revision").write_text("prior", encoding="utf-8") + + monkeypatch.setattr(deploy_module, "sync_source", sync_source) + monkeypatch.setattr(deploy_module, "copy_environment", lambda *_args: None) + monkeypatch.setattr(deploy_module, "restore_live_source", restore_source) + monkeypatch.setattr( + deploy_module, + "exact_source_matches_revision", + lambda _repo, revision, target: revision == live_revision + and (target / "source-revision").read_text(encoding="utf-8") == "prior", + ) + monkeypatch.setattr( + deploy_module, "compose_command", lambda *_args: (tmp_path, ["docker", "compose"]) + ) + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_args: {}) + monkeypatch.setattr(deploy_module, "run", lambda *_args, **_kwargs: None) + monkeypatch.setattr(deploy_module, "revalidate_deployment_candidate", lambda *_args: None) + monkeypatch.setattr( + deploy_module, "workmesh_backup_preflight_inputs", lambda *_args: backup_inputs + ) + monkeypatch.setattr( + deploy_module, + "verified_current_receipt", + lambda *_args: None, + ) + monkeypatch.setattr(deploy_module, "validate_forward_revision", lambda *_args: None) + monkeypatch.setattr( + deploy_module, + "write_state", + lambda *_args, **kwargs: state_writes.append(kwargs), + ) + monkeypatch.setattr(deploy_module, "validate_managed_v1_runtime", lambda *_args: None) + monkeypatch.setattr(deploy_module, "ensure_docker_capacity", lambda: None) + + def capture_backup(*_args, **_kwargs): + backup_calls.append("capture") + raise deploy_module.WorkMeshPromotionNoMutation("isolated backup restore rejected") + + monkeypatch.setattr(deploy_module, "capture_workmesh_data_backup", capture_backup) + monkeypatch.setattr( + deploy_module, + "deploy_project", + lambda *_args: pytest.fail("no-mutation path must not invoke deployment"), + ) + + expected_exception = ( + deploy_module.WorkMeshPromotionNoMutation + if source_restore_succeeds + else deploy_module.DeployError + ) + with pytest.raises(expected_exception): + deploy_module.main() + + if source_restore_succeeds: + assert (source_dir / "source-revision").read_text(encoding="utf-8") == "prior" + assert state_writes == [] + else: + assert (source_dir / "source-revision").read_text(encoding="utf-8") == "candidate" + assert state_writes + assert state_writes[-1]["live_status"] == "unknown" + assert backup_calls == ([] if failure_point == "backup_input_drift" else ["capture"]) + + +@pytest.mark.parametrize("source_restore_succeeds", [True, False]) +def test_workmesh_preflight_restores_prior_source_before_returning( + deploy_module, monkeypatch, tmp_path, capsys, source_restore_succeeds +): + live_revision = "a" * 40 + candidate_revision = "b" * 40 + source_dir = tmp_path / "source" + source_dir.mkdir() + (source_dir / "source-revision").write_text("prior", encoding="utf-8") + project = { + "slug": "workmesh", + "branch": "main", + "source_dir": str(source_dir), + "env_source": "/srv/example/appdata/workmesh/.env", + "compose_project": "workmesh", + "compose_files": ["compose.yaml"], + "containers": list(deploy_module.WORKMESH_PRODUCT_CONTAINERS), + "retired_compose_containers": [deploy_module.WORKMESH_RETIRED_CONTAINER], + "exact_source": True, + "health_timeout": 300, + "verify_oci_revision": False, + } + projects = {"jens/workmesh": project} + previous_state = { + "schema_version": 3, + "repository": "jens/workmesh", + "live_revision": live_revision, + "live_status": "healthy", + "live_receipt": {"containers": {}}, + } + state_writes: list[dict[str, object]] = [] + + @contextmanager + def no_lock(*_args): + yield + + monkeypatch.setattr(sys, "argv", [ + "deploy.py", "workmesh-preflight", "jens/workmesh", candidate_revision, + ]) + monkeypatch.setattr(deploy_module, "require_host_root", lambda: None) + monkeypatch.setattr(deploy_module, "load_projects", lambda: projects) + monkeypatch.setattr(deploy_module, "repository_path", lambda *_args: tmp_path) + monkeypatch.setattr(deploy_module, "read_state", lambda _project: previous_state) + monkeypatch.setattr( + deploy_module, "validate_commit", lambda _repo, _branch, value: value + ) + monkeypatch.setattr(deploy_module, "deployment_metadata", lambda *_args: {"tree": "5" * 40}) + monkeypatch.setattr(deploy_module, "validate_project_config", lambda *_args: None) + monkeypatch.setattr(deploy_module, "_workmesh_require_product_target", lambda *_args: None) + monkeypatch.setattr(deploy_module, "exclusive_lock", no_lock) + monkeypatch.setattr(deploy_module, "sync_source", lambda *_args, **_kwargs: ( + source_dir / "source-revision" + ).write_text("candidate", encoding="utf-8")) + monkeypatch.setattr(deploy_module, "copy_environment", lambda *_args: None) + monkeypatch.setattr( + deploy_module, "compose_command", lambda *_args: (tmp_path, ["docker", "compose"]) + ) + monkeypatch.setattr(deploy_module, "deployment_environment", lambda *_args: {}) + monkeypatch.setattr(deploy_module, "run", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + deploy_module, + "workmesh_backup_preflight_inputs", + lambda *_args: ( + {"observed": "candidate"} + if (source_dir / "source-revision").read_text(encoding="utf-8") == "candidate" + else pytest.fail("backup preflight must inspect the prepared candidate") + ), + ) + + def restore_source(_project, _repository, requested_revision): + assert requested_revision == live_revision + if source_restore_succeeds: + (source_dir / "source-revision").write_text("prior", encoding="utf-8") + + monkeypatch.setattr(deploy_module, "restore_live_source", restore_source) + monkeypatch.setattr( + deploy_module, + "exact_source_matches_revision", + lambda _repo, revision, target: revision == live_revision + and (target / "source-revision").read_text(encoding="utf-8") == "prior", + ) + monkeypatch.setattr( + deploy_module, + "write_state", + lambda *_args, **kwargs: state_writes.append(kwargs), + ) + + if source_restore_succeeds: + assert deploy_module.main() == 0 + response = json.loads(capsys.readouterr().out) + assert response["status"] == "preflight-ok" + assert response["revision"] == candidate_revision + assert (source_dir / "source-revision").read_text(encoding="utf-8") == "prior" + assert state_writes == [] + else: + with pytest.raises(deploy_module.DeployError, match="source restoration"): + deploy_module.main() + assert (source_dir / "source-revision").read_text(encoding="utf-8") == "candidate" + assert state_writes + assert state_writes[-1]["live_status"] == "unknown" + + +def test_workmesh_promotion_worker_keeps_failed_mutation_without_receipt_ambiguous( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "appops-inbox" + project = _workmesh_promotion_project(status_module) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "_load_projects", lambda: {"jens/workmesh": project}) + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda *_args: { + "status": "preflight_ok", + "preflightSha256": "f" * 64, + "backupInputsSha256": "e" * 64, + }) + + monkeypatch.setattr( + status_module, + "_run_workmesh_promotion_deploy", + lambda args: status_module.subprocess.CompletedProcess(args, 1), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {"live_status": "degraded"}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: None) + _, request_id = status_module._enqueue_appops_request( + repository="jens/workmesh", + revision="a" * 40, + operation="workmesh_promote", + idempotency_key="workmesh-mutation-failure-01", + plan={"revision": "a" * 40, "preflightSha256": "f" * 64, "backupInputsSha256": "e" * 64}, + ) + marker = inbox / f"{request_id}.json" + + status_module._process_appops_record(marker) + + record = json.loads(marker.read_text(encoding="utf-8")) + assert record["status"] == "outcome_unknown" + assert record["reason"] == "controller_deployment_failed" + + +def test_workmesh_promotion_worker_reconciles_recovered_request_without_redeploy( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "appops-inbox" + inbox.mkdir() + request_id = "a" * 64 + marker = inbox / f"{request_id}.json" + marker.write_text(json.dumps({ + "schema": "appops-deployment-request.v1", + "request": request_id, + "repository": "jens/workmesh", + "revision": "b" * 40, + "operation": "workmesh_promote", + "preflight_sha256": "f" * 64, + "backup_inputs_sha256": "e" * 64, + "status": "running", + }), encoding="utf-8") + project = _workmesh_promotion_project(status_module) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "_load_projects", lambda: {"jens/workmesh": project}) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {"live_status": "healthy"}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: None) + monkeypatch.setattr(status_module, "_verified_workmesh_backup_receipt", lambda *_args: None) + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda *_args: { + "status": "preflight_ok", + "preflightSha256": "f" * 64, + "backupInputsSha256": "e" * 64, + }) + monkeypatch.setattr(status_module.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("recovered request must not redeploy")) + + status_module._process_appops_record(marker) + + record = json.loads(marker.read_text(encoding="utf-8")) + assert record["status"] == "outcome_unknown" + assert record["reason"] == "controller_recovered_without_receipt" + + receipt = {"repository": "jens/workmesh", "revision": "b" * 40, "state": "deployed"} + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: receipt) + monkeypatch.setattr(status_module, "_verified_workmesh_backup_receipt", lambda *_args: "b" * 64) + status_module._process_appops_record(marker) + record = json.loads(marker.read_text(encoding="utf-8")) + assert record["status"] == "deployed" + assert record["receipt_sha256"] == hashlib.sha256( + json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + assert record["backup_receipt_sha256"] == "b" * 64 + + +def test_workmesh_promotion_timeout_is_bounded_and_reconciles_without_retry( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "appops-inbox" + project = _workmesh_promotion_project(status_module) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "_load_projects", lambda: {"jens/workmesh": project}) + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda *_args: { + "status": "preflight_ok", + "preflightSha256": "f" * 64, + "backupInputsSha256": "e" * 64, + }) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {"live_status": "degraded"}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: None) + calls: list[tuple[list[str], dict[str, object]]] = [] + wait_timeouts: list[float] = [] + group_stops = [] + + class Process: + pid = 45678 + + def wait(self, *, timeout): + wait_timeouts.append(timeout) + raise status_module.subprocess.TimeoutExpired("deploy.py", timeout) + + def timeout_popen(args, **kwargs): + calls.append((args, kwargs)) + return Process() + + monkeypatch.setattr(status_module, "_workmesh_process_group_supported", lambda: True) + monkeypatch.setattr(status_module, "_stop_workmesh_process_group", lambda process: group_stops.append(process)) + monkeypatch.setattr(status_module.subprocess, "Popen", timeout_popen) + _, request_id = status_module._enqueue_appops_request( + repository="jens/workmesh", + revision="a" * 40, + operation="workmesh_promote", + idempotency_key="workmesh-timeout-contract-01", + plan={"revision": "a" * 40, "preflightSha256": "f" * 64, "backupInputsSha256": "e" * 64}, + ) + marker = inbox / f"{request_id}.json" + + status_module._process_appops_record(marker) + + record = json.loads(marker.read_text(encoding="utf-8")) + assert calls[0][1].get("start_new_session") is True + assert wait_timeouts == [1800] + assert len(group_stops) == 1 + assert record["status"] == "outcome_unknown" + + status_module._process_appops_record(marker) + assert len(calls) == 1 + assert json.loads(marker.read_text(encoding="utf-8"))["status"] == "outcome_unknown" + + +def test_workmesh_timeout_stops_child_group_before_releasing_lock_owner(status_module): + leader = 12345 + members = [(leader, "T"), (23456, "S"), (34567, "S")] + events = [] + + def read_members(pgid): + assert pgid == leader + return list(members) + + def send_group_signal(pgid, signum): + events.append(("group_signal", pgid, signum)) + assert signum == 19 + members[:] = [(pid, "T") for pid, _state in members] + + def send_signal(pid, signum): + events.append(("signal", pid, signum)) + if pid != leader and signum == 9: + assert all(state == "T" for _member_pid, state in members) + members[:] = [item for item in members if item[0] != pid] + elif pid == leader and signum == 9: + assert not any(member_pid != leader for member_pid, state in members if state not in {"Z", "X"}) + + class Process: + pid = leader + + def wait(self): + events.append(("wait",)) + assert events[-2] == ("signal", leader, 9) + + status_module._stop_workmesh_process_group( + Process(), + members_reader=read_members, + group_signal_sender=send_group_signal, + signal_sender=send_signal, + stop_signal=19, + kill_signal=9, + sleep_fn=lambda _seconds: None, + cleanup_timeout_seconds=1, + ) + + assert events == [ + ("group_signal", leader, 19), + ("signal", 23456, 9), + ("signal", 34567, 9), + ("signal", leader, 9), + ("wait",), + ] + + +def test_workmesh_promotion_http_contract_binds_exact_sha_and_preflight_digest( + status_module, tmp_path, monkeypatch +): + secret = b"workmesh-appops-" + b"x" * 48 + secrets_root = tmp_path / "appops-secrets" + _write_appops_secret(secrets_root, "jens__workmesh", secret) + projects_path = tmp_path / "projects.json" + project = _workmesh_promotion_project(status_module) + projects_path.write_text(json.dumps({"jens/workmesh": project}), encoding="utf-8") + inbox = tmp_path / "inbox" + monkeypatch.setattr(status_module, "PROJECTS_PATH", projects_path) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_SECRET_ROOT", secrets_root) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "APPOPS_NONCE_ROOT", tmp_path / "nonces") + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda repository, revision, _project: { + "apiVersion": "itworx.workmesh-promotion/v2", + "status": "preflight_ok", + "repository": repository, + "revision": revision, + "preflightSha256": "f" * 64, + "backupInputsSha256": "e" * 64, + "checks": {"exactMain": "passed", "pushValidation": "passed", "controllerPreflight": "passed", "dataBackup": "passed"}, + }) + server = status_module.BoundedThreadingHTTPServer(("127.0.0.1", 0), status_module.Handler) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + + def post(path: str, payload: dict[str, object], nonce: str) -> tuple[int, dict[str, object]]: + body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + timestamp = str(int(status_module.time.time())) + signature = status_module._request_signature( + secret, method="POST", path=path, timestamp=timestamp, nonce=nonce, raw_body=body + ) + client = http.client.HTTPConnection(*server.server_address, timeout=2) + client.request("POST", path, body=body, headers={ + "Content-Type": "application/json", + "X-ITWorx-Deployment-Timestamp": timestamp, + "X-ITWorx-Deployment-Nonce": nonce, + "X-ITWorx-Deployment-Signature": signature, + }) + response = client.getresponse() + decoded = json.loads(response.read()) + client.close() + return response.status, decoded + + try: + revision = "a" * 40 + preflight_path = status_module.WORKMESH_PROMOTION_PREFLIGHT_PATH + preflight_status, preflight = post(preflight_path, {"revision": revision}, "1" * 32) + assert preflight_status == 200 + assert preflight["preflightSha256"] == "f" * 64 + + apply_path = status_module.WORKMESH_PROMOTION_APPLY_PATH + rejected_status, rejected = post(apply_path, { + "revision": revision, + "preflightSha256": "e" * 64, + "idempotencyKey": "workmesh-approval-001", + }, "2" * 32) + assert rejected_status == 409 + assert rejected["status"] == "preflight_changed" + assert rejected["request"] == status_module._appops_request_digest( + "jens/workmesh", revision, "workmesh_promote", "workmesh-approval-001" + ) + assert rejected["repository"] == "jens/workmesh" + assert rejected["revision"] == revision + assert rejected["operation"] == "workmesh_promote" + assert rejected["reason"] == "approved_preflight_no_longer_matches" + assert not inbox.exists() or not list(inbox.iterdir()) + + accepted_status, accepted = post(apply_path, { + "revision": revision, + "preflightSha256": "f" * 64, + "idempotencyKey": "workmesh-approval-002", + }, "3" * 32) + assert accepted_status == 202 + assert accepted["status"] == "accepted" + assert accepted["repository"] == "jens/workmesh" + assert accepted["revision"] == revision + queued = json.loads((inbox / f"{accepted['request']}.json").read_text(encoding="utf-8")) + assert queued["operation"] == "workmesh_promote" + assert queued["preflight_sha256"] == "f" * 64 + assert queued["backup_inputs_sha256"] == "e" * 64 + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + + +def test_workmesh_isolated_n8n_restore_uses_exact_image_and_no_network( + deploy_module, monkeypatch, tmp_path +): + backup_dir = tmp_path / "backup" + backup_dir.mkdir() + state_root = tmp_path / "state" + state_root.mkdir() + calls: list[tuple[str, ...]] = [] + secret = "synthetic-n8n-key-" + "x" * 40 + n8n_id = "a" * 64 + + def docker(*args, timeout=None): + command = tuple(str(item) for item in args) + calls.append(command) + if command[:2] == ("run", "--detach"): + env_file = Path(command[command.index("--env-file") + 1]) + if os.name != "nt": + assert env_file.stat().st_mode & 0o777 == 0o600 + assert f"N8N_ENCRYPTION_KEY={secret}" in env_file.read_text(encoding="utf-8") + assert secret not in " ".join(command) + return subprocess.CompletedProcess(command, 0, stdout=(n8n_id + "\n").encode()) + if "volume" in command and "create" in command: + return subprocess.CompletedProcess(command, 0, stdout=b"workmesh-restore-volume\n") + return subprocess.CompletedProcess(command, 0, stdout=b"") + + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_root) + monkeypatch.setattr(deploy_module, "_workmesh_docker", docker) + monkeypatch.setattr( + deploy_module, + "_workmesh_inspect_container", + lambda _name: {"Id": n8n_id, "State": {"Running": True}}, + ) + monkeypatch.setattr(deploy_module.time, "sleep", lambda _seconds: None) + + evidence = deploy_module.verify_workmesh_n8n_backup_restore( + backup_dir=backup_dir, + request_id="b" * 64, + image_id="sha256:" + "c" * 64, + n8n_encryption_key=secret, + ) + + assert evidence == { + "status": "verified", + "imageId": "sha256:" + "c" * 64, + "health": "healthy", + "network": "none", + } + run_commands = [command for command in calls if command[:1] == ("run",)] + assert len(run_commands) == 3 + assert all(command[command.index("--network") + 1] == "none" for command in run_commands) + extractor = next(command for command in run_commands if "-xf" in command) + assert extractor[extractor.index("--user") + 1] == "0:0" + database_probe = next( + command for command in run_commands + if any("database.sqlite" in argument for argument in command) + ) + assert database_probe[database_probe.index("--entrypoint") + 1] == "node" + assert database_probe[database_probe.index("--user") + 1] == "1000:1000" + assert any(command.endswith("target=/home/node/.n8n,readonly") for command in database_probe) + n8n_command = next(command for command in run_commands if "start" in command) + assert "sha256:" + "c" * 64 in n8n_command + assert "N8N_USER_FOLDER=/home/node/.n8n" in n8n_command + assert calls[-2][:2] == ("rm", "-f") + assert calls[-1][:2] == ("volume", "rm") + + +def test_workmesh_n8n_restore_rejects_missing_nested_sqlite_and_cleans_scratch_volume( + deploy_module, monkeypatch, tmp_path +): + backup_dir = tmp_path / "backup" + backup_dir.mkdir() + state_root = tmp_path / "state" + state_root.mkdir() + secret = "synthetic-n8n-key-" + "y" * 40 + calls: list[tuple[str, ...]] = [] + + def docker(*args, timeout=None): + command = tuple(str(item) for item in args) + calls.append(command) + if "--entrypoint" in command: + raise deploy_module.DeployError("isolated SQLite path/header probe failed") + return subprocess.CompletedProcess(command, 0, stdout=b"") + + monkeypatch.setattr(deploy_module, "STATE_ROOT", state_root) + monkeypatch.setattr(deploy_module, "_workmesh_docker", docker) + + with pytest.raises(deploy_module.DeployError, match="SQLite path/header probe"): + deploy_module.verify_workmesh_n8n_backup_restore( + backup_dir=backup_dir, + request_id="b" * 64, + image_id="sha256:" + "c" * 64, + n8n_encryption_key=secret, + ) + + assert not any(command[:2] == ("run", "--detach") for command in calls) + assert calls[-1][:2] == ("volume", "rm") + assert not list(state_root.glob(".workmesh-n8n-restore-*")) + + +def test_workmesh_backup_restart_uses_existing_container_ids_without_compose_recreate( + deploy_module, monkeypatch +): + revision = "a" * 40 + names = ["workmesh-postgres-1", "workmesh-api-1", "workmesh-n8n-1", "workmesh-workmesh-1"] + ids = {name: f"{index:064x}" for index, name in enumerate(names, start=1)} + image = "sha256:" + "b" * 64 + receipt = { + "revision": revision, + "containers": { + name: {"container_id": ids[name], "image_id": image} + for name in names + }, + } + prior_state = { + "live_revision": revision, + "live_status": "healthy", + "live_receipt": receipt, + } + containers = { + name: { + "Id": ids[name], + "Image": image, + "State": { + "Running": name != "workmesh-n8n-1", + "Health": {"Status": "healthy"}, + }, + "Config": { + "Labels": { + "com.docker.compose.project": "workmesh", + "com.docker.compose.service": name.removeprefix("workmesh-").removesuffix("-1"), + "com.docker.compose.config-hash": "c" * 64, + } + }, + } + for name in names + } + calls: list[tuple[str, ...]] = [] + + def docker(*args, timeout=None): + calls.append(tuple(args)) + if args[:2] == ("ps", "--all"): + return subprocess.CompletedProcess(args, 0, stdout=("\n".join(names) + "\n").encode()) + if args[0] == "start": + containers["workmesh-n8n-1"]["State"]["Running"] = True + return subprocess.CompletedProcess(args, 0, stdout=b"") + return subprocess.CompletedProcess(args, 0, stdout=b"") + + monkeypatch.setattr(deploy_module, "_workmesh_docker", docker) + monkeypatch.setattr(deploy_module, "_workmesh_inspect_container", lambda name: containers.get(name)) + monkeypatch.setattr(deploy_module, "read_state", lambda _project: prior_state) + + deploy_module._workmesh_restart_previous_runtime( + {"slug": "workmesh"}, prior_state, receipt, revision + ) + + assert calls[0] == ("start", ids["workmesh-n8n-1"]) + assert not any("compose" in call or call[0] in {"run", "up", "create"} for call in calls) + assert containers["workmesh-n8n-1"]["Id"] == ids["workmesh-n8n-1"] + assert containers["workmesh-n8n-1"]["Config"]["Labels"]["com.docker.compose.config-hash"] == "c" * 64 + + +def test_workmesh_promotion_success_requires_bound_data_backup_receipt( + status_module, tmp_path, monkeypatch +): + inbox = tmp_path / "appops-inbox" + inbox.mkdir() + marker = inbox / ("d" * 64 + ".json") + marker.write_text(json.dumps({ + "schema": "appops-deployment-request.v1", + "request": "d" * 64, + "repository": "jens/workmesh", + "revision": "a" * 40, + "operation": "workmesh_promote", + "preflight_sha256": "f" * 64, + "backup_inputs_sha256": "e" * 64, + "status": "pending", + }), encoding="utf-8") + project = _workmesh_promotion_project(status_module) + monkeypatch.setattr(status_module, "APPOPS_DEPLOYMENT_INBOX_ROOT", inbox) + monkeypatch.setattr(status_module, "_load_projects", lambda: {"jens/workmesh": project}) + monkeypatch.setattr(status_module, "_workmesh_promotion_preflight", lambda *_args: { + "status": "preflight_ok", + "preflightSha256": "f" * 64, + "backupInputsSha256": "e" * 64, + }) + monkeypatch.setattr( + status_module, + "_run_workmesh_promotion_deploy", + lambda args: status_module.subprocess.CompletedProcess(args, 0), + ) + monkeypatch.setattr(status_module, "_deployment_state", lambda _slug: {"live_status": "healthy"}) + monkeypatch.setattr(status_module, "_verified_receipt", lambda *_args: {"revision": "a" * 40}) + monkeypatch.setattr(status_module, "_verified_workmesh_backup_receipt", lambda *_args: None) + + status_module._process_appops_record(marker) + + record = json.loads(marker.read_text(encoding="utf-8")) + assert record["status"] == "outcome_unknown" + assert record["reason"] == "verified_deployment_or_backup_receipt_unavailable" + assert "backup_receipt_sha256" not in record + + +def test_workmesh_external_recovery_receipt_binds_environment_and_both_encryption_keys( + deploy_module, monkeypatch, tmp_path +): + path = tmp_path / "workmesh-external-recovery.json" + now = int(deploy_module.time.time()) + n8n_key = "synthetic-n8n-key-" + "n" * 40 + workmesh_key = "synthetic-workmesh-key-" + "w" * 40 + payload = { + "schema": "itworx.workmesh-external-recovery/v1", + "repository": "jens/workmesh", + "environmentSha256": "a" * 64, + "keySha256": { + "N8N_ENCRYPTION_KEY": hashlib.sha256(n8n_key.encode()).hexdigest(), + "WORKMESH_SECRET_ENCRYPTION_KEY": hashlib.sha256(workmesh_key.encode()).hexdigest(), + }, + "externalBackupSha256": "b" * 64, + "restoreVerifiedAtEpoch": now, + } + path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") + if os.name != "nt": + path.chmod(0o600) + monkeypatch.setattr(deploy_module, "_root_owned", lambda _path: True) + + receipt_sha = deploy_module.verify_workmesh_external_recovery_receipt( + path, + environment_sha256="a" * 64, + encryption_keys={ + "N8N_ENCRYPTION_KEY": n8n_key, + "WORKMESH_SECRET_ENCRYPTION_KEY": workmesh_key, + }, + ) + + assert receipt_sha == hashlib.sha256(path.read_bytes()).hexdigest() + payload["keySha256"]["N8N_ENCRYPTION_KEY"] = "c" * 64 + path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") + with pytest.raises(deploy_module.DeployError, match="external recovery receipt"): + deploy_module.verify_workmesh_external_recovery_receipt( + path, + environment_sha256="a" * 64, + encryption_keys={ + "N8N_ENCRYPTION_KEY": n8n_key, + "WORKMESH_SECRET_ENCRYPTION_KEY": workmesh_key, + }, + ) + + +def test_workmesh_backup_manifest_binds_exact_prior_revision_and_file_digests( + deploy_module, tmp_path +): + backup = tmp_path / "backup" + backup.mkdir() + postgres = b"private pg dump bytes" + n8n = b"private n8n volume archive" + (backup / "postgres.dump").write_bytes(postgres) + (backup / "n8n.tar").write_bytes(n8n) + manifest = { + "format": "workmesh-backup/v1", + "created_at": "2026-09-27T12:00:00+00:00", + "project": "workmesh", + "declared_live_revision": "a" * 40, + "postgres": { + "file": "postgres.dump", + "bytes": len(postgres), + "sha256": hashlib.sha256(postgres).hexdigest(), + }, + "n8n": { + "file": "n8n.tar", + "bytes": len(n8n), + "sha256": hashlib.sha256(n8n).hexdigest(), + }, + "encryption_root": "external; not included", + } + manifest_path = backup / "manifest.json" + manifest_path.write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8") + + evidence = deploy_module.verify_workmesh_backup_manifest(backup, "a" * 40) + assert evidence["manifestSha256"] == hashlib.sha256(manifest_path.read_bytes()).hexdigest() + assert evidence["postgresSha256"] == hashlib.sha256(postgres).hexdigest() + assert evidence["n8nSha256"] == hashlib.sha256(n8n).hexdigest() + with pytest.raises(deploy_module.DeployError, match="revision"): + deploy_module.verify_workmesh_backup_manifest(backup, "c" * 40) + + (backup / "n8n.tar").write_bytes(b"changed") + with pytest.raises(deploy_module.DeployError, match="checksum or size"): + deploy_module.verify_workmesh_backup_manifest(backup, "a" * 40) + + +def test_workmesh_backup_receipt_requires_bound_manifest_and_verified_restore_bytes( + status_module, tmp_path, monkeypatch +): + request_id = "d" * 64 + candidate = "b" * 40 + prior = "a" * 40 + preflight = "f" * 64 + inputs = "e" * 64 + backup_root = tmp_path / "backups" + backup_dir = backup_root / request_id + backup_dir.mkdir(parents=True) + receipt_root = tmp_path / "receipts" + receipt_root.mkdir() + postgres = b"synthetic PostgreSQL dump" + n8n = b"synthetic n8n volume archive" + postgres_sha = hashlib.sha256(postgres).hexdigest() + n8n_sha = hashlib.sha256(n8n).hexdigest() + (backup_dir / "postgres.dump").write_bytes(postgres) + (backup_dir / "n8n.tar").write_bytes(n8n) + manifest = { + "format": "workmesh-backup/v1", + "created_at": "2026-09-27T12:00:00+00:00", + "project": "workmesh", + "declared_live_revision": prior, + "postgres": {"file": "postgres.dump", "bytes": len(postgres), "sha256": postgres_sha}, + "n8n": {"file": "n8n.tar", "bytes": len(n8n), "sha256": n8n_sha}, + "encryption_root": "external; not included", + } + manifest_bytes = json.dumps(manifest, sort_keys=True, indent=2).encode() + (backup_dir / "manifest.json").write_bytes(manifest_bytes) + payload = { + "schema": "itworx.workmesh-backup-receipt/v1", + "request": request_id, + "repository": "jens/workmesh", + "operation": "workmesh_promote", + "candidateRevision": candidate, + "priorLiveRevision": prior, + "preflightSha256": preflight, + "backupInputsSha256": inputs, + "backupToolSha256": "1" * 64, + "externalRecoveryReceiptSha256": "2" * 64, + "environmentSha256": "3" * 64, + "manifestSha256": hashlib.sha256(manifest_bytes).hexdigest(), + "postgres": { + "sha256": postgres_sha, + "bytes": len(postgres), + "restore": { + "status": "verified", + "alembicRevision": "0022", + "tableCount": 22, + "network": "none", + }, + }, + "n8n": { + "sha256": n8n_sha, + "bytes": len(n8n), + "restore": { + "status": "verified", + "imageId": "sha256:" + "4" * 64, + "health": "healthy", + "network": "none", + }, + }, + "automaticDataRollback": False, + "createdAt": "2026-09-27T12:01:00+00:00", + } + receipt_sha = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + (receipt_root / f"{request_id}.json").write_text( + json.dumps({"receipt": payload, "receiptSha256": receipt_sha}), encoding="utf-8" + ) + if os.name != "nt": + for directory in (backup_root, backup_dir, receipt_root): + directory.chmod(0o700) + for path in ( + backup_dir / "manifest.json", + backup_dir / "postgres.dump", + backup_dir / "n8n.tar", + receipt_root / f"{request_id}.json", + ): + path.chmod(0o600) + monkeypatch.setattr(status_module, "WORKMESH_BACKUP_ROOT", backup_root) + monkeypatch.setattr(status_module, "WORKMESH_BACKUP_RECEIPT_ROOT", receipt_root) + + assert status_module._verified_workmesh_backup_receipt( + request_id, candidate, preflight, inputs + ) == receipt_sha + assert status_module._verified_workmesh_backup_receipt( + request_id, candidate, "9" * 64, inputs + ) is None + (backup_dir / "n8n.tar").write_bytes(b"changed") + assert status_module._verified_workmesh_backup_receipt( + request_id, candidate, preflight, inputs + ) is None diff --git a/backend/tests/test_deployment_auto_rollback.py b/backend/tests/test_deployment_auto_rollback.py new file mode 100644 index 0000000..28ddead --- /dev/null +++ b/backend/tests/test_deployment_auto_rollback.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.db.models import Policy +from backend.app.schemas.deployment import DeploymentPlanV1 +from backend.app.services.deployment_control_plane import DeploymentControlPlane +from backend.app.services.secret_store import set_secret + + +def _plan() -> DeploymentPlanV1: + return DeploymentPlanV1.model_validate( + { + "application_id": "itworx-mcp-hub", + "repository": "Jens/ITWorx_MCP_Hub", + "revision": "a" * 40, + "release_version": "1.0.0", + "image_repository": "itworx-mcp-hub", + "image_tag": "1.0.0", + "topology": { + "compose_project": "itworx-mcp-hub", + "containers": ["itworx-mcp-hub"], + "proxy_network": "itworx-npm", + "internal_port": 8080, + }, + "proxy": {"hostname": "mcp-test.example.com"}, + "oidc": None, + "broker_secret_ref": "MCP_HUB_TEST_DEPLOYMENT_BROKER", + } + ) + + +class _Broker: + def __init__(self, rollback_status: str = "rolled_back") -> None: + self.rollback_status = rollback_status + self.rollback_calls: list[str] = [] + + def preflight(self, plan, *, secret): + return {"status": "preflight_ok"} + + def apply(self, plan, *, secret, idempotency_key): + return {"status": "deployed", "request": "a" * 64} + + def rollback(self, plan, *, secret, idempotency_key): + self.rollback_calls.append(idempotency_key) + return {"status": self.rollback_status, "request": "b" * 64} + + def status(self, request_id, *, secret): + return {"status": "rolled_back", "request": request_id} + + +class _Portainer: + def list_containers(self): + return {"status": "ok", "containers": []} + + +class _FailingNpm: + def proxy_hosts(self): + return {"success": True, "items": []} + + def ensure_managed_proxy_host(self, **kwargs): + return {"success": False, "status": "failed"} + + +class _Unraid: + def get_share_summary(self): + return {"status": "ok"} + + +class _Authentik: + def test_connection(self): + return {"status": "connected"} + + def applications(self): + return {"success": True, "items": []} + + +def _session(tmp_path) -> Session: + engine = create_engine(f"sqlite:///{tmp_path / 'auto-rollback.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _policy(action: str) -> Policy: + return Policy( + id=f"allow-mcp-hub-{action.replace('.', '-')}", + action=action, + target="itworx-mcp-hub", + voice="blocked", + manual="allowed", + risk="high", + reason="Exact-target no-regression deployment test policy.", + ) + + +def _plane(broker: _Broker) -> DeploymentControlPlane: + return DeploymentControlPlane( + broker=broker, + portainer=_Portainer(), + npm=_FailingNpm(), + authentik=_Authentik(), + unraid=_Unraid(), + ) + + +def _configure(session: Session, *, rollback_policy: bool = True) -> None: + set_secret(session, "MCP_HUB_TEST_DEPLOYMENT_BROKER", "x" * 32) + session.add(_policy("deployment.apply")) + if rollback_policy: + session.add(_policy("deployment.rollback")) + session.commit() + + +def test_default_compose_file_survives_durable_plan_roundtrip(): + plan = _plan() + restored = DeploymentPlanV1.model_validate(plan.model_dump(mode="json")) + assert restored.topology.compose_file == ".itworx/deployment.yml" + + +def test_post_apply_verification_failure_auto_rolls_back_with_exact_policy(tmp_path): + broker = _Broker("rolled_back") + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0001", + operation="apply", + ) + + assert result["status"] == "rolled_back" + assert result["stage"] == "auto_rollback_verified" + assert result["evidence"]["postApplyVerification"]["status"] == "failed" + assert result["evidence"]["autoRollback"]["status"] == "rolled_back" + assert broker.rollback_calls == [f"auto-rollback:{result['id']}"] + + +def test_post_apply_verification_failure_does_not_rollback_without_exact_policy(tmp_path): + broker = _Broker("rolled_back") + with _session(tmp_path) as session: + _configure(session, rollback_policy=False) + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0002", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "external_integration_failed" + assert result["evidence"]["autoRollback"]["status"] == "blocked" + assert broker.rollback_calls == [] + + +def test_post_apply_verification_failure_keeps_failure_when_controller_rejects_rollback(tmp_path): + broker = _Broker("rejected") + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0003", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "auto_rollback_failed" + assert result["evidence"]["postApplyVerification"]["status"] == "failed" + assert result["evidence"]["autoRollback"]["status"] == "rejected" + assert len(broker.rollback_calls) == 1 + + +def test_queued_auto_rollback_is_polled_and_finishes_as_auto_rollback(tmp_path): + broker = _Broker("accepted") + plane = _plane(broker) + with _session(tmp_path) as session: + _configure(session) + initial = plane.execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0004", + operation="apply", + ) + assert initial["status"] == "rolling_back" + assert initial["stage"] == "auto_rollback_queued" + + finished = plane.refresh(session, initial["id"]) + + assert finished["status"] == "rolled_back" + assert finished["stage"] == "auto_rollback_verified" + assert finished["evidence"]["autoRollback"]["status"] == "rolled_back" + + +def test_successful_post_apply_verification_never_invokes_auto_rollback(tmp_path): + class _HealthyNpm(_FailingNpm): + def ensure_managed_proxy_host(self, **kwargs): + return {"success": True, "status": "created", "hostId": 42} + + broker = _Broker("rolled_back") + plane = DeploymentControlPlane( + broker=broker, + portainer=_Portainer(), + npm=_HealthyNpm(), + authentik=_Authentik(), + unraid=_Unraid(), + ) + with _session(tmp_path) as session: + _configure(session) + result = plane.execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0005", + operation="apply", + ) + + assert result["status"] == "succeeded" + assert result["stage"] == "controller_and_edge_verified" + assert broker.rollback_calls == [] + +def test_auto_rollback_is_scoped_to_exact_mcp_hub_target(tmp_path): + broker = _Broker("rolled_back") + plan = _plan().model_copy(update={"repository": "jens/other-repository"}) + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker).execute( + session, + plan=plan, + idempotency_key="mcp-hub-apply-0006", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "external_integration_failed" + assert result["evidence"]["postApplyVerification"]["status"] == "failed" + assert "autoRollback" not in result["evidence"] + assert broker.rollback_calls == [] + +def test_auto_rollback_rejects_same_repository_with_different_application(tmp_path): + broker = _Broker("rolled_back") + plan = _plan().model_copy( + update={ + "application_id": "itworx-mcp-hub-copy", + "topology": _plan().topology.model_copy( + update={ + "compose_project": "itworx-mcp-hub-copy", + "containers": ("itworx-mcp-hub-copy",), + } + ), + } + ) + with _session(tmp_path) as session: + set_secret(session, "MCP_HUB_TEST_DEPLOYMENT_BROKER", "x" * 32) + session.add( + Policy( + id="allow-copy-apply", + action="deployment.apply", + target="itworx-mcp-hub-copy", + voice="blocked", + manual="allowed", + risk="high", + reason="Test-only exact target.", + ) + ) + session.add( + Policy( + id="allow-copy-rollback", + action="deployment.rollback", + target="itworx-mcp-hub-copy", + voice="blocked", + manual="allowed", + risk="high", + reason="Test-only exact target.", + ) + ) + session.commit() + result = _plane(broker).execute( + session, + plan=plan, + idempotency_key="mcp-hub-apply-0007", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "external_integration_failed" + assert result["evidence"]["postApplyVerification"]["status"] == "failed" + assert "autoRollback" not in result["evidence"] + assert broker.rollback_calls == [] + +def test_queued_auto_rollback_rejects_deployed_status_as_rollback_proof(tmp_path): + class _AmbiguousRollbackBroker(_Broker): + def status(self, request_id, *, secret): + return {"status": "deployed", "request": request_id} + + broker = _AmbiguousRollbackBroker("accepted") + plane = _plane(broker) + with _session(tmp_path) as session: + _configure(session) + initial = plane.execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0008", + operation="apply", + ) + assert initial["status"] == "rolling_back" + + finished = plane.refresh(session, initial["id"]) + + assert finished["status"] == "failed" + assert finished["stage"] == "auto_rollback_failed" + assert finished["evidence"]["autoRollback"]["status"] == "accepted" + assert "deployed state" in finished["result"] + +def test_manual_rollback_requires_rolled_back_terminal_proof(tmp_path): + broker = _Broker("deployed") + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-rollback-0009", + operation="rollback", + ) + + assert result["status"] == "rollback_failed" + assert result["stage"] == "controller_rejected" + + +def test_apply_rejects_rolled_back_terminal_status(tmp_path): + class _RolledBackApplyBroker(_Broker): + def apply(self, plan, *, secret, idempotency_key): + return {"status": "rolled_back", "request": "c" * 64} + + broker = _RolledBackApplyBroker() + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0010", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "controller_rejected" + assert broker.rollback_calls == [] + +def test_auto_rollback_refuses_duplicate_exact_rollback_policies(tmp_path): + broker = _Broker("rolled_back") + with _session(tmp_path) as session: + _configure(session) + session.add( + Policy( + id="duplicate-rollback-policy", + action="deployment.rollback", + target="itworx-mcp-hub", + voice="blocked", + manual="allowed", + risk="high", + reason="Duplicate exact rollback policy.", + ) + ) + session.commit() + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0011", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "external_integration_failed" + assert result["evidence"]["autoRollback"] == { + "status": "blocked", + "reason": "exact-target-policy", + } + assert broker.rollback_calls == [] + +def test_auto_rollback_rejects_unpollable_accepted_response(tmp_path): + class _MissingRequestBroker(_Broker): + def rollback(self, plan, *, secret, idempotency_key): + self.rollback_calls.append(idempotency_key) + return {"status": "accepted"} + + broker = _MissingRequestBroker() + with _session(tmp_path) as session: + _configure(session) + result = _plane(broker).execute( + session, + plan=_plan(), + idempotency_key="mcp-hub-apply-0012", + operation="apply", + ) + + assert result["status"] == "failed" + assert result["stage"] == "auto_rollback_failed" + assert result["evidence"]["autoRollback"] == { + "status": "accepted", + "reason": "missing-controller-request", + } + assert len(broker.rollback_calls) == 1 diff --git a/backend/tests/test_deployment_contract.py b/backend/tests/test_deployment_contract.py new file mode 100644 index 0000000..515dffb --- /dev/null +++ b/backend/tests/test_deployment_contract.py @@ -0,0 +1,85 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_canonical_compose_keeps_runtime_data_outside_the_source_checkout(): + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() + gitignore = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines() + + assert "${APPOPS_DATA_PATH:-./data}:/data" in compose + assert "APPOPS_SECRET_KEY_FILE: /data/secret-key" in compose + assert "- .:/data" not in compose + assert "data" in dockerignore + assert "/data/" in gitignore + + +def test_canonical_compose_uses_explicit_publish_host_and_persistent_voice_token(): + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + entrypoint = (ROOT / "docker" / "entrypoint.sh").read_text(encoding="utf-8") + + assert "${APPOPS_PUBLISH_HOST:-127.0.0.1}:1216:1216" in compose + assert "APP_OPS_VOICE_TOKEN_FILE=/data/voice-token" in dockerfile + assert 'generate_service_token "true" "${APP_OPS_VOICE_TOKEN:-}"' in entrypoint + assert '"${APP_OPS_VOICE_TOKEN_FILE:-${DATA_DIR}/voice-token}"' in entrypoint + + +def test_canonical_compose_keeps_reverse_proxy_network_after_redeploy(): + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert "- appops_proxy" in compose + assert "external: true" in compose + assert "name: ${APPOPS_PROXY_NETWORK:-itworx-mcp-hub-npm}" in compose + + +def test_canonical_compose_forwards_trusted_operator_proxy_configuration(): + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert 'APPOPS_OPERATOR_PROXY_AUTH: "${APPOPS_OPERATOR_PROXY_AUTH:-false}"' in compose + assert 'APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS: "${APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS:-}"' in compose + assert 'APPOPS_OPERATOR_PROXY_USERS: "${APPOPS_OPERATOR_PROXY_USERS:-}"' in compose + assert 'APPOPS_OPERATOR_PROXY_GROUPS: "${APPOPS_OPERATOR_PROXY_GROUPS:-appops-operators}"' in compose + + +def test_canonical_compose_mounts_only_the_file_based_gitea_observer_secret(): + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + assert "GITEA_URL: https://gitea.example.com" in compose + assert "GITEA_ADMIN_TOKEN_FILE: /data/gitea-admin-token" in compose + assert "GITEA_ADMIN_TOKEN:" not in compose + assert ( + "GITEA_RUNNER_TARGETS_JSON: '{}'" + in compose + ) + + +def test_runtime_image_label_matches_the_app_version(): + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + version = (ROOT / "backend/app/core/version.py").read_text(encoding="utf-8") + + expected_provenance = { + "APPOPS_BUILD_ID": "org.opencontainers.image.version", + "APPOPS_SOURCE_REVISION": "org.opencontainers.image.revision", + "APPOPS_SOURCE_REPOSITORY": "org.opencontainers.image.source", + "APPOPS_SOURCE_TREE": "org.opencontainers.image.source-tree", + "APPOPS_BUILD_DATE": "org.opencontainers.image.created", + } + for build_argument, image_label in expected_provenance.items(): + assert f"ARG {build_argument}=" in dockerfile + assert f'{image_label}="${{{build_argument}}}"' in dockerfile + assert f'{build_argument}: "${{{build_argument}:-' in compose + + assert 'io.itworx.app.version="1.8.0"' in dockerfile + assert 'io.itworx.app.operator-auth="http-only-session"' in dockerfile + assert 'APP_VERSION = "1.8.0"' in version + + +def test_unraid_upgrade_can_restore_voice_token_without_logging_it(): + script = (ROOT / "scripts" / "upgrade_single_1216.sh").read_text(encoding="utf-8") + + assert "for env_key in \\\n APP_OPS_VOICE_TOKEN \\" in script + assert 'printf \'%s=%s\\n\' "$env_key" "$env_value" >> "$ENV_FILE"' in script + assert 'log "${env_value}"' not in script diff --git a/backend/tests/test_deployment_control_plane.py b/backend/tests/test_deployment_control_plane.py new file mode 100644 index 0000000..35a907e --- /dev/null +++ b/backend/tests/test_deployment_control_plane.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.db.models import Policy +from backend.app.schemas.deployment import ( + DeploymentPlanV1, + validate_compose_runtime_contract, +) +from backend.app.services.deployment_control_plane import DeploymentControlPlane +from backend.app.services.secret_store import set_secret + + +def _plan(**overrides): + payload = { + "application_id": "wsbc-portal", + "repository": "Jens/Windows-Support-Bundle-Collector", + "revision": "a" * 40, + "release_version": "0.23.0", + "image_repository": "wsbc-portal", + "image_tag": "0.23.0", + "topology": { + "compose_project": "wsbc-portal", + "containers": ["wsbc-portal"], + "proxy_network": "itworx-npm", + "internal_port": 8080, + }, + "proxy": {"hostname": "support.example.com"}, + "oidc": { + "application_slug": "wsbc-portal", + "client_secret_ref": "WSBC_PORTAL_OIDC_CLIENT_SECRET", + "roles": { + "administrator_group": "Administrator", + "support_agent_group": "SupportAgent", + "read_only_group": "ReadOnly", + }, + }, + "broker_secret_ref": "WSBC_PORTAL_DEPLOYMENT_BROKER", + } + payload.update(overrides) + return DeploymentPlanV1.model_validate(payload) + + +class _Broker: + def preflight(self, plan, *, secret): + assert secret == "a" * 32 + return {"status": "preflight_ok"} + + def apply(self, plan, *, secret, idempotency_key): + assert secret == "a" * 32 + return {"status": "accepted", "request": "b" * 64} + + def rollback(self, plan, *, secret, idempotency_key): + return {"status": "accepted", "request": "c" * 64} + + def status(self, request_id, *, secret): + return {"status": "deployed", "request": request_id} + + +class _Portainer: + def list_containers(self): + return {"status": "ok", "containers": []} + + +class _Npm: + def proxy_hosts(self): + return {"success": True, "items": []} + + def ensure_managed_proxy_host(self, **kwargs): + assert kwargs["forward_host"] == "wsbc-portal" + return {"success": True, "status": "created", "hostId": 42} + + +class _Authentik: + def test_connection(self): + return {"status": "connected"} + + def applications(self): + return {"success": True, "items": []} + + def ensure_deployment_oidc_application(self, **kwargs): + assert kwargs["application_slug"] == "wsbc-portal" + assert kwargs["client_secret"] == "b" * 32 + return {"success": True, "status": "created", "applicationId": "app-1"} + + +class _Unraid: + def get_share_summary(self): + return {"status": "ok"} + + +def _session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'deployment.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _plane(): + return DeploymentControlPlane( + broker=_Broker(), + portainer=_Portainer(), + npm=_Npm(), + authentik=_Authentik(), + unraid=_Unraid(), + ) + + +def test_plan_rejects_latest_tag_and_raw_secret_like_fields(): + raw = _plan().model_dump() + raw["image_tag"] = "latest" + try: + DeploymentPlanV1.model_validate(raw) + except ValueError as exc: + assert "non-latest" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("latest tag must be rejected") + + raw = _plan().model_dump() + raw["deployment_secret"] = "not-allowed" + try: + DeploymentPlanV1.model_validate(raw) + except ValueError as exc: + assert "Extra inputs" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("raw secret fields must be rejected") + + raw = _plan().model_dump() + raw["proxy"]["force_ssl"] = False + try: + DeploymentPlanV1.model_validate(raw) + except ValueError as exc: + assert "force HTTPS" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("managed proxy must force HTTPS") + + +def test_preflight_fails_closed_without_exact_target_policy(tmp_path): + plan = _plan(oidc=None) + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) + result = _plane().execute( + session, + plan=plan, + idempotency_key="preflight-without-policy-0001", + operation="preflight", + ) + + assert result["status"] == "preflight_failed" + assert "deployment_policy" in result["preflight"]["blockers"] + + +def test_preflight_rejects_wildcard_deployment_policy(tmp_path): + plan = _plan(oidc=None) + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) + session.add(Policy( + id="wildcard-deploy", + action="deployment.apply", + target="*", + voice="blocked", + manual="allowed", + risk="high", + reason="Wildcard must not authorize a production deployment.", + )) + session.commit() + result = _plane().execute( + session, + plan=plan, + idempotency_key="preflight-wildcard-policy-0001", + operation="preflight", + ) + + assert result["status"] == "preflight_failed" + assert "deployment_policy" in result["preflight"]["blockers"] + + +def test_compose_contract_rejects_docker_socket_and_unowned_mounts(): + plan = _plan(oidc=None) + unsafe = """ +services: + wsbc-portal: + image: wsbc-portal:0.23.0 + restart: unless-stopped + read_only: true + cap_drop: [ALL] + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: [itworx-npm] +networks: + itworx-npm: + external: true +""" + try: + validate_compose_runtime_contract(plan, unsafe) + except ValueError as exc: + assert "docker socket" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("docker socket mount must be rejected") + + +def test_compose_contract_rejects_path_traversal_outside_owned_appdata(): + plan = _plan(oidc=None) + unsafe = """ +services: + wsbc-portal: + image: wsbc-portal:0.23.0 + restart: unless-stopped + read_only: true + cap_drop: [ALL] + volumes: + - /srv/example/appdata/wsbc-portal/../../other-app:/data + networks: [itworx-npm] +networks: + itworx-npm: + external: true +""" + try: + validate_compose_runtime_contract(plan, unsafe) + except ValueError as exc: + assert "normalized" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("path traversal mount must be rejected") + + +def test_compose_contract_requires_an_owned_persistent_bind_mount(): + plan = _plan(oidc=None) + unsafe = """ +services: + wsbc-portal: + image: wsbc-portal:0.23.0 + restart: unless-stopped + read_only: true + cap_drop: [ALL] + networks: [itworx-npm] +networks: + itworx-npm: + external: true +""" + try: + validate_compose_runtime_contract(plan, unsafe) + except ValueError as exc: + assert "persistent" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("persistent bind mount must be required") + + +def test_preflight_and_apply_are_durable_and_idempotent(tmp_path): + plan = _plan() + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + session.add(Policy( + id="allow-wsbc-deploy", + action="deployment.apply", + target="wsbc-portal", + voice="blocked", + manual="allowed", + risk="high", + reason="Test exact-target deployment policy.", + )) + session.commit() + plane = _plane() + result = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-0001", operation="apply") + + assert not result["preflight"]["blockers"], result["preflight"] + assert result["status"] == "applying", result + assert result["brokerRequestId"] == "b" * 64 + replay = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-0001", operation="apply") + assert replay["idempotentReplay"] is True + assert replay["id"] == result["id"] + + +def test_verified_controller_apply_completes_only_after_typed_oidc_and_proxy(tmp_path): + class DirectBroker(_Broker): + def apply(self, plan, *, secret, idempotency_key): + return {"status": "deployed", "request": "b" * 64} + + plan = _plan() + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + session.add(Policy( + id="allow-wsbc-deploy-direct", + action="deployment.apply", + target="wsbc-portal", + voice="blocked", + manual="allowed", + risk="high", + reason="Test exact-target deployment policy.", + )) + session.commit() + plane = DeploymentControlPlane( + broker=DirectBroker(), + portainer=_Portainer(), + npm=_Npm(), + authentik=_Authentik(), + unraid=_Unraid(), + ) + result = plane.execute(session, plan=plan, idempotency_key="apply-wsbc-direct-0001", operation="apply") + + assert result["status"] == "succeeded" + assert result["stage"] == "controller_and_edge_verified" + assert result["evidence"]["externalIntegration"]["oidc"]["status"] == "created" + assert result["evidence"]["externalIntegration"]["proxy"]["status"] == "created" + + +def test_deployment_evidence_uses_allowlists_and_never_persists_connector_secrets(tmp_path): + class DirectBroker(_Broker): + def apply(self, plan, *, secret, idempotency_key): + return {"status": "deployed", "request": "b" * 64, "token": "broker-secret"} + + class SecretEchoNpm(_Npm): + def ensure_managed_proxy_host(self, **kwargs): + return {"success": True, "status": "created", "hostId": 42, "meta": {"token": "npm-secret"}} + + class SecretEchoAuthentik(_Authentik): + def ensure_deployment_oidc_application(self, **kwargs): + return {"success": True, "status": "created", "applicationId": "app-1", "client_secret_value": "oidc-secret"} + + plan = _plan() + with _session(tmp_path) as session: + set_secret(session, "WSBC_PORTAL_DEPLOYMENT_BROKER", "a" * 32) + set_secret(session, "WSBC_PORTAL_OIDC_CLIENT_SECRET", "b" * 32) + session.add(Policy( + id="allow-wsbc-deploy-evidence", + action="deployment.apply", + target="wsbc-portal", + voice="blocked", + manual="allowed", + risk="high", + reason="Test exact-target deployment policy.", + )) + session.commit() + result = DeploymentControlPlane( + broker=DirectBroker(), + portainer=_Portainer(), + npm=SecretEchoNpm(), + authentik=SecretEchoAuthentik(), + unraid=_Unraid(), + ).execute(session, plan=plan, idempotency_key="apply-wsbc-evidence-0001", operation="apply") + + assert result["status"] == "succeeded" + assert "broker-secret" not in str(result) + assert "npm-secret" not in str(result) + assert "oidc-secret" not in str(result) diff --git a/backend/tests/test_deployment_hygiene.py b/backend/tests/test_deployment_hygiene.py new file mode 100644 index 0000000..75f825d --- /dev/null +++ b/backend/tests/test_deployment_hygiene.py @@ -0,0 +1,54 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_upgrade_replaces_the_canonical_container_without_versioned_copies(): + script = (ROOT / 'scripts' / 'upgrade_single_1216.sh').read_text(encoding='utf-8') + + assert 'assert_single_canonical_container' in script + assert 'docker rm "$SOURCE_CONTAINER_NAME"' in script + assert 'run_canonical_container "$TARGET_IMAGE"' in script + assert 'run_canonical_container "$CURRENT_IMAGE_ID"' in script + assert 'unraid-appops-gateway:rollback' in script + assert 'docker rename' not in script + assert 'OLD_CONTAINER' not in script + assert '${CONTAINER_NAME}-pre-' not in script + assert '${CONTAINER_NAME}-failed-' not in script + assert 'CONTAINER_NAME="${CONTAINER_NAME:-Unraid-AppOps-Gateway}"' in script + assert '--hostname unraid-appops-gateway' in script + assert 'net.unraid.docker.icon' in script + assert 'net.unraid.docker.webui' in script + assert 'urlopen(req, timeout=30)' in script + assert 'previous-container-name.txt' in script + assert 'umask 077' in script + assert 'trap \'rm -f "$ENV_FILE" "$ENV_FILE_RAW"\' EXIT' in script + assert 'value[key] = $0' in script + assert 'for (position = 1; position <= count; position++)' in script + assert 'for (index = 1;' not in script + assert '/^APPOPS_ADMIN_TOKEN=/d' in script + assert '/^VITE_APPOPS_ADMIN_TOKEN=/d' in script + assert '/^VITE_APPOPS_VOICE_TOKEN=/d' in script + assert '/^APPOPS_PORT=/d' in script + assert '/^APPOPS_FRONTEND_ORIGIN=/d' in script + assert 'attempt ${attempt}/3 failed; retrying' in script + assert 'if ! check_endpoint "$path"' in script + assert 'return 1' in script + + +def test_upgrade_retention_is_scoped_to_appops_timestamped_backups_and_images(): + script = (ROOT / 'scripts' / 'upgrade_single_1216.sh').read_text(encoding='utf-8') + + assert 'BACKUP_RETENTION="${BACKUP_RETENTION:-20}"' in script + assert '^[0-9]{8}T[0-9]{6}Z$' in script + assert 'rm -rf -- "$candidate"' in script + assert "docker image ls unraid-appops-gateway" in script + assert 'unraid-appops-gateway:production|unraid-appops-gateway:rollback' in script + + +def test_powershell_wrapper_requires_explicit_execute_confirmation(): + script = (ROOT / 'scripts' / 'upgrade_single_1216.ps1').read_text(encoding='utf-8') + + assert 'throw "Execute requires -Confirm UPGRADE_APPOPS_1216"' in script + assert '$Confirm = "UPGRADE_APPOPS_1216"' not in script diff --git a/backend/tests/test_deployment_target_registry.py b/backend/tests/test_deployment_target_registry.py new file mode 100644 index 0000000..c372b79 --- /dev/null +++ b/backend/tests/test_deployment_target_registry.py @@ -0,0 +1,239 @@ +from types import SimpleNamespace + +from backend.app.services.deployment_target_registry import DeploymentTargetRegistry + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakePortainer: + def __init__(self, exit_code=0): + self.exit_code = exit_code + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": { + "id": "controller-id", + "name": "gitea-deploy-control", + "state": "running", + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-1"}) + if path.endswith("/exec/exec-1/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-1/json"): + return FakeResponse({"Running": False, "ExitCode": self.exit_code}) + raise AssertionError(path) + + +def test_rejects_non_jens_repository_without_portainer_access(): + fake = FakePortainer() + result = DeploymentTargetRegistry(fake).register_single_container( + "other/CertScope", "a" * 40 + ) + assert result["status"] == "invalid" + assert fake.calls == [] + + +def test_rejects_non_sha_without_portainer_access(): + fake = FakePortainer() + result = DeploymentTargetRegistry(fake).register_single_container( + "Jens/CertScope", "main" + ) + assert result["status"] == "invalid" + assert fake.calls == [] + + +def test_derives_bounded_single_container_topology_and_uses_fixed_exec(): + fake = FakePortainer() + revision = "b" * 40 + result = DeploymentTargetRegistry(fake).register_single_container( + "Jens/CertScope", revision + ) + + assert result == { + "status": "registered", + "repository": "jens/certscope", + "revision": revision, + "branch": "main", + "slug": "certscope", + "compose_file": "docker-compose.yml", + "compose_project": "certscope", + "container": "certscope", + "containers": ["certscope"], + } + create = fake.calls[0] + assert create[0] == "POST" + assert create[1].endswith("/containers/controller-id/exec") + command = create[2]["json"]["Cmd"] + assert command[:2] == ["python3", "-c"] + assert command[3:6] == ["jens/certscope", revision, "main"] + assert '"compose_files":["docker-compose.yml"]' in command[6] + assert '"source_dir":"/mnt/cache/appdata/gitea-deploy-runners/sources/certscope"' in command[6] + assert '"containers":["certscope"]' in command[6] + script = command[2] + assert '/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json' in script + assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in script + assert '/mnt/cache/appdata/gitea-deploy-runners/deploy.lock' in script + assert 'os.path.samefile(path, host_path)' in script + assert 'refs/heads/{branch}' in script + assert create[2]["json"]["AttachStdout"] is False + assert create[2]["json"]["AttachStderr"] is False + + +def test_supports_master_branch_and_bounded_multi_container_topology(): + fake = FakePortainer() + revision = "e" * 40 + result = DeploymentTargetRegistry(fake).register_compose_target( + "Jens/Mediarr", + revision, + branch="master", + compose_file="compose.yaml", + compose_project="mediarr", + containers=["mediarr-api-1", "mediarr-web-1"], + ) + assert result["status"] == "registered" + assert result["branch"] == "master" + assert result["compose_file"] == "compose.yaml" + assert result["container"] is None + assert result["containers"] == ["mediarr-api-1", "mediarr-web-1"] + command = fake.calls[0][2]["json"]["Cmd"] + assert command[3:6] == ["jens/mediarr", revision, "master"] + + +def test_rejects_branch_traversal_and_unsafe_compose_path_before_portainer_access(): + fake = FakePortainer() + result = DeploymentTargetRegistry(fake).register_compose_target( + "Jens/Mediarr", + "e" * 40, + branch="../main", + compose_file="../compose.yml", + containers=["mediarr"], + ) + assert result["status"] == "invalid" + assert fake.calls == [] + + +def test_reports_existing_conflicting_target_without_exposing_exec_output(): + fake = FakePortainer(exit_code=23) + result = DeploymentTargetRegistry(fake).register_single_container( + "jens/eventlens", "c" * 40 + ) + assert result == { + "status": "conflict", + "reason": "repository already has a different deployment target", + } + + +def test_reports_bounded_controller_preflight_and_mutation_reasons_without_exec_output(): + expected = { + 20: "deployment projects registry is unavailable or unsafe", + 21: "deployment projects registry is not a regular file", + 22: "deployment projects registry does not contain an object", + 24: "mounted bare repository is unavailable", + 25: "exact revision cannot be resolved in the mounted bare repository", + 26: "default branch cannot be resolved in the mounted bare repository", + 27: "exact revision is not an ancestor of the default branch in the mounted bare repository", + 28: "configured Compose file is absent at the exact revision", + 29: "deployment registry lock file cannot be opened", + 30: "deployment registry lock cannot be acquired", + 31: "deployment projects registry cannot be read as JSON", + 32: "deployment projects registry backup cannot be created", + 33: "deployment projects registry replacement file cannot be durably written", + 34: "deployment projects registry replacement cannot be installed atomically", + 35: "deployment projects registry directory durability sync failed after replacement", + 36: "deployment projects registry host alias is unavailable or does not match the controller registry", + 37: "controller project branch does not match the requested default branch", + 38: "controller project Compose contract is invalid", + } + for exit_code, reason in expected.items(): + fake = FakePortainer(exit_code=exit_code) + result = DeploymentTargetRegistry(fake).register_single_container( + "jens/eventlens", "d" * 40 + ) + assert result == {"status": "validation_failed", "reason": reason} + + +def test_existing_target_may_preserve_only_bounded_root_owned_runtime_inputs(): + fake = FakePortainer() + revision = "f" * 40 + result = DeploymentTargetRegistry(fake).register_compose_target( + "Jens/Mediarr", + revision, + branch="master", + compose_file="docker-compose.unraid.yml", + compose_project="mediarr", + containers=["mediarr"], + verify_oci_revision=True, + ) + assert result["status"] == "registered" + script = fake.calls[0][2]["json"]["Cmd"][2] + assert '"env_source"' in script + assert '"environment"' in script + assert '"gitea_repository_id"' in script + assert '"gitea_webhook_secret_file"' in script + assert '"validation"' in script + assert "extra_keys.issubset(allowed_extra_keys)" in script + assert 'env_source.startswith("/srv/example/appdata/")' in script + assert "not isinstance(environment, dict) or len(environment) > 64" in script + assert 'expected_secret_file = repository.replace("/", "__")' in script + assert '"Managed validation / full (push)"' in script + assert '"timeout_seconds": 3600 if repository == "jens/mediarr" else 1800' in script + + +def test_existing_target_compatibility_still_rejects_unknown_or_behavioral_metadata(): + fake = FakePortainer() + DeploymentTargetRegistry(fake).register_single_container( + "Jens/EventLens", "a" * 40 + ) + script = fake.calls[0][2]["json"]["Cmd"][2] + line = next( + value for value in script.splitlines() + if "allowed_extra_keys =" in value + ) + assert '"deploy_script"' not in line + assert '"manual"' not in line + assert '"live_probe"' not in line + + + +def test_runnerless_validation_compatibility_is_fixed_push_only(): + fake = FakePortainer() + DeploymentTargetRegistry(fake).register_single_container( + "Jens/EventLens", "b" * 40 + ) + script = fake.calls[0][2]["json"]["Cmd"][2] + assert '"validation_gate_version": 1' in script + assert '"required": True' in script + assert '"contexts": ["Managed validation / full (push)"]' in script + assert '"timeout_seconds": 3600 if repository == "jens/mediarr" else 1800' in script + assert '"poll_interval_seconds": 5' in script + assert '"max_poll_interval_seconds": 30' in script + assert "if validation != expected_validation" in script + +def test_runnerless_compatibility_requires_the_complete_metadata_triplet(): + fake = FakePortainer() + DeploymentTargetRegistry(fake).register_single_container( + "Jens/EventLens", "c" * 40 + ) + script = fake.calls[0][2]["json"]["Cmd"][2] + assert 'runnerless_keys = {' in script + assert '"gitea_repository_id"' in script + assert '"gitea_webhook_secret_file"' in script + assert '"validation"' in script + assert "present_runnerless_keys and present_runnerless_keys != runnerless_keys" in script diff --git a/backend/tests/test_dockdeck_classifier.py b/backend/tests/test_dockdeck_classifier.py new file mode 100644 index 0000000..514c7f0 --- /dev/null +++ b/backend/tests/test_dockdeck_classifier.py @@ -0,0 +1,32 @@ +from backend.app.services.app_classifier import classify_container + + +def test_dockdeck_image_has_first_class_inventory_metadata(): + result = classify_container( + { + 'name': 'web-portal', + 'image': 'dockdeck:local', + 'ports': ['1218:3000'], + } + ) + + assert result['app_type'] == 'dockdeck' + assert result['suggested_app_id'] == 'dockdeck' + assert result['category'] == 'infrastructure' + assert result['subcategory'] == 'service_portal' + assert result['criticality'] == 'medium' + assert result['connector'] == 'none' + assert result['policy_profile'] == 'internal_service' + assert result['protected'] is False + assert result['privacy_sensitive'] is True + assert result['healthcheck_suggestion'] == {'port': 1218} + assert result['voice_suggestion'] == 'disabled' + assert result['confidence'] >= 0.8 + assert result['import_status'] == 'discovered' + + +def test_dockdeck_name_match_does_not_require_local_image_tag(): + result = classify_container({'name': 'DockDeck', 'image': 'custom/build'}) + + assert result['app_type'] == 'dockdeck' + assert result['category'] == 'infrastructure' diff --git a/backend/tests/test_enrollment_plane.py b/backend/tests/test_enrollment_plane.py new file mode 100644 index 0000000..b691a79 --- /dev/null +++ b/backend/tests/test_enrollment_plane.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.db.models import Enrollment, EnrollmentJob, Policy +from backend.app.schemas.enrollment import EnrollmentPlanV1, EnrollmentTrustConfigV1 +from backend.app.services import enrollment_plane as enrollment_plane_module +from backend.app.services.enrollment_plane import EnrollmentPlane +from backend.app.services.secret_store import get_secret, set_secret + + +def _plan(**overrides): + value = { + "application_id": "wsbc-portal", + "display_name": "Windows Support Bundle Collector", + "repository": "Jens/Windows-Support-Bundle-Collector", + "revision": "a" * 40, + "profile": "internal-webapp-oidc", + "target": {"target_identity": "unraid-controller", "container_name": "wsbc-portal"}, + "storage": {"runtime_uid": 1654, "runtime_gid": 1654}, + "network": {"docker_network": "itworx-npm", "internal_port": 8080}, + "artifact": {"release_version": "0.23.0", "image_repository": "wsbc-portal", "image_tag": "0.23.0"}, + "exposure": {"hostname": "wsbc.example.com", "upstream_service": "wsbc-portal"}, + "authentication": { + "mode": "native-oidc", + "redirect_path": "/signin-oidc", + "logout_path": "/signout-callback-oidc", + "roles": {"administrator": "Administrator", "support_agent": "SupportAgent", "read_only": "ReadOnly"}, + }, + "secrets": {"oidc_client_secret_required": True}, + } + value.update(overrides) + return EnrollmentPlanV1.model_validate(value) + + +def _repopulse_plan(**overrides): + value = { + "application_id": "repopulse", + "display_name": "RepoPulse", + "environment": "production", + "repository": "Jens/RepoPulse", + "revision": "c" * 40, + "profile": "repopulse-authenticated", + "target": {"target_identity": "unraid-controller", "container_name": "repopulse"}, + "storage": {"runtime_uid": 10001, "runtime_gid": 10001}, + "network": {"docker_network": "itworx-mcp-hub-npm", "internal_port": 8000}, + "artifact": { + "release_version": "1.5.0", + "image_repository": "repopulse-managed", + "image_tag": "production", + "health_path": "/health", + }, + "exposure": {"hostname": "repopulse.example.com", "upstream_service": "repopulse"}, + "secrets": {"additional_secret_refs": ["REPOPULSE_GITEA_TOKEN"]}, + } + value.update(overrides) + return EnrollmentPlanV1.model_validate(value) + + +def _trust(): + return EnrollmentTrustConfigV1.model_validate({ + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "approved_networks": ["itworx-npm", "itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["internal-webapp-oidc", "internal-webapp", "internal-service", "repopulse-authenticated"], + "repopulse_proxy_access_list_id": 42, + "oidc_issuer": "https://auth.example.com/application/o/wsbc-portal", + "administrator_group": "Administrator", + "support_agent_group": "SupportAgent", + "read_only_group": "ReadOnly", + }) + + +class _Broker: + def is_configured(self): + return True + + def enrollment_capabilities(self, *, secret): + assert secret == "b" * 64 + return {"status": "ready"} + + def enrollment_preflight(self, enrollment, *, secret): + assert secret == "b" * 64 + assert "target_credential" not in enrollment + return {"status": "preflight_ok"} + + def enroll_target(self, enrollment, *, secret, idempotency_key): + assert secret == "b" * 64 + assert len(enrollment["target_credential"]) >= 48 + assert enrollment["application_id"] == "wsbc-portal" + return {"status": "enrolled", "targetId": enrollment["enrollment_id"], "applicationId": "wsbc-portal"} + + def configure_enrollment_runtime(self, runtime, *, secret): + assert len(secret) >= 48 + assert set(runtime["values"]) == {"APPOPS_PUBLIC_URL", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_ISSUER", "OIDC_ROLE_CLAIM"} + return {"status": "configured", "enrollmentId": runtime["enrollment_id"], "applicationId": "wsbc-portal"} + + +class _Portainer: + def list_containers(self): + return {"status": "ok", "containers": []} + + +class _CollisionPortainer(_Portainer): + def list_containers(self): + return {"status": "ok", "containers": [{"name": "wsbc-portal"}]} + + +class _Npm: + def is_configured(self): + return True + + def proxy_hosts(self): + return {"success": True, "items": []} + + def ensure_managed_proxy_host(self, **kwargs): + assert kwargs["hostname"] == "wsbc.example.com" + assert kwargs["certificate_mode"] == "managed" + return {"success": True, "status": "created", "hostId": 10, "hostname": kwargs["hostname"]} + + +class _RepoPulseNpm(_Npm): + def __init__(self, *, access_list_ok=True): + self.access_list_ok = access_list_ok + self.validated_ids = [] + self.created_host = None + + def validate_managed_access_list(self, access_list_id): + self.validated_ids.append(access_list_id) + return { + "success": self.access_list_ok, + "status": "verified" if self.access_list_ok else "blocked", + "message": "required_access_list_verified" if self.access_list_ok else "required_access_list_unavailable", + **({"accessListId": access_list_id} if self.access_list_ok else {}), + } + + def ensure_managed_proxy_host(self, **kwargs): + assert kwargs["hostname"] == "repopulse.example.com" + assert kwargs["forward_host"] == "repopulse" + assert kwargs["forward_port"] == 8000 + assert kwargs["access_list_id"] == 42 + self.created_host = dict(kwargs) + return {"success": True, "status": "created", "hostId": 10, "hostname": kwargs["hostname"]} + + +class _RepoPulseBroker(_Broker): + def __init__(self): + self.runtime_values = None + + def enroll_target(self, enrollment, *, secret, idempotency_key): + assert secret == "b" * 64 + assert enrollment["application_id"] == "repopulse" + assert enrollment["repository"] == "jens/repopulse" + assert enrollment["profile"] == "repopulse-authenticated" + assert enrollment["network"] == "itworx-mcp-hub-npm" + assert enrollment["runtime_uid"] == enrollment["runtime_gid"] == 10001 + assert enrollment["image_repository"] == "repopulse-managed" + assert enrollment["image_tag"] == "production" + return {"status": "enrolled", "targetId": enrollment["enrollment_id"], "applicationId": "repopulse"} + + def configure_enrollment_runtime(self, runtime, *, secret): + assert len(secret) >= 48 + self.runtime_values = dict(runtime["values"]) + assert set(self.runtime_values) == { + "REPOPULSE_GITEA_URL", "REPOPULSE_GITEA_TOKEN", "REPOPULSE_MAINTENANCE_TOKEN", + } + return {"status": "configured", "enrollmentId": runtime["enrollment_id"], "applicationId": "repopulse"} + + +class _Authentik: + def is_configured(self): + return True + + def test_connection(self): + return {"status": "connected"} + + def applications(self): + return {"success": True, "items": []} + + def groups(self): + return {"success": True, "items": [{"name": "Administrator"}, {"name": "SupportAgent"}, {"name": "ReadOnly"}]} + + def deployment_oidc_template_status(self, *, role_claim): + assert role_claim == "roles" + return {"status": "ready", "missing": []} + + def ensure_deployment_oidc_application(self, **kwargs): + assert kwargs["application_slug"] == "wsbc-portal" + assert kwargs["client_secret"] + return {"success": True, "status": "created", "applicationId": "application-1", "providerId": "provider-1"} + + +def _session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'enrollment.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _plane(portainer=None, broker=None, npm=None, authentik=None): + return EnrollmentPlane( + broker=broker or _Broker(), + portainer=portainer or _Portainer(), + npm=npm or _Npm(), + authentik=authentik or _Authentik(), + ) + + +def _configure(session): + set_secret(session, "ENROLLMENT_BOOTSTRAP", "b" * 64) + EnrollmentPlane.set_trust_config(session, _trust()) + + +@pytest.fixture(autouse=True) +def _production_grade_secret_store(monkeypatch): + """Plane unit tests exercise enrollment behavior with a managed key store.""" + monkeypatch.setattr( + enrollment_plane_module, + "secret_storage_status", + lambda: {"status": "ready", "productionReady": True}, + ) + + +def test_enrollment_schema_rejects_plaintext_secrets_and_untrusted_topology(): + raw = _plan().model_dump() + raw["target"]["command"] = "id" + try: + EnrollmentPlanV1.model_validate(raw) + except ValueError as exc: + assert "Extra inputs" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("arbitrary target command must be rejected") + + raw = _plan().model_dump() + raw["storage"]["appdata_subpath"] = "../etc" + try: + EnrollmentPlanV1.model_validate(raw) + except ValueError as exc: + assert "subpath" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("appdata traversal must be rejected") + + raw = _plan().model_dump() + raw["exposure"]["hostname"] = "*.example.com" + try: + EnrollmentPlanV1.model_validate(raw) + except ValueError as exc: + assert "hostname" in str(exc) + else: # pragma: no cover - assertion clarity + raise AssertionError("wildcard hostname must be rejected") + + +def test_repopulse_profile_accepts_only_its_fixed_authenticated_target(): + plan = _repopulse_plan() + assert plan.application_id == "repopulse" + assert plan.repository == "jens/repopulse" + assert plan.network.docker_network == "itworx-mcp-hub-npm" + assert plan.secrets.additional_secret_refs == ("REPOPULSE_GITEA_TOKEN",) + + for field, value in ( + ("application_id", "another-app"), + ("repository", "Jens/another-app"), + ("network", {"docker_network": "other-network", "internal_port": 8000}), + ("artifact", {"release_version": "1.5.0", "image_repository": "repopulse-managed", "image_tag": "latest", "health_path": "/health"}), + ("exposure", {"hostname": "other.example.com", "upstream_service": "repopulse"}), + ("secrets", {"additional_secret_refs": ["OTHER_TOKEN"]}), + ): + raw = _repopulse_plan().model_dump(mode="json") + raw[field] = value + with pytest.raises(ValueError): + EnrollmentPlanV1.model_validate(raw) + + +def test_repopulse_preflight_requires_existing_secret_and_trusted_npm_access_list(tmp_path): + with _session(tmp_path) as session: + _configure(session) + npm = _RepoPulseNpm(access_list_ok=False) + plane = _plane(npm=npm) + + missing_secret = plane.preflight(session, _repopulse_plan()) + assert missing_secret["status"] == "blocked" + assert "repopulse_gitea_token_unavailable" in missing_secret["blockers"] + assert npm.validated_ids == [42] + + set_secret(session, "REPOPULSE_GITEA_TOKEN", "existing-encrypted-gitea-token") + bad_access_list = plane.preflight(session, _repopulse_plan()) + assert bad_access_list["status"] == "blocked" + assert "repopulse_access_list_unavailable" in bad_access_list["blockers"] + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(EnrollmentJob)).all() == [] + + +def test_repopulse_enrollment_seals_exact_runtime_values_without_exposing_secrets(tmp_path): + with _session(tmp_path) as session: + _configure(session) + gitea_token = "existing-encrypted-gitea-token" + set_secret(session, "REPOPULSE_GITEA_TOKEN", gitea_token) + npm = _RepoPulseNpm() + broker = _RepoPulseBroker() + result = _plane(broker=broker, npm=npm).enroll( + session, _repopulse_plan(), idempotency_key="enroll-repopulse-0001", + ) + + assert result["enrollment"]["status"] == "ready" + assert npm.validated_ids == [42] + assert npm.created_host["access_list_id"] == 42 + assert broker.runtime_values["REPOPULSE_GITEA_URL"] == "https://gitea.example.com" + assert broker.runtime_values["REPOPULSE_GITEA_TOKEN"] == gitea_token + maintenance_token = get_secret(session, "ENROLL_REPOPULSE_MAINTENANCE_TOKEN") + assert maintenance_token and broker.runtime_values["REPOPULSE_MAINTENANCE_TOKEN"] == maintenance_token + assert gitea_token not in str(result) + assert maintenance_token not in str(result) + assert gitea_token not in str(result["job"]["evidence"]) + assert maintenance_token not in str(result["job"]["evidence"]) + + +def test_preflight_is_read_only_and_rejects_existing_container(tmp_path): + with _session(tmp_path) as session: + _configure(session) + result = _plane(_CollisionPortainer()).preflight(session, _plan()) + assert result["status"] == "blocked" + assert "unmanaged_resource_collision" in result["blockers"] + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(EnrollmentJob)).all() == [] + + +def test_preflight_rejects_the_development_secret_fallback(tmp_path, monkeypatch): + with _session(tmp_path) as session: + _configure(session) + monkeypatch.setattr( + enrollment_plane_module, + "secret_storage_status", + lambda: {"status": "ready", "productionReady": False}, + ) + result = _plane().preflight(session, _plan()) + assert result["status"] == "blocked" + assert "secret_store" in result["blockers"] + assert session.exec(select(Enrollment)).all() == [] + + +def test_capabilities_require_a_live_bootstrap_controller_contract(tmp_path): + with _session(tmp_path) as session: + _configure(session) + result = _plane().capabilities(session) + assert result["targetEnrollment"]["status"] == "ready" + assert result["controller"]["status"] == "ready" + + +def test_enrollment_creates_write_only_credentials_exact_policies_and_is_idempotent(tmp_path): + with _session(tmp_path) as session: + _configure(session) + plane = _plane() + result = plane.enroll(session, _plan(), idempotency_key="enroll-wsbc-portal-0001") + assert result["enrollment"]["status"] == "ready" + assert result["job"]["status"] == "ready" + assert result["enrollment"]["targetSecretRef"] == "ENROLL_WSBC_PORTAL_TARGET_HMAC" + assert result["enrollment"]["oidcSecretRef"] == "ENROLL_WSBC_PORTAL_OIDC_CLIENT_SECRET" + target_secret = get_secret(session, "ENROLL_WSBC_PORTAL_TARGET_HMAC") + oidc_secret = get_secret(session, "ENROLL_WSBC_PORTAL_OIDC_CLIENT_SECRET") + assert target_secret and oidc_secret + assert target_secret not in str(result) + assert oidc_secret not in str(result) + policies = session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() + assert {(policy.action, policy.target, policy.manual, policy.voice) for policy in policies} == { + ("deployment.apply", "wsbc-portal", "allowed", "blocked"), + ("deployment.rollback", "wsbc-portal", "allowed", "blocked"), + } + assert all(policy.provenance_json["enrollmentId"] == result["enrollment"]["id"] for policy in policies) + replay = plane.enroll(session, _plan(), idempotency_key="enroll-wsbc-portal-0001") + assert replay["idempotentReplay"] is True + assert replay["enrollment"]["id"] == result["enrollment"]["id"] + assert len(session.exec(select(Enrollment)).all()) == 1 + assert len(session.exec(select(EnrollmentJob)).all()) == 1 + + +def test_interrupted_external_enrollment_requires_explicit_recovery(tmp_path): + class InterruptedBroker(_Broker): + def enroll_target(self, _enrollment, *, secret, idempotency_key): + assert secret == "b" * 64 + assert idempotency_key == "enroll-wsbc-interrupted-0001" + raise RuntimeError("controller connection interrupted after request dispatch") + + with _session(tmp_path) as session: + _configure(session) + plane = _plane(broker=InterruptedBroker()) + with pytest.raises(RuntimeError, match="interrupted"): + plane.enroll(session, _plan(), idempotency_key="enroll-wsbc-interrupted-0001") + + job = session.exec(select(EnrollmentJob)).one() + assert job.status == "running" + assert job.stage == "registering_target" + assert job.evidence_json["intent"]["controller"]["applicationId"] == "wsbc-portal" + assert "target_credential" not in str(job.evidence_json) + + recovered = plane.enroll(session, _plan(), idempotency_key="enroll-wsbc-interrupted-0001") + assert recovered["recoveryRequired"] is True + assert recovered["job"]["status"] == "recovery_required" + assert recovered["enrollment"]["status"] == "recovery_required" + + +def test_preflight_blocks_hostname_outside_root_trust_boundary(tmp_path): + with _session(tmp_path) as session: + _configure(session) + raw = _plan().model_dump() + raw["exposure"]["hostname"] = "wsbc.example.net" + result = _plane().preflight(session, EnrollmentPlanV1.model_validate(raw)) + assert result["status"] == "blocked" + assert "hostname_not_allowed" in result["blockers"] + + +def test_preflight_rejects_an_oidc_role_model_outside_root_trust(tmp_path): + with _session(tmp_path) as session: + _configure(session) + raw = _plan().model_dump() + raw["authentication"]["roles"]["administrator"] = "TenantAdmin" + result = _plane().preflight(session, EnrollmentPlanV1.model_validate(raw)) + assert result["status"] == "blocked" + assert "oidc_role_model" in result["blockers"] + + +def test_failed_identity_provisioning_compensates_only_created_resources(tmp_path): + class CompensatingBroker(_Broker): + def unenroll_target(self, target, *, secret): + assert target["application_id"] == "wsbc-portal" + assert secret == "b" * 64 + return {"status": "rolled_back"} + + class CompensatingNpm(_Npm): + def remove_managed_proxy_host(self, *, host_id, ownership_id): + assert host_id == 10 + assert ownership_id.startswith("enr-") + return {"success": True, "status": "removed"} + + class FailingAuthentik(_Authentik): + def ensure_deployment_oidc_application(self, **_kwargs): + return {"success": False, "status": "failed"} + + with _session(tmp_path) as session: + _configure(session) + result = _plane( + broker=CompensatingBroker(), + npm=CompensatingNpm(), + authentik=FailingAuthentik(), + ).enroll(session, _plan(), idempotency_key="enroll-wsbc-compensate-0001") + assert result["enrollment"]["status"] == "failed" + assert result["job"]["evidence"]["compensation"] == { + "completed": True, + "outcomes": {"proxy": "removed", "controller": "rolled_back"}, + } + assert get_secret(session, "ENROLL_WSBC_PORTAL_TARGET_HMAC") is None + assert get_secret(session, "ENROLL_WSBC_PORTAL_OIDC_CLIENT_SECRET") is None + assert session.exec(select(Policy).where(Policy.target == "wsbc-portal")).all() == [] diff --git a/backend/tests/test_gitea_actions.py b/backend/tests/test_gitea_actions.py new file mode 100644 index 0000000..44791dc --- /dev/null +++ b/backend/tests/test_gitea_actions.py @@ -0,0 +1,326 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + +from backend.app.connectors import gitea_actions +from backend.app.connectors.gitea_actions import GiteaActionsConnector +from backend.app.core.config import settings + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "appops_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + engine = create_engine( + f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False} + ) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def _configure(monkeypatch): + monkeypatch.setitem(settings.apis, "GITEA_URL", "https://gitea.test") + monkeypatch.setitem(settings.apis, "GITEA_ADMIN_TOKEN", "fixture-secret") + monkeypatch.setitem( + settings.apis, + "GITEA_RUNNER_TARGETS_JSON", + '{"unraid-deploy":"itworx-security-runner"}', + ) + monkeypatch.setattr(gitea_actions, "_CACHE", None) + + +def test_runner_snapshot_recommends_only_approval_gated_restart_for_stalled_queue( + monkeypatch, +): + _configure(monkeypatch) + connector = GiteaActionsConnector() + created_at = (datetime.now(UTC) - timedelta(minutes=12)).isoformat() + + def fake_request(path, params=None): + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 7, + "name": "deploy-runner", + "status": "offline", + "busy": False, + "disabled": False, + "labels": [{"name": "unraid-deploy"}], + } + ], + } + if params.get("status") == "queued": + return { + "total_count": 1, + "jobs": [ + { + "id": 8, + "status": "queued", + "created_at": created_at, + "labels": ["unraid-deploy"], + } + ], + } + return {"total_count": 0, "jobs": []} + + monkeypatch.setattr(connector, "_request", fake_request) + result = connector.snapshot(refresh=True) + + assert result["severity"] == "critical" + assert result["queue"]["queued"] == 1 + assert result["queue"]["oldestQueuedAgeSeconds"] >= 600 + assert result["queue"]["sampleTruncated"] is False + assert result["recommendedAction"] == { + "intent": "container.restart", + "target": "itworx-security-runner", + "requiresApproval": True, + "autoExecute": False, + "reason": "Gitea Actions queue is stalled without a matching online runner.", + } + assert "fixture-secret" not in repr(result) + + +def test_runner_snapshot_is_cached_and_healthy_when_queue_is_clear(monkeypatch): + _configure(monkeypatch) + connector = GiteaActionsConnector() + calls = 0 + + def fake_request(path, params=None): + nonlocal calls + calls += 1 + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 1, + "name": "shared", + "status": "online", + "labels": ["ubuntu-latest"], + } + ], + } + return {"total_count": 0, "jobs": []} + + monkeypatch.setattr(connector, "_request", fake_request) + first = connector.snapshot(refresh=True) + second = connector.snapshot() + + assert first["severity"] == "healthy" + assert first["recommendedAction"] is None + assert second["cache"]["hit"] is True + assert calls == 4 + + +def test_long_running_shared_job_is_critical_without_ambiguous_restart(monkeypatch): + _configure(monkeypatch) + monkeypatch.setitem(settings.apis, "GITEA_RUNNER_TARGETS_JSON", "{}") + connector = GiteaActionsConnector() + created_at = (datetime.now(UTC) - timedelta(hours=3)).isoformat() + + def fake_request(path, params=None): + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 1, + "name": "shared", + "status": "online", + "busy": True, + "labels": ["ubuntu-latest"], + } + ], + } + if params.get("status") == "in_progress": + return { + "total_count": 1, + "jobs": [ + { + "id": 9, + "status": "in_progress", + "created_at": created_at, + "labels": ["ubuntu-latest"], + } + ], + } + return {"total_count": 0, "jobs": []} + + monkeypatch.setattr(connector, "_request", fake_request) + result = connector.snapshot(refresh=True) + + assert result["severity"] == "critical" + assert result["queue"]["oldestInProgressAgeSeconds"] >= 7_200 + assert result["recommendedAction"] is None + + +def test_recently_started_old_queued_job_is_not_reported_as_stale(monkeypatch): + _configure(monkeypatch) + connector = GiteaActionsConnector() + created_at = (datetime.now(UTC) - timedelta(hours=3)).isoformat() + started_at = (datetime.now(UTC) - timedelta(seconds=30)).isoformat() + + def fake_request(path, params=None): + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 1, + "name": "shared", + "status": "online", + "busy": True, + "labels": ["ubuntu-latest"], + } + ], + } + if params.get("status") == "in_progress": + return { + "total_count": 1, + "jobs": [ + { + "id": 9, + "status": "in_progress", + "created_at": created_at, + "started_at": started_at, + "labels": ["ubuntu-latest"], + } + ], + } + return {"total_count": 0, "jobs": []} + + monkeypatch.setattr(connector, "_request", fake_request) + result = connector.snapshot(refresh=True) + + assert result["severity"] == "healthy" + assert 0 <= result["queue"]["oldestInProgressAgeSeconds"] < 120 + assert result["queue"]["staleActiveRunnerLabels"] == [] + + +def test_long_running_job_recommends_only_exact_mapped_runner(monkeypatch): + _configure(monkeypatch) + connector = GiteaActionsConnector() + created_at = (datetime.now(UTC) - timedelta(hours=3)).isoformat() + + def fake_request(path, params=None): + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 7, + "name": "deploy-runner", + "status": "online", + "busy": True, + "labels": ["unraid-deploy"], + } + ], + } + if params.get("status") == "in_progress": + return { + "total_count": 1, + "jobs": [ + { + "id": 10, + "status": "in_progress", + "created_at": created_at, + "labels": ["unraid-deploy"], + } + ], + } + return {"total_count": 0, "jobs": []} + + monkeypatch.setattr(connector, "_request", fake_request) + result = connector.snapshot(refresh=True) + + assert result["recommendedAction"] == { + "intent": "container.restart", + "target": "itworx-security-runner", + "requiresApproval": True, + "autoExecute": False, + "reason": "A Gitea Actions job exceeded the two-hour guardrail on an explicitly mapped runner.", + } + + +def test_runner_status_endpoint_degrades_when_not_configured(api_client, monkeypatch): + monkeypatch.setitem(settings.apis, "GITEA_URL", None) + monkeypatch.setitem(settings.apis, "GITEA_ADMIN_TOKEN", None) + monkeypatch.setattr(gitea_actions, "_CACHE", None) + + response = api_client.get("/api/gitea/actions/status") + + assert response.status_code == 200 + assert response.json()["status"] == "missing_config" + assert response.json()["recommendedAction"] is None + + +def test_default_docker_target_falls_back_to_v1_capability(monkeypatch): + _configure(monkeypatch) + monkeypatch.setitem(settings.apis, "GITEA_RUNNER_TARGETS_JSON", "{}") + + target = GiteaActionsConnector._target_for_label( + "docker-validation", + [ + { + "name": "itworx-validation-runner-2", + "labels": ["linux-validation", "docker-validation"], + } + ], + GiteaActionsConnector._restart_targets(), + ) + + assert target == "itworx-validation-runner-2" + + +def test_default_docker_target_prefers_v2_specialized_runner(monkeypatch): + _configure(monkeypatch) + monkeypatch.setitem(settings.apis, "GITEA_RUNNER_TARGETS_JSON", "{}") + + target = GiteaActionsConnector._target_for_label( + "docker-validation", + [ + { + "name": "itworx-validation-runner-2", + "labels": ["linux-validation"], + }, + { + "name": "itworx-docker-runner", + "labels": ["docker-validation"], + }, + ], + GiteaActionsConnector._restart_targets(), + ) + + assert target == "itworx-docker-runner" + + +def test_explicit_runner_target_mapping_remains_authoritative(monkeypatch): + _configure(monkeypatch) + monkeypatch.setitem( + settings.apis, + "GITEA_RUNNER_TARGETS_JSON", + '{"docker-validation":"operator-selected-container"}', + ) + + target = GiteaActionsConnector._target_for_label( + "docker-validation", + [{"name": "some-registration", "labels": ["docker-validation"]}], + GiteaActionsConnector._restart_targets(), + ) + + assert target == "operator-selected-container" diff --git a/backend/tests/test_gitea_project_auto_registration.py b/backend/tests/test_gitea_project_auto_registration.py new file mode 100644 index 0000000..20dec0f --- /dev/null +++ b/backend/tests/test_gitea_project_auto_registration.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import json + +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.db.models import Policy +from backend.app.services.gitea_project_auto_registration import ( + GiteaProjectAutoRegistration, +) + + +class FakeClient: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class FakeTargetRegistry: + def __init__(self): + self.calls = [] + + def register_compose_target(self, repository, revision, **kwargs): + self.calls.append((repository, revision, kwargs)) + return { + "status": "registered", + "repository": repository, + "revision": revision, + } + + +class FakeProjectSync(GiteaProjectAutoRegistration): + def __init__(self, repositories, contracts, *, target_registry): + super().__init__(target_registry=target_registry) + self.repositories = repositories + self.contracts = contracts + self.branch_calls = [] + self.contract_calls = [] + + def _client(self): + return FakeClient() + + def _repositories(self, client): + return list(self.repositories) + + def _branch_head(self, client, *, repository_name, branch): + self.branch_calls.append((repository_name, branch)) + return (repository_name[0].lower() if repository_name else "a") * 40 + + def _compose_contract(self, client, *, repository_name, revision): + self.contract_calls.append((repository_name, revision)) + return self.contracts.get(repository_name) + + +class FakeComposeInspection(GiteaProjectAutoRegistration): + def __init__(self, root_names, files): + super().__init__(target_registry=FakeTargetRegistry()) + self.root_names = list(root_names) + self.files = dict(files) + + def _contents( + self, + client, + *, + repository_name, + revision, + path="", + allow_not_found=False, + ): + if not path: + return [{"name": name} for name in self.root_names] + raise AssertionError(f"unexpected direct contents read: {path}") + + def _read_text( + self, + client, + *, + repository_name, + revision, + path, + allow_not_found=False, + ): + value = self.files.get(path) + if value is None and not allow_not_found: + raise AssertionError(f"missing fake file: {path}") + return value + + +def make_session(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def repo(name, *, branch="main", updated="2026-09-16T12:00:00Z", archived=False, empty=False): + return { + "name": name, + "full_name": f"Jens/{name}", + "default_branch": branch, + "updated_at": updated, + "archived": archived, + "empty": empty, + "private": True, + } + + +def deployable_contract(): + return { + "applicationId": "deployable", + "composeFile": "docker-compose.yml", + "composeProject": "deployable", + "containers": ["deployable"], + "serviceNames": ["deployable"], + "healthTimeout": 180, + "verifyOciRevision": False, + } + + +def test_repository_names_normalize_to_deployment_plan_application_ids(): + assert GiteaProjectAutoRegistration._application_id("ITWorx_MCP_Hub") == "itworx-mcp-hub" + assert GiteaProjectAutoRegistration._application_id("Media.Codec_Dashboard") == "media-codec-dashboard" + + +def test_single_service_compose_can_be_inferred_without_descriptor(): + service = FakeComposeInspection( + ["docker-compose.yml"], + { + "docker-compose.yml": "services:\n my-app:\n image: example/app:1\n", + }, + ) + contract = service._compose_contract( + FakeClient(), repository_name="My_App", revision="a" * 40 + ) + assert contract is not None + assert contract["applicationId"] == "my-app" + assert contract["composeProject"] == "my-app" + assert contract["serviceNames"] == ["my-app"] + assert contract["containers"] == ["my-app-my-app-1"] + + +def test_ambiguous_multi_service_compose_stays_known_but_unconfigured(): + service = FakeComposeInspection( + ["compose.yml"], + { + "compose.yml": "services:\n api:\n image: example/api:1\n worker:\n image: example/worker:1\n", + }, + ) + assert ( + service._compose_contract( + FakeClient(), repository_name="DifferentName", revision="b" * 40 + ) + is None + ) + + +def test_descriptor_can_select_primary_service_for_multi_service_project(): + descriptor = { + "apiVersion": "itworx.deployment-target/v1", + "applicationId": "api", + "composeFile": "compose.yml", + } + service = FakeComposeInspection( + [".itworx", "compose.yml"], + { + ".itworx/deployment-target.json": json.dumps(descriptor), + "compose.yml": "services:\n api:\n image: example/api:1\n worker:\n image: example/worker:1\n", + }, + ) + contract = service._compose_contract( + FakeClient(), repository_name="MultiService", revision="c" * 40 + ) + assert contract is not None + assert contract["applicationId"] == "api" + assert contract["composeProject"] == "api" + assert contract["serviceNames"] == ["api", "worker"] + assert contract["containers"] == ["api-api-1", "api-worker-1"] + + +def test_descriptor_cannot_diverge_compose_project_from_application_id(): + with pytest.raises(ValueError, match="composeProject must equal applicationId"): + GiteaProjectAutoRegistration._safe_descriptor( + { + "apiVersion": "itworx.deployment-target/v1", + "applicationId": "api", + "composeProject": "different", + }, + "Example", + ) + + +def test_sync_catalogs_every_repo_but_only_activates_proven_compose_targets(): + registry = FakeTargetRegistry() + contract = deployable_contract() + contract["containers"] = ["deployable-api-1", "deployable-web-1"] + contract["serviceNames"] = ["deployable", "worker"] + service = FakeProjectSync( + [repo("Deployable"), repo("DocsOnly"), repo("Legacy", branch="master")], + { + "Deployable": contract, + "DocsOnly": None, + "Legacy": None, + }, + target_registry=registry, + ) + + with make_session() as session: + result = service.sync(session) + assert result["status"] == "ok" + assert result["summary"]["total"] == 3 + assert result["summary"]["registered"] == 1 + assert result["summary"]["knownUnconfigured"] == 2 + assert len(registry.calls) == 1 + repository, revision, kwargs = registry.calls[0] + assert repository == "jens/deployable" + assert len(revision) == 40 + assert kwargs["branch"] == "main" + assert kwargs["containers"] == ["deployable-api-1", "deployable-web-1"] + + policies = session.exec( + select(Policy).where(Policy.target == "deployable") + ).all() + assert {item.action for item in policies} == { + "deployment.apply", + "deployment.rollback", + "deployment.runnerless.provision", + } + assert all(item.manual == "allowed" for item in policies) + assert all(item.voice == "blocked" for item in policies) + + +def test_unchanged_catalog_entries_do_not_repeat_registration_work(): + registry = FakeTargetRegistry() + repositories = [repo("Deployable")] + service = FakeProjectSync( + repositories, + {"Deployable": deployable_contract()}, + target_registry=registry, + ) + + with make_session() as session: + first = service.sync(session) + assert first["summary"]["registered"] == 1 + assert len(registry.calls) == 1 + first_branch_calls = len(service.branch_calls) + first_contract_calls = len(service.contract_calls) + + second = service.sync(session) + assert second["summary"]["registered"] == 1 + assert second["summary"]["changedThisSync"] == 0 + assert len(registry.calls) == 1 + assert len(service.branch_calls) == first_branch_calls + assert len(service.contract_calls) == first_contract_calls + + +def test_existing_exact_policy_is_not_repurposed_when_it_blocks_deployments(): + registry = FakeTargetRegistry() + service = FakeProjectSync( + [repo("Deployable")], + {"Deployable": deployable_contract()}, + target_registry=registry, + ) + + with make_session() as session: + session.add( + Policy( + id="operator-owned-policy", + action="deployment.apply", + target="deployable", + voice="blocked", + manual="blocked", + risk="high", + reason="operator-owned denial", + ) + ) + session.commit() + result = service.sync(session) + project = result["projects"][0] + assert project["state"] == "policy_conflict" + preserved = session.get(Policy, "operator-owned-policy") + assert preserved is not None + assert preserved.manual == "blocked" + policies = session.exec(select(Policy).where(Policy.target == "deployable")).all() + assert [item.id for item in policies] == ["operator-owned-policy"] + + +def test_rollback_policy_conflict_does_not_stage_or_commit_apply_policy(): + registry = FakeTargetRegistry() + service = FakeProjectSync( + [repo("Deployable")], + {"Deployable": deployable_contract()}, + target_registry=registry, + ) + + with make_session() as session: + session.add( + Policy( + id="operator-owned-rollback-denial", + action="deployment.rollback", + target="deployable", + voice="blocked", + manual="blocked", + risk="high", + reason="operator-owned rollback denial", + ) + ) + session.commit() + + result = service.sync(session) + assert result["projects"][0]["state"] == "policy_conflict" + policies = session.exec(select(Policy).where(Policy.target == "deployable")).all() + assert [(item.id, item.action, item.manual) for item in policies] == [ + ("operator-owned-rollback-denial", "deployment.rollback", "blocked") + ] + +def test_unchanged_controller_conflict_is_retried_and_can_self_heal(): + class ConflictThenRegisteredRegistry(FakeTargetRegistry): + def register_compose_target(self, repository, revision, **kwargs): + self.calls.append((repository, revision, kwargs)) + if len(self.calls) == 1: + return { + "status": "conflict", + "repository": repository, + "revision": revision, + "reason": "deployment target already exists with different topology", + } + return { + "status": "registered", + "repository": repository, + "revision": revision, + } + + registry = ConflictThenRegisteredRegistry() + service = FakeProjectSync( + [repo("Deployable")], + {"Deployable": deployable_contract()}, + target_registry=registry, + ) + + with make_session() as session: + first = service.sync(session) + assert first["projects"][0]["state"] == "controller_conflict" + assert len(registry.calls) == 1 + + second = service.sync(session) + assert second["projects"][0]["state"] == "registered" + assert second["summary"]["changedThisSync"] == 1 + assert len(registry.calls) == 2 + assert len(second["projects"][0]["policyIds"]) == 3 + + +def test_unchanged_policy_conflict_is_retried_after_operator_owned_policy_is_repaired(): + registry = FakeTargetRegistry() + service = FakeProjectSync( + [repo("Deployable")], + {"Deployable": deployable_contract()}, + target_registry=registry, + ) + + with make_session() as session: + policy = Policy( + id="operator-owned-policy", + action="deployment.apply", + target="deployable", + voice="blocked", + manual="blocked", + risk="high", + reason="operator-owned denial", + ) + session.add(policy) + session.commit() + + first = service.sync(session) + assert first["projects"][0]["state"] == "policy_conflict" + assert len(registry.calls) == 1 + + policy.manual = "allowed" + session.add(policy) + session.commit() + + second = service.sync(session) + assert second["projects"][0]["state"] == "registered" + assert second["summary"]["changedThisSync"] == 1 + assert len(registry.calls) == 2 + assert { + item.action + for item in session.exec(select(Policy).where(Policy.target == "deployable")).all() + } == {"deployment.apply", "deployment.rollback", "deployment.runnerless.provision"} diff --git a/backend/tests/test_home_assistant_voice_export.py b/backend/tests/test_home_assistant_voice_export.py new file mode 100644 index 0000000..bd0da9a --- /dev/null +++ b/backend/tests/test_home_assistant_voice_export.py @@ -0,0 +1,217 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "appops_voice_export.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + monkeypatch.setenv("HOME_ASSISTANT_TOKEN", "test-ha-notify-token") + + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def _voice_headers(): + return {"Authorization": "Bearer test-voice-token"} + + +def test_home_assistant_export_contains_safe_yaml_without_tokens(api_client): + response = api_client.get("/api/voice/home-assistant/export", headers=_voice_headers()) + + assert response.status_code == 200 + payload = response.json() + yaml = payload["yaml"] + assert "rest_command:" in yaml + assert "script:" in yaml + assert "appops_voice_authorization" in yaml + assert "Authorization: !secret appops_voice_authorization" in yaml + assert "test-voice-token" not in yaml + assert "test-ha-notify-token" not in yaml + assert "HOME_ASSISTANT_TOKEN" not in yaml + assert "http://testserver/api/voice/actions/setup/status" in yaml + assert "response_variable: appops_result" in yaml + assert "appops_base_url" not in yaml + assert len(payload["setupGuide"]) == 6 + + +def test_home_assistant_export_does_not_include_blocked_actions(api_client): + payload = api_client.get("/api/voice/home-assistant/export", headers=_voice_headers()).json() + text = str(payload) + + assert "unraid.mover.start" not in text + assert "volume.delete" not in text + assert "stack.redeploy" not in text + assert "authentik/restart" not in text + assert "pause-all" not in text + + +def test_voice_commands_expose_full_command_contract(api_client): + response = api_client.get("/api/voice/commands", headers=_voice_headers()) + + assert response.status_code == 200 + commands = response.json() + assert len(commands) >= 23 + required = { + "id", + "label", + "spokenPhraseNl", + "category", + "endpoint", + "method", + "payload", + "risk", + "policyDecision", + "requiresApproval", + "enabledByDefault", + "homeAssistantRestCommandName", + "homeAssistantScriptName", + "description", + "safetyNotes", + } + assert required <= set(commands[0]) + assert any(command["id"] == "container.widefrog.restart" for command in commands) + assert any(command["id"] == "unraid.vms" for command in commands) + assert any(command["id"] == "security.exposure" for command in commands) + assert next(command for command in commands if command["id"] == "unraid.storage")["policyDecision"] == "allowed" + assert all(command["policyDecision"] != "blocked" for command in commands) + + +def test_voice_export_endpoints_require_voice_token(api_client): + missing = api_client.get("/api/voice/home-assistant/export") + wrong = api_client.get( + "/api/voice/home-assistant/export", + headers={"Authorization": "Bearer wrong-token"}, + ) + + assert missing.status_code == 401 + assert wrong.status_code == 403 + + +def test_operator_voice_catalog_is_read_only_and_does_not_require_browser_secret(api_client): + commands = api_client.get('/api/operator/voice/commands') + export = api_client.get('/api/operator/voice/home-assistant/export') + dry_run = api_client.post('/api/operator/voice/test-intent', json={'input': 'controleer appops setup status'}) + + assert commands.status_code == 200 + assert len(commands.json()) >= 11 + assert export.status_code == 200 + assert 'test-voice-token' not in str(export.json()) + assert dry_run.status_code == 200 + assert dry_run.json()['executed'] is False + assert api_client.post('/api/voice/actions/setup/status').status_code == 401 + + +def test_operator_voice_readiness_uses_live_home_assistant_state(api_client, monkeypatch): + from backend.app.core.config import settings + from backend.app.services import home_assistant_voice_readiness + + previous = dict(settings.apis) + settings.apis["HOME_ASSISTANT_URL"] = "http://home-assistant:8123" + settings.apis["HOME_ASSISTANT_TOKEN"] = "runtime-token-not-returned" + + def fake_get_json(_url, _token, path, _verify_tls): + if path == "/api/config": + return { + "components": [ + "conversation", + "google_generative_ai_conversation", + ] + }, "ok", 200 + return [ + {"entity_id": "script.appops_check_setup"}, + {"entity_id": "script.appops_check_unraid"}, + ], "ok", 200 + + monkeypatch.setattr(home_assistant_voice_readiness, "_get_json", fake_get_json) + try: + response = api_client.get("/api/operator/voice/home-assistant/readiness") + finally: + settings.apis.clear() + settings.apis.update(previous) + + assert response.status_code == 200 + payload = response.json() + assert payload["apiReachable"] is True + assert payload["operational"] is True + assert payload["coreScriptAvailable"] is True + assert payload["conversationAvailable"] is True + assert payload["geminiConversationAvailable"] is True + assert payload["detectedScriptCount"] == 2 + assert payload["expectedScriptCount"] >= 20 + assert payload["llmExposureStatus"] == "manual_verification_required" + assert "runtime-token-not-returned" not in str(payload) + + +def test_operator_voice_readiness_reports_missing_connector_without_network(api_client, monkeypatch): + from backend.app.core.config import settings + from backend.app.services import home_assistant_voice_readiness + + previous = dict(settings.apis) + settings.apis["HOME_ASSISTANT_URL"] = None + settings.apis["HOME_ASSISTANT_TOKEN"] = None + monkeypatch.setattr( + home_assistant_voice_readiness, + "_get_json", + lambda *_args: (_ for _ in ()).throw(AssertionError("missing config must not call Home Assistant")), + ) + try: + response = api_client.get("/api/operator/voice/home-assistant/readiness") + finally: + settings.apis.clear() + settings.apis.update(previous) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "missing_config" + assert payload["apiReachable"] is False + assert set(payload["missing"]) == {"HOME_ASSISTANT_URL", "HOME_ASSISTANT_TOKEN"} + + +def test_test_intent_is_dry_run_and_unknown_does_not_execute(api_client): + before_jobs = api_client.get("/api/jobs").json() + + response = api_client.post( + "/api/voice/test-intent", + headers=_voice_headers(), + json={"input": "zet alles stuk aub"}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "unknown" + assert payload["executed"] is False + assert "jobId" not in payload + assert api_client.get("/api/jobs").json() == before_jobs + + +def test_dry_run_does_not_mutate_or_create_approval(api_client): + before_jobs = api_client.get("/api/jobs").json() + before_approvals = api_client.get("/api/approvals").json() + + response = api_client.post( + "/api/voice/commands/container.widefrog.restart/dry-run", + headers=_voice_headers(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["executed"] is False + assert payload["command"]["id"] == "container.widefrog.restart" + assert payload["policyPreview"]["decision"] == "allowed" + assert api_client.get("/api/jobs").json() == before_jobs + assert api_client.get("/api/approvals").json() == before_approvals diff --git a/backend/tests/test_inventory_discovery.py b/backend/tests/test_inventory_discovery.py new file mode 100644 index 0000000..a41807d --- /dev/null +++ b/backend/tests/test_inventory_discovery.py @@ -0,0 +1,1237 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "inventory_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.main import app + from backend.app.db import session + from backend.app.db.init_db import init_db + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def test_classifier_known_patterns(): + from backend.app.services.app_classifier import classify_container + + cases = [ + ({'name': 'binhex-sonarr', 'image': 'binhex/arch-sonarr', 'ports': ['8989:8989']}, 'sonarr', 'media', 'sonarr', 'medium', False), + ({'name': 'binhex-radarr', 'image': 'binhex/arch-radarr', 'ports': ['7878:7878']}, 'radarr', 'media', 'radarr', 'medium', False), + ({'name': 'authentik-server', 'image': 'ghcr.io/goauthentik/server'}, 'authentik', 'auth', 'authentik', 'critical', True), + ({'name': 'postgres', 'image': 'postgres:16'}, 'postgres', 'database', 'none', 'critical', True), + ({'name': 'nginxproxymanager', 'image': 'jc21/nginx-proxy-manager'}, 'nginx_proxy_manager', 'network', 'nginx_proxy_manager', 'critical', True), + ({'name': 'widefrog', 'image': 'widefrog/app', 'ports': ['1207:1207']}, 'widefrog', 'media', 'widefrog', 'medium', False), + ({'name': 'adguard-home', 'image': 'adguard/adguardhome'}, 'adguard_home', 'network', 'none', 'high', True), + ({'name': 'audiobookshelf', 'image': 'ghcr.io/advplyr/audiobookshelf'}, 'audiobookshelf', 'media', 'none', 'medium', False), + ({'name': 'vaultwarden', 'image': 'vaultwarden/server'}, 'vaultwarden', 'auth', 'none', 'critical', True), + ({'name': 'netdata', 'image': 'netdata/netdata'}, 'netdata', 'monitoring', 'none', 'medium', False), + ({'name': 'Gpu Hot', 'image': ''}, 'gpu_hot', 'monitoring', 'none', 'medium', False), + ({'name': 'System Monitor', 'image': ''}, 'system_monitor', 'monitoring', 'none', 'medium', False), + ({'name': 'Unraid Appops Gateway 1216', 'image': ''}, 'unraid_appops_gateway', 'infrastructure', 'none', 'critical', True), + ({'name': 'netronome', 'image': 'ghcr.io/autobrr/netronome'}, 'netronome', 'monitoring', 'none', 'medium', False), + ({'name': 'grimmory', 'image': 'ghcr.io/grimmory-tools/grimmory'}, 'grimmory', 'media', 'none', 'medium', False), + ({'name': 'leafwiki', 'image': 'ghcr.io/perber/leafwiki'}, 'leafwiki', 'productivity', 'none', 'medium', False), + ({'name': 'hivekeep', 'image': 'ghcr.io/marlburrow/hivekeep'}, 'hivekeep', 'infrastructure', 'none', 'high', True), + ({'name': 'stirlingpdf', 'image': 'stirlingtools/stirling-pdf'}, 'stirlingpdf', 'productivity', 'none', 'medium', False), + ({'name': 'geointel', 'image': 'geointel-all-in-one:latest'}, 'geointel', 'ai', 'none', 'medium', True), + ({'name': 'opscenter-it-tools', 'image': 'opscenter-it-tools:latest'}, 'opscenter_it_tools', 'dev', 'none', 'high', True), + ({'name': 'projectbrain', 'image': 'projectbrain-unraid:latest'}, 'projectbrain', 'productivity', 'none', 'high', True), + ({'name': 'psforge', 'image': 'psforge:local'}, 'psforge', 'productivity', 'none', 'medium', True), + ({'name': 'QuizMonitor', 'image': 'quiz-monitor:latest'}, 'quizmonitor', 'ai', 'none', 'high', True), + ({'name': 'spoortracker', 'image': 'spoortracker:unraid'}, 'spoortracker', 'monitoring', 'none', 'low', False), + ({'name': 'certscope', 'image': 'certscope-app'}, 'certscope', 'monitoring', 'none', 'low', False), + ({'name': 'ps4-http', 'image': 'python:3-alpine'}, 'ps4_http', 'infrastructure', 'none', 'low', False), + ({'name': 'launchpad', 'image': 'launchpad:local'}, 'launchpad', 'infrastructure', 'none', 'medium', False), + ({'name': 'media-codec-dashboard', 'image': 'media-codec-dashboard-media-codec-dashboard'}, 'media_codec_dashboard', 'media', 'none', 'medium', False), + ({'name': 'ollama', 'image': 'ollama/ollama'}, 'ollama', 'infrastructure', 'none', 'medium', False), + ({'name': 'something-random', 'image': 'local/random'}, 'unknown', 'unknown', 'none', 'low', False), + ] + + for raw, app_type, category, connector, criticality, protected in cases: + result = classify_container(raw) + assert result['app_type'] == app_type + assert result['category'] == category + assert result['connector'] == connector + assert result['criticality'] == criticality + assert result['protected'] is protected + + unknown = classify_container({'name': 'something-random', 'image': 'local/random'}) + assert unknown['import_status'] == 'unmapped' + assert unknown['confidence'] < 0.5 + + +def test_unknown_canonical_apps_receive_conservative_classification_upgrade(api_client): + from sqlmodel import Session + from backend.app.db.init_db import _upgrade_unknown_app_classifications + from backend.app.db.models import App, AppContainerMapping, DiscoveredContainer + from backend.app.db.session import engine + + with Session(engine) as session: + session.add(App(id='vaultwarden-local', name='Vaultwarden', category='Unknown')) + session.add(App(id='custom-local', name='example-host Custom Tool', category='Unknown')) + session.add(App(id='relay-local', name='Mail helper', category='Unknown')) + session.add(AppContainerMapping(id='map-relay', app_id='relay-local', container_id='relay-container', container_name='mail-helper')) + session.add(DiscoveredContainer( + id='disc-relay', + discovery_run_id='run-relay', + container_id='relay-container', + name='mail-helper', + image='mwader/postfix-relay:latest', + )) + session.commit() + + assert _upgrade_unknown_app_classifications(session) is True + session.commit() + + vaultwarden = session.get(App, 'vaultwarden-local') + custom = session.get(App, 'custom-local') + relay = session.get(App, 'relay-local') + assert vaultwarden is not None + assert vaultwarden.category == 'Auth' + assert vaultwarden.criticality == 'critical' + assert vaultwarden.protected is True + assert custom is not None + assert custom.category == 'Unknown' + assert relay is not None + assert relay.category == 'Network' + assert relay.protected is True + + +def test_active_app_views_exclude_deployment_history(api_client): + from sqlmodel import Session + from backend.app.db.models import App + from backend.app.db.session import engine + + with Session(engine) as session: + session.add(App(id='unraid_appops_gateway', name='Unraid AppOps Gateway', category='Infrastructure')) + session.add(App(id='unraid_appops_gateway_1216', name='Unraid AppOps Gateway 1216', category='Infrastructure')) + session.add(App( + id='unraid_appops_gateway_1216_pre_20260712t203550z', + name='Unraid AppOps Gateway 1216 pre deployment', + category='Infrastructure', + )) + session.commit() + + app_ids = {item['id'] for item in api_client.get('/api/apps').json()} + assert 'unraid_appops_gateway_1216' in app_ids + assert 'unraid_appops_gateway' not in app_ids + assert 'unraid_appops_gateway_1216_pre_20260712t203550z' not in app_ids + + inventory_ids = { + item['id'] + for item in api_client.get('/api/inventory/apps?include_unmanaged=false').json() + } + assert 'unraid_appops_gateway_1216' in inventory_ids + assert 'unraid_appops_gateway' not in inventory_ids + assert 'unraid_appops_gateway_1216_pre_20260712t203550z' not in inventory_ids + + +def test_canonical_app_metadata_update_is_audited_and_metadata_only(api_client): + before = api_client.get('/api/apps/sonarr').json() + response = api_client.patch('/api/apps/sonarr/metadata', json={ + 'name': 'Sonarr Media', + 'category': 'Media Automation', + 'criticality': 'high', + }) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['app']['name'] == 'Sonarr Media' + assert payload['app']['category'] == 'Media Automation' + assert payload['app']['criticality'] == 'high' + + app = api_client.get('/api/apps/sonarr').json() + assert app['name'] == 'Sonarr Media' + assert app['category'] == 'Media Automation' + assert app['connectorStatus'] == before['connectorStatus'] + assert app['voiceEnabled'] == before['voiceEnabled'] + assert app['protected'] == before['protected'] + + audit = api_client.get('/api/audit').json() + event = next(item for item in audit if item['action_id'] == 'app.metadata.update') + assert event['target'] == 'sonarr' + assert event['result'] == 'success' + + +def test_canonical_app_metadata_update_rejects_invalid_values(api_client): + response = api_client.patch('/api/apps/sonarr/metadata', json={ + 'name': '', + 'category': '', + 'criticality': 'extreme', + }) + + assert response.status_code == 200 + assert response.json()['success'] is False + assert response.json()['status'] == 'validation_error' + + +def test_canonical_app_uses_live_published_port_for_external_url(api_client): + from datetime import datetime, timezone + from sqlmodel import Session + from backend.app.core.config import settings + from backend.app.db.models import App, AppContainerMapping, DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + from backend.app.services.inventory_discovery import invalidate_discovery_cache + + previous_url = settings.apis.get('UNRAID_URL') + settings.apis['UNRAID_URL'] = 'http://198.18.10.150' + try: + with Session(engine) as session: + now = datetime.now(timezone.utc) + run = DiscoveryRun(id='runtime-link-run', status='complete', started_at=now, finished_at=now) + session.add(run) + session.add(App(id='runtime_link_app', name='Runtime Link App', category='Dev')) + session.add(DiscoveredContainer( + id='runtime-link-discovered', + discovery_run_id=run.id, + container_id='runtime-link-container', + name='runtime-link-app', + image='runtime-link:test', + state='running', + status='healthy', + ports_json=['4321:8080'], + import_status='managed', + matched_app_id='runtime_link_app', + )) + session.add(AppContainerMapping( + id='runtime-link-mapping', + app_id='runtime_link_app', + container_id='runtime-link-container', + container_name='runtime-link-app', + )) + session.commit() + invalidate_discovery_cache() + + payload = api_client.get('/api/apps/runtime_link_app').json() + assert payload['url'] == 'http://198.18.10.150' + assert payload['port'] == 4321 + assert 'api_key' not in str(payload).lower() + assert 'token' not in str(payload).lower() + finally: + settings.apis['UNRAID_URL'] = previous_url + + +def test_app_runtime_urls_strip_credentials_query_and_fragment(): + from backend.app.api.routes_apps import _credential_free_url + + assert _credential_free_url('https://operator:secret@example.test:8443/app/?token=hidden#panel') == 'https://example.test:8443/app' + assert _credential_free_url('javascript:alert(1)') is None + assert _credential_free_url('http://example.test:99999') is None + + +def test_category_labels_preserve_operational_acronyms(): + from backend.app.services.app_registry import category_label + + assert category_label('ai') == 'AI' + assert category_label('it_tools') == 'IT Tools' + assert category_label('api_services') == 'API Services' + + +def test_discovery_missing_config_returns_failure(api_client): + response = api_client.post('/api/inventory/discover') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['containersFound'] == 0 + assert payload['runId'].startswith('disc-') + + +def test_discovery_redacts_secret_metadata_and_never_stores_env(api_client, monkeypatch): + secret = 'SUPER_SECRET_TOKEN' + + def fake_test_connection(self): + return {'status': 'connected'} + + def fake_list_containers(self): + return { + 'status': 'ok', + 'containers': [ + { + 'id': 'abc123', + 'name': 'binhex-sonarr', + 'image': 'binhex/arch-sonarr:latest', + 'state': 'running', + 'status': 'running', + 'ports': ['8989:8989'], + 'mounts': [{'Source': '/srv/example/appdata/sonarr', 'Destination': '/config'}], + 'networks': ['bridge'], + 'labels': {'com.example.token': secret, 'safe': 'value'}, + 'env': {'API_KEY': secret}, + 'raw': {'Env': [f'API_KEY={secret}'], 'Labels': {'secret': secret}}, + } + ], + } + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', fake_test_connection, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', fake_list_containers, raising=True) + + response = api_client.post('/api/inventory/discover') + payload = response.json() + + assert response.status_code == 200 + assert payload['success'] is True + assert payload['containersFound'] == 1 + assert secret not in str(payload) + + discovered = api_client.get('/api/inventory/discovered-containers').json() + assert len(discovered) == 1 + assert secret not in str(discovered) + assert 'Env' not in str(discovered) + assert discovered[0]['suggestedAppType'] == 'sonarr' + assert discovered[0]['importStatus'] == 'discovered' + + +def test_import_discovered_container_creates_app_mapping_and_safe_defaults(api_client, monkeypatch): + def fake_test_connection(self): + return {'status': 'connected'} + + def fake_list_containers(self): + return { + 'status': 'ok', + 'containers': [ + { + 'id': 'sonarr-container', + 'name': 'binhex-sonarr', + 'image': 'binhex/arch-sonarr:latest', + 'state': 'running', + 'status': 'running', + 'ports': ['8989:8989'], + }, + { + 'id': 'auth-container', + 'name': 'authentik-server', + 'image': 'ghcr.io/goauthentik/server:latest', + 'state': 'running', + 'status': 'running', + 'ports': ['9000:9000'], + }, + ], + } + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', fake_test_connection, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', fake_list_containers, raising=True) + + api_client.post('/api/inventory/discover') + suggestions = api_client.get('/api/inventory/suggestions').json() + sonarr = next(item for item in suggestions if item['suggestedAppType'] == 'sonarr') + authentik = next(item for item in suggestions if item['suggestedAppType'] == 'authentik') + + sonarr_import = api_client.post('/api/inventory/import', json={'discoveredContainerId': sonarr['id']}).json() + auth_import = api_client.post('/api/inventory/import', json={'discoveredContainerId': authentik['id']}).json() + + assert sonarr_import['success'] is True + assert sonarr_import['app']['id'] == 'sonarr' + assert sonarr_import['app']['voiceEnabled'] is False + assert auth_import['app']['protected'] is True + assert auth_import['app']['voiceEnabled'] is False + + mappings = api_client.get('/api/inventory/mappings').json() + assert any(mapping['appId'] == 'sonarr' and mapping['containerId'] == 'sonarr-container' for mapping in mappings) + assert any(mapping['appId'] == 'authentik' and mapping['containerId'] == 'auth-container' for mapping in mappings) + + policy = api_client.post( + '/api/policies/evaluate', + json={'action': 'container.stop', 'target': 'authentik', 'source': 'voice'}, + ).json() + assert policy['decision'] in {'blocked', 'approval_required'} + + +def test_complete_inventory_review_marks_managed_apps_reviewed(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'binhex/arch-sonarr', 'state': 'running', 'status': 'running'}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + suggestion = api_client.get('/api/inventory/suggestions').json()[0] + api_client.post('/api/inventory/import', json={'discoveredContainerId': suggestion['id']}) + + before = api_client.get('/api/setup/status').json() + assert before['summary']['appsNeedReview'] == 1 + + payload = api_client.post('/api/inventory/review/complete', json={}).json() + assert payload['success'] is True + assert payload['reviewed'] == 1 + + after = api_client.get('/api/setup/status').json() + assert after['summary']['appsNeedReview'] == 0 + assert {step['id']: step['status'] for step in after['steps']}['review'] == 'complete' + + +def test_inventory_ignore_survives_recreated_container_id(api_client, monkeypatch): + current_id = {'value': 'custom-container-1'} + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': current_id['value'], 'name': 'release-candidate-stopped', 'image': 'local/candidate:latest', 'state': 'exited', 'status': 'exited'}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + first = api_client.get('/api/inventory/discovered-containers').json()[0] + api_client.post('/api/inventory/ignore', json={'discoveredContainerId': first['id']}) + current_id['value'] = 'custom-container-2' + api_client.post('/api/inventory/discover') + + latest = next(item for item in api_client.get('/api/inventory/discovered-containers').json() if item['containerId'] == 'custom-container-2') + assert latest['importStatus'] == 'ignored' + assert api_client.get('/api/setup/status').json()['summary']['appsNeedReview'] == 0 + + +def test_inventory_ignore_survives_rediscovery_for_mapped_container(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'mapped-migrate-1', 'name': 'mapped-migrate', 'image': 'local/migrate:latest', 'state': 'exited', 'status': 'unhealthy'}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + discovered = api_client.get('/api/inventory/discovered-containers').json()[0] + imported = api_client.post('/api/inventory/import', json={'discoveredContainerId': discovered['id']}).json() + assert imported['success'] is True + latest = api_client.get('/api/inventory/discovered-containers').json()[0] + api_client.post('/api/inventory/ignore', json={'discoveredContainerId': latest['id']}) + + api_client.post('/api/inventory/discover') + + refreshed = api_client.get('/api/inventory/discovered-containers').json()[0] + assert refreshed['importStatus'] == 'ignored' + app = next(item for item in api_client.get('/api/apps').json() if item['id'] == imported['app']['id']) + assert app['status'] == 'healthy' + assert app['containerStatus'] == 'ignored' + + +def test_inventory_auto_ignores_ephemeral_ci_and_buildkit_containers(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: { + 'status': 'ok', + 'containers': [ + {'id': 'ci-1', 'name': 'GITEA-ACTIONS-TASK-42-WORKFLOW-validation-JOB-browser-abc', 'image': 'docker.gitea.com/runner-images:ubuntu-latest', 'state': 'running', 'status': 'running'}, + {'id': 'buildkit-1', 'name': 'buildx_buildkit_builder-example', 'image': 'moby/buildkit:buildx-stable-1', 'state': 'running', 'status': 'running'}, + {'id': 'phase-1', 'name': 'beautiful_colden', 'image': 'devrunbook-phase12b-migrate', 'state': 'running', 'status': 'running'}, + {'id': 'candidate-1', 'name': 'ludarium-candidate-039-postgres-1', 'image': 'postgres:16-alpine', 'state': 'running', 'status': 'running'}, + {'id': 'dotnet-sdk-1', 'name': 'friendly_matsumoto', 'image': 'mcr.microsoft.com/dotnet/sdk:10.0.300', 'state': 'running', 'status': 'running'}, + ], + }, + raising=True, + ) + + api_client.post('/api/inventory/discover') + latest = api_client.get('/api/inventory/discovered-containers').json() + + assert {item['importStatus'] for item in latest} == {'ignored'} + assert api_client.get('/api/setup/status').json()['summary']['appsNeedReview'] == 0 + + +def test_setup_managed_app_count_matches_canonical_apps_not_mappings(api_client): + apps = api_client.get('/api/apps').json() + assert len(apps) > 0 + + mapping_response = api_client.patch( + f"/api/apps/{apps[0]['id']}/container-mapping", + json={'containerId': 'secondary-container-for-same-app', 'containerName': 'secondary-container-for-same-app'}, + ).json() + assert mapping_response['success'] is True + + status = api_client.get('/api/setup/status').json() + + assert status['summary']['appsManaged'] == len(api_client.get('/api/apps').json()) + assert status['managedApps'] == status['summary']['appsManaged'] + + +def test_update_discovered_review_metadata_updates_only_appops_classification(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'custom-container', 'name': 'custom-app', 'image': 'local/custom:latest', 'state': 'running', 'status': 'running'}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + discovered = api_client.get('/api/inventory/discovered-containers').json()[0] + + payload = api_client.post( + '/api/inventory/review/update', + json={ + 'discoveredContainerId': discovered['id'], + 'displayName': 'Custom Ops App', + 'category': 'ops tooling', + 'criticality': 'high', + 'connectorType': 'none', + 'policyProfile': 'review_only', + }, + ).json() + + assert payload['success'] is True + updated = api_client.get('/api/inventory/discovered-containers').json()[0] + assert updated['displayName'] == 'Custom Ops App' + assert updated['suggestedCategory'] == 'ops_tooling' + assert updated['suggestedCriticality'] == 'high' + assert updated['suggestedConnector'] == 'none' + assert updated['suggestedPolicyProfile'] == 'review_only' + assert updated['importStatus'] == 'unmapped' + assert api_client.get('/api/inventory/mappings').json() == [] + + +def test_map_discovered_container_to_existing_app_creates_manual_mapping_without_new_app(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'custom-container', 'name': 'custom-app', 'image': 'local/custom:latest', 'state': 'running', 'status': 'running'}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + discovered = api_client.get('/api/inventory/discovered-containers').json()[0] + + payload = api_client.post('/api/inventory/map-existing', json={'discoveredContainerId': discovered['id'], 'appId': 'widefrog'}).json() + + assert payload['success'] is True + assert payload['app']['id'] == 'widefrog' + assert payload['app']['voiceEnabled'] is False + assert payload['mapping']['appId'] == 'widefrog' + assert payload['mapping']['containerId'] == 'custom-container' + assert payload['mapping']['source'] == 'manual_review' + + mappings = api_client.get('/api/inventory/mappings').json() + assert any(mapping['appId'] == 'widefrog' and mapping['containerId'] == 'custom-container' for mapping in mappings) + assert all(mapping['appId'] != 'unknown' for mapping in mappings) + updated = api_client.get('/api/inventory/discovered-containers').json()[0] + assert updated['importStatus'] == 'managed' + assert updated['matchedAppId'] == 'widefrog' + + +def test_discovery_lists_only_latest_container_records(api_client, monkeypatch): + first = {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}]} + second = {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}]} + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + api_client.post('/api/inventory/discover') + + discovered = api_client.get('/api/inventory/discovered-containers').json() + setup = api_client.get('/api/setup/status').json() + + assert len(discovered) == 1 + assert discovered[0]['containerId'] == 'sonarr-container' + assert setup['summary']['containersDiscovered'] == 1 + + +def test_latest_inventory_lookup_uses_a_single_window_query(api_client): + from datetime import datetime + from sqlalchemy import event + from sqlmodel import Session + from backend.app.db.models import DiscoveredContainer, DiscoveryRun + from backend.app.db.session import engine + from backend.app.services.inventory_discovery import _latest_by_container, invalidate_discovery_cache + + with Session(engine) as session: + for index in range(3): + run = DiscoveryRun(id=f'window-run-{index}', status='success', started_at=datetime(2026, 7, 13, 8, index)) + session.add(run) + session.add(DiscoveredContainer( + id=f'window-row-{index}', + discovery_run_id=run.id, + container_id='window-container', + name='window-container', + image=f'window:{index}', + )) + session.commit() + invalidate_discovery_cache() + + statements: list[str] = [] + + def capture_statement(_connection, _cursor, statement, _parameters, _context, _executemany): + if statement.lstrip().upper().startswith('SELECT'): + statements.append(statement) + + event.listen(engine, 'before_cursor_execute', capture_statement) + try: + latest = _latest_by_container(session) + finally: + event.remove(engine, 'before_cursor_execute', capture_statement) + + assert latest['window-container'].image == 'window:2' + assert len(statements) == 1 + assert 'row_number() OVER' in statements[0] + + +def test_inventory_runs_are_paginated_and_bounded(api_client): + from datetime import datetime + from sqlmodel import Session + from backend.app.db.models import DiscoveryRun + from backend.app.db.session import engine + + with Session(engine) as session: + for index in range(6): + session.add(DiscoveryRun( + id=f'page-run-{index}', + status='success', + started_at=datetime(2026, 8, 21, 12, index), + )) + session.commit() + + first = api_client.get('/api/inventory/runs?limit=2&offset=0') + second = api_client.get('/api/inventory/runs?limit=2&offset=2') + invalid = api_client.get('/api/inventory/runs?limit=501') + + assert first.status_code == 200 + assert second.status_code == 200 + assert invalid.status_code == 422 + assert len(first.json()) == 2 + assert len(second.json()) == 2 + assert {item['id'] for item in first.json()}.isdisjoint({item['id'] for item in second.json()}) + + +def test_discovery_retention_removes_old_run_summaries(api_client): + from datetime import datetime + from sqlmodel import Session, select + from backend.app.db.models import DiscoveryRun + from backend.app.db.session import engine + from backend.app.services.inventory_discovery import prune_discovery_history + + with Session(engine) as session: + for index in range(8): + session.add(DiscoveryRun( + id=f'retention-run-{index}', + status='failed', + started_at=datetime(2026, 8, 21, 13, index), + )) + session.commit() + result = prune_discovery_history(session, 3) + retained = session.exec( + select(DiscoveryRun).where(DiscoveryRun.id.startswith('retention-run-')) + ).all() + + assert result['runsRemoved'] >= 5 + assert {run.id for run in retained} == {'retention-run-5', 'retention-run-6', 'retention-run-7'} + + +def test_discovery_hides_appops_deploy_artifacts_from_operator_lists(api_client, monkeypatch): + containers = [ + {'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}, + {'id': 'appops-legacy', 'name': 'unraid-appops-gateway', 'image': 'unraid-appops-gateway:legacy', 'state': 'exited', 'status': 'exited'}, + {'id': 'appops-current', 'name': 'unraid-appops-gateway-1216', 'image': 'unraid-appops-gateway:current', 'state': 'running', 'status': 'running'}, + {'id': 'appops-pre', 'name': 'unraid-appops-gateway-1216-pre-20260628T005759Z', 'image': 'unraid-appops-gateway:old', 'state': 'exited', 'status': 'exited'}, + ] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: {'status': 'ok', 'containers': containers}, raising=True) + + api_client.post('/api/inventory/discover') + + discovered = api_client.get('/api/inventory/discovered-containers').json() + inventory_containers = api_client.get('/api/inventory/containers').json() + apps = api_client.get('/api/inventory/apps?include_unmanaged=true').json() + suggestions = api_client.get('/api/inventory/suggestions').json() + run_detail = api_client.get('/api/inventory/runs').json()[0] + historical = api_client.get(f"/api/inventory/runs/{run_detail['id']}").json()['containers'] + + for collection in [discovered, inventory_containers, suggestions]: + names = {item['name'] for item in collection} + assert 'binhex-sonarr' in names + assert 'unraid-appops-gateway' not in names + assert 'unraid-appops-gateway-1216' not in names + assert 'unraid-appops-gateway-1216-pre-20260628T005759Z' not in names + + app_names = {item['name'] for item in apps} + assert 'Sonarr' in app_names + assert 'unraid-appops-gateway' not in app_names + assert 'unraid-appops-gateway-1216' not in app_names + assert 'unraid-appops-gateway-1216-pre-20260628T005759Z' not in app_names + + assert {item['name'] for item in historical} >= { + 'unraid-appops-gateway', + 'unraid-appops-gateway-1216', + 'unraid-appops-gateway-1216-pre-20260628T005759Z', + } + + +def test_inventory_review_coalesces_recreated_container_ids_by_unique_name(api_client, monkeypatch): + snapshots = [ + {'status': 'ok', 'containers': [{'id': 'sonarr-old-id', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}]}, + {'status': 'ok', 'containers': [{'id': 'sonarr-new-id', 'name': 'binhex-sonarr', 'image': 'sonarr:v2', 'state': 'running', 'status': 'running'}]}, + ] + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: snapshots.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + api_client.post('/api/inventory/discover') + + review = api_client.get('/api/inventory/discovered-containers').json() + inventory_apps = api_client.get('/api/inventory/apps?include_unmanaged=true').json() + runs = api_client.get('/api/inventory/runs').json() + latest_run = api_client.get(f"/api/inventory/runs/{runs[0]['id']}").json() + + assert len(review) == 1 + assert review[0]['containerId'] == 'sonarr-new-id' + assert review[0]['image'] == 'sonarr:v2' + assert len([item for item in inventory_apps if item['name'] == 'Sonarr' and item['id'].startswith('discovered-')]) == 1 + assert any(item['containerId'] == 'sonarr-new-id' for item in latest_run['containers']) + + +def test_discovery_reconciles_same_name_mapping_after_container_recreation(api_client, monkeypatch): + snapshots = [ + {'status': 'ok', 'containers': [{'id': 'sonarr-old-id', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}]}, + {'status': 'ok', 'containers': [{'id': 'sonarr-new-id', 'name': 'binhex-sonarr', 'image': 'sonarr:v2', 'state': 'running', 'status': 'running'}]}, + ] + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: snapshots.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + suggestion = api_client.get('/api/inventory/suggestions').json()[0] + api_client.post('/api/inventory/import', json={'discoveredContainerId': suggestion['id']}) + api_client.post('/api/inventory/review/complete', json={}) + + result = api_client.post('/api/inventory/discover').json() + mappings = api_client.get('/api/inventory/mappings').json() + setup = api_client.get('/api/setup/status').json() + audit = api_client.get('/api/audit').json() + + assert result['reconciledMappings'] == 1 + assert result['duplicateMappingsRemoved'] == 0 + assert len([item for item in mappings if item['containerName'] == 'binhex-sonarr']) == 1 + assert any(item['containerId'] == 'sonarr-new-id' and item['appId'] == 'sonarr' for item in mappings) + assert setup['summary']['appsNeedReview'] == 0 + assert any(item['action_id'] == 'inventory.mapping.reconcile' for item in audit) + + +def test_discovery_ignores_port_mount_and_network_order_changes(api_client, monkeypatch): + first = { + 'status': 'ok', + 'containers': [{ + 'id': 'sonarr-container', + 'name': 'binhex-sonarr', + 'image': 'sonarr:v1', + 'state': 'running', + 'status': 'running', + 'ports': ['8989:8989', '9898:9898'], + 'mounts': [ + {'source': '/config', 'destination': '/config', 'type': 'bind'}, + {'source': '/media', 'destination': '/media', 'type': 'bind'}, + ], + 'networks': ['bridge', 'media'], + }], + } + second = { + 'status': 'ok', + 'containers': [{ + **first['containers'][0], + 'ports': list(reversed(first['containers'][0]['ports'])), + 'mounts': list(reversed(first['containers'][0]['mounts'])), + 'networks': list(reversed(first['containers'][0]['networks'])), + }], + } + snapshots = [first, second] + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: snapshots.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + result = api_client.post('/api/inventory/discover').json() + current = api_client.get('/api/inventory/discovered-containers').json()[0] + + assert result['changedItems'] == 0 + assert current['importStatus'] == 'discovered' + + +def test_complete_inventory_review_marks_mapped_drift_reviewed(api_client, monkeypatch): + first = {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}]} + second = {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v2', 'state': 'running', 'status': 'running'}]} + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + suggestion = api_client.get('/api/inventory/suggestions').json()[0] + api_client.post('/api/inventory/import', json={'discoveredContainerId': suggestion['id']}) + api_client.post('/api/inventory/review/complete', json={}) + + api_client.post('/api/inventory/discover') + before = api_client.get('/api/setup/status').json() + changed = api_client.get('/api/inventory/discovered-containers').json()[0] + + assert changed['importStatus'] == 'changed' + assert before['summary']['appsNeedReview'] == 0 + + payload = api_client.post('/api/inventory/review/complete', json={}).json() + after = api_client.get('/api/inventory/discovered-containers').json()[0] + + assert payload['success'] is True + assert payload['driftReviewed'] == 1 + assert after['importStatus'] == 'managed' + + +def test_inventory_drift_acknowledge_and_create_incident_are_metadata_only(api_client, monkeypatch): + runs = [ + {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v1', 'state': 'running', 'status': 'running'}]}, + {'status': 'ok', 'containers': [{'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'sonarr:v2', 'state': 'running', 'status': 'running'}]}, + ] + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: runs.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + api_client.post('/api/inventory/discover') + + drift = api_client.get('/api/inventory/drift').json() + image_drift = next(item for item in drift if item['type'] == 'image_changed') + assert image_drift['driftId'].startswith('drift-') + assert image_drift['status'] == 'open' + + incident = api_client.post(f"/api/inventory/drift/{image_drift['driftId']}/incident").json() + assert incident['success'] is True + assert incident['incident']['id'] == f"incident-{image_drift['driftId']}" + + incidents = api_client.get('/api/incidents').json() + assert any(item['id'] == incident['incident']['id'] for item in incidents) + + status_update = api_client.post(f"/api/incidents/{incident['incident']['id']}/status", json={'status': 'resolved'}).json() + assert status_update['success'] is True + assert all(item['id'] != incident['incident']['id'] for item in api_client.get('/api/incidents').json()) + + acknowledged = api_client.post(f"/api/inventory/drift/{image_drift['driftId']}/acknowledge").json() + assert acknowledged['success'] is True + assert all(item['driftId'] != image_drift['driftId'] for item in api_client.get('/api/inventory/drift').json()) + + +def test_discovery_first_inventory_endpoints_show_known_and_unknown(api_client, monkeypatch): + def fake_test_connection(self): + return {'status': 'connected'} + + def fake_list_containers(self): + return { + 'status': 'ok', + 'containers': [ + {'id': 'sonarr-container', 'name': 'binhex-sonarr', 'image': 'binhex/arch-sonarr', 'state': 'running', 'status': 'running', 'ports': ['8989:8989']}, + {'id': 'custom-container', 'name': 'bespoke-worker', 'image': 'local/bespoke', 'state': 'running', 'status': 'running', 'ports': ['4567:4567']}, + ], + } + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', fake_test_connection, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', fake_list_containers, raising=True) + + api_client.post('/api/inventory/discover') + + containers = api_client.get('/api/inventory/containers').json() + apps = api_client.get('/api/inventory/apps?include_unmanaged=true').json() + app_cards = api_client.get('/api/apps?include_discovered=true').json() + unknown = api_client.get('/api/inventory/unknown').json() + unmapped = api_client.get('/api/inventory/unmapped').json() + + assert {item['containerId'] for item in containers} == {'sonarr-container', 'custom-container'} + custom_container = next(item for item in containers if item['containerId'] == 'custom-container') + assert custom_container['appType'] == 'unknown' + assert custom_container['policyProfile'] == 'generic_unknown' + assert custom_container['connectorType'] == 'none' + assert 'container.logs' in custom_container['actionsAvailable'] + assert 'container.restart' in custom_container['actionsAvailable'] + + sonarr_app = next(item for item in apps if item['primary_container'] == 'binhex-sonarr') + custom_app = next(item for item in apps if item['primary_container'] == 'bespoke-worker') + assert sonarr_app['connector_type'] == 'sonarr' + assert custom_app['app_type'] == 'unknown' + assert custom_app['connector_type'] == 'none' + assert custom_app['lifecycle_state'] == 'unmapped' + assert custom_app['source'] == 'portainer' + + assert any(item['id'].startswith('discovered-') and item['app_type'] == 'unknown' for item in app_cards) + assert [item['containerId'] for item in unknown] == ['custom-container'] + assert [item['containerId'] for item in unmapped] == ['custom-container'] + + +def test_connector_candidates_are_filtered_and_secret_safe(api_client, monkeypatch): + def fake_test_connection(self): + return {'status': 'connected'} + + def fake_list_containers(self): + return { + 'status': 'ok', + 'containers': [ + { + 'id': 'sonarr-container', + 'name': 'binhex-sonarr', + 'image': 'binhex/arch-sonarr', + 'state': 'running', + 'status': 'running', + 'ports': ['8989:8989'], + 'env': {'SONARR_API_KEY': 'super-secret'}, + }, + {'id': 'custom-container', 'name': 'bespoke-worker', 'image': 'local/bespoke', 'state': 'running', 'status': 'running'}, + ], + } + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', fake_test_connection, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', fake_list_containers, raising=True) + + api_client.post('/api/inventory/discover') + + candidates = api_client.get('/api/connectors/sonarr/candidates').json() + assert len(candidates) == 1 + assert candidates[0]['connectorId'] == 'sonarr' + assert candidates[0]['discoveredContainerId'].startswith('dc-') + assert candidates[0]['containerId'] == 'sonarr-container' + assert candidates[0]['appType'] == 'sonarr' + assert candidates[0]['suggestedAppId'] == 'sonarr' + assert candidates[0]['connectorStatus'] == 'missing_config' + assert candidates[0]['missingSecrets'] == ['SONARR_URL', 'SONARR_API_KEY'] + assert 'super-secret' not in str(candidates) + assert 'custom-container' not in str(candidates) + + +def test_connector_bind_and_unbind_keep_safe_defaults(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'radarr-container', 'name': 'binhex-radarr', 'image': 'binhex/arch-radarr', 'state': 'running', 'status': 'running', 'ports': ['7878:7878']}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + candidate = api_client.get('/api/connectors/radarr/candidates').json()[0] + + bound = api_client.post('/api/connectors/radarr/bind', json={'discoveredContainerId': candidate['discoveredContainerId']}).json() + assert bound['success'] is True + assert bound['message'] == 'connector_bound' + assert bound['app']['id'] == 'radarr' + assert bound['app']['voiceEnabled'] is False + assert bound['app']['needsReview'] is True + assert bound['binding']['connectorId'] == 'radarr' + assert bound['binding']['containerId'] == 'radarr-container' + + bindings = api_client.get('/api/connectors/radarr/bindings').json() + assert len(bindings) == 1 + assert bindings[0]['appId'] == 'radarr' + assert bindings[0]['connectorStatus'] == 'missing_config' + + from backend.app.services import inventory_discovery + + latest_calls = 0 + original_latest = inventory_discovery._latest_by_container + + def counted_latest(session): + nonlocal latest_calls + latest_calls += 1 + return original_latest(session) + + monkeypatch.setattr(inventory_discovery, '_latest_by_container', counted_latest) + api_client.get('/api/connectors/radarr/bindings') + assert latest_calls == 1 + + unbound = api_client.post('/api/connectors/radarr/unbind', json={'appId': 'radarr'}).json() + assert unbound['success'] is True + assert unbound['message'] == 'connector_unbound' + assert unbound['app']['connectorStatus'] == 'disconnected' + assert unbound['app']['voiceEnabled'] is False + assert unbound['app']['needsReview'] is True + + mappings = api_client.get('/api/inventory/mappings').json() + assert any(mapping['appId'] == 'radarr' and mapping['containerId'] == 'radarr-container' for mapping in mappings) + + +def test_discovered_synthetic_app_detail_is_ui_ready(api_client, monkeypatch): + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'x1', 'name': 'unknown-tool', 'image': 'local/tool', 'state': 'running', 'status': 'running', 'ports': ['8080:8080'], 'mounts': [{'Destination': '/config'}], 'networks': ['bridge']}]}, + raising=True, + ) + + api_client.post('/api/inventory/discover') + discovered = api_client.get('/api/inventory/unknown').json()[0] + detail = api_client.get(f"/api/apps/discovered-{discovered['id']}").json() + + assert detail['id'] == f"discovered-{discovered['id']}" + assert detail['app_type'] == 'unknown' + assert detail['lifecycle_state'] == 'unmapped' + assert detail['primary_container'] == 'unknown-tool' + assert detail['connector_type'] == 'none' + assert detail['voiceEnabled'] is False + assert 'Confirm app type' in detail['setup_checklist'] + assert detail['discovery']['containerId'] == 'x1' + + +def test_ignore_marks_container_without_deleting(api_client, monkeypatch): + def fake_test_connection(self): + return {'status': 'connected'} + + def fake_list_containers(self): + return {'status': 'ok', 'containers': [{'id': 'x1', 'name': 'unknown-tool', 'image': 'local/tool'}]} + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', fake_test_connection, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', fake_list_containers, raising=True) + + api_client.post('/api/inventory/discover') + discovered = api_client.get('/api/inventory/discovered-containers').json()[0] + response = api_client.post('/api/inventory/ignore', json={'discoveredContainerId': discovered['id']}) + + assert response.status_code == 200 + assert response.json()['success'] is True + updated = api_client.get('/api/inventory/discovered-containers').json()[0] + assert updated['importStatus'] == 'ignored' + + +def test_drift_detects_image_port_missing_and_new(api_client, monkeypatch): + from backend.app.services.inventory_discovery import run_discovery + + first = { + 'status': 'ok', + 'containers': [ + {'id': 'sonarr-container', 'name': 'sonarr', 'image': 'sonarr:v1', 'ports': ['8989:8989'], 'state': 'running'}, + {'id': 'radarr-container', 'name': 'radarr', 'image': 'radarr:v1', 'ports': ['7878:7878'], 'state': 'running'}, + ], + } + second = { + 'status': 'ok', + 'containers': [ + {'id': 'sonarr-container', 'name': 'sonarr', 'image': 'sonarr:v2', 'ports': ['8990:8989'], 'state': 'running'}, + {'id': 'prowlarr-container', 'name': 'prowlarr', 'image': 'prowlarr:v1', 'ports': ['9696:9696'], 'state': 'running'}, + ], + } + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + run_discovery() + run_discovery() + drift = api_client.get('/api/inventory/drift').json() + types = {item['type'] for item in drift} + + assert {'image_changed', 'port_changed', 'missing_container', 'new_container'}.issubset(types) + + +def test_discovery_does_not_repeat_missing_rows(api_client, monkeypatch): + from sqlmodel import Session, select + + from backend.app.db.models import DiscoveredContainer + from backend.app.db.session import engine + from backend.app.services.inventory_discovery import run_discovery + + calls = [ + {'status': 'ok', 'containers': [{'id': 'gone', 'name': 'gone-app', 'image': 'local/gone', 'state': 'running'}]}, + {'status': 'ok', 'containers': []}, + {'status': 'ok', 'containers': []}, + ] + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + run_discovery() + run_discovery() + run_discovery() + + with Session(engine) as session: + rows = session.exec(select(DiscoveredContainer).where(DiscoveredContainer.container_id == 'gone')).all() + assert len(rows) == 2 + assert sum(row.import_status == 'missing' for row in rows) == 1 + + +def test_discovery_history_retention_keeps_recent_run_details(api_client, monkeypatch): + from sqlmodel import Session, select + + from backend.app.db.models import DiscoveredContainer + from backend.app.db.session import engine + from backend.app.services.inventory_discovery import prune_discovery_history, run_discovery + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr( + 'backend.app.connectors.portainer.PortainerConnector.list_containers', + lambda self: {'status': 'ok', 'containers': [{'id': 'stable', 'name': 'stable-app', 'image': 'local/stable', 'state': 'running'}]}, + raising=True, + ) + + run_discovery() + run_discovery() + run_discovery() + + with Session(engine) as session: + result = prune_discovery_history(session, keep_runs=2) + rows = session.exec(select(DiscoveredContainer).where(DiscoveredContainer.container_id == 'stable')).all() + assert result['removed'] == 1 + assert result['retainedRuns'] == 2 + assert len(rows) == 2 + + +def test_inventory_baseline_is_persisted_audited_and_drives_drift(api_client, monkeypatch): + runs = [ + { + 'status': 'ok', + 'containers': [ + {'id': 'sonarr-container', 'name': 'sonarr', 'image': 'sonarr:v1', 'ports': ['8989:8989'], 'state': 'running', 'status': 'running'}, + {'id': 'radarr-container', 'name': 'radarr', 'image': 'radarr:v1', 'ports': ['7878:7878'], 'state': 'running', 'status': 'running'}, + ], + }, + { + 'status': 'ok', + 'containers': [ + {'id': 'sonarr-container', 'name': 'sonarr', 'image': 'sonarr:v2', 'ports': ['8990:8989'], 'state': 'running', 'status': 'running'}, + {'id': 'prowlarr-container', 'name': 'prowlarr', 'image': 'prowlarr:v1', 'ports': ['9696:9696'], 'state': 'running', 'status': 'running'}, + ], + }, + ] + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: runs.pop(0), raising=True) + + api_client.post('/api/inventory/discover') + baseline = api_client.post('/api/inventory/baseline/update').json() + + assert baseline['success'] is True + assert baseline['data']['containerCount'] == 2 + assert baseline['auditId'].startswith('audit-') + assert api_client.get('/api/inventory/drift').json() == [] + + api_client.post('/api/inventory/discover') + drift = api_client.get('/api/inventory/drift').json() + assert {'image_changed', 'port_changed', 'missing_container', 'new_container'}.issubset({item['type'] for item in drift}) + + refreshed = api_client.post('/api/inventory/baseline/update').json() + assert refreshed['success'] is True + assert refreshed['data']['containerCount'] == 2 + assert api_client.get('/api/inventory/drift').json() == [] + + audit = api_client.get('/api/audit').json() + assert any(item['action_id'] == 'inventory.baseline.update' for item in audit) + + +def test_inventory_baseline_requires_live_inventory(api_client): + result = api_client.post('/api/inventory/baseline/update').json() + + assert result['success'] is False + assert result['status'] == 'failed' + assert 'discovery' in result['message'].lower() + + +def test_drift_ignores_port_order_only_changes(api_client, monkeypatch): + from backend.app.services.inventory_discovery import run_discovery + + first = { + 'status': 'ok', + 'containers': [ + {'id': 'jdownloader', 'name': 'JDownloader2', 'image': 'jdownloader:v1', 'ports': ['7807:5800', '7907:5900'], 'state': 'running'}, + ], + } + second = { + 'status': 'ok', + 'containers': [ + {'id': 'jdownloader', 'name': 'JDownloader2', 'image': 'jdownloader:v1', 'ports': ['7907:5900', '7807:5800'], 'state': 'running'}, + ], + } + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + run_discovery() + run_discovery() + + drift = api_client.get('/api/inventory/drift').json() + + assert not any(item['type'] == 'port_changed' for item in drift) + + +def test_drift_suppresses_recreated_container_missing_new_pair(api_client, monkeypatch): + from backend.app.services.inventory_discovery import run_discovery + + first = { + 'status': 'ok', + 'containers': [ + {'id': 'infrabinder-old', 'name': 'infrabinder-local', 'image': 'infrabinder-local:latest', 'ports': ['1215:1215'], 'state': 'running'}, + ], + } + second = { + 'status': 'ok', + 'containers': [ + {'id': 'infrabinder-new', 'name': 'infrabinder-local', 'image': 'infrabinder-local:latest', 'ports': ['1215:1215'], 'state': 'running'}, + ], + } + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + run_discovery() + run_discovery() + + drift = api_client.get('/api/inventory/drift').json() + + assert not any(item['type'] in {'missing_container', 'new_container'} for item in drift) + + +def test_drift_ignores_upgrade_rollback_containers(api_client, monkeypatch): + from backend.app.services.inventory_discovery import run_discovery + + first = { + 'status': 'ok', + 'containers': [ + {'id': 'appops-rollback', 'name': 'unraid-appops-gateway-1216-pre-20260627T040601Z', 'image': 'appops:old', 'ports': ['1216:1216'], 'state': 'running', 'status': 'healthy'}, + ], + } + second = { + 'status': 'ok', + 'containers': [ + {'id': 'appops-rollback', 'name': 'unraid-appops-gateway-1216-pre-20260627T040601Z', 'image': 'appops:old', 'ports': [], 'state': 'exited', 'status': 'unhealthy'}, + ], + } + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + run_discovery() + run_discovery() + + drift = api_client.get('/api/inventory/drift').json() + + assert drift == [] + + +def test_drift_ignores_canonical_appops_container_redeploy(api_client, monkeypatch): + from backend.app.services.inventory_discovery import run_discovery + + first = { + 'status': 'ok', + 'containers': [ + {'id': 'appops-old', 'name': 'unraid-appops-gateway-1216', 'image': 'appops:old', 'ports': ['1216:1216'], 'state': 'running'}, + ], + } + second = { + 'status': 'ok', + 'containers': [ + {'id': 'appops-new', 'name': 'unraid-appops-gateway-1216', 'image': 'appops:new', 'ports': ['1216:1216'], 'state': 'running'}, + ], + } + calls = [first, second] + + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.test_connection', lambda self: {'status': 'connected'}, raising=True) + monkeypatch.setattr('backend.app.connectors.portainer.PortainerConnector.list_containers', lambda self: calls.pop(0), raising=True) + + run_discovery() + run_discovery() + + drift = api_client.get('/api/inventory/drift').json() + + assert drift == [] diff --git a/backend/tests/test_live_observability.py b/backend/tests/test_live_observability.py new file mode 100644 index 0000000..3057289 --- /dev/null +++ b/backend/tests/test_live_observability.py @@ -0,0 +1,164 @@ +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "appops_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.main import app + from backend.app.db import session + from backend.app.db.init_db import init_db + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def test_statistics_summary_uses_live_audit_job_and_unraid_data(api_client, monkeypatch): + from backend.app.connectors.unraid import UnraidConnector + from backend.app.api import routes_observability + + monkeypatch.setattr( + UnraidConnector, + "get_system_metrics", + lambda self: {"status": "ok", "cpuUsage": 37, "ramUsage": 64}, + raising=True, + ) + routes_observability._STATISTICS_HISTORY.clear() + + now = datetime.now(UTC).replace(tzinfo=None) + + api_client.post( + "/api/actions/execute", + json={ + "intent": "volume.delete", + "target": "postgres-data", + "source": "manual", + "risk": "critical", + "plan": [], + }, + ) + api_client.post( + "/api/actions/execute", + json={ + "intent": "container.restart", + "target": "portainer", + "source": "voice", + "risk": "high", + "plan": [], + }, + ) + + response = api_client.get("/api/statistics/summary") + + assert response.status_code == 200 + payload = response.json() + assert payload["cpuHistory"][-1]["value"] == 37 + assert payload["ramHistory"][-1]["value"] == 64 + assert payload["blockedActions"] >= 1 + assert payload["voiceActionCount"] >= 1 + assert all(point["time"] != "00:00" for point in payload["cpuHistory"]) + assert payload["generatedAt"] >= (now - timedelta(minutes=1)).isoformat() + + +def test_statistics_summary_keeps_partial_unraid_metrics_explicit(api_client, monkeypatch): + from backend.app.connectors.unraid import UnraidConnector + from backend.app.api import routes_observability + + monkeypatch.setattr( + UnraidConnector, + "get_system_metrics", + lambda self: { + "status": "partial", + "cpuUsage": None, + "ramUsage": 92, + "reason": "cpu_unavailable", + }, + raising=True, + ) + routes_observability._STATISTICS_HISTORY.clear() + + response = api_client.get("/api/statistics/summary") + + assert response.status_code == 200 + payload = response.json() + assert payload["sources"]["unraid"] == "partial" + assert payload["cpuHistory"][-1]["value"] is None + assert payload["ramHistory"][-1]["value"] == 92 + assert payload["sourceWarnings"][0]["source"] == "unraid" + assert payload["sourceWarnings"][0]["surface"] == "metrics" + + +def test_storage_summary_exposes_cache_pool_inventory(api_client, monkeypatch): + from backend.app.connectors.portainer import PortainerConnector + from backend.app.connectors.unraid import UnraidConnector + + monkeypatch.setattr( + UnraidConnector, + "get_cache_status", + lambda self: { + "status": "ok", + "cacheUsage": 88, + "pools": [ + { + "name": "cache", + "status": "DISK_OK", + "healthy": True, + "type": "CACHE", + "usedGiB": 88, + "sizeGiB": 100, + "freeGiB": 12, + "usagePercent": 88, + } + ], + }, + raising=True, + ) + monkeypatch.setattr( + UnraidConnector, + "get_share_summary", + lambda self: {"status": "ok", "shares": [{"name": "appdata", "usedGiB": 88, "sizeGiB": 100, "critical": True}]}, + raising=True, + ) + monkeypatch.setattr( + UnraidConnector, + "get_disk_warnings", + lambda self: { + "status": "ok", + "disks": [{"name": "disk1", "status": "DISK_OK", "healthy": True, "type": "DATA", "usedGiB": 50, "sizeGiB": 100, "freeGiB": 50}], + "diskWarnings": 0, + }, + raising=True, + ) + monkeypatch.setattr(PortainerConnector, "list_containers", lambda self: {"status": "ok", "containers": []}, raising=True) + + response = api_client.get("/api/storage/summary") + + assert response.status_code == 200 + payload = response.json() + assert payload["cacheUsage"] == 88 + assert payload["storagePools"] == [ + { + "name": "cache", + "status": "DISK_OK", + "healthy": True, + "type": "CACHE", + "usedGiB": 88, + "sizeGiB": 100, + "freeGiB": 12, + "usagePercent": 88, + } + ] + assert payload["disks"][0]["name"] == "disk1" diff --git a/backend/tests/test_managed_validation_checkout.py b/backend/tests/test_managed_validation_checkout.py new file mode 100644 index 0000000..2fae45a --- /dev/null +++ b/backend/tests/test_managed_validation_checkout.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".gitea" / "workflows" / "managed-validation.yml" + + +def test_managed_validation_uses_internal_gitea_checkout_path() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" in workflow + assert "github-server-url: http://gitea-runner-internal:3000" in workflow diff --git a/backend/tests/test_managed_validation_parallel.py b/backend/tests/test_managed_validation_parallel.py new file mode 100644 index 0000000..1145dbb --- /dev/null +++ b/backend/tests/test_managed_validation_parallel.py @@ -0,0 +1,18 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".gitea" / "workflows" / "managed-validation.yml" + + +def test_managed_validation_keeps_parallel_lanes_and_full_gate(): + text = WORKFLOW.read_text(encoding="utf-8") + for job in ("preflight:", "backend:", "frontend:", "security:", "polyglot:", "full:"): + assert f" {job}" in text + assert "needs: [preflight, backend, frontend, security, polyglot]" in text + assert "All managed validation lanes passed." in text + + +def test_preflight_preserves_docs_only_fast_path(): + text = WORKFLOW.read_text(encoding="utf-8") + assert "runtime_change" in text + assert "*.md|*.mdx|docs/*" in text diff --git a/backend/tests/test_mcp_hub_deployment_reconciliation.py b/backend/tests/test_mcp_hub_deployment_reconciliation.py new file mode 100644 index 0000000..1f6dc9f --- /dev/null +++ b/backend/tests/test_mcp_hub_deployment_reconciliation.py @@ -0,0 +1,282 @@ +from types import SimpleNamespace + +from backend.app.services.mcp_hub_deployment_reconciliation import ( + McpHubDeploymentReconciliation, +) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakePortainer: + def __init__(self, exit_code=0): + self.exit_code = exit_code + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": { + "id": "controller-id", + "name": "gitea-deploy-control", + "state": "running", + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-1"}) + if path.endswith("/exec/exec-1/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-1/json"): + return FakeResponse({"Running": False, "ExitCode": self.exit_code}) + raise AssertionError(path) + + +def test_reconciliation_is_fixed_to_mcp_hub_and_reconciles_only_verified_failed_forward_state(): + fake = FakePortainer() + result = McpHubDeploymentReconciliation(fake).run() + + assert result == { + "status": "reconciled", + "repository": "jens/itworx_mcp_hub", + "composeProject": "itworx-mcp-hub", + } + + create = fake.calls[0] + command = create[2]["json"]["Cmd"] + assert command[:2] == ["python3", "-c"] + assert command[3:] == ["jens/itworx_mcp_hub", "itworx-mcp-hub"] + assert create[2]["json"]["AttachStdout"] is False + assert create[2]["json"]["AttachStderr"] is False + + script = command[2] + assert "/opt/gitea-deploy/projects.json" in script + assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script + assert "os.path.samefile(registry_path, host_registry_path)" in script + assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script + assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script + assert "/mnt/cache/appdata/gitea-deploy-runners/deploy.lock" in script + assert 'project.get("branch") != "main"' in script + assert 'project.get("compose_project") != expected_compose_project' in script + assert 'recorded_status != "failed"' in script + assert 'recorded_live_status not in {"healthy", "unknown"}' in script + assert 'attempt_status != "failed"' in script + assert 'attempt_revision == recorded_live' in script + assert 'git_run("merge-base", "--is-ancestor", recorded_live, attempt_revision)' in script + assert 'git_run("merge-base", "--is-ancestor", attempt_revision, main_revision)' in script + + +def test_unknown_live_status_is_accepted_only_for_a_proven_snapshot_stage_failure_without_rollback_error(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert 'last_attempt_message = str(last_attempt.get("message") or "")' in script + assert "snapshot_failure_prefixes = (" in script + assert 'recorded_live_status == "unknown"' in script + assert 'last_attempt_message.startswith(snapshot_failure_prefixes)' in script + assert '"; rollback error:" in last_attempt_message' in script + assert '"; source rollback error:" in last_attempt_message' in script + assert "raise SystemExit(55)" in script + + +def test_reconciliation_proves_complete_live_compose_topology_and_preserves_actual_image_ids(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert 'expected_containers = project.get("containers")' in script + assert "set(project_names) != set(expected_containers)" in script + assert 'labels.get("com.docker.compose.project") != expected_compose_project' in script + assert 'not runtime_state.get("Running")' in script + assert 'health in {"starting", "unhealthy"}' in script + assert 'image_id = str(container.get("Image") or "")' in script + assert 'artifacts = {"containers": container_artifacts, "compose_sha256": ""}' in script + assert '"live_legacy_provenance": True' in script + assert '"live_artifacts": artifacts' in script + assert '"live_revision": recorded_live' in script + + +def test_reconciliation_recognizes_only_the_fixed_mcp_hub_oci_source_as_owned(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert 'owned_image_source = "https://gitea.example.com/jens/itworx-mcp-hub"' in script + assert "if image_source == owned_image_source:" in script + assert 'git_run("merge-base", "--is-ancestor", recorded_live, image_revision)' in script + assert 'git_run("merge-base", "--is-ancestor", image_revision, main_revision)' in script + assert 'image_source.startswith("https://gitea.example.com/")' not in script + assert 'image_source == repository' not in script + assert '"live_revision": main_revision' not in script + assert '"live_revision": attempt_revision' not in script + + +def test_owned_image_ancestry_can_fail_closed_to_the_canonical_gitea_store(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert 'canonical_repository_root = Path("/mnt/cache/appdata/gitea/git/repositories")' in script + assert 'canonical_bare_repo = canonical_repository_root / owner / f"{name}.git"' in script + assert "def canonical_git_run(*args):" in script + assert "def canonical_image_ancestry_is_proven(" in script + assert 'resolved_recorded = canonical_git_run("rev-parse", f"{recorded_revision}^{{commit}}")' in script + assert 'resolved_image = canonical_git_run("rev-parse", f"{image_revision}^{{commit}}")' in script + assert 'resolved_main = canonical_git_run("rev-parse", "refs/heads/main")' in script + assert 'resolved_main.stdout.strip() == expected_main' in script + assert '"merge-base", "--is-ancestor", recorded_revision, image_revision' in script + assert '"merge-base", "--is-ancestor", image_revision, expected_main' in script + assert "if not mounted_ancestry_proven and not canonical_image_ancestry_is_proven(" in script + assert "canonical_bare_repo.is_symlink()" in script + + +def test_unchanged_recorded_image_ids_do_not_reinterpret_historical_oci_revision_labels(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + change_probe = "image_changed = image_id != recorded_image_id" + provenance_gate = "if image_changed:" + compat_probe = "contextforge_compat_image = main_declares_contextforge_compat_image(name, image_ref)" + dataplane_probe = "main_declares_sessionless_dataplane_image(name, image_ref)" + owned_probe = "elif image_source == owned_image_source:" + external_probe = "elif not main_declares_external_image(image_ref):" + + assert change_probe in script + assert provenance_gate in script + assert compat_probe in script + assert dataplane_probe in script + assert "not already_reconciled" in script + assert owned_probe in script + assert external_probe in script + assert script.index(change_probe) < script.index(provenance_gate) + assert script.index(provenance_gate) < script.index(compat_probe) + assert script.index(compat_probe) < script.index(dataplane_probe) + assert script.index(dataplane_probe) < script.index(owned_probe) + assert script.index(owned_probe) < script.index(external_probe) + assert "if image_id != recorded_image_id or container_id != recorded_container_id:" in script + + +def test_external_image_drift_requires_an_immutable_digest_declared_in_reviewed_main_compose(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert "def main_declares_external_image(image_ref):" in script + assert "@sha256:" in script + assert 'git_run("show", f"{main_revision}:{compose_path}")' in script + assert '"infrastructure/compose/docker-compose.yml"' in script + assert '"infrastructure/compose/docker-compose.prod.yml"' in script + assert 'line.strip() in {f"image: {image_ref}", f\'image: "{image_ref}"\', f"image: \'{image_ref}\'"}' in script + assert "main_declares_external_image(image_ref)" in script + assert "elif not main_declares_external_image(image_ref):" in script + + +def test_contextforge_compat_image_drift_requires_exact_reviewed_main_contract(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert 'container_name != "itworx-mcp-hub-contextforge-1"' in script + assert 'f"{main_revision}:infrastructure/images/contextforge/compat-image.env"' in script + assert 'f"{main_revision}:scripts/build_contextforge_compat_image.sh"' in script + assert '"CONTEXTFORGE_UPSTREAM_COMMIT"' in script + assert '"CONTEXTFORGE_ALEMBIC_HEAD"' in script + assert '"CONTEXTFORGE_MCP_SDK"' in script + assert '"CONTEXTFORGE_LOCAL_IMAGE"' in script + assert 're.fullmatch(r"[0-9a-f]{40}", upstream_commit)' in script + assert 're.fullmatch(r"[0-9a-f]{12}", alembic_head)' in script + assert 'local_image != f"itworx-mcp-hub/contextforge-schema:{alembic_head}"' in script + assert 'git -C "$SRC" fetch --depth 1 origin "$CONTEXTFORGE_UPSTREAM_COMMIT"' in script + assert 'git -C "$SRC" checkout --detach FETCH_HEAD' in script + assert 'MCP_REQUIREMENT="mcp==${CONTEXTFORGE_MCP_SDK}"' in script + assert 'alembic -c mcpgateway/alembic.ini heads' in script + assert 'docker tag "$STAGED_IMAGE" "$CONTEXTFORGE_LOCAL_IMAGE"' in script + assert 'image_ref.startswith("itworx-mcp-hub/contextforge-schema:")' not in script + + + compat_probe = "contextforge_compat_image = main_declares_contextforge_compat_image(name, image_ref)" + owned_probe = "elif image_source == owned_image_source:" + assert compat_probe in script + assert "if contextforge_compat_image or sessionless_dataplane_image:" in script + assert owned_probe in script + assert script.index(compat_probe) < script.index(owned_probe) + + +def test_sessionless_dataplane_baseline_is_one_time_and_commit_bound(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert 'container_name != "itworx-mcp-hub-dataplane-router-1"' in script + assert 'image_ref != "itworx-mcp-hub/dataplane:sessionless-final"' in script + assert 'sessionless_commit = "b519a8c4cf17cd9df00a575acfe096838aab7789"' in script + assert 'f"{sessionless_commit}:services/dataplane_router/router.py"' in script + assert 'f"{sessionless_commit}:tests/test_dataplane_session_headers.py"' in script + assert 'f"{main_revision}:evidence/mcp-session-boundary-20260916/SUMMARY.md"' in script + assert 'MCP_TRANSPORT_STATE_HEADERS = frozenset({b"mcp-session-id"})' in script + assert 'del response.headers["mcp-session-id"]' in script + assert '"2025-11-25"' in script + assert '"2026-07-28"' in script + assert '"permanently enforce stateless public transport"' in script + assert ( + 'git_run("merge-base", "--is-ancestor", sessionless_commit, main_revision)' + in script + ) + assert "not already_reconciled" in script + + +def test_already_reconciled_state_is_reverified_before_idempotent_success(): + fake = FakePortainer() + McpHubDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert "already_reconciled = (" in script + assert script.index("already_reconciled = (") < script.index("project_names =") + assert script.index("container_artifacts = {}") < script.index("if already_reconciled:") + assert 'print("already-reconciled", flush=True)' in script + assert "if drift_found:" in script + + +def test_reconciliation_refuses_unknown_runtime_topology(): + result = McpHubDeploymentReconciliation(FakePortainer(exit_code=47)).run() + assert result == { + "status": "validation_failed", + "reason": "live MCP Hub Compose project differs from the controller target", + } + + +def test_reconciliation_refuses_unproven_runtime_revision_drift(): + result = McpHubDeploymentReconciliation(FakePortainer(exit_code=51)).run() + assert result == { + "status": "validation_failed", + "reason": "live MCP Hub image revision or external immutable image is not proven by the recorded-live to main chain", + } + + +def test_reconciliation_refuses_state_that_is_not_the_known_failed_forward_shape(): + result = McpHubDeploymentReconciliation(FakePortainer(exit_code=52)).run() + assert result == { + "status": "validation_failed", + "reason": "controller state drift is not the exact known recoverable MCP Hub state", + } + + +def test_reconciliation_refuses_unknown_state_without_snapshot_stage_proof(): + result = McpHubDeploymentReconciliation(FakePortainer(exit_code=55)).run() + assert result == { + "status": "validation_failed", + "reason": "unknown MCP Hub live status was not produced by a proven recoverable snapshot-stage failure", + } diff --git a/backend/tests/test_mcp_hub_policy_reconciliation.py b/backend/tests/test_mcp_hub_policy_reconciliation.py new file mode 100644 index 0000000..95eb08d --- /dev/null +++ b/backend/tests/test_mcp_hub_policy_reconciliation.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.db.models import Policy +from backend.app.services.discovery_scheduler import ( + _reconcile_mcp_hub_exact_policies, +) + + +def _session(tmp_path) -> Session: + engine = create_engine(f"sqlite:///{tmp_path / 'mcp-hub-policies.db'}") + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def test_reconciliation_creates_only_exact_apply_and_rollback_policies(tmp_path): + with _session(tmp_path) as session: + result = _reconcile_mcp_hub_exact_policies(session) + rows = session.exec( + select(Policy).where(Policy.target == "itworx-mcp-hub") + ).all() + + assert result["status"] == "ready" + assert result["created"] == 2 + assert {row.action for row in rows} == { + "deployment.apply", + "deployment.rollback", + } + assert all(row.manual == "allowed" for row in rows) + assert all(row.voice == "blocked" for row in rows) + assert all( + row.provenance_json.get("createdBy") == "mcp-hub-state-reconciliation/v1" + for row in rows + ) + + +def test_reconciliation_is_idempotent(tmp_path): + with _session(tmp_path) as session: + first = _reconcile_mcp_hub_exact_policies(session) + second = _reconcile_mcp_hub_exact_policies(session) + rows = session.exec( + select(Policy).where(Policy.target == "itworx-mcp-hub") + ).all() + + assert first["created"] == 2 + assert second["status"] == "ready" + assert second["created"] == 0 + assert len(rows) == 2 + + +def test_reconciliation_never_overwrites_blocked_exact_policy(tmp_path): + with _session(tmp_path) as session: + session.add( + Policy( + id="existing-block", + action="deployment.apply", + target="itworx-mcp-hub", + voice="blocked", + manual="blocked", + risk="high", + reason="Explicit local block.", + ) + ) + session.commit() + + result = _reconcile_mcp_hub_exact_policies(session) + rows = session.exec( + select(Policy).where(Policy.target == "itworx-mcp-hub") + ).all() + + assert result == { + "status": "conflict", + "action": "deployment.apply", + "created": 0, + "policyIds": [], + } + assert len(rows) == 1 + assert rows[0].manual == "blocked" + +def test_reconciliation_refuses_weakened_allowed_exact_policy(tmp_path): + with _session(tmp_path) as session: + session.add( + Policy( + id="existing-weak", + action="deployment.apply", + target="itworx-mcp-hub", + voice="allowed", + manual="allowed", + risk="medium", + reason="Unsafe test policy.", + ) + ) + session.commit() + + result = _reconcile_mcp_hub_exact_policies(session) + rows = session.exec( + select(Policy).where(Policy.target == "itworx-mcp-hub") + ).all() + + assert result == { + "status": "conflict", + "action": "deployment.apply", + "created": 0, + "policyIds": [], + } + assert len(rows) == 1 + assert rows[0].voice == "allowed" + assert rows[0].risk == "medium" + +def test_reconciliation_refuses_duplicate_exact_policies(tmp_path): + with _session(tmp_path) as session: + for policy_id in ("duplicate-a", "duplicate-b"): + session.add( + Policy( + id=policy_id, + action="deployment.rollback", + target="itworx-mcp-hub", + voice="blocked", + manual="allowed", + risk="high", + reason="Duplicate exact policy.", + ) + ) + session.commit() + + result = _reconcile_mcp_hub_exact_policies(session) + rows = session.exec( + select(Policy).where( + (Policy.action == "deployment.rollback") + & (Policy.target == "itworx-mcp-hub") + ) + ).all() + + assert result == { + "status": "conflict", + "action": "deployment.rollback", + "created": 0, + "policyIds": [], + } + assert len(rows) == 2 + +def test_reconciliation_refuses_deterministic_policy_id_collision(tmp_path): + with _session(tmp_path) as session: + session.add( + Policy( + id="mcp-hub-reconciled-apply", + action="container.restart", + target="unrelated-container", + voice="blocked", + manual="allowed", + risk="medium", + reason="Unrelated existing policy ID.", + ) + ) + session.commit() + + result = _reconcile_mcp_hub_exact_policies(session) + + assert result == { + "status": "conflict", + "action": "deployment.apply", + "created": 0, + "policyIds": [], + } diff --git a/backend/tests/test_mcp_rag_n8n_integrations.py b/backend/tests/test_mcp_rag_n8n_integrations.py new file mode 100644 index 0000000..271e5ca --- /dev/null +++ b/backend/tests/test_mcp_rag_n8n_integrations.py @@ -0,0 +1,1264 @@ +from __future__ import annotations + +import asyncio +import hashlib +import json +from concurrent.futures import ThreadPoolExecutor + +import pytest +from fastapi.testclient import TestClient +from mcp import Client +from sqlmodel import Session, SQLModel, create_engine, select + +MCP_TOKEN = "mcp-test-token-that-is-long-and-distinct" +N8N_TOKEN = "n8n-test-token-that-is-long-and-distinct" +OPERATOR_TOKEN = "operator-test-token-that-is-long-enough" + + +@pytest.fixture +def integration_client(tmp_path, monkeypatch): + from backend.app import main as app_main + from backend.app import mcp_server + from backend.app.core import security + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + + app = app_main.app + + engine = create_engine( + f"sqlite:///{tmp_path / 'integrations.db'}", + connect_args={"check_same_thread": False}, + ) + previous_engine = db_session.engine + previous_mcp_engine = mcp_server.engine + previous = { + "mcp_enabled": settings.mcp_enabled, + "mcp_token": settings.mcp_token, + "n8n_enabled": settings.n8n_enabled, + "n8n_token": settings.n8n_token, + "operator_token": settings.operator_token, + "operator_auth_required": settings.operator_auth_required, + "environment": settings.environment, + "mutation_rate_limit_per_minute": settings.mutation_rate_limit_per_minute, + } + db_session.engine = engine + mcp_server.engine = engine + # The HTTP session manager is intentionally single-use. Start TestClient with + # MCP disabled, then enable the auth boundary for request-level assertions. + settings.mcp_enabled = False + settings.mcp_token = MCP_TOKEN + settings.n8n_enabled = True + settings.n8n_token = N8N_TOKEN + settings.operator_token = OPERATOR_TOKEN + settings.operator_auth_required = True + settings.environment = "production" + settings.mutation_rate_limit_per_minute = 10 + security.settings = settings + app_main._MUTATION_WINDOWS.clear() + + SQLModel.metadata.create_all(engine) + init_db() + with TestClient(app) as client: + settings.mcp_enabled = True + yield client, engine + + db_session.engine = previous_engine + mcp_server.engine = previous_mcp_engine + for key, value in previous.items(): + setattr(settings, key, value) + app_main._MUTATION_WINDOWS.clear() + + +def test_integrations_use_separate_auth_scopes(integration_client): + client, _ = integration_client + + assert client.get("/api/integrations/status").status_code == 401 + assert ( + client.get( + "/api/integrations/status", + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, + ).status_code + == 200 + ) + assert client.get("/api/integrations/n8n/events").status_code == 401 + assert ( + client.get( + "/api/integrations/n8n/events", + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, + ).status_code + == 403 + ) + assert ( + client.get( + "/api/integrations/n8n/events", + headers={"Authorization": f"Bearer {N8N_TOKEN}"}, + ).status_code + == 200 + ) + assert client.post("/mcp/").status_code == 401 + + +def test_deployment_http_surface_requires_operator_and_rejects_raw_fields(integration_client): + client, _ = integration_client + assert client.get("/api/deployments").status_code == 401 + response = client.post( + "/api/deployments", + headers={ + "Authorization": f"Bearer {OPERATOR_TOKEN}", + "Idempotency-Key": "deployment-schema-test-0001", + }, + json={ + "operation": "preflight", + "plan": { + "api_version": "itworx.deployment/v1", + "application_id": "wsbc-portal", + "repository": "jens/windows-support-bundle-collector", + "revision": "a" * 40, + "release_version": "0.23.0", + "image_repository": "wsbc-portal", + "image_tag": "0.23.0", + "topology": { + "compose_project": "wsbc-portal", + "containers": ["wsbc-portal"], + "proxy_network": "itworx-npm", + "internal_port": 8080, + }, + "broker_secret_ref": "WSBC_PORTAL_DEPLOYMENT_BROKER", + "command": "docker compose up", + }, + }, + ) + assert response.status_code == 422 + assert "command" in response.text + + +def test_n8n_preflight_approval_idempotency_and_no_execute(integration_client): + client, _ = integration_client + headers = {"Authorization": f"Bearer {N8N_TOKEN}"} + + preview = client.post( + "/api/integrations/n8n/preflight", + headers=headers, + json={"action": "stack.redeploy", "target": "appops"}, + ) + assert preview.status_code == 200 + assert preview.json()["policyDecision"] == "approval_required" + assert preview.json()["executionAvailable"] is False + + approval_headers = {**headers, "Idempotency-Key": "workflow-run-00000001"} + first = client.post( + "/api/integrations/n8n/approvals", + headers=approval_headers, + json={ + "action": "stack.redeploy", + "target": "appops", + "reason": "Gecontroleerde workflowtest", + "plan": ["preflight", "menselijke beoordeling"], + }, + ) + second = client.post( + "/api/integrations/n8n/approvals", + headers=approval_headers, + json={ + "action": "stack.redeploy", + "target": "appops", + "reason": "Gecontroleerde workflowtest", + "plan": ["preflight", "menselijke beoordeling"], + }, + ) + assert first.status_code == 200 + assert first.json()["created"] is True + assert first.json()["idempotentReplay"] is False + assert second.status_code == 200 + assert second.json()["approvalId"] == first.json()["approvalId"] + assert second.json()["idempotentReplay"] is True + + status = client.get( + f"/api/integrations/n8n/approvals/{first.json()['approvalId']}", + headers=headers, + ) + assert status.status_code == 200 + assert status.json()["status"] == "pending" + assert client.post( + "/api/integrations/n8n/execute", + headers=headers, + json={"approvalId": first.json()["approvalId"]}, + ).status_code in {404, 405} + + blocked = client.post( + "/api/integrations/n8n/approvals", + headers={**headers, "Idempotency-Key": "workflow-run-00000002"}, + json={ + "action": "volume.delete", + "target": "important-data", + "reason": "Moet geblokkeerd blijven", + "plan": [], + }, + ) + assert blocked.status_code == 403 + + +def test_n8n_uses_the_shared_grounded_knowledge_service(integration_client): + client, _ = integration_client + response = client.post( + "/api/integrations/n8n/knowledge/query", + headers={"Authorization": f"Bearer {N8N_TOKEN}"}, + json={"question": "Welke acties vereisen menselijke goedkeuring?", "limit": 4}, + ) + + assert response.status_code == 200 + assert response.json()["guardrails"]["executionViaPolicyOnly"] is True + assert response.json()["guardrails"]["secretsExcluded"] is True + + evaluation = client.get( + "/api/ai/rag/evaluate", + headers={"X-AppOps-Operator-Token": OPERATOR_TOKEN}, + ) + assert evaluation.status_code == 200 + assert evaluation.json()["total"] == 5 + assert evaluation.json()["status"] == "passed" + assert evaluation.json()["passed"] == 5 + assert ( + evaluation.json()["vectorSpace"] == "normalized_binary_word_bigram_char_trigram" + ) + + +def test_local_rag_indexes_allowlisted_docs_and_redacts_secrets(tmp_path): + from backend.app.services import knowledge_service + + root = tmp_path / "knowledge" + docs = root / "docs" + docs.mkdir(parents=True) + (root / "README.md").write_text( + "# AppOps password=TOPSECRET-HEADING\n\nStack redeploy vereist menselijke goedkeuring. api_key=TOPSECRET-RAG-TOKEN", + encoding="utf-8", + ) + (docs / "private_deployment_security.md").write_text( + "# Private\n\nNEVER-INDEX-THIS-SECRET", + encoding="utf-8", + ) + engine = create_engine(f"sqlite:///{tmp_path / 'rag.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + status = knowledge_service.rebuild_index(session, root) + result = knowledge_service.query( + session, "Wanneer vereist stack redeploy goedkeuring?", root=root + ) + + assert status["sources"] == 1 + assert status["redactedMatches"] == 2 + assert status["secretScanStatus"] == "passed_with_redactions" + assert result["citations"] + serialized = str(result) + assert "NEVER-INDEX-THIS-SECRET" not in serialized + assert "TOPSECRET-RAG-TOKEN" not in serialized + assert "TOPSECRET-HEADING" not in serialized + assert "[REDACTED]" in serialized + assert result["guardrails"]["executionViaPolicyOnly"] is True + assert result["retrieval"] == { + "engine": "local_hybrid_lexical_semantic", + "vectorSpace": "normalized_binary_word_bigram_char_trigram", + "externalEmbeddingProvider": False, + } + + +def test_local_rag_rebuilds_when_document_manifest_changes(tmp_path): + from backend.app.services import knowledge_service + + root = tmp_path / "knowledge-refresh" + root.mkdir() + readme = root / "README.md" + readme.write_text( + "# Eerste versie\n\nMenselijke approval blijft verplicht.", encoding="utf-8" + ) + engine = create_engine(f"sqlite:///{tmp_path / 'rag-refresh.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + first = knowledge_service.ensure_index_current(session, root) + readme.write_text( + "# Tweede versie\n\nPolicy preflight en menselijke approval blijven verplicht.", + encoding="utf-8", + ) + second = knowledge_service.ensure_index_current(session, root) + + assert first["manifestHash"] != second["manifestHash"] + assert second["status"] == "ready" + + +def test_mcp_exposes_governed_server_operations_and_policy_preview(integration_client): + _, engine = integration_client + from backend.app import mcp_server + from backend.app.core.version import APP_VERSION + + mcp_server.engine = engine + assert mcp_server.appops_mcp.version == APP_VERSION + + async def exercise_mcp(): + async with Client(mcp_server.appops_mcp) as client: + tools = await client.list_tools() + names = {tool.name for tool in tools.tools} + assert "query_knowledge" in names + assert "preview_action" in names + assert "server.operation.request" in names + assert "server.operation.status" in names + assert "server.operation.execute" in names + assert "server.job.status" in names + assert "server.job.cancel" in names + assert "deployment.preflight" in names + assert "deployment.apply" in names + assert "deployment.status" in names + assert "deployment.rollback" in names + assert "appops.deployment.status" in names + assert "privatemuse.artifact.install.preflight" in names + assert "privatemuse.artifact.install" in names + installer = next(tool for tool in tools.tools if tool.name == "privatemuse.artifact.install") + assert installer.annotations.destructive_hint is True + assert "destination" not in installer.input_schema["properties"] + assert not any( + "approve" in name or "delete" in name or "shell" in name + for name in names + ) + request_tool = next( + tool for tool in tools.tools if tool.name == "server.operation.request" + ) + assert request_tool.annotations.read_only_hint is False + assert request_tool.annotations.destructive_hint is True + execute_tool = next( + tool for tool in tools.tools if tool.name == "server.operation.execute" + ) + assert execute_tool.annotations.read_only_hint is False + assert execute_tool.annotations.destructive_hint is True + deployment_preflight = next( + tool for tool in tools.tools if tool.name == "deployment.preflight" + ) + assert deployment_preflight.annotations.read_only_hint is True + deployment_apply = next( + tool for tool in tools.tools if tool.name == "deployment.apply" + ) + assert deployment_apply.annotations.destructive_hint is True + + preview = await client.call_tool( + "preview_action", + {"action": "volume.delete", "target": "important-data"}, + ) + assert preview.structured_content["policyDecision"] == "blocked" + assert preview.structured_content["executed"] is False + assert preview.structured_content["mcpApprovalRequired"] is False + assert preview.structured_content["mcpExecutionMode"] == "blocked" + + registry_result = await client.call_tool("get_action_registry", {}) + registry = registry_result.structured_content + by_id = {item["id"]: item for item in registry["items"]} + assert registry["summary"]["mcpApprovalRequired"] == 0 + assert by_id["container.restart"]["mcpApprovalRequired"] is False + assert by_id["container.restart"]["mcpExecutionMode"] == "direct_typed" + assert by_id["container.restart"]["mcpExecutionSupported"] is True + assert by_id["stack.redeploy"]["mcpApprovalRequired"] is False + assert ( + by_id["stack.redeploy"]["mcpExecutionMode"] + == "blocked_no_typed_executor" + ) + assert by_id["stack.redeploy"]["mcpExecutionSupported"] is False + + asyncio.run(exercise_mcp()) + metrics = mcp_server.runtime_status()["toolMetrics"]["preview_action"] + assert metrics["calls"] >= 1 + assert metrics["errors"] == 0 + assert metrics["averageMs"] >= 0 + + canary = mcp_server.run_read_only_canary() + assert canary["status"] == "ok" + assert mcp_server.runtime_status()["canary"]["lastCheckedAt"] + + +def test_typed_artifact_mcp_reuses_durable_idempotency_receipt(integration_client, tmp_path, monkeypatch): + from types import SimpleNamespace + + from backend.app import mcp_server + + _, engine = integration_client + mcp_server.engine = engine + monkeypatch.setattr(mcp_server, "_artifact_roots", lambda: (tmp_path / "models", tmp_path / "receipts")) + monkeypatch.setattr(mcp_server, "artifact_preflight", lambda payload, model_root: {"fingerprint": "pinned"}) + calls = [] + + def fake_install(payload, *, model_root, receipt_root): + calls.append(payload) + return {"status": "installed", "path": str(model_root / "loras" / payload["filename"]), + "sha256": payload["sha256"], "bytes": 5, "receipt": str(receipt_root / "receipt.json")} + + monkeypatch.setattr(mcp_server, "install_artifact_file", fake_install) + monkeypatch.setattr(mcp_server, "log_event", lambda *args, **kwargs: SimpleNamespace(id="audit-1")) + args = ("lora", "https://example.com/model.safetensors", "a" * 64, + "model.safetensors", "source-1", "test-license", "test fixture", "pinned", "repeatable-key") + first = mcp_server.privatemuse_artifact_install(*args) + second = mcp_server.privatemuse_artifact_install(*args) + assert first == second + assert len(calls) == 1 + + +def test_mcp_operation_executes_directly_without_human_approval_and_is_idempotent( + integration_client, monkeypatch +): + _, engine = integration_client + from backend.app.db.models import Approval + from backend.app.services import mcp_operations + + calls = [] + + def execute_direct(_session, **kwargs): + calls.append(kwargs) + return { + "success": True, + "status": "success", + "result": "success", + "jobId": "job-mcp-direct", + "auditId": "audit-mcp-direct", + "policyDecision": "allowed", + "originalPolicyDecision": kwargs["original_policy_decision"], + } + + monkeypatch.setattr(mcp_operations, "execute_governed_action", execute_direct) + + with Session(engine) as session: + requested = mcp_operations.request_operation( + session, + action="container.restart", + target="widefrog", + reason="Recover the unhealthy application through the typed restart executor.", + idempotency_key="chatgpt-operation-0001", + ) + replay = mcp_operations.request_operation( + session, + action="container.restart", + target="widefrog", + reason="Recover the unhealthy application through the typed restart executor.", + idempotency_key="chatgpt-operation-0001", + ) + approvals = session.exec( + select(Approval).where(Approval.target == "widefrog") + ).all() + + assert requested["created"] is False + assert requested["executed"] is True + assert requested["policyDecision"] == "allowed" + assert requested["originalPolicyDecision"] == "allowed" + assert requested["executionSupported"] is True + assert requested["idempotentReplay"] is False + assert "approvalId" not in requested + assert replay["jobId"] == requested["jobId"] + assert replay["idempotentReplay"] is True + assert len(calls) == 1 + assert approvals == [] + + +def test_mcp_operation_crash_boundary_refuses_duplicate_reexecution( + integration_client, monkeypatch +): + _, engine = integration_client + from backend.app.services import mcp_operations + + calls = [] + + def crash_after_boundary(_session, **kwargs): + calls.append(kwargs) + raise RuntimeError("simulated crash after side-effect boundary") + + monkeypatch.setattr( + mcp_operations, "execute_governed_action", crash_after_boundary + ) + + with Session(engine) as session: + with pytest.raises(RuntimeError, match="simulated crash"): + mcp_operations.request_operation( + session, + action="container.restart", + target="widefrog-crash", + reason="Exercise durable MCP idempotency boundary.", + idempotency_key="chatgpt-operation-crash-0001", + ) + + replay = mcp_operations.request_operation( + session, + action="container.restart", + target="widefrog-crash", + reason="Exercise durable MCP idempotency boundary.", + idempotency_key="chatgpt-operation-crash-0001", + ) + + assert len(calls) == 1 + assert replay["idempotentReplay"] is True + assert replay["executed"] is False + assert replay["policyDecision"] == "blocked" + assert replay["executionState"] == "reconciliation_required" + assert "approvalId" not in replay + + +def test_mcp_operation_executes_typed_action_even_when_policy_was_approval_required( + integration_client, monkeypatch +): + _, engine = integration_client + from backend.app.db.models import Approval, Policy + from backend.app.services import mcp_operations + + monkeypatch.setattr( + mcp_operations, + "execute_governed_action", + lambda _session, **kwargs: { + "success": True, + "status": "success", + "result": "success", + "jobId": "job-mcp-policy", + "auditId": "audit-mcp-policy", + "policyDecision": "allowed", + "originalPolicyDecision": kwargs["original_policy_decision"], + }, + ) + + with Session(engine) as session: + session.add( + Policy( + id="policy-mcp-no-approval", + action="container.restart", + target="widefrog-policy", + voice="approval_required", + manual="approval_required", + risk="medium", + reason="Historical policy requested approval.", + ) + ) + session.commit() + result = mcp_operations.request_operation( + session, + action="container.restart", + target="widefrog-policy", + reason="Autonomously recover the typed target.", + idempotency_key="chatgpt-operation-policy-0001", + ) + approvals = session.exec( + select(Approval).where(Approval.target == "widefrog-policy") + ).all() + + assert result["executed"] is True + assert result["policyDecision"] == "allowed" + assert result["originalPolicyDecision"] == "approval_required" + assert "approvalId" not in result + assert approvals == [] + + +def test_mcp_operation_keeps_blocked_and_read_only_actions_non_executable( + integration_client, +): + _, engine = integration_client + from backend.app.services import mcp_operations + + with Session(engine) as session: + blocked = mcp_operations.request_operation( + session, + action="volume.delete", + target="important-data", + reason="This must remain blocked.", + idempotency_key="chatgpt-operation-blocked-0001", + ) + read_only = mcp_operations.request_operation( + session, + action="container.status", + target="widefrog", + reason="Read status only.", + idempotency_key="chatgpt-operation-readonly-0001", + ) + + assert blocked["policyDecision"] == "blocked" + assert blocked["created"] is False + assert blocked["executed"] is False + assert blocked["executionSupported"] is False + assert "approvalId" not in blocked + assert read_only["created"] is False + assert read_only["executed"] is False + assert read_only["executionSupported"] is False + assert "approvalId" not in read_only + + +def test_mcp_nonblocked_action_without_typed_executor_fails_closed_without_approval( + integration_client, +): + _, engine = integration_client + from backend.app.db.models import Approval + from backend.app.services import mcp_operations + + with Session(engine) as session: + result = mcp_operations.request_operation( + session, + action="stack.redeploy", + target="appops", + reason="No generic stack executor is exposed through MCP.", + idempotency_key="chatgpt-operation-unsupported-0001", + ) + approvals = session.exec( + select(Approval).where(Approval.target == "appops") + ).all() + + assert result["policyDecision"] == "blocked" + assert result["originalPolicyDecision"] == "approval_required" + assert result["executionSupported"] is False + assert result["executed"] is False + assert "approvalId" not in result + assert approvals == [] + + +def test_mcp_legacy_operation_cannot_execute_after_approval_expiry(integration_client): + _, engine = integration_client + from datetime import timedelta + + from backend.app.core.time import utc_now + from backend.app.db.models import Approval + from backend.app.services import mcp_operations + from backend.app.services.approval_service import create_approval, update_approval_status + + with Session(engine) as session: + created = create_approval( + session, + action="container.restart", + target="widefrog-legacy", + source="automation", + risk="medium", + reason="Legacy approval compatibility test.", + requester="legacy-test", + ) + update_approval_status(session, created.id, "approved", actor="test-operator") + approval = session.get(Approval, created.id) + assert approval is not None + approval.expires_at = utc_now() - timedelta(seconds=1) + session.add(approval) + session.commit() + + with pytest.raises(ValueError, match="approval_expired"): + mcp_operations.execute_operation( + session, + approval_id=approval.id, + expected_action_fingerprint=str(approval.action_fingerprint), + ) + + +def test_mcp_recursively_redacts_stored_metadata(): + from backend.app.mcp_server import _json + + serialized = str( + _json( + { + "last_action": "api_key=MCP-SYNTHETIC-SECRET", + "labels": {"example.secret": "MCP-LABEL-SECRET"}, + } + ) + ) + + assert "MCP-SYNTHETIC-SECRET" not in serialized + assert "MCP-LABEL-SECRET" not in serialized + assert "[REDACTED]" in serialized + + +def test_n8n_event_and_audit_fields_are_redacted(integration_client): + client, engine = integration_client + from backend.app.db.models import AuditEvent + + headers = { + "Authorization": f"Bearer {N8N_TOKEN}", + "Idempotency-Key": "workflow-redaction-0001", + } + response = client.post( + "/api/integrations/n8n/approvals", + headers=headers, + json={ + "action": "stack.redeploy", + "target": "appops", + "reason": "api_key=N8N-REASON-SYNTHETIC", + "plan": [], + }, + ) + assert response.status_code == 200 + + with Session(engine) as session: + approval_audit = session.get(AuditEvent, response.json()["auditId"]) + assert approval_audit is not None + assert "N8N-REASON-SYNTHETIC" not in str(approval_audit.reason) + session.add( + AuditEvent( + id="audit-secret-target", + source="manual", + identity="operator", + action_id="container.status", + target="api_key=N8N-TARGET-SYNTHETIC", + risk="low", + policy_decision="allowed", + reason="status", + result="success", + ) + ) + session.commit() + + events = client.get( + "/api/integrations/n8n/events", headers={"Authorization": f"Bearer {N8N_TOKEN}"} + ) + assert events.status_code == 200 + serialized = str(events.json()) + assert "N8N-TARGET-SYNTHETIC" not in serialized + assert "[REDACTED]" in serialized + + +def test_n8n_heartbeat_makes_reachability_truthful(integration_client): + client, _ = integration_client + n8n_headers = {"Authorization": f"Bearer {N8N_TOKEN}"} + operator_headers = {"Authorization": f"Bearer {OPERATOR_TOKEN}"} + + before = client.get("/api/integrations/status", headers=operator_headers).json() + assert before["n8n"]["state"] == "configured" + + heartbeat = client.post( + "/api/integrations/n8n/heartbeat", + headers=n8n_headers, + json={ + "workflowId": "appops-test-workflow", + "workflowVersion": "2", + "branch": "events", + "processedEvents": 42, + "backlogRemaining": True, + }, + ) + assert heartbeat.status_code == 200 + assert heartbeat.json()["canExecute"] is False + + knowledge_heartbeat = client.post( + "/api/integrations/n8n/heartbeat", + headers=n8n_headers, + json={ + "workflowId": "appops-test-workflow", + "workflowVersion": "2", + "branch": "knowledge", + "confidence": "high", + }, + ) + assert knowledge_heartbeat.status_code == 200 + + after = client.get("/api/integrations/status", headers=operator_headers).json() + assert after["n8n"]["state"] == "reachable" + assert after["n8n"]["workflow"]["processedEvents"] == 42 + assert after["n8n"]["workflow"]["backlogRemaining"] is True + assert after["n8n"]["workflow"]["branches"]["events"]["processedEvents"] == 42 + assert after["n8n"]["workflow"]["branches"]["knowledge"]["confidence"] == "high" + + +def test_audit_endpoint_is_paginated(integration_client): + client, engine = integration_client + from backend.app.db.models import AuditEvent + + with Session(engine) as session: + for index in range(4): + session.add( + AuditEvent( + id=f"audit-page-{index}", + source="system", + identity="test", + action_id="audit.page", + target=str(index), + risk="low", + policy_decision="allowed", + result="success", + ) + ) + session.commit() + + first = client.get("/api/audit?limit=2&offset=0").json() + second = client.get("/api/audit?limit=2&offset=2").json() + assert len(first) == 2 + assert len(second) == 2 + assert {item["id"] for item in first}.isdisjoint({item["id"] for item in second}) + + +def test_n8n_concurrent_idempotency_key_creates_one_approval(integration_client): + _, engine = integration_client + from backend.app.db.models import Approval + from backend.app.services.n8n_integration import request_approval + + def submit(): + with Session(engine) as session: + return request_approval( + session, + action="stack.redeploy", + target="appops-concurrent", + reason="Gecontroleerde gelijktijdige workflowtest", + plan=["preflight"], + idempotency_key="workflow-concurrent-0001", + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: submit(), range(2))) + + with Session(engine) as session: + approvals = session.exec( + select(Approval).where(Approval.target == "appops-concurrent") + ).all() + assert len(approvals) == 1 + assert {result["idempotentReplay"] for result in results} == {False, True} + + +def test_n8n_machine_body_limit_and_mutation_rate_limit(integration_client): + client, _ = integration_client + headers = {"Authorization": f"Bearer {N8N_TOKEN}"} + + too_large = client.post( + "/api/integrations/n8n/preflight", + headers=headers, + content=b"x" * (1_048_576 + 1), + ) + assert too_large.status_code == 413 + + statuses = [ + client.post( + "/api/integrations/n8n/preflight", + headers=headers, + json={"action": "container.status", "target": f"app-{index}"}, + ).status_code + for index in range(11) + ] + assert statuses[:10] == [200] * 10 + assert statuses[10] == 429 + + +def test_workmesh_promotion_preflight_creates_sha_bound_approval_and_receipt_job( + integration_client, monkeypatch +): + client, engine = integration_client + from backend.app.db.models import Approval + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.secret_store import set_secret + + revision = "a" * 40 + digest = "b" * 64 + backup_inputs_digest = "d" * 64 + receipt_digest = "c" * 64 + backup_receipt_digest = "f" * 64 + broker_secret = "workmesh-controller-secret-" + "x" * 32 + with Session(engine) as session: + set_secret(session, "WORKMESH_DEPLOYMENT_BROKER", broker_secret) + + preflight_calls = [] + + def preflight(_self, requested_revision, *, secret): + assert requested_revision == revision + assert secret == broker_secret + preflight_calls.append(requested_revision) + return { + "apiVersion": "itworx.workmesh-promotion/v2", + "status": "preflight_ok", + "repository": "jens/workmesh", + "revision": revision, + "preflightSha256": digest, + "backupInputsSha256": backup_inputs_digest, + "checks": { + "exactMain": "passed", + "pushValidation": "passed", + "controllerPreflight": "passed", + "dataBackup": "passed", + }, + } + + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight) + preflight_headers = { + "Authorization": f"Bearer {OPERATOR_TOKEN}", + "Idempotency-Key": "workmesh-promote-preflight-0001", + } + direct_action = client.post( + "/api/actions/execute", + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, + json={ + "plan": { + "intent": "deployment.target.promote_workmesh", + "target": "jens/workmesh", + "source": "manual", + "plan": [], + } + }, + ) + assert direct_action.status_code == 200 + assert direct_action.json()["result"] == "preflight_required" + assert client.post( + "/api/deployments/workmesh/promotions/preflight", + headers=preflight_headers, + json={"revision": revision, "repository": "jens/other"}, + ).status_code == 422 + response = client.post( + "/api/deployments/workmesh/promotions/preflight", + headers=preflight_headers, + json={"revision": revision}, + ) + assert response.status_code == 200, response.text + requested = response.json() + assert requested["status"] == "approval_required" + assert requested["revision"] == revision + assert requested["preflight"]["preflightSha256"] == digest + assert requested["approvalId"] + assert requested["promotionId"] + + with Session(engine) as session: + approval = session.get(Approval, requested["approvalId"]) + assert approval is not None + assert approval.action == "deployment.target.promote_workmesh" + assert approval.target == "jens/workmesh" + assert approval.request_context_json["revision"] == revision + assert approval.request_context_json["preflightSha256"] == digest + assert approval.request_context_json["backupInputsSha256"] == backup_inputs_digest + assert approval.request_context_json["promotionId"] == requested["promotionId"] + assert revision in approval.reason + assert digest in approval.reason + + listed = client.get( + "/api/deployments/workmesh/promotions", + headers={"Authorization": f"Bearer {OPERATOR_TOKEN}"}, + ) + assert listed.status_code == 200 + assert listed.json()["items"][0]["promotionId"] == requested["promotionId"] + assert listed.json()["items"][0]["approvalStatus"] == "pending" + + replay = client.post( + "/api/deployments/workmesh/promotions/preflight", + headers=preflight_headers, + json={"revision": revision}, + ) + assert replay.status_code == 200 + assert replay.json()["promotionId"] == requested["promotionId"] + assert replay.json()["approvalId"] == requested["approvalId"] + assert replay.json()["idempotentReplay"] is True + assert preflight_calls == [revision] + changed_revision_replay = client.post( + "/api/deployments/workmesh/promotions/preflight", + headers=preflight_headers, + json={"revision": "c" * 40}, + ) + assert changed_revision_replay.status_code == 409 + assert preflight_calls == [revision] + + expected_request = hashlib.sha256( + json.dumps( + { + "repository": "jens/workmesh", + "revision": revision, + "operation": "workmesh_promote", + "idempotency": f"workmesh-promotion:{requested['promotionId']}", + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + + def apply(_self, requested_revision, *, secret, preflight_sha256, idempotency_key): + assert requested_revision == revision + assert secret == broker_secret + assert preflight_sha256 == digest + assert idempotency_key == f"workmesh-promotion:{requested['promotionId']}" + return { + "status": "accepted", + "request": expected_request, + "repository": "jens/workmesh", + "revision": revision, + "operation": "workmesh_promote", + } + + def status(_self, request_id, *, secret): + assert request_id == expected_request + assert secret == broker_secret + return { + "status": "deployed", + "request": expected_request, + "repository": "jens/workmesh", + "revision": revision, + "operation": "workmesh_promote", + "receiptSha256": receipt_digest, + "backupReceiptSha256": backup_receipt_digest, + } + + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply) + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_status", status) + auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"} + assert client.patch( + f"/api/approvals/{requested['approvalId']}", + headers=auth, + json={"status": "approved"}, + ).status_code == 200 + approved_promotion = client.get( + f"/api/deployments/workmesh/promotions/{requested['promotionId']}", + headers=auth, + ) + assert approved_promotion.status_code == 200 + assert approved_promotion.json()["approvalStatus"] == "approved" + executed = client.post( + f"/api/approvals/{requested['approvalId']}/execute", headers=auth + ) + assert executed.status_code == 200, executed.text + assert executed.json()["approvalStatus"] == "executed" + assert executed.json()["status"] == "applying" + assert executed.json()["jobId"] == requested["promotionId"] + assert preflight_calls == [revision, revision] + + reconciled = client.get( + f"/api/deployments/workmesh/promotions/{requested['promotionId']}", + headers=auth, + ) + assert reconciled.status_code == 200, reconciled.text + assert reconciled.json()["status"] == "succeeded" + assert reconciled.json()["revision"] == revision + assert reconciled.json()["evidence"]["receiptSha256"] == receipt_digest + assert reconciled.json()["evidence"]["backupReceiptSha256"] == backup_receipt_digest + assert reconciled.json()["approvalStatus"] == "executed" + + +def test_workmesh_ambiguous_controller_apply_is_consumed_and_only_reconciled( + integration_client, monkeypatch +): + client, engine = integration_client + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.secret_store import set_secret + + revision = "d" * 40 + broker_secret = "workmesh-controller-secret-" + "y" * 32 + with Session(engine) as session: + set_secret(session, "WORKMESH_DEPLOYMENT_BROKER", broker_secret) + + def preflight(_self, requested_revision, *, secret): + assert requested_revision == revision + assert secret == broker_secret + return { + "apiVersion": "itworx.workmesh-promotion/v2", + "status": "preflight_ok", + "repository": "jens/workmesh", + "revision": revision, + "preflightSha256": "e" * 64, + "backupInputsSha256": "f" * 64, + "checks": { + "exactMain": "passed", + "pushValidation": "passed", + "controllerPreflight": "passed", + "dataBackup": "passed", + }, + } + + apply_calls = [] + status_calls = [] + + def apply(*_args, **_kwargs): + apply_calls.append(True) + return {"status": "unreachable", "reason": "connection closed"} + + def status(*_args, **_kwargs): + status_calls.append(True) + return {"status": "not_found"} + + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight) + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply) + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_status", status) + + auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"} + requested = client.post( + "/api/deployments/workmesh/promotions/preflight", + headers={**auth, "Idempotency-Key": "workmesh-promote-uncertain-0001"}, + json={"revision": revision}, + ) + assert requested.status_code == 200, requested.text + approval_id = requested.json()["approvalId"] + assert client.patch( + f"/api/approvals/{approval_id}", headers=auth, json={"status": "approved"} + ).status_code == 200 + + executed = client.post(f"/api/approvals/{approval_id}/execute", headers=auth) + assert executed.status_code == 200, executed.text + assert executed.json()["status"] == "outcome_unknown" + assert executed.json()["approvalStatus"] == "executed" + assert apply_calls == [True] + + for _ in range(2): + reconciled = client.get( + f"/api/deployments/workmesh/promotions/{requested.json()['promotionId']}", + headers=auth, + ) + assert reconciled.status_code == 200, reconciled.text + assert reconciled.json()["status"] == "outcome_unknown" + assert reconciled.json()["evidence"] == {"reconciliationRequired": True} + assert len(status_calls) == 2 + assert apply_calls == [True] + replay = client.post(f"/api/approvals/{approval_id}/execute", headers=auth) + assert replay.status_code == 409 + + +def test_mcp_legacy_approval_executor_does_not_expose_workmesh_promotion(integration_client): + _, engine = integration_client + from backend.app.services import mcp_operations + from backend.app.services.approval_service import create_approval, update_approval_status + + with Session(engine) as session: + approval = create_approval( + session, + action="deployment.target.promote_workmesh", + target="jens/workmesh", + source="manual", + risk="high", + reason="Fixed WorkMesh promotion contract test.", + context={"apiVersion": "itworx.workmesh-promotion/v2"}, + ) + update_approval_status(session, approval.id, "approved", actor="test-operator") + assert mcp_operations.operation_status(session, approval.id)["executionSupported"] is False + with pytest.raises(ValueError, match="operation_executor_unavailable"): + mcp_operations.execute_operation( + session, + approval_id=approval.id, + expected_action_fingerprint=str(approval.action_fingerprint), + ) + + +def test_workmesh_changed_preflight_expires_approval_before_apply( + integration_client, monkeypatch +): + client, engine = integration_client + from backend.app.db.models import Approval + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.secret_store import set_secret + + revision = "f" * 40 + with Session(engine) as session: + set_secret(session, "WORKMESH_DEPLOYMENT_BROKER", "workmesh-controller-secret-" + "z" * 32) + + preflight_calls = [] + + def preflight(_self, requested_revision, *, secret): + assert requested_revision == revision + assert secret.startswith("workmesh-controller-secret-") + preflight_calls.append(True) + return { + "apiVersion": "itworx.workmesh-promotion/v2", + "status": "preflight_ok", + "repository": "jens/workmesh", + "revision": revision, + "preflightSha256": ("1" if len(preflight_calls) == 1 else "2") * 64, + "backupInputsSha256": "b" * 64, + "checks": { + "exactMain": "passed", + "pushValidation": "passed", + "controllerPreflight": "passed", + "dataBackup": "passed", + }, + } + + apply_calls = [] + + def apply(*_args, **_kwargs): + apply_calls.append(True) + return {"status": "accepted"} + + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight) + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply) + auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"} + requested = client.post( + "/api/deployments/workmesh/promotions/preflight", + headers={**auth, "Idempotency-Key": "workmesh-promote-drift-0001"}, + json={"revision": revision}, + ) + assert requested.status_code == 200, requested.text + approval_id = requested.json()["approvalId"] + assert client.patch( + f"/api/approvals/{approval_id}", headers=auth, json={"status": "approved"} + ).status_code == 200 + + executed = client.post(f"/api/approvals/{approval_id}/execute", headers=auth) + assert executed.status_code == 200 + assert executed.json()["status"] == "preflight_changed" + assert apply_calls == [] + with Session(engine) as session: + approval = session.get(Approval, approval_id) + assert approval.status == "expired" + replay = client.post(f"/api/approvals/{approval_id}/execute", headers=auth) + assert replay.status_code == 409 + + +def test_controller_preflight_changed_identity_expires_approval_without_unknown_state( + integration_client, monkeypatch +): + client, engine = integration_client + from backend.app.db.models import Approval, WorkMeshPromotion + from backend.app.services.deployment_broker import DeploymentBrokerClient + from backend.app.services.secret_store import set_secret + + revision = "9" * 40 + broker_secret = "workmesh-controller-secret-" + "x" * 32 + with Session(engine) as session: + set_secret(session, "WORKMESH_DEPLOYMENT_BROKER", broker_secret) + + def preflight(_self, requested_revision, *, secret): + assert requested_revision == revision + assert secret == broker_secret + return { + "apiVersion": "itworx.workmesh-promotion/v2", + "status": "preflight_ok", + "repository": "jens/workmesh", + "revision": revision, + "preflightSha256": "a" * 64, + "backupInputsSha256": "b" * 64, + "checks": { + "exactMain": "passed", + "pushValidation": "passed", + "controllerPreflight": "passed", + "dataBackup": "passed", + }, + } + + apply_calls = [] + + def apply(_self, requested_revision, *, secret, preflight_sha256, idempotency_key): + assert requested_revision == revision + assert secret == broker_secret + assert preflight_sha256 == "a" * 64 + apply_calls.append(idempotency_key) + request_id = hashlib.sha256( + json.dumps( + { + "repository": "jens/workmesh", + "revision": revision, + "operation": "workmesh_promote", + "idempotency": idempotency_key, + }, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + ).hexdigest() + return { + "status": "preflight_changed", + "request": request_id, + "repository": "jens/workmesh", + "revision": revision, + "operation": "workmesh_promote", + "reason": "approved_preflight_no_longer_matches", + } + + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_preflight", preflight) + monkeypatch.setattr(DeploymentBrokerClient, "workmesh_promotion_apply", apply) + auth = {"Authorization": f"Bearer {OPERATOR_TOKEN}"} + requested = client.post( + "/api/deployments/workmesh/promotions/preflight", + headers={**auth, "Idempotency-Key": "workmesh-promote-controller-drift-01"}, + json={"revision": revision}, + ) + assert requested.status_code == 200, requested.text + promotion_id = requested.json()["promotionId"] + approval_id = requested.json()["approvalId"] + assert client.patch( + f"/api/approvals/{approval_id}", headers=auth, json={"status": "approved"} + ).status_code == 200 + + executed = client.post(f"/api/approvals/{approval_id}/execute", headers=auth) + assert executed.status_code == 200, executed.text + assert executed.json()["status"] == "preflight_changed" + assert executed.json()["success"] is False + assert apply_calls == [f"workmesh-promotion:{promotion_id}"] + with Session(engine) as session: + approval = session.get(Approval, approval_id) + promotion = session.get(WorkMeshPromotion, promotion_id) + assert approval is not None and approval.status == "expired" + assert promotion is not None and promotion.status == "preflight_changed" diff --git a/backend/tests/test_media_codec_dashboard_deployment_reconciliation.py b/backend/tests/test_media_codec_dashboard_deployment_reconciliation.py new file mode 100644 index 0000000..f259b54 --- /dev/null +++ b/backend/tests/test_media_codec_dashboard_deployment_reconciliation.py @@ -0,0 +1,190 @@ +from types import SimpleNamespace + +from backend.app.services.media_codec_dashboard_deployment_reconciliation import ( + MediaCodecDashboardDeploymentReconciliation, +) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakePortainer: + def __init__(self, exit_code=0): + self.exit_code = exit_code + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": { + "id": "controller-id", + "name": "gitea-deploy-control", + "state": "running", + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-1"}) + if path.endswith("/exec/exec-1/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-1/json"): + return FakeResponse({"Running": False, "ExitCode": self.exit_code}) + raise AssertionError(path) + + +def test_embedded_reconciliation_script_is_valid_python(): + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + compile(script, "media-codec-dashboard-reconcile", "exec") + + +def test_reconciliation_derives_the_verified_live_revision_from_runtime_evidence(): + fake = FakePortainer() + result = MediaCodecDashboardDeploymentReconciliation(fake).run() + + assert result == { + "status": "reconciled", + "repository": "jens/media-codec-dashboard", + "container": "Media-Codec-Dashboard", + } + + create = fake.calls[0] + command = create[2]["json"]["Cmd"] + assert command[:2] == ["python3", "-c"] + assert command[3:] == [ + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + ] + script = command[2] + # The controller registry must be the canonical physical cache bind, not + # the user-share alias that can resolve to a different inode. + assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script + assert "/srv/example/appdata/gitea-deploy-runners/controller/projects.json" not in script + assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script + assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script + assert "/mnt/cache/appdata/gitea-deploy-runners/deploy.lock" in script + assert "os.path.samefile(registry_path, host_registry_path)" in script + assert '"/app/data": "/srv/example/appdata/media-codec-dashboard/data"' in script + assert '"/app/data/quality-runtime": "/srv/example/appdata/tdarr/logs/media-codec-dashboard-quality-audit"' in script + assert '"/run/secrets": "/srv/example/appdata/media-codec-dashboard/secrets"' in script + assert '"/tdarr-output": "/srv/example/Media"' in script + assert '"/tdarr-db"' not in script + + # Never freeze a one-time recovery helper to a historical deployment SHA. + assert "971df2cc305c1b447b4feb255d4e9fda871315c3" not in script + assert "adf53604739d4247193a7b5bfeb8ad8981d9d359" not in script + assert "live_revision, stale_revision" not in script + + # The immutable revision must be proven independently by both the running + # container label and the immutable image label, then resolved in Gitea. + assert 'container_revision = str(labels.get("org.opencontainers.image.revision") or "")' in script + assert 'image_revision = str(image_labels.get("org.opencontainers.image.revision") or "")' in script + assert "container_revision != image_revision" in script + assert 're.fullmatch(r"[0-9a-f]{40}", live_revision)' in script + assert 'git_run("rev-parse", f"{live_revision}^{{commit}}")' in script + assert 'git_run("merge-base", "--is-ancestor", live_revision, main_revision)' in script + + # The stale controller revision is read from durable state. Recovery is + # allowed only when that recorded live revision is a real older ancestor of + # the currently running immutable image, never for arbitrary drift. + assert 'recorded_live = str(state.get("live_revision") or state.get("revision") or "")' in script + assert 're.fullmatch(r"[0-9a-f]{40}", recorded_live)' in script + assert 'git_run("merge-base", "--is-ancestor", recorded_live, live_revision)' in script + assert 'recorded_live == live_revision' in script + assert '"live_legacy_provenance": True' in script + assert create[2]["json"]["AttachStdout"] is False + assert create[2]["json"]["AttachStderr"] is False + + +def test_reconciliation_distinguishes_failed_attempt_from_recorded_live_revision(): + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + # Controller schema v3 keeps the real live revision while top-level status + # describes the most recent deployment attempt. A failed forward attempt is + # therefore recoverable when the independently verified runtime still equals + # recorded_live and the failed attempt is a distinct valid descendant. + assert 'recorded_live_status = str(state.get("live_status") or "")' in script + assert 'last_attempt = dict(state.get("last_attempt") or {})' in script + assert 'attempt_revision = str(last_attempt.get("revision") or "")' in script + assert 'attempt_status = str(last_attempt.get("status") or "")' in script + assert "current_live_failed_baseline = (" in script + assert 'recorded_status == "failed"' in script + assert 'recorded_live_status == "healthy" or current_live_unknown_snapshot_baseline' in script + assert 'git_run("merge-base", "--is-ancestor", live_revision, attempt_revision)' in script + assert 'git_run("merge-base", "--is-ancestor", attempt_revision, main_revision)' in script + + +def test_reconciliation_accepts_exact_current_legacy_snapshot_failure_only_with_matching_artifacts(): + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert "current_live_unknown_snapshot_baseline = (" in script + assert 'recorded_live_status == "unknown"' in script + assert 'state.get("live_legacy_provenance") is True' in script + assert 'attempt.get("message") == "Existing production containers require a valid healthy live deployment state"' in script + assert 'attempt.get("updated_at_epoch") == state.get("updated_at_epoch")' in script + assert 'current_receipt == {}' in script + assert 'set(current_artifacts) == {"compose_sha256", "containers"}' in script + assert 'set(current_artifact_containers) == {expected_container}' in script + assert 'all(current_stored_artifact.get(field) == container_artifact.get(field) for field in current_immutable_fields)' in script + assert 'current_live_failed_baseline = (' in script + assert 'recorded_live_status == "healthy" or current_live_unknown_snapshot_baseline' in script + + +def test_reconciliation_accepts_only_an_internally_consistent_stale_success_baseline(): + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + # A previously successful controller baseline may legitimately lag behind + # an independently verified newer immutable runtime. Accept that drift only + # when the old state is internally self-consistent: healthy live status and + # a successful last attempt for exactly the recorded old live revision. + assert "stale_success_baseline = (" in script + assert 'recorded_status == "success"' in script + assert 'recorded_live_status == "healthy"' in script + assert 'attempt_status == "success"' in script + assert "attempt_revision == recorded_live" in script + assert 'stale_failed_attempt = recorded_status == "failed"' in script + assert "if not (stale_success_baseline or stale_failed_attempt):" in script + assert 'git_run("merge-base", "--is-ancestor", recorded_live, live_revision)' in script + + +def test_reconciliation_refuses_wrong_live_mount_topology(): + result = MediaCodecDashboardDeploymentReconciliation(FakePortainer(exit_code=48)).run() + assert result == { + "status": "validation_failed", + "reason": "live Media Codec Dashboard mount topology differs from the verified runtime", + } + + +def test_reconciliation_refuses_unknown_state_drift(): + result = MediaCodecDashboardDeploymentReconciliation(FakePortainer(exit_code=52)).run() + assert result == { + "status": "validation_failed", + "reason": "controller state drift is not the exact known recoverable state", + } + + +def test_reconciliation_refuses_wrong_image_revision_label(): + result = MediaCodecDashboardDeploymentReconciliation(FakePortainer(exit_code=51)).run() + assert result == { + "status": "validation_failed", + "reason": "live Media Codec Dashboard image revision label does not match the verified runtime", + } diff --git a/backend/tests/test_media_codec_dashboard_reconcile_diagnostics.py b/backend/tests/test_media_codec_dashboard_reconcile_diagnostics.py new file mode 100644 index 0000000..400839e --- /dev/null +++ b/backend/tests/test_media_codec_dashboard_reconcile_diagnostics.py @@ -0,0 +1,382 @@ +import ast +import copy +import re + +from backend.app.services.media_codec_dashboard_deployment_reconciliation import ( + _RECONCILE_SCRIPT, + MediaCodecDashboardDeploymentReconciliation, +) +from backend.tests.test_media_codec_dashboard_deployment_reconciliation import ( + FakePortainer, +) + + +def _load_reconcile_script_function(name: str): + tree = ast.parse(_RECONCILE_SCRIPT) + function = next( + (node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == name), + None, + ) + assert function is not None, f"reconciliation script function {name} is missing" + namespace = {"re": re} + exec(compile(ast.Module(body=[function], type_ignores=[]), "", "exec"), namespace) # noqa: S102 + return namespace[name] + + +def test_full_receipt_snapshot_recovery_requires_exact_failed_state(): + classify = _load_reconcile_script_function("_is_current_verified_receipt_snapshot_baseline") + live = "a" * 40 + failed = "b" * 40 + artifact = {"image_id": "sha256:" + "c" * 64} + state = { + "schema_version": 3, + "repository": "jens/media-codec-dashboard", + "revision": live, + "live_revision": live, + "status": "failed", + "live_status": "unknown", + "live_legacy_provenance": False, + "updated_at_epoch": 123, + "last_attempt": { + "revision": failed, + "status": "failed", + "message": "Existing production containers require a valid healthy live deployment state", + "updated_at_epoch": 123, + }, + "live_artifacts": { + "compose_sha256": "d" * 64, + "containers": {"Media-Codec-Dashboard": artifact}, + }, + "live_receipt": { + "schema": "gitea-deploy-receipt.v1", + "repository": "jens/media-codec-dashboard", + "revision": live, + "live": None, + "containers": {"Media-Codec-Dashboard": artifact}, + }, + } + assert classify(state, "jens/media-codec-dashboard", "Media-Codec-Dashboard", live, failed) + for path, value in ( + (("live_status",), "healthy"), + (("live_legacy_provenance",), True), + (("last_attempt", "message"), "unrelated failure"), + (("last_attempt", "updated_at_epoch"), 122), + (("live_artifacts", "compose_sha256"), ""), + (("live_receipt", "containers"), {}), + ): + changed = copy.deepcopy(state) + target = changed + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + assert not classify(changed, "jens/media-codec-dashboard", "Media-Codec-Dashboard", live, failed) + + +def test_reconciliation_accepts_only_the_receipt_proven_legacy_state_ahead_of_runtime(): + classify = _load_reconcile_script_function("_is_exact_legacy_state_ahead_baseline") + recorded = "b" * 40 + live = "9" * 40 + attempt = "d" * 40 + artifact = { + "container_id": "f" * 64, + "image_id": "sha256:" + "c" * 64, + "image_ref": "media-codec-dashboard-media-codec-dashboard", + "container_revision": live, + "image_revision": live, + "image_source": "", + "image_tree": "", + "image_version": "", + "image_created": "", + } + state = { + "schema_version": 3, + "repository": "jens/media-codec-dashboard", + "revision": recorded, + "live_revision": recorded, + "live_status": "unknown", + "live_legacy_provenance": False, + "status": "failed", + "updated_at_epoch": 30, + "last_attempt": { + "revision": attempt, + "status": "failed", + "message": "Existing production containers require a valid healthy live deployment state", + "updated_at_epoch": 30, + "metadata": { + "id": attempt[:12], + "repository": "jens/media-codec-dashboard", + "revision": attempt, + "tree": "1" * 40, + "treeDigest": "2" * 64, + "builtAt": "2026-09-20T11:17:29.051Z", + }, + }, + "live_artifacts": { + "compose_sha256": "3" * 64, + "containers": {"Media-Codec-Dashboard": artifact}, + }, + "live_receipt": { + "schema": "gitea-deploy-receipt.v1", + "repository": "jens/media-codec-dashboard", + "revision": recorded, + "tree": "4" * 40, + "treeDigest": "5" * 64, + "build": { + "id": recorded[:12], + "repository": "jens/media-codec-dashboard", + "revision": recorded, + "tree": "4" * 40, + "treeDigest": "5" * 64, + "builtAt": "2026-09-16T23:43:44.387Z", + }, + "containers": {"Media-Codec-Dashboard": artifact}, + "live": None, + }, + } + git_facts = { + "recorded_to_live": 1, + "canonical_recorded_to_live": 1, + "live_to_recorded": 0, + "canonical_live_to_recorded": 0, + "mounted_recorded_main": 0, + "canonical_recorded_main": 0, + "canonical_live_main": 0, + "recorded_to_attempt": 0, + "canonical_recorded_to_attempt": 0, + "attempt_to_main": 0, + "canonical_attempt_to_main": 0, + } + + assert classify( + state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + git_facts, + ) is True + + recreated_container_state = copy.deepcopy(state) + recreated_container_state["live_artifacts"]["containers"]["Media-Codec-Dashboard"][ + "container_id" + ] = "e" * 64 + recreated_container_state["live_receipt"]["containers"]["Media-Codec-Dashboard"][ + "container_id" + ] = "e" * 64 + assert classify( + recreated_container_state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + git_facts, + ) is True + + wrong_artifact_state = copy.deepcopy(state) + wrong_artifact_state["live_artifacts"]["containers"]["Media-Codec-Dashboard"] = { + **artifact, + "container_id": "e" * 64, + } + assert classify( + wrong_artifact_state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + git_facts, + ) is False + + for field, changed_value in { + "image_id": "sha256:" + "a" * 64, + "image_ref": "changed-image-reference", + "container_revision": "8" * 40, + "image_revision": "8" * 40, + "image_source": "changed-source", + "image_tree": "changed-tree", + "image_version": "changed-version", + "image_created": "changed-created", + }.items(): + changed_immutable_state = copy.deepcopy(recreated_container_state) + changed_immutable_state["live_artifacts"]["containers"]["Media-Codec-Dashboard"][ + field + ] = changed_value + changed_immutable_state["live_receipt"]["containers"]["Media-Codec-Dashboard"][ + field + ] = changed_value + assert classify( + changed_immutable_state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + git_facts, + ) is False + + malformed_stored_container_id = copy.deepcopy(recreated_container_state) + malformed_stored_container_id["live_artifacts"]["containers"]["Media-Codec-Dashboard"][ + "container_id" + ] = "not-a-container-id" + malformed_stored_container_id["live_receipt"]["containers"]["Media-Codec-Dashboard"][ + "container_id" + ] = "not-a-container-id" + assert classify( + malformed_stored_container_id, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + git_facts, + ) is False + + malformed_live_artifact = {**artifact, "container_id": "not-a-container-id"} + assert classify( + recreated_container_state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + malformed_live_artifact, + git_facts, + ) is False + + wrong_failure_state = copy.deepcopy(state) + wrong_failure_state["last_attempt"]["message"] = "different failure" + assert classify( + wrong_failure_state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + git_facts, + ) is False + + wrong_ancestry = {**git_facts, "live_to_recorded": 1} + assert classify( + state, + "jens/media-codec-dashboard", + "Media-Codec-Dashboard", + recorded, + live, + artifact, + wrong_ancestry, + ) is False + + +def test_reconciliation_reports_specific_state_drift_failure_boundaries(): + expected = { + 55: "controller state for the current live revision is internally inconsistent", + 56: "failed deployment attempt is not a descendant of the live revision", + 57: "failed deployment attempt is not an ancestor of main", + 58: "recorded live deployment revision is invalid", + 59: "stale controller state metadata is not a supported recoverable baseline", + 60: "recorded live deployment revision is not an ancestor of the verified runtime", + 61: "mounted deployment repository ancestry differs from canonical Gitea repository", + } + + for exit_code, reason in expected.items(): + result = MediaCodecDashboardDeploymentReconciliation(FakePortainer(exit_code=exit_code)).run() + assert result == {"status": "validation_failed", "reason": reason} + + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + for exit_code in expected: + assert f"raise SystemExit({exit_code})" in script + + assert 'canonical_repository_root = Path("/mnt/cache/appdata/gitea/git/repositories")' in script + assert "def canonical_git_run(*args):" in script + assert 'canonical_recorded_to_live = canonical_git_run("merge-base", "--is-ancestor", recorded_live, live_revision)' in script + + +def test_reconciliation_falls_back_only_to_positive_canonical_gitea_ancestry(): + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert "live_revision = image_revision" in script + assert 'recorded_live = str(state.get("live_revision") or state.get("revision") or "")' in script + + assert 'recorded_to_live = git_run("merge-base", "--is-ancestor", recorded_live, live_revision)' in script + assert "if recorded_to_live.returncode != 0:" in script + + assert 'canonical_recorded = canonical_git_run("rev-parse", f"{recorded_live}^{{commit}}")' in script + assert 'canonical_live = canonical_git_run("rev-parse", f"{live_revision}^{{commit}}")' in script + assert 'canonical_main = canonical_git_run("rev-parse", "refs/heads/main")' in script + assert "canonical_main.stdout.strip() == main_revision" in script + + assert 'canonical_recorded_to_live = canonical_git_run("merge-base", "--is-ancestor", recorded_live, live_revision)' in script + assert 'canonical_recorded_main = canonical_git_run("merge-base", "--is-ancestor", recorded_live, main_revision)' in script + assert 'canonical_live_main = canonical_git_run("merge-base", "--is-ancestor", live_revision, main_revision)' in script + assert "canonical_ancestry_fallback = (" in script + assert "canonical_recorded_to_live.returncode == 0" in script + assert "canonical_recorded_main.returncode == 0" in script + assert "canonical_live_main.returncode == 0" in script + assert "if not canonical_ancestry_fallback:" in script + + # Positive canonical ancestry remains the normal fallback. A genuine + # history transition requires the stricter cross-repository proof below. + assert "history_transition_baseline = (" in script + assert 'recorded_updated_at = int(state.get("updated_at_epoch") or 0)' in script + assert "canonical_merge_base_value == mounted_merge_base_value" in script + assert "recorded_commit_epoch < live_commit_epoch" in script + assert "0 <= live_distance < recorded_distance" in script + assert "971df2cc305c1b447b4feb255d4e9fda871315c3" not in script + assert "adf53604739d4247193a7b5bfeb8ad8981d9d359" not in script + + +def test_reconciliation_requires_cross_repository_proof_for_divergent_history_transition(): + fake = FakePortainer() + MediaCodecDashboardDeploymentReconciliation(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + compile(script, "", "exec") + + assert "history_transition_baseline = (" in script + assert "stale_success_baseline" in script + assert "recorded_to_live.returncode == 1" in script + assert "canonical_recorded_to_live.returncode == 1" in script + assert "live_to_recorded.returncode == 1" in script + assert "canonical_live_to_recorded.returncode == 1" in script + assert "mounted_recorded_main.returncode == 0" in script + assert "canonical_recorded_main.returncode == 0" in script + assert "canonical_live_main.returncode == 0" in script + assert "canonical_merge_base_value == mounted_merge_base_value" in script + assert "mounted_merge_base_value not in {recorded_live, live_revision}" in script + assert "canonical_recorded_commit_epoch == recorded_commit_epoch" in script + assert "canonical_live_commit_epoch == live_commit_epoch" in script + assert "recorded_commit_epoch <= recorded_updated_at" in script + assert "recorded_updated_at < live_commit_epoch" in script + assert "canonical_recorded_distance_value == recorded_distance" in script + assert "canonical_live_distance_value == live_distance" in script + assert "0 <= live_distance < recorded_distance" in script + + +def test_current_live_unknown_recovery_is_runtime_proven_not_error_text_bound(): + script = _RECONCILE_SCRIPT + historical_message = ( + 'attempt.get("message") == ' + '"Existing production containers require a valid healthy live deployment state"' + ) + + # The full-receipt path requires the exact snapshot error; the older + # legacy snapshot path remains based on immutable runtime evidence. + assert script.count(historical_message) == 2 + current_live_block = script[ + script.index("current_live_unknown_snapshot_baseline = ("): + script.index("current_live_failed_baseline = (") + ] + assert historical_message not in current_live_block + + # Recovery still requires exact immutable runtime evidence, exact candidate + # metadata and the same atomic state timestamp. + assert 'current_artifacts.get("compose_sha256") == ""' in current_live_block + assert "current_receipt == {}" in current_live_block + assert "current_stored_artifact.get(field) == container_artifact.get(field)" in current_live_block + assert 'set(current_attempt_metadata) == {"builtAt", "id", "repository", "revision", "tree", "treeDigest"}' in current_live_block + assert 'attempt.get("updated_at_epoch") == state.get("updated_at_epoch")' in current_live_block diff --git a/backend/tests/test_media_codec_dashboard_reconciliation_action.py b/backend/tests/test_media_codec_dashboard_reconciliation_action.py new file mode 100644 index 0000000..808216d --- /dev/null +++ b/backend/tests/test_media_codec_dashboard_reconciliation_action.py @@ -0,0 +1,117 @@ +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.services import action_execution +from backend.app.services.action_execution import approval_execution_supported +from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, +) + + +def test_media_codec_dashboard_reconciliation_is_fixed_high_risk_typed_action(): + action = "deployment.target.reconcile_media_codec_dashboard" + assert action in ACTIONS + assert ACTION_RISK[action] == "high" + assert action_default_target(action) == "jens/media-codec-dashboard" + assert approval_execution_supported(action) is True + + +def test_media_codec_dashboard_reconciliation_rejects_any_other_target(): + result = action_execution.execute_governed_action( + None, # type: ignore[arg-type] + action="deployment.target.reconcile_media_codec_dashboard", + target="jens/not-media-codec-dashboard", + source="chatgpt-mcp", + risk="high", + reason="Regression test for exact-target enforcement.", + original_policy_decision="allowed", + ) + + assert result["success"] is False + assert result["result"] == "blocked" + assert result["message"] == ( + "Media Codec Dashboard reconciliation accepts only " + "jens/media-codec-dashboard." + ) + + +def test_media_codec_dashboard_reconciliation_dispatches_fixed_executor( + monkeypatch, tmp_path +): + engine = create_engine(f"sqlite:///{tmp_path / 'dashboard-reconciliation.db'}") + SQLModel.metadata.create_all(engine) + + class FakeReconciliation: + def run(self): + return { + "status": "reconciled", + "repository": "jens/media-codec-dashboard", + "container": "Media-Codec-Dashboard", + } + + import backend.app.services.media_codec_dashboard_deployment_reconciliation as module + + monkeypatch.setattr( + module, + "MediaCodecDashboardDeploymentReconciliation", + FakeReconciliation, + ) + + with Session(engine) as session: + result = action_execution.execute_governed_action( + session, + action="deployment.target.reconcile_media_codec_dashboard", + target="jens/media-codec-dashboard", + source="chatgpt-mcp", + risk="high", + reason="Re-attest only the bounded dashboard controller state.", + original_policy_decision="allowed", + ) + + assert result["success"] is True + assert result["status"] == "success" + assert result["result"] == "success" + assert result["policyDecision"] == "allowed" + assert result["originalPolicyDecision"] == "allowed" + assert result["jobId"].startswith("job-") + assert result["auditId"].startswith("audit-") + + +def test_media_codec_dashboard_reconciliation_propagates_fail_closed_result( + monkeypatch, tmp_path +): + engine = create_engine(f"sqlite:///{tmp_path / 'dashboard-failure.db'}") + SQLModel.metadata.create_all(engine) + + class FakeReconciliation: + def run(self): + return { + "status": "validation_failed", + "reason": "controller state for the current live revision is internally inconsistent", + } + + import backend.app.services.media_codec_dashboard_deployment_reconciliation as module + + monkeypatch.setattr( + module, + "MediaCodecDashboardDeploymentReconciliation", + FakeReconciliation, + ) + + with Session(engine) as session: + result = action_execution.execute_governed_action( + session, + action="deployment.target.reconcile_media_codec_dashboard", + target="jens/media-codec-dashboard", + source="chatgpt-mcp", + risk="high", + reason="Verify fail-closed propagation.", + original_policy_decision="allowed", + ) + + assert result["success"] is False + assert result["status"] == "failed" + assert result["message"] == ( + "controller state for the current live revision is internally inconsistent" + ) diff --git a/backend/tests/test_media_pipeline.py b/backend/tests/test_media_pipeline.py new file mode 100644 index 0000000..6ddb3b2 --- /dev/null +++ b/backend/tests/test_media_pipeline.py @@ -0,0 +1,386 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "media_pipeline_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def _healthy_payloads(): + return { + "sonarr_health": {"success": True, "status": "connected"}, + "sonarr_wanted": {"success": True, "status": "connected", "total": 3, "items": [{"title": "The Boys"}]}, + "sonarr_queue": {"success": True, "status": "connected", "total": 1, "downloading": 1, "queued": 0, "failed": 0, "stalled": 0, "items": [{"title": "The Boys", "status": "downloading"}]}, + "prowlarr_health": {"success": True, "status": "connected"}, + "prowlarr_indexers": {"success": True, "status": "ok", "total": 4, "enabled": 4, "failing": 0, "items": []}, + "prowlarr_apps": {"success": True, "status": "ok", "total": 2, "items": [{"name": "Sonarr", "status": "healthy"}, {"name": "Radarr", "status": "healthy"}]}, + "prowlarr_sync": {"success": True, "status": "ok", "applicationsConfigured": 2, "indexersConfigured": 4, "indexersEnabled": 4, "indexersFailing": 0, "healthWarnings": 0, "healthErrors": 0}, + "deluge_health": {"success": True, "status": "connected"}, + "deluge_session": {"success": True, "status": "ok", "connected": True, "downloadRate": 1_200_000, "uploadRate": 200_000, "numTorrents": 2, "active": 1, "paused": 1, "stalled": 0, "errors": 0}, + "deluge_active": {"success": True, "status": "ok", "total": 1, "items": [{"id": "active-1", "name": "The Boys", "state": "Downloading", "progress": 55}]}, + "deluge_stalled": {"success": True, "status": "ok", "total": 0, "items": []}, + "deluge_paused": {"success": True, "status": "ok", "total": 1, "items": [{"id": "paused-1", "name": "Paused", "state": "Paused"}]}, + "deluge_speeds": {"success": True, "status": "ok", "downloadRate": 1_200_000, "uploadRate": 200_000, "active": 1, "paused": 1, "stalled": 0, "errors": 0}, + "plex_health": {"success": True, "status": "connected"}, + "plex_libraries": {"success": True, "status": "ok", "total": 2, "libraries": [{"id": "1", "title": "Series", "type": "show", "itemCount": 200}]}, + "plex_sessions": {"success": True, "status": "ok", "total": 1, "sessions": [{"id": "plex-1", "title": "The Boys", "user": "Jens", "videoDecision": "directplay"}]}, + "plex_recent": {"success": True, "status": "ok", "total": 1, "items": [{"title": "The Boys", "library": "Series", "type": "episode"}]}, + "plex_streams": {"success": True, "status": "ok", "total": 1, "directPlay": 1, "directStream": 0, "transcoding": 0, "bandwidth": 4000, "users": 1}, + "tautulli_health": {"success": True, "status": "connected"}, + "tautulli_activity": {"success": True, "status": "ok", "streamCount": 1, "sessions": [{"sessionKey": "t1", "title": "The Boys", "user": "Jens", "ipAddress": "192.168.10.x", "transcodeDecision": "directplay"}]}, + "tautulli_history": {"success": True, "status": "ok", "total": 1, "items": [{"rowId": "h1", "title": "The Boys", "user": "Jens", "duration": 3600}]}, + "tautulli_stats": {"success": True, "status": "ok", "totalPlays": 10, "totalDuration": 36000, "topUsers": [{"name": "Jens", "plays": 10, "duration": 36000}], "topLibraries": [], "topPlatforms": [], "playsByDay": []}, + "tautulli_streams": {"success": True, "status": "ok", "activeStreams": 1, "directPlay": 1, "directStream": 0, "transcoding": 0, "lanStreams": 1, "wanStreams": 0, "bandwidth": 4000}, + "tautulli_libraries": {"success": True, "status": "ok", "total": 1, "items": [{"sectionId": "1", "name": "Series", "type": "show", "count": 200}]}, + "widefrog_health": {"success": True, "status": "connected"}, + "widefrog_queue": {"success": True, "status": "ok", "total": 1, "active": 1, "queued": 0, "completed": 0, "failed": 0, "items": [{"id": "wf-1", "title": "Process media"}]}, + "widefrog_failed": {"success": True, "status": "ok", "failed": 0, "items": []}, + "widefrog_activity": {"success": True, "status": "ok", "recent": [{"message": "Processed media"}], "last_event": {"message": "Processed media"}}, + "widefrog_downloads": {"success": True, "status": "ok", "active": 1, "processing": 1, "items": [{"title": "Import"}]}, + } + + +def _patch_pipeline(monkeypatch, payloads=None): + payloads = payloads or _healthy_payloads() + + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", lambda self: payloads["sonarr_health"], raising=False) + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.wanted_missing", lambda self: payloads["sonarr_wanted"], raising=False) + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.queue_status", lambda self: payloads["sonarr_queue"], raising=False) + monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.health", lambda self: payloads["prowlarr_health"], raising=False) + monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.indexer_status", lambda self: payloads["prowlarr_indexers"], raising=False) + monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.application_status", lambda self: payloads["prowlarr_apps"], raising=False) + monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.sync_status", lambda self: payloads["prowlarr_sync"], raising=False) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.health", lambda self: payloads["deluge_health"], raising=False) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.session_status", lambda self: payloads["deluge_session"], raising=False) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.active_downloads", lambda self: payloads["deluge_active"], raising=False) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.stalled_downloads", lambda self: payloads["deluge_stalled"], raising=False) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.paused_downloads", lambda self: payloads["deluge_paused"], raising=False) + monkeypatch.setattr("backend.app.connectors.deluge.DelugeConnector.speed_status", lambda self: payloads["deluge_speeds"], raising=False) + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.health", lambda self: payloads["plex_health"], raising=False) + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.library_status", lambda self: payloads["plex_libraries"], raising=False) + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.active_sessions", lambda self: payloads["plex_sessions"], raising=False) + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.recent_media", lambda self: payloads["plex_recent"], raising=False) + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.stream_summary", lambda self: payloads["plex_streams"], raising=False) + monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.health", lambda self: payloads["tautulli_health"], raising=False) + monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.activity", lambda self: payloads["tautulli_activity"], raising=False) + monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.history", lambda self: payloads["tautulli_history"], raising=False) + monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.watch_stats", lambda self: payloads["tautulli_stats"], raising=False) + monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.stream_summary", lambda self: payloads["tautulli_streams"], raising=False) + monkeypatch.setattr("backend.app.connectors.tautulli.TautulliConnector.libraries", lambda self: payloads["tautulli_libraries"], raising=False) + monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.health", lambda self: payloads["widefrog_health"], raising=False) + monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.queue_status", lambda self: payloads["widefrog_queue"], raising=False) + monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.failed_jobs", lambda self: payloads["widefrog_failed"], raising=False) + monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.activity", lambda self: payloads["widefrog_activity"], raising=False) + monkeypatch.setattr("backend.app.connectors.widefrog.WidefrogConnector.download_status", lambda self: payloads["widefrog_downloads"], raising=False) + return payloads + + +def test_media_pipeline_summary_healthy(api_client, monkeypatch): + _patch_pipeline(monkeypatch) + + payload = api_client.get("/api/media-pipeline/summary").json() + + assert payload["success"] is True + assert payload["status"] == "healthy" + assert payload["connectors"]["sonarr"]["status"] == "connected" + assert payload["counts"]["wantedSeries"] == 3 + assert payload["counts"]["activeDownloads"] == 1 + assert payload["counts"]["plexLibraries"] == 2 + assert payload["counts"]["activeStreams"] == 1 + assert payload["counts"]["indexers"] == 4 + assert payload["risk"]["level"] == "low" + + +def test_media_pipeline_reuses_snapshot_for_burst_reads(api_client, monkeypatch): + from backend.app.services import media_pipeline_service + + payloads = _patch_pipeline(monkeypatch) + calls = {"sonarr_health": 0} + + def sonarr_health(_self): + calls["sonarr_health"] += 1 + return payloads["sonarr_health"] + + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", sonarr_health, raising=False) + media_pipeline_service._SNAPSHOT_CACHE.clear() + + summary_payload = api_client.get("/api/media-pipeline/summary").json() + issues_payload = api_client.get("/api/media-pipeline/issues").json() + + assert summary_payload["success"] is True + assert issues_payload["success"] is True + assert calls["sonarr_health"] == 1 + + +def test_media_pipeline_summary_reuses_persistent_cache_after_snapshot_loss(api_client, monkeypatch): + from backend.app.services import media_pipeline_service + + _patch_pipeline(monkeypatch) + media_pipeline_service._SNAPSHOT_CACHE.clear() + + first = api_client.get("/api/media-pipeline/summary").json() + media_pipeline_service._SNAPSHOT_CACHE.clear() + + def fail_build_snapshot(): + raise AssertionError("persistent media summary cache should avoid cold connector reads") + + monkeypatch.setattr(media_pipeline_service, "_build_snapshot", fail_build_snapshot) + + second = api_client.get("/api/media-pipeline/summary").json() + + assert second["status"] == first["status"] + assert second["counts"] == first["counts"] + assert second["connectors"] == first["connectors"] + + +def test_media_pipeline_snapshot_cache_prevents_concurrent_stampede(api_client, monkeypatch): + from concurrent.futures import ThreadPoolExecutor + from time import sleep + from backend.app.services import media_pipeline_service + + payloads = _patch_pipeline(monkeypatch) + calls = {"sonarr_health": 0} + + def sonarr_health(_self): + calls["sonarr_health"] += 1 + sleep(0.05) + return payloads["sonarr_health"] + + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", sonarr_health, raising=False) + media_pipeline_service._SNAPSHOT_CACHE.clear() + + with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(lambda _index: media_pipeline_service._snapshot_cached(), range(4))) + + assert all(result["sonarrHealth"]["success"] is True for result in results) + assert calls["sonarr_health"] == 1 + + +def test_media_pipeline_cold_snapshot_runs_connector_reads_in_parallel(api_client, monkeypatch): + from threading import Barrier + from backend.app.services import media_pipeline_service + + payloads = _patch_pipeline(monkeypatch) + barrier = Barrier(3, timeout=1) + + def wait_and_return(key): + def _inner(_self): + barrier.wait() + return payloads[key] + + return _inner + + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.health", wait_and_return("sonarr_health"), raising=False) + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.wanted_missing", wait_and_return("sonarr_wanted"), raising=False) + monkeypatch.setattr("backend.app.connectors.sonarr.SonarrConnector.queue_status", wait_and_return("sonarr_queue"), raising=False) + media_pipeline_service._SNAPSHOT_CACHE.clear() + + payload = api_client.get("/api/media-pipeline/summary").json() + + assert payload["success"] is True + assert payload["connectors"]["sonarr"]["status"] == "connected" + assert payload["counts"]["wantedSeries"] == 3 + + +def test_media_pipeline_summary_partial_when_connector_missing_config(api_client, monkeypatch): + payloads = _healthy_payloads() + payloads["prowlarr_health"] = {"success": False, "status": "missing_config", "message": "missing_config"} + payloads["prowlarr_indexers"] = {"success": False, "status": "missing_config", "total": 0, "enabled": 0, "failing": 0, "items": []} + payloads["prowlarr_sync"] = {"success": False, "status": "missing_config", "applicationsConfigured": 0, "indexersConfigured": 0, "indexersEnabled": 0, "indexersFailing": 0, "healthWarnings": 0, "healthErrors": 0} + _patch_pipeline(monkeypatch, payloads) + + payload = api_client.get("/api/media-pipeline/summary").json() + + assert payload["success"] is True + assert payload["status"] in {"partial", "missing_config"} + assert payload["connectors"]["prowlarr"]["status"] == "missing_config" + assert "prowlarr" in " ".join(payload["risk"]["reasons"]).lower() + + +def test_media_pipeline_flow_returns_nodes_and_edges(api_client, monkeypatch): + _patch_pipeline(monkeypatch) + + payload = api_client.get("/api/media-pipeline/flow").json() + + node_ids = {node["id"] for node in payload["nodes"]} + assert {"sonarr", "prowlarr", "deluge", "widefrog", "plex", "tautulli"}.issubset(node_ids) + assert {"from": "sonarr", "to": "prowlarr", "status": "healthy", "label": "indexer sync"} in payload["edges"] + assert not any(edge["from"] == "deluge" and edge["to"] == "widefrog" for edge in payload["edges"]) + assert {"from": "widefrog", "to": "plex", "status": "healthy", "label": "media processing"} in payload["edges"] + action_ids = [action["actionId"] for node in payload["nodes"] for action in node.get("actions", [])] + assert not any("delete" in action or "remove" in action for action in action_ids) + + +def test_media_pipeline_issues_detect_cross_connector_problems(api_client, monkeypatch): + payloads = _healthy_payloads() + payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": [{"name": "Indexer", "status": "error"}]} + payloads["deluge_stalled"] = {"success": True, "status": "ok", "total": 1, "items": [{"id": "stalled-1", "name": "The Boys", "state": "Error"}]} + payloads["widefrog_failed"] = {"success": True, "status": "ok", "failed": 2, "items": [{"id": "wf-fail", "title": "Import failed"}]} + payloads["tautulli_health"] = {"success": False, "status": "missing_config", "message": "missing_config"} + _patch_pipeline(monkeypatch, payloads) + + payload = api_client.get("/api/media-pipeline/issues").json() + titles = " ".join(issue["title"] for issue in payload["issues"]).lower() + + assert "indexer" in titles + assert "stalled" in titles + assert "widefrog" in titles + assert "tautulli" in titles + actions = [action for issue in payload["issues"] for action in issue["suggestedActions"]] + assert actions + assert all(action["policyDecision"] in {"allowed", "approval_required", "blocked"} for action in actions) + assert all(action.get("link") for action in actions) + assert not any(any(blocked in action["actionId"] for blocked in ["delete", "remove", "kill_session", "terminate"]) for action in actions) + + +def test_deluge_stalled_issue_includes_source_and_timestamp(api_client, monkeypatch): + payloads = _healthy_payloads() + payloads["deluge_stalled"] = {"success": True, "status": "ok", "total": 1, "items": [{"id": "stalled-1", "name": "The Boys", "state": "Downloading"}]} + _patch_pipeline(monkeypatch, payloads) + + payload = api_client.get("/api/media-pipeline/issues").json() + stalled = next(issue for issue in payload["issues"] if issue["id"] == "deluge-stalled") + + assert stalled["source"] == "deluge.stalled_downloads" + assert stalled["observedAt"] + assert any(str(item).startswith("source=deluge.stalled_downloads") for item in stalled["evidence"]) + assert any(str(item).startswith("observedAt=") for item in stalled["evidence"]) + + +def test_media_pipeline_issues_handles_review_apps_without_connector_type(api_client, monkeypatch): + from backend.app.db.models import App + from backend.app.db.session import engine + from sqlmodel import Session + + _patch_pipeline(monkeypatch) + with Session(engine) as session: + session.add(App(id="media-review-app", name="Media Review App", category="media", needs_review=True)) + session.commit() + + response = api_client.get("/api/media-pipeline/issues") + + assert response.status_code == 200 + payload = response.json() + review_issue = next(issue for issue in payload["issues"] if issue["id"] == "app-needs-review-media-review-app") + assert review_issue["affectedConnectors"] == ["media-review-app"] + assert review_issue["suggestedActions"][0]["target"] == "media-review-app" + assert review_issue["suggestedActions"][0]["link"] == "/apps/media-review-app" + + +def test_media_pipeline_downloads_combines_deluge_and_widefrog(api_client, monkeypatch): + payloads = _healthy_payloads() + payloads["deluge_stalled"] = {"success": True, "status": "ok", "total": 1, "items": [{"id": "stalled-1", "name": "The Boys", "state": "Error"}]} + payloads["widefrog_failed"] = {"success": True, "status": "ok", "failed": 1, "items": [{"id": "wf-fail", "title": "Import failed"}]} + _patch_pipeline(monkeypatch, payloads) + + payload = api_client.get("/api/media-pipeline/downloads").json() + + assert payload["active"]["total"] == 1 + assert payload["stalled"]["total"] == 1 + assert payload["failedWidefrogJobs"]["failed"] == 1 + action_ids = [action["actionId"] for action in payload["suggestedActions"]] + assert "deluge.resume_torrent" in action_ids + assert "widefrog.retry_failed" in action_ids + assert {action["target"]: action["link"] for action in payload["suggestedActions"] if action["target"] in {"deluge", "widefrog"}} == { + "deluge": "/apps/deluge", + "widefrog": "/apps/widefrog", + } + assert not any("remove" in action or "delete" in action for action in action_ids) + + +def test_media_pipeline_library_combines_plex_tautulli_widefrog(api_client, monkeypatch): + _patch_pipeline(monkeypatch) + + payload = api_client.get("/api/media-pipeline/library").json() + + assert payload["plexLibraries"]["total"] == 2 + assert payload["tautulliStats"]["totalPlays"] == 10 + assert payload["widefrogQueue"]["total"] == 1 + action_ids = [action["actionId"] for action in payload["suggestedActions"]] + assert "plex.scan_library" in action_ids + assert "plex.scan_all_libraries" in action_ids + assert "widefrog.scan_library" in action_ids + assert all(action.get("link") in {"/apps/plex", "/apps/widefrog"} for action in payload["suggestedActions"]) + assert not any("empty_trash" in action or "kill_session" in action for action in action_ids) + + +def test_media_pipeline_activity_marks_privacy_sensitive_data(api_client, monkeypatch): + _patch_pipeline(monkeypatch) + + payload = api_client.get("/api/media-pipeline/activity").json() + + assert payload["privacy"]["sensitive"] is True + assert "tautulli.history" in payload["privacy"]["actions"] + assert payload["tautulliActivity"]["streamCount"] == 1 + assert payload["plexSessions"]["total"] == 1 + assert "192.168.10.x" in str(payload) + + +def test_media_pipeline_recommendations_are_generated_from_issues(api_client, monkeypatch): + payloads = _healthy_payloads() + payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": []} + payloads["plex_streams"] = {"success": True, "status": "ok", "total": 3, "directPlay": 0, "directStream": 0, "transcoding": 3, "bandwidth": 12000, "users": 3} + _patch_pipeline(monkeypatch, payloads) + + payload = api_client.get("/api/media-pipeline/recommendations").json() + + assert payload["success"] is True + assert payload["recommendations"] + assert all("confidence" in item for item in payload["recommendations"]) + assert any("transcode" in item["title"].lower() or "indexer" in item["title"].lower() for item in payload["recommendations"]) + + +def test_media_pipeline_check_is_read_only_and_audited(api_client, monkeypatch): + _patch_pipeline(monkeypatch) + + payload = api_client.post("/api/media-pipeline/check").json() + + assert payload["success"] is True + assert payload["summary"]["status"] == "healthy" + assert payload["auditId"].startswith("audit-") + assert payload["jobId"].startswith("job-") + + +def test_media_pipeline_diagnose_returns_evidence_without_execution(api_client, monkeypatch): + payloads = _healthy_payloads() + payloads["sonarr_wanted"] = {"success": True, "status": "connected", "total": 1, "items": [{"title": "The Boys"}]} + payloads["deluge_active"] = {"success": True, "status": "ok", "total": 0, "items": []} + payloads["prowlarr_indexers"] = {"success": True, "status": "ok", "total": 3, "enabled": 2, "failing": 1, "items": []} + _patch_pipeline(monkeypatch, payloads) + + payload = api_client.post("/api/media-pipeline/diagnose", json={"query": "waarom downloadt The Boys niet?", "scope": "downloads"}).json() + + assert payload["success"] is True + assert payload["diagnosis"]["likelyCauses"] + assert payload["diagnosis"]["evidence"] + assert payload["diagnosis"]["executedActions"] == [] + assert not any("delete" in action["actionId"] or "remove" in action["actionId"] for action in payload["diagnosis"]["suggestedActions"]) + + +def test_media_pipeline_diagnose_policy_is_read_only_allowed(api_client): + payload = api_client.post( + "/api/policies/evaluate", + json={"action": "media_pipeline.diagnose", "target": "media-pipeline", "source": "manual"}, + ).json() + + assert payload["decision"] == "allowed" + assert payload["risk"] == "low" diff --git a/backend/tests/test_mediarr_validation_window.py b/backend/tests/test_mediarr_validation_window.py new file mode 100644 index 0000000..068e04a --- /dev/null +++ b/backend/tests/test_mediarr_validation_window.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts/reconcile_mediarr_validation_window.py" +SPEC = importlib.util.spec_from_file_location("mediarr_validation_window", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +window = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(window) + + +def _projects(timeout: int = 1800) -> dict: + return { + "jens/mediarr": { + "branch": "master", + "slug": "mediarr", + "source_dir": "/srv/example/appdata/mediarr/source", + "compose_files": ["docker-compose.unraid.yml"], + "compose_project": "mediarr", + "containers": ["mediarr"], + "gitea_repository_id": 42, + "gitea_webhook_secret_file": "jens__mediarr", + "validation": {**window.EXPECTED_VALIDATION, "timeout_seconds": timeout}, + }, + "jens/other": {"validation": {"timeout_seconds": 1800}}, + } + + +def test_accepts_only_exact_old_or_new_mediarr_policy() -> None: + assert window._validate(_projects()) == 1800 + assert window._validate(_projects(3600)) == 3600 + for mutate in ( + lambda value: value["jens/mediarr"]["validation"].update(timeout_seconds=7200), + lambda value: value["jens/mediarr"].update(containers=["other"]), + lambda value: value["jens/mediarr"]["validation"].update(required=False), + ): + value = _projects() + mutate(value) + with pytest.raises(RuntimeError): + window._validate(value) + + +def test_refuses_active_mediarr_delivery_and_ignores_other_projects( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(window, "INBOX", tmp_path) + (tmp_path / "other.json").write_text( + json.dumps({"repository": "jens/other", "status": "deploying"}), encoding="utf-8" + ) + window._no_active_delivery() + (tmp_path / "mediarr.json").write_text( + json.dumps({"repository": "jens/mediarr", "status": "awaiting_validation"}), + encoding="utf-8", + ) + with pytest.raises(RuntimeError, match="active delivery"): + window._no_active_delivery() + + +def test_refuses_invalid_delivery_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(window, "INBOX", tmp_path) + (tmp_path / "broken.json").write_text("{", encoding="utf-8") + with pytest.raises(RuntimeError, match="invalid"): + window._no_active_delivery() diff --git a/backend/tests/test_nginx_proxy_manager_connector.py b/backend/tests/test_nginx_proxy_manager_connector.py new file mode 100644 index 0000000..1bc225f --- /dev/null +++ b/backend/tests/test_nginx_proxy_manager_connector.py @@ -0,0 +1,927 @@ +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +def _reset_db(tmp_path, monkeypatch): + db_path = tmp_path / "npm_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.db import session + from backend.app.db.init_db import init_db + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + +def _set_npm_config( + url: str | None = "https://npm.example.test", + identity: str | None = "admin@example.test", + secret: str | None = "NPM_SECRET_VALUE", +): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis["NPM_URL"] = url + settings.apis["NPM_IDENTITY"] = identity + settings.apis["NPM_SECRET"] = secret + settings.apis["NPM_VERIFY_TLS"] = False + settings.apis["NPM_TIMEOUT_SECONDS"] = "10" + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def _proxy_hosts(): + return [ + { + "id": 1, + "domain_names": ["media.example.com"], + "forward_host": "plex", + "forward_port": 32400, + "forward_scheme": "http", + "enabled": 1, + "ssl_forced": 1, + "http2_support": 1, + "hsts_enabled": 0, + "certificate_id": 10, + "access_list_id": 2, + "block_exploits": 1, + "caching_enabled": 0, + "allow_websocket_upgrade": 1, + "locations": [{"path": "/"}], + "meta": {"letsencrypt_agree": True}, + }, + { + "id": 2, + "domain_names": ["open.example.com"], + "forward_host": "unknown-app", + "forward_port": 8080, + "forward_scheme": "http", + "enabled": True, + "ssl_forced": False, + "http2_support": False, + "hsts_enabled": False, + "certificate_id": None, + "access_list_id": 0, + "block_exploits": False, + "caching_enabled": False, + "allow_websocket_upgrade": False, + }, + ] + + +def _certificates(): + return [ + { + "id": 10, + "nice_name": "media wildcard", + "domain_names": ["media.example.com"], + "provider": "letsencrypt", + "expires_on": "2099-01-01T00:00:00Z", + }, + { + "id": 11, + "nice_name": "soon", + "domain_names": ["soon.example.com"], + "provider": "letsencrypt", + "expires_on": "2000-01-01T00:00:00Z", + }, + ] + + +def _access_lists(): + return [ + { + "id": 2, + "name": "Authentik forward auth", + "auth_type": "authentik", + "satisfy_any": False, + "items": [{"username": "redacted"}], + "clients": [{"address": "198.18.10.0/24"}], + } + ] + + +def test_npm_connector_missing_config_reports_required_env(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config(url=None, identity=None, secret=None) + + from backend.app.main import app + + with TestClient(app) as client: + payload = client.post("/api/connectors/nginx_proxy_manager/test").json() + + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert payload["missing_config"] is True + assert payload["required_env_vars"] == ["NPM_URL", "NPM_IDENTITY", "NPM_SECRET"] + assert payload["secretRef"] == "NPM_SECRET" + + _restore_config(previous) + + +def test_npm_health_success_and_auth_failure_redact_secret(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + monkeypatch.setattr(NginxProxyManagerConnector, "_request", lambda self, method, path, **kwargs: _proxy_hosts() if path == "/nginx/proxy-hosts" else [], raising=True) + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + + healthy = NginxProxyManagerConnector().health() + + assert healthy["success"] is True + assert healthy["status"] == "connected" + assert healthy["proxyHostCount"] == 2 + assert healthy["url"] == "https://npm.example.test" + assert "NPM_SECRET_VALUE" not in str(healthy) + + def fail_login(self): + raise RuntimeError("auth_failed password=NPM_SECRET_VALUE") + + monkeypatch.setattr(NginxProxyManagerConnector, "_login", fail_login, raising=True) + failed = NginxProxyManagerConnector().health() + + assert failed["success"] is False + assert failed["status"] == "auth_failed" + assert "NPM_SECRET_VALUE" not in str(failed) + assert "[REDACTED]" in str(failed) + + _restore_config(previous) + + +def test_typed_proxy_host_refuses_takeover_and_creates_only_hardened_plan_target(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.core.config import settings + + previous = _set_npm_config() + settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' + calls = [] + + def request(self, method, path, **kwargs): + calls.append((method, path, kwargs)) + if method == 'GET' and path == '/nginx/proxy-hosts': + return [] + if method == 'POST' and path == '/nginx/proxy-hosts': + return {'id': 42} + raise AssertionError((method, path)) + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='support.itworx.test', + forward_host='wsbc-portal', + forward_port=8080, + certificate_mode='managed', + force_ssl=True, + http2=True, + upload_limit_mb=256, + read_timeout_seconds=180, + ) + + assert result == { + 'success': True, + 'status': 'created', + 'message': 'proxy_host_created_from_typed_plan', + 'hostId': 42, + 'hostname': 'support.itworx.test', + } + payload = calls[-1][2]['json'] + assert payload['forward_host'] == 'wsbc-portal' + assert payload['forward_port'] == 8080 + assert payload['ssl_forced'] is True + assert payload['http2_support'] is True + assert payload['caching_enabled'] is False + assert payload['allow_websocket_upgrade'] is False + assert payload['block_exploits'] is True + assert payload['advanced_config'] == 'client_max_body_size 256m;\nproxy_read_timeout 180s;\nproxy_send_timeout 180s;' + _restore_config(previous) + + +def test_managed_proxy_requires_and_verifies_requested_access_list(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.core.config import settings + + previous = _set_npm_config() + settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' + calls = [] + + def request(self, method, path, **kwargs): + calls.append((method, path, kwargs)) + if path == '/nginx/access-lists': + return [{'id': 1, 'name': 'Jens', 'satisfy_any': False, 'items': [{'id': 7}]}] + if method == 'GET' and path == '/nginx/proxy-hosts': + if len([call for call in calls if call[:2] == ('GET', path)]) == 1: + return [] + return [{ + 'id': 42, 'domain_names': ['repopulse.itworx.test'], + 'forward_host': 'repopulse', 'forward_port': 8000, + 'access_list_id': 1, + }] + if method == 'POST' and path == '/nginx/proxy-hosts': + return {'id': 42} + raise AssertionError((method, path)) + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='repopulse.itworx.test', forward_host='repopulse', forward_port=8000, + certificate_mode='managed', force_ssl=True, http2=True, + upload_limit_mb=128, read_timeout_seconds=120, access_list_id=1, + ) + + assert result['status'] == 'created' + assert next(call[2]['json'] for call in calls if call[:2] == ('POST', '/nginx/proxy-hosts'))['access_list_id'] == 1 + _restore_config(previous) + + +def test_managed_proxy_blocks_missing_or_bypassable_access_list(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + lists = [] + calls = [] + + def request(self, method, path, **kwargs): + calls.append((method, path)) + if path == '/nginx/access-lists': + return lists + raise AssertionError('Proxy host must not be inspected or created') + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + arguments = { + 'hostname': 'repopulse.itworx.test', 'forward_host': 'repopulse', 'forward_port': 8000, + 'certificate_mode': 'managed', 'force_ssl': True, 'http2': True, + 'upload_limit_mb': 128, 'read_timeout_seconds': 120, 'access_list_id': 1, + } + assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' + lists.append({'id': 1, 'name': 'Jens', 'satisfy_any': True, 'items': [{'id': 7}]}) + assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' + lists[0]['satisfy_any'] = False + lists[0]['items'] = [] + assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' + lists[0]['items'] = [{'id': 7}] + del lists[0]['satisfy_any'] + assert NginxProxyManagerConnector().ensure_managed_proxy_host(**arguments)['status'] == 'blocked' + assert all(path == '/nginx/access-lists' for _, path in calls) + _restore_config(previous) + + +def test_preflight_access_list_verification_is_read_only_and_redacted(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + calls = [] + + def request(self, method, path, **kwargs): + calls.append((method, path, kwargs)) + return [{ + 'id': 42, 'name': 'RepoPulse operators', 'satisfy_any': False, + 'items': [{'username': 'private-user', 'password': 'private-password'}], + 'clients': [], + }] + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + result = NginxProxyManagerConnector().validate_managed_access_list(42) + + assert result == { + 'success': True, 'status': 'verified', + 'message': 'required_access_list_verified', 'accessListId': 42, + } + assert calls == [('GET', '/nginx/access-lists', { + 'params': {'limit': 1000, 'expand': 'items,clients'}, + })] + assert 'private-user' not in str(result) + assert 'private-password' not in str(result) + + +def test_managed_proxy_rejects_access_list_lost_after_creation(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.core.config import settings + + previous = _set_npm_config() + settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' + host_reads = 0 + + def request(self, method, path, **kwargs): + nonlocal host_reads + if path == '/nginx/access-lists': + return [{'id': 1, 'satisfy_any': False, 'items': [{'id': 7}]}] + if method == 'GET' and path == '/nginx/proxy-hosts': + host_reads += 1 + return [] if host_reads == 1 else [{ + 'id': 42, 'domain_names': ['repopulse.itworx.test'], + 'forward_host': 'repopulse', 'forward_port': 8000, + 'access_list_id': 0, + }] + if method == 'POST' and path == '/nginx/proxy-hosts': + return {'id': 42} + raise AssertionError((method, path)) + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='repopulse.itworx.test', forward_host='repopulse', forward_port=8000, + certificate_mode='managed', force_ssl=True, http2=True, + upload_limit_mb=128, read_timeout_seconds=120, access_list_id=1, + ) + assert result['status'] == 'ownership_unverified' + _restore_config(previous) + + +def test_enrollment_proxy_creation_requires_persisted_ownership_marker(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.core.config import settings + + previous = _set_npm_config() + settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' + requests = [] + + def request(self, method, path, **kwargs): + requests.append((method, path, kwargs)) + if method == 'POST': + return {'id': 42} + if len([item for item in requests if item[0] == 'GET']) == 1: + return [] + return [{ + 'id': 42, + 'domain_names': ['support.itworx.test'], + 'forward_host': 'wsbc-portal', + 'forward_port': 8080, + 'meta': {'appops_enrollment_id': 'enr-0123456789abcdef0123'}, + }] + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='support.itworx.test', + forward_host='wsbc-portal', + forward_port=8080, + certificate_mode='managed', + force_ssl=True, + http2=True, + upload_limit_mb=256, + read_timeout_seconds=180, + ownership_id='enr-0123456789abcdef0123', + ) + + assert result['success'] is True + assert result['status'] == 'created' + assert len([item for item in requests if item[0] == 'GET']) == 2 + _restore_config(previous) + + +def test_enrollment_proxy_creation_stops_when_ownership_is_not_persisted(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.core.config import settings + + previous = _set_npm_config() + settings.apis['NPM_LETSENCRYPT_EMAIL'] = 'ops@example.test' + calls = {'get': 0} + + def request(self, method, path, **kwargs): + if method == 'POST': + return {'id': 42} + calls['get'] += 1 + return [] if calls['get'] == 1 else [{ + 'id': 42, + 'domain_names': ['support.itworx.test'], + 'forward_host': 'wsbc-portal', + 'forward_port': 8080, + 'meta': {}, + }] + + monkeypatch.setattr(NginxProxyManagerConnector, '_request', request, raising=True) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='support.itworx.test', + forward_host='wsbc-portal', + forward_port=8080, + certificate_mode='managed', + force_ssl=True, + http2=True, + upload_limit_mb=256, + read_timeout_seconds=180, + ownership_id='enr-0123456789abcdef0123', + ) + + assert result == { + 'success': False, + 'status': 'ownership_unverified', + 'message': 'managed_proxy_creation_ownership_not_verified', + 'hostId': 42, + } + _restore_config(previous) + + +def test_typed_proxy_host_does_not_take_over_existing_hostname(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + monkeypatch.setattr( + NginxProxyManagerConnector, + '_request', + lambda self, method, path, **kwargs: [{ + 'id': 12, + 'domain_names': ['support.itworx.test'], + 'forward_host': 'other-portal', + 'forward_port': 8080, + }], + raising=True, + ) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='support.itworx.test', + forward_host='wsbc-portal', + forward_port=8080, + certificate_mode='existing', + force_ssl=True, + http2=True, + upload_limit_mb=256, + read_timeout_seconds=180, + ) + + assert result == { + 'success': False, + 'status': 'conflict', + 'message': 'hostname_owned_by_different_target', + } + _restore_config(previous) + + +def test_typed_proxy_host_rejects_insecure_existing_target(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + monkeypatch.setattr( + NginxProxyManagerConnector, + '_request', + lambda self, method, path, **kwargs: [{ + 'id': 12, + 'domain_names': ['support.itworx.test'], + 'forward_host': 'wsbc-portal', + 'forward_port': 8080, + 'ssl_forced': False, + 'certificate_id': None, + }], + raising=True, + ) + result = NginxProxyManagerConnector().ensure_managed_proxy_host( + hostname='support.itworx.test', + forward_host='wsbc-portal', + forward_port=8080, + certificate_mode='existing', + force_ssl=True, + http2=True, + upload_limit_mb=256, + read_timeout_seconds=180, + ) + + assert result == { + 'success': False, + 'status': 'conflict', + 'message': 'existing_proxy_host_does_not_meet_managed_security_contract', + } + _restore_config(previous) + + +def test_npm_proxy_hosts_certificates_and_access_lists_normalize(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + calls = [] + + def request(self, method, path, **kwargs): + calls.append((path, kwargs)) + return { + "/nginx/proxy-hosts": _proxy_hosts(), + "/nginx/certificates": _certificates(), + "/nginx/access-lists": _access_lists(), + }[path] + + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "_request", + request, + raising=True, + ) + + hosts = NginxProxyManagerConnector().proxy_hosts() + certs = NginxProxyManagerConnector().certificates() + lists = NginxProxyManagerConnector().access_lists() + + assert hosts["success"] is True + assert hosts["items"][0]["domainNames"] == ["media.example.com"] + assert hosts["items"][0]["forwardHost"] == "plex" + assert hosts["items"][0]["forwardPort"] == 32400 + assert hosts["items"][0]["sslForced"] is True + assert hosts["items"][0]["accessListId"] == 2 + assert hosts["items"][0]["locationsCount"] == 1 + assert certs["items"][0]["provider"] == "letsencrypt" + assert certs["items"][1]["isExpired"] is True + assert certs["items"][1]["isExpiringSoon"] is True + assert lists["items"][0]["name"] == "Authentik forward auth" + assert lists["items"][0]["itemsCount"] == 1 + assert all(kwargs.get("params") == {"limit": 100} for _, kwargs in calls) + + _restore_config(previous) + + +def test_npm_exposure_and_security_warnings(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "_request", + lambda self, method, path, **kwargs: { + "/nginx/proxy-hosts": _proxy_hosts(), + "/nginx/certificates": _certificates(), + "/nginx/access-lists": _access_lists(), + }[path], + raising=True, + ) + + summary = NginxProxyManagerConnector().exposure_summary() + warnings = NginxProxyManagerConnector().security_warnings() + + assert summary["totalProxyHosts"] == 2 + assert summary["enabledProxyHosts"] == 2 + assert summary["hostsWithoutCertificate"] == 1 + assert summary["hostsWithoutAccessList"] == 1 + assert summary["hostsWithoutSslForce"] == 1 + assert any(item["type"] == "host_without_certificate" for item in warnings["items"]) + assert any(item["type"] == "public_host_without_access_list" for item in warnings["items"]) + assert any(item["type"] == "ssl_not_forced" for item in warnings["items"]) + expiring = NginxProxyManagerConnector._build_warnings( + [ + { + "id": 3, + "domainNames": ["soon.example.com"], + "enabled": True, + "sslForced": True, + "hstsEnabled": True, + "certificateId": 11, + "accessListId": 2, + "forwardScheme": "https", + "forwardHost": "soon", + "forwardPort": 443, + } + ], + {11: {"isExpiringSoon": True, "expiresInDays": 30, "expiresOn": "2026-08-01T00:00:00Z"}}, + ) + expiring_warning = next(item for item in expiring if item["type"] == "certificate_expiring_soon") + assert expiring_warning["evidence"][0] == "soon.example.com" + assert expiring_warning["evidence"][1] == "30 days" + + _restore_config(previous) + + +def test_npm_reuses_read_results_within_connector_instance(monkeypatch): + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + + previous = _set_npm_config() + calls = [] + + def request(self, method, path, **kwargs): + calls.append(path) + return { + "/nginx/proxy-hosts": _proxy_hosts(), + "/nginx/certificates": _certificates(), + "/nginx/access-lists": _access_lists(), + }[path] + + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr(NginxProxyManagerConnector, "_request", request, raising=True) + + connector = NginxProxyManagerConnector() + summary = connector.exposure_summary() + hosts = connector.proxy_hosts() + warnings = connector.security_warnings() + + assert summary["success"] is True + assert hosts["total"] == 2 + assert warnings["total"] >= 1 + assert calls.count("/nginx/proxy-hosts") == 1 + assert calls.count("/nginx/certificates") == 1 + assert calls.count("/nginx/access-lists") == 1 + + _restore_config(previous) + + +def test_npm_routes_are_read_only_and_shape_is_consistent(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config() + + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.main import app + + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "_request", + lambda self, method, path, **kwargs: { + "/nginx/proxy-hosts": _proxy_hosts(), + "/nginx/certificates": _certificates(), + "/nginx/access-lists": _access_lists(), + }[path], + raising=True, + ) + + with TestClient(app) as client: + for path in [ + "/api/apps/nginx-proxy-manager/health", + "/api/apps/nginx-proxy-manager/proxy-hosts", + "/api/apps/nginx-proxy-manager/certificates", + "/api/apps/nginx-proxy-manager/certificates/expiring", + "/api/apps/nginx-proxy-manager/access-lists", + "/api/apps/nginx-proxy-manager/exposure-summary", + "/api/apps/nginx-proxy-manager/host-reachability", + "/api/apps/nginx-proxy-manager/security-warnings", + "/api/apps/npm/health", + ]: + response = client.get(path) + assert response.status_code == 200 + payload = response.json() + assert {"success", "status", "message", "data"}.issubset(payload.keys()) + assert client.post("/api/apps/nginx-proxy-manager/proxy-hosts").status_code == 405 + + _restore_config(previous) + + +def test_network_exposure_uses_npm_metadata_without_demo_hosts(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config() + + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.main import app + + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "_request", + lambda self, method, path, **kwargs: { + "/nginx/proxy-hosts": _proxy_hosts(), + "/nginx/certificates": _certificates(), + "/nginx/access-lists": _access_lists(), + }[path], + raising=True, + ) + + with TestClient(app) as client: + payload = client.get("/api/network/exposure").json() + + assert [item["host"] for item in payload] == ["media.example.com", "open.example.com"] + assert all(item["source"] == "reverse_proxy" for item in payload) + assert "unraid.example.net" not in str(payload) + assert payload[0]["protected"] is True + assert payload[0]["risk"] == "low" + assert payload[1]["protected"] is False + assert payload[1]["risk"] == "high" + + _restore_config(previous) + + +def test_network_exposure_uses_authentik_coverage_for_protection(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config() + + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.main import app + + hosts = [ + { + "id": 10, + "domain_names": ["protected.example.com"], + "forward_host": "protected-app", + "forward_port": 8080, + "forward_scheme": "http", + "enabled": True, + "ssl_forced": True, + "hsts_enabled": True, + "certificate_id": 10, + "access_list_id": 0, + }, + { + "id": 11, + "domain_names": ["open.example.com"], + "forward_host": "open-app", + "forward_port": 8081, + "forward_scheme": "http", + "enabled": True, + "ssl_forced": True, + "hsts_enabled": True, + "certificate_id": 10, + "access_list_id": 0, + }, + ] + monkeypatch.setattr(NginxProxyManagerConnector, "_login", lambda self: "runtime-token", raising=True) + monkeypatch.setattr( + NginxProxyManagerConnector, + "_request", + lambda self, method, path, **kwargs: { + "/nginx/proxy-hosts": hosts, + "/nginx/certificates": _certificates(), + "/nginx/access-lists": [], + }[path], + raising=True, + ) + monkeypatch.setattr( + AuthentikConnector, + "auth_coverage", + lambda self: { + "success": True, + "status": "ok", + "publicHosts": [ + {"hostId": 10, "coverageStatus": "protected", "authentikMatches": ["protected.example.com"]}, + {"hostId": 11, "coverageStatus": "unprotected", "authentikMatches": []}, + ], + }, + raising=True, + ) + + with TestClient(app) as client: + payload = client.get("/api/network/exposure").json() + + protected = next(item for item in payload if item["host"] == "protected.example.com") + open_host = next(item for item in payload if item["host"] == "open.example.com") + assert protected["protected"] is True + assert protected["protectionSource"] == "authentik" + assert protected["authCoverageStatus"] == "protected" + assert protected["risk"] == "low" + assert open_host["protected"] is False + assert open_host["protectionSource"] == "none" + assert open_host["risk"] == "high" + assert "No NPM access list or Authentik match" in open_host["riskReasons"] + assert open_host["externalChangeRequired"] is True + + _restore_config(previous) + + +def test_network_exposure_reads_npm_and_authentik_in_parallel(tmp_path, monkeypatch): + from threading import Barrier + + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config() + + from backend.app.connectors.authentik import AuthentikConnector + from backend.app.connectors.nginx_proxy_manager import NginxProxyManagerConnector + from backend.app.main import app + + barrier = Barrier(3, timeout=1) + + def wait_and_return(payload): + def _inner(self): + barrier.wait() + return payload + + return _inner + + monkeypatch.setattr( + NginxProxyManagerConnector, + "proxy_hosts", + wait_and_return({"success": True, "status": "ok", "items": [{"id": 10, "domainNames": ["protected.example.com"], "forwardPort": 8080, "forwardScheme": "http", "enabled": True, "sslForced": True, "certificateId": 10, "accessListId": 0}]}), + raising=True, + ) + monkeypatch.setattr( + NginxProxyManagerConnector, + "certificates", + wait_and_return({"success": True, "status": "ok", "items": [{"id": 10, "isExpired": False, "isExpiringSoon": False}]}), + raising=True, + ) + monkeypatch.setattr( + AuthentikConnector, + "auth_coverage", + wait_and_return({"success": True, "status": "ok", "publicHosts": [{"hostId": 10, "coverageStatus": "protected", "authentikMatches": ["protected.example.com"]}]}), + raising=True, + ) + + with TestClient(app) as client: + payload = client.get("/api/network/exposure").json() + + assert payload[0]["protected"] is True + assert payload[0]["protectionSource"] == "authentik" + + _restore_config(previous) + + +def test_network_exposure_reuses_persistent_cache_after_live_context_loss(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config() + + from backend.app.api import routes_observability + from backend.app.main import app + + monkeypatch.setattr( + routes_observability, + "_build_network_exposure", + lambda: [ + { + "source": "reverse_proxy", + "host": "cached.example.com", + "port": 443, + "protocol": "https", + "protected": True, + "protectionSource": "authentik", + "authCoverageStatus": "protected", + "authentikMatches": ["cached.example.com"], + "appId": "plex", + "risk": "low", + } + ], + raising=True, + ) + + with TestClient(app) as client: + first = client.get("/api/network/exposure").json() + + def fail_live_build(): + raise AssertionError("persistent network exposure cache should avoid live connector reads") + + monkeypatch.setattr(routes_observability, "_build_network_exposure", fail_live_build, raising=True) + + with TestClient(app) as client: + second = client.get("/api/network/exposure").json() + + assert second == first + assert second[0]["host"] == "cached.example.com" + + _restore_config(previous) + + +def test_network_exposure_missing_npm_config_does_not_infer_public_hosts(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + previous = _set_npm_config(url=None, identity=None, secret=None) + + from backend.app.main import app + + with TestClient(app) as client: + payload = client.get("/api/network/exposure").json() + + assert payload == [] + + _restore_config(previous) + + +def test_npm_policy_blocks_mutations_and_unknowns_fail_closed(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + from backend.app.main import app + + with TestClient(app) as client: + for action in [ + "npm.delete_proxy_host", + "npm.update_proxy_host", + "npm.renew_certificate", + "npm.modify_access_list", + "npm.create_user", + "npm.mass_edit", + "npm.unknown_mutation", + ]: + payload = client.post("/api/policies/evaluate", json={"action": action, "target": "nginx_proxy_manager", "source": "manual"}).json() + assert payload["decision"] == "blocked" + read = client.post("/api/policies/evaluate", json={"action": "npm.exposure_summary", "target": "nginx_proxy_manager", "source": "voice"}).json() + assert read["decision"] == "allowed" + + +def test_npm_inventory_classification_and_candidate(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + from backend.app.core.config import settings + from backend.app.main import app + from backend.app.services.app_classifier import classify_container + + for key in list(settings.apis): + monkeypatch.delitem(settings.apis, key) + + result = classify_container({"name": "nginx-proxy-manager", "image": "jc21/nginx-proxy-manager:latest", "ports": ["81:81"]}) + + assert result["app_type"] == "nginx_proxy_manager" + assert result["category"] == "network" + assert result["subcategory"] == "reverse_proxy" + assert result["connector"] == "nginx_proxy_manager" + assert result["criticality"] == "critical" + assert result["protected"] is True + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "ok", "containers": [{"id": "npm-container", "name": "nginx-proxy-manager", "image": "jc21/nginx-proxy-manager", "state": "running", "status": "running", "ports": ["81:81"]}]}, + raising=True, + ) + + with TestClient(app) as client: + client.post("/api/inventory/discover") + candidates = client.get("/api/connectors/nginx_proxy_manager/candidates").json() + + assert len(candidates) == 1 + assert candidates[0]["connectorId"] == "nginx_proxy_manager" + assert candidates[0]["appType"] == "nginx_proxy_manager" + assert candidates[0]["missingSecrets"] == ["NPM_URL", "NPM_IDENTITY", "NPM_SECRET"] diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py new file mode 100644 index 0000000..17c4bab --- /dev/null +++ b/backend/tests/test_notifications.py @@ -0,0 +1,1082 @@ +from pathlib import Path + +import pytest +from sqlalchemy import inspect, text +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, Session, create_engine, select + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "notifications_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def test_default_notification_rules_and_dashboard_channel_are_seeded(api_client): + rules = api_client.get('/api/notifications/rules').json() + channels = api_client.get('/api/notifications/channels').json() + + rule_ids = {rule['id'] for rule in rules} + assert 'notify-critical-safety-finding' in rule_ids + assert 'notify-coverage-incomplete' in rule_ids + assert 'notify-connector-missing-config' in rule_ids + assert all(rule['cooldownSeconds'] > 0 for rule in rules) + assert any(channel['id'] == 'dashboard' and channel['enabled'] for channel in channels) + assert {'discord', 'telegram', 'email'}.issubset({channel['id'] for channel in channels}) + + serialized = str({'rules': rules, 'channels': channels}) + assert 'TOKEN_VALUE' not in serialized + assert 'Bearer ' not in serialized + + +def test_external_notification_credentials_are_write_only_and_delivery_is_bounded(api_client, monkeypatch): + from backend.app.services.notification_channels import external + + calls = [] + monkeypatch.setattr(external, '_http_json', lambda url, payload, timeout: calls.append((url, payload, timeout)) or {'success': True, 'status': 'sent', 'httpStatus': 204}) + + updated = api_client.patch('/api/notifications/channels/discord', json={ + 'enabled': True, + 'token': 'https://discord.com/api/webhooks/123/write-only-value', + 'config': {'username': 'AppOps private', 'timeoutSeconds': 9, 'unexpected': 'discard-me'}, + }).json() + tested = api_client.post('/api/notifications/channels/discord/test').json() + listed = next(item for item in api_client.get('/api/notifications/channels').json() if item['id'] == 'discord') + + assert updated['hasSecret'] is True + assert updated['safeConfig']['username'] == 'AppOps private' + assert 'unexpected' not in updated['safeConfig'] + assert tested['status'] == 'sent' + assert calls and calls[0][2] == 9 + assert 'write-only-value' not in str(updated) + assert 'write-only-value' not in str(listed) + + +def test_external_delivery_retries_are_bounded_and_dead_lettered(api_client, monkeypatch): + from backend.app.db.models import NotificationChannel + from backend.app.db.session import engine + from backend.app.services.notification_channels import external + + api_client.patch('/api/notifications/channels/discord', json={ + 'enabled': True, + 'token': 'https://discord.com/api/webhooks/123/write-only-retry', + 'config': {'maxAttempts': 3, 'retryBackoffSeconds': 0}, + }) + calls = [] + monkeypatch.setattr(external, '_http_json', lambda *args: calls.append(args) or {'success': False, 'status': 'failed', 'reason': 'connection_failed'}) + + with Session(engine) as session: + channel = session.get(NotificationChannel, 'discord') + result = external.send_notification(session, channel, {'title': 'test', 'message': 'safe'}) + + assert len(calls) == 3 + assert result['attempts'] == 3 + assert result['deadLetter'] is True + + +def test_email_starttls_mode_is_supported(api_client, monkeypatch): + from backend.app.services.notification_channels import external + + actions = [] + + class FakeSmtp: + def __init__(self, host, port, timeout): + actions.append(('connect', host, port, timeout)) + def __enter__(self): + return self + def __exit__(self, *_): + return None + def ehlo(self): + actions.append(('ehlo',)) + def starttls(self, context): + actions.append(('starttls', bool(context))) + def login(self, username, password): + actions.append(('login', username, bool(password))) + def send_message(self, message): + actions.append(('send', message['To'])) + + monkeypatch.setattr(external.smtplib, 'SMTP', FakeSmtp) + updated = api_client.patch('/api/notifications/channels/email', json={ + 'enabled': True, + 'token': 'write-only-smtp-password', + 'config': {'host': 'smtp.internal', 'port': 587, 'username': 'appops', 'fromAddress': 'appops@internal', 'toAddress': 'ops@internal', 'smtpMode': 'starttls'}, + }).json() + result = api_client.post('/api/notifications/channels/email/test').json() + + assert updated['safeConfig']['smtpMode'] == 'starttls' + assert result['status'] == 'sent' + assert any(action[0] == 'starttls' for action in actions) + assert 'write-only-smtp-password' not in str(result) + + +def test_scheduled_notification_cycle_runs_retries_before_evaluation(api_client, monkeypatch): + from backend.app.services import maintenance_scheduler, notification_service + + calls = [] + monkeypatch.setattr(notification_service, 'retry_failed_deliveries', lambda session: calls.append('retry') or {}) + monkeypatch.setattr(notification_service, 'evaluate_source', lambda session, source: calls.append(f'evaluate:{source}') or {}) + + maintenance_scheduler.evaluate_notifications_and_retries() + + assert calls == ['retry', 'evaluate:all'] + + +def test_notification_evaluation_prefers_specific_rule_over_generic_duplicate(api_client): + from backend.app.db.session import engine + from backend.app.services import notification_service + + event = { + 'source': 'media_pipeline', + 'event_type': 'media.issue', + 'severity': 'medium', + 'title': 'Deluge stalled downloads', + 'message': '6 stalled download(s).', + 'category': 'downloads', + 'target': 'deluge', + 'dedupe_key': 'media.issue:deluge:stalled', + 'payload': {'count': 6}, + } + + with Session(engine) as session: + result = notification_service.evaluate_rules(session, [event]) + + assert result['created'] == 1 + assert result['events'][0]['ruleId'] == 'notify-deluge-stalled-downloads' + + +def test_notification_upgrade_dismisses_existing_generic_media_overlap(api_client): + from datetime import UTC, datetime, timedelta + from backend.app.db.init_db import _upgrade_duplicate_notification_events + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + + created_at = datetime.now(UTC).replace(tzinfo=None) + with Session(engine) as session: + session.add(NotificationEvent( + id='notif-generic-duplicate', rule_id='notify-media-pipeline-issue', source='media_pipeline', + event_type='media.issue', severity='medium', title='Media Pipeline issue', + message='Deluge has stalled downloads', category='media', target='media-pipeline', + dedupe_key='media_pipeline:media.issue:media-pipeline:media', status='sent', created_at=created_at, + )) + session.add(NotificationEvent( + id='notif-specific-duplicate', rule_id='notify-deluge-stalled-downloads', source='media_pipeline', + event_type='media.issue', severity='medium', title='Deluge stalled downloads', + message='6 stalled download(s).', category='downloads', target='deluge', + dedupe_key='media_pipeline:media.issue:deluge:downloads', status='sent', created_at=created_at + timedelta(milliseconds=10), + )) + session.commit() + + assert _upgrade_duplicate_notification_events(session) is True + session.commit() + + generic = session.get(NotificationEvent, 'notif-generic-duplicate') + specific = session.get(NotificationEvent, 'notif-specific-duplicate') + assert generic is not None + assert generic.status == 'dismissed' + assert generic.reason == 'superseded_by_specific_rule' + assert generic.read is True + assert specific is not None + assert specific.status == 'sent' + + +def test_notification_upgrade_compacts_only_routine_suppression_flood(api_client): + from datetime import UTC, datetime, timedelta + + from backend.app.db.init_db import _ensure_notification_suppression_index, _upgrade_suppressed_notification_events + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + + created_at = datetime.now(UTC).replace(tzinfo=None) + with engine.begin() as connection: + connection.execute(text('DROP INDEX IF EXISTS uq_notification_routine_suppression')) + with Session(engine) as session: + for index in range(3): + row_created_at = created_at + timedelta(minutes=index) + session.add(NotificationEvent( + id=f'notif-cooldown-{index}', rule_id='notify-deluge-stalled-downloads', source='media_pipeline', + event_type='media.issue', severity='medium', title='Deluge stalled downloads', + message='6 stalled download(s).', category='downloads', target='deluge', + dedupe_key='media.issue:deluge:legacy-flood', channels_json=['dashboard'], + status='suppressed', reason='cooldown', read=False, + payload_json={ + 'suppressionCount': 1, + 'firstSuppressedAt': (created_at - timedelta(minutes=5) if index == 0 else row_created_at).isoformat(), + 'lastSuppressedAt': (created_at + timedelta(minutes=9) if index == 2 else row_created_at).isoformat(), + }, + created_at=row_created_at, + )) + session.add(NotificationEvent( + id='notif-unsupported-channel', rule_id='manual-test', source='audit', + event_type='audit.notice', severity='low', title='Unsupported delivery evidence', + message='No channel accepted this event.', category='delivery', target='custom', + dedupe_key='audit.notice:unsupported', channels_json=['custom'], + status='suppressed', reason=None, read=False, created_at=created_at, + )) + session.commit() + + assert _upgrade_suppressed_notification_events(session) is True + session.commit() + _ensure_notification_suppression_index() + + routine_rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.dedupe_key == 'media.issue:deluge:legacy-flood') + ).all() + unsupported = session.get(NotificationEvent, 'notif-unsupported-channel') + + assert len(routine_rows) == 1 + assert routine_rows[0].id == 'notif-cooldown-2' + assert routine_rows[0].read is True + assert routine_rows[0].payload_json['suppressionCount'] == 3 + assert routine_rows[0].payload_json['firstSuppressedAt'] == (created_at - timedelta(minutes=5)).isoformat() + assert routine_rows[0].payload_json['lastSuppressedAt'] == (created_at + timedelta(minutes=9)).isoformat() + assert unsupported is not None + assert unsupported.read is True + assert 'uq_notification_routine_suppression' in {index['name'] for index in inspect(engine).get_indexes('notification_events')} + + +def test_notification_summary_is_lightweight_db_read(api_client, monkeypatch): + from backend.app.services import notification_service + + def fail_reconcile(_session): + raise AssertionError("summary must not run live reconciliation") + + monkeypatch.setattr(notification_service, "_reconcile_current_state", fail_reconcile) + + response = api_client.get('/api/notifications/summary') + + assert response.status_code == 200 + payload = response.json() + assert payload['activeRulesCount'] > 0 + assert 'channels' in payload + + +def test_notification_event_lists_are_lightweight_db_reads(api_client, monkeypatch): + from datetime import UTC, datetime + + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + from backend.app.services import notification_service + + with Session(engine) as session: + session.add( + NotificationEvent( + id='event-lightweight-read', + rule_id='manual-test', + source='audit', + event_type='audit.blocked_action', + severity='medium', + title='Stored event', + message='Stored event must be returned without live reconciliation', + category='ops', + target='container.stop', + dedupe_key='test:event-lightweight-read', + channels_json=['dashboard'], + status='sent', + read=False, + created_at=datetime.now(UTC), + ) + ) + session.add( + NotificationEvent( + id='event-dismissed-read', + rule_id='manual-test', + source='audit', + event_type='audit.blocked_action', + severity='medium', + title='Dismissed event', + message='Dismissed unread events stay out of the unread list', + category='ops', + target='container.restart', + dedupe_key='test:event-dismissed-read', + channels_json=['dashboard'], + status='dismissed', + read=False, + created_at=datetime.now(UTC), + ) + ) + session.commit() + + def fail_reconcile(_session): + raise AssertionError("event list reads must not run live reconciliation") + + monkeypatch.setattr(notification_service, "_reconcile_current_state", fail_reconcile) + + events_response = api_client.get('/api/notifications/events') + unread_response = api_client.get('/api/notifications/events/unread') + + assert events_response.status_code == 200 + assert unread_response.status_code == 200 + event_ids = {event['id'] for event in events_response.json()} + unread_ids = {event['id'] for event in unread_response.json()} + assert 'event-lightweight-read' in event_ids + assert 'event-lightweight-read' in unread_ids + assert 'event-dismissed-read' not in unread_ids + + +def test_home_assistant_channel_seeded_and_default_rules_are_spam_safe(api_client): + rules = {rule['id']: rule for rule in api_client.get('/api/notifications/rules').json()} + channels = {channel['id']: channel for channel in api_client.get('/api/notifications/channels').json()} + + home_assistant = channels['home_assistant'] + + assert home_assistant['type'] == 'home_assistant' + assert home_assistant['name'] == 'Home Assistant' + assert home_assistant['enabled'] is False + assert home_assistant['status'] == 'missing_config' + assert home_assistant['hasSecret'] is False + assert set(home_assistant['missing']) >= {'HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'} + assert home_assistant['safeConfig']['notifyDomain'] == 'notify' + assert home_assistant['safeConfig']['notifyService'] == 'notify' + + for rule_id in [ + 'notify-critical-safety-finding', + 'notify-high-safety-finding', + 'notify-npm-certificate-expiring', + 'notify-public-host-without-auth', + 'notify-blocked-dangerous-action', + ]: + assert 'dashboard' in rules[rule_id]['channels'] + assert 'home_assistant' in rules[rule_id]['channels'] + + for rule_id in [ + 'notify-coverage-incomplete', + 'notify-connector-missing-config', + 'notify-media-pipeline-issue', + 'notify-deluge-stalled-downloads', + 'notify-voice-auth-failure', + ]: + assert rules[rule_id]['channels'] == ['dashboard'] + + serialized = str({'rules': rules, 'channels': channels}) + assert 'HOME_ASSISTANT_TOKEN_VALUE' not in serialized + assert 'Bearer ' not in serialized + + +def test_home_assistant_connector_test_uses_notification_channel_missing_config(api_client): + payload = api_client.post('/api/connectors/home_assistant/test').json() + + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['missing_config'] is True + assert payload['required_env_vars'] == ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'] + assert payload['secretRef'] == 'HOME_ASSISTANT_TOKEN' + assert payload['message'] != 'test_not_implemented' + assert 'HOME_ASSISTANT_TOKEN_VALUE' not in str(payload) + + +def test_home_assistant_connector_test_uses_notification_channel_connection(api_client): + api_client.patch('/api/notifications/channels/home_assistant', json={ + 'enabled': False, + 'token': 'HOME_ASSISTANT_TOKEN_VALUE', + 'config': { + 'url': 'http://homeassistant.local:8123', + 'notifyDomain': 'notify', + 'notifyService': 'persistent_notification', + 'verifyTls': False, + 'timeoutSeconds': 5, + 'dryRun': True, + }, + }) + + payload = api_client.post('/api/connectors/home_assistant/test').json() + + assert payload['success'] is True + assert payload['status'] == 'connected' + assert payload['missing_config'] is False + assert payload['required_env_vars'] == ['HOME_ASSISTANT_URL', 'HOME_ASSISTANT_TOKEN'] + assert payload['secretRef'] == 'HOME_ASSISTANT_TOKEN' + assert 'HOME_ASSISTANT_TOKEN_VALUE' not in str(payload) + + +def test_home_assistant_channel_patch_stores_secret_and_test_notification_is_audited(api_client): + from backend.app.db.models import AuditEvent + from backend.app.db.session import engine + from backend.app.services.secret_store import get_secret + + patched = api_client.patch('/api/notifications/channels/home_assistant', json={ + 'enabled': True, + 'token': 'HOME_ASSISTANT_TOKEN_VALUE', + 'config': { + 'url': 'http://homeassistant.local:8123', + 'notifyDomain': 'notify', + 'notifyService': 'persistent_notification', + 'target': 'operator_phone', + 'verifyTls': False, + 'timeoutSeconds': 5, + 'dryRun': True, + }, + }).json() + + assert patched['enabled'] is True + assert patched['hasSecret'] is True + assert patched['missing'] == [] + assert patched['safeConfig']['url'] == 'http://homeassistant.local:8123' + assert patched['safeConfig']['target'] == 'operator_phone' + assert 'HOME_ASSISTANT_TOKEN_VALUE' not in str(patched) + + with Session(engine) as session: + assert get_secret(session, 'HOME_ASSISTANT_TOKEN') == 'HOME_ASSISTANT_TOKEN_VALUE' + + test_result = api_client.post('/api/notifications/channels/home_assistant/test').json() + + assert test_result['success'] is True + assert test_result['status'] == 'sent' + assert test_result['channel']['hasSecret'] is True + assert test_result['channel']['lastTestAt'] + + with Session(engine) as session: + audit = session.exec( + select(AuditEvent).where(AuditEvent.action_id == 'notification.channel.test') + ).first() + assert audit is not None + assert audit.target == 'home_assistant' + assert audit.result == 'success' + + serialized = str({'patch': patched, 'test': test_result}) + assert 'HOME_ASSISTANT_TOKEN_VALUE' not in serialized + assert 'Bearer ' not in serialized + + +def test_home_assistant_delivery_records_channel_results_and_preserves_dashboard(api_client): + from backend.app.db.session import engine + from backend.app.services import notification_service + + api_client.patch('/api/notifications/channels/home_assistant', json={ + 'enabled': True, + 'token': 'HOME_ASSISTANT_TOKEN_VALUE', + 'config': { + 'url': 'http://homeassistant.local:8123', + 'notifyDomain': 'notify', + 'notifyService': 'persistent_notification', + 'verifyTls': False, + 'dryRun': True, + }, + }) + + event = { + 'source': 'safety', + 'event_type': 'safety.finding', + 'severity': 'critical', + 'title': 'Public host without auth coverage', + 'message': 'Public endpoint needs auth coverage.', + 'category': 'auth', + 'target': 'npm:widefrog.example', + 'dedupe_key': 'safety.finding:public-host-auth-test', + 'payload': {'secretToken': 'HOME_ASSISTANT_TOKEN_VALUE'}, + } + + with Session(engine) as session: + first = notification_service.evaluate_rules(session, [event]) + second = notification_service.evaluate_rules(session, [event]) + + assert first['created'] == 1 + assert first['events'][0]['ruleId'] == 'notify-public-host-without-auth' + assert all(created['status'] == 'sent' for created in first['events']) + assert all(created['channelResults']['dashboard']['status'] == 'sent' for created in first['events']) + assert all(created['channelResults']['home_assistant']['status'] == 'sent' for created in first['events']) + assert 'HOME_ASSISTANT_TOKEN_VALUE' not in str(first) + + assert second['created'] == 0 + assert second['suppressed'] == 1 + assert all(event['channelResults']['home_assistant']['status'] == 'suppressed' for event in second['suppressedEvents']) + + +def test_repeated_cooldown_suppression_stays_bounded_and_out_of_unread(api_client): + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + from backend.app.services import notification_service + + event = { + 'source': 'media_pipeline', + 'event_type': 'media.issue', + 'severity': 'medium', + 'title': 'Deluge stalled downloads', + 'message': '6 stalled download(s).', + 'category': 'downloads', + 'target': 'deluge', + 'dedupe_key': 'media.issue:deluge:bounded-suppression', + 'payload': {'count': 6}, + } + + with Session(engine) as session: + first = notification_service.evaluate_rules(session, [event]) + repeated = [notification_service.evaluate_rules(session, [event]) for _ in range(7)] + stored_dedupe_key = first['events'][0]['dedupeKey'] + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.dedupe_key == stored_dedupe_key) + .order_by(NotificationEvent.created_at.asc()) + ).all() + + assert first['created'] == 1 + assert all(result['created'] == 0 and result['suppressed'] == 1 for result in repeated) + assert len({result['suppressedEvents'][0]['suppressedUntil'] for result in repeated}) == 1 + assert len(rows) == 2 + assert [row.status for row in rows] == ['sent', 'suppressed'] + assert rows[1].read is True + assert rows[1].payload_json['suppressionCount'] == 7 + + summary = api_client.get('/api/notifications/summary').json() + unread = api_client.get('/api/notifications/events/unread').json() + assert summary['unreadCount'] == 1 + assert {item['status'] for item in unread} == {'sent'} + + +def test_concurrent_routine_suppression_updates_one_atomic_counter(api_client): + from concurrent.futures import ThreadPoolExecutor + from datetime import timedelta + + from backend.app.db.models import NotificationEvent, NotificationRule + from backend.app.db.session import engine + from backend.app.services import notification_service + + event = { + 'source': 'media_pipeline', + 'event_type': 'media.issue', + 'severity': 'medium', + 'title': 'Concurrent stalled downloads', + 'message': 'The same routine signal arrived concurrently.', + 'category': 'downloads', + 'target': 'deluge', + 'dedupe_key': 'media.issue:deluge:concurrent-suppression', + 'payload': {'count': 4}, + } + + def record(_: int) -> str: + with Session(engine) as session: + rule = session.get(NotificationRule, 'notify-deluge-stalled-downloads') + assert rule is not None + row = notification_service._record_suppression( + session, + rule, + event, + 'cooldown', + notification_service._now() + timedelta(minutes=15), + ) + return row.id + + with ThreadPoolExecutor(max_workers=4) as executor: + row_ids = list(executor.map(record, range(4))) + + with Session(engine) as session: + rows = session.exec( + select(NotificationEvent) + .where(NotificationEvent.reason == 'cooldown') + .where(NotificationEvent.title == 'Concurrent stalled downloads') + ).all() + + assert len(set(row_ids)) == 1 + assert len(rows) == 1 + assert rows[0].payload_json['suppressionCount'] == 4 + + +def test_quiet_hours_use_the_exact_window_end_and_release_at_boundary(): + from datetime import datetime + + from backend.app.db.models import NotificationRule + from backend.app.services import notification_service + + rule = NotificationRule( + id='quiet-hours-test', + name='Quiet hours test', + source='audit', + event_type='audit.notice', + quiet_hours_json={ + 'enabled': True, + 'startHour': 22, + 'endHour': 7, + 'allowCriticalOverride': True, + }, + ) + medium_event = {'severity': 'medium'} + + assert notification_service._quiet_hours_until( + rule, + medium_event, + datetime(2026, 8, 23, 23, 30), + ) == datetime(2026, 8, 24, 7, 0) + assert notification_service._quiet_hours_until( + rule, + medium_event, + datetime(2026, 8, 24, 6, 59), + ) == datetime(2026, 8, 24, 7, 0) + assert notification_service._quiet_hours_until( + rule, + medium_event, + datetime(2026, 8, 24, 7, 0), + ) is None + assert notification_service._quiet_hours_until( + rule, + {'severity': 'critical'}, + datetime(2026, 8, 23, 23, 30), + ) is None + + +def test_retry_query_selects_old_pending_delivery_before_recent_noise(api_client, monkeypatch): + from datetime import UTC, datetime, timedelta + + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + from backend.app.services import notification_service + + created_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1) + with Session(engine) as session: + session.add(NotificationEvent( + id='notif-pending-old', rule_id='manual-test', source='audit', event_type='audit.notice', + severity='high', title='Pending delivery', message='Retry this delivery.', + dedupe_key='audit.notice:pending-old', channels_json=['dashboard'], status='sent', + payload_json={'deliveryPending': True, 'deliveryRetryCount': 1}, created_at=created_at, + )) + for index in range(125): + session.add(NotificationEvent( + id=f'notif-noise-{index}', rule_id='manual-test', source='audit', event_type='audit.notice', + severity='low', title='Recent notification', message='No retry required.', + dedupe_key=f'audit.notice:noise-{index}', channels_json=['dashboard'], status='sent', + payload_json={'deliveryPending': False}, created_at=created_at + timedelta(minutes=index + 1), + )) + session.commit() + + retried_ids: list[str] = [] + + def deliver(_session, row): + retried_ids.append(row.id) + row.payload_json = {**row.payload_json, 'deliveryPending': False} + return row + + monkeypatch.setattr(notification_service, '_deliver_event', deliver) + result = notification_service.retry_failed_deliveries(session, limit=1) + + assert retried_ids == ['notif-pending-old'] + assert result == {'retried': 1, 'recovered': 1, 'deadLettered': 0} + + +def test_notification_event_limit_is_bounded_by_api(api_client): + assert api_client.get('/api/notifications/events?limit=1').status_code == 200 + assert api_client.get('/api/notifications/events?limit=501').status_code == 422 + assert api_client.get('/api/notifications/events?offset=-1').status_code == 422 + assert api_client.get('/api/notifications/events/unread?limit=501').status_code == 422 + + +def test_bounded_event_inbox_prioritizes_unread_over_newer_dismissed_history(api_client): + from datetime import UTC, datetime, timedelta + + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + + created_at = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=1) + with Session(engine) as session: + session.add(NotificationEvent( + id='notif-unread-priority', rule_id='manual-test', source='safety', event_type='safety.finding', + severity='critical', title='Unread safety signal', message='This must stay visible.', + dedupe_key='safety.finding:unread-priority', channels_json=['dashboard'], status='sent', + read=False, created_at=created_at, + )) + for index in range(260): + session.add(NotificationEvent( + id=f'notif-dismissed-history-{index}', rule_id='manual-test', source='audit', event_type='audit.notice', + severity='low', title='Dismissed history', message='Historical event.', + dedupe_key=f'audit.notice:dismissed-history-{index}', channels_json=['dashboard'], status='dismissed', + read=True, created_at=created_at + timedelta(minutes=index + 1), + )) + session.commit() + + bounded = api_client.get('/api/notifications/events?limit=10').json() + active_only = api_client.get('/api/notifications/events?limit=10&include_dismissed=false').json() + older_page = api_client.get('/api/notifications/events?limit=10&offset=250').json() + + assert bounded[0]['id'] == 'notif-unread-priority' + assert any(event['id'] == 'notif-unread-priority' for event in active_only) + assert all(event['status'] != 'dismissed' for event in active_only) + assert len(older_page) == 10 + + +def test_unread_event_pages_remain_reachable_with_stable_offset(api_client): + from datetime import UTC, datetime, timedelta + + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + + created_at = datetime.now(UTC).replace(tzinfo=None) + with Session(engine) as session: + for index in range(3): + session.add(NotificationEvent( + id=f'notif-unread-page-{index}', rule_id='manual-test', source='safety', event_type='safety.finding', + severity='high', title=f'Unread page {index}', message='Paged unread event.', + dedupe_key=f'safety.finding:unread-page-{index}', channels_json=['dashboard'], status='sent', + read=False, created_at=created_at + timedelta(seconds=index), + )) + session.commit() + + first = api_client.get('/api/notifications/events/unread?limit=1&offset=0').json() + second = api_client.get('/api/notifications/events/unread?limit=1&offset=1').json() + + assert [event['id'] for event in first] == ['notif-unread-page-2'] + assert [event['id'] for event in second] == ['notif-unread-page-1'] + + +def test_home_assistant_failure_does_not_break_dashboard_delivery(api_client, monkeypatch): + from backend.app.db.session import engine + from backend.app.services import notification_service + from backend.app.services.notification_channels import home_assistant + + api_client.patch('/api/notifications/channels/home_assistant', json={ + 'enabled': True, + 'token': 'HOME_ASSISTANT_TOKEN_VALUE', + 'config': { + 'url': 'http://homeassistant.local:8123', + 'verifyTls': False, + 'dryRun': False, + }, + }) + + monkeypatch.setattr( + home_assistant, + 'send_notification', + lambda session, channel, event: {'status': 'failed', 'reason': 'auth_failed', 'detail': 'auth_failed'}, + ) + + with Session(engine) as session: + result = notification_service.evaluate_rules(session, [{ + 'source': 'audit', + 'event_type': 'audit.blocked_action', + 'severity': 'high', + 'title': 'Blocked dangerous action attempt', + 'message': 'volume.delete was blocked.', + 'category': 'policy', + 'target': 'array', + 'dedupe_key': 'audit.blocked_action:ha-failure-test', + 'payload': {}, + }]) + + assert result['created'] == 1 + created = result['events'][0] + assert created['status'] == 'sent' + assert created['channelResults']['dashboard']['status'] == 'sent' + assert created['channelResults']['home_assistant']['status'] == 'failed' + assert created['channelResults']['home_assistant']['reason'] == 'auth_failed' + + +def test_safety_evaluation_creates_events_and_dedupes_coverage(api_client): + first = api_client.post('/api/notifications/evaluate/safety').json() + second = api_client.post('/api/notifications/evaluate/safety').json() + events = api_client.get('/api/notifications/events').json() + + assert first['created'] >= 1 + assert first['suppressed'] >= 0 + assert second['created'] == 0 + assert second['suppressed'] >= 1 + assert any(event['source'] == 'safety' and event['eventType'] == 'safety.finding' for event in events) + assert all(event['dedupeKey'] for event in events) + + +def test_global_safety_score_alone_does_not_create_notification(api_client, monkeypatch): + from backend.app.services import notification_service + + def score_only_events(session): + return [{ + 'source': 'safety', + 'event_type': 'safety.score', + 'severity': 'critical', + 'title': 'Global score critical', + 'message': 'Score is low without concrete finding.', + 'category': 'score', + 'target': 'safety-center', + 'dedupe_key': 'safety.score:critical', + 'payload': {'score': 40}, + }] + + monkeypatch.setattr(notification_service, '_safety_events', score_only_events) + + result = api_client.post('/api/notifications/evaluate/safety').json() + + assert result['created'] == 0 + assert result['suppressed'] == 0 + + +def test_connector_media_and_audit_evaluation_create_rule_bound_events(api_client): + from backend.app.db.models import AuditEvent + from backend.app.db.session import engine + + with Session(engine) as session: + session.add(AuditEvent( + id='audit-blocked-dangerous', + source='voice', + identity='operator', + action_id='volume.delete', + target='array', + target_type='volume', + risk='critical', + policy_decision='blocked', + result='blocked', + reason='Dangerous action blocked.', + )) + session.commit() + + connector_result = api_client.post('/api/notifications/evaluate/connectors').json() + media_result = api_client.post('/api/notifications/evaluate/media').json() + all_result = api_client.post('/api/notifications/evaluate').json() + + assert connector_result['created'] >= 1 + assert media_result['created'] >= 0 + assert all_result['created'] >= 1 or all_result['suppressed'] >= 1 + + events = api_client.get('/api/notifications/events').json() + assert any(event['eventType'] == 'connector.missing_config' for event in events) + assert any(event['eventType'] == 'audit.blocked_action' for event in events) + + +def test_media_notification_evaluation_passes_session_to_media_summary(api_client, monkeypatch): + from backend.app.services import media_pipeline_service + + observed = {'session': None} + + def media_summary(session): + observed['session'] = session + return { + 'success': True, + 'status': 'warning', + 'risk': {'level': 'medium', 'reasons': ['Deluge has stalled downloads']}, + 'counts': {'stalledDownloads': 1, 'failedWidefrogJobs': 0}, + } + + monkeypatch.setattr(media_pipeline_service, 'summary', media_summary) + + response = api_client.post('/api/notifications/evaluate/media') + + assert response.status_code == 200 + assert observed['session'] is not None + payload = response.json() + assert payload['evaluated'] == 1 + assert payload['created'] == 1 + assert payload['events'][0]['ruleId'] == 'notify-deluge-stalled-downloads' + + +def test_connector_evaluation_auto_resolves_stale_connector_events(api_client): + from backend.app.db.models import Connector, NotificationEvent + from backend.app.db.session import engine + + api_client.post('/api/notifications/evaluate/connectors') + + with Session(engine) as session: + deluge = session.get(Connector, 'deluge') + deluge.status = 'connected' + session.add(deluge) + session.commit() + + api_client.post('/api/notifications/evaluate/connectors') + + with Session(engine) as session: + stale = session.exec( + select(NotificationEvent) + .where(NotificationEvent.source == 'connector') + .where(NotificationEvent.target == 'deluge') + .order_by(NotificationEvent.created_at.desc()) + ).first() + + assert stale.status == 'dismissed' + assert stale.reason == 'auto_resolved' + assert stale.read is True + + +def test_safety_evaluation_auto_resolves_stale_safety_events(api_client, monkeypatch): + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + from backend.app.services import notification_service + + def stale_safety_event(session): + return [{ + 'source': 'safety', + 'event_type': 'safety.finding', + 'severity': 'high', + 'title': 'Critical discovered app needs review: NginxProxyManager', + 'message': 'A critical-looking discovered app still needs review or mapping.', + 'category': 'inventory', + 'target': 'NginxProxyManager', + 'dedupe_key': 'safety.finding:ai-sec-inventory-critical-review-dc-old-npm', + 'payload': {'findingId': 'ai-sec-inventory-critical-review-dc-old-npm'}, + }] + + monkeypatch.setattr(notification_service, '_safety_events', stale_safety_event) + api_client.post('/api/notifications/evaluate/safety') + monkeypatch.setattr(notification_service, '_safety_events', lambda session: []) + result = api_client.post('/api/notifications/evaluate/safety').json() + + with Session(engine) as session: + stale = session.exec( + select(NotificationEvent) + .where(NotificationEvent.source == 'safety') + .where(NotificationEvent.event_type == 'safety.finding') + .where(NotificationEvent.target == 'NginxProxyManager') + ).first() + + assert result['resolved'] == 1 + assert stale.status == 'dismissed' + assert stale.reason == 'auto_resolved' + assert stale.read is True + + +def test_notification_lifecycle_rule_disable_and_secret_safe_channel_test(api_client): + rules = api_client.get('/api/notifications/rules').json() + high_rule = next(rule for rule in rules if rule['id'] == 'notify-high-safety-finding') + patched = api_client.patch(f"/api/notifications/rules/{high_rule['id']}", json={'enabled': False}).json() + assert patched['enabled'] is False + + api_client.post('/api/notifications/evaluate/safety') + event = api_client.get('/api/notifications/events').json()[0] + read = api_client.post(f"/api/notifications/events/{event['id']}/mark-read").json() + dismissed = api_client.post(f"/api/notifications/events/{event['id']}/dismiss").json() + + assert read['status'] == 'sent' + assert read['read'] is True + assert dismissed['status'] == 'dismissed' + + channel = api_client.post('/api/notifications/channels', json={ + 'id': 'ha-main', + 'type': 'home_assistant', + 'name': 'Home Assistant', + 'enabled': True, + 'config': {'url': 'http://homeassistant.local:8123', 'token': 'TOKEN_VALUE', 'dryRun': True}, + 'secretRefs': ['HOME_ASSISTANT_TOKEN'], + }).json() + test_result = api_client.post(f"/api/notifications/channels/{channel['id']}/test").json() + + serialized = str({'channel': channel, 'test': test_result}) + assert 'TOKEN_VALUE' not in serialized + assert 'Bearer ' not in serialized + assert test_result['success'] is True + assert test_result['status'] in {'prepared', 'sent', 'missing_config'} + assert test_result['channel']['lastTestAt'] + listed_channel = next(item for item in api_client.get('/api/notifications/channels').json() if item['id'] == channel['id']) + assert listed_channel['lastTestAt'] == test_result['channel']['lastTestAt'] + + +def test_notification_evaluation_does_not_create_jobs_or_approvals(api_client): + before_jobs = api_client.get('/api/jobs').json() + before_approvals = api_client.get('/api/approvals').json() + + api_client.post('/api/notifications/evaluate') + + after_jobs = api_client.get('/api/jobs').json() + after_approvals = api_client.get('/api/approvals').json() + assert before_jobs == after_jobs + assert before_approvals == after_approvals + + +def test_notification_tables_store_redacted_payloads(api_client): + api_client.post('/api/notifications/evaluate/safety') + + from backend.app.db.models import NotificationEvent + from backend.app.db.session import engine + + with Session(engine) as session: + rows = session.exec(select(NotificationEvent)).all() + + serialized = str([row.payload_json for row in rows]) + assert 'TOKEN_VALUE' not in serialized + assert 'Bearer ' not in serialized + assert 'api_key' not in serialized.lower() + + +def test_init_db_upgrades_existing_production_db_without_resetting_setup_or_secrets(tmp_path, monkeypatch): + db_path = tmp_path / "upgrade_existing.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APPOPS_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("APPOPS_ENV", "production") + monkeypatch.setenv("APPOPS_SECRET_KEY", "promotion-upgrade-test-master-key") + monkeypatch.delenv("APPOPS_SECRET_KEY_FILE", raising=False) + + from backend.app.core.config import settings + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.db.models import AppSetting, NotificationChannel, NotificationRule, Secret + from backend.app.services.secret_store import get_secret, set_secret + from backend.app.services.setup_service import get_setup_status, serialize_connector_settings + + previous = { + "db_path": settings.db_path, + "data_dir": settings.data_dir, + "environment": settings.environment, + "secret_key": settings.secret_key, + "secret_key_source": settings.secret_key_source, + "secret_key_file": settings.secret_key_file, + "apis": dict(settings.apis), + } + settings.db_path = str(db_path) + settings.data_dir = str(tmp_path / "data") + settings.environment = "production" + settings.secret_key = "promotion-upgrade-test-master-key" + settings.secret_key_source = "env" + settings.secret_key_file = None + for key in settings.apis: + if key.startswith("PORTAINER_") or key.startswith("UNRAID_"): + settings.apis[key] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + db_session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(AppSetting(key="setup:first_run_completed", value_json={"value": True}, is_secret=False)) + session.add(AppSetting(key="connector:portainer:config", value_json={"url": "http://portainer.local", "endpointId": "1", "verifyTls": False}, is_secret=False)) + set_secret(session, "PORTAINER_API_KEY", "PORTAINER_UPGRADE_SECRET", used_by=["portainer"], commit=False) + session.commit() + + with engine.begin() as connection: + connection.execute(text("DROP TABLE notification_channels")) + connection.execute(text(""" + CREATE TABLE notification_channels ( + id VARCHAR NOT NULL PRIMARY KEY, + type VARCHAR NOT NULL, + name VARCHAR NOT NULL, + enabled BOOLEAN NOT NULL, + config_json JSON, + secret_refs_json JSON, + created_at DATETIME, + updated_at DATETIME + ) + """)) + connection.execute(text(""" + INSERT INTO notification_channels (id, type, name, enabled, config_json, secret_refs_json, created_at, updated_at) + VALUES ('dashboard', 'dashboard', 'Dashboard', 1, '{"delivery":"in_app"}', '[]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """)) + + try: + init_db() + init_db() + + inspector = inspect(engine) + assert "notification_rules" in inspector.get_table_names() + assert "notification_events" in inspector.get_table_names() + assert "last_test_at" in {column["name"] for column in inspector.get_columns("notification_channels")} + + with Session(engine) as session: + assert get_setup_status(session)["firstRunCompleted"] is True + assert serialize_connector_settings(session, "portainer")["hasSecret"] is True + assert get_secret(session, "PORTAINER_API_KEY") == "PORTAINER_UPGRADE_SECRET" + assert len(session.exec(select(NotificationChannel).where(NotificationChannel.id == "dashboard")).all()) == 1 + assert len(session.exec(select(NotificationRule).where(NotificationRule.id == "notify-high-safety-finding")).all()) == 1 + assert len(session.exec(select(Secret).where(Secret.id == "PORTAINER_API_KEY")).all()) == 1 + finally: + settings.db_path = previous["db_path"] + settings.data_dir = previous["data_dir"] + settings.environment = previous["environment"] + settings.secret_key = previous["secret_key"] + settings.secret_key_source = previous["secret_key_source"] + settings.secret_key_file = previous["secret_key_file"] + settings.apis = previous["apis"] diff --git a/backend/tests/test_performance_regressions.py b/backend/tests/test_performance_regressions.py new file mode 100644 index 0000000..a4fb634 --- /dev/null +++ b/backend/tests/test_performance_regressions.py @@ -0,0 +1,35 @@ +from sqlmodel import Session, SQLModel, create_engine + + +def test_safety_signature_ignores_identical_discovery_history(tmp_path): + from backend.app.db.models import DiscoveredContainer, DiscoveryRun + from backend.app.services import inventory_discovery, safety_center_service + + engine = create_engine(f"sqlite:///{tmp_path / 'safety-signature.db'}") + SQLModel.metadata.create_all(engine) + + with Session(engine) as session: + session.add(DiscoveryRun(id="disc-1", status="success", containers_found=1)) + session.add(DiscoveredContainer( + id="dc-1", discovery_run_id="disc-1", container_id="container-1", + name="AppOps", image="appops:test", state="running", status="running", + import_status="managed", matched_app_id="appops", + )) + session.commit() + inventory_discovery.invalidate_discovery_cache() + with Session(engine) as session: + first = safety_center_service._source_signature(session) + + with Session(engine) as session: + session.add(DiscoveryRun(id="disc-2", status="success", containers_found=1)) + session.add(DiscoveredContainer( + id="dc-2", discovery_run_id="disc-2", container_id="container-1", + name="AppOps", image="appops:test", state="running", status="running", + import_status="managed", matched_app_id="appops", + )) + session.commit() + inventory_discovery.invalidate_discovery_cache() + with Session(engine) as session: + second = safety_center_service._source_signature(session) + + assert second == first diff --git a/backend/tests/test_plex_connector.py b/backend/tests/test_plex_connector.py new file mode 100644 index 0000000..7618c1f --- /dev/null +++ b/backend/tests/test_plex_connector.py @@ -0,0 +1,345 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "plex_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + previous_apis = dict(settings.apis) + for key in ["PLEX_URL", "PLEX_TOKEN", "PLEX_VERIFY_TLS", "PLEX_TIMEOUT_SECONDS", "PLEX_DEFAULT_LIBRARY_ID", "PLEX_MACHINE_IDENTIFIER"]: + settings.apis[key] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + settings.apis = previous_apis + + +def _set_plex_config(url: str | None = "http://plex:32400", token: str | None = "PLEX_SECRET_TOKEN"): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis["PLEX_URL"] = url + settings.apis["PLEX_TOKEN"] = token + settings.apis["PLEX_VERIFY_TLS"] = False + settings.apis["PLEX_TIMEOUT_SECONDS"] = "10" + settings.apis["PLEX_DEFAULT_LIBRARY_ID"] = None + settings.apis["PLEX_MACHINE_IDENTIFIER"] = None + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def _server_payload(): + return { + "MediaContainer": { + "friendlyName": "Widefrog Plex", + "machineIdentifier": "machine-123456789", + "version": "1.40.2", + "platform": "Linux", + "updatedAt": 1700000000, + "myPlex": True, + } + } + + +def _libraries_payload(): + return { + "MediaContainer": { + "Directory": [ + {"key": "1", "title": "Movies", "type": "movie", "agent": "tv.plex.agents.movie", "scanner": "Plex Movie", "Location": [{"path": "/srv/example/media/movies"}]}, + {"key": "2", "title": "Shows", "type": "show", "agent": "tv.plex.agents.series", "scanner": "Plex Series", "Location": [{"path": "/srv/example/media/tv"}]}, + ] + } + } + + +def _sessions_payload(): + return { + "MediaContainer": { + "size": 2, + "Metadata": [ + { + "sessionKey": "11", + "title": "Direct Movie", + "type": "movie", + "User": {"title": "Jens"}, + "Player": {"title": "Apple TV", "state": "playing", "product": "Plex", "platform": "tvOS", "address": "198.18.10.55"}, + "Session": {"bandwidth": 12000}, + "Media": [{"Part": [{"decision": "directplay"}], "videoDecision": "directplay", "audioDecision": "directplay"}], + "viewOffset": 120000, + "duration": 240000, + }, + { + "sessionKey": "12", + "title": "Transcoded Episode", + "type": "episode", + "User": {"title": "Remote"}, + "Player": {"title": "Chrome", "state": "playing", "product": "Plex Web", "platform": "Windows", "address": "203.0.113.9"}, + "Session": {"bandwidth": 4500}, + "TranscodeSession": {"videoDecision": "transcode", "audioDecision": "copy"}, + "Media": [{"videoDecision": "transcode", "audioDecision": "copy"}], + "viewOffset": 50000, + "duration": 100000, + }, + ], + } + } + + +def _recent_payload(): + return { + "MediaContainer": { + "Metadata": [ + {"title": "Arrival", "type": "movie", "year": 2016, "librarySectionTitle": "Movies", "addedAt": 1700000000, "thumb": "/library/metadata/1/thumb", "summary": "A quiet first contact story."}, + {"title": "Pilot", "grandparentTitle": "Example Show", "type": "episode", "year": 2024, "librarySectionTitle": "Shows", "addedAt": 1700000500}, + ] + } + } + + +def test_plex_connector_missing_config_reports_required_env(api_client): + response = api_client.post("/api/connectors/plex/test") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert payload["missing_config"] is True + assert payload["required_env_vars"] == ["PLEX_URL", "PLEX_TOKEN"] + assert payload["secretRef"] == "PLEX_TOKEN" + + +def test_plex_health_success_normalizes_response(monkeypatch): + from backend.app.connectors.plex import PlexConnector + + previous = _set_plex_config() + monkeypatch.setattr(PlexConnector, "_request", lambda self, path, params=None: _server_payload(), raising=True) + + payload = PlexConnector().health() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["friendlyName"] == "Widefrog Plex" + assert payload["version"] == "1.40.2" + assert payload["machineIdentifier"] == "machine-123456789" + assert payload["url"] == "http://plex:32400" + assert "PLEX_SECRET_TOKEN" not in str(payload) + + _restore_config(previous) + + +def test_plex_auth_failure_redacts_token(monkeypatch): + from backend.app.connectors.plex import PlexConnector + + previous = _set_plex_config() + + def fail_request(self, path, params=None): + raise RuntimeError("auth_failed token=PLEX_SECRET_TOKEN") + + monkeypatch.setattr(PlexConnector, "_request", fail_request, raising=True) + + payload = PlexConnector().health() + + assert payload["success"] is False + assert payload["status"] == "auth_failed" + assert "PLEX_SECRET_TOKEN" not in str(payload) + assert "[REDACTED]" in str(payload) + + _restore_config(previous) + + +def test_plex_library_status_normalizes_sections(monkeypatch): + from backend.app.connectors.plex import PlexConnector + + previous = _set_plex_config() + + def fake_request(self, path, params=None): + if path == "/library/sections": + return _libraries_payload() + if path == "/library/sections/1/all": + return {"MediaContainer": {"size": 14}} + if path == "/library/sections/2/all": + return {"MediaContainer": {"size": 8}} + return _server_payload() + + monkeypatch.setattr(PlexConnector, "_request", fake_request, raising=True) + + payload = PlexConnector().library_status() + + assert payload["success"] is True + assert payload["total"] == 2 + assert payload["items"][0]["id"] == "1" + assert payload["items"][0]["type"] == "movie" + assert payload["items"][0]["count"] == 14 + assert payload["items"][0]["locations"] == [".../movies"] + + _restore_config(previous) + + +def test_plex_sessions_and_stream_summary(monkeypatch): + from backend.app.connectors.plex import PlexConnector + + previous = _set_plex_config() + monkeypatch.setattr(PlexConnector, "_request", lambda self, path, params=None: _sessions_payload(), raising=True) + + sessions = PlexConnector().active_sessions() + summary = PlexConnector().stream_summary() + + assert sessions["success"] is True + assert sessions["total"] == 2 + assert sessions["items"][0]["user"] == "Jens" + assert sessions["items"][0]["ip"] == "192.168.10.x" + assert sessions["items"][1]["transcodeDecision"] == "transcode" + assert summary["activeStreams"] == 2 + assert summary["directPlay"] == 1 + assert summary["transcoding"] == 1 + assert summary["users"] == 2 + + _restore_config(previous) + + +def test_plex_recent_media_normalizes_items(monkeypatch): + from backend.app.connectors.plex import PlexConnector + + previous = _set_plex_config() + monkeypatch.setattr(PlexConnector, "_request", lambda self, path, params=None: _recent_payload(), raising=True) + + payload = PlexConnector().recent_media() + + assert payload["success"] is True + assert payload["total"] == 2 + assert payload["items"][0]["title"] == "Arrival" + assert payload["items"][1]["title"] == "Example Show - Pilot" + + _restore_config(previous) + + +def test_plex_scan_library_allowed_creates_job_and_audit(api_client, monkeypatch): + monkeypatch.setattr( + "backend.app.connectors.plex.PlexConnector.scan_library", + lambda self, library_id: {"success": True, "status": "ok", "message": "scan_started", "data": {"libraryId": library_id}}, + raising=True, + ) + + response = api_client.post("/api/apps/plex/libraries/1/scan", json={"source": "manual"}) + payload = response.json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["decision"] == "allowed" + assert payload["jobId"].startswith("job-") + assert payload["auditId"].startswith("audit-") + assert payload["data"]["libraryId"] == "1" + + +def test_plex_scan_library_voice_requires_approval_without_connector_call(api_client, monkeypatch): + def fail_scan(*_args, **_kwargs): + raise AssertionError("connector should not run before approval") + + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.scan_library", fail_scan, raising=True) + + payload = api_client.post("/api/apps/plex/libraries/1/scan", json={"source": "voice"}).json() + + assert payload["success"] is False + assert payload["decision"] == "approval_required" + assert payload["approvalId"].startswith("appr-") + assert payload["auditId"].startswith("audit-") + + +def test_plex_scan_all_requires_approval_or_blocks_without_connector_call(api_client, monkeypatch): + def fail_scan_all(*_args, **_kwargs): + raise AssertionError("scan all should wait for approval") + + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.scan_all_libraries", fail_scan_all, raising=True) + + manual = api_client.post("/api/apps/plex/libraries/scan-all", json={"source": "manual"}).json() + voice = api_client.post("/api/apps/plex/libraries/scan-all", json={"source": "voice"}).json() + + assert manual["decision"] == "approval_required" + assert voice["decision"] == "blocked" + + +def test_plex_scan_all_executes_after_approval(api_client, monkeypatch): + calls = [] + + def scan_all(_self): + calls.append('scan') + return {'success': True, 'status': 'accepted', 'message': 'scan_all_started'} + + monkeypatch.setattr("backend.app.connectors.plex.PlexConnector.scan_all_libraries", scan_all, raising=True) + + requested = api_client.post("/api/apps/plex/libraries/scan-all", json={"source": "manual"}).json() + api_client.patch(f"/api/approvals/{requested['approvalId']}", json={"status": "approved"}) + executed = api_client.post(f"/api/approvals/{requested['approvalId']}/execute").json() + + assert executed['success'] is True + assert executed['approvalStatus'] == 'executed' + assert calls == ['scan'] + + +def test_plex_destructive_actions_are_blocked(api_client): + for action in [ + "plex.delete_media", + "plex.delete_library", + "plex.empty_trash", + "plex.clean_bundles", + "plex.optimize_database", + "plex.kill_session", + "plex.user_management", + "plex.force_refresh_all_metadata", + "plex.mass_edit", + "plex.unknown_mutation", + ]: + payload = api_client.post("/api/policies/evaluate", json={"action": action, "target": "plex", "source": "manual"}).json() + assert payload["decision"] == "blocked" + + +def test_plex_inventory_classification_and_candidate(api_client, monkeypatch): + from backend.app.services.app_classifier import classify_container + + result = classify_container({"name": "binhex-plexpass", "image": "binhex/arch-plexpass:latest", "ports": ["32400:32400"]}) + + assert result["app_type"] == "plex" + assert result["category"] == "media" + assert result["subcategory"] == "media_server" + assert result["connector"] == "plex" + assert result["policy_profile"] == "media_server" + assert result["healthcheck_suggestion"] == {"port": 32400} + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "ok", "containers": [{"id": "plex-container", "name": "binhex-plexpass", "image": "binhex/arch-plexpass", "state": "running", "status": "running", "ports": ["32400:32400"]}]}, + raising=True, + ) + + api_client.post("/api/inventory/discover") + candidates = api_client.get("/api/connectors/plex/candidates").json() + + assert len(candidates) == 1 + assert candidates[0]["connectorId"] == "plex" + assert candidates[0]["appType"] == "plex" + assert candidates[0]["missingSecrets"] == ["PLEX_URL", "PLEX_TOKEN"] diff --git a/backend/tests/test_portainer_restart_timeout.py b/backend/tests/test_portainer_restart_timeout.py new file mode 100644 index 0000000..887bbf1 --- /dev/null +++ b/backend/tests/test_portainer_restart_timeout.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace + +from backend.app.connectors.portainer import PortainerConnector +from backend.app.core.config import settings + + +def test_restart_request_allows_docker_grace_period_without_shortening_container_timeout(monkeypatch): + previous = dict(settings.apis) + settings.apis.update({ + "PORTAINER_URL": "https://portainer.example.test", + "PORTAINER_API_KEY": "test-key", + "PORTAINER_ENDPOINT_ID": "9", + }) + connector = PortainerConnector() + monkeypatch.setattr( + connector, + "list_containers", + lambda: { + "status": "ok", + "containers": [{"id": "runner-id", "name": "itworx-validation-runner-2", "appId": "itworx-validation-runner-2"}], + }, + ) + calls: list[tuple[str, str, dict]] = [] + + def request(method: str, path: str, **kwargs): + calls.append((method, path, kwargs)) + return SimpleNamespace(status_code=204) + + monkeypatch.setattr(connector, "_request", request) + try: + result = connector.restart_container("itworx-validation-runner-2") + finally: + settings.apis.clear() + settings.apis.update(previous) + + assert result["status"] == "ok" + assert calls == [( + "POST", + "/api/endpoints/9/docker/containers/runner-id/restart", + {"timeout": 30.0}, + )] diff --git a/backend/tests/test_portfolio_deployment_enrollment.py b/backend/tests/test_portfolio_deployment_enrollment.py new file mode 100644 index 0000000..022c15e --- /dev/null +++ b/backend/tests/test_portfolio_deployment_enrollment.py @@ -0,0 +1,156 @@ +from backend.app.services.action_execution import approval_execution_supported +from backend.app.services.action_registry import ACTION_RISK, ACTIONS, action_default_target +from backend.app.services.portfolio_deployment_enrollment import ( + _ENROLL_SCRIPT, + _INSPECT_SCRIPT, + _LEGACY_REVISION, + _LEGACY_SINGLE_PROJECT, + _PROJECT, + _RECOVERY_FAILED_REVISION, + _RECOVERY_FAILURE_MESSAGE, + _RECOVERY_LIVE_REVISION, + _REPOSITORY, + PortfolioDeploymentEnrollment, +) + + +class _Response: + def __init__(self, *, payload=None, text=""): + self._payload = payload + self.text = text + + def json(self): + return self._payload + + +class _InspectPortainer: + def __init__(self): + self.calls = [] + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/exec"): + return _Response(payload={"Id": "exec-safe-inspect"}) + if path.endswith("/start"): + return _Response( + text='{"branch":"main","compose_files":["docker-compose.yml"],"containers":["Portfolio","Portfolio-api"],"present":true}' + ) + raise AssertionError(f"Unexpected Portainer path: {path}") + + +def test_portfolio_enrollment_contract_is_fixed_and_paired(): + assert _REPOSITORY == "jens/portfolio" + assert _LEGACY_REVISION == "e59bfb3484531883e5486ee9e10b663ed0f6764f" + assert _PROJECT["branch"] == "main" + assert _PROJECT["exact_source"] is True + assert _PROJECT["env_source"] == "/srv/example/appdata/Portfolio/.env" + assert _PROJECT["compose_files"] == ["docker-compose.yml"] + assert _PROJECT["compose_project"] == "portfolio" + assert _PROJECT["containers"] == ["Portfolio", "Portfolio-api"] + assert _PROJECT["verify_oci_revision"] is False + assert _PROJECT["environment"] == { + "GIT_COMMIT": "{revision}", + "BUILD_ID": "build-{short_revision}", + } + + +def test_portfolio_enrollment_registry_alias_matches_controller_cache_bind_source(): + assert 'registry_path = Path("/opt/gitea-deploy/projects.json")' in _ENROLL_SCRIPT + assert ( + 'host_registry_path = Path("/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json")' + in _ENROLL_SCRIPT + ) + assert "os.path.samefile(registry_path, host_registry_path)" in _ENROLL_SCRIPT + assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in _ENROLL_SCRIPT + assert '/mnt/cache/appdata/gitea-deploy-runners/deploy.lock' in _ENROLL_SCRIPT + + +def test_portfolio_enrollment_uses_project_then_global_lock_order_without_child_deadlock(): + project_lock = "fcntl.flock(project_lock, fcntl.LOCK_EX)" + cache_lock = "fcntl.flock(lock, fcntl.LOCK_EX)" + + assert _ENROLL_SCRIPT.index(project_lock) < _ENROLL_SCRIPT.index(cache_lock) + assert "controller.bootstrap_legacy_state(repository, project, legacy_revision)" in _ENROLL_SCRIPT + assert '["python3", "/opt/gitea-deploy/deploy.py", "bootstrap-legacy"' not in _ENROLL_SCRIPT + + +def test_portfolio_enrollment_only_migrates_known_legacy_single_target(): + assert _LEGACY_SINGLE_PROJECT["containers"] == ["portfolio"] + assert _LEGACY_SINGLE_PROJECT["compose_project"] == "portfolio" + assert _LEGACY_SINGLE_PROJECT["compose_files"] == ["docker-compose.yml"] + assert "env_source" not in _LEGACY_SINGLE_PROJECT + + +def test_portfolio_target_registration_is_a_typed_high_risk_action(): + action = "deployment.target.register_portfolio" + assert action in ACTIONS + assert ACTION_RISK[action] == "high" + assert action_default_target(action) == "jens/portfolio" + assert approval_execution_supported(action) is True + + +def test_portfolio_target_inspection_returns_only_sanitized_json_from_controller(): + portainer = _InspectPortainer() + service = PortfolioDeploymentEnrollment(portainer=portainer) + + result = service._inspect_current_target(endpoint="1", container_id="controller-id") + + assert result == { + "branch": "main", + "compose_files": ["docker-compose.yml"], + "containers": ["Portfolio", "Portfolio-api"], + "present": True, + } + assert portainer.calls[0][2]["json"]["AttachStdout"] is True + assert portainer.calls[0][2]["json"]["Tty"] is True + assert portainer.calls[1][2]["json"] == {"Detach": False, "Tty": True} + + +def test_portfolio_target_inspection_exposes_only_bounded_state_diagnostics(): + assert ( + 'state_path = Path("/mnt/cache/appdata/gitea-deploy-runners/state/portfolio.json")' + in _INSPECT_SCRIPT + ) + for allowed in ( + '"schema_version"', + '"live_revision"', + '"live_status"', + '"live_legacy_provenance"', + '"last_attempt"', + '"live_artifact_containers"', + ): + assert allowed in _INSPECT_SCRIPT + assert 'safe_attempt["message"]' in _INSPECT_SCRIPT + assert "[:768]" in _INSPECT_SCRIPT + assert 'state.get("live_receipt")' not in _INSPECT_SCRIPT + assert 'state.get("rollback_snapshot")' not in _INSPECT_SCRIPT + + +def test_portfolio_runtime_reconciliation_is_pinned_to_observed_failure(): + assert _RECOVERY_LIVE_REVISION == "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e" + assert _RECOVERY_FAILED_REVISION == "b7955d90b63860ffff8e0bd84be123aaba632043" + assert ( + _RECOVERY_FAILURE_MESSAGE + == "Existing production containers require a valid healthy live deployment state" + ) + assert 'existing_state.get("live_status") == "unknown"' in _ENROLL_SCRIPT + assert 'existing_state.get("live_legacy_provenance") is False' in _ENROLL_SCRIPT + assert 'last_attempt.get("revision") == recovery_failed_revision' in _ENROLL_SCRIPT + assert 'last_attempt.get("message") == recovery_failure_message' in _ENROLL_SCRIPT + assert 'isinstance(live_receipt, dict)' in _ENROLL_SCRIPT + assert 'bool(live_receipt)' in _ENROLL_SCRIPT + assert 'set(artifact_containers) == set(project["containers"])' in _ENROLL_SCRIPT + + +def test_portfolio_runtime_reconciliation_proves_exact_recorded_artifacts(): + assert 'revision_label != recovery_live_revision' in _ENROLL_SCRIPT + assert 'build_label != f"build-{recovery_live_revision[:12]}"' in _ENROLL_SCRIPT + assert 'artifact.get("image_id")' in _ENROLL_SCRIPT + assert 'artifact.get("container_id")' in _ENROLL_SCRIPT + assert 'str(container.get("Image") or "")' in _ENROLL_SCRIPT + assert 'str(container.get("Id") or "")' in _ENROLL_SCRIPT + assert 'reconciled_state = dict(existing_state)' in _ENROLL_SCRIPT + assert 'reconciled_state["live_status"] = "healthy"' in _ENROLL_SCRIPT + assert 'reconciled_state["status"] = "success"' in _ENROLL_SCRIPT + assert 'bool(verify.get("live_receipt"))' in _ENROLL_SCRIPT + assert "runtime-state-reconciled" in _ENROLL_SCRIPT diff --git a/backend/tests/test_portfolio_deployment_recovery.py b/backend/tests/test_portfolio_deployment_recovery.py new file mode 100644 index 0000000..c42d5e9 --- /dev/null +++ b/backend/tests/test_portfolio_deployment_recovery.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from backend.app.services.portfolio_deployment_recovery import ( + _FAILED_REVISION, + _FAILURE_MESSAGE, + _LIVE_REVISION, + _REATTEST_SCRIPT, + _REPOSITORY, +) + + +def test_portfolio_recovery_is_pinned_to_exact_observed_incident(): + assert _REPOSITORY == "jens/portfolio" + assert _LIVE_REVISION == "52c6f2fc74a1e39ad288f1c5e987a9cd5b072d3e" + assert _FAILED_REVISION == "b7955d90b63860ffff8e0bd84be123aaba632043" + assert _FAILURE_MESSAGE == "Existing production containers require a valid healthy live deployment state" + assert 'main.stdout.strip() != failed_revision' in _REATTEST_SCRIPT + assert 'state.get("live_status") == "unknown"' in _REATTEST_SCRIPT + assert 'attempt.get("revision") == failed_revision' in _REATTEST_SCRIPT + assert 'attempt.get("message") == failure_message' in _REATTEST_SCRIPT + assert '/mnt/cache/appdata/gitea-deploy-runners/state' in _REATTEST_SCRIPT + assert '/srv/example/appdata/gitea-deploy-runners/deploy.lock' not in _REATTEST_SCRIPT + assert '/mnt/cache/appdata/gitea-deploy-runners/deploy.lock' in _REATTEST_SCRIPT + + +def test_portfolio_recovery_reuses_controller_receipt_verifiers(): + assert "controller.validate_recorded_receipt" in _REATTEST_SCRIPT + assert "controller.verify_deployment" in _REATTEST_SCRIPT + assert "expected_image_ids=expected_image_ids" in _REATTEST_SCRIPT + assert "controller.deployment_artifacts" in _REATTEST_SCRIPT + assert "controller.verified_current_receipt" in _REATTEST_SCRIPT + + +def test_portfolio_recovery_allows_only_ephemeral_container_identity_change(): + assert 'if key != "container_id"' in _REATTEST_SCRIPT + assert "container_id_changed = False" in _REATTEST_SCRIPT + assert "if not container_id_changed" in _REATTEST_SCRIPT + assert 'artifacts.get("compose_sha256") != observed_artifacts.get("compose_sha256")' in _REATTEST_SCRIPT + assert 'repaired["live_receipt"] = observed_receipt' in _REATTEST_SCRIPT + assert 'repaired["live_artifacts"] = observed_artifacts' in _REATTEST_SCRIPT + + +def test_portfolio_recovery_has_no_application_runtime_mutation_commands(): + forbidden = ( + "docker restart", + "docker stop", + "docker rm", + "docker compose up", + "docker compose down", + "subprocess.run([\"docker\", \"restart\"", + "subprocess.run([\"docker\", \"rm\"", + ) + assert all(value not in _REATTEST_SCRIPT for value in forbidden) + assert "shutil.copy2(state_path, backup)" in _REATTEST_SCRIPT + assert "os.fsync" in _REATTEST_SCRIPT + + +def test_portfolio_action_execution_accepts_reconciled_status(): + source = Path("backend/app/services/action_execution.py").read_text(encoding="utf-8") + assert "from .portfolio_deployment_recovery import PortfolioDeploymentRecovery" in source + assert "PortfolioDeploymentRecovery().run()" in source + assert 'status in {"enrolled", "already-enrolled", "reconciled"}' in source diff --git a/backend/tests/test_privatemuse_adoption_health.py b/backend/tests/test_privatemuse_adoption_health.py new file mode 100644 index 0000000..17951c3 --- /dev/null +++ b/backend/tests/test_privatemuse_adoption_health.py @@ -0,0 +1,206 @@ +import importlib +import json +import subprocess +from pathlib import Path + +import pytest +from test_privatemuse_f84_adoption import ( + _install_fixture, + _post_adoption_evidence, + _run_adoption, + adoption, + diagnosis, +) + + +def _setup(monkeypatch, tmp_path): + module_path = Path(adoption.__file__).with_name("finalize_privatemuse_adoption_health.py") + assert module_path.exists(), "The approved baseline needs a verified health finalization command" + finalizer = importlib.import_module("finalize_privatemuse_adoption_health") + state_path, archive, *_rest, controller = _install_fixture(monkeypatch, tmp_path) + state = json.loads(state_path.read_text()) + state["live_status"] = "unknown" + state_path.write_bytes(adoption._json_bytes(state)) + _run_adoption() + state = json.loads(state_path.read_text()) + state.pop("f84_runtime_adoption") + state["message"] = finalizer.FAILURE + state["last_attempt"] = { + "revision": finalizer.REVISION, + "status": "failed", + "message": finalizer.FAILURE, + "metadata": {"revision": finalizer.REVISION}, + } + state["updated_at_epoch"] += 1 + state_path.write_bytes(adoption._json_bytes(state)) + monkeypatch.setattr(finalizer, "JOURNAL", tmp_path / "health-finalization") + monkeypatch.setattr(finalizer, "_main_revision", lambda *_: finalizer.REVISION) + monkeypatch.setattr(diagnosis, "collect_live_evidence_under_lock", lambda **_: _post_adoption_evidence()) + return finalizer, state_path, archive, controller + + +def _run(module): + return module.finalize(approval_reference=adoption._APPROVAL_REFERENCE, apply=True) + + +def test_finalization_restores_usable_health_without_rewriting_failed_attempt(monkeypatch, tmp_path): + module, path, archive, _ = _setup(monkeypatch, tmp_path) + before = path.read_bytes() + old_archive = {p.name: p.read_bytes() for p in archive.iterdir()} + result = _run(module) + after = json.loads(path.read_bytes()) + expected = json.loads(before) + expected["live_status"] = "healthy" + assert after == expected + assert result["status"] == "finalized" + assert (module.JOURNAL / "before.json").read_bytes() == before + assert {p.name: p.read_bytes() for p in archive.iterdir()} == old_archive + assert _run(module)["status"] == "already_finalized" + + +@pytest.mark.parametrize("change", ["main", "attempt", "receipt", "configuration", "archive"]) +def test_unproven_baseline_never_changes_state(monkeypatch, tmp_path, change): + module, path, archive, controller = _setup(monkeypatch, tmp_path) + if change == "main": + monkeypatch.setattr(module, "_main_revision", lambda *_: "f" * 40) + elif change == "attempt": + state = json.loads(path.read_bytes()) + state["last_attempt"]["message"] = "A different failure" + path.write_bytes(adoption._json_bytes(state)) + elif change == "receipt": + controller.current_receipt["containers"]["privatemuse-backend-1"]["container_id"] = "f" * 64 + elif change == "configuration": + evidence = _post_adoption_evidence() + evidence["backend_runtime_mount_network_port_and_env_match_current_compose"] = False + monkeypatch.setattr(diagnosis, "collect_live_evidence_under_lock", lambda **_: evidence) + else: + (archive / "approval-audit.json").write_text("{}") + before = path.read_bytes() + with pytest.raises(adoption.AdoptionError): + _run(module) + assert path.read_bytes() == before + + +def test_failure_after_write_restores_exact_state(monkeypatch, tmp_path): + module, path, _, _ = _setup(monkeypatch, tmp_path) + before = path.read_bytes() + real_verify = module._verify_runtime + calls = 0 + + def fail_post(*args): + nonlocal calls + calls += 1 + if calls == 3: + raise adoption.AdoptionError("runtime changed after write") + return real_verify(*args) + + monkeypatch.setattr(module, "_verify_runtime", fail_post) + with pytest.raises(adoption.AdoptionError, match="runtime changed"): + _run(module) + assert path.read_bytes() == before + + +def test_concurrent_state_is_preserved(monkeypatch, tmp_path): + module, path, _, _ = _setup(monkeypatch, tmp_path) + original_verify = module._verify_runtime + calls = 0 + outsider = b'{"other":"operator state"}\n' + + def change_post(*args): + nonlocal calls + calls += 1 + if calls == 3: + path.write_bytes(outsider) + raise adoption.AdoptionError("concurrent update") + return original_verify(*args) + + monkeypatch.setattr(module, "_verify_runtime", change_post) + with pytest.raises(adoption.AdoptionError, match="diverged"): + _run(module) + assert path.read_bytes() == outsider + + +def test_journal_tampering_and_missing_approval_fail_closed(monkeypatch, tmp_path): + module, path, _, _ = _setup(monkeypatch, tmp_path) + before = path.read_bytes() + with pytest.raises(adoption.AdoptionError): + module.finalize(approval_reference="other", apply=True) + assert path.read_bytes() == before + _run(module) + healthy = path.read_bytes() + (module.JOURNAL / "after.json").write_text("{}") + with pytest.raises(adoption.AdoptionError): + _run(module) + assert path.read_bytes() == healthy + + +def test_main_change_after_write_restores_unknown_state(monkeypatch, tmp_path): + module, path, _, _ = _setup(monkeypatch, tmp_path) + before = path.read_bytes() + calls = 0 + + def moving_main(*_): + nonlocal calls + calls += 1 + return module.REVISION if calls < 5 else "f" * 40 + + monkeypatch.setattr(module, "_main_revision", moving_main) + with pytest.raises(adoption.AdoptionError, match="main changed"): + _run(module) + assert path.read_bytes() == before + + +def test_canonical_bare_repository_needs_no_origin(monkeypatch, tmp_path): + import finalize_privatemuse_adoption_health as module + + repo = tmp_path / "canonical.git" + subprocess.run(["git", "init", "--bare", str(repo)], check=True, capture_output=True) + tree = subprocess.check_output(["git", f"--git-dir={repo}", "mktree"], input=b"").decode().strip() + revision = subprocess.check_output([ + "git", f"--git-dir={repo}", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "commit-tree", tree, "-m", "baseline", + ]).decode().strip() + subprocess.run(["git", f"--git-dir={repo}", "update-ref", "refs/heads/main", revision], check=True) + monkeypatch.setattr(module, "REVISION", revision) + + class Controller: + def repository_path(self, *_): + return repo + + assert module._main_revision(Controller(), {}) == revision + subprocess.run(["git", f"--git-dir={repo}", "update-ref", "-d", "refs/heads/main"], check=True) + with pytest.raises(adoption.AdoptionError): + module._main_revision(Controller(), {}) + + +def test_finalized_baseline_passes_real_rollback_capture_gate(monkeypatch, tmp_path): + from test_deploy_broker import BROKER_ROOT, _load_module + + deploy = _load_module("privatemuse_health_consumer_test", BROKER_ROOT / "deploy.py") + + module, path, _, fake = _setup(monkeypatch, tmp_path) + receipt = fake.current_receipt + names = list(receipt["containers"]) + project = {"slug": "privatemuse", "compose_project": "privatemuse", "containers": names, + "_repository_name": "jens/privatemuse"} + monkeypatch.setattr(deploy, "read_state", lambda _: json.loads(path.read_bytes())) + monkeypatch.setattr(deploy, "compose_project_container_names", lambda _: set(names)) + monkeypatch.setattr(deploy, "inspect_container", lambda name: { + "Id": receipt["containers"][name]["container_id"], + "Image": receipt["containers"][name]["image_id"], + "Config": {"Image": receipt["containers"][name]["image_ref"], + "Labels": {"com.docker.compose.project": "privatemuse"}}, + "State": {"Running": True}, + }) + monkeypatch.setattr(deploy, "inspect_image", lambda _: {"present": True}) + monkeypatch.setattr(deploy, "verify_deployment", lambda *_, **__: receipt) + monkeypatch.setattr(deploy, "run", lambda _: None) + monkeypatch.setattr(deploy, "cleanup_rollback_image_tags", lambda *_: None) + with pytest.raises(deploy.DeployError, match="valid healthy live deployment state"): + deploy.capture_compose_rollback(project) + _run(module) + snapshot = deploy.capture_compose_rollback(project) + assert snapshot["revision"] == diagnosis._LIVE_REVISION + assert snapshot["receipt"] == receipt + assert snapshot["compose_containers"] == sorted(names) + assert json.loads(path.read_bytes())["status"] == "failed" diff --git a/backend/tests/test_privatemuse_artifact_installer.py b/backend/tests/test_privatemuse_artifact_installer.py new file mode 100644 index 0000000..37e433a --- /dev/null +++ b/backend/tests/test_privatemuse_artifact_installer.py @@ -0,0 +1,265 @@ +import hashlib +from pathlib import Path + +import pytest + +from backend.app.services.privatemuse_artifact_installer import ( + ArtifactInstallError, + install, + preflight, + validate_request, +) + + +def request(tmp_path: Path, data: bytes = b"model"): + source = tmp_path / "source.safetensors" + source.write_bytes(data) + return { + "artifact_type": "lora", + "source_url": "https://models.example.invalid/model.safetensors", + "sha256": hashlib.sha256(data).hexdigest(), + "filename": "candidate.safetensors", + "source_id": "civitai:model/version", + "license": "upstream terms verified", + } + + +def test_validation_is_fail_closed(): + payload = { + "artifact_type": "lora", + "source_url": "http://example.test/x", + "sha256": "0" * 64, + "filename": "x.safetensors", + "source_id": "v1", + "license": "x", + } + with pytest.raises(ArtifactInstallError, match="HTTPS"): + validate_request(payload) + payload["source_url"] = "https://example.test/x" + payload["filename"] = "../x.safetensors" + with pytest.raises(ArtifactInstallError, match="filename"): + validate_request(payload) + + +def test_installer_hash_pins_and_writes_receipt(tmp_path, monkeypatch): + data = b"verified model bytes" + payload = request(tmp_path, data) + + class Response: + headers = {} + + def __init__(self): + self.data = data + self.headers = {"Content-Length": str(len(self.data))} + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def geturl(self): + return payload["source_url"] + + def read(self, n): + out = self.data[:n] + self.data = self.data[n:] + return out + + monkeypatch.setattr( + "urllib.request.urlopen", + lambda *args, **kwargs: Response(), + ) + result = install( + payload, + model_root=tmp_path / "models", + receipt_root=tmp_path / "receipts", + ) + assert result["status"] == "installed" + assert ( + tmp_path / "models" / "loras" / "candidate.safetensors" + ).read_bytes() == data + assert (tmp_path / "receipts" / f'{payload["sha256"]}.json').exists() + again = install(payload, model_root=tmp_path / "models", receipt_root=tmp_path / "receipts") + assert again["status"] == "already_installed" + assert again["receipt"] == result["receipt"] + + +def test_installer_rejects_digest_mismatch(tmp_path, monkeypatch): + payload = request(tmp_path, b"expected") + + class Response: + headers = {} + data = b"different" + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def geturl(self): + return payload["source_url"] + + def read(self, n): + out = self.data[:n] + self.data = self.data[n:] + return out + + monkeypatch.setattr( + "urllib.request.urlopen", + lambda *args, **kwargs: Response(), + ) + with pytest.raises(ArtifactInstallError, match="SHA-256"): + install( + payload, + model_root=tmp_path / "models", + receipt_root=tmp_path / "receipts", + ) + assert not ( + tmp_path / "models" / "loras" / "candidate.safetensors" + ).exists() + assert not list((tmp_path / "models" / "loras").glob("*.part")) + + +def test_preflight_requires_persistent_mount_and_detects_changed_destination(tmp_path, monkeypatch): + payload = request(tmp_path) + root = tmp_path / "models" + (root / "loras").mkdir(parents=True) + with pytest.raises(ArtifactInstallError, match="persistent mount"): + preflight(payload, model_root=root) + monkeypatch.setattr("os.path.ismount", lambda path: path == root) + first = preflight(payload, model_root=root) + assert first["already_installed"] is False + (root / "loras" / payload["filename"]).write_bytes(b"model") + second = preflight(payload, model_root=root) + assert second["already_installed"] is True + assert second["fingerprint"] != first["fingerprint"] + + +def test_preflight_rejects_unwritable_model_directory(tmp_path, monkeypatch): + payload = request(tmp_path) + root = tmp_path / "models" + (root / "loras").mkdir(parents=True) + monkeypatch.setattr("os.path.ismount", lambda path: path == root) + monkeypatch.setattr("os.access", lambda path, mode: False) + with pytest.raises(ArtifactInstallError, match="not writable"): + preflight(payload, model_root=root) + + +def test_installer_never_follows_destination_symlink(tmp_path): + payload = request(tmp_path) + outside = tmp_path / "outside.safetensors" + outside.write_bytes(b"model") + destination_dir = tmp_path / "models" / "loras" + destination_dir.mkdir(parents=True) + try: + (destination_dir / payload["filename"]).symlink_to(outside) + except OSError: + pytest.skip("creating symlinks is not permitted on this host") + with pytest.raises(ArtifactInstallError, match="symlinks"): + install(payload, model_root=tmp_path / "models", receipt_root=tmp_path / "receipts") + assert outside.read_bytes() == b"model" + + +def test_racing_destination_is_never_overwritten(tmp_path, monkeypatch): + payload = request(tmp_path) + + class Response: + headers = {"Content-Length": "5"} + + def __enter__(self): + self.data = b"model" + return self + + def __exit__(self, *args): + pass + + def geturl(self): + return payload["source_url"] + + def read(self, size): + data, self.data = self.data, b"" + return data + + monkeypatch.setattr("urllib.request.urlopen", lambda *args, **kwargs: Response()) + + def race_link(source, destination): + Path(destination).write_bytes(b"other") + raise FileExistsError + + monkeypatch.setattr("os.link", race_link) + with pytest.raises(ArtifactInstallError, match="different digest"): + install(payload, model_root=tmp_path / "models", receipt_root=tmp_path / "receipts") + assert (tmp_path / "models" / "loras" / payload["filename"]).read_bytes() == b"other" + assert not list((tmp_path / "models" / "loras").glob("*.part")) + + +def test_gguf_requires_exact_type_and_extension(): + payload = { + "artifact_type": "gguf", + "source_url": "https://models.example.invalid/qwen.gguf", + "sha256": "a" * 64, + "filename": "qwen-image-2.1-Q4_K_M.gguf", + "source_id": "hf:qwen-image-2.1:q4km", + "license": "qwen-research", + } + assert validate_request(payload)["artifact_type"] == "gguf" + + wrong_type = {**payload, "artifact_type": "lora"} + with pytest.raises(ArtifactInstallError, match="safetensors"): + validate_request(wrong_type) + + wrong_extension = {**payload, "filename": "qwen-image-2.1-Q4_K_M.safetensors"} + with pytest.raises(ArtifactInstallError, match="gguf"): + validate_request(wrong_extension) + + +def test_gguf_installs_only_into_diffusion_models(tmp_path, monkeypatch): + data = b"gguf model bytes" + payload = { + "artifact_type": "gguf", + "source_url": "https://models.example.invalid/qwen.gguf", + "sha256": hashlib.sha256(data).hexdigest(), + "filename": "qwen-image-2.1-Q4_K_M.gguf", + "source_id": "hf:qwen-image-2.1:q4km", + "license": "qwen-research", + } + + class Response: + def __init__(self): + self.data = data + self.headers = {"Content-Length": str(len(data))} + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def geturl(self): + return payload["source_url"] + + def read(self, n): + out = self.data[:n] + self.data = self.data[n:] + return out + + monkeypatch.setattr( + "urllib.request.urlopen", + lambda *args, **kwargs: Response(), + ) + result = install( + payload, + model_root=tmp_path / "models", + receipt_root=tmp_path / "receipts", + ) + expected = ( + tmp_path + / "models" + / "diffusion_models" + / "qwen-image-2.1-Q4_K_M.gguf" + ) + assert result["status"] == "installed" + assert expected.read_bytes() == data + assert not (tmp_path / "models" / "loras" / payload["filename"]).exists() diff --git a/backend/tests/test_privatemuse_deployment_action.py b/backend/tests/test_privatemuse_deployment_action.py new file mode 100644 index 0000000..00bf345 --- /dev/null +++ b/backend/tests/test_privatemuse_deployment_action.py @@ -0,0 +1,249 @@ +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.api import routes_jobs +from backend.app.db.models import AppSetting, AuditEvent, JobRun +from backend.app.services import action_execution, mcp_operations +from backend.app.services import privatemuse_deployment_recovery as recovery +from backend.app.services.action_execution import approval_execution_supported +from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, +) + + +def test_privatemuse_target_registration_is_a_typed_high_risk_action(): + action = "deployment.target.register_privatemuse" + assert action in ACTIONS + assert ACTION_RISK[action] == "high" + assert action_default_target(action) == "jens/privatemuse" + assert approval_execution_supported(action) is True + + +def test_privatemuse_target_registration_rejects_any_other_target(): + result = action_execution.execute_governed_action( + None, # type: ignore[arg-type] + action="deployment.target.register_privatemuse", + target="jens/not-privatemuse", + source="chatgpt-mcp", + risk="high", + reason="Regression test for exact-target enforcement.", + original_policy_decision="allowed", + ) + + assert result["success"] is False + assert result["result"] == "blocked" + assert result["policyDecision"] == "blocked" + assert result["message"] == "PrivateMuse target registration accepts only jens/privatemuse." + + +def test_privatemuse_target_registration_dispatches_only_fixed_executor(monkeypatch): + observed = {} + + def fake_executor(session, **kwargs): + observed["session"] = session + observed.update(kwargs) + return { + "success": True, + "status": "success", + "result": "success", + "message": "enrolled", + "jobId": "job-private-muse", + "auditId": "audit-private-muse", + "policyDecision": "allowed", + "originalPolicyDecision": kwargs["original_policy_decision"], + } + + monkeypatch.setattr( + action_execution, + "_execute_privatemuse_target_enrollment", + fake_executor, + ) + + sentinel_session = object() + result = action_execution.execute_governed_action( + sentinel_session, # type: ignore[arg-type] + action="deployment.target.register_privatemuse", + target="JENS/PrivateMuse", + source="chatgpt-mcp", + risk="high", + reason="Enroll the fixed PrivateMuse deployment target.", + original_policy_decision="allowed", + ) + + assert result["success"] is True + assert observed == { + "session": sentinel_session, + "source": "chatgpt-mcp", + "risk": "high", + "reason": "Enroll the fixed PrivateMuse deployment target.", + "original_policy_decision": "allowed", + } + + +def test_privatemuse_unknown_outcome_keeps_job_running_and_retains_exec_evidence( + tmp_path, monkeypatch +): + engine = create_engine(f"sqlite:///{tmp_path / 'unknown-action.db'}") + SQLModel.metadata.create_all(engine) + + def uncertain_run(self, *, session, job_id): + return { + "status": "outcome_unknown", + "reason": "Detached recovery outcome is unknown; do not retry.", + "execution_id": "exec-action-unknown", + "original_job_id": job_id, + } + + monkeypatch.setattr(recovery.PrivateMuseDeploymentRecovery, "run", uncertain_run) + + with Session(engine) as session: + result = action_execution.execute_governed_action( + session, + action="deployment.target.register_privatemuse", + target="jens/privatemuse", + source="chatgpt-mcp", + risk="high", + reason="Exercise durable unknown-outcome reporting.", + original_policy_decision="allowed", + ) + job = session.get(JobRun, result["jobId"]) + audit = session.get(AuditEvent, result["auditId"]) + + assert result["success"] is False + assert result["status"] == "outcome_unknown" + assert result["result"] == "outcome_unknown" + assert result["executionId"] == "exec-action-unknown" + assert job is not None + assert job.status == "running" + assert "exec-action-unknown" in str(job.result_summary) + assert audit is not None + assert audit.result == "outcome_unknown" + assert audit.after_state["executionId"] == "exec-action-unknown" + assert audit.after_state["originalJobId"] == result["jobId"] + + +def test_unknown_privatemuse_recovery_job_cannot_be_cancelled(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'cancel-guard.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add( + JobRun( + id="job-unknown-recovery", + type="deployment.target.register_privatemuse", + status="running", + source="chatgpt-mcp", + action_id="deployment.target.register_privatemuse", + target="jens/privatemuse", + risk="high", + policy_decision="allowed", + result_summary="outcome_unknown; executionId=exec-cancel-guard", + ) + ) + session.add( + AppSetting( + key=recovery._RECOVERY_STATE_SETTING, + value_json={ + "state": "outcome_unknown", + "executionId": "exec-cancel-guard", + "controllerId": "controller-id", + "jobId": "job-unknown-recovery", + }, + is_secret=False, + ) + ) + session.commit() + + result = mcp_operations.cancel_operation_job( + session, "job-unknown-recovery" + ) + route_result = routes_jobs.cancel("job-unknown-recovery", session=session) + persisted = session.exec( + select(JobRun).where(JobRun.id == "job-unknown-recovery") + ).one() + + assert result == { + "jobId": "job-unknown-recovery", + "status": "running", + "cancelled": False, + "changed": False, + "reason": ( + "This job owns an unresolved detached PrivateMuse recovery execution; " + "reconcile that execution before changing the job record." + ), + } + assert route_result == { + "success": False, + "message": ( + "This job owns an unresolved detached PrivateMuse recovery execution; " + "reconcile that execution before changing the job record." + ), + "id": "job-unknown-recovery", + "status": "running", + } + assert persisted.status == "running" + + +def test_privatemuse_job_is_cancellation_guarded_at_admission(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'admission-cancel.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + _state, admitted = recovery.PrivateMuseDeploymentRecovery.admit_job( + session, + JobRun( + id="job-admitted-recovery", + type="deployment.target.register_privatemuse", + status="queued", + source="chatgpt-mcp", + action_id="deployment.target.register_privatemuse", + target="jens/privatemuse", + risk="high", + policy_decision="allowed", + ), + ) + result = mcp_operations.cancel_operation_job( + session, "job-admitted-recovery" + ) + + assert admitted is True + assert result["cancelled"] is False + assert result["changed"] is False + assert result["status"] == "queued" + assert "unresolved detached PrivateMuse recovery" in result["reason"] + + +def test_privatemuse_cancellation_guard_applies_only_to_the_owner(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'owner-cancel.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + recovery.PrivateMuseDeploymentRecovery.admit_job( + session, + JobRun( + id="job-owner", + type="deployment.target.register_privatemuse", + status="queued", + source="chatgpt-mcp", + action_id="deployment.target.register_privatemuse", + target="jens/privatemuse", + risk="high", + policy_decision="allowed", + ), + ) + unrelated = JobRun( + id="job-unrelated", + type="deployment.target.register_privatemuse", + status="running", + source="chatgpt-mcp", + action_id="deployment.target.register_privatemuse", + target="jens/privatemuse", + risk="high", + policy_decision="allowed", + ) + session.add(unrelated) + session.commit() + + result = mcp_operations.cancel_operation_job(session, unrelated.id) + + assert result["cancelled"] is True + assert result["changed"] is True + assert result["status"] == "cancelled" diff --git a/backend/tests/test_privatemuse_deployment_enrollment.py b/backend/tests/test_privatemuse_deployment_enrollment.py new file mode 100644 index 0000000..fa91c97 --- /dev/null +++ b/backend/tests/test_privatemuse_deployment_enrollment.py @@ -0,0 +1,189 @@ +import json +from types import SimpleNamespace + +from backend.app.services.privatemuse_deployment_enrollment import ( + PrivateMuseDeploymentEnrollment, +) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakePortainer: + def __init__(self, exit_code=0): + self.exit_code = exit_code + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": { + "id": "controller-id", + "name": "gitea-deploy-control", + "state": "running", + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-1"}) + if path.endswith("/exec/exec-1/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-1/json"): + return FakeResponse({"Running": False, "ExitCode": self.exit_code}) + raise AssertionError(path) + + +def test_enrollment_uses_fixed_private_muse_contract_and_controller_exec(): + fake = FakePortainer() + result = PrivateMuseDeploymentEnrollment(fake).run() + + assert result["status"] == "enrolled" + assert result["repository"] == "jens/privatemuse" + assert result["legacy_revision"] == "715f7d152d35745521a2e014e334a1509bdce6a2" + assert result["containers"] == [ + "privatemuse-backend-1", + "privatemuse-frontend-1", + "privatemuse-audio-enhancement-1", + ] + + create = fake.calls[0] + assert create[0] == "POST" + assert create[1].endswith("/containers/controller-id/exec") + command = create[2]["json"]["Cmd"] + assert command[:2] == ["python3", "-c"] + assert command[3] == "jens/privatemuse" + assert command[4] == "715f7d152d35745521a2e014e334a1509bdce6a2" + assert '"exact_source":true' in command[5] + assert '"env_source":"/srv/example/appdata/privatemuse/.env"' in command[5] + assert '"compose_files":["docker-compose.yml","docker-compose.unraid.yml"]' in command[5] + assert '"privatemuse-audio-enhancement-1"' in command[5] + canonical_payload = json.loads(command[5]) + assert "gitea_repository_id" not in canonical_payload + assert "gitea_webhook_secret_file" not in canonical_payload + assert "validation" not in canonical_payload + assert "appops_managed_target" not in canonical_payload + + script = command[2] + assert "/mnt/cache/appdata/gitea-deploy-runners/controller/projects.json" in script + assert "/srv/example/appdata/gitea-deploy-runners/controller/projects.json" not in script + assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script + assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script + assert "/mnt/cache/appdata/gitea-deploy-runners/deploy.lock" in script + assert "os.path.samefile(registry_path, host_registry_path)" in script + assert '"docker", "rm", stale' in script + assert "controller.bootstrap_legacy_state(repository, project, legacy_revision)" in script + assert "privatemuse-audio-music-1" in script + assert "privatemuse-audio-lipsync-1" in script + assert '"gitea_repository_id"' in script + assert '"gitea_webhook_secret_file"' in script + assert '"jens__privatemuse"' in script + assert '"Managed validation / full (push)"' in script + assert "extra_keys != allowed_metadata_keys" in script + assert "canonical_target_match = True" in script + assert "if not canonical_target_match:" in script + assert create[2]["json"]["AttachStdout"] is False + assert create[2]["json"]["AttachStderr"] is False + + +def test_privatemuse_enrollment_uses_project_then_global_lock_order_without_child_deadlock(): + fake = FakePortainer() + PrivateMuseDeploymentEnrollment(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + project_lock = "fcntl.flock(project_lock, fcntl.LOCK_EX)" + cache_lock = "fcntl.flock(lock, fcntl.LOCK_EX)" + assert script.index(project_lock) < script.index(cache_lock) + assert "controller.bootstrap_legacy_state(repository, project, legacy_revision)" in script + assert '"/opt/gitea-deploy/deploy.py",\n "bootstrap-legacy"' not in script + + +def test_enrollment_pins_exact_runnerless_metadata_values(): + fake = FakePortainer() + PrivateMuseDeploymentEnrollment(fake).run() + script = fake.calls[0][2]["json"]["Cmd"][2] + + assert '"gitea_repository_id",' in script + assert '"gitea_webhook_secret_file",' in script + assert '"validation",' in script + assert 'current.get("gitea_webhook_secret_file") != "jens__privatemuse"' in script + assert '"validation_gate_version": 1' in script + assert '"required": True' in script + assert '"contexts": ["Managed validation / full (push)"]' in script + assert '"timeout_seconds": 1800' in script + assert '"poll_interval_seconds": 5' in script + assert '"max_poll_interval_seconds": 30' in script + assert 'current.get("validation") != expected_validation' in script + + +def test_enrollment_reports_conflicting_target_without_exec_output(): + fake = FakePortainer(exit_code=23) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse already has a different deployment target", + } + + +def test_enrollment_refuses_unexpected_compose_container(): + fake = FakePortainer(exit_code=45) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse Compose project contains an unexpected container", + } + + +def test_enrollment_refuses_running_stale_audio_provider(): + fake = FakePortainer(exit_code=48) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "a stale PrivateMuse audio provider is still running and was not removed", + } + + +def test_enrollment_reports_bootstrap_rejection(): + fake = FakePortainer(exit_code=52) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "deployment controller rejected PrivateMuse legacy-state bootstrap", + } + + +def test_enrollment_classifies_existing_target_field_difference_without_values(): + fake = FakePortainer(exit_code=64) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse deployment target differs in env_source", + } + + +def test_enrollment_classifies_controller_managed_target_collision(): + fake = FakePortainer(exit_code=55) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse deployment target is owned by the controller v2 managed-target contract", + } + + +def test_enrollment_classifies_unsupported_or_invalid_controller_metadata(): + fake = FakePortainer(exit_code=71) + result = PrivateMuseDeploymentEnrollment(fake).run() + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse deployment target matches canonical fields but has additional or invalid controller metadata", + } diff --git a/backend/tests/test_privatemuse_deployment_recovery.py b/backend/tests/test_privatemuse_deployment_recovery.py new file mode 100644 index 0000000..b45a066 --- /dev/null +++ b/backend/tests/test_privatemuse_deployment_recovery.py @@ -0,0 +1,630 @@ +from datetime import UTC, datetime +from pathlib import Path +from threading import Barrier, Event, Thread +from types import SimpleNamespace + +from sqlalchemy import event +from sqlmodel import Session, SQLModel, create_engine, select + +import backend.app.services.privatemuse_deployment_recovery as recovery +from backend.app.db.models import JobRun + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakePortainer: + def __init__(self, exit_code=0): + self.exit_code = exit_code + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": { + "id": "controller-id", + "name": "gitea-deploy-control", + "state": "running", + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-1"}) + if path.endswith("/exec/exec-1/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-1/json"): + return FakeResponse( + { + "ID": "exec-1", + "ContainerID": "controller-id", + "Running": False, + "ExitCode": self.exit_code, + } + ) + raise AssertionError(path) + + +def recovery_job(job_id): + return JobRun( + id=job_id, + type="deployment.target.register_privatemuse", + status="queued", + source="chatgpt-mcp", + action_id="deployment.target.register_privatemuse", + target="jens/privatemuse", + risk="high", + policy_decision="allowed", + ) + + +def test_recovery_runs_only_the_exact_observed_privatemuse_incident(): + fake = FakePortainer() + + result = recovery.PrivateMuseDeploymentRecovery(fake).run() + + assert result == { + "status": "reconciled", + "repository": "jens/privatemuse", + "live_revision": "2103d5795d151885edeeb964466b643b8fd4a0eb", + "failed_revision": "6e4c959eea2e7f3bf2c6b3560a2c1658865bfc38", + "containers": [ + "privatemuse-backend-1", + "privatemuse-frontend-1", + "privatemuse-audio-enhancement-1", + ], + "compose_project": "privatemuse", + } + command = fake.calls[0][2]["json"]["Cmd"] + assert command[:3] == ["python3", "-c", recovery._REATTEST_SCRIPT] + assert command[3:] == [ + "jens/privatemuse", + "2103d5795d151885edeeb964466b643b8fd4a0eb", + "6e4c959eea2e7f3bf2c6b3560a2c1658865bfc38", + "Existing production containers require a valid healthy live deployment state", + ] + + +def test_recovery_script_allows_only_the_two_observed_container_id_changes(): + script = recovery._REATTEST_SCRIPT + + assert ( + 'allowed_changed_ids = {"privatemuse-backend-1", "privatemuse-frontend-1"}' + in script + ) + assert "changed_receipt_ids != allowed_changed_ids" in script + assert "changed_artifact_ids != allowed_changed_ids" in script + assert 'state.get("revision") == live_revision' in script + assert 'state.get("live_revision") == live_revision' in script + assert 'state.get("live_status") == "unknown"' in script + assert 'attempt.get("revision") == failed_revision' in script + assert 'attempt.get("message") == failure_message' in script + + +def test_recovery_reuses_controller_verifiers_and_writes_state_durably(): + script = recovery._REATTEST_SCRIPT + + assert "controller.validate_recorded_receipt" in script + assert "controller.verify_deployment" in script + assert "expected_image_ids=expected_image_ids" in script + assert "controller.deployment_artifacts" in script + assert "controller.verified_current_receipt" in script + assert "write_exclusive(backup, original_state, state_metadata)" in script + assert "os.replace(temporary, state_path)" in script + assert "os.fsync" in script + + +def test_recovery_durably_preserves_the_original_state_before_replacement(): + script = recovery._REATTEST_SCRIPT + + assert "/mnt/cache/appdata/gitea-deploy-runners/state" in script + assert "/srv/example/appdata/gitea-deploy-runners/deploy.lock" not in script + assert "/mnt/cache/appdata/gitea-deploy-runners/deploy.lock" in script + assert "original_state = state_path.read_bytes()" in script + assert "write_exclusive(backup, original_state, state_metadata)" in script + assert "os.fchmod(handle.fileno(), stat.S_IMODE(metadata.st_mode))" in script + assert "os.fchown(handle.fileno(), metadata.st_uid, metadata.st_gid)" in script + assert "fsync_directory(state_root)" in script + + +def test_recovery_restores_original_state_when_post_write_verification_fails(): + script = recovery._REATTEST_SCRIPT + + assert "def restore_original_state():" in script + assert ( + "write_exclusive(restore_temporary, original_state, state_metadata)" in script + ) + assert "os.replace(restore_temporary, state_path)" in script + assert "return state_path.read_bytes() == original_state" in script + assert "if not restore_original_state():" in script + assert "raise SystemExit(45)" in script + + +def test_recovery_restores_original_state_when_post_replace_durability_fails(): + script = recovery._REATTEST_SCRIPT + + assert "state_replaced = False" in script + assert "state_replaced = True" in script + assert "if state_replaced and not restore_original_state():" in script + + +def test_recovery_script_has_no_application_runtime_mutation_commands(): + forbidden = ( + "docker restart", + "docker stop", + "docker rm", + "docker compose up", + "docker compose down", + 'subprocess.run(["docker", "restart"', + 'subprocess.run(["docker", "rm"', + ) + + assert all(value not in recovery._REATTEST_SCRIPT for value in forbidden) + + +def test_recovery_reports_verified_healthy_state_without_enrollment_fallback(): + fake = FakePortainer(exit_code=35) + + result = recovery.PrivateMuseDeploymentRecovery(fake).run() + + assert result == { + "status": "already-enrolled", + "repository": "jens/privatemuse", + } + assert "PrivateMuseDeploymentEnrollment" not in Path( + "backend/app/services/privatemuse_deployment_recovery.py" + ).read_text(encoding="utf-8") + + +def test_recovery_fails_closed_when_controller_is_unavailable(): + class UnavailablePortainer(FakePortainer): + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return {"status": "unavailable", "reason": "offline"} + + def _config(self): + raise AssertionError("configuration must not be read") + + result = recovery.PrivateMuseDeploymentRecovery(UnavailablePortainer()).run() + + assert result == {"status": "controller_unavailable", "reason": "offline"} + + +def test_recovery_waits_beyond_controller_health_timeout_and_marks_timeout_uncertain( + monkeypatch, +): + fake = FakePortainer() + fake._request = lambda method, path, **kwargs: ( + FakeResponse({"Id": "exec-1"}) + if path.endswith("/containers/controller-id/exec") + else FakeResponse({}) + if path.endswith("/exec/exec-1/start") + else FakeResponse({"Running": True, "ExitCode": None}) + ) + monotonic_values = iter((0.0, 301.0)) + monkeypatch.setattr(recovery.time, "monotonic", lambda: next(monotonic_values)) + + result = recovery.PrivateMuseDeploymentRecovery(fake).run() + + assert result == { + "status": "outcome_unknown", + "reason": ( + "PrivateMuse runtime re-attestation is still running; inspect the " + "existing execution and do not retry" + ), + "execution_id": "exec-1", + } + source = Path("backend/app/services/privatemuse_deployment_recovery.py").read_text( + encoding="utf-8" + ) + assert "deadline = time.monotonic() + 300" in source + + +def test_recovery_marks_connector_failure_after_exec_start_as_uncertain(): + class InspectFailurePortainer(FakePortainer): + def _request(self, method, path, **kwargs): + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-uncertain"}) + if path.endswith("/exec/exec-uncertain/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-uncertain/json"): + raise RuntimeError("connection lost") + raise AssertionError(path) + + result = recovery.PrivateMuseDeploymentRecovery(InspectFailurePortainer()).run() + + assert result == { + "status": "outcome_unknown", + "reason": ( + "PrivateMuse runtime re-attestation outcome is unknown after execution " + "start; inspect the existing execution and do not retry" + ), + "execution_id": "exec-uncertain", + } + + +def test_recovery_reconciles_durable_unknown_exec_without_starting_another( + tmp_path, +): + engine = create_engine(f"sqlite:///{tmp_path / 'recovery.db'}") + SQLModel.metadata.create_all(engine) + + class FirstAttemptPortainer(FakePortainer): + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-durable"}) + if path.endswith("/exec/exec-durable/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-durable/json"): + raise RuntimeError("connection lost") + raise AssertionError(path) + + first = FirstAttemptPortainer() + with Session(engine) as session: + _tracked, admitted = recovery.PrivateMuseDeploymentRecovery.admit_job( + session, recovery_job("job-original") + ) + assert admitted is True + uncertain = recovery.PrivateMuseDeploymentRecovery(first).run( + session=session, + job_id="job-original", + ) + + assert uncertain["status"] == "outcome_unknown" + assert uncertain["execution_id"] == "exec-durable" + assert uncertain["original_job_id"] == "job-original" + + class ReconcilePortainer(FakePortainer): + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + assert method == "GET" + assert path.endswith("/exec/exec-durable/json") + return FakeResponse( + { + "ID": "exec-durable", + "ContainerID": "controller-id", + "Running": False, + "ExitCode": 0, + } + ) + + reconciler = ReconcilePortainer() + with Session(engine) as session: + reconciled = recovery.PrivateMuseDeploymentRecovery(reconciler).run( + session=session, + job_id="job-fresh-key", + ) + + assert reconciled["status"] == "reconciled" + assert reconciled["execution_id"] == "exec-durable" + assert reconciled["original_job_id"] == "job-original" + assert [method for method, _path, _kwargs in reconciler.calls] == ["GET"] + + +def test_recovery_keeps_unknown_exec_guard_when_controller_identity_changed(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'identity.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recovery.AppSetting( + key=recovery._RECOVERY_STATE_SETTING, + value_json={ + "state": "outcome_unknown", + "executionId": "exec-existing", + "controllerId": "old-controller", + "jobId": "job-original", + }, + is_secret=False, + ) + ) + session.commit() + + portainer = FakePortainer() + with Session(engine) as session: + result = recovery.PrivateMuseDeploymentRecovery(portainer).run( + session=session, + job_id="job-fresh-key", + ) + + assert result["status"] == "outcome_unknown" + assert result["execution_id"] == "exec-existing" + assert result["original_job_id"] == "job-original" + assert "controller identity changed" in result["reason"] + assert portainer.calls == [] + + +def test_recovery_admission_creates_only_the_winning_job(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'admission.db'}") + SQLModel.metadata.create_all(engine) + + with Session(engine) as first_session: + first_state, first_admitted = recovery.PrivateMuseDeploymentRecovery.admit_job( + first_session, recovery_job("job-winner") + ) + with Session(engine) as second_session: + second_state, second_admitted = ( + recovery.PrivateMuseDeploymentRecovery.admit_job( + second_session, recovery_job("job-loser") + ) + ) + jobs = second_session.exec(select(JobRun)).all() + + assert first_admitted is True + assert second_admitted is False + assert first_state["jobId"] == "job-winner" + assert second_state["jobId"] == "job-winner" + assert [job.id for job in jobs] == ["job-winner"] + + +def test_recovery_admission_cas_uses_generation_when_timestamps_collide( + tmp_path, monkeypatch +): + fixed = datetime(2026, 9, 20, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) + monkeypatch.setattr(recovery, "utc_now", lambda: fixed) + engine = create_engine( + f"sqlite:///{tmp_path / 'admission-generation.db'}", + connect_args={"check_same_thread": False, "timeout": 10}, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recovery.AppSetting( + key=recovery._RECOVERY_STATE_SETTING, + value_json={"state": "idle", "version": 0}, + is_secret=False, + updated_at=fixed, + ) + ) + session.commit() + + update_barrier = Barrier(2) + + def align_cas_updates(_conn, _cursor, statement, _parameters, _context, _many): + if statement.startswith("UPDATE app_settings SET"): + update_barrier.wait(timeout=10) + + event.listen(engine, "before_cursor_execute", align_cas_updates) + admissions = [] + + def admit(job_id): + with Session(engine) as session: + _state, admitted = recovery.PrivateMuseDeploymentRecovery.admit_job( + session, recovery_job(job_id) + ) + admissions.append((job_id, admitted)) + + workers = [Thread(target=admit, args=(f"job-{index}",)) for index in range(2)] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=10) + assert not worker.is_alive() + event.remove(engine, "before_cursor_execute", align_cas_updates) + + with Session(engine) as session: + jobs = session.exec(select(JobRun)).all() + state = session.get(recovery.AppSetting, recovery._RECOVERY_STATE_SETTING) + + assert sorted(admitted for _job_id, admitted in admissions) == [False, True] + assert len(jobs) == 1 + assert state is not None + assert state.value_json["jobId"] == jobs[0].id + assert state.value_json["version"] == 1 + + +def test_reserved_recovery_can_only_be_started_by_its_admitted_job(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'owner.db'}") + SQLModel.metadata.create_all(engine) + + class NoPreflightPortainer(FakePortainer): + def get_container_status(self, name): + raise AssertionError("a losing caller must not reach Portainer preflight") + + with Session(engine) as session: + recovery.PrivateMuseDeploymentRecovery.admit_job( + session, recovery_job("job-owner") + ) + result = recovery.PrivateMuseDeploymentRecovery(NoPreflightPortainer()).run( + session=session, + job_id="job-loser", + ) + + assert result["status"] == "in_progress" + assert result["original_job_id"] == "job-owner" + assert "no second execution was started" in result["reason"] + + +def test_terminal_reconciliation_cannot_regress_to_late_unknown(tmp_path): + engine = create_engine( + f"sqlite:///{tmp_path / 'monotonic.db'}", + connect_args={"check_same_thread": False}, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + recovery.PrivateMuseDeploymentRecovery.admit_job( + session, recovery_job("job-race") + ) + + inspect_started = Event() + reconciliation_finished = Event() + worker_result = {} + + class DelayedWorkerPortainer(FakePortainer): + def _request(self, method, path, **kwargs): + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-race"}) + if path.endswith("/exec/exec-race/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-race/json"): + inspect_started.set() + assert reconciliation_finished.wait(timeout=10) + raise RuntimeError("late connection loss") + raise AssertionError(path) + + def run_delayed_worker(): + with Session(engine) as worker_session: + worker_result.update( + recovery.PrivateMuseDeploymentRecovery( + DelayedWorkerPortainer() + ).run(session=worker_session, job_id="job-race") + ) + + worker = Thread(target=run_delayed_worker) + worker.start() + assert inspect_started.wait(timeout=10) + + class TerminalInspectPortainer(FakePortainer): + def _request(self, method, path, **kwargs): + assert method == "GET" + assert path.endswith("/exec/exec-race/json") + return FakeResponse( + { + "ID": "exec-race", + "ContainerID": "controller-id", + "Running": False, + "ExitCode": 0, + } + ) + + with Session(engine) as reconcile_session: + reconciled = recovery.PrivateMuseDeploymentRecovery( + TerminalInspectPortainer() + ).run(session=reconcile_session, job_id="job-fresh-key") + reconciliation_finished.set() + worker.join(timeout=10) + assert not worker.is_alive() + + with Session(engine) as verification_session: + row = verification_session.get( + recovery.AppSetting, recovery._RECOVERY_STATE_SETTING + ) + + assert reconciled["status"] == "reconciled" + assert worker_result["status"] == "reconciled" + assert row is not None + assert row.value_json["state"] == "completed" + assert row.value_json["result"]["status"] == "reconciled" + + +def test_recovery_state_cas_prevents_same_timestamp_terminal_regression( + tmp_path, monkeypatch +): + fixed = datetime(2026, 9, 20, 12, 0, 0, tzinfo=UTC).replace(tzinfo=None) + monkeypatch.setattr(recovery, "utc_now", lambda: fixed) + engine = create_engine( + f"sqlite:///{tmp_path / 'terminal-generation.db'}", + connect_args={"check_same_thread": False, "timeout": 10}, + ) + SQLModel.metadata.create_all(engine) + tracked = { + "state": "started", + "claimId": "claim-race", + "jobId": "job-race", + "controllerId": "controller-id", + "executionId": "exec-race", + "version": 7, + } + with Session(engine) as session: + session.add( + recovery.AppSetting( + key=recovery._RECOVERY_STATE_SETTING, + value_json=tracked, + is_secret=False, + updated_at=fixed, + ) + ) + session.commit() + + completed_at_update = Event() + unknown_at_update = Event() + completed_finished = Event() + + def order_cas_updates(_conn, _cursor, statement, parameters, _context, _many): + if not statement.startswith("UPDATE app_settings SET"): + return + payload = str(parameters[0]) + if '"state": "completed"' in payload: + completed_at_update.set() + assert unknown_at_update.wait(timeout=10) + elif '"state": "outcome_unknown"' in payload: + unknown_at_update.set() + assert completed_at_update.wait(timeout=10) + assert completed_finished.wait(timeout=10) + + event.listen(engine, "before_cursor_execute", order_cas_updates) + results = {} + + def persist_completed(): + with Session(engine) as session: + results["completed"] = recovery.PrivateMuseDeploymentRecovery._persist_state( + session, + dict(tracked), + state="completed", + execution_id="exec-race", + result={"status": "reconciled"}, + ) + completed_finished.set() + + def persist_unknown(): + with Session(engine) as session: + results["unknown"] = recovery.PrivateMuseDeploymentRecovery._persist_state( + session, + dict(tracked), + state="outcome_unknown", + execution_id="exec-race", + result={"status": "outcome_unknown"}, + ) + + completed_worker = Thread(target=persist_completed) + unknown_worker = Thread(target=persist_unknown) + completed_worker.start() + unknown_worker.start() + completed_worker.join(timeout=10) + unknown_worker.join(timeout=10) + assert not completed_worker.is_alive() + assert not unknown_worker.is_alive() + event.remove(engine, "before_cursor_execute", order_cas_updates) + + with Session(engine) as session: + state = session.get(recovery.AppSetting, recovery._RECOVERY_STATE_SETTING) + + assert results["completed"]["state"] == "completed" + assert results["unknown"]["state"] == "completed" + assert state is not None + assert state.value_json["state"] == "completed" + assert state.value_json["result"]["status"] == "reconciled" + assert state.value_json["version"] == 8 + + +def test_recovery_fails_closed_when_runtime_provenance_differs(): + result = recovery.PrivateMuseDeploymentRecovery(FakePortainer(exit_code=38)).run() + + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse immutable container/image provenance differs from the recorded receipt", + } + + +def test_privatemuse_action_execution_accepts_reconciled_status(): + source = Path("backend/app/services/action_execution.py").read_text( + encoding="utf-8" + ) + + assert ( + "from .privatemuse_deployment_recovery import PrivateMuseDeploymentRecovery" + in source + ) + assert "tracked, admitted = recovery.admit_job(session, candidate_job)" in source + assert "job_id=job.id if admitted else candidate_job.id" in source + assert 'status in {"enrolled", "already-enrolled", "reconciled"}' in source diff --git a/backend/tests/test_privatemuse_f84_adoption.py b/backend/tests/test_privatemuse_f84_adoption.py new file mode 100644 index 0000000..6ef9a23 --- /dev/null +++ b/backend/tests/test_privatemuse_f84_adoption.py @@ -0,0 +1,535 @@ +import copy +import hashlib +import json +import os +import sys +from contextlib import contextmanager +from pathlib import Path + +import pytest + +sys.path.insert( + 0, + str(Path(__file__).resolve().parents[2] / "ops" / "gitea-deploy-broker"), +) + +import adopt_privatemuse_f84_runtime as adoption +import diagnose_privatemuse_f84_receipt as diagnosis +import privatemuse_f84_receipt_diagnosis as incident_contract + + +def _incident_evidence(): + return { + "repository": "jens/privatemuse", + "candidate_revision": diagnosis._FAILED_REVISION, + "live_revision": diagnosis._LIVE_REVISION, + "delivery_state": "failed", + "delivery_message": diagnosis._FAILURE_MESSAGE, + "candidate_container_mutation": False, + "recorded_backend_container_id": adoption._RECORDED_BACKEND_ID, + "observed_backend_container_id": adoption._OBSERVED_BACKEND_ID, + "backend_image_id": incident_contract._EXPECTED_IMAGE_IDS["backend"], + "frontend_image_id": incident_contract._EXPECTED_IMAGE_IDS["frontend"], + "audio_image_id": incident_contract._EXPECTED_IMAGE_IDS["audio"], + "all_recorded_image_ids_match": True, + "frontend_and_audio_container_ids_match": True, + "compose_source_sha256": incident_contract._COMPOSE_SHA256, + "compose_source_hash_matches_receipt": True, + "backend_health": "healthy", + "frontend_state": "running", + "audio_health": "healthy", + "compose_project": "privatemuse", + "backend_service": "backend", + "backend_mounts": sorted(incident_contract._EXPECTED_MOUNTS), + "backend_networks": sorted(incident_contract._EXPECTED_NETWORKS), + "configuration_evidence": { + "old_delivery_updated_at_epoch": 1790271038, + "protected_env_changed_at_utc": "2026-09-24T18:13:33Z", + "backup_filename": ".env.pre-modelforge-ready-1790273613.bak", + "changed_key_names": [diagnosis._HEALTH_PATH_KEY], + "live_value_matches_current_file": True, + "live_value_matches_backup": False, + "historical_effective_runtime_fingerprint": "not_recorded", + "secret_values_recorded": False, + }, + "provider_submissions": 0, + "job_retries": 0, + "production_mutations": 0, + "backend_runtime_mount_network_port_and_env_match_current_compose": True, + "backend_frontend_same_compose_project": True, + "backend_frontend_share_network": True, + "backend_alias_visible_to_frontend": True, + "audio_container_identity_matches_contract": True, + } + + +def _post_adoption_evidence(): + evidence = _incident_evidence() + evidence["recorded_backend_container_id"] = adoption._OBSERVED_BACKEND_ID + return evidence + + +def _fixture_state(): + revision = diagnosis._LIVE_REVISION + tree = "a" * 40 + built_at = "2026-09-24T17:00:00.000Z" + build = { + "id": revision[:12], + "repository": diagnosis._REPOSITORY, + "revision": revision, + "tree": tree, + "treeDigest": hashlib.sha256(f"git-tree:{tree}".encode()).hexdigest(), + "builtAt": built_at, + } + container_ids = { + "backend": adoption._RECORDED_BACKEND_ID, + "frontend": "1" * 64, + "audio": "2" * 64, + } + containers = {} + for role, name in diagnosis._CONTAINERS.items(): + containers[name] = { + "container_id": container_ids[role], + "image_id": incident_contract._EXPECTED_IMAGE_IDS[role], + "image_ref": f"local/privatemuse-{role}:b79", + "container_revision": revision, + "image_revision": revision, + "image_source": diagnosis._REPOSITORY, + "image_tree": tree, + "image_version": revision[:12], + "image_created": built_at, + } + receipt = { + "schema": "gitea-deploy-receipt.v1", + "repository": diagnosis._REPOSITORY, + "revision": revision, + "tree": tree, + "treeDigest": build["treeDigest"], + "build": build, + "containers": copy.deepcopy(containers), + "live": None, + } + artifacts = { + "containers": copy.deepcopy(containers), + "compose_sha256": incident_contract._COMPOSE_SHA256, + } + state = { + "schema_version": 3, + "repository": diagnosis._REPOSITORY, + "slug": "privatemuse", + "compose_project": "privatemuse", + "revision": revision, + "live_revision": revision, + "live_status": "healthy", + "live_legacy_provenance": False, + "live_artifacts": copy.deepcopy(artifacts), + "live_receipt": copy.deepcopy(receipt), + "rollback_snapshot": {}, + "status": "failed", + "message": diagnosis._FAILURE_MESSAGE, + "updated_at_epoch": 1790271038, + "artifacts": copy.deepcopy(artifacts), + "last_attempt": { + "revision": diagnosis._FAILED_REVISION, + "status": "failed", + "message": diagnosis._FAILURE_MESSAGE, + "updated_at_epoch": 1790271038, + "metadata": {"operation": "delivery"}, + }, + } + current_containers = copy.deepcopy(containers) + current_containers["privatemuse-backend-1"]["container_id"] = adoption._OBSERVED_BACKEND_ID + current_receipt = copy.deepcopy(receipt) + current_receipt["containers"] = copy.deepcopy(current_containers) + current_artifacts = { + "containers": copy.deepcopy(current_containers), + "compose_sha256": incident_contract._COMPOSE_SHA256, + } + return state, receipt, current_receipt, current_artifacts + + +class FakeController: + def __init__(self, current_receipt, current_artifacts): + self.current_receipt = current_receipt + self.current_artifacts = current_artifacts + self.verified_image_maps = [] + + def repository_path(self, repository, _project): + assert repository == diagnosis._REPOSITORY + return Path("/unused/fixed/repository.git") + + def validate_recorded_receipt(self, _project, receipt, repository, revision): + assert receipt["repository"] == repository + assert receipt["revision"] == revision + return receipt["build"] + + def verify_deployment(self, _project, _metadata, *, expected_image_ids): + self.verified_image_maps.append(expected_image_ids) + return copy.deepcopy(self.current_receipt) + + def deployment_artifacts(self, _project): + return copy.deepcopy(self.current_artifacts) + + +def _install_fixture(monkeypatch, tmp_path, evidence_sequence=None): + state, old_receipt, current_receipt, current_artifacts = _fixture_state() + state_path = tmp_path / "privatemuse.json" + original_bytes = adoption._json_bytes(state) + state_path.write_bytes(original_bytes) + state_path.chmod(0o600) + transaction_dir = tmp_path / "privatemuse-f84-adoption" + controller = FakeController(current_receipt, current_artifacts) + project = { + "slug": "privatemuse", + "compose_project": "privatemuse", + "containers": list(diagnosis._CONTAINERS.values()), + } + sequence = list(evidence_sequence or [_incident_evidence(), _incident_evidence(), _post_adoption_evidence()]) + + @contextmanager + def no_controller_locks(): + yield + + monkeypatch.setattr(adoption, "_STATE_PATH", state_path) + monkeypatch.setattr(adoption, "_INCIDENT_DIR", transaction_dir) + monkeypatch.setattr(adoption, "_BACKUP_PATH", transaction_dir / "original-state.json") + monkeypatch.setattr(adoption, "_RECEIPT_BACKUP_PATH", transaction_dir / "original-live-receipt.json") + monkeypatch.setattr(adoption, "_AUDIT_PATH", transaction_dir / "approval-audit.json") + monkeypatch.setattr(adoption, "_CANDIDATE_PATH", transaction_dir / "baseline-state.json") + monkeypatch.setattr(adoption, "_MANIFEST_PATH", transaction_dir / "transaction-manifest.json") + monkeypatch.setattr(adoption, "_TRANSACTION_FILES", { + "original-state.json", + "original-live-receipt.json", + "approval-audit.json", + "baseline-state.json", + "transaction-manifest.json", + }) + monkeypatch.setattr(adoption, "exclusive_controller_locks", no_controller_locks) + monkeypatch.setattr(adoption, "_protected_main_revision", lambda *_args: diagnosis._FAILED_REVISION) + monkeypatch.setattr(diagnosis, "_load_controller_module", lambda: controller) + monkeypatch.setattr(diagnosis, "_expected_project", lambda: copy.deepcopy(project)) + monkeypatch.setattr( + diagnosis, + "collect_live_evidence_under_lock", + lambda: copy.deepcopy(sequence.pop(0) if sequence else _post_adoption_evidence()), + ) + monkeypatch.setattr(adoption, "_fsync_directory", lambda _path: None) + monkeypatch.setattr(adoption.os, "fchown", lambda *_args: None, raising=False) + return state_path, transaction_dir, original_bytes, state, old_receipt, current_receipt, current_artifacts, controller + + +def _run_adoption(): + return adoption.adopt_current_runtime( + approval_reference=adoption._APPROVAL_REFERENCE, + acknowledge_unknown_historical_configuration=True, + apply=True, + ) + + +def test_adoption_writes_an_audited_current_baseline_and_preserves_failure_history(monkeypatch, tmp_path): + state_path, transaction_dir, original_bytes, original, _old_receipt, current_receipt, artifacts, controller = ( + _install_fixture(monkeypatch, tmp_path) + ) + + result = _run_adoption() + + state = json.loads(state_path.read_text(encoding="utf-8")) + audit = json.loads((transaction_dir / "approval-audit.json").read_text(encoding="utf-8")) + assert result["status"] == "adopted" + assert (transaction_dir / "original-state.json").read_bytes() == original_bytes + assert json.loads((transaction_dir / "original-live-receipt.json").read_text()) == original["live_receipt"] + assert state["live_receipt"] == current_receipt + assert state["live_artifacts"] == artifacts + assert state["last_attempt"] == original["last_attempt"] + assert state["status"] == "failed" + assert state["live_revision"] == diagnosis._LIVE_REVISION + assert state["f84_runtime_adoption"]["historical_effective_configuration"] == "unknown" + assert state["f84_runtime_adoption"]["historical_equivalence_claimed"] is False + assert audit["approval_reference"] == adoption._APPROVAL_REFERENCE + assert audit["known_configuration_delta"] == { + "key": diagnosis._HEALTH_PATH_KEY, + "prior_file_value": diagnosis._OLD_HEALTH_PATH, + "adopted_runtime_value": diagnosis._CURRENT_HEALTH_PATH, + } + assert "secret" not in (transaction_dir / "approval-audit.json").read_text().lower() + assert controller.verified_image_maps[-1] == adoption._expected_image_ids_by_name() + + +def test_adoption_is_idempotent_after_a_verified_commit(monkeypatch, tmp_path): + state_path, _transaction_dir, *_rest = _install_fixture(monkeypatch, tmp_path) + + assert _run_adoption()["status"] == "adopted" + adopted_bytes = state_path.read_bytes() + assert _run_adoption()["status"] == "already_adopted" + assert state_path.read_bytes() == adopted_bytes + + +def test_prepared_partial_transaction_resumes_only_after_fresh_checks(monkeypatch, tmp_path): + bad_second = _incident_evidence() + bad_second["audio_health"] = "unhealthy" + state_path, transaction_dir, original_bytes, *_rest = _install_fixture( + monkeypatch, + tmp_path, + evidence_sequence=[_incident_evidence(), bad_second, _incident_evidence(), _incident_evidence(), _post_adoption_evidence()], + ) + + with pytest.raises(adoption.AdoptionError, match="fresh live evidence"): + _run_adoption() + assert state_path.read_bytes() == original_bytes + assert (transaction_dir / "approval-audit.json").is_file() + + resumed = _run_adoption() + + assert resumed["status"] == "adoption_resumed" + assert json.loads(state_path.read_text())["f84_runtime_adoption"]["approval_reference"] == adoption._APPROVAL_REFERENCE + + +def test_incomplete_transaction_fails_closed_without_replacing_state(monkeypatch, tmp_path): + state_path, transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + transaction_dir.mkdir() + (transaction_dir / "unexpected-partial-file").write_bytes(b"preserve") + + with pytest.raises(adoption.AdoptionError, match="incomplete adoption transaction"): + _run_adoption() + + assert state_path.read_bytes() == original_bytes + assert (transaction_dir / "unexpected-partial-file").read_bytes() == b"preserve" + + +def test_post_write_verification_failure_restores_exact_original_state(monkeypatch, tmp_path): + invalid_post = _post_adoption_evidence() + invalid_post["backend_runtime_mount_network_port_and_env_match_current_compose"] = False + state_path, _transaction_dir, original_bytes, *_rest = _install_fixture( + monkeypatch, + tmp_path, + evidence_sequence=[_incident_evidence(), _incident_evidence(), invalid_post], + ) + + with pytest.raises(adoption.AdoptionError, match="post-adoption runtime"): + _run_adoption() + + assert state_path.read_bytes() == original_bytes + + +def test_third_party_state_after_replace_is_preserved_without_rollback_overwrite( + monkeypatch, tmp_path +): + state_path, transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + original_collector = diagnosis.collect_live_evidence_under_lock + calls = 0 + third_party_bytes = b"third-party-controller-update" + + def diverge_after_state_replace(): + nonlocal calls + calls += 1 + evidence = original_collector() + if calls == 3: + state_path.write_bytes(third_party_bytes) + return evidence + + monkeypatch.setattr(diagnosis, "collect_live_evidence_under_lock", diverge_after_state_replace) + + with pytest.raises(adoption.AdoptionError, match="diverged after adoption failure"): + _run_adoption() + + assert state_path.read_bytes() == third_party_bytes + assert (transaction_dir / "original-state.json").read_bytes() == original_bytes + + +def test_state_rename_failure_leaves_original_state_and_transaction_evidence( + monkeypatch, tmp_path +): + state_path, transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + real_replace = adoption.os.replace + + def fail_state_rename(source, destination): + if Path(destination) == state_path: + raise OSError("injected state rename failure") + return real_replace(source, destination) + + monkeypatch.setattr(adoption.os, "replace", fail_state_rename) + + with pytest.raises(adoption.AdoptionError, match="state replacement did not complete"): + _run_adoption() + + assert state_path.read_bytes() == original_bytes + assert (transaction_dir / "original-state.json").read_bytes() == original_bytes + + +@pytest.mark.skipif(sys.platform == "win32", reason="controller directory fsync requires Linux") +def test_directory_fsync_failure_after_rename_restores_original_state(monkeypatch, tmp_path): + real_fsync_directory = adoption._fsync_directory + state_path, _transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + real_replace = adoption.os.replace + state_was_replaced = False + injected = False + + def track_state_replace(source, destination): + nonlocal state_was_replaced + real_replace(source, destination) + if Path(destination) == state_path: + state_was_replaced = True + + def fail_first_post_replace_fsync(path): + nonlocal injected + if state_was_replaced and Path(path) == state_path.parent and not injected: + injected = True + raise OSError("injected directory fsync failure") + return real_fsync_directory(path) + + monkeypatch.setattr(adoption.os, "replace", track_state_replace) + monkeypatch.setattr(adoption, "_fsync_directory", fail_first_post_replace_fsync) + + with pytest.raises(adoption.AdoptionError, match="state replacement did not complete"): + _run_adoption() + + assert injected is True + assert state_path.read_bytes() == original_bytes + + +@pytest.mark.parametrize( + ("field", "replacement"), + [ + ("schema", "unexpected-schema"), + ("action", "unbounded-adoption"), + ("known_configuration_delta", {}), + ("container_ids", {"audio": "tampered"}), + ("image_ids", {"audio": "sha256:tampered"}), + ], +) +def test_candidate_verification_rejects_tampered_audit_fields( + field, replacement +): + original_state, original_receipt, current_receipt, current_artifacts = _fixture_state() + original_bytes = adoption._json_bytes(original_state) + candidate, candidate_bytes, audit, audit_bytes, _manifest, _manifest_bytes = ( + adoption._prepare_transaction( + original_bytes, + original_state, + original_receipt, + receipt=current_receipt, + artifacts=current_artifacts, + ) + ) + tampered_audit = copy.deepcopy(audit) + tampered_audit[field] = replacement + + with pytest.raises(adoption.AdoptionError, match="prepared adoption baseline"): + adoption._verify_transaction_candidate( + original_bytes=original_bytes, + original_state=original_state, + original_receipt=original_receipt, + current_receipt=current_receipt, + current_artifacts=current_artifacts, + candidate_bytes=candidate_bytes, + candidate=candidate, + audit_bytes=audit_bytes, + audit=tampered_audit, + ) + + +def test_state_race_immediately_before_replace_does_not_clobber_new_bytes(monkeypatch, tmp_path): + state_path, _transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + original_main_check = adoption._protected_main_revision + calls = 0 + + def change_state_on_final_head_check(controller, project): + nonlocal calls + calls += 1 + if calls == 3: + state_path.write_bytes(b"concurrent-controller-update") + return original_main_check(controller, project) + + monkeypatch.setattr(adoption, "_protected_main_revision", change_state_on_final_head_check) + + with pytest.raises(adoption.AdoptionError): + _run_adoption() + + assert state_path.read_bytes() == b"concurrent-controller-update" + assert (tmp_path / "privatemuse-f84-adoption" / "original-state.json").read_bytes() == original_bytes + + +def test_mutation_requires_exact_approval_acknowledgement_and_apply(monkeypatch, tmp_path): + state_path, _transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + + with pytest.raises(adoption.AdoptionError, match="approval reference"): + adoption.adopt_current_runtime( + approval_reference="other", + acknowledge_unknown_historical_configuration=True, + apply=True, + ) + with pytest.raises(adoption.AdoptionError, match="acknowledgement"): + adoption.adopt_current_runtime( + approval_reference=adoption._APPROVAL_REFERENCE, + acknowledge_unknown_historical_configuration=False, + apply=True, + ) + with pytest.raises(adoption.AdoptionError, match="--apply"): + adoption.adopt_current_runtime( + approval_reference=adoption._APPROVAL_REFERENCE, + acknowledge_unknown_historical_configuration=True, + apply=False, + ) + + assert state_path.read_bytes() == original_bytes + + +def test_fresh_runtime_drift_rejects_before_creating_adoption_files(monkeypatch, tmp_path): + bad = _incident_evidence() + bad["backend_networks"] = ["unexpected_network"] + state_path, transaction_dir, original_bytes, *_rest = _install_fixture( + monkeypatch, + tmp_path, + evidence_sequence=[bad], + ) + + with pytest.raises(adoption.AdoptionError, match="fresh live evidence"): + _run_adoption() + + assert state_path.read_bytes() == original_bytes + assert not transaction_dir.exists() + + +def test_protected_main_change_rejects_before_state_or_audit_mutation(monkeypatch, tmp_path): + state_path, transaction_dir, original_bytes, *_rest = _install_fixture(monkeypatch, tmp_path) + monkeypatch.setattr(adoption, "_protected_main_revision", lambda *_args: "0" * 40) + + with pytest.raises(adoption.AdoptionError, match="protected main"): + _run_adoption() + + assert state_path.read_bytes() == original_bytes + assert not transaction_dir.exists() + + +@pytest.mark.skipif(sys.platform == "win32", reason="controller locks use Linux flock") +def test_exclusive_controller_locks_are_ordered_and_refuse_contention(monkeypatch, tmp_path): + import fcntl + + project_lock = tmp_path / "project.lock" + global_lock = tmp_path / "global.lock" + project_lock.write_bytes(b"") + global_lock.write_bytes(b"") + monkeypatch.setattr(diagnosis, "_PROJECT_LOCK", project_lock) + monkeypatch.setattr(diagnosis, "_GLOBAL_LOCK", global_lock) + + real_open = os.open + opened = [] + + def recording_open(path, *args, **kwargs): + opened.append(Path(path)) + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(adoption.os, "open", recording_open) + with adoption.exclusive_controller_locks(): + assert opened == [project_lock, global_lock] + + held_descriptor = real_open(project_lock, os.O_RDWR) + try: + fcntl.flock(held_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + opened.clear() + with pytest.raises(adoption.AdoptionError, match="deployment is active"), adoption.exclusive_controller_locks(): + pytest.fail("contended project lock unexpectedly acquired") + assert opened == [project_lock] + finally: + fcntl.flock(held_descriptor, fcntl.LOCK_UN) + os.close(held_descriptor) diff --git a/backend/tests/test_privatemuse_f84_receipt_diagnosis.py b/backend/tests/test_privatemuse_f84_receipt_diagnosis.py new file mode 100644 index 0000000..e676ed9 --- /dev/null +++ b/backend/tests/test_privatemuse_f84_receipt_diagnosis.py @@ -0,0 +1,311 @@ +import importlib.util +import sys +from copy import deepcopy +from pathlib import Path + +sys.path.insert( + 0, + str(Path(__file__).resolve().parents[2] / "ops" / "gitea-deploy-broker"), +) + +from privatemuse_f84_receipt_diagnosis import ( + assess_privatemuse_f84_receipt_drift, +) + +_COLLECTOR_PATH = ( + Path(__file__).resolve().parents[2] + / "ops" + / "gitea-deploy-broker" + / "diagnose_privatemuse_f84_receipt.py" +) +_COLLECTOR_SPEC = importlib.util.spec_from_file_location( + "privatemuse_f84_diagnostic_cli", _COLLECTOR_PATH +) +assert _COLLECTOR_SPEC is not None and _COLLECTOR_SPEC.loader is not None +collector = importlib.util.module_from_spec(_COLLECTOR_SPEC) +_COLLECTOR_SPEC.loader.exec_module(collector) + + +def incident_evidence(): + return { + "repository": "Jens/PrivateMuse", + "protected_main": "f84fa7047f65346eaaee4e5a09b0973339e94a9b", + "live_revision": "b79bdca08a138a752c414d4d6eb681b67bf6cb8f", + "delivery_state": "failed", + "delivery_message": "Existing deployment no longer matches its verified live receipt", + "candidate_container_mutation": False, + "recorded_backend_container_id": "000ea567f0f6dc6274ecaf9e7e2bf75763599f814b2b48c0cd7f806f53a5aa2a", + "observed_backend_container_id": "4508b169cd3ee0ab398bcc3cc11fb7cb8ec49eb86642ff577fa6dbbfe8e1284b", + "backend_image_id": "sha256:36392964088a546fc861860198c3e8f483edc89c81a3e7a6ad40c2c914fc4eda", + "frontend_image_id": "sha256:2c64ef9571a02dfa4040ae47d2c292488173d7ef3a8d66e9b007250b704dfd79", + "audio_image_id": "sha256:4f75a143b608dec4d19f1af800a2016710116fb917166617db0b7229ecc49afc", + "all_recorded_image_ids_match": True, + "frontend_and_audio_container_ids_match": True, + "compose_source_sha256": "f0e83adea9038d9bf356ec60190e83782a7260b35251fb87f118150f09be16d2", + "compose_source_hash_matches_receipt": True, + "backend_health": "healthy", + "frontend_state": "running", + "audio_health": "healthy", + "compose_project": "privatemuse", + "backend_service": "backend", + "backend_mounts": [ + "bind:/srv/example/appdata/privatemuse/data->/app/data", + "bind:/srv/example/appdata/comfyui-nvidia/basedir/models->/comfy-models", + ], + "backend_networks": ["privatemuse_default"], + "configuration_evidence": { + "old_delivery_updated_at_epoch": 1790271038, + "protected_env_changed_at_utc": "2026-09-24T18:13:33Z", + "backup_filename": ".env.pre-modelforge-ready-1790273613.bak", + "changed_key_names": ["PRIVATEMUSE_MODELFORGE_HEALTH_PATH"], + "live_value_matches_current_file": True, + "live_value_matches_backup": False, + "historical_effective_runtime_fingerprint": "not_recorded", + "secret_values_recorded": False, + }, + "provider_submissions": 0, + "job_retries": 0, + "production_mutations": 0, + } + + +def test_exact_incident_is_diagnosed_but_never_reattested(): + result = assess_privatemuse_f84_receipt_drift(incident_evidence()) + + assert result["status"] == "diagnosed" + assert result["decision"] == "rebaseline_required" + assert result["reattestation_permitted"] is False + assert result["historical_effective_configuration"] == "unproven" + assert result["configuration_delta"] == { + "key": "PRIVATEMUSE_MODELFORGE_HEALTH_PATH", + "recorded_before_value": "/healthz", + "current_runtime_value": "/api/v1/health/ready", + } + assert result["receipt_drift"] == "backend_container_id_changed_only" + assert result["runtime_state"] == { + "backend": "healthy", + "frontend": "running", + "audio": "healthy", + "frontend_http_probe": "not_performed", + } + + +def test_diagnostic_fails_closed_if_a_second_container_id_changed(): + evidence = incident_evidence() + evidence["frontend_and_audio_container_ids_match"] = False + + result = assess_privatemuse_f84_receipt_drift(evidence) + + assert result["status"] == "evidence_incomplete" + assert result["decision"] == "no_reattestation" + assert result["reattestation_permitted"] is False + assert "frontend_and_audio_container_ids_match" in result["failed_checks"] + + +def test_diagnostic_fails_closed_if_config_drift_is_broader_or_unbounded(): + evidence = incident_evidence() + evidence["configuration_evidence"]["changed_key_names"].append("DATABASE_URL") + + result = assess_privatemuse_f84_receipt_drift(evidence) + + assert result["status"] == "evidence_incomplete" + assert result["decision"] == "no_reattestation" + assert "configuration_changed_keys" in result["failed_checks"] + + +def test_diagnostic_fails_closed_when_any_recorded_image_differs(): + evidence = incident_evidence() + evidence["all_recorded_image_ids_match"] = False + + result = assess_privatemuse_f84_receipt_drift(evidence) + + assert result["status"] == "evidence_incomplete" + assert "all_recorded_image_ids_match" in result["failed_checks"] + + +def test_diagnostic_fails_closed_when_runtime_mounts_or_network_drift(): + evidence = incident_evidence() + evidence["backend_mounts"] = ["bind:/unexpected->/app/data"] + evidence["backend_networks"] = ["unexpected_default"] + + result = assess_privatemuse_f84_receipt_drift(evidence) + + assert result["status"] == "evidence_incomplete" + assert "backend_mounts_match_contract" in result["failed_checks"] + assert "backend_networks_match_contract" in result["failed_checks"] + + +def test_diagnostic_projection_never_returns_untrusted_input_values(): + evidence = incident_evidence() + evidence["private_key"] = "do-not-project-this" + evidence["configuration_evidence"]["unexpected_value"] = "also-do-not-project" + + result = assess_privatemuse_f84_receipt_drift(evidence) + rendered = str(result) + + assert "do-not-project-this" not in rendered + assert "also-do-not-project" not in rendered + + +def test_mismatched_incident_does_not_claim_a_diagnosis(): + evidence = deepcopy(incident_evidence()) + evidence["protected_main"] = "0" * 40 + + result = assess_privatemuse_f84_receipt_drift(evidence) + + assert result["status"] == "evidence_incomplete" + assert result["decision"] == "no_reattestation" + assert "candidate_revision" in result["failed_checks"] + + +def test_malformed_timestamps_and_unhashable_mount_entries_fail_closed(): + evidence = incident_evidence() + evidence["backend_mounts"] = [{"source": "untrusted"}] + evidence["configuration_evidence"]["old_delivery_updated_at_epoch"] = 10**100 + + result = assess_privatemuse_f84_receipt_drift(evidence) + + assert result["status"] == "evidence_incomplete" + assert "backend_mounts_match_contract" in result["failed_checks"] + assert "configuration_change_after_delivery" in result["failed_checks"] + + +def test_non_mapping_snapshot_fails_closed_without_exception(): + result = assess_privatemuse_f84_receipt_drift([]) # type: ignore[arg-type] + + assert result["decision"] == "no_reattestation" + assert result["failed_checks"] == ["evidence_shape"] + + +def runtime_config_and_inspection(): + rendered = { + "services": { + "backend": { + "environment": { + "PRIVATEMUSE_MODELFORGE_HEALTH_PATH": "/api/v1/health/ready", + "DATABASE_URL": "postgresql://redacted-fixture", + }, + "volumes": [ + { + "type": "bind", + "source": "/srv/example/appdata/privatemuse/data", + "target": "/app/data", + }, + { + "type": "bind", + "source": "/srv/example/appdata/comfyui-nvidia/basedir/models", + "target": "/comfy-models", + }, + ], + "networks": {"default": {}}, + "ports": [], + } + }, + "networks": {"default": {"name": "privatemuse_default"}}, + } + inspection = { + "Mounts": [ + { + "Type": "bind", + "Source": "/srv/example/appdata/privatemuse/data", + "Destination": "/app/data", + "RW": True, + }, + { + "Type": "bind", + "Source": "/srv/example/appdata/comfyui-nvidia/basedir/models", + "Destination": "/comfy-models", + "RW": True, + }, + ], + "Config": { + "Env": [ + "PRIVATEMUSE_MODELFORGE_HEALTH_PATH=/api/v1/health/ready", + "DATABASE_URL=postgresql://redacted-fixture", + ] + }, + "NetworkSettings": { + "Networks": {"privatemuse_default": {"Aliases": ["backend"]}} + }, + "HostConfig": {"PortBindings": {}}, + } + return rendered, inspection + + +def test_live_runtime_projection_matches_declared_mounts_networks_and_ports(): + rendered, inspection = runtime_config_and_inspection() + + assert collector._runtime_matches_compose(inspection, rendered) is True + + +def test_live_runtime_projection_rejects_mount_network_or_port_drift(): + rendered, inspection = runtime_config_and_inspection() + altered_mount = deepcopy(inspection) + altered_mount["Mounts"][0]["Destination"] = "/unexpected" + altered_network = deepcopy(inspection) + altered_network["NetworkSettings"]["Networks"] = {"other_default": {}} + altered_ports = deepcopy(inspection) + altered_ports["HostConfig"]["PortBindings"] = { + "8000/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8000"}] + } + + assert collector._runtime_matches_compose(altered_mount, rendered) is False + assert collector._runtime_matches_compose(altered_network, rendered) is False + assert collector._runtime_matches_compose(altered_ports, rendered) is False + + +def test_live_runtime_projection_checks_every_declared_backend_environment_key(): + rendered, inspection = runtime_config_and_inspection() + wrong_env = deepcopy(inspection) + wrong_env["Config"]["Env"][1] = "DATABASE_URL=postgresql://different-fixture" + + assert collector._runtime_matches_compose(inspection, rendered) is True + assert collector._runtime_matches_compose(wrong_env, rendered) is False + + +def test_live_backend_frontend_pairing_requires_shared_compose_network_alias(): + backend = { + "NetworkSettings": { + "Networks": { + "privatemuse_default": {"Aliases": ["backend"]}, + } + } + } + frontend = { + "NetworkSettings": { + "Networks": { + "privatemuse_default": {"Aliases": ["frontend"]}, + } + } + } + disconnected_frontend = { + "NetworkSettings": {"Networks": {"other_default": {"Aliases": ["frontend"]}}} + } + + assert collector._backend_alias_visible_to_frontend(backend, frontend) is True + assert collector._backend_alias_visible_to_frontend(backend, disconnected_frontend) is False + + +def test_protected_env_parser_normalizes_only_the_health_path_and_keeps_values_private(): + previous = collector._parse_env_file( + b'PRIVATEMUSE_MODELFORGE_HEALTH_PATH="/healthz"\nPROVIDER_TOKEN="secret\\x"\n' + ) + current = collector._parse_env_file( + b'PRIVATEMUSE_MODELFORGE_HEALTH_PATH=/api/v1/health/ready\nPROVIDER_TOKEN="secret\\x"\n' + ) + + changed_keys = sorted(key for key in previous.keys() | current.keys() if previous.get(key) != current.get(key)) + assert changed_keys == ["PRIVATEMUSE_MODELFORGE_HEALTH_PATH"] + assert previous["PRIVATEMUSE_MODELFORGE_HEALTH_PATH"] == "/healthz" + assert current["PRIVATEMUSE_MODELFORGE_HEALTH_PATH"] == "/api/v1/health/ready" + + +def test_protected_env_parser_rejects_duplicate_keys(): + try: + collector._parse_env_file( + b"PRIVATEMUSE_MODELFORGE_HEALTH_PATH=/healthz\nPRIVATEMUSE_MODELFORGE_HEALTH_PATH=/ready\n" + ) + except collector.DiagnosisUnavailable: + pass + else: + raise AssertionError("duplicate environment keys must fail closed") diff --git a/backend/tests/test_privatemuse_webhook_repair.py b/backend/tests/test_privatemuse_webhook_repair.py new file mode 100644 index 0000000..013e986 --- /dev/null +++ b/backend/tests/test_privatemuse_webhook_repair.py @@ -0,0 +1,177 @@ +from types import SimpleNamespace + +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.services import action_execution +from backend.app.services.action_execution import approval_execution_supported +from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, +) +from backend.app.services.privatemuse_webhook_repair import ( + PrivateMuseWebhookRepair, + _ENDPOINT, + _REPAIR_SCRIPT, +) + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakePortainer: + def __init__(self, exit_code=0): + self.exit_code = exit_code + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": { + "id": "controller-id", + "name": "gitea-deploy-control", + "state": "running", + }, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return FakeResponse({"Id": "exec-webhook"}) + if path.endswith("/exec/exec-webhook/start"): + return FakeResponse({}) + if path.endswith("/exec/exec-webhook/json"): + return FakeResponse({"Running": False, "ExitCode": self.exit_code}) + raise AssertionError(path) + + +def test_privatemuse_webhook_repair_is_fixed_high_risk_typed_action(): + action = "deployment.webhook.repair_privatemuse" + assert action in ACTIONS + assert ACTION_RISK[action] == "high" + assert action_default_target(action) == "jens/privatemuse" + assert approval_execution_supported(action) is True + + +def test_privatemuse_webhook_repair_executes_only_fixed_contract(): + fake = FakePortainer() + + result = PrivateMuseWebhookRepair(fake).run() + + assert result == { + "status": "repaired", + "repository": "jens/privatemuse", + "endpoint": _ENDPOINT, + "branch": "main", + "event": "push", + } + command = fake.calls[0][2]["json"]["Cmd"] + assert command[:3] == ["python3", "-c", _REPAIR_SCRIPT] + assert command[3:] == ["jens/privatemuse", _ENDPOINT] + + +def test_privatemuse_webhook_repair_script_never_returns_credentials(): + assert "runner_guard_admin_token" in _REPAIR_SCRIPT + assert 'secret_root = Path("/opt/gitea-deploy/webhook-secrets")' in _REPAIR_SCRIPT + assert 'secret_path = secret_root / "jens__privatemuse"' in _REPAIR_SCRIPT + assert "secretMaterialReturned" not in _REPAIR_SCRIPT + assert 'print(f"privatemuse_runnerless_webhook_ready hook_id={hook_id}"' in _REPAIR_SCRIPT + assert "print(secret" not in _REPAIR_SCRIPT + assert "print(token" not in _REPAIR_SCRIPT + + +def test_privatemuse_webhook_repair_proves_effective_controller_reachability(): + assert "gitea_config_path" not in _REPAIR_SCRIPT + assert '"docker", "exec", "Gitea"' in _REPAIR_SCRIPT + assert '"http://198.19.0.1:1299/health"' in _REPAIR_SCRIPT + assert 'health_payload.get("status") != "ok"' in _REPAIR_SCRIPT + assert 'health_payload.get("protocolVersion") != "itworx-controller-request/v2"' in _REPAIR_SCRIPT + + +def test_privatemuse_webhook_repair_is_fail_closed_and_non_destructive(): + assert 'repository != "jens/privatemuse"' in _REPAIR_SCRIPT + assert 'project.get("branch") != "main"' in _REPAIR_SCRIPT + assert '"Managed validation / full (push)"' in _REPAIR_SCRIPT + assert 'url.endswith("/v1/gitea/push/jens/privatemuse")' in _REPAIR_SCRIPT + assert "len(exact) > 1 or semantic_other" in _REPAIR_SCRIPT + assert 'request("PATCH", f"/repos/Jens/PrivateMuse/hooks/{hook_id}", payload)' in _REPAIR_SCRIPT + assert 'request(\n "POST",\n "/repos/Jens/PrivateMuse/hooks",' in _REPAIR_SCRIPT + for forbidden in ( + "docker restart", + "docker stop", + "docker rm", + "docker compose", + 'request("DELETE"', + ): + assert forbidden not in _REPAIR_SCRIPT + + +def test_privatemuse_webhook_repair_maps_validation_failures(): + result = PrivateMuseWebhookRepair(FakePortainer(exit_code=34)).run() + + assert result == { + "status": "validation_failed", + "reason": "PrivateMuse has ambiguous or stale runnerless webhook endpoints", + } + + +def test_privatemuse_webhook_repair_rejects_any_other_action_target(): + result = action_execution.execute_governed_action( + None, # type: ignore[arg-type] + action="deployment.webhook.repair_privatemuse", + target="jens/not-privatemuse", + source="chatgpt-mcp", + risk="high", + reason="Regression test for exact-target enforcement.", + original_policy_decision="allowed", + ) + + assert result["success"] is False + assert result["result"] == "blocked" + assert result["message"] == "PrivateMuse webhook repair accepts only jens/privatemuse." + + +def test_privatemuse_webhook_repair_dispatches_fixed_executor(monkeypatch, tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'webhook-action.db'}") + SQLModel.metadata.create_all(engine) + + class FakeRepair: + def run(self): + return { + "status": "repaired", + "repository": "jens/privatemuse", + "endpoint": _ENDPOINT, + "branch": "main", + "event": "push", + } + + import backend.app.services.privatemuse_webhook_repair as repair_module + + monkeypatch.setattr(repair_module, "PrivateMuseWebhookRepair", FakeRepair) + + with Session(engine) as session: + result = action_execution.execute_governed_action( + session, + action="deployment.webhook.repair_privatemuse", + target="jens/privatemuse", + source="chatgpt-mcp", + risk="high", + reason="Repair the fixed PrivateMuse runnerless hook.", + original_policy_decision="approval_required", + ) + + assert result["success"] is True + assert result["status"] == "success" + assert result["policyDecision"] == "allowed" + assert result["originalPolicyDecision"] == "approval_required" + assert result["jobId"].startswith("job-") + assert result["auditId"].startswith("audit-") diff --git a/backend/tests/test_production_runtime.py b/backend/tests/test_production_runtime.py new file mode 100644 index 0000000..5ac7b1b --- /dev/null +++ b/backend/tests/test_production_runtime.py @@ -0,0 +1,411 @@ +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine + +ROOT = Path(__file__).resolve().parents[2] + + +def test_settings_load_voice_token_from_file(tmp_path): + voice_token_file = tmp_path / "voice-token" + voice_token_file.write_text("persistent-voice-token-value", encoding="utf-8") + environment = dict(os.environ) + environment.pop("APP_OPS_VOICE_TOKEN", None) + environment["APP_OPS_VOICE_TOKEN_FILE"] = str(voice_token_file) + + result = subprocess.run( + [ + sys.executable, + "-c", + "from backend.app.core.config import Settings; " + "loaded = Settings(); " + "raise SystemExit(0 if loaded.voice_token == 'persistent-voice-token-value' " + "and loaded.voice_token_file else 1)", + ], + cwd=ROOT, + env=environment, + check=False, + ) + + assert result.returncode == 0 + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "runtime_test.db" + data_dir = tmp_path / "data" + data_dir.mkdir() + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APPOPS_DATA_DIR", str(data_dir)) + monkeypatch.setenv("APPOPS_ENV", "production") + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.core import security + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + previous = { + "db_path": settings.db_path, + "data_dir": settings.data_dir, + "environment": settings.environment, + "voice_token": settings.voice_token, + "security_voice_token": security.settings.voice_token, + "apis": dict(settings.apis), + } + settings.db_path = str(db_path) + settings.data_dir = str(data_dir) + settings.environment = "production" + settings.voice_token = "test-voice-token" + security.settings.voice_token = "test-voice-token" + for key in ["PORTAINER_URL", "PORTAINER_API_KEY", "PORTAINER_ENDPOINT_ID"]: + settings.apis[key] = None + + engine = create_engine( + f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False} + ) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + settings.db_path = previous["db_path"] + settings.data_dir = previous["data_dir"] + settings.environment = previous["environment"] + settings.voice_token = previous["voice_token"] + settings.apis = previous["apis"] + security.settings.voice_token = previous["security_voice_token"] + + +def test_system_summary_prefers_setup_config_over_stale_connector_status(api_client): + from sqlmodel import Session + + from backend.app.db import session as db_session + from backend.app.db.models import Connector + + with Session(db_session.engine) as session: + connector = session.get(Connector, "portainer") + connector.status = "connected" + session.add(connector) + session.commit() + + payload = api_client.get("/api/system/summary").json() + + assert payload["portainerStatus"] == "missing_config" + assert payload["gatewayHealth"] == "critical" + + +def test_system_summary_reports_latest_audit_action_without_secret_leak(api_client): + from sqlmodel import Session + + from backend.app.db import session as db_session + from backend.app.db.models import AuditEvent + + secret = "SUMMARY_SECRET_TOKEN" + with Session(db_session.engine) as session: + session.add( + AuditEvent( + id="audit-summary-action", + source="manual", + identity="operator", + action_id="container.restart", + target=f"widefrog api_key={secret}", + target_type="container", + risk="medium", + policy_decision="allowed", + result="success", + ) + ) + session.commit() + + payload = api_client.get("/api/system/summary").json() + + assert payload["lastAction"].startswith("container.restart -> widefrog") + assert "api_key=[REDACTED]" in payload["lastAction"] + assert secret not in payload["lastAction"] + assert secret not in str(payload) + + +def test_runtime_info_is_secret_safe(api_client): + secret = "PORTAINER_RUNTIME_SECRET" + api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://portainer.local", + "endpointId": "1", + "apiKey": secret, + "verifyTls": False, + }, + ) + + payload = api_client.get("/api/runtime/info").json() + + assert payload["version"] == "1.8.0" + assert payload["environment"] == "production" + assert payload["dataDirConfigured"] is True + assert payload["dbStatus"] == "ok" + assert payload["connectorConfigSummary"]["portainer"]["hasSecret"] is True + assert secret not in str(payload) + + +def test_private_request_metrics_are_bounded_and_request_ids_are_returned(api_client): + response = api_client.get( + "/api/runtime/info", headers={"X-Request-ID": "review-request-1234"} + ) + + assert response.status_code == 200 + assert response.headers["X-Request-ID"] == "review-request-1234" + assert response.headers["Server-Timing"].startswith("app;dur=") + + metrics = api_client.get("/api/admin/observability/requests").json() + assert metrics["sampleLimit"] == 2_000 + assert metrics["sampleCount"] >= 1 + assert any(item["path"] == "/api/runtime/info" for item in metrics["slowEndpoints"]) + assert all("query" not in item for item in metrics["slowEndpoints"]) + openmetrics = api_client.get("/api/admin/observability/metrics") + assert openmetrics.status_code == 200 + assert "appops_http_request_duration_p95_milliseconds" in openmetrics.text + assert "review-request-1234" not in openmetrics.text + + maintenance = api_client.get("/api/admin/maintenance/status").json() + assert maintenance["status"] == "running" + assert "notificationEvaluation" in maintenance["intervalsSeconds"] + assert "observabilityPersistence" in maintenance["intervalsSeconds"] + + +def test_sqlite_checkpoint_is_backup_first_and_reports_safe_sizes(api_client): + status = api_client.get("/api/admin/database/status").json() + result = api_client.post("/api/admin/database/checkpoint").json() + + assert status["status"] == "ready" + assert status["databaseBytes"] > 0 + assert status["journalMode"] in {"delete", "wal"} + assert result["success"] is True + assert result["status"] == "checkpointed" + assert result["backup"]["name"].startswith("appops-db-") + assert "auditId" in result + + +def test_latest_backup_restore_drill_is_read_only(api_client): + from backend.app.db.session import engine + from backend.app.services.runtime_service import verify_latest_backup_drill + + created = api_client.post("/api/admin/backups/sqlite").json() + with Session(engine) as session: + result = verify_latest_backup_drill(session) + + assert created["success"] is True + assert result["success"] is True + assert result["integrityCheck"] == "ok" + assert result["restoreCheck"] == "ok" + assert result["backup"]["name"] == created["backup"]["name"] + + +def test_app_connector_read_routes_load_stored_runtime_config(api_client, monkeypatch): + from backend.app.connectors.plex import PlexConnector + from backend.app.core.config import settings + + secret = "PLEX_RUNTIME_SECRET" + api_client.patch( + "/api/settings/connectors/plex", + json={"url": "http://plex.local:32400", "apiKey": secret, "verifyTls": False}, + ) + settings.apis["PLEX_URL"] = None + settings.apis["PLEX_TOKEN"] = None + + monkeypatch.setattr( + PlexConnector, + "_request", + lambda self, path, params=None: { + "MediaContainer": { + "friendlyName": "Lab Plex", + "version": "1.2.3", + "machineIdentifier": "machine-1", + "size": "0", + } + }, + raising=True, + ) + + response = api_client.get("/api/apps/plex/health") + payload = response.json() + + assert response.status_code == 200 + assert payload["status"] == "connected" + assert payload["friendlyName"] == "Lab Plex" + assert secret not in response.text + + +def test_deep_health_is_available_without_admin_token(api_client): + payload = api_client.get("/api/health/deep").json() + assert payload["status"] == "ok" + assert payload["checks"]["dbReachable"] is True + assert payload["checks"]["dataDirWritable"] is True + assert "setup" in payload + + +def test_operator_token_is_exchanged_for_a_revocable_browser_session(api_client): + from backend.app.core import security + from backend.app.core.config import settings + + token = "strong-session-test-operator-token-value" + previous = (settings.operator_auth_required, settings.operator_token) + settings.operator_auth_required = True + settings.operator_token = token + try: + unlocked = api_client.post( + "/api/operator/session", + headers={"X-AppOps-Operator-Token": token}, + ) + assert unlocked.status_code == 200 + assert unlocked.json() == {"authorized": True, "mode": "session", "identity": None} + cookie = unlocked.headers["set-cookie"] + assert "appops_operator_session=" in cookie + assert "HttpOnly" in cookie + assert "SameSite=strict" in cookie + assert "Max-Age" not in cookie + assert token not in cookie + + restored = api_client.get("/api/operator/session") + assert restored.json() == {"authorized": True, "mode": "session", "identity": None} + + settings.operator_token = "a-new-strong-session-test-operator-token" + assert api_client.get("/api/operator/session").json()["authorized"] is False + settings.operator_token = token + assert api_client.get("/api/operator/session").json()["authorized"] is False + + wrong_token = api_client.post( + "/api/operator/session", + headers={"X-AppOps-Operator-Token": "wrong-operator-token"}, + ) + assert wrong_token.status_code == 403 + assert "set-cookie" not in wrong_token.headers + + unlocked = api_client.post( + "/api/operator/session", + headers={"X-AppOps-Operator-Token": token}, + ) + assert unlocked.status_code == 200 + + missing_csrf = api_client.delete("/api/operator/session") + assert missing_csrf.status_code == 403 + locked = api_client.delete( + "/api/operator/session", + headers={security.OPERATOR_SESSION_CSRF_HEADER: "1"}, + ) + assert locked.status_code == 200 + assert api_client.get("/api/operator/session").json()["authorized"] is False + finally: + settings.operator_auth_required, settings.operator_token = previous + + +def test_operator_auth_policy_can_be_enabled_and_persists(api_client): + from backend.app.core.config import settings + from backend.app.db.models import AppSetting + from backend.app.db.session import engine + + previous = settings.operator_auth_required + settings.operator_auth_required = False + try: + assert api_client.get("/api/runtime/operator-auth").json() == {"required": False} + response = api_client.patch("/api/runtime/operator-auth", json={"required": True}) + assert response.status_code == 200 + assert response.json() == {"required": True} + with Session(engine) as session: + assert session.get(AppSetting, "security.operator_auth").value_json == {"required": True} + finally: + settings.operator_auth_required = previous + + +def test_admin_exports_are_secret_safe(api_client): + secret = "EXPORT_SECRET_VALUE" + api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://portainer.local", + "endpointId": "1", + "apiKey": secret, + "verifyTls": False, + }, + ) + + for path in [ + "/api/admin/export/config", + "/api/admin/export/policies", + "/api/admin/export/inventory", + "/api/admin/export/audit-summary", + ]: + response = api_client.get(path) + assert response.status_code == 200 + assert secret not in response.text + assert "API_KEY" not in response.text or "hasSecret" in response.text + + +def test_sqlite_online_backup_creates_integrity_checked_copy(api_client): + secret = "BACKUP_SECRET_VALUE" + api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://portainer.local", + "endpointId": "1", + "apiKey": secret, + "verifyTls": False, + }, + ) + + response = api_client.post("/api/admin/backups/sqlite") + payload = response.json() + + assert response.status_code == 200 + assert payload["success"] is True + assert payload["integrityCheck"] == "ok" + assert payload["restoreMode"] == "manual_stop_container" + assert payload["backup"]["name"].startswith("appops-db-") + assert Path(payload["backup"]["path"]).exists() + assert payload["backup"]["manifestAvailable"] is True + manifest_path = Path(payload["backup"]["path"]).with_suffix(".manifest.json") + assert manifest_path.exists() + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["databaseFile"] == payload["backup"]["name"] + assert manifest["sha256"] == payload["backup"]["sha256"] + assert secret not in response.text + + list_response = api_client.get("/api/admin/backups/sqlite") + listed = list_response.json() + assert list_response.status_code == 200 + assert listed["backupDir"].endswith("backups") + assert listed["backups"][0]["name"] == payload["backup"]["name"] + assert secret not in list_response.text + + backup_name = payload["backup"]["name"] + verified = api_client.post(f"/api/admin/backups/sqlite/{backup_name}/verify") + assert verified.status_code == 200 + assert verified.json()["success"] is True + assert verified.json()["integrityCheck"] == "ok" + assert verified.json()["manifestCheck"] == "ok" + assert verified.json()["restoreMode"] == "manual_stop_container" + + downloaded = api_client.get(f"/api/admin/backups/sqlite/{backup_name}/download") + assert downloaded.status_code == 200 + assert downloaded.headers["content-type"] == "application/vnd.sqlite3" + assert "no-store" in downloaded.headers["cache-control"] + assert len(downloaded.content) == payload["backup"]["sizeBytes"] + + +def test_sqlite_backup_download_rejects_traversal(api_client): + assert ( + api_client.get("/api/admin/backups/sqlite/not-a-backup/download").status_code + == 404 + ) + assert ( + api_client.post("/api/admin/backups/sqlite/not-a-backup/verify").status_code + == 404 + ) diff --git a/backend/tests/test_prowlarr_connector.py b/backend/tests/test_prowlarr_connector.py new file mode 100644 index 0000000..4103dd7 --- /dev/null +++ b/backend/tests/test_prowlarr_connector.py @@ -0,0 +1,333 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "prowlarr_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.main import app + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + + previous_apis = dict(settings.apis) + for key in ["PROWLARR_URL", "PROWLARR_API_KEY", "PROWLARR_VERIFY_TLS", "PROWLARR_TIMEOUT_SECONDS"]: + settings.apis[key] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + settings.apis = previous_apis + + +def _set_prowlarr_config(url: str | None = "http://prowlarr:9696", api_key: str | None = "PROWLARR_SECRET_TOKEN"): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis["PROWLARR_URL"] = url + settings.apis["PROWLARR_API_KEY"] = api_key + settings.apis["PROWLARR_VERIFY_TLS"] = False + settings.apis["PROWLARR_TIMEOUT_SECONDS"] = "10" + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def test_prowlarr_connector_missing_config_reports_required_env(api_client): + response = api_client.post("/api/connectors/prowlarr/test") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert payload["missing_config"] is True + assert payload["required_env_vars"] == ["PROWLARR_URL", "PROWLARR_API_KEY"] + assert payload["secretRef"] == "PROWLARR_API_KEY" + + +def test_prowlarr_health_success_normalizes_response(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + monkeypatch.setattr( + ProwlarrConnector, + "_request", + lambda self, method, path, **_kwargs: {"version": "1.14.2", "appName": "Prowlarr"}, + raising=True, + ) + + payload = ProwlarrConnector().health() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["version"] == "1.14.2" + assert payload["url"] == "http://prowlarr:9696" + assert "PROWLARR_SECRET_TOKEN" not in str(payload) + + _restore_config(previous) + + +def test_prowlarr_error_redacts_api_key(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + def fail_request(self, method, path, **_kwargs): + raise RuntimeError("api_key=PROWLARR_SECRET_TOKEN connection refused") + + monkeypatch.setattr(ProwlarrConnector, "_request", fail_request, raising=True) + + payload = ProwlarrConnector().health() + + assert payload["success"] is False + assert payload["status"] in {"unreachable", "error"} + assert "PROWLARR_SECRET_TOKEN" not in str(payload) + assert "[REDACTED]" in str(payload) + + _restore_config(previous) + + +def test_prowlarr_indexer_status_normalizes_counts(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + monkeypatch.setattr( + ProwlarrConnector, + "_request", + lambda self, method, path, **_kwargs: [ + {"id": 1, "name": "Index A", "protocol": "torrent", "enable": True, "tags": [1], "privacy": "public"}, + {"id": 2, "name": "Index B", "protocol": "usenet", "enabled": False, "status": "error", "categories": [{"name": "Movies"}], "privacy": "private"}, + {"id": 3, "name": "Index C", "protocol": "torrent", "enable": True, "status": "warning"}, + ], + raising=True, + ) + + payload = ProwlarrConnector().indexer_status() + + assert payload["success"] is True + assert payload["status"] == "ok" + assert payload["total"] == 3 + assert payload["enabled"] == 2 + assert payload["disabled"] == 1 + assert payload["failing"] == 2 + assert payload["protocols"] == {"torrent": 2, "usenet": 1, "unknown": 0} + assert payload["items"][0]["name"] == "Index A" + assert payload["items"][1]["categories"] == ["Movies"] + + _restore_config(previous) + + +def test_prowlarr_application_status_normalizes_apps(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + monkeypatch.setattr( + ProwlarrConnector, + "_request", + lambda self, method, path, **_kwargs: [ + {"id": 7, "name": "Sonarr", "implementation": "Sonarr", "syncLevel": "fullSync", "enable": True}, + {"id": 8, "name": "Radarr", "implementation": "Radarr", "syncLevel": "addOnly", "enabled": False, "status": "warning"}, + ], + raising=True, + ) + + payload = ProwlarrConnector().application_status() + + assert payload["success"] is True + assert payload["status"] == "ok" + assert payload["total"] == 2 + assert payload["items"][0]["name"] == "Sonarr" + assert payload["items"][0]["syncLevel"] == "fullSync" + assert payload["items"][1]["enabled"] is False + assert payload["items"][1]["status"] == "warning" + + _restore_config(previous) + + +def test_prowlarr_indexer_health_normalizes_warnings_and_errors(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + monkeypatch.setattr( + ProwlarrConnector, + "_request", + lambda self, method, path, **_kwargs: [ + {"type": "warning", "message": "Indexer A slow"}, + {"type": "error", "message": "Indexer B failing"}, + ], + raising=True, + ) + + payload = ProwlarrConnector().indexer_health() + + assert payload["success"] is True + assert payload["warnings"] == ["Indexer A slow"] + assert payload["errors"] == ["Indexer B failing"] + assert payload["message"] == "health_issues_detected" + + _restore_config(previous) + + +def test_prowlarr_search_test_is_read_only_and_normalizes_results(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + def fake_request(self, method, path, **kwargs): + assert method == "GET" + assert path == "/api/v1/search" + assert kwargs["params"]["query"] == "ubuntu" + return [ + { + "title": "Ubuntu ISO", + "indexer": "Index A", + "protocol": "torrent", + "size": 123456, + "age": 2, + "seeders": 20, + "leechers": 1, + "categories": [{"name": "Linux"}], + "downloadUrl": "https://download.example/secret", + } + ] + + monkeypatch.setattr(ProwlarrConnector, "_request", fake_request, raising=True) + + payload = ProwlarrConnector().search_test("ubuntu") + + assert payload["success"] is True + assert payload["status"] == "ok" + assert payload["total"] == 1 + assert payload["items"][0]["title"] == "Ubuntu ISO" + assert payload["items"][0]["category"] == "Linux" + assert payload["items"][0]["downloadAllowed"] is False + assert "downloadUrl" not in str(payload) + assert "PROWLARR_SECRET_TOKEN" not in str(payload) + + _restore_config(previous) + + +def test_prowlarr_sync_status_summarizes_indexers_apps_and_health(monkeypatch): + from backend.app.connectors.prowlarr import ProwlarrConnector + + previous = _set_prowlarr_config() + + monkeypatch.setattr(ProwlarrConnector, "indexer_status", lambda self: {"success": True, "total": 3, "enabled": 2, "failing": 1}, raising=True) + monkeypatch.setattr(ProwlarrConnector, "application_status", lambda self: {"success": True, "total": 2, "items": []}, raising=True) + monkeypatch.setattr(ProwlarrConnector, "indexer_health", lambda self: {"success": True, "warnings": ["slow"], "errors": []}, raising=True) + + payload = ProwlarrConnector().sync_status() + + assert payload["success"] is True + assert payload["status"] == "ok" + assert payload["applicationsConfigured"] == 2 + assert payload["indexersConfigured"] == 3 + assert payload["indexersEnabled"] == 2 + assert payload["healthWarnings"] == 1 + + _restore_config(previous) + + +def test_prowlarr_search_test_route_audits_manual_read_only(api_client, monkeypatch): + from backend.app.db.models import AuditEvent + from backend.app.db.session import engine + from sqlmodel import Session, select + + monkeypatch.setattr( + "backend.app.connectors.prowlarr.ProwlarrConnector.search_test", + lambda self, query: {"success": True, "status": "ok", "message": "search_ok", "total": 1, "items": [{"title": query, "downloadAllowed": False}]}, + raising=True, + ) + + response = api_client.get("/api/apps/prowlarr/search-test?q=ubuntu") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["items"][0]["downloadAllowed"] is False + + with Session(engine) as session: + events = session.exec(select(AuditEvent).where(AuditEvent.action_id == "prowlarr.search_test")).all() + assert len(events) == 1 + assert events[0].redacted_metadata["query"] == "ubuntu" + assert events[0].redacted_metadata["downloadAllowed"] is False + assert "PROWLARR_SECRET_TOKEN" not in str(events[0].redacted_metadata) + + +def test_prowlarr_voice_search_test_requires_approval_without_connector_call(api_client, monkeypatch): + def fail_search(*_args, **_kwargs): + raise AssertionError("connector should not run before approval") + + monkeypatch.setattr("backend.app.connectors.prowlarr.ProwlarrConnector.search_test", fail_search, raising=True) + + response = api_client.get("/api/apps/prowlarr/search-test?q=ubuntu&source=voice") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert payload["decision"] == "approval_required" + assert payload["approvalId"].startswith("appr-") + assert payload["auditId"].startswith("audit-") + + +def test_prowlarr_mutating_actions_are_blocked(api_client): + for action in [ + "prowlarr.delete_indexer", + "prowlarr.delete_application", + "prowlarr.update_indexer", + "prowlarr.update_application", + "prowlarr.grab_release", + "prowlarr.push_release", + "prowlarr.mass_edit", + "prowlarr.test_all_indexers", + ]: + payload = api_client.post("/api/policies/evaluate", json={"action": action, "target": "prowlarr", "source": "manual"}).json() + assert payload["decision"] == "blocked" + + +def test_prowlarr_inventory_classification_and_candidate(api_client, monkeypatch): + from backend.app.services.app_classifier import classify_container + + result = classify_container({"name": "binhex-prowlarr", "image": "ghcr.io/hotio/prowlarr:latest", "ports": ["9696:9696"]}) + + assert result["app_type"] == "prowlarr" + assert result["category"] == "media" + assert result["connector"] == "prowlarr" + assert result["policy_profile"] == "media_indexer" + assert result["healthcheck_suggestion"] == {"port": 9696} + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "ok", "containers": [{"id": "prowlarr-container", "name": "binhex-prowlarr", "image": "linuxserver/prowlarr", "state": "running", "status": "running", "ports": ["9696:9696"]}]}, + raising=True, + ) + + api_client.post("/api/inventory/discover") + candidates = api_client.get("/api/connectors/prowlarr/candidates").json() + + assert len(candidates) == 1 + assert candidates[0]["connectorId"] == "prowlarr" + assert candidates[0]["appType"] == "prowlarr" + assert candidates[0]["missingSecrets"] == ["PROWLARR_URL", "PROWLARR_API_KEY"] diff --git a/backend/tests/test_repopulse_adoption_cli.py b/backend/tests/test_repopulse_adoption_cli.py new file mode 100644 index 0000000..25a72da --- /dev/null +++ b/backend/tests/test_repopulse_adoption_cli.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import sys +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +CLI_PATH = REPO_ROOT / "ops" / "gitea-deploy-broker" / "repopulse_adoption_cli.py" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +cli = _load_module("repopulse_adoption_cli_test", CLI_PATH) + + +def _sha(value: bytes | str) -> str: + raw = value.encode("ascii") if isinstance(value, str) else value + return hashlib.sha256(raw).hexdigest() + + +def _canonical_sha(value: object) -> str: + raw = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + return hashlib.sha256(raw).hexdigest() + + +def _projection() -> dict[str, object]: + return { + "repository": "jens/repopulse", + "main_revision": "a" * 40, + "live_revision": "b" * 40, + "container_id_sha256": _sha("legacy-container"), + "container_image_sha256": _sha("legacy-image"), + "queue": {"queued": 0, "active": 0, "completed": 10, "failed": 0}, + "database": { + "database_bytes": 12345, + "journal_bytes": 0, + "scan_count": 10, + "compacted_scan_count": 4, + }, + "container_state": "running", + "container_health": "healthy", + } + + +def _payload() -> dict[str, object]: + idempotency = _sha("itworx.repopulse-legacy-adoption/v1:jens/repopulse:cli-test") + projection = _projection() + return { + "intentId": f"rpa-{idempotency[:20]}", + "idempotencySha256": idempotency, + "preflightSha256": _canonical_sha(projection), + "preflightProjection": projection, + "targetCredential": "A" * 64, + } + + +def _raw(payload: dict[str, object] | None = None) -> bytes: + return json.dumps(payload or _payload(), separators=(",", ":")).encode("utf-8") + + +def _core(): + return cli._load_core_modules() + + +def _parsed(payload: dict[str, object] | None = None): + core = _core() + return cli.parse_handoff(_raw(payload), core.executor) + + +def test_parser_accepts_exact_handoff_and_rejects_duplicate_keys_at_any_depth(): + core = _core() + payload = _payload() + parsed = cli.parse_handoff(_raw(payload), core.executor) + + assert parsed.intent_id == payload["intentId"] + assert parsed.target_credential == b"A" * 64 + assert parsed.preflight_projection == payload["preflightProjection"] + + regular = _raw(payload) + duplicate_top = ( + b'{"intentId":' + + json.dumps(payload["intentId"]).encode("ascii") + + bytes([44]) + + regular[1:] + ) + with pytest.raises(cli.ControllerCliError) as error: + cli.parse_handoff(duplicate_top, core.executor) + assert error.value.code == "controller_handoff_duplicate_key" + + duplicate_nested = regular.replace(b'"queued":0', b'"queued":0,"queued":0', 1) + with pytest.raises(cli.ControllerCliError) as error: + cli.parse_handoff(duplicate_nested, core.executor) + assert error.value.code == "controller_handoff_duplicate_key" + + +@pytest.mark.parametrize( + "change", + [ + lambda value: value.pop("targetCredential"), + lambda value: value.update({"extra": True}), + lambda value: value.update({"targetCredential": "short"}), + lambda value: value.update({"targetCredential": "x" * 48 + "\n"}), + ], +) +def test_parser_rejects_schema_changes_and_noncontroller_credentials(change): + core = _core() + payload = _payload() + change(payload) + + with pytest.raises(cli.ControllerCliError): + cli.parse_handoff(_raw(payload), core.executor) + + +def test_parser_enforces_bounded_stdin_size(): + core = _core() + with pytest.raises(cli.ControllerCliError) as error: + cli.parse_handoff(b"x" * (cli._MAX_HANDOFF_BYTES + 1), core.executor) + assert error.value.code == "controller_handoff_too_large" + + +def test_reserved_binding_requires_existing_matching_intent_and_credential(): + core = _core() + handoff = _parsed() + binding = core.store.intent_binding_sha256( + handoff.intent_id, + handoff.idempotency_sha256, + handoff.preflight_sha256, + ) + record = { + "intentId": handoff.intent_id, + "idempotencySha256": handoff.idempotency_sha256, + "preflightSha256": handoff.preflight_sha256, + "intentBindingSha256": binding, + "expectedTargetCredentialSha256": _sha(handoff.target_credential), + "status": "in_progress", + "registration": None, + } + + class Store: + def read_only_status(self): + return { + "status": record["status"], + "hashes": { + "idempotencySha256": record["idempotencySha256"], + "preflightSha256": record["preflightSha256"], + "intentBindingSha256": record["intentBindingSha256"], + }, + } + + def load_intent(self): + return record + + assert cli._require_reserved_binding( + store=Store(), store_module=core.store, handoff=handoff + ) is record + + mismatched = replace(handoff, preflight_sha256=_sha("another preflight")) + with pytest.raises(cli.ControllerCliError) as error: + cli._require_reserved_binding( + store=Store(), store_module=core.store, handoff=mismatched + ) + assert error.value.code == "controller_intent_binding_mismatch" + + wrong_credential = replace(handoff, target_credential=b"B" * 64) + with pytest.raises(cli.ControllerCliError) as error: + cli._require_reserved_binding( + store=Store(), store_module=core.store, handoff=wrong_credential + ) + assert error.value.code == "target_credential_binding_mismatch" + + +def test_missing_reservation_is_rejected_before_loading_or_creating_journal(): + core = _core() + handoff = _parsed() + + class Store: + def read_only_status(self): + return None + + def load_intent(self): + raise AssertionError("missing reservation must not load mutable state") + + with pytest.raises(cli.ControllerCliError) as error: + cli._require_reserved_binding( + store=Store(), store_module=core.store, handoff=handoff + ) + assert error.value.code == "controller_intent_not_reserved" + + +def test_fixed_storage_bridge_forwards_executor_revision_and_keeps_credential_in_memory(): + handoff = _parsed() + + class StorageHooks: + def __init__(self): + self.calls = [] + + def ensure_quiesced(self, **kwargs): + self.calls.append(("quiesce", kwargs)) + return {"ok": True} + + def create_or_verify_fresh_snapshot(self, **kwargs): + self.calls.append(("snapshot", kwargs)) + return {"ok": True} + + def copy_and_verify_managed_data(self, **kwargs): + self.calls.append(("copy", kwargs)) + return {"ok": True} + + hooks = StorageHooks() + bridge = cli._FixedHandoffStorageEffects( + store=object(), + store_module=SimpleNamespace(REPOSITORY="jens/repopulse"), + storage_effects=hooks, + legacy_rollback=object(), + registry_restore=object(), + handoff=handoff, + ) + revision = handoff.preflight_projection["main_revision"] + binding = "c" * 64 + + bridge.ensure_quiesced( + intent_id=handoff.intent_id, + intent_binding_sha256=binding, + expected_main_revision=revision, + expected_container_id_sha256="d" * 64, + expected_container_image_sha256="e" * 64, + ) + bridge.create_or_verify_fresh_snapshot( + intent_id=handoff.intent_id, + intent_binding_sha256=binding, + expected_main_revision=revision, + expected_snapshot=None, + ) + bridge.copy_and_verify_managed_data( + intent_id=handoff.intent_id, + expected_main_revision=revision, + snapshot_evidence={"intentId": handoff.intent_id}, + ) + + assert [call[0] for call in hooks.calls] == ["quiesce", "snapshot", "copy"] + assert all(call[1]["expected_main_revision"] == revision for call in hooks.calls) + assert bridge.registration_material(intent_id=handoff.intent_id) == ( + f"enr-{handoff.intent_id[4:]}", + b"A" * 64, + ) + with pytest.raises(cli.ControllerCliError): + bridge.registration_material(intent_id="rpa-" + "f" * 20) + + +def test_executor_store_proxy_snapshots_registry_before_registration_cas(): + events = [] + + class Store: + def register_target(self, **kwargs): + events.append(("register", kwargs)) + return {"status": "registered"} + + def read_only_status(self): + return {"status": "in_progress"} + + class RegistryRestore: + def preserve_before_registry_cas(self, store): + assert isinstance(store, Store) + events.append(("preserve", None)) + + proxy = cli._RegistrySnapshotStoreProxy(Store(), RegistryRestore()) + assert proxy.register_target(intent_id="rpa-" + "a" * 20) == { + "status": "registered" + } + assert proxy.read_only_status() == {"status": "in_progress"} + assert [name for name, _value in events] == ["preserve", "register"] + + +def test_serialized_result_rejects_secret_leaks_and_unexpected_fields(): + credential = b"A" * 64 + intent_id = "rpa-" + "a" * 20 + result = {key: None for key in cli._RESULT_KEYS} + result.update( + { + "apiVersion": "itworx.repopulse-controller-adoption-execution-result/v1", + "repository": "jens/repopulse", + "applicationId": "repopulse", + "intentId": intent_id, + "status": "deployment_verified", + "phase": "completion_verified", + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + } + ) + result["deploymentReceipt"] = {"note": credential.decode("ascii")} + + with pytest.raises(cli.ControllerCliError) as error: + cli._serialize_result( + result, target_credential=credential, intent_id=intent_id + ) + assert error.value.code == "controller_result_secret_detected" + + result["deploymentReceipt"] = {} + result["targetCredential"] = credential.decode("ascii") + with pytest.raises(cli.ControllerCliError) as error: + cli._serialize_result( + result, target_credential=credential, intent_id=intent_id + ) + assert error.value.code == "controller_result_schema_invalid" + + +def test_disabled_controller_does_not_read_handoff_or_load_effects(): + core = cli.CoreModules( + store=SimpleNamespace(EXECUTION_ENABLED=False), + executor=SimpleNamespace(EXECUTION_ENABLED=False), + ) + effects_loaded = False + root_checked = False + + class UnreadableInput: + def read(self, _size): + raise AssertionError("disabled controller must not read stdin") + + def load_effects(_core): + nonlocal effects_loaded + effects_loaded = True + raise AssertionError("disabled controller must not construct effects") + + def require_root(): + nonlocal root_checked + root_checked = True + + output = io.StringIO() + code = cli._run_cli( + argv=(), + stdin=UnreadableInput(), + stdout=output, + load_core=lambda: core, + load_effects=load_effects, + require_root=require_root, + ) + + assert code == 2 + assert json.loads(output.getvalue()) == { + "status": "disabled", + "reason": "controller_adoption_execution_disabled", + } + assert not effects_loaded + assert root_checked + + +def test_nonroot_controller_rejects_before_loading_modules_or_reading_handoff(): + core_loaded = False + + class UnreadableInput: + def read(self, _size): + raise AssertionError("nonroot controller must not read stdin") + + def load_core(): + nonlocal core_loaded + core_loaded = True + raise AssertionError("nonroot controller must not load modules") + + def require_root(): + raise cli.ControllerCliError("controller_root_required") + + output = io.StringIO() + code = cli._run_cli( + argv=(), + stdin=UnreadableInput(), + stdout=output, + load_core=load_core, + require_root=require_root, + ) + + assert code == 2 + assert json.loads(output.getvalue()) == { + "status": "blocked", + "reason": "controller_root_required", + } + assert not core_loaded + + +def test_real_fixed_effect_modules_compose_without_side_effects(): + core = cli._load_core_modules() + effects = cli._load_effect_modules(core) + handoff = _parsed() + store = core.store.RepoPulseAdoptionStore() + + executor = cli._compose_executor( + core=core, + effects=effects, + store=store, + handoff=handoff, + ) + + assert isinstance(executor, core.executor.RepoPulseAdoptionExecutor) + assert executor._effects.__class__.__name__ == "_RevisionBoundRuntimeEffects" diff --git a/backend/tests/test_repopulse_adoption_effects.py b/backend/tests/test_repopulse_adoption_effects.py new file mode 100644 index 0000000..7f65ce0 --- /dev/null +++ b/backend/tests/test_repopulse_adoption_effects.py @@ -0,0 +1,1082 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import re +import sys +import tempfile +from contextlib import contextmanager +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BROKER_DIR = REPO_ROOT / "ops" / "gitea-deploy-broker" +EFFECTS_PATH = BROKER_DIR / "repopulse_adoption_effects.py" +REPOSITORY = "jens/repopulse" +APPLICATION_ID = "repopulse" +REVISION = "a" * 40 +TREE = "b" * 40 +CONTEXT = "Managed validation / full (push)" +DATA_SOURCE = "/srv/example/appdata/repopulse/data" +REPOPULSE_SCAN_SOURCE = "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse" +PRIVATEMUSE_SCAN_SOURCE = "/mnt/cache/appdata/gitea-deploy-runners/sources/privatemuse" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _sha(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def _canonical_sha(value: object) -> str: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + return hashlib.sha256(payload).hexdigest() + + +def _managed_project( + enrollment_id: str = "enr-0123456789abcdef0123", +) -> dict[str, object]: + return { + "branch": "main", + "slug": "repopulse", + "source_dir": REPOPULSE_SCAN_SOURCE, + "exact_source": True, + "env_source": "/srv/example/appdata/repopulse/deploy.managed.env", + "compose_files": [".itworx/deployment.yml"], + "compose_project": "repopulse", + "containers": ["repopulse"], + "managed_plan_version": "itworx.deployment/v1", + "managed_enrollment_id": enrollment_id, + "managed_profile": "repopulse-authenticated", + "managed_application_id": "repopulse", + "managed_appdata_path": "/srv/example/appdata/repopulse", + "managed_image_repository": "repopulse-managed", + "managed_image_tag": "production", + "managed_proxy_network": "itworx-mcp-hub-npm", + "managed_internal_port": 8000, + "managed_proxy_hostname": "repopulse.example.com", + "managed_oidc_application_slug": None, + "managed_runtime_environment_keys": [ + "REPOPULSE_GITEA_URL", + "REPOPULSE_GITEA_TOKEN", + "REPOPULSE_MAINTENANCE_TOKEN", + ], + "managed_runtime_uid": 10001, + "managed_runtime_gid": 10001, + "managed_health_path": "/health", + "appops_deployment_secret_file": "jens__repopulse", + "environment": {}, + "appops_managed_target": True, + "managed_repository": "jens/repopulse", + "managed_build_context": ".", + "managed_build_dockerfile": "deploy/managed/Dockerfile", + "gitea_repository_id": 64, + "gitea_webhook_secret_file": "jens__repopulse", + "health_timeout": 180, + "validation": { + "validation_gate_version": 1, + "required": True, + "contexts": [CONTEXT], + "timeout_seconds": 1800, + "poll_interval_seconds": 5, + "max_poll_interval_seconds": 30, + }, + "verify_oci_revision": False, + } + + +def _deployment_receipt(revision: str = REVISION) -> dict[str, object]: + return { + "schema": "gitea-deploy-receipt.v1", + "repository": REPOSITORY, + "revision": revision, + "tree": TREE, + "treeDigest": _sha(f"git-tree:{TREE}"), + "build": { + "id": revision[:12], + "repository": REPOSITORY, + "revision": revision, + "tree": TREE, + "treeDigest": _sha(f"git-tree:{TREE}"), + "builtAt": "2026-09-23T10:00:00.000Z", + }, + "containers": { + "repopulse": { + "container_id": "c" * 64, + "image_id": "sha256:" + "d" * 64, + "image_ref": "repopulse-managed:production", + "container_revision": revision, + "image_revision": revision, + "image_source": REPOSITORY, + "image_tree": TREE, + "image_version": revision[:12], + "image_created": "2026-09-23T10:00:00.000Z", + } + }, + "live": None, + } + + +def _registration_receipt() -> dict[str, object]: + return { + "apiVersion": "itworx.repopulse-controller-registration-receipt/v1", + "intentId": "rpa-0123456789abcdef0123", + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "phase": "registry_committed", + "intentBindingSha256": _sha("binding"), + "legacyVolumeRetained": True, + "intentDurable": True, + "managedEnrollmentId": "enr-0123456789abcdef0123", + "projectsSha256": _sha("projects"), + "snapshotFileSha256": _sha("snapshot"), + "receiptSha256": _sha("receipt"), + } + + +class FakeControllerStoreModule: + PROJECT_LOCK = Path("/controller/state/locks/repopulse.lock") + GLOBAL_DEPLOY_LOCK = Path("/controller/state/deploy.lock") + + def __init__(self, rw_root: Path): + self.CONTROLLER_RW_ROOT = rw_root + self.PROJECTS_FILENAME = "projects.json" + self.SECRET_ROOT_NAME = "appops-deployment-secrets" + self.locks_held = True + self.lock_checks: list[Path] = [] + self.managed_target = _managed_project() + self.capabilities = { + "repository": REPOSITORY, + "executionEnabled": False, + "controllerAliasesReady": True, + "registryReady": True, + "secretRootReady": True, + } + self.projects = {REPOSITORY: dict(self.managed_target)} + self.registration_receipt: dict[str, object] | None = None + + def transaction_lock_held(self, path: Path) -> bool: + self.lock_checks.append(path) + return self.locks_held and path in {self.PROJECT_LOCK, self.GLOBAL_DEPLOY_LOCK} + + def _managed_target(self, enrollment_id: str) -> dict[str, object]: + return _managed_project(enrollment_id) + + def capability_report(self) -> dict[str, object]: + return dict(self.capabilities) + + def _check_controller_aliases(self): + return ( + b"", + {key: dict(value) for key, value in self.projects.items()}, + b"secret", + ) + + +class FakeStore: + def __init__(self, controller_store: FakeControllerStoreModule): + self.controller_store = controller_store + self.registration_receipt: dict[str, object] | None = None + + @contextmanager + def transaction(self): + self.controller_store.locks_held = True + yield self + + def load_receipt(self): + return self.registration_receipt + + def load_intent(self): + return {"intentId": "rpa-0123456789abcdef0123"} + + +class FakeStorageEffects: + def __init__(self): + self.enrollment_id = "enr-0123456789abcdef0123" + self.credential = b"C" * 64 + self.registration_material_calls: list[str] = [] + + def registration_material(self, *, intent_id: str): + self.registration_material_calls.append(intent_id) + return self.enrollment_id, self.credential + + +class FakeStatusServer: + def __init__(self): + self.bootstrap_secret: bytes | None = ( + b"bootstrap-secret-material-that-is-never-returned" + ) + self.target_secret: bytes | None = ( + b"target-secret-material-that-is-never-returned" + ) + self.tls_ready = True + self.tls_configuration = {"ready": True} + self.validation_status = ("pass", [{"context": CONTEXT, "status": "success"}]) + self.validation_calls: list[tuple[str, str, list[dict[str, object]]]] = [] + + def _enrollment_bootstrap_secret(self): + return self.bootstrap_secret + + def _private_tls_configuration(self, *, required: bool): + assert required is True + return self.tls_configuration if self.tls_ready else None + + def _private_tls_context(self, _configuration): + if not self.tls_ready: + raise RuntimeError("unavailable") + return object() + + def _appops_policy(self, repository: str, project: dict[str, object]): + if ( + repository == REPOSITORY + and project.get("managed_repository") == REPOSITORY + and project.get("managed_application_id") == APPLICATION_ID + and project.get("appops_deployment_secret_file") == "jens__repopulse" + ): + return APPLICATION_ID, "jens__repopulse" + return None + + def _appops_deployment_secret(self, repository: str, project: dict[str, object]): + return self.target_secret if self._appops_policy(repository, project) else None + + def _validation_policy(self, repository: str, project: dict[str, object]): + if repository != REPOSITORY or not isinstance(project.get("validation"), dict): + return None + validation = dict(project["validation"]) + return { + "repository": repository, + "contexts": list(validation.get("contexts", [])), + } + + def _fetch_gitea_validation(self, repository: str, revision: str, policy): + self.validation_calls.append((repository, revision, list(policy["contexts"]))) + return self.validation_status + + +class FakeDeployCore: + def __init__(self, project: dict[str, object] | None = None): + self.REPOSITORY_ROOT = Path("/gitea-repositories") + self.EXACT_SOURCE_ROOT = Path("/mnt/cache/appdata/gitea-deploy-runners/sources") + self.MANAGED_APPCONFIG_ROOT = Path("/srv/example/appdata") + self.STATE_ROOT = Path("/controller/state") + self.project = dict(project or _managed_project()) + self.main_revision = REVISION + self.receipt = _deployment_receipt() + self.container = self._container() + self.container_ids = ["c" * 64] + self.commands: list[list[str]] = [] + self.events: list[tuple[str, object]] = [] + self.runtime_meta = { + "health": "ok", + "name": "RepoPulse", + "queue": {"queued": 0, "active": 0}, + "databaseRecoveryStatus": "healthy", + } + + def _container(self) -> dict[str, object]: + return { + "Id": "c" * 64, + "Image": "sha256:" + "d" * 64, + "State": {"Running": True, "Health": {"Status": "healthy"}}, + "Config": { + "Image": "repopulse-managed:production", + "Labels": { + "com.docker.compose.project": "repopulse", + "com.docker.compose.service": "repopulse", + "org.opencontainers.image.revision": REVISION, + "org.opencontainers.image.source": REPOSITORY, + }, + }, + "HostConfig": {"PortBindings": {}}, + "Mounts": [ + { + "Type": "bind", + "Source": DATA_SOURCE, + "Destination": "/data", + "RW": True, + }, + { + "Type": "bind", + "Source": REPOPULSE_SCAN_SOURCE, + "Destination": "/scan/repopulse", + "RW": False, + }, + { + "Type": "bind", + "Source": PRIVATEMUSE_SCAN_SOURCE, + "Destination": "/scan/privatemuse", + "RW": False, + }, + ], + } + + def output(self, command: list[str]) -> str: + self.commands.append(list(command)) + if command[:2] == [ + "git", + f"--git-dir={self.REPOSITORY_ROOT / 'jens' / 'repopulse.git'}", + ]: + if command[-1] == "refs/heads/main": + return self.main_revision + if command[-1] == f"{self.main_revision}^{{commit}}": + return self.main_revision + if command[:3] == ["docker", "ps", "--all"]: + return "\n".join(self.container_ids) + if command[:3] == ["docker", "volume", "inspect"]: + return json.dumps([{"Name": "repopulse_data"}]) + if command[:3] == ["docker", "exec", "repopulse"]: + return json.dumps(self.runtime_meta) + raise AssertionError(f"unexpected fixed controller command: {command!r}") + + def run(self, command: list[str]) -> None: + self.events.append(("run", list(command))) + if command[:2] == ["docker", "stop"]: + self.container["State"]["Running"] = False + + def load_projects(self): + return {REPOSITORY: dict(self.project)} + + def deployment_metadata(self, repo: Path, revision: str, repository: str): + assert repository == REPOSITORY + assert revision == self.main_revision + return {"repository": repository, "revision": revision, "tree": TREE} + + def repository_path(self, repository: str, _project): + assert repository == REPOSITORY + return self.REPOSITORY_ROOT / "jens" / "repopulse.git" + + def validate_project_config(self, repo: Path, project, revision: str): + assert repo == self.REPOSITORY_ROOT / "jens" / "repopulse.git" + assert project["managed_repository"] == REPOSITORY + assert revision == self.main_revision + + def exact_source_matches_revision(self, _repo: Path, revision: str, _target: Path): + return revision == self.main_revision + + def validate_managed_v1_runtime(self, project, revision: str, repository: str): + assert project["managed_profile"] == "repopulse-authenticated" + assert revision == self.main_revision + assert repository == REPOSITORY + + def managed_runtime_environment(self, _project): + return { + "REPOPULSE_GITEA_URL": "https://gitea.example.com", + "REPOPULSE_GITEA_TOKEN": "private-token", + "REPOPULSE_MAINTENANCE_TOKEN": "private-maintenance-token", + } + + def deploy_repopulse_under_adoption_transaction(self, revision: str): + self.events.append(("deploy", revision)) + assert revision == self.main_revision + return dict(self.receipt) + + def verified_current_receipt(self, repository, project, revision, tree): + self.events.append(("verified_current_receipt", revision)) + if ( + repository == REPOSITORY + and project.get("managed_profile") == "repopulse-authenticated" + and revision == self.receipt["revision"] + and tree == self.receipt["tree"] + ): + return dict(self.receipt) + return None + + def read_state(self, _project): + return {"live_receipt": dict(self.receipt)} + + def inspect_container(self, name: str): + if name not in {"repopulse", "c" * 64}: + raise AssertionError("adapter inspected a non-fixed container") + return ( + self.container + if name == "repopulse" or name == self.container["Id"] + else None + ) + + +class EffectsFixture: + def __init__(self, tmp_path: Path): + controller_root = tmp_path / "controller" + controller_root.mkdir(mode=0o700) + projects_file = controller_root / "projects.json" + projects_file.write_text("{}\n", encoding="utf-8") + projects_file.chmod(0o600) + (controller_root / "appops-deployment-secrets").mkdir(mode=0o700) + self.controller_store = FakeControllerStoreModule(controller_root) + self.store = FakeStore(self.controller_store) + self.storage_effects = FakeStorageEffects() + self.status = FakeStatusServer() + self.deploy = FakeDeployCore(project=self.controller_store.managed_target) + + self.effects_module = _load_module( + "repopulse_adoption_effects_test", EFFECTS_PATH + ) + self.effects = self.effects_module.FixedControllerAdoptionEffects( + store=self.store, + storage_effects=self.storage_effects, + controller_store=self.controller_store, + deployment_core=self.deploy, + status_server=self.status, + ) + + +@pytest.fixture +def effects_env(tmp_path): + return EffectsFixture(tmp_path) + + +@pytest.fixture +def runtime_readable_scan_root(): + # The pytest tmp_path parent is mode 0700 on Linux, so runtime UID 10001 + # cannot traverse it even when the leaf scan directories are mode 0755. + with tempfile.TemporaryDirectory(prefix="repopulse-scan-") as directory: + root = Path(directory) + root.chmod(0o755) + yield root + + +def test_target_gates_report_only_proved_controller_facts_and_keep_unavailable_external_facts_false( + effects_env, +): + env = effects_env + + with env.store.transaction(): + gates = env.effects.target_gates() + + assert gates["profileId"] == "repopulse-authenticated" + assert gates["profileVersion"] == "enrollment-profile/v1" + assert re.fullmatch(r"[0-9a-f]{64}", gates["planSha256"]) + assert gates["profileReady"] is True + assert gates["exactPolicyReady"] is True + assert gates["controllerBootstrapSecretImported"] is True + assert gates["targetSecretReady"] is True + assert gates["controllerAdoptionCapabilityReady"] is True + assert gates["controllerRegistryWriterReady"] is True + assert gates["controllerSecretWriterReady"] is True + assert gates["controllerTrustReady"] is False + assert gates["typedEnrollmentTrustReady"] is False + assert gates["authenticatedRouteReady"] is False + assert gates["accessListReady"] is False + assert gates["maintenanceAuthReady"] is False + assert "authenticatedRouteReady" in env.effects.target_gate_gaps + assert "accessListReady" in env.effects.target_gate_gaps + assert any( + "certificate" in gap.lower() for gap in env.effects.target_gate_gap_details + ) + assert any( + "access-list" in gap.lower() and "rule" in gap.lower() + for gap in env.effects.target_gate_gap_details + ) + + +def test_target_secret_gate_uses_validated_cli_credential_before_registration( + effects_env, +): + env = effects_env + env.status.target_secret = None + + with env.store.transaction(): + gates = env.effects.target_gates() + + assert gates["targetSecretReady"] is True + assert env.storage_effects.registration_material_calls == [ + "rpa-0123456789abcdef0123" + ] + + +def test_registration_reuses_the_same_preflighted_target_credential(effects_env): + env = effects_env + intent_id = "rpa-0123456789abcdef0123" + + with env.store.transaction(): + gates = env.effects.target_gates() + assert gates["targetSecretReady"] is True + enrollment_id, credential = env.effects.registration_material( + intent_id=intent_id + ) + + assert enrollment_id == env.storage_effects.enrollment_id + assert credential == env.storage_effects.credential + + env.storage_effects.credential = b"D" * 64 + with ( + env.store.transaction(), + pytest.raises(env.effects_module.ControllerEffectsError), + ): + env.effects.registration_material(intent_id=intent_id) + + +@pytest.mark.parametrize( + "credential", + [b"short", b"C" * 47, b"C" * 257, b"C" * 63 + b"\x20", b"C" * 63 + b"\x7f"], +) +def test_target_secret_gate_rejects_credentials_outside_store_registration_contract( + effects_env, credential +): + env = effects_env + env.storage_effects.credential = credential + + with env.store.transaction(): + gates = env.effects.target_gates() + + assert gates["targetSecretReady"] is False + assert "targetSecretReady" in env.effects.target_gate_gaps + + +class FakeTargetAttestationVerifier: + def __init__(self, attestation): + self.attestation = attestation + self.expected: dict[str, str] | None = None + + def verify_target_attestation(self, *, expected): + self.expected = dict(expected) + return self.attestation + + +def _valid_attestation(env, certificate_sha256: str): + effects = env.effects + target = effects._target_template() + target.pop("managed_enrollment_id", None) + plan_sha256 = _canonical_sha( + { + "profileId": "repopulse-authenticated", + "profileVersion": "enrollment-profile/v1", + "target": target, + } + ) + now = datetime.now(timezone.utc).replace(microsecond=0) + rule_type = env.effects_module.NpmAccessRule + rules = (rule_type("authenticate", "basic_user_sha256", _sha("npm-auth-username")),) + rules_payload = [ + { + "action": rule.action, + "subjectType": rule.subject_type, + "subject": rule.subject, + } + for rule in rules + ] + def timestamp(value): + return value.isoformat().replace("+00:00", "Z") + return env.effects_module.VerifiedTargetAttestation( + schema="itworx.repopulse-target-attestation/v1", + attestation_id="attest-0123456789", + intent_id="rpa-0123456789abcdef0123", + nonce="a" * 32, + issued_at=timestamp(now), + expires_at=timestamp(now + timedelta(minutes=2)), + controller_identity="appops-controller-prod-01", + controller_trust_profile_id="appops-controller-trust", + controller_trust_profile_version="v1", + controller_bootstrap_key_ref="repopulse-enrollment-hmac", + controller_tls_certificate_sha256=certificate_sha256, + repository=REPOSITORY, + application_id=APPLICATION_ID, + profile_id="repopulse-authenticated", + profile_version="enrollment-profile/v1", + plan_sha256=plan_sha256, + target_secret_ref="jens__repopulse", + npm_hostname="repopulse.example.com", + npm_access_list_route_hostname="repopulse.example.com", + npm_upstream_host="repopulse", + npm_upstream_network="itworx-mcp-hub-npm", + npm_upstream_port=8000, + npm_certificate_sha256=_sha("npm-leaf-certificate"), + npm_certificate_sans=("repopulse.example.com",), + npm_certificate_not_before=timestamp(now - timedelta(days=5)), + npm_certificate_not_after=timestamp(now + timedelta(days=60)), + npm_access_list_id=1, + npm_access_list_satisfy_any=0, + npm_access_list_is_deleted=0, + npm_access_list_pass_auth=1, + npm_access_list_client_count=0, + npm_access_list_rule_count=len(rules), + npm_access_list_rules_sha256=_canonical_sha(rules_payload), + npm_access_list_rules=rules, + maintenance_method="GET", + maintenance_path="/api/v1/operator/maintenance", + maintenance_status_code=200, + maintenance_auth_mechanism="operator_token", + maintenance_authenticated_principal="operator:jens", + maintenance_response_sha256=_sha("maintenance-response"), + maintenance_observed_at=timestamp(now), + ) + + +def test_target_gates_accept_only_verified_immutable_concrete_external_facts( + effects_env, tmp_path +): + env = effects_env + certificate_file = tmp_path / "controller.pem" + certificate_file.write_text( + "-----BEGIN CERTIFICATE-----\nYWJjZA==\n-----END CERTIFICATE-----\n", + encoding="ascii", + ) + certificate_file.chmod(0o600) + env.status.tls_configuration = {"certificate_file": certificate_file} + local_certificate_sha256 = hashlib.sha256(b"abcd").hexdigest() + attestation = _valid_attestation(env, local_certificate_sha256) + verifier = FakeTargetAttestationVerifier(attestation) + env.effects._external_attestation_verifier = verifier + + with env.store.transaction(): + gates = env.effects.target_gates() + + external_gates = ( + "controllerTrustReady", + "typedEnrollmentTrustReady", + "authenticatedRouteReady", + "accessListReady", + "maintenanceAuthReady", + ) + assert all(gates[name] is True for name in external_gates) + assert verifier.expected == { + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "profileId": "repopulse-authenticated", + "profileVersion": "enrollment-profile/v1", + "planSha256": attestation.plan_sha256, + "intentId": attestation.intent_id, + "targetSecretRef": "jens__repopulse", + "controllerTlsCertificateSha256": local_certificate_sha256, + } + assert not any(name in env.effects.target_gate_gaps for name in external_gates) + with pytest.raises(AttributeError): + attestation.npm_hostname = "attacker.example" + + +def test_target_gates_ignore_untyped_boolean_external_attestation( + effects_env, tmp_path +): + env = effects_env + certificate_file = tmp_path / "controller.pem" + certificate_file.write_text( + "-----BEGIN CERTIFICATE-----\nYWJjZA==\n-----END CERTIFICATE-----\n", + encoding="ascii", + ) + certificate_file.chmod(0o600) + env.status.tls_configuration = {"certificate_file": certificate_file} + env.effects._external_attestation_verifier = FakeTargetAttestationVerifier( + { + name: True + for name in ( + "controllerTrustReady", + "typedEnrollmentTrustReady", + "authenticatedRouteReady", + "accessListReady", + "maintenanceAuthReady", + ) + } + ) + + with env.store.transaction(): + gates = env.effects.target_gates() + + assert gates["controllerTrustReady"] is False + assert gates["typedEnrollmentTrustReady"] is False + assert gates["authenticatedRouteReady"] is False + assert gates["accessListReady"] is False + assert gates["maintenanceAuthReady"] is False + + +@pytest.mark.parametrize( + ("field", "value", "gate"), + [ + ("intent_id", "rpa-ffffffffffffffffffff", "typedEnrollmentTrustReady"), + ("plan_sha256", _sha("different-plan"), "typedEnrollmentTrustReady"), + ( + "controller_tls_certificate_sha256", + _sha("different-controller-cert"), + "controllerTrustReady", + ), + ("npm_certificate_sans", ("wrong.example",), "authenticatedRouteReady"), + ( + "npm_certificate_not_after", + "2020-01-01T00:00:00Z", + "authenticatedRouteReady", + ), + ("npm_access_list_id", 2, "accessListReady"), + ("npm_access_list_satisfy_any", 1, "accessListReady"), + ("npm_access_list_is_deleted", 1, "accessListReady"), + ("npm_access_list_pass_auth", 0, "accessListReady"), + ("npm_access_list_client_count", 1, "accessListReady"), + ("npm_access_list_rules_sha256", _sha("different-rules"), "accessListReady"), + ("maintenance_observed_at", "2020-01-01T00:00:00Z", "maintenanceAuthReady"), + ("maintenance_authenticated_principal", "", "maintenanceAuthReady"), + ], +) +def test_target_gates_reject_attestations_with_unbound_concrete_facts( + effects_env, tmp_path, field, value, gate +): + env = effects_env + certificate_file = tmp_path / "controller.pem" + certificate_file.write_text( + "-----BEGIN CERTIFICATE-----\nYWJjZA==\n-----END CERTIFICATE-----\n", + encoding="ascii", + ) + certificate_file.chmod(0o600) + env.status.tls_configuration = {"certificate_file": certificate_file} + local_certificate_sha256 = hashlib.sha256(b"abcd").hexdigest() + attestation = replace( + _valid_attestation(env, local_certificate_sha256), **{field: value} + ) + env.effects._external_attestation_verifier = FakeTargetAttestationVerifier( + attestation + ) + + with env.store.transaction(): + gates = env.effects.target_gates() + + assert gates[gate] is False + assert gate in env.effects.target_gate_gaps + + +def test_exact_sha_ci_accepts_only_current_main_with_the_single_push_context( + effects_env, +): + env = effects_env + + with env.store.transaction(): + evidence = env.effects.validate_exact_sha_ci_locked( + repository=REPOSITORY, revision=REVISION + ) + + assert evidence == { + "repository": REPOSITORY, + "revision": REVISION, + "contexts": {CONTEXT: "success"}, + } + assert env.status.validation_calls == [(REPOSITORY, REVISION, [CONTEXT])] + git_dir = f"--git-dir={env.deploy.REPOSITORY_ROOT / 'jens' / 'repopulse.git'}" + assert env.deploy.commands == [ + ["git", git_dir, "rev-parse", "refs/heads/main"], + ["git", git_dir, "rev-parse", f"{REVISION}^{{commit}}"], + ] + + +@pytest.mark.parametrize( + ( + "repository", + "revision", + "main_revision", + "validation_status", + "validation_contexts", + ), + [ + ( + "jens/other", + REVISION, + REVISION, + ("pass", [{"context": CONTEXT, "status": "success"}]), + [CONTEXT], + ), + ( + REPOSITORY, + "a" * 39, + REVISION, + ("pass", [{"context": CONTEXT, "status": "success"}]), + [CONTEXT], + ), + ( + REPOSITORY, + REVISION, + "f" * 40, + ("pass", [{"context": CONTEXT, "status": "success"}]), + [CONTEXT], + ), + ( + REPOSITORY, + REVISION, + REVISION, + ("pass", [{"context": CONTEXT, "status": "success"}]), + [CONTEXT, "Other (push)"], + ), + ( + REPOSITORY, + REVISION, + REVISION, + ("wait", [{"context": CONTEXT, "status": "pending"}]), + [CONTEXT], + ), + ( + REPOSITORY, + REVISION, + REVISION, + ("pass", [{"context": "Other (push)", "status": "success"}]), + [CONTEXT], + ), + ], +) +def test_exact_sha_ci_rejects_wrong_identity_head_or_validation_context( + effects_env, + repository, + revision, + main_revision, + validation_status, + validation_contexts, +): + env = effects_env + env.deploy.main_revision = main_revision + env.status.validation_status = validation_status + env.controller_store.managed_target["validation"]["contexts"] = list( + validation_contexts + ) + env.controller_store.projects[REPOSITORY] = dict( + env.controller_store.managed_target + ) + + with ( + env.store.transaction(), + pytest.raises(env.effects_module.ControllerEffectsError), + ): + env.effects.validate_exact_sha_ci_locked( + repository=repository, revision=revision + ) + + +def test_deploy_calls_the_existing_locked_core_and_returns_a_receipt_bound_to_live_runtime( + effects_env, tmp_path, runtime_readable_scan_root, monkeypatch +): + env = effects_env + env.store.registration_receipt = _registration_receipt() + env.controller_store.registration_receipt = env.store.registration_receipt + env.deploy.project = _managed_project() + env.controller_store.projects = {REPOSITORY: dict(env.deploy.project)} + + legacy_env = tmp_path / "deploy.env" + legacy_env.write_text("PRIVATE=preserved\n", encoding="utf-8") + legacy_env.chmod(0o600) + managed_env = tmp_path / "deploy.managed.env" + managed_env.write_text( + "REPOPULSE_GITEA_URL=https://gitea.example.com\n" + "REPOPULSE_GITEA_TOKEN=private-token\n" + "REPOPULSE_MAINTENANCE_TOKEN=private-maintenance-token\n", + encoding="utf-8", + ) + managed_env.chmod(0o600) + monkeypatch.setattr(env.effects_module, "LEGACY_ENVIRONMENT_FILE", legacy_env) + monkeypatch.setattr(env.effects_module, "MANAGED_ENVIRONMENT_FILE", managed_env) + monkeypatch.setattr(env.effects_module, "MANAGED_DATA_PATH", Path(DATA_SOURCE)) + repopulse_scan = runtime_readable_scan_root / "repopulse" + privatemuse_scan = runtime_readable_scan_root / "privatemuse" + repopulse_scan.mkdir(parents=True) + privatemuse_scan.mkdir(parents=True) + repopulse_scan.chmod(0o755) + privatemuse_scan.chmod(0o755) + monkeypatch.setattr(env.effects_module, "REPOPULSE_SCAN_SOURCE", repopulse_scan) + monkeypatch.setattr(env.effects_module, "PRIVATEMUSE_SCAN_SOURCE", privatemuse_scan) + env.deploy.container["Mounts"][1]["Source"] = str(repopulse_scan) + env.deploy.container["Mounts"][2]["Source"] = str(privatemuse_scan) + + snapshot = { + "intentId": "rpa-0123456789abcdef0123", + "snapshotFileSha256": _sha("snapshot"), + "snapshotLogicalSha256": _sha("logical"), + "snapshotQueueSha256": _sha("queue"), + "sourceAuxiliaryTreeSha256": _sha("aux"), + "sourceAuxiliaryFileCount": 2, + "snapshotScanCount": 14, + "snapshotCompactedScanCount": 4, + } + copied = { + "intentId": "rpa-0123456789abcdef0123", + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "targetLogicalSha256": snapshot["snapshotLogicalSha256"], + "targetScanCount": 14, + "targetCompactedScanCount": 4, + "targetQueueSha256": snapshot["snapshotQueueSha256"], + "sourceAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "targetAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "sourceAuxiliaryFileCount": 2, + "targetAuxiliaryFileCount": 2, + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetDataUid": 10001, + "targetDataGid": 10001, + "targetRecoveryStatus": "healthy", + "managedDataMountMatches": True, + "copySourceIsVerifiedSnapshot": True, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + } + + with env.store.transaction(): + proof = env.effects.deploy_exact_sha_locked( + intent_id="rpa-0123456789abcdef0123", + revision=REVISION, + controller_registry_receipt=env.store.registration_receipt, + snapshot_evidence=snapshot, + copied_data_evidence=copied, + ) + + assert env.deploy.events[0] == ("deploy", REVISION) + assert any(name == "verified_current_receipt" for name, _value in env.deploy.events) + assert proof["repository"] == REPOSITORY + assert proof["revision"] == REVISION + assert proof["containerRevision"] == REVISION + assert proof["deploymentReceiptVerified"] is True + assert proof["deploymentReceiptSha256"] == _canonical_sha(env.deploy.receipt) + assert proof["runtimeHealthy"] is True + assert proof["publishedHostPorts"] == [] + assert proof["managedDataMountMatches"] is True + assert proof["targetQueuedJobs"] == proof["targetActiveJobs"] == 0 + assert proof["legacyVolumeRetained"] is True + assert "private-token" not in json.dumps(proof) + + +def test_deploy_rejects_a_receipt_whose_runtime_container_has_a_different_revision( + effects_env, +): + env = effects_env + env.store.registration_receipt = {"intentId": "rpa-0123456789abcdef0123"} + env.controller_store.registration_receipt = env.store.registration_receipt + env.controller_store.projects = {REPOSITORY: dict(env.deploy.project)} + env.deploy.container["Config"]["Labels"]["org.opencontainers.image.revision"] = ( + "f" * 40 + ) + + with ( + env.store.transaction(), + pytest.raises(env.effects_module.ControllerEffectsError), + ): + env.effects.deploy_exact_sha_locked( + intent_id="rpa-0123456789abcdef0123", + revision=REVISION, + controller_registry_receipt=env.store.registration_receipt, + snapshot_evidence={}, + copied_data_evidence={}, + ) + + +def test_stop_managed_writer_stops_only_the_fixed_matching_managed_container_and_proves_volume_retention( + effects_env, +): + env = effects_env + env.deploy.project = _managed_project() + env.controller_store.projects = {REPOSITORY: dict(env.deploy.project)} + + with env.store.transaction(): + proof = env.effects.stop_managed_writer( + intent_id="rpa-0123456789abcdef0123", revision=REVISION + ) + + assert proof["managedWriterStopped"] is True + assert proof["activeWriterCount"] == 0 + assert proof["legacyVolumeRetained"] is True + assert ("run", ["docker", "stop", "c" * 64]) in env.deploy.events + + +def test_stop_managed_writer_refuses_to_stop_a_different_revision(effects_env): + env = effects_env + env.deploy.project = _managed_project() + env.controller_store.projects = {REPOSITORY: dict(env.deploy.project)} + env.deploy.container["Config"]["Labels"]["org.opencontainers.image.revision"] = ( + "f" * 40 + ) + + with ( + env.store.transaction(), + pytest.raises(env.effects_module.ControllerEffectsError), + ): + env.effects.stop_managed_writer( + intent_id="rpa-0123456789abcdef0123", revision=REVISION + ) + + assert not any(event[0] == "run" for event in env.deploy.events) + + +def test_live_completion_revalidates_receipt_container_mount_health_and_exact_identity( + effects_env, tmp_path, monkeypatch +): + env = effects_env + env.deploy.project = _managed_project() + env.controller_store.projects = {REPOSITORY: dict(env.deploy.project)} + env.store.registration_receipt = _registration_receipt() + env.controller_store.registration_receipt = env.store.registration_receipt + legacy_env = tmp_path / "deploy.env" + legacy_env.write_text("PRIVATE=preserved\n", encoding="utf-8") + legacy_env.chmod(0o600) + managed_env = tmp_path / "deploy.managed.env" + managed_env.write_text("REPOPULSE_GITEA_TOKEN=private-token\n", encoding="utf-8") + managed_env.chmod(0o600) + monkeypatch.setattr(env.effects_module, "LEGACY_ENVIRONMENT_FILE", legacy_env) + monkeypatch.setattr(env.effects_module, "MANAGED_ENVIRONMENT_FILE", managed_env) + receipt_sha = _canonical_sha(env.deploy.receipt) + completion = { + "mainRevision": REVISION, + "deploymentReceiptSha256": receipt_sha, + "controllerRegistrationReceiptSha256": env.store.registration_receipt[ + "receiptSha256" + ], + "containerIdSha256": _sha("c" * 64), + "containerImageSha256": "d" * 64, + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + } + + with env.store.transaction(): + proof = env.effects.verify_live_completion_locked(completion=completion) + + assert proof == { + "repository": REPOSITORY, + "revision": REVISION, + "containerRevision": REVISION, + "deploymentReceiptSha256": receipt_sha, + "containerIdSha256": _sha("c" * 64), + "containerImageSha256": "d" * 64, + "runtimeHealthy": True, + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + } + + +def test_live_completion_rejects_changed_container_id_and_refuses_unlocked_calls( + effects_env, +): + env = effects_env + env.deploy.project = _managed_project() + env.controller_store.projects = {REPOSITORY: dict(env.deploy.project)} + env.store.registration_receipt = _registration_receipt() + env.controller_store.registration_receipt = env.store.registration_receipt + completion = { + "mainRevision": REVISION, + "deploymentReceiptSha256": _canonical_sha(env.deploy.receipt), + "controllerRegistrationReceiptSha256": env.store.registration_receipt[ + "receiptSha256" + ], + "containerIdSha256": _sha("other-container"), + "containerImageSha256": "d" * 64, + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + } + + with ( + env.store.transaction(), + pytest.raises(env.effects_module.ControllerEffectsError), + ): + env.effects.verify_live_completion_locked(completion=completion) + + env.controller_store.locks_held = False + with pytest.raises(env.effects_module.ControllerEffectsError): + env.effects.verify_live_completion_locked(completion=completion) + assert env.controller_store.lock_checks[-2:] == [ + env.controller_store.PROJECT_LOCK, + env.controller_store.GLOBAL_DEPLOY_LOCK, + ] diff --git a/backend/tests/test_repopulse_adoption_executor.py b/backend/tests/test_repopulse_adoption_executor.py new file mode 100644 index 0000000..dd8c87d --- /dev/null +++ b/backend/tests/test_repopulse_adoption_executor.py @@ -0,0 +1,818 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import os +import stat +import sys +import threading +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BROKER_DIR = REPO_ROOT / "ops" / "gitea-deploy-broker" +STORE_PATH = BROKER_DIR / "repopulse_adoption_store.py" +EXECUTOR_PATH = BROKER_DIR / "repopulse_adoption_executor.py" +REPOSITORY = "jens/repopulse" +APPLICATION_ID = "repopulse" +LEGACY_VOLUME = "repopulse_data" +PROTOCOL_VERSION = "itworx.repopulse-legacy-adoption/v1" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def _sha(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def _canonical_sha(value: object) -> str: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ).encode("ascii") + return hashlib.sha256(payload).hexdigest() + + +def _preflight_projection() -> dict: + return { + "repository": REPOSITORY, + "main_revision": "a" * 40, + "live_revision": "b" * 40, + "container_id_sha256": _sha("legacy-container"), + "container_image_sha256": _sha("legacy-image"), + "queue": {"queued": 0, "active": 0, "completed": 572, "failed": 0}, + "database": { + "database_bytes": 44_576_768, + "journal_bytes": 0, + "scan_count": 572, + "compacted_scan_count": 96, + }, + "container_state": "running", + "container_health": "healthy", + } + + +def _command(executor): + idempotency_sha256 = _sha(f"{PROTOCOL_VERSION}:{REPOSITORY}:repopulse-adoption-2026") + projection = _preflight_projection() + return executor.AdoptionCommand( + intent_id=f"rpa-{idempotency_sha256[:20]}", + idempotency_sha256=idempotency_sha256, + preflight_sha256=_canonical_sha(projection), + preflight_projection=projection, + ) + + +def _target_gates() -> dict: + return { + "profileId": "repopulse-authenticated", + "profileVersion": "enrollment-profile/v1", + "planSha256": _sha("reviewed fixed profile plan"), + "profileReady": True, + "controllerTrustReady": True, + "controllerBootstrapSecretImported": True, + "typedEnrollmentTrustReady": True, + "targetSecretReady": True, + "exactPolicyReady": True, + "authenticatedRouteReady": True, + "accessListReady": True, + "mutationBoundaryReady": True, + "managedRuntimeReady": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "ownedDataTargetReady": True, + "managedEnvironmentReady": True, + "legacyEnvironmentRetained": True, + "maintenanceAuthReady": True, + "controllerAdoptionCapabilityReady": True, + "controllerRegistryWriterReady": True, + "controllerSecretWriterReady": True, + } + + +def _quiescence(command, binding: str) -> dict: + projection = command.preflight_projection + return { + "intentId": command.intent_id, + "globalDeployLockHeld": True, + "projectLockHeld": True, + "maintenanceModeActive": True, + "queueFrozen": True, + "queuedJobs": 0, + "activeJobs": 0, + "maintenanceModeAuthenticated": True, + "containerName": "repopulse", + "containerIdSha256": projection["container_id_sha256"], + "containerImageSha256": projection["container_image_sha256"], + "containerState": "exited", + "legacyVolume": LEGACY_VOLUME, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": binding, + } + + +def _snapshot(command, binding: str) -> dict: + return { + "intentId": command.intent_id, + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyContainerStopped": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "sqliteBackupMethod": "sqlite_online_backup", + "quickCheck": "ok", + "integrityCheck": "ok", + "sourceLogicalSha256": _sha("logical-sqlite-data"), + "snapshotLogicalSha256": _sha("logical-sqlite-data"), + "snapshotFileSha256": _sha("fresh-sqlite-backup"), + "sourceScanCount": 572, + "snapshotScanCount": 572, + "sourceCompactedScanCount": 96, + "snapshotCompactedScanCount": 96, + "sourceQueueSha256": _sha("queue-state"), + "snapshotQueueSha256": _sha("queue-state"), + "sourceAuxiliaryTreeSha256": _sha("auxiliary-tree"), + "sourceAuxiliaryFileCount": 42, + "targetDataPathWasAbsent": True, + "snapshotReceiptDurable": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "controllerIntentBindingSha256": binding, + } + + +def _copy_evidence(command, snapshot: dict) -> dict: + return { + "intentId": command.intent_id, + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "targetLogicalSha256": snapshot["snapshotLogicalSha256"], + "targetScanCount": snapshot["snapshotScanCount"], + "targetCompactedScanCount": snapshot["snapshotCompactedScanCount"], + "targetQueueSha256": snapshot["snapshotQueueSha256"], + "sourceAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "targetAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "sourceAuxiliaryFileCount": snapshot["sourceAuxiliaryFileCount"], + "targetAuxiliaryFileCount": snapshot["sourceAuxiliaryFileCount"], + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetDataUid": 10001, + "targetDataGid": 10001, + "targetRecoveryStatus": "healthy", + "managedDataMountMatches": True, + "copySourceIsVerifiedSnapshot": True, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + } + + +def _legacy_rollback_evidence(command, legacy_projects_sha256: str) -> dict: + return { + "intentId": command.intent_id, + "repository": REPOSITORY, + "legacyContainerName": "repopulse", + "legacyVolume": LEGACY_VOLUME, + "legacyProjectsSha256": legacy_projects_sha256, + "rollbackSnapshotSha256": _sha("durable old compose rollback snapshot"), + "rollbackSnapshotDurable": True, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + } + + +def _deployment_receipt(command, snapshot: dict, copy: dict) -> dict: + revision = command.preflight_projection["main_revision"] + return { + "repository": REPOSITORY, + "revision": revision, + "containerRevision": revision, + "containerIdSha256": _sha("managed-container"), + "deploymentReceiptSchema": "gitea-deploy-receipt.v1", + "deploymentReceiptSha256": _sha("verified-deploy-receipt"), + "deploymentReceiptVerified": True, + "runtimeHealthy": True, + "publishedHostPorts": [], + "managedDataMountMatches": copy["managedDataMountMatches"], + "targetDataUid": copy["targetDataUid"], + "targetDataGid": copy["targetDataGid"], + "targetLogicalSha256": copy["targetLogicalSha256"], + "targetScanCount": copy["targetScanCount"], + "targetCompactedScanCount": copy["targetCompactedScanCount"], + "targetQueueSha256": copy["targetQueueSha256"], + "targetAuxiliaryTreeSha256": copy["targetAuxiliaryTreeSha256"], + "targetAuxiliaryFileCount": copy["targetAuxiliaryFileCount"], + "targetQuickCheck": copy["targetQuickCheck"], + "targetIntegrityCheck": copy["targetIntegrityCheck"], + "targetRecoveryStatus": copy["targetRecoveryStatus"], + "targetQueuedJobs": 0, + "targetActiveJobs": 0, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "containerName": "repopulse", + "containerImageSha256": _sha("managed-image"), + "snapshotFileSha256": snapshot["snapshotFileSha256"], + } + + +@pytest.fixture +def executor_env(tmp_path, monkeypatch): + module = _load_module("repopulse_adoption_store", STORE_PATH) + controller = tmp_path / "controller" + controller.mkdir() + controller.chmod(0o755) + (controller / "projects.json").write_text( + json.dumps( + { + REPOSITORY: { + "branch": "main", + "compose_files": ["docker-compose.yml"], + "compose_project": "repopulse", + "containers": ["repopulse"], + "env_source": "/srv/example/appdata/repopulse/deploy.env", + "exact_source": True, + "gitea_repository_id": 64, + "gitea_webhook_secret_file": "jens__repopulse", + "health_timeout": 180, + "slug": "repopulse", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse", + "validation": { + "contexts": ["Managed validation / full (push)"], + "max_poll_interval_seconds": 30, + "poll_interval_seconds": 5, + "required": True, + "timeout_seconds": 1800, + "validation_gate_version": 1, + }, + "verify_oci_revision": False, + }, + "jens/other": {"slug": "other"}, + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + (controller / "projects.json").chmod(0o600) + secrets = controller / "appops-deployment-secrets" + secrets.mkdir() + secrets.chmod(0o700) + state = tmp_path / "state" + state.mkdir() + state.chmod(0o755) + locks = state / "locks" + locks.mkdir() + locks.chmod(0o755) + global_lock = tmp_path / "deploy.lock" + state_store = state / "repopulse-adoption" + + monkeypatch.setattr(module, "CONTROLLER_READONLY_ROOT", controller) + monkeypatch.setattr(module, "CONTROLLER_RW_ROOT", controller) + monkeypatch.setattr(module, "STATE_ROOT", state) + monkeypatch.setattr(module, "GLOBAL_DEPLOY_LOCK", global_lock) + monkeypatch.setattr(module, "PROJECT_LOCK", locks / "repopulse.lock") + monkeypatch.setattr(module, "ADOPTION_STATE_ROOT", state_store) + monkeypatch.setattr(module, "EXECUTION_ENABLED", True) + monkeypatch.setattr(module, "_IS_POSIX_CONTROLLER", True) + test_uid = controller.stat().st_uid + monkeypatch.setattr(module, "_ROOT_UID", test_uid) + monkeypatch.setattr(module, "_effective_uid", lambda: test_uid) + if os.name != "posix": + monkeypatch.setattr( + module, + "_mode_bits", + lambda path, info: ( + 0o700 + if path.name in {"appops-deployment-secrets", "repopulse-adoption"} + else 0o755 + if stat.S_ISDIR(info.st_mode) + else 0o600 + ), + ) + monkeypatch.setattr(module, "_directory_fsync", lambda _path: None) + + executor = _load_module("repopulse_adoption_executor_test", EXECUTOR_PATH) + return module, module.RepoPulseAdoptionStore(), executor, controller, state_store + + +class Effects: + def __init__( + self, + store_module, + command, + *, + bad_receipt_field: str | None = None, + bad_copy=False, + bad_validation=False, + fail_rollback_stop=False, + bad_live_completion=False, + ): + self.store_module = store_module + self.command = command + self.bad_receipt_field = bad_receipt_field + self.bad_copy = bad_copy + self.bad_validation = bad_validation + self.fail_rollback_stop = fail_rollback_stop + self.bad_live_completion = bad_live_completion + self.events: list[str] = [] + self.credential = b"target-secret-never-log-" + b"x" * 40 + + def _event(self, name: str) -> None: + assert self.store_module._transaction_owner(self.store_module._lock_identity()) == threading.get_ident() + self.events.append(name) + + def target_gates(self): + self._event("gates") + return _target_gates() + + def capture_legacy_rollback(self, *, intent_id: str, expected_legacy_projects_sha256: str): + self._event("capture_legacy_rollback") + assert intent_id == self.command.intent_id + return _legacy_rollback_evidence(self.command, expected_legacy_projects_sha256) + + def ensure_quiesced( + self, + *, + intent_id: str, + intent_binding_sha256: str, + expected_main_revision: str, + expected_container_id_sha256: str, + expected_container_image_sha256: str, + ): + self._event("quiesce") + assert intent_id == self.command.intent_id + assert expected_main_revision == self.command.preflight_projection["main_revision"] + assert expected_container_id_sha256 == self.command.preflight_projection["container_id_sha256"] + assert expected_container_image_sha256 == self.command.preflight_projection["container_image_sha256"] + return _quiescence(self.command, intent_binding_sha256) + + def create_or_verify_fresh_snapshot( + self, + *, + intent_id: str, + intent_binding_sha256: str, + expected_main_revision: str, + expected_snapshot, + ): + self._event("snapshot") + assert intent_id == self.command.intent_id + assert expected_main_revision == self.command.preflight_projection["main_revision"] + proof = _snapshot(self.command, intent_binding_sha256) + if expected_snapshot is not None: + assert expected_snapshot["fileSha256"] == proof["snapshotFileSha256"] + return proof + + def copy_and_verify_managed_data( + self, *, intent_id: str, expected_main_revision: str, snapshot_evidence: dict + ): + self._event("copy") + assert intent_id == self.command.intent_id + assert expected_main_revision == self.command.preflight_projection["main_revision"] + proof = _copy_evidence(self.command, snapshot_evidence) + if self.bad_copy: + proof["copySourceIsVerifiedSnapshot"] = False + return proof + + def registration_material(self, *, intent_id: str): + self._event("registration_material") + assert intent_id == self.command.intent_id + return "enr-0123456789abcdef0123", self.credential + + def validate_exact_sha_ci_locked(self, *, repository: str, revision: str): + self._event("validation_gate") + assert repository == REPOSITORY + assert revision == self.command.preflight_projection["main_revision"] + _raw, projects, credential = self.store_module._check_controller_aliases() + assert projects[REPOSITORY]["managed_profile"] == "repopulse-authenticated" + assert credential == self.credential + return { + "repository": REPOSITORY, + "revision": revision, + "contexts": {"Managed validation / full (push)": "failure" if self.bad_validation else "success"}, + } + + def deploy_exact_sha_locked( + self, + *, + intent_id: str, + revision: str, + controller_registry_receipt: dict, + snapshot_evidence: dict, + copied_data_evidence: dict, + ): + self._event("deploy") + assert intent_id == self.command.intent_id + assert revision == self.command.preflight_projection["main_revision"] + assert controller_registry_receipt["phase"] == "registry_committed" + proof = _deployment_receipt(self.command, snapshot_evidence, copied_data_evidence) + if self.bad_receipt_field: + proof[self.bad_receipt_field] = "wrong" if self.bad_receipt_field in {"repository", "revision", "containerRevision"} else False + return proof + + def stop_managed_writer(self, *, intent_id: str, revision: str): + self._event("stop_managed_writer") + assert intent_id == self.command.intent_id + assert revision == self.command.preflight_projection["main_revision"] + return { + "intentId": intent_id, + "repository": REPOSITORY, + "containerName": "repopulse", + "managedWriterStopped": not self.fail_rollback_stop, + "activeWriterCount": 0 if not self.fail_rollback_stop else 1, + "legacyVolumeRetained": True, + } + + def restore_legacy_registry( + self, + *, + intent_id: str, + expected_legacy_projects_sha256: str, + controller_registry_receipt: dict | None, + ): + self._event("restore_legacy_registry") + assert intent_id == self.command.intent_id + assert expected_legacy_projects_sha256 == _legacy_rollback_evidence(self.command, expected_legacy_projects_sha256)["legacyProjectsSha256"] + projects_path = self.store_module.CONTROLLER_RW_ROOT / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects[REPOSITORY] = self.store_module._legacy_registry_record() + projects_path.write_text(json.dumps(projects, sort_keys=True) + "\n", encoding="utf-8") + projects_path.chmod(0o600) + return { + "intentId": intent_id, + "repository": REPOSITORY, + "projectsSha256": expected_legacy_projects_sha256, + "legacyRegistryRestored": True, + "legacyVolumeRetained": True, + } + + def restore_legacy_runtime( + self, + *, + legacy_rollback_evidence: dict, + legacy_registry_evidence: dict, + failed_revision: str, + ): + self._event("restore_legacy_runtime") + assert failed_revision == self.command.preflight_projection["main_revision"] + assert legacy_registry_evidence["legacyRegistryRestored"] is True + assert legacy_rollback_evidence["legacyVolume"] == LEGACY_VOLUME + return { + "intentId": self.command.intent_id, + "repository": REPOSITORY, + "containerName": "repopulse", + "projectsSha256": legacy_registry_evidence["projectsSha256"], + "legacyRuntimeHealthy": True, + "activeWriterCount": 1, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + } + + def verify_live_completion_locked(self, *, completion: dict): + self._event("verify_completion") + proof = { + "repository": REPOSITORY, + "revision": completion["mainRevision"], + "containerRevision": completion["mainRevision"], + "deploymentReceiptSha256": completion["deploymentReceiptSha256"], + "containerIdSha256": completion["containerIdSha256"], + "containerImageSha256": completion["containerImageSha256"], + "runtimeHealthy": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + } + if self.bad_live_completion: + proof["containerRevision"] = "f" * 40 + return proof + + +def test_executor_stays_hard_disabled_without_touching_store_or_effects(executor_env, monkeypatch): + store_module, store, executor_module, _controller, state_store = executor_env + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", False) + command = _command(executor_module) + effects = object() + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + + assert executor_module.EXECUTION_ENABLED is False + assert executor_module.execution_status()["hasEffectfulRoute"] is False + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor.execute(command) + + assert error.value.code == "controller_adoption_execution_disabled" + assert not state_store.exists() + assert store_module._transaction_owner(store_module._lock_identity()) is None + + +def test_executor_holds_project_then_global_transaction_through_deploy_receipt(executor_env, monkeypatch): + store_module, store, executor_module, controller, state_store = executor_env + command = _command(executor_module) + effects = Effects(store_module, command) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + + result = executor.execute(command) + + assert effects.events == ["gates", "capture_legacy_rollback", "quiesce", "snapshot", "copy", "registration_material", "validation_gate", "deploy"] + assert result["repository"] == REPOSITORY + assert result["applicationId"] == APPLICATION_ID + assert result["deploymentReceipt"]["revision"] == command.preflight_projection["main_revision"] + assert result["deploymentReceipt"]["containerRevision"] == command.preflight_projection["main_revision"] + assert result["legacyVolumeRetained"] is True + intent = store.load_intent() + assert intent is not None + assert intent["phase"] == "snapshot_verified" + assert intent["status"] == "in_progress" + assert intent["registration"]["status"] == "committed" + assert store.load_receipt()["phase"] == "registry_committed" + persisted = (state_store / "intent.json").read_text(encoding="utf-8") + persisted += (state_store / "receipt.json").read_text(encoding="utf-8") + assert effects.credential.decode("ascii") not in persisted + assert effects.credential.decode("ascii") not in json.dumps(result) + assert result["completion"]["legacyVolumeRetained"] is True + assert result["completion"]["mainRevision"] == command.preflight_projection["main_revision"] + completion_path = state_store / "completion.json" + assert completion_path.is_file() + assert executor_module._store._mode_bits(completion_path, completion_path.stat()) == 0o600 + assert json.loads((controller / "projects.json").read_text(encoding="utf-8"))[REPOSITORY]["managed_profile"] == "repopulse-authenticated" + + +def test_completion_replay_reverifies_registry_and_live_receipt_without_redeploy(executor_env, monkeypatch): + store_module, store, executor_module, _controller, _state_store = executor_env + command = _command(executor_module) + effects = Effects(store_module, command) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + + executor.execute(command) + effects.events.clear() + replay = executor.execute(command) + + assert effects.events == ["verify_completion"] + assert replay["phase"] == "completion_verified" + assert replay["completion"]["intentBindingSha256"] == store.load_intent()["intentBindingSha256"] + + +def test_completion_replay_mismatch_fails_closed_without_starting_or_deploying(executor_env, monkeypatch): + store_module, store, executor_module, _controller, _state_store = executor_env + command = _command(executor_module) + effects = Effects(store_module, command) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + executor.execute(command) + effects.events.clear() + effects.bad_live_completion = True + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor.execute(command) + + assert error.value.code == "controller_completion_live_receipt_invalid" + assert effects.events == ["verify_completion"] + assert store.load_intent()["status"] == "recovery_required" + + +def test_registry_cutover_without_completion_requires_reconciliation(executor_env, monkeypatch): + store_module, store, executor_module, _controller, state_store = executor_env + command = _command(executor_module) + effects = Effects(store_module, command) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + executor.execute(command) + (state_store / "completion.json").unlink() + effects.events.clear() + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor.execute(command) + + assert error.value.code == "controller_completion_reconciliation_required" + assert effects.events == [] + assert store.load_intent()["status"] == "recovery_required" + + +def test_private_legacy_snapshot_is_durable_and_restores_only_fixed_compose_runtime(executor_env, tmp_path, monkeypatch): + store_module, store, executor_module, controller, state_store = executor_env + legacy_env = tmp_path / "legacy-deploy.env" + legacy_env.write_text("PRIVATE=not-read\n", encoding="utf-8") + legacy_env.chmod(0o600) + fixed_legacy = store_module._legacy_registry_record() + fixed_legacy["env_source"] = str(legacy_env) + monkeypatch.setattr(store_module, "_legacy_registry_record", lambda: dict(fixed_legacy)) + projects_path = controller / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects[REPOSITORY] = fixed_legacy + projects_path.write_text(json.dumps(projects, sort_keys=True) + "\n", encoding="utf-8") + projects_path.chmod(0o600) + monkeypatch.setattr(executor_module, "LEGACY_ENVIRONMENT_FILE", legacy_env) + + class FakeDeployCore: + def __init__(self): + self.restored = [] + + def repository_path(self, repository, project): + assert repository == REPOSITORY + assert project["compose_project"] == "repopulse" + return tmp_path / "repopulse.git" + + def inspect_container(self, name): + assert name == "repopulse" + return { + "Id": "c" * 64, + "Image": "sha256:" + "d" * 64, + "State": {"Running": True}, + "Mounts": [{"Type": "volume", "Name": LEGACY_VOLUME, "RW": True}], + } + + def output(self, command): + assert command == [ + "docker", + "ps", + "--all", + "--filter", + f"volume={LEGACY_VOLUME}", + "--format", + "{{.Names}}", + ] + return "repopulse\n" + + def capture_compose_rollback(self, project): + assert project["_repository_name"] == REPOSITORY + assert project["compose_files"] == ["docker-compose.yml"] + return { + "revision": "b" * 40, + "legacy_provenance": True, + "receipt": {}, + "compose_containers": ["repopulse"], + "containers": [{ + "name": "repopulse", + "container_id": "c" * 64, + "image_id": "sha256:" + "d" * 64, + "image_ref": "repopulse:legacy", + "rollback_ref": "gitea-deploy-rollback/repopulse-bbbbbbbbbbbb", + "was_running": True, + }], + "captured_at_epoch": 1, + } + + def restore_compose_snapshot(self, project, snapshot, repository, failed_revision): + assert project["compose_files"] == ["docker-compose.yml"] + assert project["env_source"] == str(legacy_env) + assert snapshot["legacy_provenance"] is True + assert repository == REPOSITORY + assert failed_revision == "a" * 40 + self.restored.append("legacy-compose") + + def verify_containers(self, project, *, expected_revision, expected_image_ids): + assert project["compose_project"] == "repopulse" + assert expected_revision is None + assert expected_image_ids == {"repopulse": "sha256:" + "d" * 64} + return {"repopulse": {"image_id": "sha256:" + "d" * 64}} + + def container_state(self, name): + assert name == "repopulse" + return True, "healthy", "repopulse" + + core = FakeDeployCore() + adapter = executor_module.FixedLegacyRollbackAdapter(store=store, deployment_core=core) + intent_id = "rpa-0123456789abcdef0123" + projects_sha = hashlib.sha256(projects_path.read_bytes()).hexdigest() + with store.transaction(): + evidence = adapter.capture_legacy_rollback( + intent_id=intent_id, + expected_legacy_projects_sha256=projects_sha, + ) + snapshot_file = state_store / f"legacy-runtime-{intent_id}.json" + assert snapshot_file.is_file() + assert executor_module._store._mode_bits(snapshot_file, snapshot_file.stat()) == 0o600 + raw_record = json.loads(snapshot_file.read_text(encoding="utf-8")) + assert raw_record["snapshot"]["containers"][0]["name"] == "repopulse" + proof = adapter.restore_legacy_runtime( + legacy_rollback_evidence=evidence, + legacy_registry_evidence={ + "intentId": intent_id, + "repository": REPOSITORY, + "projectsSha256": projects_sha, + "legacyRegistryRestored": True, + "legacyVolumeRetained": True, + }, + failed_revision="a" * 40, + ) + + assert core.restored == ["legacy-compose"] + assert proof["legacyRuntimeHealthy"] is True + assert proof["activeWriterCount"] == 1 + assert "legacy-deploy.env" not in json.dumps(proof) + + +@pytest.mark.parametrize("field", ["repository", "revision", "containerRevision"]) +def test_unverified_exact_sha_controller_receipt_enters_durable_recovery(executor_env, monkeypatch, field): + store_module, store, executor_module, _controller, state_store = executor_env + command = _command(executor_module) + effects = Effects(store_module, command, bad_receipt_field=field) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor.execute(command) + + assert error.value.code == "controller_deployment_receipt_mismatch" + intent = store.load_intent() + assert intent is not None + assert intent["phase"] == "snapshot_verified" + assert intent["status"] == "recovery_required" + assert intent["recovery"]["reason"] == "target_health_failed" + assert LEGACY_VOLUME in str(intent) + assert "target-secret-never-log" not in str(error.value) + assert effects.events[-4:] == ["deploy", "stop_managed_writer", "restore_legacy_registry", "restore_legacy_runtime"] + assert effects.events.index("stop_managed_writer") < effects.events.index("restore_legacy_registry") < effects.events.index("restore_legacy_runtime") + assert (state_store / "receipt.json").is_file() + + +def test_unverified_managed_copy_blocks_registry_cas_and_records_recovery(executor_env, monkeypatch): + store_module, store, executor_module, controller, _state_store = executor_env + command = _command(executor_module) + effects = Effects(store_module, command, bad_copy=True) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + executor = executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects) + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor.execute(command) + + assert error.value.code == "managed_data_copy_not_verified" + assert effects.events == ["gates", "capture_legacy_rollback", "quiesce", "snapshot", "copy", "stop_managed_writer", "restore_legacy_registry", "restore_legacy_runtime"] + assert json.loads((controller / "projects.json").read_text(encoding="utf-8"))[REPOSITORY]["env_source"] == "/srv/example/appdata/repopulse/deploy.env" + assert not (controller / "appops-deployment-secrets" / "jens__repopulse").exists() + intent = store.load_intent() + assert intent is not None + assert intent["phase"] == "quiesced" + assert intent["status"] == "recovery_required" + assert intent["recovery"]["reason"] == "database_backup_failed" + + +def test_exact_sha_ci_gate_is_required_before_deploy_and_failed_gate_restores_legacy(executor_env, monkeypatch): + _store_module, store, executor_module, controller, _state_store = executor_env + command = _command(executor_module) + effects = Effects(_store_module, command, bad_validation=True) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects).execute(command) + + assert error.value.code == "exact_sha_validation_not_passed" + assert effects.events[-3:] == ["stop_managed_writer", "restore_legacy_registry", "restore_legacy_runtime"] + assert "deploy" not in effects.events + assert json.loads((controller / "projects.json").read_text(encoding="utf-8"))[REPOSITORY]["env_source"] == "/srv/example/appdata/repopulse/deploy.env" + assert store.load_intent()["status"] == "recovery_required" + + +def test_legacy_runtime_is_never_restarted_when_managed_writer_stop_is_unproven(executor_env, monkeypatch): + _store_module, store, executor_module, _controller, _state_store = executor_env + command = _command(executor_module) + effects = Effects(_store_module, command, bad_receipt_field="revision", fail_rollback_stop=True) + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects).execute(command) + + assert error.value.code == "legacy_rollback_incomplete" + assert effects.events[-1] == "stop_managed_writer" + assert "restore_legacy_registry" not in effects.events + assert "restore_legacy_runtime" not in effects.events + assert store.load_intent()["status"] == "recovery_required" + + +def test_invalid_or_changed_fixed_preflight_is_rejected_before_external_effects(executor_env, monkeypatch): + _store_module, store, executor_module, _controller, state_store = executor_env + command = _command(executor_module) + changed_projection = dict(command.preflight_projection) + changed_projection["repository"] = "jens/other" + changed = executor_module.AdoptionCommand( + intent_id=command.intent_id, + idempotency_sha256=command.idempotency_sha256, + preflight_sha256=command.preflight_sha256, + preflight_projection=changed_projection, + ) + effects = object() + monkeypatch.setattr(executor_module, "EXECUTION_ENABLED", True) + + with pytest.raises(executor_module.AdoptionExecutorError) as error: + executor_module.RepoPulseAdoptionExecutor(store=store, effects=effects).execute(changed) + + assert error.value.code == "preflight_projection_invalid" + assert not state_store.exists() diff --git a/backend/tests/test_repopulse_adoption_journal.py b/backend/tests/test_repopulse_adoption_journal.py new file mode 100644 index 0000000..07a9015 --- /dev/null +++ b/backend/tests/test_repopulse_adoption_journal.py @@ -0,0 +1,497 @@ +from __future__ import annotations + +import hashlib + +import pytest +from sqlalchemy import inspect +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.db.models import RepoPulseAdoptionJournal +from backend.app.services import repopulse_adoption_journal as journal +from backend.app.services.enrollment_profiles import PROFILE_VERSION +from backend.app.services.repopulse_legacy_adoption_protocol import ( + APPLICATION_ID, + CONTROLLER_RECEIPT_VERSION, + LEGACY_VOLUME, + PROTOCOL_VERSION, + REPOSITORY, + advance_intent, + create_intent, + intent_binding_sha256, +) + + +def _sha(char: str) -> str: + return char * 64 + + +def _passing_preflight() -> dict: + check_codes = ( + "legacy_registry", + "controller_state", + "gitea_ancestry", + "runtime_identity_and_health", + "legacy_storage_topology", + "legacy_appdata", + "product_api", + "database_health", + "queue_quiescence", + "database_baseline", + ) + return { + "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", + "repository": REPOSITORY, + "status": "preflight_passed", + "legacyCompatibility": { + "status": "passed", + "checks": [ + {"code": code, "status": "passed", "gate": "legacy_compatibility"} + for code in check_codes + ], + }, + "targetAcceptance": {"status": "blocked", "checks": []}, + "readOnly": True, + "adoptionAuthorized": False, + "evidence": { + "main_revision": "a" * 40, + "live_revision": "b" * 40, + "container_id_sha256": _sha("d"), + "container_image_sha256": _sha("1"), + "container_state": "running", + "container_health": "healthy", + "queue": {"queued": 0, "active": 0, "completed": 569, "failed": 0}, + "database": { + "database_bytes": 44_576_768, + "journal_bytes": 0, + "scan_count": 572, + "compacted_scan_count": 96, + }, + }, + } + + +def _target_gates() -> dict: + return { + "profileId": "repopulse-authenticated", + "profileVersion": PROFILE_VERSION, + "planSha256": _sha("c"), + **{ + key: True + for key in ( + "profileReady", + "controllerTrustReady", + "controllerBootstrapSecretImported", + "typedEnrollmentTrustReady", + "targetSecretReady", + "exactPolicyReady", + "authenticatedRouteReady", + "accessListReady", + "mutationBoundaryReady", + "managedRuntimeReady", + "scanSourceMatches", + "scanMountReadOnly", + "scanPermissionPolicyPersistent", + "scanAccessForRuntimeIdentityReady", + "ownedDataTargetReady", + "managedEnvironmentReady", + "legacyEnvironmentRetained", + "maintenanceAuthReady", + "controllerAdoptionCapabilityReady", + "controllerRegistryWriterReady", + "controllerSecretWriterReady", + ) + }, + } + + +def _quiescence(intent: dict) -> dict: + return { + "intentId": intent["intentId"], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "maintenanceModeActive": True, + "queueFrozen": True, + "queuedJobs": 0, + "activeJobs": 0, + "maintenanceModeAuthenticated": True, + "containerName": "repopulse", + "containerIdSha256": _sha("d"), + "containerImageSha256": _sha("1"), + "containerState": "exited", + "legacyVolume": LEGACY_VOLUME, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _snapshot(intent: dict) -> dict: + return { + "intentId": intent["intentId"], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyContainerStopped": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "sqliteBackupMethod": "sqlite_online_backup", + "quickCheck": "ok", + "integrityCheck": "ok", + "sourceLogicalSha256": _sha("e"), + "snapshotLogicalSha256": _sha("e"), + "snapshotFileSha256": _sha("f"), + "sourceScanCount": 572, + "snapshotScanCount": 572, + "sourceCompactedScanCount": 96, + "snapshotCompactedScanCount": 96, + "sourceQueueSha256": _sha("1"), + "snapshotQueueSha256": _sha("1"), + "sourceAuxiliaryTreeSha256": _sha("3"), + "sourceAuxiliaryFileCount": 42, + "targetDataPathWasAbsent": True, + "snapshotReceiptDurable": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _registration(intent: dict) -> dict: + gates = intent["evidence"]["targetGates"] + snapshot = intent["evidence"]["snapshot"] + return { + "receiptVersion": "itworx.repopulse-legacy-adoption-receipt/v1", + "intentId": intent["intentId"], + "receiptSha256": _sha("2"), + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": "enr-0123456789abcdefabcd", + "profileId": gates["profileId"], + "planSha256": gates["planSha256"], + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "targetLogicalSha256": snapshot["snapshotLogicalSha256"], + "targetScanCount": snapshot["snapshotScanCount"], + "targetCompactedScanCount": snapshot["snapshotCompactedScanCount"], + "targetQueueSha256": snapshot["snapshotQueueSha256"], + "targetAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "targetAuxiliaryFileCount": snapshot["sourceAuxiliaryFileCount"], + "targetQueuedJobs": 0, + "targetActiveJobs": 0, + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetRecoveryStatus": "healthy", + "targetDataUid": 10001, + "targetDataGid": 10001, + "managedDataMountMatches": True, + "copySourceIsVerifiedSnapshot": True, + "registryTargetMatches": True, + "controllerStateReceiptVerified": True, + "deploymentReceiptSchema": "gitea-deploy-receipt.v1", + "deploymentReceiptSha256": _sha("4"), + "deploymentReceiptVerified": True, + "runtimeHealthy": True, + "publishedHostPorts": [], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _ownership(intent: dict, registration: dict) -> dict: + return { + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": registration["enrollmentId"], + "profileId": registration["profileId"], + "planSha256": intent["evidence"]["targetGates"]["planSha256"], + "controllerReceiptSha256": registration["receiptSha256"], + "enrollmentPersisted": True, + "exactApplyPolicyPersisted": True, + "exactRollbackPolicyPersisted": True, + "secretReferenceReady": True, + } + + +@pytest.fixture +def engine(tmp_path): + database_engine = create_engine(f"sqlite:///{tmp_path / 'appops.db'}") + SQLModel.metadata.create_all(database_engine) + yield database_engine + database_engine.dispose() + + +def _reserve(engine, key: str = "repopulse-adoption-2026") -> dict: + with Session(engine) as session: + return journal.reserve_repopulse_adoption(session, _passing_preflight(), key) + + +def test_create_all_registers_singleton_table_and_reservation_survives_restart(engine) -> None: + assert inspect(engine).has_table("repopulse_adoption_journal") + + first = _reserve(engine) + assert first["apiVersion"] == PROTOCOL_VERSION + assert first["status"] == "in_progress" + assert first["phase"] == "reserved" + assert first["repository"] == REPOSITORY + assert first["applicationId"] == APPLICATION_ID + assert first["revision"] == 1 + + # A new Session models a process restart: the slot and idempotent replay + # are read back from SQLite, not held in adapter memory. + replay = _reserve(engine) + assert replay == first + + with Session(engine) as session: + status = journal.get_repopulse_adoption_status(session) + assert status == first + + +def test_changed_preflight_and_different_key_conflict_without_replacing_slot(engine) -> None: + first = _reserve(engine) + changed = _passing_preflight() + changed["evidence"]["live_revision"] = "c" * 40 + + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.reserve_repopulse_adoption(session, changed, "repopulse-adoption-2026") + assert error.value.code == "idempotency_conflict" + + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.reserve_repopulse_adoption( + session, _passing_preflight(), "repopulse-adoption-2027" + ) + assert error.value.code == "one_time_adoption_already_reserved" + assert _reserve(engine) == first + + +def test_reservation_rejects_another_repository_identity_without_creating_a_row(engine) -> None: + other_repository = _passing_preflight() + other_repository["repository"] = "someone-else/repopulse" + + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.reserve_repopulse_adoption( + session, other_repository, "repopulse-adoption-2026" + ) + assert error.value.code == "legacy_preflight_not_compatible" + + with Session(engine) as session: + assert journal.get_repopulse_adoption_status(session)["status"] == "unreserved" + + intent = create_intent(_passing_preflight(), "repopulse-adoption-2026") + with Session(engine) as session, pytest.raises(IntegrityError): + session.add( + RepoPulseAdoptionJournal( + slot="other", + repository="someone-else/repopulse", + application_id="other-app", + intent_id=intent["intentId"], + idempotency_sha256=intent["idempotencySha256"], + preflight_sha256=intent["preflightSha256"], + phase=intent["phase"], + status=intent["status"], + revision=1, + intent_json=intent, + ) + ) + session.commit() + + +def test_controller_intent_confirmation_requires_exact_binding_and_replays(engine) -> None: + _reserve(engine) + with Session(engine) as session: + after_gates = journal.advance_repopulse_adoption( + session, "target_gates", _target_gates(), expected_revision=1 + ) + assert after_gates["phase"] == "target_gates_ready" + assert after_gates["revision"] == 2 + + current = create_intent(_passing_preflight(), "repopulse-adoption-2026") + valid_confirmation = _quiescence(current) + invalid_confirmation = {**valid_confirmation, "controllerIntentBindingSha256": _sha("0")} + + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.confirm_repopulse_controller_intent( + session, invalid_confirmation, expected_revision=2 + ) + assert error.value.code == "controller_intent_binding_mismatch" + + with Session(engine) as session: + confirmed = journal.confirm_repopulse_controller_intent( + session, valid_confirmation, expected_revision=2 + ) + assert confirmed["phase"] == "quiesced" + assert confirmed["revision"] == 3 + + with Session(engine) as session: + replay = journal.confirm_repopulse_controller_intent( + session, valid_confirmation, expected_revision=2 + ) + assert replay == confirmed + + +def test_compare_and_swap_rejects_stale_checkpoint_without_overwriting_state(engine) -> None: + first = _reserve(engine) + with Session(engine) as session: + updated = journal.advance_repopulse_adoption( + session, + "target_gates", + _target_gates(), + expected_revision=1, + ) + assert updated["status"] == "in_progress" + assert updated["revision"] == 2 + + candidate = create_intent(_passing_preflight(), "repopulse-adoption-2026") + valid_confirmation = _quiescence(candidate) + + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.confirm_repopulse_controller_intent( + session, + valid_confirmation, + expected_revision=1, + ) + assert error.value.code == "journal_revision_conflict" + + with Session(engine) as session: + assert journal.get_repopulse_adoption_status(session) == updated + assert updated["intentId"] == first["intentId"] + + +def test_recovery_resume_checks_controller_binding_and_persists_across_sessions(engine) -> None: + _reserve(engine) + with Session(engine) as session: + recovery = journal.mark_repopulse_adoption_recovery( + session, + "operator_interruption", + {"operatorNote": "resume review", "path": "private/path"}, + expected_revision=1, + ) + assert "private/path" not in str(recovery) + + receipt = { + "apiVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": recovery["intentId"], + "phase": "reserved", + "receiptSha256": hashlib.sha256(b"durable controller journal row").hexdigest(), + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "legacyVolumeRetained": True, + "intentDurable": True, + "intentBindingSha256": recovery["intentBindingSha256"], + "registrationEvidence": None, + } + invalid_receipt = {**receipt, "intentBindingSha256": _sha("0")} + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.resume_repopulse_adoption(session, invalid_receipt, expected_revision=2) + assert error.value.code == "controller_recovery_receipt_unbound" + + with Session(engine) as session: + resumed = journal.resume_repopulse_adoption( + session, receipt, expected_revision=2 + ) + assert resumed["status"] == "in_progress" + assert resumed["phase"] == "reserved" + assert resumed["revision"] == 3 + + with Session(engine) as session: + assert journal.get_repopulse_adoption_status(session) == resumed + + +def test_controller_registration_reconciles_before_appops_ownership_can_advance(engine) -> None: + _reserve(engine) + candidate = create_intent(_passing_preflight(), "repopulse-adoption-2026") + with Session(engine) as session: + current = journal.advance_repopulse_adoption( + session, "target_gates", _target_gates(), expected_revision=1 + ) + candidate = advance_intent(candidate, "target_gates", _target_gates()) + with Session(engine) as session: + current = journal.confirm_repopulse_controller_intent( + session, _quiescence(candidate), expected_revision=2 + ) + candidate = advance_intent(candidate, "quiescence", _quiescence(candidate)) + with Session(engine) as session: + current = journal.advance_repopulse_adoption( + session, "snapshot", _snapshot(candidate), expected_revision=3 + ) + candidate = advance_intent(candidate, "snapshot", _snapshot(candidate)) + + early_ownership = { + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": "enr-0123456789abcdefabcd", + "profileId": "repopulse-authenticated", + "planSha256": _sha("c"), + "controllerReceiptSha256": _sha("2"), + "enrollmentPersisted": True, + "exactApplyPolicyPersisted": True, + "exactRollbackPolicyPersisted": True, + "secretReferenceReady": True, + } + with Session(engine) as session, pytest.raises(journal.RepoPulseAdoptionJournalError) as error: + journal.advance_repopulse_adoption( + session, "appops_ownership", early_ownership, expected_revision=4 + ) + assert error.value.code == "controller_registration_missing" + assert current["phase"] == "snapshot_verified" + + registration = _registration(candidate) + with Session(engine) as session: + registered = journal.advance_repopulse_adoption( + session, + "controller_registration", + registration, + expected_revision=4, + ) + candidate = advance_intent(candidate, "controller_registration", registration) + assert registered["phase"] == "target_registered" + assert registered["revision"] == 5 + + with Session(engine) as session: + recovery = journal.mark_repopulse_adoption_recovery( + session, + "appops_commit_uncertain", + {"uncertain": True, "path": "private/path"}, + expected_revision=5, + ) + assert recovery["status"] == "recovery_required" + assert recovery["phase"] == "target_registered" + assert recovery["legacyVolumePreserved"] is True + assert recovery["controllerReconciliationRequired"] is True + assert "private/path" not in str(recovery) + + controller_receipt = { + "apiVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": candidate["intentId"], + "phase": "target_registered", + "receiptSha256": registration["receiptSha256"], + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "legacyVolumeRetained": True, + "intentDurable": True, + "intentBindingSha256": intent_binding_sha256(candidate), + "registrationEvidence": registration, + } + with Session(engine) as session: + resumed = journal.resume_repopulse_adoption( + session, controller_receipt, expected_revision=6 + ) + assert resumed["status"] == "in_progress" + assert resumed["phase"] == "target_registered" + assert resumed["revision"] == 7 + + ownership = _ownership(candidate, registration) + with Session(engine) as session: + owned = journal.advance_repopulse_adoption( + session, "appops_ownership", ownership, expected_revision=7 + ) + assert owned["phase"] == "appops_owned" + assert owned["revision"] == 8 + assert owned["status"] != "completed" diff --git a/backend/tests/test_repopulse_adoption_ownership.py b/backend/tests/test_repopulse_adoption_ownership.py new file mode 100644 index 0000000..ade21e4 --- /dev/null +++ b/backend/tests/test_repopulse_adoption_ownership.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import hashlib +import json + +import pytest +from sqlmodel import Session, SQLModel, create_engine, select + +from backend.app.db.models import ( + AppSetting, + Enrollment, + Policy, + RepoPulseAdoptionJournal, +) +from backend.app.schemas.enrollment import EnrollmentPlanV1 +from backend.app.services import repopulse_adoption_journal as journal +from backend.app.services import repopulse_adoption_ownership as ownership +from backend.app.services.repopulse_legacy_adoption_protocol import ( + APPLICATION_ID, + CONTROLLER_RECEIPT_VERSION, + LEGACY_VOLUME, + REPOSITORY, + intent_binding_sha256, +) +from backend.app.services.secret_store import set_secret + + +def _sha(char: str) -> str: + return char * 64 + + +def _plan_payload(revision: str) -> dict: + return EnrollmentPlanV1.model_validate( + { + "application_id": "repopulse", + "display_name": "RepoPulse", + "environment": "production", + "repository": "Jens/RepoPulse", + "revision": revision, + "profile": "repopulse-authenticated", + "target": { + "target_identity": "unraid-controller", + "container_name": "repopulse", + }, + "storage": {"runtime_uid": 10001, "runtime_gid": 10001}, + "network": {"docker_network": "itworx-mcp-hub-npm", "internal_port": 8000}, + "artifact": { + "release_version": "1.5.0", + "image_repository": "repopulse-managed", + "image_tag": "production", + "health_path": "/health", + }, + "exposure": { + "hostname": "repopulse.example.com", + "upstream_service": "repopulse", + }, + "secrets": {"additional_secret_refs": ["REPOPULSE_GITEA_TOKEN"]}, + } + ).model_dump(mode="json") + + +def _plan_sha(revision: str) -> str: + payload = _plan_payload(revision) + canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(canonical).hexdigest() + + +def _preflight() -> dict: + check_codes = ( + "legacy_registry", + "controller_state", + "gitea_ancestry", + "runtime_identity_and_health", + "legacy_storage_topology", + "legacy_appdata", + "product_api", + "database_health", + "queue_quiescence", + "database_baseline", + ) + return { + "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", + "repository": REPOSITORY, + "status": "preflight_passed", + "legacyCompatibility": { + "status": "passed", + "checks": [ + {"code": code, "status": "passed", "gate": "legacy_compatibility"} + for code in check_codes + ], + }, + "targetAcceptance": {"status": "blocked", "checks": []}, + "readOnly": True, + "adoptionAuthorized": False, + "evidence": { + "main_revision": "a" * 40, + "live_revision": "b" * 40, + "container_id_sha256": _sha("d"), + "container_image_sha256": _sha("1"), + "container_state": "running", + "container_health": "healthy", + "queue": {"queued": 0, "active": 0, "completed": 569, "failed": 0}, + "database": { + "database_bytes": 44_576_768, + "journal_bytes": 0, + "scan_count": 572, + "compacted_scan_count": 96, + }, + }, + } + + +def _target_gates() -> dict: + return { + "profileId": "repopulse-authenticated", + "profileVersion": "enrollment-profile/v1", + "planSha256": _plan_sha("a" * 40), + **{ + key: True + for key in ( + "profileReady", + "controllerTrustReady", + "controllerBootstrapSecretImported", + "typedEnrollmentTrustReady", + "targetSecretReady", + "exactPolicyReady", + "authenticatedRouteReady", + "accessListReady", + "mutationBoundaryReady", + "managedRuntimeReady", + "scanSourceMatches", + "scanMountReadOnly", + "scanPermissionPolicyPersistent", + "scanAccessForRuntimeIdentityReady", + "ownedDataTargetReady", + "managedEnvironmentReady", + "legacyEnvironmentRetained", + "maintenanceAuthReady", + "controllerAdoptionCapabilityReady", + "controllerRegistryWriterReady", + "controllerSecretWriterReady", + ) + }, + } + + +def _quiescence(intent: dict) -> dict: + return { + "intentId": intent["intentId"], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "maintenanceModeActive": True, + "queueFrozen": True, + "queuedJobs": 0, + "activeJobs": 0, + "maintenanceModeAuthenticated": True, + "containerName": "repopulse", + "containerIdSha256": _sha("d"), + "containerImageSha256": _sha("1"), + "containerState": "exited", + "legacyVolume": LEGACY_VOLUME, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _snapshot(intent: dict) -> dict: + return { + "intentId": intent["intentId"], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyContainerStopped": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "sqliteBackupMethod": "sqlite_online_backup", + "quickCheck": "ok", + "integrityCheck": "ok", + "sourceLogicalSha256": _sha("e"), + "snapshotLogicalSha256": _sha("e"), + "snapshotFileSha256": _sha("f"), + "sourceScanCount": 572, + "snapshotScanCount": 572, + "sourceCompactedScanCount": 96, + "snapshotCompactedScanCount": 96, + "sourceQueueSha256": _sha("1"), + "snapshotQueueSha256": _sha("1"), + "sourceAuxiliaryTreeSha256": _sha("3"), + "sourceAuxiliaryFileCount": 42, + "targetDataPathWasAbsent": True, + "snapshotReceiptDurable": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _registration(intent: dict, *, enrollment_id: str | None = None) -> dict: + gates = intent["evidence"]["targetGates"] + snapshot = intent["evidence"]["snapshot"] + return { + "receiptVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": intent["intentId"], + "receiptSha256": _sha("2"), + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": enrollment_id or f"enr-{intent['intentId'][4:]}", + "profileId": gates["profileId"], + "planSha256": gates["planSha256"], + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "targetLogicalSha256": snapshot["snapshotLogicalSha256"], + "targetScanCount": snapshot["snapshotScanCount"], + "targetCompactedScanCount": snapshot["snapshotCompactedScanCount"], + "targetQueueSha256": snapshot["snapshotQueueSha256"], + "targetAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "targetAuxiliaryFileCount": snapshot["sourceAuxiliaryFileCount"], + "targetQueuedJobs": 0, + "targetActiveJobs": 0, + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetRecoveryStatus": "healthy", + "targetDataUid": 10001, + "targetDataGid": 10001, + "managedDataMountMatches": True, + "copySourceIsVerifiedSnapshot": True, + "registryTargetMatches": True, + "controllerStateReceiptVerified": True, + "deploymentReceiptSchema": "gitea-deploy-receipt.v1", + "deploymentReceiptSha256": _sha("4"), + "deploymentReceiptVerified": True, + "runtimeHealthy": True, + "publishedHostPorts": [], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _ready_engine(): + engine = create_engine("sqlite://") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + session.add( + AppSetting( + key="deployment_enrollment:trust", + is_secret=False, + value_json={ + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "ENROLLMENT_BOOTSTRAP", + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 1, + }, + ) + ) + set_secret( + session, + "ENROLL_REPOPULSE_TARGET_HMAC", + "test-target-credential-only-for-fixture", + commit=False, + ) + session.commit() + return engine + + +def _register_target( + engine, *, enrollment_id: str | None = None +) -> tuple[dict, dict, int]: + preflight = _preflight() + with Session(engine) as session: + reserved = journal.reserve_repopulse_adoption( + session, preflight, "repopulse-adoption-2026" + ) + intent = journal.protocol.create_intent(preflight, "repopulse-adoption-2026") + gates = _target_gates() + with Session(engine) as session: + journal.advance_repopulse_adoption( + session, "target_gates", gates, expected_revision=reserved["revision"] + ) + intent = journal.protocol.advance_intent(intent, "target_gates", gates) + quiescence = _quiescence(intent) + with Session(engine) as session: + journal.confirm_repopulse_controller_intent( + session, quiescence, expected_revision=2 + ) + intent = journal.protocol.advance_intent(intent, "quiescence", quiescence) + snapshot = _snapshot(intent) + with Session(engine) as session: + journal.advance_repopulse_adoption( + session, "snapshot", snapshot, expected_revision=3 + ) + intent = journal.protocol.advance_intent(intent, "snapshot", snapshot) + registration = _registration(intent, enrollment_id=enrollment_id) + with Session(engine) as session: + registered = journal.advance_repopulse_adoption( + session, + "controller_registration", + registration, + expected_revision=4, + ) + return intent, registration, registered["revision"] + + +@pytest.fixture +def engine(): + db = _ready_engine() + yield db + db.dispose() + + +def test_ownership_commit_is_atomic_fixed_and_replayable(engine) -> None: + intent, registration, revision = _register_target(engine) + + with Session(engine) as session: + result = ownership.commit_repopulse_adoption_ownership( + session, + registration, + expected_revision=revision, + ) + + assert result["phase"] == "appops_owned" + assert result["revision"] == revision + 1 + with Session(engine) as session: + row = session.get(Enrollment, registration["enrollmentId"]) + assert row is not None + assert row.repository == "jens/repopulse" + assert row.application_id == "repopulse" + assert row.revision == "a" * 40 + assert row.profile == "repopulse-authenticated" + assert row.controller_identity == "unraid-controller" + assert row.plan_sha256 == registration["planSha256"] + assert row.target_secret_ref == "ENROLL_REPOPULSE_TARGET_HMAC" + assert row.oidc_secret_ref is None + assert row.plan_json == _plan_payload("a" * 40) + assert row.provenance_json["createdBy"] == "repopulse-legacy-adoption/v1" + assert ( + row.provenance_json["controllerReceiptSha256"] + == registration["receiptSha256"] + ) + policies = session.exec(select(Policy).order_by(Policy.id)).all() + assert [policy.id for policy in policies] == [ + "enrollment-repopulse-deployment-apply", + "enrollment-repopulse-deployment-rollback", + ] + assert { + (item.action, item.target, item.voice, item.manual, item.risk) + for item in policies + } == { + ("deployment.apply", "repopulse", "blocked", "allowed", "high"), + ("deployment.rollback", "repopulse", "blocked", "allowed", "high"), + } + assert all( + item.provenance_json["adoptionIntentId"] == intent["intentId"] + for item in policies + ) + stored_journal = session.get(RepoPulseAdoptionJournal, "repopulse") + assert stored_journal.phase == "appops_owned" + assert ( + stored_journal.intent_json["evidence"]["appopsOwnership"][ + "secretReferenceReady" + ] + is True + ) + + with Session(engine) as session: + replay = ownership.commit_repopulse_adoption_ownership( + session, + registration, + expected_revision=revision + 1, + ) + assert replay["phase"] == "appops_owned" + assert replay["revision"] == revision + 1 + + +def test_ownership_commit_rejects_live_controller_receipt_drift(engine) -> None: + _, registration, revision = _register_target(engine) + changed = {**registration, "deploymentReceiptSha256": _sha("5")} + + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.commit_repopulse_adoption_ownership( + session, changed, expected_revision=revision + ) + + assert error.value.code == "idempotency_conflict" + with Session(engine) as session: + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(Policy)).all() == [] + + +def test_enrollment_identity_is_derived_from_persisted_intent(engine) -> None: + _, registration, revision = _register_target( + engine, enrollment_id="enr-0123456789abcdefabcd" + ) + + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + + assert error.value.code == "fixed_enrollment_id_mismatch" + with Session(engine) as session: + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(Policy)).all() == [] + + +def test_preexisting_exact_policy_id_is_not_adopted(engine) -> None: + _, registration, revision = _register_target(engine) + with Session(engine) as session: + session.add( + Policy( + id="enrollment-repopulse-deployment-apply", + action="deployment.apply", + target="repopulse", + voice="blocked", + manual="denied", + risk="high", + ) + ) + session.commit() + + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + assert error.value.code == "ownership_resource_conflict" + with Session(engine) as session: + assert session.exec(select(Enrollment)).all() == [] + assert ( + session.get(Policy, "enrollment-repopulse-deployment-apply").manual + == "denied" + ) + assert session.get(Policy, "enrollment-repopulse-deployment-rollback") is None + assert ( + session.get(RepoPulseAdoptionJournal, "repopulse").phase + == "target_registered" + ) + + +def test_ownership_commit_rejects_missing_fixed_secret_ref(engine) -> None: + _, registration, revision = _register_target(engine) + with Session(engine) as session: + from backend.app.db.models import Secret + + session.delete(session.get(Secret, "ENROLL_REPOPULSE_TARGET_HMAC")) + session.commit() + + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + assert error.value.code == "target_secret_reference_unavailable" + with Session(engine) as session: + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(Policy)).all() == [] + + +def test_completion_is_a_separate_idempotent_step_bound_to_live_receipt(engine) -> None: + _, registration, revision = _register_target(engine) + with Session(engine) as session: + owned = ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + + with Session(engine) as session: + completed = ownership.complete_repopulse_adoption( + session, + registration, + expected_revision=owned["revision"], + ) + assert completed["phase"] == "completed" + assert completed["status"] == "completed" + assert completed["revision"] == owned["revision"] + 1 + + with Session(engine) as session: + replay = ownership.complete_repopulse_adoption( + session, + registration, + expected_revision=completed["revision"], + ) + assert replay["revision"] == completed["revision"] + assert replay["phase"] == "completed" + + with Session(engine) as session: + ownership_replay = ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=completed["revision"] + ) + assert ownership_replay["revision"] == completed["revision"] + assert ownership_replay["phase"] == "completed" + + changed = {**registration, "receiptSha256": _sha("9")} + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.complete_repopulse_adoption( + session, changed, expected_revision=completed["revision"] + ) + assert error.value.code == "idempotency_conflict" + + +def test_completion_replay_rechecks_owned_policy_rows(engine) -> None: + _, registration, revision = _register_target(engine) + with Session(engine) as session: + owned = ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + with Session(engine) as session: + completed = ownership.complete_repopulse_adoption( + session, registration, expected_revision=owned["revision"] + ) + with Session(engine) as session: + policy = session.get(Policy, "enrollment-repopulse-deployment-apply") + policy.manual = "denied" + session.add(policy) + session.commit() + + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.complete_repopulse_adoption( + session, registration, expected_revision=completed["revision"] + ) + assert error.value.code == "ownership_policy_readback_mismatch" + + +def test_half_write_failure_rolls_back_all_ownership_rows_and_marks_recovery( + engine, monkeypatch +) -> None: + _, registration, revision = _register_target(engine) + original_commit = Session.commit + calls = 0 + + def fail_first_commit(session): + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("simulated commit interruption") + return original_commit(session) + + monkeypatch.setattr(Session, "commit", fail_first_commit) + with ( + Session(engine) as session, + pytest.raises(ownership.RepoPulseOwnershipError) as error, + ): + ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + assert error.value.code == "appops_commit_uncertain" + with Session(engine) as session: + assert session.exec(select(Enrollment)).all() == [] + assert session.exec(select(Policy)).all() == [] + stored = session.get(RepoPulseAdoptionJournal, "repopulse") + assert stored.phase == "target_registered" + assert stored.status == "recovery_required" + assert stored.intent_json["recovery"]["reason"] == "appops_commit_uncertain" + + +def test_commit_exception_after_durable_commit_is_resolved_by_readback( + engine, monkeypatch +) -> None: + _, registration, revision = _register_target(engine) + original_commit = Session.commit + calls = 0 + + def commit_then_lose_response(session): + nonlocal calls + calls += 1 + if calls == 1: + original_commit(session) + raise RuntimeError("simulated lost commit response") + return original_commit(session) + + monkeypatch.setattr(Session, "commit", commit_then_lose_response) + with Session(engine) as session: + result = ownership.commit_repopulse_adoption_ownership( + session, registration, expected_revision=revision + ) + + assert result["phase"] == "appops_owned" + with Session(engine) as session: + assert session.get(Enrollment, registration["enrollmentId"]) is not None + stored = session.get(RepoPulseAdoptionJournal, "repopulse") + assert stored.phase == "appops_owned" + assert stored.status == "in_progress" diff --git a/backend/tests/test_repopulse_adoption_preflight.py b/backend/tests/test_repopulse_adoption_preflight.py new file mode 100644 index 0000000..43baace --- /dev/null +++ b/backend/tests/test_repopulse_adoption_preflight.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import ast +import json +from copy import deepcopy +from types import SimpleNamespace + +from backend.app.services.repopulse_adoption_preflight import ( + _EXPECTED_REGISTRY_KEYS, + _EXPECTED_TARGET, + _EXPECTED_VALIDATION, + _PREFLIGHT_SCRIPT, + RepoPulseAdoptionPreflight, + evaluate_facts, +) + + +def _healthy_facts() -> dict: + return { + "registry": { + "present": True, + "alias_matches": True, + "entry_type": "object", + "keys": sorted(_EXPECTED_REGISTRY_KEYS), + "target": { + key: value + for key, value in _EXPECTED_TARGET.items() + if key != "environment_keys" + }, + "environment_keys": [], + "validation": deepcopy(_EXPECTED_VALIDATION), + "repository_id": 64, + "webhook_secret_file_matches": True, + }, + "state": { + "schema_version": 3, + "repository": "jens/repopulse", + "revision": "12431667760d2bce8c7533b57f462aac8d2e289e", + "live_revision": "12431667760d2bce8c7533b57f462aac8d2e289e", + "live_status": "healthy", + "live_legacy_provenance": False, + "status": "success", + }, + "git": { + "bare_repo_present": True, + "main_revision": "12431667760d2bce8c7533b57f462aac8d2e289e", + "live_revision": "12431667760d2bce8c7533b57f462aac8d2e289e", + "live_revision_resolves": True, + "live_is_ancestor": True, + "compose_present_on_main": True, + }, + "runtime": { + "name_matches": True, + "compose_project_matches": True, + "container_id_sha256": "a" * 64, + "image_sha256": "b" * 64, + "state": "running", + "health": "healthy", + "image": "repopulse-repopulse:latest", + "network_mode": "repopulse_default", + "mounts": [ + { + "destination": "/data", + "type": "volume", + "name": "repopulse_data", + "read_write": True, + }, + { + "destination": "/scan", + "type": "bind", + "source": "/mnt/cache/appdata/gitea-deploy-runners/sources", + "read_write": False, + }, + ], + "read_only_root": False, + "cap_drop_all": False, + "no_new_privileges": True, + "host_ports": [{"ip": "0.0.0.0", "port": 1254}], + }, + "volume": {"name_matches": True, "mountpoint_matches": True}, + "scan_access": { + "runtime_uid": 10001, + "runtime_gid": 10001, + "root_readable": True, + "repopulse_source_readable": True, + "privatemuse_source_readable": True, + }, + "appdata": { + "root_is_safe_directory": True, + "deploy_env_is_regular_file": True, + "managed_env_exists": False, + "managed_env_is_regular_file": False, + "managed_env_is_private": False, + "target_data_exists": False, + "target_data_is_symlink": False, + "sources_exists": False, + "sources_is_safe_directory": True, + "unexpected_entry_count": 0, + }, + "health": {"http_ok": True, "status": "ok"}, + "meta": { + "http_ok": True, + "product_matches": True, + "repository_count": 71, + "queue": {"queued": 0, "active": 0, "completed": 568, "failed": 0}, + "storage": { + "database_bytes": 44_408_832, + "journal_bytes": 0, + "scan_count": 567, + "compacted_scan_count": 94, + "mirror_count": 69, + "mirror_cache_bytes": 3_000_000_000, + "worktree_count": 0, + }, + "database_recovery_status": "healthy", + "mutation_authentication": "perimeter_required", + }, + } + + +def _blocked_codes(report: dict) -> set[str]: + return {check["code"] for check in report["checks"] if check["status"] != "passed"} + + +def test_healthy_legacy_facts_pass_compatibility_but_not_target_acceptance() -> None: + report = evaluate_facts(_healthy_facts()) + + assert report["status"] == "preflight_passed" + assert report["legacyCompatibility"]["status"] == "passed" + assert report["targetAcceptance"]["status"] == "blocked" + assert report["repository"] == "jens/repopulse" + assert report["readOnly"] is True + assert report["adoptionAuthorized"] is False + assert report["evidence"]["host_ports"] == [{"ip": "0.0.0.0", "port": 1254}] + assert report["evidence"]["operator_token_configured"] is False + assert report["evidence"]["queue"] == { + "queued": 0, + "active": 0, + "completed": 568, + "failed": 0, + } + + +def test_registry_extra_keys_and_webhook_reference_mismatch_block() -> None: + facts = _healthy_facts() + facts["registry"]["keys"].append("unreviewed_target_option") + facts["registry"]["webhook_secret_file_matches"] = False + + assert "legacy_registry" in _blocked_codes(evaluate_facts(facts)) + + +def test_malformed_registry_key_list_fails_closed() -> None: + facts = _healthy_facts() + facts["registry"]["keys"] = [[], "branch"] + + assert "legacy_registry" in _blocked_codes(evaluate_facts(facts)) + + +def test_registry_json_types_must_match_the_fixed_contract() -> None: + facts = _healthy_facts() + facts["registry"]["target"]["exact_source"] = 1 + facts["registry"]["validation"]["validation_gate_version"] = True + + assert "legacy_registry" in _blocked_codes(evaluate_facts(facts)) + + +def test_missing_registry_state_and_runtime_facts_fail_closed() -> None: + facts = _healthy_facts() + facts["registry"]["present"] = False + facts["state"] = None + facts["runtime"] = None + + blocked = _blocked_codes(evaluate_facts(facts)) + assert {"legacy_registry", "controller_state", "runtime_identity_and_health"} <= blocked + + +def test_wrong_live_sha_or_non_ancestor_blocks() -> None: + facts = _healthy_facts() + facts["git"]["live_is_ancestor"] = False + + assert "gitea_ancestry" in _blocked_codes(evaluate_facts(facts)) + + +def test_changed_data_volume_or_writable_scan_mount_blocks() -> None: + facts = _healthy_facts() + facts["runtime"]["mounts"][0]["name"] = "other_volume" + facts["runtime"]["mounts"][1]["read_write"] = True + + assert "legacy_storage_topology" in _blocked_codes(evaluate_facts(facts)) + + +def test_unhealthy_or_exposed_container_blocks() -> None: + facts = _healthy_facts() + facts["runtime"]["health"] = "unhealthy" + facts["runtime"]["host_ports"] = [{"ip": "0.0.0.0", "port": 1254}] + + report = evaluate_facts(facts) + assert report["status"] == "blocked" + assert "runtime_identity_and_health" in _blocked_codes(report) + assert "host_exposure" in _blocked_codes(report) + + +def test_container_identity_requires_redacted_id_and_image_fingerprints() -> None: + facts = _healthy_facts() + facts["runtime"]["container_id_sha256"] = None + facts["runtime"]["image_sha256"] = "not-a-sha256" + + report = evaluate_facts(facts) + + assert report["legacyCompatibility"]["status"] == "blocked" + assert "runtime_identity_and_health" in _blocked_codes(report) + assert report["evidence"]["container_id_sha256"] is None + assert report["evidence"]["container_image_sha256"] is None + + +def test_host_network_container_blocks_even_without_port_bindings() -> None: + facts = _healthy_facts() + facts["runtime"]["network_mode"] = "host" + + report = evaluate_facts(facts) + assert report["legacyCompatibility"]["status"] == "passed" + assert "managed_network" in _blocked_codes(report) + + +def test_data_target_collision_and_unsafe_legacy_runtime_block_target_acceptance_only() -> None: + facts = _healthy_facts() + facts["appdata"]["target_data_exists"] = True + facts["runtime"]["read_only_root"] = False + facts["runtime"]["cap_drop_all"] = False + facts["runtime"]["no_new_privileges"] = False + + report = evaluate_facts(facts) + assert report["status"] == "preflight_passed" + assert report["legacyCompatibility"]["status"] == "passed" + blocked = _blocked_codes(report) + assert {"managed_data_target", "runtime_hardening"} <= blocked + + +def test_optional_sources_staging_directory_must_be_safe() -> None: + facts = _healthy_facts() + facts["appdata"]["sources_exists"] = True + facts["appdata"]["sources_is_safe_directory"] = True + assert evaluate_facts(facts)["legacyCompatibility"]["status"] == "passed" + + facts["appdata"]["sources_is_safe_directory"] = False + assert "legacy_appdata" in _blocked_codes(evaluate_facts(facts)) + + +def test_managed_env_may_be_staged_only_as_private_regular_file() -> None: + facts = _healthy_facts() + facts["appdata"].update( + managed_env_exists=True, + managed_env_is_regular_file=True, + managed_env_is_private=True, + managed_env_contents="SECRET_VALUE", + ) + report = evaluate_facts(facts) + assert report["legacyCompatibility"]["status"] == "passed" + assert "managed_environment_file" not in _blocked_codes(report) + assert "SECRET_VALUE" not in str(report) + + facts["appdata"]["managed_env_is_private"] = False + report = evaluate_facts(facts) + assert "legacy_appdata" in _blocked_codes(report) + assert "managed_environment_file" in _blocked_codes(report) + assert report["evidence"]["managed_environment_file_ready"] is False + + +def test_scan_access_is_a_managed_acceptance_gate_for_runtime_uid() -> None: + facts = _healthy_facts() + facts["scan_access"]["repopulse_source_readable"] = False + + report = evaluate_facts(facts) + + assert report["legacyCompatibility"]["status"] == "passed" + assert "scan_access" in _blocked_codes(report) + assert report["evidence"]["scan_access_uid_10001"] is False + + +def test_missing_or_busy_queue_and_bad_database_recovery_block() -> None: + facts = _healthy_facts() + facts["meta"]["queue"]["active"] = 1 + facts["meta"]["database_recovery_status"] = "unknown" + + blocked = _blocked_codes(evaluate_facts(facts)) + assert {"queue_quiescence", "database_health"} <= blocked + + facts["meta"]["queue"].pop("failed") + assert "queue_quiescence" in _blocked_codes(evaluate_facts(facts)) + + +def test_report_never_echoes_untrusted_or_secret_values() -> None: + facts = _healthy_facts() + facts["state"]["last_attempt"] = {"message": "TOP_SECRET_VALUE"} + facts["meta"]["gitea"] = {"token": "TOP_SECRET_VALUE"} + facts["runtime"]["operator_token"] = "TOP_SECRET_VALUE" + + report_text = json.dumps(evaluate_facts(facts)) + assert "TOP_SECRET_VALUE" not in report_text + + +class _FakeResponse: + def __init__(self, *, payload=None, text=""): + self.payload = payload + self.text = text + + def json(self): + return self.payload + + +class _FakePortainer: + def __init__(self, facts): + self.facts = facts + self.calls = [] + + def get_container_status(self, name): + assert name == "gitea-deploy-control" + return { + "status": "ok", + "container": {"id": "controller-id", "name": name, "state": "running"}, + } + + def _config(self): + return SimpleNamespace(endpoint_id="3") + + def _request(self, method, path, **kwargs): + self.calls.append((method, path, kwargs)) + if path.endswith("/containers/controller-id/exec"): + return _FakeResponse(payload={"Id": "exec-1"}) + if path.endswith("/exec/exec-1/start"): + return _FakeResponse(text=json.dumps(self.facts)) + raise AssertionError(path) + + +def test_live_service_uses_only_fixed_read_only_collector() -> None: + fake = _FakePortainer(_healthy_facts()) + report = RepoPulseAdoptionPreflight(fake).run() + + assert report["status"] == "preflight_passed" + assert len(fake.calls) == 2 + create = fake.calls[0] + assert create[0] == "POST" + assert create[2]["json"]["Cmd"] == ["python3", "-c", _PREFLIGHT_SCRIPT] + assert create[2]["json"]["AttachStdout"] is True + assert create[2]["json"]["AttachStderr"] is False + assert create[2]["json"]["Tty"] is True + start = fake.calls[1] + assert start[2]["json"] == {"Detach": False, "Tty": True} + assert "docker rm" not in _PREFLIGHT_SCRIPT + assert "docker start" not in _PREFLIGHT_SCRIPT + assert "write_text(" not in _PREFLIGHT_SCRIPT + + +def test_fixed_collector_source_is_valid_python() -> None: + ast.parse(_PREFLIGHT_SCRIPT) + + +def test_collector_failure_is_sanitized() -> None: + class BrokenPortainer(_FakePortainer): + def _request(self, method, path, **kwargs): + if path.endswith("/containers/controller-id/exec"): + return _FakeResponse(payload={"Id": "exec-1"}) + return _FakeResponse(text="not json with secret=TOP_SECRET_VALUE") + + report = RepoPulseAdoptionPreflight(BrokenPortainer(_healthy_facts())).run() + report_text = json.dumps(report) + assert report["status"] == "unavailable" + assert "TOP_SECRET_VALUE" not in report_text diff --git a/backend/tests/test_repopulse_adoption_runner.py b/backend/tests/test_repopulse_adoption_runner.py new file mode 100644 index 0000000..5ed0e0b --- /dev/null +++ b/backend/tests/test_repopulse_adoption_runner.py @@ -0,0 +1,631 @@ +from __future__ import annotations + +import hashlib +import importlib +import json + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.core.config import settings +from backend.app.db.models import AppSetting +from backend.app.schemas.enrollment import EnrollmentPlanV1 +from backend.app.services import repopulse_adoption_journal as journal +from backend.app.services import repopulse_legacy_adoption_protocol as protocol +from backend.app.services.secret_store import set_secret + + +def _sha(char: str) -> str: + return char * 64 + + +def _passing_preflight() -> dict: + check_codes = ( + "legacy_registry", + "controller_state", + "gitea_ancestry", + "runtime_identity_and_health", + "legacy_storage_topology", + "legacy_appdata", + "product_api", + "database_health", + "queue_quiescence", + "database_baseline", + ) + return { + "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", + "repository": "jens/repopulse", + "status": "preflight_passed", + "legacyCompatibility": { + "status": "passed", + "checks": [ + {"code": code, "status": "passed", "gate": "legacy_compatibility"} + for code in check_codes + ], + }, + "targetAcceptance": {"status": "blocked", "checks": []}, + "readOnly": True, + "adoptionAuthorized": False, + "evidence": { + "main_revision": "a" * 40, + "live_revision": "b" * 40, + "container_id_sha256": _sha("d"), + "container_image_sha256": _sha("1"), + "container_state": "running", + "container_health": "healthy", + "queue": {"queued": 0, "active": 0, "completed": 569, "failed": 0}, + "database": { + "database_bytes": 44_576_768, + "journal_bytes": 0, + "scan_count": 572, + "compacted_scan_count": 96, + }, + }, + } + + +def _plan_sha(revision: str) -> str: + plan = EnrollmentPlanV1.model_validate( + { + "application_id": "repopulse", + "display_name": "RepoPulse", + "environment": "production", + "repository": "Jens/RepoPulse", + "revision": revision, + "profile": "repopulse-authenticated", + "target": { + "target_identity": "unraid-controller", + "container_name": "repopulse", + }, + "storage": {"runtime_uid": 10001, "runtime_gid": 10001}, + "network": {"docker_network": "itworx-mcp-hub-npm", "internal_port": 8000}, + "artifact": { + "release_version": "1.5.0", + "image_repository": "repopulse-managed", + "image_tag": "production", + "health_path": "/health", + }, + "exposure": { + "hostname": "repopulse.example.com", + "upstream_service": "repopulse", + }, + "secrets": {"additional_secret_refs": ["REPOPULSE_GITEA_TOKEN"]}, + } + ).model_dump(mode="json") + encoded = json.dumps(plan, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _document_sha(value: dict) -> str: + encoded = (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n").encode("ascii") + return hashlib.sha256(encoded).hexdigest() + + +def _completion_sha(value: dict) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") + return hashlib.sha256(encoded).hexdigest() + + +def _controller_result(intent_id: str, binding: str) -> dict: + plan_sha = _plan_sha("a" * 40) + gates = { + "profileId": "repopulse-authenticated", + "profileVersion": "enrollment-profile/v1", + "planSha256": plan_sha, + **{ + key: True + for key in ( + "profileReady", + "controllerTrustReady", + "controllerBootstrapSecretImported", + "typedEnrollmentTrustReady", + "targetSecretReady", + "exactPolicyReady", + "authenticatedRouteReady", + "accessListReady", + "mutationBoundaryReady", + "managedRuntimeReady", + "scanSourceMatches", + "scanMountReadOnly", + "scanPermissionPolicyPersistent", + "scanAccessForRuntimeIdentityReady", + "ownedDataTargetReady", + "managedEnvironmentReady", + "legacyEnvironmentRetained", + "maintenanceAuthReady", + "controllerAdoptionCapabilityReady", + "controllerRegistryWriterReady", + "controllerSecretWriterReady", + ) + }, + } + quiescence = { + "intentId": intent_id, + "globalDeployLockHeld": True, + "projectLockHeld": True, + "maintenanceModeActive": True, + "queueFrozen": True, + "queuedJobs": 0, + "activeJobs": 0, + "maintenanceModeAuthenticated": True, + "containerName": "repopulse", + "containerIdSha256": _sha("d"), + "containerImageSha256": _sha("1"), + "containerState": "exited", + "legacyVolume": "repopulse_data", + "controllerIntentDurable": True, + "controllerIntentBindingSha256": binding, + } + snapshot = { + "intentId": intent_id, + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyContainerStopped": True, + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + "sqliteBackupMethod": "sqlite_online_backup", + "quickCheck": "ok", + "integrityCheck": "ok", + "sourceLogicalSha256": _sha("e"), + "snapshotLogicalSha256": _sha("e"), + "snapshotFileSha256": _sha("f"), + "sourceScanCount": 572, + "snapshotScanCount": 572, + "sourceCompactedScanCount": 96, + "snapshotCompactedScanCount": 96, + "sourceQueueSha256": _sha("1"), + "snapshotQueueSha256": _sha("1"), + "sourceAuxiliaryTreeSha256": _sha("3"), + "sourceAuxiliaryFileCount": 42, + "targetDataPathWasAbsent": True, + "snapshotReceiptDurable": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "controllerIntentBindingSha256": binding, + } + copied = { + "intentId": intent_id, + "snapshotFileSha256": _sha("f"), + "targetLogicalSha256": _sha("e"), + "targetScanCount": 572, + "targetCompactedScanCount": 96, + "targetQueueSha256": _sha("1"), + "sourceAuxiliaryTreeSha256": _sha("3"), + "targetAuxiliaryTreeSha256": _sha("3"), + "sourceAuxiliaryFileCount": 42, + "targetAuxiliaryFileCount": 42, + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetDataUid": 10001, + "targetDataGid": 10001, + "targetRecoveryStatus": "healthy", + "managedDataMountMatches": True, + "copySourceIsVerifiedSnapshot": True, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + } + enrollment_id = f"enr-{intent_id[4:]}" + registry_receipt = { + "apiVersion": "itworx.repopulse-controller-adoption-store-receipt/v1", + "intentId": intent_id, + "repository": "jens/repopulse", + "applicationId": "repopulse", + "phase": "registry_committed", + "intentBindingSha256": binding, + "legacyVolumeRetained": True, + "intentDurable": True, + "managedEnrollmentId": enrollment_id, + "projectsSha256": _sha("2"), + "snapshotFileSha256": _sha("f"), + } + registry_receipt["receiptSha256"] = _document_sha(registry_receipt) + deployment_receipt = { + "repository": "jens/repopulse", + "revision": "a" * 40, + "containerRevision": "a" * 40, + "containerIdSha256": _sha("4"), + "deploymentReceiptSchema": "gitea-deploy-receipt.v1", + "deploymentReceiptSha256": _sha("5"), + "deploymentReceiptVerified": True, + "runtimeHealthy": True, + "publishedHostPorts": [], + "managedDataMountMatches": True, + "targetDataUid": 10001, + "targetDataGid": 10001, + "targetLogicalSha256": _sha("e"), + "targetScanCount": 572, + "targetCompactedScanCount": 96, + "targetQueueSha256": _sha("1"), + "targetAuxiliaryTreeSha256": _sha("3"), + "targetAuxiliaryFileCount": 42, + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetRecoveryStatus": "healthy", + "targetQueuedJobs": 0, + "targetActiveJobs": 0, + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "containerName": "repopulse", + "containerImageSha256": _sha("6"), + "snapshotFileSha256": _sha("f"), + } + completion = { + "apiVersion": "itworx.repopulse-controller-adoption-completion/v1", + "intentId": intent_id, + "intentBindingSha256": binding, + "controllerRegistrationReceiptSha256": registry_receipt["receiptSha256"], + "mainRevision": "a" * 40, + "deploymentReceiptSha256": deployment_receipt["deploymentReceiptSha256"], + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "snapshotLogicalSha256": snapshot["snapshotLogicalSha256"], + "snapshotQueueSha256": snapshot["snapshotQueueSha256"], + "auxiliaryTreeSha256": copied["targetAuxiliaryTreeSha256"], + "containerIdSha256": deployment_receipt["containerIdSha256"], + "containerImageSha256": deployment_receipt["containerImageSha256"], + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + } + completion["completionSha256"] = _completion_sha(completion) + return { + "apiVersion": "itworx.repopulse-controller-adoption-execution-result/v1", + "repository": "jens/repopulse", + "applicationId": "repopulse", + "intentId": intent_id, + "status": "deployment_verified", + "phase": "completion_verified", + "legacyVolume": "repopulse_data", + "legacyVolumeRetained": True, + "targetGates": gates, + "exactShaValidation": { + "repository": "jens/repopulse", + "revision": "a" * 40, + "contexts": {"Managed validation / full (push)": "success"}, + }, + "quiescence": quiescence, + "snapshot": snapshot, + "copy": copied, + "registryReceipt": registry_receipt, + "deploymentReceipt": deployment_receipt, + "completion": completion, + } + + +class FakePreflight: + def __init__(self) -> None: + self.calls = 0 + + def run(self) -> dict: + self.calls += 1 + return _passing_preflight() + + +class FakeBroker: + base_url = "https://controller.test" + verify_tls = True + ca_file = None + + def __init__( + self, + *, + reserve_result: dict | None = None, + status_result: dict | None = None, + ) -> None: + self.reserve_result = reserve_result + self.status_result = status_result + self.execute_calls: list[dict] = [] + self.status_calls = 0 + self.reserve_calls: list[dict] = [] + + def configuration_error(self): + return None + + def repopulse_adoption_capabilities(self, *, secret: str) -> dict: + return { + "apiVersion": "itworx.repopulse-adoption-capabilities/v1", + "status": "ready", + "repository": "jens/repopulse", + "adoptionStore": {"executionEnabled": True}, + "privateTls": {"status": "ready"}, + "controller": { + "aliases": "ready", + "registry": "ready", + "secretRoot": "ready", + }, + } + + def repopulse_adoption_reserve(self, **kwargs) -> dict: + self.reserve_calls.append(kwargs) + if self.reserve_result is not None: + return self.reserve_result + binding = hashlib.sha256( + json.dumps( + { + "intentId": kwargs["intent_id"], + "repository": "jens/repopulse", + "applicationId": "repopulse", + "preflightSha256": kwargs["preflight_sha256"], + "idempotencySha256": kwargs["idempotency_sha256"], + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("ascii") + ).hexdigest() + return { + "apiVersion": "itworx.repopulse-adoption-reserve/v1", + "status": "reserved", + "intentId": kwargs["intent_id"], + "phase": "reserved", + "intentSha256": _sha("7"), + "intentBindingSha256": binding, + } + + def repopulse_adoption_execute(self, **kwargs) -> dict: + self.execute_calls.append(kwargs) + raise AssertionError("AppOps must not invoke the controller cutover") + + def repopulse_adoption_status(self, *, secret: str) -> dict: + self.status_calls += 1 + return self.status_result or { + "apiVersion": "itworx.repopulse-adoption-status/v1", + "status": "not_reserved", + "phase": None, + "hashes": {}, + } + + +def _runner_type(): + try: + module = importlib.import_module("backend.app.services.repopulse_adoption_runner") + except ModuleNotFoundError: + pytest.fail("fixed RepoPulse AppOps runner is missing") + return module.RepoPulseAdoptionRunner + + +def _controller_status(intent: dict, controller_result: dict) -> dict: + snapshot = controller_result["snapshot"] + gates = controller_result["targetGates"] + registry = controller_result["registryReceipt"] + return { + "apiVersion": "itworx.repopulse-adoption-status/v1", + "status": "in_progress", + "phase": "snapshot_verified", + "hashes": { + "intentSha256": _sha("7"), + "intentBindingSha256": protocol.intent_binding_sha256(intent), + "idempotencySha256": intent["idempotencySha256"], + "preflightSha256": intent["preflightSha256"], + "expectedProjectsSha256": registry["projectsSha256"], + "quiescenceSha256": _sha("d"), + "planSha256": gates["planSha256"], + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "snapshotLogicalSha256": snapshot["snapshotLogicalSha256"], + "snapshotQueueSha256": snapshot["snapshotQueueSha256"], + "targetProjectsSha256": registry["projectsSha256"], + "receiptSha256": registry["receiptSha256"], + }, + } + + +def _session(monkeypatch) -> Session: + monkeypatch.setattr(settings, "environment", "production") + monkeypatch.setattr(settings, "secret_key", "appops-test-key-with-at-least-32-bytes") + monkeypatch.setattr(settings, "secret_key_source", "env") + engine = create_engine("sqlite://") + SQLModel.metadata.create_all(engine) + session = Session(engine) + session.add( + AppSetting( + key="deployment_enrollment:trust", + is_secret=False, + value_json={ + "controller_identity": "unraid-controller", + "bootstrap_secret_ref": "APPOPS_DEPLOYMENT_BOOTSTRAP", + "approved_networks": ["itworx-mcp-hub-npm"], + "allowed_domain_suffixes": ["example.com"], + "autonomous_profiles": ["repopulse-authenticated"], + "repopulse_proxy_access_list_id": 1, + }, + ) + ) + session.commit() + set_secret(session, "APPOPS_DEPLOYMENT_BOOTSTRAP", "B" * 64) + return session + + +def test_synthetic_prepare_and_finalize_commits_protocol_ownership_and_completion(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + preflight = FakePreflight() + report = _passing_preflight() + candidate = protocol.create_intent(report, "repopulse-adoption-2026") + binding = protocol.intent_binding_sha256(candidate) + controller_result = _controller_result(candidate["intentId"], binding) + broker = FakeBroker() + runner = runner_type(session=session, preflight=preflight, broker=broker) + + handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") + prepared = journal.get_repopulse_adoption_status(session) + broker.status_result = _controller_status(candidate, controller_result) + result = runner.finalize(controller_result) + + assert prepared["status"] == "in_progress" + assert prepared["phase"] == "reserved" + assert handoff["intentId"] == candidate["intentId"] + assert result["status"] == "completed" + assert result["phase"] == "completed" + assert result["revision"] == 7 + assert preflight.calls == 1 + assert len(broker.reserve_calls) == 1 + assert broker.reserve_calls[0]["intent_id"] == candidate["intentId"] + assert broker.reserve_calls[0]["preflight_sha256"] == candidate["preflightSha256"] + assert broker.execute_calls == [] + + +def test_prepared_and_completed_replays_never_repeat_external_mutations(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + report = _passing_preflight() + candidate = protocol.create_intent(report, "repopulse-adoption-2026") + binding = protocol.intent_binding_sha256(candidate) + controller_result = _controller_result(candidate["intentId"], binding) + broker = FakeBroker() + runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) + + broker.status_result = _controller_status(candidate, controller_result) + handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") + replay = runner.prepare("repopulse-adoption-2026") + completed = runner.finalize(controller_result) + completed_replay = runner.prepare("repopulse-adoption-2026") + + assert handoff["intentId"] == candidate["intentId"] + assert replay["status"] == "prepared" + assert completed["status"] == completed_replay["status"] == "completed" + assert completed_replay["revision"] == completed["revision"] + assert len(broker.reserve_calls) == 1 + assert broker.execute_calls == [] + + +def test_controller_cli_handoff_is_exact_in_memory_contract_and_not_printed(monkeypatch, capsys): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + report = _passing_preflight() + candidate = protocol.create_intent(report, "repopulse-adoption-2026") + broker = FakeBroker() + runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) + + handoff = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") + + assert set(handoff) == { + "intentId", + "idempotencySha256", + "preflightSha256", + "preflightProjection", + "targetCredential", + } + assert handoff["intentId"] == candidate["intentId"] + assert handoff["idempotencySha256"] == candidate["idempotencySha256"] + assert handoff["preflightSha256"] == candidate["preflightSha256"] + assert handoff["preflightProjection"] == candidate["evidence"]["preflight"] + assert isinstance(handoff["targetCredential"], str) + assert 32 <= len(handoff["targetCredential"]) <= 256 + assert capsys.readouterr().out == "" + assert len(broker.reserve_calls) == 1 + assert broker.execute_calls == [] + + +def test_controller_cli_handoff_cannot_be_reissued(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + candidate = protocol.create_intent(_passing_preflight(), "repopulse-adoption-2026") + controller_result = _controller_result( + candidate["intentId"], protocol.intent_binding_sha256(candidate) + ) + broker = FakeBroker(status_result=_controller_status(candidate, controller_result)) + runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) + + first = runner.prepare_controller_cli_handoff("repopulse-adoption-2026") + with pytest.raises(ValueError, match="controller_cli_handoff_replay_requires_recovery"): + runner.prepare_controller_cli_handoff("repopulse-adoption-2026") + + status = journal.get_repopulse_adoption_status(session) + assert first["intentId"] == candidate["intentId"] + assert status["status"] == "recovery_required" + assert status["phase"] == "reserved" + assert len(broker.reserve_calls) == 1 + assert broker.execute_calls == [] + + +def test_operator_runner_hydrates_portainer_settings_without_fastapi_lifespan(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + calls = [] + runner_module = importlib.import_module( + "backend.app.services.repopulse_adoption_runner" + ) + monkeypatch.setattr( + runner_module, + "apply_stored_connector_settings", + lambda active_session, connector_ids: calls.append( + (active_session, connector_ids) + ), + ) + + runner = runner_type(session=session, preflight=FakePreflight(), broker=FakeBroker()) + assert calls == [] + runner.prepare("repopulse-adoption-2026") + + assert calls == [(session, {"portainer"})] + + +def test_execution_flag_remains_fail_closed_in_source(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", False) + session = _session(monkeypatch) + preflight = FakePreflight() + broker = FakeBroker() + runner = runner_type(session=session, preflight=preflight, broker=broker) + + assert runner.prepare("repopulse-adoption-2026")["status"] == "disabled" + assert preflight.calls == 0 + assert broker.reserve_calls == [] + assert broker.execute_calls == [] + + +def test_tampered_completion_is_rejected_and_marked_for_recovery(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + candidate = protocol.create_intent(_passing_preflight(), "repopulse-adoption-2026") + binding = protocol.intent_binding_sha256(candidate) + controller_result = _controller_result(candidate["intentId"], binding) + broker = FakeBroker() + runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) + runner.prepare_controller_cli_handoff("repopulse-adoption-2026") + broker.status_result = _controller_status(candidate, controller_result) + controller_result["completion"]["completionSha256"] = _sha("0") + + result = runner.finalize(controller_result) + + assert result["status"] == "recovery_required" + assert result["phase"] == "reserved" + assert broker.status_calls == 1 + assert broker.execute_calls == [] + + +def test_ambiguous_controller_reserve_queries_status_and_requires_recovery(monkeypatch): + runner_type = _runner_type() + monkeypatch.setattr(protocol, "EXECUTION_ENABLED", True) + session = _session(monkeypatch) + broker = FakeBroker( + reserve_result={ + "status": "unreachable", + "reason": "secret-bearing raw transport error", + } + ) + runner = runner_type(session=session, preflight=FakePreflight(), broker=broker) + + result = runner.prepare("repopulse-adoption-2026") + + assert result["status"] == "recovery_required" + assert result["reason"] == "unknown_external_effect" + assert broker.status_calls == 1 + assert len(broker.reserve_calls) == 1 + assert broker.execute_calls == [] + assert "secret-bearing raw transport error" not in json.dumps(result) + + replay = runner.prepare("repopulse-adoption-2026") + assert replay["status"] == "recovery_required" + assert len(broker.reserve_calls) == 1 diff --git a/backend/tests/test_repopulse_adoption_storage_effects.py b/backend/tests/test_repopulse_adoption_storage_effects.py new file mode 100644 index 0000000..3bb9a4e --- /dev/null +++ b/backend/tests/test_repopulse_adoption_storage_effects.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import hashlib +import http.client +import importlib.util +import io +import json +import subprocess +import sys +import threading +from contextlib import redirect_stdout +from pathlib import Path +from types import ModuleType + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +BROKER_DIR = REPO_ROOT / "ops" / "gitea-deploy-broker" + + +def _load_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def storage_effects(): + _load_module("repopulse_adoption_store", BROKER_DIR / "repopulse_adoption_store.py") + return _load_module( + "repopulse_adoption_storage_effects_test", + BROKER_DIR / "repopulse_adoption_storage_effects.py", + ) + + +def _run_git(*args: str, cwd: Path | None = None) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _bare_repo_with_helper(tmp_path: Path, helper: bytes) -> tuple[Path, str]: + worktree = tmp_path / "worktree" + bare_repo = tmp_path / "repositories" / "jens" / "repopulse.git" + worktree.mkdir() + bare_repo.parent.mkdir(parents=True) + _run_git("init", "--bare", str(bare_repo)) + _run_git("init", "--initial-branch=main", str(worktree)) + _run_git("config", "user.name", "Storage effects test", cwd=worktree) + _run_git("config", "user.email", "storage-effects@example.invalid", cwd=worktree) + helper_path = worktree / "scripts" / "repopulse_db_migration.py" + helper_path.parent.mkdir() + helper_path.write_bytes(helper) + _run_git("add", "scripts/repopulse_db_migration.py", cwd=worktree) + _run_git("commit", "-m", "Add reviewed migration helper", cwd=worktree) + _run_git("remote", "add", "origin", str(bare_repo), cwd=worktree) + _run_git("push", "origin", "main", cwd=worktree) + return bare_repo, _run_git("rev-parse", "HEAD", cwd=worktree) + + +def test_maintenance_http_error_is_sanitized(storage_effects): + with pytest.raises(storage_effects.StorageEffectsError) as error: + storage_effects._maintenance_is_drained( + { + "before": { + "status": 200, + "body": { + "enabled": False, + "queue_depth": 0, + "active_jobs": 0, + }, + }, + "enter": {"status": [], "body": None}, + "status": {"status": 200, "body": {"enabled": True}}, + } + ) + + assert error.value.code == "maintenance_response_invalid" + + +def test_maintenance_queue_precheck_avoids_freezing_queued_work( + storage_effects, monkeypatch +): + token = "test-maintenance-token-value-0123456789" + calls: list[tuple[str, str]] = [] + + class Response: + status = 200 + + def __init__(self, payload): + self.payload = json.dumps(payload).encode("utf-8") + + def read(self, _limit): + return self.payload + + class Connection: + def __init__(self, _host, *args, **kwargs): + assert args == (8000,) + assert kwargs["timeout"] == 5 + + def request(self, method, path, **_kwargs): + calls.append((method, path)) + + def getresponse(self): + if len(calls) == 1: + return Response( + { + "enabled": False, + "queue_depth": 2, + "active_jobs": 0, + "active_drained": True, + "drained": False, + } + ) + return Response( + { + "enabled": True, + "queue_depth": 2, + "active_jobs": 0, + "active_drained": True, + "drained": False, + } + ) + + def close(self): + return None + + monkeypatch.setattr(http.client, "HTTPConnection", Connection) + monkeypatch.setattr(sys, "stdin", io.StringIO(token)) + output = io.StringIO() + with redirect_stdout(output): + exec( # noqa: S102 - exercise the exact fixed in-container exchange script + storage_effects._REMOTE_MAINTENANCE_EXCHANGE, {"__name__": "__main__"} + ) + + response = json.loads(output.getvalue()) + assert calls == [("GET", storage_effects.MAINTENANCE_STATUS_PATH)] + assert response["enter"] is None + assert token not in output.getvalue() + + +def test_legacy_main_revision_mismatch_stops_before_loading_helper( + storage_effects, tmp_path, monkeypatch +): + helper_bytes = b"# reviewed test helper\n" + bare_repo, actual_revision = _bare_repo_with_helper(tmp_path, helper_bytes) + source_root = tmp_path / "sources" / "repopulse" + helper_source = source_root / "scripts" / "repopulse_db_migration.py" + helper_source.parent.mkdir(parents=True) + helper_source.write_bytes(helper_bytes) + expected_revision = "0" * 40 + assert actual_revision != expected_revision + + core = type("GitCore", (), {})() + core.repository_path = lambda _repository, _project: bare_repo + core.output = lambda command: subprocess.run( + command, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + core.validate_commit = lambda repo, branch, revision: storage_effects._fixed_core( + None + ).validate_commit(repo, branch, revision) + + monkeypatch.setattr(storage_effects, "EXACT_SOURCE_ROOT", source_root.parent) + monkeypatch.setattr(storage_effects._store, "_ROOT_UID", source_root.stat().st_uid) + + def controller_directory(path: Path, *, root_owned: bool = False): + storage_effects._store._assert_no_symlink_components(path) + info = path.lstat() + assert info.st_mode + return info + + monkeypatch.setattr(storage_effects, "_validate_directory", controller_directory) + monkeypatch.setattr( + storage_effects, + "_validate_git_repository_directory", + lambda path: path.lstat(), + ) + monkeypatch.setattr( + storage_effects._store, + "_safe_directory", + lambda path, **_kwargs: ( + storage_effects._store._assert_no_symlink_components(path) or path.lstat() + ), + ) + + with pytest.raises(storage_effects.StorageEffectsError) as error: + storage_effects._load_reviewed_migration_helper( + core, + {"source_dir": str(source_root)}, + expected_revision, + ) + + assert error.value.code == "main_revision_mismatch" + + +def test_oversized_helper_is_rejected_before_git_returns_blob( + storage_effects, tmp_path, monkeypatch +): + helper_bytes = b"#" * (storage_effects._HELPER_MAX_BYTES + 1) + bare_repo, revision = _bare_repo_with_helper(tmp_path, helper_bytes) + real_run = subprocess.run + commands: list[list[str]] = [] + + def recording_run(args, *positional, **keyword): + commands.append(list(args)) + return real_run(args, *positional, **keyword) + + monkeypatch.setattr(storage_effects.subprocess, "run", recording_run) + + with pytest.raises(storage_effects.StorageEffectsError) as error: + storage_effects._read_git_blob(bare_repo, revision) + + assert error.value.code == "reviewed_migration_helper_invalid" + assert len(commands) == 1 + assert commands[0][2:4] == ["cat-file", "-s"] + + +def test_legacy_maintenance_404_blocks_stop(storage_effects, tmp_path, monkeypatch): + revision = "a" * 40 + container_id = "b" * 64 + image_id = "sha256:" + "c" * 64 + image_ref = "ghcr.io/jens/repopulse:legacy" + token = "fixed-maintenance-token-value-0123456789" + intent_id = "rpa-0123456789abcdef0123" + binding = "d" * 64 + container_id_sha256 = hashlib.sha256(container_id.encode("ascii")).hexdigest() + image_sha256 = hashlib.sha256(image_ref.encode("utf-8")).hexdigest() + + class Store: + def _lock_identity(self): + return ("project-lock", "global-lock") + + def _transaction_owner(self, _identity): + return threading.get_ident() + + def load_intent(self): + return { + "intentId": intent_id, + "intentBindingSha256": binding, + "status": "in_progress", + "phase": "target_gates_ready", + } + + class Core: + def __init__(self): + self.commands: list[list[str]] = [] + + def inspect_container(self, _name): + return { + "Id": container_id, + "Name": "/repopulse", + "Image": image_id, + "Config": { + "Image": image_ref, + "Env": [f"REPOPULSE_MAINTENANCE_TOKEN={token}"], + "Labels": { + "com.docker.compose.project": "repopulse", + "org.opencontainers.image.revision": revision, + }, + }, + "State": { + "Status": "running", + "Running": True, + "Health": {"Status": "healthy"}, + }, + "Mounts": [ + { + "Type": "volume", + "Name": "repopulse_data", + "Destination": "/data", + "Source": str(storage_effects.LEGACY_VOLUME_MOUNTPOINT), + "RW": True, + } + ], + } + + def inspect_image(self, _image_id): + return { + "Id": image_id, + "Config": { + "Labels": { + "org.opencontainers.image.revision": revision, + "org.opencontainers.image.source": "jens/repopulse", + } + }, + } + + def run(self, command): + self.commands.append(list(command)) + + core = Core() + effects = storage_effects.FixedRepoPulseStorageEffects( + store=Store(), deployment_core=core + ) + monkeypatch.setattr( + effects, + "_project_and_helper", + lambda _intent, _revision: ({}, ModuleType("unused"), "e" * 64), + ) + monkeypatch.setattr( + storage_effects, "_private_environment_facts", lambda: (token, True, True) + ) + monkeypatch.setattr( + storage_effects, + "_private_record_path", + lambda _name, _intent: tmp_path / "unused-receipt.json", + ) + monkeypatch.setattr(storage_effects, "_read_private_record", lambda *_args: None) + monkeypatch.setattr( + storage_effects, + "_assert_legacy_volume_retained", + lambda _core: storage_effects.LEGACY_VOLUME_MOUNTPOINT, + ) + monkeypatch.setattr( + storage_effects, + "_maintenance_exchange", + lambda *_args: { + "before": {"status": 404, "body": None}, + "enter": None, + "status": None, + }, + ) + + with pytest.raises(storage_effects.StorageEffectsError) as error: + effects.ensure_quiesced( + intent_id=intent_id, + intent_binding_sha256=binding, + expected_main_revision=revision, + expected_container_id_sha256=container_id_sha256, + expected_container_image_sha256=image_sha256, + ) + + assert error.value.code == "maintenance_endpoint_unavailable" + assert core.commands == [] diff --git a/backend/tests/test_repopulse_controller_adoption_store.py b/backend/tests/test_repopulse_controller_adoption_store.py new file mode 100644 index 0000000..06b2e04 --- /dev/null +++ b/backend/tests/test_repopulse_controller_adoption_store.py @@ -0,0 +1,1062 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +import multiprocessing +import os +import stat +import sys +import threading +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +STORE_PATH = REPO_ROOT / "ops" / "gitea-deploy-broker" / "repopulse_adoption_store.py" +RESTORE_PATH = REPO_ROOT / "ops" / "gitea-deploy-broker" / "repopulse_registry_restore.py" + + +def _load_store(): + spec = importlib.util.spec_from_file_location("repopulse_adoption_store_test", STORE_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_registry_restore(store_module, monkeypatch): + monkeypatch.setitem(sys.modules, "repopulse_adoption_store", store_module) + spec = importlib.util.spec_from_file_location( + "repopulse_registry_restore_test", RESTORE_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _sha(value: str) -> str: + return hashlib.sha256(value.encode("ascii")).hexdigest() + + +def test_intent_binding_matches_appops_canonical_digest() -> None: + module = _load_store() + idempotency = _sha("repopulse-adoption-key") + intent_id = f"rpa-{idempotency[:20]}" + preflight = _sha("preflight-projection") + binding = { + "intentId": intent_id, + "repository": "jens/repopulse", + "applicationId": "repopulse", + "preflightSha256": preflight, + "idempotencySha256": idempotency, + } + expected = hashlib.sha256( + json.dumps(binding, sort_keys=True, separators=(",", ":")).encode("ascii") + ).hexdigest() + + assert module.intent_binding_sha256(intent_id, idempotency, preflight) == expected + assert module._document_sha256(binding) != expected + + +@pytest.fixture +def store_env(tmp_path, monkeypatch): + module = _load_store() + controller = tmp_path / "controller" + controller.mkdir() + controller.chmod(0o755) + (controller / "projects.json").write_text( + json.dumps( + { + "jens/repopulse": { + "branch": "main", + "compose_files": ["docker-compose.yml"], + "compose_project": "repopulse", + "containers": ["repopulse"], + "env_source": "/srv/example/appdata/repopulse/deploy.env", + "exact_source": True, + "gitea_repository_id": 64, + "gitea_webhook_secret_file": "jens__repopulse", + "health_timeout": 180, + "slug": "repopulse", + "source_dir": "/mnt/cache/appdata/gitea-deploy-runners/sources/repopulse", + "validation": { + "contexts": ["Managed validation / full (push)"], + "max_poll_interval_seconds": 30, + "poll_interval_seconds": 5, + "required": True, + "timeout_seconds": 1800, + "validation_gate_version": 1, + }, + "verify_oci_revision": False, + }, + "jens/other": {"slug": "other"}, + }, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + (controller / "projects.json").chmod(0o600) + secrets = controller / "appops-deployment-secrets" + secrets.mkdir() + secrets.chmod(0o700) + state = tmp_path / "state" + state.mkdir() + state.chmod(0o755) + locks = state / "locks" + locks.mkdir() + locks.chmod(0o755) + global_lock = tmp_path / "deploy.lock" + state_store = state / "repopulse-adoption" + + monkeypatch.setattr(module, "CONTROLLER_READONLY_ROOT", controller) + monkeypatch.setattr(module, "CONTROLLER_RW_ROOT", controller) + monkeypatch.setattr(module, "STATE_ROOT", state) + monkeypatch.setattr(module, "GLOBAL_DEPLOY_LOCK", global_lock) + monkeypatch.setattr(module, "PROJECT_LOCK", locks / "repopulse.lock") + monkeypatch.setattr(module, "ADOPTION_STATE_ROOT", state_store) + monkeypatch.setattr(module, "EXECUTION_ENABLED", True) + monkeypatch.setattr(module, "_IS_POSIX_CONTROLLER", True) + test_uid = controller.stat().st_uid + monkeypatch.setattr(module, "_ROOT_UID", test_uid) + monkeypatch.setattr(module, "_effective_uid", lambda: test_uid) + if os.name != "posix": + monkeypatch.setattr( + module, + "_mode_bits", + lambda path, info: ( + 0o700 + if path.name in {"appops-deployment-secrets", "repopulse-adoption"} + else 0o755 + if stat.S_ISDIR(info.st_mode) + else 0o600 + ), + ) + monkeypatch.setattr(module, "_directory_fsync", lambda _path: None) + + return module, module.RepoPulseAdoptionStore(), controller, state_store + + +def _reserve(store, *, key: str = "adoption-key-2026", preflight: str = "f"): + key_sha = hashlib.sha256( + f"itworx.repopulse-legacy-adoption/v1:jens/repopulse:{key}".encode("ascii") + ).hexdigest() + intent_id = f"rpa-{key_sha[:20]}" + return store.reserve_intent( + intent_id=intent_id, + idempotency_sha256=key_sha, + preflight_sha256=_sha(preflight), + ) + + +def _to_snapshot_verified(store): + intent = _reserve(store) + intent = store.record_target_gates( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + profile_id="repopulse-managed-v2", + plan_sha256=_sha("managed profile"), + ) + intent = store.record_quiescence( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + evidence_sha256=_sha("maintenance and queue drained"), + ) + return store.record_snapshot_verified( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + snapshot_file_sha256=_sha("online sqlite backup"), + snapshot_logical_sha256=_sha("database logical digest"), + snapshot_queue_sha256=_sha("empty queue digest"), + ) + + +def test_execution_can_be_hard_disabled_without_effects(monkeypatch): + module = _load_store() + monkeypatch.setattr(module, "EXECUTION_ENABLED", False) + + assert module.execution_status()["enabled"] is False + assert module.execution_status()["reason"] == "controller_adoption_execution_disabled" + assert module.execution_status()["hasRoute"] is True + assert module.execution_status()["hasEffectfulRoute"] is False + + +def test_capability_report_is_sanitized_and_does_not_create_state_or_locks( + store_env, monkeypatch +): + module, _store, controller, state_root = store_env + monkeypatch.setattr(module, "EXECUTION_ENABLED", False) + credential = b"private-capability-test-secret-value" * 2 + credential_path = controller / "appops-deployment-secrets" / "jens__repopulse" + credential_path.write_bytes(credential) + credential_path.chmod(0o600) + + report = module.capability_report() + + assert report == { + "repository": "jens/repopulse", + "executionEnabled": False, + "controllerAliasesReady": True, + "registryReady": True, + "secretRootReady": True, + } + assert credential.decode("ascii") not in json.dumps(report) + assert not state_root.exists() + assert not module.PROJECT_LOCK.exists() + assert not module.GLOBAL_DEPLOY_LOCK.exists() + + +def test_capability_report_only_exposes_registry_readiness_on_identity_drift(store_env): + module, _store, controller, _state_root = store_env + projects_path = controller / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects["jens/repopulse"]["source_dir"] = "/tmp/private-value" + projects_path.write_text(json.dumps(projects), encoding="utf-8") + projects_path.chmod(0o600) + + report = module.capability_report() + + assert report["controllerAliasesReady"] is True + assert report["registryReady"] is False + assert report["secretRootReady"] is True + assert "/tmp/private-value" not in json.dumps(report) + + +def test_read_only_status_when_unreserved_does_not_create_state_or_locks( + store_env, monkeypatch +): + module, store, _controller, _state = store_env + monkeypatch.setattr(module, "EXECUTION_ENABLED", False) + + assert store.read_only_status() is None + assert not module.ADOPTION_STATE_ROOT.exists() + assert not module.PROJECT_LOCK.exists() + assert not module.GLOBAL_DEPLOY_LOCK.exists() + + +def test_read_only_status_fails_closed_on_receipt_without_intent(store_env, monkeypatch): + module, store, _controller, state_root = store_env + monkeypatch.setattr(module, "EXECUTION_ENABLED", False) + state_root.mkdir(mode=0o700) + state_root.chmod(0o700) + + idempotency_sha256 = _sha("orphan receipt idempotency") + intent_id = f"rpa-{idempotency_sha256[:20]}" + receipt = { + "apiVersion": module.RECEIPT_SCHEMA, + "intentId": intent_id, + "repository": module.REPOSITORY, + "applicationId": module.APPLICATION_ID, + "phase": "registry_committed", + "intentBindingSha256": module.intent_binding_sha256( + intent_id, idempotency_sha256, _sha("orphan receipt preflight") + ), + "legacyVolumeRetained": True, + "intentDurable": True, + "managedEnrollmentId": "enr-" + "a" * 20, + "projectsSha256": _sha("orphan receipt projects"), + "snapshotFileSha256": _sha("orphan receipt snapshot"), + } + receipt["receiptSha256"] = module._document_sha256(receipt) + receipt_path = state_root / "receipt.json" + receipt_path.write_bytes(module._canonical_json(receipt)) + receipt_path.chmod(0o600) + receipt_before = receipt_path.read_bytes() + + with pytest.raises( + module.AdoptionStoreError, match="controller_receipt_without_intent" + ): + store.read_only_status() + + assert receipt_path.read_bytes() == receipt_before + assert not (state_root / "intent.json").exists() + assert {path.name for path in state_root.iterdir()} == {"receipt.json"} + assert not module.PROJECT_LOCK.exists() + assert not module.GLOBAL_DEPLOY_LOCK.exists() + + +def test_registry_write_guard_does_not_mark_an_execution_transaction( + store_env, monkeypatch +): + module, store, _controller, _state_root = store_env + monkeypatch.setattr(module, "EXECUTION_ENABLED", False) + identity = module._lock_identity() + + with store.registry_write_guard(): + assert module._transaction_owner(identity) is None + assert not module.ADOPTION_STATE_ROOT.exists() + + assert module._transaction_owner(identity) is None + assert not module.ADOPTION_STATE_ROOT.exists() + + +def test_read_only_status_returns_only_sanitized_hashes_and_phase(store_env): + _module, store, controller, _state = store_env + credential = b"private-status-test-credential-value" * 2 + credential_path = controller / "appops-deployment-secrets" / "jens__repopulse" + credential_path.write_bytes(credential) + credential_path.chmod(0o600) + intent = _reserve(store) + + status = store.read_only_status() + + assert status is not None + assert status["status"] == "in_progress" + assert status["phase"] == "reserved" + assert status["hashes"]["intentSha256"] == store.intent_sha256(intent) + assert "expectedTargetCredentialSha256" not in json.dumps(status) + assert hashlib.sha256(credential).hexdigest() not in json.dumps(status) + assert credential.decode("ascii") not in json.dumps(status) + + +def test_read_only_status_fails_closed_on_unsafe_state_root(store_env): + module, store, _controller, _state = store_env + module.ADOPTION_STATE_ROOT.write_text("unsafe state", encoding="utf-8") + + with pytest.raises(module.AdoptionStoreError, match="controller_path_unsafe"): + store.read_only_status() + + assert module.ADOPTION_STATE_ROOT.read_text(encoding="utf-8") == "unsafe state" + + +def test_store_reserves_one_fixed_idempotent_intent(store_env): + module, store, _controller, _state = store_env + + first = _reserve(store) + replay = _reserve(store) + + assert replay == first + assert first["repository"] == "jens/repopulse" + assert first["applicationId"] == "repopulse" + assert first["legacyVolume"] == "repopulse_data" + assert first["adoptionAuthorized"] is False + assert first["phase"] == "reserved" + with pytest.raises(module.AdoptionStoreError, match="one_time_adoption_already_reserved"): + _reserve(store, key="different-adoption-key-2026") + + +def test_reservation_rejects_legacy_registry_identity_drift(store_env): + module, store, controller, _state = store_env + projects_path = controller / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects["jens/repopulse"]["source_dir"] = "/tmp/attacker" + projects_path.write_text(json.dumps(projects), encoding="utf-8") + projects_path.chmod(0o600) + + with pytest.raises(module.AdoptionStoreError, match="legacy_registry_identity_mismatch"): + _reserve(store) + + +def test_reservation_rejects_unrecognized_legacy_registry_fields(store_env): + module, store, controller, _state = store_env + projects_path = controller / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects["jens/repopulse"]["unexpected_override"] = True + projects_path.write_text(json.dumps(projects), encoding="utf-8") + projects_path.chmod(0o600) + + with pytest.raises(module.AdoptionStoreError, match="legacy_registry_identity_mismatch"): + _reserve(store) + + +def test_reservation_rejects_boolean_integer_alias_in_legacy_gate(store_env): + module, store, controller, _state = store_env + projects_path = controller / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects["jens/repopulse"]["validation"]["validation_gate_version"] = True + projects_path.write_text(json.dumps(projects), encoding="utf-8") + projects_path.chmod(0o600) + + with pytest.raises(module.AdoptionStoreError, match="legacy_registry_identity_mismatch"): + _reserve(store) + + +def test_store_rejects_read_write_controller_alias_mismatch(store_env, monkeypatch, tmp_path): + module, store, _controller, _state = store_env + other = tmp_path / "other-controller" + other.mkdir() + other.chmod(0o755) + monkeypatch.setattr(module, "CONTROLLER_RW_ROOT", other) + + with pytest.raises(module.AdoptionStoreError, match="controller_alias_mismatch"): + _reserve(store) + + +def test_store_rejects_controller_registry_with_unsafe_mode(store_env, monkeypatch): + module, store, _controller, _state = store_env + original = module._mode_bits + + def unsafe_projects_mode(path, info): + if path.name == "projects.json": + return 0o666 + return original(path, info) + + monkeypatch.setattr(module, "_mode_bits", unsafe_projects_mode) + with pytest.raises(module.AdoptionStoreError, match="controller_file_mode_invalid"): + _reserve(store) + + +def test_store_rejects_symlinked_controller_root(store_env, monkeypatch, tmp_path): + module, store, controller, _state = store_env + alias = tmp_path / "controller-alias" + try: + alias.symlink_to(controller, target_is_directory=True) + except OSError as exc: + pytest.skip(f"creating a symlink is unavailable on this Windows host: {exc}") + monkeypatch.setattr(module, "CONTROLLER_RW_ROOT", alias) + + with pytest.raises(module.AdoptionStoreError, match="controller_path_unsafe"): + _reserve(store) + + +def test_checkpoint_order_and_idempotent_replay_are_digest_bound(store_env): + module, store, _controller, _state = store_env + intent = _reserve(store) + + with pytest.raises(module.AdoptionStoreError, match="adoption_step_out_of_order"): + store.record_quiescence( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + evidence_sha256=_sha("queue drained"), + ) + + first = store.record_target_gates( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + profile_id="repopulse-managed-v2", + plan_sha256=_sha("profile"), + ) + replay = store.record_target_gates( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(first), + profile_id="repopulse-managed-v2", + plan_sha256=_sha("profile"), + ) + assert replay == first + + with pytest.raises(module.AdoptionStoreError, match="idempotency_conflict"): + store.record_target_gates( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(first), + profile_id="repopulse-managed-v2", + plan_sha256=_sha("changed profile"), + ) + + +def test_registry_and_fixed_secret_registration_are_atomic_and_redacted(store_env): + module, store, controller, state_root = store_env + snapshot = _to_snapshot_verified(store) + credential = b"target-token-" + b"x" * 40 + + receipt = store.register_target( + intent_id=snapshot["intentId"], + expected_intent_sha256=store.intent_sha256(snapshot), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + + projects = json.loads((controller / "projects.json").read_text(encoding="utf-8")) + target = projects["jens/repopulse"] + assert target["managed_profile"] == "repopulse-authenticated" + assert target["managed_application_id"] == "repopulse" + assert target["managed_runtime_uid"] == target["managed_runtime_gid"] == 10001 + assert target["managed_proxy_hostname"] == "repopulse.example.com" + assert target["appops_deployment_secret_file"] == "jens__repopulse" + assert target["compose_files"] == [".itworx/deployment.yml"] + assert target["gitea_repository_id"] == 64 + assert target["gitea_webhook_secret_file"] == "jens__repopulse" + assert target["health_timeout"] == 180 + assert target["validation"] == { + "contexts": ["Managed validation / full (push)"], + "max_poll_interval_seconds": 30, + "poll_interval_seconds": 5, + "required": True, + "timeout_seconds": 1800, + "validation_gate_version": 1, + } + assert projects["jens/other"] == {"slug": "other"} + assert (controller / "appops-deployment-secrets" / "jens__repopulse").read_bytes() == credential + assert module._mode_bits( + controller / "appops-deployment-secrets", + (controller / "appops-deployment-secrets").stat(), + ) == 0o700 + assert module._mode_bits( + controller / "appops-deployment-secrets" / "jens__repopulse", + (controller / "appops-deployment-secrets" / "jens__repopulse").stat(), + ) == 0o600 + assert receipt["phase"] == "registry_committed" + assert receipt["legacyVolumeRetained"] is True + assert credential.decode("ascii") not in json.dumps(receipt) + intent_bytes = (state_root / "intent.json").read_text(encoding="utf-8") + receipt_bytes = (state_root / "receipt.json").read_text(encoding="utf-8") + assert credential.decode("ascii") not in intent_bytes + receipt_bytes + + +def test_registration_replay_is_idempotent_after_receipt_is_durable(store_env): + _module, store, _controller, _state = store_env + snapshot = _to_snapshot_verified(store) + credential = b"target-token-" + b"x" * 40 + first = store.register_target( + intent_id=snapshot["intentId"], + expected_intent_sha256=store.intent_sha256(snapshot), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + current_intent = store.load_intent() + assert current_intent is not None + + replay = store.register_target( + intent_id=current_intent["intentId"], + expected_intent_sha256=store.intent_sha256(current_intent), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + + assert replay == first + + +def test_registry_digest_compare_and_swap_rejects_drift_without_mutating_secret(store_env): + module, store, controller, _state = store_env + snapshot = _to_snapshot_verified(store) + path = controller / "projects.json" + projects = json.loads(path.read_text(encoding="utf-8")) + projects["jens/other"]["changed"] = True + path.write_text(json.dumps(projects), encoding="utf-8") + path.chmod(0o600) + + with pytest.raises(module.AdoptionStoreError, match="projects_digest_conflict"): + store.register_target( + intent_id=snapshot["intentId"], + expected_intent_sha256=store.intent_sha256(snapshot), + enrollment_id="enr-0123456789abcdef0123", + target_credential=b"target-token-" + b"x" * 40, + ) + assert not (controller / "appops-deployment-secrets" / "jens__repopulse").exists() + + +def test_existing_nonmatching_secret_is_never_overwritten(store_env): + module, store, controller, _state = store_env + snapshot = _to_snapshot_verified(store) + credential_path = controller / "appops-deployment-secrets" / "jens__repopulse" + credential_path.write_bytes(b"existing-other-secret-value-not-for-adoption") + credential_path.chmod(0o600) + + with pytest.raises(module.AdoptionStoreError, match="target_credential_conflict"): + store.register_target( + intent_id=snapshot["intentId"], + expected_intent_sha256=store.intent_sha256(snapshot), + enrollment_id="enr-0123456789abcdef0123", + target_credential=b"target-token-" + b"x" * 40, + ) + assert credential_path.read_bytes() == b"existing-other-secret-value-not-for-adoption" + + +def test_registration_replay_recovers_after_registry_commit_receipt_failure(store_env, monkeypatch): + module, store, controller, _state = store_env + snapshot = _to_snapshot_verified(store) + credential = b"target-token-" + b"x" * 40 + original = store._write_receipt_cas + + def fail_once(*args, **kwargs): + raise OSError("simulated receipt fsync interruption") + + monkeypatch.setattr(store, "_write_receipt_cas", fail_once) + with pytest.raises(module.AdoptionStoreError, match="registration_receipt_write_failed"): + store.register_target( + intent_id=snapshot["intentId"], + expected_intent_sha256=store.intent_sha256(snapshot), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + assert json.loads((controller / "projects.json").read_text(encoding="utf-8"))["jens/repopulse"]["managed_application_id"] == "repopulse" + assert (controller / "appops-deployment-secrets" / "jens__repopulse").read_bytes() == credential + + monkeypatch.setattr(store, "_write_receipt_cas", original) + current_intent = store.load_intent() + assert current_intent is not None + receipt = store.register_target( + intent_id=current_intent["intentId"], + expected_intent_sha256=store.intent_sha256(current_intent), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + assert store.load_receipt()["receiptSha256"] == receipt["receiptSha256"] + + +def test_registration_replay_recovers_after_secret_only_partial_commit(store_env, monkeypatch): + module, store, controller, _state = store_env + snapshot = _to_snapshot_verified(store) + credential = b"target-token-" + b"x" * 40 + original = module._atomic_replace_cas + failed = False + + def fail_projects_once(path, payload, **kwargs): + nonlocal failed + if path.name == "projects.json" and not failed: + failed = True + raise module.AdoptionStoreError("simulated_registry_replace_failure") + return original(path, payload, **kwargs) + + monkeypatch.setattr(module, "_atomic_replace_cas", fail_projects_once) + with pytest.raises(module.AdoptionStoreError, match="projects_registry_write_failed"): + store.register_target( + intent_id=snapshot["intentId"], + expected_intent_sha256=store.intent_sha256(snapshot), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + assert json.loads((controller / "projects.json").read_text(encoding="utf-8"))["jens/repopulse"]["env_source"] == "/srv/example/appdata/repopulse/deploy.env" + assert (controller / "appops-deployment-secrets" / "jens__repopulse").read_bytes() == credential + + monkeypatch.setattr(module, "_atomic_replace_cas", original) + current_intent = store.load_intent() + assert current_intent is not None + receipt = store.register_target( + intent_id=current_intent["intentId"], + expected_intent_sha256=store.intent_sha256(current_intent), + enrollment_id="enr-0123456789abcdef0123", + target_credential=credential, + ) + assert receipt["phase"] == "registry_committed" + + +def test_recovery_marker_is_durable_and_digest_bound(store_env): + module, store, _controller, _state = store_env + intent = _to_snapshot_verified(store) + marked = store.mark_recovery_required( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + reason="operator_interruption", + failure_evidence_sha256=_sha("operator interruption marker"), + ) + assert marked["status"] == "recovery_required" + assert store.load_intent() == marked + with pytest.raises(module.AdoptionStoreError, match="idempotency_conflict"): + store.mark_recovery_required( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(marked), + reason="operator_interruption", + failure_evidence_sha256=_sha("different interruption marker"), + ) + + +def test_lock_order_is_project_then_global(store_env, monkeypatch): + module, store, _controller, _state = store_env + observed = [] + + class FakeLock: + def __init__(self, path): + self.path = path + + def __enter__(self): + observed.append(self.path) + return self + + def __exit__(self, *_args): + return False + + monkeypatch.setattr(module, "_exclusive_flock", lambda path: FakeLock(path)) + _reserve(store) + + assert observed == [module.PROJECT_LOCK, module.GLOBAL_DEPLOY_LOCK] + + +def test_transaction_holds_each_lock_once_and_rechecks_aliases_under_lock(store_env, monkeypatch): + module, store, _controller, _state = store_env + another_store = module.RepoPulseAdoptionStore() + events = [] + original_alias_check = module._check_controller_aliases + + class FakeLock: + def __init__(self, path): + self.path = path + + def __enter__(self): + events.append(("lock", self.path)) + return self + + def __exit__(self, *_args): + return False + + def check_aliases(): + events.append(("aliases", None)) + return original_alias_check() + + monkeypatch.setattr(module, "_exclusive_flock", lambda path: FakeLock(path)) + monkeypatch.setattr(module, "_check_controller_aliases", check_aliases) + + with store.transaction(): + assert store.load_intent() is None + assert another_store.load_receipt() is None + + assert events == [ + ("lock", module.PROJECT_LOCK), + ("lock", module.GLOBAL_DEPLOY_LOCK), + ("aliases", None), + ("aliases", None), + ("aliases", None), + ] + + +def test_deploy_lock_reuse_is_only_visible_after_both_flocks_are_acquired(store_env): + module, store, _controller, _state = store_env + unrelated = module.STATE_ROOT / "locks" / "other.lock" + assert not module.transaction_lock_held(module.PROJECT_LOCK) + assert not module.transaction_lock_held(module.GLOBAL_DEPLOY_LOCK) + with store.transaction(): + assert module.transaction_lock_held(module.PROJECT_LOCK) + assert module.transaction_lock_held(module.GLOBAL_DEPLOY_LOCK) + assert not module.transaction_lock_held(unrelated) + assert not module.transaction_lock_held(module.PROJECT_LOCK) + + +def test_nested_transaction_is_rejected_without_losing_outer_lock(store_env): + module, store, _controller, _state = store_env + another_store = module.RepoPulseAdoptionStore() + + with store.transaction(): + for nested_store in (store, another_store): + with pytest.raises( + module.AdoptionStoreError, match="controller_transaction_nested" + ), nested_store.transaction(): + pytest.fail("nested transaction must not enter") + assert store.load_intent() is None + + +def test_transaction_owner_is_released_after_alias_validation_failure(store_env, monkeypatch): + module, store, _controller, _state = store_env + original_alias_check = module._check_controller_aliases + calls = 0 + + def fail_once(): + nonlocal calls + calls += 1 + if calls == 1: + raise module.AdoptionStoreError("controller_alias_mismatch") + return original_alias_check() + + monkeypatch.setattr(module, "_check_controller_aliases", fail_once) + with pytest.raises( + module.AdoptionStoreError, match="controller_alias_mismatch" + ), store.transaction(): + pytest.fail("transaction must not yield after alias validation failure") + + with store.transaction(): + assert store.load_intent() is None + + +def test_transaction_rejects_other_thread_store_calls_and_transactions(store_env): + module, store, _controller, _state = store_env + another_store = module.RepoPulseAdoptionStore() + entered = threading.Event() + release = threading.Event() + failures = [] + + def hold_transaction(): + try: + with store.transaction(): + try: + with store.transaction(): + pass + except module.AdoptionStoreError as exc: + if exc.code != "controller_transaction_nested": + failures.append(exc) + else: + failures.append("nested transaction unexpectedly entered") + entered.set() + if not release.wait(5): + failures.append("transaction release timed out") + except (AssertionError, module.AdoptionStoreError) as exc: + failures.append(exc) + + thread = threading.Thread(target=hold_transaction) + thread.start() + try: + assert entered.wait(2) + with pytest.raises( + module.AdoptionStoreError, match="controller_transaction_thread_mismatch" + ): + another_store.load_intent() + with pytest.raises( + module.AdoptionStoreError, match="controller_transaction_thread_mismatch" + ), another_store.transaction(): + pytest.fail("other thread must not enter transaction") + finally: + release.set() + thread.join(timeout=5) + + assert not thread.is_alive() + assert failures == [] + + +@pytest.mark.skipif(os.name != "posix", reason="cross-process POSIX flock test") +def test_project_flock_blocks_a_second_process(store_env): + module, store, _controller, _state = store_env + ready = multiprocessing.Event() + acquired = multiprocessing.Event() + + def second_process_lock(path_text, started, locked): + import fcntl + + started.set() + with open(path_text, "a+") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + locked.set() + fcntl.flock(handle, fcntl.LOCK_UN) + + with store.transaction(): + assert store.load_intent() is None + child = multiprocessing.Process( + target=second_process_lock, + args=(str(module.PROJECT_LOCK), ready, acquired), + ) + child.start() + assert ready.wait(2) + assert not acquired.wait(0.1) + assert acquired.wait(2) + child.join(timeout=2) + assert child.exitcode == 0 + + +def _registered_recovery(store, restore, *, preserve=True, reason="target_health_failed"): + intent = _to_snapshot_verified(store) + if preserve: + with store.transaction(): + restore.preserve_before_registry_cas(store) + with store.transaction(): + store.register_target( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + enrollment_id="enr-0123456789abcdefabcd", + target_credential=b"R" * 64, + ) + intent = store.load_intent() + return store.mark_recovery_required( + intent_id=intent["intentId"], + expected_intent_sha256=store.intent_sha256(intent), + reason=reason, + failure_evidence_sha256=_sha("managed cutover health failed"), + ) + + +def test_registry_snapshot_preserves_exact_old_bytes_only_inside_store_transaction( + store_env, monkeypatch +): + store_module, store, controller, state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + intent = _to_snapshot_verified(store) + original = (controller / "projects.json").read_bytes() + + with pytest.raises(store_module.AdoptionStoreError, match="restore_transaction_required"): + restore.preserve_before_registry_cas(store) + + with store.transaction(): + first = restore.preserve_before_registry_cas(store) + replay = restore.preserve_before_registry_cas(store) + + snapshot_path = state_root / "projects-before.bin" + receipt_path = state_root / "registry-snapshot.json" + assert snapshot_path.read_bytes() == original + assert store_module._mode_bits(snapshot_path, snapshot_path.stat()) == 0o600 + assert store_module._mode_bits(receipt_path, receipt_path.stat()) == 0o600 + assert first == replay + assert first["intentId"] == intent["intentId"] + assert first["sourceProjectsSha256"] == hashlib.sha256(original).hexdigest() + assert first["snapshotSha256"] == hashlib.sha256(original).hexdigest() + assert first["snapshotBytes"] == len(original) + assert b"R" * 64 not in receipt_path.read_bytes() + + +def test_registry_snapshot_rejects_source_digest_drift(store_env, monkeypatch): + store_module, store, controller, state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _to_snapshot_verified(store) + projects_path = controller / "projects.json" + projects = json.loads(projects_path.read_text(encoding="utf-8")) + projects["jens/other"]["display"] = "changed while preparing" + projects_path.write_text(json.dumps(projects, sort_keys=True) + "\n", encoding="utf-8") + projects_path.chmod(0o600) + + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="projects_digest_conflict" + ): + restore.preserve_before_registry_cas(store) + + assert not state_root.exists() or not (state_root / "projects-before.bin").exists() + + +def test_registry_restore_reverts_only_exact_managed_target_and_replays_idempotently( + store_env, monkeypatch +): + store_module, store, controller, _state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + original = (controller / "projects.json").read_bytes() + intent = _registered_recovery(store, restore) + assert intent["status"] == "recovery_required" + + with pytest.raises(store_module.AdoptionStoreError, match="restore_transaction_required"): + restore.restore_after_failed_managed_cutover(store) + + with store.transaction(): + assert store_module.transaction_lock_held(store_module.PROJECT_LOCK) + assert store_module.transaction_lock_held(store_module.GLOBAL_DEPLOY_LOCK) + first = restore.restore_after_failed_managed_cutover(store) + replay = restore.restore_after_failed_managed_cutover(store) + + assert (controller / "projects.json").read_bytes() == original + assert first == replay + assert first["status"] == "restored" + assert first["restoredProjectsSha256"] == hashlib.sha256(original).hexdigest() + assert first["managedProjectsSha256"] != first["restoredProjectsSha256"] + assert first["aliasesVerified"] is True + assert "target_credential" not in json.dumps(first) + assert b"R" * 64 not in json.dumps(first).encode("utf-8") + + +def test_registry_restore_requires_durable_intent_before_accepting_old_digest( + store_env, monkeypatch +): + store_module, store, controller, state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + original = (controller / "projects.json").read_bytes() + _registered_recovery(store, restore) + projects_path = controller / "projects.json" + projects_path.write_bytes(original) + projects_path.chmod(0o600) + + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="registry_restore_intent_missing" + ): + restore.restore_after_failed_managed_cutover(store) + + assert projects_path.read_bytes() == original + assert not (state_root / "registry-restore-receipt.json").exists() + + +def test_registry_restore_receipt_prevents_a_second_revert_after_re_registration( + store_env, monkeypatch +): + store_module, store, controller, _state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _registered_recovery(store, restore) + projects_path = controller / "projects.json" + managed = projects_path.read_bytes() + with store.transaction(): + restore.restore_after_failed_managed_cutover(store) + + projects_path.write_bytes(managed) + projects_path.chmod(0o600) + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="registry_restore_receipt_conflict" + ): + restore.restore_after_failed_managed_cutover(store) + + assert projects_path.read_bytes() == managed + + +def test_registry_restore_compare_and_swap_drift_never_overwrites_new_registry( + store_env, monkeypatch +): + store_module, store, controller, _state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _registered_recovery(store, restore) + projects_path = controller / "projects.json" + drift = b'{"jens/repopulse":{"changed":"after-preflight"}}\n' + atomic_replace = store_module._atomic_replace_cas + injected = False + + def change_before_cas(path, payload, **kwargs): + nonlocal injected + if path == projects_path and not injected: + injected = True + projects_path.write_bytes(drift) + projects_path.chmod(0o600) + return atomic_replace(path, payload, **kwargs) + + monkeypatch.setattr(store_module, "_atomic_replace_cas", change_before_cas) + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="projects_digest_conflict" + ): + restore.restore_after_failed_managed_cutover(store) + + assert injected + assert projects_path.read_bytes() == drift + + +def test_registry_restore_requires_a_durable_snapshot(store_env, monkeypatch): + store_module, store, controller, _state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _registered_recovery(store, restore, preserve=False) + managed = (controller / "projects.json").read_bytes() + + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="registry_snapshot_missing" + ): + restore.restore_after_failed_managed_cutover(store) + + assert (controller / "projects.json").read_bytes() == managed + + +def test_registry_restore_rejects_recovery_reasons_before_cutover(store_env, monkeypatch): + store_module, store, controller, _state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _registered_recovery(store, restore, reason="database_backup_failed") + managed = (controller / "projects.json").read_bytes() + + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="registry_restore_not_eligible" + ): + restore.restore_after_failed_managed_cutover(store) + + assert (controller / "projects.json").read_bytes() == managed + + +def test_registry_restore_rejects_symlinked_snapshot(store_env, monkeypatch): + store_module, store, _controller, state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _to_snapshot_verified(store) + with store.transaction(): + store._prepare_journal_root() + snapshot_path = state_root / "projects-before.bin" + outside = state_root.parent / "outside-snapshot" + outside.write_bytes(b"do not read or overwrite") + try: + os.symlink(outside, snapshot_path) + except (OSError, NotImplementedError): + pytest.skip("symlink creation is unavailable on this test host") + + with pytest.raises( + store_module.AdoptionStoreError, match="controller_path_unsafe" + ): + restore.preserve_before_registry_cas(store) + assert outside.read_bytes() == b"do not read or overwrite" + + +def test_registry_snapshot_rejects_group_or_world_accessible_snapshot( + store_env, monkeypatch +): + store_module, store, _controller, state_root = store_env + restore = _load_registry_restore(store_module, monkeypatch) + _to_snapshot_verified(store) + snapshot_path = state_root / "projects-before.bin" + with store.transaction(): + store._prepare_journal_root() + snapshot_path.write_bytes(b"{}\n") + snapshot_path.chmod(0o600) + + original_mode_bits = store_module._mode_bits + + def unsafe_snapshot_mode(path, info): + if path == snapshot_path: + return 0o644 + return original_mode_bits(path, info) + + monkeypatch.setattr(store_module, "_mode_bits", unsafe_snapshot_mode) + with store.transaction(), pytest.raises( + store_module.AdoptionStoreError, match="controller_file_mode_invalid" + ): + restore.preserve_before_registry_cas(store) diff --git a/backend/tests/test_repopulse_legacy_adoption_protocol.py b/backend/tests/test_repopulse_legacy_adoption_protocol.py new file mode 100644 index 0000000..9e0dfca --- /dev/null +++ b/backend/tests/test_repopulse_legacy_adoption_protocol.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import pytest + +from backend.app.services.enrollment_profiles import PROFILE_VERSION +from backend.app.services.repopulse_legacy_adoption_protocol import ( + APPLICATION_ID, + CONTROLLER_RECEIPT_VERSION, + EXECUTION_ENABLED, + LEGACY_VOLUME, + REPOSITORY, + AdoptionProtocolError, + advance_intent, + complete_intent, + create_intent, + execution_status, + intent_binding_sha256, + mark_recovery_required, + reserve_or_replay, + resume_intent, +) + + +def _sha(value: str) -> str: + return value * 64 + + +def _passing_preflight() -> dict: + checks = [ + { + "code": code, + "status": "passed", + "gate": "legacy_compatibility", + "detail": "sanitized fixed check", + } + for code in ( + "legacy_registry", + "controller_state", + "gitea_ancestry", + "runtime_identity_and_health", + "legacy_storage_topology", + "legacy_appdata", + "product_api", + "database_health", + "queue_quiescence", + "database_baseline", + ) + ] + return { + "apiVersion": "itworx.repopulse-legacy-adoption-preflight/v1", + "repository": REPOSITORY, + "status": "preflight_passed", + "legacyCompatibility": {"status": "passed", "checks": checks}, + "targetAcceptance": {"status": "blocked", "checks": []}, + "readOnly": True, + "adoptionAuthorized": False, + "evidence": { + "main_revision": "a" * 40, + "live_revision": "b" * 40, + "container_id_sha256": _sha("d"), + "container_image_sha256": _sha("1"), + "container_state": "running", + "container_health": "healthy", + "queue": {"queued": 0, "active": 0, "completed": 569, "failed": 0}, + "database": { + "database_bytes": 44_576_768, + "journal_bytes": 0, + "scan_count": 572, + "compacted_scan_count": 96, + }, + }, + } + + +def _intent() -> dict: + return create_intent(_passing_preflight(), "repopulse-adoption-2026") + + +def _target_gates() -> dict: + return { + "profileId": "repopulse-authenticated", + "profileVersion": PROFILE_VERSION, + "planSha256": _sha("c"), + "profileReady": True, + "controllerTrustReady": True, + "controllerBootstrapSecretImported": True, + "typedEnrollmentTrustReady": True, + "targetSecretReady": True, + "exactPolicyReady": True, + "authenticatedRouteReady": True, + "accessListReady": True, + "mutationBoundaryReady": True, + "managedRuntimeReady": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "ownedDataTargetReady": True, + "managedEnvironmentReady": True, + "legacyEnvironmentRetained": True, + "maintenanceAuthReady": True, + "controllerAdoptionCapabilityReady": True, + "controllerRegistryWriterReady": True, + "controllerSecretWriterReady": True, + } + + +def _quiescence(intent: dict) -> dict: + return { + "intentId": intent["intentId"], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "maintenanceModeActive": True, + "queueFrozen": True, + "queuedJobs": 0, + "activeJobs": 0, + "maintenanceModeAuthenticated": True, + "containerName": "repopulse", + "containerIdSha256": _sha("d"), + "containerImageSha256": _sha("1"), + "containerState": "exited", + "legacyVolume": LEGACY_VOLUME, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _snapshot(intent: dict) -> dict: + return { + "intentId": intent["intentId"], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyContainerStopped": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "sqliteBackupMethod": "sqlite_online_backup", + "quickCheck": "ok", + "integrityCheck": "ok", + "sourceLogicalSha256": _sha("e"), + "snapshotLogicalSha256": _sha("e"), + "snapshotFileSha256": _sha("f"), + "sourceScanCount": 572, + "snapshotScanCount": 572, + "sourceCompactedScanCount": 96, + "snapshotCompactedScanCount": 96, + "sourceQueueSha256": _sha("1"), + "snapshotQueueSha256": _sha("1"), + "sourceAuxiliaryTreeSha256": _sha("3"), + "sourceAuxiliaryFileCount": 42, + "targetDataPathWasAbsent": True, + "snapshotReceiptDurable": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _registration(intent: dict) -> dict: + gates = intent["evidence"]["targetGates"] + snapshot = intent["evidence"]["snapshot"] + return { + "receiptVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": intent["intentId"], + "receiptSha256": _sha("2"), + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": "enr-0123456789abcdefabcd", + "profileId": gates["profileId"], + "planSha256": gates["planSha256"], + "snapshotFileSha256": snapshot["snapshotFileSha256"], + "targetLogicalSha256": snapshot["snapshotLogicalSha256"], + "targetScanCount": snapshot["snapshotScanCount"], + "targetCompactedScanCount": snapshot["snapshotCompactedScanCount"], + "targetQueueSha256": snapshot["snapshotQueueSha256"], + "targetAuxiliaryTreeSha256": snapshot["sourceAuxiliaryTreeSha256"], + "targetAuxiliaryFileCount": snapshot["sourceAuxiliaryFileCount"], + "targetQueuedJobs": 0, + "targetActiveJobs": 0, + "targetQuickCheck": "ok", + "targetIntegrityCheck": "ok", + "targetRecoveryStatus": "healthy", + "targetDataUid": 10001, + "targetDataGid": 10001, + "managedDataMountMatches": True, + "copySourceIsVerifiedSnapshot": True, + "registryTargetMatches": True, + "controllerStateReceiptVerified": True, + "deploymentReceiptSchema": "gitea-deploy-receipt.v1", + "deploymentReceiptSha256": _sha("4"), + "deploymentReceiptVerified": True, + "runtimeHealthy": True, + "publishedHostPorts": [], + "globalDeployLockHeld": True, + "projectLockHeld": True, + "legacyVolume": LEGACY_VOLUME, + "legacyVolumeRetained": True, + "legacyEnvironmentRetained": True, + "managedEnvironmentFilePrivate": True, + "scanSourceMatches": True, + "scanMountReadOnly": True, + "scanPermissionPolicyPersistent": True, + "scanAccessForRuntimeIdentityReady": True, + "controllerIntentDurable": True, + "controllerIntentBindingSha256": intent_binding_sha256(intent), + } + + +def _to_snapshot_verified() -> dict: + intent = advance_intent(_intent(), "target_gates", _target_gates()) + intent = advance_intent(intent, "quiescence", _quiescence(intent)) + return advance_intent(intent, "snapshot", _snapshot(intent)) + + +def _to_target_registered() -> dict: + intent = _to_snapshot_verified() + return advance_intent(intent, "controller_registration", _registration(intent)) + + +def test_passing_legacy_preflight_can_reserve_but_never_authorizes_adoption() -> None: + intent = _intent() + + assert intent["repository"] == REPOSITORY + assert intent["applicationId"] == APPLICATION_ID + assert intent["legacyVolume"] == LEGACY_VOLUME + assert intent["phase"] == "reserved" + assert intent["status"] == "in_progress" + assert "adoptionAuthorized" not in intent + assert intent["evidence"]["preflight"]["queue"]["queued"] == 0 + + +def test_reservation_is_idempotent_and_binds_the_preflight_facts() -> None: + first = create_intent(_passing_preflight(), "repopulse-adoption-2026") + replay = reserve_or_replay(first, _passing_preflight(), "repopulse-adoption-2026") + changed = _passing_preflight() + changed["evidence"]["main_revision"] = "c" * 40 + + assert replay == first + with pytest.raises(AdoptionProtocolError, match="idempotency_conflict"): + reserve_or_replay(first, changed, "repopulse-adoption-2026") + with pytest.raises(AdoptionProtocolError, match="one_time_adoption_already_reserved"): + reserve_or_replay(first, _passing_preflight(), "repopulse-adoption-2027") + assert intent_binding_sha256(first) == intent_binding_sha256(replay) + + +def test_modified_persisted_intent_evidence_fails_integrity_check() -> None: + intent = _intent() + intent["evidence"]["preflight"]["repository"] = "jens/other" + + with pytest.raises(AdoptionProtocolError, match="intent_evidence_invalid"): + advance_intent(intent, "target_gates", _target_gates()) + + +@pytest.mark.parametrize( + "mutate,code", + [ + (lambda report: report.update(repository="jens/other"), "legacy_preflight_not_compatible"), + (lambda report: report.update(adoptionAuthorized=True), "legacy_preflight_not_compatible"), + (lambda report: report.update(status="blocked"), "legacy_preflight_not_compatible"), + (lambda report: report["evidence"]["queue"].update(active=1), "legacy_preflight_queue_not_quiescent"), + (lambda report: report["legacyCompatibility"]["checks"].pop(), "legacy_preflight_check_incomplete"), + ], +) +def test_invalid_or_tampered_preflight_cannot_reserve(mutate, code: str) -> None: + report = _passing_preflight() + mutate(report) + + with pytest.raises(AdoptionProtocolError) as error: + create_intent(report, "repopulse-adoption-2026") + assert error.value.code == code + + +def test_changed_or_unready_profile_trust_secret_acl_or_runtime_gate_blocks() -> None: + intent = _intent() + gates = _target_gates() + gates["accessListReady"] = False + + with pytest.raises(AdoptionProtocolError, match="target_gates_not_ready"): + advance_intent(intent, "target_gates", gates) + with pytest.raises(AdoptionProtocolError, match="adoption_step_out_of_order"): + advance_intent(intent, "quiescence", _quiescence(intent)) + + +@pytest.mark.parametrize( + ("field", "value"), + [("profileId", "internal-webapp"), ("profileVersion", "unreviewed/v9")], +) +def test_target_gates_require_the_exact_managed_profile(field: str, value: str) -> None: + gates = _target_gates() + gates[field] = value + + with pytest.raises(AdoptionProtocolError, match="target_gates_not_ready"): + advance_intent(_intent(), "target_gates", gates) + + +def test_quiescence_requires_locks_maintenance_drain_stop_and_the_named_volume() -> None: + intent = advance_intent(_intent(), "target_gates", _target_gates()) + evidence = _quiescence(intent) + evidence["globalDeployLockHeld"] = False + + with pytest.raises(AdoptionProtocolError, match="quiescence_not_proven"): + advance_intent(intent, "quiescence", evidence) + + evidence = _quiescence(intent) + evidence["activeJobs"] = 1 + with pytest.raises(AdoptionProtocolError, match="quiescence_not_proven"): + advance_intent(intent, "quiescence", evidence) + + evidence = _quiescence(intent) + evidence["controllerIntentBindingSha256"] = _sha("9") + with pytest.raises(AdoptionProtocolError, match="controller_intent_binding_mismatch"): + advance_intent(intent, "quiescence", evidence) + + evidence = _quiescence(intent) + evidence["containerIdSha256"] = _sha("9") + with pytest.raises(AdoptionProtocolError, match="quiescence_not_proven"): + advance_intent(intent, "quiescence", evidence) + + +def test_quiesced_snapshot_requires_sqlite_online_backup_and_matching_logical_data() -> None: + intent = advance_intent(_intent(), "target_gates", _target_gates()) + intent = advance_intent(intent, "quiescence", _quiescence(intent)) + evidence = _snapshot(intent) + evidence["sqliteBackupMethod"] = "raw_file_copy" + + with pytest.raises(AdoptionProtocolError, match="sqlite_snapshot_not_verified"): + advance_intent(intent, "snapshot", evidence) + + evidence = _snapshot(intent) + evidence["sourceLogicalSha256"] = _sha("a") + with pytest.raises(AdoptionProtocolError, match="sqlite_snapshot_not_verified"): + advance_intent(intent, "snapshot", evidence) + + +def test_controller_receipt_must_bind_exact_target_keep_legacy_volume_and_have_no_ports() -> None: + intent = _to_snapshot_verified() + evidence = _registration(intent) + evidence["publishedHostPorts"] = [{"ip": "0.0.0.0", "port": 1254}] + + with pytest.raises(AdoptionProtocolError, match="controller_adoption_receipt_not_verified"): + advance_intent(intent, "controller_registration", evidence) + + evidence = _registration(intent) + evidence["legacyVolumeRetained"] = False + with pytest.raises(AdoptionProtocolError, match="controller_adoption_receipt_not_verified"): + advance_intent(intent, "controller_registration", evidence) + + evidence = _registration(intent) + evidence["targetDataUid"] = 0 + with pytest.raises(AdoptionProtocolError, match="controller_adoption_receipt_not_verified"): + advance_intent(intent, "controller_registration", evidence) + + evidence = _registration(intent) + evidence["targetActiveJobs"] = 1 + with pytest.raises(AdoptionProtocolError, match="controller_adoption_receipt_not_verified"): + advance_intent(intent, "controller_registration", evidence) + + for field, invalid in ( + ("targetAuxiliaryTreeSha256", _sha("5")), + ("targetAuxiliaryFileCount", 41), + ("deploymentReceiptSchema", "unknown"), + ("deploymentReceiptSha256", "missing"), + ("deploymentReceiptVerified", False), + ): + evidence = _registration(intent) + evidence[field] = invalid + with pytest.raises(AdoptionProtocolError, match="controller_adoption_receipt_not_verified"): + advance_intent(intent, "controller_registration", evidence) + + +def test_exact_controller_and_appops_ownership_are_separate_checkpoints() -> None: + intent = _to_target_registered() + registration = intent["evidence"]["controllerRegistration"] + gates = intent["evidence"]["targetGates"] + evidence = { + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": registration["enrollmentId"], + "profileId": registration["profileId"], + "planSha256": gates["planSha256"], + "controllerReceiptSha256": registration["receiptSha256"], + "enrollmentPersisted": True, + "exactApplyPolicyPersisted": True, + "exactRollbackPolicyPersisted": True, + "secretReferenceReady": True, + } + appops_owned = advance_intent(intent, "appops_ownership", evidence) + complete = complete_intent( + appops_owned, + { + "intentId": intent["intentId"], + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "enrollmentId": registration["enrollmentId"], + "controllerReceiptSha256": registration["receiptSha256"], + "enrollmentStillPersisted": True, + "exactApplyPolicyStillPersisted": True, + "exactRollbackPolicyStillPersisted": True, + "controllerReceiptStillMatches": True, + "legacyVolumeRetained": True, + }, + ) + + assert complete["phase"] == "completed" + assert complete["status"] == "completed" + assert "appopsOwnership" in complete["evidence"] + assert "completion" in complete["evidence"] + + +def test_step_replay_is_idempotent_but_changed_evidence_is_a_conflict() -> None: + intent = advance_intent(_intent(), "target_gates", _target_gates()) + replay = advance_intent(intent, "target_gates", _target_gates()) + changed = _target_gates() + changed["planSha256"] = _sha("9") + + assert replay == intent + with pytest.raises(AdoptionProtocolError, match="idempotency_conflict"): + advance_intent(intent, "target_gates", changed) + + +def test_recovery_marker_keeps_last_good_phase_and_never_deletes_legacy_data() -> None: + intent = _to_snapshot_verified() + failed = mark_recovery_required( + intent, + "registry_receipt_uncertain", + {"rawError": "SECRET_OR_PATH_MUST_NOT_BE_STORED"}, + ) + + assert failed["status"] == "recovery_required" + assert failed["phase"] == "snapshot_verified" + assert failed["legacyVolumePreserved"] is True + assert "legacyVolumePreserved" not in failed["recovery"] or failed["recovery"]["legacyVolumePreserved"] is True + assert "SECRET_OR_PATH_MUST_NOT_BE_STORED" not in str(failed) + with pytest.raises(AdoptionProtocolError, match="intent_requires_recovery"): + advance_intent(failed, "controller_registration", _registration(intent)) + + +def test_recovery_reconciles_only_exact_controller_committed_registration() -> None: + intent = _to_snapshot_verified() + failed = mark_recovery_required(intent, "appops_commit_uncertain", {"timeout": True}) + registration = _registration(intent) + receipt = { + "apiVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": intent["intentId"], + "phase": "target_registered", + "receiptSha256": registration["receiptSha256"], + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "legacyVolumeRetained": True, + "intentDurable": True, + "intentBindingSha256": intent_binding_sha256(intent), + "registrationEvidence": registration, + } + + resumed = resume_intent(failed, receipt) + + assert resumed["status"] == "in_progress" + assert resumed["phase"] == "target_registered" + assert resumed["evidence"]["controllerRegistration"]["receiptSha256"] == registration["receiptSha256"] + + +def test_recovery_replays_registered_target_without_repeating_cutover() -> None: + intent = _to_target_registered() + failed = mark_recovery_required(intent, "appops_commit_uncertain", {"commit": "timeout"}) + registration = intent["evidence"]["controllerRegistration"] + receipt = { + "apiVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": intent["intentId"], + "phase": "target_registered", + "receiptSha256": registration["receiptSha256"], + "repository": REPOSITORY, + "applicationId": APPLICATION_ID, + "legacyVolumeRetained": True, + "intentDurable": True, + "intentBindingSha256": intent_binding_sha256(intent), + "registrationEvidence": registration, + } + + resumed = resume_intent(failed, receipt) + + assert resumed["phase"] == "target_registered" + assert resumed["status"] == "in_progress" + assert resumed["receipts"]["controller_registration"] == intent["receipts"]["controller_registration"] + + +def test_recovery_receipt_for_another_repository_or_intent_is_rejected() -> None: + intent = mark_recovery_required(_to_snapshot_verified(), "registry_receipt_uncertain", {}) + receipt = { + "apiVersion": CONTROLLER_RECEIPT_VERSION, + "intentId": "rpa-00000000000000000000", + "phase": "target_registered", + "receiptSha256": _sha("2"), + "repository": "jens/other", + "applicationId": APPLICATION_ID, + "legacyVolumeRetained": True, + "intentDurable": True, + "intentBindingSha256": intent_binding_sha256(intent), + "registrationEvidence": {}, + } + + with pytest.raises(AdoptionProtocolError, match="controller_recovery_receipt_unbound"): + resume_intent(intent, receipt) + + +def test_protocol_activation_reports_enabled_without_claiming_readiness() -> None: + assert EXECUTION_ENABLED is True + assert execution_status() == { + "apiVersion": "itworx.repopulse-legacy-adoption/v1", + "repository": REPOSITORY, + "status": "enabled", + "reason": None, + "executionEnabled": True, + } diff --git a/backend/tests/test_repopulse_maintenance_environment.py b/backend/tests/test_repopulse_maintenance_environment.py new file mode 100644 index 0000000..9b666e1 --- /dev/null +++ b/backend/tests/test_repopulse_maintenance_environment.py @@ -0,0 +1,75 @@ +"""Verify that the legacy maintenance stager preserves existing environment bytes.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import os +import sys +from pathlib import Path + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[2] + / "ops" + / "gitea-deploy-broker" + / "stage_repopulse_maintenance_environment.py" +) + + +def _load_stage(): + sys.path.insert(0, str(SCRIPT.parent)) + try: + spec = importlib.util.spec_from_file_location("repopulse_maintenance_stage_test", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + finally: + sys.path.pop(0) + + +@pytest.mark.skipif(os.name != "posix" or os.geteuid() != 0, reason="root-owned Linux files required") +def test_mixed_legacy_line_endings_are_backed_up_byte_for_byte_and_staged_idempotently( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + stage = _load_stage() + legacy_env = tmp_path / "deploy.env" + managed_env = tmp_path / "deploy.managed.env" + backup_root = tmp_path / "environment-backup" + original = ( + b"REPOPULSE_GITEA_URL=http://198.18.10.150:3000\n" + b"REPOPULSE_GITEA_TOKEN=validation-only-token-1234567890\n" + b"REPOPULSE_MAX_CONCURRENT_SCANS=1\r\n" + ) + legacy_env.write_bytes(original) + legacy_env.chmod(0o600) + monkeypatch.setattr(stage, "LEGACY_ENV", legacy_env) + monkeypatch.setattr(stage, "MANAGED_ENV", managed_env) + monkeypatch.setattr(stage, "BACKUP_ROOT", backup_root) + monkeypatch.setattr(stage, "BACKUP_FILE", backup_root / "legacy-deploy.env") + monkeypatch.setattr(stage, "BACKUP_SHA_FILE", backup_root / "legacy-deploy.env.sha256") + + first = stage.prepare() + assert first == {"status": "prepared", "legacyBackupSha256": hashlib.sha256(original).hexdigest()} + assert stage.BACKUP_FILE.read_bytes() == original + assert stage.BACKUP_SHA_FILE.read_text() == hashlib.sha256(original).hexdigest() + "\n" + staged_legacy = legacy_env.read_bytes() + assert staged_legacy.startswith(original) + assert staged_legacy[len(original) :].endswith(b"\r\n") + legacy_values = stage._parse_environment(staged_legacy) + managed_values = stage._parse_environment(managed_env.read_bytes()) + assert legacy_values["REPOPULSE_GITEA_URL"] == "http://198.18.10.150:3000" + assert managed_values["REPOPULSE_GITEA_URL"] == "https://gitea.example.com" + assert managed_values["REPOPULSE_MAINTENANCE_TOKEN"] == legacy_values["REPOPULSE_MAINTENANCE_TOKEN"] + + assert stage.prepare() == first + assert legacy_env.read_bytes() == staged_legacy + assert stage.BACKUP_FILE.read_bytes() == original + + +def test_bare_carriage_return_is_rejected() -> None: + stage = _load_stage() + with pytest.raises(ValueError): + stage._parse_environment(b"REPOPULSE_GITEA_URL=https://gitea.example.com\rREPOPULSE_GITEA_TOKEN=x\n") diff --git a/backend/tests/test_repopulse_proxy_stage.py b/backend/tests/test_repopulse_proxy_stage.py new file mode 100644 index 0000000..5e3053f --- /dev/null +++ b/backend/tests/test_repopulse_proxy_stage.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from backend.app.core.config import settings +from backend.app.services import repopulse_proxy_stage as stage + + +class _Npm: + def __init__(self, *, valid_acl: bool = True) -> None: + self.valid_acl = valid_acl + self.calls: list[dict] = [] + + def is_configured(self) -> bool: + return True + + def validate_managed_access_list(self, access_list_id: int) -> dict: + assert access_list_id == 1 + return { + "success": self.valid_acl, + "status": "verified" if self.valid_acl else "blocked", + "accessListId": access_list_id, + } + + def ensure_managed_proxy_host(self, **kwargs: object) -> dict: + self.calls.append(kwargs) + return { + "success": True, + "status": "created", + "hostname": "repopulse.example.com", + "hostId": 7, + } + + +def _ready(monkeypatch: pytest.MonkeyPatch) -> None: + intent_id = "rpa-" + "a" * 20 + monkeypatch.setattr(stage.journal, "_load", lambda _session: (object(), {"intentId": intent_id, "status": "in_progress"})) + monkeypatch.setattr( + stage.EnrollmentPlane, + "get_trust_config", + lambda _session: SimpleNamespace( + controller_identity="unraid-controller", + repopulse_proxy_access_list_id=1, + autonomous_profiles=("repopulse-authenticated",), + approved_networks=("itworx-mcp-hub-npm",), + allowed_domain_suffixes=("example.com",), + ), + ) + monkeypatch.setattr(stage, "apply_stored_connector_settings", lambda *_args: None) + monkeypatch.setitem(settings.apis, "NPM_IDENTITY", "operator@example.org") + + +def test_stages_only_reserved_fixed_authenticated_host(monkeypatch: pytest.MonkeyPatch) -> None: + _ready(monkeypatch) + npm = _Npm() + result = stage.stage_repopulse_proxy_host(object(), connector=npm) + assert result == { + "status": "ready", + "intentId": "rpa-" + "a" * 20, + "hostname": "repopulse.example.com", + "accessListId": 1, + "hostId": 7, + } + assert npm.calls == [ + { + "hostname": "repopulse.example.com", + "forward_host": "repopulse", + "forward_port": 8000, + "certificate_mode": "managed", + "force_ssl": True, + "http2": True, + "upload_limit_mb": 128, + "read_timeout_seconds": 120, + "ownership_id": "enr-" + "a" * 20, + "access_list_id": 1, + } + ] + + +def test_rejects_unverified_access_list_before_host_mutation(monkeypatch: pytest.MonkeyPatch) -> None: + _ready(monkeypatch) + npm = _Npm(valid_acl=False) + with pytest.raises(stage.RepoPulseProxyStageError, match="repopulse_access_list_unavailable"): + stage.stage_repopulse_proxy_host(object(), connector=npm) + assert npm.calls == [] + + +def test_rejects_unreserved_intent_before_connector_mutation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(stage.journal, "_load", lambda _session: (None, None)) + npm = _Npm() + with pytest.raises(stage.RepoPulseProxyStageError, match="adoption_intent_unavailable"): + stage.stage_repopulse_proxy_host(object(), connector=npm) + assert npm.calls == [] diff --git a/backend/tests/test_repopulse_secret_import.py b/backend/tests/test_repopulse_secret_import.py new file mode 100644 index 0000000..8c8f4de --- /dev/null +++ b/backend/tests/test_repopulse_secret_import.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.db.models import Secret +from backend.app.services import repopulse_secret_import as importer +from backend.app.services.secret_store import get_secret + + +@pytest.fixture +def session(tmp_path, monkeypatch): + engine = create_engine(f"sqlite:///{tmp_path / 'secrets.db'}") + SQLModel.metadata.create_all(engine) + monkeypatch.setattr(importer, "secret_storage_status", lambda: {"productionReady": True}) + with Session(engine) as value: + yield value + + +def test_import_is_write_only_and_idempotent(session): + assert importer.import_repopulse_gitea_token(session, "test-token") == "imported" + assert importer.import_repopulse_gitea_token(session, "test-token") == "already_present" + assert get_secret(session, importer.SECRET_REF, touch=False) == "test-token" + + +def test_import_rejects_replacement_and_malformed_input(session): + importer.import_repopulse_gitea_token(session, "first") + with pytest.raises(ValueError, match="different value"): + importer.import_repopulse_gitea_token(session, "second") + for value in ("", " leading", "trailing ", "line\nbreak", "x" * 8193): + with pytest.raises(ValueError): + importer.import_repopulse_gitea_token(session, value) + assert get_secret(session, importer.SECRET_REF, touch=False) == "first" + + +def test_import_requires_production_secret_storage(session, monkeypatch): + monkeypatch.setattr(importer, "secret_storage_status", lambda: {"productionReady": False}) + with pytest.raises(ValueError, match="not ready"): + importer.import_repopulse_gitea_token(session, "test-token") + assert get_secret(session, importer.SECRET_REF, touch=False) is None + + +def test_import_does_not_overwrite_an_undecryptable_secret(session): + importer.import_repopulse_gitea_token(session, "original") + row = session.get(Secret, importer.SECRET_REF) + assert row is not None + row.encrypted_value = "corrupted-ciphertext" + session.add(row) + session.commit() + with pytest.raises(ValueError, match="cannot be decrypted"): + importer.import_repopulse_gitea_token(session, "replacement") + assert session.get(Secret, importer.SECRET_REF).encrypted_value == "corrupted-ciphertext" + + +def test_controller_bootstrap_import_uses_fixed_distinct_reference(session): + assert importer.import_controller_bootstrap(session, "bootstrap-test-token") == "imported" + assert importer.import_controller_bootstrap(session, "bootstrap-test-token") == "already_present" + assert get_secret(session, importer.BOOTSTRAP_SECRET_REF, touch=False) == "bootstrap-test-token" + assert get_secret(session, importer.SECRET_REF, touch=False) is None + with pytest.raises(ValueError, match="different value"): + importer.import_controller_bootstrap(session, "other-token") diff --git a/backend/tests/test_repopulse_secret_import_host.py b/backend/tests/test_repopulse_secret_import_host.py new file mode 100644 index 0000000..c896d59 --- /dev/null +++ b/backend/tests/test_repopulse_secret_import_host.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "import_repopulse_gitea_token.py" +SPEC = importlib.util.spec_from_file_location("repopulse_import_host", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +host_import = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(host_import) + + +def test_reads_only_fixed_token_key(tmp_path, monkeypatch): + path = tmp_path / "deploy.env" + path.write_text("REPOPULSE_GITEA_URL=https://example.invalid\nREPOPULSE_GITEA_TOKEN=test-value\n") + path.chmod(0o600) + monkeypatch.setattr(host_import.os, "geteuid", lambda: 0, raising=False) + assert host_import.read_legacy_token(path) == "test-value" + path.write_text("REPOPULSE_GITEA_TOKEN=one\nREPOPULSE_GITEA_TOKEN=two\n") + with pytest.raises(ValueError, match="duplicated"): + host_import.read_legacy_token(path) + + +def test_import_passes_token_only_on_stdin(monkeypatch): + observed = {} + + def fake_run(argv, *, input, capture_output, check, timeout): + observed.update({"argv": argv, "input": input, "capture_output": capture_output}) + + class Result: + returncode = 0 + stdout = json.dumps({"status": "imported", "secretRef": "REPOPULSE_GITEA_TOKEN"}).encode() + + return Result() + + monkeypatch.setattr(host_import.subprocess, "run", fake_run) + assert host_import.import_token("sensitive-test-value") == "imported" + assert observed["input"] == b"sensitive-test-value" + assert "sensitive-test-value" not in " ".join(observed["argv"]) + assert observed["capture_output"] is True diff --git a/backend/tests/test_repopulse_target_attestation.py b/backend/tests/test_repopulse_target_attestation.py new file mode 100644 index 0000000..d00a010 --- /dev/null +++ b/backend/tests/test_repopulse_target_attestation.py @@ -0,0 +1,308 @@ +"""Cryptographic and intent-binding checks for the fixed target attestation.""" + +from __future__ import annotations + +import hashlib +import hmac +import importlib +import json +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def _module(monkeypatch): + root = Path(__file__).resolve().parents[2] / "ops" / "gitea-deploy-broker" + monkeypatch.syspath_prepend(str(root)) + return importlib.import_module("repopulse_target_attestation") + + +def _sample(now: datetime) -> dict: + return { + "schema": "itworx.repopulse-target-attestation/v1", + "attestation_id": "rpa-target-0123456789abcdef0123", + "intent_id": "rpa-0123456789abcdef0123", + "nonce": "1" * 32, + "issued_at": now.isoformat(), + "expires_at": (now + timedelta(minutes=2)).isoformat(), + "controller_identity": "unraid-controller", + "controller_trust_profile_id": "repopulse-authenticated", + "controller_trust_profile_version": "enrollment-profile/v1", + "controller_bootstrap_key_ref": "ENROLLMENT_BOOTSTRAP", + "controller_tls_certificate_sha256": "a" * 64, + "repository": "jens/repopulse", + "application_id": "repopulse", + "profile_id": "repopulse-authenticated", + "profile_version": "enrollment-profile/v1", + "plan_sha256": "b" * 64, + "target_secret_ref": "jens__repopulse", + "npm_hostname": "repopulse.example.com", + "npm_access_list_route_hostname": "repopulse.example.com", + "npm_upstream_host": "repopulse", + "npm_upstream_network": "itworx-mcp-hub-npm", + "npm_upstream_port": 8000, + "npm_certificate_sha256": "c" * 64, + "npm_certificate_sans": ["repopulse.example.com"], + "npm_certificate_not_before": (now - timedelta(days=1)).isoformat(), + "npm_certificate_not_after": (now + timedelta(days=1)).isoformat(), + "npm_access_list_id": 1, + "npm_access_list_satisfy_any": 0, + "npm_access_list_is_deleted": 0, + "npm_access_list_pass_auth": 1, + "npm_access_list_client_count": 0, + "npm_access_list_rule_count": 1, + "npm_access_list_rules_sha256": "d" * 64, + "npm_access_list_rules": [ + { + "action": "authenticate", + "subject_type": "basic_user_sha256", + "subject": "e" * 64, + } + ], + "maintenance_method": "GET", + "maintenance_path": "/api/v1/operator/maintenance", + "maintenance_status_code": 200, + "maintenance_auth_mechanism": "operator_token", + "maintenance_authenticated_principal": "root_operator", + "maintenance_response_sha256": "f" * 64, + "maintenance_observed_at": now.isoformat(), + } + + +def test_signed_target_attestation_is_bound_to_one_intent_and_nonce( + monkeypatch, tmp_path +): + module = _module(monkeypatch) + secret = b"fixed-test-bootstrap-secret-value-0123456789" + claim_path = tmp_path / "target-attestation.json" + bootstrap_path = tmp_path / "bootstrap" + bootstrap_path.write_bytes(secret + b"\n") + monkeypatch.setattr(module, "ATTESTATION_PATH", claim_path) + monkeypatch.setattr(module, "BOOTSTRAP_PATH", bootstrap_path) + monkeypatch.setattr( + module, "_private_file", lambda path, maximum: path.read_bytes() + ) + consumed: dict[str, tuple[str, str]] = {} + + def consume(nonce, digest, intent): + value = (digest, intent) + if nonce in consumed and consumed[nonce] != value: + raise ValueError("nonce_replayed") + consumed[nonce] = value + + monkeypatch.setattr(module, "_record_nonce", consume) + now = datetime.now(timezone.utc) + claims = _sample(now) + + def stage(value): + raw = module._canonical(value) + claim_path.write_text( + json.dumps( + { + "schema": module.ENVELOPE_SCHEMA, + "claims": value, + "signatureSha256": hmac.new( + secret, module._SIGNING_DOMAIN + raw, hashlib.sha256 + ).hexdigest(), + } + ), + encoding="utf-8", + ) + + expected = { + "repository": "jens/repopulse", + "applicationId": "repopulse", + "profileId": "repopulse-authenticated", + "profileVersion": "enrollment-profile/v1", + "planSha256": "b" * 64, + "intentId": claims["intent_id"], + "targetSecretRef": "jens__repopulse", + "controllerTlsCertificateSha256": "a" * 64, + } + verifier = module.FixedTargetAttestationVerifier() + stage(claims) + result = verifier.verify_target_attestation(expected=expected) + assert result is not None and result.intent_id == claims["intent_id"] + assert verifier.verify_target_attestation(expected=expected) == result + + tampered = dict(claims, plan_sha256="0" * 64) + claim_path.write_text( + json.dumps( + { + "schema": module.ENVELOPE_SCHEMA, + "claims": tampered, + "signatureSha256": hmac.new( + secret, + module._SIGNING_DOMAIN + module._canonical(claims), + hashlib.sha256, + ).hexdigest(), + } + ), + encoding="utf-8", + ) + assert verifier.verify_target_attestation(expected=expected) is None + + expired = dict( + claims, + issued_at=(now - timedelta(minutes=9)).isoformat(), + expires_at=(now - timedelta(minutes=8)).isoformat(), + ) + stage(expired) + assert verifier.verify_target_attestation(expected=expected) is None + + changed_intent = dict(claims, intent_id="rpa-ffffffffffffffffffff") + stage(changed_intent) + assert verifier.verify_target_attestation(expected=expected) is None + + reused_nonce = dict(claims, attestation_id="rpa-target-elsewhere") + stage(reused_nonce) + assert verifier.verify_target_attestation(expected=expected) is None + + +def test_npm_producer_requires_the_fixed_authenticated_route(monkeypatch, tmp_path): + _module(monkeypatch) + producer = importlib.import_module("stage_repopulse_target_attestation") + database_path = tmp_path / "npm.sqlite" + with sqlite3.connect(database_path) as database: + database.executescript(""" + CREATE TABLE proxy_host (id INTEGER, domain_names TEXT, forward_host TEXT, + forward_port INTEGER, access_list_id INTEGER, certificate_id INTEGER, + ssl_forced INTEGER, enabled INTEGER, forward_scheme TEXT, is_deleted INTEGER); + CREATE TABLE certificate (id INTEGER, domain_names TEXT, is_deleted INTEGER); + CREATE TABLE access_list (id INTEGER, is_deleted INTEGER, satisfy_any INTEGER, + pass_auth INTEGER); + CREATE TABLE access_list_auth (id INTEGER, access_list_id INTEGER, username TEXT); + CREATE TABLE access_list_client (id INTEGER, access_list_id INTEGER, + address TEXT, directive TEXT); + INSERT INTO proxy_host VALUES (7, '["repopulse.example.com"]', 'repopulse', + 8000, 1, 9, 1, 1, 'http', 0); + INSERT INTO certificate VALUES (9, '["repopulse.example.com"]', 0); + INSERT INTO access_list VALUES (1, 0, 0, 1); + INSERT INTO access_list_auth VALUES (3, 1, 'private-user'); + """) + monkeypatch.setattr(producer, "NPM_DATABASE", database_path) + rules, cert_id = producer._npm_host_and_rules(1) + assert cert_id == 9 + assert rules == [ + { + "action": "authenticate", + "subjectType": "basic_user_sha256", + "subject": hashlib.sha256(b"private-user").hexdigest(), + } + ] + with sqlite3.connect(database_path) as database: + database.execute("UPDATE access_list SET satisfy_any=1 WHERE id=1") + try: + producer._npm_host_and_rules(1) + except ValueError: + pass + else: + raise AssertionError("permissive access list must fail closed") + + +def test_maintenance_proof_timestamp_is_captured_with_the_probe(monkeypatch): + _module(monkeypatch) + producer = importlib.import_module("stage_repopulse_target_attestation") + monkeypatch.setattr( + producer, + "_private_bytes", + lambda *_args, **_kwargs: ( + b"REPOPULSE_GITEA_URL=https://gitea.example.com\n" + b"REPOPULSE_GITEA_TOKEN=token-value\n" + b"REPOPULSE_MAINTENANCE_TOKEN=maintenance-token-value-123456789\n" + ), + ) + monkeypatch.setattr( + producer, + "_docker_python", + lambda *_args, **_kwargs: {"status": 200, "responseSha256": "a" * 64}, + ) + before = datetime.now(timezone.utc) + mechanism, digest, observed = producer._maintenance() + after = datetime.now(timezone.utc) + assert mechanism == "operator_token" + assert digest == "a" * 64 + assert before <= datetime.fromisoformat(observed) <= after + + +def test_producer_main_builds_verifier_shape_with_proof_before_issue( + monkeypatch, capsys +): + verifier = _module(monkeypatch) + producer = importlib.import_module("stage_repopulse_target_attestation") + intent_id = "rpa-" + "a" * 20 + now = datetime.now(timezone.utc) + observed = now.isoformat() + captured: dict = {} + + monkeypatch.setattr(producer.os, "geteuid", lambda: 0, raising=False) + monkeypatch.setattr(producer.sys, "argv", ["stage_repopulse_target_attestation.py"]) + monkeypatch.setattr( + producer, + "_private_bytes", + lambda path, _maximum: ( + b"x" * 64 + if path == verifier.BOOTSTRAP_PATH + else b'{"certificate_file":"/fixed/server.crt"}' + ), + ) + monkeypatch.setattr( + producer.store, + "RepoPulseAdoptionStore", + lambda: type( + "Store", + (), + { + "load_intent": lambda self: { + "status": "in_progress", + "intentId": intent_id, + } + }, + )(), + ) + monkeypatch.setattr( + producer, "_appops_trust", lambda _secret: {"repopulseProxyAccessListId": 1} + ) + monkeypatch.setattr( + producer, + "_npm_host_and_rules", + lambda _acl: ( + [ + { + "action": "authenticate", + "subjectType": "basic_user_sha256", + "subject": "c" * 64, + } + ], + 9, + ), + ) + monkeypatch.setattr(producer, "_npm_network", lambda: None) + monkeypatch.setattr( + producer, + "_npm_tls", + lambda: ( + "d" * 64, + ("repopulse.example.com",), + (now - timedelta(days=1)).isoformat(), + (now + timedelta(days=1)).isoformat(), + ), + ) + monkeypatch.setattr( + producer, "_maintenance", lambda: ("operator_token", "e" * 64, observed) + ) + monkeypatch.setattr(producer, "_certificate_bytes", lambda _path: b"pem") + monkeypatch.setattr(producer.ssl, "PEM_cert_to_DER_cert", lambda _pem: b"der") + monkeypatch.setattr(producer, "_plan", lambda _intent: "b" * 64) + + def capture(claims, _secret): + captured.update(claims) + return "f" * 64 + + monkeypatch.setattr(producer, "_stage", capture) + assert producer.main() == 0 + assert '"status":"staged"' in capsys.readouterr().out + assert verifier._claims(captured).intent_id == intent_id + assert datetime.fromisoformat( + captured["maintenance_observed_at"] + ) <= datetime.fromisoformat(captured["issued_at"]) diff --git a/backend/tests/test_repopulse_target_credential.py b/backend/tests/test_repopulse_target_credential.py new file mode 100644 index 0000000..a8fb39a --- /dev/null +++ b/backend/tests/test_repopulse_target_credential.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest +from sqlmodel import Session, SQLModel, create_engine + +from backend.app.db.models import Secret +from backend.app.services import repopulse_target_credential as target + + +def test_fixed_target_credential_is_durable_and_collision_safe(tmp_path, monkeypatch): + monkeypatch.setattr( + target.secret_store, + "secret_storage_status", + lambda: {"productionReady": True}, + ) + engine = create_engine(f"sqlite:///{tmp_path / 'credentials.db'}") + SQLModel.metadata.create_all(engine) + first_intent = "rpa-" + "a" * 20 + + with Session(engine) as session: + first_id, first_secret = target.get_or_create_repopulse_target_credential( + session, intent_id=first_intent + ) + row = session.get(Secret, "ENROLL_REPOPULSE_TARGET_HMAC") + assert row is not None + assert first_secret.decode("ascii") not in row.encrypted_value + + with Session(engine) as session: + replay_id, replay_secret = target.get_or_create_repopulse_target_credential( + session, intent_id=first_intent + ) + assert (replay_id, replay_secret) == (first_id, first_secret) + with pytest.raises( + target.RepoPulseTargetCredentialError, match="target_credential_collision" + ): + target.get_or_create_repopulse_target_credential( + session, intent_id="rpa-" + "b" * 20 + ) + + with Session(engine) as session: + row = session.get(Secret, "ENROLL_REPOPULSE_TARGET_HMAC") + assert row is not None + row.metadata_json = {} + session.add(row) + session.commit() + with pytest.raises( + target.RepoPulseTargetCredentialError, match="target_credential_collision" + ): + target.get_or_create_repopulse_target_credential( + session, intent_id=first_intent + ) + + +def test_fixed_target_credential_requires_production_secret_store( + tmp_path, monkeypatch +): + monkeypatch.setattr( + target.secret_store, + "secret_storage_status", + lambda: {"productionReady": False}, + ) + engine = create_engine(f"sqlite:///{tmp_path / 'credentials.db'}") + SQLModel.metadata.create_all(engine) + with Session(engine) as session: + with pytest.raises( + target.RepoPulseTargetCredentialError, match="secret_store_not_ready" + ): + target.get_or_create_repopulse_target_credential( + session, intent_id="rpa-" + "a" * 20 + ) + assert session.get(Secret, "ENROLL_REPOPULSE_TARGET_HMAC") is None diff --git a/backend/tests/test_repopulse_trust_stage.py b/backend/tests/test_repopulse_trust_stage.py new file mode 100644 index 0000000..f960a05 --- /dev/null +++ b/backend/tests/test_repopulse_trust_stage.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from backend.app.services import repopulse_trust_stage as stage + + +class _Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit: int) -> bytes: + return b'{"status":"ok"}' + + +class _Opener: + def open(self, url: str, *, timeout: int): + assert url == "https://203.0.113.1:1300/health" + assert timeout == 8 + return _Response() + + +def _ready(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + stage, "secret_storage_status", lambda: {"productionReady": True} + ) + monkeypatch.setattr(stage, "get_secret", lambda *_args, **_kwargs: "x" * 64) + monkeypatch.setattr(stage, "has_secret", lambda *_args: True) + monkeypatch.setattr(stage.ssl, "create_default_context", lambda *, cafile: object()) + monkeypatch.setattr(stage.urllib.request, "build_opener", lambda *_args: _Opener()) + + +def test_stages_exact_trust_once_after_tls_and_secret_checks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _ready(monkeypatch) + state: dict = {} + monkeypatch.setattr( + stage.EnrollmentPlane, "get_trust_config", lambda _session: state.get("trust") + ) + + def save(_session, trust): + state["trust"] = trust + return {"status": "ready", "bootstrapSecretConfigured": True} + + monkeypatch.setattr(stage.EnrollmentPlane, "set_trust_config", save) + broker = SimpleNamespace( + base_url="https://203.0.113.1:1300", + verify_tls=True, + ca_file="/data/controller-ca.pem", + configuration_error=lambda: None, + ) + assert stage.stage(object(), broker=broker) == { + "status": "configured", + "controllerIdentity": "unraid-controller", + } + assert state["trust"].repopulse_proxy_access_list_id == 1 + assert stage.stage(object(), broker=broker)["status"] == "already_configured" + + +def test_rejects_wrong_broker_before_trust_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _ready(monkeypatch) + monkeypatch.setattr( + stage.EnrollmentPlane, + "set_trust_config", + lambda *_args: pytest.fail("trust must not be written"), + ) + broker = SimpleNamespace( + base_url="http://127.0.0.1:1299", + verify_tls=False, + ca_file=None, + configuration_error=lambda: None, + ) + with pytest.raises(ValueError, match="repopulse_trust_not_ready"): + stage.stage(object(), broker=broker) diff --git a/backend/tests/test_request_metrics.py b/backend/tests/test_request_metrics.py new file mode 100644 index 0000000..13fcd7f --- /dev/null +++ b/backend/tests/test_request_metrics.py @@ -0,0 +1,13 @@ +from backend.app.services.request_metrics import _percentile + + +def test_nearest_rank_p95_uses_the_maximum_for_small_samples(): + assert _percentile([10.0], 0.95) == 10.0 + assert _percentile([10.0, 40.0], 0.95) == 40.0 + assert _percentile([10.0, 20.0, 40.0], 0.95) == 40.0 + + +def test_nearest_rank_percentile_is_bounded(): + assert _percentile([1.0, 2.0, 3.0], 0.0) == 1.0 + assert _percentile([1.0, 2.0, 3.0], 2.0) == 3.0 + assert _percentile([], 0.95) == 0.0 diff --git a/backend/tests/test_run_repopulse_adoption.py b/backend/tests/test_run_repopulse_adoption.py new file mode 100644 index 0000000..77de6ef --- /dev/null +++ b/backend/tests/test_run_repopulse_adoption.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "run_repopulse_adoption.py" +_SPEC = importlib.util.spec_from_file_location("run_repopulse_adoption", _SCRIPT) +assert _SPEC is not None and _SPEC.loader is not None +handoff = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(handoff) + + +def test_fixed_operator_sequence_keeps_credential_out_of_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(handoff, "_require_root", lambda: None) + intent_id = "rpa-" + "a" * 20 + calls: list[str] = [] + secret = "operator-secret-value" * 4 + + def appops( + *arguments: str, payload: bytes | None = None, **_kwargs: object + ) -> dict: + if "prepare" in arguments: + calls.append("prepare") + return {"status": "prepared", "intentId": intent_id} + if "backend.app.services.repopulse_proxy_stage" in arguments: + calls.append("proxy") + return {"status": "ready", "intentId": intent_id} + if "-c" in arguments: + calls.append("handoff") + return { + "intentId": intent_id, + "idempotencySha256": "b" * 64, + "preflightSha256": "c" * 64, + "preflightProjection": {}, + "targetCredential": secret, + } + assert "finalize" in arguments + assert payload is not None and secret.encode() not in payload + calls.append("finalize") + return {"status": "completed", "intentId": intent_id} + + def controller( + command: list[str], *, payload: bytes | None = None, **_kwargs: object + ) -> dict: + if command[-1].endswith("stage_repopulse_target_attestation.py"): + calls.append("attestation") + return {"status": "staged", "intentId": intent_id} + assert command[-1].endswith("repopulse_adoption_cli.py") + assert json.loads(payload or b"{}")["targetCredential"] == secret + calls.append("controller") + return {"status": "deployment_verified", "intentId": intent_id} + + monkeypatch.setattr(handoff, "_appops", appops) + monkeypatch.setattr(handoff, "_run", controller) + result = handoff.run() + assert result == {"status": "completed", "intentId": intent_id} + assert secret not in json.dumps(result) + assert calls == [ + "prepare", + "proxy", + "attestation", + "handoff", + "controller", + "finalize", + ] + + +def test_proxy_failure_prevents_secret_handoff(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(handoff, "_require_root", lambda: None) + calls: list[str] = [] + + def appops(*arguments: str, **_kwargs: object) -> dict: + calls.append(arguments[-1]) + if "prepare" in arguments: + return {"status": "prepared", "intentId": "rpa-" + "a" * 20} + return {"status": "blocked"} + + monkeypatch.setattr(handoff, "_appops", appops) + monkeypatch.setattr( + handoff, + "_run", + lambda *_args, **_kwargs: pytest.fail("controller must not execute"), + ) + with pytest.raises(handoff.AdoptionHandoffError, match="operator_step_blocked"): + handoff.run() + assert len(calls) == 2 + + +def test_operator_subprocess_uses_a_fixed_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PYTHONPATH", "/untrusted/modules") + observed: dict = {} + + def invoke(command, **kwargs): + observed.update(kwargs) + assert command == ["python3", "fixed.py"] + return SimpleNamespace(returncode=0, stdout=b'{"status":"ready"}', stderr=b"") + + monkeypatch.setattr(handoff.subprocess, "run", invoke) + assert handoff._run(["python3", "fixed.py"]) == {"status": "ready"} + assert "PYTHONPATH" not in observed["env"] + assert observed["env"]["PATH"].startswith("/usr/local/sbin:") + + +def test_operator_rejects_ambiguous_child_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + handoff.subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=0, + stdout=b'{"status":"ready","status":"blocked"}', + stderr=b"", + ), + ) + with pytest.raises(handoff.AdoptionHandoffError, match="operator_result_invalid"): + handoff._run(["python3", "fixed.py"]) diff --git a/backend/tests/test_runner_3_5_upgrade_contract.py b/backend/tests/test_runner_3_5_upgrade_contract.py new file mode 100644 index 0000000..40ffadf --- /dev/null +++ b/backend/tests/test_runner_3_5_upgrade_contract.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +IN_PLACE = ROOT / "scripts" / "upgrade_gitea_runner_3_5.sh" +FLEET_BOOTSTRAP = ROOT / "scripts" / "upgrade_ci_runner_fleet.sh" + +OLD_IMAGE = ( + "gitea/runner@sha256:" + "3ebc3ad9654552377896e1edbc77ac420025bb01ff1736bc43232d0bf2866f5a" +) +NEW_IMAGE = ( + "gitea/runner:3.5.0@sha256:" + "66b7da94dc7dcadb2e076bec6928221336a9a637196399281c4b766fe1288242" +) + + +def test_runner_3_5_upgrade_script_is_syntactically_valid() -> None: + for script in (IN_PLACE, FLEET_BOOTSTRAP): + subprocess.run( + ["bash", "-n"], + input=script.read_bytes().replace(b"\r\n", b"\n"), + check=True, + ) + + +def test_future_fleet_bootstrap_uses_reviewed_runner_3_5_pin() -> None: + text = FLEET_BOOTSTRAP.read_text(encoding="utf-8") + assert NEW_IMAGE in text + assert OLD_IMAGE not in text + assert "gitea/runner:latest" not in text + assert "# v3.5.0" in text + + +def test_in_place_upgrade_is_fixed_target_and_fail_closed() -> None: + text = IN_PLACE.read_text(encoding="utf-8") + + assert 'readonly PRIMARY="itworx-security-runner"' in text + assert 'readonly SECONDARY="itworx-validation-runner-2"' in text + assert 'readonly DIND="gitea-runner-dind"' in text + assert OLD_IMAGE in text + assert NEW_IMAGE in text + assert 'readonly CONFIRMATION="UPGRADE_GITEA_RUNNER_3_5"' in text + + assert 'active DinD workload prevents runner upgrade' in text + assert 'grep -F -c "$OLD_IMAGE"' in text + assert 'runner image baseline is not the reviewed 2.0.0 pin' in text + assert 'docker pull "$NEW_IMAGE"' in text + assert 'gitea-runner "$NEW_IMAGE" --version' in text + + assert 'set_runner_disabled "$PRIMARY" true' in text + assert 'set_runner_disabled "$SECONDARY" true' in text + assert 'set_runner_disabled "$PRIMARY" false' in text + assert 'set_runner_disabled "$SECONDARY" false' in text + + assert ( + 'up -d --no-deps --force-recreate "$PRIMARY_SERVICE" "$SECONDARY_SERVICE"' + in text + ) + assert 'docker compose --project-directory "$ROOT"' in text + assert 'cp -a -- "$backup/docker-compose.yml" "$COMPOSE_FILE"' in text + + # The maintenance path may inspect DinD for active CI work, but must never + # recreate or prune the daemon, Gitea, unrelated networks, or host storage. + forbidden = ( + 'force-recreate "$DIND"', + 'force-recreate "$GITEA_CONTAINER"', + "docker system prune", + "docker network prune", + "docker volume prune", + 'docker rm --force "$DIND"', + 'docker rm --force "$GITEA_CONTAINER"', + ) + for token in forbidden: + assert token not in text + + +def test_runner_3_5_upgrade_preserves_separate_runner_credentials() -> None: + text = IN_PLACE.read_text(encoding="utf-8") + assert ( + 'PRIMARY_RUNNER_FILE="/mnt/cache/appdata/itworx-security-runner/.runner"' + in text + ) + assert ( + 'SECONDARY_RUNNER_FILE="/mnt/cache/appdata/itworx-validation-runner-2/.runner"' + in text + ) + assert ( + 'readlink -f "$PRIMARY_RUNNER_FILE"' + in text + and 'readlink -f "$SECONDARY_RUNNER_FILE"' in text + ) diff --git a/backend/tests/test_runner_capacity_controller.py b/backend/tests/test_runner_capacity_controller.py new file mode 100644 index 0000000..b88eb34 --- /dev/null +++ b/backend/tests/test_runner_capacity_controller.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +CONTROLLER_ROOT = ROOT / "ops" / "gitea-deploy-broker" +CONTROLLER_PATH = CONTROLLER_ROOT / "runner_capacity_controller.py" + + +@pytest.fixture +def capacity_module(monkeypatch): + monkeypatch.syspath_prepend(str(CONTROLLER_ROOT)) + spec = importlib.util.spec_from_file_location( + "appops_runner_capacity_controller_test", CONTROLLER_PATH + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _wire_common(service, monkeypatch, *, running=False, busy=False, disabled=True): + monkeypatch.setattr( + service, + "_burst_container", + lambda: { + "name": "itworx-validation-runner-burst-1", + "running": running, + "health": "healthy" if running else "none", + "startedAt": "", + }, + ) + monkeypatch.setattr( + service, + "_runner_registration", + lambda: { + "id": 44, + "status": "online" if running else "offline", + "busy": busy, + "disabled": disabled, + "labels": ["linux-validation", "ubuntu-latest"], + }, + ) + monkeypatch.setattr(service, "_read_state", lambda: {}) + monkeypatch.setattr(service, "_write_state", lambda _state: None) + monkeypatch.setattr(service, "_record", lambda _event: None) + + +def test_cycle_starts_and_enables_burst_for_queue_pressure(capacity_module, monkeypatch): + service = capacity_module.RunnerCapacityController() + _wire_common(service, monkeypatch, running=False, disabled=True) + monkeypatch.setattr( + service, + "_queue", + lambda: {"depth": 2, "oldestSeconds": 20}, + ) + monkeypatch.setattr( + service, + "_host", + lambda _previous, now: { + "cpuCount": 20, + "loadOne": 5.0, + "loadRatio": 0.25, + "availableMemoryBytes": 20 * 1024**3, + "cacheFreeBytes": 100 * 1024**3, + "swapInKbps": 0.0, + "swapOutKbps": 0.0, + "swapPressureSamples": 0, + "swapCritical": False, + "swapSampleAt": now, + "swapInPages": 0, + "swapOutPages": 0, + }, + ) + commands = [] + disabled = [] + monkeypatch.setattr( + service, + "_run", + lambda args, timeout=20, check=True: commands.append(args) or "", + ) + monkeypatch.setattr( + service, + "_set_disabled", + lambda runner_id, value: disabled.append((runner_id, value)), + ) + + result = service.cycle() + + assert result["decision"]["action"] == "start" + assert commands == [["docker", "start", "itworx-validation-runner-burst-1"]] + assert disabled == [(44, False)] + + +def test_cycle_drains_busy_burst_under_host_pressure(capacity_module, monkeypatch): + service = capacity_module.RunnerCapacityController() + _wire_common(service, monkeypatch, running=True, busy=True, disabled=False) + monkeypatch.setattr(service, "_queue", lambda: {"depth": 3, "oldestSeconds": 90}) + monkeypatch.setattr( + service, + "_host", + lambda _previous, now: { + "cpuCount": 20, + "loadOne": 19.0, + "loadRatio": 0.95, + "availableMemoryBytes": 20 * 1024**3, + "cacheFreeBytes": 100 * 1024**3, + "swapInKbps": 0.0, + "swapOutKbps": 0.0, + "swapPressureSamples": 0, + "swapCritical": False, + "swapSampleAt": now, + "swapInPages": 0, + "swapOutPages": 0, + }, + ) + disabled = [] + commands = [] + monkeypatch.setattr( + service, + "_set_disabled", + lambda runner_id, value: disabled.append((runner_id, value)), + ) + monkeypatch.setattr( + service, + "_run", + lambda args, timeout=20, check=True: commands.append(args) or "", + ) + + result = service.cycle() + + assert result["decision"]["action"] == "drain" + assert disabled == [(44, True)] + assert commands == [] + + +def test_cycle_stops_only_disabled_idle_runner_without_active_assignment( + capacity_module, monkeypatch +): + service = capacity_module.RunnerCapacityController() + _wire_common(service, monkeypatch, running=True, busy=False, disabled=True) + monkeypatch.setattr(service, "_read_state", lambda: {"lastDemandAt": 1, "lastActionAt": 1}) + monkeypatch.setattr(service, "_queue", lambda: {"depth": 0, "oldestSeconds": None}) + monkeypatch.setattr( + service, + "_host", + lambda _previous, now: { + "cpuCount": 20, + "loadOne": 5.0, + "loadRatio": 0.25, + "availableMemoryBytes": 20 * 1024**3, + "cacheFreeBytes": 100 * 1024**3, + "swapInKbps": 0.0, + "swapOutKbps": 0.0, + "swapPressureSamples": 0, + "swapCritical": False, + "swapSampleAt": now, + "swapInPages": 0, + "swapOutPages": 0, + }, + ) + monkeypatch.setattr(service, "_active_on_runner", lambda _runner_id: False) + commands = [] + monkeypatch.setattr( + service, + "_run", + lambda args, timeout=20, check=True: commands.append(args) or "", + ) + + result = service.cycle() + + assert result["decision"]["action"] == "stop" + assert commands == [ + ["docker", "stop", "--time", "30", "itworx-validation-runner-burst-1"] + ] + + +def test_cycle_never_stops_when_gitea_still_has_active_assignment( + capacity_module, monkeypatch +): + service = capacity_module.RunnerCapacityController() + _wire_common(service, monkeypatch, running=True, busy=False, disabled=True) + monkeypatch.setattr(service, "_read_state", lambda: {"lastDemandAt": 1, "lastActionAt": 1}) + monkeypatch.setattr(service, "_queue", lambda: {"depth": 0, "oldestSeconds": None}) + monkeypatch.setattr( + service, + "_host", + lambda _previous, now: { + "cpuCount": 20, + "loadOne": 5.0, + "loadRatio": 0.25, + "availableMemoryBytes": 20 * 1024**3, + "cacheFreeBytes": 100 * 1024**3, + "swapInKbps": 0.0, + "swapOutKbps": 0.0, + "swapPressureSamples": 0, + "swapCritical": False, + "swapSampleAt": now, + "swapInPages": 0, + "swapOutPages": 0, + }, + ) + monkeypatch.setattr(service, "_active_on_runner", lambda _runner_id: True) + commands = [] + monkeypatch.setattr( + service, + "_run", + lambda args, timeout=20, check=True: commands.append(args) or "", + ) + + result = service.cycle() + + assert result["decision"]["result"] == "stop_deferred_not_proven_idle" + assert commands == [] diff --git a/backend/tests/test_runner_capacity_policy.py b/backend/tests/test_runner_capacity_policy.py new file mode 100644 index 0000000..1700a73 --- /dev/null +++ b/backend/tests/test_runner_capacity_policy.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +POLICY_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_capacity_policy.py" + + +@pytest.fixture +def policy_module(): + spec = importlib.util.spec_from_file_location("appops_runner_capacity_policy_test", POLICY_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _state(policy_module, **overrides): + values = { + "queue_depth": 0, + "oldest_queued_seconds": None, + "load_ratio": 0.30, + "available_memory_bytes": 20 * policy_module.GIB, + "burst_running": False, + "burst_busy": False, + "burst_disabled": True, + "seconds_since_demand": 600, + "seconds_since_action": 600, + "swap_critical": False, + "cache_free_bytes": 100 * policy_module.GIB, + } + values.update(overrides) + return policy_module.BurstCapacityInput(**values) + + +def test_burst_starts_only_for_queue_demand_with_headroom(policy_module): + decision = policy_module.decide_burst_capacity( + _state(policy_module, queue_depth=2) + ) + + assert decision.action == "start" + assert decision.reason == "queue_pressure_with_host_headroom" + assert decision.pressure == "headroom" + + +def test_old_job_can_trigger_burst_even_with_single_queued_job(policy_module): + decision = policy_module.decide_burst_capacity( + _state(policy_module, queue_depth=1, oldest_queued_seconds=60) + ) + + assert decision.action == "start" + + +@pytest.mark.parametrize( + ("load_ratio", "memory_gib", "swap_critical"), + [ + (0.75, 20, False), + (0.30, 10, False), + (0.30, 20, True), + ], +) +def test_burst_does_not_start_without_headroom( + policy_module, load_ratio, memory_gib, swap_critical +): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + queue_depth=3, + load_ratio=load_ratio, + available_memory_bytes=memory_gib * policy_module.GIB, + swap_critical=swap_critical, + ) + ) + + assert decision.action == "hold" + assert decision.reason == "queue_pressure_but_host_headroom_insufficient" + + +def test_high_pressure_drains_busy_burst_instead_of_stopping_it(policy_module): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + queue_depth=3, + load_ratio=0.95, + burst_running=True, + burst_busy=True, + burst_disabled=False, + ) + ) + + assert decision.action == "drain" + assert decision.reason == "host_pressure_drain_after_current_job" + + +def test_high_pressure_stops_only_already_drained_idle_burst(policy_module): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + load_ratio=0.95, + burst_running=True, + burst_busy=False, + burst_disabled=True, + seconds_since_action=600, + ) + ) + + assert decision.action == "stop" + assert decision.reason == "host_pressure_idle_burst_can_stop" + + +def test_idle_burst_is_drained_before_stop(policy_module): + first = policy_module.decide_burst_capacity( + _state( + policy_module, + burst_running=True, + burst_disabled=False, + seconds_since_demand=600, + seconds_since_action=600, + ) + ) + second = policy_module.decide_burst_capacity( + _state( + policy_module, + burst_running=True, + burst_disabled=True, + seconds_since_demand=600, + seconds_since_action=600, + ) + ) + + assert first.action == "drain" + assert second.action == "stop" + + +def test_disabled_running_burst_reenables_when_demand_returns(policy_module): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + queue_depth=2, + burst_running=True, + burst_disabled=True, + seconds_since_action=600, + ) + ) + + assert decision.action == "enable" + + +def test_start_cooldown_prevents_flapping(policy_module): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + queue_depth=2, + seconds_since_action=30, + ) + ) + + assert decision.action == "hold" + + +def test_burst_does_not_start_when_cache_headroom_is_low(policy_module): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + queue_depth=3, + cache_free_bytes=20 * policy_module.GIB, + ) + ) + assert decision.action == "hold" + assert decision.reason == "queue_pressure_but_host_headroom_insufficient" + + +def test_low_cache_headroom_drains_busy_burst(policy_module): + decision = policy_module.decide_burst_capacity( + _state( + policy_module, + queue_depth=3, + burst_running=True, + burst_busy=True, + burst_disabled=False, + cache_free_bytes=10 * policy_module.GIB, + ) + ) + assert decision.action == "drain" + assert decision.pressure == "critical" diff --git a/backend/tests/test_runner_fleet.py b/backend/tests/test_runner_fleet.py new file mode 100644 index 0000000..5bca2e9 --- /dev/null +++ b/backend/tests/test_runner_fleet.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +FLEET_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_fleet.py" + + +@pytest.fixture +def fleet_module(): + spec = importlib.util.spec_from_file_location("appops_runner_fleet_test", FLEET_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_cleanup_candidates_exclude_unknown_attached_and_labeled_networks( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + old = "2026-01-01T00:00:00Z" + monkeypatch.setattr( + service, + "_network_inventory", + lambda: ( + [ + { + "id": "a" * 64, + "name": "GITEA-ACTIONS-TASK-12-WORKFLOW-ci-JOB-test-network", + "created": old, + "attached": 0, + "labels": {}, + }, + { + "id": "b" * 64, + "name": "production_default", + "created": old, + "attached": 0, + "labels": {}, + }, + { + "id": "c" * 64, + "name": "GITEA-ACTIONS-TASK-13-WORKFLOW-ci-JOB-test-network", + "created": old, + "attached": 1, + "labels": {}, + }, + { + "id": "d" * 64, + "name": "GITEA-ACTIONS-TASK-14-WORKFLOW-ci-JOB-test-network", + "created": old, + "attached": 0, + "labels": {"com.example.keep": "true"}, + }, + ], + 2048, + ), + ) + + candidates = service.cleanup_candidates() + + assert [candidate["id"] for candidate in candidates] == ["a" * 64] + assert candidates[0]["reason"] == "empty_gitea_actions_task_network_in_dedicated_dind" + + +def test_automatic_cycle_only_deletes_exact_preview_candidates(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + candidate = { + "id": "a" * 64, + "name": "GITEA-ACTIONS-TASK-12-WORKFLOW-ci-JOB-test-network", + "ageSeconds": 3600, + "reason": "empty_gitea_actions_task_network_in_dedicated_dind", + } + snapshot = { + "status": "healthy", + "networks": {"cleanupCandidates": [candidate]}, + "storage": {"status": "healthy"}, + "topology": { + "dind": { + "name": "gitea-runner-dind", + "running": True, + "health": "healthy", + "startedAt": "2026-09-16T18:00:00Z", + "restartPolicy": "unless-stopped", + }, + "runners": [], + }, + } + calls: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", lambda: snapshot) + monkeypatch.setattr(service, "_dind", lambda args, timeout=20: calls.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **_kwargs: None) + + result = service.automatic_cycle() + + assert result["removedNetworks"] == 1 + assert calls == [["network", "rm", "a" * 64]] + + +def test_recovery_budget_stops_restart_loops(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + monkeypatch.setattr( + service, + "_events", + lambda: [ + { + "action": "restart", + "target": "itworx-security-runner", + "at": "2027-01-15T08:00:00Z", + } + for _ in range(fleet_module.RECOVERY_BUDGET) + ], + ) + + assert service._recovery_available("itworx-security-runner") is False + + +def test_fresh_starting_runner_is_not_restarted_before_health_grace_expires( + fleet_module, +): + fresh = { + "name": "itworx-validation-runner-2", + "running": True, + "health": "starting", + "startedAt": "1970-01-01T00:16:00Z", + } + stale = {**fresh, "startedAt": "1970-01-01T00:13:00Z"} + + assert fleet_module._container_is_healthy(fresh, now=1_000) + assert not fleet_module._container_is_healthy(stale, now=1_000) + + +def test_automatic_cycle_starts_only_an_exited_known_runner(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + snapshot = { + "status": "healthy", + "networks": {"cleanupCandidates": []}, + "storage": {"status": "healthy"}, + "topology": { + "dind": { + "name": "gitea-runner-dind", + "running": True, + "health": "healthy", + "startedAt": "2026-09-16T18:00:00Z", + "restartPolicy": "unless-stopped", + }, + "runners": [ + { + "name": "itworx-validation-runner-2", + "running": False, + "health": "unhealthy", + }, + { + "name": "itworx-security-runner", + "running": True, + "health": "unhealthy", + }, + ] + }, + } + commands: list[list[str]] = [] + events: list[dict[str, object]] = [] + monkeypatch.setattr(service, "snapshot", lambda: snapshot) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **event: events.append(event)) + + service.automatic_cycle() + + assert commands == [["docker", "start", "itworx-validation-runner-2"]] + assert {event["action"] for event in events} == {"start", "restart.deferred"} diff --git a/backend/tests/test_runner_fleet_scheduler_recovery.py b/backend/tests/test_runner_fleet_scheduler_recovery.py new file mode 100644 index 0000000..aff8bf5 --- /dev/null +++ b/backend/tests/test_runner_fleet_scheduler_recovery.py @@ -0,0 +1,1047 @@ +from __future__ import annotations + +import importlib.util +from datetime import UTC, datetime +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +FLEET_PATH = ROOT / "ops" / "gitea-deploy-broker" / "runner_fleet.py" + + +@pytest.fixture +def fleet_module(): + spec = importlib.util.spec_from_file_location("appops_runner_fleet_scheduler_test", FLEET_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _healthy_snapshot(): + return { + "status": "healthy", + "networks": {"cleanupCandidates": []}, + "storage": {"status": "healthy"}, + "topology": { + "dind": { + "name": "gitea-runner-dind", + "running": True, + "health": "healthy", + "startedAt": "2026-09-16T18:00:00Z", + "restartPolicy": "unless-stopped", + }, + "runners": [ + { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + "startedAt": "2026-09-16T18:00:00Z", + }, + { + "name": "itworx-security-runner", + "running": True, + "health": "healthy", + "startedAt": "2026-09-16T18:00:00Z", + }, + ] + }, + } + + + + +def test_container_projection_includes_restart_policy(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + payload = [ + { + "Config": {"Labels": {"com.docker.compose.project": "gitea-deploy-runners"}}, + "HostConfig": {"RestartPolicy": {"Name": "unless-stopped", "MaximumRetryCount": 0}}, + "State": { + "Running": True, + "StartedAt": "2026-09-21T16:00:00Z", + "OOMKilled": False, + "Health": {"Status": "healthy"}, + }, + "RestartCount": 2, + } + ] + monkeypatch.setattr(service, "_run", lambda _args, timeout=20: __import__("json").dumps(payload)) + + result = service._container("gitea-runner-dind") + + assert result["restartPolicy"] == "unless-stopped" + assert result["running"] is True + assert result["health"] == "healthy" + assert result["restarts"] == 2 + + +def test_snapshot_keeps_exited_dind_actionable_without_querying_dind_networks( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + states = { + "gitea-runner-dind": { + "name": "gitea-runner-dind", + "running": False, + "health": "none", + "restarts": 0, + "startedAt": "", + "oomKilled": False, + "restartPolicy": "no", + }, + "itworx-validation-runner-2": { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + "restarts": 0, + "startedAt": "2026-09-16T18:00:00Z", + "oomKilled": False, + "restartPolicy": "unless-stopped", + }, + "itworx-security-runner": { + "name": "itworx-security-runner", + "running": True, + "health": "healthy", + "restarts": 0, + "startedAt": "2026-09-16T18:00:00Z", + "oomKilled": False, + "restartPolicy": "unless-stopped", + }, + } + monkeypatch.setattr(service, "_container", lambda name: states[name]) + monkeypatch.setattr(service, "_optional_container", lambda _name: None) + monkeypatch.setattr(service, "_storage", lambda: {"status": "healthy", "freeBytes": 20_000_000_000, "totalBytes": 40_000_000_000}) + monkeypatch.setattr( + service, + "_network_inventory", + lambda: pytest.fail("network inventory must not query an exited DinD daemon"), + ) + monkeypatch.setattr( + service, + "cleanup_candidates", + lambda: pytest.fail("cleanup must not query an exited DinD daemon"), + ) + monkeypatch.setattr(service, "_scheduler_recovery_candidate", lambda: None) + + result = service.snapshot() + + assert result["status"] == "critical" + assert result["topology"]["dind"]["running"] is False + assert result["networks"] == {"count": 0, "capacity": None, "cleanupCandidates": []} + assert result["policy"]["hostDockerMutationTargets"][0] == "gitea-runner-dind" + + +def test_snapshot_does_not_query_dind_api_during_health_start_grace( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + states = { + "gitea-runner-dind": { + "name": "gitea-runner-dind", + "running": True, + "health": "starting", + "restarts": 0, + "startedAt": datetime.fromtimestamp(now - 10, UTC).isoformat(), + "oomKilled": False, + "restartPolicy": "unless-stopped", + }, + "itworx-validation-runner-2": { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + "restarts": 0, + "startedAt": datetime.fromtimestamp(now - 300, UTC).isoformat(), + "oomKilled": False, + "restartPolicy": "unless-stopped", + }, + "itworx-security-runner": { + "name": "itworx-security-runner", + "running": True, + "health": "healthy", + "restarts": 0, + "startedAt": datetime.fromtimestamp(now - 300, UTC).isoformat(), + "oomKilled": False, + "restartPolicy": "unless-stopped", + }, + } + monkeypatch.setattr(service, "_container", lambda name: states[name]) + monkeypatch.setattr(service, "_optional_container", lambda _name: None) + monkeypatch.setattr(service, "_storage", lambda: {"status": "healthy", "freeBytes": 20_000_000_000, "totalBytes": 40_000_000_000}) + monkeypatch.setattr( + service, + "_network_inventory", + lambda: pytest.fail("DinD API must not be queried during health start grace"), + ) + monkeypatch.setattr( + service, + "cleanup_candidates", + lambda: pytest.fail("cleanup must not query DinD during health start grace"), + ) + monkeypatch.setattr(service, "_scheduler_recovery_candidate", lambda: None) + + result = service.snapshot() + + assert result["status"] == "healthy" + assert result["topology"]["dind"]["health"] == "starting" + assert result["networks"] == {"count": 0, "capacity": None, "cleanupCandidates": []} + + +def test_automatic_cycle_defers_every_other_recovery_while_dind_is_starting( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + snapshot = _healthy_snapshot() + snapshot["topology"]["dind"] = { + "name": "gitea-runner-dind", + "running": True, + "health": "starting", + "startedAt": "2026-09-21T16:00:00Z", + "restartPolicy": "unless-stopped", + } + commands: list[list[str]] = [] + events: list[dict[str, object]] = [] + monkeypatch.setattr(service, "snapshot", lambda: snapshot) + monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **event: events.append(event)) + + result = service.automatic_cycle() + + assert commands == [] + assert events == [] + assert result["dindRecovery"] == { + "status": "starting", + "target": "gitea-runner-dind", + } + assert result["schedulerRecovery"] == {"status": "deferred_for_dind_recovery"} + + +def test_automatic_cycle_sets_self_restart_and_starts_only_fixed_exited_dind( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + snapshot = _healthy_snapshot() + snapshot["status"] = "critical" + snapshot["topology"]["dind"] = { + "name": "gitea-runner-dind", + "running": False, + "health": "none", + "startedAt": "", + "restartPolicy": "no", + } + commands: list[tuple[list[str], int]] = [] + events: list[dict[str, object]] = [] + monkeypatch.setattr(service, "snapshot", lambda: snapshot) + monkeypatch.setattr(service, "_recovery_available", lambda target: target == "gitea-runner-dind") + monkeypatch.setattr( + service, + "_run", + lambda args, timeout=20: commands.append((args, timeout)) or "", + ) + monkeypatch.setattr(service, "_record", lambda **event: events.append(event)) + + result = service.automatic_cycle() + + assert commands == [ + (["docker", "update", "--restart", "unless-stopped", "gitea-runner-dind"], 30), + (["docker", "start", "gitea-runner-dind"], 30), + ] + assert result["dindRecovery"]["status"] == "requested" + assert result["schedulerRecovery"]["status"] == "deferred_for_dind_recovery" + assert any( + event.get("action") == "start" + and event.get("target") == "gitea-runner-dind" + and event.get("reason") == "known_ci_dind_exited" + for event in events + ) + + +def test_automatic_cycle_never_restarts_running_unhealthy_dind( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + snapshot = _healthy_snapshot() + snapshot["status"] = "critical" + snapshot["topology"]["dind"] = { + "name": "gitea-runner-dind", + "running": True, + "health": "unhealthy", + "startedAt": "2026-09-16T18:00:00Z", + "restartPolicy": "unless-stopped", + } + commands: list[list[str]] = [] + events: list[dict[str, object]] = [] + monkeypatch.setattr(service, "snapshot", lambda: snapshot) + monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **event: events.append(event)) + + result = service.automatic_cycle() + + assert commands == [] + assert result["dindRecovery"] == { + "status": "deferred", + "target": "gitea-runner-dind", + } + assert any( + event.get("action") == "restart.deferred" + and event.get("target") == "gitea-runner-dind" + for event in events + ) + + +def test_recovery_budget_counts_start_and_restart_attempts(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + recent = datetime.fromtimestamp(now - 60, UTC).isoformat() + monkeypatch.setattr( + service, + "_events", + lambda: [ + {"at": recent, "action": "start", "target": "gitea-runner-dind"}, + {"at": recent, "action": "restart", "target": "gitea-runner-dind"}, + {"at": recent, "action": "start", "target": "gitea-runner-dind"}, + ], + ) + + assert service._recovery_available("gitea-runner-dind") is False + + +def test_scheduler_candidate_uses_live_gitea_queue_and_fixed_label_target( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + created_at = datetime.fromtimestamp(now - 900, UTC).isoformat() + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + + def fake_gitea(path, params=None): + params = params or {} + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 17, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": False, + "disabled": False, + "labels": ["linux-validation", "ubuntu-latest"], + } + ] + } + if params.get("status") == "queued": + return { + "jobs": [ + { + "id": 22931, + "created_at": created_at, + "labels": ["linux-validation"], + } + ] + } + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + assert service._scheduler_recovery_candidate() == { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + } + + +def test_scheduler_candidate_never_targets_busy_runner(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + created_at = datetime.fromtimestamp(now - 900, UTC).isoformat() + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + + def fake_gitea(path, params=None): + params = params or {} + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 17, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": True, + "disabled": False, + "labels": ["linux-validation"], + } + ] + } + if params.get("status") == "queued": + return { + "jobs": [ + { + "id": 22931, + "created_at": created_at, + "labels": ["linux-validation"], + } + ] + } + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + assert service._scheduler_recovery_candidate() is None + + +def test_scheduler_candidate_never_restarts_runner_with_in_progress_job( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + created_at = datetime.fromtimestamp(now - 900, UTC).isoformat() + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + + def fake_gitea(path, params=None): + params = params or {} + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [{ + "id": 17, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": False, + "disabled": False, + "labels": ["linux-validation"], + }], + } + if params.get("status") == "queued": + return {"jobs": [{"id": 22931, "created_at": created_at, "labels": ["linux-validation"]}]} + if params.get("status") == "in_progress": + return {"jobs": [{"id": 22932, "runner_id": 17, "status": "in_progress"}]} + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + assert service._scheduler_recovery_candidate() is None + + +def test_scheduler_candidate_never_hides_disabled_busy_runner(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + created_at = datetime.fromtimestamp(now - 900, UTC).isoformat() + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + + def fake_gitea(path, params=None): + params = params or {} + if path.endswith("/runners"): + if params.get("disabled") == "false": + return {"total_count": 0, "runners": []} + return { + "total_count": 1, + "runners": [ + { + "id": 17, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": True, + "disabled": True, + "labels": ["linux-validation"], + } + ] + } + if params.get("status") == "queued": + return { + "jobs": [ + { + "id": 22931, + "created_at": created_at, + "labels": ["linux-validation"], + } + ] + } + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + assert service._scheduler_recovery_candidate() is None + + +def test_scheduler_candidate_reads_later_runner_inventory_pages( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + created_at = datetime.fromtimestamp(now - 900, UTC).isoformat() + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + runner_calls: list[dict[str, object]] = [] + + def fake_gitea(path, params=None): + params = params or {} + if path.endswith("/runners"): + runner_calls.append(params) + if params.get("page") == 2: + return { + "total_count": 2, + "runners": [ + { + "id": 18, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": True, + "disabled": True, + "labels": ["linux-validation"], + } + ], + } + return { + "total_count": 2, + "runners": [ + { + "id": 17, + "name": "unrelated-runner", + "status": "online", + "busy": False, + "disabled": False, + "labels": ["unrelated"], + } + ], + } + if params.get("status") == "queued": + return { + "jobs": [ + { + "id": 22931, + "created_at": created_at, + "labels": ["linux-validation"], + } + ] + } + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + assert service._scheduler_recovery_candidate() is None + assert [call.get("page") for call in runner_calls] == [1, 2] + + +def test_scheduler_candidate_fails_closed_on_malformed_runner_inventory( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + + def fake_gitea(path, params=None): + if path.endswith("/runners"): + return {"total_count": 1} + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + with pytest.raises( + fleet_module.RunnerFleetError, + match="runner_fleet_gitea_invalid_runner_inventory", + ): + service._scheduler_recovery_candidate() + + +@pytest.mark.parametrize("unsafe_first", [True, False]) +def test_scheduler_candidate_vetoes_any_unsafe_duplicate_fixed_runner( + fleet_module, monkeypatch, unsafe_first +): + service = fleet_module.RunnerFleetService() + now = 1_800_000_000 + created_at = datetime.fromtimestamp(now - 900, UTC).isoformat() + monkeypatch.setattr(fleet_module.time, "time", lambda: now) + unsafe = { + "id": 17, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": True, + "disabled": True, + "labels": ["linux-validation"], + } + idle = { + "id": 18, + "name": "itworx-validation-runner-2", + "status": "online", + "busy": False, + "disabled": False, + "labels": ["linux-validation"], + } + + def fake_gitea(path, params=None): + params = params or {} + if path.endswith("/runners"): + runners = [unsafe, idle] if unsafe_first else [idle, unsafe] + return {"total_count": 2, "runners": runners} + if params.get("status") == "queued": + return { + "jobs": [ + { + "id": 22931, + "created_at": created_at, + "labels": ["linux-validation"], + } + ] + } + return {"jobs": []} + + monkeypatch.setattr(service, "_gitea_request", fake_gitea) + + assert service._scheduler_recovery_candidate() is None + + +def test_automatic_cycle_restarts_only_exact_known_idle_runner_for_stale_scheduler( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + commands: list[tuple[list[str], int]] = [] + events: list[dict[str, object]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr( + service, + "_container", + lambda _target: { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + }, + ) + monkeypatch.setattr( + service, + "_run", + lambda args, timeout=20: commands.append((args, timeout)) or "", + ) + monkeypatch.setattr( + service, "_record", lambda **event: events.append(event) or True + ) + + result = service.automatic_cycle() + + assert commands == [ + (["docker", "restart", "--timeout", "30", "itworx-validation-runner-2"], 45) + ] + assert any( + event.get("action") == "restart" + and event.get("target") == "itworx-validation-runner-2" + and event.get("reason") == "stale_scheduler_assignment" + for event in events + ) + assert result["schedulerRecovery"] == { + "status": "requested", + "target": "itworx-validation-runner-2", + "jobId": 22931, + } + + +def test_scheduler_restart_persists_intent_before_invoking_docker( + fleet_module, monkeypatch, tmp_path +): + service = fleet_module.RunnerFleetService() + monkeypatch.setattr(fleet_module, "STATE_ROOT", tmp_path) + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr( + service, + "_container", + lambda _target: { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + }, + ) + + def observe_durable_intent(args, timeout=20): + assert args == [ + "docker", + "restart", + "--timeout", + "30", + "itworx-validation-runner-2", + ] + assert timeout == 45 + persisted = service._events() + assert persisted[-1] == { + "at": persisted[-1]["at"], + "action": "restart", + "target": "itworx-validation-runner-2", + "reason": "stale_scheduler_assignment", + "result": "intent", + "ageSeconds": 900, + } + return "" + + monkeypatch.setattr(service, "_run", observe_durable_intent) + + result = service.automatic_cycle() + + assert result["schedulerRecovery"]["status"] == "requested" + + +def test_scheduler_recovery_refuses_restart_when_intent_cannot_be_persisted( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr( + service, + "_container", + lambda _target: { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + }, + ) + monkeypatch.setattr(service, "_safe_state_root", lambda: False) + monkeypatch.setattr( + service, "_run", lambda args, timeout=20: commands.append(args) or "" + ) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"] == { + "status": "failed", + "target": "itworx-validation-runner-2", + "jobId": 22931, + "reason": "restart_intent_not_persisted", + } + + +def test_scheduler_recovery_refuses_restart_when_intent_write_raises_oserror( + fleet_module, monkeypatch, tmp_path +): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + monkeypatch.setattr(fleet_module, "STATE_ROOT", tmp_path) + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr( + service, + "_container", + lambda _target: { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + }, + ) + monkeypatch.setattr( + fleet_module.os, + "open", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + PermissionError("state storage is unwritable") + ), + ) + monkeypatch.setattr( + service, "_run", lambda args, timeout=20: commands.append(args) or "" + ) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"] == { + "status": "failed", + "target": "itworx-validation-runner-2", + "jobId": 22931, + "reason": "restart_intent_not_persisted", + } + + +def test_scheduler_recovery_keeps_intent_when_outcome_write_fails( + fleet_module, monkeypatch, tmp_path +): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + event_open_count = 0 + real_open = fleet_module.os.open + monkeypatch.setattr(fleet_module, "STATE_ROOT", tmp_path) + monkeypatch.setattr(fleet_module, "RECOVERY_BUDGET", 1) + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr( + service, + "_container", + lambda _target: { + "name": "itworx-validation-runner-2", + "running": True, + "health": "healthy", + }, + ) + + def fail_outcome_open(path, flags, mode=0o777): + nonlocal event_open_count + if Path(path).name.startswith(".events."): + event_open_count += 1 + if event_open_count == 2: + raise PermissionError("outcome storage is unwritable") + return real_open(path, flags, mode) + + monkeypatch.setattr(fleet_module.os, "open", fail_outcome_open) + monkeypatch.setattr( + service, "_run", lambda args, timeout=20: commands.append(args) or "" + ) + + result = service.automatic_cycle() + + assert commands == [ + ["docker", "restart", "--timeout", "30", "itworx-validation-runner-2"] + ] + assert result["schedulerRecovery"] == { + "status": "requested", + "target": "itworx-validation-runner-2", + "jobId": 22931, + } + assert [event["action"] for event in service._events()] == ["restart"] + assert service._recovery_available("itworx-validation-runner-2") is False + + +@pytest.mark.parametrize( + ("running", "health"), + [ + (True, "starting"), + (True, "none"), + (True, "unhealthy"), + (False, "healthy"), + ], +) +def test_scheduler_recovery_rechecks_strict_runner_health_before_restart( + fleet_module, monkeypatch, running, health +): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr( + service, + "_container", + lambda _target: { + "name": "itworx-validation-runner-2", + "running": running, + "health": health, + }, + ) + monkeypatch.setattr( + service, "_run", lambda args, timeout=20: commands.append(args) or "" + ) + monkeypatch.setattr(service, "_record", lambda **_event: True) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"] == { + "status": "ineligible", + "target": "itworx-validation-runner-2", + "jobId": 22931, + "reason": "docker_health_not_healthy", + } + + +def test_scheduler_recovery_rejects_duplicate_fresh_inspect_results( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + commands: list[tuple[list[str], int]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + + def duplicate_inspect(args, timeout=20): + commands.append((args, timeout)) + return "[{}, {}]" + + monkeypatch.setattr(service, "_run", duplicate_inspect) + monkeypatch.setattr(service, "_record", lambda **_event: True) + + result = service.automatic_cycle() + + assert commands == [ + (["docker", "inspect", "itworx-validation-runner-2"], 20) + ] + assert result["schedulerRecovery"] == { + "status": "ineligible", + "target": "itworx-validation-runner-2", + "jobId": 22931, + "reason": "docker_health_not_healthy", + } + + +def test_scheduler_recovery_rejects_runner_without_healthy_docker_state( + fleet_module, monkeypatch +): + service = fleet_module.RunnerFleetService() + snapshot = _healthy_snapshot() + snapshot["topology"]["runners"][0]["health"] = "unhealthy" + commands: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", lambda: snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr( + service, "_run", lambda args, timeout=20: commands.append(args) or "" + ) + monkeypatch.setattr(service, "_record", lambda **_event: None) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"] == { + "status": "ineligible", + "target": "itworx-validation-runner-2", + "jobId": 22931, + "reason": "docker_health_not_healthy", + } + + +def test_scheduler_recovery_refuses_unknown_target(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "Gitea", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: True) + monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **_event: None) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"]["status"] == "rejected" + + +def test_scheduler_recovery_obeys_existing_recovery_budget(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr( + service, + "_scheduler_recovery_candidate", + lambda: { + "target": "itworx-validation-runner-2", + "jobId": 22931, + "label": "linux-validation", + "ageSeconds": 900, + "reason": "stale_scheduler_assignment", + }, + ) + monkeypatch.setattr(service, "_recovery_available", lambda _target: False) + monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **_event: None) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"]["status"] == "budget_exhausted" + + +def test_scheduler_recovery_does_nothing_without_stale_candidate(fleet_module, monkeypatch): + service = fleet_module.RunnerFleetService() + commands: list[list[str]] = [] + monkeypatch.setattr(service, "snapshot", _healthy_snapshot) + monkeypatch.setattr(service, "_scheduler_recovery_candidate", lambda: None) + monkeypatch.setattr(service, "_run", lambda args, timeout=20: commands.append(args) or "") + monkeypatch.setattr(service, "_record", lambda **_event: None) + + result = service.automatic_cycle() + + assert commands == [] + assert result["schedulerRecovery"] == {"status": "not_needed"} diff --git a/backend/tests/test_runner_fleet_v2_execution_surface.py b/backend/tests/test_runner_fleet_v2_execution_surface.py new file mode 100644 index 0000000..2287cb2 --- /dev/null +++ b/backend/tests/test_runner_fleet_v2_execution_surface.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def test_runner_fleet_upgrade_action_is_fixed_and_registered(): + registry = (ROOT / "backend/app/services/action_registry.py").read_text(encoding="utf-8") + execution = (ROOT / "backend/app/services/action_execution.py").read_text(encoding="utf-8") + assert "'runner.fleet.upgrade_v2'" in registry + assert "return 'tower/runner-fleet-v2'" in registry + assert 'action == "runner.fleet.upgrade_v2"' in execution + assert 'target.strip().lower() != "tower/runner-fleet-v2"' in execution + + +def test_runner_fleet_upgrade_uses_signed_fixed_broker_path(): + broker = (ROOT / "backend/app/services/deployment_broker.py").read_text(encoding="utf-8") + assert '"/v1/appops/runner-fleet/upgrade-v2"' in broker + assert "runner_fleet_upgrade_v2" in broker + + +def test_root_controller_upgrade_route_is_exact_revision_bound(): + status = (ROOT / "ops/gitea-deploy-broker/status_server.py").read_text(encoding="utf-8") + assert 'RUNNER_FLEET_UPGRADE_PATH = "/v1/appops/runner-fleet/upgrade-v2"' in status + assert "_self_upgrade_signature_is_valid" in status + assert "_self_upgrade_preflight(revision)" in status + assert 'f"{revision}:scripts/upgrade_ci_runner_fleet_v2.sh"' in status + assert "execute_runner_fleet_v2(script_result.stdout)" in status + + +def test_controller_artifact_carries_fixed_fleet_executor(): + status = (ROOT / "ops/gitea-deploy-broker/status_server.py").read_text(encoding="utf-8") + prepare = (ROOT / "scripts/prepare_controller_artifact.py").read_text(encoding="utf-8") + upgrade = (ROOT / "scripts/upgrade_deployment_controller.sh").read_text(encoding="utf-8") + for text in (status, prepare, upgrade): + assert "runner_capacity_controller.py" in text + assert "runner_capacity_policy.py" in text + assert "runner_fleet_upgrade_v2.py" in text + + +def test_root_controller_sources_compile(): + for relative in ( + "ops/gitea-deploy-broker/status_server.py", + "ops/gitea-deploy-broker/runner_fleet_upgrade_v2.py", + "ops/gitea-deploy-broker/runner_capacity_controller.py", + "ops/gitea-deploy-broker/runner_capacity_policy.py", + ): + path = ROOT / relative + compile(path.read_text(encoding="utf-8"), str(path), "exec") diff --git a/backend/tests/test_runner_scheduler_liveness_recovery.py b/backend/tests/test_runner_scheduler_liveness_recovery.py new file mode 100644 index 0000000..5750131 --- /dev/null +++ b/backend/tests/test_runner_scheduler_liveness_recovery.py @@ -0,0 +1,101 @@ +from datetime import UTC, datetime, timedelta + +from backend.app.connectors import gitea_actions +from backend.app.connectors.gitea_actions import GiteaActionsConnector +from backend.app.core.config import settings + + +def _configure(monkeypatch) -> GiteaActionsConnector: + monkeypatch.setitem(settings.apis, "GITEA_URL", "https://gitea.test") + monkeypatch.setitem(settings.apis, "GITEA_ADMIN_TOKEN", "fixture-secret") + monkeypatch.setitem( + settings.apis, + "GITEA_RUNNER_TARGETS_JSON", + '{"linux-validation":"itworx-validation-runner-2",' + '"mcp-validation":"itworx-security-runner"}', + ) + monkeypatch.setattr(gitea_actions, "_CACHE", None) + return GiteaActionsConnector() + + +def _requests(created_at: str, *, busy: bool = False): + def fake_request(path, params=None): + params = params or {} + if path.endswith("/runners"): + return { + "total_count": 1, + "runners": [ + { + "id": 17, + "name": "validation-secondary", + "status": "online", + "busy": busy, + "disabled": False, + "labels": ["linux-validation", "ubuntu-latest"], + } + ], + } + if params.get("status") == "queued": + return { + "total_count": 1, + "jobs": [ + { + "id": 91, + "name": "full", + "status": "queued", + "created_at": created_at, + "labels": ["linux-validation"], + } + ], + } + return {"total_count": 0, "jobs": []} + + return fake_request + + +def test_old_unassigned_job_on_idle_matching_runner_is_recovery_candidate(monkeypatch): + connector = _configure(monkeypatch) + created_at = (datetime.now(UTC) - timedelta(minutes=12)).isoformat() + monkeypatch.setattr(connector, "_request", _requests(created_at)) + + result = connector.snapshot(refresh=True) + + assert result["severity"] == "critical" + assert result["queue"]["items"][0]["reason"] == "awaiting_scheduler_assignment" + assert result["queue"]["staleAssignmentRunnerLabels"] == ["linux-validation"] + assert result["recommendedAction"] == { + "intent": "runner.recover", + "target": "itworx-validation-runner-2", + "requiresApproval": False, + "autoExecute": True, + "reason": "Gitea Actions scheduler left a compatible idle runner unassigned for at least ten minutes.", + "evidence": { + "jobId": 91, + "label": "linux-validation", + "minimumAgeSeconds": 600, + }, + } + + +def test_recent_unassigned_job_does_not_trigger_recovery(monkeypatch): + connector = _configure(monkeypatch) + created_at = (datetime.now(UTC) - timedelta(minutes=2)).isoformat() + monkeypatch.setattr(connector, "_request", _requests(created_at)) + + result = connector.snapshot(refresh=True) + + assert result["queue"]["items"][0]["reason"] == "awaiting_scheduler_assignment" + assert result["queue"]["staleAssignmentRunnerLabels"] == [] + assert result["recommendedAction"] is None + + +def test_busy_matching_runner_is_never_restarted_for_waiting_queue(monkeypatch): + connector = _configure(monkeypatch) + created_at = (datetime.now(UTC) - timedelta(minutes=12)).isoformat() + monkeypatch.setattr(connector, "_request", _requests(created_at, busy=True)) + + result = connector.snapshot(refresh=True) + + assert result["queue"]["items"][0]["reason"] == "all_matching_runners_are_busy" + assert result["queue"]["staleAssignmentRunnerLabels"] == [] + assert result["recommendedAction"] is None diff --git a/backend/tests/test_secret_storage.py b/backend/tests/test_secret_storage.py new file mode 100644 index 0000000..e449722 --- /dev/null +++ b/backend/tests/test_secret_storage.py @@ -0,0 +1,271 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, SQLModel, create_engine, select + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "secret_storage.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APPOPS_DATA_DIR", str(tmp_path / "data")) + monkeypatch.setenv("APPOPS_ENV", "development") + monkeypatch.setenv("APPOPS_SECRET_KEY", "test-secret-storage-master-key") + monkeypatch.delenv("APPOPS_SECRET_KEY_FILE", raising=False) + for name in [ + "PORTAINER_URL", + "PORTAINER_API_KEY", + "PORTAINER_ENDPOINT_ID", + "UNRAID_URL", + "UNRAID_API_KEY", + "SONARR_URL", + "SONARR_API_KEY", + "WIDEFROG_URL", + "WIDEFROG_API_KEY", + ]: + monkeypatch.delenv(name, raising=False) + + from backend.app.main import app + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + + previous = { + "db_path": settings.db_path, + "data_dir": settings.data_dir, + "environment": settings.environment, + "secret_key": getattr(settings, "secret_key", None), + "secret_key_source": getattr(settings, "secret_key_source", None), + "apis": dict(settings.apis), + } + settings.db_path = str(db_path) + settings.data_dir = str(tmp_path / "data") + settings.environment = "development" + settings.secret_key = "test-secret-storage-master-key" + settings.secret_key_source = "env" + settings.secret_key_file = None + for key in previous["apis"]: + if key.endswith("_API_KEY") or key in {"PORTAINER_URL", "PORTAINER_ENDPOINT_ID", "UNRAID_URL", "SONARR_URL", "WIDEFROG_URL"}: + settings.apis[key] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + settings.db_path = previous["db_path"] + settings.data_dir = previous["data_dir"] + settings.environment = previous["environment"] + settings.secret_key = previous["secret_key"] + settings.secret_key_source = previous["secret_key_source"] + settings.apis = previous["apis"] + + +def test_secret_store_encrypts_at_rest_and_decrypts_internally(api_client): + from backend.app.db.models import Secret + from backend.app.db.session import engine + from backend.app.services.secret_store import get_secret, has_secret, list_secret_status, set_secret + + secret_value = "PORTAINER_SECRET_AT_REST_VALUE" + with Session(engine) as session: + set_secret(session, "PORTAINER_API_KEY", secret_value, used_by=["portainer"]) + row = session.get(Secret, "PORTAINER_API_KEY") + + assert row is not None + assert row.encrypted_value + assert row.encrypted_value != secret_value + assert secret_value not in row.encrypted_value + assert has_secret(session, "PORTAINER_API_KEY") is True + assert get_secret(session, "PORTAINER_API_KEY") == secret_value + + statuses = list_secret_status(session) + portainer_status = next(item for item in statuses if item["secretRef"] == "PORTAINER_API_KEY") + assert portainer_status["hasSecret"] is True + assert portainer_status["status"] == "configured" + assert "value" not in portainer_status + assert secret_value not in str(statuses) + + +def test_connector_settings_store_secret_encrypted_and_keep_response_safe(api_client): + from backend.app.db.models import AppSetting, Secret + from backend.app.db.session import engine + + secret_value = "PORTAINER_PATCH_SECRET" + response = api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://198.18.10.150:9200", + "endpointId": "1", + "verifyTls": False, + "apiKey": secret_value, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["connectorId"] == "portainer" + assert payload["configured"] is True + assert payload["hasSecret"] is True + assert payload["secretRef"] == "PORTAINER_API_KEY" + assert payload["secretStatus"]["status"] == "configured" + assert payload["safeConfig"] == { + "url": "http://198.18.10.150:9200", + "endpointId": "1", + "verifyTls": False, + } + assert secret_value not in str(payload) + + with Session(engine) as session: + row = session.get(Secret, "PORTAINER_API_KEY") + assert row is not None + assert secret_value not in row.encrypted_value + + settings_rows = session.exec(select(AppSetting)).all() + assert secret_value not in str([item.value_json for item in settings_rows]) + + listed = api_client.get("/api/settings/connectors").json() + assert secret_value not in str(listed) + assert next(item for item in listed if item["connectorId"] == "portainer")["hasSecret"] is True + + +def test_runtime_exports_and_secrets_endpoint_never_show_secret_values(api_client): + secret_values = { + "PORTAINER_API_KEY": "PORTAINER_EXPORT_SECRET", + "UNRAID_API_KEY": "UNRAID_EXPORT_SECRET", + "SONARR_API_KEY": "SONARR_EXPORT_SECRET", + "WIDEFROG_API_KEY": "WIDEFROG_EXPORT_SECRET", + } + api_client.patch( + "/api/settings/connectors/portainer", + json={"url": "http://portainer.local", "endpointId": "1", "verifyTls": False, "apiKey": secret_values["PORTAINER_API_KEY"]}, + ) + api_client.patch("/api/settings/connectors/unraid", json={"url": "http://unraid.local", "apiKey": secret_values["UNRAID_API_KEY"]}) + api_client.patch("/api/settings/connectors/sonarr", json={"url": "http://sonarr.local", "apiKey": secret_values["SONARR_API_KEY"]}) + api_client.patch("/api/settings/connectors/widefrog", json={"url": "http://widefrog.local", "apiKey": secret_values["WIDEFROG_API_KEY"]}) + + for path in [ + "/api/settings/connectors", + "/api/settings/secrets", + "/api/setup/status", + "/api/runtime/info", + "/api/admin/export/config", + "/api/admin/export/policies", + "/api/admin/export/inventory", + "/api/admin/export/audit-summary", + ]: + response = api_client.get(path) + assert response.status_code == 200 + body = response.text + for secret in secret_values.values(): + assert secret not in body + assert "test-secret-storage-master-key" not in body + + secrets_payload = api_client.get("/api/settings/secrets").json() + refs = {item["secretRef"] for item in secrets_payload["secrets"]} + assert {"PORTAINER_API_KEY", "UNRAID_API_KEY", "SONARR_API_KEY", "WIDEFROG_API_KEY"}.issubset(refs) + assert secrets_payload["storage"]["encryptedAtRest"] is True + assert secrets_payload["storage"]["keySource"] == "env" + + +def test_setup_status_does_not_touch_secret_last_used(api_client): + from backend.app.db.models import Secret + from backend.app.db.session import engine + + api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://portainer.local", + "endpointId": "1", + "verifyTls": False, + "apiKey": "PORTAINER_READONLY_STATUS_SECRET", + }, + ) + + with Session(engine) as session: + row = session.get(Secret, "PORTAINER_API_KEY") + assert row is not None + row.last_used_at = None + session.add(row) + session.commit() + + response = api_client.get("/api/setup/status") + + assert response.status_code == 200 + with Session(engine) as session: + row = session.get(Secret, "PORTAINER_API_KEY") + assert row is not None + assert row.last_used_at is None + + +def test_legacy_plaintext_connector_secret_is_migrated(api_client): + from backend.app.core.config import settings + from backend.app.db.models import AppSetting, Secret + from backend.app.db.session import engine + from backend.app.services.setup_service import apply_stored_connector_settings, serialize_connector_settings + + legacy_secret = "LEGACY_PORTAINER_PLAINTEXT" + with Session(engine) as session: + session.add(AppSetting(key="connector:portainer:config", value_json={"url": "http://legacy.local", "endpointId": "1"}, is_secret=False)) + session.add(AppSetting(key="connector:portainer:secret", value_json={"value": legacy_secret}, is_secret=True)) + session.commit() + + apply_stored_connector_settings(session) + payload = serialize_connector_settings(session, "portainer") + legacy_row = session.get(AppSetting, "connector:portainer:secret") + secret_row = session.get(Secret, "PORTAINER_API_KEY") + + assert payload["hasSecret"] is True + assert payload["secretRef"] == "PORTAINER_API_KEY" + assert settings.apis["PORTAINER_API_KEY"] == legacy_secret + assert secret_row is not None + assert legacy_secret not in secret_row.encrypted_value + assert legacy_secret not in str(legacy_row.value_json) + assert legacy_row.value_json["secretRef"] == "PORTAINER_API_KEY" + + response = api_client.get("/api/settings/connectors") + assert response.status_code == 200 + assert legacy_secret not in response.text + + +def test_secret_redaction_masks_common_tokens_and_known_values(api_client): + from backend.app.db.session import engine + from backend.app.services.secret_store import redact_text, set_secret + + with Session(engine) as session: + set_secret(session, "SONARR_API_KEY", "KNOWN_SONARR_SECRET", used_by=["sonarr"]) + message = ( + "Bearer abcdefghijklmnop x-api-key: XAPISECRET " + "apiKey=INLINESECRET password=hunter2 known=KNOWN_SONARR_SECRET" + ) + redacted = redact_text(message, session=session) + + assert "abcdefghijklmnop" not in redacted + assert "XAPISECRET" not in redacted + assert "INLINESECRET" not in redacted + assert "hunter2" not in redacted + assert "KNOWN_SONARR_SECRET" not in redacted + assert "[REDACTED]" in redacted + + +def test_production_missing_secret_key_reports_warning(api_client): + from backend.app.core.config import settings + from backend.app.services.secret_store import secret_storage_status + + previous = (settings.environment, settings.secret_key, settings.secret_key_source) + settings.environment = "production" + settings.secret_key = None + settings.secret_key_source = "missing" + + status = secret_storage_status() + + assert status["productionReady"] is False + assert status["keySource"] == "missing" + assert status["warnings"] + assert "APPOPS_SECRET_KEY" in status["warnings"][0] + + settings.environment, settings.secret_key, settings.secret_key_source = previous diff --git a/backend/tests/test_security_boundaries.py b/backend/tests/test_security_boundaries.py new file mode 100644 index 0000000..e203f9e --- /dev/null +++ b/backend/tests/test_security_boundaries.py @@ -0,0 +1,122 @@ +import socket + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from backend.app.core.network_security import UnsafeConnectorUrl, validate_connector_url + + +def test_connector_url_allows_private_lan_hosts(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", lambda *_args, **_kwargs: (_ for _ in ()).throw(socket.gaierror())) + assert validate_connector_url("http://198.18.1.10:9000/") == "http://198.18.1.10:9000" + assert validate_connector_url("https://sonarr.internal/api") == "https://sonarr.internal/api" + + +def test_connector_url_blocks_unsafe_targets(): + blocked = [ + "file:///etc/passwd", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://169.254.169.254/latest/meta-data", + "http://user:pass@host.local", + "http://0.0.0.0:80", + ] + for value in blocked: + try: + validate_connector_url(value) + except UnsafeConnectorUrl: + continue + raise AssertionError(f"unsafe connector URL accepted: {value}") + + +def test_connector_url_blocks_dns_names_resolving_to_loopback(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", lambda *_args, **_kwargs: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80))]) + with pytest.raises(UnsafeConnectorUrl): + validate_connector_url("http://connector.internal") + + +def _request(headers: list[tuple[bytes, bytes]] | None = None, client_host: str = "198.18.10.20") -> Request: + return Request({"type": "http", "method": "POST", "path": "/api/actions/plan", "headers": headers or [], "client": (client_host, 12345)}) + + +def test_operator_boundary_protects_mutations_and_accepts_valid_token(): + from backend.app.core import security + from backend.app.core.config import settings + + previous = (settings.operator_auth_required, settings.operator_token, security.settings.operator_auth_required, security.settings.operator_token) + settings.operator_auth_required = True + settings.operator_token = "test-operator-boundary-token" + security.settings.operator_auth_required = True + security.settings.operator_token = "test-operator-boundary-token" + try: + with pytest.raises(HTTPException) as missing: + security.require_operator_request(_request()) + assert missing.value.status_code == 401 + request = _request([(b"x-appops-operator-token", b"test-operator-boundary-token")]) + assert security.require_operator_request(request) == "operator" + finally: + settings.operator_auth_required, settings.operator_token, security.settings.operator_auth_required, security.settings.operator_token = previous + + +def test_disabled_operator_boundary_ignores_stale_configured_token(): + from backend.app.core import security + from backend.app.core.config import settings + + previous = (settings.operator_auth_required, settings.operator_token) + settings.operator_auth_required = False + settings.operator_token = "stale-operator-token-that-no-longer-unlocks" + try: + assert security.require_operator_request(_request()) == "private_unlocked" + assert security.operator_session_status(_request()) == { + "authorized": True, + "mode": "private", + "identity": None, + } + finally: + settings.operator_auth_required, settings.operator_token = previous + + +def test_operator_boundary_accepts_allowed_authentik_account_only_from_trusted_proxy(): + from backend.app.core import security + from backend.app.core.config import settings + + previous = ( + settings.operator_auth_required, + settings.operator_token, + settings.operator_proxy_auth_enabled, + settings.operator_proxy_trusted_networks, + settings.operator_proxy_users, + settings.operator_proxy_groups, + ) + settings.operator_auth_required = True + settings.operator_token = "fallback-operator-token-value" + settings.operator_proxy_auth_enabled = True + settings.operator_proxy_trusted_networks = ["203.0.113.0/16"] + settings.operator_proxy_users = ["example-host"] + settings.operator_proxy_groups = ["appops-operators"] + headers = [(b"x-authentik-username", b"example-host"), (b"x-authentik-groups", b"users|appops-operators")] + try: + assert security.require_operator_request(_request(headers, "203.0.113.8")) == "authentik:example-host" + assert security.operator_session_status(_request(headers, "203.0.113.8")) == { + "authorized": True, + "mode": "account", + "identity": "example-host", + } + assert security.require_operator_request(_request(headers + [(b"x-appops-operator-token", b"stale-token")], "203.0.113.8")) == "authentik:example-host" + with pytest.raises(HTTPException) as spoofed: + security.require_operator_request(_request(headers, "198.18.10.20")) + assert spoofed.value.status_code == 401 + denied_headers = [(b"x-authentik-username", b"someone-else"), (b"x-authentik-groups", b"users")] + with pytest.raises(HTTPException) as denied: + security.require_operator_request(_request(denied_headers, "203.0.113.8")) + assert denied.value.status_code == 401 + finally: + ( + settings.operator_auth_required, + settings.operator_token, + settings.operator_proxy_auth_enabled, + settings.operator_proxy_trusted_networks, + settings.operator_proxy_users, + settings.operator_proxy_groups, + ) = previous diff --git a/backend/tests/test_setup_onboarding.py b/backend/tests/test_setup_onboarding.py new file mode 100644 index 0000000..8945060 --- /dev/null +++ b/backend/tests/test_setup_onboarding.py @@ -0,0 +1,343 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "setup_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + for name in [ + "PORTAINER_URL", + "PORTAINER_API_KEY", + "PORTAINER_ENDPOINT_ID", + "UNRAID_URL", + "UNRAID_API_KEY", + "AUTHENTIK_URL", + "AUTHENTIK_TOKEN", + "AUTHENTIK_OUTPOST_URL", + "RADARR_URL", + "RADARR_API_KEY", + "PROWLARR_URL", + "PROWLARR_API_KEY", + ]: + monkeypatch.delenv(name, raising=False) + + from backend.app.main import app + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + + for name in [ + "PORTAINER_URL", + "PORTAINER_API_KEY", + "PORTAINER_ENDPOINT_ID", + "UNRAID_URL", + "UNRAID_API_KEY", + "AUTHENTIK_URL", + "AUTHENTIK_TOKEN", + "AUTHENTIK_OUTPOST_URL", + "RADARR_URL", + "RADARR_API_KEY", + "PROWLARR_URL", + "PROWLARR_API_KEY", + ]: + settings.apis[name] = None + settings.verify_tls = False + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def test_setup_status_initial_is_pending_and_secret_safe(api_client): + payload = api_client.get("/api/setup/status").json() + + assert payload["firstRunCompleted"] is False + assert payload["summary"]["containersDiscovered"] == 0 + assert payload["summary"]["appsManaged"] >= 0 + assert payload["summary"]["connectorsMissingConfig"] >= 1 + assert {step["id"]: step["status"] for step in payload["steps"]}["portainer"] == "pending" + assert {step["id"]: step["status"] for step in payload["steps"]}["discovery"] == "pending" + assert "test-voice-token" not in str(payload) + + +def test_portainer_config_save_never_returns_secret(api_client): + secret = "PORTAINER_SUPER_SECRET" + + payload = api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://198.18.10.150:9443", + "endpointId": "1", + "verifyTls": False, + "apiKey": secret, + }, + ).json() + + assert payload["connectorId"] == "portainer" + assert payload["configured"] is True + assert payload["hasSecret"] is True + assert payload["missing"] == [] + assert payload["safeConfig"] == { + "url": "http://198.18.10.150:9443", + "endpointId": "1", + "verifyTls": False, + } + assert secret not in str(payload) + + all_settings = api_client.get("/api/settings/connectors").json() + assert secret not in str(all_settings) + assert next(item for item in all_settings if item["connectorId"] == "portainer")["hasSecret"] is True + + +def test_portainer_config_save_trims_safe_fields(api_client): + payload = api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": " http://198.18.10.150:9200/ ", + "endpointId": " 1 ", + "verifyTls": False, + "apiKey": "PORTAINER_SUPER_SECRET", + }, + ).json() + + assert payload["safeConfig"]["url"] == "http://198.18.10.150:9200" + assert payload["safeConfig"]["endpointId"] == "1" + + +def test_portainer_config_save_defaults_endpoint_id(api_client): + payload = api_client.patch( + "/api/settings/connectors/portainer", + json={ + "url": "http://198.18.10.150:9200", + "endpointId": "", + "verifyTls": False, + "apiKey": "PORTAINER_SUPER_SECRET", + }, + ).json() + + assert payload["safeConfig"]["endpointId"] == "1" + assert "PORTAINER_ENDPOINT_ID" not in payload["missing"] + + +def test_setup_test_portainer_missing_config_and_redaction(api_client): + response = api_client.post("/api/setup/test/portainer") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert "PORTAINER_API_KEY" in payload["requiredFields"] + + +def test_setup_test_portainer_success_summary(api_client, monkeypatch): + secret = "PORTAINER_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/portainer", + json={"url": "http://portainer.local", "endpointId": "1", "verifyTls": False, "apiKey": secret}, + ) + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.list_containers", lambda self: {"status": "ok", "containers": [{"id": "one"}]}, raising=True) + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.list_stacks", lambda self: {"status": "ok", "stacks": [{"id": "stack"}]}, raising=True) + + payload = api_client.post("/api/setup/test/portainer").json() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["summary"]["containers"] == 1 + assert payload["summary"]["stacks"] == 1 + assert secret not in str(payload) + + +def test_setup_test_portainer_endpoint_error_is_actionable(api_client, monkeypatch): + secret = "PORTAINER_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/portainer", + json={"url": "http://portainer.local", "endpointId": "99", "verifyTls": False, "apiKey": secret}, + ) + + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.test_connection", + lambda self: {"status": "endpoint_error", "reason": "Portainer API error: HTTP 404 endpoint not found"}, + raising=True, + ) + + payload = api_client.post("/api/setup/test/portainer").json() + + assert payload["success"] is False + assert payload["status"] == "endpoint_error" + assert payload["requiredFields"] == ["PORTAINER_ENDPOINT_ID"] + assert payload["safeConfig"]["endpointId"] == "99" + assert secret not in str(payload) + + +def test_setup_test_portainer_container_list_error_marks_endpoint(api_client, monkeypatch): + secret = "PORTAINER_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/portainer", + json={"url": "http://portainer.local", "endpointId": "1", "verifyTls": False, "apiKey": secret}, + ) + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "error", "reason": "Portainer API error: HTTP 404 endpoint not found", "containers": []}, + raising=True, + ) + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.list_stacks", lambda self: {"status": "ok", "stacks": []}, raising=True) + + payload = api_client.post("/api/setup/test/portainer").json() + + assert payload["success"] is False + assert payload["status"] == "endpoint_error" + assert payload["summary"]["endpointReachable"] is False + assert payload["requiredFields"] == ["PORTAINER_ENDPOINT_ID"] + assert secret not in str(payload) + + +def test_unraid_config_save_never_returns_secret(api_client): + secret = "UNRAID_SUPER_SECRET" + + payload = api_client.patch( + "/api/settings/connectors/unraid", + json={"url": "http://unraid.local", "verifyTls": False, "apiKey": secret}, + ).json() + + assert payload["connectorId"] == "unraid" + assert payload["configured"] is True + assert payload["hasSecret"] is True + assert payload["safeConfig"] == {"url": "http://unraid.local", "verifyTls": False} + assert secret not in str(payload) + + +def test_setup_test_authentik_404_marks_url_error(api_client, monkeypatch): + secret = "AUTHENTIK_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/authentik", + json={"url": "https://auth.example.local/if/flow/default-authentication-flow", "verifyTls": False, "apiKey": secret}, + ) + + monkeypatch.setattr( + "backend.app.connectors.authentik.AuthentikConnector.test_connection", + lambda self: {"status": "unreachable", "reason": "Authentik API error: HTTP 404 "}, + raising=True, + ) + + payload = api_client.post("/api/setup/test/connector/authentik").json() + + assert payload["success"] is False + assert payload["status"] == "url_error" + assert payload["requiredFields"] == ["AUTHENTIK_URL"] + assert "root/base URL" in payload["message"] + assert secret not in str(payload) + + +def test_setup_test_authentik_auth_failed_marks_token(api_client, monkeypatch): + secret = "AUTHENTIK_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/authentik", + json={"url": "https://auth.example.local", "verifyTls": False, "apiKey": secret}, + ) + + monkeypatch.setattr( + "backend.app.connectors.authentik.AuthentikConnector.test_connection", + lambda self: {"status": "auth_failed", "reason": "auth_failed"}, + raising=True, + ) + + payload = api_client.post("/api/setup/test/connector/authentik").json() + + assert payload["success"] is False + assert payload["status"] == "auth_failed" + assert payload["requiredFields"] == ["AUTHENTIK_TOKEN"] + assert "token ongeldig of verlopen" in payload["message"] + assert "API Token" in payload["message"] + assert secret not in str(payload) + + +def test_unraid_partial_config_update_preserves_url_and_secret(api_client): + secret = "UNRAID_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/unraid", + json={"url": "https://unraid.local", "verifyTls": True, "apiKey": secret}, + ) + + payload = api_client.patch( + "/api/settings/connectors/unraid", + json={"verifyTls": False}, + ).json() + + assert payload["configured"] is True + assert payload["hasSecret"] is True + assert payload["safeConfig"] == {"url": "https://unraid.local", "verifyTls": False} + assert secret not in str(payload) + + +def test_setup_test_prowlarr_uses_connector_test(api_client, monkeypatch): + secret = "PROWLARR_SUPER_SECRET" + api_client.patch( + "/api/settings/connectors/prowlarr", + json={"url": "http://prowlarr.local:9696", "verifyTls": False, "apiKey": secret}, + ) + + monkeypatch.setattr( + "backend.app.connectors.prowlarr.ProwlarrConnector.test_connection", + lambda self: {"status": "connected", "version": "1.2.3"}, + raising=True, + ) + + payload = api_client.post("/api/setup/test/connector/prowlarr").json() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["connected"] is True + assert payload["settings"]["safeConfig"]["url"] == "http://prowlarr.local:9696" + assert secret not in str(payload) + + +def test_setup_test_prowlarr_duplicate_radarr_url_is_actionable(api_client, monkeypatch): + prowlarr_secret = "PROWLARR_SUPER_SECRET" + radarr_secret = "RADARR_SUPER_SECRET" + shared_url = "http://media.local:7878" + api_client.patch( + "/api/settings/connectors/radarr", + json={"url": shared_url, "verifyTls": False, "apiKey": radarr_secret}, + ) + api_client.patch( + "/api/settings/connectors/prowlarr", + json={"url": shared_url, "verifyTls": False, "apiKey": prowlarr_secret}, + ) + + monkeypatch.setattr( + "backend.app.connectors.prowlarr.ProwlarrConnector.test_connection", + lambda self: {"status": "error", "reason": "unauthorized"}, + raising=True, + ) + + payload = api_client.post("/api/setup/test/connector/prowlarr").json() + + assert payload["success"] is False + assert payload["status"] == "url_mismatch" + assert payload["requiredFields"] == ["PROWLARR_URL", "PROWLARR_API_KEY"] + assert "Radarr" in payload["message"] + assert "Prowlarr" in payload["message"] + assert prowlarr_secret not in str(payload) + assert radarr_secret not in str(payload) + + +def test_complete_setup_sets_first_run_completed(api_client): + payload = api_client.post("/api/setup/complete").json() + + assert payload["success"] is True + assert payload["status"]["firstRunCompleted"] is True + assert payload["status"]["setupCompletedAt"] diff --git a/backend/tests/test_setup_reconciliation.py b/backend/tests/test_setup_reconciliation.py new file mode 100644 index 0000000..b5bcadb --- /dev/null +++ b/backend/tests/test_setup_reconciliation.py @@ -0,0 +1,535 @@ +from datetime import UTC, datetime +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, Session, create_engine, select + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "setup_reconcile.db" + data_dir = tmp_path / "data" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APPOPS_DATA_DIR", str(data_dir)) + monkeypatch.setenv("APPOPS_ENV", "production") + monkeypatch.setenv("APPOPS_PORT", "1216") + monkeypatch.setenv("APPOPS_SECRET_KEY", "test-secret-key-with-enough-length") + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + for name in [ + "PORTAINER_URL", + "PORTAINER_API_KEY", + "PORTAINER_ENDPOINT_ID", + "UNRAID_URL", + "UNRAID_API_KEY", + "NPM_URL", + "NPM_IDENTITY", + "NPM_SECRET", + "AUTHENTIK_URL", + "AUTHENTIK_TOKEN", + "HOME_ASSISTANT_URL", + "HOME_ASSISTANT_TOKEN", + ]: + monkeypatch.delenv(name, raising=False) + + from backend.app.main import app + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + + settings.db_path = str(db_path) + settings.data_dir = str(data_dir) + settings.environment = "production" + settings.port = 1216 + settings.secret_key = "test-secret-key-with-enough-length" + settings.secret_key_source = "env" + settings.voice_token = "test-voice-token" + for name in [ + "PORTAINER_URL", + "PORTAINER_API_KEY", + "PORTAINER_ENDPOINT_ID", + "UNRAID_URL", + "UNRAID_API_KEY", + "NPM_URL", + "NPM_IDENTITY", + "NPM_SECRET", + "AUTHENTIK_URL", + "AUTHENTIK_TOKEN", + "HOME_ASSISTANT_URL", + "HOME_ASSISTANT_TOKEN", + ]: + settings.apis[name] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + try: + with TestClient(app) as client: + yield client + finally: + settings.environment = "development" + settings.port = 1216 + settings.secret_key = None + settings.secret_key_source = "missing" + settings.voice_token = None + + +def _make_required_complete() -> None: + from backend.app.db import session as db_session + from backend.app.db.models import App, AppContainerMapping, Connector, DiscoveryRun + + with Session(db_session.engine) as session: + portainer = session.get(Connector, "portainer") + portainer.status = "connected" + session.add(portainer) + run = DiscoveryRun( + id="run-required-complete", + source="portainer", + finished_at=datetime.now(UTC).replace(tzinfo=None), + status="success", + containers_found=1, + apps_matched=1, + ) + session.add(run) + app = session.get(App, "widefrog") + app.needs_review = False + app.connector_status = "connected" + app.container_status = "running" + session.add(app) + session.add( + AppContainerMapping( + id="mapping-widefrog", + app_id="widefrog", + container_id="container-widefrog", + container_name="widefrog", + last_seen_run_id=run.id, + ) + ) + session.commit() + + +def test_required_complete_optional_missing_is_live_ready_after_reconcile(api_client): + _make_required_complete() + + result = api_client.post("/api/setup/reconcile").json() + status = result["after"] + + assert result["success"] is True + assert status["firstRunCompleted"] is True + assert status["setupComplete"] is True + assert status["liveReady"] is True + assert status["coverageComplete"] is False + assert status["securityCoveragePartial"] is True + assert status["requiredStepsComplete"] is True + assert status["reviewBlockers"] == 0 + assert status["managedApps"] == len(api_client.get("/api/apps").json()) + assert status["summary"]["appsManaged"] == status["managedApps"] + assert "nginx_proxy_manager" in status["missingOptionalSources"] + assert "AUTHENTIK_TOKEN" not in str(status) + + +def test_setup_counts_current_container_names_not_historical_docker_ids(api_client): + from backend.app.db import session as db_session + from backend.app.db.models import AppContainerMapping, DiscoveredContainer, DiscoveryRun + from backend.app.services.inventory_discovery import invalidate_discovery_cache + + with Session(db_session.engine) as session: + old_run = DiscoveryRun( + id="run-old-container-id", + source="portainer", + started_at=datetime(2026, 1, 1, 10, 0, 0), + finished_at=datetime(2026, 1, 1, 10, 1, 0), + status="success", + ) + current_run = DiscoveryRun( + id="run-current-container-id", + source="portainer", + started_at=datetime(2026, 1, 2, 10, 0, 0), + finished_at=datetime(2026, 1, 2, 10, 1, 0), + status="success", + ) + session.add(old_run) + session.add(current_run) + session.add(DiscoveredContainer( + id="discovered-old-id", + discovery_run_id=old_run.id, + container_id="docker-old-id", + name="recreated-app", + image="local/recreated:old", + state="exited", + status="exited", + import_status="unmapped", + )) + session.add(DiscoveredContainer( + id="discovered-current-id", + discovery_run_id=current_run.id, + container_id="docker-current-id", + name="recreated-app", + image="local/recreated:current", + state="running", + status="running", + import_status="managed", + matched_app_id="widefrog", + )) + session.add(AppContainerMapping( + id="mapping-recreated-current", + app_id="widefrog", + container_id="docker-current-id", + container_name="recreated-app", + last_seen_run_id=current_run.id, + )) + session.commit() + + invalidate_discovery_cache() + status = api_client.get("/api/setup/status").json() + + assert status["summary"]["containersDiscovered"] == 1 + assert status["summary"]["appsNeedReview"] == 0 + assert status["needsReview"] == status["summary"]["appsNeedReview"] + + +def test_apps_without_registered_connector_use_none_status(api_client): + apps = {app["id"]: app for app in api_client.get("/api/apps").json()} + + assert apps["gitea"]["connectorStatus"] == "none" + assert apps["jellyfin"]["connectorStatus"] == "none" + + +def test_missing_optional_unraid_npm_auth_does_not_block_required_setup(api_client): + _make_required_complete() + status = api_client.post("/api/setup/reconcile").json()["after"] + + assert status["setupComplete"] is True + assert status["liveReady"] is True + assert status["coverageComplete"] is False + assert {step["id"]: step["status"] for step in status["steps"]}["unraid"] == "warning" + assert {step["id"]: step["status"] for step in status["steps"]}["connectors"] == "warning" + + +def test_connector_status_sync_applies_stored_settings_once(api_client, monkeypatch): + _make_required_complete() + from backend.app.db import session as db_session + from backend.app.services import inventory_discovery, setup_service + + calls = 0 + + def apply_once(session): + nonlocal calls + calls += 1 + + monkeypatch.setattr(setup_service, "apply_stored_connector_settings", apply_once) + with Session(db_session.engine) as session: + result = inventory_discovery.sync_app_connector_statuses(session) + + assert result["success"] is True + assert calls == 1 + + +def test_status_sync_refreshes_running_and_ignored_mapped_apps(api_client): + _make_required_complete() + from backend.app.db import session as db_session + from backend.app.db.models import App, AppContainerMapping, DiscoveredContainer + from backend.app.services import inventory_discovery + + with Session(db_session.engine) as session: + widefrog = session.get(App, "widefrog") + widefrog.status = "warning" + widefrog.container_status = "exited" + session.add(widefrog) + gitea = session.get(App, "gitea") + gitea.status = "warning" + gitea.container_status = "exited" + session.add(gitea) + session.add( + DiscoveredContainer( + id="discovered-widefrog-running", + discovery_run_id="run-required-complete", + container_id="container-widefrog", + name="widefrog", + image="widefrog:test", + state="running", + status="healthy", + import_status="managed", + matched_app_id="widefrog", + ) + ) + session.add( + DiscoveredContainer( + id="discovered-widefrog-retired-helper", + discovery_run_id="run-required-complete", + container_id="container-widefrog-retired-helper", + name="widefrog-retired-helper", + image="widefrog-helper:test", + state="missing", + status="missing", + import_status="missing", + matched_app_id="widefrog", + ) + ) + session.add( + AppContainerMapping( + id="mapping-widefrog-retired-helper", + app_id="widefrog", + container_id="container-widefrog-retired-helper", + container_name="widefrog-retired-helper", + last_seen_run_id="run-required-complete", + ) + ) + session.add( + DiscoveredContainer( + id="discovered-gitea-migrate", + discovery_run_id="run-required-complete", + container_id="container-gitea-migrate", + name="gitea-migrate", + image="gitea-migrate:test", + state="exited", + status="unhealthy", + import_status="ignored", + matched_app_id="gitea", + ) + ) + session.add( + AppContainerMapping( + id="mapping-gitea-migrate", + app_id="gitea", + container_id="container-gitea-migrate", + container_name="gitea-migrate", + last_seen_run_id="run-required-complete", + ) + ) + session.commit() + + inventory_discovery.invalidate_discovery_cache() + with Session(db_session.engine) as session: + result = inventory_discovery.sync_app_connector_statuses(session) + widefrog = session.get(App, "widefrog") + gitea = session.get(App, "gitea") + + assert result["success"] is True + assert widefrog.status == "healthy" + assert widefrog.container_status == "running" + assert gitea.status == "healthy" + assert gitea.container_status == "ignored" + + +def test_latest_discovery_cache_reuses_short_lived_snapshot(api_client, monkeypatch): + _make_required_complete() + from backend.app.db import session as db_session + from backend.app.services import inventory_discovery + + calls = 0 + original_exec = Session.exec + + def counting_exec(self, statement, *args, **kwargs): + nonlocal calls + if "discovered_containers" in str(statement): + calls += 1 + return original_exec(self, statement, *args, **kwargs) + + inventory_discovery.invalidate_discovery_cache() + monkeypatch.setattr(Session, "exec", counting_exec) + with Session(db_session.engine) as session: + first = inventory_discovery._latest_by_container(session) + second = inventory_discovery._latest_by_container(session) + inventory_discovery.invalidate_discovery_cache() + third = inventory_discovery._latest_by_container(session) + + assert len(first) == len(second) == len(third) + assert calls == 2 + + +def test_connectors_list_is_snapshot_by_default(api_client, monkeypatch): + from backend.app.api import routes_connectors + + def fail_if_live_tested(self): + raise AssertionError("plain connector list should not run live connector tests") + + monkeypatch.setattr(routes_connectors.PortainerConnector, "test_connection", fail_if_live_tested) + + response = api_client.get("/api/connectors") + + assert response.status_code == 200 + assert any(item["id"] == "portainer" for item in response.json()) + + +def test_setup_status_is_fast_snapshot_without_connector_sync(api_client, monkeypatch): + _make_required_complete() + from backend.app.services import inventory_discovery + + def fail_if_synced(session): + raise AssertionError("setup status should not sync connector statuses") + + monkeypatch.setattr(inventory_discovery, "sync_app_connector_statuses", fail_if_synced) + + payload = api_client.get("/api/setup/status").json() + + assert payload["requiredStepsComplete"] is True + assert payload["managedApps"] >= 1 + + +def test_voice_step_warns_when_home_assistant_connector_is_missing(api_client): + _make_required_complete() + status = api_client.post("/api/setup/reconcile").json()["after"] + steps = {step["id"]: step["status"] for step in status["steps"]} + + assert status["liveReady"] is True + assert "home_assistant" in status["missingOptionalSources"] + assert steps["voice"] == "warning" + + +def test_home_assistant_connector_settings_use_token_secret_contract(api_client): + payload = api_client.get("/api/settings/connectors").json() + home_assistant = next(item for item in payload if item["connectorId"] == "home_assistant") + + assert home_assistant["secretRef"] == "HOME_ASSISTANT_TOKEN" + assert home_assistant["missing"] == ["HOME_ASSISTANT_URL", "HOME_ASSISTANT_TOKEN"] + assert "HOME_ASSISTANT_API_KEY" not in str(home_assistant) + + +def test_reconcile_sets_first_run_completed_and_audit(api_client): + from backend.app.db import session as db_session + from backend.app.db.models import AuditEvent + + _make_required_complete() + payload = api_client.post("/api/setup/reconcile").json() + + assert payload["before"]["firstRunCompleted"] is False + assert payload["after"]["firstRunCompleted"] is True + assert payload["after"]["setupCompletedAt"] + with Session(db_session.engine) as session: + audit = session.exec(select(AuditEvent).where(AuditEvent.action_id == "setup.reconcile")).first() + assert audit is not None + assert audit.result == "success" + + +def test_reconcile_with_blockers_does_not_set_first_run_completed(api_client): + payload = api_client.post("/api/setup/reconcile").json() + + assert payload["success"] is False + assert payload["after"]["firstRunCompleted"] is False + assert payload["after"]["setupComplete"] is False + assert payload["blockers"] + + +def test_mark_complete_requires_confirm(api_client): + response = api_client.post("/api/setup/mark-complete", json={"confirm": "wrong"}) + + assert response.status_code == 400 + assert response.json()["detail"] == "confirm_required" + + +def test_mark_complete_writes_audit(api_client): + from backend.app.db import session as db_session + from backend.app.db.models import AuditEvent + + payload = api_client.post("/api/setup/mark-complete", json={"confirm": "MARK_SETUP_COMPLETE"}).json() + + assert payload["success"] is True + assert payload["status"]["firstRunCompleted"] is True + with Session(db_session.engine) as session: + audit = session.exec(select(AuditEvent).where(AuditEvent.action_id == "setup.mark_complete")).first() + assert audit is not None + assert audit.result == "success" + + +def test_runtime_info_uses_setup_status(api_client): + _make_required_complete() + api_client.post("/api/setup/reconcile") + + runtime = api_client.get("/api/runtime/info").json() + + assert runtime["setupComplete"] is True + assert runtime["liveReady"] is True + assert runtime["firstRunCompleted"] is True + assert runtime["coverageComplete"] is False + assert runtime["canonicalPort"] == 1216 + assert runtime["setupStatus"]["setupComplete"] is True + assert "nginx_proxy_manager" in runtime["missingOptionalSources"] + assert "test-secret-key" not in str(runtime) + + +def test_setup_status_does_not_return_secret_values(api_client): + secret = "PORTAINER_RECONCILE_SECRET" + api_client.patch( + "/api/settings/connectors/portainer", + json={"url": "http://portainer.local", "endpointId": "2", "verifyTls": False, "apiKey": secret}, + ) + + payload = api_client.get("/api/setup/status").json() + + assert secret not in str(payload) + + +def test_existing_first_run_completed_preserved_after_init_db(api_client): + from backend.app.db.init_db import init_db + + _make_required_complete() + api_client.post("/api/setup/reconcile") + init_db() + + payload = api_client.get("/api/setup/status").json() + assert payload["firstRunCompleted"] is True + + +def test_completed_first_run_keeps_live_ready_with_later_review_items(api_client): + from backend.app.db import session as db_session + from backend.app.db.models import DiscoveredContainer + + _make_required_complete() + api_client.post("/api/setup/reconcile") + with Session(db_session.engine) as session: + session.add( + DiscoveredContainer( + id="dc-new-after-setup", + discovery_run_id="run-required-complete", + container_id="container-new-after-setup", + name="new-lab-app", + image="local/new-lab-app:latest", + state="running", + status="running", + import_status="unmapped", + suggested_app_type="unknown", + suggested_category="unknown", + suggested_criticality="low", + suggested_connector="none", + suggested_policy_profile="generic_unknown", + ) + ) + session.commit() + from backend.app.services.inventory_discovery import invalidate_discovery_cache + + invalidate_discovery_cache() + + status = api_client.get("/api/setup/status").json() + + assert status["firstRunCompleted"] is True + assert status["setupComplete"] is True + assert status["liveReady"] is True + assert status["requiredStepsComplete"] is True + assert status["reviewBlockers"] == 0 + assert status["summary"]["appsNeedReview"] == 1 + assert status["needsReview"] == 1 + review_step = {step["id"]: step for step in status["steps"]}["review"] + assert review_step["status"] == "complete" + assert review_step["label"] == "Reviewbaseline vastgelegd" + + +def test_notification_rules_do_not_reset_first_run_completed(api_client): + from backend.app.db import session as db_session + from backend.app.db.init_db import init_db + from backend.app.db.models import NotificationRule + + _make_required_complete() + api_client.post("/api/setup/reconcile") + with Session(db_session.engine) as session: + rule = session.get(NotificationRule, "notify-coverage-incomplete") + rule.channels_json = ["dashboard", "home_assistant"] + session.add(rule) + session.commit() + init_db() + + payload = api_client.get("/api/setup/status").json() + assert payload["firstRunCompleted"] is True diff --git a/backend/tests/test_sqlite_runtime.py b/backend/tests/test_sqlite_runtime.py new file mode 100644 index 0000000..de602a7 --- /dev/null +++ b/backend/tests/test_sqlite_runtime.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from sqlmodel import SQLModel + +from backend.app.db.session import create_app_engine + + +def test_sqlite_engine_waits_for_busy_database_and_uses_wal(tmp_path): + db_path = tmp_path / "runtime.db" + engine = create_app_engine(Path(db_path)) + SQLModel.metadata.create_all(engine) + + with engine.connect() as connection: + busy_timeout = connection.exec_driver_sql("PRAGMA busy_timeout").scalar_one() + journal_mode = str(connection.exec_driver_sql("PRAGMA journal_mode").scalar_one()).lower() + + assert int(busy_timeout) >= 30000 + assert journal_mode == "wal" + + +def test_sqlite_engine_allows_ui_request_bursts(tmp_path): + db_path = tmp_path / "runtime.db" + engine = create_app_engine(Path(db_path)) + + assert engine.pool.size() >= 20 + assert engine.pool._max_overflow >= 20 + assert engine.pool._timeout <= 10 diff --git a/backend/tests/test_tautulli_connector.py b/backend/tests/test_tautulli_connector.py new file mode 100644 index 0000000..e404881 --- /dev/null +++ b/backend/tests/test_tautulli_connector.py @@ -0,0 +1,325 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "tautulli_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.core.config import settings + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + previous_apis = dict(settings.apis) + for key in ["TAUTULLI_URL", "TAUTULLI_API_KEY", "TAUTULLI_VERIFY_TLS", "TAUTULLI_TIMEOUT_SECONDS"]: + settings.apis[key] = None + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + settings.apis = previous_apis + + +def _set_tautulli_config(url: str | None = "http://tautulli:8181", api_key: str | None = "TAUTULLI_SECRET_KEY"): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis["TAUTULLI_URL"] = url + settings.apis["TAUTULLI_API_KEY"] = api_key + settings.apis["TAUTULLI_VERIFY_TLS"] = False + settings.apis["TAUTULLI_TIMEOUT_SECONDS"] = "10" + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def _api_payload(data, result="success", message="ok"): + return {"response": {"result": result, "message": message, "data": data}} + + +def _server_info(): + return _api_payload({ + "tautulli_version": "2.14.0", + "pms_name": "Widefrog Plex", + "pms_version": "1.41.0", + "pms_platform": "Linux", + "pms_identifier": "machine-abcdef123456", + "pms_is_plexpass": True, + }) + + +def _activity(): + return _api_payload({ + "stream_count": 2, + "lan_bandwidth": 12000, + "wan_bandwidth": 4500, + "total_bandwidth": 16500, + "sessions": [ + { + "session_key": "1", + "friendly_name": "Jens", + "full_title": "Direct Movie", + "media_type": "movie", + "player": "Apple TV", + "platform": "tvOS", + "state": "playing", + "progress_percent": 55, + "quality_profile": "1080p", + "transcode_decision": "direct play", + "video_decision": "direct play", + "audio_decision": "direct play", + "bandwidth": 12000, + "ip_address": "198.18.10.55", + "location": "lan", + }, + { + "session_key": "2", + "username": "Remote", + "full_title": "Transcoded Episode", + "media_type": "episode", + "player": "Chrome", + "platform": "Windows", + "state": "playing", + "progress_percent": 10, + "quality_profile": "720p", + "transcode_decision": "transcode", + "video_decision": "transcode", + "audio_decision": "copy", + "bandwidth": 4500, + "ip_address": "203.0.113.9", + "location": "wan", + }, + ], + }) + + +def _history(): + return _api_payload({"data": [ + {"row_id": 10, "friendly_name": "Jens", "full_title": "Arrival", "media_type": "movie", "watched_status": 1, "date": 1700000000, "duration": 7200, "platform": "tvOS", "transcode_decision": "direct play"}, + {"row_id": 11, "username": "Remote", "full_title": "Example Show - Pilot", "media_type": "episode", "watched_status": 0, "date": 1700000500, "duration": 1800, "platform": "Web", "transcode_decision": "transcode"}, + ]}) + + +def _users(): + return _api_payload([ + {"user_id": 1, "username": "jens", "friendly_name": "Jens", "email": "secret@example.test", "thumb": "http://avatar", "last_seen": 1700000000, "plays": 42, "duration": 15000}, + {"user_id": 2, "username": "remote", "friendly_name": "Remote", "last_seen": 1700000500, "plays": 7, "duration": 2400}, + ]) + + +def _libraries(): + return _api_payload([ + {"section_id": 1, "section_name": "Movies", "section_type": "movie", "count": 1200, "parent_count": 0, "child_count": 0, "last_accessed": 1700000000}, + {"section_id": 2, "section_name": "Shows", "section_type": "show", "count": 120, "parent_count": 120, "child_count": 1200}, + ]) + + +def _recently_added(): + return _api_payload({"recently_added": [ + {"title": "Dune", "media_type": "movie", "section_name": "Movies", "added_at": 1700000000, "year": 2024, "thumb": "/library/metadata/1/thumb"}, + {"title": "Pilot", "grandparent_title": "Example Show", "media_type": "episode", "section_name": "Shows", "added_at": 1700000500, "year": 2025}, + ]}) + + +def _plays_by_date(): + return _api_payload({"data": [{"date": "2026-06-24", "plays": 3, "duration": 3000}, {"date": "2026-06-25", "plays": 5, "duration": 6000}]}) + + +def _top_users(): + return _api_payload({"data": [{"friendly_name": "Jens", "plays": 8, "duration": 9000}]}) + + +def _top_platforms(): + return _api_payload({"data": [{"platform": "tvOS", "plays": 6}, {"platform": "Web", "plays": 2}]}) + + +def test_tautulli_connector_missing_config_reports_required_env(api_client): + response = api_client.post("/api/connectors/tautulli/test") + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is False + assert payload["status"] == "missing_config" + assert payload["missing_config"] is True + assert payload["required_env_vars"] == ["TAUTULLI_URL", "TAUTULLI_API_KEY"] + assert payload["secretRef"] == "TAUTULLI_API_KEY" + + +def test_tautulli_health_success_normalizes_response(monkeypatch): + from backend.app.connectors.tautulli import TautulliConnector + + previous = _set_tautulli_config() + monkeypatch.setattr(TautulliConnector, "_command", lambda self, cmd, params=None: _server_info(), raising=True) + + payload = TautulliConnector().health() + + assert payload["success"] is True + assert payload["status"] == "connected" + assert payload["tautulliVersion"] == "2.14.0" + assert payload["plexServerName"] == "Widefrog Plex" + assert payload["url"] == "http://tautulli:8181" + assert "TAUTULLI_SECRET_KEY" not in str(payload) + + _restore_config(previous) + + +def test_tautulli_auth_failure_redacts_api_key(monkeypatch): + from backend.app.connectors.tautulli import TautulliConnector + + previous = _set_tautulli_config() + + def fail_command(self, cmd, params=None): + raise RuntimeError("auth_failed apikey=TAUTULLI_SECRET_KEY") + + monkeypatch.setattr(TautulliConnector, "_command", fail_command, raising=True) + + payload = TautulliConnector().health() + + assert payload["success"] is False + assert payload["status"] == "auth_failed" + assert "TAUTULLI_SECRET_KEY" not in str(payload) + assert "[REDACTED]" in str(payload) + + _restore_config(previous) + + +def test_tautulli_activity_and_stream_summary(monkeypatch): + from backend.app.connectors.tautulli import TautulliConnector + + previous = _set_tautulli_config() + monkeypatch.setattr(TautulliConnector, "_command", lambda self, cmd, params=None: _activity(), raising=True) + + activity = TautulliConnector().activity() + summary = TautulliConnector().stream_summary() + + assert activity["success"] is True + assert activity["streamCount"] == 2 + assert activity["sessions"][0]["ipAddress"] == "192.168.10.x" + assert activity["sessions"][1]["transcodeDecision"] == "transcode" + assert summary["activeStreams"] == 2 + assert summary["directPlay"] == 1 + assert summary["transcoding"] == 1 + assert summary["lanStreams"] == 1 + assert summary["wanStreams"] == 1 + + _restore_config(previous) + + +def test_tautulli_history_users_libraries_recent_and_stats(monkeypatch): + from backend.app.connectors.tautulli import TautulliConnector + + previous = _set_tautulli_config() + + def fake_command(self, cmd, params=None): + return { + "get_history": _history(), + "get_users": _users(), + "get_libraries": _libraries(), + "get_recently_added": _recently_added(), + "get_plays_by_date": _plays_by_date(), + "get_plays_by_top_10_users": _top_users(), + "get_plays_by_top_10_platforms": _top_platforms(), + }[cmd] + + monkeypatch.setattr(TautulliConnector, "_command", fake_command, raising=True) + connector = TautulliConnector() + + history = connector.history() + users = connector.users() + libraries = connector.libraries() + recent = connector.recently_added() + stats = connector.watch_stats() + + assert history["items"][0]["rowId"] == 10 + assert history["items"][1]["transcodeDecision"] == "transcode" + assert users["items"][0]["friendlyName"] == "Jens" + assert "email" not in users["items"][0] + assert "secret@example.test" not in str(users) + assert libraries["items"][0]["sectionId"] == "1" + assert recent["items"][1]["title"] == "Example Show - Pilot" + assert stats["totalPlays"] == 8 + assert stats["topUsers"][0]["name"] == "Jens" + assert stats["topPlatforms"][0]["platform"] == "tvOS" + + _restore_config(previous) + + +def test_tautulli_plex_info_normalizes_server_info(monkeypatch): + from backend.app.connectors.tautulli import TautulliConnector + + previous = _set_tautulli_config() + monkeypatch.setattr(TautulliConnector, "_command", lambda self, cmd, params=None: _server_info(), raising=True) + + payload = TautulliConnector().plex_info() + + assert payload["success"] is True + assert payload["name"] == "Widefrog Plex" + assert payload["version"] == "1.41.0" + assert payload["machineIdentifier"] == "machine-..." + assert payload["plexPass"] is True + + _restore_config(previous) + + +def test_tautulli_privacy_policies_and_destructive_actions_are_blocked(api_client): + for action in ["tautulli.history", "tautulli.users", "tautulli.watch_stats"]: + voice = api_client.post("/api/policies/evaluate", json={"action": action, "target": "tautulli", "source": "voice"}).json() + assert voice["decision"] in {"approval_required", "blocked"} + + for action in [ + "tautulli.terminate_session", + "tautulli.download_database", + "tautulli.download_config", + "tautulli.delete_notifier", + "tautulli.notify", + "tautulli.user_management", + "tautulli.mass_edit", + "tautulli.unknown_mutation", + ]: + payload = api_client.post("/api/policies/evaluate", json={"action": action, "target": "tautulli", "source": "manual"}).json() + assert payload["decision"] == "blocked" + + +def test_tautulli_inventory_classification_and_candidate(api_client, monkeypatch): + from backend.app.services.app_classifier import classify_container + + result = classify_container({"name": "binhex-tautulli", "image": "linuxserver/tautulli:latest", "ports": ["8181:8181"]}) + + assert result["app_type"] == "tautulli" + assert result["category"] == "media" + assert result["subcategory"] == "plex_analytics" + assert result["connector"] == "tautulli" + assert result["policy_profile"] == "media_analytics" + assert result["privacy_sensitive"] is True + + monkeypatch.setattr("backend.app.connectors.portainer.PortainerConnector.test_connection", lambda self: {"status": "connected"}, raising=True) + monkeypatch.setattr( + "backend.app.connectors.portainer.PortainerConnector.list_containers", + lambda self: {"status": "ok", "containers": [{"id": "tautulli-container", "name": "binhex-tautulli", "image": "linuxserver/tautulli", "state": "running", "status": "running", "ports": ["8181:8181"]}]}, + raising=True, + ) + + api_client.post("/api/inventory/discover") + candidates = api_client.get("/api/connectors/tautulli/candidates").json() + + assert len(candidates) == 1 + assert candidates[0]["connectorId"] == "tautulli" + assert candidates[0]["appType"] == "tautulli" + assert candidates[0]["missingSecrets"] == ["TAUTULLI_URL", "TAUTULLI_API_KEY"] diff --git a/backend/tests/test_unraid_connector.py b/backend/tests/test_unraid_connector.py new file mode 100644 index 0000000..7601742 --- /dev/null +++ b/backend/tests/test_unraid_connector.py @@ -0,0 +1,478 @@ +import httpx +import pytest + +from backend.app.connectors.unraid import UnraidConnector +from backend.app.core.config import settings + + +def test_unraid_request_preserves_api_key_for_same_host_https_redirect(monkeypatch): + calls = [] + + class FakeClient: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def post(self, url, *, headers, json): + calls.append((url, headers.copy(), json.copy())) + request = httpx.Request('POST', url) + if len(calls) == 1: + return httpx.Response( + 302, + headers={'location': 'https://example-host:5001/graphql'}, + request=request, + ) + return httpx.Response(200, json={'data': {'__typename': 'Query'}}, request=request) + + monkeypatch.setitem(settings.apis, 'UNRAID_URL', 'http://example-host:5000') + monkeypatch.setitem(settings.apis, 'UNRAID_API_KEY', 'test-api-key') + monkeypatch.setattr('backend.app.connectors.unraid.httpx.Client', FakeClient) + + result = UnraidConnector()._request('query { __typename }') + + assert result == {'__typename': 'Query'} + assert [call[0] for call in calls] == [ + 'http://example-host:5000/graphql', + 'https://example-host:5001/graphql', + ] + assert calls[1][1]['x-api-key'] == 'test-api-key' + + +def test_unraid_request_refuses_cross_host_redirect(monkeypatch): + class FakeClient: + def __init__(self, **_kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def post(self, url, **_kwargs): + return httpx.Response( + 302, + headers={'location': 'https://attacker.example/graphql'}, + request=httpx.Request('POST', url), + ) + + monkeypatch.setitem(settings.apis, 'UNRAID_URL', 'http://example-host:5000') + monkeypatch.setitem(settings.apis, 'UNRAID_API_KEY', 'test-api-key') + monkeypatch.setattr('backend.app.connectors.unraid.httpx.Client', FakeClient) + + with pytest.raises(RuntimeError, match='untrusted redirect'): + UnraidConnector()._request('query { __typename }') + + +def test_unraid_disk_warnings_maps_live_array_disk_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "array": { + "disks": [ + { + "name": "disk1", + "status": "DISK_OK", + "warning": None, + "critical": None, + "fsUsed": 2_097_152, + "fsFree": 1_048_576, + "fsSize": 3_145_728, + }, + { + "name": "disk2", + "status": "DISK_DSBL", + "warning": "SMART warning", + "critical": None, + }, + ] + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_disk_warnings() + + assert result["status"] == "ok" + assert result["diskWarnings"] == 1 + assert result["disks"][0] | {"type": None, "temperature": None} == { + "name": "disk1", + "status": "DISK_OK", + "healthy": True, + "type": None, + "usedGiB": 2, + "sizeGiB": 3, + "freeGiB": 1, + "temperature": None, + } + assert result["disks"][1]["name"] == "disk2" + assert result["disks"][1]["healthy"] is False + + +def test_unraid_share_summary_maps_live_share_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "shares": [ + { + "name": "media", + "used": 2_097_152, + "free": 1_048_576, + "size": 0, + }, + { + "name": "backups", + "used": 10, + "free": 5, + "size": 20, + }, + ] + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_share_summary() + + assert result["status"] == "ok" + assert result["shares"] == [ + {"name": "media", "usedGiB": 2, "sizeGiB": 3, "critical": False}, + {"name": "backups", "usedGiB": 10, "sizeGiB": 20, "critical": False}, + ] + + +def test_unraid_docker_status_maps_live_docker_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "docker": { + "containers": [ + {"id": "1", "names": ["plex"], "state": "running"}, + {"id": "2", "names": ["radarr"], "state": "exited"}, + ], + "networks": [{"id": "n1", "name": "bridge"}], + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_docker_service_status() + + assert result["status"] == "ok" + assert result["running"] is True + assert result["serviceStatus"] == "running" + assert result["containers"] == 2 + + +def test_unraid_vm_summary_maps_live_vms_domains_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(queries): + assert "vms { domains" in queries[0] + return ({"vms": {"domains": [{"id": "vm-1", "uuid": "uuid-1", "name": "Windows", "state": "RUNNING"}]}}, "ok") + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_vm_summary() + + assert result == {"status": "ok", "vms": [{"id": "vm-1", "name": "Windows", "state": "RUNNING"}]} + + +def test_unraid_vm_summary_does_not_claim_vm_manager_is_disabled(monkeypatch): + connector = UnraidConnector() + monkeypatch.setattr(connector, "_query", lambda _queries: ({"error": "GraphQL error: Failed to retrieve VM domains: VMs are not available"}, "error")) + + result = connector.get_vm_summary() + + assert result["status"] == "api_unavailable" + assert "vm:read" in result["reason"] + assert "kan nog steeds actief zijn" in result["reason"] + assert result["vms"] == [] + + +def test_unraid_vm_mutation_uses_escaped_graphql_id(monkeypatch): + connector = UnraidConnector() + monkeypatch.setattr(connector, "is_configured", lambda: True) + captured = {} + + def fake_request(query): + captured["query"] = query + return {"vm": {"start": True}} + + monkeypatch.setattr(connector, "_request", fake_request) + + result = connector.mutate_vm('server:uuid-"quoted"', "start") + + assert result["success"] is True + assert result["status"] == "ok" + assert 'start(id: "server:uuid-\\\"quoted\\\"")' in captured["query"] + + +def test_unraid_vm_mutation_blocks_unsafe_actions_without_request(monkeypatch): + connector = UnraidConnector() + monkeypatch.setattr(connector, "_request", lambda _query: (_ for _ in ()).throw(AssertionError("must not call Unraid"))) + + result = connector.mutate_vm("vm-1", "forceStop") + + assert result == { + "success": False, + "status": "blocked", + "message": "unsupported_or_blocked_vm_action", + "action": "forcestop", + } + + +def test_unraid_query_stops_after_vm_api_reports_unavailable(monkeypatch): + connector = UnraidConnector() + calls = [] + + def fake_request(query): + calls.append(query) + raise RuntimeError("GraphQL error: Failed to retrieve VM domains: VMs are not available") + + monkeypatch.setattr(connector, "_request", fake_request) + payload, status = connector._query(["vm-query", "legacy-fallback"]) + + assert status == "unavailable" + assert "VMs are not available" in payload["error"] + assert calls == ["vm-query"] + + +def test_unraid_cache_status_maps_live_array_cache_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "array": { + "caches": [ + {"name": "cache", "fsUsed": 25, "fsSize": 100}, + {"name": "cache2", "fsUsed": 10, "fsSize": 100}, + ] + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_cache_status() + + assert result["status"] == "ok" + assert result["cacheUsage"] == 17 + assert [pool["name"] for pool in result["pools"]] == ["cache", "cache2"] + + +def test_unraid_cache_status_returns_cache_pool_details(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "array": { + "caches": [ + { + "name": "cache", + "status": "DISK_OK", + "fsUsed": 25, + "fsFree": 75, + "fsSize": 100, + "type": "CACHE", + }, + { + "name": "fastpool", + "status": "DISK_DSBL", + "fsUsed": 90, + "fsFree": 10, + "fsSize": 100, + "warning": "pool warning", + }, + ] + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_cache_status() + + assert result["status"] == "ok" + assert result["cacheUsage"] == 57 + assert result["pools"] == [ + { + "name": "cache", + "status": "DISK_OK", + "healthy": True, + "type": "CACHE", + "usedGiB": 25, + "sizeGiB": 100, + "freeGiB": 75, + "usagePercent": 25, + }, + { + "name": "fastpool", + "status": "DISK_DSBL", + "healthy": False, + "type": None, + "usedGiB": 90, + "sizeGiB": 100, + "freeGiB": 10, + "usagePercent": 90, + }, + ] + + +def test_unraid_parity_status_uses_live_parity_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(queries): + assert "parityCheckStatus" in queries[0] + assert "parities" in queries[0] + return ( + { + "array": { + "parityCheckStatus": { + "status": "IDLE", + "running": False, + "paused": False, + "correcting": False, + "progress": 100, + "speed": 0, + "errors": 3, + }, + "parities": [{"name": "parity", "status": "DISK_OK", "warning": None, "critical": None}], + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_parity_status() + + assert result["status"] == "ok" + assert result["parityStatus"] == "valid" + assert result["checkStatus"] == "idle" + assert result["errors"] == 3 + + +def test_unraid_parity_status_reports_running_check(monkeypatch): + connector = UnraidConnector() + monkeypatch.setattr( + connector, + "_query", + lambda _queries: ( + { + "array": { + "parityCheckStatus": { + "status": "RUNNING", + "running": None, + "paused": False, + "correcting": False, + "progress": 42, + "speed": 1234, + "errors": 0, + }, + "parities": [{"name": "parity", "status": "DISK_OK"}], + } + }, + "ok", + ), + ) + + result = connector.get_parity_status() + + assert result["parityStatus"] == "checking" + assert result["running"] is True + assert result["progress"] == 42 + + +def test_unraid_parity_status_is_invalid_for_unhealthy_or_missing_parity(monkeypatch): + connector = UnraidConnector() + + for parities in ([{"name": "parity", "status": "DISK_DSBL"}], []): + monkeypatch.setattr( + connector, + "_query", + lambda _queries, value=parities: ({"array": {"parities": value}}, "ok"), + ) + result = connector.get_parity_status() + assert result["parityStatus"] == "invalid" + + +def test_unraid_system_metrics_maps_live_metrics_schema(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "metrics": { + "cpu": { + "cpus": [ + {"percentIdle": 80.0, "percentUser": 10.0, "percentSystem": 10.0}, + {"percentIdle": 60.0, "percentUser": 25.0, "percentSystem": 15.0}, + ] + }, + "memory": { + "total": 1000, + "used": 640, + "free": 360, + "available": 300, + }, + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_system_metrics() + + assert result["status"] == "ok" + assert result["cpuUsage"] == 30 + assert result["ramUsage"] == 70 + assert result["sources"] == { + "cpu": "metrics.cpu.cpus.percentIdle", + "ram": "metrics.memory.available_total", + } + + +def test_unraid_system_metrics_reports_partial_instead_of_inventing_zero(monkeypatch): + connector = UnraidConnector() + + def fake_query(_queries): + return ( + { + "metrics": { + "cpu": {"cpus": []}, + "memory": {"total": 1000, "used": 920}, + } + }, + "ok", + ) + + monkeypatch.setattr(connector, "_query", fake_query) + + result = connector.get_system_metrics() + + assert result["status"] == "partial" + assert result["cpuUsage"] is None + assert result["ramUsage"] == 92 + assert "cpu_unavailable" in result["reason"] diff --git a/backend/tests/test_unraid_vm_actions.py b/backend/tests/test_unraid_vm_actions.py new file mode 100644 index 0000000..b47ef07 --- /dev/null +++ b/backend/tests/test_unraid_vm_actions.py @@ -0,0 +1,154 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "unraid_vm_actions.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + + from backend.app.db import session + from backend.app.db.init_db import init_db + from backend.app.main import app + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def test_vm_capabilities_expose_safe_execution_boundary(api_client): + response = api_client.get("/api/unraid/vms/capabilities") + + assert response.status_code == 200 + payload = response.json() + actions = {item["id"]: item for item in payload["actions"]} + assert actions["start"]["policyDecision"] == "allowed" + assert actions["start"]["executionSupported"] is True + assert actions["pause"]["policyDecision"] == "approval_required" + assert actions["pause"]["executionSupported"] is True + assert actions["stop"]["policyDecision"] == "blocked" + assert actions["stop"]["executionSupported"] is False + assert actions["force_stop"]["policyDecision"] == "blocked" + + +def test_manual_vm_start_runs_job_postcheck_and_audit(api_client, monkeypatch): + from backend.app.services import unraid_vm_service + + inventory_calls = {"count": 0} + + def fake_inventory(_self): + inventory_calls["count"] += 1 + state = "SHUTOFF" if inventory_calls["count"] == 1 else "RUNNING" + return {"status": "ok", "vms": [{"id": "server:vm-1", "name": "Test VM", "state": state}]} + + monkeypatch.setattr(unraid_vm_service.UnraidConnector, "get_vm_summary", fake_inventory) + monkeypatch.setattr( + unraid_vm_service.UnraidConnector, + "mutate_vm", + lambda _self, vm_id, action: { + "success": vm_id == "server:vm-1" and action == "start", + "status": "ok", + "message": "vm_start_accepted", + }, + ) + + response = api_client.post( + "/api/unraid/vms/server%3Avm-1/actions/start", + json={"source": "manual", "confirm": True}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["success"] is True + assert payload["decision"] == "allowed" + assert payload["before_state"]["state"] == "SHUTOFF" + assert payload["after_state"] == {"state": "RUNNING", "verified": True} + assert payload["jobId"].startswith("job-") + assert payload["auditId"].startswith("audit-") + + jobs = api_client.get("/api/jobs").json() + assert any(job["id"] == payload["jobId"] and job["status"] == "succeeded" for job in jobs) + + audits = api_client.get("/api/audit").json() + audit = next(item for item in audits if item["id"] == payload["auditId"]) + assert audit["action_id"] == "unraid.vm.start" + assert audit["target_type"] == "virtual_machine" + + +def test_vm_start_requires_explicit_confirmation(api_client): + response = api_client.post( + "/api/unraid/vms/vm-1/actions/start", + json={"source": "manual", "confirm": False}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "confirm_required" + + +def test_vm_pause_creates_executable_approval(api_client, monkeypatch): + from backend.app.services import unraid_vm_service + + inventory_calls = {"count": 0} + + def fake_inventory(_self): + inventory_calls["count"] += 1 + state = "RUNNING" if inventory_calls["count"] == 1 else "PAUSED" + return {"status": "ok", "vms": [{"id": "vm-2", "name": "Workload", "state": state}]} + + monkeypatch.setattr(unraid_vm_service.UnraidConnector, "get_vm_summary", fake_inventory) + monkeypatch.setattr( + unraid_vm_service.UnraidConnector, + "mutate_vm", + lambda _self, vm_id, action: { + "success": vm_id == "vm-2" and action == "pause", + "status": "ok", + "message": "vm_pause_accepted", + }, + ) + + requested = api_client.post( + "/api/unraid/vms/vm-2/actions/pause", + json={"source": "manual", "confirm": True}, + ).json() + assert requested["decision"] == "approval_required" + assert requested["approvalId"].startswith("appr-") + + approval_id = requested["approvalId"] + approved = api_client.patch(f"/api/approvals/{approval_id}", json={"status": "approved"}) + assert approved.status_code == 200 + + executed = api_client.post(f"/api/approvals/{approval_id}/execute") + assert executed.status_code == 200 + payload = executed.json() + assert payload["success"] is True + assert payload["approvalStatus"] == "executed" + assert payload["after_state"] == {"state": "PAUSED", "verified": True} + + +def test_vm_stop_is_blocked_without_calling_connector(api_client, monkeypatch): + from backend.app.services import unraid_vm_service + + monkeypatch.setattr( + unraid_vm_service.UnraidConnector, + "mutate_vm", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("blocked action reached connector")), + ) + + response = api_client.post( + "/api/unraid/vms/vm-3/actions/stop", + json={"source": "manual", "confirm": True}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["decision"] == "blocked" + assert payload["result"] == "blocked" + assert "force-stop" in payload["message"] diff --git a/backend/tests/test_widefrog_connector.py b/backend/tests/test_widefrog_connector.py new file mode 100644 index 0000000..0805c2d --- /dev/null +++ b/backend/tests/test_widefrog_connector.py @@ -0,0 +1,274 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import SQLModel, create_engine + + +@pytest.fixture +def api_client(tmp_path, monkeypatch): + db_path = tmp_path / "widefrog_test.db" + monkeypatch.setenv("APPOPS_DB_PATH", str(db_path)) + monkeypatch.setenv("APP_OPS_VOICE_TOKEN", "test-voice-token") + + from backend.app.main import app + from backend.app.db import session + from backend.app.db.init_db import init_db + + engine = create_engine(f"sqlite:///{Path(db_path)}", connect_args={"check_same_thread": False}) + session.engine = engine + SQLModel.metadata.drop_all(engine) + SQLModel.metadata.create_all(engine) + init_db() + + with TestClient(app) as client: + yield client + + +def _set_widefrog_config(url: str | None = "http://widefrog:1207", api_key: str | None = None): + from backend.app.core.config import settings + + previous = dict(settings.apis) + settings.apis['WIDEFROG_URL'] = url + settings.apis['WIDEFROG_API_KEY'] = api_key + settings.apis['WIDEFROG_VERIFY_TLS'] = False + settings.apis['WIDEFROG_TIMEOUT_SECONDS'] = 10 + return previous + + +def _restore_config(previous): + from backend.app.core.config import settings + + settings.apis.clear() + settings.apis.update(previous) + + +def test_widefrog_connector_missing_config_reports_required_env(api_client): + response = api_client.post('/api/connectors/widefrog/test') + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['status'] == 'missing_config' + assert payload['missing_config'] is True + assert payload['required_env_vars'] == ['WIDEFROG_URL'] + assert payload['secretRef'] == 'WIDEFROG_API_KEY' + + +def test_widefrog_health_success_normalizes_response(monkeypatch): + from backend.app.connectors.widefrog import WidefrogConnector + + previous = _set_widefrog_config(api_key='WIDEFROG_SECRET_TOKEN') + + def fake_request(self, method, path, **_kwargs): + assert method == 'GET' + assert path == '/api/health' + return {'status': 'ok', 'version': '1.2.3', 'checks': [{'name': 'db', 'status': 'ok'}]} + + monkeypatch.setattr(WidefrogConnector, '_request_json', fake_request, raising=True) + + payload = WidefrogConnector().health() + + assert payload['success'] is True + assert payload['status'] == 'connected' + assert payload['version'] == '1.2.3' + assert payload['port'] == 1207 + assert payload['checks'] == [{'name': 'db', 'status': 'ok'}] + assert 'WIDEFROG_SECRET_TOKEN' not in str(payload) + + _restore_config(previous) + + +def test_widefrog_unreachable_redacts_secret(monkeypatch): + from backend.app.connectors.widefrog import WidefrogConnector + + previous = _set_widefrog_config(api_key='WIDEFROG_SECRET_TOKEN') + + def fail_request(self, method, path, **_kwargs): + raise RuntimeError('api_key=WIDEFROG_SECRET_TOKEN connection refused') + + monkeypatch.setattr(WidefrogConnector, '_request_json', fail_request, raising=True) + + payload = WidefrogConnector().health() + + assert payload['success'] is False + assert payload['status'] in {'unreachable', 'error'} + assert 'WIDEFROG_SECRET_TOKEN' not in str(payload) + assert '[REDACTED]' in str(payload) + + _restore_config(previous) + + +def test_widefrog_queue_unsupported_is_not_fake_success(monkeypatch): + from backend.app.connectors.widefrog import WidefrogConnector + + previous = _set_widefrog_config() + + def fake_request(self, method, path, **_kwargs): + raise RuntimeError('unsupported_endpoint') + + monkeypatch.setattr(WidefrogConnector, '_request_json', fake_request, raising=True) + + payload = WidefrogConnector().queue_status() + + assert payload['success'] is False + assert payload['status'] in {'unsupported', 'unavailable'} + assert payload['total'] == 0 + assert payload['items'] == [] + + _restore_config(previous) + + +def test_widefrog_queue_success_normalizes_summary(monkeypatch): + from backend.app.connectors.widefrog import WidefrogConnector + + previous = _set_widefrog_config() + + def fake_request(self, method, path, **_kwargs): + assert path == '/api/queue' + return { + 'items': [ + {'id': 'a', 'title': 'active job', 'status': 'active'}, + {'id': 'q', 'title': 'queued job', 'status': 'queued'}, + {'id': 'c', 'title': 'completed job', 'status': 'completed'}, + {'id': 'f', 'title': 'failed job', 'status': 'failed'}, + ] + } + + monkeypatch.setattr(WidefrogConnector, '_request_json', fake_request, raising=True) + + payload = WidefrogConnector().queue_status() + + assert payload['success'] is True + assert payload['status'] == 'ok' + assert payload['total'] == 4 + assert payload['active'] == 1 + assert payload['queued'] == 1 + assert payload['completed'] == 1 + assert payload['failed'] == 1 + + _restore_config(previous) + + +def test_widefrog_failed_jobs_success_normalizes_items(monkeypatch): + from backend.app.connectors.widefrog import WidefrogConnector + + previous = _set_widefrog_config() + + monkeypatch.setattr( + WidefrogConnector, + '_request_json', + lambda self, method, path, **_kwargs: {'items': [{'id': 'f1', 'title': 'failed', 'status': 'failed'}]}, + raising=True, + ) + + payload = WidefrogConnector().failed_jobs() + + assert payload['success'] is True + assert payload['failed'] == 1 + assert payload['items'][0]['id'] == 'f1' + + _restore_config(previous) + + +def test_widefrog_retry_failed_voice_requires_approval_without_connector_call(api_client, monkeypatch): + def fail_retry(*_args, **_kwargs): + raise AssertionError('connector should not run before approval') + + monkeypatch.setattr('backend.app.connectors.widefrog.WidefrogConnector.retry_failed', fail_retry, raising=True) + + response = api_client.post('/api/apps/widefrog/retry-failed', json={'source': 'voice'}) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'approval_required' + assert payload['approvalId'].startswith('appr-') + assert payload['auditId'].startswith('audit-') + + +def test_widefrog_retry_failed_allowed_creates_job_and_audit(api_client, monkeypatch): + monkeypatch.setattr( + 'backend.app.connectors.widefrog.WidefrogConnector.retry_failed', + lambda self, payload=None: {'success': True, 'status': 'ok', 'message': 'retry_started', 'data': {'retried': 2}}, + raising=True, + ) + + response = api_client.post('/api/apps/widefrog/retry-failed', json={'source': 'manual'}) + + assert response.status_code == 200 + payload = response.json() + assert payload['success'] is True + assert payload['decision'] == 'allowed' + assert payload['status'] == 'success' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + assert payload['data']['retried'] == 2 + + +def test_widefrog_retry_failed_unsupported_is_failed_not_fake_success(api_client, monkeypatch): + monkeypatch.setattr( + 'backend.app.connectors.widefrog.WidefrogConnector.retry_failed', + lambda self, payload=None: {'success': False, 'status': 'unsupported', 'message': 'unsupported_endpoint'}, + raising=True, + ) + + response = api_client.post('/api/apps/widefrog/retry-failed', json={'source': 'manual'}) + + payload = response.json() + assert payload['success'] is False + assert payload['decision'] == 'failed' + assert payload['status'] == 'failed' + assert payload['message'] == 'unsupported_endpoint' + assert payload['jobId'].startswith('job-') + assert payload['auditId'].startswith('audit-') + + +def test_widefrog_scan_library_allowed_and_unsupported_paths(api_client, monkeypatch): + monkeypatch.setattr( + 'backend.app.connectors.widefrog.WidefrogConnector.scan_library', + lambda self, payload=None: {'success': True, 'status': 'ok', 'message': 'scan_started', 'data': {'accepted': True}}, + raising=True, + ) + + success = api_client.post('/api/apps/widefrog/scan-library', json={'source': 'manual'}).json() + assert success['success'] is True + assert success['decision'] == 'allowed' + assert success['jobId'].startswith('job-') + + monkeypatch.setattr( + 'backend.app.connectors.widefrog.WidefrogConnector.scan_library', + lambda self, payload=None: {'success': False, 'status': 'unsupported', 'message': 'unsupported_endpoint'}, + raising=True, + ) + + failed = api_client.post('/api/apps/widefrog/scan-library', json={'source': 'manual'}).json() + assert failed['success'] is False + assert failed['decision'] == 'failed' + assert failed['message'] == 'unsupported_endpoint' + + +def test_widefrog_destructive_actions_blocked(api_client): + for action in [ + 'widefrog.delete_job', + 'widefrog.delete_media', + 'widefrog.clear_library', + 'widefrog.clear_queue', + 'widefrog.reset_database', + 'widefrog.delete_downloads', + 'widefrog.prune', + ]: + payload = api_client.post('/api/policies/evaluate', json={'action': action, 'target': 'widefrog', 'source': 'manual'}).json() + assert payload['decision'] == 'blocked' + + +def test_widefrog_inventory_classification_has_connector_and_port(): + from backend.app.services.app_classifier import classify_container + + result = classify_container({'name': 'widefrog', 'image': 'widefrog/app:latest', 'ports': ['1207:1207']}) + + assert result['app_type'] == 'widefrog' + assert result['category'] == 'media' + assert result['connector'] == 'widefrog' + assert result['policy_profile'] == 'media_custom_app' + assert result['healthcheck_suggestion'] == {'port': 1207} diff --git a/backend/tests/test_workmesh_promotion_contract.py b/backend/tests/test_workmesh_promotion_contract.py new file mode 100644 index 0000000..728571e --- /dev/null +++ b/backend/tests/test_workmesh_promotion_contract.py @@ -0,0 +1,34 @@ +from backend.app.services.action_execution import ( + action_execution_profile, + approval_execution_supported, + mcp_execution_supported, +) +from backend.app.services.action_registry import ( + ACTION_RISK, + ACTIONS, + action_default_target, +) +from backend.app.services.policy_engine import evaluate_policy + + +def test_workmesh_promotion_is_fixed_target_human_approved_and_not_mcp_executable(): + action = "deployment.target.promote_workmesh" + + assert action in ACTIONS + assert ACTION_RISK[action] == "high" + assert action_default_target(action) == "jens/workmesh" + assert evaluate_policy(None, action, "jens/workmesh", "manual")["decision"] == "approval_required" + assert approval_execution_supported(action) + assert not mcp_execution_supported(action) + assert action_execution_profile(action, "approval_required", "operator") == { + "executionMode": "approval_then_execute", + "executionSupported": True, + "commandExecutionSupported": False, + "executionSurface": "/approvals", + } + + +def test_workmesh_promotion_action_does_not_generalize_to_another_target(): + # The product executor resolves its fixed target from persisted preflight + # state; callers cannot alter it through the shared action registry. + assert action_default_target("deployment.target.promote_workmesh") == "jens/workmesh" diff --git a/components.json b/components.json new file mode 100644 index 0000000..62e1011 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..822313e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +name: unraid-appops-gateway +services: + appops-gateway: + build: . + image: unraid-appops-gateway:local + ports: ["127.0.0.1:1216:1216"] + environment: + APPOPS_REQUIRE_OPERATOR_AUTH: "true" + APPOPS_OPERATOR_TOKEN: "${APPOPS_OPERATOR_TOKEN:?Set a strong unique operator token}" + APPOPS_SECRET_KEY: "${APPOPS_SECRET_KEY:?Set a persistent encryption key}" + APPOPS_ENABLE_MCP: "false" + APPOPS_ENABLE_N8N: "false" + volumes: ["./data:/data"] + restart: unless-stopped diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..febb5f7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +DATA_DIR="${APPOPS_DATA_DIR:-/data}" +mkdir -p "$DATA_DIR" + +if [ "${APPOPS_REQUIRE_OPERATOR_AUTH:-false}" = "true" ] && [ -z "${APPOPS_OPERATOR_TOKEN:-}" ]; then + OPERATOR_TOKEN_FILE="${APPOPS_OPERATOR_TOKEN_FILE:-${DATA_DIR}/operator-token}" + if [ ! -s "$OPERATOR_TOKEN_FILE" ]; then + umask 077 + python -c 'import secrets; print(secrets.token_urlsafe(48))' > "$OPERATOR_TOKEN_FILE" + fi + chmod 600 "$OPERATOR_TOKEN_FILE" +fi + +generate_service_token() { + enabled="$1" + token_value="$2" + token_file="$3" + if [ "$enabled" = "true" ] && [ -z "$token_value" ]; then + if [ ! -s "$token_file" ]; then + umask 077 + python -c 'import secrets; print(secrets.token_urlsafe(48))' > "$token_file" + fi + chmod 600 "$token_file" + fi +} + +generate_service_token "${APPOPS_ENABLE_MCP:-false}" "${APPOPS_MCP_TOKEN:-}" "${APPOPS_MCP_TOKEN_FILE:-${DATA_DIR}/mcp-token}" +generate_service_token "${APPOPS_ENABLE_N8N:-false}" "${APPOPS_N8N_TOKEN:-}" "${APPOPS_N8N_TOKEN_FILE:-${DATA_DIR}/n8n-token}" +generate_service_token "true" "${APP_OPS_VOICE_TOKEN:-}" "${APP_OPS_VOICE_TOKEN_FILE:-${DATA_DIR}/voice-token}" + +OWNER_MARKER="${DATA_DIR}/.appops-owner-v1" +if [ ! -f "$OWNER_MARKER" ]; then + chown -R appops:appops "$DATA_DIR" + touch "$OWNER_MARKER" +fi +chown appops:appops "$DATA_DIR" "$OWNER_MARKER" +for token_file in \ + "${APPOPS_OPERATOR_TOKEN_FILE:-${DATA_DIR}/operator-token}" \ + "${APP_OPS_VOICE_TOKEN_FILE:-${DATA_DIR}/voice-token}" \ + "${APPOPS_MCP_TOKEN_FILE:-${DATA_DIR}/mcp-token}" \ + "${APPOPS_N8N_TOKEN_FILE:-${DATA_DIR}/n8n-token}"; do + [ ! -e "$token_file" ] || chown appops:appops "$token_file" +done + +exec gosu appops "$@" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9dd057a --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-unused-vars": "off", + }, + }, +); diff --git a/index.html b/index.html new file mode 100644 index 0000000..8969866 --- /dev/null +++ b/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + Unraid AppOps Gateway + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..48404c6 --- /dev/null +++ b/package.json @@ -0,0 +1,95 @@ +{ + "name": "unraid-appops-gateway-frontend", + "version": "1.8.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "test": "tsx --test src/**/*.test.ts", + "test:frontend": "tsx --test src/**/*.test.ts", + "test:backend": "python -m pytest backend/tests -q", + "verify": "pnpm run check-types && pnpm run lint && pnpm run test:frontend && pnpm run build && pnpm run test:backend", + "audit:dependencies": "pnpm audit --prod --audit-level=high", + "sbom": "pnpm sbom --sbom-format cyclonedx --sbom-spec-version 1.6 --prod > appops-gateway.cdx.json", + "preview": "vite preview", + "check-types": "tsc -p tsconfig.app.json --noEmit" + }, + "dependencies": { + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/manrope": "^5.2.8", + "@fontsource/space-grotesk": "^5.2.10", + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-aspect-ratio": "^1.1.0", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.0", + "@radix-ui/react-context-menu": "^2.2.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-hover-card": "^1.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.1", + "@radix-ui/react-navigation-menu": "^1.2.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-primitive": "^2.0.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "@tanstack/query-core": "^5.56.2", + "@tanstack/react-query": "^5.56.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.3.0", + "input-otp": "^1.2.4", + "lucide-react": "^0.462.0", + "next-themes": "^0.4.6", + "react": "^19.0.0", + "react-day-picker": "^9.13.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.53.0", + "react-resizable-panels": "^2.1.3", + "react-router-dom": "^7.18.2", + "recharts": "^2.12.7", + "sonner": "^1.5.0", + "tailwind-merge": "^2.5.2", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^3.25.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@tailwindcss/typography": "^0.5.15", + "@types/node": "^22.5.5", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react-swc": "^4.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.5", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", + "globals": "^15.9.0", + "postcss": "8.5.26", + "tailwindcss": "^3.4.11", + "tsx": "^4.23.1", + "typescript": "^5.5.3", + "typescript-eslint": "^8.64.0", + "vite": "6.4.3" + }, + "packageManager": "pnpm@11.15.0+sha512.266f8957a30d2be6e9468e5e66bcdedd35a794175f71b067ba8504d686cce1d0c0f429b33c323c3c569ad4891e667574a49ff71d1b89a22cc66f13c65818c578" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..b6007cb --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5601 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + brace-expansion@>=2.0.0 <2.0.3: 2.0.3 + brace-expansion@>=5.0.0 <5.0.9: 5.0.9 + flatted@<3.4.2: 3.4.2 + glob@>=10.2.0 <10.5.0: 10.5.0 + lodash@<=4.17.23: 4.18.1 + minimatch@>=9.0.0 <9.0.7: 9.0.7 + nanoid@>=3.0.0 <3.3.18: 3.3.18 + picomatch@<2.3.2: 2.3.2 + postcss@<8.5.23: 8.5.26 + yaml@>=2.0.0 <2.8.3: 2.9.0 + +importers: + + .: + dependencies: + '@fontsource/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/manrope': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/space-grotesk': + specifier: ^5.2.10 + version: 5.2.10 + '@hookform/resolvers': + specifier: ^3.9.0 + version: 3.10.0(react-hook-form@7.55.0(react@19.2.3)) + '@radix-ui/react-accordion': + specifier: ^1.2.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-aspect-ratio': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-avatar': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-checkbox': + specifier: ^1.1.1 + version: 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collapsible': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-context-menu': + specifier: ^2.2.1 + version: 2.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dialog': + specifier: ^1.1.2 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.1 + version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-hover-card': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-label': + specifier: ^2.1.0 + version: 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-menubar': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-navigation-menu': + specifier: ^1.2.0 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popover': + specifier: ^1.1.1 + version: 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': + specifier: ^2.0.0 + version: 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-progress': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-radio-group': + specifier: ^1.2.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-scroll-area': + specifier: ^1.1.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-select': + specifier: ^2.1.1 + version: 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-separator': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slider': + specifier: ^1.2.0 + version: 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': + specifier: ^1.1.0 + version: 1.2.0(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-switch': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tabs': + specifier: ^1.1.0 + version: 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toast': + specifier: ^1.2.1 + version: 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle-group': + specifier: ^1.1.0 + version: 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tooltip': + specifier: ^1.1.4 + version: 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/query-core': + specifier: ^5.56.2 + version: 5.101.0 + '@tanstack/react-query': + specifier: ^5.56.2 + version: 5.74.4(react@19.2.3) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.0.0 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + date-fns: + specifier: ^3.6.0 + version: 3.6.0 + embla-carousel-react: + specifier: ^8.3.0 + version: 8.6.0(react@19.2.3) + input-otp: + specifier: ^1.2.4 + version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + lucide-react: + specifier: ^0.462.0 + version: 0.462.0(react@19.2.3) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: ^19.0.0 + version: 19.2.3 + react-day-picker: + specifier: ^9.13.0 + version: 9.13.0(react@19.2.3) + react-dom: + specifier: ^19.0.0 + version: 19.2.3(react@19.2.3) + react-hook-form: + specifier: ^7.53.0 + version: 7.55.0(react@19.2.3) + react-resizable-panels: + specifier: ^2.1.3 + version: 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-router-dom: + specifier: ^7.18.2 + version: 7.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + recharts: + specifier: ^2.12.7 + version: 2.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + sonner: + specifier: ^1.5.0 + version: 1.7.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + tailwind-merge: + specifier: ^2.5.2 + version: 2.6.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@3.4.17) + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + zod: + specifier: ^3.25.0 + version: 3.25.76 + devDependencies: + '@eslint/js': + specifier: ^9.39.5 + version: 9.39.5 + '@tailwindcss/typography': + specifier: ^0.5.15 + version: 0.5.16(tailwindcss@3.4.17) + '@types/node': + specifier: ^22.5.5 + version: 22.14.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.8 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.8) + '@vitejs/plugin-react-swc': + specifier: ^4.3.1 + version: 4.3.1(vite@6.4.3(@types/node@22.14.1)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0)) + autoprefixer: + specifier: ^10.4.20 + version: 10.4.21(postcss@8.5.26) + eslint: + specifier: ^9.39.5 + version: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + eslint-plugin-react-hooks: + specifier: ^5.1.0-rc.0 + version: 5.2.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0)) + eslint-plugin-react-refresh: + specifier: ^0.4.9 + version: 0.4.19(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0)) + globals: + specifier: ^15.9.0 + version: 15.15.0 + postcss: + specifier: 8.5.26 + version: 8.5.26 + tailwindcss: + specifier: ^3.4.11 + version: 3.4.17 + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^5.5.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.64.0 + version: 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + vite: + specifier: 6.4.3 + version: 6.4.3(@types/node@22.14.1)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.6.9': + resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} + + '@floating-ui/dom@1.6.13': + resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.9': + resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + + '@fontsource/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} + + '@fontsource/manrope@5.2.8': + resolution: {integrity: sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA==} + + '@fontsource/space-grotesk@5.2.10': + resolution: {integrity: sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew==} + + '@hookform/resolvers@3.10.0': + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + peerDependencies: + react-hook-form: ^7.0.0 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.2': + resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-accordion@1.2.4': + resolution: {integrity: sha512-SGCxlSBaMvEzDROzyZjsVNzu9XY5E28B3k8jOENyrz6csOv/pG1eHyYfLJai1n9tRjwG61coXDhfpgtxKxUv5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.7': + resolution: {integrity: sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.3': + resolution: {integrity: sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.3': + resolution: {integrity: sha512-yIrYZUc2e/JtRkDpuJCmaR6kj/jzekDfQLcPFdEWzSOygCPy8poR4YcszaHP5A7mh25ncofHEpeTwfhxEuBv8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.4': + resolution: {integrity: sha512-+kBesLBzwqyDiYCtYFK+6Ktf+N7+Y6QOTUueLGLIbLZ/YeyFW6bsBGDsN+5HxHpM55C90u5fxsg0ErxzXTcwKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.1.5': + resolution: {integrity: sha512-B0gYIVxl77KYDR25AY9EGe/G//ef85RVBIxQvK+m5pxAC7XihAc/8leMHhDvjvhDu02SBSb6BuytlWr/G7F3+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.4': + resolution: {integrity: sha512-u7LCw1EYInQtBNLGjm9nZ89S/4GcvX1UR5XbekEgnQae2Hkpq39ycJ1OhdeN1/JDfVNG91kWaWoest127TaEKQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.3': + resolution: {integrity: sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.7': + resolution: {integrity: sha512-EwO3tyyqwGaLPg0P64jmIKJnBywD0yjiL1eRuMPyhUXPkWWpa5JPDS+oyeIWHy2JbhF+NUlfUPVq6vE7OqgZww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dialog@1.1.7': + resolution: {integrity: sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.6': + resolution: {integrity: sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.7': + resolution: {integrity: sha512-7/1LiuNZuCQE3IzdicGoHdQOHkS2Q08+7p8w6TXZ6ZjgAULaCI85ZY15yPl4o4FVgoKLRT43/rsfNVN8osClQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.2': + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-scope@1.1.3': + resolution: {integrity: sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.7': + resolution: {integrity: sha512-HwM03kP8psrv21J1+9T/hhxi0f5rARVbqIZl9+IAq13l4j4fX+oGIuxisukZZmebO7J35w9gpoILvtG8bbph0w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.3': + resolution: {integrity: sha512-zwSQ1NzSKG95yA0tvBMgv6XPHoqapJCcg9nsUBaQQ66iRBhZNhlpaQG2ERYYX4O4stkYFK5rxj5NsWfO9CS+Hg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.7': + resolution: {integrity: sha512-tBODsrk68rOi1/iQzbM54toFF+gSw/y+eQgttFflqlGekuSebNqvFNHjJgjqPhiMb4Fw9A0zNFly1QT6ZFdQ+Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.7': + resolution: {integrity: sha512-YB2zFhGdZ5SWEgRS+PgrF7EkwpsjEHntIFB/LRbT49LJdnIeK/xQQyuwLiRcOCgTDN+ALlPXQ08f0P0+TfR41g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.6': + resolution: {integrity: sha512-HJqyzqG74Lj7KV58rk73i/B1nnopVyCfUmKgeGWWrZZiCuMNcY0KKugTrmqMbIeMliUnkBUDKCy9J6Mzl6xeWw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.7': + resolution: {integrity: sha512-I38OYWDmJF2kbO74LX8UsFydSHWOJuQ7LxPnTefjxxvdvPLempvAnmsyX9UsBlywcbSGpRH7oMLfkUf+ij4nrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.3': + resolution: {integrity: sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.5': + resolution: {integrity: sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.3': + resolution: {integrity: sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.0.3': + resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.5': + resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.3': + resolution: {integrity: sha512-F56aZPGTPb4qJQ/vDjnAq63oTu/DRoIG/Asb5XKOWj8rpefNLtUllR969j5QDN2sRrTk9VXIqQDRj5VvAuquaw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.2.4': + resolution: {integrity: sha512-oLz7ATfKgVTUbpr5OBu6Q7hQcnV22uPT306bmG0QwgnKqBStR98RfWfJGCfW/MmhL4ISmrmmBPBW+c77SDwV9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.3': + resolution: {integrity: sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.4': + resolution: {integrity: sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.1.7': + resolution: {integrity: sha512-exzGIRtc7S8EIM2KjFg+7lJZsH7O7tpaBaJbBNVDnOZNhtoQ2iV+iSNfi2Wth0m6h3trJkMVvzAehB3c6xj/3Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.3': + resolution: {integrity: sha512-2omrWKJvxR0U/tkIXezcc1nFMwtLU0+b/rDK40gnzJqTLWQ/TD/D5IYVefp9sC3QWfeQbpSbEA6op9MQKyaALQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.2.4': + resolution: {integrity: sha512-Vr/OgNejNJPAghIhjS7Mf/2F/EXGDT0qgtiHf2BHz71+KqgN+jndFLKq5xAB9JOGejGzejfJLIvT04Do+yzhcg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.0': + resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.5': + resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.1.4': + resolution: {integrity: sha512-zGP6W8plLeogoeGMiTHJ/uvf+TE1C2chVsEwfP8YlvpQKJHktG+iCkUtCLGPAuDV8/qDSmIRPm4NggaTxFMVBQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.4': + resolution: {integrity: sha512-fuHMHWSf5SRhXke+DbHXj2wVMo+ghVH30vhX3XVacdXqDl+J4XWafMIGOOER861QpBx1jxgwKXL2dQnfrsd8MQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.7': + resolution: {integrity: sha512-0IWTbAUKvzdpOaWDMZisXZvScXzF0phaQjWspK8RUMEUxjLbli+886mB/kXTIC3F+t5vQ0n0vYn+dsX8s+WdfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.3': + resolution: {integrity: sha512-khTzdGIxy8WurYUEUrapvj5aOev/tUA8TDEFi1D0Dn3yX+KR5AqjX0b7E5sL9ngRRpxDN2RRJdn5siasu5jtcg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.3': + resolution: {integrity: sha512-Za5HHd9nvsiZ2t3EI/dVd4Bm/JydK+D22uHKk46fPtvuPxVCJBUo5mQybN+g5sZe35y7I6GDTTfdkVv5SnxlFg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.0': + resolution: {integrity: sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.1.1': + resolution: {integrity: sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.1.3': + resolution: {integrity: sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@swc/core-darwin-arm64@1.15.30': + resolution: {integrity: sha512-VvpP+vq08HmGYewMWvrdsxh9s2lthz/808zXm8Yu5kaqeR8Yia2b0eYXleHQ3VAjoStUDk6LzTheBW9KXYQdMA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.30': + resolution: {integrity: sha512-WiJA0hiZI3nwQAO6mu5RqigtWGDtth4Hiq6rbZxAaQyhIcqKIg5IoMRc1Y071lrNJn29eEDMC86Rq58xgUxlDg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.30': + resolution: {integrity: sha512-YANuFUo48kIT6plJgCD0keae9HFXfjxsbvsgevqc0hr/07X/p7sAWTFOGYEc2SXcASaK7UvuQqzlbW8pr7R79g==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.30': + resolution: {integrity: sha512-VndG8jaR4ugY6u+iVOT0Q+d2fZd7sLgjPgN8W/Le+3EbZKl+cRfFxV7Eoz4gfLqhmneZPdcIzf9T3LkgkmqNLg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.30': + resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.30': + resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.30': + resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.30': + resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.30': + resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.30': + resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.30': + resolution: {integrity: sha512-30VdLeGk6fugiUs/kUdJ/pAg7z/zpvVbR11RH60jZ0Z42WIeIniYx0rLEWN7h/pKJ3CopqsQ3RsogCAkRKiA2g==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.30': + resolution: {integrity: sha512-4iObHPR+Q4oDY110EF5SF5eIaaVJNpMdG9C0q3Q92BsJ5y467uHz7sYQhP60WYlLFsLQ1el2YrIPUItUAQGOKg==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.30': + resolution: {integrity: sha512-R8VQbQY1BZcbIF2p3gjlTCwAQzx1A194ugWfwld5y+WgVVWqVKm7eURGGOVbQVubgKWzidP2agomBbg96rZilQ==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + + '@tailwindcss/typography@0.5.16': + resolution: {integrity: sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/query-core@5.74.4': + resolution: {integrity: sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A==} + + '@tanstack/react-query@5.74.4': + resolution: {integrity: sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q==} + peerDependencies: + react: ^18 || ^19 + + '@types/d3-array@3.2.1': + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.7': + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.14.1': + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.8': + resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + + '@typescript-eslint/eslint-plugin@8.64.0': + resolution: {integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.64.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.64.0': + resolution: {integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.64.0': + resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.64.0': + resolution: {integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.64.0': + resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.64.0': + resolution: {integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.64.0': + resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.64.0': + resolution: {integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.64.0': + resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react-swc@4.3.1': + resolution: {integrity: sha512-PaeokKjAGraNN+s5SIApgsktnJprIyt3zgEIu7awnEdfn29QiB2crTcCzyi2XGpX9rUnTc0cKU07Wm0N0g7H2w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 || ^8 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: 8.5.26 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.138: + resolution: {integrity: sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.19: + resolution: {integrity: sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.2.2: + resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==} + engines: {node: '>=6.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.castarray@4.4.0: + resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lucide-react@0.462.0: + resolution: {integrity: sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.7: + resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: 8.5.26 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: 8.5.26 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: 8.5.26 + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: 8.5.26 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-day-picker@9.13.0: + resolution: {integrity: sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-hook-form@7.55.0: + resolution: {integrity: sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.6.3: + resolution: {integrity: sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-resizable-panels@2.1.7: + resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + react-router-dom@7.18.2: + resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.2: + resolution: {integrity: sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==} + engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonner@1.7.4: + resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@2.6.0: + resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@3.4.17: + resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.64.0: + resolution: {integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: 2.9.0 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@date-fns/tz@1.4.1': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))': + dependencies: + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2(supports-color@7.2.0)': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.0(supports-color@7.2.0) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6(supports-color@7.2.0)': + dependencies: + ajv: 6.15.0 + debug: 4.4.0(supports-color@7.2.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.6.9': + dependencies: + '@floating-ui/utils': 0.2.9 + + '@floating-ui/dom@1.6.13': + dependencies: + '@floating-ui/core': 1.6.9 + '@floating-ui/utils': 0.2.9 + + '@floating-ui/react-dom@2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/dom': 1.6.13 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@floating-ui/utils@0.2.9': {} + + '@fontsource/jetbrains-mono@5.2.8': {} + + '@fontsource/manrope@5.2.8': {} + + '@fontsource/space-grotesk@5.2.10': {} + + '@hookform/resolvers@3.10.0(react-hook-form@7.55.0(react@19.2.3))': + dependencies: + react-hook-form: 7.55.0(react@19.2.3) + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.2': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.2': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-accordion@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collapsible': 1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-alert-dialog@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-arrow@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-aspect-ratio@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-avatar@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-checkbox@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-collapsible@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-collection@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-context-menu@2.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-context@1.2.2(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.8)(react@19.2.3) + aria-hidden: 1.2.6 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.7.2(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-dialog@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + aria-hidden: 1.2.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-dismissable-layer@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-dropdown-menu@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-focus-scope@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-hover-card@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-id@1.1.4(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-label@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-menu@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + aria-hidden: 1.2.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-menubar@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-menu': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-navigation-menu@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-popover@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + aria-hidden: 1.2.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-popper@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-arrow': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/rect': 1.1.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-portal@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-presence@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-primitive@2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-progress@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-radio-group@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-roving-focus@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-scroll-area@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-select@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + aria-hidden: 1.2.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-remove-scroll: 2.6.3(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-separator@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-slider@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-slot@1.2.0(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-slot@1.2.5(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-switch@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-tabs@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-toast@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-toggle-group@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-roving-focus': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-toggle': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-toggle@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-tooltip@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-popper': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.0(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-controllable-state@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.8)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.8 + + '@radix-ui/react-visually-hidden@1.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@swc/core-darwin-arm64@1.15.30': + optional: true + + '@swc/core-darwin-x64@1.15.30': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.30': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.30': + optional: true + + '@swc/core-linux-arm64-musl@1.15.30': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.30': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.30': + optional: true + + '@swc/core-linux-x64-gnu@1.15.30': + optional: true + + '@swc/core-linux-x64-musl@1.15.30': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.30': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.30': + optional: true + + '@swc/core-win32-x64-msvc@1.15.30': + optional: true + + '@swc/core@1.15.30': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.30 + '@swc/core-darwin-x64': 1.15.30 + '@swc/core-linux-arm-gnueabihf': 1.15.30 + '@swc/core-linux-arm64-gnu': 1.15.30 + '@swc/core-linux-arm64-musl': 1.15.30 + '@swc/core-linux-ppc64-gnu': 1.15.30 + '@swc/core-linux-s390x-gnu': 1.15.30 + '@swc/core-linux-x64-gnu': 1.15.30 + '@swc/core-linux-x64-musl': 1.15.30 + '@swc/core-win32-arm64-msvc': 1.15.30 + '@swc/core-win32-ia32-msvc': 1.15.30 + '@swc/core-win32-x64-msvc': 1.15.30 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.26': + dependencies: + '@swc/counter': 0.1.3 + + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17)': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.17 + + '@tanstack/query-core@5.101.0': {} + + '@tanstack/query-core@5.74.4': {} + + '@tanstack/react-query@5.74.4(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.74.4 + react: 19.2.3 + + '@types/d3-array@3.2.1': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.7': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.14.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.8)': + dependencies: + '@types/react': 19.2.8 + + '@types/react@19.2.8': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/type-utils': 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.64.0 + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.64.0(supports-color@7.2.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.8.3) + '@typescript-eslint/types': 8.64.0 + debug: 4.4.3(supports-color@7.2.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + + '@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.64.0': {} + + '@typescript-eslint/typescript-estree@8.64.0(supports-color@7.2.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.64.0(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.8.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.64.0 + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@5.8.3) + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react-swc@4.3.1(vite@6.4.3(@types/node@22.14.1)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@swc/core': 1.15.30 + vite: 6.4.3(@types/node@22.14.1)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@swc/helpers' + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.4: + dependencies: + tslib: 2.8.1 + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + autoprefixer@10.4.21(postcss@8.5.26): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001805 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001805 + electron-to-chromium: 1.5.138 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001805: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + date-fns-jalali@4.1.0-0: {} + + date-fns@3.6.0: {} + + date-fns@4.1.0: {} + + debug@4.4.0(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decimal.js-light@2.5.1: {} + + deep-is@0.1.4: {} + + detect-libc@2.1.2: + optional: true + + detect-node-es@1.1.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.27.0 + csstype: 3.2.3 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.138: {} + + embla-carousel-react@8.6.0(react@19.2.3): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.3 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0)): + dependencies: + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + + eslint-plugin-react-refresh@0.4.19(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0)): + dependencies: + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.2(supports-color@7.2.0) + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6(supports-color@7.2.0) + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.2 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.0(supports-color@7.2.0) + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + eventemitter3@4.0.7: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@5.2.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fraction.js@4.3.7: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-nonce@1.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.7 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + globals@15.15.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + input-otp@1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + internmap@2.0.3: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + optional: true + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.castarray@4.4.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.merge@4.6.2: {} + + lodash@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lucide-react@0.462.0(react@19.2.3): + dependencies: + react: 19.2.3 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.7: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + natural-compare@1.4.0: {} + + next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + node-releases@2.0.19: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.10 + + postcss-js@4.0.1(postcss@8.5.26): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.26 + + postcss-load-config@4.0.2(postcss@8.5.26): + dependencies: + lilconfig: 3.1.3 + yaml: 2.9.0 + optionalDependencies: + postcss: 8.5.26 + + postcss-nested@6.2.0(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-day-picker@9.13.0(react@19.2.3): + dependencies: + '@date-fns/tz': 1.4.1 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.3 + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-hook-form@7.55.0(react@19.2.3): + dependencies: + react: 19.2.3 + + react-is@16.13.1: {} + + react-is@18.3.1: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.3): + dependencies: + react: 19.2.3 + react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.3) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + react-remove-scroll@2.6.3(@types/react@19.2.8)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.8)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.8)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + + react-remove-scroll@2.7.2(@types/react@19.2.8)(react@19.2.3): + dependencies: + react: 19.2.3 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.8)(react@19.2.3) + react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.3) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.8)(react@19.2.3) + use-sidecar: 1.1.3(@types/react@19.2.8)(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.8 + + react-resizable-panels@2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + react-router-dom@7.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-router: 7.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + react-router@7.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + react-smooth@4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + fast-equals: 5.2.2 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-transition-group: 4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + react-style-singleton@2.2.3(@types/react@19.2.8)(react@19.2.3): + dependencies: + get-nonce: 1.0.1 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + react-transition-group@4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@babel/runtime': 7.27.0 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + react@19.2.3: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + + regenerator-runtime@0.14.1: {} + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + sonner@1.7.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.5.0 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@2.6.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@3.4.17): + dependencies: + tailwindcss: 3.4.17 + + tailwindcss@3.4.17: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-import: 15.1.0(postcss@8.5.26) + postcss-js: 4.0.1(postcss@8.5.26) + postcss-load-config: 4.0.2(postcss@8.5.26) + postcss-nested: 6.2.0(postcss@8.5.26) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3))(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.64.0(supports-color@7.2.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.64.0(eslint@9.39.5(jiti@1.21.7)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.8.3) + eslint: 9.39.5(jiti@1.21.7)(supports-color@7.2.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.8)(react@19.2.3): + dependencies: + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + use-sidecar@1.1.3(@types/react@19.2.8)(react@19.2.3): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.3 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.8 + + util-deprecate@1.0.2: {} + + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + vite@6.4.3(@types/node@22.14.1)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + jiti: 1.21.7 + lightningcss: 1.32.0 + tsx: 4.23.1 + yaml: 2.9.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..f6e6205 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,383 @@ +allowBuilds: + "@airbnb/node-memwatch": true + "@anthropic-ai/claude-code": true + "@apollo/protobufjs": true + "@apollo/rover": true + "@appsignal/nodejs": true + "@arkweid/lefthook": true + "@aws-amplify/cli": true + "@bahmutov/add-typescript-to-cypress": true + "@bazel/concatjs": true + "@bazel/cypress": true + "@bazel/esbuild": true + "@bazel/hide-bazel-files": true + "@bazel/jasmine": true + "@bazel/protractor": true + "@bazel/rollup": true + "@bazel/terser": true + "@bazel/typescript": true + "@bufbuild/buf": true + "@cdktf/node-pty-prebuilt-multiarch": true + "@ckeditor/ckeditor5-vue": true + "@cloudflare/wrangler": true + "@contrast/fn-inspect": true + "@cubejs-backend/cubestore": true + "@cubejs-backend/native": true + "@cypress/snapshot": true + "@danmarshall/deckgl-typings": true + "@datadog/mobile-react-native": true + "@discordjs/opus": true + "@eversdk/lib-node": true + "@evilmartians/lefthook": true + "@ffmpeg-installer/darwin-arm64": true + "@ffmpeg-installer/darwin-x64": true + "@ffmpeg-installer/linux-arm": true + "@ffmpeg-installer/linux-arm64": true + "@ffmpeg-installer/linux-ia32": true + "@ffmpeg-installer/linux-x64": true + "@ffprobe-installer/darwin-arm64": true + "@ffprobe-installer/darwin-x64": true + "@ffprobe-installer/linux-arm": true + "@ffprobe-installer/linux-arm64": true + "@ffprobe-installer/linux-ia32": true + "@ffprobe-installer/linux-x64": true + "@fingerprintjs/fingerprintjs-pro-react": true + "@ghaiklor/x509": true + "@go-task/cli": true + "@injectivelabs/sdk-ts": true + "@instana/autoprofile": true + "@intlify/vue-i18n-bridge": true + "@intlify/vue-router-bridge": true + "@matteodisabatino/gc_info": true + "@memlab/cli": true + "@microsoft.azure/autorest-core": true + "@microsoft/teamsfx-cli": true + "@microsoft/ts-command-line": true + "@napi-rs/pinyin": true + "@nativescript/core": true + "@netlify/esbuild": true + "@newrelic/native-metrics": true + "@notarize/qlc-cli": true + "@nx-dotnet/core": true + "@opensearch-project/oui": true + "@pact-foundation/pact-node": true + "@paloaltonetworks/postman-code-generators": true + "@pdftron/pdfnet-node": true + "@percy/core": true + "@pnpm/exe": true + "@prisma/client": true + "@prisma/engines": true + "@progress/kendo-licensing": true + "@pulumi/aws-native": true + "@pulumi/awsx": true + "@pulumi/command": true + "@pulumi/kubernetes": true + "@railway/cli": true + "@replayio/cypress": true + "@replayio/playwright": true + "@roots/bud-framework": true + "@sap/hana-client": true + "@sap/hana-performance-tools": true + "@sap/hana-theme-vscode": true + "@scarf/scarf": true + "@sematext/gc-stats": true + "@sentry/capacitor": true + "@sentry/profiling-node": true + "@serialport/bindings": true + "@serialport/bindings-cpp": true + "@shopify/ngrok": true + "@shopify/plugin-cloudflare": true + "@sitespeed.io/chromedriver": true + "@sitespeed.io/edgedriver": true + "@softvisio/core": true + "@splunk/otel": true + "@strapi/strapi": true + "@sveltejs/kit": true + "@swc/core": true + "@syncfusion/ej2-angular-base": true + "@taquito/taquito": true + "@temporalio/core-bridge": true + "@tensorflow/tfjs-node": true + "@trufflesuite/bigint-buffer": true + "@typescript-tools/rust-implementation": true + "@vaadin/vaadin-usage-statistics": true + "@vscode/ripgrep": true + "@vscode/sqlite3": true + abstract-socket: true + admin-lte: true + appdynamics: true + appium-chromedriver: true + appium-windows-driver: true + applicationinsights-native-metrics: true + argon2: true + autorest: true + aws-crt: true + azure-functions-core-tools: true + azure-streamanalytics-cicd: true + backport: true + bcrypt: true + better-sqlite3: true + bigint-buffer: true + blake-hash: true + bs-platform: true + bufferutil: true + bun: true + canvacord: true + canvas: true + cbor-extract: true + chromedriver: true + chromium: true + classic-level: true + cld: true + cldr-data: true + clevertap-react-native: true + clientjs: true + cmark-gfm: true + compression: true + contentlayer: true + contextify: true + cordova.plugins.diagnostic: true + couchbase: true + cpu-features: true + cwebp-bin: true + cy2: true + cypress: true + dd-trace: true + deasync: true + detox: true + detox-recorder: true + diskusage: true + dotnet-2.0.0: true + dprint: true + drivelist: true + dtrace-provider: true + duckdb: true + dugite: true + eccrypto: true + egg-bin: true + egg-ci: true + electron: true + electron-chromedriver: true + electron-prebuilt: true + electron-winstaller: true + elm: true + elm-format: true + esbuild: true + esoftplay: true + event-loop-stats: true + exifreader: true + farmhash: true + fast-folder-size: true + faunadb: true + ffi: true + ffi-napi: true + ffmpeg-static: true + fibers: true + fmerge: true + free-email-domains: true + fs-xattr: true + full-icu: true + gatsby: true + gc-stats: true + gcstats.js: true + geckodriver: true + gentype: true + ghooks: true + gif2webp-bin: true + gifsicle: true + git-commit-msg-linter: true + git-validate: true + git-win: true + gl: true + go-ios: true + grpc: true + grpc-tools: true + handbrake-js: true + hasura-cli: true + heapdump: true + hiredis: true + hnswlib-node: true + hugo-bin: true + hummus: true + ibm_db: true + iconv: true + iedriver: true + iltorb: true + incremental-json-parser: true + install-peers: true + interruptor: true + iobroker.js-controller: true + iso-constants: true + isolated-vm: true + java: true + jest-preview: true + jpeg-recompress-bin: true + jpegtran-bin: true + keccak: true + kerberos: true + keytar: true + lefthook: true + leveldown: true + libpg-query: true + libpq: true + libxmljs: true + libxmljs2: true + lightningcss-cli: true + lint: true + lmdb: true + lmdb-store: true + local-cypress: true + lz4: true + lzma-native: true + lzo: true + macos-alias: true + mbt: true + memlab: true + microtime: true + minidump: true + mmmagic: true + modern-syslog: true + mongodb-client-encryption: true + mongodb-crypt-library-dummy: true + mongodb-crypt-library-version: true + mongodb-memory-server: true + mozjpeg: true + ms-chromium-edge-driver: true + msgpackr-extract: true + msnodesqlv8: true + msw: true + muhammara: true + netlify-cli: true + ngrok: true + ngx-popperjs: true + nice-napi: true + node: true + node-expat: true + node-hid: true + node-jq: true + node-libcurl: true + node-mac-contacts: true + node-pty: true + node-rdkafka: true + node-sass: true + node-webcrypto-ossl: true + node-zopfli: true + node-zopfli-es: true + nodegit: true + nodejieba: true + nodent-runtime: true + nx: true + odiff-bin: true + oniguruma: true + opencode-ai: true + optipng-bin: true + oracledb: true + os-dns-native: true + parse-server: true + phantomjs: true + phantomjs-prebuilt: true + pkcs11js: true + playwright-chromium: true + playwright-firefox: true + playwright-webkit: true + pngout-bin: true + pngquant-bin: true + posix: true + pprof: true + pre-commit: true + pre-push: true + prisma: true + protoc: true + protoc-gen-grpc-web: true + puppeteer: true + purescript: true + re2: true + react-jsx-parser: true + react-native-stylex: true + react-particles: true + react-tsparticles: true + react-vertical-timeline-component: true + realm: true + redis-memory-server: true + ref: true + ref-napi: true + registry-js: true + robotjs: true + sauce-connect-launcher: true + saucectl: true + secp256k1: true + segfault-handler: true + shared-git-hooks: true + sharp: true + simple-git-hooks: true + sleep: true + slice2js: true + snyk: true + sockopt: true + sodium-native: true + sonar-scanner: true + spago: true + spectron: true + spellchecker: true + sq-native: true + sqlite3: true + sse4_crc32: true + ssh2: true + storage-engine: true + subrequests: true + subrequests-express: true + subrequests-json-merger: true + supabase: true + svf-lib: true + swagger-ui: true + swiftlint: true + taiko: true + tldjs: true + tree-sitter: true + tree-sitter-cli: true + tree-sitter-json: true + tree-sitter-kotlin: true + tree-sitter-typescript: true + tree-sitter-yaml: true + truffle: true + tsparticles-engine: true + ttag-cli: true + ttf2woff2: true + typemoq: true + unix-dgram: true + ursa-optional: true + usb: true + utf-8-validate: true + v8-profiler-next: true + vue-demi: true + vue-echarts: true + vue-inbrowser-compiler-demi: true + wd: true + wdeasync: true + weak-napi: true + webdev-toolkit: true + windows-build-tools: true + wix-style-react: true + wordpos: true + workerd: true + wrtc: true + xxhash: true + yo: true + yorkie: true + zeromq: true + zlib-sync: true + zopflipng-bin: true +packages: + - . +minimumReleaseAge: 1440 +overrides: + "brace-expansion@>=2.0.0 <2.0.3": 2.0.3 + "brace-expansion@>=5.0.0 <5.0.9": 5.0.9 + "flatted@<3.4.2": 3.4.2 + "glob@>=10.2.0 <10.5.0": 10.5.0 + "lodash@<=4.17.23": 4.18.1 + "minimatch@>=9.0.0 <9.0.7": 9.0.7 + "nanoid@>=3.0.0 <3.3.18": 3.3.18 + "picomatch@<2.3.2": 2.3.2 + "postcss@<8.5.23": 8.5.26 + "yaml@>=2.0.0 <2.8.3": 2.9.0 diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bf56b95acf72abec264f489a67c0b7d43a40f2b9 GIT binary patch literal 6015 zcmX|lXH=6-*EL8lp-V@So2v8@0jZ$|k)nc13)Rp&2#6396oPb7dQ(8E(tF3y14Iy{ zBUL)mLN&bMS>O78%r$GSIdk^RoIPh|?I;r?Jpe5?EeQz;Kp(38h*;NL7ivo4S9OR| zmRQkvL(Tn2Na$Fv3n@u@CMO9Ahm5{9#55>#XO707O@~1BH~W5*2uM&+E^RkkfCi+i zM24a&qo$@sHI=<1D3MdFGU-4VI82#no;FBT@-I-Z=SSyoiuKRsJ0XGZT^)sl`6%}GWY8Y1cK%#rZ5HqhAj=eG)uQI z22M;;*4n5Kk>oVbipn6_7u<_(LgE>8>c0kGtY zu~~7lgG9j%_DkL4rxt!Ft0cLJ#VR0d8%gd$MaK38q0tgVPcyn@C6s~P!2d3=5QbR9 zkZ(}#qpC=RzzudR`8wxKZijU*g<%$N$v1>4C>)uK$~p~MrM|8NSz5=<=iP6K;wS_{ z&ZAMNO6sPfPQxUi#EN3%$Gi=VmdV=)ILaUSjGS-cbxIhqofr`zH7yABLvy_8W&%q$ z?oHGUIEvHx8VaKIpd83L5=D~f-LB)0VhaS-{buY7Ba*h~~So;vS{d;*YemPi2Y)|1O9=wQHdDySu;S zza%o$SFi5q3`th4rZop}qLxFMz1qdmTa^Yhz*?=My~ z>`NaqtvX|D=`7T=cDB&WLU_&6fo7i?x;wvI8SxDKnwz=hbfAy}a6X}0G|saWwEJzB zi8-muo33OtEGs$@^C&wJh<6Bddae^&r9kd>SPv=~A%*h!b3dXA)>1R0uTl;-W*2U} z3WL6Ki&Djh#MuPY+BNZi7^aspj7SH-=2PjIZGhxunM3ae)`r2KTWb9 zdjWC$=pSK8Osd?+xaPLg{lg330{@9n9Mi@dA9Xh`e`WHpEt_4!x_U* ze?BPsFi)8PWW5@KQZ15)1ne#9B1T2D{Z3EQ6L3Z94NYEeeii% zjb!>h^{5;@tIVfYru;@a({bKMEv?MMQqJ*NB;{|jXl96k<$;J+SkqTHmA6W34O68t zmK%)dmTu?bT>D_Bry4xN8Zu(W6yl|TF2a7fQk;k7XtjK$b1}(h5h8AW^ECpM-2SLC=Mn9w z2+KR|=5*B8RYB;EUyI@jp$!=6D!kmVmipLRccm#w#ho9`_XTpq$4Dm2gE$`%L>ugX zmPa7pAdyy>g1o_CGq8f13QDnVMzribAkTwN<>MYFF> zMkF3T9F|~XPFslxG1?nL1ZUnlDT#j1gRI@4hHiY!h(FEr!v2&;OpiD^M=1qAc$oQ} zNVGX>w2_z5>EA01n4Rgt{>L2|+nIu=j2U|5x>9zdIi+4J=Drie; zf@uC*_7VvDC&s-A(NoSqo#Gs!JO6H)ZiUQKtyR-Xk5$L;)vS5tD>(9}_I^Mr(K&h9 zS>**nma3D0O}b*6g;zzC9&R$Eqp)uD$VKm^1y^dZCn4@_*_pu;q>_sj1g|lAs%q<^ z5Ys?6hf1~>q9&0mPK#jzBhqc>ZG5ov35PPv&y-_?cQxyCyCa?6Fv(>NPIkHy-uF<( zDY;yFR=nWVnSDD9NpdEvTlP$_AFAo3CJ9U9HX)^5?%3z<9vSVSVZDPwGIBVJ*-zuc zj-0_<%IG1R_l#J@WFz;!Pb~5hL;Q1tlbbSow>hfamQv_znv{?H>tYP^&t@Le`;x*b zEp`&}j{Rvf+F9*>Tne3aU^Uz*(j^=oBd5SLj7qvJIsZl#oB>X<>9NX;dB1ET81b;T z@r*W%usHJfZjk=>WQdMFsW9Y4b-b7>;T>eScRT&Vca72;bMSQd&_do=d$?I6M4MN7 z&8>6fhg-!#pP6&(@BI5%rMHzT-*>NpLQ9CaG69@xO6A^wio+|c-r#VS90SqmO^9UIc6!$?t_0#%8>8xejG%!C#|N)F zsk@?xSRs*Nj_L5h$w2yl+`66(?q$us6w*$f1TN2v8mZhgdPJ)A;}@s`IUcQ25>>Oq z`pS$4igecOmn0)nZR;QTf1e2v#&*K#)DBuj_|NiTk9U%h9Z&_ z^e@NyuUro9)>W-Z6=L^&JFTabiPYn+37hXBYz)-ChAboWqU$SbJqcE7(#nNiPPyaA z?9UnXm1DYpC}SFdF3Su|+5MB|AS`{&G^p3%vH#)S7w4R-xnsTjrvL8NO$_B!Gx;*Z z71o`m#3Ntg?gjB!0@i1Qd2=0w9rV&tv*e`L1JNU48$2~e^F2oVrKHX`y&Ouj`n`?B z$PQyr?he<04|*TyQowA@0Qa2bxSH9(h&JhFhbN;toz(D zs8QOcwe|e*{*gF1_k`8F=S8v{3?E^|H0byJnsCYyb^k)EJn!Co3P}H)Mqg-*wVbod zttI&yZrD9m&2XDa)kQt=xx3SA;^erTj{}{v-0x(o83EQqwUaBep34*Opb>G}X01}v zxfiklLpkkEsb=BA^Oqh{9Myhn?9lar9^4%b=v!7V`hen6KECroe@Htzgw|j)iEMo%0!cQHMo=!?-9QQi#s0GxvREuv3Z|pYAueVXW8L{kzD* zi~ikx(eISHe^6_IlLsRh_)~qBrt$wvS<8ESJU`c@gbtaGtissuh6d>W_j zv2j$63*DjdE_Nr}cbK;luk?Tcud`gK{3f3~_E?zjtw*64L9-NAbv%mzIqZ6l582$t zR{IbDANjW*%)a%6^v;b*hz{ArmVNgwxxpui$i7ahamB~l zCdrlix>ET+D3qtD2zg5I%=PoHQO!7s_4NiVsk$*_nogOfqMBZCMlkvIuK!^IZscNyvm zj#9)Vp0ZBJ&yds*QBi~*KHr|QfeA85PWG#vevL>~r@ELO0pa&KqC#-0>!~q?@(AI) zo;OlX{?Ji5)Ufe>Z35y#W67}n4ef&W37d**I>xFZ-j$1~p3%?y*bS&HsVy(B#C zOKmqe4r&yH^UkM1X2W+Od7{dK?X^$4<*;r1eZBfz)%-8N-6P9rBk*5-C_IczYCGd< z@=~Of1f;9mq^_p973EZoQ8`0ygp}We1V@+;5>LUG-G}tD9=b-jTYZ5aq8t4W{{W`4 zPSnFO3QjD6ww$;qW4Oy}rG|`mrKHw7bJmd1h$-fdYjP{@TSqcoTT}v?T?iU&6Bp8j zhGWzMY~o?BnfmcM=*94r2&Z3 zUhd~>lNf>g+5h#+$F(lpxzipSn^HcGWSEn!YmM3W1+7u=fMom__P0EOaQhNAc?UC- zn;KpsYDsn{O=1pM#me|Vk+4<(vDIMZc zvm`%G>V?9xzEk>Z*Rb+xsTc0fOrAn6G-Q?$kw*_ATFK!)=a0}uE5K_Td81cbq}uJV zJ6p)h*g^yi=TU@Lm@w|r;|>@m*%8A!V`Squ54tLiXE0mn*=^em>d3X3^p?*j z&WV30M*$u@&;7f56I1RriR{temW?X$a%hITM@sw&`zpOA@drWSM4eK(6BfZaufMK! z;xt1Kn9h79CHQ>j%jV4W!JCbKI?$bYv3#=#j1bvhGnPya0OrpbUN%KYIA>xcS&J|3 z;&=RBG%397Z?#cuT zIJadP&iV`Sn3+Ss(X2nc>+lT3k!xm`@$f%JcyOl$)1yshDlUAk4`~@+ugQgnyG3&4 z&T~6h&8?-7_2sXg-x-y>$%`2ps$M)g`+uFb$$!cz=ZoW0Y`@=-H|k>5D!ROT7ce+^ z&?PG^1!MHFh6#oJ<6LLaL8HIY;_cjMUh!o7l*0~Ws87*VdrY&S(FP!fL?r&^dRo4UmhNNxQ`A_`u2k&cn>T)vdSt+F9G z+`|2~;jV45hQ!xT5q0$uK_tS391t4W6Xo_pO8~ps9&6~!Wr>%;h(cF;o0!^-=>2t~ zZIe4E{7QO;_^pW-bEi9($`g(dRk0}09!@bad8RYFF?q;QTr$o)^O#wCdYup?Otw=< z{iD_r={E3!7A^|~BetTYr8=(~*|C9lMdIR+PaeKG6`_hC(tUgU@}@nkiec$q>jPe1 zvag+67Ike?i`6l$eH?4GwxND1xy~X!-t;w;I_MZK8kX1 z$OVg|Y)42gQfn#G`=8l>u=aS_-%0CNycthqw#6gMwavUJ|}IJ=YpBp7u@F&REc!v79Bp$nC82yuY5aNFPVG;WDw6O1Pc zAc-hePDlsU=bMxukeUK%`mE&Q5-2r>dZ|n z%ujEMOT~ThbV{3x?O>dmlUH%zoxOC94u%Hsv91}6;z9 zmsWsyXf=Asu_2OkLVhVg<3r>!>)wiDT=f5Rf&O*S55N_}=ub0{8v%2Yto2~9FzU)P zKi+CdQSwH3PwXN$tr3(Xs%D-cSxq23ZtCD%6wS5=B!nay*)J6!d?=_`Fob+#s8_MS zjb*}@Z#V+-(wtw@e_1c;N-e(`UCxeLBX1NwV;!~mD;()#jK2{)%zZpKNvDpyV|@W3 z&xj)v`h{ZM9*@>&-Wwey9o0DEhJB-CQf;_aju~@t?7d z1!4ozkzl#zuGFu2a8#oa>(Zatqg&NK3)!3{by*UDbHFfPN_3;AUj;+XVqi$JnAi}O$*QR>&^R7!bkZyW>QV>cPYCr77 z;0A;=>2srgpcYyf>g&rNI{PLq7fcf%&42O>KC)$s(v^~acuJN8lw2NAC2EI)P(&rF zO8t&@Ai&eRmc;86 zYevdF@)8|DBz2F8j49e3aG(e)>2~qaPVHugGL_5zqzC`^IDCs4ZYgFoNXCeny7M1d ztfo!4CAvP~2b&*g6WXYtHDr>98~-i*uBMff4clFKh;eg2?X=CyozQ5B2&yFGcqh;$ z4%S2Yzx@O+dgv{E#co%pihX)*t!O?N~4c zP^Q4<_OqolnSO`gMgQ^!{cHqIPJ%IWr_+mW>+?BN)dgRjI81KzUXi)hil82RtUwbL PbrO9YBkgaRwy6IDwWq9* literal 0 HcmV?d00001 diff --git a/public/appops-icon-192.png b/public/appops-icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a59ed9d056a891b5f3148c837da2744afaa452a7 GIT binary patch literal 6567 zcmX|Gc{r5c+n)(z$z-j@kbTQ8CR+?yLRqr!dszm9kkA-LC~KBTwz6j@Br;>kS`?8X z%vk!`Vr-ch$HDU1(C3qIcP=yYq+LJK zog*e`mCPlx6yx!n#F$K9GGWb1CDj%_1hX|I$=5l}x!f1MfwPOBy))s`CUbf8soARx zg~V0zB-BnuN>32P7c&x1#>;pk$7h-gQ<~)4zohhE7f*BT5hj|kT!CHTgKc#v)A6`g9Oe6 zMFm*@E1r3$t7;dxI4?CDzQ0pU=0@4Pgg9Lna_7(TxXc4~m07?x8NN5{sU);)^b>@W z$$_kWc|0c0m%20bDS<3n48Z)T|ISZ14(zQv+ zOWw=-d_a$r->M|sCD{cGxD`JW51L!cDkh(^d|jnD)1#2aUB0gJO4Xgm8Neb~gMs9! zQn{SU?xvs|ehXo%tZo*FldKX~TT5@o{Ab<$U}X;`DG9)56a!OefF)GTF_`kr*d206JR`uizgq| zR0^nVSfdKffL4ki*hGDtgUTgLOJ5~nyP5x5{y{u+-F7lhWUAvNOJ@x67Jst54(3L+ zdbo`Su>5)9`8qsZ*PJx+!+PWB;`i+bkoa4Rm~3@6K(d}6sB8?H#|~FIN40Mysj~P1 z?$M3ib2`t{esu0(@Kx@E)7ct9GTrwx*NnL^UN02^ddW@>*g1M_$YIs zB!z_mzl6@Nafl_6LaXLaZcDC3$#7F_yIIa-v3^Hz7LU%q<}FpSd3AC$z^}&yqtMsG z^4OP9Z5KCLib=-QJNSy0tqt?3q7mr_>f+|8)W<(!E8&Ykylf(7>JcyOTxJJ&DgH3_ zV9qHppJIHz0{<%^A6!H~xClqbX}Rj^6bq6{dlup96qhT9ez?0q;eR21^Ail8tN(sy zHJte1%tCfgS&hGNurA=rUY9l}*okK!+W8>Ba?E!WC!j((dv=8iq^h55W#o_T>h2V( zJd`xN>TjBgUYZB({-(Tz_wxCnXGU_@Rk$%Vr&5wzsjuPu8kEyAT#T|X1sHj%@ZQCa zfybotTZq)NVBUB#4+;tBj)Mw@H2QB>xM9x%B-d=Vfmjx*Cy^MV527@D0-kLq(q+Tf z6js19Y|(QMdu)!7I0qlKoKy;^iZp@bV3DK{M{{4stb;q8+itq=aM|M0*{F7z&Z~rR z0V|jCo>js|2XnInD#g6dOnls{`{j#FzKCnch@p(NP1!Y z$wDPNsa(9%uUCK-#ZIveW#3EWr$3S|&zO&7vU6(ZOxJj{s~L3eMj<7YUCPzvL{zod zj`fLVI%AwXBNa7b6DiyyK&=+IK*iV+uy5QMZ-jI;a!&+q(*MdIwl{?dxl2*^YO)Vf zMJ;T0Zxu0|+f+x>aa5n!H zty}{PB6~rS5|2fgue!RN@x7m|tB>!t5Hfolmg!tBt!dm_{NE%~1K&J6;o!c!PHKvJ z{|{N9y4xCO)tAQ-GdsMD49@A=?&(=~2@-`{#!ujTD7XuG-(c%#BlR>xBUEK(&II82 z@g&vJlvajQz6}&bjO4ceiT!ChSJHS(Uy__jr0wHRlg8IsD3HGq$M=1TZAFe^YtGb% zqD$a{QMyZ;HMQjZ82~m5%fT>``=n0CUz3aF!<8C(IEuV#9r@iTyxb~M7=GB-gN5Iq zh@7@w-dp&2{*lNL+WZEcMMKPI!Bs0Nmi6ze47udf`$K-$0DB*{;(E8W)|-gG}P(j zc-ev|9i?3)*KaPADPN-Ov7nNGT!pWrd(GpAPNaYQ(eZgb6e8>lsN zzN_RE^>0g_CIM>0VjqwYSGPUi|46X_SiccIQtC&v6~#|QIAWd+2Ns#Rx83+gIPz0j zNjrR|ET5pP4Ip15s2DryMw13Jx3-hnP%)%aLM6z&2S6-7R@^$uDy!3JG9G`$K@hm2 zLFw>($PwcP-3FILHX}0HKw{FG9Zmj^;)pSb+*@Uj6f1ylF~w~#Q5pOf@OkXzXg@$J zE4yuZVrX+s7LCbX)_f3YQU&b?DhU8*9-V{`1T0Tca0u#Xu9#y{5rRCgHwWd++Dgm3 z00%8fOmsoU0q^Q`TH6wwt_}ynNS1U}et_BOlKYOjfd#{Xs^t0QHD2v2bXDf-9S;t! zuG#Hrh8AsBKGJ%8*=7IqP&U+s-M;XW$78Ot2QD84^aTpQ=7ajg(1DsyQ|rp1PD_>L zm9J0Vu=rd%Oxp5N;ieYyKVSGKMOjdJ?qpkwOr7I{Dh}FsfJCW0EcCaPDNK z4_?oLqNxTh(RS=h((8>ed_|I%Kk_422#8wKGHZw5Y!R@*qaE9p&}mR?97rD34I6yd zmzhb^zH@L#u-j8%;gVrbTS8h3wv@!kQ*xS_#W4q6dQuG4zDOteqpz5qZf#{4`*RTx zY!i8|ry*`pGqxLOrG(}T06$EvE+H`@V=HoRFYGY(nSo0tc(sT5sT>HDX<8*JTjDIF zfF7yUAW7L~zSrOWIbjr-T{Io^rKG`aB|q7S?JVibf5eU5C^7L)BJrVF**{lrMB^qx zw~W>l{&S4*-93tR;H=%LuiBh2Jf5z(H?`J>EIE=`>>MvwtK9H;!4YXK52PW3qgS?V7uyoz?`~9-4R~yxM1zP$)JG^6R%6+ZpwD z3~3QgWo4&6+}DD$r4C-1aB{?GY*@nCX*PwimhAh2k4*n^N}(vS^b|L1$^ox(akyR~ zo1JJI?EP~5;^A2MlH>j5Re*ZYG`g75gxf*J4J@;XAJAQD|9*;skgI8DF&b3~BLtY$ zl1`M7J7;45*806v_$c22eh}@-NoYN)$nA{1aApMWu58P02%wPvregG|+As*xwwqERt+~zgFEZT$ zg{BBd%U@pF`lrjn$mN`NJ~*1>HcLg5nS>^S8|1ikRm<{b!x)F z@ve?Z$d}+;I-l}7p@nbp&ES%k#^Y^y#D^xYLFVHCb&dF&Jaj%P_^`hiS7sV06!}kG zp+&&T9Mmbem*&rWpt9Ba)8GqOVbIq*<=%)0awYwUT5C4>1wKLzogl3_^@TWy{bhU| z*QS*3g9!S>8dJ?kbL~aej76+dZlbxQC!je1T<`ZU9W*46qEwvnPhP=>9WCeo2inEK zTe3b8m+Ot5%&$LqFLiVRjBMcUZ`TqZOD3`sw%eZ23S71? zxb)&Rz;GML_A?3j#}LvHuPUFQgx>tr%6vPV_hT5-2N^A(?sVZ3BJWra3GCg6D8-K9 zNG8@$n`)CmIm7F?isj30cI#ImZDMv8t!EtyO?wG-5+(c3O1}op$}wck%W@D0mlEX! z|A!hK{$wKnoVqA?^!eBGJ#fSH;m#rz-NA^BeZ$k4eLK7|p?9aCjc1F_o8zAvL7^@c zevZ2P)wh>rURrvuW+~mI1?eN_!I1i!WT2F(y z@_x(vKPOGSJBr%l%>S$FTj{B`viMJP{1fKl4iDIhvI@5nFapvo`)*5JkTHP0Ea>ey zxM@V{`~T9#BzzPeRMvW_&{Nl4%Hh;&!7i-cXoq%qgRx_u(rr`r#OK-S4L4+@@nA;m zgJXj#Jf--57#6&(cgyR5xk^SDLu1|1v{7`7r5(A+zx|!?Kjqz|eJW@i8V`pNXra0E zQRDEwo}Gdwj@mG!#J_cP$TKTngyDwl*S8_|!+`*b(%b&s!>9Wms&sh)azinv=p~xz z2E>9notkxRnm5nBW8e59Vq4Y!N8+Z|S!Yw8D3C5J0rq+WJNl}&N-S;yZuR}g{$GD1 zh)^7D+vE$TYEn`q_zw7tb)X}9)k{k>Q}M!2%WWfjdv@|VfY*uzObfXBpn}Uq3nM7p z(vM+$^f9qa0hoZrGe&V6g%{@Z5>B%Jd~NAhQ>+DAB=cJy(!ChqV#9TPT;y%%+5Xx= zGNT?Xq2Zq`7hT(4ZA*+jbqAy4d98McgHriqhEV%IFjjm7RQyHZ`R^Bs#}fR!OT@Qs z(A}$)GfrsRI?&H++*H0Z6UHLGRc{a_Tzw9Ngo6rga$Jofa^4=O_ThH&TKQF*fWfA$ z2*C6bx(nFJed4CcF_@aRfO2RC8thb=M>}9FhU-OGDNCKj00hVI0}neoL14f4qAiF@ zU-({f5KtO!B}pq$)m}e{jNNAV22WgD@5WpA16WKD87}L1_KT=n!;y{GKfF>ENKiqm!Pr6) zqw5WurT_MSQZ>jKx{i*Ugl69fzXBvR&;=j+Dg5{>_74#)#x0+rStoQ?LM}}%%N<=P zaj|f^#Hx9d6t&?=ZfZWk>J}uIDQX)ecR#H@kPKpaLaQG)%e|+MlSrOZky~m@PNJzm z|1d|Nw-c80q1W*sb7j0WBWiFf%uV(~mg;3APr!p`ygwUqty@-&{L3ppe2d=$XAF5g z^t>}1s6biDUUf4K=J-$8mld6}f7Em>(;1e|3oaO|NCI-_PYc5F&H{jLTZB+c1R$6{ za}015EMU<-QTkPgyL41)g-5%`7o16yXMVhKLKtjbh!FT65t)wXXwwmPcfC4LP9o4d z!yi3v%85V(^)q1=c#ts%`@j@;8s231yZf2*hPuA1yxNY#^?i(Id=1w=bQu?5%3#{Y zebf0y;UXrUFTBxQ{j*R$ccLpV0REyi_`wpNXdSR?u4E#<6=tXw?~&~RGW2)Ei1fY~ z6hOI8c(?@|*HR(90K1oU$6~d0IwL~d#t=dG;qBUuqZ2Ag<5z9$I|&!TZmJOyFt)%R za%)?>Xv;u7-ec|hx~FYI_La#yGzZYR7IcF8Gjnjvhe>n=2Ou1<@nM97PypD%)M=}0 zFUgB8)Wy=YtgeRxOzl7jw#+Fkku2z}Ks`1M46(*$_ICzTuje&Q?rR2ik(SSX&EoIH zOKBqd0v|<_JnfV7ITLfCIC&0Vvpah_m@pEAFLslLIKv-Petpx*j9 zVhXq}zmlcg5a){c^+#sbq?RTn1L}nQ_XBnHM^tCjoLd;WbTMRV7W(#f`0~qXwD$qQUNhqUcuBM8 zSKq_eZ{u^_XvSxu{Am)y&8F-hsQlywNFTmvV>S(Mh1co$HA^4&q~Gf-h3y`egOV=M zC)4$7CDZC$(u*y>Fb7Q8DfOomsVT(3MCH!v;8&V(*#>o-F6(zU`#8=MP?3fp8=1H?(4h>%%Kyh9=?qaI#Vqt5|o@cR(;GwnYW-W5_~(4yvq{YZ==H zQWU1tXy@&|7K%h=ZiCU*HGdyGYlBtgZe<1_*hO&Z)-BdiOPhvZK^T*b1k>Y7Qm?i~ zVqGx-;H95mWog&b0+q`3?nQX@BEW9C=2cvjlYMWNv+Ny2M&{PD98&VWOh&#$F_i@Bb(`3f)BzGpq87E<^uhks%?FggHCi}6Ml^7`%A2OFDtB7SHHCL?6B?_Hfx?~7Ybgq+_E z1Bi!Jrbhc9($$*zuVqL?5`E0E(&MKMY_ty@9fisdJqw8Y7VWV3UMy#GqS?eM@N!5_ zD9xl$`!H=tm-5v)Xm1a)Sb@=R#mOYvxeCY$q0f#IQ5~o?dE1sikWGK@CdQ*<1!JbVIx%f?P%Fc5O_IDFbM+D2 z=mxbchn6WihKSvira36(&xzJl^gE?s@;gC4^oVvDe8yTL0E}4a!3K$yX;N8(Np`78 zv^0RMr+4|kZ%be&7fXs?xKbl@myVu6^z_?yf-iz80XuCe&%u~Bl?S=Bi_Pk0uB)CF zk>B)l(0%8XtM&H@6XMI+WnRLq37qfUYLmG*&ppr=Gei>08RcTe>>JtLh~|9mm?C$z z?l?-SkLC26s2)Z4ob6d?G^eo^{pOb=&UBF)>tG5qg?9Nyi9lbJ6YjGQ$V|F5J3cN? zEN*6(2}26;!)@l&(O~npvAo~jsovt4VH}^1cEr#^`!CZP_xs^lkX0y-Oy z2VTyNi|a){97u_`SwZT#-u$ZqfcvN2pM`EM-v@r!5MK&*4-;47bi9_LFDdv~Br;p= z<8`k~Y{ukw2^q5E=NsZWY_xVG8r8Wy!&Vf@Z_3Trb>6we2#}Dn#u1Km@OwZz`lilx)JIP43%!$Ebl(J@ot} zrFnBWq-v^OdVW*n)AYKT_$N6V9sm-fisJH(ND)nIVB;w~iS_&gT_4`2mDzg)w-i*=jsgWxQ|`TA5)s_bakL)||FBO#JE zR*}Kq#NjUK?ztMc(B+WtrNcpSDPPHfG^_;?%6M7ouy`WX%iMH+2t?{;xP%}WRQs@M zuS4=O8Z(|o2LIW#6LqRMuWd2}l?LVVq{%-E1dKas=QYIg{%eD-7Ez;_x%P8L&dYGh zePt*S_X2a!Q6dEns zxo@%v)eJ*OQHrH0A96p6Q%lek0a~+Gklg2*4dPvzZB#JL55JP=2LrAfEg(bcH22FI1FzdHf*3N1$hD?*6`cKTLe; z-o^c0-zzdpQTZ7P7gdyrTogM~)$P;NSpHvi@T@&-)bv=UiY9)>%VutHlSMXd>@!cu-#OTiYpz?M?B(k5Q$g<5Eh zF^%bK&Y7KAwuNPPA$XGSYUZ5hJ>PlHm-8{q+>tEk60|}E%L!LmPWKnf30R(>YmFpm zg>u4R<{KJL|2oSFcb5;kVBFPkdi#9v2P*(q;z3A6vY=a`oj)A245R0n;F-rXIWgsN zq~>gcacc*}ce=3Vh4+z|_GHW=60|}Y!*YTz+6k+~7s0<=1KSQiq@n;M=MC73Ct>^W zI;?wq&^a2gYV9jvll0Mw7$E}gMfypbwhrkV3SfTQi>zHgLn^!psc1@u?~&oQz1LtZ z@x!`r9MsCp(O~| zN#G>q0pvIThN`YvIXp9Pb(6RQ*M&Q9_53ME?;kX_wB3UF6B#bnUW#9U6ogC8Ddf9< zMU_W^%iy~HS=4!F!r-pH-?8QFv2;Gg^d(y*VI48n(G_#pznNa3c2AL18a3o~1|)9tM{LIS>+ zkW)_K0}u~~;HFRe;}>9c`d}>yIh~|^^XULKpP52w_b;k|(w^IJv`@hCl^@TZyaH1_ zfqP+U?1$lSXZ!-hUHu_=-8FjN3Ur|$|EzZpQMmE5UI^9g@e44#`!$7IDtyR2;*VZH z%OHgtkN1Gzdm?@TnRzY>w^Uw*W&bdqYZ!y$%SkxeCQ#CQTZNW*r?KhW7&d)7ik#L< zD%^OY8*8@ij$c4ZZhok;Dc3A$wvA`~s(`$2Myc71NBdwrM&K7-y zQm(+eVEDWf>FWgY+qj$WX+;1;6Th6J05WLBw!u8D^;G}1kE`a3( zq}z+tvZ}yMjeTKeqi}Z+-g+LXZ|+!h-KYgVxYf!=&pQZL%qGLXr|^u*Mra-r zmrnpoUPiig5X#!-b-Kab34MzU=WCjv6|$pRQxOpLi&&eP4Zg%3ZZ-ve>O3-CN0G>{ zjqPR3(k>|K3D6{qNOf$7p{Y|oZ@TmEkhZN5niO*^a1tZrSzaFFB>8qc^*l0)4zk~e>NsF3}eVtS~L5~w`P3a!~G!zdb(n2XqwunX>+EB73M%GY`WM5`TMIn{!Yl~E5LM7`^*-a`T ziLs7l>|-!v=HB;Q)$jek@AK4C5BHvPzT4;f`F_ti_l}u~(ON-CK?s7@9ydOE7J|6J zf4Lz6KJd?czz-JqXSJ8H&2P5ZAV>~6e)Q0}fb_`>{zuZa??}NxCBa&X zQ&T=N9vTuPltz%jYHc-PB?FfI(%5Z>*tUI54Vuv`a|50A^DW0 zQ}^G*uH9$+IVjid-S^umJt-Mxk4d^C{_w%+3=(B@s&mmlZ+ziO{YCdT<9X7iLQ3@7 zt5+{bPqS)nsi`DO&}*liGLxrR13Nm!aOL(h1vLJwg0IL0tnpDH2Z-;-J`w7Zje)yb z&Ri^*izrny)%%tD>BgOlCfA$e>u>3NnT@CIH!;r{&u?~2@Uv)dPpTC=P*$~n8#TK7 z0w$oBabw4Jul4(dsttxDWO&19BFPRLk_#U? z{2AhNs1D%!c%e*{8cl0r58?Z%f(IO5VlySEtbzPD=Y2zG-|-0x%JZp0Jnz+KUeowh z>MHuy=js*S8r)ytq2zGPCD74YAd_}SSTjIGL@kU>s{Ew&-4SvUNC- z=_uSO-a7l{f!9S;wDWxS-j$!X_FJ}IJQmu%;LGWA?p)K`2Ax__l|%Lhp=Zxu-7OBt zQ7x+@RMDc5`(DQT(aA+D_-+u&@m>Xk+gUICc36J13bml!!$==`DXI78T?1k8#Jfy^ zA66GXFYIlNjTTeGw+nAx5l>RR?@rvuxJ}=-IW+E$=I*k`=-A?viYQ@ZUj9!!kkT<#2V8&v} z!m8f=rP<`w{3**o$2Fa6?9GLR{wUR9BTr^P-X7)mwKw0{@+?QnM)ZkQNa}50dHH8^ z^`aW~_xS$Pny>8Q+B=3u?g(o@Jfb`jsLi;6u7yo_ekiL@?}$#YGy^35*iwdvl6&yT zM81rhwtdsELZ=DY$5I&jGo6S#-;Ja#D%MHz*{hlvpZoheGkzf7L{T3Ae?%^d&fKEy zARG}jd;2aZOXUZaZO4@F&!Tc<`0?+Z1A?|(3pE^&+yvzNB$?dO5x(^cb(T9 zGuUbw*ppZwzK2V9y)bI->M&8EfB`4qdpgGqWb%OfkFaX?4HNzv6XIiEa1_}CyvS+; zSEcpK)&s)f)6^wkX`*xh7v_J0hqe~U8v6L(Kjq%uh3Z` z;Vr^j{;ab{Xw~(l(xJLtozG22OSw~w z|K{}vngXhS%nI?G{Ui~BqwwEsinqOs(o5&7-FB z;g{OAQu_gi_L{$|b*R3?r*|#c>Bn3=?E`)$XRi8hXs9}9@YIOk!;%u6>dx!S;D-!C zzy8zFoA7Xl0#Od6W_sb}LF<1L|NLaovPRj@$KGYuA-GeV@^amVe=;Vj&DhyoxJ(-@ z1;*2#4d?p6s-~64r=U>kab+j<8g!%pkojwmm zXZX%uY5k7zYaklij?% z_VB-RILMtBT7}iY_eeIeF;rgRJYnvC3;yN=%)NZBicZvn{!MT+A-pKSvHt8bp_Il? zd;v%P4I)(#niURbMjLlrCul31oP*>3*&O0~W-B9YF25|^UZAi^%tuD=PjpC#j5`6$ zlFofuG`cX&8aOqKSpPGugF(UFLtZByOk%#(gS50NgI^f+v=wJl6F7J+{a4koVnV0z z?L5TjpJ^eUtDO?*7TcGJVip?3A@Q`)L1q5S_*Z+TNqm=UHVG|Re(rmA7!{It+H*!2^^>4RgW>Gg&W=e+ zLw)Ah#3Nr=!Tf6vax8Y?uRYB_AF4gZI_}tVosXyLvD(ZH|24Ye)r=bvkC3OpU&7Nj z&+z4M<7dyG>+<Xv>l!$NG_nlPM$kX|97Ul771zh=wc z1S!Y4-k`$X^7VUL4Bn6RsPw`9LC>`<+}gRf@I zt!v0!+1_@<37s}mB8p$CIra6I2NCx&-Zjk^?}Ig{l;dOteFd7Iq>_@PR>U#un_O5# z0?lXi1teMJsyRpa5Rmq}x|wCy_GO{voP5m8y?f8InhQw$^y%#Dy^q#LVzw_|VcXhR z_I#w=aueeV(RUJ|N2Jl;A~4Sd%+g^7W);A!(FTKK98>4X_uHJC@2E6xb*p-JxK<0% z)_pPjqjXpxnjljc><`B1>gc+07>s~ZgLTGki zRjR?6X`DGV#ImyL>}iEB_cgn*a-3hj{ZSXVd8$$+_Sp{y*X|5qJi(-0At6xyL5*15WI*U$#iTeYab8W){qOgsW5 zI9m*KF5U}#-ag;`tc6WcOlL;w;u82W=JNAFfw;XIg8(p^iy>O1@+h?WW%KA-fU* zu+D)d@M>*jm_U@O*=XmY3~={=1I4p$8i@V6r8CzklureuVy3_4U}0Pnmu==p+RY)^ zxj5p0WOW`QI>RL^v|*f4nFj*J-sU2^Cz-d>K6nhxg zV07yP-^_a2;wVxen=b4H@$*y?LUDbrxs8WNp=B9kNlk3Z+mCE3kAS>Xlfd&G6ZEt{=bZ7c3i5T&xIvRyW{tR=vMW3vSd`Xx^GzwKXyme$PckZi`YWhek;9@sYoG zF6WnEz=Tg5&fp6`9_@(WXbby<&aU<15*Km?4v;v~M17idIt8CLp^kjiSaxxNKNYuS zx4Aa`>M?A*YiO@9WU~WX-c{QtRloTshCW3RD|+j@ zE>mJ*RZbUUq9+aAcVDtXmWgH=2|6;=N1 z3gzcn@yxafD>FKdNG*14F3ZC1&a>>6x!vfb+UFm8sAWyTSOM3}#%eBU32O$+9*Mn| z7#pdV{%xMTe~!>ru?$O-NtNf7FKm~(N3dR?j62&aY|nbo%3i0IoKyoZql95gHFWC7 z=1ak$xQ52)GEKaC3#HnXq}&n@C~hBE&aog?e?ITF4;E6;BvG)4 zWJrZOsbc{fb=K00C=oSQ@?+7xGfEUYR^C7jr1Ir{O|zQEDFML`^nvyIEP zz{rYiPd7v)D=43^g7hAvubX`bu@Y1OV&&X=dfXRl>|Qb<$KK@SSWK?V;u0;GAM>zxo~}FB z`Hj7IbiTD~DcT9%^3F^ved+!LiE+Frq*`)F(fiTc9ejW?MEcbu1E<k{MLU9XQeBlzj#phc{ir(ea{9Y8$D0RcrZUH<799gbm z7OQvDNF()WX3`PUQ2$%rYWP&n+Yi%LE^y!ktK1{}^5%CZPP<`tcrJc0u1gEZxL>hz zJ}zA0;Q2F9l*cfJPc%XXODQjIpSZ4x&uhW_atb}^OUcRutL0qtr|WLjvGb6LnBJ0G z)pSiy?No$pMARXEzTFSg8h>TQKH6JP#<{U7H4#Ov<&D+sZ9gZHh)nAAtz$pEe37kn#0R8l{lY;6EJbcM^*W1ycDRsR(asnv&8AiYw^pJ1*>o_<0*Sok$Pv=q(ykj zjC16Ek6M(1mV0REbV+MTD&Dfrv^eWvJ?sZWzcxg9%Je@wsAJlcs&6!-`z+3I!;fp(z7(CXU7{%RfhE;rj z)T%VH%%_t$CHmo=t2G;f3&!R?Q$BI`@eE^DZQdNwRJRcT*35Z@L6stathueBq+3p* zzM80!Jl>yT2i?fO5Z$@jV&qWgxxkv`{>ZR{XW%3EZHUvHrI?P+#j<4l%X{oAqw`?u zlts=snN%#?4hk#rN8UUODj^Oz;>gD_wrUs9@83m#l+3RRs$j@*9_RE5vHe9-&7bWl_9 z>wQgZP0t0Vd=)ajD;24=Nkw*ywFkgrMXtm(E*0+P!-1<-pLP-n5zT}`^596h*Y*_K zWx1|5(;42QyQT~F;c4Wo!m6MN_S%Vg6=ne`m_EJd+FX`1g)BVmdwA_!US3j_uf~$7 zaa!l1VPm4xCLaxs3>h1mi*X9wdv!tTjPamKuQhgu5o~kmYtCc)Q!i(qLh#*qm z*i4?1rFYh|uWJGZYnn?s7|+>|Lhvt#*Old0+8e*@uP_=4)Bo~N2CMMs6>jV10g?PkI7z^ZDH zX*{$s*!R+EMg@Lz%e#hg@OTSk@ z)jE<~N$X=77T1R;#Y}TZV6b*$m`(qd(O5gJT%6hQ(QUDrWR< zR})=!ePC^v67*?n5xojHV+R0Z2mW64*8-zotWbpVl^|6DBLlpsIZ#Cz0HjG3 zIb`L{-RIHAg${l;l&jhrTgOWErr_=~(++l&WE;U-$YAzRI2ZaV7d5^HqNk$PX*U5_ znCcD}mnUO*!^|>~T@d?)I3$?@6sqa5VD1c`S|~2+QprGNA7+9PBIG=*Ak~h}{6+x& zF8RbKg;k|8E5Fr2e_K5!uglp?YeuKvRNfVb3d}&)bV@sgnJViP`t%wFXI2dUb6ty2 z0NtD&&@_+|g!0h1L#=4x$3kZc%miaOj45Uu;GD$#uD_iE6Ag>oFy2|Snt5GuV$j8$ z{akN~AcuUMMhoH0MKUF-b;%Jvritu{C5^orF+ZDu+|P!h_N`*cf_#yLs!{V|KCXaj z$|V;gt^iqXgwWa6TpJ<|egRXLrTi0LL*R)FZ`f5pwQ@;`Mz9Y{y~7GTP@4Mh0aQQ# zT-z-lcP^Fl{eNH9;)R?-*LE?bz5~dX&47FUQ!E&Hjyqei91lXtDIeEb2jWqN$V}1h z(C)Td8GUgVq#=;G?)zYaEM1%4fUVB}YxKRm@Dvhhx~>m`ldJ9YI2qZy4Ko@~c|ckV z{D+e1&rxyT|Jz8Gj+3mHhT3X9!z{(2vJ}Dwh2@JdZx=Huq$uki6=S4C&jrKK!9;wA zeiQ@M5rgR3&${9Yt2CAwAK7Vj`nh_@)(2KX`?*x5x{)3&enHOn zmWcR9)im#(w{Z4t{W$DBo{lsYn!+b#f;yMId+w~szr1kL4c-8K47kSaaEvS4witVn zOnCZWx}je<NX~ECLP?AyEiLb#3(Jl3hSkP%)@8gu+FKsB4bN%8^2E1W5c9uRst|Sv zm(um=Ef_%1&&THSS8HCl!1dD}lJJ0ovB*{`F=N&V?n)&r%R97oBUe9LQ!b!k^)INq zxvRgrk{BvMw!O0hR?^E(aNQ)UZCAxQ>*;Z(q4xi*7~sR+H4sl4=hp@;_P3taFBCpH zt}U=l+!ocr;lMv}`fDS!yN>1R3G2tu*B*dN7wg$q_914A6%`i>{2AQa4sg^{2yIJl z2_u;8b9cG)q1#7(NYPa|4+snD9AQ%6>T<#q`}ICVi$k4SocCC0?qJvFYX3!%%ex?< zD+{#zlg|l;Tn2z%P-Q(@%#j~Kf8*rQ2zYSqjTaS=>f0`r9=DKOeNqf}m7K==A!=;}FP|?OBY~rH=@Pabe!4B27wAMjEF`6Iq@2>>o0> z=+8}iz>k&j^Q3Q6TnkIqHYwhv9f%U%yD%OTE zjXtu?WqHc{GF_<1sS~gMpu7w~`R{@GI9v zHUwlt6tsB5EC4PU!IkQ$ITPb1mAF-5;}m48KgshLsxw?_!|Je=9Ct~?n1Ny#VrUQY zZlyw~9J1fPG-$An-JbUDVm%zp=|3GC$EKs6@Go0|7sjYakZZA5F+c|dRLCs~2 zunfZX7$oynv-w)WmILsl#=kevh|}1I=*6+yQV79URH=Idm##z)*R~-_x%!JaI=oy| z+-qj!Z4Bd9j`xH)@+h*Go_S$WvOATtFYO}?G-)vd){lw%p7Iw zM+xVZFSjZ6cpi1DOt%+z`Tp^qWJ>I&hQu>@8qV|FrS+$AO=@ujMcII1^U}UgDnk!j zNe@pZg~p#<${Hao9UCWTvu1^dWy~q}BkEg{$WN^!*(G=>Y%T6!*#Eai@SO|OuaY!15b={Fx)rzaJ3X$;i+0-uP(D@^0MVbbf8t-lY0o`aK(JRuWoakEe1Jp&uhbAZWRtr}=0h z>==Y0(7U+U%eIm#HtW?}j=v#Aa66q=d~{u90Dggr8wv$E1pCvKG@qLDKiJ<>&-y-L z6+*|-9wrSxC312{ffvmn9m2|3027+1D41cuC%vT< z$Bw*buZUE<@S#$w1SEnQLsB_={^9Rn?dmX1ls?@8VDyWfGHrt?#5y!H&9P0n`iv@} zuVnVqpA%L=bYU!Gd5P4VAP)6Q@zdgLt-a{EU8aQpjq2x+Do9>F3d?}3KK{7Kwn!tS zT}>_C&K=Kr(mj^d_;1|oLn2@pz|vZ*DP|bS5@Y;=3w#7GnB!&rwN4EC{wMpXT1QE_ zD+Z0GPL%tp_I$R+#RrrYImiYJ^XynQqQqFy+cVRP7^1#{0!F zCi82aZfYn&m^t2`rt|#~PL6cQotsidxM9liF zIOozrcTQfT(*RFiK{joBb@M>@$PI3}CK1&RtIlQkV;bQFh?8f(N7dcuv6!zjRg~ME z?9BG>E7=_I_A>6U!m9VFgctnw%yXsR+`jWf!>?1AMulyp{bYPz3;{*(f6Q;(97?_l zf0Lng$EO;p++TB$eHeX(9S6I;VJc0{cLF}PLb3j@@dDIuP#s^WfBSb907n6or3G_t z$;Q6jFza|fPW=~q+rbWPtN#om%iyjKLt8JKs@WsCZ+%h@P9y%7OWhJQPq$+1&X->Z z#zPx83SMdxp#X3m402%^7E%qy4a_cpn0@Au&L>PY!4oJt3f!T%h|Y+ zz~cqOj8h1!{y*B6$Q!nnj8BYXklWd(cR^sWub8bZ|La2Ul+d5PV7h7H%>|*I%g5^3 zQp5}ZzbNOr#H}Iyq&Jy>FUL!{vS^fvBul3~is%cO%v?2C3zZtwE}OY4r{8%d7^gd~fiMAt5Y8tT}uIWa_iGTz;Hi#}A)KOk5=$1V4ZjMqvg9sn+Nf1iq%&Q^Xvdoe{+yij;H&8X3EI~^nKOkKL z2$IUFJC^(ZmKh*9UkarFhFj4>3d}#w8}^tg+yB2r9Apn=-mqTKL~Y7OU!j9bw z<%g163ks{cHY2NaQ*?I9xrmSFy)g;at~RV3v+S|6=Y zFSI(e;)D|V{b9pRP!l=J1W6|&tcey<|82|v{J6XYbKWVm+;c&_3R3;TMr%!tzbH@_ zEe0tGhFyqbs}Z&U_FGQVE`Z+jvL{!(`Y#$)#|3eT%%Y;P*Lp6{UX|{0#{zYa5r?XP z<07`_|JQ!6&}wHk15RlnG1teh0q%JDztZ7oCA8lkAoJdp@`JhwD%-+r8_RmlblV3% zDj=K%cq&hn_{!t3|L9&f(&au-%TF&Q;q!5pRMN*KG72B&bu6kgtj#{%fu(~)^hZaR zbUdprQgPRE3HMNkAkcxTTCh(|2v_3R#p=j8F4c==|2=zf@K`jNGKG3K&^ag|uV^D? zjn@cgGXIvbTC84ih5(u+4#+~-h|9Tn%nb-#R;w!e*#$a?_k)m(NtJ5cfRpX_phqgi*~(7-0e zh^+&h7eo2M4YEjR3rPeNr>_nSp8q%heRjKsE+<$Osd1t<>p!pBYJ`Cwhx8?w4zqft1^?~U`Aw+=FxGY4xQeDKt@O38n@jk_zv_bo8F2fS7r?A@4F zfNJee&Rw-^@A);9;O-Ag#bLMCw=P*%El;~h?5#C45vo|uQW^R%LP?V9)u`{=h76<- zUZg%XRBHVL>^I8vmDNA9PilAgS1Mr>waNDK7`I5%{&=^3IWk{xXjvnV#P#{3m**n*b-QU-~&K{wTaQOC`w&!#+Qx%W0cr+cKU_kdXhB z<r^rJJ(ajrMXH=FdD&_bx%;1R_%xZ# zW3HsFPMaDcH-+A1I>9?!`W}?PyKD>6T*zpG!klBY;$Qv~ignq`!I_NNP!DUcURJC# zBMbqScf{h#`=oy%^MX}#^}!aJloq>?o&%XRR)34D@Bq5+2i&5ztb5nLneQG-GxE;I zExn@DBRSn!+{T=4E=>Ad1=!_0*WW_$&Kro({x8bx$!`RFSpHiErK(2an1#!o;o-aJ z7*!DLpu?d>7q+l3fBtU)ig}kFMW_Vi7#P8>$%x?~_CZ!?qXf3SF4A)CnqOYOE_ZZ`hiILue<5SIdeH4;2VhG*DtM8 zs%JenU$Vfoh|}92gxgIA9glD&4Wto14UwA4)OS7%4y8wnm_U2Xys80%Ssr9E+ZH_(O|1cl#E@SMmu z$|ZzPFCR!F(31nb$M*FiR#$u!LxmERk-9Qhk`Gn(mE+HjA$tm2{-zkTW{6)((vLzp z0P;X}2w4$ok)m}!WtxsDn&v;%n+5;}t$N)C*(zIKiek1-zhg!Y2pUv=dpuPDwSYVm z&D8$ABw-GP9uP!zcBajpQ2doW;wySOOyW)N>9C!YMYoHvIlQ|zhj1=)8uS{W8x|tl zo57OT=Krg26f%xuLe^maO-2!}50R^|q;g5i+>p!n7(EGN{ z@%}wvnsrd9xD9FAB**ow!pyW>%I&+8A!az1g(dfmKDr?F@9y8 z+20kL6hnK)=3AZNE0P%cwiWkU5Q+)z*N?cz4#vR@ZT+oE_y!@U^Wn;( zjqD>=H2PhbT}fD{kI8Q~358Tmc$W6>ZM6wRN%WC~&Vt8!lob_k*(%|)a6KrEAX5%o zR=z!}O}2TWH@nSIWS^=_4MyGxb{yatvZG{jgH=aA(i{K6y_w)%$6Dw>yC+w}*-OR4 z$*Mc^0i2yoYL+z#?x=7jHI{BBqD%Hk9LqD3**6b#d_gFbYGxNpmS`Run;q`?+>v^U=MQV0bb zKsT-AdeeUom>?5XR`iep!8wr`NB+)MnX<=xaI6ZMXPBxTD&RTc3g?+-y8D7!CXj(S zmpQWHJQ^bBel%12ug7ktb$8nltjGyRR0eg!xU)6t;h<@DFo?8RrUH>*T{gSDw6i2v zpnp~MzIIsx3B*g!X2OZ2{O^T}m1Knooz;p>x%dNG?9E@tu-SHmv~j~zmYI~5Nm@jH zOkO|cOeUSC#bV`fK^F_zAzj)NcX#p4$;^pWjbJX1t6PHFpo2}z1?Re?{glB}c`H*f zy4ja#x}583$ilCs@mt>PeBt5pEd24U23zmbFC$(RxX#stvPsrSC;0KJ5{z*me#JKX zj+mAH@?(Kq?Frj}R)jPAilmSG3vrib7nPobD}6c7VPSt zqEDYmde^xo#WzrwJoZW{==+4FT}no3K~PnM52aE2bjb5)NzlB=C~$9Unex5jMm8qW z_Kl6*8($EsLOD(H+>1Rl^?wqSMP~Ouiv$g1+ZWpim1KfnFBRv=`HKC~6S`;oMHqj-~l<-x3)o^HJL*fH^j5O_JaaI>^ zSQnsusRZ`}oVMMUp^5HO0Em=OAUelT`0WXJ?OM>4abGx@cWu)KD4Dd$DO4S_*v)&Q zq!LEd(MXUB7Vub*&W$I`r?H#>+8B#PaK%}3dMhg&T6d`8nt2Nrw3&M}mp9u&)=u!kN~|1V zMrg?`vKCOOf940A)yg>V>1zgw;}lxl!yx_$5EF#-4{#X27@$WN=ldYp%WZe0Vylxe zxJ7Y@C}+&})&tdt8Ud6%4cf~o7vaZzGND{lTDM)87q>$x>vE7P*ic%jPJq5Omfb;X z>bQmN)N4J$iYGD2JeOoP%8ay)5y{RZ`&3dPFGM8*QZ+TTe859@8d+WtmrI9^8ZZ!P}IAO_ZFEYE4E;7 zRm}5v7-87rnoqn&B{eO@uJlJP6l2}!roBsUHXUvvz{N?x#pUGJN19&DC-zfu_Gkq& zNk*szuk<h|^eWDKz4H;<5P=m)(U)ctf84Z8F^5L|&{o0a77|PyD{3_% z?ObGiL(^g;c_R~drs>aLw5?8 zMxhX-2mL%mSvVU9x4wfRd0(1ma!2$L?0T)&*#v7#ibddVm(- zl{4Vf7_^@Q)cWMd7%`+1D?ulcT*@Z=s{tdxoIeI&Nz{DU1Ec=5OND#IqT2)^u(SHw zb3qB5pS(+CiVZVYujp5P)of5#z?4`FqrC!c$7r9{cn7I+swf2}2w+$-q$4Z&C(aUG z>MD!k5#HN9izJa!lz-t|xg5%v-CYx%y`XBqM8zQ=B!^s43h7wB`}XCF_nAh-HcZBB zniceQjQ9!k0b>ZmvxrBUV(UqisY&G(DqfDIb{8FLc8>9IbJgZ2F2mE zSq-3mGWSiTFhybBqHE7dJ5~%D1NL29Ntel#1DGYGn+g{Y| z-Zb&&@8pk7xsuK7R_o9f9~5p6VC-2V2(^(ZF@R?$6Xu?i8s<{lAB}89{lsAHsyjz^ zI>M5m7Wk3>h?!;wNdPSYW9kCPn)&=-$PV)hO)#QX!@A}BIyyz@+9hb=e3hD2!8o%S zi<25=@}o916F8)|OX>^z`jk4yeL<;|nPvlxzNf`G61Xv(XT?*gpP&&_Uc~-yps%$b?Q#gW`;bpDJ8}{8s|y*7DMHo#)A4&`X=$&So<07>7_p&`|PY>?cPsdgGJ(t!R)?v<$G(Sp126CE*-=t-oAq=4n+n(|F zM$Y7!)ybGZ?DJ?Y1t{#nrkL^A1pxCOT}e}5H^&Gw_iB#2@5LC=0hW|umVg*;%9=RA zWssQ!Q0{)H{EHV!3^z=+e{oj7Qz#vrF(=W9xIym%q$&Hajbmme`k^qj%z9i5f;(=4+zP9B`=z?}4e^G>JKIAdb{;m7N( zdgObW_yi&Eif5n*wBK>#V4Ca=rl%9E{+(8~xoG%y%8t@?0&yIxDR2TX&nF*OwhXT; zN3JZ?up5_)$|bzGAvygl?({{sPyVo^FKZ-l2&9B=+NcrsbZBD9*E(6)-|x~=+gugp zz6m0)x$OI!Lus1I`mG#%mx~v=KQC~cvR4>jd+Qs}lhs1B)|QWL)^UL`XL*9=G~pT2 zwvbjrhI_GwHUe_}WaSS;qIe&X-(BoXYu?=hw1*tS){7G=x76An(}mRSBQ`z``C-5$!d8f3~~^q%KFJ4x)_4 zsDrnOWKCiG-M6&PYtNB?PjIP_*dW< zg9f3}kE5}?@fSP3M1Rkq=VjpI$}1~-c+||lVWSUA+&!OIiSDbAQVXlxeS`D(qzMPn z2(7$>MoycO7kh}WXzaTUps6Vbz~M9uPYd=?F*cYk-X?S3{*$U8H1`4Z!-qR>a0RD7 z?q%%zpKzA;Tbm(Wxf&J4y42Lo=|F_Bx%PLM@; z38j}UCWcFL2~FDtHT0tZ^Jx2x@zV>j+051$!(1fRCY7XyZiOJ9lq^cSuA=cx72`31 zAjRqJ>(fbz$64)ZA^wgO|j*0+@S((Gzmuv>2~b z=mA+k=JpC7Cyd4cNb5mod~3XlXHg&QH2`Vf(K`e3afWmf=VI4%U;HD?w6V1)m`-D9 z+t2JdIIr>cnYQcQOr_H4lE-b7KRbx|UBt>8w5j_p9DXH2?}In;eBdY=TlDf7M%-da zKDt!|4pELRa<)h!S`<6x^?Nrp)v}?leQ^%)xfPtMeSe{$Y)5&Cb9Y)vkcXcu{qrV{noVC2h{Iqbiwu@-&pz3>7)MU6h9e;p|n+Mw)2MECjI9B6} z!p*WpkbMxI_y%e*b$2t$Xn5LA_TZ21s5lq`(6S-~H6d|h0^Bxrrm63oU z8C!1wxqK}w@E|}*&XXQD;EZX(ymAT!Cq-V2X9%Bzf6HGC{WG;SG2raZd!@GvtDb9t z!LtTYArp|D3{$~=TFu1!(lv};+y7vx1t|Cl1`3F|V`6Wl3q zMh*6Dd>)%aYIt>J(Gu2^7j69e%qtJ!&0=@Z{cpn>w(L{_Q&{iXhq)1vI}IRmP-UF6 z=*YFXONX7GgR|LScTu{I<);Ok`6l%!HnW7z!qW1hU4O?aI!}^ZTICe_u6!Dt^lJs> z*;otqRAmy8?S7x|J1Tl*ydM^oU0gAYTJ~$)f~PuSmS8*il3jX4ehye1;@&HBBYZi_ zv*r}=?(Y+w#mvP6L)(X2rR4dx@@xS3Z?q32XWx44maTNcE3bOEPH6GY)nY8#pF35S zuy#C4dpl4)By#DI0LqAK8$R9Bf*>(t^t%AJ{KwiSazTEUYQc0aJYa;SHBMai2~RPNs2$w$Qq5 zYJ5pa_MBf$SyD%Ze#-#7fVLZMzZ=lJvk1OB=EF*Jg+(Q3-Tl6#mx~Qc<7tWndmvA7 z6i|P*MEsuJx34Hj?bOQ|Lqi<75R2RaXM@DQps7esN$Zyh^ZJdb@H7`Roz}j%d8TqW zyl6HxR}+Ob!(KK4akF_hsqzRT8zu2U2i1{7%t^`6k^?P$pshT`a$jgGZNo4lEg4;O z?WU|XV1?37V1*inJMY05%vNqP-qf(H%e1qwDuYa#t}@z9kO8X;MuWFnO)F67=b{h5 zPgfXhO}tAXkdBz1fu-df`xf>A_^fui%6&&+xEj0G?KvVi*!qk8`qF|q4(7S1leUZS z>N@n0iLNND1s^rQD56FcF8HyOFzC%8*)up;L3O~2SsdqFg*vo6UG~{sJ{$kv8^;fT zbIP8$V(bCBY``z$F-xr%BQl~qSs?8guPVxab*4k+y3WcGoF!|D)}XM(^$u(5ay;(Y zUbVheAP~8Cn+fe6cOgG;5~9s(UoOIau4BE|M#4$u#$^d1zav9-u!HvK`Z7XrtsFK#1yfIWC(A{h_1o5r|Xvh;RbiCF=xRp*|`_kE3) zpqcV+v zdg&?*uEV&(CHjha?RPj-(KQ2m}|ODp|O?I;2KY~ z`(oqWSpPnq?c&W1pR3pO`d=`u$YDpyIhq~j+uiZ|!cJny{lcoc6hePJ>$c!d$_}O5 zS8CLaWfC9U4Oi$DB0|*N2$1YiG8f6kX_sk=_ivGaRRva zBcf(jYhdFWpReQLGq1}@m|!u~knmjl?ai!p@{9L_ozH>WyR}zLTGs4*h2D%(%-Z;- za~4pZ@di==wew0y=Y;>Q`HdP+;Wc317ui@tqFUrc&qyvz(8slW4prAzm{FIengupWY`n=1sL< z?ldjN)-~ee;q8M`mw8%;G$dx!KUa&%K1VnDmufCMaBY%kXbBU~-%I!Yw>gZLlB|;JC%c|BLUI7?2Kfv=P92jES?6Z%)EDxG>8Sk7^?(!9q+d?DQcn z5uqV)K|5k%G1|%H(@_2OpA5HBaI%mXOh-6bwni*V@1fS~C#U?b$Ap{jy z_Ql+Pmur8#7^|ks>E6QZO!qC^L$u{FX|z!7TU6##Gd*Q$1;wn|oqZ#mE%D-)2R5Z* zVXIRr=hSAJ((~SJdH%mKjA28G9i}y54cqu?ZrKTjc`2(N8m9t}*DBn6cG*=qqlb^%dZ-2iz~t zX18nKJ~k&Rv~(-xkgE1;CNWtcKp$Mp721#--elP2Pkid){ryv7DxtrQbvJD~DRklB z1;8S_jFoS2k=~%u`8kHyASLU!@MzdNzPq2oIzMFQ>t6>AboB|Ar?RvPCy~(~C%uXX zf6dX1${*~q2a?W3An-j2@F}8?1`i~n6kOUnmQP$`RK_suF2XgB%AFZkNzXrJRUGM^ zjh2icZgF!!t_p^oldV2wu$2fEs{uYcjD>`U`TQ8F#BjQ zCZPy+m=?N6BvR7Z;>%>Dga52LyP5T_S|X5-kNv&2diDk*NEwn=pau=_^sO?HF#Eba zVBDp{j|B=TozwWCjdOR2a(wnP_|eI(4N$*z-?cEo7pmE}!|yUm67EFj+5nhWyZS2s zr!#+md~S6ZaVKz?3scUOO%ddy><-9 z(GsJd4fJ*?5x}L}3%<7QWK8UAT!e%e1*e6 zm#^tnnsZiA-P`v7Do~kcu0#!n!>3Acq`>dlgugB4RRBfPj z%OFc%IKGDAFa8^S;hiKBw)Krbv}zwsHX>Q}l<%B9_zDTQzKDKnVq)|EY2x1Fk~;eU zfQu-YVwh;~EmFlWwl7fw{mLbt!$RyWv;Aw zD=-x;w%O)weYRvVe3xc{rJ|zjfg|biMBK ztJa?rY1!qQ%Qug7;>=PkqZeyT#Tevm9g+mZj`q(gK{FGwa3b-_56NK#-4!Ki*J__n z4Ar3RB&*saZ0dSMY<6@7*WN^+C~PiIJ2C|}af5MqoV4{*%btT;m`Yo0*++gNOqll1 zCw|=R8%HJXuh23>dQ6FJ2=5)UbkMEi-1d{c>lJ)I%2lsEtMk!TtT<9m!P$&TiR%QZ zmJj3STe4M&N*x4W=}8jCnf$FPzM`LLf8AtvnQ7_1zH}8tm9QAcaJjL)=Y{JaQy$(ybz6LDmCjK<<-qnloH3lE%|2!1%>rMWFvEaS9Gznl{ zFnpf`=tO#;=u<~HMN=2p5j;8P0!~hcxgU3Er$`MfRhni z06TTzLY?|O>~aynL%%ECj;K51>mAoJ@n%MhUio5(9*0Wky3)*BbQ3lq_}GWF^t)@BG_9*Zhbt3z1=mzOca= zkExB-`(5~jLGfZeR=fT5mmsGg5a|+(l19<4Z>UnPId~G#HAj>m+?;iY2kyDK2@YjU za2fte{M0y^P@wQ&s_^h4fFU^yNDSzh-q~4>;9~J{_z_z4Y;RKpph`Y)KGZ85d5$3J zes#<*$-r|OgMYm=OSS}+#^3IMVam|;!GtA>%LIWs3UC~e1CKe3rfxrNV>GC!d7L;| z^UTk8g|>T!Fplvy@2RMFabOWQf74_wuow9@rfZZ=%B?#f=ZD4k0>Pz}C97kk)xo3W z8O48KLgPtq_*~w)p59mxqe#w55+GnkR);Z$<;gclN4u`Ugs>&YDE!B0(8}fen}wvy ztO!B)6UOLS-E@eS(4qk;ABiv)#%_>)HK%ctN{!#oR3LZx2MH-Tr_z{q(0P3@|X+HjY z+pOqF)y+9Nq`86a=SImW-Pv~`WTNG2-JGz5Tn#rKC*FwwzdiaD_>V}KhkcvVZ9)Ka z_6)mWZCAVCPDXgsld%VnaVz~MNwbF3_ds=K(tJueRnNln4+A|!b|2o)%Y~+O{&}~2 zzJ$E<_H>{#Q*-Dm)sx-gU8Y<^N>Ot;tmbiklyLgc9cKEATY6MVC-pO~#XAT1!OXu4 zk@Q0e6-Bj2xkF}qd|e27w1~>)Tmjuvd;HK{wez;(V=C$U1w13zD#KoOkg9{OecSY8 z$`;w0t)k1FVbEoItV0|yWVJkKqTp$063s<>o{Pvi1x=6mi#}ctPaYV!hieKZJc#@g zr1+G{(gm|32FwDx--4dl@C@bL`uIiksv)bzgK)@7V{_c7a`QJn%HF!5c}Mpqzo`od z$(usn(giyFSUgjgpPeb&_3IUw#L1edhkd}xf;ZEc#7#+R&w-87u`_i9fa2ZgxBGZN zyfo-JPe5)h$W33*9SF0x<^e$~cnieAiP?NqXPcZvCZK{7;%YOmnq|?VymmuL!6u>7 zB%)Y#r$4R%FOqqn15jAlMHR%Z?=lQLLyen1wBEA4>3E~%gF$r!#rr~m)p1p& zzPwW_t>F@KJ>{q5((1i=LvyaeNA!E+NL>J$0F|`L9*yqh+zXVnrXgTrZ|x<@p+7R5 zY}))yoeV){_~8J%6i)Z{d1sPWY-IV|kQiuqw?RQp{kZG$Ww*M$*M<}amk;{_fDJ5? zIhw@B>7%kAD27g;`7WJgc{&t)qZGa@ZXsB>?^K#NL&eL7*0;%i1fXVU)lEH*p2xPMo!R?Y>vd4BfAG#D*ufh8E{-w)iIPg(xS>8!?} zS;$Oetihi3S*>BV`g)ON5$?&uX@Kqey18y``S5S*@u9s+-=eCF1^Q3Z;)`WB?Pg8D NXU`6Qzxr(~?msQiVITki literal 0 HcmV?d00001 diff --git a/public/appops-icon.svg b/public/appops-icon.svg new file mode 100644 index 0000000..0128c0e --- /dev/null +++ b/public/appops-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a549d9b168b5b7764512f9e48fa33e7ecb5ab0c3 GIT binary patch literal 8565 zcmXw92|SeD_kYHWA;uOmwh(2@nh3?%Qd%h6D_N5@jS>p8*h`7G5|XWm$d)bZ49dRK z*kVSuvdv^24D)}y{r+=5^Ld`nz2}~D&-a{r&%MvN0005Y!4CpR0!~r@a0GmRVsX`! zi$jD1eC4`$!T9%pHL4nA()&vOSKhx{(s`2zqa|G^&uym^ZRKn@p;4Xhr%T~1?5 z5VR(>?rx;6Xz`uc#N0dl1+IEB*5!|-nquZ-*BgQkuiTCaqYT1f5WxfuHw0Pv-Z1LC zg^axRM||Y>V(-!K21@__3T{}~a*x((4q3Y%MDEKi*tfQPb$de>gJm{s{TSRbJDibZ zd0+5!VE)SZZR^F~7VR=IhOgTc>{JTeVm>A8ea;vE*Lpeo(_xeg;zciH*-bSmT-zk@ zm-dJGH|hStE5Ehns$)f1{ST{@6*1sf6U*<(&2ZTrx97bBdvopm$dX0dB3McCKe8v8 zc&T6czTB9F-FMA4h3&X~=y?~!g=1=G3g?TAq>N-tYI(aQi#jt@6mQ7d9rsZk?emN5 zfS8Oc70j^8m$k*YTg>0DDHj!wtyGX^w10 zj;OI!p0N%JNn>q4pJZgyhOrj39^(O`)Bw0^N%zt7nZdmQa&FpCh^Y$#Uyt-GI3A^u z__g+I3U?HOIIBB$DEepidDZhTIPS~vDPyi!*_2a}>?b*H5zV>f#;QPdUYuzdFhpZk zofaC6T@duHFtXA6Fixu{1&R}vCkx!fp%Abd)&}u?KQqvA{HStnenq17s`OrFmMZ+% zEI#n-NAI8uC!Ib&J^RE6JN6{}@EJH^Q9|It|6!f)H=LuhmA9_rIX^_t;{ZkZmnroqY`71G)JVPugSJW3*9`PYet@!#|sm z1{z#mJyN^S=>_9bUWQGY>a#GXJEf{j%rE#nSZfyp6X{q~_$?>};@O^>D z-W6rdITvU+%DLUw){s zAx=h`jlt+zMHY{j%B%W+?89V-QH^oM_#1wFdTiEjZ$6M4i`f{=$l*Kt&K{^fn=qPp zI-&Lh%8OcTL}@*Qv%qXduK%>9Y_EkROswnqec1iQR2IlaMhxmqZ*=WdI3nG!{M|VY z@a^ICFw}5`#U?|`+U)SYYU1VuG;C!tlR}b8SgC)pLpA2}*W~Vxmv5o0COY>Z^xd?o zCC+X!1v}Emh2RbMXi1xUHLvT$_3`j-2{sR#LmTAyV^2hgAKKOxv*qD0Ga}8lZXRM) zVl!;Fw9j0sMjNV&?k6>E2nuXXvPTt~qTIKy3{8g}yVr`kUd|~k!1Q}*e)Kg}L~{`~ z$^EnkC80&%eTOG#anLuKC5AdZW)^E;_nEo6%Ch-OommM6|IRTp1{>6@W{__5;;?u2 zWcrmF6Pj2OjQI4d(17}|DbEv-1?Xfeg5<->s3*xRy2ow zLuYNi5GGwv{`~uxm3yQ>I5qfers~n zVzNMUy<&>lf~0dPY<}08iRWe(jfwES$X#P;tNy7%>1vrZPWgF1)6%AbzdWqu>A-}o zVPgO^D|_5Vjv4@Tgh-T73VV+-jza@Ua(y09fk|yKb&-h+d)sZ5{{|j=P?A@Wr?!_` z+Pv!EK|5D>28O9P8HVE-q$ME`!`w8zJt>X472yk=--`Kfzg)Y1|6lcbU?f&k@|{96 zPDx;bapKjvvRY00(3JUu1~eDLfQ#ef7p8mPQ6!u2{3QG{C*kU=D{bbv1;1=Cdjwfu zi%9#1+Rs`r9V~qI+#;&?IHO--uXZ{Dhl0`B&f{?wFs{j?uc+062{?PFn^&#kdTf{N zF6ukI)zmONNlvVSZ;20Pe%jTOqdqD*O5ZAj5OR$6XQp^;|4<51Iz9MLmsTJiaBP}f zZgLfO;^n?1AjyL5v1N6vas;EmDg5VE(fmyPWEsYtij4u}YHK9Z+F{�hz>Dpd?n zW(p3iq2{c(P3*H(AVs+tX@(N{9Q)*^zhUMu3sW+}2%AnJ9~A<%{7AC#)7KWdUlfc; z;{P7G_9)H~I&x7|;pnTC1`duQaoBN(Dx*j7I(?@&nZqnZf)WSA-pA*c4Q=jDk@lB%rARs$~a7!!=`YK_oM`H{r{yex?-};YD88Zol6?@QIPK_NNC{d#P z-sbUqxM!L0dOTUvjrd7m8L!8ZmyMNvCf^(cnqnO^#fG&YeWS7WVQKHzO2@%ijC_+s z5s|`Wu}(v_ouS%*fG*C}Y}VfeWWAuRU8@sJ0+B*)_|B?GvY@mLtEm#P!{5@PV#q4rVQ-pbaUxEc%rjZ*DAIV%_&< zbsUF-++K{(Dq|96T9R#AFB zd)G;7eu2C4#?@C#C84$tyPf5h>!2d%3XGV&NSdq;dHxV~$G4uuvppT5ue$myG{V*Z zyK9i}hwV=if0Py=zT+*-qc5F^Jvts-_=aj4iMwS*he{LJy{s;juL*GxC7~tU*gK1} z{QD|B;SyXZUg%>^tn%FMF@arVG|tKabM+kV9xTC?w&u8OIYFNn zj-v!<01$*^zm&c?&GPP|cw11Z3NI9^0sMPVx?nQ(Bf>x$b39N zU|#tId3+1#>fSPu+f+l~IM5ho>h~Mw=RnqU6V%fw{amWvL$?+eWZ9}va@n>F1Jf$6 zS$@hcEjwS^KEC-^FE%p9T_XJRwbUzV)LkEnlaut=_EUI5KI9*E<~Qh8bEJ9vK#T(~ z-N$SAB9gG_;i(>&dYiV}55P2lm$8_g)C_HUP%IQz(Nes3sN=lvXBd|0DF%6b8#u8- zD&X1u2vs@5_#W)J(wIFK%pXMt(Rd8`$6`seY(Z$J@2SR2JPVK*?K&og#P2y@-&p9y3MI4JZ2 z+&On5_dvB zAG-o4o6-Gu>(ksNB8?JTr@nq9FMMz8n5KRG0qvrad{`OSw=C?=Kzox!&PeN=M_vDv zN=Sh2Wk)78nKJx+W+Nu+0g{tU8;l_92Xg%n-Iu|ORKYpXeK$(6S{PT%_9#*Mn`;K) zc__N@P;B7-J4p5&U~__KB(W*WiK|FYuFYtl2!83cctrp!A%d5LJ2y`RZ-kSz$P$-E z0d9sLLL`2q0q|Opw2lX!@Xl5nKv$E2e3I> zXzR3asslK(w9`o3*3+l$wqVZxbzP#&Sg^?mI9sN073)l)Q{Nf{#)7AXS9ilHF%(lM zZxgUoZBOlOy(;Ps=u7B+10|mq+70D9Xln!zDbzI>&NJlhdrI+f4SV>aH5Mn zx( z-c}tf5FciZeHB*hCykCKTycXkNgfR3TEiI%=4Is_!&NUsh z5CNFyfoc5_c;`H&Ec^bOhoVIs4h6)$InyDC3G~8uAQ;nwIRMc=#TPhRxp9JpV5^0{Mzv%U z;FA0%2@C$@4zlH9xQhV2zV)Tij7bGrfr(?Pskns(ULb<9SQ*JjuQGV=vh(B|o6G)l zvyt75TYl@2_w0k#6wbjzaA!Ey2s>I2?AA}ey82Hij4>H&oyip%qm$_g$io}g=;V}< z!mTITY!v6&dke(Uq6qzc4srdR7dni|_JYh3bJhS+9&2oNh zHzLIa2R%76#)%Sg9d~N=l(+zGyP={GtLpAijks}PR zmc@K5-%uX0yC^PpO@q1EOd{$ZzbRofXB~9?FUoG|<8RFDRk)dPr|_6WQ!sqSohEOQ z!klb_2Nyp=W8q-Xy*a6vSH7_yx+Jinl`FTiO_v{3*qfB2h2&xp`ApZprmE%bk5f5N(ORN&~$f5WwTF%9xDPyRr9Qzn%T8SA=7-zW&*XRIr zR7QPwLdo`*6eWun3;sv)NUPp?8-6s-vDpjHN2BWB7w9;FbC+kNdnslb)E51ZYP9=k z_9_>Xe{IvUICHJ&`O_fb0a7aa0hQ#M`Oht2#MRQm;rBPCyk(N^qh^-{-aq-Vg=7&h zTJd0e%LOS4K3C77`vC+;_^Dc$8T?M}XM6N-nv&}F8tz)m6H_|$WtYn4BWWTvrAF-S zC}hF!9R!JlIrZjPZ7qek?rZs#9MLA1!$A+;3add90J{ z3BcK;s%{6J2wXgON&12Br@|W3dl>=BPD}5rpB;TsR&HOMLJRqz60>B3%la#-?QF3i zf9=Kz1nvdf{WmVarzMil2}b(w&c^TM#{*PB6}b&>tY}-Pd)i<3t>>GHzo#)QOw%%j z;4B4bmN8@m)R_khjl~T1M3YbX3TMwIUSk7o87WB*KKYqmXwHVk#A64!@QU>JVT5;@ zjKL(M{=Sr`_7S&dzOUJ;qL>*as2R(EDmcIJs%NLH6dU*G4P1RIbMy<|1ZG z&fMH*A78^nP_()Mm?Ee!2x}=qy|_A6=anaLg4JN-rh-)tp#ki!1zd_|Jeb<*`Uf6c zB_ANy4_rosQ=4OCX~VT%2?NyzvI= zYP1S->v zX-Dh536I*7wTR*4BEFNFk0}kF~AVf3}SYJgKH(^h0f#uDC%d-|5EJ#E&_SrN{itP z1^T(N*Wot;g#&o|0XbOX;4Mf<;y&ytrh-yDR^BWIr4$!x9i?+Rr5TDFY5-;io)4$X zZvH373r@WDR~4nGCC&KjF}z&(>%3OWhH^(MpDemM$<@Z@5Rk{&*Db)v9s@iCY#E}lS;qJ)<0A8Jyo%rt^-lX*HgFvV?2s})u#xzs0 zA&1;ZP>_rG4|BBo41(M-Y1U{CPiF;=pcdn*?a!EC*i3k$dKap2wtT(8`#MucP;-zjXTZ%N6maOO3)hW7#aEZ0U{5J8~#R4%N1rYklh zG6x47KxogWQezaU?Y4OlvZsz|;PX*#8V%2->C$*7kE+RmCoPwun-ba_IhHo3&XUa$ zScjsuqIcZsU61>Krf`Gw;F$Kx$z+=8Xi3?0lZvG#{`sZAdk(*g3m8eQ7BN@dFL(kj zjcs*blwN`-+qa#zrL(o!QyJyU_S{p&4okZ4zfrIrYS~(ZD4hrDMut#!p2wYBE9w5p zK8sgoZ#EDkzgv`U)Mit*Y-!&~qMZBTQZK`MlQsXN&Erq__q!~M1^xequN#}eG@Qa6(HLrOg7rHxTiPOZyi zdUHwDs?eF>pARf{$UB4p$4XJN6S$7s2Y7QvTT#ZHOrVcD1y8AXb*eJ@duI6`R87h} z#Z1xI$ookXm-l=XPsxcvj1N8ATt~Q%1ux%M&QM$1E1T-2#w6l|xg^y*E~7)aVCQ|E z-xVgUT>Ejh(MgoyU9IeCuy;lAl!6#!i2v=o=eJTk7jIK<>P3$QzpvYf2o6%4*+3eL ztEaeRfdpdTF^A|*_RUvvoXWvnbsE*S+1$O3C$`rycdx?}Xo1UZJ)e9gxQhtSXx|1u z(bbY2VA{*N*Vrz*-#o+em`~CpQ0?Ik?m~T`(81o(Nn_K&Z%)ASpxG0+2a{hLsD&wP zVvgM@Ef&E#2;2TRFRq*d&4PRt&TDEl{@i?Cp_T}v=JVZ}BpoYtQ$y$l z8E0iJPxC8Hm5v?Y8bj%&>ay(aS!)2CA6Pn(Z>Zx12OZw%tp|}q0AEswGPv3?$_2`5SK}@%z|Y1NgD6; z91*~7f`~RcopDt^QwAL}c%dyQg5qfO7j%L*TvJr?U7Yo309Ssu9TtmA%nRbjQf`4= zk;J7VTes|sqrNqG(9PZv-C-@N^3Ud;6hIDSIB8>@V=Ea*UXg zEz<*rk1KtIN^_pj0Qke-JTlRx#c_sA-?B%WfwtdcSGS)u5kF#w%7P>djJ&l^Cst0% zj0gKPGA9k&?yj*1ShyKP!JS?GL@O~i&s;O2eCwwuwag%?nIBJgciKGxhCPem^d~t9%eMVMq*~YywjIB*LG%@Sv@bnGnxXI< z_3e13o|ZZKEZ|?QfDVxzw z|8^6z;}>7N4L0RnPBk!jZa~`L!PmOV8+~h_Q)J5~V?NpNO!-^eb^9)V>1ntN>|CcD z+0lxOJp#3zR?PJ4dr(7@rIA#SdVEQAE7F?XxQw+EcU0X~X0*jdi)1sKcA*5@*39vx z%8iCKJ&lLIV}+ByeI&i#BHVgcd@QlTID3OetPhpx2~RkK|+4!+im4i1nilZhHJTH zF7kSciR92kfr0fqP|cZIf{S2v$msdkKE z9RM(tQM}bX^&A|I!b$23R-+TB;K72A%%cKo3h-Xz(}4U_&o3g97N4tsB;4B&w>Wdh zyNM>mU5tNLU_T8{%eT-`y6q)X1A#pa8#9F86}Vl(2L?b^4)~SFhX~wQ_s&4}1_eiM z9f4)2JZbiUmsVfHH826?=MZ{M0D%lTLT5FSH9XH5I^D@KpD}Z6_?rgaLJ9vV2U8~J ze~VHx6Wm7C(vjOkKdYi?SLrF!Bi;jvFcq7P0_E^z}+5+maVG_|zNN4W6(&+s$zxGH)E?t<~b|0R7 zTCGp>S8YhL3-IFA3RheHCUj5Y$pK}XM>EdYEGX%MyW>^sS|giwoEiiyZ0wiR=-nS{ z&`F=Z%xDyHLiLdc$v}$&SIRN-OE!*Rw)QIwEMk&~uc$?|Jj3HMO7s%56+~dO8xmu7 z;-Y{`uF-e*L2ed~Th6UwetBbLZ*F?F4~yHezxmS+Fn@YwIBD*w;&V%qo_rCEK*4N-U7ju=}Xp#?JxRTRQM|ksSjg z7ld&eWSO(wq(i2sNCV8cQeNQ$S2NE%Z=`aYh~9EU6IheNs0Pu1ZJcw2z=;4XmD+19Lt90W`l%wTW2 zW@3)XXFrkInYDU4)~+yWs80bIx=6yu=G>}Q=61~#YsRuXB?fT-Se?ZeQL*#+(du|0 z=Y4I5>>Q(hy|3@v`ybzNHU<;t!~W@-K?ukFP`iE|7aquljLAoME2=+mae4Ln>+_zL zEv-2TCqR^6Sbyd=g|o}Ve0Xp};^*S7`!BYozLqCz1(K#zS@z}2pM0BUjThSEt}nKr zoPLrtbp+JahdwL*R6nW<-YxK&4SpZ_$$Mg>_g`@iyPFk0e>Cl+c8$o=p0a9oe59fK zC|%*M9OtY5`tGb-^_~Xp2D-+b5 ( +
+

Routes laden

+

De geselecteerde route en live databronnen worden voorbereid.

+
+); + +const App = () => ( + + + + + + + }> + + {appRoutes.map(({ path, Component }) => ( + } /> + ))} + + + + + + +); + +export default App; diff --git a/src/api/appopsClient.livePolicy.test.ts b/src/api/appopsClient.livePolicy.test.ts new file mode 100644 index 0000000..e4220f0 --- /dev/null +++ b/src/api/appopsClient.livePolicy.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("./appopsClient.ts", import.meta.url), "utf8"); +const liveClientSource = readFileSync(new URL("./liveClient.ts", import.meta.url), "utf8"); + +test("appops client is live-only and never instantiates mock data", () => { + assert.match(source, /createLiveClient/); + assert.match(source, /mode\(\)[\s\S]+live/); + assert.doesNotMatch(source, /createMockClient|mockClient|mockData|aiMockData|VITE_APPOPS_API_MODE|API_MODE|liveOnly|withFallback/); +}); + +test("browser client never embeds the Home Assistant voice token", () => { + assert.doesNotMatch(liveClientSource, /VITE_APPOPS_VOICE_TOKEN|voiceToken|\/api\/voice\//); + assert.match(liveClientSource, /\/api\/operator\/voice\//); +}); diff --git a/src/api/appopsClient.ts b/src/api/appopsClient.ts new file mode 100644 index 0000000..5076f2e --- /dev/null +++ b/src/api/appopsClient.ts @@ -0,0 +1,12 @@ +import { createLiveClient } from './liveClient'; + +const liveClient = createLiveClient(); + +export const appopsClient = { + ...liveClient, + get mode() { + return 'live' as const; + }, +}; + +export type AppopsClient = typeof appopsClient; diff --git a/src/api/liveClient.ts b/src/api/liveClient.ts new file mode 100644 index 0000000..746df6c --- /dev/null +++ b/src/api/liveClient.ts @@ -0,0 +1,818 @@ +import { ActionID, ActionPlan, ActionRegistryDetail, ActionRegistryResponse, ActionResult, ApprovalRequest, AiMemoryItem, AiRecommendation, App, AuditDetail, AuditEvent, Connector, ConnectorBinding, ConnectorCandidate, ConnectorSettings, ConnectorSettingsPayload, DeepHealth, HealthCheck, HomeAssistantExport, HomeAssistantVoiceReadiness, Incident, JobRun, NetworkExposure, PreflightPreview, RuntimeInfo, SetupCompleteResult, SetupConnectionTestResult, SetupReconcileResult, SetupStatus, Stack, StorageSummary, Container, ContainerLogsResponse, StatisticsSummary, SystemSummary, UnraidSummary, VoiceCommand, SourceType, PolicyRule, Playbook, PolicyDecision, RiskLevel, AiToolPermission, Status, SonarrAddSeriesPayload, SonarrHealth, SonarrQueueSummary, SonarrSearchResponse, SonarrWantedSummary, RadarrAddMoviePayload, RadarrHealth, RadarrQueueSummary, RadarrSearchResponse, RadarrWantedSummary, InventoryDiscoveryResult, InventoryDriftItem, InventoryMapExistingPayload, InventoryReviewUpdatePayload, InventoryRunSummary, DiscoveredContainer, AppContainerMapping, WidefrogActivity, WidefrogDownloadStatus, WidefrogFailedJobs, WidefrogHealth, WidefrogQueueSummary, SecretsResponse, SqliteBackupCreateResult, SqliteBackupsResponse, SqliteBackupVerifyResult, SqliteCheckpointResult, SqliteMaintenanceStatus, ProwlarrApplicationStatus, ProwlarrHealth, ProwlarrIndexerHealth, ProwlarrIndexerStatus, ProwlarrSearchTest, ProwlarrSyncStatus, DelugeDownloadSummary, DelugeHealth, DelugeSessionStatus, DelugeTorrentStatus, DelugeSpeedStatus, PlexHealth, PlexLibraryStatus, PlexRecentMedia, PlexServerStatus, PlexSessionStatus, PlexStreamSummary, TautulliActivity, TautulliHealth, TautulliHistory, TautulliLibraries, TautulliPlexInfo, TautulliRecentlyAdded, TautulliStatus, TautulliStreamSummary, TautulliUsers, TautulliWatchStats, NpmAccessLists, NpmCertificates, NpmExposureSummary, NpmHealth, NpmHostReachability, NpmProxyHosts, NpmSecurityWarnings, AuthentikApplication, AuthentikAuthCoverage, AuthentikCollection, AuthentikEvent, AuthentikFlowsSummary, AuthentikHealth, AuthentikOAuth2Provider, AuthentikOutpost, AuthentikOutpostHealth, AuthentikPoliciesSummary, AuthentikProvider, AuthentikProxyProvider, AuthentikSecurityWarnings, AuthentikSystemStatus, MediaPipelineActivity, MediaPipelineCheckResult, MediaPipelineDiagnosis, MediaPipelineDownloads, MediaPipelineFlow, MediaPipelineIssues, MediaPipelineLibrary, MediaPipelineRecommendations, MediaPipelineSummary, VoiceIntentTestResult } from '../types/appops'; +import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } from '../types/ai'; +import { toIsoString } from '@/lib/backendDateTime'; +import type { AiSecurityFinding, AiSecurityRecommendation, AiSecurityReviewResult, AiSecurityScope, AiSecuritySummary, NotificationChannel, NotificationEvaluationResult, NotificationEvent, NotificationRule, NotificationSummary, RemediationApprovalProposal, RemediationDryRunResult, RemediationPlan, RemediationPlanCollection, SafetyDashboard, SafetyFinding, SafetyRecommendation, SafetyScoreBreakdown, SafetySection, SafetySummary, UnraidDisksResponse, UnraidDockerResponse, UnraidSharesResponse, UnraidVmCapabilitiesResponse, UnraidVmsResponse } from '../types/appops'; +import type { GiteaActionsStatus, IntegrationStatus, OperatorAuthPolicy, OperatorSessionStatus, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops'; +import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, WorkMeshPromotion } from '../types/appops'; + +type HealthStatus = { + status: string; +}; + +type NotificationEventPageOptions = { + limit?: number; + offset?: number; + includeDismissed?: boolean; +}; + +const notificationEventPath = (path: string, options: NotificationEventPageOptions = {}): string => { + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + if (options.offset !== undefined) params.set('offset', String(options.offset)); + if (options.includeDismissed !== undefined) params.set('include_dismissed', String(options.includeDismissed)); + const query = params.toString(); + return query ? `${path}?${query}` : path; +}; + +export interface LiveClientConfig { + baseUrl: string; + requestTimeoutMs: number; +} + +const defaultConfig: LiveClientConfig = { + baseUrl: (import.meta.env.VITE_APPOPS_API_BASE_URL || '').replace(/\/$/, ''), + requestTimeoutMs: 12000, +}; + +const safetyConfig: LiveClientConfig = { + ...defaultConfig, + requestTimeoutMs: 30000, +}; + +const containerListConfig: LiveClientConfig = { + ...defaultConfig, + requestTimeoutMs: 30000, +}; + +const containerStatsConfig: LiveClientConfig = { + ...defaultConfig, + requestTimeoutMs: 45000, +}; + +const inventoryConfig: LiveClientConfig = { + ...defaultConfig, + requestTimeoutMs: 30000, +}; + +const mediaPipelineConfig: LiveClientConfig = { + ...defaultConfig, + requestTimeoutMs: 45000, +}; + +const REDACT_PATTERNS: RegExp[] = [ + /Bearer\s+\S+/gi, + /\b(?:token|password|api[_-]?key)\s*[:=]\s*[^\s"'`]+/gi, +]; + +const redactText = (input: string): string => { + let value = input; + for (const pattern of REDACT_PATTERNS) { + value = value.replace(pattern, (match) => `${match.split(':')[0].split('=')[0]} [REDACTED]`); + } + return value; +}; + +const friendlyBackendError = (status: number, message: string): string => { + if (status === 401) { + return "Operatorautorisatie ontbreekt of is verlopen. Meld opnieuw aan via de accountproxy of ontgrendel dit tabblad via Instellingen → Beveiliging."; + } + if (status === 403) { + return "Backend autorisatie is geweigerd."; + } + return `Backend ${status}: ${redactText(message || "Request failed")}`; +}; + +const request = async ( + path: string, + config: LiveClientConfig, + options: RequestInit = {}, +): Promise => { + const url = `${config.baseUrl}${path.startsWith('/') ? path : `/${path}`}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs); + + const headers = new Headers(options.headers || {}); + headers.set('Content-Type', 'application/json'); + const method = (options.method || 'GET').toUpperCase(); + if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) { + headers.set('X-AppOps-Session-CSRF', '1'); + } + + try { + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + signal: controller.signal, + }); + + if (!response.ok) { + const message = await response.text(); + throw new Error(friendlyBackendError(response.status, message || response.statusText)); + } + + if (response.status === 204) { + return null as T; + } + + return (await response.json()) as T; + } catch (error) { + if (error instanceof Error) { + throw new Error(redactText(error.message)); + } + throw new Error('Onbekende netwerkfout in backend aanvraag.'); + } finally { + clearTimeout(timeout); + } +}; + +const toRisk = (risk?: unknown): RiskLevel => ( + risk === 'low' || risk === 'medium' || risk === 'high' || risk === 'critical' + ? risk + : 'low' +) as RiskLevel; + +const toPolicyDecision = (decision?: unknown): PolicyDecision => ( + decision === 'allowed' || decision === 'approval_required' || decision === 'blocked' + ? decision + : 'blocked' +) as PolicyDecision; + +const toStringArray = (value: unknown): string[] => ( + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +); + +const toAuditStatus = (value: unknown): Status => { + if (value === 'queued') { + return 'running'; + } + + if (value === 'succeeded') { + return 'success'; + } + + if (value === 'running' || value === 'failed' || value === 'success' || value === 'missing_config' + || value === 'blocked' || value === 'acknowledged' || value === 'muted' || value === 'resolved' + || value === 'warning' || value === 'healthy' || value === 'critical' || value === 'starting' + || value === 'stopped' || value === 'unhealthy') { + return value; + } + + return 'failed'; +}; + +const normalizePolicyRule = (raw: Record): PolicyRule => ({ + id: String(raw.id ?? `${raw.action}_${raw.target}`), + action: String(raw.action ?? ''), + target: String(raw.target ?? ''), + voice: toPolicyDecision(raw.voice), + manual: toPolicyDecision(raw.manual), + confirmation: Boolean(raw.confirmation), + risk: toRisk(raw.risk), + reason: typeof raw.reason === 'string' ? raw.reason : undefined, +}); + +const normalizeConnector = (raw: Record): Connector => ({ + id: String(raw.id ?? ''), + name: String(raw.name ?? ''), + status: (raw.status === 'connected' || raw.status === 'disconnected' || raw.status === 'error' || raw.status === 'missing_config') + ? raw.status + : 'disconnected', + lastTest: typeof raw.last_test === 'string' + ? raw.last_test + : typeof raw.lastTest === 'string' + ? raw.lastTest + : 'never', + capabilities: toStringArray(raw.capabilities), + type: typeof raw.type === 'string' ? raw.type : 'Infrastructure', + endpoint: typeof raw.endpoint === 'string' ? raw.endpoint : undefined, + secretRef: typeof raw.secret_ref === 'string' ? raw.secret_ref : ( + typeof raw.secretRef === 'string' ? raw.secretRef : undefined + ), +}); + +const normalizeApproval = (raw: Record): ApprovalRequest => { + const target = String(raw.target ?? ''); + const action = String(raw.action_id ?? raw.action ?? ''); + const source = raw.source === 'voice' || raw.source === 'manual' || raw.source === 'automation' || raw.source === 'system' + ? raw.source + : 'manual'; + const createdAt = raw.created_at ?? raw.timestamp; + const expiresAt = raw.expires_at ?? raw.expiresAt; + const reason = typeof raw.reason === 'string' ? raw.reason : ''; + return { + id: String(raw.id ?? ''), + action, + target, + requester: typeof raw.requester === 'string' ? raw.requester : 'system', + source, + risk: toRisk(raw.risk), + timestamp: toIsoString(createdAt), + expiresAt: toIsoString(expiresAt), + context: reason || `${action} op ${target}`, + affectedApps: toStringArray(raw.affected_apps ?? raw.affectedApps).length + ? toStringArray(raw.affected_apps ?? raw.affectedApps) + : [target].filter(Boolean), + reason, + plan: toStringArray(raw.plan ?? raw.execution_plan), + status: raw.status === 'approved' || raw.status === 'rejected' || raw.status === 'expired' || raw.status === 'executed' + ? raw.status + : 'pending', + auditId: typeof raw.audit_event_id === 'string' ? raw.audit_event_id : ( + typeof raw.audit_id === 'string' ? raw.audit_id : ( + typeof raw.auditId === 'string' ? raw.auditId : undefined + ) + ), + jobId: typeof raw.job_id === 'string' ? raw.job_id : ( + typeof raw.jobId === 'string' ? raw.jobId : undefined + ), + executionSupported: raw.executionSupported === true || raw.execution_supported === true, + executionReason: typeof raw.executionReason === 'string' ? raw.executionReason : ( + typeof raw.execution_reason === 'string' ? raw.execution_reason : undefined + ), + }; +}; + +const normalizeAudit = (raw: Record): AuditEvent => { + const details: AuditDetail = { + reason: typeof raw.reason === 'string' ? raw.reason : undefined, + beforeState: raw.before_state ? JSON.stringify(raw.before_state) : undefined, + afterState: raw.after_state ? JSON.stringify(raw.after_state) : undefined, + metadata: raw.redacted_metadata && typeof raw.redacted_metadata === 'object' + ? raw.redacted_metadata as Record + : undefined, + }; + + const executionPlan = raw.execution_plan; + if (executionPlan) { + if (Array.isArray(executionPlan)) { + details.plan = executionPlan.filter((step): step is string => typeof step === 'string'); + } else if (typeof executionPlan === 'object' && executionPlan !== null && Array.isArray((executionPlan as Record).plan)) { + const nestedExecutionPlan = (executionPlan as Record).plan; + details.plan = Array.isArray(nestedExecutionPlan) + ? nestedExecutionPlan.filter((step): step is string => typeof step === 'string') + : []; + } else if (typeof executionPlan === 'string') { + details.plan = [executionPlan]; + } + } + + if (Array.isArray(raw.post_checks)) { + details.postChecks = raw.post_checks.filter((item): item is string => typeof item === 'string'); + } else if (Array.isArray(raw.postChecks)) { + details.postChecks = raw.postChecks.filter((item): item is string => typeof item === 'string'); + } + + return { + id: String(raw.id ?? ''), + time: toIsoString(raw.timestamp), + source: raw.source === 'manual' || raw.source === 'voice' || raw.source === 'automation' || raw.source === 'system' + ? raw.source + : 'manual', + user: typeof raw.identity === 'string' ? raw.identity : 'system', + action: String(raw.action_id ?? raw.action ?? ''), + target: String(raw.target ?? ''), + connector: typeof raw.target_type === 'string' ? raw.target_type : 'system', + risk: toRisk(raw.risk), + policy: toPolicyDecision(raw.policy_decision), + result: toAuditStatus(raw.result), + duration: typeof raw.duration_ms === 'number' ? `${raw.duration_ms}ms` : undefined, + details, + }; +}; + +const normalizeJob = (raw: Record): JobRun => { + const durationMs = typeof raw.duration_ms === 'number' ? raw.duration_ms : undefined; + return { + id: String(raw.id ?? ''), + type: typeof raw.type === 'string' ? raw.type : 'job', + status: (raw.status === 'queued' || raw.status === 'running' || raw.status === 'succeeded' || raw.status === 'failed' || raw.status === 'cancelled') + ? raw.status + : 'queued', + source: raw.source === 'manual' || raw.source === 'voice' || raw.source === 'automation' || raw.source === 'system' + ? raw.source + : 'manual', + actionId: String(raw.action_id ?? raw.actionId ?? ''), + target: String(raw.target ?? ''), + risk: toRisk(raw.risk), + policyDecision: toPolicyDecision(raw.policy_decision), + stepsJson: typeof raw.steps_json === 'string' ? raw.steps_json : JSON.stringify(raw.steps_json ?? []), + progress: typeof raw.progress === 'number' ? raw.progress : 0, + startedAt: typeof raw.started_at === 'string' || typeof raw.started_at === 'number' + ? toIsoString(raw.started_at) + : toIsoString(raw.startedAt), + finishedAt: typeof raw.finished_at === 'string' || typeof raw.finished_at === 'number' + ? toIsoString(raw.finished_at) + : typeof raw.finishedAt === 'string' || typeof raw.finishedAt === 'number' + ? toIsoString(raw.finishedAt) + : undefined, + durationMs, + auditEventId: typeof raw.audit_event_id === 'string' ? raw.audit_event_id : ( + typeof raw.auditEventId === 'string' ? raw.auditEventId : undefined + ), + resultSummary: typeof raw.result_summary === 'string' ? raw.result_summary : ( + typeof raw.resultSummary === 'string' ? raw.resultSummary : undefined + ), + }; +}; + +const post = (path: string, body: unknown, config: LiveClientConfig) => + request(path, config, { method: 'POST', body: JSON.stringify(body) }); + +const patch = (path: string, body: unknown, config: LiveClientConfig) => + request(path, config, { method: 'PATCH', body: JSON.stringify(body) }); + +const get = (path: string, config: LiveClientConfig) => + request(path, config, { method: 'GET' }); + +export const createLiveClient = () => ({ + async getApps(includeDiscovered = false): Promise { return get(`/api/apps${includeDiscovered ? '?include_discovered=true' : ''}`, defaultConfig); }, + async getApp(id: string): Promise { return get(`/api/apps/${id}`, defaultConfig); }, + async updateAppMetadata(id: string, payload: Pick): Promise { + return request(`/api/apps/${encodeURIComponent(id)}/metadata`, defaultConfig, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + }, + async getContainers(includeStats = false, statsLimit = 250): Promise { + const query = includeStats ? `?include_stats=true&stats_limit=${Math.max(1, Math.min(statsLimit, 500))}` : ''; + return get(`/api/containers${query}`, includeStats ? containerStatsConfig : containerListConfig); + }, + async getContainerLogs(id: string): Promise { + return get(`/api/containers/${encodeURIComponent(id)}/logs`, defaultConfig); + }, + async getStacks(): Promise { return get('/api/stacks', defaultConfig); }, + async getDeploymentJobs(): Promise<{ items: DeploymentJob[] }> { return get<{ items: DeploymentJob[] }>('/api/deployments', defaultConfig); }, + async getWorkMeshPromotions(limit = 20): Promise<{ items: WorkMeshPromotion[] }> { + return get<{ items: WorkMeshPromotion[] }>(`/api/deployments/workmesh/promotions?limit=${Math.max(1, Math.min(limit, 50))}`, defaultConfig); + }, + async getWorkMeshPromotion(id: string): Promise { + return get(`/api/deployments/workmesh/promotions/${encodeURIComponent(id)}`, defaultConfig); + }, + async requestWorkMeshPromotionPreflight(revision: string, idempotencyKey: string): Promise { + return request('/api/deployments/workmesh/promotions/preflight', defaultConfig, { + method: 'POST', + headers: { 'Idempotency-Key': idempotencyKey }, + body: JSON.stringify({ revision }), + }); + }, + async getDeliveryStatus(): Promise { return get('/api/deployments/delivery-status', defaultConfig); }, + async getEnrollmentCapabilities(): Promise { return get('/api/enrollments/capabilities', defaultConfig); }, + async getEnrollments(): Promise<{ items: Enrollment[] }> { return get<{ items: Enrollment[] }>('/api/enrollments', defaultConfig); }, + async getSystemSummary(): Promise { return get('/api/system/summary', defaultConfig); }, + async getHealth(): Promise { return get('/api/health', defaultConfig); }, + async getRuntimeInfo(): Promise { return get('/api/runtime/info', defaultConfig); }, + async getOperatorSession(): Promise { return get('/api/operator/session', defaultConfig); }, + async getOperatorAuthPolicy(): Promise { return get('/api/runtime/operator-auth', defaultConfig); }, + async updateOperatorAuthPolicy(required: boolean): Promise { + return request('/api/runtime/operator-auth', defaultConfig, { + method: 'PATCH', + body: JSON.stringify({ required }), + }); + }, + async unlockOperatorSession(token: string): Promise { + return request('/api/operator/session', defaultConfig, { + method: 'POST', + headers: { 'X-AppOps-Operator-Token': token }, + body: '{}', + }); + }, + async lockOperatorSession(): Promise { + return request('/api/operator/session', defaultConfig, { method: 'DELETE' }); + }, + async getDeepHealth(): Promise { return get('/api/health/deep', defaultConfig); }, + async exportAdminData(kind: 'config' | 'policies' | 'inventory' | 'audit-summary'): Promise> { + return get>(`/api/admin/export/${kind}`, defaultConfig); + }, + async listSqliteBackups(): Promise { return get('/api/admin/backups/sqlite', defaultConfig); }, + async getSqliteMaintenanceStatus(): Promise { return get('/api/admin/database/status', defaultConfig); }, + async checkpointSqlite(): Promise { return post('/api/admin/database/checkpoint', {}, defaultConfig); }, + async createSqliteBackup(): Promise { return post('/api/admin/backups/sqlite', {}, defaultConfig); }, + async verifySqliteBackup(name: string): Promise { + return post(`/api/admin/backups/sqlite/${encodeURIComponent(name)}/verify`, {}, defaultConfig); + }, + async getUnraidSummary(): Promise { return get('/api/unraid/summary', defaultConfig); }, + async getIncidents(): Promise { return get('/api/incidents', defaultConfig); }, + async getAuditLogs(limit = 200, offset = 0): Promise { + const response = await get>>(`/api/audit?limit=${Math.max(1, Math.min(limit, 500))}&offset=${Math.max(0, offset)}`, defaultConfig); + return response.map(normalizeAudit); + }, + async getPolicies(): Promise { + const response = await get>>('/api/policies', defaultConfig); + return response.map(normalizePolicyRule); + }, + async updatePolicy(id: string, payload: Pick & { reason: string }): Promise { + const response = await request>(`/api/policies/${encodeURIComponent(id)}`, defaultConfig, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + return normalizePolicyRule(response); + }, + async getConnectors(): Promise { + const response = await get>>('/api/connectors?refresh=true', defaultConfig); + return response.map(normalizeConnector); + }, + async getSetupStatus(): Promise { return get('/api/setup/status', defaultConfig); }, + async completeSetup(): Promise { + return post('/api/setup/complete', {}, defaultConfig); + }, + async reconcileSetup(): Promise { + return post('/api/setup/reconcile', {}, defaultConfig); + }, + async markSetupComplete(): Promise { + return post('/api/setup/mark-complete', { confirm: 'MARK_SETUP_COMPLETE' }, defaultConfig); + }, + async markVoiceConfigured(): Promise { + return post('/api/setup/voice/mark-configured', {}, defaultConfig); + }, + async getConnectorSettings(): Promise { return get('/api/settings/connectors', defaultConfig); }, + async getSecrets(): Promise { return get('/api/settings/secrets', defaultConfig); }, + async updateConnectorSettings(id: string, payload: ConnectorSettingsPayload): Promise { + return request(`/api/settings/connectors/${encodeURIComponent(id)}`, defaultConfig, { method: 'PATCH', body: JSON.stringify(payload) }); + }, + async testSetupConnector(id: string): Promise { + if (id === 'portainer' || id === 'unraid') { + return post(`/api/setup/test/${id}`, {}, defaultConfig); + } + return post(`/api/setup/test/connector/${encodeURIComponent(id)}`, {}, defaultConfig); + }, + async getConnectorCandidates(id: string): Promise { + return get(`/api/connectors/${encodeURIComponent(id)}/candidates`, defaultConfig); + }, + async getConnectorBindings(id: string): Promise { + return get(`/api/connectors/${encodeURIComponent(id)}/bindings`, defaultConfig); + }, + async bindConnector(id: string, discoveredContainerId: string): Promise { + return post(`/api/connectors/${encodeURIComponent(id)}/bind`, { discoveredContainerId }, defaultConfig); + }, + async unbindConnector(id: string, appId: string): Promise { + return post(`/api/connectors/${encodeURIComponent(id)}/unbind`, { appId }, defaultConfig); + }, + async getPlaybooks(): Promise { return get('/api/playbooks', defaultConfig); }, + async getApprovals(): Promise { + const response = await get>>('/api/approvals', defaultConfig); + return response.map(normalizeApproval); + }, + async cleanupExpiredApprovals(): Promise { + return post('/api/approvals/cleanup', {}, defaultConfig); + }, + async getVoiceCommands(): Promise { return get('/api/operator/voice/commands', defaultConfig); }, + async getHomeAssistantExport(): Promise { return get('/api/operator/voice/home-assistant/export', defaultConfig); }, + async getHomeAssistantVoiceReadiness(): Promise { + return get('/api/operator/voice/home-assistant/readiness', defaultConfig); + }, + async testVoiceIntent(input: string): Promise { + return post('/api/operator/voice/test-intent', { input }, defaultConfig); + }, + async dryRunVoiceCommand(id: string): Promise { + return post(`/api/operator/voice/commands/${encodeURIComponent(id)}/dry-run`, {}, defaultConfig); + }, + async getActionRegistry(): Promise { + return get('/api/actions/registry', defaultConfig); + }, + async getActionRegistryDetail(id: string): Promise { + return get(`/api/actions/registry/${encodeURIComponent(id)}`, defaultConfig); + }, + async getActionPlan(input: string, source: SourceType): Promise { + const response = await post<{ intent?: string; target?: string; source?: SourceType; risk?: string; policy?: string; reason?: string; plan?: string[]; postChecks?: string[] }>( + '/api/actions/plan', + { input, source }, + defaultConfig + ); + if (!response.intent || !response.target) { + return null; + } + const risk = response.risk; + const policy = response.policy; + + const mappedRisk = (risk === 'low' || risk === 'medium' || risk === 'high' || risk === 'critical') ? (risk as RiskLevel) : 'low'; + const mappedPolicy = (policy === 'allowed' || policy === 'approval_required' || policy === 'blocked') ? (policy as PolicyDecision) : 'blocked'; + + return { + intent: response.intent as ActionID, + target: response.target, + source: response.source || source, + risk: mappedRisk, + policy: mappedPolicy, + reason: response.reason, + plan: response.plan || [], + postChecks: response.postChecks || [], + }; + }, + async evaluatePolicy(action: string, target: string, source: string): Promise<{ decision: string; reason: string }> { + return post<{ decision: string; reason: string }>('/api/policies/evaluate', { action, target, source }, defaultConfig); + }, + async executeAction(plan: ActionPlan): Promise { + return post('/api/actions/execute', { plan }, defaultConfig); + }, + async restartContainer(appId: string): Promise { + return post(`/api/actions/container/${encodeURIComponent(appId)}/restart`, {}, defaultConfig); + }, + async requestApproval(plan: ActionPlan): Promise { + return post('/api/approvals', { action: plan.intent, target: plan.target, source: plan.source, risk: plan.risk, reason: plan.reason, plan: plan.plan }, defaultConfig); + }, + async updateApprovalStatus(id: string, status: 'approved' | 'rejected'): Promise { + return patch(`/api/approvals/${encodeURIComponent(id)}`, { status }, defaultConfig); + }, + async executeApproval(id: string): Promise { + return post(`/api/approvals/${encodeURIComponent(id)}/execute`, {}, defaultConfig); + }, + async updateIncidentStatus(id: string, status: string): Promise { + return post(`/api/incidents/${id}/status`, { status }, defaultConfig); + }, + async exportAudit(format: string): Promise { + return post('/api/audit/export', { format }, defaultConfig); + }, + async testConnector(id: string): Promise { return post(`/api/connectors/${id}/test`, {}, defaultConfig); }, + async getAISummary(): Promise { return get('/api/ai/status', defaultConfig); }, + async getAIRecommendations(): Promise { return get('/api/ai/recommendations', defaultConfig); }, + async getAIChatHistory(): Promise { return get('/api/ai/history', defaultConfig); }, + async getAIAutopilotRules(): Promise { return get('/api/ai/tools', defaultConfig); }, + async getAIMemory(): Promise { return get('/api/ai/memory', defaultConfig); }, + async getAITools(): Promise { return get('/api/ai/tools', defaultConfig); }, + async sendAIChatMessage(content: string): Promise { + return post('/api/ai/chat', { content }, defaultConfig); + }, + async getIntegrationStatus(): Promise { + return get('/api/integrations/status', defaultConfig); + }, + async getRagStatus(): Promise { + return get('/api/ai/rag/status', defaultConfig); + }, + async rebuildRagIndex(): Promise { + return post('/api/ai/rag/reindex', {}, defaultConfig); + }, + async queryRag(question: string, limit = 6): Promise { + return post('/api/ai/rag/query', { question, limit }, defaultConfig); + }, + async getAiSecurityStatus(): Promise { return get('/api/ai/security/status', defaultConfig); }, + async getAiSecuritySummary(): Promise { return get('/api/ai/security/summary', defaultConfig); }, + async getAiSecurityFindings(scope: AiSecurityScope = 'all'): Promise { + return get(`/api/ai/security/findings?scope=${encodeURIComponent(scope)}`, defaultConfig); + }, + async getAiSecurityFinding(id: string): Promise { + return get(`/api/ai/security/findings/${encodeURIComponent(id)}`, defaultConfig); + }, + async getAiSecurityRecommendations(): Promise { + return get('/api/ai/security/recommendations', defaultConfig); + }, + async runAiSecurityReview(scope: AiSecurityScope = 'all'): Promise { + return post(`/api/ai/security/review/scope/${encodeURIComponent(scope)}`, {}, defaultConfig); + }, + async acknowledgeAiSecurityFinding(id: string): Promise<{ success: boolean; status: string; finding?: AiSecurityFinding | null }> { + return post<{ success: boolean; status: string; finding?: AiSecurityFinding | null }>(`/api/ai/security/findings/${encodeURIComponent(id)}/acknowledge`, {}, defaultConfig); + }, + async dismissAiSecurityFinding(id: string): Promise<{ success: boolean; status: string; finding?: AiSecurityFinding | null }> { + return post<{ success: boolean; status: string; finding?: AiSecurityFinding | null }>(`/api/ai/security/findings/${encodeURIComponent(id)}/dismiss`, {}, defaultConfig); + }, + async snoozeAiSecurityRecommendation(id: string): Promise<{ success: boolean; status: string; applied: boolean; recommendation?: AiSecurityRecommendation | null }> { + return post<{ success: boolean; status: string; applied: boolean; recommendation?: AiSecurityRecommendation | null }>(`/api/ai/security/recommendations/${encodeURIComponent(id)}/snooze`, {}, defaultConfig); + }, + async dismissAiSecurityRecommendation(id: string): Promise<{ success: boolean; status: string; applied: boolean; recommendation?: AiSecurityRecommendation | null }> { + return post<{ success: boolean; status: string; applied: boolean; recommendation?: AiSecurityRecommendation | null }>(`/api/ai/security/recommendations/${encodeURIComponent(id)}/dismiss`, {}, defaultConfig); + }, + async acceptAiSecurityRecommendation(id: string): Promise<{ success: boolean; status: string; applied: boolean; recommendation?: AiSecurityRecommendation | null }> { + return post<{ success: boolean; status: string; applied: boolean; recommendation?: AiSecurityRecommendation | null }>(`/api/ai/security/recommendations/${encodeURIComponent(id)}/accept`, {}, defaultConfig); + }, + async getRemediationStatus(): Promise> { return get>('/api/ai/remediation/status', defaultConfig); }, + async getRemediationPlans(): Promise { return get('/api/ai/remediation/plans', defaultConfig); }, + async getRemediationPlan(id: string): Promise { return get(`/api/ai/remediation/plans/${encodeURIComponent(id)}`, defaultConfig); }, + async generateRemediationPlans(): Promise { return post('/api/ai/remediation/plans/generate', {}, defaultConfig); }, + async generateRemediationFromFinding(id: string): Promise { + return post(`/api/ai/remediation/plans/from-finding/${encodeURIComponent(id)}`, {}, defaultConfig); + }, + async generateRemediationFromRecommendation(id: string): Promise { + return post(`/api/ai/remediation/plans/from-recommendation/${encodeURIComponent(id)}`, {}, defaultConfig); + }, + async reviewRemediationPlan(id: string): Promise<{ success: boolean; status: string; plan?: RemediationPlan | null }> { + return post<{ success: boolean; status: string; plan?: RemediationPlan | null }>(`/api/ai/remediation/plans/${encodeURIComponent(id)}/review`, {}, defaultConfig); + }, + async dismissRemediationPlan(id: string): Promise<{ success: boolean; status: string; plan?: RemediationPlan | null }> { + return post<{ success: boolean; status: string; plan?: RemediationPlan | null }>(`/api/ai/remediation/plans/${encodeURIComponent(id)}/dismiss`, {}, defaultConfig); + }, + async createRemediationApprovalProposal(id: string): Promise { + return post(`/api/ai/remediation/plans/${encodeURIComponent(id)}/create-approval-proposal`, {}, defaultConfig); + }, + async dryRunRemediationPlan(id: string): Promise { + return post(`/api/ai/remediation/plans/${encodeURIComponent(id)}/dry-run`, {}, defaultConfig); + }, + async getStatisticsSummary(): Promise { return get('/api/statistics/summary', defaultConfig); }, + async getStorageSummary(): Promise { return get('/api/storage/summary', defaultConfig); }, + async getUnraidDisks(): Promise { return get('/api/unraid/disks', defaultConfig); }, + async getUnraidVms(): Promise { return get('/api/unraid/vms', defaultConfig); }, + async getUnraidVmCapabilities(): Promise { return get('/api/unraid/vms/capabilities', defaultConfig); }, + async runUnraidVmAction(vmId: string, action: string): Promise { + return post(`/api/unraid/vms/${encodeURIComponent(vmId)}/actions/${encodeURIComponent(action)}`, { source: 'manual', confirm: true }, defaultConfig); + }, + async getUnraidShares(): Promise { return get('/api/unraid/shares', defaultConfig); }, + async getUnraidDocker(): Promise { return get('/api/unraid/docker', defaultConfig); }, + async getNetworkExposure(refresh = false): Promise { return get(`/api/network/exposure${refresh ? '?refresh=true' : ''}`, defaultConfig); }, + async getSafetyDashboard(refresh = false): Promise { + return get(`/api/safety/dashboard${refresh ? '?refresh=true' : ''}`, safetyConfig); + }, + async getSafetySummary(): Promise { return get('/api/safety/summary', safetyConfig); }, + async getSafetySections(): Promise { return get('/api/safety/sections', safetyConfig); }, + async getSafetyFindings(): Promise { return get('/api/safety/findings', safetyConfig); }, + async getSafetyRecommendations(): Promise { return get('/api/safety/recommendations', safetyConfig); }, + async getSafetyScoreBreakdown(): Promise { return get('/api/safety/score-breakdown', safetyConfig); }, + async refreshSafety(): Promise { return post('/api/safety/refresh', {}, safetyConfig); }, + async getNotificationSummary(): Promise { return get('/api/notifications/summary', defaultConfig); }, + async getNotificationRules(): Promise { return get('/api/notifications/rules', defaultConfig); }, + async createNotificationRule(payload: Partial): Promise { return post('/api/notifications/rules', payload, defaultConfig); }, + async updateNotificationRule(id: string, payload: Partial): Promise { return request(`/api/notifications/rules/${encodeURIComponent(id)}`, defaultConfig, { method: 'PATCH', body: JSON.stringify(payload) }); }, + async deleteNotificationRule(id: string): Promise<{ success: boolean; status: string }> { return request<{ success: boolean; status: string }>(`/api/notifications/rules/${encodeURIComponent(id)}`, defaultConfig, { method: 'DELETE' }); }, + async getNotificationEvents(options: NotificationEventPageOptions = {}): Promise { + return get(notificationEventPath('/api/notifications/events', options), defaultConfig); + }, + async getUnreadNotificationEvents(options: Omit = {}): Promise { + return get(notificationEventPath('/api/notifications/events/unread', options), defaultConfig); + }, + async dismissNotificationEvent(id: string): Promise { return post(`/api/notifications/events/${encodeURIComponent(id)}/dismiss`, {}, defaultConfig); }, + async markNotificationRead(id: string): Promise { return post(`/api/notifications/events/${encodeURIComponent(id)}/mark-read`, {}, defaultConfig); }, + async markAllNotificationsRead(): Promise<{ success: boolean; updated: number }> { return post<{ success: boolean; updated: number }>('/api/notifications/events/mark-all-read', {}, defaultConfig); }, + async getNotificationChannels(): Promise { return get('/api/notifications/channels', defaultConfig); }, + async createNotificationChannel(payload: Partial): Promise { return post('/api/notifications/channels', payload, defaultConfig); }, + async updateNotificationChannel(id: string, payload: Partial): Promise { return request(`/api/notifications/channels/${encodeURIComponent(id)}`, defaultConfig, { method: 'PATCH', body: JSON.stringify(payload) }); }, + async testNotificationChannel(id: string): Promise<{ success: boolean; status: string; channel?: NotificationChannel }> { return post<{ success: boolean; status: string; channel?: NotificationChannel }>(`/api/notifications/channels/${encodeURIComponent(id)}/test`, {}, defaultConfig); }, + async testNotificationChannelConnection(id: string): Promise<{ success: boolean; status: string; channel?: NotificationChannel }> { return post<{ success: boolean; status: string; channel?: NotificationChannel }>(`/api/notifications/channels/${encodeURIComponent(id)}/test-connection`, {}, defaultConfig); }, + async evaluateNotifications(source: 'all' | 'safety' | 'media' | 'connectors' = 'all'): Promise { + return post(source === 'all' ? '/api/notifications/evaluate' : `/api/notifications/evaluate/${source}`, {}, defaultConfig); + }, + async acknowledgeSafetyFinding(id: string): Promise<{ success: boolean; status: string }> { + return post<{ success: boolean; status: string }>(`/api/safety/findings/${encodeURIComponent(id)}/acknowledge`, {}, defaultConfig); + }, + async dismissSafetyFinding(id: string): Promise<{ success: boolean; status: string }> { + return post<{ success: boolean; status: string }>(`/api/safety/findings/${encodeURIComponent(id)}/dismiss`, {}, defaultConfig); + }, + async dismissSafetyRecommendation(id: string): Promise<{ success: boolean; status: string }> { + return post<{ success: boolean; status: string }>(`/api/safety/recommendations/${encodeURIComponent(id)}/dismiss`, {}, defaultConfig); + }, + async createSafetyRemediationPlan(id: string): Promise<{ success: boolean; status: string; href: string; executable: boolean; planId?: string | null }> { + return post<{ success: boolean; status: string; href: string; executable: boolean; planId?: string | null }>(`/api/safety/recommendations/${encodeURIComponent(id)}/create-remediation-plan`, {}, defaultConfig); + }, + async getAiToolPermissions(): Promise { return get('/api/ai/tools', defaultConfig); }, + async getHealthChecks(): Promise { return get('/api/healthchecks', defaultConfig); }, + async getGiteaActionsStatus(): Promise { return get('/api/gitea/actions/status', defaultConfig); }, + async getRunnerFleet(): Promise { return get('/api/gitea/actions/fleet', defaultConfig); }, + async getJobs(): Promise { + const response = await get>>('/api/jobs', defaultConfig); + return response.map(normalizeJob); + }, + async cancelJob(id: string): Promise { + return post(`/api/jobs/${encodeURIComponent(id)}/cancel`, {}, defaultConfig); + }, + async getOperationMap(): Promise<{ generatedAt?: string; sources?: Record; nodes: string[]; edges: Array<{ from: string; to: string }>}> + { return get<{ generatedAt?: string; sources?: Record; nodes: string[]; edges: Array<{ from: string; to: string }> }>('/api/operations-map', defaultConfig); }, + async previewAction(action: ActionID | string, target: string): Promise { + return post('/api/actions/preflight', { action, target }, defaultConfig); + }, + async getSonarrHealth(): Promise { return get('/api/apps/sonarr/health', defaultConfig); }, + async getSonarrQueue(): Promise { return get('/api/apps/sonarr/queue', defaultConfig); }, + async getSonarrWanted(): Promise { return get('/api/apps/sonarr/wanted', defaultConfig); }, + async searchSonarrSeries(query: string): Promise { + return get(`/api/apps/sonarr/search?q=${encodeURIComponent(query)}`, defaultConfig); + }, + async addSonarrSeries(payload: SonarrAddSeriesPayload): Promise { + return post('/api/apps/sonarr/series', payload, defaultConfig); + }, + async getRadarrHealth(): Promise { return get('/api/apps/radarr/health', defaultConfig); }, + async getRadarrQueue(): Promise { return get('/api/apps/radarr/queue', defaultConfig); }, + async getRadarrWanted(): Promise { return get('/api/apps/radarr/wanted', defaultConfig); }, + async searchRadarrMovie(query: string): Promise { + return get(`/api/apps/radarr/search?q=${encodeURIComponent(query)}`, defaultConfig); + }, + async addRadarrMovie(payload: RadarrAddMoviePayload): Promise { + return post('/api/apps/radarr/movie', payload, defaultConfig); + }, + async getProwlarrHealth(): Promise { return get('/api/apps/prowlarr/health', defaultConfig); }, + async getProwlarrIndexers(): Promise { return get('/api/apps/prowlarr/indexers', defaultConfig); }, + async getProwlarrApplications(): Promise { return get('/api/apps/prowlarr/applications', defaultConfig); }, + async getProwlarrIndexerHealth(): Promise { return get('/api/apps/prowlarr/indexer-health', defaultConfig); }, + async getProwlarrSyncStatus(): Promise { return get('/api/apps/prowlarr/sync-status', defaultConfig); }, + async searchProwlarr(query: string): Promise { + return get(`/api/apps/prowlarr/search-test?q=${encodeURIComponent(query)}`, defaultConfig); + }, + async getDelugeHealth(): Promise { return get('/api/apps/deluge/health', defaultConfig); }, + async getDelugeSession(): Promise { return get('/api/apps/deluge/session', defaultConfig); }, + async getDelugeTorrents(): Promise { return get('/api/apps/deluge/torrents', defaultConfig); }, + async getDelugeActive(): Promise { return get('/api/apps/deluge/active', defaultConfig); }, + async getDelugeStalled(): Promise { return get('/api/apps/deluge/stalled', defaultConfig); }, + async getDelugePaused(): Promise { return get('/api/apps/deluge/paused', defaultConfig); }, + async getDelugeSpeeds(): Promise { return get('/api/apps/deluge/speeds', defaultConfig); }, + async pauseDelugeTorrent(torrentId: string, payload: { source?: SourceType } = { source: 'manual' }): Promise { + return post(`/api/apps/deluge/torrents/${encodeURIComponent(torrentId)}/pause`, payload, defaultConfig); + }, + async resumeDelugeTorrent(torrentId: string, payload: { source?: SourceType } = { source: 'manual' }): Promise { + return post(`/api/apps/deluge/torrents/${encodeURIComponent(torrentId)}/resume`, payload, defaultConfig); + }, + async pauseAllDeluge(payload: { source?: SourceType } = { source: 'manual' }): Promise { + return post('/api/apps/deluge/pause-all', payload, defaultConfig); + }, + async resumeAllDeluge(payload: { source?: SourceType } = { source: 'manual' }): Promise { + return post('/api/apps/deluge/resume-all', payload, defaultConfig); + }, + async getPlexHealth(): Promise { return get('/api/apps/plex/health', defaultConfig); }, + async getPlexServer(): Promise { return get('/api/apps/plex/server', defaultConfig); }, + async getPlexLibraries(): Promise { return get('/api/apps/plex/libraries', defaultConfig); }, + async getPlexSessions(): Promise { return get('/api/apps/plex/sessions', defaultConfig); }, + async getPlexRecent(): Promise { return get('/api/apps/plex/recent', defaultConfig); }, + async getPlexStreams(): Promise { return get('/api/apps/plex/streams', defaultConfig); }, + async scanPlexLibrary(libraryId: string, payload: { source?: SourceType } = { source: 'manual' }): Promise { + return post(`/api/apps/plex/libraries/${encodeURIComponent(libraryId)}/scan`, payload, defaultConfig); + }, + async scanAllPlex(payload: { source?: SourceType } = { source: 'manual' }): Promise { + return post('/api/apps/plex/libraries/scan-all', payload, defaultConfig); + }, + async getTautulliHealth(): Promise { return get('/api/apps/tautulli/health', defaultConfig); }, + async getTautulliStatus(): Promise { return get('/api/apps/tautulli/status', defaultConfig); }, + async getTautulliActivity(): Promise { return get('/api/apps/tautulli/activity', defaultConfig); }, + async getTautulliHistory(): Promise { return get('/api/apps/tautulli/history', defaultConfig); }, + async getTautulliUsers(): Promise { return get('/api/apps/tautulli/users', defaultConfig); }, + async getTautulliLibraries(): Promise { return get('/api/apps/tautulli/libraries', defaultConfig); }, + async getTautulliRecentlyAdded(): Promise { return get('/api/apps/tautulli/recently-added', defaultConfig); }, + async getTautulliWatchStats(): Promise { return get('/api/apps/tautulli/watch-stats', defaultConfig); }, + async getTautulliStreams(): Promise { return get('/api/apps/tautulli/streams', defaultConfig); }, + async getTautulliPlexInfo(): Promise { return get('/api/apps/tautulli/plex-info', defaultConfig); }, + async getNpmHealth(): Promise { return get('/api/apps/nginx-proxy-manager/health', defaultConfig); }, + async getNpmProxyHosts(): Promise { return get('/api/apps/nginx-proxy-manager/proxy-hosts', defaultConfig); }, + async getNpmCertificates(): Promise { return get('/api/apps/nginx-proxy-manager/certificates', defaultConfig); }, + async getNpmExpiringCertificates(days = 30): Promise { return get(`/api/apps/nginx-proxy-manager/certificates/expiring?days=${days}`, defaultConfig); }, + async getNpmAccessLists(): Promise { return get('/api/apps/nginx-proxy-manager/access-lists', defaultConfig); }, + async getNpmExposureSummary(): Promise { return get('/api/apps/nginx-proxy-manager/exposure-summary', defaultConfig); }, + async getNpmHostReachability(): Promise { return get('/api/apps/nginx-proxy-manager/host-reachability', defaultConfig); }, + async getNpmSecurityWarnings(): Promise { return get('/api/apps/nginx-proxy-manager/security-warnings', defaultConfig); }, + async getAuthentikHealth(): Promise { return get('/api/apps/authentik/health', defaultConfig); }, + async getAuthentikSystem(): Promise { return get('/api/apps/authentik/system', defaultConfig); }, + async getAuthentikApplications(): Promise> { return get>('/api/apps/authentik/applications', defaultConfig); }, + async getAuthentikProviders(): Promise> { return get>('/api/apps/authentik/providers', defaultConfig); }, + async getAuthentikProxyProviders(): Promise> { return get>('/api/apps/authentik/providers/proxy', defaultConfig); }, + async getAuthentikOAuth2Providers(): Promise> { return get>('/api/apps/authentik/providers/oauth2', defaultConfig); }, + async getAuthentikOutposts(): Promise> { return get>('/api/apps/authentik/outposts', defaultConfig); }, + async getAuthentikOutpostHealth(): Promise { return get('/api/apps/authentik/outposts/health', defaultConfig); }, + async getAuthentikFlows(): Promise { return get('/api/apps/authentik/flows', defaultConfig); }, + async getAuthentikPolicies(): Promise { return get('/api/apps/authentik/policies', defaultConfig); }, + async getAuthentikEvents(): Promise> { return get>('/api/apps/authentik/events', defaultConfig); }, + async getAuthentikAuthCoverage(): Promise { return get('/api/apps/authentik/auth-coverage', defaultConfig); }, + async getAuthentikSecurityWarnings(): Promise { return get('/api/apps/authentik/security-warnings', defaultConfig); }, + async getMediaPipelineSummary(refresh = false): Promise { return get(`/api/media-pipeline/summary${refresh ? '?refresh=true' : ''}`, mediaPipelineConfig); }, + async getMediaPipelineHealth(): Promise { return get('/api/media-pipeline/health', mediaPipelineConfig); }, + async getMediaPipelineFlow(): Promise { return get('/api/media-pipeline/flow', mediaPipelineConfig); }, + async getMediaPipelineIssues(): Promise { return get('/api/media-pipeline/issues', mediaPipelineConfig); }, + async getMediaPipelineDownloads(): Promise { return get('/api/media-pipeline/downloads', mediaPipelineConfig); }, + async getMediaPipelineLibrary(): Promise { return get('/api/media-pipeline/library', mediaPipelineConfig); }, + async getMediaPipelineActivity(): Promise { return get('/api/media-pipeline/activity', mediaPipelineConfig); }, + async getMediaPipelineRecommendations(): Promise { return get('/api/media-pipeline/recommendations', mediaPipelineConfig); }, + async checkMediaPipeline(): Promise { return post('/api/media-pipeline/check', {}, mediaPipelineConfig); }, + async diagnoseMediaPipeline(payload: { query: string; target?: string; scope?: string }): Promise { + return post('/api/media-pipeline/diagnose', payload, mediaPipelineConfig); + }, + async runInventoryDiscovery(): Promise { + return post('/api/inventory/discover', {}, defaultConfig); + }, + async getInventoryRuns(limit = 100, offset = 0): Promise { + return get(`/api/inventory/runs?limit=${Math.max(1, Math.min(limit, 500))}&offset=${Math.max(0, offset)}`, inventoryConfig); + }, + async getDiscoveredContainers(): Promise { return get('/api/inventory/discovered-containers', inventoryConfig); }, + async getInventoryContainers(): Promise { return get('/api/inventory/containers', defaultConfig); }, + async getInventoryApps(): Promise { return get('/api/inventory/apps?include_unmanaged=true', inventoryConfig); }, + async getInventoryUnmapped(): Promise { return get('/api/inventory/unmapped', defaultConfig); }, + async getInventoryUnknown(): Promise { return get('/api/inventory/unknown', defaultConfig); }, + async getInventorySuggestions(): Promise { return get('/api/inventory/suggestions', defaultConfig); }, + async getInventoryMappings(): Promise { return get('/api/inventory/mappings', inventoryConfig); }, + async getInventoryDrift(): Promise { return get('/api/inventory/drift', inventoryConfig); }, + async acknowledgeInventoryDrift(id: string): Promise { + return post(`/api/inventory/drift/${encodeURIComponent(id)}/acknowledge`, {}, defaultConfig); + }, + async createInventoryDriftIncident(id: string): Promise { + return post(`/api/inventory/drift/${encodeURIComponent(id)}/incident`, {}, defaultConfig); + }, + async importDiscoveredContainer(id: string): Promise { + return post('/api/inventory/import', { discoveredContainerId: id }, defaultConfig); + }, + async ignoreDiscoveredContainer(id: string): Promise { + return post('/api/inventory/ignore', { discoveredContainerId: id }, defaultConfig); + }, + async updateDiscoveredReview(payload: InventoryReviewUpdatePayload): Promise { + return post('/api/inventory/review/update', payload, defaultConfig); + }, + async mapDiscoveredToExisting(payload: InventoryMapExistingPayload): Promise { + return post('/api/inventory/map-existing', payload, defaultConfig); + }, + async completeInventoryReview(): Promise { + return post('/api/inventory/review/complete', {}, defaultConfig); + }, + async updateInventoryBaseline(): Promise { + return post('/api/inventory/baseline/update', {}, defaultConfig); + }, + async getWidefrogHealth(): Promise { return get('/api/apps/widefrog/health', defaultConfig); }, + async getWidefrogQueue(): Promise { return get('/api/apps/widefrog/queue', defaultConfig); }, + async getWidefrogFailedJobs(): Promise { return get('/api/apps/widefrog/failed', defaultConfig); }, + async getWidefrogActivity(): Promise { return get('/api/apps/widefrog/activity', defaultConfig); }, + async getWidefrogDownloadStatus(): Promise { return get('/api/apps/widefrog/download-status', defaultConfig); }, + async retryWidefrogFailed(payload: Record = {}): Promise { + return post('/api/apps/widefrog/retry-failed', payload, defaultConfig); + }, + async scanWidefrogLibrary(payload: Record = {}): Promise { + return post('/api/apps/widefrog/scan-library', payload, defaultConfig); + }, +}); diff --git a/src/api/operatorSession.test.ts b/src/api/operatorSession.test.ts new file mode 100644 index 0000000..98a71ac --- /dev/null +++ b/src/api/operatorSession.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + getOperatorSessionSnapshot, + setOperatorSessionStatus, +} from "./operatorSession"; + +describe("operator session", () => { + it("tracks backend-owned account and opaque browser sessions without storing tokens", () => { + setOperatorSessionStatus({ authorized: false, mode: "locked" }); + assert.deepEqual(getOperatorSessionSnapshot(), { unlocked: false, mode: "locked" }); + + setOperatorSessionStatus({ authorized: true, mode: "session" }); + assert.deepEqual(getOperatorSessionSnapshot(), { unlocked: true, mode: "session" }); + + setOperatorSessionStatus({ authorized: true, mode: "account", identity: "example-host" }); + assert.deepEqual(getOperatorSessionSnapshot(), { + unlocked: true, + mode: "account", + identity: "example-host", + }); + + setOperatorSessionStatus({ authorized: false, mode: "locked" }); + assert.deepEqual(getOperatorSessionSnapshot(), { unlocked: false, mode: "locked" }); + }); +}); diff --git a/src/api/operatorSession.ts b/src/api/operatorSession.ts new file mode 100644 index 0000000..a784c22 --- /dev/null +++ b/src/api/operatorSession.ts @@ -0,0 +1,31 @@ +import type { OperatorSessionStatus } from "@/types/appops"; + +export const OPERATOR_SESSION_EVENT = "appops:operator-session"; + +export type OperatorSessionMode = "locked" | "token" | "session" | "account" | "private"; + +export interface OperatorSessionSnapshot { + unlocked: boolean; + mode: OperatorSessionMode; + identity?: string; +} + +let snapshot: OperatorSessionSnapshot = { unlocked: false, mode: "locked" }; + +const notify = (): void => { + if (typeof document !== "undefined") document.dispatchEvent(new CustomEvent(OPERATOR_SESSION_EVENT)); +}; + +export const setOperatorSessionStatus = (status: OperatorSessionStatus): void => { + const mode = status.authorized && status.mode !== "locked" ? status.mode : "locked"; + snapshot = mode === "account" + ? { unlocked: true, mode, identity: (status.identity || "account").trim() } + : mode === "locked" + ? { unlocked: false, mode } + : { unlocked: true, mode }; + notify(); +}; + +export const hasOperatorSession = (): boolean => snapshot.unlocked; + +export const getOperatorSessionSnapshot = (): OperatorSessionSnapshot => snapshot; diff --git a/src/components/ActionResultNotice.tsx b/src/components/ActionResultNotice.tsx new file mode 100644 index 0000000..0d543fc --- /dev/null +++ b/src/components/ActionResultNotice.tsx @@ -0,0 +1,25 @@ +import type { ActionResult } from "@/types/appops"; +import { buildActionResultFeedback, type ActionResultFeedbackCopy } from "@/lib/actionResultStatus"; +import { cn } from "@/lib/utils"; + +interface ActionResultNoticeProps { + result: ActionResult; + copy: ActionResultFeedbackCopy; + className?: string; +} + +const toneClasses = { + success: "bg-emerald-500/5 border-emerald-500/20 text-emerald-400", + approval: "bg-amber-500/5 border-amber-500/20 text-amber-400", + error: "bg-rose-500/5 border-rose-500/20 text-rose-400", +}; + +export const ActionResultNotice = ({ result, copy, className }: ActionResultNoticeProps) => { + const feedback = buildActionResultFeedback(result, copy); + + return ( +
+ {feedback.message} +
+ ); +}; diff --git a/src/components/AppDetailActionPanel.tsx b/src/components/AppDetailActionPanel.tsx new file mode 100644 index 0000000..92d1dca --- /dev/null +++ b/src/components/AppDetailActionPanel.tsx @@ -0,0 +1,42 @@ +import { Activity, Square, Zap } from "lucide-react"; +import { Link } from "react-router-dom"; +import type { App } from "@/types/appops"; +import { ActionButton } from "@/components/PolicyAction"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export const AppDetailActionPanel = ({ + app, + lifecycleBusy, + onRestart, + onRequestRestartApproval, +}: { + app: App; + lifecycleBusy: boolean; + onRestart: () => void; + onRequestRestartApproval: () => void; +}) => ( +
+ + Lifecycle + + + + + + + Maintenance + + + + + + + Danger Zone + + + + + +
+); diff --git a/src/components/AppMetadataEditor.tsx b/src/components/AppMetadataEditor.tsx new file mode 100644 index 0000000..b659173 --- /dev/null +++ b/src/components/AppMetadataEditor.tsx @@ -0,0 +1,96 @@ +import { useEffect, useState } from "react"; +import { Save, Settings2, X } from "lucide-react"; + +import { appopsClient } from "@/api/appopsClient"; +import type { App } from "@/types/appops"; +import { showError, showSuccess } from "@/utils/toast"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { formatOperatorError } from "@/lib/operatorError"; + +const criticalities: App["criticality"][] = ["low", "medium", "high", "critical"]; + +interface AppMetadataEditorProps { + app: App; + onSaved: (app: App) => void; +} + +export const AppMetadataEditor = ({ app, onSaved }: AppMetadataEditorProps) => { + const [editing, setEditing] = useState(false); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(app.name); + const [category, setCategory] = useState(app.category); + const [criticality, setCriticality] = useState(app.criticality); + + useEffect(() => { + if (editing) return; + setName(app.name); + setCategory(app.category); + setCriticality(app.criticality); + }, [app, editing]); + + const save = async () => { + if (!name.trim() || !category.trim() || busy) return; + setBusy(true); + try { + const result = await appopsClient.updateAppMetadata(app.id, { + name: name.trim(), + category: category.trim(), + criticality, + }); + if (!result.success || !result.app) { + showError(result.message || "App-metadata kon niet worden opgeslagen."); + return; + } + onSaved(result.app); + setEditing(false); + showSuccess("App-metadata opgeslagen."); + } catch (error) { + showError(formatOperatorError(error, "App-metadata kon niet worden opgeslagen.")); + } finally { + setBusy(false); + } + }; + + if (!editing) { + return ( + + ); + } + + return ( +
+
+ + setName(event.target.value)} maxLength={80} className="border-slate-800 bg-slate-950" /> +
+
+ + setCategory(event.target.value)} maxLength={40} placeholder="Bijv. Productivity, Monitoring of Dev" className="border-slate-800 bg-slate-950" /> +
+
+ +
+ {criticalities.map(level => ( + + ))} +
+
+

Wijzigt alleen de AppOps-catalogus. Containers, connectorconfig, protection en voice blijven ongewijzigd.

+
+ + +
+
+ ); +}; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx new file mode 100644 index 0000000..30da77e --- /dev/null +++ b/src/components/AppShell.tsx @@ -0,0 +1,133 @@ +import { Sidebar } from "./Sidebar"; +import { TopStatusBar } from "./TopStatusBar"; +import { CommandPalette } from "./CommandPalette"; +import { ReactNode, useEffect, useState } from "react"; +import { appopsClient } from "@/api/appopsClient"; +import { AlertTriangle, Boxes, CheckSquare, Settings, X } from "lucide-react"; +import { Link, useLocation } from "react-router-dom"; +import { getSetupBannerKind, type SetupBannerKind } from "@/lib/setupBannerView"; +import { getRouteContentWidthClass } from "@/lib/routeContentWidth"; +import { OperatorReadOnlyBanner } from "./OperatorReadOnlyBanner"; +import { getRouteDocumentTitle } from "@/lib/routeTitle"; +import { setOperatorSessionStatus } from "@/api/operatorSession"; + +interface AppShellProps { + children: ReactNode; +} + +export const AppShell = ({ children }: AppShellProps) => { + const location = useLocation(); + const [isMaintenance, setIsMaintenance] = useState(false); + const [showBanner, setShowBanner] = useState(true); + const [setupBannerKind, setSetupBannerKind] = useState("hidden"); + const contentWidthClass = getRouteContentWidthClass(location.pathname); + + useEffect(() => { + document.title = getRouteDocumentTitle(location.pathname); + }, [location.pathname]); + + useEffect(() => { + appopsClient.getOperatorSession().then(session => { + setOperatorSessionStatus(session); + }).catch(() => setOperatorSessionStatus({ authorized: false, mode: "locked", identity: null })); + appopsClient.getSystemSummary().then(s => { + setIsMaintenance(s.maintenanceMode); + }).catch(() => setIsMaintenance(false)); + appopsClient.getSetupStatus().then(status => { + setSetupBannerKind(getSetupBannerKind(status)); + }).catch(() => { + setSetupBannerKind("hidden"); + }); + }, []); + + const setupIncomplete = setupBannerKind === "setup_incomplete"; + const coveragePartial = setupBannerKind === "coverage_partial"; + const routeName = location.pathname === "/" + ? "overview" + : location.pathname.split("/").filter(Boolean).join("-"); + + return ( +
+ Ga naar hoofdinhoud +
+ +
+
+ {isMaintenance && showBanner && ( +
+
+ + ONDERHOUDSMODUS ACTIEF: Automatisering en Voice commando's zijn gepauzeerd. +
+ +
+ )} + {setupIncomplete && showBanner && ( +
+
+ + Setup nog niet voltooid. Verbind Portainer, draai discovery en review apps voordat je live gaat. +
+
+ + Open Setup + + + Run Discovery + + + Review Apps + + +
+
+ )} + {coveragePartial && showBanner && ( +
+
+ + Setup is live-ready. Optionele connectorcoverage is nog gedeeltelijk. +
+
+ + Coverage aanvullen + + +
+
+ )} + + +
+
+
+ {children} +
+
+
+
+ +
+ ); +}; diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 0000000..6f9ca30 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,142 @@ +"use client"; + +import * as React from "react"; +import { useNavigate } from "react-router-dom"; +import { AppWindow, Box, ShieldCheck, Zap } from "lucide-react"; +import { appopsClient } from "@/api/appopsClient"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; +import { buildCommandPaletteGroups } from "@/lib/commandPaletteNavigation"; +import { isCommandPaletteShortcut } from "@/lib/commandPaletteShortcut"; +import { navigationGroups } from "@/lib/navigationGroups"; + +const commandPaletteGroups = buildCommandPaletteGroups(navigationGroups); + +export function CommandPalette() { + const [open, setOpen] = React.useState(false); + const [liveItems, setLiveItems] = React.useState<{ + apps: Awaited>; + containers: Awaited>; + policies: Awaited>; + actions: Awaited>["items"]; + }>({ apps: [], containers: [], policies: [], actions: [] }); + const navigate = useNavigate(); + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (isCommandPaletteShortcut(e)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + React.useEffect(() => { + if (!open) return; + let active = true; + Promise.allSettled([ + appopsClient.getApps(), + appopsClient.getContainers(), + appopsClient.getPolicies(), + appopsClient.getActionRegistry(), + ]).then(([apps, containers, policies, actions]) => { + if (!active) return; + setLiveItems({ + apps: apps.status === "fulfilled" ? apps.value : [], + containers: containers.status === "fulfilled" ? containers.value : [], + policies: policies.status === "fulfilled" ? policies.value : [], + actions: actions.status === "fulfilled" ? actions.value.items : [], + }); + }); + return () => { active = false; }; + }, [open]); + + const runCommand = (command: () => void) => { + setOpen(false); + command(); + }; + + return ( + + + + Geen resultaten gevonden. + {commandPaletteGroups.map((group, groupIndex) => ( + + {groupIndex > 0 && } + + {group.items.map((item) => ( + runCommand(() => navigate(item.path))} + > + + {item.searchLabel} + + ))} + + + ))} + {liveItems.apps.length > 0 && ( + <> + + + {liveItems.apps.map((app) => ( + runCommand(() => navigate(`/apps/${encodeURIComponent(app.id)}`))}> + {app.name}{app.status} + + ))} + + + )} + {liveItems.containers.length > 0 && ( + <> + + + {liveItems.containers.map((container) => ( + runCommand(() => navigate(`/containers?query=${encodeURIComponent(container.name)}`))}> + {container.name}{container.state} + + ))} + + + )} + {liveItems.actions.length > 0 && ( + <> + + + {liveItems.actions.map((action) => ( + runCommand(() => navigate(`/command-center?query=${encodeURIComponent(action.id)}`))}> + {action.label}{action.policyDecision} + + ))} + + + )} + {liveItems.policies.length > 0 && ( + <> + + + {liveItems.policies.map((policy) => ( + runCommand(() => navigate(`/policies?query=${encodeURIComponent(policy.id)}`))}> + {policy.action}{policy.manual} + + ))} + + + )} + + + ); +} diff --git a/src/components/ConnectorActionResultNotice.tsx b/src/components/ConnectorActionResultNotice.tsx new file mode 100644 index 0000000..9c295e2 --- /dev/null +++ b/src/components/ConnectorActionResultNotice.tsx @@ -0,0 +1,40 @@ +import { Link } from "react-router-dom"; +import type { ActionResult } from "@/types/appops"; +import { Button } from "@/components/ui/button"; +import { buildConnectorActionFollowup, type ConnectorActionFollowupCopy } from "@/lib/connectorActionFollowup"; +import { cn } from "@/lib/utils"; + +interface ConnectorActionResultNoticeProps { + result: ActionResult; + copy: ConnectorActionFollowupCopy; + className?: string; +} + +const toneClasses = { + success: "border-emerald-500/20 bg-emerald-500/5 text-emerald-100", + approval: "border-amber-500/20 bg-amber-500/5 text-amber-100", + error: "border-rose-500/20 bg-rose-500/5 text-rose-100", +}; + +export const ConnectorActionResultNotice = ({ result, copy, className }: ConnectorActionResultNoticeProps) => { + const view = buildConnectorActionFollowup(result, copy); + + return ( +
+
+
+

{view.title}

+

{view.message}

+

{view.safetyNote}

+
+
+ {view.links.map(link => ( + + ))} +
+
+
+ ); +}; diff --git a/src/components/ExternalNotificationChannels.tsx b/src/components/ExternalNotificationChannels.tsx new file mode 100644 index 0000000..9ec572a --- /dev/null +++ b/src/components/ExternalNotificationChannels.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { Loader2, Save, Send } from "lucide-react"; +import type { NotificationChannel } from "@/types/appops"; +import { appopsClient } from "@/api/appopsClient"; +import { useOperatorSession } from "@/hooks/useOperatorSession"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +type ExternalType = "discord" | "telegram" | "email"; +type FormState = Record & { enabled: boolean; credential: string }; + +const definitions: Record }> = { + discord: { label: "Discord", credential: "Webhook URL", fields: [{ key: "username", label: "Webhook username" }] }, + telegram: { label: "Telegram", credential: "Bot token", fields: [{ key: "chatId", label: "Chat ID" }] }, + email: { + label: "E-mail", + credential: "SMTP password", + fields: [ + { key: "host", label: "SMTP host" }, { key: "port", label: "Port", type: "number" }, + { key: "username", label: "Username" }, { key: "fromAddress", label: "From" }, { key: "toAddress", label: "To" }, + ], + }, +}; + +const initialForm = (channel: NotificationChannel | undefined): FormState => ({ + enabled: channel?.enabled ?? false, + credential: "", + ...((channel?.safeConfig || channel?.config || {}) as Record), + smtpMode: String((channel?.safeConfig || channel?.config || {}).smtpMode || ((channel?.safeConfig || channel?.config || {}).useTls === false ? "plain" : "implicit_tls")), +}); + +export const ExternalNotificationChannels = ({ + channels, + working, + run, + reload, +}: { + channels: NotificationChannel[]; + working: string | null; + run: (key: string, action: () => Promise) => Promise; + reload: () => Promise; +}) => ( +
+ {(["discord", "telegram", "email"] as const).map(type => ( + item.id === type)} working={working} run={run} reload={reload} /> + ))} +
+); + +const ExternalChannelCard = ({ type, channel, working, run, reload }: { + type: ExternalType; + channel?: NotificationChannel; + working: string | null; + run: (key: string, action: () => Promise) => Promise; + reload: () => Promise; +}) => { + const definition = definitions[type]; + const unlocked = useOperatorSession(); + const [form, setForm] = useState(() => initialForm(channel)); + useEffect(() => setForm(initialForm(channel)), [channel]); + const update = (key: string, value: string | boolean) => setForm(current => ({ ...current, [key]: value })); + const busy = working === `${type}-save` || working === `${type}-test`; + const idPrefix = `notification-${type}`; + + const save = () => run(`${type}-save`, async () => { + const config = Object.fromEntries(Object.entries(form).filter(([key]) => !["enabled", "credential"].includes(key))); + await appopsClient.updateNotificationChannel(type, { + type, + enabled: Boolean(form.enabled), + config, + ...(String(form.credential || "").trim() ? { token: String(form.credential).trim() } : {}), + } as Partial & { token?: string }); + await reload(); + }); + const test = () => run(`${type}-test`, async () => { + await appopsClient.testNotificationChannel(type); + await reload(); + }); + + return ( + + +
+
{channel?.status || "missing_config"}{definition.label}
+ update("enabled", value)} disabled={!unlocked} /> +
+
+ + {definition.fields.map(field => ( +
update(field.key, event.target.value)} disabled={!unlocked} className="border-slate-800 bg-slate-950" />
+ ))} + {type === "email" ? ( +
+ + +
+ ) : null} +
update("credential", event.target.value)} placeholder={channel?.hasSecret ? "Nieuwe waarde optioneel" : definition.credential} disabled={!unlocked} className="border-slate-800 bg-slate-950" />
+

Credential blijft write-only. Laatste test: {channel?.lastTestAt ? new Date(channel.lastTestAt).toLocaleString() : "nog niet"}.

+
+ + +
+
+
+ ); +}; diff --git a/src/components/LiveActivityFeed.tsx b/src/components/LiveActivityFeed.tsx new file mode 100644 index 0000000..949a5f7 --- /dev/null +++ b/src/components/LiveActivityFeed.tsx @@ -0,0 +1,138 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Activity, AlertCircle, BriefcaseBusiness, History, RefreshCw } from "lucide-react"; + +import { appopsClient } from "@/api/appopsClient"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { buildLiveActivityFeedView, type LiveActivityFeedView } from "@/lib/liveActivityFeedView"; +import type { AuditEvent, JobRun } from "@/types/appops"; + +interface LiveActivityFeedProps { + className?: string; +} + +const emptyView: LiveActivityFeedView = buildLiveActivityFeedView({ audits: [], jobs: [] }); + +const toneClass = { + success: "border-emerald-500/20 bg-emerald-500/5 text-emerald-200", + warning: "border-amber-500/20 bg-amber-500/5 text-amber-200", + error: "border-rose-500/20 bg-rose-500/5 text-rose-200", + info: "border-blue-500/20 bg-blue-500/5 text-blue-200", +}; + +const formatFeedTime = (timestamp: string): string => { + if (!timestamp) return "geen tijd"; + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) return "onbekend"; + return parsed.toLocaleString("nl-BE", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +}; + +export const LiveActivityFeed = ({ className }: LiveActivityFeedProps) => { + const [view, setView] = useState(emptyView); + const [loading, setLoading] = useState(true); + + const load = async () => { + setLoading(true); + const [auditResult, jobsResult] = await Promise.allSettled([ + appopsClient.getAuditLogs(50), + appopsClient.getJobs(), + ]); + + const audits: AuditEvent[] = auditResult.status === "fulfilled" ? auditResult.value : []; + const jobs: JobRun[] = jobsResult.status === "fulfilled" ? jobsResult.value : []; + + setView(buildLiveActivityFeedView({ + audits, + jobs, + auditError: auditResult.status === "rejected" ? String(auditResult.reason instanceof Error ? auditResult.reason.message : auditResult.reason) : null, + jobsError: jobsResult.status === "rejected" ? String(jobsResult.reason instanceof Error ? jobsResult.reason.message : jobsResult.reason) : null, + })); + setLoading(false); + }; + + useEffect(() => { + void load(); + }, []); + + return ( +
+
+
+ +
+

Live activity

+

Audit en Jobs, zonder gesimuleerde output.

+
+
+ +
+ + {view.partialWarning && ( +
+
+ + {view.partialWarning} +
+
+ )} + +
+ {loading && !view.rows.length ? ( +
+ + Live activiteit laden... +
+ ) : view.rows.length ? ( +
+ {view.rows.map((row) => { + const Icon = row.kind === "job" ? BriefcaseBusiness : History; + return ( +
+
+
+
+ +

{row.title}

+
+

{row.subtitle}

+ {typeof row.progress === "number" && ( +

Progress {row.progress}%

+ )} +
+
+ {formatFeedTime(row.timestamp)} + +
+
+
+ ); + })} +
+ ) : ( +
+

{view.emptyTitle}

+

{view.emptyMessage}

+
+ )} +
+
+ ); +}; diff --git a/src/components/MetricCard.tsx b/src/components/MetricCard.tsx new file mode 100644 index 0000000..2bca706 --- /dev/null +++ b/src/components/MetricCard.tsx @@ -0,0 +1,76 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface MetricCardProps { + title: string; + value: string | number; + subtitle?: string; + icon: LucideIcon; + trend?: { + value: string; + positive: boolean; + }; + color?: "blue" | "emerald" | "amber" | "rose" | "indigo"; + className?: string; +} + +export const MetricCard = ({ + title, + value, + subtitle, + icon: Icon, + trend, + color = "blue", + className +}: MetricCardProps) => { + const colorStyles = { + blue: "text-blue-500 bg-blue-500/10", + emerald: "text-emerald-500 bg-emerald-500/10", + amber: "text-amber-500 bg-amber-500/10", + rose: "text-rose-500 bg-rose-500/10", + indigo: "text-indigo-500 bg-indigo-500/10", + }; + + return ( + + +
+

{title}

+
+ +
+
+ +
+

{value}

+ {trend && ( + + {trend.value} + + )} +
+ + {subtitle && ( +

+ {subtitle} +

+ )} + +
+
+
+ + + ); +}; \ No newline at end of file diff --git a/src/components/OperatorReadOnlyBanner.tsx b/src/components/OperatorReadOnlyBanner.tsx new file mode 100644 index 0000000..b423b2c --- /dev/null +++ b/src/components/OperatorReadOnlyBanner.tsx @@ -0,0 +1,22 @@ +import { LockKeyhole } from "lucide-react"; +import { Link } from "react-router-dom"; +import { useOperatorSession } from "@/hooks/useOperatorSession"; + +export const OperatorReadOnlyBanner = () => { + const unlocked = useOperatorSession(); + if (unlocked) return null; + + return ( +
+
+ + + + Operatorsessie ontgrendelen + +
+
+ ); +}; diff --git a/src/components/PolicyAction.tsx b/src/components/PolicyAction.tsx new file mode 100644 index 0000000..4430e17 --- /dev/null +++ b/src/components/PolicyAction.tsx @@ -0,0 +1,289 @@ +import React from 'react'; +import { cn } from "@/lib/utils"; +import { PolicyDecision, RiskLevel } from "@/types/appops"; +import { Shield, Lock, Check, X, AlertTriangle, Clock, Info, Zap, History } from "lucide-react"; +import { Button, ButtonProps } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { RiskBadge } from "@/components/RiskBadge"; +import { getPolicyActionButtonLabel, getPolicyActionState } from "@/lib/policyActionState"; +import { showError } from "@/utils/toast"; +import { useOperatorSession } from "@/hooks/useOperatorSession"; + +interface PolicyBadgeProps { + decision: PolicyDecision | string; + className?: string; +} + +export const PolicyBadge = ({ decision, className }: PolicyBadgeProps) => { + const styles: Record = { + allowed: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20', + approval_required: 'bg-amber-500/10 text-amber-500 border-amber-500/20', + blocked: 'bg-rose-500/10 text-rose-500 border-rose-500/20', + }; + + const icons: Record = { + allowed: , + approval_required: , + blocked: , + }; + + const labels: Record = { + allowed: 'ALLOWED', + approval_required: 'NEEDS APPROVAL', + blocked: 'BLOCKED', + }; + + const d = decision.toLowerCase(); + + return ( + + {icons[d] || icons.blocked} + {labels[d] || labels.blocked} + + ); +}; + +interface ActionButtonProps extends ButtonProps { + decision: PolicyDecision | string; + risk: RiskLevel; + label: string; + reason?: string; + explicitOverride?: boolean; + onExecute?: () => void; + onRequestApproval?: () => void; +} + +export const ActionButton = ({ + decision, + risk, + label, + reason, + explicitOverride = false, + onExecute, + onRequestApproval, + className, + disabled, + title, + ...props +}: ActionButtonProps) => { + const operatorUnlocked = useOperatorSession(); + const d = decision.toLowerCase(); + const state = getPolicyActionState(decision, risk, Boolean(onExecute), Boolean(onRequestApproval), explicitOverride); + + if (state.kind === 'critical_override_required') { + return ( + + + + + + + Kritieke actie: Directe uitvoering geblokkeerd voor veiligheid. + + + + ); + } + + if (d === 'blocked') { + return ( + + ); + } + + if (state.kind === 'approval') { + return ( + + ); + } + + return ( + + ); +}; + +export const ActionImpactDrawer = ({ + action, + target, + risk, + policy, + affectedApps = [], + plan = [], + postChecks = [], + onAuditPreview, + onConfirm, + onRequestApproval +}: { + action: string, + target: string, + risk: RiskLevel, + policy: PolicyDecision, + affectedApps?: string[], + plan?: string[], + postChecks?: string[], + onAuditPreview?: () => void, + onConfirm?: () => void, + onRequestApproval?: () => void +}) => ( + + + + + + + + Action Impact Analysis + + +
+
+
+

Action / Target

+

{action} on {target}

+
+
+

Risk / Policy

+
+ + +
+
+
+ +
+

+ Blast Radius +

+
+

Deze actie kan de volgende services beïnvloeden:

+
+ {affectedApps.map(app => ( + {app} + ))} + {affectedApps.length === 0 && Geen directe afhankelijkheden gedetecteerd.} +
+
+
+ +
+

+ Execution Plan +

+
+ {plan.map((step, idx) => ( +
+ {idx + 1}. + {step} +
+ ))} +
+
+ +
+ {postChecks.length > 0 && ( +
+

Post-checks

+
+ {postChecks.map((check, idx) => ( +
+ + {check} +
+ ))} +
+
+ )} + {(onAuditPreview || onConfirm || onRequestApproval) ? ( +
+ {onAuditPreview && ( + + )} + {(onConfirm || onRequestApproval) && ( + + )} +
+ ) : ( +
+ Dit paneel is read-only. Gebruik de actieknop naast de analyse voor uitvoering of goedkeuring. +
+ )} +
+
+
+
+); + +export const ActionPlanPreview = ({ plan, postChecks }: { plan: string[], postChecks: string[] }) => ( +
+
+

Execution Plan

+
+ {plan.map((step, idx) => ( +
+
+ {idx + 1} +
+ {step} +
+ ))} +
+
+
+

Post-Execution Checks

+
+ {postChecks.map((check, idx) => ( +
+ + {check} +
+ ))} +
+
+
+); diff --git a/src/components/RiskBadge.tsx b/src/components/RiskBadge.tsx new file mode 100644 index 0000000..edf02ee --- /dev/null +++ b/src/components/RiskBadge.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils"; +import { RiskLevel } from "@/types/appops"; + +interface RiskBadgeProps { + level: RiskLevel; +} + +export const RiskBadge = ({ level }: RiskBadgeProps) => { + const styles = { + low: 'text-slate-400', + medium: 'text-amber-400', + high: 'text-orange-500', + critical: 'text-rose-500 font-bold' + }; + + const labels = { + low: 'LAAG', + medium: 'MEDIUM', + high: 'HOOG', + critical: 'CRITICAL' + }; + + return ( + + {labels[level]} + + ); +}; diff --git a/src/components/RouteLoadingCard.tsx b/src/components/RouteLoadingCard.tsx new file mode 100644 index 0000000..1bd84ea --- /dev/null +++ b/src/components/RouteLoadingCard.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; +import { Loader2 } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import type { RouteLoadingState } from "@/lib/routeLoadingView"; + +interface RouteLoadingCardProps { + state: RouteLoadingState; + children?: ReactNode; +} + +export const RouteLoadingCard = ({ state, children }: RouteLoadingCardProps) => ( + + +
+ +
+

{state.title}

+

{state.message}

+
+
+
+ {state.facts.map((fact) => ( + + {fact} + + ))} +
+ {children} +
+
+); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..066f8a3 --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,101 @@ +import { useMemo, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { ChevronDown, ChevronRight, ShieldCheck } from "lucide-react"; +import { findActiveNavigationGroup, isNavigationActive, navigationGroups } from "@/lib/navigationGroups"; + +export const Sidebar = () => { + const location = useLocation(); + const activeGroupLabel = useMemo( + () => findActiveNavigationGroup(location.pathname)?.label, + [location.pathname] + ); + const [expandedGroups, setExpandedGroups] = useState>(() => new Set(["Core"])); + + const toggleGroup = (label: string) => { + setExpandedGroups((current) => { + const next = new Set(current); + if (next.has(label)) { + next.delete(label); + } else { + next.add(label); + } + return next; + }); + }; + + return ( + + ); +}; diff --git a/src/components/StatusChip.tsx b/src/components/StatusChip.tsx new file mode 100644 index 0000000..39bd91e --- /dev/null +++ b/src/components/StatusChip.tsx @@ -0,0 +1,61 @@ +import { cn } from "@/lib/utils"; +import { Status } from "@/types/appops"; + +interface StatusChipProps { + status: Status | string; + className?: string; +} + +export const StatusChip = ({ status, className }: StatusChipProps) => { + const getStatusStyles = (s: string) => { + switch (s.toLowerCase()) { + case 'healthy': + case 'running': + case 'success': + return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'; + case 'warning': + case 'starting': + return 'bg-amber-500/10 text-amber-500 border-amber-500/20'; + case 'checking': + case 'loading': + return 'bg-blue-500/10 text-blue-500 border-blue-500/20'; + case 'unknown': + return 'bg-amber-500/10 text-amber-400 border-amber-500/30'; + case 'critical': + case 'unhealthy': + case 'failed': + return 'bg-rose-500/10 text-rose-500 border-rose-500/20'; + case 'stopped': + case 'exited': + return 'bg-slate-500/10 text-slate-300 border-slate-500/30'; + default: + return 'bg-blue-500/10 text-blue-500 border-blue-500/20'; + } + }; + + const labelMap: Record = { + healthy: 'Gezond', + running: 'Actief', + warning: 'Waarschuwing', + critical: 'Kritiek', + unhealthy: 'Ongezond', + stopped: 'Gestopt', + starting: 'Starten...', + checking: 'Checking', + loading: 'Laden', + unknown: 'Onbekend', + success: 'Succes', + failed: 'Gefaald' + }; + + return ( + + + ); +}; diff --git a/src/components/SystemLoadSvgChart.tsx b/src/components/SystemLoadSvgChart.tsx new file mode 100644 index 0000000..2b0d1a9 --- /dev/null +++ b/src/components/SystemLoadSvgChart.tsx @@ -0,0 +1,38 @@ +import { useMemo } from "react"; +import type { SystemLoadChartPoint } from "@/lib/systemLoadChartView"; + +const polylinePoints = (points: SystemLoadChartPoint[], key: "cpu" | "ram") => { + const denominator = Math.max(1, points.length - 1); + return points.flatMap((point, index) => { + const value = point[key]; + return typeof value === "number" + ? [`${(index / denominator) * 100},${100 - Math.max(0, Math.min(value, 100))}`] + : []; + }).join(" "); +}; + +export const SystemLoadSvgChart = ({ points }: { points: SystemLoadChartPoint[] }) => { + const cpu = useMemo(() => polylinePoints(points, "cpu"), [points]); + const ram = useMemo(() => polylinePoints(points, "ram"), [points]); + const firstTime = points[0]?.time ?? ""; + const lastTime = points.at(-1)?.time ?? ""; + + return ( +
+ + Live CPU- en RAM-belasting in procenten + CPU is blauw en RAM is groen. De verticale schaal loopt van nul tot honderd procent. + {[0, 25, 50, 75, 100].map(value => ( + + ))} + {cpu ? : null} + {ram ? : null} + +
+ {firstTime} + CPURAM + {lastTime} +
+
+ ); +}; diff --git a/src/components/TopStatusBar.tsx b/src/components/TopStatusBar.tsx new file mode 100644 index 0000000..b967f4c --- /dev/null +++ b/src/components/TopStatusBar.tsx @@ -0,0 +1,158 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; +import { Shield, Activity, Server, Lock, Bell, Search, Menu, Zap } from "lucide-react"; +import { StatusChip } from "./StatusChip"; +import { appopsClient } from "@/api/appopsClient"; +import { cn } from "@/lib/utils"; +import { Button } from "./ui/button"; +import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"; +import { Sidebar } from "./Sidebar"; +import { createCommandPaletteShortcutEvent } from "@/lib/commandPaletteShortcut"; +import { useOperatorSession } from "@/hooks/useOperatorSession"; + +export const TopStatusBar = () => { + const navigate = useNavigate(); + const operatorUnlocked = useOperatorSession(); + const summaryQuery = useQuery({ + queryKey: ['system-summary'], + queryFn: () => appopsClient.getSystemSummary(), + refetchInterval: 30_000, + }); + const notificationsQuery = useQuery({ + queryKey: ['notifications-summary'], + queryFn: () => appopsClient.getNotificationSummary(), + refetchInterval: 30_000, + }); + const summary = summaryQuery.data ?? null; + const notifications = notificationsQuery.data ?? null; + const summaryError = summaryQuery.isError; + const isLoadingSummary = summaryQuery.isPending; + + return ( +
+
+
+ + + + + + + + +
+ +
+ + Gateway: + +
+ +
+ +
+
+ + Unraid: + + {summary?.unraidStatus === 'connected' ? 'ONLINE' : isLoadingSummary ? 'CHECKING' : summaryError ? 'UNKNOWN' : 'OFFLINE'} + +
+
+ + Portainer: + + {summary?.portainerStatus === 'connected' ? 'CONNECTED' : isLoadingSummary ? 'CHECKING' : summaryError ? 'UNKNOWN' : summary?.portainerStatus === 'missing_config' ? 'MISSING CONFIG' : 'ERROR'} + +
+
+
+ +
+ + {summary?.lastAction && ( +
+ + Last + {summary.lastAction} +
+ )} + + + +
+ + Maintenance {summary?.maintenanceMode ? 'ON' : 'OFF'} +
+ + + +
+
+

{operatorUnlocked ? "Operator actief" : "Read-only viewer"}

+

{operatorUnlocked ? "Unlocked" : "Operator locked"}

+
+
+ {operatorUnlocked ? "OP" : "RO"} +
+
+
+
+ ); +}; diff --git a/src/components/integrations/GovernedIntegrationFlow.tsx b/src/components/integrations/GovernedIntegrationFlow.tsx new file mode 100644 index 0000000..1a67ebf --- /dev/null +++ b/src/components/integrations/GovernedIntegrationFlow.tsx @@ -0,0 +1,271 @@ +import { useMemo, useState, type ComponentType } from "react"; +import { + ArrowDown, + ArrowRight, + Ban, + Bot, + Brain, + CheckCircle2, + ClipboardCheck, + ShieldCheck, + UserRoundCheck, + Workflow, + type LucideProps, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + getIntegrationRuntimeStates, + integrationFlowStateLabel, + type IntegrationFlowState, +} from "@/lib/integrationFlow"; +import type { IntegrationStatus } from "@/types/appops"; + +type FlowNodeId = "mcp" | "rag" | "n8n" | "policy" | "approval" | "operator" | "blocked"; + +interface GovernedIntegrationFlowProps { + status?: IntegrationStatus; + loading: boolean; + unavailable: boolean; +} + +interface FlowNodeDefinition { + id: FlowNodeId; + label: string; + eyebrow: string; + description: string; + detail: string; + state: IntegrationFlowState; + stateLabel?: string; + Icon: ComponentType; +} + +const nodeTone: Record = { + healthy: "border-emerald-500/45 bg-emerald-500/[0.07] text-emerald-200", + warning: "border-amber-500/45 bg-amber-500/[0.07] text-amber-100", + critical: "border-rose-500/55 bg-rose-500/10 text-rose-100", + stopped: "border-slate-700 bg-slate-900/80 text-slate-300", + loading: "border-blue-500/40 bg-blue-500/[0.06] text-blue-100", + unknown: "border-amber-500/30 bg-slate-900/80 text-slate-300", +}; + +const stateDotTone: Record = { + healthy: "bg-emerald-400", + warning: "bg-amber-400", + critical: "bg-rose-400", + stopped: "bg-slate-500", + loading: "bg-blue-400", + unknown: "bg-amber-300", +}; + +const FlowArrow = ({ direction = "right" }: { direction?: "right" | "down" }) => { + const Icon = direction === "down" ? ArrowDown : ArrowRight; + return ( + + ); +}; + +const FlowNode = ({ + node, + selected, + onSelect, +}: { + node: FlowNodeDefinition; + selected: boolean; + onSelect: (id: FlowNodeId) => void; +}) => ( + +); + +export const GovernedIntegrationFlow = ({ status, loading, unavailable }: GovernedIntegrationFlowProps) => { + const [selectedId, setSelectedId] = useState("policy"); + const states = getIntegrationRuntimeStates(status, loading, unavailable); + const nodes = useMemo>(() => ({ + mcp: { + id: "mcp", + label: "MCP tools", + eyebrow: "read-only context", + description: "MCP ontsluit alleen begrensde status-, policy- en kennisreads voor geauthenticeerde clients.", + detail: `${status?.mcp.toolCount ?? 0} tools · ${status?.mcp.scope ?? "read_only"} · ${status?.mcp.state ?? "status onbekend"}`, + state: states.mcp, + Icon: Bot, + }, + rag: { + id: "rag", + label: "Lokale RAG", + eyebrow: "citations + redaction", + description: "De gedeelde lokale kennislaag levert bronverwijzingen en negeert instructies uit geïndexeerde bronnen.", + detail: `${status?.rag.sources ?? 0} bronnen · ${status?.rag.chunks ?? 0} chunks · secret scan ${status?.rag.secretScanStatus ?? "onbekend"}`, + state: states.rag, + Icon: Brain, + }, + n8n: { + id: "n8n", + label: "n8n machine", + eyebrow: "events + preflight", + description: "n8n kan events en kennis lezen, policy-preflight uitvoeren en een approval aanvragen; nooit zelf uitvoeren.", + detail: `${status?.n8n.state ?? "status onbekend"} · ${status?.n8n.workflow?.processedEvents ?? 0} events verwerkt${status?.n8n.workflow?.backlogRemaining ? " · begrensde backlog actief" : ""}`, + state: states.n8n, + Icon: Workflow, + }, + policy: { + id: "policy", + label: "Policy preflight", + eyebrow: "default deny", + description: "Elke voorgenomen mutatie wordt als typed action tegen de centrale policy en risico-classificatie beoordeeld.", + detail: states.executionBoundary === "critical" + ? "Directe machine-executie is gerapporteerd en schendt de platformgrens." + : states.executionBoundary === "healthy" + ? "De runtime bevestigt canExecute=false; preflight geeft alleen een beslissing terug." + : "De executiegrens kan pas na een geldige operatorstatus worden geverifieerd.", + state: states.executionBoundary, + stateLabel: states.executionBoundary === "healthy" ? "policygrens" : undefined, + Icon: ShieldCheck, + }, + approval: { + id: "approval", + label: "Approval inbox", + eyebrow: "idempotent request", + description: "Approval-required acties komen één keer in de AppOps-inbox terecht met plan, reden en auditcontext.", + detail: "Een service mag een approval aanvragen, maar niet goedkeuren, afwijzen of uitvoeren.", + state: states.executionBoundary, + stateLabel: states.executionBoundary === "healthy" ? "menselijke gate" : undefined, + Icon: ClipboardCheck, + }, + operator: { + id: "operator", + label: "Menselijke operator", + eyebrow: "beslist in AppOps", + description: "Alleen een ontgrendelde menselijke operatorsessie kan beslissen en de bestaande typed action-flow vervolgen.", + detail: "Policy, approvalstatus, audittrail en uitvoerresultaat blijven zichtbaar binnen het platform.", + state: states.executionBoundary, + stateLabel: states.executionBoundary === "healthy" ? "handmatig" : undefined, + Icon: UserRoundCheck, + }, + blocked: { + id: "blocked", + label: "Geen execute-endpoint", + eyebrow: "bewuste dead end", + description: "De machine-interface heeft geen algemene execute-, shell- of Docker-socketroute.", + detail: "Geblokkeerde acties blijven geblokkeerd; allowed preflight is geen uitvoerautorisatie voor n8n.", + state: states.executionBoundary === "critical" ? "critical" : states.executionBoundary === "loading" ? "loading" : states.executionBoundary === "unknown" ? "unknown" : "healthy", + stateLabel: states.executionBoundary === "healthy" ? "afgesloten" : undefined, + Icon: Ban, + }, + }), [states.executionBoundary, states.mcp, states.n8n, states.rag, status]); + const selected = nodes[selectedId]; + + return ( +
+
+
+

Governed dataflow

+

Van private context naar menselijke beslissing

+

+ Selecteer een knooppunt voor de live betekenis. De contextlane leest; de actionlane stopt altijd bij policy en approval. +

+
+
+ geverifieerd + aandacht + grensfout +
+
+ +
+
+
+
+ 01 · Contextlane + + alleen lezen +
+
+ +
+
+ +
+
+ +
+

+ MCP en n8n delen dezelfde geciteerde RAG-context; de pijlen stellen gegevensuitwisseling voor, geen uitvoerrechten. +

+
+ +
+
+ 02 · Actionlane + + menselijke gate +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+
+
+ + +
+
+ ); +}; diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..83ff017 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..2350014 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..13219e7 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..c9e6f4b --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..444b1db --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "@/lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f7c5e18 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,38 @@ +/* eslint-disable react-refresh/only-export-components */ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded border px-2 py-0.5 font-mono text-[10px] font-semibold uppercase transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1", + { + variants: { + variant: { + default: + "border-blue-500/30 bg-blue-500/10 text-blue-300 hover:bg-blue-500/15", + secondary: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15", + destructive: + "border-rose-500/30 bg-rose-500/10 text-rose-300 hover:bg-rose-500/15", + outline: "border-slate-700 bg-slate-900 text-slate-300", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..6934f83 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>