Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -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'},
|
||||
)
|
||||
Reference in new issue
Block a user