|
1 | 1 | import pytest |
2 | 2 | import re |
| 3 | +import random |
| 4 | +import string |
| 5 | +import subprocess |
3 | 6 | import schemathesis |
4 | | -from typing import Generator, Any |
| 7 | +import shlex |
| 8 | +from typing import Generator, Any, List, Dict |
5 | 9 | from kubernetes.dynamic.exceptions import ResourceNotFoundError |
6 | 10 | from ocp_resources.pod import Pod |
7 | 11 | from ocp_resources.secret import Secret |
|
10 | 14 | from ocp_resources.persistent_volume_claim import PersistentVolumeClaim |
11 | 15 | from ocp_resources.data_science_cluster import DataScienceCluster |
12 | 16 | from ocp_resources.deployment import Deployment |
| 17 | +from ocp_resources.service_account import ServiceAccount |
| 18 | +from ocp_resources.role import Role |
| 19 | +from ocp_resources.role_binding import RoleBinding |
13 | 20 |
|
14 | 21 | from ocp_resources.model_registry import ModelRegistry |
15 | 22 | import schemathesis.schemas |
|
23 | 30 | from simple_logger.logger import get_logger |
24 | 31 | from kubernetes.dynamic import DynamicClient |
25 | 32 | from pytest_testconfig import config as py_config |
| 33 | +from pyhelper_utils.shell import run_command |
26 | 34 | from model_registry.types import RegisteredModel |
27 | 35 | from tests.model_registry.constants import ( |
28 | 36 | MR_OPERATOR_NAME, |
|
44 | 52 |
|
45 | 53 |
|
46 | 54 | LOGGER = get_logger(name=__name__) |
| 55 | +DEFAULT_TOKEN_DURATION = "10m" |
47 | 56 |
|
48 | 57 |
|
49 | 58 | @pytest.fixture(scope="class") |
@@ -293,3 +302,194 @@ def model_registry_operator_pod(admin_client: DynamicClient) -> Pod: |
293 | 302 | if not model_registry_operator_pods: |
294 | 303 | raise ResourceNotFoundError("Model registry operator pod not found") |
295 | 304 | return model_registry_operator_pods[0] |
| 305 | + |
| 306 | + |
| 307 | +# --- Fixture Helper Function --- |
| 308 | + |
| 309 | + |
| 310 | +def generate_random_name(prefix: str = "test", length: int = 8) -> str: |
| 311 | + """Generates a random string for resource names.""" |
| 312 | + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=length)) |
| 313 | + return f"{prefix}-{suffix}" |
| 314 | + |
| 315 | + |
| 316 | +# --- Service Account and Namespace Fixtures (Function Scoped for Isolation) --- |
| 317 | + |
| 318 | + |
| 319 | +@pytest.fixture(scope="function") |
| 320 | +def sa_namespace(admin_client: DynamicClient) -> Generator[Namespace, None, None]: |
| 321 | + """ |
| 322 | + Creates a temporary namespace using a context manager for automatic cleanup. |
| 323 | + Function scope ensures a fresh namespace for each test needing it. |
| 324 | + """ |
| 325 | + ns_name = generate_random_name(prefix="mr-rbac-test-ns") |
| 326 | + LOGGER.info(f"Creating temporary namespace: {ns_name}") |
| 327 | + # Use context manager for creation and deletion |
| 328 | + with Namespace(client=admin_client, name=ns_name) as ns: |
| 329 | + try: |
| 330 | + ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=120) |
| 331 | + LOGGER.info(f"Namespace {ns_name} is active.") |
| 332 | + yield ns |
| 333 | + # Cleanup happens automatically when exiting 'with' block |
| 334 | + LOGGER.info(f"Namespace {ns_name} deletion initiated by context manager.") |
| 335 | + # Add a final wait within the fixture if immediate confirmation is needed, |
| 336 | + # but context manager handles the delete call. Let's rely on the manager. |
| 337 | + # Consider adding ns.wait_deleted(timeout=180) here if needed AFTER yield returns. |
| 338 | + except Exception: |
| 339 | + LOGGER.error(f"Timeout waiting for namespace {ns_name} to become active.") |
| 340 | + pytest.fail(f"Namespace {ns_name} failed to become active.") |
| 341 | + |
| 342 | + |
| 343 | +@pytest.fixture(scope="function") |
| 344 | +def service_account(admin_client: DynamicClient, sa_namespace: Namespace) -> Generator[ServiceAccount, None, None]: |
| 345 | + """ |
| 346 | + Creates a ServiceAccount within the temporary namespace using a context manager. |
| 347 | + Function scope ensures it's tied to the lifetime of sa_namespace for that test. |
| 348 | + """ |
| 349 | + sa_name = generate_random_name(prefix="mr-test-user") |
| 350 | + LOGGER.info(f"Creating ServiceAccount: {sa_name} in namespace {sa_namespace.name}") |
| 351 | + # Use context manager for creation and deletion |
| 352 | + with ServiceAccount(client=admin_client, name=sa_name, namespace=sa_namespace.name) as sa: |
| 353 | + try: |
| 354 | + sa.wait(timeout=60) # Wait for SA object to exist |
| 355 | + LOGGER.info(f"ServiceAccount {sa_name} created.") |
| 356 | + yield sa |
| 357 | + # Cleanup happens automatically when exiting 'with' block |
| 358 | + LOGGER.info(f"ServiceAccount {sa_name} deletion initiated by context manager.") |
| 359 | + except Exception: |
| 360 | + LOGGER.error(f"Timeout waiting for ServiceAccount {sa_name} to be created.") |
| 361 | + pytest.fail(f"ServiceAccount {sa_name} failed to be created.") |
| 362 | + |
| 363 | + |
| 364 | +@pytest.fixture(scope="function") |
| 365 | +def sa_token(service_account: ServiceAccount) -> str: # type: ignore[return] |
| 366 | + """ |
| 367 | + Retrieves a short-lived token for the ServiceAccount using 'oc create token'. |
| 368 | + Function scope because token is temporary and tied to the SA for that test. |
| 369 | + """ |
| 370 | + sa_name = service_account.name |
| 371 | + namespace = service_account.namespace # Get namespace name from SA object |
| 372 | + LOGGER.info(f"Retrieving token for ServiceAccount: {sa_name} in namespace {namespace}") |
| 373 | + # (Keep the subprocess logic from previous version - it's appropriate here) |
| 374 | + try: |
| 375 | + cmd = f"oc create token {sa_name} -n {namespace} --duration={DEFAULT_TOKEN_DURATION}" |
| 376 | + LOGGER.debug(f"Executing command: {cmd}") |
| 377 | + res, out, err = run_command(command=shlex.split(cmd), verify_stderr=False, check=True) |
| 378 | + token = out.strip() |
| 379 | + if not token: |
| 380 | + pytest.fail(f"Retrieved token is empty for SA {sa_name} in {namespace}.") |
| 381 | + LOGGER.info(f"Successfully retrieved token for SA {sa_name}") |
| 382 | + return token |
| 383 | + except subprocess.CalledProcessError as e: |
| 384 | + LOGGER.error(f"Failed to create token for SA {sa_name} ns {namespace}: {e.stderr}") |
| 385 | + pytest.fail(f"Failed to create token for SA {sa_name}: {e.stderr}") |
| 386 | + except subprocess.TimeoutExpired: |
| 387 | + LOGGER.error(f"Timeout creating token for SA {sa_name} ns {namespace}") |
| 388 | + pytest.fail(f"Timeout creating token for SA {sa_name}") |
| 389 | + except Exception as e: |
| 390 | + LOGGER.error( |
| 391 | + f"An unexpected error occurred during token retrieval for SA {sa_name} ns {namespace}: {e}", exc_info=True |
| 392 | + ) |
| 393 | + pytest.fail(f"Unexpected error getting token for SA {sa_name}: {e}") |
| 394 | + |
| 395 | + |
| 396 | +# --- RBAC Fixtures (Using Context Managers, Function Scoped) --- |
| 397 | + |
| 398 | + |
| 399 | +@pytest.fixture(scope="function") |
| 400 | +def mr_access_role( |
| 401 | + admin_client: DynamicClient, |
| 402 | + model_registry_namespace: str, # Existing fixture from main conftest |
| 403 | + sa_namespace: Namespace, # Used for unique naming |
| 404 | +) -> Generator[Role, None, None]: |
| 405 | + """ |
| 406 | + Creates the MR Access Role using direct constructor parameters and a context manager. |
| 407 | + """ |
| 408 | + role_name = f"registry-user-{MR_INSTANCE_NAME}-{sa_namespace.name[:8]}" |
| 409 | + LOGGER.info(f"Defining Role: {role_name} in namespace {model_registry_namespace}") |
| 410 | + |
| 411 | + # Define rules directly as required by the Role constructor's 'rules' parameter |
| 412 | + role_rules: List[Dict[str, Any]] = [ |
| 413 | + { |
| 414 | + "apiGroups": [""], # Core API group |
| 415 | + "resources": ["services"], # As per last refinement for REST access |
| 416 | + "resourceNames": [MR_INSTANCE_NAME], # Grant access only to the specific MR service object |
| 417 | + "verbs": ["get"], |
| 418 | + } |
| 419 | + ] |
| 420 | + |
| 421 | + # Define labels, to be passed via **kwargs |
| 422 | + role_labels = { |
| 423 | + "app.kubernetes.io/component": "model-registry-test-rbac", |
| 424 | + "test.opendatahub.io/namespace": sa_namespace.name, |
| 425 | + } |
| 426 | + |
| 427 | + LOGGER.info(f"Attempting to create Role: {role_name} with rules and labels.") |
| 428 | + # Use context manager for creation and deletion |
| 429 | + # Pass rules and labels directly |
| 430 | + with Role( |
| 431 | + client=admin_client, |
| 432 | + name=role_name, |
| 433 | + namespace=model_registry_namespace, |
| 434 | + rules=role_rules, |
| 435 | + label=role_labels, # Pass labels via kwargs |
| 436 | + ) as role: |
| 437 | + try: |
| 438 | + role.wait(timeout=60) # Wait for role object to exist |
| 439 | + LOGGER.info(f"Role {role.name} created successfully.") |
| 440 | + yield role |
| 441 | + LOGGER.info(f"Role {role.name} deletion initiated by context manager.") |
| 442 | + except Exception as e: # Catch other potential errors during Role instantiation or wait |
| 443 | + LOGGER.error(f"Error during Role {role_name} creation or wait: {e}", exc_info=True) |
| 444 | + pytest.fail(f"Failed during Role {role_name} creation: {e}") |
| 445 | + |
| 446 | + |
| 447 | +@pytest.fixture(scope="function") |
| 448 | +def mr_access_role_binding( |
| 449 | + admin_client: DynamicClient, |
| 450 | + model_registry_namespace: str, # Existing fixture from main conftest |
| 451 | + mr_access_role: Role, # Depend on the role fixture to get its name |
| 452 | + sa_namespace: Namespace, # The namespace containing the test SA |
| 453 | +) -> Generator[RoleBinding, None, None]: |
| 454 | + """ |
| 455 | + Creates the MR Access RoleBinding using direct constructor parameters and a context manager. |
| 456 | + """ |
| 457 | + binding_name = f"{mr_access_role.name}-binding" # Simplify name slightly, role name is already unique |
| 458 | + role_name_ref = mr_access_role.name # Get the actual name from the created Role object |
| 459 | + |
| 460 | + LOGGER.info( |
| 461 | + f"Defining RoleBinding: {binding_name} linking Group 'system:serviceaccounts:{sa_namespace.name}' " |
| 462 | + f"to Role '{role_name_ref}' in namespace {model_registry_namespace}" |
| 463 | + ) |
| 464 | + |
| 465 | + # Define labels, to be passed via **kwargs |
| 466 | + binding_labels = { |
| 467 | + "app.kubernetes.io/component": "model-registry-test-rbac", |
| 468 | + "test.opendatahub.io/namespace": sa_namespace.name, |
| 469 | + } |
| 470 | + |
| 471 | + LOGGER.info(f"Attempting to create RoleBinding: {binding_name} with labels.") |
| 472 | + # Use context manager for creation and deletion |
| 473 | + # Pass subject and role_ref details directly to constructor |
| 474 | + with RoleBinding( |
| 475 | + client=admin_client, |
| 476 | + name=binding_name, |
| 477 | + namespace=model_registry_namespace, |
| 478 | + # Subject parameters |
| 479 | + subjects_kind="Group", |
| 480 | + subjects_name=f"system:serviceaccounts:{sa_namespace.name}", |
| 481 | + subjects_api_group="rbac.authorization.k8s.io", # This is the default apiGroup for Group kind |
| 482 | + # Role reference parameters |
| 483 | + role_ref_kind="Role", |
| 484 | + role_ref_name=role_name_ref, |
| 485 | + # role_ref_api_group="rbac.authorization.k8s.io", # This is automatically set by the class |
| 486 | + label=binding_labels, # Pass labels via kwargs |
| 487 | + ) as binding: |
| 488 | + try: |
| 489 | + binding.wait(timeout=60) # Wait for binding object to exist |
| 490 | + LOGGER.info(f"RoleBinding {binding.name} created successfully.") |
| 491 | + yield binding |
| 492 | + LOGGER.info(f"RoleBinding {binding.name} deletion initiated by context manager.") |
| 493 | + except Exception as e: # Catch other potential errors |
| 494 | + LOGGER.error(f"Error during RoleBinding {binding_name} creation or wait: {e}", exc_info=True) |
| 495 | + pytest.fail(f"Failed during RoleBinding {binding_name} creation: {e}") |
0 commit comments