refactor: single HTTP port, WebSocket proxy for LSP endpoints
- Remove static ports 2087/2088/2089 from Nomad; use one dynamic port - pylsp and Bicep LS stay on localhost inside container (not exposed) - aiohttp adds /python and /bicep WebSocket routes bridging to localhost LSPs - Nomad env: HTTP_PORT=$NOMAD_PORT_http, internal ports fixed at 2087/2088 - Editors connect via ws://ilsp.i80.dk/python and ws://ilsp.i80.dk/bicep - Traefik routes ilsp.i80.dk → single dynamic HTTP port
This commit is contained in:
22
ilsp.nomad
22
ilsp.nomad
@@ -44,13 +44,11 @@ job "ilsp" {
|
|||||||
auto_revert = true
|
auto_revert = true
|
||||||
}
|
}
|
||||||
|
|
||||||
# Static ports: LSP uses raw TCP — Traefik isn't involved for 2087/2088.
|
# Single dynamic port — Traefik routes ilsp.i80.dk → this port.
|
||||||
# DNS: lsp.i80.dk → autobox.i80.dk (A record)
|
# Editors connect via WebSocket: ws://ilsp.i80.dk/python ws://ilsp.i80.dk/bicep
|
||||||
# Editors connect directly: nc lsp.i80.dk 2087 / nc lsp.i80.dk 2088
|
# LSP servers (pylsp, Bicep LS) run on localhost inside the container only.
|
||||||
network {
|
network {
|
||||||
port "python_lsp" { static = 2087 }
|
port "http" {}
|
||||||
port "bicep_lsp" { static = 2088 }
|
|
||||||
port "health" { static = 2089 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reschedule {
|
reschedule {
|
||||||
@@ -66,7 +64,7 @@ job "ilsp" {
|
|||||||
service {
|
service {
|
||||||
provider = "consul"
|
provider = "consul"
|
||||||
name = var.service_name
|
name = var.service_name
|
||||||
port = "health"
|
port = "http"
|
||||||
|
|
||||||
tags = [
|
tags = [
|
||||||
"traefik.enable=true",
|
"traefik.enable=true",
|
||||||
@@ -77,7 +75,7 @@ job "ilsp" {
|
|||||||
check {
|
check {
|
||||||
name = "health_http"
|
name = "health_http"
|
||||||
type = "http"
|
type = "http"
|
||||||
port = "health"
|
port = "http"
|
||||||
path = "/health"
|
path = "/health"
|
||||||
interval = "15s"
|
interval = "15s"
|
||||||
timeout = "5s"
|
timeout = "5s"
|
||||||
@@ -89,7 +87,7 @@ job "ilsp" {
|
|||||||
|
|
||||||
config {
|
config {
|
||||||
image = "registry.i80.dk/gitea/ilsp:${var.image_tag}"
|
image = "registry.i80.dk/gitea/ilsp:${var.image_tag}"
|
||||||
ports = ["python_lsp", "bicep_lsp", "health"]
|
ports = ["http"]
|
||||||
force_pull = true
|
force_pull = true
|
||||||
auth {
|
auth {
|
||||||
username = "robot$gitserver"
|
username = "robot$gitserver"
|
||||||
@@ -105,9 +103,9 @@ job "ilsp" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
env {
|
env {
|
||||||
PYTHON_LSP_PORT = "${NOMAD_PORT_python_lsp}"
|
HTTP_PORT = "${NOMAD_PORT_http}"
|
||||||
BICEP_LSP_PORT = "${NOMAD_PORT_bicep_lsp}"
|
PYTHON_LSP_PORT = "2087"
|
||||||
HEALTH_PORT = "${NOMAD_PORT_health}"
|
BICEP_LSP_PORT = "2088"
|
||||||
DEVOPS_MCP_URL = "https://devops-mcp.i80.dk"
|
DEVOPS_MCP_URL = "https://devops-mcp.i80.dk"
|
||||||
PYTHONUNBUFFERED = "1"
|
PYTHONUNBUFFERED = "1"
|
||||||
}
|
}
|
||||||
|
|||||||
104
ilsp/server.py
104
ilsp/server.py
@@ -1,10 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
iLSP main entrypoint.
|
iLSP main entrypoint.
|
||||||
|
|
||||||
Starts three services concurrently:
|
Starts all services in one container:
|
||||||
- pylsp server on TCP :2087 (Python LSP + i80 completions)
|
- pylsp on localhost:2087 (internal only — Python LSP + i80 completions)
|
||||||
- Bicep proxy on TCP :2088 (Bicep LS wrapper + LRU modules)
|
- Bicep proxy on localhost:2088 (internal only — Bicep LS wrapper + LRU modules)
|
||||||
- Health HTTP on TCP :2089 (for Consul/Nomad health checks)
|
- 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
|
import asyncio
|
||||||
@@ -25,21 +29,81 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
|
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
|
||||||
BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
|
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:
|
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:
|
while True:
|
||||||
proc = subprocess.Popen(
|
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()
|
proc.wait()
|
||||||
logger.warning("pylsp exited (code %s) — restarting", proc.returncode)
|
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()
|
app = web.Application()
|
||||||
|
|
||||||
async def health(_: web.Request) -> web.Response:
|
async def health(_: web.Request) -> web.Response:
|
||||||
@@ -50,7 +114,6 @@ async def _health_app() -> web.Application:
|
|||||||
})
|
})
|
||||||
|
|
||||||
async def reload(_: web.Request) -> web.Response:
|
async def reload(_: web.Request) -> web.Response:
|
||||||
"""Hot-reload catalogs from /data/ volume without restarting."""
|
|
||||||
before = len(BicepModuleCatalog._modules)
|
before = len(BicepModuleCatalog._modules)
|
||||||
BicepModuleCatalog.load()
|
BicepModuleCatalog.load()
|
||||||
after = len(BicepModuleCatalog._modules)
|
after = len(BicepModuleCatalog._modules)
|
||||||
@@ -61,8 +124,16 @@ async def _health_app() -> web.Application:
|
|||||||
"bicep_modules_after": after,
|
"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_get("/health", health)
|
||||||
app.router.add_post("/reload", reload)
|
app.router.add_post("/reload", reload)
|
||||||
|
app.router.add_get("/python", python_ws)
|
||||||
|
app.router.add_get("/bicep", bicep_ws)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -72,24 +143,21 @@ async def main_async() -> None:
|
|||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-warm catalogs before accepting connections
|
|
||||||
logger.info("Pre-warming catalogs…")
|
logger.info("Pre-warming catalogs…")
|
||||||
BicepModuleCatalog.load()
|
BicepModuleCatalog.load()
|
||||||
await PypiCatalog.start_background_refresh()
|
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_python_lsp, args=(PYTHON_LSP_PORT,), daemon=True).start()
|
||||||
threading.Thread(target=serve_bicep, args=(BICEP_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
|
app = await _build_app()
|
||||||
health_app = await _health_app()
|
runner = web.AppRunner(app)
|
||||||
runner = web.AppRunner(health_app)
|
|
||||||
await runner.setup()
|
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()
|
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()
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user