modules/sites.py was still a fully Docker-era hardcoded registry: container names (odoo-clean-odoo-1, frappe-erpnext, nextcloud-app, mautic-app, n8n-app) that no longer exist post-migration, checked via `docker inspect` over SSH, plus hardcoded domain/port fields - nextcloud and mautic even had domain: None, silently falling back to dead Docker host-port URLs while their real Ingress domains (next.cloud.nav.ovh, mautics.nav.ovh) sat unused. Now sources everything live from the cluster: - Domain + TLS from the actual Ingress object per app (via the new get_ingress_info() in modules/kubernetes.py), not a static guess. Odoo/Nextcloud's Ingress lives in `default` (see prior commit for the RBAC this needed); n8n/mautic/erpnext's lives in their own namespace. - App/DB/cache/worker/scheduler status from real Deployment state (list_deployments_for_namespace(), new in modules/kubernetes.py) instead of `docker inspect`. - Health probe now hits the real domain directly from the pod (it has normal internet egress) instead of SSH-ing back out to the host to curl a public HTTPS URL, which is what the RUNNING_ON_MAIN_SERVER branch would have done for a pod whose hostname never matches the main-server hostname check. Same API/template contract as before (get_sites_list/get_site_health return the same field shapes) - templates/pages/sites.html needs no changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017avLHFqkiti3g62Anq9sVA
306 lines
11 KiB
Python
306 lines
11 KiB
Python
# 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
|