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:
@@ -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
12584
bicep_modules_catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user