208 lines
7.8 KiB
Python
208 lines
7.8 KiB
Python
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}
|