feat: bake bicep catalog into image; fix dict-based modules parsing
- 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:
@@ -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": (
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user