Files
GUI-Agent/SystemTest-Agent/app/controller_discovery.py
T

137 lines
4.2 KiB
Python

from __future__ import annotations
import concurrent.futures
import ipaddress
import json
import socket
from urllib.error import URLError
from urllib.request import Request, urlopen
DISCOVERY_PATH = "/api/v1/discovery"
AUTO_VALUES = {"", "auto", "discover", "lan"}
def _is_private_ipv4(value: str) -> bool:
try:
ip = ipaddress.ip_address(value)
except ValueError:
return False
return ip.version == 4 and ip.is_private and not ip.is_loopback
def _local_private_ipv4s() -> list[str]:
addresses: set[str] = set()
try:
for value in socket.gethostbyname_ex(socket.gethostname())[2]:
if _is_private_ipv4(value):
addresses.add(value)
except OSError:
pass
# UDP connect does not send packets; it reveals the chosen outbound address.
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect(("8.8.8.8", 80))
value = sock.getsockname()[0]
if _is_private_ipv4(value):
addresses.add(value)
except OSError:
pass
return sorted(addresses)
def _candidate_hosts() -> list[str]:
hosts: set[str] = {"127.0.0.1"}
for address in _local_private_ipv4s():
hosts.add(address)
network = ipaddress.ip_network(f"{address}/24", strict=False)
for host in network.hosts():
hosts.add(str(host))
return sorted(hosts, key=lambda value: tuple(int(part) for part in value.split(".")))
def _probe(base_url: str, timeout_seconds: float) -> str | None:
url = f"{base_url.rstrip('/')}{DISCOVERY_PATH}"
request = Request(url, headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout_seconds) as response:
body = response.read()
except (OSError, URLError):
return None
try:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
return None
if payload.get("service") != "Agent-S-Controller":
return None
discovered = str(payload.get("base_url") or base_url).rstrip("/")
return discovered or None
def discover_controller_base_url(
ports: list[int],
timeout_seconds: float,
workers: int = 96,
log=print,
) -> str | None:
hosts = _candidate_hosts()
attempts = [(host, port) for port in ports for host in hosts]
if not attempts:
return None
log(
"[agent] discovering controller on "
+ f"{len(hosts)} hosts x ports {', '.join(str(p) for p in ports)} ..."
)
with concurrent.futures.ThreadPoolExecutor(max_workers=min(workers, len(attempts))) as executor:
future_map = {
executor.submit(_probe, f"http://{host}:{port}", timeout_seconds): (host, port)
for host, port in attempts
}
for future in concurrent.futures.as_completed(future_map):
found = future.result()
if found:
host, port = future_map[future]
log(f"[agent] discovered controller at {found} ({host}:{port})")
for pending in future_map:
pending.cancel()
return found
return None
def resolve_controller_base_url(
configured_base_url: str,
discovery_enabled: bool,
discovery_ports: list[int],
timeout_seconds: float,
log=print,
) -> str:
configured = (configured_base_url or "auto").strip().rstrip("/")
if configured.lower() not in AUTO_VALUES:
found = _probe(configured, timeout_seconds)
if found:
return found
if not discovery_enabled:
return configured
log(f"[agent] configured controller is not reachable yet: {configured}")
if discovery_enabled:
found = discover_controller_base_url(
ports=discovery_ports,
timeout_seconds=timeout_seconds,
log=log,
)
if found:
return found
ports = ", ".join(str(port) for port in discovery_ports)
raise RuntimeError(
"Controller was not found. Start the controller first, allow TCP "
f"{ports} through the firewall, or start the agent with "
"start_agent.bat http://<controller-lan-ip>:8001"
)