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
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.6.0
current_version = 0.6.1
commit = True
tag = False

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.6.0
0.6.1
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/generate_container_packages/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 6 additions & 12 deletions src/generate_container_packages/oidc_snippet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 5 additions & 168 deletions src/generate_container_packages/traefik.py
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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],
Expand Down
16 changes: 0 additions & 16 deletions src/schemas/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 0 additions & 8 deletions tests/test_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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": {
Expand All @@ -56,7 +54,6 @@ def test_oidc_app_returns_none(self) -> None:
"app_id": "homarr",
"package_name": "homarr-container",
"routing": {
"subdomain": "",
"auth": {
"mode": "oidc",
},
Expand All @@ -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",
},
Expand All @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
Loading
Loading