from __future__ import annotations import re from typing import Any def _text(raw: dict[str, Any]) -> str: labels = raw.get('labels') if isinstance(raw.get('labels'), dict) else {} label_text = ' '.join(f'{key} {value}' for key, value in labels.items()) ports = ' '.join(str(port) for port in raw.get('ports', []) if port is not None) text = f"{raw.get('name', '')} {raw.get('image', '')} {label_text} {ports}".lower() # Preserve the source while also matching humanized names such as "Gpu Hot" # against the same conservative patterns used for container IDs and images. return ' '.join((text, re.sub(r'[\s_]+', '-', text), re.sub(r'[\s-]+', '_', text))) def _match(text: str, *patterns: str) -> bool: return any(re.search(pattern, text) for pattern in patterns) def _base(raw: dict[str, Any]) -> dict[str, Any]: name = str(raw.get('name') or raw.get('container_id') or 'unknown').strip('/') or 'unknown' safe_id = re.sub(r'[^a-z0-9_]+', '-', name.lower()).strip('-') or 'unknown' return { 'suggested_app_id': safe_id.replace('-', '_'), 'display_name': name.replace('-', ' ').replace('_', ' ').title(), 'app_type': 'unknown', 'category': 'unknown', 'criticality': 'low', 'connector': 'none', 'policy_profile': 'generic_unknown', 'protected': False, 'healthcheck_suggestion': None, 'voice_suggestion': 'disabled', 'confidence': 0.2, 'reasons': ['No known pattern matched.'], 'import_status': 'unmapped', } def classify_container(raw: dict[str, Any]) -> dict[str, Any]: text = _text(raw) result = _base(raw) def apply( *, app_type: str, category: str, criticality: str, connector: str = 'none', policy_profile: str | None = None, subcategory: str | None = None, protected: bool = False, privacy_sensitive: bool = False, confidence: float = 0.85, reason: str, default_port: int | None = None, ) -> dict[str, Any]: result.update( { 'suggested_app_id': app_type, 'display_name': app_type.replace('_', ' ').title(), 'app_type': app_type, 'category': category, 'subcategory': subcategory, 'criticality': criticality, 'connector': connector, 'policy_profile': policy_profile or category, 'protected': protected, 'privacy_sensitive': privacy_sensitive, 'healthcheck_suggestion': {'port': default_port} if default_port else {'type': 'http'}, 'voice_suggestion': 'blocked' if protected else 'disabled', 'confidence': confidence, 'reasons': [reason], 'import_status': 'discovered', } ) return result if _match(text, r'\b(binhex-)?sonarr\b', r'arch-sonarr'): return apply(app_type='sonarr', category='media', criticality='medium', connector='sonarr', policy_profile='media_app', reason='Matched Sonarr name/image pattern.') if _match(text, r'\b(binhex-)?radarr\b', r'arch-radarr'): return apply(app_type='radarr', category='media', criticality='medium', connector='radarr', policy_profile='media_app', reason='Matched Radarr name/image pattern.') if _match(text, r'\bprowlarr\b'): return apply(app_type='prowlarr', category='media', criticality='medium', connector='prowlarr', policy_profile='media_indexer', reason='Matched Prowlarr pattern.', default_port=9696) if _match(text, r'\b(binhex-)?deluge(vpn)?\b', r'arch-delugevpn', r'linuxserver/deluge'): return apply(app_type='deluge', category='downloads', subcategory='torrent_client', criticality='medium', connector='deluge', policy_profile='download_client', reason='Matched Deluge download client pattern.', default_port=8112) if _match(text, r'\b(binhex-)?plex(pass)?\b', r'arch-plexpass', r'linuxserver/plex', r'plexinc/pms-docker'): return apply(app_type='plex', category='media', subcategory='media_server', criticality='medium', connector='plex', policy_profile='media_server', reason='Matched Plex media server pattern.', default_port=32400) if _match(text, r'\bjellyfin\b'): return apply(app_type='jellyfin', category='media', criticality='medium', connector='none', policy_profile='media_app', reason='Matched Jellyfin media server pattern; no AppOps Jellyfin connector is registered.') if _match(text, r'\b(binhex-)?tautulli\b', r'linuxserver/tautulli', r'tautulli/tautulli'): return apply(app_type='tautulli', category='media', subcategory='plex_analytics', criticality='medium', connector='tautulli', policy_profile='media_analytics', privacy_sensitive=True, reason='Matched Tautulli Plex analytics pattern.', default_port=8181) if _match(text, r'\b(qbittorrent|deluge|sabnzbd|nzbget)\b'): app_type = next((name for name in ['qbittorrent', 'deluge', 'sabnzbd', 'nzbget'] if name in text), 'downloads') return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download client pattern.') if _match(text, r'\bauthentik\b', r'\bauthentik-(server|worker)\b', r'ghcr\.io/goauthentik/(server|proxy)', r'beryju/authentik', r'\bgoauthentik\b'): return apply( app_type='authentik', category='auth', subcategory='identity_provider', criticality='critical', connector='authentik', protected=True, policy_profile='auth_provider', reason='Matched Authentik identity provider pattern.', default_port=9000, ) if _match(text, r'\b(postgres|postgresql|mariadb|mysql)\b'): app_type = next((name for name in ['postgres', 'postgresql', 'mariadb', 'mysql'] if name in text), 'database') return apply(app_type=app_type, category='database', criticality='critical', protected=True, policy_profile='database', reason='Matched database pattern.') if _match(text, r'\bredis\b'): return apply(app_type='redis', category='database', criticality='high', protected=True, policy_profile='database', reason='Matched Redis pattern.') if _match(text, r'\b(nginxproxymanager|nginx-proxy-manager|jc21/nginx-proxy-manager|npm)\b'): return apply( app_type='nginx_proxy_manager', category='network', subcategory='reverse_proxy', criticality='critical', connector='nginx_proxy_manager', protected=True, policy_profile='reverse_proxy', reason='Matched Nginx Proxy Manager reverse proxy pattern.', default_port=81, ) if _match(text, r'\bportainer\b'): return apply(app_type='portainer', category='infrastructure', criticality='critical', protected=True, policy_profile='protected_service', reason='Matched Portainer pattern.') if _match(text, r'\b(homeassistant|home-assistant|home_assistant)\b'): return apply(app_type='home_assistant', category='home_automation', criticality='high', protected=True, policy_profile='protected_service', reason='Matched Home Assistant pattern.') if _match(text, r'\bgitea\b'): return apply(app_type='gitea', category='dev', criticality='medium', policy_profile='dev_tool', reason='Matched Gitea pattern.') if _match(text, r'\bwidefrog\b'): return apply(app_type='widefrog', category='media', criticality='medium', connector='widefrog', policy_profile='media_custom_app', reason='Matched Widefrog pattern.', default_port=1207) if _match(text, r'\b(adguard|adguardhome|adguard-home)\b'): return apply(app_type='adguard_home', category='network', criticality='high', protected=True, policy_profile='network_service', reason='Matched AdGuard network service pattern.') if _match(text, r'\b(vaultwarden|bitwarden)\b'): return apply(app_type='vaultwarden', category='auth', criticality='critical', protected=True, privacy_sensitive=True, policy_profile='auth_provider', reason='Matched credential vault pattern.') if _match(text, r'\b(adminer|phpmyadmin)\b'): app_type = 'adminer' if 'adminer' in text else 'phpmyadmin' return apply(app_type=app_type, category='database', criticality='high', protected=True, policy_profile='database_admin', reason='Matched database administration pattern.') if _match(text, r'\b(audiobookshelf|audiodeck|bazarr|seerr|komga|lidarr|mylar3?|immich|peertube|romm|tdarr|grimmory|listenarr|readmeabook)\b'): app_type = next((name for name in ['audiobookshelf', 'audiodeck', 'bazarr', 'seerr', 'komga', 'lidarr', 'mylar3', 'immich', 'peertube', 'romm', 'tdarr', 'grimmory', 'listenarr', 'readmeabook'] if name.replace('_', '-') in text or name in text), 'media_app') protected = app_type == 'immich' return apply(app_type=app_type, category='media', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='media_app', reason='Matched media library or companion app pattern.') if _match(text, r'\b(paperless-ngx|paperless_ngx|stirlingpdf|stirling-pdf|leafwiki|infrabinder-local|infrabinder_local)\b'): app_type = next((name for name in ['paperless_ngx', 'stirlingpdf', 'leafwiki', 'infrabinder_local'] if name.replace('_', '-') in text or name in text), 'document_service') protected = app_type in {'paperless_ngx', 'infrabinder_local'} return apply(app_type=app_type, category='productivity', subcategory='documents', criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=True, policy_profile='document_service', reason='Matched document or knowledge-management service pattern.') if _match(text, r'\b(jdownloader2?|metube|slskd|unpackerr)\b'): app_type = next((name for name in ['jdownloader2', 'metube', 'slskd', 'unpackerr'] if name in text), 'download_tool') return apply(app_type=app_type, category='downloads', criticality='medium', policy_profile='download_client', reason='Matched download workflow pattern.') if _match(text, r'\b(ollama|anythingllm|litellm|chromadb|hivekeep|odysseus-aio|odysseus_aio)\b'): app_type = next((name for name in ['ollama', 'anythingllm', 'litellm', 'chromadb', 'hivekeep', 'odysseus_aio'] if name.replace('_', '-') in text or name in text), 'ai_service') category = 'database' if app_type == 'chromadb' else 'infrastructure' protected = app_type in {'chromadb', 'hivekeep', 'odysseus_aio'} return apply(app_type=app_type, category=category, criticality='high' if protected else 'medium', protected=protected, privacy_sensitive=protected, policy_profile='database' if app_type == 'chromadb' else 'ai_service', reason='Matched local AI platform pattern.') if _match(text, r'\b(searxng|flaresolverr|postfix-relay|postfix_relay)\b'): app_type = next((name for name in ['searxng', 'flaresolverr', 'postfix_relay'] if name.replace('_', '-') in text or name in text), 'network_service') return apply(app_type=app_type, category='network', criticality='medium', protected=app_type == 'postfix_relay', policy_profile='network_service', reason='Matched network support service pattern.') if _match(text, r'\b(unraid-appops-gateway|unraid_appops_gateway|watchtower)\b'): app_type = 'unraid_appops_gateway' if 'appops' in text else 'watchtower' return apply(app_type=app_type, category='infrastructure', criticality='critical' if app_type == 'unraid_appops_gateway' else 'high', protected=True, policy_profile='protected_service', reason='Matched infrastructure control-plane service pattern.') if _match(text, r'\b(organizrv?2?|organizr|unraid-progressive-web-app|unraid_progressive_web_app|ntfy)\b'): app_type = next((name for name in ['organizrv2', 'unraid_progressive_web_app', 'ntfy'] if name.replace('_', '-') in text or name in text), 'infrastructure_service') return apply(app_type=app_type, category='infrastructure', criticality='medium', policy_profile='infrastructure_service', reason='Matched self-hosted infrastructure support service pattern.') if _match(text, r'\b(retroarch|rickroll)\b'): app_type = 'retroarch' if 'retroarch' in text else 'rickroll' return apply(app_type=app_type, category='media', criticality='low', policy_profile='media_app', reason='Matched media or entertainment service pattern.') if _match(text, r'\bdockdeck\b', r'dockdeck:local'): return apply(app_type='dockdeck', category='infrastructure', subcategory='service_portal', criticality='medium', privacy_sensitive=True, policy_profile='internal_service', reason='Matched the local DockDeck self-hosted start dashboard pattern.', default_port=1218) if _match(text, r'\blaunchpad\b', r'launchpad:local'): return apply(app_type='launchpad', category='infrastructure', subcategory='service_portal', criticality='medium', policy_profile='internal_service', reason='Matched the local Launchpad service portal pattern.') if _match(text, r'\bmedia-codec-dashboard\b', r'\bmedia_codec_dashboard\b'): return apply(app_type='media_codec_dashboard', category='media', subcategory='media_observability', criticality='medium', privacy_sensitive=True, policy_profile='media_app', reason='Matched the local media codec observability dashboard pattern.') if _match(text, r'\bgeointel\b'): return apply(app_type='geointel', category='ai', subcategory='geospatial_workbench', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local GeoIntel GeoAI workbench pattern.') if _match(text, r'\bopscenter-it-tools\b', r'\bopscenter_it_tools\b'): return apply(app_type='opscenter_it_tools', category='dev', subcategory='operations_tooling', criticality='high', protected=True, privacy_sensitive=True, policy_profile='dev_tool', reason='Matched the local OpsCenter diagnostics workbench pattern.') if _match(text, r'\bprojectbrain\b'): return apply(app_type='projectbrain', category='productivity', subcategory='project_operations', criticality='high', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local ProjectBrain project operating system pattern.') if _match(text, r'\bpsforge\b'): return apply(app_type='psforge', category='productivity', subcategory='project_planning', criticality='medium', protected=True, privacy_sensitive=True, policy_profile='document_service', reason='Matched the local PSForge planning and documentation workbench pattern.') if _match(text, r'\bquizmonitor\b', r'\bquiz-monitor\b'): return apply(app_type='quizmonitor', category='ai', subcategory='knowledge_assistant', criticality='high', protected=True, privacy_sensitive=True, policy_profile='ai_service', reason='Matched the local QuizMonitor AI knowledge platform pattern.') if _match(text, r'\bspoortracker\b'): return apply(app_type='spoortracker', category='monitoring', subcategory='transport_monitoring', criticality='low', policy_profile='monitoring', reason='Matched the local SpoorTracker live transport monitoring pattern.') if _match(text, r'\bcertscope\b'): return apply(app_type='certscope', category='monitoring', subcategory='domain_inspection', criticality='low', policy_profile='monitoring', reason='Matched the local CertScope domain posture inspector pattern.') if _match(text, r'\bps4-http\b', r'\bps4_http\b'): return apply(app_type='ps4_http', category='infrastructure', subcategory='http_service', criticality='low', policy_profile='infrastructure_service', reason='Matched the local PS4 HTTP support service pattern.') if _match(text, r'\b(zabbix|grafana|uptime-kuma|glances|netdata|netronome|unraid-stats|unraid_stats|system-monitor|system_monitor|gpu-hot|gpu_hot)\b'): app_type = next((name for name in ['zabbix', 'grafana', 'uptime-kuma', 'glances', 'netdata', 'netronome', 'unraid_stats', 'system_monitor', 'gpu_hot'] if name.replace('_', '-') in text or name in text), 'monitoring') return apply(app_type=app_type.replace('-', '_'), category='monitoring', criticality='medium', policy_profile='monitoring', reason='Matched monitoring pattern.') return result