Files
Unraid-AppOps-Gateway-Public/backend/app/api/routes_runtime.py
T

199 lines
6.5 KiB
Python

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'},
)