|
| 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