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,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}
|
||||
Reference in new issue
Block a user