From 03ec0466a05424ebc1953cb2b9bbe68d4de2f8e7 Mon Sep 17 00:00:00 2001 From: Yarden Maymon Date: Fri, 29 May 2026 15:47:23 +0300 Subject: [PATCH] Support configurable listener port for the per-stack Gateway Render gateway.listenerPort (when set) into the listeners block for the gke, agentgateway, and istio gateway classes. data-science-gateway-class pins the listener to 443 HTTPS+TLS, so a new `raise` jinja global makes the template fail loudly if listenerPort is set there. Default behaviour (no listenerPort) is unchanged. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Yarden Maymon --- config/templates/jinja/11_infra.yaml.j2 | 30 ++++++ config/templates/values/defaults.yaml | 4 + llmdbenchmark/parser/render_plans.py | 10 ++ tests/test_gateway_listener_port.py | 135 ++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 tests/test_gateway_listener_port.py diff --git a/config/templates/jinja/11_infra.yaml.j2 b/config/templates/jinja/11_infra.yaml.j2 index 323dd1268..8914b17ed 100644 --- a/config/templates/jinja/11_infra.yaml.j2 +++ b/config/templates/jinja/11_infra.yaml.j2 @@ -9,12 +9,24 @@ gateway: gatewayClassName: gke-l7-regional-external-managed destinationRule: {{ model_id_label }}-gaie-epp.{{ namespace.name }} +{% if gateway.listenerPort is defined and gateway.listenerPort %} + listeners: + - name: default + port: {{ gateway.listenerPort }} + protocol: HTTP + allowedRoutes: + namespaces: + from: All +{% endif %} provider: name: gke {% elif gw_class == 'data-science-gateway-class' %} {# ── OpenDataHub / OpenShift AI ── #} +{% if gateway.listenerPort is defined and gateway.listenerPort %} +{{ raise("gateway.listenerPort is not supported for gateway class 'data-science-gateway-class' (this class pins the listener to port 443, protocol HTTPS, TLS terminate). Remove gateway.listenerPort from the scenario.") }} +{% endif %} gateway: gatewayClassName: data-science-gateway-class labels: @@ -63,6 +75,15 @@ gateway: enabled: false service: type: {{ gateway.service.type | default('NodePort') }} +{% if gateway.listenerPort is defined and gateway.listenerPort %} + listeners: + - name: default + port: {{ gateway.listenerPort }} + protocol: HTTP + allowedRoutes: + namespaces: + from: All +{% endif %} {% else %} {# ── Istio (default) ── #} @@ -80,5 +101,14 @@ gateway: memory: {{ gateway.resources.requests.memory }} service: type: {{ gateway.service.type }} +{% if gateway.listenerPort is defined and gateway.listenerPort %} + listeners: + - name: default + port: {{ gateway.listenerPort }} + protocol: HTTP + allowedRoutes: + namespaces: + from: All +{% endif %} {% endif %} {% endif %} diff --git a/config/templates/values/defaults.yaml b/config/templates/values/defaults.yaml index 59dc3752c..39b587e28 100644 --- a/config/templates/values/defaults.yaml +++ b/config/templates/values/defaults.yaml @@ -560,6 +560,10 @@ gateway: providerNamespace: istio-system # Gateway version is derived from chartVersions.istiod at plan time logLevel: error + # Optional: override the Gateway's HTTP listener port. When unset (default) + # the chart's built-in listener is used. Not supported for + # className: data-science-gateway-class (that class pins port 443 HTTPS+TLS). + # listenerPort: 80 service: type: NodePort resources: diff --git a/llmdbenchmark/parser/render_plans.py b/llmdbenchmark/parser/render_plans.py index b7a4b3c1d..846cf1921 100644 --- a/llmdbenchmark/parser/render_plans.py +++ b/llmdbenchmark/parser/render_plans.py @@ -113,9 +113,19 @@ def _get_jinja_env(self) -> Environment: env.filters["b64encode"] = self._b64encode_filter env.filters["model_id_label"] = self._model_id_label_filter + # `raise` global lets templates abort rendering with a clear + # error when an input is invalid for the current code path + # (e.g. an option that only applies to some gateway classes). + env.globals["raise"] = self._raise_helper + self._jinja_env = env return env + @staticmethod + def _raise_helper(message: str) -> str: + """Abort template rendering with the given error message.""" + raise ValueError(message) + @staticmethod def _indent_filter(text: str, width: int = 4, first: bool = False) -> str: """Indent text by specified width.""" diff --git a/tests/test_gateway_listener_port.py b/tests/test_gateway_listener_port.py new file mode 100644 index 000000000..772c3923f --- /dev/null +++ b/tests/test_gateway_listener_port.py @@ -0,0 +1,135 @@ +"""Tests for the configurable Gateway listener port (gateway.listenerPort). + +Covers 11_infra.yaml.j2, which renders the per-stack Gateway resource for +each supported gateway class: + +* gke / agentgateway / istio (and any custom istio class) - emit a default + HTTP listener on ``gateway.listenerPort`` when it is set, and omit the + ``listeners`` block entirely when it is not. +* data-science-gateway-class - pins the listener to port 443 (HTTPS+TLS), + so setting ``gateway.listenerPort`` is unsupported and must fail loudly. + +The template is rendered through the real RenderPlans Jinja environment so +the ``raise`` global and custom filters are exercised exactly as in prod. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import yaml + +from llmdbenchmark.parser.render_plans import RenderPlans + +TEMPLATE_PATH = ( + Path(__file__).resolve().parents[1] + / "config" + / "templates" + / "jinja" + / "11_infra.yaml.j2" +) + +# Gateway classes that support a configurable listener port. "istio" and an +# arbitrary custom class both fall through to the default (Istio) branch. +LISTENER_PORT_CLASSES = ["gke", "agentgateway", "istio", "custom-istio-class"] + + +@pytest.fixture(scope="module") +def template() -> str: + return TEMPLATE_PATH.read_text(encoding="utf-8") + + +@pytest.fixture +def renderer(): + """A RenderPlans wired only with what _render_template needs.""" + r = RenderPlans.__new__(RenderPlans) + r.logger = MagicMock() + r._jinja_env = None + return r + + +def _values(gw_class: str, listener_port: int | None = None) -> dict: + """Minimal values dict mirroring defaults.yaml for the infra template.""" + gateway = { + "className": gw_class, + "logLevel": "error", + "service": {"type": "NodePort"}, + "resources": { + "limits": {"cpu": "16", "memory": "16Gi"}, + "requests": {"cpu": "4", "memory": "4Gi"}, + }, + } + if listener_port is not None: + gateway["listenerPort"] = listener_port + return { + "standalone": {"enabled": False}, + "kustomize": {"enabled": False}, + "gateway": gateway, + "model_id_label": "model-1", + "namespace": {"name": "llmdbench"}, + } + + +class TestListenerPortRendered: + """Classes that honour gateway.listenerPort.""" + + @pytest.mark.parametrize("gw_class", LISTENER_PORT_CLASSES) + def test_port_set_emits_http_listener(self, renderer, template, gw_class): + out = renderer._render_template(template, _values(gw_class, 8080)) + doc = yaml.safe_load(out) + listeners = doc["gateway"]["listeners"] + assert listeners == [ + { + "name": "default", + "port": 8080, + "protocol": "HTTP", + "allowedRoutes": {"namespaces": {"from": "All"}}, + } + ] + + @pytest.mark.parametrize("gw_class", LISTENER_PORT_CLASSES) + def test_port_unset_omits_listeners(self, renderer, template, gw_class): + out = renderer._render_template(template, _values(gw_class, None)) + doc = yaml.safe_load(out) + assert "listeners" not in doc["gateway"] + + @pytest.mark.parametrize("gw_class", LISTENER_PORT_CLASSES) + def test_port_zero_is_treated_as_unset(self, renderer, template, gw_class): + """A falsy port (0) must not emit a listener block.""" + out = renderer._render_template(template, _values(gw_class, 0)) + doc = yaml.safe_load(out) + assert "listeners" not in doc["gateway"] + + +class TestDataScienceGatewayClass: + """data-science-gateway-class pins the listener to 443 - port is rejected.""" + + def test_default_keeps_fixed_443_listener(self, renderer, template): + out = renderer._render_template( + template, _values("data-science-gateway-class", None) + ) + doc = yaml.safe_load(out) + listener = doc["gateway"]["listeners"][0] + assert listener["port"] == 443 + assert listener["protocol"] == "HTTPS" + assert listener["tls"]["mode"] == "Terminate" + + def test_listener_port_raises(self, renderer, template): + with pytest.raises(ValueError, match="data-science-gateway-class"): + renderer._render_template( + template, _values("data-science-gateway-class", 8080) + ) + + +class TestRaiseHelper: + """The `raise` global that backs the data-science guard.""" + + def test_raise_global_registered(self, renderer): + env = renderer._get_jinja_env() + assert "raise" in env.globals + + def test_raise_helper_raises_value_error(self): + with pytest.raises(ValueError, match="boom"): + RenderPlans._raise_helper("boom")