# modules/sites.py — Managed application sites registry + live status # # REWRITTEN 2026-08-21: this used to be a fully Docker-era hardcoded registry # (container names like odoo-clean-odoo-1/frappe-erpnext/nextcloud-app that # no longer exist since the k8s migration, checked via `docker inspect` over # SSH) with hardcoded domain/port fields — nextcloud and mautic even had # `domain: None`, silently falling back to dead Docker host-port URLs while # their real k8s Ingress domains (next.cloud.nav.ovh, mautics.nav.ovh) sat # unused. Now sources everything live from the cluster: Deployment/pod # status via modules/kubernetes.py's in-cluster client, and domain/TLS via # the actual Ingress objects (not a static guess) — see # management-platform-sites-rbac.yaml for the narrow ingress-read grant this # needed in the `default` namespace (odoo/nextcloud's Ingress historically # live there, not in their app namespace). import time import urllib.request from modules.kubernetes import get_ingress_info, list_deployments_for_namespace # ──────────────────────────────────────────────────────────────── # STATIC SITE REGISTRY — identity/namespace/naming only. Live state # (domain, TLS, deployment/pod health) is always fetched fresh below, # never hardcoded. # ──────────────────────────────────────────────────────────────── PLATFORM = { 'id': 'cloudops', 'name': 'CloudOps Platform', 'tagline': 'Navitrends ops dashboard', 'domain': 'cloudops.nav.ovh', 'domain_protocol': 'https', 'ssl_configured': True, 'port': 8088, 'internal_port': 5000, 'container': 'management-platform', 'health_path': '/', 'brand_color': '#3b82f6', 'icon': 'fa-gauge-high', } SITES = [ { 'id': 'erpnext', 'name': 'ERPNext', 'tagline': 'Enterprise resource planning', 'category': 'ERP', 'icon': 'erpnext', 'brand_color': '#0089FF', 'namespace': 'erpnext', 'main_deployment': 'erpnext-web', 'service_port': 8000, 'ingress_name': 'erpnext-ingress', 'ingress_namespace': 'erpnext', 'health_path': '/', 'pvcs': ['erpnext-sites', 'erpnext-mariadb-data'], 'roles': { 'erpnext-web': 'App', 'erpnext-worker': 'Worker', 'erpnext-scheduler': 'Scheduler', 'erpnext-mariadb': 'Database', 'erpnext-redis-cache': 'Cache', 'erpnext-redis-queue': 'Queue', }, }, { 'id': 'odoo', 'name': 'Odoo', 'tagline': 'Business management suite', 'category': 'ERP', 'icon': 'odoo', 'brand_color': '#714B67', 'namespace': 'odoo', 'main_deployment': 'odoo', 'service_port': 8069, # Historical: created in `default` during migration, not the `odoo` ns. 'ingress_name': 'odoo-ingress', 'ingress_namespace': 'default', 'health_path': '/web', 'pvcs': ['odoo-data', 'odoo-postgres-data'], 'roles': { 'odoo': 'App', 'odoo-postgres': 'Database', }, }, { 'id': 'nextcloud', 'name': 'Nextcloud', 'tagline': 'File sync & collaboration', 'category': 'Storage', 'icon': 'nextcloud', 'brand_color': '#0082C9', 'namespace': 'nextcloud', 'main_deployment': 'nextcloud', 'service_port': 80, # Historical: created in `default` during migration, not the `nextcloud` ns. 'ingress_name': 'nextcloud-ingress', 'ingress_namespace': 'default', 'health_path': '/status.php', 'pvcs': ['nextcloud-app-data', 'nextcloud-postgres-data'], 'roles': { 'nextcloud': 'App', 'nextcloud-postgres': 'Database', }, }, { 'id': 'mautic', 'name': 'Mautic', 'tagline': 'Marketing automation', 'category': 'Marketing', 'icon': 'mautic', 'brand_color': '#4E5E9E', 'namespace': 'mautic', 'main_deployment': 'mautic', 'service_port': 80, 'ingress_name': 'mautic-ingress', 'ingress_namespace': 'mautic', 'health_path': '/', 'pvcs': ['mautic-app-data', 'mautic-db-data'], 'roles': { 'mautic': 'App', 'mautic-mariadb': 'Database', }, }, { 'id': 'n8n', 'name': 'n8n', 'tagline': 'Workflow automation', 'category': 'Automation', 'icon': 'n8n', 'brand_color': '#EA4B71', 'namespace': 'n8n', 'main_deployment': 'n8n', 'service_port': 5678, 'ingress_name': 'n8n-ingress', 'ingress_namespace': 'n8n', 'health_path': '/healthz', 'pvcs': ['n8n-data', 'n8n-postgres-data'], 'roles': { 'n8n': 'App', 'n8n-postgres': 'Database', }, }, ] SITE_BY_ID = {s['id']: s for s in SITES} def _build_urls(site, ingress_info): """Domain/TLS come from the live Ingress object — never hardcoded. ingress_info is None if the Ingress couldn't be read (RBAC/API issue) or {'host': str|None, 'tls': bool} otherwise.""" path = site.get('health_path', '/') or '/' host = ingress_info.get('host') if ingress_info else None has_domain = bool(host) ssl_configured = bool(ingress_info and ingress_info.get('tls')) if has_domain: proto = 'https' if ssl_configured else 'http' access_url = f"{proto}://{host}" health_url = f"{access_url}{path}" else: access_url = None health_url = None return { 'has_domain': has_domain, 'domain': host, 'domain_protocol': 'https' if ssl_configured else 'http', 'ssl_configured': ssl_configured, 'access_url': (access_url or '').rstrip('/'), 'health_url': health_url, } def _probe_http(url, timeout=5): """Direct HTTP probe. The management-platform pod has normal internet egress and resolves public DNS just like any other pod — no need to hop back out to the host via SSH the way the old Docker-era code did.""" try: req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'CloudOps-HealthCheck/1.0'}) t0 = time.time() with urllib.request.urlopen(req, timeout=timeout) as resp: code = resp.getcode() latency_ms = int((time.time() - t0) * 1000) return { 'reachable': 200 <= code < 400, 'http_code': code, 'latency_ms': latency_ms, 'error': None, } except Exception as e: return {'reachable': False, 'http_code': None, 'latency_ms': None, 'error': str(e)[:120]} def _deployments_for_site(site): """Live Deployment status for every stack member (app/db/cache/etc), keyed by k8s Deployment name.""" live = {d['name']: d for d in list_deployments_for_namespace(site['namespace'])} roles = site.get('roles', {}) containers = [] running_count = 0 for dep_name, role in roles.items(): d = live.get(dep_name) if d: is_up = d['ready'] >= 1 and d['desired'] >= 1 if is_up: running_count += 1 containers.append({ 'name': dep_name, 'role': role, 'status': 'running' if is_up else 'stopped', 'image_live': d['image'], 'ports': f"{d['ready']}/{d['desired']} ready", }) else: containers.append({ 'name': dep_name, 'role': role, 'status': 'not_found', 'image_live': '—', 'ports': '—', }) main = live.get(site['main_deployment']) main_running = bool(main and main['ready'] >= 1 and main['desired'] >= 1) return containers, running_count, main_running def _site_entry(site, include_health): ingress_info = get_ingress_info(site['ingress_name'], site['ingress_namespace']) urls = _build_urls(site, ingress_info) containers, running_count, main_running = _deployments_for_site(site) entry = { 'id': site['id'], 'name': site['name'], 'tagline': site['tagline'], 'category': site['category'], 'icon': site['icon'], 'brand_color': site['brand_color'], 'compose_dir': site['namespace'], # k8s namespace, shown as "COMPOSE PROJECT" in the UI 'main_container': site['main_deployment'], 'port': site['service_port'], 'internal_port': site['service_port'], 'port_mapping': f"svc/{site['service_port']}", 'ports_raw': f"ClusterIP:{site['service_port']}", 'volumes': site.get('pvcs', []), 'ip_url': f"{site['main_deployment']}.{site['namespace']}.svc.cluster.local:{site['service_port']} (cluster-internal only)", 'container_status': 'running' if main_running else 'stopped', 'container_running': main_running, 'containers': containers, 'containers_running': running_count, 'containers_total': len(site.get('roles', {})), **urls, } if include_health: if not urls['has_domain']: entry['health'] = { 'status': 'down', 'reachable': False, 'http_code': None, 'latency_ms': None, 'error': 'No Ingress host found for this app', } elif not main_running: entry['health'] = { 'status': 'offline', 'reachable': False, 'http_code': None, 'latency_ms': None, 'error': 'Deployment not running', } else: probe = _probe_http(urls['health_url']) entry['health'] = {'status': 'up' if probe['reachable'] else 'down', **probe} return entry def get_sites_list(include_health=False): """Return all sites with live k8s Deployment + Ingress state.""" return [_site_entry(site, include_health) for site in SITES] def get_site_health(site_id): site = SITE_BY_ID.get(site_id) if not site: return None ingress_info = get_ingress_info(site['ingress_name'], site['ingress_namespace']) urls = _build_urls(site, ingress_info) _, _, main_running = _deployments_for_site(site) if not main_running: return { 'site_id': site_id, 'status': 'offline', 'reachable': False, 'http_code': None, 'latency_ms': None, 'error': 'Deployment not running', 'checked_at': time.time(), } if not urls['has_domain']: return { 'site_id': site_id, 'status': 'down', 'reachable': False, 'http_code': None, 'latency_ms': None, 'error': 'No Ingress host found', 'checked_at': time.time(), } probe = _probe_http(urls['health_url']) probe['site_id'] = site_id probe['status'] = 'up' if probe['reachable'] else 'down' probe['checked_at'] = time.time() return probe