2026-05-10 12:23:05 +02:00
|
|
|
"""
|
|
|
|
|
iLSP main entrypoint.
|
|
|
|
|
|
2026-05-10 14:44:33 +02:00
|
|
|
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
|
2026-05-10 12:23:05 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import signal
|
2026-05-10 13:40:48 +02:00
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-05-10 13:02:52 +02:00
|
|
|
import threading
|
2026-05-10 12:23:05 +02:00
|
|
|
|
|
|
|
|
from aiohttp import web
|
|
|
|
|
|
|
|
|
|
from .python_lsp.catalog import PypiCatalog
|
2026-05-10 13:40:48 +02:00
|
|
|
from .bicep_lsp.modules import BicepModuleCatalog
|
2026-05-10 12:23:05 +02:00
|
|
|
from .bicep_lsp.proxy import serve_bicep
|
2026-05-10 15:59:37 +02:00
|
|
|
from .yaml_lsp.catalog import PipelineTemplateCatalog
|
|
|
|
|
from .yaml_lsp.proxy import yaml_ws_handler
|
2026-05-10 12:23:05 +02:00
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
|
|
|
|
|
BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
|
2026-05-10 14:44:33 +02:00
|
|
|
HTTP_PORT = int(os.getenv("HTTP_PORT", "8000"))
|
|
|
|
|
|
|
|
|
|
_CHUNK = 65536
|
2026-05-10 12:23:05 +02:00
|
|
|
|
|
|
|
|
|
2026-05-10 13:40:48 +02:00
|
|
|
def _serve_python_lsp(port: int) -> None:
|
2026-05-10 14:44:33 +02:00
|
|
|
"""Start pylsp in TCP mode on localhost only; restart on unexpected exit."""
|
2026-05-10 13:40:48 +02:00
|
|
|
while True:
|
|
|
|
|
proc = subprocess.Popen(
|
2026-05-10 14:44:33 +02:00
|
|
|
[sys.executable, "-m", "pylsp", "--tcp", "--host", "127.0.0.1", "--port", str(port)],
|
2026-05-10 13:40:48 +02:00
|
|
|
)
|
2026-05-10 14:44:33 +02:00
|
|
|
logger.info("pylsp listening on localhost:%d PID=%d", port, proc.pid)
|
2026-05-10 13:40:48 +02:00
|
|
|
proc.wait()
|
|
|
|
|
logger.warning("pylsp exited (code %s) — restarting", proc.returncode)
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 14:44:33 +02:00
|
|
|
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:
|
2026-05-10 15:13:03 +02:00
|
|
|
tcp_reader, tcp_writer = await asyncio.wait_for(
|
|
|
|
|
asyncio.open_connection(host, port), timeout=3.0
|
|
|
|
|
)
|
|
|
|
|
except (OSError, asyncio.TimeoutError) as exc:
|
2026-05-10 14:44:33 +02:00
|
|
|
logger.error("Cannot connect to LSP on %s:%d — %s", host, port, exc)
|
2026-05-10 15:16:55 +02:00
|
|
|
await ws.close(code=1011, message=b"LSP backend unavailable", timeout=2.0)
|
2026-05-10 14:44:33 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 18:17:14 +02:00
|
|
|
_INDEX_HTML = """\
|
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
<title>iLSP — Language Server Proxy</title>
|
|
|
|
|
<style>
|
|
|
|
|
*, *::before, *::after {{ box-sizing: border-box; }}
|
|
|
|
|
:root {{
|
|
|
|
|
--bg: #ffffff;
|
|
|
|
|
--surface: #f6f7f9;
|
|
|
|
|
--border: #e2e4e8;
|
|
|
|
|
--text: #1a1d23;
|
|
|
|
|
--muted: #6b7280;
|
|
|
|
|
--accent: #2563eb;
|
|
|
|
|
--accent-light: #eff6ff;
|
|
|
|
|
--code-bg: #f1f3f5;
|
|
|
|
|
--green: #16a34a;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
}}
|
|
|
|
|
body {{
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--text);
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}}
|
|
|
|
|
header {{
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
padding: 1.5rem 2rem;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
}}
|
|
|
|
|
header h1 {{
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 1.4rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
letter-spacing: -0.02em;
|
|
|
|
|
}}
|
|
|
|
|
header span {{
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
}}
|
|
|
|
|
.badge {{
|
|
|
|
|
display: inline-block;
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
padding: 0.15rem 0.5rem;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
border: 1px solid #bfdbfe;
|
|
|
|
|
}}
|
|
|
|
|
main {{
|
|
|
|
|
max-width: 860px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 2.5rem 2rem 4rem;
|
|
|
|
|
}}
|
|
|
|
|
h2 {{
|
|
|
|
|
font-size: 1.05rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
margin: 2.5rem 0 0.75rem;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
padding-bottom: 0.4rem;
|
|
|
|
|
letter-spacing: -0.01em;
|
|
|
|
|
}}
|
|
|
|
|
p {{ margin: 0.5rem 0 0.75rem; color: var(--text); }}
|
|
|
|
|
.intro {{ color: var(--muted); margin-top: 0; }}
|
|
|
|
|
table {{
|
|
|
|
|
width: 100%;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
margin: 1rem 0;
|
|
|
|
|
}}
|
|
|
|
|
th {{
|
|
|
|
|
text-align: left;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.05em;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
border-bottom: 2px solid var(--border);
|
|
|
|
|
padding: 0.5rem 0.75rem;
|
|
|
|
|
}}
|
|
|
|
|
td {{
|
|
|
|
|
padding: 0.55rem 0.75rem;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
vertical-align: top;
|
|
|
|
|
}}
|
|
|
|
|
tr:last-child td {{ border-bottom: none; }}
|
|
|
|
|
td:first-child {{ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 0.85rem; white-space: nowrap; }}
|
|
|
|
|
code {{
|
|
|
|
|
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
|
|
|
|
|
font-size: 0.85em;
|
|
|
|
|
background: var(--code-bg);
|
|
|
|
|
padding: 0.15em 0.35em;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
}}
|
|
|
|
|
pre {{
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 1rem 1.25rem;
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
margin: 0.75rem 0 1.25rem;
|
|
|
|
|
}}
|
|
|
|
|
pre code {{ background: none; padding: 0; font-size: inherit; }}
|
|
|
|
|
.status-row {{
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 1.5rem;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
margin: 1rem 0;
|
|
|
|
|
}}
|
|
|
|
|
.stat {{
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
padding: 0.75rem 1.25rem;
|
|
|
|
|
min-width: 140px;
|
|
|
|
|
}}
|
|
|
|
|
.stat .num {{
|
|
|
|
|
font-size: 1.6rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
line-height: 1;
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}}
|
|
|
|
|
.stat .lbl {{
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
margin-top: 0.2rem;
|
|
|
|
|
}}
|
|
|
|
|
.note {{
|
|
|
|
|
background: var(--accent-light);
|
|
|
|
|
border-left: 3px solid var(--accent);
|
|
|
|
|
border-radius: 0 6px 6px 0;
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
color: #1e3a8a;
|
|
|
|
|
margin: 1rem 0;
|
|
|
|
|
}}
|
|
|
|
|
footer {{
|
|
|
|
|
text-align: center;
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
border-top: 1px solid var(--border);
|
|
|
|
|
}}
|
|
|
|
|
a {{ color: var(--accent); text-decoration: none; }}
|
|
|
|
|
a:hover {{ text-decoration: underline; }}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<header>
|
|
|
|
|
<h1>iLSP</h1>
|
|
|
|
|
<span>Language Server Proxy</span>
|
|
|
|
|
<span class="badge">self-hosted</span>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<main>
|
|
|
|
|
|
|
|
|
|
<p class="intro">
|
|
|
|
|
iLSP is a WebSocket gateway that exposes language server protocol (LSP)
|
|
|
|
|
backends over a single HTTP endpoint. Editors connect via WebSocket and
|
|
|
|
|
receive code intelligence — completions, hover, diagnostics — for Bicep,
|
|
|
|
|
YAML pipelines, and Python, enriched with organisation-specific catalogs.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<h2>What is LSP</h2>
|
|
|
|
|
<p>
|
|
|
|
|
The <a href="https://microsoft.github.io/language-server-protocol/">Language Server Protocol</a>
|
|
|
|
|
is a standard protocol between editors and language analysis tools.
|
|
|
|
|
An editor sends requests (go-to-definition, completion, hover) and receives
|
|
|
|
|
structured responses without knowing anything about the language itself.
|
|
|
|
|
Any editor that speaks LSP — IntelliJ, Neovim, VS Code, Helix — works
|
|
|
|
|
with any LSP server.
|
|
|
|
|
</p>
|
|
|
|
|
<p>
|
|
|
|
|
iLSP wraps existing language servers (Bicep Language Server,
|
|
|
|
|
yaml-language-server, pylsp) and injects completion items from
|
|
|
|
|
internal catalogs before responses are returned to the editor.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<h2>Live status</h2>
|
|
|
|
|
<div class="status-row">
|
|
|
|
|
<div class="stat"><div class="num">{bicep_modules}</div><div class="lbl">Bicep modules</div></div>
|
|
|
|
|
<div class="stat"><div class="num">{pipeline_templates}</div><div class="lbl">Pipeline templates</div></div>
|
|
|
|
|
<div class="stat"><div class="num">{pypi_packages}</div><div class="lbl">Python packages</div></div>
|
|
|
|
|
<div class="stat"><div class="num">254</div><div class="lbl">AzDO tasks</div></div>
|
|
|
|
|
</div>
|
|
|
|
|
<p style="font-size:0.82rem;color:var(--muted)">
|
|
|
|
|
Full JSON status: <a href="/health">/health</a>
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<h2>WebSocket endpoints</h2>
|
|
|
|
|
<table>
|
|
|
|
|
<thead><tr><th>Endpoint</th><th>Language server</th><th>Extra completions</th></tr></thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr>
|
|
|
|
|
<td>wss://ilsp.i80.dk/bicep</td>
|
|
|
|
|
<td>Bicep Language Server</td>
|
|
|
|
|
<td>ACR module paths, versions, params</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td>wss://ilsp.i80.dk/yaml</td>
|
|
|
|
|
<td>yaml-language-server</td>
|
|
|
|
|
<td>AzDO task schema (254 tasks), pipeline templates</td>
|
|
|
|
|
</tr>
|
|
|
|
|
<tr>
|
|
|
|
|
<td>wss://ilsp.i80.dk/python</td>
|
|
|
|
|
<td>pylsp (Jedi)</td>
|
|
|
|
|
<td>Internal PyPI package stubs</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<h2>Editor setup</h2>
|
|
|
|
|
|
|
|
|
|
<p><strong>Neovim</strong> — add to your LSP config (<code>init.lua</code>):</p>
|
|
|
|
|
<pre><code>-- Bicep
|
|
|
|
|
vim.lsp.start({{
|
|
|
|
|
name = "ilsp-bicep",
|
|
|
|
|
cmd = {{ "websocat", "wss://ilsp.i80.dk/bicep" }},
|
|
|
|
|
filetypes = {{ "bicep" }},
|
|
|
|
|
}})
|
|
|
|
|
|
|
|
|
|
-- YAML pipelines
|
|
|
|
|
vim.lsp.start({{
|
|
|
|
|
name = "ilsp-yaml",
|
|
|
|
|
cmd = {{ "websocat", "wss://ilsp.i80.dk/yaml" }},
|
|
|
|
|
filetypes = {{ "yaml" }},
|
|
|
|
|
}})</code></pre>
|
|
|
|
|
|
|
|
|
|
<p>
|
|
|
|
|
<strong>IntelliJ IDEA / Rider</strong> — install the
|
|
|
|
|
<a href="https://plugins.jetbrains.com/plugin/10209-lsp-client">LSP client plugin</a>,
|
|
|
|
|
then add a server under <em>Settings → Languages & Frameworks → Language Servers</em>:
|
|
|
|
|
</p>
|
|
|
|
|
<pre><code>Name: iLSP Bicep
|
|
|
|
|
Command: websocat wss://ilsp.i80.dk/bicep
|
|
|
|
|
Pattern: *.bicep
|
|
|
|
|
|
|
|
|
|
Name: iLSP YAML
|
|
|
|
|
Command: websocat wss://ilsp.i80.dk/yaml
|
|
|
|
|
Pattern: azure-pipelines.yml, *.yaml</code></pre>
|
|
|
|
|
|
|
|
|
|
<div class="note">
|
|
|
|
|
<a href="https://github.com/vi/websocat">websocat</a> bridges a WebSocket to
|
|
|
|
|
stdio so editors that expect a local process can connect to a remote LSP.
|
|
|
|
|
Install with <code>brew install websocat</code> or
|
|
|
|
|
<code>cargo install websocat</code>.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p><strong>VS Code</strong> — use the
|
|
|
|
|
<a href="https://marketplace.visualstudio.com/items?itemName=mesonbuild.mesonlsp">LSP client</a>
|
|
|
|
|
or configure the built-in
|
|
|
|
|
<code>vscode-languageclient</code> in a workspace extension, pointing
|
|
|
|
|
<code>serverOptions.command</code> at <code>websocat wss://ilsp.i80.dk/bicep</code>.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<h2>Updating the catalogs</h2>
|
|
|
|
|
<p>
|
|
|
|
|
Catalogs are baked into the Docker image at build time and can also be
|
|
|
|
|
refreshed at runtime without restarting. Run the sync scripts from the
|
|
|
|
|
iLSP repository and call the reload endpoint:
|
|
|
|
|
</p>
|
|
|
|
|
<pre><code># Sync Bicep module catalog from ACR and push
|
|
|
|
|
python3 scripts/sync_push_catalogs.py
|
|
|
|
|
|
|
|
|
|
# Trigger a hot reload without container restart
|
|
|
|
|
curl -X POST https://ilsp.i80.dk/reload</code></pre>
|
|
|
|
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<footer>
|
|
|
|
|
iLSP — <a href="https://gea.i80.dk/hjess/iLSP">source</a> —
|
|
|
|
|
<a href="/health">health</a>
|
|
|
|
|
</footer>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 14:44:33 +02:00
|
|
|
async def _build_app() -> web.Application:
|
2026-05-10 12:23:05 +02:00
|
|
|
app = web.Application()
|
|
|
|
|
|
2026-05-10 18:17:14 +02:00
|
|
|
async def index(_: web.Request) -> web.Response:
|
|
|
|
|
html = _INDEX_HTML.format(
|
|
|
|
|
bicep_modules=len(BicepModuleCatalog._modules),
|
|
|
|
|
pipeline_templates=PipelineTemplateCatalog.template_count(),
|
|
|
|
|
pypi_packages=len(PypiCatalog._packages),
|
|
|
|
|
)
|
|
|
|
|
return web.Response(text=html, content_type="text/html", charset="utf-8")
|
|
|
|
|
|
2026-05-10 12:23:05 +02:00
|
|
|
async def health(_: web.Request) -> web.Response:
|
2026-05-10 15:40:13 +02:00
|
|
|
import shutil
|
2026-05-10 18:17:14 +02:00
|
|
|
from .yaml_lsp.proxy import _AZDO_SCHEMA_PATH
|
2026-05-10 12:23:05 +02:00
|
|
|
return web.json_response({
|
|
|
|
|
"status": "ok",
|
2026-05-10 13:40:48 +02:00
|
|
|
"pypi_packages": len(PypiCatalog._packages),
|
|
|
|
|
"bicep_modules": len(BicepModuleCatalog._modules),
|
2026-05-10 15:40:13 +02:00
|
|
|
"iac_source_modules": len(BicepModuleCatalog._iac),
|
|
|
|
|
"yaml_lsp": bool(shutil.which("yaml-language-server")),
|
2026-05-10 15:59:37 +02:00
|
|
|
"pipeline_templates": PipelineTemplateCatalog.template_count(),
|
2026-05-10 18:17:14 +02:00
|
|
|
"azdo_pipeline_schema": _AZDO_SCHEMA_PATH.exists(),
|
2026-05-10 12:23:05 +02:00
|
|
|
})
|
|
|
|
|
|
2026-05-10 13:51:01 +02:00
|
|
|
async def reload(_: web.Request) -> web.Response:
|
2026-05-10 15:59:37 +02:00
|
|
|
before_bicep = len(BicepModuleCatalog._modules)
|
|
|
|
|
before_tmpl = PipelineTemplateCatalog.template_count()
|
2026-05-10 13:51:01 +02:00
|
|
|
BicepModuleCatalog.load()
|
2026-05-10 15:59:37 +02:00
|
|
|
PipelineTemplateCatalog.load()
|
|
|
|
|
after_bicep = len(BicepModuleCatalog._modules)
|
|
|
|
|
after_tmpl = PipelineTemplateCatalog.template_count()
|
|
|
|
|
logger.info(
|
|
|
|
|
"Catalog reloaded: bicep %d→%d templates %d→%d",
|
|
|
|
|
before_bicep, after_bicep, before_tmpl, after_tmpl,
|
|
|
|
|
)
|
2026-05-10 13:51:01 +02:00
|
|
|
return web.json_response({
|
|
|
|
|
"status": "reloaded",
|
2026-05-10 15:59:37 +02:00
|
|
|
"bicep_modules_before": before_bicep,
|
|
|
|
|
"bicep_modules_after": after_bicep,
|
|
|
|
|
"pipeline_templates_before": before_tmpl,
|
|
|
|
|
"pipeline_templates_after": after_tmpl,
|
2026-05-10 13:51:01 +02:00
|
|
|
})
|
|
|
|
|
|
2026-05-10 14:44:33 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-05-10 15:40:13 +02:00
|
|
|
async def yaml_ws(request: web.Request) -> web.WebSocketResponse:
|
2026-05-10 16:37:42 +02:00
|
|
|
return await yaml_ws_handler(request)
|
2026-05-10 15:40:13 +02:00
|
|
|
|
2026-05-10 18:17:14 +02:00
|
|
|
app.router.add_get("/", index)
|
2026-05-10 12:23:05 +02:00
|
|
|
app.router.add_get("/health", health)
|
2026-05-10 13:51:01 +02:00
|
|
|
app.router.add_post("/reload", reload)
|
2026-05-10 14:44:33 +02:00
|
|
|
app.router.add_get("/python", python_ws)
|
|
|
|
|
app.router.add_get("/bicep", bicep_ws)
|
2026-05-10 15:40:13 +02:00
|
|
|
app.router.add_get("/yaml", yaml_ws)
|
2026-05-10 12:23:05 +02:00
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def main_async() -> None:
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info("Pre-warming catalogs…")
|
2026-05-10 13:40:48 +02:00
|
|
|
BicepModuleCatalog.load()
|
2026-05-10 15:59:37 +02:00
|
|
|
PipelineTemplateCatalog.load()
|
2026-05-10 13:40:48 +02:00
|
|
|
await PypiCatalog.start_background_refresh()
|
2026-05-10 12:23:05 +02:00
|
|
|
|
2026-05-10 14:44:33 +02:00
|
|
|
# LSP servers run internally on localhost — not exposed outside the container
|
2026-05-10 13:40:48 +02:00
|
|
|
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()
|
2026-05-10 16:37:42 +02:00
|
|
|
# yaml-language-server is spawned per-connection in yaml_ws_handler (--stdio mode)
|
2026-05-10 13:02:52 +02:00
|
|
|
|
2026-05-10 14:44:33 +02:00
|
|
|
app = await _build_app()
|
|
|
|
|
runner = web.AppRunner(app)
|
2026-05-10 12:23:05 +02:00
|
|
|
await runner.setup()
|
2026-05-10 14:44:33 +02:00
|
|
|
site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT)
|
2026-05-10 12:23:05 +02:00
|
|
|
await site.start()
|
2026-05-10 15:40:13 +02:00
|
|
|
logger.info(
|
|
|
|
|
"iLSP HTTP on http://0.0.0.0:%d (ws://.../python ws://.../bicep ws://.../yaml)",
|
|
|
|
|
HTTP_PORT,
|
|
|
|
|
)
|
2026-05-10 12:23:05 +02:00
|
|
|
|
2026-05-10 13:40:48 +02:00
|
|
|
await asyncio.Event().wait()
|
2026-05-10 12:23:05 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
loop = asyncio.new_event_loop()
|
|
|
|
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
|
|
|
loop.add_signal_handler(sig, loop.stop)
|
|
|
|
|
loop.run_until_complete(main_async())
|