feat: bake bicep catalog into image; fix dict-based modules parsing
Some checks failed
Build and Deploy iLSP / build-and-deploy (push) Has been cancelled
Build and Deploy iLSP / test (push) Successful in 18s

- Remove all DevOpsMCP/aiohttp runtime deps from BicepModuleCatalog
- BicepModuleCatalog.load() reads bicep_modules_catalog.json from disk at startup (sync)
- Fix _load_catalog: catalog uses dict {path: {versions, schema}} not a list
- server.py: call BicepModuleCatalog.load() synchronously, not via asyncio.gather
- Dockerfile: COPY bicep_modules_catalog.json into both builder + runtime stages
- Health endpoint now reports bicep_modules: 27

Verified locally: make run-quick → health returns pypi_packages:40 bicep_modules:27
This commit is contained in:
Henrik Jess Nielsen
2026-05-10 13:40:48 +02:00
parent 44da791f5a
commit 6b38cbd70c
4 changed files with 12658 additions and 95 deletions

View File

@@ -19,6 +19,7 @@ FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml .
COPY ilsp/ ilsp/
COPY bicep_modules_catalog.json .
RUN pip install --upgrade pip build \
&& python -m build --wheel --outdir /dist
@@ -40,6 +41,7 @@ COPY --from=bicep-downloader /opt/bicep-langserver /opt/bicep-langserver
# Install Python package and dependencies
COPY --from=builder /dist/*.whl /tmp/
COPY --from=builder /build/bicep_modules_catalog.json /bicep_modules_catalog.json
RUN pip3 install --no-cache-dir --break-system-packages /tmp/*.whl && rm /tmp/*.whl
# Configuration defaults (override via Nomad env)

12584
bicep_modules_catalog.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +1,69 @@
"""
LRU Bicep module catalog — fetched from DevOpsMCP at startup.
LRU Bicep module catalog — loaded from the bundled catalog file at startup.
The catalog (bicep_modules_catalog.json) is baked into the Docker image at build time.
No runtime dependency on DevOpsMCP or any external service.
Provides completion items for LRU-internal Bicep modules with
higher sort priority than standard Azure modules.
"""
import asyncio
import json
import logging
import os
import time
import pathlib
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
DEVOPS_MCP_URL = os.getenv("DEVOPS_MCP_URL", "https://devops-mcp.i80.dk")
REFRESH_INTERVAL = 3600 # 1 hour
# Catalog is baked into the image root at /bicep_modules_catalog.json
_CATALOG_PATHS = [
pathlib.Path("/bicep_modules_catalog.json"),
pathlib.Path(__file__).parent.parent.parent / "bicep_modules_catalog.json",
]
def _load_catalog() -> list[dict[str, Any]]:
"""Load modules from the bundled catalog file."""
for path in _CATALOG_PATHS:
if path.exists():
try:
data = json.loads(path.read_text())
modules_raw = data.get("modules", {})
registry = data.get("registry", "iactemplatereg.azurecr.io")
modules = []
# modules is a dict: { "bicep/modules/appservice": { versions: [...], ... }, ... }
for mod_path, info in modules_raw.items():
versions = info.get("versions", ["latest"])
name = mod_path.split("/")[-1] if "/" in mod_path else mod_path
modules.append({
"name": name,
"path": mod_path,
"versions": versions,
"latest": versions[-1] if versions else "latest",
"registry": registry,
})
logger.info("Bicep catalog loaded from %s: %d modules", path, len(modules))
return modules
except Exception:
logger.exception("Failed to parse catalog at %s", path)
logger.warning("No bicep_modules_catalog.json found — completions disabled")
return []
class BicepModuleCatalog:
"""Fetches and caches LRU Bicep modules from DevOpsMCP."""
"""In-memory catalog of LRU Bicep modules, loaded once at startup."""
_modules: list[dict[str, Any]] = []
_last_refresh: float = 0
_lock = asyncio.Lock()
@classmethod
async def get_modules(cls) -> list[dict[str, Any]]:
if not cls._modules or time.time() - cls._last_refresh > REFRESH_INTERVAL:
await cls._refresh()
def load(cls) -> None:
"""Load catalog from disk. Call once at startup."""
cls._modules = _load_catalog()
@classmethod
def get_modules(cls) -> list[dict[str, Any]]:
return cls._modules
@classmethod
async def start_background_refresh(cls) -> None:
asyncio.create_task(cls._refresh_loop())
@classmethod
async def _refresh_loop(cls) -> None:
while True:
await cls._refresh()
await asyncio.sleep(REFRESH_INTERVAL)
@classmethod
async def _refresh(cls) -> None:
async with cls._lock:
try:
modules = await cls._fetch_from_devops_mcp()
cls._modules = modules
cls._last_refresh = time.time()
logger.info("Bicep module catalog refreshed: %d modules", len(modules))
except Exception:
logger.exception("Failed to refresh Bicep module catalog — using stale cache")
@classmethod
async def _fetch_from_devops_mcp(cls) -> list[dict[str, Any]]:
"""Call DevOpsMCP /api/bicep-modules REST endpoint."""
url = f"{DEVOPS_MCP_URL}/api/bicep-modules"
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=20)) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise RuntimeError(f"DevOpsMCP returned {resp.status}")
data = await resp.json()
modules = []
for m in data.get("modules", []):
path = m.get("path", "")
versions = m.get("versions", ["latest"])
name = path.split("/")[-1] if "/" in path else path
modules.append({
"name": name,
"path": path,
"versions": versions,
"latest": versions[-1] if versions else "latest",
"registry": data.get("registry", "iactemplatereg.azurecr.io"),
})
return modules
@classmethod
def as_completion_items(cls) -> list[dict[str, Any]]:
items = []
@@ -89,7 +74,7 @@ class BicepModuleCatalog:
"kind": 9, # Module
"detail": f"LRU Bicep module — {mod['registry']}",
"insertText": ref,
"sortText": f"0_lru_{mod['name']}", # sorts above standard az modules
"sortText": f"0_lru_{mod['name']}",
"documentation": {
"kind": "markdown",
"value": (

View File

@@ -11,11 +11,14 @@ import asyncio
import logging
import os
import signal
import subprocess
import sys
import threading
from aiohttp import web
from .python_lsp.catalog import PypiCatalog
from .bicep_lsp.modules import BicepModuleCatalog
from .bicep_lsp.proxy import serve_bicep
logger = logging.getLogger(__name__)
@@ -25,58 +28,47 @@ BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
HEALTH_PORT = int(os.getenv("HEALTH_PORT", "2089"))
def _serve_python_lsp(port: int) -> None:
"""Start pylsp in TCP server mode; restart on unexpected exit (blocking)."""
while True:
proc = subprocess.Popen(
[sys.executable, "-m", "pylsp", "--tcp", "--host", "0.0.0.0", "--port", str(port)],
)
logger.info("Python LSP (pylsp) listening on TCP :%d PID=%d", port, proc.pid)
proc.wait()
logger.warning("pylsp exited (code %s) — restarting", proc.returncode)
async def _health_app() -> web.Application:
app = web.Application()
async def health(_: web.Request) -> web.Response:
pypi_count = len(PypiCatalog._packages)
from .bicep_lsp.modules import BicepModuleCatalog
bicep_count = len(BicepModuleCatalog._modules)
return web.json_response({
"status": "ok",
"pypi_packages": pypi_count,
"bicep_modules": bicep_count,
"pypi_packages": len(PypiCatalog._packages),
"bicep_modules": len(BicepModuleCatalog._modules),
})
app.router.add_get("/health", health)
return app
async def _serve_python_lsp(port: int) -> None:
"""Start pylsp in TCP server mode."""
import subprocess, sys
proc = await asyncio.create_subprocess_exec(
sys.executable, "-m", "pylsp",
"--tcp", "--host", "0.0.0.0", "--port", str(port),
)
logger.info("Python LSP (pylsp) listening on TCP :%d PID=%d", port, proc.pid)
await proc.wait()
logger.warning("pylsp exited — restarting")
async def main_async() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logging.getLogger("ilsp.bicep_lsp.proxy").setLevel(logging.DEBUG)
# Pre-warm caches
# Pre-warm catalogs before accepting connections
logger.info("Pre-warming catalogs…")
from .bicep_lsp.modules import BicepModuleCatalog
await asyncio.gather(
PypiCatalog.start_background_refresh(),
BicepModuleCatalog.start_background_refresh(),
)
BicepModuleCatalog.load()
await PypiCatalog.start_background_refresh()
# Start Bicep LSP proxy in a daemon thread (blocking socket server)
threading.Thread(
target=serve_bicep,
args=(BICEP_LSP_PORT,),
daemon=True,
).start()
# Both LSP servers run in daemon threads (blocking socket/process loops)
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()
# Build health app
# Health HTTP server — keeps the process alive via the asyncio event loop
health_app = await _health_app()
runner = web.AppRunner(health_app)
await runner.setup()
@@ -84,8 +76,8 @@ async def main_async() -> None:
await site.start()
logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT)
# Run remaining asyncio services
await _serve_python_lsp(PYTHON_LSP_PORT)
# Wait indefinitely; signal handlers in main() will stop the loop
await asyncio.Event().wait()
def main() -> None: