|
| 1 | +import base64 |
1 | 2 | import copy |
2 | 3 | import os |
3 | 4 | import tempfile |
4 | 5 | from collections.abc import Generator |
5 | 6 | from typing import Any |
6 | 7 |
|
| 8 | +import portforward |
7 | 9 | import pytest |
8 | 10 | from kubernetes.dynamic import DynamicClient |
9 | 11 | from ocp_resources.config_map import ConfigMap |
10 | 12 | from ocp_resources.deployment import Deployment |
| 13 | +from ocp_resources.inference_service import InferenceService |
| 14 | +from ocp_resources.namespace import Namespace |
| 15 | +from ocp_resources.pod import Pod |
11 | 16 | from ocp_resources.resource import ResourceEditor |
12 | 17 | from ocp_resources.secret import Secret |
| 18 | +from ocp_resources.serving_runtime import ServingRuntime |
13 | 19 | from pytest_testconfig import config as py_config |
14 | 20 | from simple_logger.logger import get_logger |
15 | 21 |
|
|
38 | 44 | from utilities.certificates_utils import create_ca_bundle_with_router_cert, create_k8s_secret |
39 | 45 | from utilities.exceptions import MissingParameter |
40 | 46 | from utilities.general import generate_random_name, wait_for_pods_running |
| 47 | +from utilities.infra import create_ns |
| 48 | +from utilities.operator_utils import get_cluster_service_version |
41 | 49 | from utilities.resources.model_registry_modelregistry_opendatahub_io import ModelRegistry |
42 | 50 |
|
43 | 51 | LOGGER = get_logger(name=__name__) |
@@ -408,3 +416,326 @@ def model_registry_default_postgres_deployment_match_label( |
408 | 416 | ensure_exists=True, |
409 | 417 | ) |
410 | 418 | return deployment.instance.spec.selector.matchLabels |
| 419 | + |
| 420 | + |
| 421 | +# Model Registry Deployment Fixtures (similar to HuggingFace deployment fixtures) |
| 422 | + |
| 423 | + |
| 424 | +class ModelRegistryDeploymentError(Exception): |
| 425 | + """Exception raised when model registry deployment fails.""" |
| 426 | + |
| 427 | + |
| 428 | +class PredictorPodNotFoundError(Exception): |
| 429 | + """Exception raised when predictor pods are not found for an InferenceService.""" |
| 430 | + |
| 431 | + |
| 432 | +def get_openvino_image_from_rhoai_csv(admin_client: DynamicClient) -> str: |
| 433 | + """ |
| 434 | + Get the OpenVINO model server image from the RHOAI ClusterServiceVersion. |
| 435 | +
|
| 436 | + Returns: |
| 437 | + str: The OpenVINO model server image URL from RHOAI CSV |
| 438 | +
|
| 439 | + Raises: |
| 440 | + Exception: If unable to find the image in the CSV |
| 441 | + """ |
| 442 | + # Get the RHOAI CSV using the utility function |
| 443 | + csv = get_cluster_service_version( |
| 444 | + client=admin_client, prefix="rhods-operator", namespace=py_config["applications_namespace"] |
| 445 | + ) |
| 446 | + |
| 447 | + # Look for OpenVINO image in spec.relatedImages |
| 448 | + related_images = csv.instance.spec.get("relatedImages", []) |
| 449 | + |
| 450 | + for image_info in related_images: |
| 451 | + image_url = image_info.get("image", "") |
| 452 | + if "odh-openvino-model-server" in image_url: |
| 453 | + LOGGER.info(f"Found OpenVINO image from RHOAI CSV: {image_url}") |
| 454 | + return image_url |
| 455 | + |
| 456 | + raise ModelRegistryDeploymentError("Could not find odh-openvino-model-server image in RHOAI CSV relatedImages") |
| 457 | + |
| 458 | + |
| 459 | +@pytest.fixture(scope="class") |
| 460 | +def model_registry_deployment_ns(admin_client: DynamicClient) -> Generator[Namespace, Any, Any]: |
| 461 | + """ |
| 462 | + Create a dedicated namespace for Model Registry model deployments and testing. |
| 463 | + Similar to hugging_face_deployment_ns but specifically for registered model serving tests. |
| 464 | + """ |
| 465 | + with create_ns( |
| 466 | + name="mr-deployment-ns", |
| 467 | + admin_client=admin_client, |
| 468 | + ) as ns: |
| 469 | + LOGGER.info(f"Created Model Registry deployment namespace: {ns.name}") |
| 470 | + yield ns |
| 471 | + |
| 472 | + |
| 473 | +@pytest.fixture(scope="class") |
| 474 | +def model_registry_connection_secret( |
| 475 | + admin_client: DynamicClient, |
| 476 | + model_registry_deployment_ns: Namespace, |
| 477 | + registered_model_rest_api: dict[str, Any], |
| 478 | +) -> Generator[Secret, Any, Any]: |
| 479 | + """ |
| 480 | + Create a connection secret for the registered model URI. |
| 481 | + This secret is required by the ODH admission webhook when creating InferenceServices |
| 482 | + with the opendatahub.io/connections annotation. |
| 483 | + """ |
| 484 | + resource_name = "mr-test-inference-service-connection" |
| 485 | + # Use the model URI from the registered model |
| 486 | + register_model_data = registered_model_rest_api.get("register_model", {}) |
| 487 | + model_uri = register_model_data.get("external_id", "hf://jonburdo/test2") |
| 488 | + |
| 489 | + # Base64 encode the model URI |
| 490 | + encoded_uri = base64.b64encode(model_uri.encode()).decode() |
| 491 | + |
| 492 | + # Annotations matching the connection secret structure |
| 493 | + annotations = { |
| 494 | + "opendatahub.io/connection-type-protocol": "uri", |
| 495 | + "opendatahub.io/connection-type-ref": "uri-v1", |
| 496 | + "openshift.io/display-name": resource_name, |
| 497 | + } |
| 498 | + |
| 499 | + # Labels for ODH integration |
| 500 | + labels = { |
| 501 | + "opendatahub.io/dashboard": "false", |
| 502 | + } |
| 503 | + |
| 504 | + with Secret( |
| 505 | + client=admin_client, |
| 506 | + name=resource_name, |
| 507 | + namespace=model_registry_deployment_ns.name, |
| 508 | + annotations=annotations, |
| 509 | + label=labels, |
| 510 | + data_dict={"URI": encoded_uri}, |
| 511 | + teardown=True, |
| 512 | + ) as connection_secret: |
| 513 | + LOGGER.info( |
| 514 | + f"Created Model Registry connection secret: {resource_name} in " |
| 515 | + f"namespace: {model_registry_deployment_ns.name}" |
| 516 | + ) |
| 517 | + yield connection_secret |
| 518 | + |
| 519 | + |
| 520 | +@pytest.fixture(scope="class") |
| 521 | +def model_registry_serving_runtime( |
| 522 | + admin_client: DynamicClient, |
| 523 | + model_registry_deployment_ns: Namespace, |
| 524 | +) -> Generator[ServingRuntime, Any, Any]: |
| 525 | + """ |
| 526 | + Create a ServingRuntime for OpenVINO Model Server to support registered models. |
| 527 | + Based on the HuggingFace serving runtime with complete ODH dashboard integration. |
| 528 | + """ |
| 529 | + runtime_name = "mr-test-runtime" |
| 530 | + |
| 531 | + # Complete annotations matching manually created examples |
| 532 | + annotations = { |
| 533 | + "opendatahub.io/apiProtocol": "REST", |
| 534 | + "opendatahub.io/recommended-accelerators": '["nvidia.com/gpu"]', |
| 535 | + "opendatahub.io/runtime-version": "v2025.4", |
| 536 | + "opendatahub.io/serving-runtime-scope": "global", |
| 537 | + "opendatahub.io/template-display-name": "OpenVINO Model Server", |
| 538 | + "opendatahub.io/template-name": "kserve-ovms", |
| 539 | + "openshift.io/display-name": "OpenVINO Model Server", |
| 540 | + } |
| 541 | + |
| 542 | + # Labels for ODH dashboard integration |
| 543 | + labels = { |
| 544 | + "opendatahub.io/dashboard": "true", |
| 545 | + } |
| 546 | + |
| 547 | + # Supported model formats |
| 548 | + supported_model_formats = [ |
| 549 | + {"autoSelect": True, "name": "openvino_ir", "version": "opset13"}, |
| 550 | + {"name": "onnx", "version": "1"}, |
| 551 | + {"autoSelect": True, "name": "tensorflow", "version": "1"}, |
| 552 | + {"autoSelect": True, "name": "tensorflow", "version": "2"}, |
| 553 | + {"autoSelect": True, "name": "paddle", "version": "2"}, |
| 554 | + {"autoSelect": True, "name": "pytorch", "version": "2"}, |
| 555 | + ] |
| 556 | + |
| 557 | + # Complete ServingRuntime specification |
| 558 | + runtime_spec = { |
| 559 | + "annotations": { |
| 560 | + "opendatahub.io/kserve-runtime": "ovms", |
| 561 | + "prometheus.io/path": "/metrics", |
| 562 | + "prometheus.io/port": "8888", |
| 563 | + }, |
| 564 | + "containers": [ |
| 565 | + { |
| 566 | + "args": [ |
| 567 | + "--model_name={{.Name}}", |
| 568 | + "--port=8001", |
| 569 | + "--rest_port=8888", |
| 570 | + "--model_path=/mnt/models", |
| 571 | + "--file_system_poll_wait_seconds=0", |
| 572 | + "--metrics_enable", |
| 573 | + ], |
| 574 | + "image": get_openvino_image_from_rhoai_csv(admin_client), |
| 575 | + "name": "kserve-container", |
| 576 | + "ports": [{"containerPort": 8888, "protocol": "TCP"}], |
| 577 | + } |
| 578 | + ], |
| 579 | + "multiModel": False, |
| 580 | + "protocolVersions": ["v2", "grpc-v2"], |
| 581 | + "supportedModelFormats": supported_model_formats, |
| 582 | + } |
| 583 | + |
| 584 | + # Create the ServingRuntime with complete configuration |
| 585 | + runtime_dict = { |
| 586 | + "apiVersion": "serving.kserve.io/v1alpha1", |
| 587 | + "kind": "ServingRuntime", |
| 588 | + "metadata": { |
| 589 | + "name": runtime_name, |
| 590 | + "namespace": model_registry_deployment_ns.name, |
| 591 | + "annotations": annotations, |
| 592 | + "labels": labels, |
| 593 | + }, |
| 594 | + "spec": runtime_spec, |
| 595 | + } |
| 596 | + |
| 597 | + with ServingRuntime( |
| 598 | + client=admin_client, |
| 599 | + kind_dict=runtime_dict, |
| 600 | + teardown=True, |
| 601 | + ) as serving_runtime: |
| 602 | + LOGGER.info( |
| 603 | + f"Created OpenVINO ServingRuntime: {runtime_name} in namespace: {model_registry_deployment_ns.name}" |
| 604 | + ) |
| 605 | + yield serving_runtime |
| 606 | + |
| 607 | + |
| 608 | +@pytest.fixture(scope="class") |
| 609 | +def model_registry_inference_service( |
| 610 | + admin_client: DynamicClient, |
| 611 | + model_registry_deployment_ns: Namespace, |
| 612 | + model_registry_serving_runtime: ServingRuntime, |
| 613 | + model_registry_connection_secret: Secret, |
| 614 | + registered_model_rest_api: dict[str, Any], |
| 615 | +) -> Generator[InferenceService, Any, Any]: |
| 616 | + """ |
| 617 | + Create an InferenceService for testing registered models. |
| 618 | + Based on the HuggingFace InferenceService with comprehensive ODH dashboard integration. |
| 619 | + """ |
| 620 | + name = "mr-test-inference-service" |
| 621 | + # Use the model URI from the registered model |
| 622 | + register_model_data = registered_model_rest_api.get("register_model", {}) |
| 623 | + model_uri = register_model_data.get("external_id", "hf://jonburdo/test2") |
| 624 | + model_name = register_model_data.get("name", "my-model") |
| 625 | + runtime_name = model_registry_serving_runtime.name |
| 626 | + |
| 627 | + # Resources |
| 628 | + resources = {"limits": {"cpu": "2", "memory": "4Gi"}, "requests": {"cpu": "2", "memory": "4Gi"}} |
| 629 | + |
| 630 | + # Labels for ODH dashboard integration |
| 631 | + labels = { |
| 632 | + "opendatahub.io/dashboard": "true", |
| 633 | + } |
| 634 | + |
| 635 | + # Comprehensive annotations matching ODH integration |
| 636 | + annotations = { |
| 637 | + "opendatahub.io/connections": model_registry_connection_secret.name, |
| 638 | + "opendatahub.io/hardware-profile-name": "default-profile", |
| 639 | + "opendatahub.io/hardware-profile-namespace": "redhat-ods-applications", |
| 640 | + "opendatahub.io/model-type": "predictive", |
| 641 | + "openshift.io/description": f"Model from registry: {model_name}", |
| 642 | + "openshift.io/display-name": f"registry/{name}", |
| 643 | + "security.opendatahub.io/enable-auth": "false", |
| 644 | + "serving.kserve.io/deploymentMode": "RawDeployment", |
| 645 | + } |
| 646 | + |
| 647 | + # Predictor configuration |
| 648 | + predictor_dict = { |
| 649 | + "automountServiceAccountToken": False, |
| 650 | + "deploymentStrategy": {"type": "RollingUpdate"}, |
| 651 | + "maxReplicas": 1, |
| 652 | + "minReplicas": 1, |
| 653 | + "model": { |
| 654 | + "modelFormat": {"name": "onnx", "version": "1"}, |
| 655 | + "name": "", |
| 656 | + "resources": resources, |
| 657 | + "runtime": runtime_name, |
| 658 | + "storageUri": model_uri, |
| 659 | + }, |
| 660 | + } |
| 661 | + |
| 662 | + with InferenceService( |
| 663 | + client=admin_client, |
| 664 | + name=name, |
| 665 | + namespace=model_registry_deployment_ns.name, |
| 666 | + annotations=annotations, |
| 667 | + label=labels, |
| 668 | + predictor=predictor_dict, |
| 669 | + teardown=True, |
| 670 | + ) as inference_service: |
| 671 | + # Wait for InferenceService to become Ready |
| 672 | + inference_service.wait_for_condition( |
| 673 | + condition="Ready", |
| 674 | + status="True", |
| 675 | + timeout=600, # 10 minutes timeout for model loading |
| 676 | + ) |
| 677 | + LOGGER.info( |
| 678 | + f"Created Model Registry InferenceService: {name} in namespace: {model_registry_deployment_ns.name}" |
| 679 | + ) |
| 680 | + yield inference_service |
| 681 | + |
| 682 | + |
| 683 | +@pytest.fixture(scope="class") |
| 684 | +def model_registry_predictor_pod( |
| 685 | + admin_client: DynamicClient, |
| 686 | + model_registry_deployment_ns: Namespace, |
| 687 | + model_registry_inference_service: InferenceService, |
| 688 | +) -> Pod: |
| 689 | + """ |
| 690 | + Get the predictor pod for the Model Registry InferenceService. |
| 691 | + """ |
| 692 | + namespace = model_registry_deployment_ns.name |
| 693 | + label_selector = f"serving.kserve.io/inferenceservice={model_registry_inference_service.name}" |
| 694 | + |
| 695 | + pods = Pod.get( |
| 696 | + client=admin_client, |
| 697 | + namespace=namespace, |
| 698 | + label_selector=label_selector, |
| 699 | + ) |
| 700 | + |
| 701 | + predictor_pods = [pod for pod in pods if "predictor" in pod.name] |
| 702 | + if not predictor_pods: |
| 703 | + raise PredictorPodNotFoundError( |
| 704 | + f"No predictor pods found for InferenceService {model_registry_inference_service.name}" |
| 705 | + ) |
| 706 | + |
| 707 | + pod = predictor_pods[0] # Use the first predictor pod |
| 708 | + LOGGER.info(f"Found predictor pod: {pod.name} in namespace: {namespace}") |
| 709 | + return pod |
| 710 | + |
| 711 | + |
| 712 | +@pytest.fixture(scope="class") |
| 713 | +def model_registry_model_portforward( |
| 714 | + model_registry_deployment_ns: Namespace, |
| 715 | + model_registry_inference_service: InferenceService, |
| 716 | + model_registry_predictor_pod: Pod, |
| 717 | +) -> Generator[str, Any]: |
| 718 | + """ |
| 719 | + Port-forwards the Model Registry OpenVINO model server pod to access the model API locally. |
| 720 | + Equivalent CLI: |
| 721 | + oc -n mr-deployment-ns port-forward pod/<pod-name> 8080:8888 |
| 722 | + """ |
| 723 | + namespace = model_registry_deployment_ns.name |
| 724 | + local_port = 9998 # Different from HF to avoid conflicts |
| 725 | + remote_port = 8888 # OpenVINO Model Server REST port |
| 726 | + local_url = f"http://localhost:{local_port}/v1/models" |
| 727 | + |
| 728 | + try: |
| 729 | + with portforward.forward( |
| 730 | + pod_or_service=model_registry_predictor_pod.name, |
| 731 | + namespace=namespace, |
| 732 | + from_port=local_port, |
| 733 | + to_port=remote_port, |
| 734 | + waiting=20, |
| 735 | + ): |
| 736 | + LOGGER.info(f"Model Registry model port-forward established: {local_url}") |
| 737 | + LOGGER.info(f"Test with: curl -s {local_url}/{model_registry_inference_service.name}") |
| 738 | + yield local_url |
| 739 | + except Exception as expt: |
| 740 | + LOGGER.error(f"Failed to set up port forwarding for pod {model_registry_predictor_pod.name}: {expt}") |
| 741 | + raise |
0 commit comments