Files
CloudOps/platform/modules/sites.py
root a8f996422c Rewrite Application Sites page to reflect real k8s state
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
2026-08-21 01:43:38 +02:00

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