from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from fastapi.responses import FileResponse, PlainTextResponse from sqlmodel import Session from ..core.security import require_operator_request 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, runtime_info, sqlite_maintenance_status, verify_sqlite_backup, ) router = APIRouter(prefix='/api') 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.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, } @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'}, )