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 WORKDIR /build
COPY pyproject.toml . COPY pyproject.toml .
COPY ilsp/ ilsp/ COPY ilsp/ ilsp/
COPY bicep_modules_catalog.json .
RUN pip install --upgrade pip build \ RUN pip install --upgrade pip build \
&& python -m build --wheel --outdir /dist && 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 # Install Python package and dependencies
COPY --from=builder /dist/*.whl /tmp/ 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 RUN pip3 install --no-cache-dir --break-system-packages /tmp/*.whl && rm /tmp/*.whl
# Configuration defaults (override via Nomad env) # 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 Provides completion items for LRU-internal Bicep modules with
higher sort priority than standard Azure modules. higher sort priority than standard Azure modules.
""" """
import asyncio import json
import logging import logging
import os import pathlib
import time
from typing import Any from typing import Any
import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEVOPS_MCP_URL = os.getenv("DEVOPS_MCP_URL", "https://devops-mcp.i80.dk") # Catalog is baked into the image root at /bicep_modules_catalog.json
REFRESH_INTERVAL = 3600 # 1 hour _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: 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]] = [] _modules: list[dict[str, Any]] = []
_last_refresh: float = 0
_lock = asyncio.Lock()
@classmethod @classmethod
async def get_modules(cls) -> list[dict[str, Any]]: def load(cls) -> None:
if not cls._modules or time.time() - cls._last_refresh > REFRESH_INTERVAL: """Load catalog from disk. Call once at startup."""
await cls._refresh() cls._modules = _load_catalog()
@classmethod
def get_modules(cls) -> list[dict[str, Any]]:
return cls._modules 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 @classmethod
def as_completion_items(cls) -> list[dict[str, Any]]: def as_completion_items(cls) -> list[dict[str, Any]]:
items = [] items = []
@@ -89,7 +74,7 @@ class BicepModuleCatalog:
"kind": 9, # Module "kind": 9, # Module
"detail": f"LRU Bicep module — {mod['registry']}", "detail": f"LRU Bicep module — {mod['registry']}",
"insertText": ref, "insertText": ref,
"sortText": f"0_lru_{mod['name']}", # sorts above standard az modules "sortText": f"0_lru_{mod['name']}",
"documentation": { "documentation": {
"kind": "markdown", "kind": "markdown",
"value": ( "value": (

View File

@@ -11,11 +11,14 @@ import asyncio
import logging import logging
import os import os
import signal import signal
import subprocess
import sys
import threading import threading
from aiohttp import web from aiohttp import web
from .python_lsp.catalog import PypiCatalog from .python_lsp.catalog import PypiCatalog
from .bicep_lsp.modules import BicepModuleCatalog
from .bicep_lsp.proxy import serve_bicep from .bicep_lsp.proxy import serve_bicep
logger = logging.getLogger(__name__) 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")) 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: async def _health_app() -> web.Application:
app = web.Application() app = web.Application()
async def health(_: web.Request) -> web.Response: 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({ return web.json_response({
"status": "ok", "status": "ok",
"pypi_packages": pypi_count, "pypi_packages": len(PypiCatalog._packages),
"bicep_modules": bicep_count, "bicep_modules": len(BicepModuleCatalog._modules),
}) })
app.router.add_get("/health", health) app.router.add_get("/health", health)
return app 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: async def main_async() -> None:
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", 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…") logger.info("Pre-warming catalogs…")
from .bicep_lsp.modules import BicepModuleCatalog BicepModuleCatalog.load()
await asyncio.gather( await PypiCatalog.start_background_refresh()
PypiCatalog.start_background_refresh(),
BicepModuleCatalog.start_background_refresh(),
)
# Start Bicep LSP proxy in a daemon thread (blocking socket server) # Both LSP servers run in daemon threads (blocking socket/process loops)
threading.Thread( threading.Thread(target=_serve_python_lsp, args=(PYTHON_LSP_PORT,), daemon=True).start()
target=serve_bicep, threading.Thread(target=serve_bicep, args=(BICEP_LSP_PORT,), daemon=True).start()
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() health_app = await _health_app()
runner = web.AppRunner(health_app) runner = web.AppRunner(health_app)
await runner.setup() await runner.setup()
@@ -84,8 +76,8 @@ async def main_async() -> None:
await site.start() await site.start()
logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT) logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT)
# Run remaining asyncio services # Wait indefinitely; signal handlers in main() will stop the loop
await _serve_python_lsp(PYTHON_LSP_PORT) await asyncio.Event().wait()
def main() -> None: def main() -> None: