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
-14
View File
@@ -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',
]
+4 -1
View File
@@ -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
-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