Skip to content

Commit 03ec046

Browse files
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 <noreply@anthropic.com> Signed-off-by: Yarden Maymon <yarden.maymon@twodelta.com>
1 parent d1f229b commit 03ec046

4 files changed

Lines changed: 179 additions & 0 deletions

File tree

config/templates/jinja/11_infra.yaml.j2

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,24 @@
99
gateway:
1010
gatewayClassName: gke-l7-regional-external-managed
1111
destinationRule: {{ model_id_label }}-gaie-epp.{{ namespace.name }}
12+
{% if gateway.listenerPort is defined and gateway.listenerPort %}
13+
listeners:
14+
- name: default
15+
port: {{ gateway.listenerPort }}
16+
protocol: HTTP
17+
allowedRoutes:
18+
namespaces:
19+
from: All
20+
{% endif %}
1221

1322
provider:
1423
name: gke
1524

1625
{% elif gw_class == 'data-science-gateway-class' %}
1726
{# ── OpenDataHub / OpenShift AI ── #}
27+
{% if gateway.listenerPort is defined and gateway.listenerPort %}
28+
{{ 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.") }}
29+
{% endif %}
1830
gateway:
1931
gatewayClassName: data-science-gateway-class
2032
labels:
@@ -63,6 +75,15 @@ gateway:
6375
enabled: false
6476
service:
6577
type: {{ gateway.service.type | default('NodePort') }}
78+
{% if gateway.listenerPort is defined and gateway.listenerPort %}
79+
listeners:
80+
- name: default
81+
port: {{ gateway.listenerPort }}
82+
protocol: HTTP
83+
allowedRoutes:
84+
namespaces:
85+
from: All
86+
{% endif %}
6687

6788
{% else %}
6889
{# ── Istio (default) ── #}
@@ -80,5 +101,14 @@ gateway:
80101
memory: {{ gateway.resources.requests.memory }}
81102
service:
82103
type: {{ gateway.service.type }}
104+
{% if gateway.listenerPort is defined and gateway.listenerPort %}
105+
listeners:
106+
- name: default
107+
port: {{ gateway.listenerPort }}
108+
protocol: HTTP
109+
allowedRoutes:
110+
namespaces:
111+
from: All
112+
{% endif %}
83113
{% endif %}
84114
{% endif %}

config/templates/values/defaults.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,10 @@ gateway:
560560
providerNamespace: istio-system
561561
# Gateway version is derived from chartVersions.istiod at plan time
562562
logLevel: error
563+
# Optional: override the Gateway's HTTP listener port. When unset (default)
564+
# the chart's built-in listener is used. Not supported for
565+
# className: data-science-gateway-class (that class pins port 443 HTTPS+TLS).
566+
# listenerPort: 80
563567
service:
564568
type: NodePort
565569
resources:

llmdbenchmark/parser/render_plans.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,19 @@ def _get_jinja_env(self) -> Environment:
113113
env.filters["b64encode"] = self._b64encode_filter
114114
env.filters["model_id_label"] = self._model_id_label_filter
115115

116+
# `raise` global lets templates abort rendering with a clear
117+
# error when an input is invalid for the current code path
118+
# (e.g. an option that only applies to some gateway classes).
119+
env.globals["raise"] = self._raise_helper
120+
116121
self._jinja_env = env
117122
return env
118123

124+
@staticmethod
125+
def _raise_helper(message: str) -> str:
126+
"""Abort template rendering with the given error message."""
127+
raise ValueError(message)
128+
119129
@staticmethod
120130
def _indent_filter(text: str, width: int = 4, first: bool = False) -> str:
121131
"""Indent text by specified width."""
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Tests for the configurable Gateway listener port (gateway.listenerPort).
2+
3+
Covers 11_infra.yaml.j2, which renders the per-stack Gateway resource for
4+
each supported gateway class:
5+
6+
* gke / agentgateway / istio (and any custom istio class) - emit a default
7+
HTTP listener on ``gateway.listenerPort`` when it is set, and omit the
8+
``listeners`` block entirely when it is not.
9+
* data-science-gateway-class - pins the listener to port 443 (HTTPS+TLS),
10+
so setting ``gateway.listenerPort`` is unsupported and must fail loudly.
11+
12+
The template is rendered through the real RenderPlans Jinja environment so
13+
the ``raise`` global and custom filters are exercised exactly as in prod.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from pathlib import Path
19+
from unittest.mock import MagicMock
20+
21+
import pytest
22+
import yaml
23+
24+
from llmdbenchmark.parser.render_plans import RenderPlans
25+
26+
TEMPLATE_PATH = (
27+
Path(__file__).resolve().parents[1]
28+
/ "config"
29+
/ "templates"
30+
/ "jinja"
31+
/ "11_infra.yaml.j2"
32+
)
33+
34+
# Gateway classes that support a configurable listener port. "istio" and an
35+
# arbitrary custom class both fall through to the default (Istio) branch.
36+
LISTENER_PORT_CLASSES = ["gke", "agentgateway", "istio", "custom-istio-class"]
37+
38+
39+
@pytest.fixture(scope="module")
40+
def template() -> str:
41+
return TEMPLATE_PATH.read_text(encoding="utf-8")
42+
43+
44+
@pytest.fixture
45+
def renderer():
46+
"""A RenderPlans wired only with what _render_template needs."""
47+
r = RenderPlans.__new__(RenderPlans)
48+
r.logger = MagicMock()
49+
r._jinja_env = None
50+
return r
51+
52+
53+
def _values(gw_class: str, listener_port: int | None = None) -> dict:
54+
"""Minimal values dict mirroring defaults.yaml for the infra template."""
55+
gateway = {
56+
"className": gw_class,
57+
"logLevel": "error",
58+
"service": {"type": "NodePort"},
59+
"resources": {
60+
"limits": {"cpu": "16", "memory": "16Gi"},
61+
"requests": {"cpu": "4", "memory": "4Gi"},
62+
},
63+
}
64+
if listener_port is not None:
65+
gateway["listenerPort"] = listener_port
66+
return {
67+
"standalone": {"enabled": False},
68+
"kustomize": {"enabled": False},
69+
"gateway": gateway,
70+
"model_id_label": "model-1",
71+
"namespace": {"name": "llmdbench"},
72+
}
73+
74+
75+
class TestListenerPortRendered:
76+
"""Classes that honour gateway.listenerPort."""
77+
78+
@pytest.mark.parametrize("gw_class", LISTENER_PORT_CLASSES)
79+
def test_port_set_emits_http_listener(self, renderer, template, gw_class):
80+
out = renderer._render_template(template, _values(gw_class, 8080))
81+
doc = yaml.safe_load(out)
82+
listeners = doc["gateway"]["listeners"]
83+
assert listeners == [
84+
{
85+
"name": "default",
86+
"port": 8080,
87+
"protocol": "HTTP",
88+
"allowedRoutes": {"namespaces": {"from": "All"}},
89+
}
90+
]
91+
92+
@pytest.mark.parametrize("gw_class", LISTENER_PORT_CLASSES)
93+
def test_port_unset_omits_listeners(self, renderer, template, gw_class):
94+
out = renderer._render_template(template, _values(gw_class, None))
95+
doc = yaml.safe_load(out)
96+
assert "listeners" not in doc["gateway"]
97+
98+
@pytest.mark.parametrize("gw_class", LISTENER_PORT_CLASSES)
99+
def test_port_zero_is_treated_as_unset(self, renderer, template, gw_class):
100+
"""A falsy port (0) must not emit a listener block."""
101+
out = renderer._render_template(template, _values(gw_class, 0))
102+
doc = yaml.safe_load(out)
103+
assert "listeners" not in doc["gateway"]
104+
105+
106+
class TestDataScienceGatewayClass:
107+
"""data-science-gateway-class pins the listener to 443 - port is rejected."""
108+
109+
def test_default_keeps_fixed_443_listener(self, renderer, template):
110+
out = renderer._render_template(
111+
template, _values("data-science-gateway-class", None)
112+
)
113+
doc = yaml.safe_load(out)
114+
listener = doc["gateway"]["listeners"][0]
115+
assert listener["port"] == 443
116+
assert listener["protocol"] == "HTTPS"
117+
assert listener["tls"]["mode"] == "Terminate"
118+
119+
def test_listener_port_raises(self, renderer, template):
120+
with pytest.raises(ValueError, match="data-science-gateway-class"):
121+
renderer._render_template(
122+
template, _values("data-science-gateway-class", 8080)
123+
)
124+
125+
126+
class TestRaiseHelper:
127+
"""The `raise` global that backs the data-science guard."""
128+
129+
def test_raise_global_registered(self, renderer):
130+
env = renderer._get_jinja_env()
131+
assert "raise" in env.globals
132+
133+
def test_raise_helper_raises_value_error(self):
134+
with pytest.raises(ValueError, match="boom"):
135+
RenderPlans._raise_helper("boom")

0 commit comments

Comments
 (0)