Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions docs/gen_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
),
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 "")
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 33 additions & 3 deletions models/input_conf/web_services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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]
7 changes: 5 additions & 2 deletions test-samples/homelab-complete.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
52 changes: 50 additions & 2 deletions tests/models/test_web_services.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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},
}
)
12 changes: 12 additions & 0 deletions tests/proxy/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────


Expand Down
Loading