feat: YAML LSP (/yaml endpoint) + IAC source catalog enrichment
- 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:
@@ -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 \
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user