From 12b0eed90c475e3e0349e6bb39c465cf61cac2dc Mon Sep 17 00:00:00 2001 From: Manuel Fritz Date: Wed, 19 Aug 2026 11:41:02 +0200 Subject: [PATCH 1/4] added tests --- tests/models/test_web_services.py | 52 +++++++++++++++++++++++++++++-- tests/proxy/test_render.py | 12 +++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/models/test_web_services.py b/tests/models/test_web_services.py index 285e097..65b123c 100644 --- a/tests/models/test_web_services.py +++ b/tests/models/test_web_services.py @@ -1,5 +1,5 @@ -"""Tests for web-service port collision (shared validator), proxy_name shape and -WebServices indexing.""" +"""Tests for web-service port collision (shared validator), proxy_name shape, +the `{proxy_name: port}` short form and WebServices indexing.""" import pytest from pydantic import ValidationError @@ -108,3 +108,51 @@ def test_absent_proxy_name_still_allowed() -> None: # No proxy_name means "not routed" — still a legal web_service. ws = WebServices.model_validate([{"port": 80}]) assert ws[0].proxy_name is None + + +# ─── Short form ─────────────────────────────────────────────────────────────── +# +# `web_services: {nas: 8080}` is sugar for the one-name-one-port list entry, so +# everything downstream (duplicate ports, proxy_name shape, routing) has to see +# the same objects it sees for the long form. + + +def test_shorthand_map_expands_to_entries() -> None: + ws = WebServices.model_validate({"nas": 8080, "pihole": 80}) + assert [(w.proxy_name, w.port) for w in ws.root] == [("nas", 8080), ("pihole", 80)] + # Defaults come from WebService, exactly as for the long form. + assert ws[0].access is None + assert ws[0].https is False + + +def test_shorthand_on_a_node() -> None: + node = Host.model_validate( + {"os": "debian", "ip": "10.0.0.5", "web_services": {"nas": 8080}} + ) + assert node.web_services is not None + assert node.web_services[0].proxy_name == "nas" + + +def test_shorthand_empty_map_is_no_services() -> None: + assert WebServices.model_validate({}).root == [] + + +def test_shorthand_key_must_be_a_valid_proxy_name() -> None: + with pytest.raises(ValidationError, match="not a valid hostname label"): + WebServices.model_validate({"has space": 8080}) + + +def test_shorthand_value_must_be_a_port() -> None: + with pytest.raises(ValidationError, match="port"): + WebServices.model_validate({"nas": "eighty"}) + + +def test_shorthand_duplicate_ports_still_rejected() -> None: + with pytest.raises(ValidationError, match="Duplicate port found: 8080"): + Host.model_validate( + { + "os": "debian", + "ip": "10.0.0.5", + "web_services": {"nas": 8080, "media": 8080}, + } + ) diff --git a/tests/proxy/test_render.py b/tests/proxy/test_render.py index f9687c6..0a561ce 100644 --- a/tests/proxy/test_render.py +++ b/tests/proxy/test_render.py @@ -33,6 +33,18 @@ def test_find_routes_skips_entries_without_proxy_name( assert "edge" in names # the named one survives; the unnamed one is skipped +def test_find_routes_accepts_the_short_form( + valid_config_dict: dict[str, Any], +) -> None: + # `web_services: {name: port}` has to reach the Caddyfile like the list form. + valid_config_dict["hosts"]["prox"]["lxc"]["ct1"]["web_services"] = {"ct1web": 8080} + route = next( + r for r in find_routes(_model(valid_config_dict)) if r.proxy_name == "ct1web" + ) + assert str(route.target_ip) == "10.0.0.2" + assert route.port == 8080 + + # ── render_caddyfile ──────────────────────────────────────────────────────── From d3c87eadf3690f306642d32f441a3570ea0e6b4b Mon Sep 17 00:00:00 2001 From: Manuel Fritz Date: Wed, 19 Aug 2026 11:41:12 +0200 Subject: [PATCH 2/4] added testsample --- test-samples/homelab-complete.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test-samples/homelab-complete.yml b/test-samples/homelab-complete.yml index 8db6ee9..0bce3d1 100644 --- a/test-samples/homelab-complete.yml +++ b/test-samples/homelab-complete.yml @@ -215,9 +215,12 @@ hosts: ip: 10.0.10.5 os: debian vmid: 105 + # Short form: a map of proxy_name -> port, the same as one list entry + # with `proxy_name: pihole` / `port: 8080`. It is a whole block at a + # time — use the list (as above) once a service needs `access`, `https` + # or no proxy_name. web_services: - - proxy_name: pihole - port: 8080 + pihole: 8080 vm: fr24-radar: From 8aae5809ad1b79e47beff161eb186d10303be5ac Mon Sep 17 00:00:00 2001 From: Manuel Fritz Date: Wed, 19 Aug 2026 11:41:19 +0200 Subject: [PATCH 3/4] added input conf --- models/input_conf/web_services.py | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/models/input_conf/web_services.py b/models/input_conf/web_services.py index 448edf6..2d0c1c9 100644 --- a/models/input_conf/web_services.py +++ b/models/input_conf/web_services.py @@ -1,5 +1,5 @@ -from pydantic import Field, field_validator, RootModel -from typing import Optional, List +from pydantic import BeforeValidator, Field, field_validator, RootModel +from typing import Annotated, Dict, List, Optional, Union from .custom_types import StrictModel from .common_validators.hostname import validate_hostname_label @@ -57,8 +57,38 @@ def _validate_proxy_name(cls, v: Optional[str]) -> Optional[str]: return validate_hostname_label(v, "proxy_name", "settings.proxy.proxy_suffix") +def _expand_shorthand(v: object) -> object: + """Accept the mapping shorthand ``{proxy_name: port}`` beside the list form. + + A routed service is usually nothing but a name and a port, so + + .. code-block:: yaml + + web_services: + nas: 8080 + + means the same as the long form with ``proxy_name`` and ``port`` spelled + out. The two forms are per block, not per entry: reach for the list as soon + as one service needs `access`, `https`, or no `proxy_name` at all. + """ + if isinstance(v, dict): + return [{"proxy_name": name, "port": port} for name, port in v.items()] + return v + + +WebServiceEntries = Annotated[ + List[WebService], + BeforeValidator( + _expand_shorthand, + # Without this the schema would advertise only the list, and an editor + # would flag the shorthand as invalid while labops accepts it. + json_schema_input_type=Union[List[WebService], Dict[str, int]], + ), +] + + class WebServices(RootModel): - root: List[WebService] + root: WebServiceEntries def __getitem__(self, item: int) -> WebService: return self.root[item] From c973201d2dc22c4c91e0ccf8c2c731747193206a Mon Sep 17 00:00:00 2001 From: Manuel Fritz Date: Wed, 19 Aug 2026 11:41:24 +0200 Subject: [PATCH 4/4] updated docs --- docs/gen_docs.py | 52 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/gen_docs.py b/docs/gen_docs.py index a4d3a84..bea263a 100644 --- a/docs/gen_docs.py +++ b/docs/gen_docs.py @@ -146,6 +146,31 @@ An entry without a `proxy_name` is tracked but not routed — useful for recording what a port is without publishing it. + +## Short form + +A routed service is usually nothing but a name and a port, so a `web_services` +block may be written as a map of `proxy_name: port` instead of a list: + +```yaml +web_services: + nas: 8080 + pihole: 80 +``` + +That is exactly the list below, spelled shorter: + +```yaml +web_services: + - proxy_name: nas + port: 8080 + - proxy_name: pihole + port: 80 +``` + +The choice is per block, not per entry — one `web_services` is either a map or a +list. Use the list as soon as a service in that block needs `access`, `https`, +or no `proxy_name` at all. """, ["WebService"], ), @@ -202,9 +227,12 @@ def type_name(spec: dict[str, Any], page: str) -> str: if "$ref" in spec: target = spec["$ref"].rsplit("/", 1)[-1] definition = DEFS.get(target, {}) - # A RootModel (WebServices wraps a list of WebService) has no properties - # of its own and nothing to link to — render what it actually is. - if "properties" not in definition and definition.get("type") == "array": + # A RootModel (WebServices wraps a list of WebService, or the mapping + # shorthand) has no properties of its own and nothing to link to — + # render what it actually is. + if "properties" not in definition and ( + definition.get("type") == "array" or "anyOf" in definition + ): return type_name(definition, page) return link_to(target, page) @@ -367,9 +395,12 @@ def fence_example_blocks(text: str) -> str: if is_examples: block: list[str] = [] j = i + 1 - while j < len(lines) and (lines[j].startswith(" ") or not lines[j].strip()): + while j < len(lines) and ( + lines[j].startswith(" ") or not lines[j].strip() + ): if not lines[j].strip() and not any( - lines[k].startswith(" ") for k in range(j + 1, min(j + 3, len(lines))) + lines[k].startswith(" ") + for k in range(j + 1, min(j + 3, len(lines))) ): break block.append(lines[j][2:] if lines[j].startswith(" ") else "") @@ -406,7 +437,16 @@ def demote_headings(text: str) -> str: def write_command_reference() -> list[Path]: raw = subprocess.run( - [sys.executable, "-m", "typer", "labops_cli.py", "utils", "docs", "--name", "labops"], + [ + sys.executable, + "-m", + "typer", + "labops_cli.py", + "utils", + "docs", + "--name", + "labops", + ], cwd=REPO_ROOT, capture_output=True, text=True,