Skip to content

Commit 44389d6

Browse files
test(maas): add multiple subscription selection tests for TinyLlama (#1165)
* test(maas): add multiple subscription selection tests for TinyLlama * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: resolve flake8 and formatting issues in multiple subscriptions test --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 39d3462 commit 44389d6

File tree

2 files changed

+314
-0
lines changed

2 files changed

+314
-0
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
import requests
5+
from kubernetes.dynamic import DynamicClient
6+
from ocp_resources.service_account import ServiceAccount
7+
from simple_logger.logger import get_logger
8+
9+
from tests.model_serving.model_server.maas_billing.maas_subscription.utils import (
10+
chat_payload_for_url,
11+
create_maas_subscription,
12+
poll_expected_status,
13+
)
14+
from tests.model_serving.model_server.maas_billing.utils import build_maas_headers
15+
from utilities.infra import create_inference_token, login_with_user_password
16+
from utilities.resources.maa_s_auth_policy import MaaSAuthPolicy
17+
18+
LOGGER = get_logger(name=__name__)
19+
20+
MAAS_SUBSCRIPTION_HEADER = "x-maas-subscription"
21+
22+
23+
@pytest.mark.usefixtures(
24+
"maas_unprivileged_model_namespace",
25+
"maas_controller_enabled_latest",
26+
"maas_gateway_api",
27+
"maas_api_gateway_reachable",
28+
"maas_inference_service_tinyllama_free",
29+
"maas_model_tinyllama_free",
30+
"maas_auth_policy_tinyllama_free",
31+
"maas_subscription_tinyllama_free",
32+
)
33+
class TestMultipleSubscriptionsPerModel:
34+
"""
35+
Validates behavior when multiple subscriptions exist for the same model.
36+
"""
37+
38+
@pytest.mark.sanity
39+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
40+
def test_user_in_one_of_two_subscriptions_can_access_model(
41+
self,
42+
request_session_http: requests.Session,
43+
admin_client: DynamicClient,
44+
maas_free_group: str,
45+
maas_model_tinyllama_free,
46+
model_url_tinyllama_free: str,
47+
ocp_token_for_actor: str,
48+
maas_subscription_tinyllama_free,
49+
) -> None:
50+
"""
51+
Create a second subscription for a different group the user is NOT in.
52+
User should still get 200 when explicitly selecting the correct subscription.
53+
"""
54+
assert maas_free_group, "maas_free_group fixture returned empty group name"
55+
56+
with create_maas_subscription(
57+
admin_client=admin_client,
58+
subscription_name="extra-subscription",
59+
owner_group_name="nonexistent-group-xyz",
60+
model_name=maas_model_tinyllama_free.name,
61+
tokens_per_minute=999,
62+
window="1m",
63+
priority=0,
64+
teardown=True,
65+
wait_for_resource=True,
66+
) as extra_subscription:
67+
extra_subscription.wait_for_condition(condition="Ready", status="True", timeout=300)
68+
69+
headers = build_maas_headers(token=ocp_token_for_actor)
70+
payload = chat_payload_for_url(model_url=model_url_tinyllama_free)
71+
72+
explicit_headers = dict(headers)
73+
explicit_headers[MAAS_SUBSCRIPTION_HEADER] = maas_subscription_tinyllama_free.name
74+
75+
LOGGER.info(
76+
"Polling for 200 with explicit subscription selection: "
77+
f"subscription={maas_subscription_tinyllama_free.name}"
78+
)
79+
80+
response = poll_expected_status(
81+
request_session_http=request_session_http,
82+
model_url=model_url_tinyllama_free,
83+
headers=explicit_headers,
84+
payload=payload,
85+
expected_statuses={200},
86+
)
87+
88+
assert response.status_code == 200, (
89+
f"Expected 200 after adding second subscription, got {response.status_code}: "
90+
f"{(response.text or '')[:200]}"
91+
)
92+
93+
@pytest.mark.sanity
94+
@pytest.mark.parametrize("ocp_token_for_actor", [{"type": "free"}], indirect=True)
95+
def test_high_priority_subscription_allows_access_when_explicitly_selected(
96+
self,
97+
request_session_http: requests.Session,
98+
admin_client: DynamicClient,
99+
maas_free_group: str,
100+
maas_model_tinyllama_free,
101+
model_url_tinyllama_free: str,
102+
ocp_token_for_actor: str,
103+
maas_subscription_tinyllama_free,
104+
) -> None:
105+
"""
106+
Create a second (higher priority) subscription for the same group + model.
107+
User should get 200 when explicitly selecting the high-priority subscription.
108+
"""
109+
assert maas_free_group, "maas_free_group fixture returned empty group name"
110+
_ = maas_subscription_tinyllama_free
111+
112+
with create_maas_subscription(
113+
admin_client=admin_client,
114+
subscription_name="high-tier-subscription",
115+
owner_group_name=maas_free_group,
116+
model_name=maas_model_tinyllama_free.name,
117+
tokens_per_minute=9999,
118+
window="1m",
119+
priority=10,
120+
teardown=True,
121+
wait_for_resource=True,
122+
) as high_tier_subscription:
123+
high_tier_subscription.wait_for_condition(condition="Ready", status="True", timeout=300)
124+
125+
headers = build_maas_headers(token=ocp_token_for_actor)
126+
payload = chat_payload_for_url(model_url=model_url_tinyllama_free)
127+
128+
explicit_headers = dict(headers)
129+
explicit_headers[MAAS_SUBSCRIPTION_HEADER] = high_tier_subscription.name
130+
131+
response = poll_expected_status(
132+
request_session_http=request_session_http,
133+
model_url=model_url_tinyllama_free,
134+
headers=explicit_headers,
135+
payload=payload,
136+
expected_statuses={200},
137+
)
138+
139+
assert response.status_code == 200, (
140+
f"Expected 200 when selecting high-priority subscription '{high_tier_subscription.name}', "
141+
f"got {response.status_code}: {(response.text or '')[:200]}"
142+
)
143+
144+
@pytest.mark.sanity
145+
def test_service_account_cannot_use_subscription_it_does_not_belong_to(
146+
self,
147+
request_session_http: requests.Session,
148+
admin_client: DynamicClient,
149+
maas_api_server_url: str,
150+
original_user: str,
151+
maas_premium_group: str,
152+
maas_model_tinyllama_free,
153+
model_url_tinyllama_free: str,
154+
maas_unprivileged_model_namespace,
155+
) -> None:
156+
"""
157+
A service account explicitly selecting a subscription it does not belong to
158+
should be denied.
159+
"""
160+
service_account_name = "test-service-account"
161+
162+
login_ok = login_with_user_password(api_address=maas_api_server_url, user=original_user)
163+
assert login_ok, f"Failed to login as original_user={original_user}"
164+
165+
applications_namespace = maas_unprivileged_model_namespace.name
166+
assert applications_namespace, "applications_namespace name is empty"
167+
168+
with (
169+
MaaSAuthPolicy(
170+
client=admin_client,
171+
name="service-account-access-policy",
172+
namespace=applications_namespace,
173+
model_refs=[maas_model_tinyllama_free.name],
174+
subjects={"groups": [{"name": f"system:serviceaccounts:{applications_namespace}"}]},
175+
teardown=True,
176+
wait_for_resource=True,
177+
) as service_account_auth_policy,
178+
create_maas_subscription(
179+
admin_client=admin_client,
180+
subscription_name="premium-subscription",
181+
owner_group_name=maas_premium_group,
182+
model_name=maas_model_tinyllama_free.name,
183+
tokens_per_minute=500,
184+
window="1m",
185+
priority=0,
186+
teardown=True,
187+
wait_for_resource=True,
188+
) as premium_subscription,
189+
ServiceAccount(
190+
client=admin_client,
191+
namespace=applications_namespace,
192+
name=service_account_name,
193+
teardown=True,
194+
) as service_account,
195+
):
196+
service_account_auth_policy.wait_for_condition(condition="Ready", status="True", timeout=300)
197+
premium_subscription.wait_for_condition(condition="Ready", status="True", timeout=300)
198+
service_account.wait(timeout=60)
199+
200+
service_account_token = create_inference_token(model_service_account=service_account)
201+
headers = build_maas_headers(token=service_account_token)
202+
headers[MAAS_SUBSCRIPTION_HEADER] = premium_subscription.name
203+
204+
payload = chat_payload_for_url(model_url=model_url_tinyllama_free)
205+
206+
response = poll_expected_status(
207+
request_session_http=request_session_http,
208+
model_url=model_url_tinyllama_free,
209+
headers=headers,
210+
payload=payload,
211+
expected_statuses={403, 429},
212+
)
213+
214+
assert response.status_code in {403, 429}, (
215+
f"Expected 403/429 when service account selects a subscription it doesn't belong to, "
216+
f"got {response.status_code}: {(response.text or '')[:200]}"
217+
)

tests/model_serving/model_server/maas_billing/maas_subscription/utils.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,26 @@
33
import json
44
from collections.abc import Generator, Sequence
55
from contextlib import contextmanager
6+
from typing import Any
67
from urllib.parse import urlparse
78

9+
import pytest
10+
import requests
11+
from kubernetes.dynamic import DynamicClient
812
from ocp_resources.llm_inference_service import LLMInferenceService
913
from ocp_resources.resource import ResourceEditor
14+
from pytest_testconfig import config as py_config
15+
from simple_logger.logger import get_logger
16+
from timeout_sampler import TimeoutSampler
1017

1118
from utilities.constants import (
1219
MAAS_GATEWAY_NAME,
1320
MAAS_GATEWAY_NAMESPACE,
1421
ApiGroups,
1522
)
23+
from utilities.resources.maa_s_subscription import MaaSSubscription
24+
25+
LOGGER = get_logger(name=__name__)
1626

1727

1828
@contextmanager
@@ -71,3 +81,90 @@ def chat_payload_for_url(model_url: str, *, prompt: str = "Hello", max_tokens: i
7181
"messages": [{"role": "user", "content": prompt}],
7282
"max_tokens": max_tokens,
7383
}
84+
85+
86+
def poll_expected_status(
87+
*,
88+
request_session_http: requests.Session,
89+
model_url: str,
90+
headers: dict[str, str],
91+
payload: dict[str, Any],
92+
expected_statuses: set[int],
93+
wait_timeout: int = 240,
94+
sleep: int = 5,
95+
request_timeout: int = 60,
96+
) -> requests.Response:
97+
"""
98+
Poll model endpoint until we see one of `expected_statuses` or timeout.
99+
100+
Returns the response that matched expected status.
101+
"""
102+
last_response: requests.Response | None = None
103+
observed_responses: list[tuple[int | None, str]] = []
104+
105+
for response in TimeoutSampler(
106+
wait_timeout=wait_timeout,
107+
sleep=sleep,
108+
func=request_session_http.post,
109+
url=model_url,
110+
headers=headers,
111+
json=payload,
112+
timeout=request_timeout,
113+
):
114+
last_response = response
115+
status_code = getattr(response, "status_code", None)
116+
response_text = (getattr(response, "text", "") or "")[:200]
117+
118+
observed_responses.append((status_code, response_text))
119+
120+
LOGGER.info(
121+
"Polling model_url=%s status=%s expected=%s",
122+
model_url,
123+
status_code,
124+
sorted(expected_statuses),
125+
)
126+
127+
if status_code in expected_statuses:
128+
return response
129+
130+
pytest.fail(
131+
"Timed out waiting for expected HTTP status. "
132+
f"model_url={model_url}, "
133+
f"expected={sorted(expected_statuses)}, "
134+
f"last_status={getattr(last_response, 'status_code', None)}, "
135+
f"last_body={(getattr(last_response, 'text', '') or '')[:200]}, "
136+
f"seen_count={len(observed_responses)}"
137+
)
138+
139+
140+
def create_maas_subscription(
141+
*,
142+
admin_client: DynamicClient,
143+
subscription_name: str,
144+
owner_group_name: str,
145+
model_name: str,
146+
tokens_per_minute: int,
147+
window: str = "1m",
148+
priority: int = 0,
149+
teardown: bool = True,
150+
wait_for_resource: bool = True,
151+
) -> MaaSSubscription:
152+
applications_namespace = py_config["applications_namespace"]
153+
154+
return MaaSSubscription(
155+
client=admin_client,
156+
name=subscription_name,
157+
namespace=applications_namespace,
158+
owner={
159+
"groups": [{"name": owner_group_name}],
160+
},
161+
model_refs=[
162+
{
163+
"name": model_name,
164+
"tokenRateLimits": [{"limit": tokens_per_minute, "window": window}],
165+
}
166+
],
167+
priority=priority,
168+
teardown=teardown,
169+
wait_for_resource=wait_for_resource,
170+
)

0 commit comments

Comments
 (0)