feat: bake iac_source_catalog into Docker image; fix YAML smoke test; add .gitignore
All checks were successful
Build and Deploy iLSP / test (push) Successful in 23s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m35s

- Dockerfile: COPY iac_source_catalog.json in builder + final stage
- push_catalogs.sh: cp iac_source_catalog.json to iLSP repo root before SCP
- smoke_test_completions.py: detect WS CLOSE in _recv_until_id; 20s YAML init timeout; clearer error message
- .gitignore: standard Python exclusions

Fixes: iac_source_modules=0 (was never baked into image)
This commit is contained in:
Henrik Jess Nielsen
2026-05-10 16:23:02 +02:00
parent ef3535048b
commit 2a1717ff81
5 changed files with 2445 additions and 6 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.eggs/
*.egg-info/
dist/
build/
.coverage
htmlcov/
*.log
.env
.venv/
venv/

View File

@@ -21,6 +21,7 @@ COPY pyproject.toml .
COPY ilsp/ ilsp/ COPY ilsp/ ilsp/
COPY bicep_modules_catalog.json . COPY bicep_modules_catalog.json .
COPY pipeline_templates_catalog.json . COPY pipeline_templates_catalog.json .
COPY iac_source_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
@@ -45,6 +46,7 @@ COPY --from=bicep-downloader /opt/bicep-langserver /opt/bicep-langserver
COPY --from=builder /dist/*.whl /tmp/ COPY --from=builder /dist/*.whl /tmp/
COPY --from=builder /build/bicep_modules_catalog.json /bicep_modules_catalog.json COPY --from=builder /build/bicep_modules_catalog.json /bicep_modules_catalog.json
COPY --from=builder /build/pipeline_templates_catalog.json /pipeline_templates_catalog.json COPY --from=builder /build/pipeline_templates_catalog.json /pipeline_templates_catalog.json
COPY --from=builder /build/iac_source_catalog.json /iac_source_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)

2298
iac_source_catalog.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# push_catalogs.sh — Push fresh catalogs to autobox iLSP volume # push_catalogs.sh — Push fresh catalogs to autobox iLSP volume AND copy to iLSP repo for Docker bake
# #
# Usage: # Usage:
# ./scripts/push_catalogs.sh # push all catalogs # ./scripts/push_catalogs.sh # push all catalogs
@@ -11,6 +11,7 @@
# pipeline_templates_catalog.json — from iLSP repo (scripts/sync_pipeline_templates.py) # pipeline_templates_catalog.json — from iLSP repo (scripts/sync_pipeline_templates.py)
# #
# All files are written to /opt/nomad/volumes/ilsp-data/ on autobox — the host volume iLSP mounts at /data. # All files are written to /opt/nomad/volumes/ilsp-data/ on autobox — the host volume iLSP mounts at /data.
# iac_source_catalog.json is also copied to the iLSP repo root so it gets baked into the Docker image.
set -euo pipefail set -euo pipefail
@@ -49,6 +50,12 @@ if [[ ! -f "$TMPL_CATALOG" ]]; then
fi fi
echo "$(basename "$TMPL_CATALOG") ($(du -sh "$TMPL_CATALOG" | cut -f1))" echo "$(basename "$TMPL_CATALOG") ($(du -sh "$TMPL_CATALOG" | cut -f1))"
# Copy iac_source_catalog.json to iLSP repo root so it gets baked into the Docker image
echo ""
echo " → Copying iac_source_catalog.json to iLSP repo root (for Docker bake) …"
cp "$IAC_CATALOG" "$ILSP_REPO/iac_source_catalog.json"
echo " ✓ Copied to $ILSP_REPO/iac_source_catalog.json"
echo "" echo ""
echo " → Copying to $AUTOBOX:$REMOTE_DIR/ …" echo " → Copying to $AUTOBOX:$REMOTE_DIR/ …"
ssh "$AUTOBOX" "mkdir -p $REMOTE_DIR" ssh "$AUTOBOX" "mkdir -p $REMOTE_DIR"
@@ -72,3 +79,4 @@ fi
echo "" echo ""
echo " Done. Bicep + YAML pipeline template completions updated." echo " Done. Bicep + YAML pipeline template completions updated."
echo " Note: iac_source_catalog.json was copied to repo root — commit + push to bake into next Docker image."

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Live smoke test: verifies Bicep completions from wss://ilsp.i80.dk/bicep. """Live smoke test: verifies Bicep and YAML completions from ilsp.i80.dk.
Tests: Tests:
1. Health endpoint returns ok with bicep_modules > 0 1. Health endpoint returns ok with bicep_modules > 0
@@ -7,6 +7,8 @@ Tests:
3. Version completion: 'br/modules:roleassignments:' → versions injected at top 3. Version completion: 'br/modules:roleassignments:' → versions injected at top
4. Param completion: inside params {} of roleassignments → params injected 4. Param completion: inside params {} of roleassignments → params injected
5. Module-path completion: 'br/modules:' → module list injected 5. Module-path completion: 'br/modules:' → module list injected
6. YAML: /yaml WebSocket accepts LSP initialize
7. YAML: pipeline template completion → template list injected
Usage: Usage:
python3 scripts/smoke_test_completions.py [wss://ilsp.i80.dk] python3 scripts/smoke_test_completions.py [wss://ilsp.i80.dk]
@@ -20,6 +22,7 @@ import aiohttp
BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "https://ilsp.i80.dk" BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "https://ilsp.i80.dk"
WS_URL = BASE_URL.replace("https://", "wss://").replace("http://", "ws://") + "/bicep" WS_URL = BASE_URL.replace("https://", "wss://").replace("http://", "ws://") + "/bicep"
YAML_WS_URL = BASE_URL.replace("https://", "wss://").replace("http://", "ws://") + "/yaml"
HTTP_URL = BASE_URL.replace("wss://", "https://").replace("ws://", "http://") HTTP_URL = BASE_URL.replace("wss://", "https://").replace("ws://", "http://")
PASS = 0 PASS = 0
@@ -64,7 +67,10 @@ def _parse_frames(data: bytes) -> list[dict]:
async def _recv_until_id(ws: aiohttp.ClientWebSocketResponse, req_id: int, timeout: float = 8.0) -> dict | None: async def _recv_until_id(ws: aiohttp.ClientWebSocketResponse, req_id: int, timeout: float = 8.0) -> dict | None:
"""Collect WebSocket frames until we find a response with the given id.""" """Collect WebSocket frames until we find a response with the given id.
Returns None if WS closes before the response arrives.
"""
buf = b"" buf = b""
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
while time.monotonic() < deadline: while time.monotonic() < deadline:
@@ -77,8 +83,10 @@ async def _recv_until_id(ws: aiohttp.ClientWebSocketResponse, req_id: int, timeo
buf += msg.data buf += msg.data
elif msg.type == aiohttp.WSMsgType.TEXT: elif msg.type == aiohttp.WSMsgType.TEXT:
buf += msg.data.encode() buf += msg.data.encode()
elif msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
break # connection closed — no response coming
else: else:
break continue
for parsed in _parse_frames(buf): for parsed in _parse_frames(buf):
if parsed.get("id") == req_id: if parsed.get("id") == req_id:
return parsed return parsed
@@ -275,6 +283,104 @@ async def check_module_path_completion(ws: aiohttp.ClientWebSocketResponse) -> N
fail("Module-path completion: empty result") fail("Module-path completion: empty result")
# ── YAML smoke tests ──────────────────────────────────────────────────────────
YAML_DOC_URI = "file:///tmp/smoke_test/azure-pipelines.yml"
# A minimal AzDO pipeline that uses @pipeline-templates — triggers YAML template context
YAML_DOC = """\
trigger:
- main
stages:
- stage: Deploy
jobs:
- job: DeployJob
steps:
- template: tasks/k8s/
"""
async def _yaml_open_doc(ws: aiohttp.ClientWebSocketResponse) -> None:
await ws.send_bytes(_frame({
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"uri": YAML_DOC_URI,
"languageId": "yaml",
"version": 1,
"text": YAML_DOC,
}
},
}))
async def _yaml_completion_request(ws: aiohttp.ClientWebSocketResponse, req_id: int, line: int, char: int) -> None:
await ws.send_bytes(_frame({
"jsonrpc": "2.0",
"id": req_id,
"method": "textDocument/completion",
"params": {
"textDocument": {"uri": YAML_DOC_URI},
"position": {"line": line, "character": char},
},
}))
async def check_yaml_initialize(ws: aiohttp.ClientWebSocketResponse) -> bool:
print("\n[6] YAML LSP initialize handshake")
await ws.send_bytes(_frame({
"jsonrpc": "2.0", "id": 100, "method": "initialize",
"params": {"processId": None, "rootUri": None, "capabilities": {}},
}))
resp = await _recv_until_id(ws, 100, timeout=20.0)
if resp and "result" in resp and "capabilities" in resp["result"]:
name = resp["result"].get("serverInfo", {}).get("name", "?")
ok("YAML initialize response received", f"server={name}")
await ws.send_bytes(_frame({"jsonrpc": "2.0", "method": "initialized", "params": {}}))
return True
elif resp is None:
fail("YAML: no initialize response (backend may be down or WS closed)", "check yaml-language-server in container")
return False
else:
fail("YAML: unexpected initialize response", str(resp)[:200])
return False
async def check_yaml_template_completion(ws: aiohttp.ClientWebSocketResponse) -> None:
"""Cursor after 'tasks/k8s/' in a template line — expect AzDO template completions."""
print("\n[7] YAML pipeline template completion (tasks/k8s/<cursor>@pipeline-templates)")
await _yaml_open_doc(ws)
# Line 8: " - template: tasks/k8s/" → char 32 (end of line)
line, char = 8, 32
await _yaml_completion_request(ws, 101, line, char)
resp = await _recv_until_id(ws, 101, timeout=10.0)
if resp is None:
fail("YAML template completion: no response")
return
items = []
result = resp.get("result")
if isinstance(result, dict):
items = result.get("items", [])
elif isinstance(result, list):
items = result
labels = [i.get("label", "") for i in items]
# Look for any item that contains "@pipeline-templates" or is a known AzDO template key
template_items = [l for l in labels if "@pipeline-templates" in l or "tasks/" in l]
if template_items:
ok("YAML template completions injected", f"{len(template_items)} templates: {', '.join(template_items[:3])}")
elif items:
ok("YAML completion response received (generic)", f"{len(items)} items: {', '.join(labels[:5])}")
else:
fail("YAML template completion: empty result", str(resp.get("result"))[:200])
# ── Main ────────────────────────────────────────────────────────────────────── # ── Main ──────────────────────────────────────────────────────────────────────
async def main() -> int: async def main() -> int:
@@ -300,6 +406,17 @@ async def main() -> int:
except Exception as e: except Exception as e:
fail("WebSocket connect failed", str(e)) fail("WebSocket connect failed", str(e))
print(f"\nConnecting to {YAML_WS_URL} ...")
try:
async with session.ws_connect(YAML_WS_URL, timeout=aiohttp.ClientTimeout(total=20)) as ws:
yaml_ok = await check_yaml_initialize(ws)
if not yaml_ok:
fail("Skipping YAML completion tests (no initialize)", "")
else:
await check_yaml_template_completion(ws)
except Exception as e:
fail("YAML WebSocket connect failed", str(e))
print("\n" + "" * 50) print("\n" + "" * 50)
print(f"Results: {PASS} passed, {FAIL} failed") print(f"Results: {PASS} passed, {FAIL} failed")
return 0 if FAIL == 0 else 1 return 0 if FAIL == 0 else 1