replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 14s

This commit is contained in:
Worph
2026-09-14 11:24:20 +02:00
parent 362be36552
commit a60c1ff60e
5 changed files with 45 additions and 383 deletions
+27 -2
View File
@@ -57,7 +57,9 @@ API_PREFIX = f'/s/{API_KEY}' if API_KEY else ''
# upstream by hash-lock/OIDC instead of the path-token. # upstream by hash-lock/OIDC instead of the path-token.
DASHBOARD_PATHS = frozenset({ DASHBOARD_PATHS = frozenset({
'/', '/index.html', '/configure', '/health', '/', '/index.html', '/configure', '/health',
'/api/stats', '/api/library', '/api/services', '/api/languages', '/api/stats', '/api/library', '/api/services', '/api/neighbors', '/api/languages',
# The dashboard's shared nav element, loaded as a module from index.html.
'/meta-service-menu.js',
'/transcode/metrics', '/transcode/metrics',
}) })
@@ -254,6 +256,13 @@ class Handler(BaseHTTPRequestHandler):
if path == '/' or path == '/index.html': if path == '/' or path == '/index.html':
return self.serve_setup_page() return self.serve_setup_page()
# The one static asset under www/ that the dashboard loads as a module:
# the shared <meta-service-menu> element. There is no generic static
# route here (index.html is string-injected on every request), so this
# is an explicit allowlist of one rather than an open file server.
if path == '/meta-service-menu.js':
return self.serve_www_asset('meta-service-menu.js', 'text/javascript')
# Configure page # Configure page
if path == '/configure': if path == '/configure':
return self.serve_configure_page() return self.serve_configure_page()
@@ -289,7 +298,7 @@ class Handler(BaseHTTPRequestHandler):
}) })
# Discovered services (for inter-service navigation) # Discovered services (for inter-service navigation)
if path == '/api/services': if path == '/api/services' or path == '/api/neighbors':
return self.handle_services_api() return self.handle_services_api()
# === File API === # === File API ===
@@ -370,6 +379,22 @@ class Handler(BaseHTTPRequestHandler):
self.send_error(404) self.send_error(404)
def serve_www_asset(self, name, content_type):
"""Serve one named file from www/ verbatim (no template injection)."""
for base in (os.path.dirname(__file__), os.path.dirname(os.path.dirname(__file__))):
candidate = os.path.join(base, 'www', name)
if os.path.exists(candidate):
with open(candidate, 'rb') as f:
body = f.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', len(body))
self.end_headers()
self.wfile.write(body)
return
self.send_response(404)
self.end_headers()
def serve_setup_page(self): def serve_setup_page(self):
"""Serve the setup/dashboard page.""" """Serve the setup/dashboard page."""
html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html') html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html')
-14
View File
@@ -13,15 +13,6 @@ from .provider import StorageProvider, VideoMetadata
from .redis_storage import RedisStorage from .redis_storage import RedisStorage
from .leader_storage import LeaderStorage from .leader_storage import LeaderStorage
from .leader_client import LeaderClient, LeaderLockInfo, get_leader_client from .leader_client import LeaderClient, LeaderLockInfo, get_leader_client
from .service_registration import (
ServiceRegistration,
ServiceInfo,
get_service_discovery, # Kept for backward compatibility
init_service_discovery # Kept for backward compatibility
)
# Backward compatibility alias
ServiceDiscovery = ServiceRegistration
__all__ = [ __all__ = [
'StorageProvider', 'StorageProvider',
@@ -31,9 +22,4 @@ __all__ = [
'LeaderClient', 'LeaderClient',
'LeaderLockInfo', 'LeaderLockInfo',
'get_leader_client', 'get_leader_client',
'ServiceRegistration',
'ServiceDiscovery', # Backward compatibility alias
'ServiceInfo',
'get_service_discovery',
'init_service_discovery',
] ]
+4 -1
View File
@@ -289,6 +289,9 @@ def get_leader_client() -> Optional[LeaderClient]:
_leader_client = LeaderClient( _leader_client = LeaderClient(
meta_core_path=os.environ.get("META_CORE_PATH"), meta_core_path=os.environ.get("META_CORE_PATH"),
meta_core_url=os.environ.get("META_CORE_URL"), meta_core_url=os.environ.get("META_CORE_URL"),
base_url=os.environ.get("BASE_URL") or None, # PUBLIC_URL (a browser-reachable debug-direct port) wins over
# BASE_URL (the Caddy URL), same ladder as meta-core. Announcing a
# Caddy URL on a stack with no Caddy puts a dead link in every menu.
base_url=os.environ.get("PUBLIC_URL") or os.environ.get("BASE_URL") or None,
) )
return _leader_client return _leader_client
-297
View File
@@ -1,297 +0,0 @@
"""
Service Registration for meta-stremio.
Python implementation of the service registration pattern used in meta-sort/meta-fuse.
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json.
Service discovery is centralized in meta-core - this module only handles registration.
For service discovery, use the /api/services endpoint (fetches from meta-core).
Features:
- Service registration with heartbeat
- Automatic heartbeat loop
"""
from __future__ import annotations
import os
import json
import socket
import threading
import time
from dataclasses import dataclass, asdict
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Dict, Callable
from urllib.request import urlopen
from urllib.error import URLError
# Configuration
META_CORE_PATH = os.environ.get('META_CORE_PATH', '/meta-core')
SERVICE_NAME = os.environ.get('SERVICE_NAME', 'meta-stremio')
SERVICE_VERSION = os.environ.get('SERVICE_VERSION', '1.0.0')
BASE_URL = os.environ.get('BASE_URL', '')
@dataclass
class ServiceInfo:
"""
Service registration info stored in JSON file.
Simplified format matching TypeScript ServiceInfo interface:
- name: Service name (e.g., 'meta-stremio')
- hostname: Container/host hostname
- baseUrl: Base URL for the service (e.g., 'http://localhost:8182')
- status: Current status ('running' | 'stale' | 'stopped')
- lastHeartbeat: ISO timestamp of last heartbeat
- role: Optional role ('leader' | 'follower') - only used by meta-core
"""
name: str
hostname: str
baseUrl: str
status: str = 'running' # running | stale | stopped
lastHeartbeat: str = ''
role: Optional[str] = None # "leader", "follower", or None (for non-meta-core services)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization.
Excludes None values to avoid polluting JSON with null fields.
"""
result = asdict(self)
# Remove None values (e.g., role should only appear for meta-core)
return {k: v for k, v in result.items() if v is not None}
class ServiceRegistration:
"""
Service Discovery using shared filesystem.
Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
and can discover other registered services.
"""
def __init__(
self,
meta_core_path: str = None,
service_name: str = None,
version: str = None,
api_url: str = None,
base_url: str = None,
capabilities: List[str] = None,
endpoints: Dict[str, str] = None,
heartbeat_interval: float = 30.0,
stale_threshold: float = 60.0
):
self.meta_core_path = meta_core_path or META_CORE_PATH
self.service_name = service_name or SERVICE_NAME
# Use base_url if provided, then api_url, then env BASE_URL, then auto-detect
self.base_url = base_url or api_url or BASE_URL or self._get_default_api_url()
self.heartbeat_interval = heartbeat_interval
self.stale_threshold = stale_threshold
# Keep for backwards compatibility but not used in simplified format
self._version = version or SERVICE_VERSION
self._capabilities = capabilities or []
self._endpoints = endpoints or {}
self.current_hostname = socket.gethostname()
self.services_dir = os.path.join(self.meta_core_path, 'services')
# Use hostname-based file naming like TypeScript services
self.service_file_path = os.path.join(self.services_dir, f'{self.service_name}-{self.current_hostname}.json')
self._heartbeat_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._is_started = False
def _get_default_api_url(self) -> str:
"""Get default API URL using local IP and PORT."""
port = os.environ.get('PORT', '7000')
ip = self._get_local_ip()
return f'http://{ip}:{port}'
def _get_local_ip(self) -> str:
"""Get the local machine's IP address."""
try:
# Create a socket to determine the outbound IP
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return socket.gethostname() or 'localhost'
def _ensure_services_dir(self) -> None:
"""Ensure services directory exists."""
Path(self.services_dir).mkdir(parents=True, exist_ok=True)
def _build_service_info(self, status: str = 'running') -> ServiceInfo:
"""Build service info for this service (simplified format)."""
return ServiceInfo(
name=self.service_name,
hostname=self.current_hostname,
baseUrl=self.base_url,
status=status,
lastHeartbeat=datetime.utcnow().isoformat() + 'Z'
)
def register(self) -> None:
"""Register this service."""
self._ensure_services_dir()
info = self._build_service_info('starting')
with open(self.service_file_path, 'w') as f:
json.dump(info.to_dict(), f, indent=2)
print(f'[ServiceRegistration] Registered {self.service_name}')
def update_status(self, status: str) -> None:
"""Update service status."""
try:
with open(self.service_file_path, 'r') as f:
info = json.load(f)
info['status'] = status
info['lastHeartbeat'] = datetime.utcnow().isoformat() + 'Z'
with open(self.service_file_path, 'w') as f:
json.dump(info, f, indent=2)
except Exception as e:
print(f'[ServiceRegistration] Failed to update status: {e}')
def heartbeat(self) -> None:
"""Send heartbeat (update lastHeartbeat timestamp)."""
try:
with open(self.service_file_path, 'r') as f:
info = json.load(f)
info['lastHeartbeat'] = datetime.utcnow().isoformat() + 'Z'
with open(self.service_file_path, 'w') as f:
json.dump(info, f, indent=2)
except FileNotFoundError:
# If file was deleted, re-register
self.register()
self.update_status('running')
except Exception as e:
print(f'[ServiceRegistration] Heartbeat failed: {e}')
def _heartbeat_loop(self) -> None:
"""Background heartbeat loop."""
while not self._stop_event.is_set():
try:
self.heartbeat()
except Exception as e:
print(f'[ServiceRegistration] Heartbeat error: {e}')
self._stop_event.wait(self.heartbeat_interval)
def start_heartbeat(self) -> None:
"""Start heartbeat loop in background thread."""
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
return
self._stop_event.clear()
self._heartbeat_thread = threading.Thread(
target=self._heartbeat_loop,
daemon=True,
name='service-heartbeat'
)
self._heartbeat_thread.start()
def stop_heartbeat(self) -> None:
"""Stop heartbeat loop."""
self._stop_event.set()
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
self._heartbeat_thread.join(timeout=2.0)
def unregister(self) -> None:
"""Unregister this service (on shutdown)."""
self.stop_heartbeat()
try:
self.update_status('stopped')
print(f'[ServiceRegistration] Unregistered {self.service_name}')
except Exception as e:
print(f'[ServiceRegistration] Failed to unregister: {e}')
# ========================================================================
# Lifecycle
# ========================================================================
def start(self) -> None:
"""Full startup sequence."""
if self._is_started:
return
# Try to register, but don't fail if filesystem is read-only
# This allows discovery-only mode when we can't write
can_register = os.access(self.services_dir, os.W_OK) if os.path.exists(self.services_dir) else False
if can_register:
try:
self.register()
self.update_status('running')
self.start_heartbeat()
print(f'[ServiceRegistration] Started {self.service_name}-{self.current_hostname} at {self.base_url}')
except Exception as e:
print(f'[ServiceRegistration] Registration failed (read-only?): {e}')
print(f'[ServiceRegistration] Running in discovery-only mode')
else:
print(f'[ServiceRegistration] Services directory not writable, running in discovery-only mode')
self._is_started = True
def stop(self) -> None:
"""Full shutdown sequence."""
if not self._is_started:
return
self.unregister()
self._is_started = False
# ========================================================================
# Getters
# ========================================================================
def get_service_file_path(self) -> str:
return self.service_file_path
def get_services_dir(self) -> str:
return self.services_dir
def is_started(self) -> bool:
return self._is_started
# Singleton instance
_service_registration: Optional[ServiceRegistration] = None
def get_service_discovery() -> ServiceRegistration:
"""Get or create the service registration singleton.
Note: Function name kept as 'get_service_discovery' for backward compatibility.
"""
global _service_registration
if _service_registration is None:
_service_registration = ServiceRegistration()
return _service_registration
def init_service_discovery(
api_url: str = None,
base_url: str = None,
**kwargs
) -> ServiceRegistration:
"""Initialize and start the service registration singleton.
Note: Function name kept as 'init_service_discovery' for backward compatibility.
"""
global _service_registration
_service_registration = ServiceRegistration(api_url=api_url, base_url=base_url, **kwargs)
_service_registration.start()
return _service_registration
+14 -69
View File
@@ -292,6 +292,14 @@
footer a:hover { text-decoration: underline; } footer a:hover { text-decoration: underline; }
/* Services Navigation */ /* Services Navigation */
/* <meta-service-menu> is in shadow DOM and cannot read the vars
above; map this UI's palette onto the four tokens it does read. */
meta-service-menu {
--mm-nav-fg: var(--text-primary);
--mm-nav-bg: var(--bg-tertiary);
--mm-nav-border: var(--border-color);
--mm-nav-accent: var(--accent-primary);
}
.services-nav { .services-nav {
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: 12px; border-radius: 12px;
@@ -362,16 +370,13 @@
.services-nav { flex-direction: column; align-items: flex-start; } .services-nav { flex-direction: column; align-items: flex-start; }
} }
</style> </style>
<script type="module" src="meta-service-menu.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<!-- Services Navigation --> <!-- Neighbouring meta-* services (meta-discovery v1). The element
<nav class="services-nav" id="servicesNav" style="display: none;"> fetches /api/neighbors from this origin and renders a burger menu. -->
<span class="services-nav-label">Services:</span> <meta-service-menu id="servicesNav" current="meta-stremio"></meta-service-menu>
<div class="services-nav-items" id="servicesNavItems">
<!-- Populated by JavaScript -->
</div>
</nav>
<header class="header"> <header class="header">
<div> <div>
@@ -548,78 +553,18 @@
} }
} }
// Service icons mapping // Service nav is <meta-service-menu> now — no local icon map,
const serviceIcons = { // name formatter or poller lives here any more.
'meta-sort': '📁',
'meta-fuse': '🗂️',
'meta-stremio': '🎬',
'meta-core': '⚙️',
'meta-dup': '🔍',
'meta-orbit': '🌐',
'default': '📦'
};
// Fetch and display discovered services
async function fetchServices() {
try {
const res = await fetch('/api/services');
const data = await res.json();
const navEl = document.getElementById('servicesNav');
const itemsEl = document.getElementById('servicesNavItems');
if (data.services && data.services.length > 0) {
// Sort services by name
const services = data.services.sort((a, b) => a.name.localeCompare(b.name));
// Build navigation items
itemsEl.innerHTML = services.map(svc => {
const icon = serviceIcons[svc.name] || serviceIcons.default;
const isActive = svc.name === data.current;
const activeClass = isActive ? ' active' : '';
// Use external URL for other services, current path for self
const href = isActive ? '#' : svc.baseUrl;
return `
<a href="${href}" class="service-link${activeClass}" onclick="event.preventDefault(); ${isActive ? '' : `window.location.href='${svc.baseUrl}'`}">
<span class="service-icon">${icon}</span>
<span>${formatServiceName(svc.name)}</span>
<span class="service-status"></span>
</a>
`;
}).join('');
navEl.style.display = 'flex';
} else {
navEl.style.display = 'none';
}
} catch (e) {
console.error('Failed to fetch services:', e);
document.getElementById('servicesNav').style.display = 'none';
}
}
function formatServiceName(name) {
// Convert 'meta-stremio' to 'Meta Stremio'
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
// Initial fetch // Initial fetch
fetchStats(); fetchStats();
fetchMetrics(); fetchMetrics();
fetchServices();
// Refresh metrics every 5 seconds // Refresh metrics every 5 seconds
setInterval(fetchMetrics, 5000); setInterval(fetchMetrics, 5000);
// Refresh stats every 30 seconds // Refresh stats every 30 seconds
setInterval(fetchStats, 30000); setInterval(fetchStats, 30000);
// Refresh services every 60 seconds
setInterval(fetchServices, 60000);
</script> </script>
</body> </body>
</html> </html>