Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions config/templates/jinja/11_infra.yaml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) ── #}
Expand All @@ -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 %}
4 changes: 4 additions & 0 deletions config/templates/values/defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions llmdbenchmark/parser/render_plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
135 changes: 135 additions & 0 deletions tests/test_gateway_listener_port.py
Original file line number Diff line number Diff line change
@@ -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")