From 8c63cc8d2024717a2379af0be33969d40732aa64 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:18:29 +0900 Subject: [PATCH 01/13] refactor(BA-8675): add TraefikRoutingConfigFactory for ingresses The Traefik ingress export needs the router, service and middleware configs of an ingress and the etcd keys Traefik reads them from. The factory builds them from the ingress types in one place so the client that writes them to etcd holds no Traefik naming or layout. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 265 ++++++++++++++++++ .../appproxy/coordinator/errors/ingress.py | 15 + .../worker/test_traefik_routing_config.py | 178 ++++++++++++ 3 files changed, 458 insertions(+) create mode 100644 src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py create mode 100644 tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py new file mode 100644 index 00000000000..8fe0b3fdc31 --- /dev/null +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final + +from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME +from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig +from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError +from ai.backend.common.dto.appproxy.v2.ingress.types import ( + Ingress, + IngressAccess, + IngressAuthority, + IngressBackend, + IngressFrontend, + IngressFrontendKind, + IngressKey, + IngressProtocol, + IngressTarget, +) + +_CORS_HEADERS_MIDDLEWARE_NAME: Final[str] = "CORSHeaders" + + +@dataclass(frozen=True) +class TraefikWorker: + hostname: str + wildcard_domain: str | None + tls_listen: bool + + +class TraefikRoutingConfigFactory: + """Builds the Traefik router, middleware and service configs of an ingress and the etcd keys + Traefik reads them from.""" + + _client_ip_strategy: ClientIPStrategyConfig | None + _jwt_secret: str + _permit_hash_secret: str + + def __init__( + self, traefik_config: TraefikConfig, *, jwt_secret: str, permit_hash_secret: str + ) -> None: + self._client_ip_strategy = traefik_config.client_ip_strategy + self._jwt_secret = jwt_secret + self._permit_hash_secret = permit_hash_secret + + @classmethod + def routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: + """Every routing config key an ingress can own, for clearing the ones it no longer has.""" + return [ + cls._router_etcd_key(ingress_key, IngressProtocol.HTTP), + cls._service_etcd_key(ingress_key, IngressProtocol.HTTP), + cls._http_middleware_etcd_key( + ingress_key.authority, cls._ip_allowlist_middleware_name(ingress_key) + ), + cls._http_middleware_etcd_key( + ingress_key.authority, cls._auth_plugin_middleware_name(ingress_key) + ), + cls._router_etcd_key(ingress_key, IngressProtocol.TCP), + cls._service_etcd_key(ingress_key, IngressProtocol.TCP), + ] + + def routing_configs_by_etcd_key( + self, ingress: Ingress, worker: TraefikWorker + ) -> dict[str, dict[str, Any]]: + """Router, service and middleware configs of the ingress, by the etcd key Traefik reads.""" + ingress_key = ingress.key() + frontend_protocol = ingress.frontend.protocol + router_etcd_key = self._router_etcd_key(ingress_key, frontend_protocol) + service_etcd_key = self._service_etcd_key(ingress_key, frontend_protocol) + match frontend_protocol: + case IngressProtocol.TCP: + return { + router_etcd_key: self._tcp_router_config(ingress), + service_etcd_key: self._tcp_service_config(ingress.backend), + } + case IngressProtocol.HTTP | IngressProtocol.HTTP2: + return { + router_etcd_key: self._http_router_config(ingress, worker), + service_etcd_key: self._http_service_config(ingress.backend), + **self._http_middleware_configs(ingress), + } + + @classmethod + def _router_etcd_key(cls, ingress_key: IngressKey, protocol: IngressProtocol) -> str: + traefik_protocol_key = cls._traefik_protocol_key(protocol) + return ( + f"worker_{ingress_key.authority}/{traefik_protocol_key}/routers/" + f"{cls._router_name(ingress_key)}" + ) + + @classmethod + def _service_etcd_key(cls, ingress_key: IngressKey, protocol: IngressProtocol) -> str: + traefik_protocol_key = cls._traefik_protocol_key(protocol) + return ( + f"worker_{ingress_key.authority}/{traefik_protocol_key}/services/" + f"{cls._service_name(ingress_key)}" + ) + + @classmethod + def _http_router_config(cls, ingress: Ingress, worker: TraefikWorker) -> dict[str, Any]: + ingress_key = ingress.key() + # The ipAllowList middleware must precede the auth plugin so that disallowed + # clients are rejected before any credential handling. + middleware_names = [_CORS_HEADERS_MIDDLEWARE_NAME] + if ingress.access.allowed_client_ips: + middleware_names.append(cls._ip_allowlist_middleware_name(ingress_key)) + middleware_names.append(cls._auth_plugin_middleware_name(ingress_key)) + router_config: dict[str, Any] = { + "rule": cls._host_rule(ingress, worker), + "service": cls._service_name(ingress_key), + "entrypoints": [cls._entrypoint_name(ingress.frontend)], + "middlewares": middleware_names, + } + if worker.tls_listen: + router_config["tls"] = "" + return router_config + + @classmethod + def _tcp_router_config(cls, ingress: Ingress) -> dict[str, Any]: + return { + "rule": cls._client_ip_rule(ingress.access), + "service": cls._service_name(ingress.key()), + "entrypoints": [cls._entrypoint_name(ingress.frontend)], + } + + def _http_middleware_configs(self, ingress: Ingress) -> dict[str, dict[str, Any]]: + ingress_key = ingress.key() + cors_headers_etcd_key = self._http_middleware_etcd_key( + ingress.authority, _CORS_HEADERS_MIDDLEWARE_NAME + ) + middleware_configs: dict[str, dict[str, Any]] = { + cors_headers_etcd_key: { + "headers": { + "accessControlAllowHeaders": "*", + "accessControlAllowOriginList": ["*"], + }, + }, + } + if ingress.access.allowed_client_ips: + ip_allowlist_etcd_key = self._http_middleware_etcd_key( + ingress.authority, self._ip_allowlist_middleware_name(ingress_key) + ) + middleware_configs[ip_allowlist_etcd_key] = self._ip_allowlist_middleware_config( + ingress.access + ) + auth_plugin_etcd_key = self._http_middleware_etcd_key( + ingress.authority, self._auth_plugin_middleware_name(ingress_key) + ) + middleware_configs[auth_plugin_etcd_key] = self._auth_plugin_middleware_config(ingress) + return middleware_configs + + def _ip_allowlist_middleware_config(self, access: IngressAccess) -> dict[str, Any]: + ip_allowlist: dict[str, Any] = { + "sourceRange": [str(network) for network in access.allowed_client_ips], + } + client_ip_strategy = self._client_ip_strategy + if client_ip_strategy is not None: + if client_ip_strategy.depth is not None: + ip_allowlist["ipStrategy"] = {"depth": client_ip_strategy.depth} + elif client_ip_strategy.excluded_ips: + ip_allowlist["ipStrategy"] = { + "excludedIPs": [str(network) for network in client_ip_strategy.excluded_ips], + } + return {"ipAllowList": ip_allowlist} + + def _auth_plugin_middleware_config(self, ingress: Ingress) -> dict[str, Any]: + return { + "plugin": { + "appproxy-traefik-plugin": { + # etcd holds strings only, so the ingress travels as one JSON value. + "ingress": ingress.model_dump_json(), + "jwt_secret": self._jwt_secret, + "permit_hash_secret": self._permit_hash_secret, + "permit_cookie_name": PERMIT_COOKIE_NAME, + }, + }, + } + + @classmethod + def _http_service_config(cls, backend: IngressBackend) -> dict[str, Any]: + servers = [ + {"url": f"http://{target.host}:{target.port}/"} + for target in cls._targets_receiving_traffic(backend) + ] + if not servers: + return {} + return {"loadBalancer": {"servers": servers}} + + @classmethod + def _tcp_service_config(cls, backend: IngressBackend) -> dict[str, Any]: + servers = [ + {"address": f"{target.host}:{target.port}"} + for target in cls._targets_receiving_traffic(backend) + ] + if not servers: + return {} + return {"loadBalancer": {"servers": servers}} + + # Frontend keys hold no '_', so no router, service or middleware name of one ingress is a + # prefix of another's. + @staticmethod + def _router_name(ingress_key: IngressKey) -> str: + return f"bai_ingress_{ingress_key.frontend_key}_router" + + @staticmethod + def _service_name(ingress_key: IngressKey) -> str: + return f"bai_ingress_{ingress_key.frontend_key}_service" + + @staticmethod + def _ip_allowlist_middleware_name(ingress_key: IngressKey) -> str: + return f"bai_ingress_{ingress_key.frontend_key}_ipallowlist" + + @staticmethod + def _auth_plugin_middleware_name(ingress_key: IngressKey) -> str: + return f"bai_ingress_{ingress_key.frontend_key}_auth" + + @staticmethod + def _traefik_protocol_key(protocol: IngressProtocol) -> str: + # Traefik keeps HTTP/2 routing under its http configuration. + match protocol: + case IngressProtocol.HTTP | IngressProtocol.HTTP2: + return "http" + case IngressProtocol.TCP: + return "tcp" + + @staticmethod + def _http_middleware_etcd_key(authority: IngressAuthority, middleware_name: str) -> str: + return f"worker_{authority}/http/middlewares/{middleware_name}" + + @staticmethod + def _host_rule(ingress: Ingress, worker: TraefikWorker) -> str: + match ingress.frontend.kind: + case IngressFrontendKind.PORT: + return f"Host(`{worker.hostname}`)" + case IngressFrontendKind.WILDCARD: + if worker.wildcard_domain is None: + raise TraefikWildcardDomainMissingError( + extra_data={"authority": ingress.authority} + ) + return f"Host(`{ingress.frontend.key()}{worker.wildcard_domain}`)" + + @staticmethod + def _client_ip_rule(access: IngressAccess) -> str: + allowed_client_ip_ranges = [str(network) for network in access.allowed_client_ips] + if not allowed_client_ip_ranges: + return "ClientIP(`0.0.0.0/0`)" + # Traefik v3 rule matchers take one value each, so the ranges are joined with ||. + return " || ".join(f"ClientIP(`{cidr}`)" for cidr in allowed_client_ip_ranges) + + @staticmethod + def _entrypoint_name(frontend: IngressFrontend) -> str: + match frontend.kind: + case IngressFrontendKind.PORT: + return f"portproxy_{frontend.key()}" + case IngressFrontendKind.WILDCARD: + return "domainproxy" + + @staticmethod + def _targets_receiving_traffic(backend: IngressBackend) -> list[IngressTarget]: + return [ + target + for group in backend.target_groups + if group.weight > 0 + for target in group.targets + ] diff --git a/src/ai/backend/appproxy/coordinator/errors/ingress.py b/src/ai/backend/appproxy/coordinator/errors/ingress.py index 0411dc1846d..f2bbc7605b2 100644 --- a/src/ai/backend/appproxy/coordinator/errors/ingress.py +++ b/src/ai/backend/appproxy/coordinator/errors/ingress.py @@ -73,3 +73,18 @@ def error_code(self) -> ErrorCode: operation=ErrorOperation.REQUEST, error_detail=ErrorDetail.UNAVAILABLE, ) + + +class TraefikWildcardDomainMissingError(BackendAIError, web.HTTPInternalServerError): + """Raised when a wildcard ingress targets a worker registered without a wildcard domain.""" + + error_type = "https://api.backend.ai/probs/appproxy-coordinator/traefik-wildcard-domain-missing" + error_title = "The worker has no wildcard domain to route a wildcard ingress." + + @override + def error_code(self) -> ErrorCode: + return ErrorCode( + domain=ErrorDomain.APPPROXY, + operation=ErrorOperation.UPSERT, + error_detail=ErrorDetail.INTERNAL_ERROR, + ) diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py new file mode 100644 index 00000000000..04748ae4474 --- /dev/null +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import Mock + +import pytest + +from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( + TraefikRoutingConfigFactory, + TraefikWorker, +) +from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig +from ai.backend.common.dto.appproxy.v2.ingress.types import ( + Ingress, + IngressAccess, + IngressAccessMode, + IngressAuthority, + IngressBackend, + IngressBackendID, + IngressFrontend, + IngressFrontendKind, + IngressID, + IngressProtocol, + IngressTargetGroup, + IngressTargetGroupID, + PortFrontend, +) + + +@dataclass(frozen=True) +class _ClientIPStrategyCase: + name: str + strategy: ClientIPStrategyConfig | None + expected_ip_strategy: dict[str, Any] | None + + +@dataclass(frozen=True) +class _ClientIPRuleCase: + name: str + ranges: list[str] = field(default_factory=list) + expected_rule: str = "" + + +@pytest.fixture +def routing_config_factory(case: _ClientIPStrategyCase) -> TraefikRoutingConfigFactory: + return TraefikRoutingConfigFactory( + Mock(spec=TraefikConfig, client_ip_strategy=case.strategy), + jwt_secret="jwt-secret", + permit_hash_secret="permit-hash-secret", + ) + + +@pytest.fixture +def routing_config_factory_without_client_ip_strategy() -> TraefikRoutingConfigFactory: + return TraefikRoutingConfigFactory( + Mock(spec=TraefikConfig, client_ip_strategy=None), + jwt_secret="jwt-secret", + permit_hash_secret="permit-hash-secret", + ) + + +@pytest.fixture +def worker() -> TraefikWorker: + return TraefikWorker(hostname="worker-1.example.com", wildcard_domain=None, tls_listen=False) + + +@pytest.fixture +def http_ingress_allowing_one_network() -> Ingress: + return Ingress( + id=IngressID("ingress-10205"), + authority=IngressAuthority("worker-1"), + revision=1, + frontend=IngressFrontend( + kind=IngressFrontendKind.PORT, + protocol=IngressProtocol.HTTP, + port=PortFrontend(number=10205), + ), + access=IngressAccess( + mode=IngressAccessMode.PUBLIC, + allowed_client_ips=[ipaddress.ip_network("192.168.0.0/24")], + ), + backend=IngressBackend( + id=IngressBackendID("backend-10205"), + target_groups=[ + IngressTargetGroup(id=IngressTargetGroupID("group-1"), weight=1, targets=[]) + ], + ), + ) + + +@pytest.fixture +def tcp_ingress(case: _ClientIPRuleCase) -> Ingress: + return Ingress( + id=IngressID("ingress-10205"), + authority=IngressAuthority("worker-1"), + revision=1, + frontend=IngressFrontend( + kind=IngressFrontendKind.PORT, + protocol=IngressProtocol.TCP, + port=PortFrontend(number=10205), + ), + access=IngressAccess( + mode=IngressAccessMode.PUBLIC, + allowed_client_ips=[ipaddress.ip_network(cidr) for cidr in case.ranges], + ), + backend=IngressBackend( + id=IngressBackendID("backend-10205"), + target_groups=[ + IngressTargetGroup(id=IngressTargetGroupID("group-1"), weight=1, targets=[]) + ], + ), + ) + + +class TestTraefikRoutingConfigFactory: + @pytest.mark.parametrize( + "case", + [ + _ClientIPStrategyCase(name="no-strategy", strategy=None, expected_ip_strategy=None), + _ClientIPStrategyCase( + name="depth", + strategy=ClientIPStrategyConfig(depth=2, excluded_ips=[]), + expected_ip_strategy={"depth": 2}, + ), + _ClientIPStrategyCase( + name="excluded-ips", + strategy=ClientIPStrategyConfig( + depth=None, excluded_ips=[ipaddress.ip_network("10.0.0.0/8")] + ), + expected_ip_strategy={"excludedIPs": ["10.0.0.0/8"]}, + ), + ], + ids=lambda case: case.name, + ) + def test_ip_allowlist_reads_the_client_ip_strategy( + self, + case: _ClientIPStrategyCase, + routing_config_factory: TraefikRoutingConfigFactory, + http_ingress_allowing_one_network: Ingress, + worker: TraefikWorker, + ) -> None: + routing_configs = routing_config_factory.routing_configs_by_etcd_key( + http_ingress_allowing_one_network, worker + ) + ip_allowlist = routing_configs[ + "worker_worker-1/http/middlewares/bai_ingress_10205_ipallowlist" + ]["ipAllowList"] + assert ip_allowlist.get("ipStrategy") == case.expected_ip_strategy + assert ip_allowlist["sourceRange"] == ["192.168.0.0/24"] + + @pytest.mark.parametrize( + "case", + [ + _ClientIPRuleCase(name="open", ranges=[], expected_rule="ClientIP(`0.0.0.0/0`)"), + _ClientIPRuleCase( + name="restricted", + ranges=["192.168.0.0/24", "10.1.2.3/32"], + expected_rule="ClientIP(`192.168.0.0/24`) || ClientIP(`10.1.2.3/32`)", + ), + ], + ids=lambda case: case.name, + ) + def test_tcp_router_rule_matches_the_allowed_client_ips( + self, + case: _ClientIPRuleCase, + routing_config_factory_without_client_ip_strategy: TraefikRoutingConfigFactory, + tcp_ingress: Ingress, + worker: TraefikWorker, + ) -> None: + routing_configs = ( + routing_config_factory_without_client_ip_strategy.routing_configs_by_etcd_key( + tcp_ingress, worker + ) + ) + router_config = routing_configs["worker_worker-1/tcp/routers/bai_ingress_10205_router"] + assert router_config["rule"] == case.expected_rule From a5baaad7db3208c63189edddcdabf56967da797f Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:19:41 +0900 Subject: [PATCH 02/13] changelog: add news fragment for PR #16018 Co-Authored-By: Claude Opus 5.5 --- changes/16018.enhance.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/16018.enhance.md diff --git a/changes/16018.enhance.md b/changes/16018.enhance.md new file mode 100644 index 00000000000..9f58d550551 --- /dev/null +++ b/changes/16018.enhance.md @@ -0,0 +1 @@ +Build the Traefik router, service and middleware configs of an AppProxy ingress through `TraefikRoutingConfigFactory` From 5a17e6412e9a763bbe859a8553c3ab38ee21ca0b Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:24:34 +0900 Subject: [PATCH 03/13] refactor(BA-8675): hold the TraefikConfig in TraefikRoutingConfigFactory The factory keeps the `TraefikConfig` it is given and reads `client_ip_strategy` from it when building an ipAllowList middleware, instead of keeping only the `ClientIPStrategyConfig`. Co-Authored-By: Claude Opus 5.5 --- .../coordinator/clients/worker/traefik_routing_config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index 8fe0b3fdc31..df7565eeae4 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -4,7 +4,7 @@ from typing import Any, Final from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME -from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig +from ai.backend.appproxy.coordinator.config import TraefikConfig from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError from ai.backend.common.dto.appproxy.v2.ingress.types import ( Ingress, @@ -32,14 +32,14 @@ class TraefikRoutingConfigFactory: """Builds the Traefik router, middleware and service configs of an ingress and the etcd keys Traefik reads them from.""" - _client_ip_strategy: ClientIPStrategyConfig | None + _traefik_config: TraefikConfig _jwt_secret: str _permit_hash_secret: str def __init__( self, traefik_config: TraefikConfig, *, jwt_secret: str, permit_hash_secret: str ) -> None: - self._client_ip_strategy = traefik_config.client_ip_strategy + self._traefik_config = traefik_config self._jwt_secret = jwt_secret self._permit_hash_secret = permit_hash_secret @@ -153,7 +153,7 @@ def _ip_allowlist_middleware_config(self, access: IngressAccess) -> dict[str, An ip_allowlist: dict[str, Any] = { "sourceRange": [str(network) for network in access.allowed_client_ips], } - client_ip_strategy = self._client_ip_strategy + client_ip_strategy = self._traefik_config.client_ip_strategy if client_ip_strategy is not None: if client_ip_strategy.depth is not None: ip_allowlist["ipStrategy"] = {"depth": client_ip_strategy.depth} From c1634944daa6cc0efd495975a68c49a15ebe0ec6 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:30:00 +0900 Subject: [PATCH 04/13] refactor(BA-8675): move TraefikWorker to clients/worker/types.py `TraefikWorker` is the value the Traefik worker client builds from service discovery and the routing config factory reads, so it lives in its own module next to both instead of in the factory. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 9 +-------- .../appproxy/coordinator/clients/worker/types.py | 10 ++++++++++ .../clients/worker/test_traefik_routing_config.py | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 src/ai/backend/appproxy/coordinator/clients/worker/types.py diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index df7565eeae4..d98fea95ee3 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -1,9 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any, Final from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME +from ai.backend.appproxy.coordinator.clients.worker.types import TraefikWorker from ai.backend.appproxy.coordinator.config import TraefikConfig from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -21,13 +21,6 @@ _CORS_HEADERS_MIDDLEWARE_NAME: Final[str] = "CORSHeaders" -@dataclass(frozen=True) -class TraefikWorker: - hostname: str - wildcard_domain: str | None - tls_listen: bool - - class TraefikRoutingConfigFactory: """Builds the Traefik router, middleware and service configs of an ingress and the etcd keys Traefik reads them from.""" diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/types.py b/src/ai/backend/appproxy/coordinator/clients/worker/types.py new file mode 100644 index 00000000000..6eddd80e42d --- /dev/null +++ b/src/ai/backend/appproxy/coordinator/clients/worker/types.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TraefikWorker: + hostname: str + wildcard_domain: str | None + tls_listen: bool diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py index 04748ae4474..22bc9760d34 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -9,8 +9,8 @@ from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( TraefikRoutingConfigFactory, - TraefikWorker, ) +from ai.backend.appproxy.coordinator.clients.worker.types import TraefikWorker from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( Ingress, From 729a953daf1bf183b9db4ebb07a3283817fbc8f3 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:35:03 +0900 Subject: [PATCH 05/13] refactor(BA-8675): rename TraefikWorker to WorkerFrontendSettings It holds the worker's frontend settings a Traefik router needs (hostname, wildcard domain, tls_listen), not a worker, so the type and the arguments carrying it say so. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 22 ++++++++++--------- .../coordinator/clients/worker/types.py | 2 +- .../worker/test_traefik_routing_config.py | 16 ++++++++------ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index d98fea95ee3..f9e8d27cb2c 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -3,7 +3,7 @@ from typing import Any, Final from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME -from ai.backend.appproxy.coordinator.clients.worker.types import TraefikWorker +from ai.backend.appproxy.coordinator.clients.worker.types import WorkerFrontendSettings from ai.backend.appproxy.coordinator.config import TraefikConfig from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -53,7 +53,7 @@ def routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: ] def routing_configs_by_etcd_key( - self, ingress: Ingress, worker: TraefikWorker + self, ingress: Ingress, worker_frontend_settings: WorkerFrontendSettings ) -> dict[str, dict[str, Any]]: """Router, service and middleware configs of the ingress, by the etcd key Traefik reads.""" ingress_key = ingress.key() @@ -68,7 +68,7 @@ def routing_configs_by_etcd_key( } case IngressProtocol.HTTP | IngressProtocol.HTTP2: return { - router_etcd_key: self._http_router_config(ingress, worker), + router_etcd_key: self._http_router_config(ingress, worker_frontend_settings), service_etcd_key: self._http_service_config(ingress.backend), **self._http_middleware_configs(ingress), } @@ -90,7 +90,9 @@ def _service_etcd_key(cls, ingress_key: IngressKey, protocol: IngressProtocol) - ) @classmethod - def _http_router_config(cls, ingress: Ingress, worker: TraefikWorker) -> dict[str, Any]: + def _http_router_config( + cls, ingress: Ingress, worker_frontend_settings: WorkerFrontendSettings + ) -> dict[str, Any]: ingress_key = ingress.key() # The ipAllowList middleware must precede the auth plugin so that disallowed # clients are rejected before any credential handling. @@ -99,12 +101,12 @@ def _http_router_config(cls, ingress: Ingress, worker: TraefikWorker) -> dict[st middleware_names.append(cls._ip_allowlist_middleware_name(ingress_key)) middleware_names.append(cls._auth_plugin_middleware_name(ingress_key)) router_config: dict[str, Any] = { - "rule": cls._host_rule(ingress, worker), + "rule": cls._host_rule(ingress, worker_frontend_settings), "service": cls._service_name(ingress_key), "entrypoints": [cls._entrypoint_name(ingress.frontend)], "middlewares": middleware_names, } - if worker.tls_listen: + if worker_frontend_settings.tls_listen: router_config["tls"] = "" return router_config @@ -221,16 +223,16 @@ def _http_middleware_etcd_key(authority: IngressAuthority, middleware_name: str) return f"worker_{authority}/http/middlewares/{middleware_name}" @staticmethod - def _host_rule(ingress: Ingress, worker: TraefikWorker) -> str: + def _host_rule(ingress: Ingress, worker_frontend_settings: WorkerFrontendSettings) -> str: match ingress.frontend.kind: case IngressFrontendKind.PORT: - return f"Host(`{worker.hostname}`)" + return f"Host(`{worker_frontend_settings.hostname}`)" case IngressFrontendKind.WILDCARD: - if worker.wildcard_domain is None: + if worker_frontend_settings.wildcard_domain is None: raise TraefikWildcardDomainMissingError( extra_data={"authority": ingress.authority} ) - return f"Host(`{ingress.frontend.key()}{worker.wildcard_domain}`)" + return f"Host(`{ingress.frontend.key()}{worker_frontend_settings.wildcard_domain}`)" @staticmethod def _client_ip_rule(access: IngressAccess) -> str: diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/types.py b/src/ai/backend/appproxy/coordinator/clients/worker/types.py index 6eddd80e42d..318da2718f8 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/types.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/types.py @@ -4,7 +4,7 @@ @dataclass(frozen=True) -class TraefikWorker: +class WorkerFrontendSettings: hostname: str wildcard_domain: str | None tls_listen: bool diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py index 22bc9760d34..4fea4fc8087 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -10,7 +10,7 @@ from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( TraefikRoutingConfigFactory, ) -from ai.backend.appproxy.coordinator.clients.worker.types import TraefikWorker +from ai.backend.appproxy.coordinator.clients.worker.types import WorkerFrontendSettings from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( Ingress, @@ -62,8 +62,10 @@ def routing_config_factory_without_client_ip_strategy() -> TraefikRoutingConfigF @pytest.fixture -def worker() -> TraefikWorker: - return TraefikWorker(hostname="worker-1.example.com", wildcard_domain=None, tls_listen=False) +def worker_frontend_settings() -> WorkerFrontendSettings: + return WorkerFrontendSettings( + hostname="worker-1.example.com", wildcard_domain=None, tls_listen=False + ) @pytest.fixture @@ -139,10 +141,10 @@ def test_ip_allowlist_reads_the_client_ip_strategy( case: _ClientIPStrategyCase, routing_config_factory: TraefikRoutingConfigFactory, http_ingress_allowing_one_network: Ingress, - worker: TraefikWorker, + worker_frontend_settings: WorkerFrontendSettings, ) -> None: routing_configs = routing_config_factory.routing_configs_by_etcd_key( - http_ingress_allowing_one_network, worker + http_ingress_allowing_one_network, worker_frontend_settings ) ip_allowlist = routing_configs[ "worker_worker-1/http/middlewares/bai_ingress_10205_ipallowlist" @@ -167,11 +169,11 @@ def test_tcp_router_rule_matches_the_allowed_client_ips( case: _ClientIPRuleCase, routing_config_factory_without_client_ip_strategy: TraefikRoutingConfigFactory, tcp_ingress: Ingress, - worker: TraefikWorker, + worker_frontend_settings: WorkerFrontendSettings, ) -> None: routing_configs = ( routing_config_factory_without_client_ip_strategy.routing_configs_by_etcd_key( - tcp_ingress, worker + tcp_ingress, worker_frontend_settings ) ) router_config = routing_configs["worker_worker-1/tcp/routers/bai_ingress_10205_router"] From 58e3b7649decc1843d9b33d6ba85390f28f6bc0a Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:42:53 +0900 Subject: [PATCH 06/13] refactor(BA-8675): pass the worker hostname, wildcard domain and tls_listen separately `WorkerFrontendSettings` grouped two worker API settings with the Traefik wildcard domain under a made-up name. The factory now takes the three values as keyword arguments and hands each private builder only the ones it uses, and `clients/worker/types.py` is removed. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 25 +++++++++++-------- .../coordinator/clients/worker/types.py | 10 -------- .../worker/test_traefik_routing_config.py | 17 ++++--------- 3 files changed, 20 insertions(+), 32 deletions(-) delete mode 100644 src/ai/backend/appproxy/coordinator/clients/worker/types.py diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index f9e8d27cb2c..618e6285f91 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -3,7 +3,6 @@ from typing import Any, Final from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME -from ai.backend.appproxy.coordinator.clients.worker.types import WorkerFrontendSettings from ai.backend.appproxy.coordinator.config import TraefikConfig from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -53,7 +52,7 @@ def routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: ] def routing_configs_by_etcd_key( - self, ingress: Ingress, worker_frontend_settings: WorkerFrontendSettings + self, ingress: Ingress, *, hostname: str, wildcard_domain: str | None, tls_listen: bool ) -> dict[str, dict[str, Any]]: """Router, service and middleware configs of the ingress, by the etcd key Traefik reads.""" ingress_key = ingress.key() @@ -68,7 +67,13 @@ def routing_configs_by_etcd_key( } case IngressProtocol.HTTP | IngressProtocol.HTTP2: return { - router_etcd_key: self._http_router_config(ingress, worker_frontend_settings), + router_etcd_key: self._http_router_config( + ingress, + host_rule=self._host_rule( + ingress, hostname=hostname, wildcard_domain=wildcard_domain + ), + tls_listen=tls_listen, + ), service_etcd_key: self._http_service_config(ingress.backend), **self._http_middleware_configs(ingress), } @@ -91,7 +96,7 @@ def _service_etcd_key(cls, ingress_key: IngressKey, protocol: IngressProtocol) - @classmethod def _http_router_config( - cls, ingress: Ingress, worker_frontend_settings: WorkerFrontendSettings + cls, ingress: Ingress, *, host_rule: str, tls_listen: bool ) -> dict[str, Any]: ingress_key = ingress.key() # The ipAllowList middleware must precede the auth plugin so that disallowed @@ -101,12 +106,12 @@ def _http_router_config( middleware_names.append(cls._ip_allowlist_middleware_name(ingress_key)) middleware_names.append(cls._auth_plugin_middleware_name(ingress_key)) router_config: dict[str, Any] = { - "rule": cls._host_rule(ingress, worker_frontend_settings), + "rule": host_rule, "service": cls._service_name(ingress_key), "entrypoints": [cls._entrypoint_name(ingress.frontend)], "middlewares": middleware_names, } - if worker_frontend_settings.tls_listen: + if tls_listen: router_config["tls"] = "" return router_config @@ -223,16 +228,16 @@ def _http_middleware_etcd_key(authority: IngressAuthority, middleware_name: str) return f"worker_{authority}/http/middlewares/{middleware_name}" @staticmethod - def _host_rule(ingress: Ingress, worker_frontend_settings: WorkerFrontendSettings) -> str: + def _host_rule(ingress: Ingress, *, hostname: str, wildcard_domain: str | None) -> str: match ingress.frontend.kind: case IngressFrontendKind.PORT: - return f"Host(`{worker_frontend_settings.hostname}`)" + return f"Host(`{hostname}`)" case IngressFrontendKind.WILDCARD: - if worker_frontend_settings.wildcard_domain is None: + if wildcard_domain is None: raise TraefikWildcardDomainMissingError( extra_data={"authority": ingress.authority} ) - return f"Host(`{ingress.frontend.key()}{worker_frontend_settings.wildcard_domain}`)" + return f"Host(`{ingress.frontend.key()}{wildcard_domain}`)" @staticmethod def _client_ip_rule(access: IngressAccess) -> str: diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/types.py b/src/ai/backend/appproxy/coordinator/clients/worker/types.py deleted file mode 100644 index 318da2718f8..00000000000 --- a/src/ai/backend/appproxy/coordinator/clients/worker/types.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class WorkerFrontendSettings: - hostname: str - wildcard_domain: str | None - tls_listen: bool diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py index 4fea4fc8087..923f21c5b95 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -10,7 +10,6 @@ from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( TraefikRoutingConfigFactory, ) -from ai.backend.appproxy.coordinator.clients.worker.types import WorkerFrontendSettings from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( Ingress, @@ -61,13 +60,6 @@ def routing_config_factory_without_client_ip_strategy() -> TraefikRoutingConfigF ) -@pytest.fixture -def worker_frontend_settings() -> WorkerFrontendSettings: - return WorkerFrontendSettings( - hostname="worker-1.example.com", wildcard_domain=None, tls_listen=False - ) - - @pytest.fixture def http_ingress_allowing_one_network() -> Ingress: return Ingress( @@ -141,10 +133,12 @@ def test_ip_allowlist_reads_the_client_ip_strategy( case: _ClientIPStrategyCase, routing_config_factory: TraefikRoutingConfigFactory, http_ingress_allowing_one_network: Ingress, - worker_frontend_settings: WorkerFrontendSettings, ) -> None: routing_configs = routing_config_factory.routing_configs_by_etcd_key( - http_ingress_allowing_one_network, worker_frontend_settings + http_ingress_allowing_one_network, + hostname="worker-1.example.com", + wildcard_domain=None, + tls_listen=False, ) ip_allowlist = routing_configs[ "worker_worker-1/http/middlewares/bai_ingress_10205_ipallowlist" @@ -169,11 +163,10 @@ def test_tcp_router_rule_matches_the_allowed_client_ips( case: _ClientIPRuleCase, routing_config_factory_without_client_ip_strategy: TraefikRoutingConfigFactory, tcp_ingress: Ingress, - worker_frontend_settings: WorkerFrontendSettings, ) -> None: routing_configs = ( routing_config_factory_without_client_ip_strategy.routing_configs_by_etcd_key( - tcp_ingress, worker_frontend_settings + tcp_ingress, hostname="worker-1.example.com", wildcard_domain=None, tls_listen=False ) ) router_config = routing_configs["worker_worker-1/tcp/routers/bai_ingress_10205_router"] From cebf64e01eadc4b2daea9519cbe3ef2d9a759f85 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:46:57 +0900 Subject: [PATCH 07/13] refactor(BA-8675): take TraefikRoutingConfigFactoryArgs in TraefikRoutingConfigFactory The factory receives the Traefik, secret and permit hash config sections as one `TraefikRoutingConfigFactoryArgs` and reads the auth plugin secrets from them, instead of taking the two secret strings separately. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 27 ++++++++++++------- .../worker/test_traefik_routing_config.py | 18 ++++++++----- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index 618e6285f91..3266a0046f8 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -1,7 +1,9 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Any, Final +from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME from ai.backend.appproxy.coordinator.config import TraefikConfig from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError @@ -20,20 +22,25 @@ _CORS_HEADERS_MIDDLEWARE_NAME: Final[str] = "CORSHeaders" +@dataclass(frozen=True) +class TraefikRoutingConfigFactoryArgs: + traefik_config: TraefikConfig + secrets: SecretConfig + permit_hash: PermitHashConfig + + class TraefikRoutingConfigFactory: """Builds the Traefik router, middleware and service configs of an ingress and the etcd keys Traefik reads them from.""" _traefik_config: TraefikConfig - _jwt_secret: str - _permit_hash_secret: str + _secrets: SecretConfig + _permit_hash: PermitHashConfig - def __init__( - self, traefik_config: TraefikConfig, *, jwt_secret: str, permit_hash_secret: str - ) -> None: - self._traefik_config = traefik_config - self._jwt_secret = jwt_secret - self._permit_hash_secret = permit_hash_secret + def __init__(self, args: TraefikRoutingConfigFactoryArgs) -> None: + self._traefik_config = args.traefik_config + self._secrets = args.secrets + self._permit_hash = args.permit_hash @classmethod def routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: @@ -169,8 +176,8 @@ def _auth_plugin_middleware_config(self, ingress: Ingress) -> dict[str, Any]: "appproxy-traefik-plugin": { # etcd holds strings only, so the ingress travels as one JSON value. "ingress": ingress.model_dump_json(), - "jwt_secret": self._jwt_secret, - "permit_hash_secret": self._permit_hash_secret, + "jwt_secret": self._secrets.jwt_secret, + "permit_hash_secret": self._permit_hash.secret.decode(), "permit_cookie_name": PERMIT_COOKIE_NAME, }, }, diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py index 923f21c5b95..20ed185140b 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -7,8 +7,10 @@ import pytest +from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( TraefikRoutingConfigFactory, + TraefikRoutingConfigFactoryArgs, ) from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -45,18 +47,22 @@ class _ClientIPRuleCase: @pytest.fixture def routing_config_factory(case: _ClientIPStrategyCase) -> TraefikRoutingConfigFactory: return TraefikRoutingConfigFactory( - Mock(spec=TraefikConfig, client_ip_strategy=case.strategy), - jwt_secret="jwt-secret", - permit_hash_secret="permit-hash-secret", + TraefikRoutingConfigFactoryArgs( + traefik_config=Mock(spec=TraefikConfig, client_ip_strategy=case.strategy), + secrets=Mock(spec=SecretConfig, jwt_secret="jwt-secret"), + permit_hash=Mock(spec=PermitHashConfig, secret=b"permit-hash-secret"), + ) ) @pytest.fixture def routing_config_factory_without_client_ip_strategy() -> TraefikRoutingConfigFactory: return TraefikRoutingConfigFactory( - Mock(spec=TraefikConfig, client_ip_strategy=None), - jwt_secret="jwt-secret", - permit_hash_secret="permit-hash-secret", + TraefikRoutingConfigFactoryArgs( + traefik_config=Mock(spec=TraefikConfig, client_ip_strategy=None), + secrets=Mock(spec=SecretConfig, jwt_secret="jwt-secret"), + permit_hash=Mock(spec=PermitHashConfig, secret=b"permit-hash-secret"), + ) ) From 4d4e3a8035d329fe80176c564439f4a9de80fc7a Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 16:50:29 +0900 Subject: [PATCH 08/13] refactor(BA-8675): rename routing_config_etcd_keys to all_routing_config_etcd_keys It returns every routing config key an ingress can own, whether or not the ingress uses it now, for clearing the ones it no longer has. Co-Authored-By: Claude Opus 5.5 --- .../coordinator/clients/worker/traefik_routing_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index 3266a0046f8..358a99dfbdf 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -43,7 +43,7 @@ def __init__(self, args: TraefikRoutingConfigFactoryArgs) -> None: self._permit_hash = args.permit_hash @classmethod - def routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: + def all_routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: """Every routing config key an ingress can own, for clearing the ones it no longer has.""" return [ cls._router_etcd_key(ingress_key, IngressProtocol.HTTP), From 1c75eaaba10906ccaceb89d0e95572e7b218942a Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 17:41:31 +0900 Subject: [PATCH 09/13] refactor(BA-8675): type the Traefik routing configs as TypedDicts The router, service and middleware configs the factory returns are `TypedDict`s (`TraefikHttpRouterConfig`, `TraefikTcpServiceConfig`, `TraefikIpAllowListMiddlewareConfig`, ...) instead of `dict[str, Any]`, with the keys Traefik reads. The written etcd keys and values are unchanged. The tests compare each config as a whole. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 123 ++++++++++++++++-- .../worker/test_traefik_routing_config.py | 45 +++++-- 2 files changed, 142 insertions(+), 26 deletions(-) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index 358a99dfbdf..f052318604b 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Final +from typing import Final, NotRequired, TypedDict from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME @@ -22,6 +22,99 @@ _CORS_HEADERS_MIDDLEWARE_NAME: Final[str] = "CORSHeaders" +class TraefikHttpRouterConfig(TypedDict): + rule: str + service: str + entrypoints: list[str] + middlewares: list[str] + tls: NotRequired[str] + + +class TraefikTcpRouterConfig(TypedDict): + rule: str + service: str + entrypoints: list[str] + + +class TraefikHttpServer(TypedDict): + url: str + + +class TraefikHttpLoadBalancer(TypedDict): + servers: list[TraefikHttpServer] + + +class TraefikHttpServiceConfig(TypedDict): + # Left out when no target receives traffic, which clears the service. + loadBalancer: NotRequired[TraefikHttpLoadBalancer] + + +class TraefikTcpServer(TypedDict): + address: str + + +class TraefikTcpLoadBalancer(TypedDict): + servers: list[TraefikTcpServer] + + +class TraefikTcpServiceConfig(TypedDict): + # Left out when no target receives traffic, which clears the service. + loadBalancer: NotRequired[TraefikTcpLoadBalancer] + + +class TraefikCorsHeaders(TypedDict): + accessControlAllowHeaders: str + accessControlAllowOriginList: list[str] + + +class TraefikCorsHeadersMiddlewareConfig(TypedDict): + headers: TraefikCorsHeaders + + +class TraefikIpStrategy(TypedDict, total=False): + depth: int + excludedIPs: list[str] + + +class TraefikIpAllowList(TypedDict): + sourceRange: list[str] + ipStrategy: NotRequired[TraefikIpStrategy] + + +class TraefikIpAllowListMiddlewareConfig(TypedDict): + ipAllowList: TraefikIpAllowList + + +class TraefikAuthPluginOptions(TypedDict): + ingress: str + jwt_secret: str + permit_hash_secret: str + permit_cookie_name: str + + +TraefikAuthPluginOptionsByName = TypedDict( + "TraefikAuthPluginOptionsByName", {"appproxy-traefik-plugin": TraefikAuthPluginOptions} +) + + +class TraefikAuthPluginMiddlewareConfig(TypedDict): + plugin: TraefikAuthPluginOptionsByName + + +type TraefikHttpMiddlewareConfig = ( + TraefikCorsHeadersMiddlewareConfig + | TraefikIpAllowListMiddlewareConfig + | TraefikAuthPluginMiddlewareConfig +) +type TraefikRoutingConfig = ( + TraefikHttpRouterConfig + | TraefikTcpRouterConfig + | TraefikHttpServiceConfig + | TraefikTcpServiceConfig + | TraefikHttpMiddlewareConfig +) + + @dataclass(frozen=True) class TraefikRoutingConfigFactoryArgs: traefik_config: TraefikConfig @@ -60,7 +153,7 @@ def all_routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: def routing_configs_by_etcd_key( self, ingress: Ingress, *, hostname: str, wildcard_domain: str | None, tls_listen: bool - ) -> dict[str, dict[str, Any]]: + ) -> dict[str, TraefikRoutingConfig]: """Router, service and middleware configs of the ingress, by the etcd key Traefik reads.""" ingress_key = ingress.key() frontend_protocol = ingress.frontend.protocol @@ -104,7 +197,7 @@ def _service_etcd_key(cls, ingress_key: IngressKey, protocol: IngressProtocol) - @classmethod def _http_router_config( cls, ingress: Ingress, *, host_rule: str, tls_listen: bool - ) -> dict[str, Any]: + ) -> TraefikHttpRouterConfig: ingress_key = ingress.key() # The ipAllowList middleware must precede the auth plugin so that disallowed # clients are rejected before any credential handling. @@ -112,7 +205,7 @@ def _http_router_config( if ingress.access.allowed_client_ips: middleware_names.append(cls._ip_allowlist_middleware_name(ingress_key)) middleware_names.append(cls._auth_plugin_middleware_name(ingress_key)) - router_config: dict[str, Any] = { + router_config: TraefikHttpRouterConfig = { "rule": host_rule, "service": cls._service_name(ingress_key), "entrypoints": [cls._entrypoint_name(ingress.frontend)], @@ -123,19 +216,19 @@ def _http_router_config( return router_config @classmethod - def _tcp_router_config(cls, ingress: Ingress) -> dict[str, Any]: + def _tcp_router_config(cls, ingress: Ingress) -> TraefikTcpRouterConfig: return { "rule": cls._client_ip_rule(ingress.access), "service": cls._service_name(ingress.key()), "entrypoints": [cls._entrypoint_name(ingress.frontend)], } - def _http_middleware_configs(self, ingress: Ingress) -> dict[str, dict[str, Any]]: + def _http_middleware_configs(self, ingress: Ingress) -> dict[str, TraefikHttpMiddlewareConfig]: ingress_key = ingress.key() cors_headers_etcd_key = self._http_middleware_etcd_key( ingress.authority, _CORS_HEADERS_MIDDLEWARE_NAME ) - middleware_configs: dict[str, dict[str, Any]] = { + middleware_configs: dict[str, TraefikHttpMiddlewareConfig] = { cors_headers_etcd_key: { "headers": { "accessControlAllowHeaders": "*", @@ -156,8 +249,10 @@ def _http_middleware_configs(self, ingress: Ingress) -> dict[str, dict[str, Any] middleware_configs[auth_plugin_etcd_key] = self._auth_plugin_middleware_config(ingress) return middleware_configs - def _ip_allowlist_middleware_config(self, access: IngressAccess) -> dict[str, Any]: - ip_allowlist: dict[str, Any] = { + def _ip_allowlist_middleware_config( + self, access: IngressAccess + ) -> TraefikIpAllowListMiddlewareConfig: + ip_allowlist: TraefikIpAllowList = { "sourceRange": [str(network) for network in access.allowed_client_ips], } client_ip_strategy = self._traefik_config.client_ip_strategy @@ -170,7 +265,7 @@ def _ip_allowlist_middleware_config(self, access: IngressAccess) -> dict[str, An } return {"ipAllowList": ip_allowlist} - def _auth_plugin_middleware_config(self, ingress: Ingress) -> dict[str, Any]: + def _auth_plugin_middleware_config(self, ingress: Ingress) -> TraefikAuthPluginMiddlewareConfig: return { "plugin": { "appproxy-traefik-plugin": { @@ -184,8 +279,8 @@ def _auth_plugin_middleware_config(self, ingress: Ingress) -> dict[str, Any]: } @classmethod - def _http_service_config(cls, backend: IngressBackend) -> dict[str, Any]: - servers = [ + def _http_service_config(cls, backend: IngressBackend) -> TraefikHttpServiceConfig: + servers: list[TraefikHttpServer] = [ {"url": f"http://{target.host}:{target.port}/"} for target in cls._targets_receiving_traffic(backend) ] @@ -194,8 +289,8 @@ def _http_service_config(cls, backend: IngressBackend) -> dict[str, Any]: return {"loadBalancer": {"servers": servers}} @classmethod - def _tcp_service_config(cls, backend: IngressBackend) -> dict[str, Any]: - servers = [ + def _tcp_service_config(cls, backend: IngressBackend) -> TraefikTcpServiceConfig: + servers: list[TraefikTcpServer] = [ {"address": f"{target.host}:{target.port}"} for target in cls._targets_receiving_traffic(backend) ] diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py index 20ed185140b..823ffb0b454 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -2,15 +2,16 @@ import ipaddress from dataclasses import dataclass, field -from typing import Any from unittest.mock import Mock import pytest from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( + TraefikIpAllowListMiddlewareConfig, TraefikRoutingConfigFactory, TraefikRoutingConfigFactoryArgs, + TraefikTcpRouterConfig, ) from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -34,7 +35,7 @@ class _ClientIPStrategyCase: name: str strategy: ClientIPStrategyConfig | None - expected_ip_strategy: dict[str, Any] | None + expected_ip_allowlist_middleware_config: TraefikIpAllowListMiddlewareConfig @dataclass(frozen=True) @@ -118,18 +119,34 @@ class TestTraefikRoutingConfigFactory: @pytest.mark.parametrize( "case", [ - _ClientIPStrategyCase(name="no-strategy", strategy=None, expected_ip_strategy=None), + _ClientIPStrategyCase( + name="no-strategy", + strategy=None, + expected_ip_allowlist_middleware_config={ + "ipAllowList": {"sourceRange": ["192.168.0.0/24"]}, + }, + ), _ClientIPStrategyCase( name="depth", strategy=ClientIPStrategyConfig(depth=2, excluded_ips=[]), - expected_ip_strategy={"depth": 2}, + expected_ip_allowlist_middleware_config={ + "ipAllowList": { + "sourceRange": ["192.168.0.0/24"], + "ipStrategy": {"depth": 2}, + }, + }, ), _ClientIPStrategyCase( name="excluded-ips", strategy=ClientIPStrategyConfig( depth=None, excluded_ips=[ipaddress.ip_network("10.0.0.0/8")] ), - expected_ip_strategy={"excludedIPs": ["10.0.0.0/8"]}, + expected_ip_allowlist_middleware_config={ + "ipAllowList": { + "sourceRange": ["192.168.0.0/24"], + "ipStrategy": {"excludedIPs": ["10.0.0.0/8"]}, + }, + }, ), ], ids=lambda case: case.name, @@ -146,11 +163,10 @@ def test_ip_allowlist_reads_the_client_ip_strategy( wildcard_domain=None, tls_listen=False, ) - ip_allowlist = routing_configs[ - "worker_worker-1/http/middlewares/bai_ingress_10205_ipallowlist" - ]["ipAllowList"] - assert ip_allowlist.get("ipStrategy") == case.expected_ip_strategy - assert ip_allowlist["sourceRange"] == ["192.168.0.0/24"] + assert ( + routing_configs["worker_worker-1/http/middlewares/bai_ingress_10205_ipallowlist"] + == case.expected_ip_allowlist_middleware_config + ) @pytest.mark.parametrize( "case", @@ -175,5 +191,10 @@ def test_tcp_router_rule_matches_the_allowed_client_ips( tcp_ingress, hostname="worker-1.example.com", wildcard_domain=None, tls_listen=False ) ) - router_config = routing_configs["worker_worker-1/tcp/routers/bai_ingress_10205_router"] - assert router_config["rule"] == case.expected_rule + assert routing_configs[ + "worker_worker-1/tcp/routers/bai_ingress_10205_router" + ] == TraefikTcpRouterConfig( + rule=case.expected_rule, + service="bai_ingress_10205_service", + entrypoints=["portproxy_10205"], + ) From 795c6776def0519d112add1c152e7476308e52b8 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Fri, 23 Oct 2026 17:46:56 +0900 Subject: [PATCH 10/13] refactor(BA-8675): drop the Traefik prefix from the routing config types Every TypedDict in traefik_routing_config describes a Traefik config, so the module name already carries the prefix. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik_routing_config.py | 92 +++++++++---------- .../worker/test_traefik_routing_config.py | 8 +- 2 files changed, 46 insertions(+), 54 deletions(-) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py index f052318604b..5c44ceb7580 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py @@ -22,7 +22,7 @@ _CORS_HEADERS_MIDDLEWARE_NAME: Final[str] = "CORSHeaders" -class TraefikHttpRouterConfig(TypedDict): +class HttpRouterConfig(TypedDict): rule: str service: str entrypoints: list[str] @@ -30,88 +30,82 @@ class TraefikHttpRouterConfig(TypedDict): tls: NotRequired[str] -class TraefikTcpRouterConfig(TypedDict): +class TcpRouterConfig(TypedDict): rule: str service: str entrypoints: list[str] -class TraefikHttpServer(TypedDict): +class HttpServer(TypedDict): url: str -class TraefikHttpLoadBalancer(TypedDict): - servers: list[TraefikHttpServer] +class HttpLoadBalancer(TypedDict): + servers: list[HttpServer] -class TraefikHttpServiceConfig(TypedDict): +class HttpServiceConfig(TypedDict): # Left out when no target receives traffic, which clears the service. - loadBalancer: NotRequired[TraefikHttpLoadBalancer] + loadBalancer: NotRequired[HttpLoadBalancer] -class TraefikTcpServer(TypedDict): +class TcpServer(TypedDict): address: str -class TraefikTcpLoadBalancer(TypedDict): - servers: list[TraefikTcpServer] +class TcpLoadBalancer(TypedDict): + servers: list[TcpServer] -class TraefikTcpServiceConfig(TypedDict): +class TcpServiceConfig(TypedDict): # Left out when no target receives traffic, which clears the service. - loadBalancer: NotRequired[TraefikTcpLoadBalancer] + loadBalancer: NotRequired[TcpLoadBalancer] -class TraefikCorsHeaders(TypedDict): +class CorsHeaders(TypedDict): accessControlAllowHeaders: str accessControlAllowOriginList: list[str] -class TraefikCorsHeadersMiddlewareConfig(TypedDict): - headers: TraefikCorsHeaders +class CorsHeadersMiddlewareConfig(TypedDict): + headers: CorsHeaders -class TraefikIpStrategy(TypedDict, total=False): +class IpStrategy(TypedDict, total=False): depth: int excludedIPs: list[str] -class TraefikIpAllowList(TypedDict): +class IpAllowList(TypedDict): sourceRange: list[str] - ipStrategy: NotRequired[TraefikIpStrategy] + ipStrategy: NotRequired[IpStrategy] -class TraefikIpAllowListMiddlewareConfig(TypedDict): - ipAllowList: TraefikIpAllowList +class IpAllowListMiddlewareConfig(TypedDict): + ipAllowList: IpAllowList -class TraefikAuthPluginOptions(TypedDict): +class AuthPluginOptions(TypedDict): ingress: str jwt_secret: str permit_hash_secret: str permit_cookie_name: str -TraefikAuthPluginOptionsByName = TypedDict( - "TraefikAuthPluginOptionsByName", {"appproxy-traefik-plugin": TraefikAuthPluginOptions} +AuthPluginOptionsByName = TypedDict( + "AuthPluginOptionsByName", {"appproxy-traefik-plugin": AuthPluginOptions} ) -class TraefikAuthPluginMiddlewareConfig(TypedDict): - plugin: TraefikAuthPluginOptionsByName +class AuthPluginMiddlewareConfig(TypedDict): + plugin: AuthPluginOptionsByName -type TraefikHttpMiddlewareConfig = ( - TraefikCorsHeadersMiddlewareConfig - | TraefikIpAllowListMiddlewareConfig - | TraefikAuthPluginMiddlewareConfig +type HttpMiddlewareConfig = ( + CorsHeadersMiddlewareConfig | IpAllowListMiddlewareConfig | AuthPluginMiddlewareConfig ) -type TraefikRoutingConfig = ( - TraefikHttpRouterConfig - | TraefikTcpRouterConfig - | TraefikHttpServiceConfig - | TraefikTcpServiceConfig - | TraefikHttpMiddlewareConfig +type RoutingConfig = ( + HttpRouterConfig | TcpRouterConfig | HttpServiceConfig | TcpServiceConfig | HttpMiddlewareConfig ) @@ -153,7 +147,7 @@ def all_routing_config_etcd_keys(cls, ingress_key: IngressKey) -> list[str]: def routing_configs_by_etcd_key( self, ingress: Ingress, *, hostname: str, wildcard_domain: str | None, tls_listen: bool - ) -> dict[str, TraefikRoutingConfig]: + ) -> dict[str, RoutingConfig]: """Router, service and middleware configs of the ingress, by the etcd key Traefik reads.""" ingress_key = ingress.key() frontend_protocol = ingress.frontend.protocol @@ -197,7 +191,7 @@ def _service_etcd_key(cls, ingress_key: IngressKey, protocol: IngressProtocol) - @classmethod def _http_router_config( cls, ingress: Ingress, *, host_rule: str, tls_listen: bool - ) -> TraefikHttpRouterConfig: + ) -> HttpRouterConfig: ingress_key = ingress.key() # The ipAllowList middleware must precede the auth plugin so that disallowed # clients are rejected before any credential handling. @@ -205,7 +199,7 @@ def _http_router_config( if ingress.access.allowed_client_ips: middleware_names.append(cls._ip_allowlist_middleware_name(ingress_key)) middleware_names.append(cls._auth_plugin_middleware_name(ingress_key)) - router_config: TraefikHttpRouterConfig = { + router_config: HttpRouterConfig = { "rule": host_rule, "service": cls._service_name(ingress_key), "entrypoints": [cls._entrypoint_name(ingress.frontend)], @@ -216,19 +210,19 @@ def _http_router_config( return router_config @classmethod - def _tcp_router_config(cls, ingress: Ingress) -> TraefikTcpRouterConfig: + def _tcp_router_config(cls, ingress: Ingress) -> TcpRouterConfig: return { "rule": cls._client_ip_rule(ingress.access), "service": cls._service_name(ingress.key()), "entrypoints": [cls._entrypoint_name(ingress.frontend)], } - def _http_middleware_configs(self, ingress: Ingress) -> dict[str, TraefikHttpMiddlewareConfig]: + def _http_middleware_configs(self, ingress: Ingress) -> dict[str, HttpMiddlewareConfig]: ingress_key = ingress.key() cors_headers_etcd_key = self._http_middleware_etcd_key( ingress.authority, _CORS_HEADERS_MIDDLEWARE_NAME ) - middleware_configs: dict[str, TraefikHttpMiddlewareConfig] = { + middleware_configs: dict[str, HttpMiddlewareConfig] = { cors_headers_etcd_key: { "headers": { "accessControlAllowHeaders": "*", @@ -249,10 +243,8 @@ def _http_middleware_configs(self, ingress: Ingress) -> dict[str, TraefikHttpMid middleware_configs[auth_plugin_etcd_key] = self._auth_plugin_middleware_config(ingress) return middleware_configs - def _ip_allowlist_middleware_config( - self, access: IngressAccess - ) -> TraefikIpAllowListMiddlewareConfig: - ip_allowlist: TraefikIpAllowList = { + def _ip_allowlist_middleware_config(self, access: IngressAccess) -> IpAllowListMiddlewareConfig: + ip_allowlist: IpAllowList = { "sourceRange": [str(network) for network in access.allowed_client_ips], } client_ip_strategy = self._traefik_config.client_ip_strategy @@ -265,7 +257,7 @@ def _ip_allowlist_middleware_config( } return {"ipAllowList": ip_allowlist} - def _auth_plugin_middleware_config(self, ingress: Ingress) -> TraefikAuthPluginMiddlewareConfig: + def _auth_plugin_middleware_config(self, ingress: Ingress) -> AuthPluginMiddlewareConfig: return { "plugin": { "appproxy-traefik-plugin": { @@ -279,8 +271,8 @@ def _auth_plugin_middleware_config(self, ingress: Ingress) -> TraefikAuthPluginM } @classmethod - def _http_service_config(cls, backend: IngressBackend) -> TraefikHttpServiceConfig: - servers: list[TraefikHttpServer] = [ + def _http_service_config(cls, backend: IngressBackend) -> HttpServiceConfig: + servers: list[HttpServer] = [ {"url": f"http://{target.host}:{target.port}/"} for target in cls._targets_receiving_traffic(backend) ] @@ -289,8 +281,8 @@ def _http_service_config(cls, backend: IngressBackend) -> TraefikHttpServiceConf return {"loadBalancer": {"servers": servers}} @classmethod - def _tcp_service_config(cls, backend: IngressBackend) -> TraefikTcpServiceConfig: - servers: list[TraefikTcpServer] = [ + def _tcp_service_config(cls, backend: IngressBackend) -> TcpServiceConfig: + servers: list[TcpServer] = [ {"address": f"{target.host}:{target.port}"} for target in cls._targets_receiving_traffic(backend) ] diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py index 823ffb0b454..7a04dfc1523 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py @@ -8,10 +8,10 @@ from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( - TraefikIpAllowListMiddlewareConfig, + IpAllowListMiddlewareConfig, + TcpRouterConfig, TraefikRoutingConfigFactory, TraefikRoutingConfigFactoryArgs, - TraefikTcpRouterConfig, ) from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -35,7 +35,7 @@ class _ClientIPStrategyCase: name: str strategy: ClientIPStrategyConfig | None - expected_ip_allowlist_middleware_config: TraefikIpAllowListMiddlewareConfig + expected_ip_allowlist_middleware_config: IpAllowListMiddlewareConfig @dataclass(frozen=True) @@ -193,7 +193,7 @@ def test_tcp_router_rule_matches_the_allowed_client_ips( ) assert routing_configs[ "worker_worker-1/tcp/routers/bai_ingress_10205_router" - ] == TraefikTcpRouterConfig( + ] == TcpRouterConfig( rule=case.expected_rule, service="bai_ingress_10205_service", entrypoints=["portproxy_10205"], From 9cd595303d5f901cc02b75a0996ec9e08de63c77 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 26 Oct 2026 09:41:06 +0900 Subject: [PATCH 11/13] refactor(BA-8675): split the Traefik routing configs into a traefik package Move the routing config module to clients/worker/traefik/routing_config.py and its TypedDicts to clients/worker/traefik/types.py. Co-Authored-By: Claude Opus 5.5 --- .../clients/worker/traefik/__init__.py | 0 .../routing_config.py} | 102 +++--------------- .../clients/worker/traefik/types.py | 90 ++++++++++++++++ .../coordinator/clients/worker/traefik/BUILD | 3 + .../test_routing_config.py} | 8 +- 5 files changed, 112 insertions(+), 91 deletions(-) create mode 100644 src/ai/backend/appproxy/coordinator/clients/worker/traefik/__init__.py rename src/ai/backend/appproxy/coordinator/clients/worker/{traefik_routing_config.py => traefik/routing_config.py} (85%) create mode 100644 src/ai/backend/appproxy/coordinator/clients/worker/traefik/types.py create mode 100644 tests/unit/appproxy/coordinator/clients/worker/traefik/BUILD rename tests/unit/appproxy/coordinator/clients/worker/{test_traefik_routing_config.py => traefik/test_routing_config.py} (97%) diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik/__init__.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik/routing_config.py similarity index 85% rename from src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py rename to src/ai/backend/appproxy/coordinator/clients/worker/traefik/routing_config.py index 5c44ceb7580..8b60ed6cbed 100644 --- a/src/ai/backend/appproxy/coordinator/clients/worker/traefik_routing_config.py +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik/routing_config.py @@ -1,10 +1,23 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final, NotRequired, TypedDict +from typing import Final from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig from ai.backend.appproxy.common.defs import PERMIT_COOKIE_NAME +from ai.backend.appproxy.coordinator.clients.worker.traefik.types import ( + AuthPluginMiddlewareConfig, + HttpMiddlewareConfig, + HttpRouterConfig, + HttpServer, + HttpServiceConfig, + IpAllowList, + IpAllowListMiddlewareConfig, + RoutingConfig, + TcpRouterConfig, + TcpServer, + TcpServiceConfig, +) from ai.backend.appproxy.coordinator.config import TraefikConfig from ai.backend.appproxy.coordinator.errors.ingress import TraefikWildcardDomainMissingError from ai.backend.common.dto.appproxy.v2.ingress.types import ( @@ -22,93 +35,6 @@ _CORS_HEADERS_MIDDLEWARE_NAME: Final[str] = "CORSHeaders" -class HttpRouterConfig(TypedDict): - rule: str - service: str - entrypoints: list[str] - middlewares: list[str] - tls: NotRequired[str] - - -class TcpRouterConfig(TypedDict): - rule: str - service: str - entrypoints: list[str] - - -class HttpServer(TypedDict): - url: str - - -class HttpLoadBalancer(TypedDict): - servers: list[HttpServer] - - -class HttpServiceConfig(TypedDict): - # Left out when no target receives traffic, which clears the service. - loadBalancer: NotRequired[HttpLoadBalancer] - - -class TcpServer(TypedDict): - address: str - - -class TcpLoadBalancer(TypedDict): - servers: list[TcpServer] - - -class TcpServiceConfig(TypedDict): - # Left out when no target receives traffic, which clears the service. - loadBalancer: NotRequired[TcpLoadBalancer] - - -class CorsHeaders(TypedDict): - accessControlAllowHeaders: str - accessControlAllowOriginList: list[str] - - -class CorsHeadersMiddlewareConfig(TypedDict): - headers: CorsHeaders - - -class IpStrategy(TypedDict, total=False): - depth: int - excludedIPs: list[str] - - -class IpAllowList(TypedDict): - sourceRange: list[str] - ipStrategy: NotRequired[IpStrategy] - - -class IpAllowListMiddlewareConfig(TypedDict): - ipAllowList: IpAllowList - - -class AuthPluginOptions(TypedDict): - ingress: str - jwt_secret: str - permit_hash_secret: str - permit_cookie_name: str - - -AuthPluginOptionsByName = TypedDict( - "AuthPluginOptionsByName", {"appproxy-traefik-plugin": AuthPluginOptions} -) - - -class AuthPluginMiddlewareConfig(TypedDict): - plugin: AuthPluginOptionsByName - - -type HttpMiddlewareConfig = ( - CorsHeadersMiddlewareConfig | IpAllowListMiddlewareConfig | AuthPluginMiddlewareConfig -) -type RoutingConfig = ( - HttpRouterConfig | TcpRouterConfig | HttpServiceConfig | TcpServiceConfig | HttpMiddlewareConfig -) - - @dataclass(frozen=True) class TraefikRoutingConfigFactoryArgs: traefik_config: TraefikConfig diff --git a/src/ai/backend/appproxy/coordinator/clients/worker/traefik/types.py b/src/ai/backend/appproxy/coordinator/clients/worker/traefik/types.py new file mode 100644 index 00000000000..c17725ce46d --- /dev/null +++ b/src/ai/backend/appproxy/coordinator/clients/worker/traefik/types.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import NotRequired, TypedDict + + +class HttpRouterConfig(TypedDict): + rule: str + service: str + entrypoints: list[str] + middlewares: list[str] + tls: NotRequired[str] + + +class TcpRouterConfig(TypedDict): + rule: str + service: str + entrypoints: list[str] + + +class HttpServer(TypedDict): + url: str + + +class HttpLoadBalancer(TypedDict): + servers: list[HttpServer] + + +class HttpServiceConfig(TypedDict): + # Left out when no target receives traffic, which clears the service. + loadBalancer: NotRequired[HttpLoadBalancer] + + +class TcpServer(TypedDict): + address: str + + +class TcpLoadBalancer(TypedDict): + servers: list[TcpServer] + + +class TcpServiceConfig(TypedDict): + # Left out when no target receives traffic, which clears the service. + loadBalancer: NotRequired[TcpLoadBalancer] + + +class CorsHeaders(TypedDict): + accessControlAllowHeaders: str + accessControlAllowOriginList: list[str] + + +class CorsHeadersMiddlewareConfig(TypedDict): + headers: CorsHeaders + + +class IpStrategy(TypedDict, total=False): + depth: int + excludedIPs: list[str] + + +class IpAllowList(TypedDict): + sourceRange: list[str] + ipStrategy: NotRequired[IpStrategy] + + +class IpAllowListMiddlewareConfig(TypedDict): + ipAllowList: IpAllowList + + +class AuthPluginOptions(TypedDict): + ingress: str + jwt_secret: str + permit_hash_secret: str + permit_cookie_name: str + + +AuthPluginOptionsByName = TypedDict( + "AuthPluginOptionsByName", {"appproxy-traefik-plugin": AuthPluginOptions} +) + + +class AuthPluginMiddlewareConfig(TypedDict): + plugin: AuthPluginOptionsByName + + +type HttpMiddlewareConfig = ( + CorsHeadersMiddlewareConfig | IpAllowListMiddlewareConfig | AuthPluginMiddlewareConfig +) +type RoutingConfig = ( + HttpRouterConfig | TcpRouterConfig | HttpServiceConfig | TcpServiceConfig | HttpMiddlewareConfig +) diff --git a/tests/unit/appproxy/coordinator/clients/worker/traefik/BUILD b/tests/unit/appproxy/coordinator/clients/worker/traefik/BUILD new file mode 100644 index 00000000000..57341b1358b --- /dev/null +++ b/tests/unit/appproxy/coordinator/clients/worker/traefik/BUILD @@ -0,0 +1,3 @@ +python_tests( + name="tests", +) diff --git a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py similarity index 97% rename from tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py rename to tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py index 7a04dfc1523..b3a11870efa 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/test_traefik_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py @@ -7,12 +7,14 @@ import pytest from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig -from ai.backend.appproxy.coordinator.clients.worker.traefik_routing_config import ( - IpAllowListMiddlewareConfig, - TcpRouterConfig, +from ai.backend.appproxy.coordinator.clients.worker.traefik.routing_config import ( TraefikRoutingConfigFactory, TraefikRoutingConfigFactoryArgs, ) +from ai.backend.appproxy.coordinator.clients.worker.traefik.types import ( + IpAllowListMiddlewareConfig, + TcpRouterConfig, +) from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( Ingress, From a18f2ccaa613f8814b2d32274461d45dff2f060b Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 26 Oct 2026 12:10:08 +0900 Subject: [PATCH 12/13] test(BA-8675): build the routing config factory from real config instances Co-Authored-By: Claude Opus 5.5 --- .../worker/traefik/test_routing_config.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py index b3a11870efa..c0fc9f727b4 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py @@ -2,11 +2,11 @@ import ipaddress from dataclasses import dataclass, field -from unittest.mock import Mock import pytest from ai.backend.appproxy.common.config import PermitHashConfig, SecretConfig +from ai.backend.appproxy.common.types import DigestModType from ai.backend.appproxy.coordinator.clients.worker.traefik.routing_config import ( TraefikRoutingConfigFactory, TraefikRoutingConfigFactoryArgs, @@ -16,6 +16,7 @@ TcpRouterConfig, ) from ai.backend.appproxy.coordinator.config import ClientIPStrategyConfig, TraefikConfig +from ai.backend.common.configs.etcd import EtcdConfig from ai.backend.common.dto.appproxy.v2.ingress.types import ( Ingress, IngressAccess, @@ -51,9 +52,14 @@ class _ClientIPRuleCase: def routing_config_factory(case: _ClientIPStrategyCase) -> TraefikRoutingConfigFactory: return TraefikRoutingConfigFactory( TraefikRoutingConfigFactoryArgs( - traefik_config=Mock(spec=TraefikConfig, client_ip_strategy=case.strategy), - secrets=Mock(spec=SecretConfig, jwt_secret="jwt-secret"), - permit_hash=Mock(spec=PermitHashConfig, secret=b"permit-hash-secret"), + traefik_config=TraefikConfig( + etcd=EtcdConfig.model_validate({"namespace": "traefik"}), + client_ip_strategy=case.strategy, + ), + secrets=SecretConfig(jwt_secret="jwt-secret", api_secret="api-secret"), + permit_hash=PermitHashConfig( + secret=b"permit-hash-secret", digest_mod=DigestModType.SHA256 + ), ) ) @@ -62,9 +68,13 @@ def routing_config_factory(case: _ClientIPStrategyCase) -> TraefikRoutingConfigF def routing_config_factory_without_client_ip_strategy() -> TraefikRoutingConfigFactory: return TraefikRoutingConfigFactory( TraefikRoutingConfigFactoryArgs( - traefik_config=Mock(spec=TraefikConfig, client_ip_strategy=None), - secrets=Mock(spec=SecretConfig, jwt_secret="jwt-secret"), - permit_hash=Mock(spec=PermitHashConfig, secret=b"permit-hash-secret"), + traefik_config=TraefikConfig( + etcd=EtcdConfig.model_validate({"namespace": "traefik"}), client_ip_strategy=None + ), + secrets=SecretConfig(jwt_secret="jwt-secret", api_secret="api-secret"), + permit_hash=PermitHashConfig( + secret=b"permit-hash-secret", digest_mod=DigestModType.SHA256 + ), ) ) From ef7a93b42b15395cc08d9c81c019039a65163a78 Mon Sep 17 00:00:00 2001 From: Gyubong Date: Mon, 26 Oct 2026 12:11:07 +0900 Subject: [PATCH 13/13] test(BA-8675): cover all_routing_config_etcd_keys Co-Authored-By: Claude Opus 5.5 --- .../worker/traefik/test_routing_config.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py b/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py index c0fc9f727b4..6d82745fa09 100644 --- a/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py +++ b/tests/unit/appproxy/coordinator/clients/worker/traefik/test_routing_config.py @@ -25,8 +25,10 @@ IngressBackend, IngressBackendID, IngressFrontend, + IngressFrontendKey, IngressFrontendKind, IngressID, + IngressKey, IngressProtocol, IngressTargetGroup, IngressTargetGroupID, @@ -127,6 +129,13 @@ def tcp_ingress(case: _ClientIPRuleCase) -> Ingress: ) +@pytest.fixture +def port_10205_ingress_key() -> IngressKey: + return IngressKey( + authority=IngressAuthority("worker-1"), frontend_key=IngressFrontendKey("10205") + ) + + class TestTraefikRoutingConfigFactory: @pytest.mark.parametrize( "case", @@ -210,3 +219,15 @@ def test_tcp_router_rule_matches_the_allowed_client_ips( service="bai_ingress_10205_service", entrypoints=["portproxy_10205"], ) + + def test_all_routing_config_etcd_keys_lists_every_key_of_both_protocols( + self, port_10205_ingress_key: IngressKey + ) -> None: + assert TraefikRoutingConfigFactory.all_routing_config_etcd_keys(port_10205_ingress_key) == [ + "worker_worker-1/http/routers/bai_ingress_10205_router", + "worker_worker-1/http/services/bai_ingress_10205_service", + "worker_worker-1/http/middlewares/bai_ingress_10205_ipallowlist", + "worker_worker-1/http/middlewares/bai_ingress_10205_auth", + "worker_worker-1/tcp/routers/bai_ingress_10205_router", + "worker_worker-1/tcp/services/bai_ingress_10205_service", + ]