From 5501254b558c29bea3fbd6d1e78d31c470176e85 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sun, 10 May 2026 15:40:13 +0200 Subject: [PATCH] feat: YAML LSP (/yaml endpoint) + IAC source catalog enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add yaml-language-server (Node.js) to Dockerfile stage 3 - Add YAML_LSP_PORT=2090 env var (Dockerfile + ilsp.nomad) - Start yaml-language-server in background thread (_serve_yaml_lsp) - Expose /yaml WebSocket endpoint (same WS→TCP proxy as /python and /bicep) - Load iac_source_catalog.json alongside bicep_modules_catalog.json - Enrich param_completion_items() with descriptions + required flag from IAC source - Required params sorted first (sortText 0_lru_param_0_...) and marked with * - detail field shows * prefix for required params - Update /health to report iac_source_modules + yaml_lsp fields - Rewrite EDITOR_SETUP.md: WebSocket URLs, YAML schemas config for all editors (Helix, Neovim, PyCharm, VS Code) with azure-pipelines + gitea actions schemas - All 35 tests pass --- Dockerfile | 6 +- editor_configs/EDITOR_SETUP.md | 110 ++++++++++++++++++++++++--------- ilsp.nomad | 1 + ilsp/bicep_lsp/modules.py | 52 ++++++++++++++-- ilsp/server.py | 28 ++++++++- 5 files changed, 161 insertions(+), 36 deletions(-) diff --git a/Dockerfile b/Dockerfile index da54ca8..fa2f841 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,11 @@ RUN pip install --upgrade pip build \ # issue on newer Debian hosts (trixie+ rejects packages.microsoft.com GPG since 2026-02-01). FROM mcr.microsoft.com/dotnet/runtime:10.0 -# Install Python 3 + pip (the dotnet base image is Debian bookworm) +# Install Python 3 + pip + Node.js (for yaml-language-server) RUN apt-get update \ && apt-get install -y --no-install-recommends \ - python3 python3-pip wget \ + python3 python3-pip wget nodejs npm \ + && npm install -g yaml-language-server \ && rm -rf /var/lib/apt/lists/* # Copy Bicep Language Server (baked in at build time — no volume needed) @@ -47,6 +48,7 @@ RUN pip3 install --no-cache-dir --break-system-packages /tmp/*.whl && rm /tmp/*. # Configuration defaults (override via Nomad env) ENV PYTHON_LSP_PORT=2087 \ BICEP_LSP_PORT=2088 \ + YAML_LSP_PORT=2090 \ HEALTH_PORT=2089 \ BICEP_LS_PATH=/opt/bicep-langserver/Bicep.LangServer.dll \ DEVOPS_MCP_URL=https://devops-mcp.i80.dk \ diff --git a/editor_configs/EDITOR_SETUP.md b/editor_configs/EDITOR_SETUP.md index 65adca1..dcb0b64 100644 --- a/editor_configs/EDITOR_SETUP.md +++ b/editor_configs/EDITOR_SETUP.md @@ -1,42 +1,105 @@ -# Editor configs for lsp.i80.dk +# Editor configs for ilsp.i80.dk + +## Available endpoints + +| Endpoint | Language | Port (internal) | +|----------|----------|-----------------| +| `wss://ilsp.i80.dk/python` | Python | 2087 | +| `wss://ilsp.i80.dk/bicep` | Bicep + LRU modules | 2088 | +| `wss://ilsp.i80.dk/yaml` | YAML (pipelines, Actions) | 2090 | + +## VS Code (`.vscode/settings.json` — recommended) + +Install the **vscode-lsp-client** or use the built-in `yaml` extension for YAML: + +```json +{ + "yaml.schemas": { + "https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json": [ + "azure-pipelines.yml", + "azure-pipelines*.yml" + ], + "https://json.schemastore.org/github-workflow.json": [ + ".github/workflows/*.yml", + ".gitea/workflows/*.yml" + ] + }, + "yaml.customTags": [], + "yaml.validate": true, + "[yaml]": { + "editor.defaultFormatter": "redhat.vscode-yaml" + } +} +``` + +> For Python and Bicep in VS Code, the built-in extensions (Pylance, Bicep) are preferred. +> Use ilsp endpoints if you want i80-specific completions (pypi packages, LRU modules). ## Helix (`~/.config/helix/languages.toml`) ```toml [language-server.ilsp-python] -command = "nc" -args = ["lsp.i80.dk", "2087"] +command = "websocat" +args = ["wss://ilsp.i80.dk/python"] [language-server.ilsp-bicep] -command = "nc" -args = ["lsp.i80.dk", "2088"] +command = "websocat" +args = ["wss://ilsp.i80.dk/bicep"] + +[language-server.ilsp-yaml] +command = "websocat" +args = ["wss://ilsp.i80.dk/yaml"] [[language]] name = "python" -language-servers = ["ilsp-python", "pylsp"] # runs alongside local pylsp if present +language-servers = ["ilsp-python"] [[language]] name = "bicep" file-types = ["bicep", "bicepparam"] language-servers = ["ilsp-bicep"] + +[[language]] +name = "yaml" +file-types = ["yaml", "yml"] +language-servers = ["ilsp-yaml"] ``` ## Neovim (`~/.config/nvim/lua/lsp.lua`) ```lua --- Python: i80 completions alongside pyright +-- Python: i80 pypi completions vim.lsp.start({ - name = "ilsp-python", - cmd = { "nc", "lsp.i80.dk", "2087" }, + name = "ilsp-python", + cmd = { "websocat", "wss://ilsp.i80.dk/python" }, filetypes = { "python" }, }) --- Bicep: full Bicep LS via i80 proxy +-- Bicep: LRU module completions vim.lsp.start({ - name = "ilsp-bicep", - cmd = { "nc", "lsp.i80.dk", "2088" }, + name = "ilsp-bicep", + cmd = { "websocat", "wss://ilsp.i80.dk/bicep" }, filetypes = { "bicep" }, }) + +-- YAML: Azure Pipelines + Gitea Actions schema +vim.lsp.start({ + name = "ilsp-yaml", + cmd = { "websocat", "wss://ilsp.i80.dk/yaml" }, + filetypes = { "yaml" }, + settings = { + yaml = { + schemas = { + ["https://raw.githubusercontent.com/microsoft/azure-pipelines-vscode/master/service-schema.json"] = { + "azure-pipelines.yml", "azure-pipelines*.yml" + }, + ["https://json.schemastore.org/github-workflow.json"] = { + ".github/workflows/*.yml", ".gitea/workflows/*.yml" + }, + }, + }, + }, +}) ``` ## PyCharm / IntelliJ IDEA Ultimate (LSP4IJ plugin) @@ -44,22 +107,11 @@ vim.lsp.start({ 1. Install **LSP4IJ** from JetBrains Marketplace 2. Settings → Languages & Frameworks → Language Servers → **+** -| Field | Python | Bicep | -|----------------|-------------------------|-------------------------| -| Name | `ilsp-python` | `ilsp-bicep` | -| Server type | External process | External process | -| Command | `nc lsp.i80.dk 2087` | `nc lsp.i80.dk 2088` | -| File patterns | `*.py` | `*.bicep, *.bicepparam` | +| Field | Python | Bicep | YAML | +|----------------|-------------------------------------|--------------------------------|-------------------------------------| +| Name | `ilsp-python` | `ilsp-bicep` | `ilsp-yaml` | +| Server type | External process | External process | External process | +| Command | `websocat wss://ilsp.i80.dk/python` | `websocat wss://ilsp.i80.dk/bicep` | `websocat wss://ilsp.i80.dk/yaml` | +| File patterns | `*.py` | `*.bicep, *.bicepparam` | `*.yml, *.yaml` | > PyCharm's built-in Python intelligence runs **alongside** ilsp-python — additive, not replacing. - -## VS Code (`.vscode/settings.json`) - -```json -{ - "pylsp.server.command": ["nc", "lsp.i80.dk", "2087"], - "bicep.languageServerPath": "nc" -} -``` - -> Note: VS Code has better native support. Use only if you want the i80-specific completions. diff --git a/ilsp.nomad b/ilsp.nomad index b3a52bb..5fb23cd 100644 --- a/ilsp.nomad +++ b/ilsp.nomad @@ -106,6 +106,7 @@ job "ilsp" { HTTP_PORT = "${NOMAD_PORT_http}" PYTHON_LSP_PORT = "2087" BICEP_LSP_PORT = "2088" + YAML_LSP_PORT = "2090" DEVOPS_MCP_URL = "https://devops-mcp.i80.dk" PYTHONUNBUFFERED = "1" } diff --git a/ilsp/bicep_lsp/modules.py b/ilsp/bicep_lsp/modules.py index 10d4b15..920591a 100644 --- a/ilsp/bicep_lsp/modules.py +++ b/ilsp/bicep_lsp/modules.py @@ -29,6 +29,31 @@ _CATALOG_PATHS = [ pathlib.Path(__file__).parent.parent.parent / "bicep_modules_catalog.json", # dev ] +# IAC source catalog — richer param descriptions from Bicep source code +_IAC_SOURCE_PATHS = [ + pathlib.Path("/data/iac_source_catalog.json"), # volume-mount + pathlib.Path(__file__).parent.parent.parent / "iac_source_catalog.json", # dev +] + + +def _load_iac_source_catalog() -> dict[str, dict[str, Any]]: + """Load IAC source catalog for enriched param descriptions. + + Returns dict keyed by module name (e.g. 'roleassignments') → module info + with 'params' list containing name/description/required/type. + """ + for path in _IAC_SOURCE_PATHS: + if path.exists(): + try: + data = json.loads(path.read_text()) + modules = data.get("modules", {}) + logger.info("IAC source catalog loaded from %s: %d modules", path, len(modules)) + return modules + except Exception: + logger.exception("Failed to parse IAC source catalog at %s", path) + logger.info("No iac_source_catalog.json found — param descriptions unavailable") + return {} + def _load_catalog() -> list[dict[str, Any]]: """Load modules from the bundled catalog file, preserving per-version schema.""" @@ -62,11 +87,19 @@ class BicepModuleCatalog: """In-memory catalog of LRU Bicep modules, loaded once at startup.""" _modules: list[dict[str, Any]] = [] + _iac: dict[str, dict[str, Any]] = {} # module name → IAC source info @classmethod def load(cls) -> None: - """Load catalog from disk. Call once at startup.""" + """Load both catalogs from disk. Call once at startup.""" cls._modules = _load_catalog() + cls._iac = _load_iac_source_catalog() + + @classmethod + def _iac_param_map(cls, module_name: str) -> dict[str, dict[str, Any]]: + """Return {param_name: {description, required, type}} from IAC source catalog.""" + iac_mod = cls._iac.get(module_name, {}) + return {p["name"]: p for p in iac_mod.get("params", [])} @classmethod def get_modules(cls) -> list[dict[str, Any]]: @@ -206,12 +239,23 @@ class BicepModuleCatalog: logger.debug("Param fallback: %s %s→%s", module_name, version, v) break + # IAC source catalog: richer descriptions + required flag + iac_params = cls._iac_param_map(module_name) + items = [] for param_name, param_info in ver_params.items(): ptype = param_info.get("type", "any") - description = param_info.get("description", "").strip() allowed = param_info.get("allowed", []) + + # Prefer IAC source description (has human-readable text from Bicep source) + iac = iac_params.get(param_name, {}) + description = iac.get("description", "") or param_info.get("description", "") + description = description.strip() + required = iac.get("required", False) + doc_lines = [f"**{param_name}** (`{ptype}`)"] + if required: + doc_lines[0] += " ⚠️ required" if description: doc_lines.append(f"\n{description}") if allowed: @@ -221,9 +265,9 @@ class BicepModuleCatalog: items.append({ "label": param_name, "kind": 5, # Field - "detail": ptype, + "detail": f"{'*' if required else ''}{ptype}", "insertText": f"{param_name}: ", - "sortText": f"0_lru_param_{param_name}", + "sortText": f"0_lru_param_{'0' if required else '1'}_{param_name}", "documentation": { "kind": "markdown", "value": "\n".join(doc_lines), diff --git a/ilsp/server.py b/ilsp/server.py index cc00043..37c0002 100644 --- a/ilsp/server.py +++ b/ilsp/server.py @@ -29,6 +29,7 @@ logger = logging.getLogger(__name__) PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087")) BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088")) +YAML_LSP_PORT = int(os.getenv("YAML_LSP_PORT", "2090")) HTTP_PORT = int(os.getenv("HTTP_PORT", "8000")) _CHUNK = 65536 @@ -45,6 +46,20 @@ def _serve_python_lsp(port: int) -> None: logger.warning("pylsp exited (code %s) — restarting", proc.returncode) +def _serve_yaml_lsp(port: int) -> None: + """Start yaml-language-server in TCP socket mode; restart on unexpected exit.""" + import shutil + yaml_ls = shutil.which("yaml-language-server") + if not yaml_ls: + logger.warning("yaml-language-server not found — /yaml completions disabled") + return + while True: + proc = subprocess.Popen([yaml_ls, f"--socket={port}"]) + logger.info("yaml-language-server listening on localhost:%d PID=%d", port, proc.pid) + proc.wait() + logger.warning("yaml-language-server exited (code %s) — restarting", proc.returncode) + + async def _pipe(reader: asyncio.StreamReader, writer) -> None: """Pipe bytes from reader to writer until EOF.""" try: @@ -109,10 +124,13 @@ async def _build_app() -> web.Application: app = web.Application() async def health(_: web.Request) -> web.Response: + import shutil return web.json_response({ "status": "ok", "pypi_packages": len(PypiCatalog._packages), "bicep_modules": len(BicepModuleCatalog._modules), + "iac_source_modules": len(BicepModuleCatalog._iac), + "yaml_lsp": bool(shutil.which("yaml-language-server")), }) async def reload(_: web.Request) -> web.Response: @@ -132,10 +150,14 @@ async def _build_app() -> web.Application: async def bicep_ws(request: web.Request) -> web.WebSocketResponse: return await _ws_proxy(request, "127.0.0.1", BICEP_LSP_PORT) + async def yaml_ws(request: web.Request) -> web.WebSocketResponse: + return await _ws_proxy(request, "127.0.0.1", YAML_LSP_PORT) + app.router.add_get("/health", health) app.router.add_post("/reload", reload) app.router.add_get("/python", python_ws) app.router.add_get("/bicep", bicep_ws) + app.router.add_get("/yaml", yaml_ws) return app @@ -152,13 +174,17 @@ async def main_async() -> None: # LSP servers run internally on localhost — not exposed outside the container 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() + threading.Thread(target=_serve_yaml_lsp, args=(YAML_LSP_PORT,), daemon=True).start() app = await _build_app() runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT) await site.start() - logger.info("iLSP HTTP on http://0.0.0.0:%d (ws://.../python ws://.../bicep)", HTTP_PORT) + logger.info( + "iLSP HTTP on http://0.0.0.0:%d (ws://.../python ws://.../bicep ws://.../yaml)", + HTTP_PORT, + ) await asyncio.Event().wait()