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
This commit is contained in:
root
2026-08-21 01:43:38 +02:00
parent 6b0402ff8b
commit a8f996422c
2 changed files with 249 additions and 225 deletions

View File

@@ -21,11 +21,12 @@ NAMESPACES = [
_core_v1 = None
_apps_v1 = None
_custom_objects = None
_networking_v1 = None
K8S_AVAILABLE = False
def _init_client():
global _core_v1, _apps_v1, _custom_objects, K8S_AVAILABLE
global _core_v1, _apps_v1, _custom_objects, _networking_v1, K8S_AVAILABLE
if _core_v1 is not None or not _K8S_IMPORT_OK:
return
try:
@@ -33,6 +34,7 @@ def _init_client():
_core_v1 = client.CoreV1Api()
_apps_v1 = client.AppsV1Api()
_custom_objects = client.CustomObjectsApi()
_networking_v1 = client.NetworkingV1Api()
K8S_AVAILABLE = True
except Exception as e:
print(f"[kubernetes] in-cluster config unavailable: {e}")
@@ -181,6 +183,54 @@ def list_deployments():
return deployments
def get_ingress_info(name, namespace):
"""Real domain/TLS for one Ingress, or None if unavailable/not found.
Used by modules/sites.py instead of the old hardcoded Docker-era
domain fields — reflects actual live k8s Ingress state."""
_init_client()
if not K8S_AVAILABLE:
return None
try:
ing = _networking_v1.read_namespaced_ingress(name, namespace)
except Exception:
return None
host = None
if ing.spec and ing.spec.rules:
for rule in ing.spec.rules:
if rule.host:
host = rule.host
break
tls = bool(ing.spec and ing.spec.tls)
return {'host': host, 'tls': tls}
def list_deployments_for_namespace(ns):
"""Targeted single-namespace deployment list (vs. list_deployments()'s
fixed NAMESPACES sweep) — used by modules/sites.py per app."""
_init_client()
if not K8S_AVAILABLE:
return []
try:
resp = _apps_v1.list_namespaced_deployment(ns)
except Exception as e:
print(f"[kubernetes] list deployments failed for {ns}: {e}")
return []
out = []
for dep in resp.items:
desired = dep.spec.replicas or 0
ready = dep.status.ready_replicas or 0
containers = dep.spec.template.spec.containers or []
image = containers[0].image if containers else ''
out.append({
'name': dep.metadata.name,
'desired': desired,
'ready': ready,
'health': _deploy_health(desired, ready),
'image': image,
})
return out
def get_pod_metrics():
"""Best-effort CPU/mem per pod via metrics-server. Returns {} if it's not
installed or unreachable — callers must treat an empty dict as 'no data',

View File

@@ -1,15 +1,27 @@
# modules/sites.py — Managed application sites registry + live status
import re
#
# 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
from urllib.request import urlopen, Request
from urllib.error import URLError
import urllib.request
from config import MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER
from modules.backups import _ssh_main, get_all_root_containers, get_container_status
from modules.kubernetes import get_ingress_info, list_deployments_for_namespace
# ────────────────────────────────────────────────────────────────
# STATIC SITE REGISTRY (source of truth for Application Sites UI)
# STATIC SITE REGISTRY — identity/namespace/naming only. Live state
# (domain, TLS, deployment/pod health) is always fetched fresh below,
# never hardcoded.
# ────────────────────────────────────────────────────────────────
PLATFORM = {
@@ -35,20 +47,21 @@ SITES = [
'category': 'ERP',
'icon': 'erpnext',
'brand_color': '#0089FF',
'compose_dir': 'frappe-setup',
'main_container': 'frappe-erpnext',
'containers': [
{'name': 'frappe-erpnext', 'role': 'App', 'image': 'frappe/erpnext:latest'},
{'name': 'frappe-mariadb', 'role': 'Database', 'image': 'mariadb:10.6'},
{'name': 'frappe-redis', 'role': 'Cache / Queue', 'image': 'redis:alpine'},
],
'port': 8080,
'internal_port': 8000,
'domain': 'erpnext.navitrends.ovh',
'domain_protocol': 'http',
'ssl_configured': False,
'namespace': 'erpnext',
'main_deployment': 'erpnext-web',
'service_port': 8000,
'ingress_name': 'erpnext-ingress',
'ingress_namespace': 'erpnext',
'health_path': '/',
'volumes': ['frappe-setup_frappe-sites', 'frappe-setup_mariadb-data'],
'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',
@@ -57,19 +70,18 @@ SITES = [
'category': 'ERP',
'icon': 'odoo',
'brand_color': '#714B67',
'compose_dir': 'odoo-clean',
'main_container': 'odoo-clean-odoo-1',
'containers': [
{'name': 'odoo-clean-odoo-1', 'role': 'App', 'image': 'odoo:18'},
{'name': 'odoo-clean-db-1', 'role': 'Database', 'image': 'postgres:15'},
],
'port': 8069,
'internal_port': 8069,
'domain': 'odooo.nav.ovh',
'domain_protocol': 'https',
'ssl_configured': True,
'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',
'volumes': ['odoo-clean_db-data', 'odoo-clean_odoo-etc'],
'pvcs': ['odoo-data', 'odoo-postgres-data'],
'roles': {
'odoo': 'App',
'odoo-postgres': 'Database',
},
},
{
'id': 'nextcloud',
@@ -78,19 +90,18 @@ SITES = [
'category': 'Storage',
'icon': 'nextcloud',
'brand_color': '#0082C9',
'compose_dir': 'nextcloud-setup',
'main_container': 'nextcloud-app',
'containers': [
{'name': 'nextcloud-app', 'role': 'App', 'image': 'nextcloud:latest'},
{'name': 'nextcloud-postgres', 'role': 'Database', 'image': 'postgres:15'},
],
'port': 8082,
'internal_port': 80,
'domain': 'next.cloud.nav.ovh',
'domain_protocol': 'https',
'ssl_configured': True,
'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',
'volumes': ['nextcloud-setup_nextcloud-data', 'nextcloud-setup_nextcloud-db-data'],
'pvcs': ['nextcloud-app-data', 'nextcloud-postgres-data'],
'roles': {
'nextcloud': 'App',
'nextcloud-postgres': 'Database',
},
},
{
'id': 'mautic',
@@ -99,20 +110,17 @@ SITES = [
'category': 'Marketing',
'icon': 'mautic',
'brand_color': '#4E5E9E',
'compose_dir': 'mautic-setup',
'main_container': 'mautic-app',
'containers': [
{'name': 'mautic-app', 'role': 'App', 'image': 'mautic/mautic:latest'},
{'name': 'mautic-mariadb', 'role': 'Database', 'image': 'mariadb:10.11'},
],
'port': 8081,
'internal_port': 80,
'domain': None,
'domain_protocol': 'http',
'ssl_configured': False,
'namespace': 'mautic',
'main_deployment': 'mautic',
'service_port': 80,
'ingress_name': 'mautic-ingress',
'ingress_namespace': 'mautic',
'health_path': '/',
'volumes': ['mautic-setup_mautic-data', 'mautic-setup_mautic-db-data'],
'networks': ['mautic-network'],
'pvcs': ['mautic-app-data', 'mautic-db-data'],
'roles': {
'mautic': 'App',
'mautic-mariadb': 'Database',
},
},
{
'id': 'n8n',
@@ -121,210 +129,176 @@ SITES = [
'category': 'Automation',
'icon': 'n8n',
'brand_color': '#EA4B71',
'compose_dir': 'n8n-setup',
'main_container': 'n8n-app',
'containers': [
{'name': 'n8n-app', 'role': 'App', 'image': 'n8nio/n8n:latest'},
{'name': 'n8n-postgres', 'role': 'Database', 'image': 'postgres:15'},
],
'port': 5678,
'internal_port': 5678,
'domain': None,
'domain_protocol': 'http',
'ssl_configured': False,
'namespace': 'n8n',
'main_deployment': 'n8n',
'service_port': 5678,
'ingress_name': 'n8n-ingress',
'ingress_namespace': 'n8n',
'health_path': '/healthz',
'volumes': ['n8n-setup_n8n-data', 'n8n-setup_n8n-db-data'],
'networks': ['n8n-network', 'integration-network'],
'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):
ip = MAIN_SERVER_IP
port = site['port']
proto = site.get('domain_protocol', 'http')
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 '/'
ssl_configured = site.get('ssl_configured', proto == 'https')
domain = site.get('domain')
has_domain = bool(domain)
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:
if ssl_configured:
access_url = f"{proto}://{domain}"
health_url = f"{access_url}{path}"
else:
access_url = f"http://{domain}:{port}"
health_url = f"{access_url}{path}"
else:
access_url = f"http://{ip}:{port}"
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': domain,
'domain_protocol': proto,
'domain': host,
'domain_protocol': 'https' if ssl_configured else 'http',
'ssl_configured': ssl_configured,
'ip_url': f"http://{ip}:{port}",
'access_url': access_url.rstrip('/'),
'access_url': (access_url or '').rstrip('/'),
'health_url': health_url,
}
def _parse_port_mapping(ports_str, host_port, internal_port=None):
"""Extract host:container mapping from docker ports string."""
if not ports_str:
if internal_port and internal_port != host_port:
return f"{host_port}{internal_port}"
return str(host_port) if host_port else None
m = re.search(rf'0\.0\.0\.0:{host_port}->(\d+)/', ports_str)
if m:
return f"{host_port}{m.group(1)}"
m = re.search(r'0\.0\.0\.0:(\d+)->(\d+)/', ports_str)
if m:
return f"{m.group(1)}{m.group(2)}"
return ports_str[:60] if ports_str else None
def _probe_http(url, timeout=5):
"""HTTP health probe — local urllib or remote curl via SSH."""
if RUNNING_ON_MAIN_SERVER:
try:
req = Request(url, method='GET', headers={'User-Agent': 'CloudOps-HealthCheck/1.0'})
with urlopen(req, timeout=timeout) as resp:
code = resp.getcode()
return {
'reachable': 200 <= code < 400,
'http_code': code,
'latency_ms': None,
'error': None,
}
except URLError as e:
return {'reachable': False, 'http_code': None, 'latency_ms': None, 'error': str(e.reason)[:120]}
except Exception as e:
return {'reachable': False, 'http_code': None, 'latency_ms': None, 'error': str(e)[:120]}
else:
safe_url = url.replace("'", "'\\''")
"""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()
out, _ = _ssh_main(
f"curl -sf -o /dev/null -w '%{{http_code}}' --connect-timeout {timeout} "
f"-m {timeout} '{safe_url}' 2>/dev/null || echo '000'",
timeout=timeout + 5,
)
latency = int((time.time() - t0) * 1000)
code_str = (out or '000').strip()
try:
code = int(code_str)
except ValueError:
code = 0
return {
'reachable': 200 <= code < 400,
'http_code': code if code else None,
'latency_ms': latency,
'error': None if 200 <= code < 400 else f'HTTP {code_str}',
}
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 _container_map():
ctrs = get_all_root_containers()
return {c['name']: c for c in ctrs}
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 container + URL metadata."""
ctr_map = _container_map()
results = []
for site in SITES:
urls = _build_urls(site)
main_name = site['main_container']
main_ctr = ctr_map.get(main_name, {})
main_status = get_container_status(main_name) if main_name else {'status': 'unknown'}
containers_live = []
running_count = 0
for ctr_def in site.get('containers', []):
name = ctr_def['name']
live = ctr_map.get(name, {})
status_raw = live.get('status', '')
is_up = 'Up' in status_raw
if is_up:
running_count += 1
containers_live.append({
**ctr_def,
'status': 'running' if is_up else ('stopped' if live else 'not_found'),
'status_raw': status_raw or 'not found',
'image_live': live.get('image', ctr_def.get('image', '')),
'ports': live.get('ports', ''),
})
entry = {
'id': site['id'],
'name': site['name'],
'tagline': site['tagline'],
'category': site['category'],
'icon': site['icon'],
'brand_color': site['brand_color'],
'compose_dir': site['compose_dir'],
'main_container': main_name,
'port': site['port'],
'internal_port': site.get('internal_port', site['port']),
'port_mapping': _parse_port_mapping(
main_ctr.get('ports', ''), site['port'], site.get('internal_port')
),
'ports_raw': main_ctr.get('ports', ''),
'volumes': site.get('volumes', []),
'networks': site.get('networks', ['default']),
'container_status': main_status['status'],
'container_running': main_status['status'] == 'running',
'containers': containers_live,
'containers_running': running_count,
'containers_total': len(site.get('containers', [])),
**urls,
}
if include_health:
if entry['container_running']:
probe = _probe_http(urls['health_url'])
entry['health'] = {
'status': 'up' if probe['reachable'] else 'down',
**probe,
}
else:
entry['health'] = {
'status': 'offline',
'reachable': False,
'http_code': None,
'latency_ms': None,
'error': 'Container not running',
}
results.append(entry)
return results
"""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
urls = _build_urls(site)
status = get_container_status(site['main_container'])
if status['status'] != 'running':
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': 'Container not running',
'site_id': site_id, 'status': 'offline', 'reachable': False,
'http_code': None, 'latency_ms': None, 'error': 'Deployment not running',
'checked_at': time.time(),
}
t0 = 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['latency_ms'] = probe.get('latency_ms') or int((time.time() - t0) * 1000)
probe['site_id'] = site_id
probe['status'] = 'up' if probe['reachable'] else 'down'
probe['checked_at'] = time.time()