replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 14s
Build and Push Docker Image / build (push) Failing after 14s
This commit is contained in:
+27
-2
@@ -57,7 +57,9 @@ API_PREFIX = f'/s/{API_KEY}' if API_KEY else ''
|
||||
# upstream by hash-lock/OIDC instead of the path-token.
|
||||
DASHBOARD_PATHS = frozenset({
|
||||
'/', '/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',
|
||||
})
|
||||
|
||||
@@ -254,6 +256,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == '/' or path == '/index.html':
|
||||
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
|
||||
if path == '/configure':
|
||||
return self.serve_configure_page()
|
||||
@@ -289,7 +298,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
})
|
||||
|
||||
# Discovered services (for inter-service navigation)
|
||||
if path == '/api/services':
|
||||
if path == '/api/services' or path == '/api/neighbors':
|
||||
return self.handle_services_api()
|
||||
|
||||
# === File API ===
|
||||
@@ -370,6 +379,22 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
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):
|
||||
"""Serve the setup/dashboard page."""
|
||||
html_path = os.path.join(os.path.dirname(__file__), 'www', 'index.html')
|
||||
|
||||
@@ -13,15 +13,6 @@ from .provider import StorageProvider, VideoMetadata
|
||||
from .redis_storage import RedisStorage
|
||||
from .leader_storage import LeaderStorage
|
||||
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__ = [
|
||||
'StorageProvider',
|
||||
@@ -31,9 +22,4 @@ __all__ = [
|
||||
'LeaderClient',
|
||||
'LeaderLockInfo',
|
||||
'get_leader_client',
|
||||
'ServiceRegistration',
|
||||
'ServiceDiscovery', # Backward compatibility alias
|
||||
'ServiceInfo',
|
||||
'get_service_discovery',
|
||||
'init_service_discovery',
|
||||
]
|
||||
|
||||
@@ -289,6 +289,9 @@ def get_leader_client() -> Optional[LeaderClient]:
|
||||
_leader_client = LeaderClient(
|
||||
meta_core_path=os.environ.get("META_CORE_PATH"),
|
||||
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
|
||||
|
||||
@@ -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
@@ -292,6 +292,14 @@
|
||||
footer a:hover { text-decoration: underline; }
|
||||
|
||||
/* 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 {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
@@ -362,16 +370,13 @@
|
||||
.services-nav { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="meta-service-menu.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Services Navigation -->
|
||||
<nav class="services-nav" id="servicesNav" style="display: none;">
|
||||
<span class="services-nav-label">Services:</span>
|
||||
<div class="services-nav-items" id="servicesNavItems">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
<!-- Neighbouring meta-* services (meta-discovery v1). The element
|
||||
fetches /api/neighbors from this origin and renders a burger menu. -->
|
||||
<meta-service-menu id="servicesNav" current="meta-stremio"></meta-service-menu>
|
||||
|
||||
<header class="header">
|
||||
<div>
|
||||
@@ -548,78 +553,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Service icons mapping
|
||||
const serviceIcons = {
|
||||
'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(' ');
|
||||
}
|
||||
// Service nav is <meta-service-menu> now — no local icon map,
|
||||
// name formatter or poller lives here any more.
|
||||
|
||||
// Initial fetch
|
||||
fetchStats();
|
||||
fetchMetrics();
|
||||
fetchServices();
|
||||
|
||||
// Refresh metrics every 5 seconds
|
||||
setInterval(fetchMetrics, 5000);
|
||||
|
||||
// Refresh stats every 30 seconds
|
||||
setInterval(fetchStats, 30000);
|
||||
|
||||
// Refresh services every 60 seconds
|
||||
setInterval(fetchServices, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user