chnage service discovery

This commit is contained in:
Worph
2026-09-13 19:48:33 +02:00
parent ba6e152b4f
commit 362be36552
6 changed files with 973 additions and 263 deletions
+183 -227
View File
@@ -1,40 +1,41 @@
"""LeaderClient — locates meta-core and exposes its URLs.
Since meta-discovery v1 this is a thin adapter over ``MetaCoreLocator``
(``meshdisco.py``): meta-core is found by UDP announce instead of by reading
``/meta-core/locks/kv-leader.info``, so meta-stremio no longer needs the
``/meta-core`` volume mounted at all.
The public surface is deliberately unchanged so ``LeaderStorage`` did not have
to move: ``get_leader_info``, ``get_urls``, ``get_api_url``,
``get_webdav_url*``, ``wait_for_leader``, ``start_watching`` / ``on_change``
(the watchdog on the lock directory became "an announce arrived carrying a
different apiUrl", feeding the same callback).
Precedence is the pin: when ``meta_core_url`` (``META_CORE_URL``) is set it
always wins and the wire is never consulted for core selection. See
docs/project-architecture/service-discovery.md.
The name "leader" is legacy: meta-core's flock election is vestigial, and
``redis_url`` has been empty since the api-mediated-access lockdown — every
read goes over HTTP.
"""
LeaderClient - Client for reading leader info from meta-core
This replaces the old LeaderDiscovery.py by delegating leader election
to meta-core (Go sidecar) and just reading the results.
Features:
- Reads kv-leader.info file for leader URLs
- Calls meta-core /urls API for current leader URLs
- Watches for leader changes via file system watcher
"""
from __future__ import annotations
import os
import json
import threading
import os
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Callable
from urllib.request import urlopen, Request
from urllib.error import URLError
from typing import Callable, List, Optional
# Try to import watchdog for file watching (optional)
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
WATCHDOG_AVAILABLE = True
except ImportError:
WATCHDOG_AVAILABLE = False
Observer = None
FileSystemEventHandler = object
from .meshdisco import MetaCoreLocator
@dataclass
class LeaderLockInfo:
"""Leader lock file content format (kv-leader.info).
Written by meta-core leader, read by other services.
"""meta-core's identity and URLs.
Historically the parsed kv-leader.info + /urls pair; now assembled from the
announce (or from /urls when meta-core is pinned).
"""
hostname: str
base_url: str
@@ -53,140 +54,133 @@ class URLsResponse:
base_url: str
api_url: str
redis_url: str
webdav_url: str # External WebDAV URL (via nginx/HTTPS)
webdav_url_internal: str # Internal WebDAV URL (direct to port 9000)
webdav_url: str
webdav_url_internal: str
is_leader: bool
class LockFileHandler(FileSystemEventHandler):
"""File system event handler for lock file changes."""
def __init__(self, callback: Callable[[], None], lock_filename: str):
self.callback = callback
self.lock_filename = lock_filename
def on_modified(self, event):
if event.src_path.endswith(self.lock_filename):
self.callback()
def on_created(self, event):
if event.src_path.endswith(self.lock_filename):
self.callback()
def on_deleted(self, event):
if event.src_path.endswith(self.lock_filename):
self.callback()
class LeaderClient:
"""
LeaderClient for meta-stremio.
Reads leader info from kv-leader.info file written by meta-core.
"""
"""Locates meta-core over UDP and resolves its URL set."""
def __init__(
self,
meta_core_path: str,
meta_core_url: Optional[str] = None
):
self.meta_core_path = meta_core_path
self.meta_core_url = meta_core_url
meta_core_path: Optional[str] = None,
meta_core_url: Optional[str] = None,
service_name: str = "meta-stremio",
base_url: Optional[str] = None,
version: str = "",
) -> None:
# meta_core_path is accepted and ignored — kept so existing callers
# construct unchanged. Nothing reads the volume any more.
self._meta_core_path = meta_core_path
self._meta_core_url = (meta_core_url or "").strip() or None
self._leader_info: Optional[LeaderLockInfo] = None
self._on_change_callbacks: List[Callable[[], None]] = []
self._started = False
self.info_file_path = os.path.join(meta_core_path, 'locks', 'kv-leader.info')
self.leader_info: Optional[LeaderLockInfo] = None
self._file_observer = None
self._on_change_callbacks: list[Callable[[], None]] = []
self._stop_event = threading.Event()
# URL caching
# Only used on the pinned path, where the URLs still come from an HTTP
# GET /urls. A discovered core carries them in the announce itself.
self._cached_urls: Optional[URLsResponse] = None
self._urls_cache_time: float = 0
self._urls_cache_ttl: float = 5.0 # 5 seconds
self._urls_cache_time: float = 0.0
self._urls_cache_ttl: float = 5.0
def _get_api_url_from_file(self) -> Optional[str]:
"""Read API URL from file (plain text format)."""
try:
with open(self.info_file_path, 'r') as f:
content = f.read()
return content.strip() or None
except FileNotFoundError:
print(f"[LeaderClient] Leader info file not found: {self.info_file_path}")
return None
except Exception as e:
print(f"[LeaderClient] Error reading API URL from file: {e}")
return None
self._locator = MetaCoreLocator(
service_name=service_name,
base_url=base_url,
version=version,
meta_core_url=self._meta_core_url,
)
self._locator.on_change(self._on_locator_change)
# -- lifecycle ---------------------------------------------------------
def _ensure_started(self) -> None:
if self._started:
return
self._started = True
self._locator.start()
def _on_locator_change(self) -> None:
self._cached_urls = None
self._urls_cache_time = 0.0
self._notify_change()
# -- url resolution ----------------------------------------------------
def _assert_no_redis_url(self, redis_url: Optional[str]) -> None:
"""Guard against a rollback reintroducing direct Redis exposure.
api-mediated-access PR D removed redis_url; ALLOW_LEGACY_REDIS_URL=1
downgrades this to a warning during a deliberate temporary rollback.
"""
if not redis_url:
return
msg = (
"meta-core still publishes redisUrl; direct Redis access was retired "
"by the api-mediated-access lockdown. Verify meta-core version."
)
if os.environ.get("ALLOW_LEGACY_REDIS_URL") == "1":
print(f"[LeaderClient] WARNING: {msg}")
else:
raise RuntimeError(msg)
def _fetch_urls(self, api_url: str) -> Optional[URLsResponse]:
"""Fetch URLs from meta-core /urls API with caching."""
import time
# Check cache
now = time.time()
if self._cached_urls and (now - self._urls_cache_time) < self._urls_cache_ttl:
return self._cached_urls
try:
url = f"{api_url}/urls"
req = Request(url, headers={'Accept': 'application/json'})
with urlopen(req, timeout=5) as response:
if response.status != 200:
print(f"[LeaderClient] Failed to fetch URLs: {response.status}")
return None
data = json.loads(response.read().decode())
# api-mediated-access PR D: redisUrl is no longer published by
# meta-core. If it shows up, an old build snuck back in —
# fail loudly so the rollback can't go unnoticed. Set
# ALLOW_LEGACY_REDIS_URL=1 to downgrade to a warning during
# a deliberate temporary rollback.
if data.get('redisUrl'):
msg = (
"[LeaderClient] meta-core still publishes redisUrl; "
"direct Redis access was retired by the api-mediated-access "
"lockdown. Verify meta-core version."
)
if os.environ.get("ALLOW_LEGACY_REDIS_URL") == "1":
print("WARNING: " + msg)
else:
raise RuntimeError(msg)
self._cached_urls = URLsResponse(
hostname=data.get('hostname', ''),
base_url=data.get('baseUrl', ''),
api_url=data.get('apiUrl', ''),
redis_url=data.get('redisUrl', ''),
webdav_url=data.get('webdavUrl', ''),
webdav_url_internal=data.get('webdavUrlInternal', ''),
is_leader=data.get('isLeader', False)
)
self._urls_cache_time = now
return self._cached_urls
except URLError as e:
req = urllib.request.Request(
f"{api_url.rstrip('/')}/urls", headers={"Accept": "application/json"}
)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
self._assert_no_redis_url(data.get("redisUrl"))
self._cached_urls = URLsResponse(
hostname=data.get("hostname", ""),
base_url=data.get("baseUrl", ""),
api_url=data.get("apiUrl", ""),
redis_url=data.get("redisUrl", "") or "",
webdav_url=data.get("webdavUrl", ""),
webdav_url_internal=data.get("webdavUrlInternal", ""),
is_leader=bool(data.get("isLeader", True)),
)
self._urls_cache_time = now
return self._cached_urls
except Exception as e:
print(f"[LeaderClient] Error calling /urls API: {e}")
return None
except Exception as e:
print(f"[LeaderClient] Error fetching URLs: {e}")
def get_urls(self) -> Optional[URLsResponse]:
"""Resolve meta-core's URLs.
Straight from the announce when discovered (no HTTP hop needed); over
HTTP when meta-core is pinned.
"""
self._ensure_started()
wire = self._locator.get_urls()
if wire and not self._meta_core_url:
return URLsResponse(
hostname=wire.get("hostname", ""),
base_url=wire.get("baseUrl", ""),
api_url=wire.get("apiUrl", ""),
redis_url="",
webdav_url=wire.get("webdavUrl", ""),
webdav_url_internal=wire.get("webdavUrlInternal", ""),
is_leader=True,
)
api_url = self._locator.get_api_url()
if not api_url:
return None
return self._fetch_urls(api_url)
def get_leader_info(self) -> Optional[LeaderLockInfo]:
"""Read leader info from file and /urls API."""
try:
# Read API URL from file (plain text)
api_url = self._get_api_url_from_file()
if not api_url:
return None
# Fetch full info from /urls API
urls = self._fetch_urls(api_url)
urls = self.get_urls()
if not urls:
return None
# Convert URLsResponse to LeaderLockInfo
import time
self.leader_info = LeaderLockInfo(
self._leader_info = LeaderLockInfo(
hostname=urls.hostname,
base_url=urls.base_url,
api_url=urls.api_url,
@@ -194,145 +188,107 @@ class LeaderClient:
webdav_url=urls.webdav_url,
webdav_url_internal=urls.webdav_url_internal,
timestamp=int(time.time() * 1000),
pid=0 # Unknown for remote leader
pid=0,
)
return self.leader_info
return self._leader_info
except Exception as e:
print(f"[LeaderClient] Error reading leader info: {e}")
print(f"[LeaderClient] Failed to read leader info: {e}")
return None
def get_redis_url(self) -> Optional[str]:
"""Get Redis URL from leader info."""
info = self.get_leader_info()
return info.redis_url if info else None
def get_webdav_url(self) -> Optional[str]:
"""Get WebDAV URL from leader info (external, via nginx/HTTPS)."""
info = self.get_leader_info()
return info.webdav_url if info else None
def get_webdav_url_internal(self) -> Optional[str]:
"""Get internal WebDAV URL from leader info (direct to port 9000).
Use this for container-to-container communication.
"""
"""Internal WebDAV URL — use for container-to-container access."""
info = self.get_leader_info()
return info.webdav_url_internal if info else None
def get_api_url(self) -> Optional[str]:
"""Get meta-core API URL from leader info."""
info = self.get_leader_info()
return info.api_url if info else None
def get_urls(self) -> Optional[URLsResponse]:
"""Call meta-core /urls API to get current URLs."""
# First try using configured meta_core_url
api_url = self.meta_core_url
# Fall back to reading from file
if not api_url:
api_url = self._get_api_url_from_file()
if not api_url:
print("[LeaderClient] No meta-core API URL available")
return None
return self._fetch_urls(api_url)
def wait_for_leader(self, timeout_ms: int = 30000) -> LeaderLockInfo:
"""Wait for leader info to be available."""
import time
start_time = time.time()
poll_interval = 0.5 # 500ms
"""Block until meta-core is reachable.
while (time.time() - start_time) * 1000 < timeout_ms:
Probes immediately then every 500ms, matching the previous
file-polling loop's timing.
"""
self._ensure_started()
timeout_s = None if timeout_ms <= 0 else timeout_ms / 1000.0
deadline = None if timeout_s is None else time.time() + timeout_s
if self._locator.wait_for_core(timeout_s) is None:
raise TimeoutError(f"No meta-core found within {timeout_ms}ms")
while deadline is None or time.time() < deadline:
info = self.get_leader_info()
if info:
print(f"[LeaderClient] Leader found: {info.hostname} at {info.redis_url}")
print(f"[LeaderClient] meta-core found: {info.hostname} at {info.api_url}")
return info
time.sleep(0.5)
raise TimeoutError(f"No meta-core found within {timeout_ms}ms")
print("[LeaderClient] Waiting for leader...")
time.sleep(poll_interval)
raise TimeoutError(f"No leader found within {timeout_ms}ms")
# -- change notification ----------------------------------------------
def start_watching(self) -> None:
"""Start watching for leader changes."""
if not WATCHDOG_AVAILABLE:
print("[LeaderClient] watchdog not available, file watching disabled")
return
"""Previously a watchdog on the lock directory.
try:
lock_dir = os.path.dirname(self.info_file_path)
handler = LockFileHandler(
callback=self._on_file_change,
lock_filename='kv-leader.info'
)
self._file_observer = Observer()
self._file_observer.schedule(handler, lock_dir, recursive=False)
self._file_observer.start()
print(f"[LeaderClient] Watching for leader changes in {lock_dir}")
except Exception as e:
print(f"[LeaderClient] Could not start file watcher: {e}")
Discovery is always listening, so this only has to make sure the node
is running.
"""
self._ensure_started()
def stop_watching(self) -> None:
"""Stop watching for leader changes."""
if self._file_observer:
self._file_observer.stop()
self._file_observer.join(timeout=2.0)
self._file_observer = None
print("[LeaderClient] Stopped watching for leader changes")
"""No-op: the locator keeps listening until close()."""
def _on_file_change(self) -> None:
"""Handle file change event."""
print("[LeaderClient] Leader info changed, invalidating cache...")
# Invalidate cache to force fresh API call
self._cached_urls = None
self._urls_cache_time = 0
self.get_leader_info()
self._notify_change()
def on_change(self, callback: Callable[[], None]) -> 'LeaderClient':
"""Register callback for leader changes."""
def on_change(self, callback: Callable[[], None]) -> "LeaderClient":
self._on_change_callbacks.append(callback)
return self
def _notify_change(self) -> None:
"""Notify all change callbacks."""
for callback in self._on_change_callbacks:
print("[LeaderClient] meta-core changed, invalidating cache...")
for cb in list(self._on_change_callbacks):
try:
callback()
cb()
except Exception as e:
print(f"[LeaderClient] Error in change callback: {e}")
def get_cached_leader_info(self) -> Optional[LeaderLockInfo]:
"""Get cached leader info (without re-reading file)."""
return self.leader_info
return self._leader_info
# -- nav menu ----------------------------------------------------------
def neighbors(self) -> List[dict]:
"""Neighbours for the nav menu (one row per service name)."""
self._ensure_started()
return self._locator.neighbors()
def self_announce(self) -> dict:
"""This service's own announce, so the menu can show itself."""
self._ensure_started()
return self._locator.self_announce()
def close(self) -> None:
"""Clean up resources."""
self._stop_event.set()
self.stop_watching()
self._on_change_callbacks = []
self._locator.stop()
self._started = False
# Singleton instance
_leader_client: Optional[LeaderClient] = None
def get_leader_client() -> Optional[LeaderClient]:
"""Get or create the LeaderClient singleton.
Returns None if META_CORE_PATH is not set.
"""
"""Process-wide singleton, so one UDP socket serves every caller."""
global _leader_client
if _leader_client is None:
meta_core_path = os.environ.get('META_CORE_PATH', '/meta-core')
meta_core_url = os.environ.get('META_CORE_URL')
_leader_client = LeaderClient(meta_core_path, meta_core_url)
_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,
)
return _leader_client