feat: YAML LSP (/yaml endpoint) + IAC source catalog enrichment
All checks were successful
Build and Deploy iLSP / test (push) Successful in 21s
Build and Deploy iLSP / build-and-deploy (push) Successful in 2m49s

- 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
This commit is contained in:
Henrik Jess Nielsen
2026-05-10 15:40:13 +02:00
parent b93aa84737
commit 5501254b55
5 changed files with 161 additions and 36 deletions

View File

@@ -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). # issue on newer Debian hosts (trixie+ rejects packages.microsoft.com GPG since 2026-02-01).
FROM mcr.microsoft.com/dotnet/runtime:10.0 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && 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/* && rm -rf /var/lib/apt/lists/*
# Copy Bicep Language Server (baked in at build time — no volume needed) # 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) # Configuration defaults (override via Nomad env)
ENV PYTHON_LSP_PORT=2087 \ ENV PYTHON_LSP_PORT=2087 \
BICEP_LSP_PORT=2088 \ BICEP_LSP_PORT=2088 \
YAML_LSP_PORT=2090 \
HEALTH_PORT=2089 \ HEALTH_PORT=2089 \
BICEP_LS_PATH=/opt/bicep-langserver/Bicep.LangServer.dll \ BICEP_LS_PATH=/opt/bicep-langserver/Bicep.LangServer.dll \
DEVOPS_MCP_URL=https://devops-mcp.i80.dk \ DEVOPS_MCP_URL=https://devops-mcp.i80.dk \

View File

@@ -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`) ## Helix (`~/.config/helix/languages.toml`)
```toml ```toml
[language-server.ilsp-python] [language-server.ilsp-python]
command = "nc" command = "websocat"
args = ["lsp.i80.dk", "2087"] args = ["wss://ilsp.i80.dk/python"]
[language-server.ilsp-bicep] [language-server.ilsp-bicep]
command = "nc" command = "websocat"
args = ["lsp.i80.dk", "2088"] args = ["wss://ilsp.i80.dk/bicep"]
[language-server.ilsp-yaml]
command = "websocat"
args = ["wss://ilsp.i80.dk/yaml"]
[[language]] [[language]]
name = "python" name = "python"
language-servers = ["ilsp-python", "pylsp"] # runs alongside local pylsp if present language-servers = ["ilsp-python"]
[[language]] [[language]]
name = "bicep" name = "bicep"
file-types = ["bicep", "bicepparam"] file-types = ["bicep", "bicepparam"]
language-servers = ["ilsp-bicep"] language-servers = ["ilsp-bicep"]
[[language]]
name = "yaml"
file-types = ["yaml", "yml"]
language-servers = ["ilsp-yaml"]
``` ```
## Neovim (`~/.config/nvim/lua/lsp.lua`) ## Neovim (`~/.config/nvim/lua/lsp.lua`)
```lua ```lua
-- Python: i80 completions alongside pyright -- Python: i80 pypi completions
vim.lsp.start({ vim.lsp.start({
name = "ilsp-python", name = "ilsp-python",
cmd = { "nc", "lsp.i80.dk", "2087" }, cmd = { "websocat", "wss://ilsp.i80.dk/python" },
filetypes = { "python" }, filetypes = { "python" },
}) })
-- Bicep: full Bicep LS via i80 proxy -- Bicep: LRU module completions
vim.lsp.start({ vim.lsp.start({
name = "ilsp-bicep", name = "ilsp-bicep",
cmd = { "nc", "lsp.i80.dk", "2088" }, cmd = { "websocat", "wss://ilsp.i80.dk/bicep" },
filetypes = { "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) ## PyCharm / IntelliJ IDEA Ultimate (LSP4IJ plugin)
@@ -44,22 +107,11 @@ vim.lsp.start({
1. Install **LSP4IJ** from JetBrains Marketplace 1. Install **LSP4IJ** from JetBrains Marketplace
2. Settings → Languages & Frameworks → Language Servers → **+** 2. Settings → Languages & Frameworks → Language Servers → **+**
| Field | Python | Bicep | | Field | Python | Bicep | YAML |
|----------------|-------------------------|-------------------------| |----------------|-------------------------------------|--------------------------------|-------------------------------------|
| Name | `ilsp-python` | `ilsp-bicep` | | Name | `ilsp-python` | `ilsp-bicep` | `ilsp-yaml` |
| Server type | External process | External process | | Server type | External process | External process | External process |
| Command | `nc lsp.i80.dk 2087` | `nc lsp.i80.dk 2088` | | Command | `websocat wss://ilsp.i80.dk/python` | `websocat wss://ilsp.i80.dk/bicep` | `websocat wss://ilsp.i80.dk/yaml` |
| File patterns | `*.py` | `*.bicep, *.bicepparam` | | File patterns | `*.py` | `*.bicep, *.bicepparam` | `*.yml, *.yaml` |
> PyCharm's built-in Python intelligence runs **alongside** ilsp-python — additive, not replacing. > 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.

View File

@@ -106,6 +106,7 @@ job "ilsp" {
HTTP_PORT = "${NOMAD_PORT_http}" HTTP_PORT = "${NOMAD_PORT_http}"
PYTHON_LSP_PORT = "2087" PYTHON_LSP_PORT = "2087"
BICEP_LSP_PORT = "2088" BICEP_LSP_PORT = "2088"
YAML_LSP_PORT = "2090"
DEVOPS_MCP_URL = "https://devops-mcp.i80.dk" DEVOPS_MCP_URL = "https://devops-mcp.i80.dk"
PYTHONUNBUFFERED = "1" PYTHONUNBUFFERED = "1"
} }

View File

@@ -29,6 +29,31 @@ _CATALOG_PATHS = [
pathlib.Path(__file__).parent.parent.parent / "bicep_modules_catalog.json", # dev 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]]: def _load_catalog() -> list[dict[str, Any]]:
"""Load modules from the bundled catalog file, preserving per-version schema.""" """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.""" """In-memory catalog of LRU Bicep modules, loaded once at startup."""
_modules: list[dict[str, Any]] = [] _modules: list[dict[str, Any]] = []
_iac: dict[str, dict[str, Any]] = {} # module name → IAC source info
@classmethod @classmethod
def load(cls) -> None: 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._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 @classmethod
def get_modules(cls) -> list[dict[str, Any]]: 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) logger.debug("Param fallback: %s %s%s", module_name, version, v)
break break
# IAC source catalog: richer descriptions + required flag
iac_params = cls._iac_param_map(module_name)
items = [] items = []
for param_name, param_info in ver_params.items(): for param_name, param_info in ver_params.items():
ptype = param_info.get("type", "any") ptype = param_info.get("type", "any")
description = param_info.get("description", "").strip()
allowed = param_info.get("allowed", []) 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}`)"] doc_lines = [f"**{param_name}** (`{ptype}`)"]
if required:
doc_lines[0] += " ⚠️ required"
if description: if description:
doc_lines.append(f"\n{description}") doc_lines.append(f"\n{description}")
if allowed: if allowed:
@@ -221,9 +265,9 @@ class BicepModuleCatalog:
items.append({ items.append({
"label": param_name, "label": param_name,
"kind": 5, # Field "kind": 5, # Field
"detail": ptype, "detail": f"{'*' if required else ''}{ptype}",
"insertText": f"{param_name}: ", "insertText": f"{param_name}: ",
"sortText": f"0_lru_param_{param_name}", "sortText": f"0_lru_param_{'0' if required else '1'}_{param_name}",
"documentation": { "documentation": {
"kind": "markdown", "kind": "markdown",
"value": "\n".join(doc_lines), "value": "\n".join(doc_lines),

View File

@@ -29,6 +29,7 @@ logger = logging.getLogger(__name__)
PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087")) PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087"))
BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088")) 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")) HTTP_PORT = int(os.getenv("HTTP_PORT", "8000"))
_CHUNK = 65536 _CHUNK = 65536
@@ -45,6 +46,20 @@ def _serve_python_lsp(port: int) -> None:
logger.warning("pylsp exited (code %s) — restarting", proc.returncode) 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: async def _pipe(reader: asyncio.StreamReader, writer) -> None:
"""Pipe bytes from reader to writer until EOF.""" """Pipe bytes from reader to writer until EOF."""
try: try:
@@ -109,10 +124,13 @@ async def _build_app() -> web.Application:
app = web.Application() app = web.Application()
async def health(_: web.Request) -> web.Response: async def health(_: web.Request) -> web.Response:
import shutil
return web.json_response({ return web.json_response({
"status": "ok", "status": "ok",
"pypi_packages": len(PypiCatalog._packages), "pypi_packages": len(PypiCatalog._packages),
"bicep_modules": len(BicepModuleCatalog._modules), "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: 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: async def bicep_ws(request: web.Request) -> web.WebSocketResponse:
return await _ws_proxy(request, "127.0.0.1", BICEP_LSP_PORT) 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_get("/health", health)
app.router.add_post("/reload", reload) app.router.add_post("/reload", reload)
app.router.add_get("/python", python_ws) app.router.add_get("/python", python_ws)
app.router.add_get("/bicep", bicep_ws) app.router.add_get("/bicep", bicep_ws)
app.router.add_get("/yaml", yaml_ws)
return app return app
@@ -152,13 +174,17 @@ async def main_async() -> None:
# LSP servers run internally on localhost — not exposed outside the container # 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_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_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() app = await _build_app()
runner = web.AppRunner(app) runner = web.AppRunner(app)
await runner.setup() await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT) site = web.TCPSite(runner, "0.0.0.0", HTTP_PORT)
await site.start() 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() await asyncio.Event().wait()