Skip to content

Commit f8c8a5a

Browse files
committed
test(evalhub): add multi-tenancy integration tests
1 parent 90cb97d commit f8c8a5a

14 files changed

+1676
-14
lines changed

tests/model_explainability/evalhub/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def evalhub_cr(
2525
client=admin_client,
2626
name="evalhub",
2727
namespace=model_namespace.name,
28+
database={"type": "sqlite"},
2829
wait_for_resource=True,
2930
) as evalhub:
3031
yield evalhub

tests/model_explainability/evalhub/constants.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,26 @@
33
EVALHUB_CONTAINER_PORT: int = 8080
44
EVALHUB_HEALTH_PATH: str = "/api/v1/health"
55
EVALHUB_PROVIDERS_PATH: str = "/api/v1/evaluations/providers"
6+
EVALHUB_JOBS_PATH: str = "/api/v1/evaluations/jobs"
67
EVALHUB_HEALTH_STATUS_HEALTHY: str = "healthy"
78

89
EVALHUB_APP_LABEL: str = "eval-hub"
10+
EVALHUB_CONTAINER_NAME: str = "evalhub"
11+
EVALHUB_COMPONENT_LABEL: str = "api"
912

1013
# CRD details
1114
EVALHUB_API_GROUP: str = "trustyai.opendatahub.io"
1215
EVALHUB_API_VERSION: str = "v1alpha1"
1316
EVALHUB_KIND: str = "EvalHub"
1417
EVALHUB_PLURAL: str = "evalhubs"
18+
19+
# Multi-tenancy
20+
EVALHUB_TENANT_LABEL_KEY: str = "evalhub.trustyai.opendatahub.io/tenant"
21+
EVALHUB_COLLECTIONS_PATH: str = "/api/v1/evaluations/collections"
22+
EVALHUB_PROVIDERS_ACCESS_CLUSTERROLE: str = "trustyai-service-operator-evalhub-providers-access"
23+
EVALHUB_MT_CR_NAME: str = "evalhub-mt"
24+
EVALHUB_VLLM_EMULATOR_PORT: int = 8000
25+
26+
# ClusterRole names (kustomize namePrefix applied by operator install)
27+
EVALHUB_JOBS_WRITER_CLUSTERROLE: str = "trustyai-service-operator-evalhub-jobs-writer"
28+
EVALHUB_JOB_CONFIG_CLUSTERROLE: str = "trustyai-service-operator-evalhub-job-config"

tests/model_explainability/evalhub/multitenancy/__init__.py

