diff --git a/ilsp.nomad b/ilsp.nomad index 552021f..b3a52bb 100644 --- a/ilsp.nomad +++ b/ilsp.nomad @@ -44,13 +44,11 @@ job "ilsp" { auto_revert = true } - # Static ports: LSP uses raw TCP — Traefik isn't involved for 2087/2088. - # DNS: lsp.i80.dk → autobox.i80.dk (A record) - # Editors connect directly: nc lsp.i80.dk 2087 / nc lsp.i80.dk 2088 + # Single dynamic port — Traefik routes ilsp.i80.dk → this port. + # Editors connect via WebSocket: ws://ilsp.i80.dk/python ws://ilsp.i80.dk/bicep + # LSP servers (pylsp, Bicep LS) run on localhost inside the container only. network { - port "python_lsp" { static = 2087 } - port "bicep_lsp" { static = 2088 } - port "health" { static = 2089 } + port "http" {} } reschedule { @@ -66,7 +64,7 @@ job "ilsp" { service { provider = "consul" name = var.service_name - port = "health" + port = "http" tags = [ "traefik.enable=true", @@ -77,7 +75,7 @@ job "ilsp" { check { name = "health_http" type = "http" - port = "health" + port = "http" path = "/health" interval = "15s" timeout = "5s" @@ -89,7 +87,7 @@ job "ilsp" { config { image = "registry.i80.dk/gitea/ilsp:${var.image_tag}" - ports = ["python_lsp", "bicep_lsp", "health"] + ports = ["http"] force_pull = true auth { username = "robot$gitserver" @@ -105,9 +103,9 @@ job "ilsp" { } env { - PYTHON_LSP_PORT = "${NOMAD_PORT_python_lsp}" - BICEP_LSP_PORT = "${NOMAD_PORT_bicep_lsp}" - HEALTH_PORT = "${NOMAD_PORT_health}" + HTTP_PORT = "${NOMAD_PORT_http}" + PYTHON_LSP_PORT = "2087" + BICEP_LSP_PORT = "2088" DEVOPS_MCP_URL = "https://devops-mcp.i80.dk" PYTHONUNBUFFERED = "1" } diff --git a/ilsp/server.py b/ilsp/server.py index d4091f5..f1bee87 100644 --- a/ilsp/server.py +++ b/ilsp/server.py @@ -1,10 +1,14 @@ """ iLSP main entrypoint. -Starts three services concurrently: - - pylsp server on TCP :2087 (Python LSP + i80 completions) - - Bicep proxy on TCP :2088 (Bicep LS wrapper + LRU modules) - - Health HTTP on TCP :2089 (for Consul/Nomad health checks) +Starts all services in one container: + - pylsp on localhost:2087 (internal only — Python LSP + i80 completions) + - Bicep proxy on localhost:2088 (internal only — Bicep LS wrapper + LRU modules) + - aiohttp HTTP on $HTTP_PORT (exposed — health, reload, WebSocket proxies) + +Only the HTTP port is exposed to Traefik/Nomad. Editors connect via: + ws://ilsp.i80.dk/python → pylsp + ws://ilsp.i80.dk/bicep → Bicep LS """ import asyncio @@ -25,21 +29,81 @@ logger = logging.getLogger(__name__) PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087")) BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088")) -HEALTH_PORT = int(os.getenv("HEALTH_PORT", "2089")) +HTTP_PORT = int(os.getenv("HTTP_PORT", "8000")) + +_CHUNK = 65536 def _serve_python_lsp(port: int) -> None: - """Start pylsp in TCP server mode; restart on unexpected exit (blocking).""" + """Start pylsp in TCP mode on localhost only; restart on unexpected exit.""" while True: proc = subprocess.Popen( - [sys.executable, "-m", "pylsp", "--tcp", "--host", "0.0.0.0", "--port", str(port)], + [sys.executable, "-m", "pylsp", "--tcp", "--host", "127.0.0.1", "--port", str(port)], ) - logger.info("Python LSP (pylsp) listening on TCP :%d PID=%d", port, proc.pid) + logger.info("pylsp listening on localhost:%d PID=%d", port, proc.pid) proc.wait() logger.warning("pylsp exited (code %s) — restarting", proc.returncode) -async def _health_app() -> web.Application: +async def _pipe(reader: asyncio.StreamReader, writer) -> None: + """Pipe bytes from reader to writer until EOF.""" + try: + while True: + data = await reader.read(_CHUNK) + if not data: + break + writer.write(data) + if hasattr(writer, "drain"): + await writer.drain() + else: + await writer.send_bytes(data) + except Exception: + pass + + +async def _ws_proxy(request: web.Request, host: str, port: int) -> web.WebSocketResponse: + """Bridge an incoming WebSocket connection to an internal TCP LSP server.""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + try: + tcp_reader, tcp_writer = await asyncio.open_connection(host, port) + except OSError as exc: + logger.error("Cannot connect to LSP on %s:%d — %s", host, port, exc) + await ws.close(code=1011, message=b"LSP backend unavailable") + return ws + + async def ws_to_tcp(): + try: + async for msg in ws: + if msg.type == web.WSMsgType.BINARY: + tcp_writer.write(msg.data) + await tcp_writer.drain() + elif msg.type == web.WSMsgType.TEXT: + tcp_writer.write(msg.data.encode()) + await tcp_writer.drain() + except Exception: + pass + finally: + tcp_writer.close() + + async def tcp_to_ws(): + try: + while True: + data = await tcp_reader.read(_CHUNK) + if not data: + break + await ws.send_bytes(data) + except Exception: + pass + finally: + await ws.close() + + await asyncio.gather(ws_to_tcp(), tcp_to_ws(), return_exceptions=True) + return ws + + +async def _build_app() -> web.Application: app = web.Application() async def health(_: web.Request) -> web.Response: @@ -50,7 +114,6 @@ async def _health_app() -> web.Application: }) async def reload(_: web.Request) -> web.Response: - """Hot-reload catalogs from /data/ volume without restarting.""" before = len(BicepModuleCatalog._modules) BicepModuleCatalog.load() after = len(BicepModuleCatalog._modules) @@ -61,8 +124,16 @@ async def _health_app() -> web.Application: "bicep_modules_after": after, }) + async def python_ws(request: web.Request) -> web.WebSocketResponse: + return await _ws_proxy(request, "127.0.0.1", PYTHON_LSP_PORT) + + async def bicep_ws(request: web.Request) -> web.WebSocketResponse: + return await _ws_proxy(request, "127.0.0.1", BICEP_LSP_PORT) + app.router.add_get("/health", health) app.router.add_post("/reload", reload) + app.router.add_get("/python", python_ws) + app.router.add_get("/bicep", bicep_ws) return app @@ -72,24 +143,21 @@ async def main_async() -> None: format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) - # Pre-warm catalogs before accepting connections logger.info("Pre-warming catalogs…") BicepModuleCatalog.load() await PypiCatalog.start_background_refresh() - # Both LSP servers run in daemon threads (blocking socket/process loops) + # LSP servers run internally on localhost — not exposed outside the container threading.Thread(target=_serve_python_lsp, args=(PYTHON_LSP_PORT,), daemon=True).start() threading.Thread(target=serve_bicep, args=(BICEP_LSP_PORT,), daemon=True).start() - # Health HTTP server — keeps the process alive via the asyncio event loop - health_app = await _health_app() - runner = web.AppRunner(health_app) + app = await _build_app() + runner = web.AppRunner(app) await runner.setup() - site = web.TCPSite(runner, "0.0.0.0", HEALTH_PORT) + site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT) await site.start() - logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT) + logger.info("iLSP HTTP on http://0.0.0.0:%d (ws://.../python ws://.../bicep)", HTTP_PORT) - # Wait indefinitely; signal handlers in main() will stop the loop await asyncio.Event().wait()