mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 15:41:41 +02:00
291 lines
8.9 KiB
Python
291 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
import shutil
|
|
import subprocess
|
|
|
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
|
from .platforms import PlatformProvider, detect_platform
|
|
from .route_policy import route_issues
|
|
from .transports import (
|
|
TransportAdapter,
|
|
fallback_transport_adapter,
|
|
transport_adapter,
|
|
)
|
|
|
|
|
|
VALID_DAEMON_POLICIES = {"external", "adopt", "manage", "auto"}
|
|
SUPPORTED_RENDERERS = {"clearnet", "privacy-html", "gemtext"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DaemonProbe:
|
|
installed: bool
|
|
running: bool
|
|
executable_path: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TransportStatus:
|
|
site_id: str
|
|
protocol: str
|
|
renderer: str
|
|
daemon: str
|
|
daemon_policy: str
|
|
platform: str
|
|
supervisor: str
|
|
adapter: str
|
|
backend: str
|
|
installed: bool
|
|
running: bool
|
|
adoptable: bool
|
|
manageable: bool
|
|
executable_path: str | None
|
|
status: str
|
|
action: str
|
|
message: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DoctorIssue:
|
|
site_id: str
|
|
severity: str
|
|
code: str
|
|
message: str
|
|
protocol: str = "-"
|
|
|
|
|
|
DaemonProbeFunc = Callable[[TransportAdapter], DaemonProbe]
|
|
|
|
|
|
def gateway_status(
|
|
config: GatewayConfig,
|
|
*,
|
|
platform_provider: PlatformProvider | None = None,
|
|
daemon_probe: DaemonProbeFunc | None = None,
|
|
) -> list[TransportStatus]:
|
|
provider = platform_provider or detect_platform()
|
|
probe = daemon_probe or probe_daemon
|
|
statuses: list[TransportStatus] = []
|
|
for site in config.sites:
|
|
for protocol in site.protocols.values():
|
|
if not protocol.enabled:
|
|
continue
|
|
adapter = transport_adapter(protocol.name, protocol.daemon)
|
|
adapter = adapter or fallback_transport_adapter(protocol.name, protocol.daemon)
|
|
daemon = probe(adapter)
|
|
statuses.append(_transport_status(site, protocol, adapter, provider, daemon))
|
|
return statuses
|
|
|
|
|
|
def doctor_gateway(
|
|
config: GatewayConfig,
|
|
*,
|
|
platform_provider: PlatformProvider | None = None,
|
|
daemon_probe: DaemonProbeFunc | None = None,
|
|
statuses: list[TransportStatus] | None = None,
|
|
) -> list[DoctorIssue]:
|
|
if statuses is None:
|
|
statuses = gateway_status(
|
|
config,
|
|
platform_provider=platform_provider,
|
|
daemon_probe=daemon_probe,
|
|
)
|
|
issues: list[DoctorIssue] = []
|
|
|
|
for site in config.sites:
|
|
if not site.source.path.exists():
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=site.id,
|
|
severity="error",
|
|
code="source-missing",
|
|
message=f"source path does not exist: {site.source.path}",
|
|
)
|
|
)
|
|
elif not site.source.path.is_dir():
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=site.id,
|
|
severity="error",
|
|
code="source-not-directory",
|
|
message=f"source path is not a directory: {site.source.path}",
|
|
)
|
|
)
|
|
|
|
for protocol in site.protocols.values():
|
|
if not protocol.enabled:
|
|
continue
|
|
if protocol.renderer not in SUPPORTED_RENDERERS:
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
severity="error",
|
|
code="unsupported-renderer",
|
|
message=f"renderer {protocol.renderer!r} is not implemented",
|
|
)
|
|
)
|
|
output_root = site.outputs.root / protocol.name
|
|
if not (output_root / ".ampg-output").exists():
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
severity="warning",
|
|
code="output-missing",
|
|
message=f"build output is missing: {output_root}",
|
|
)
|
|
)
|
|
|
|
for status in statuses:
|
|
if status.status == "error":
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=status.site_id,
|
|
protocol=status.protocol,
|
|
severity="error",
|
|
code="daemon-status",
|
|
message=status.message,
|
|
)
|
|
)
|
|
elif status.status == "warn":
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=status.site_id,
|
|
protocol=status.protocol,
|
|
severity="warning",
|
|
code="daemon-status",
|
|
message=status.message,
|
|
)
|
|
)
|
|
|
|
for issue in route_issues(config):
|
|
issues.append(
|
|
DoctorIssue(
|
|
site_id=issue.site_id,
|
|
protocol="-",
|
|
severity="error",
|
|
code=issue.code,
|
|
message=issue.message,
|
|
)
|
|
)
|
|
|
|
return issues
|
|
|
|
|
|
def probe_daemon(adapter: TransportAdapter) -> DaemonProbe:
|
|
executable_path = shutil.which(adapter.executable)
|
|
running = _process_running(adapter.executable)
|
|
return DaemonProbe(
|
|
installed=bool(executable_path),
|
|
running=running,
|
|
executable_path=executable_path,
|
|
)
|
|
|
|
|
|
def _transport_status(
|
|
site: SiteConfig,
|
|
protocol: ProtocolConfig,
|
|
adapter: TransportAdapter,
|
|
provider: PlatformProvider,
|
|
daemon: DaemonProbe,
|
|
) -> TransportStatus:
|
|
adapter_known = adapter.backend != "unknown"
|
|
adoptable = adapter.adopt_supported and daemon.running
|
|
manageable = adapter.managed_supported and provider.can_manage_daemons
|
|
policy = protocol.daemon_policy
|
|
|
|
if policy not in VALID_DAEMON_POLICIES:
|
|
status = "error"
|
|
action = "unavailable"
|
|
message = f"unknown daemon policy {policy!r}"
|
|
elif policy == "external":
|
|
status = "ok" if adapter_known else "warn"
|
|
action = "render-only"
|
|
message = (
|
|
"render outputs and review generated snippets"
|
|
if adapter_known
|
|
else "no registered daemon adapter; render output only"
|
|
)
|
|
elif not adapter_known:
|
|
status = "error"
|
|
action = "unavailable"
|
|
message = f"no registered adapter for {protocol.name}/{protocol.daemon}"
|
|
elif policy == "adopt":
|
|
if adoptable:
|
|
status = "ok"
|
|
action = "adopt-existing"
|
|
message = "existing daemon appears to be running"
|
|
elif daemon.installed:
|
|
status = "error"
|
|
action = "unavailable"
|
|
message = f"{adapter.executable} is installed but not running"
|
|
else:
|
|
status = "error"
|
|
action = "unavailable"
|
|
message = f"{adapter.executable} is not installed or not on PATH"
|
|
elif policy == "manage":
|
|
if manageable:
|
|
status = "ok"
|
|
action = "manage-owned"
|
|
message = f"AMPG can manage {protocol.name} with {provider.name}"
|
|
else:
|
|
status = "error"
|
|
action = "unavailable"
|
|
message = f"{provider.name} cannot manage {protocol.name}/{protocol.daemon}"
|
|
else:
|
|
if adoptable:
|
|
status = "ok"
|
|
action = "adopt-existing"
|
|
message = "existing daemon appears to be running"
|
|
elif manageable:
|
|
status = "ok"
|
|
action = "manage-owned"
|
|
if daemon.installed:
|
|
message = f"{adapter.executable} is installed but not running; AMPG would manage it"
|
|
else:
|
|
message = f"{adapter.executable} is missing; AMPG would prepare managed setup"
|
|
else:
|
|
status = "error"
|
|
action = "unavailable"
|
|
message = f"no running daemon and {provider.name} cannot manage {protocol.name}/{protocol.daemon}"
|
|
|
|
return TransportStatus(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
renderer=protocol.renderer,
|
|
daemon=protocol.daemon,
|
|
daemon_policy=policy,
|
|
platform=provider.name,
|
|
supervisor=provider.process_supervisor,
|
|
adapter=f"{adapter.protocol}/{adapter.daemon}",
|
|
backend=adapter.backend,
|
|
installed=daemon.installed,
|
|
running=daemon.running,
|
|
adoptable=adoptable,
|
|
manageable=manageable,
|
|
executable_path=daemon.executable_path,
|
|
status=status,
|
|
action=action,
|
|
message=message,
|
|
)
|
|
|
|
|
|
def _process_running(executable: str) -> bool:
|
|
pgrep = shutil.which("pgrep")
|
|
if not pgrep:
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
[pgrep, "-x", executable],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=1,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
return result.returncode == 0
|