From 57bb21070017f1d4967a07469b072ddd7971130a Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Sun, 24 May 2026 22:32:00 +0300 Subject: [PATCH 1/3] refactor: remove dead subdomain routing path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multi-level mDNS subdomain approach is gone — halos-mdns-publisher is archived, no live system discovers subdomain hostnames, and the deployed reverse proxy in halos-core-containers routes by path under ${HALOS_DOMAIN} (expanded across configured hostnames by prestart). Deletes the parts of c-p-t that were still generating that extinct routing surface. Pure dead-code removal — no behavior change for any live deployment. src/generate_container_packages/traefik.py - delete generate_traefik_labels (only called by its own tests) - delete _build_host_rule and _extract_container_port helpers (the latter is duplicated in routing.py for the live path) - keep inject_traefik_network / inject_proxy_network — still called by builder.py and unrelated to the dead label path src/generate_container_packages/oidc_snippet.py - stop prepending {subdomain}. to the redirect URIs - keep both http:// and https:// entries unchanged so the Authelia client allow-list shape stays identical src/schemas/metadata.py - drop the subdomain field from TraefikConfig and RoutingConfig. Both models default to extra='ignore', so any leftover 'subdomain:' in a metadata.yaml is silently dropped instead of raising — verified zero apps in the workspace actually set it. Tests: - tests/test_traefik_labels.py → renamed to test_traefik_network.py, collapsed to the proxy-network suite (TestExtractContainerPort coverage already lives in tests/test_routing.py, plus a new test_container_port_integer_format pins the integer-port branch) - test_oidc_snippet, test_traefik_schema, test_routing_schema: drop subdomain assertions and fixtures - test_middleware, test_templates_oidc: strip dead subdomain keys from fixtures (Pydantic was ignoring them) - test_routing: rename test_routing_without_subdomain to test_generated_routing_omits_subdomain_key as a regression guard - test_routing_schema: add test_legacy_subdomain_field_silently_ignored pinning the backward-compat contract for RoutingConfig 878 tests pass; ruff clean. Refs halos-org/container-packaging-tools#204 --- .../oidc_snippet.py | 18 +- src/generate_container_packages/traefik.py | 173 +-------- src/schemas/metadata.py | 16 - tests/test_middleware.py | 8 - tests/test_oidc_snippet.py | 73 +--- tests/test_routing.py | 26 +- tests/test_routing_schema.py | 66 ++-- tests/test_templates_oidc.py | 8 - tests/test_traefik_labels.py | 363 ------------------ tests/test_traefik_network.py | 54 +++ tests/test_traefik_schema.py | 44 +-- 11 files changed, 128 insertions(+), 721 deletions(-) delete mode 100644 tests/test_traefik_labels.py create mode 100644 tests/test_traefik_network.py diff --git a/src/generate_container_packages/oidc_snippet.py b/src/generate_container_packages/oidc_snippet.py index 5152332..fe0ddf8 100644 --- a/src/generate_container_packages/oidc_snippet.py +++ b/src/generate_container_packages/oidc_snippet.py @@ -32,24 +32,18 @@ def generate_oidc_snippet(metadata: dict[str, Any]) -> str | None: app_id = metadata.get("app_id", "") package_name = metadata.get("package_name", "") - # Get subdomain (default to app_id if not specified) - subdomain_raw = traefik_config.get("subdomain") - subdomain: str = subdomain_raw if subdomain_raw is not None else app_id - # Get redirect path and ensure it starts with / redirect_path = oidc_config.get("redirect_path", "/callback") if not redirect_path.startswith("/"): redirect_path = "/" + redirect_path - # Build redirect URIs - if subdomain: - base_url = f"{subdomain}.${{HALOS_DOMAIN}}" - else: - base_url = "${HALOS_DOMAIN}" - + # Build redirect URIs. The reverse proxy's prestart script in + # halos-core-containers expands ${HALOS_DOMAIN} across every configured + # hostname at runtime (halos_expand_oidc_redirect_uri), so each entry here + # becomes one entry per hostname in the merged oidc-clients.yml. redirect_uris = [ - f"http://{base_url}{redirect_path}", - f"https://{base_url}{redirect_path}", + f"http://${{HALOS_DOMAIN}}{redirect_path}", + f"https://${{HALOS_DOMAIN}}{redirect_path}", ] # Build snippet content diff --git a/src/generate_container_packages/traefik.py b/src/generate_container_packages/traefik.py index a85f1f4..cf67912 100644 --- a/src/generate_container_packages/traefik.py +++ b/src/generate_container_packages/traefik.py @@ -1,123 +1,15 @@ -"""Traefik label generation for container apps. +"""Proxy-network injection for container apps. -This module generates Docker labels for Traefik routing and SSO integration -based on the traefik section in metadata.yaml. +Routing rules themselves are generated at runtime by the reverse proxy +from `/etc/halos/routing.d/{app_id}.yml` (see ``routing.py``); this module +only attaches container services to the shared proxy network so the proxy +can reach them. """ import copy from typing import Any -def generate_traefik_labels( - metadata: dict[str, Any], - compose: dict[str, Any], -) -> dict[str, str]: - """Generate Traefik Docker labels from metadata. - - Args: - metadata: Package metadata dictionary - compose: Parsed docker-compose.yml - - Returns: - Dictionary of Traefik labels (empty if traefik not configured) - - Raises: - ValueError: If host networking is detected without host_port - """ - traefik_config = metadata.get("traefik") - web_ui = metadata.get("web_ui") - app_id = metadata.get("app_id") - - # Default behavior: if web_ui.enabled but no traefik section, - # create implicit forward_auth config - if not traefik_config: - if web_ui and web_ui.get("enabled"): - traefik_config = {"auth": "forward_auth"} - else: - return {} - - # Get configuration values - subdomain_raw = traefik_config.get("subdomain") - subdomain: str = subdomain_raw if subdomain_raw is not None else (app_id or "") - auth_mode = traefik_config.get("auth", "forward_auth") - - # Detect host networking - is_host_network = _detect_host_networking(compose) - host_port = traefik_config.get("host_port") - - # Determine the port to use - if is_host_network: - if host_port is None: - # Try to infer from web_ui.port - if web_ui and web_ui.get("port"): - host_port = web_ui.get("port") - else: - raise ValueError( - f"App '{app_id}' uses host networking but no host_port specified. " - "Add traefik.host_port to metadata.yaml or ensure web_ui.port is set." - ) - else: - # For bridge networking, get container port from docker-compose - # Fall back to web_ui.port only if no ports defined in compose - container_port = _extract_container_port(compose) - if container_port is not None: - port = container_port - else: - port = web_ui.get("port", 80) if web_ui else 80 - - # Build labels - labels: dict[str, str] = { - "traefik.enable": "true", - f"traefik.http.routers.{app_id}.rule": _build_host_rule(subdomain), - f"traefik.http.routers.{app_id}.entrypoints": "web,websecure", - "halos.subdomain": subdomain, - } - - # Add middleware based on auth mode - if auth_mode == "forward_auth": - forward_auth_config = traefik_config.get("forward_auth") or {} - headers = ( - forward_auth_config.get("headers") - if isinstance(forward_auth_config, dict) - else None - ) - if headers: - # Per-app middleware with custom headers - labels[f"traefik.http.routers.{app_id}.middlewares"] = ( - f"authelia-{app_id}@file" - ) - else: - # Default Authelia middleware - labels[f"traefik.http.routers.{app_id}.middlewares"] = "authelia@file" - # No middleware for oidc or none auth modes - - # Backend configuration - if is_host_network: - labels[f"traefik.http.services.{app_id}.loadbalancer.server.url"] = ( - f"http://host.docker.internal:{host_port}" - ) - else: - labels[f"traefik.http.services.{app_id}.loadbalancer.server.port"] = str(port) - - return labels - - -def _build_host_rule(subdomain: str) -> str: - """Build Traefik Host rule for the subdomain. - - Args: - subdomain: Subdomain for routing (empty string for root domain) - - Returns: - Traefik Host rule string - """ - if subdomain: - return f"Host(`{subdomain}.${{HALOS_DOMAIN}}`)" - else: - # Empty subdomain means root domain - return "Host(`${HALOS_DOMAIN}`)" - - def _detect_host_networking(compose: dict[str, Any]) -> bool: """Detect if any service uses host networking. @@ -135,61 +27,6 @@ def _detect_host_networking(compose: dict[str, Any]) -> bool: return False -def _extract_container_port(compose: dict[str, Any]) -> int | None: - """Extract the container port from the first port mapping. - - Docker-compose ports can be in formats like: - - "8080" (just container port) - - "3011:8080" (host:container) - - "${PORT:-3011}:8080" (env var host:container) - - "3011:8080/tcp" (with protocol) - - Args: - compose: Parsed docker-compose.yml - - Returns: - Container port as integer, or None if no ports defined - """ - services = compose.get("services", {}) - for service_config in services.values(): - if not isinstance(service_config, dict): - continue - - ports = service_config.get("ports", []) - if not ports: - continue - - # Get first port mapping - port_mapping = ports[0] - - # Handle dict format (long syntax) - if isinstance(port_mapping, dict): - target = port_mapping.get("target") - if target is not None: - return int(target) - continue - - # Handle string format (short syntax) - if isinstance(port_mapping, str): - # Remove protocol suffix if present (e.g., "/tcp", "/udp") - port_str = port_mapping.split("/")[0] - - # Check for host:container format - if ":" in port_str: - # Take the right side (container port) - container_port = port_str.rsplit(":", 1)[1] - return int(container_port) - else: - # Just container port - return int(port_str) - - # Handle integer format - if isinstance(port_mapping, int): - return port_mapping - - return None - - def inject_traefik_network( compose: dict[str, Any], metadata: dict[str, Any], diff --git a/src/schemas/metadata.py b/src/schemas/metadata.py index ffb2ad4..1add402 100644 --- a/src/schemas/metadata.py +++ b/src/schemas/metadata.py @@ -124,14 +124,6 @@ class TraefikConfig(BaseModel): with the 'routing' key instead. Both are supported for backwards compatibility. """ - subdomain: str | None = Field( - default=None, - pattern=r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$|^$", - description=( - "Subdomain for routing (defaults to app_id). " - "Must be lowercase alphanumeric with hyphens, or empty string for root domain." - ), - ) auth: Literal["forward_auth", "oidc", "none"] = Field( default="forward_auth", description="Authentication mode: forward_auth (default), oidc, or none", @@ -191,14 +183,6 @@ class RoutingConfig(BaseModel): Prefer using this over TraefikConfig for new apps. """ - subdomain: str | None = Field( - default=None, - pattern=r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$|^$", - description=( - "Subdomain for routing (defaults to app_id). " - "Must be lowercase alphanumeric with hyphens, or empty string for root domain." - ), - ) auth: RoutingAuth | None = Field( default=None, description="Authentication configuration", diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 8ef9705..1d0c275 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -23,7 +23,6 @@ def test_no_forward_auth_section_returns_none(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", }, @@ -38,7 +37,6 @@ def test_no_custom_headers_returns_none(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { @@ -56,7 +54,6 @@ def test_oidc_app_returns_none(self) -> None: "app_id": "homarr", "package_name": "homarr-container", "routing": { - "subdomain": "", "auth": { "mode": "oidc", }, @@ -71,7 +68,6 @@ def test_none_auth_returns_none(self) -> None: "app_id": "avnav", "package_name": "avnav-container", "routing": { - "subdomain": "avnav", "auth": { "mode": "none", }, @@ -86,7 +82,6 @@ def test_custom_headers_generates_middleware(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { @@ -121,7 +116,6 @@ def test_auth_response_headers_from_mapping(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { @@ -154,7 +148,6 @@ def test_middleware_has_header_comments(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { @@ -175,7 +168,6 @@ def test_flat_format_generates_middleware(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "routing": { - "subdomain": "grafana", "auth": "forward_auth", # string, not dict "forward_auth": { # at routing level "headers": { diff --git a/tests/test_oidc_snippet.py b/tests/test_oidc_snippet.py index a8b4ac9..cd3fda0 100644 --- a/tests/test_oidc_snippet.py +++ b/tests/test_oidc_snippet.py @@ -14,7 +14,6 @@ def test_non_oidc_app_returns_none(self) -> None: "app_id": "grafana", "package_name": "grafana-container", "traefik": { - "subdomain": "grafana", "auth": "forward_auth", }, } @@ -36,7 +35,6 @@ def test_oidc_app_generates_snippet(self) -> None: "app_id": "homarr", "package_name": "homarr-container", "traefik": { - "subdomain": "", "auth": "oidc", "oidc": { "client_name": "Homarr Dashboard", @@ -64,39 +62,17 @@ def test_oidc_app_generates_snippet(self) -> None: assert snippet["scopes"] == ["openid", "profile", "email", "groups"] assert snippet["consent_mode"] == "implicit" - def test_redirect_uris_include_both_http_and_https(self) -> None: - """Redirect URIs should include both HTTP and HTTPS.""" - metadata = { - "app_id": "myapp", - "package_name": "myapp-container", - "traefik": { - "subdomain": "myapp", - "auth": "oidc", - "oidc": { - "client_name": "My App", - "redirect_path": "/callback", - }, - }, - } - result = generate_oidc_snippet(metadata) - - assert result is not None - content_lines = [ - line for line in result.split("\n") if line and not line.startswith("#") - ] - snippet = yaml.safe_load("\n".join(content_lines)) - - assert len(snippet["redirect_uris"]) == 2 - assert "http://myapp.${HALOS_DOMAIN}/callback" in snippet["redirect_uris"] - assert "https://myapp.${HALOS_DOMAIN}/callback" in snippet["redirect_uris"] + def test_redirect_uris_use_path_under_halos_domain(self) -> None: + """Redirect URIs are http+https entries under ${HALOS_DOMAIN}. - def test_empty_subdomain_uses_root_domain(self) -> None: - """Empty subdomain should use root domain in redirect URIs.""" + The reverse proxy's prestart script in halos-core-containers expands + ${HALOS_DOMAIN} across every configured hostname at runtime, so each + entry here becomes one entry per hostname in the merged config. + """ metadata = { "app_id": "homarr", "package_name": "homarr-container", "traefik": { - "subdomain": "", "auth": "oidc", "oidc": { "client_name": "Homarr", @@ -112,10 +88,10 @@ def test_empty_subdomain_uses_root_domain(self) -> None: ] snippet = yaml.safe_load("\n".join(content_lines)) - # Root domain - no subdomain prefix - assert ( - "http://${HALOS_DOMAIN}/api/auth/callback/oidc" in snippet["redirect_uris"] - ) + assert snippet["redirect_uris"] == [ + "http://${HALOS_DOMAIN}/api/auth/callback/oidc", + "https://${HALOS_DOMAIN}/api/auth/callback/oidc", + ] def test_redirect_path_without_leading_slash(self) -> None: """Redirect path without leading slash should be normalized.""" @@ -123,7 +99,6 @@ def test_redirect_path_without_leading_slash(self) -> None: "app_id": "myapp", "package_name": "myapp-container", "traefik": { - "subdomain": "myapp", "auth": "oidc", "oidc": { "client_name": "My App", @@ -139,8 +114,8 @@ def test_redirect_path_without_leading_slash(self) -> None: ] snippet = yaml.safe_load("\n".join(content_lines)) - # Should still have proper path - assert "http://myapp.${HALOS_DOMAIN}/callback" in snippet["redirect_uris"] + assert "http://${HALOS_DOMAIN}/callback" in snippet["redirect_uris"] + assert "https://${HALOS_DOMAIN}/callback" in snippet["redirect_uris"] def test_default_scopes(self) -> None: """Default scopes should be used if not specified.""" @@ -148,7 +123,6 @@ def test_default_scopes(self) -> None: "app_id": "myapp", "package_name": "myapp-container", "traefik": { - "subdomain": "myapp", "auth": "oidc", "oidc": { "client_name": "My App", @@ -171,7 +145,6 @@ def test_snippet_has_header_comments(self) -> None: "app_id": "myapp", "package_name": "myapp-container", "traefik": { - "subdomain": "myapp", "auth": "oidc", "oidc": { "client_name": "My App", @@ -183,25 +156,3 @@ def test_snippet_has_header_comments(self) -> None: assert result is not None assert "# OIDC client configuration for myapp" in result assert "# Installed to /etc/halos/oidc-clients.d/myapp.yml" in result - - def test_subdomain_defaults_to_app_id(self) -> None: - """When subdomain is None, app_id is used.""" - metadata = { - "app_id": "grafana", - "package_name": "grafana-container", - "traefik": { - "auth": "oidc", - "oidc": { - "client_name": "Grafana", - }, - }, - } - result = generate_oidc_snippet(metadata) - - assert result is not None - content_lines = [ - line for line in result.split("\n") if line and not line.startswith("#") - ] - snippet = yaml.safe_load("\n".join(content_lines)) - - assert "http://grafana.${HALOS_DOMAIN}/callback" in snippet["redirect_uris"] diff --git a/tests/test_routing.py b/tests/test_routing.py index e5ca56c..7e4891f 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -156,8 +156,13 @@ def test_none_auth_mode(self) -> None: routing = yaml.safe_load(result) assert routing["auth"]["mode"] == "none" - def test_routing_without_subdomain(self) -> None: - """Routing config without subdomain still generates correct structure.""" + def test_generated_routing_omits_subdomain_key(self) -> None: + """Generated routing.yml never includes a subdomain key. + + Subdomain routing has been removed from halos; the live reverse-proxy + prestart script in halos-core-containers expects path-based routing + only. Pinning this guards against accidental reintroduction. + """ metadata = { "app_id": "myapp", "web_ui": {"enabled": True, "port": 8080}, @@ -449,6 +454,23 @@ def test_container_port_long_syntax(self) -> None: routing = yaml.safe_load(result) assert routing["routing"]["backend"]["port"] == 3000 + def test_container_port_integer_format(self) -> None: + """Container port extracted when ports entry is a bare YAML integer. + + docker-compose accepts ports as integers (`ports: [8080]`) in addition + to strings. The extractor has an isinstance(int) branch for this and it + otherwise has no test coverage. + """ + metadata = { + "app_id": "myapp", + "routing": {}, + } + compose: dict = {"services": {"app": {"ports": [8080]}}} + result = generate_routing_yml(metadata, compose, "myapp-container") + + routing = yaml.safe_load(result) + assert routing["routing"]["backend"]["port"] == 8080 + def test_fallback_to_web_ui_port_when_no_ports(self) -> None: """Falls back to web_ui.port when no ports in docker-compose.""" metadata = { diff --git a/tests/test_routing_schema.py b/tests/test_routing_schema.py index 03531dc..cb934c8 100644 --- a/tests/test_routing_schema.py +++ b/tests/test_routing_schema.py @@ -15,38 +15,9 @@ class TestRoutingConfig: def test_minimal_routing_config(self) -> None: """Minimal routing config should be valid.""" - config = RoutingConfig(subdomain="myapp") - assert config.subdomain == "myapp" + config = RoutingConfig() assert config.auth is None # Default to None, will be forward_auth at runtime - def test_subdomain_pattern_valid(self) -> None: - """Valid subdomains should pass validation.""" - valid_subdomains = [ - "myapp", - "my-app", - "my-long-app-name", - "app123", - "123app", - "", # Empty string for root domain - ] - for subdomain in valid_subdomains: - config = RoutingConfig(subdomain=subdomain) - assert config.subdomain == subdomain - - def test_subdomain_pattern_invalid(self) -> None: - """Invalid subdomains should fail validation.""" - invalid_subdomains = [ - "MyApp", # Uppercase - "my_app", # Underscore - "-myapp", # Leading hyphen - "myapp-", # Trailing hyphen - "my.app", # Dot - "my app", # Space - ] - for subdomain in invalid_subdomains: - with pytest.raises(ValidationError): - RoutingConfig(subdomain=subdomain) - def test_auth_modes(self) -> None: """All auth modes should be valid.""" for mode in ["forward_auth", "oidc", "none"]: @@ -60,25 +31,40 @@ def test_invalid_auth_mode(self) -> None: def test_host_port_valid_range(self) -> None: """Host port in valid range should pass.""" - config = RoutingConfig(subdomain="myapp", host_port=3000) + config = RoutingConfig(host_port=3000) assert config.host_port == 3000 - config = RoutingConfig(subdomain="myapp", host_port=1) + config = RoutingConfig(host_port=1) assert config.host_port == 1 - config = RoutingConfig(subdomain="myapp", host_port=65535) + config = RoutingConfig(host_port=65535) assert config.host_port == 65535 def test_host_port_invalid_range(self) -> None: """Host port outside valid range should fail.""" with pytest.raises(ValidationError): - RoutingConfig(subdomain="myapp", host_port=0) + RoutingConfig(host_port=0) with pytest.raises(ValidationError): - RoutingConfig(subdomain="myapp", host_port=65536) + RoutingConfig(host_port=65536) with pytest.raises(ValidationError): - RoutingConfig(subdomain="myapp", host_port=-1) + RoutingConfig(host_port=-1) + + def test_legacy_subdomain_field_silently_ignored(self) -> None: + """RoutingConfig drops a leftover 'subdomain' key without raising. + + Old metadata.yaml files may still carry 'routing.subdomain' from before + subdomain routing was retired. RoutingConfig has no explicit + model_config, so Pydantic's default extra='ignore' silently drops the + field. This test pins that backward-compat contract — a future + maintainer who adds extra='forbid' would break old files. + """ + config = RoutingConfig.model_validate( + {"subdomain": "grafana", "host_port": 3000} + ) + assert config.host_port == 3000 + assert not hasattr(config, "subdomain") class TestRoutingAuth: @@ -121,16 +107,16 @@ def base_metadata(self) -> dict: def test_routing_field_accepted(self, base_metadata: dict) -> None: """routing field should be accepted in PackageMetadata.""" base_metadata["routing"] = { - "subdomain": "testapp", + "auth": {"mode": "forward_auth"}, } metadata = PackageMetadata(**base_metadata) assert metadata.routing is not None - assert metadata.routing.subdomain == "testapp" + assert metadata.routing.auth is not None + assert metadata.routing.auth.mode == "forward_auth" def test_traefik_field_is_rejected(self, base_metadata: dict) -> None: """traefik field should be rejected (deprecated).""" base_metadata["traefik"] = { - "subdomain": "testapp", "auth": "forward_auth", } with pytest.raises(ValidationError, match="Extra inputs are not permitted"): @@ -140,7 +126,6 @@ def test_full_routing_config(self, base_metadata: dict) -> None: """Full routing config should be valid.""" base_metadata["web_ui"] = {"enabled": True, "port": 3000} base_metadata["routing"] = { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { @@ -154,7 +139,6 @@ def test_full_routing_config(self, base_metadata: dict) -> None: } metadata = PackageMetadata(**base_metadata) assert metadata.routing is not None - assert metadata.routing.subdomain == "grafana" assert metadata.routing.auth is not None assert metadata.routing.auth.mode == "forward_auth" assert metadata.routing.auth.forward_auth is not None diff --git a/tests/test_templates_oidc.py b/tests/test_templates_oidc.py index c44fad4..2af76bd 100644 --- a/tests/test_templates_oidc.py +++ b/tests/test_templates_oidc.py @@ -23,7 +23,6 @@ def test_oidc_app_generates_secret(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "oidc", "auth": {"mode": "oidc"}, }, } @@ -68,7 +67,6 @@ def test_non_oidc_app_no_secret(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "fwd", "auth": {"mode": "forward_auth"}, }, } @@ -116,7 +114,6 @@ def test_oidc_app_removes_snippet(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "oidc", "auth": {"mode": "oidc"}, }, } @@ -160,7 +157,6 @@ def test_middleware_app_removes_middleware(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { @@ -254,7 +250,6 @@ def test_oidc_app_depends_on_authelia(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "oidc", "auth": {"mode": "oidc"}, }, } @@ -298,7 +293,6 @@ def test_non_oidc_app_no_authelia_dependency(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "fwd", "auth": {"mode": "forward_auth"}, }, } @@ -384,7 +378,6 @@ def test_oidc_app_installs_snippet(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "oidc", "auth": {"mode": "oidc"}, }, } @@ -428,7 +421,6 @@ def test_middleware_app_installs_middleware(self, tmp_path): "debian_section": "net", "architecture": "all", "routing": { - "subdomain": "grafana", "auth": { "mode": "forward_auth", "forward_auth": { diff --git a/tests/test_traefik_labels.py b/tests/test_traefik_labels.py deleted file mode 100644 index 9c4389b..0000000 --- a/tests/test_traefik_labels.py +++ /dev/null @@ -1,363 +0,0 @@ -"""Tests for Traefik label generation.""" - -import pytest - -from generate_container_packages.traefik import ( - _extract_container_port, - generate_traefik_labels, - inject_proxy_network, -) - - -class TestGenerateTraefikLabels: - """Tests for generate_traefik_labels function.""" - - def test_no_traefik_config_no_web_ui_returns_empty(self) -> None: - """Apps without traefik config and without web_ui get no labels.""" - metadata = {"app_id": "myapp"} - compose: dict = {"services": {"app": {}}} - labels = generate_traefik_labels(metadata, compose) - assert labels == {} - - def test_no_traefik_config_with_web_ui_enabled_gets_default_labels(self) -> None: - """Apps with web_ui.enabled but no traefik config get default forward_auth.""" - metadata = { - "app_id": "myapp", - "web_ui": {"enabled": True, "port": 8080}, - } - compose: dict = {"services": {"app": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert labels["traefik.enable"] == "true" - assert "myapp.${HALOS_DOMAIN}" in labels["traefik.http.routers.myapp.rule"] - assert labels["traefik.http.routers.myapp.middlewares"] == "authelia@file" - assert labels["traefik.http.services.myapp.loadbalancer.server.port"] == "8080" - assert labels["halos.subdomain"] == "myapp" - - def test_forward_auth_labels(self) -> None: - """Forward auth apps get authelia@file middleware.""" - metadata = { - "app_id": "grafana", - "web_ui": {"enabled": True, "port": 3000}, - "traefik": { - "subdomain": "grafana", - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"grafana": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert labels["traefik.enable"] == "true" - assert "grafana.${HALOS_DOMAIN}" in labels["traefik.http.routers.grafana.rule"] - assert labels["traefik.http.routers.grafana.entrypoints"] == "web,websecure" - assert labels["traefik.http.routers.grafana.middlewares"] == "authelia@file" - assert ( - labels["traefik.http.services.grafana.loadbalancer.server.port"] == "3000" - ) - assert labels["halos.subdomain"] == "grafana" - - def test_forward_auth_with_custom_headers_per_app_middleware(self) -> None: - """Forward auth with custom headers uses per-app middleware.""" - metadata = { - "app_id": "grafana", - "web_ui": {"enabled": True, "port": 3000}, - "traefik": { - "subdomain": "grafana", - "auth": "forward_auth", - "forward_auth": { - "headers": { - "Remote-User": "X-WEBAUTH-USER", - "Remote-Groups": "X-WEBAUTH-GROUPS", - } - }, - }, - } - compose: dict = {"services": {"grafana": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert ( - labels["traefik.http.routers.grafana.middlewares"] - == "authelia-grafana@file" - ) - - def test_oidc_labels_no_middleware(self) -> None: - """OIDC apps don't get middleware (they handle auth themselves).""" - metadata = { - "app_id": "homarr", - "web_ui": {"enabled": True, "port": 7575}, - "traefik": { - "subdomain": "", - "auth": "oidc", - "oidc": { - "client_name": "Homarr Dashboard", - "scopes": ["openid", "profile", "email", "groups"], - "redirect_path": "/api/auth/callback/oidc", - }, - }, - } - compose: dict = {"services": {"homarr": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert labels["traefik.enable"] == "true" - # Empty subdomain means root domain - assert "${HALOS_DOMAIN}`)" in labels["traefik.http.routers.homarr.rule"] - # No middleware key should be present for OIDC apps - assert "traefik.http.routers.homarr.middlewares" not in labels - assert labels["halos.subdomain"] == "" - - def test_none_auth_labels_no_middleware(self) -> None: - """None auth apps don't get middleware.""" - metadata = { - "app_id": "avnav", - "web_ui": {"enabled": True, "port": 8080}, - "traefik": { - "subdomain": "avnav", - "auth": "none", - }, - } - compose: dict = {"services": {"avnav": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert labels["traefik.enable"] == "true" - assert "avnav.${HALOS_DOMAIN}" in labels["traefik.http.routers.avnav.rule"] - assert "traefik.http.routers.avnav.middlewares" not in labels - - def test_custom_subdomain(self) -> None: - """Custom subdomain is used in routing rule.""" - metadata = { - "app_id": "signalk-server", - "web_ui": {"enabled": True, "port": 3000}, - "traefik": { - "subdomain": "signalk", - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"signalk": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert ( - "signalk.${HALOS_DOMAIN}" - in labels["traefik.http.routers.signalk-server.rule"] - ) - assert labels["halos.subdomain"] == "signalk" - - def test_default_subdomain_from_app_id(self) -> None: - """When subdomain is None, app_id is used.""" - metadata = { - "app_id": "myapp", - "web_ui": {"enabled": True, "port": 8080}, - "traefik": { - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"app": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert "myapp.${HALOS_DOMAIN}" in labels["traefik.http.routers.myapp.rule"] - assert labels["halos.subdomain"] == "myapp" - - def test_host_networking_backend_url(self) -> None: - """Host networking apps use host.docker.internal URL.""" - metadata = { - "app_id": "signalk", - "web_ui": {"enabled": True, "port": 3000}, - "traefik": { - "subdomain": "signalk", - "auth": "forward_auth", - "host_port": 3000, - }, - } - compose: dict = {"services": {"signalk": {"network_mode": "host"}}} - labels = generate_traefik_labels(metadata, compose) - - assert ( - labels["traefik.http.services.signalk.loadbalancer.server.url"] - == "http://host.docker.internal:3000" - ) - # No port label when using URL - assert "traefik.http.services.signalk.loadbalancer.server.port" not in labels - - def test_host_networking_without_host_port_infers_from_web_ui(self) -> None: - """Host networking without host_port infers from web_ui.port.""" - metadata = { - "app_id": "signalk", - "web_ui": {"enabled": True, "port": 3000}, - "traefik": { - "subdomain": "signalk", - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"signalk": {"network_mode": "host"}}} - labels = generate_traefik_labels(metadata, compose) - - assert ( - labels["traefik.http.services.signalk.loadbalancer.server.url"] - == "http://host.docker.internal:3000" - ) - - def test_host_networking_without_port_raises_error(self) -> None: - """Host networking without host_port or web_ui.port raises error.""" - metadata = { - "app_id": "signalk", - "traefik": { - "subdomain": "signalk", - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"signalk": {"network_mode": "host"}}} - - with pytest.raises(ValueError) as exc_info: - generate_traefik_labels(metadata, compose) - assert "host_port" in str(exc_info.value).lower() - - def test_bridge_networking_service_port(self) -> None: - """Bridge networking apps use service port label.""" - metadata = { - "app_id": "grafana", - "web_ui": {"enabled": True, "port": 3000}, - "traefik": { - "subdomain": "grafana", - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"grafana": {}}} - labels = generate_traefik_labels(metadata, compose) - - assert ( - labels["traefik.http.services.grafana.loadbalancer.server.port"] == "3000" - ) - assert "traefik.http.services.grafana.loadbalancer.server.url" not in labels - - def test_bridge_networking_uses_container_port_from_compose(self) -> None: - """Bridge networking uses container port from docker-compose, not web_ui.port.""" - metadata = { - "app_id": "avnav", - "web_ui": {"enabled": True, "port": 8082}, # Host port (wrong for Traefik) - "traefik": { - "subdomain": "avnav", - "auth": "none", - }, - } - # docker-compose: ports: ["${PORT:-3011}:8080"] -> container port is 8080 - compose: dict = {"services": {"avnav": {"ports": ["${PORT:-3011}:8080"]}}} - labels = generate_traefik_labels(metadata, compose) - - # Should use container port 8080, not web_ui.port 8082 - assert labels["traefik.http.services.avnav.loadbalancer.server.port"] == "8080" - - def test_bridge_networking_falls_back_to_web_ui_port(self) -> None: - """Bridge networking falls back to web_ui.port when no ports in compose.""" - metadata = { - "app_id": "myapp", - "web_ui": {"enabled": True, "port": 9000}, - "traefik": { - "subdomain": "myapp", - "auth": "forward_auth", - }, - } - compose: dict = {"services": {"myapp": {}}} # No ports defined - labels = generate_traefik_labels(metadata, compose) - - # Should fall back to web_ui.port - assert labels["traefik.http.services.myapp.loadbalancer.server.port"] == "9000" - - -class TestExtractContainerPort: - """Tests for _extract_container_port helper function.""" - - def test_simple_host_container_format(self) -> None: - """Extract container port from host:container format.""" - compose = {"services": {"app": {"ports": ["3011:8080"]}}} - assert _extract_container_port(compose) == 8080 - - def test_env_var_host_container_format(self) -> None: - """Extract container port when host is env var.""" - compose = {"services": {"app": {"ports": ["${PORT:-3011}:8080"]}}} - assert _extract_container_port(compose) == 8080 - - def test_container_port_only(self) -> None: - """Extract port when only container port specified.""" - compose = {"services": {"app": {"ports": ["8080"]}}} - assert _extract_container_port(compose) == 8080 - - def test_with_protocol_suffix(self) -> None: - """Extract port with protocol suffix like /tcp.""" - compose = {"services": {"app": {"ports": ["3011:8080/tcp"]}}} - assert _extract_container_port(compose) == 8080 - - def test_long_syntax_dict_format(self) -> None: - """Extract port from long syntax dict format.""" - compose = { - "services": { - "app": { - "ports": [{"target": 8080, "published": 3011, "protocol": "tcp"}] - } - } - } - assert _extract_container_port(compose) == 8080 - - def test_integer_port(self) -> None: - """Extract port when specified as integer.""" - compose = {"services": {"app": {"ports": [8080]}}} - assert _extract_container_port(compose) == 8080 - - def test_no_ports_returns_none(self) -> None: - """Return None when no ports defined.""" - compose = {"services": {"app": {}}} - assert _extract_container_port(compose) is None - - def test_empty_ports_returns_none(self) -> None: - """Return None when ports list is empty.""" - compose = {"services": {"app": {"ports": []}}} - assert _extract_container_port(compose) is None - - -class TestInjectProxyNetwork: - """Tests for inject_proxy_network function.""" - - def test_adds_network_to_compose(self) -> None: - """Network is added to compose when not host networking.""" - compose = {"services": {"app": {}}} - result = inject_proxy_network(compose, is_host_network=False) - - assert "networks" in result - assert "halos-proxy-network" in result["networks"] - assert result["networks"]["halos-proxy-network"]["external"] is True - - def test_adds_network_to_service(self) -> None: - """Service gets network reference added.""" - compose = {"services": {"app": {}}} - result = inject_proxy_network(compose, is_host_network=False) - - assert "networks" in result["services"]["app"] - assert "halos-proxy-network" in result["services"]["app"]["networks"] - - def test_host_network_no_changes(self) -> None: - """Host networking apps don't get network added.""" - compose = {"services": {"app": {"network_mode": "host"}}} - result = inject_proxy_network(compose, is_host_network=True) - - assert "networks" not in result or "halos-proxy-network" not in result.get( - "networks", {} - ) - - def test_preserves_existing_networks(self) -> None: - """Existing networks are preserved.""" - compose = { - "services": {"app": {"networks": ["existing-net"]}}, - "networks": {"existing-net": {}}, - } - result = inject_proxy_network(compose, is_host_network=False) - - assert "existing-net" in result["networks"] - assert "halos-proxy-network" in result["networks"] - assert "existing-net" in result["services"]["app"]["networks"] - assert "halos-proxy-network" in result["services"]["app"]["networks"] - - def test_converts_list_networks_to_list_with_new_network(self) -> None: - """List-format service networks get new network appended.""" - compose = {"services": {"app": {"networks": ["existing"]}}} - result = inject_proxy_network(compose, is_host_network=False) - - assert "existing" in result["services"]["app"]["networks"] - assert "halos-proxy-network" in result["services"]["app"]["networks"] diff --git a/tests/test_traefik_network.py b/tests/test_traefik_network.py new file mode 100644 index 0000000..ceb0e6c --- /dev/null +++ b/tests/test_traefik_network.py @@ -0,0 +1,54 @@ +"""Tests for Traefik proxy-network injection.""" + +from generate_container_packages.traefik import inject_proxy_network + + +class TestInjectProxyNetwork: + """Tests for inject_proxy_network function.""" + + def test_adds_network_to_compose(self) -> None: + """Network is added to compose when not host networking.""" + compose = {"services": {"app": {}}} + result = inject_proxy_network(compose, is_host_network=False) + + assert "networks" in result + assert "halos-proxy-network" in result["networks"] + assert result["networks"]["halos-proxy-network"]["external"] is True + + def test_adds_network_to_service(self) -> None: + """Service gets network reference added.""" + compose = {"services": {"app": {}}} + result = inject_proxy_network(compose, is_host_network=False) + + assert "networks" in result["services"]["app"] + assert "halos-proxy-network" in result["services"]["app"]["networks"] + + def test_host_network_no_changes(self) -> None: + """Host networking apps don't get network added.""" + compose = {"services": {"app": {"network_mode": "host"}}} + result = inject_proxy_network(compose, is_host_network=True) + + assert "networks" not in result or "halos-proxy-network" not in result.get( + "networks", {} + ) + + def test_preserves_existing_networks(self) -> None: + """Existing networks are preserved.""" + compose = { + "services": {"app": {"networks": ["existing-net"]}}, + "networks": {"existing-net": {}}, + } + result = inject_proxy_network(compose, is_host_network=False) + + assert "existing-net" in result["networks"] + assert "halos-proxy-network" in result["networks"] + assert "existing-net" in result["services"]["app"]["networks"] + assert "halos-proxy-network" in result["services"]["app"]["networks"] + + def test_converts_list_networks_to_list_with_new_network(self) -> None: + """List-format service networks get new network appended.""" + compose = {"services": {"app": {"networks": ["existing"]}}} + result = inject_proxy_network(compose, is_host_network=False) + + assert "existing" in result["services"]["app"]["networks"] + assert "halos-proxy-network" in result["services"]["app"]["networks"] diff --git a/tests/test_traefik_schema.py b/tests/test_traefik_schema.py index 965c40e..f08de6c 100644 --- a/tests/test_traefik_schema.py +++ b/tests/test_traefik_schema.py @@ -76,9 +76,8 @@ class TestTraefikConfig: """Tests for TraefikConfig model.""" def test_default_values(self) -> None: - """Default values should use forward_auth with no subdomain.""" + """Default values should use forward_auth.""" config = TraefikConfig() - assert config.subdomain is None assert config.auth == "forward_auth" assert config.forward_auth is None assert config.oidc is None @@ -87,11 +86,9 @@ def test_default_values(self) -> None: def test_forward_auth_config(self) -> None: """Valid forward_auth configuration.""" config = TraefikConfig( - subdomain="grafana", auth="forward_auth", forward_auth=TraefikForwardAuth(headers={"Remote-User": "X-WEBAUTH-USER"}), ) - assert config.subdomain == "grafana" assert config.auth == "forward_auth" assert config.forward_auth is not None assert config.forward_auth.headers["Remote-User"] == "X-WEBAUTH-USER" @@ -99,7 +96,6 @@ def test_forward_auth_config(self) -> None: def test_oidc_config(self) -> None: """Valid OIDC configuration.""" config = TraefikConfig( - subdomain="homarr", auth="oidc", oidc=TraefikOIDC( client_name="Homarr Dashboard", @@ -107,18 +103,13 @@ def test_oidc_config(self) -> None: redirect_path="/api/auth/callback/oidc", ), ) - assert config.subdomain == "homarr" assert config.auth == "oidc" assert config.oidc is not None assert config.oidc.client_name == "Homarr Dashboard" def test_none_auth_config(self) -> None: """Valid none authentication configuration.""" - config = TraefikConfig( - subdomain="avnav", - auth="none", - ) - assert config.subdomain == "avnav" + config = TraefikConfig(auth="none") assert config.auth == "none" def test_oidc_requires_oidc_section(self) -> None: @@ -130,7 +121,6 @@ def test_oidc_requires_oidc_section(self) -> None: def test_host_port_config(self) -> None: """Valid host_port configuration for host networking apps.""" config = TraefikConfig( - subdomain="signalk", auth="forward_auth", host_port=3000, ) @@ -148,36 +138,6 @@ def test_host_port_invalid_range_high(self) -> None: TraefikConfig(host_port=70000) assert "host_port" in str(exc_info.value) - def test_valid_subdomain_patterns(self) -> None: - """Valid subdomain patterns should pass.""" - valid_subdomains = [ - "a", - "grafana", - "my-app", - "app123", - "a1b2c3", - "my-cool-app", - "", # Empty string is valid (means root domain) - ] - for subdomain in valid_subdomains: - config = TraefikConfig(subdomain=subdomain) - assert config.subdomain == subdomain - - def test_invalid_subdomain_patterns(self) -> None: - """Invalid subdomain patterns should fail.""" - invalid_subdomains = [ - "-app", # Starts with hyphen - "app-", # Ends with hyphen - "APP", # Uppercase - "my_app", # Underscore - "my.app", # Dot - "my app", # Space - ] - for subdomain in invalid_subdomains: - with pytest.raises(ValidationError) as exc_info: - TraefikConfig(subdomain=subdomain) - assert "subdomain" in str(exc_info.value).lower() - def test_invalid_auth_mode(self) -> None: """Invalid auth mode should fail.""" with pytest.raises(ValidationError) as exc_info: From a881ba7132513370959b1519281b2c17326c7d04 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Sun, 24 May 2026 22:32:08 +0300 Subject: [PATCH 2/3] =?UTF-8?q?Bump=20version:=200.6.0=20=E2=86=92=200.6.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- VERSION | 2 +- pyproject.toml | 2 +- src/generate_container_packages/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 2b204fa..0733e54 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.6.0 +current_version = 0.6.1 commit = True tag = False diff --git a/VERSION b/VERSION index a918a2a..ee6cdce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/pyproject.toml b/pyproject.toml index a49fcb6..17484e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "generate-container-packages" -version = "0.6.0" +version = "0.6.1" description = "Generate Debian packages from container application definitions" readme = "README.md" requires-python = ">=3.11" diff --git a/src/generate_container_packages/__init__.py b/src/generate_container_packages/__init__.py index 8ce2acf..f2a7a9e 100644 --- a/src/generate_container_packages/__init__.py +++ b/src/generate_container_packages/__init__.py @@ -1,5 +1,5 @@ """Container Packaging Tools - Generate Debian packages from container app definitions.""" -__version__ = "0.6.0" +__version__ = "0.6.1" __author__ = "Matti Airas" __license__ = "MIT" From 624de36ac488460f5fe2b0005b284bea94103430 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Sun, 24 May 2026 22:32:17 +0300 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20sync=20uv.lock=20version=20after?= =?UTF-8?q?=200.6.0=20=E2=86=92=200.6.1=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 11c6448..bb19ac4 100644 --- a/uv.lock +++ b/uv.lock @@ -211,7 +211,7 @@ toml = [ [[package]] name = "generate-container-packages" -version = "0.6.0" +version = "0.6.1" source = { editable = "." } dependencies = [ { name = "jinja2" },