Files
2026-07-13 12:24:33 +08:00

1037 lines
36 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from importlib import resources
from urllib.parse import unquote
import argparse
import asyncio
import json
import os
import threading
import time
# Third Party
from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse, PlainTextResponse
import httpx
import uvicorn
_PACKAGE = "lmcache.lmcache_frontend"
def _package_resource_path(relative: str) -> str:
"""Return absolute filesystem path for a file shipped inside the package.
Replacement for the deprecated ``pkg_resources.resource_filename``;
works for regular (non-zipped) installs, which is how LMCache ships.
"""
return str(resources.files(_PACKAGE).joinpath(relative))
try:
# Local
from .heartbeat import HeartbeatService # import as module
except ImportError:
# Third Party
from heartbeat import HeartbeatService # type: ignore # import as script
# Create router
router = APIRouter()
class _NodeRegistry:
"""Encapsulates the mutable proxy/node list used by the frontend.
Replacing the list is done in-place via :py:meth:`replace` so that
aliases (the module-level ``target_nodes`` reference and the list
handed to ``HeartbeatService``) stay in sync.
"""
def __init__(self) -> None:
self._nodes: list[dict] = []
@property
def nodes(self) -> list[dict]:
"""Return the underlying list (mutated in place)."""
return self._nodes
def replace(self, new_nodes: list[dict]) -> None:
"""Swap the registry content in place with ``new_nodes``."""
self._nodes[:] = new_nodes
def is_allowed(self, host: str, port: str) -> bool:
"""Return True if ``host:port`` matches any registered node.
Used by the SSRF guard in :func:`proxy_request` so the proxy
only forwards to pre-registered destinations.
"""
return self.resolve(host, port) is not None
def resolve(self, host: str, port: str) -> tuple[str, str] | None:
"""Return the registry-owned ``(host, port)`` matching the input.
The returned tuple is taken from the registry itself, not from
the caller-supplied arguments. Using this value to build the
outbound URL breaks the SSRF taint flow for static analysers.
"""
port = str(port)
for proxy in self._nodes:
p_host, p_port = str(proxy.get("host")), str(proxy.get("port"))
if p_host == host and p_port == port:
return p_host, p_port
for child in proxy.get("nodes", []):
c_host, c_port = str(child.get("host")), str(child.get("port"))
if c_host == host and c_port == port:
return c_host, c_port
return None
_node_registry = _NodeRegistry()
# ``target_nodes`` is a module-level alias to the list owned by the
# registry. External readers and in-place mutations (append / element
# update) keep working unchanged; whole-list replacement MUST go
# through ``_node_registry.replace`` so all aliases stay in sync.
target_nodes = _node_registry.nodes
# Initialize heartbeat service with app context
heartbeat_service: HeartbeatService = HeartbeatService()
global args
args = None
async def fetch_child_nodes_from_proxy(proxy_node: dict) -> list[dict]:
"""Fetch child nodes from a single proxy node.
Args:
proxy_node: Proxy dict with ``name``/``host``/``port`` keys.
``/api/nodes`` of the proxy is queried.
Returns:
A list of leaf node dicts discovered from the proxy. An
empty list is returned when the request fails.
"""
try:
url = f"http://{proxy_node['host']}:{proxy_node['port']}/api/nodes"
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
response.raise_for_status()
return_nodes = []
added_names = set() # Track node names to avoid duplicates
nodes = response.json().get("nodes", [])
# Process each node: only add leaf nodes (nodes without children)
for node in nodes:
if node.get("children"):
# If node has children, add only the children (leaf nodes)
for child in node["children"]:
# Skip if already added
if child["name"] in added_names:
continue
child["proxy_id"] = proxy_node["name"]
return_nodes.append(child)
added_names.add(child["name"])
else:
# Skip if already added
if node["name"] in added_names:
continue
node["proxy_id"] = proxy_node["name"]
return_nodes.append(node)
added_names.add(node["name"])
return return_nodes
except Exception as e:
print(f"Failed to fetch nodes from proxy {proxy_node['name']}: {e}")
return []
async def fetch_all_child_nodes_concurrently(
proxy_nodes: list[dict],
) -> list[dict]:
"""Fetch child nodes from multiple proxy nodes concurrently.
Args:
proxy_nodes: Proxy dicts to query. Each dict is mutated in
place with a new ``nodes`` field.
Returns:
The same ``proxy_nodes`` list with ``nodes`` populated.
"""
tasks = [fetch_child_nodes_from_proxy(proxy) for proxy in proxy_nodes]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results and handle exceptions
for i, result in enumerate(results):
if isinstance(result, Exception):
print(f"Error fetching nodes from proxy {proxy_nodes[i]['name']}: {result}")
proxy_nodes[i]["nodes"] = []
else:
proxy_nodes[i]["nodes"] = result
return proxy_nodes
async def _fetch_child_nodes_from_api_nodes(
host: str, port: str, proxy_name: str
) -> list:
"""Try to fetch child nodes via /api/nodes from a multiProcess server.
Returns a non-empty list of child node dicts when the target is a
multiProcess lmcache server that exposes /api/nodes. Returns an
empty list for inProcess nodes (connection error or no children).
"""
try:
url = "http://%s:%s/api/nodes" % (host, port)
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
response.raise_for_status()
nodes_data = response.json().get("nodes", [])
children = []
for node in nodes_data:
if node.get("children"):
for child in node["children"]:
child["proxy_id"] = proxy_name
children.append(child)
else:
node["proxy_id"] = proxy_name
children.append(node)
return children
except Exception:
return []
async def fetch_nodes_from_supplier(url: str) -> list[dict]:
"""Fetch node information from node supplier.
For each discovered node, attempt to retrieve its child nodes via
``/api/nodes`` (multiProcess lmcache server). If that call
succeeds and returns children, those children are used as the
``nodes`` list (multiProcess mode). Otherwise the node itself is
used as the sole child (inProcess / leaf mode).
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
data = response.json()
unique_nodes: dict[str, dict] = {}
for api_address, info in data.get("processInfos", {}).items():
for entity in info.get("lmCacheInfoEntities", []):
addr = entity["apiAddress"]
if addr.startswith("http://"):
addr = addr[7:]
host, port = addr.split(":")
node_key = "%s:%s" % (host, port)
if node_key not in unique_nodes:
unique_nodes[node_key] = {
"host": host,
"port": port,
}
# Fetch child nodes concurrently for all discovered nodes.
proxy_list = [
{
"name": "proxy_%s" % node_key.replace(":", "_"),
"host": info["host"],
"port": info["port"],
}
for node_key, info in unique_nodes.items()
]
child_tasks = [
_fetch_child_nodes_from_api_nodes(p["host"], p["port"], p["name"])
for p in proxy_list
]
child_results = await asyncio.gather(*child_tasks, return_exceptions=True)
result = []
for proxy, children in zip(proxy_list, child_results, strict=False):
if isinstance(children, Exception) or not children:
# inProcess mode: node itself is the leaf target.
leaf = {
"name": proxy["name"],
"host": proxy["host"],
"port": proxy["port"],
}
nodes = [leaf]
else:
# multiProcess mode: use discovered child nodes.
assert not isinstance(children, BaseException)
nodes = children
result.append(
{
"name": proxy["name"],
"host": proxy["host"],
"port": proxy["port"],
"nodes": nodes,
}
)
return result
except Exception as e:
print(f"Failed to fetch nodes from supplier: {e}")
return []
def load_config(config_path: str | None = None) -> None:
"""Load proxy/node list from a JSON config file.
Args:
config_path: Optional path to the JSON file. When ``None`` the
packaged ``config.json`` is used.
"""
try:
# Prioritize user-specified configuration file
if config_path:
with open(config_path, "r") as f:
_node_registry.replace(json.load(f))
print(
f"Loaded {len(target_nodes)} target nodes from specified path: "
f"{config_path}"
)
else:
# Use package resource path as default configuration
default_config_path = _package_resource_path("config.json")
with open(default_config_path, "r") as f:
_node_registry.replace(json.load(f))
print(f"Loaded default configuration with {len(target_nodes)} target nodes")
except Exception as e:
print(f"Failed to load configuration file: {e}")
_node_registry.replace([])
def validate_node(node: dict, is_proxy: bool = False) -> bool:
"""Validate a single node configuration dict.
Args:
node: Candidate node dict.
is_proxy: Reserved for future proxy-specific validation.
Returns:
True when ``node`` has the required ``name``/``host``/``port``
keys and (optionally) a string ``proxy_id``.
"""
if not isinstance(node, dict):
return False
required_keys = {"name", "host", "port"}
if not required_keys.issubset(node.keys()):
return False
if "proxy_id" in node and node["proxy_id"]:
if not isinstance(node["proxy_id"], str):
return False
return True
def validate_nodes(nodes: list) -> bool:
"""Validate a list of node dicts; see :func:`validate_node`."""
if not isinstance(nodes, list):
return False
return all(validate_node(node) for node in nodes)
@router.get("/api/nodes")
async def get_all_nodes() -> dict:
"""Get all nodes in tree structure (proxies with their child nodes).
Returns:
``{"nodes": [...]}`` where each element is a proxy dict whose
``children`` list contains the leaf nodes behind it.
"""
all_nodes = []
for proxy in target_nodes:
# Create proxy node with children property
proxy_node = {
"id": f"proxy_{proxy['name']}",
"name": proxy["name"],
"host": proxy["host"],
"port": proxy["port"],
"is_proxy": True,
"children": [],
}
# Add child nodes
for node in proxy.get("nodes", []):
proxy_node["children"].append(
{
"id": "node_%s" % node["name"],
"name": node["name"],
"host": node["host"],
"port": node["port"],
"is_proxy": False,
"proxy_id": proxy["name"],
}
)
all_nodes.append(proxy_node)
return {"nodes": all_nodes}
@router.get("/api/proxies/{proxy_name}/refresh")
async def refresh_proxy_nodes(proxy_name: str):
"""Refresh child nodes of a proxy"""
proxy = next((p for p in target_nodes if p["name"] == proxy_name), None)
if not proxy:
raise HTTPException(status_code=404, detail="Proxy not found")
try:
child_nodes = await fetch_child_nodes_from_proxy(proxy)
proxy["nodes"] = child_nodes
return {"status": "success", "nodes": child_nodes}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/api/proxies")
async def get_proxies():
"""Get all proxy nodes (without child nodes)"""
proxies = [
{"name": proxy["name"], "host": proxy["host"], "port": proxy["port"]}
for proxy in target_nodes
]
return {"proxies": proxies}
@router.get("/api/proxies/{proxy_name}/nodes")
async def get_proxy_nodes(proxy_name: str):
"""Get all nodes under specified proxy"""
proxy = next((p for p in target_nodes if p["name"] == proxy_name), None)
if not proxy:
raise HTTPException(status_code=404, detail="Proxy not found")
return {"nodes": proxy["nodes"]}
# ==== Node Management Endpoints ====
@router.post("/api/proxies")
async def add_proxy(request: Request):
"""Add a new proxy node"""
try:
new_proxy = await request.json()
if not validate_node(new_proxy, is_proxy=True):
raise HTTPException(status_code=400, detail="Invalid proxy format")
# Check for duplicate names
if any(proxy["name"] == new_proxy["name"] for proxy in target_nodes):
raise HTTPException(status_code=409, detail="Proxy name already exists")
# Ensure nodes field exists
if "nodes" not in new_proxy:
new_proxy["nodes"] = []
target_nodes.append(new_proxy)
return {"status": "success", "message": "Proxy added"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.post("/api/proxies/{proxy_name}/nodes")
async def add_node_to_proxy(proxy_name: str, request: Request):
"""Add child node to proxy"""
try:
new_node = await request.json()
if not validate_node(new_node):
raise HTTPException(status_code=400, detail="Invalid node format")
# Find corresponding proxy
proxy = next((p for p in target_nodes if p["name"] == proxy_name), None)
if not proxy:
raise HTTPException(status_code=404, detail="Proxy not found")
# Check for duplicate names
if any(node["name"] == new_node["name"] for node in proxy["nodes"]):
raise HTTPException(status_code=409, detail="Node name already exists")
proxy["nodes"].append(new_node)
return {"status": "success", "message": "Node added to proxy"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.put("/api/nodes/{node_name}")
async def update_node(node_name: str, request: Request):
"""Update a proxy or child node identified by ``node_name``.
Searches top-level proxies first, then children of every proxy,
so both direct proxies and supplier-discovered leaf nodes can be
updated through the same endpoint (mirrors ``proxy_request_by_name``).
"""
try:
updated_node = await request.json()
if not validate_node(updated_node):
raise HTTPException(status_code=400, detail="Invalid node format")
for i, node in enumerate(target_nodes):
if node["name"] == node_name:
# Preserve existing children when the caller did not
# provide one; proxies are expected to keep ``nodes``.
if "nodes" not in updated_node and "nodes" in node:
updated_node["nodes"] = node["nodes"]
target_nodes[i] = updated_node
return {"status": "success", "message": "Node updated"}
for proxy in target_nodes:
children = proxy.get("nodes", [])
for j, child in enumerate(children):
if child["name"] == node_name:
children[j] = updated_node
return {
"status": "success",
"message": "Child node updated",
}
raise HTTPException(status_code=404, detail="Node not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.delete("/api/nodes/{node_name}")
async def delete_node(node_name: str):
"""Delete a proxy or child node by ``node_name``.
Mirrors :func:`update_node`: matches top-level proxies first,
falls back to scanning children of each proxy.
"""
try:
original_count = len(target_nodes)
_node_registry.replace(
[node for node in target_nodes if node["name"] != node_name]
)
if len(target_nodes) != original_count:
return {"status": "success", "message": "Node deleted"}
for proxy in target_nodes:
children = proxy.get("nodes", [])
for idx, child in enumerate(children):
if child["name"] == node_name:
del children[idx]
return {
"status": "success",
"message": "Child node deleted",
}
raise HTTPException(status_code=404, detail="Node not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.api_route(
"/proxy2/{node_name}/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_request_by_name(request: Request, node_name: str, path: str):
"""Proxy requests using node name as identifier.
Searches top-level target_nodes first, then child nodes of every
proxy, so both direct nodes and supplier-discovered leaf nodes are
reachable with a single /proxy2/{name}/{path} call.
"""
# 1. top-level proxy nodes
node = next((n for n in target_nodes if n["name"] == node_name), None)
# 2. child nodes of every proxy
if not node:
for proxy in target_nodes:
node = next(
(n for n in proxy.get("nodes", []) if n["name"] == node_name),
None,
)
if node:
break
if not node:
raise HTTPException(
status_code=404, detail=f"Node with name '{node_name}' not found"
)
# Use existing proxy_request logic
return await proxy_request(
request, target_host=node["host"], target_port_or_socket=node["port"], path=path
)
@router.api_route(
"/proxy/{target_host}/{target_port_or_socket}/{path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
)
async def proxy_request(
request: Request, target_host: str, target_port_or_socket: str | int, path: str
):
"""Proxy requests to the specified target host and port or socket path.
For security, non-socket targets must match a host/port already
registered in :data:`_node_registry`; this prevents the endpoint
from being used as an open relay (SSRF). Socket paths are
accepted as-is because they are local UDS endpoints.
"""
target_port_or_socket = unquote(str(target_port_or_socket))
# Check if target_port_or_socket is a socket path (contains '/')
is_socket_path = "/" in target_port_or_socket
if is_socket_path:
# For socket paths, use UDS transport
socket_path = target_port_or_socket
target_url = f"http://localhost/{path}"
# Create UDS transport
transport = httpx.AsyncHTTPTransport(uds=socket_path)
else:
port = target_port_or_socket
# SSRF guard: resolve against the registry and reuse the
# trusted host/port from there when building the outbound URL.
# This keeps user-controlled values out of the URL sink.
resolved = _node_registry.resolve(target_host, port)
if resolved is None:
raise HTTPException(
status_code=403,
detail=("Target %s:%s is not a registered node" % (target_host, port)),
)
safe_host, safe_port = resolved
target_url = f"http://{safe_host}:{safe_port}/{path}"
transport = None # Use default transport
headers = {}
for key, value in request.headers.items():
if key.lower() in [
"host",
"content-length",
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
]:
continue
headers[key] = value
body = await request.body()
# Create client with appropriate transport
async with httpx.AsyncClient(transport=transport) as client:
try:
response = await client.request(
method=request.method,
url=target_url,
headers=headers,
content=body,
params=request.query_params,
timeout=60.0,
)
response_headers = {}
for key, value in response.headers.items():
if key.lower() in [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
]:
continue
response_headers[key] = value
return PlainTextResponse(
content=response.content,
headers=response_headers,
media_type=response.headers.get("content-type", "text/plain"),
status_code=response.status_code,
)
except httpx.ConnectError as e:
if is_socket_path:
detail = f"Failed to connect to socket: {socket_path}"
else:
detail = f"Failed to connect to target service {target_host}:{port}"
raise HTTPException(status_code=502, detail=detail) from e
except httpx.TimeoutException as e:
if is_socket_path:
detail = f"Connection to socket {socket_path} timed out"
else:
detail = f"Connection to target service {target_host}:{port} timed out"
raise HTTPException(status_code=504, detail=detail) from e
except httpx.HTTPError as e:
raise HTTPException(
status_code=502,
detail=f"Error communicating with target service: {str(e)}",
) from e
except Exception as e:
raise HTTPException(status_code=500, detail=f"Proxy error: {str(e)}") from e
@router.get("/health")
async def health_check():
return {"status": "healthy", "service": "lmcache-monitor"}
@router.get("/api/heartbeat/status")
async def get_heartbeat_status():
"""Get heartbeat status"""
return heartbeat_service.status()
@router.post("/api/heartbeat/start")
async def start_heartbeat_api(request: Request):
"""Start heartbeat service"""
try:
data = await request.json()
heartbeat_url = data.get("heartbeat_url")
initial_delay = data.get("initial_delay", 0)
interval = data.get("interval", 30)
if not heartbeat_url:
raise HTTPException(status_code=400, detail="heartbeat_url is required")
heartbeat_service.start(heartbeat_url, initial_delay, interval)
return {"status": "success", "message": "Heartbeat service started"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
@router.post("/api/heartbeat/stop")
async def stop_heartbeat_api():
"""Stop heartbeat service"""
try:
heartbeat_service.stop()
return {"status": "success", "message": "Heartbeat service stopped"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e
async def load_nodes_from_supplier(node_supplier_url: str | None = None) -> bool:
"""Load node information from node supplier.
Args:
node_supplier_url: Discovery service URL, e.g. ``/lmcache_infos``.
Returns:
``True`` when a non-empty node list was retrieved and stored.
"""
if not node_supplier_url:
return False
print(f"Fetching nodes from supplier: {node_supplier_url}")
nodes = await fetch_nodes_from_supplier(node_supplier_url)
if nodes:
_node_registry.replace(nodes)
print(f"Loaded {len(target_nodes)} proxy nodes from supplier")
# Child nodes are already populated by fetch_nodes_from_supplier;
# no secondary /api/nodes round-trip is needed.
for proxy in target_nodes:
print(f"Proxy {proxy['name']} loaded {len(proxy['nodes'])} child nodes")
return True
else:
print("Warning: No nodes loaded from supplier")
return False
async def initialize_nodes(node_supplier_url: str | None = None) -> None:
"""Initialize node configuration from CLI args or supplier URL.
Args:
node_supplier_url: Optional discovery service URL. When set,
nodes are loaded via :func:`load_nodes_from_supplier`.
Otherwise falls back to ``args.nodes`` / ``args.config``.
"""
global args
if args is None:
raise ValueError("args is not initialized")
if node_supplier_url:
await load_nodes_from_supplier(node_supplier_url)
elif args.nodes:
try:
nodes = json.loads(args.nodes)
if validate_nodes(nodes):
_node_registry.replace(
[
{
"name": "local_proxy",
"host": args.host,
"port": args.port,
"nodes": nodes,
}
]
)
print(f"Loaded {len(nodes)} target nodes from command line arguments")
except json.JSONDecodeError:
print("Failed to parse nodes JSON parameter")
elif args.config:
load_config(args.config)
# Minimum seconds between two supplier refreshes triggered by ``/``.
# Each refresh fans out to every registered proxy's ``/api/nodes``
# endpoint, so an unthrottled ``/`` would DOS both the supplier and
# every proxy. 30s matches the default heartbeat interval.
_SUPPLIER_REFRESH_INTERVAL_SEC = 30.0
_supplier_last_refresh: float = 0.0
_supplier_refresh_lock = asyncio.Lock()
async def _maybe_refresh_from_supplier(node_supplier_url: str) -> None:
"""Refresh the node registry from supplier, at most once per interval.
The first caller within the interval performs the refresh; other
concurrent callers return immediately (stale-on-read). This keeps
the homepage responsive even under high traffic.
"""
global _supplier_last_refresh
now = time.monotonic()
if now - _supplier_last_refresh < _SUPPLIER_REFRESH_INTERVAL_SEC:
return
if _supplier_refresh_lock.locked():
return
async with _supplier_refresh_lock:
now = time.monotonic()
if now - _supplier_last_refresh < _SUPPLIER_REFRESH_INTERVAL_SEC:
return
await initialize_nodes(node_supplier_url)
_supplier_last_refresh = time.monotonic()
@router.get("/")
async def serve_frontend():
"""Return frontend homepage.
When a node supplier URL is configured, trigger a throttled
background-style refresh so opening the homepage repeatedly does
not hammer the supplier or every proxy's ``/api/nodes``.
"""
if args.node_supplier_url:
await _maybe_refresh_from_supplier(args.node_supplier_url)
try:
# Use package resource path
index_path = _package_resource_path("static/index.html")
return FileResponse(index_path)
except Exception:
# Development environment uses local files
return FileResponse("static/index.html")
# Helper function to fetch metrics from a single node
async def _fetch_node_metrics(node):
"""Fetch metrics from a single node"""
try:
# Check if port is a socket path
is_socket_path = "/" in node["port"]
if is_socket_path:
# Use UDS transport for socket paths
transport = httpx.AsyncHTTPTransport(uds=node["port"])
# Use localhost as host
url = "http://localhost/metrics"
async with httpx.AsyncClient(transport=transport, timeout=5.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.text
else:
# Build URL for regular port
url = f"http://{node['host']}:{node['port']}/metrics"
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.text
except Exception as e:
return f"# ERROR: Failed to get metrics from {node['name']}: {str(e)}\n"
@router.get("/metrics")
async def aggregated_metrics():
"""Aggregate /metrics from all leaf (child) nodes across every proxy.
Previously only nodes under the ``local_proxy`` entry were
aggregated, which silently returned nothing in supplier mode
(where proxy names follow the ``proxy_<host>_<port>`` pattern).
Now every proxy's ``nodes`` list is flattened.
"""
if not target_nodes:
return PlainTextResponse("# No nodes configured\n", status_code=404)
nodes: list[dict] = []
seen: set[str] = set()
for proxy in target_nodes:
for child in proxy.get("nodes", []) or []:
# De-duplicate by name so the same leaf reported via
# multiple proxies is only scraped once.
name = child.get("name")
if name and name in seen:
continue
if name:
seen.add(name)
nodes.append(child)
if not nodes:
return PlainTextResponse(
"# No nodes available for metrics collection\n", status_code=404
)
metrics_results = await asyncio.gather(
*[_fetch_node_metrics(node) for node in nodes]
)
# Combine all metrics with node name as comment header
aggregated = ""
for i, metrics in enumerate(metrics_results):
node = nodes[i]
aggregated += (
f"# Metrics from node: {node['name']} ({node['host']}:{node['port']})\n"
)
aggregated += metrics
aggregated += "\n\n"
return PlainTextResponse(aggregated)
def create_app():
"""Create and configure FastAPI application"""
app = FastAPI(
title="Flexible Proxy Server",
description="HTTP proxy service supporting specified target hosts and ports",
)
app.include_router(router)
# Get static file path (prefer package resources)
try:
static_path = _package_resource_path("static")
except Exception:
static_path = os.path.join(os.path.dirname(__file__), "static")
# Mount static file service
app.mount("/static", StaticFiles(directory=static_path), name="static")
return app
def main():
global args
parser = argparse.ArgumentParser(description="LMCache Cluster Monitoring Tool")
parser.add_argument(
"--port", type=int, default=8000, help="Service port, default 8000"
)
parser.add_argument(
"--host", type=str, default="0.0.0.0", help="Bind host address, default 0.0.0.0"
)
parser.add_argument(
"--config",
type=str,
default=None,
help="Specify configuration file path, default uses internal config.json",
)
parser.add_argument(
"--nodes",
type=str,
default=None,
help="Directly specify target nodes as a JSON string. "
'Example: \'[{"name":"node1","host":"127.0.0.1","port":8001}]\'',
)
parser.add_argument(
"--heartbeat-url",
type=str,
default=None,
help="Heartbeat service URL, e.g.: http://example.com/heartbeat",
)
parser.add_argument(
"--heartbeat-initial-delay",
type=int,
default=0,
help="Initial delay before starting heartbeat (seconds), default 0",
)
parser.add_argument(
"--heartbeat-interval",
type=int,
default=30,
help="Heartbeat interval (seconds), default 30",
)
parser.add_argument(
"--node-supplier-url",
type=str,
default=None,
help="URL to fetch node information from, e.g.: http://example.com/lmcache_infos",
)
parser.add_argument(
"--log-level",
type=str,
default="warning",
choices=["critical", "error", "warning", "warn", "info", "debug", "trace"],
help="Uvicorn log level, default: warn",
)
parser.add_argument(
"--no-http",
action="store_true",
default=False,
help="Disable HTTP server startup (heartbeat still runs)",
)
args = parser.parse_args()
# Initialize node configuration
asyncio.run(initialize_nodes(args.node_supplier_url))
app = create_app()
print(f"Monitoring service running at http://{args.host}:{args.port}")
print(f"Node management: http://{args.host}:{args.port}/static/index.html")
# Start heartbeat service if URL is configured
if args.heartbeat_url:
# Set application configuration for heartbeat service
heartbeat_service.set_app_config(args.host, args.port, target_nodes)
print("Starting heartbeat service...")
print(f"Heartbeat URL: {args.heartbeat_url}")
print(f"Initial delay: {args.heartbeat_initial_delay}s")
print(f"Interval: {args.heartbeat_interval}s")
print(f"API Address: http://{args.host}:{args.port}")
print(f"Target nodes count: {len(target_nodes)}")
heartbeat_service.start(
args.heartbeat_url, args.heartbeat_initial_delay, args.heartbeat_interval
)
else:
print("Heartbeat URL not configured, heartbeat disabled")
if args.no_http:
print("HTTP server disabled (--no-http), running heartbeat only")
try:
stop_event = threading.Event()
stop_event.wait()
finally:
print("Shutting down application...")
heartbeat_service.stop()
return
try:
uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level)
finally:
# Stop heartbeat service when app closes
print("Shutting down application...")
heartbeat_service.stop()
if __name__ == "__main__":
main()