Whitespace-only changes.
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
from collections.abc import Generator
2+
from typing import Any
3+
4+
import pytest
5+
import structlog
6+
from kubernetes.dynamic import DynamicClient
7+
from ocp_resources.deployment import Deployment
8+
from ocp_resources.namespace import Namespace
9+
from ocp_resources.role import Role
10+
from ocp_resources.role_binding import RoleBinding
11+
from ocp_resources.route import Route
12+
from ocp_resources.service import Service
13+
from ocp_resources.service_account import ServiceAccount
14+
from timeout_sampler import TimeoutSampler
15+
16+
from tests.model_explainability.evalhub.constants import (
17+
EVALHUB_MT_CR_NAME,
18+
EVALHUB_TENANT_LABEL_KEY,
19+
EVALHUB_VLLM_EMULATOR_PORT,
20+
)
21+
from utilities.certificates_utils import create_ca_bundle_file
22+
from utilities.constants import Labels, Protocols, Timeout
23+
from utilities.infra import create_inference_token, create_ns
24+
from utilities.resources.evalhub import EvalHub
25+
26+
LOGGER = structlog.get_logger(name=__name__)
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# EvalHub instance (shared across the class)
31+
# ---------------------------------------------------------------------------
32+
33+
34+
@pytest.fixture(scope="class")
35+
def evalhub_mt_cr(
36+
admin_client: DynamicClient,
37+
model_namespace: Namespace,
38+
) -> Generator[EvalHub, Any, Any]:
39+
"""Create an EvalHub CR for multi-tenancy tests.
40+
41+
Uses a distinct name ('evalhub-mt') to avoid RoleBinding name collisions
42+
with the production EvalHub instance. The operator names tenant RoleBindings
43+
as '{instance.Name}-{ns}-job-config-rb' and uses Get-or-Create (not Update),
44+
so two instances named 'evalhub' would collide and the first one wins.
45+
"""
46+
with EvalHub(
47+
client=admin_client,
48+
name="evalhub-mt",
49+
namespace=model_namespace.name,
50+
database={"type": "sqlite"},
51+
wait_for_resource=True,
52+
) as evalhub:
53+
yield evalhub
54+
55+
56+
@pytest.fixture(scope="class")
57+
def evalhub_mt_deployment(
58+
admin_client: DynamicClient,
59+
model_namespace: Namespace,
60+
evalhub_mt_cr: EvalHub,
61+
) -> Deployment:
62+
"""Wait for the EvalHub deployment to become available."""
63+
deployment = Deployment(
64+
client=admin_client,
65+
name=evalhub_mt_cr.name,
66+
namespace=model_namespace.name,
67+
)
68+
deployment.wait_for_replicas(timeout=Timeout.TIMEOUT_5MIN)
69+
return deployment
70+
71+
72+
@pytest.fixture(scope="class")
73+
def evalhub_mt_route(
74+
admin_client: DynamicClient,
75+
model_namespace: Namespace,
76+
evalhub_mt_deployment: Deployment,
77+
) -> Route:
78+
"""Get the Route for the EvalHub service."""
79+
return Route(
80+
client=admin_client,
81+
name=evalhub_mt_deployment.name,
82+
namespace=model_namespace.name,
83+
ensure_exists=True,
84+
)
85+
86+
87+
@pytest.fixture(scope="class")
88+
def evalhub_mt_ca_bundle_file(
89+
admin_client: DynamicClient,
90+
) -> str:
91+
"""CA bundle file for verifying TLS on the EvalHub route."""
92+
return create_ca_bundle_file(client=admin_client)
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# Tenant namespaces
97+
# ---------------------------------------------------------------------------
98+
99+
100+
@pytest.fixture(scope="class")
101+
def tenant_a_namespace(
102+
admin_client: DynamicClient,
103+
) -> Generator[Namespace, Any, Any]:
104+
"""Tenant namespace where the test user HAS access."""
105+
with create_ns(
106+
admin_client=admin_client,
107+
name="test-evalhub-tenant-a",
108+
labels={EVALHUB_TENANT_LABEL_KEY: "true"},
109+
) as ns:
110+
yield ns
111+
112+
113+
@pytest.fixture(scope="class")
114+
def tenant_b_namespace(
115+
admin_client: DynamicClient,
116+
) -> Generator[Namespace, Any, Any]:
117+
"""Tenant namespace where the test user does NOT have access."""
118+
with create_ns(
119+
admin_client=admin_client,
120+
name="test-evalhub-tenant-b",
121+
labels={EVALHUB_TENANT_LABEL_KEY: "true"},
122+
) as ns:
123+
yield ns
124+
125+
126+
# ---------------------------------------------------------------------------
127+
# Wait for operator to provision tenant RBAC
128+
# ---------------------------------------------------------------------------
129+
130+
131+
def _tenant_rbac_ready(admin_client: DynamicClient, namespace: str) -> bool:
132+
"""Check if the operator has provisioned job RBAC for the test EvalHub instance."""
133+
rbs = list(RoleBinding.get(client=admin_client, namespace=namespace))
134+
rb_names = [rb.name for rb in rbs]
135+
# Look for RoleBindings prefixed with the test instance name to avoid
136+
# matching RoleBindings from the production EvalHub instance.
137+
has_job_config = any(name.startswith(EVALHUB_MT_CR_NAME) and "job-config" in name for name in rb_names)
138+
has_job_writer = any(name.startswith(EVALHUB_MT_CR_NAME) and "job-writer" in name for name in rb_names)
139+
return has_job_config and has_job_writer
140+
141+
142+
@pytest.fixture(scope="class")
143+
def tenant_a_rbac_ready(
144+
admin_client: DynamicClient,
145+
tenant_a_namespace: Namespace,
146+
evalhub_mt_deployment: Deployment,
147+
) -> None:
148+
"""Wait for the operator to provision job RBAC in tenant-a.
149+
150+
The operator watches for namespaces with the tenant label and
151+
creates jobs-writer + job-config RoleBindings. This fixture
152+
blocks until those RoleBindings exist.
153+
"""
154+
for ready in TimeoutSampler(
155+
wait_timeout=120,
156+
sleep=5,
157+
func=_tenant_rbac_ready,
158+
admin_client=admin_client,
159+
namespace=tenant_a_namespace.name,
160+
):
161+
if ready:
162+
LOGGER.info(f"Operator RBAC provisioned in {tenant_a_namespace.name}")
163+
return
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# ServiceAccount and RBAC (only in tenant-a)
168+
# ---------------------------------------------------------------------------
169+
170+
# Mirrors the user RBAC template from resources/evalhub-user-rbac-template.yaml.
171+
# evaluations/collections/providers are virtual SAR resources — not real CRDs.
172+
EVALHUB_USER_ROLE_RULES: list[dict] = [
173+
{
174+
"apiGroups": ["trustyai.opendatahub.io"],
175+
"resources": ["evaluations", "collections", "providers"],
176+
"verbs": ["get", "list", "create", "update", "delete"],
177+
},
178+
{
179+
"apiGroups": ["mlflow.kubeflow.org"],
180+
"resources": ["experiments"],
181+
"verbs": ["create", "get"],
182+
},
183+
]
184+
185+
186+
@pytest.fixture(scope="class")
187+
def tenant_a_service_account(
188+
admin_client: DynamicClient,
189+
tenant_a_namespace: Namespace,
190+
) -> Generator[ServiceAccount, Any, Any]:
191+
"""ServiceAccount in tenant-a for multi-tenancy tests."""
192+
with ServiceAccount(
193+
client=admin_client,
194+
name="evalhub-test-user",
195+
namespace=tenant_a_namespace.name,
196+
wait_for_resource=True,
197+
) as sa:
198+
yield sa
199+
200+
201+
@pytest.fixture(scope="class")
202+
def tenant_a_evalhub_role(
203+
admin_client: DynamicClient,
204+
tenant_a_namespace: Namespace,
205+
) -> Generator[Role, Any, Any]:
206+
"""Role granting full EvalHub API access in tenant-a (virtual SAR resources)."""
207+
with Role(
208+
client=admin_client,
209+
name="evalhub-test-user-access",
210+
namespace=tenant_a_namespace.name,
211+
rules=EVALHUB_USER_ROLE_RULES,
212+
wait_for_resource=True,
213+
) as role:
214+
yield role
215+
216+
217+
@pytest.fixture(scope="class")
218+
def tenant_a_evalhub_role_binding(
219+
admin_client: DynamicClient,
220+
tenant_a_namespace: Namespace,
221+
tenant_a_service_account: ServiceAccount,
222+
tenant_a_evalhub_role: Role,
223+
) -> Generator[RoleBinding, Any, Any]:
224+
"""RoleBinding granting the test SA EvalHub access in tenant-a only."""
225+
with RoleBinding(
226+
client=admin_client,
227+
name="evalhub-test-user-binding",
228+
namespace=tenant_a_namespace.name,
229+
subjects_kind="ServiceAccount",
230+
subjects_name=tenant_a_service_account.name,
231+
role_ref_kind="Role",
232+
role_ref_name=tenant_a_evalhub_role.name,
233+
wait_for_resource=True,
234+
) as rb:
235+
yield rb
236+
237+
238+
@pytest.fixture(scope="class")
239+
def tenant_a_token(
240+
tenant_a_service_account: ServiceAccount,
241+
tenant_a_evalhub_role_binding: RoleBinding,
242+
) -> str:
243+
"""Bearer token for the test SA (has access to tenant-a, not tenant-b)."""
244+
return create_inference_token(model_service_account=tenant_a_service_account)
245+
246+
247+
# ---------------------------------------------------------------------------
248+
# vLLM emulator (deployed in tenant-a for job submission tests)
249+
# ---------------------------------------------------------------------------
250+
251+
VLLM_EMULATOR: str = "vllm-emulator"
252+
VLLM_EMULATOR_IMAGE: str = (
253+
"quay.io/trustyai_testing/vllm_emulator@sha256:c4bdd5bb93171dee5b4c8454f36d7c42b58b2a4ceb74f29dba5760ac53b5c12d"
254+
)
255+
256+
257+
@pytest.fixture(scope="class")
258+
def vllm_emulator_deployment(
259+
admin_client: DynamicClient,
260+
tenant_a_namespace: Namespace,
261+
tenant_a_rbac_ready: None,
262+
) -> Generator[Deployment, Any, Any]:
263+
"""Deploy the vLLM emulator in tenant-a.
264+
265+
Depends on tenant_a_rbac_ready to ensure the operator has provisioned
266+
the jobs-writer and job-config RoleBindings before any job is submitted.
267+
"""
268+
label = {Labels.Openshift.APP: VLLM_EMULATOR}
269+
with Deployment(
270+
client=admin_client,
271+
namespace=tenant_a_namespace.name,
272+
name=VLLM_EMULATOR,
273+
label=label,
274+
selector={"matchLabels": label},
275+
template={
276+
"metadata": {
277+
"labels": label,
278+
"name": VLLM_EMULATOR,
279+
},
280+
"spec": {
281+
"containers": [
282+
{
283+
"image": VLLM_EMULATOR_IMAGE,
284+
"name": VLLM_EMULATOR,
285+
"securityContext": {
286+
"allowPrivilegeEscalation": False,
287+
"capabilities": {"drop": ["ALL"]},
288+
"seccompProfile": {"type": "RuntimeDefault"},
289+
},
290+
}
291+
]
292+
},
293+
},
294+
replicas=1,
295+
) as deployment:
296+
deployment.wait_for_replicas(timeout=Timeout.TIMEOUT_5MIN)
297+
yield deployment
298+
299+
300+
@pytest.fixture(scope="class")
301+
def vllm_emulator_service(
302+
admin_client: DynamicClient,
303+
tenant_a_namespace: Namespace,
304+
vllm_emulator_deployment: Deployment,
305+
) -> Generator[Service, Any, Any]:
306+
"""Service fronting the vLLM emulator in tenant-a."""
307+
with Service(
308+
client=admin_client,
309+
namespace=tenant_a_namespace.name,
310+
name=f"{VLLM_EMULATOR}-service",
311+
ports=[
312+
{
313+
"name": f"{VLLM_EMULATOR}-endpoint",
314+
"port": EVALHUB_VLLM_EMULATOR_PORT,
315+
"protocol": Protocols.TCP,
316+
"targetPort": EVALHUB_VLLM_EMULATOR_PORT,
317+
}
318+
],
319+
selector={Labels.Openshift.APP: VLLM_EMULATOR},
320+
) as service:
321+
yield service

0 commit comments

Comments
 (0)