diff --git a/.gitignore b/.gitignore index 2dc53ca..c9d5b3b 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,7 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +# OS generated files # +.DS_Store +.DS_Store? diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11d6d84..54088e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,3 +31,8 @@ pre-commit install ## General - Add typing to new code; typing is enforced using [mypy](https://mypy-lang.org/) + - Rules are defined in [our pyproject.toml file](//pyproject.toml#L10) + +If you use Visual Studio Code as your IDE, we recommend using the [Mypy Type Checker](https://marketplace.visualstudio.com/items?itemName=ms-python.mypy-type-checker) extension. +After installing it, make sure to update the `Mypy-type-checkers: Args` setting +to `"mypy-type-checker.args" = ["--config-file=pyproject.toml"]`. diff --git a/conftest.py b/conftest.py index 58c87cd..ad52469 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,9 @@ import os import pathlib import shutil +from pytest import Parser, Session, FixtureRequest, FixtureDef, Item, Config, CollectReport +from _pytest.terminal import TerminalReporter +from typing import Optional, Any from utilities.logger import separator, setup_logging @@ -10,7 +13,7 @@ BASIC_LOGGER = logging.getLogger("basic") -def pytest_addoption(parser): +def pytest_addoption(parser: Parser) -> None: aws_group = parser.getgroup(name="AWS") buckets_group = parser.getgroup(name="Buckets") @@ -31,8 +34,34 @@ def pytest_addoption(parser): "--ci-s3-bucket-name", default=os.environ.get("CI_S3_BUCKET_NAME"), help="Ci S3 bucket name" ) + buckets_group.addoption( + "--ci-s3-bucket-region", default=os.environ.get("CI_S3_BUCKET_REGION"), help="Ci S3 bucket region" + ) + + buckets_group.addoption( + "--ci-s3-bucket-endpoint", default=os.environ.get("CI_S3_BUCKET_ENDPOINT"), help="Ci S3 bucket endpoint" + ) + + buckets_group.addoption( + "--models-s3-bucket-name", + default=os.environ.get("MODELS_S3_BUCKET_NAME"), + help="Models S3 bucket name", + ) + + buckets_group.addoption( + "--models-s3-bucket-region", + default=os.environ.get("MODELS_S3_BUCKET_REGION"), + help="Models S3 bucket region", + ) + + buckets_group.addoption( + "--models-s3-bucket-endpoint", + default=os.environ.get("MODELS_S3_BUCKET_ENDPOINT"), + help="Models S3 bucket endpoint", + ) + -def pytest_sessionstart(session): +def pytest_sessionstart(session: Session) -> None: tests_log_file = session.config.getoption("log_file") or "pytest-tests.log" if os.path.exists(tests_log_file): pathlib.Path(tests_log_file).unlink() @@ -43,24 +72,24 @@ def pytest_sessionstart(session): ) -def pytest_fixture_setup(fixturedef, request): +def pytest_fixture_setup(fixturedef: FixtureDef[Any], request: FixtureRequest) -> None: LOGGER.info(f"Executing {fixturedef.scope} fixture: {fixturedef.argname}") -def pytest_runtest_setup(item): +def pytest_runtest_setup(item: Item) -> None: BASIC_LOGGER.info(f"\n{separator(symbol_='-', val=item.name)}") BASIC_LOGGER.info(f"{separator(symbol_='-', val='SETUP')}") -def pytest_runtest_call(item): +def pytest_runtest_call(item: Item) -> None: BASIC_LOGGER.info(f"{separator(symbol_='-', val='CALL')}") -def pytest_runtest_teardown(item): +def pytest_runtest_teardown(item: Item) -> None: BASIC_LOGGER.info(f"{separator(symbol_='-', val='TEARDOWN')}") -def pytest_report_teststatus(report, config): +def pytest_report_teststatus(report: CollectReport, config: Config) -> None: test_name = report.head_line when = report.when call_str = "call" @@ -78,8 +107,9 @@ def pytest_report_teststatus(report, config): BASIC_LOGGER.info(f"\nTEST: {test_name} STATUS: \033[0;31mFAILED\033[0m") -def pytest_sessionfinish(session, exitstatus): +def pytest_sessionfinish(session: Session, exitstatus: int) -> None: shutil.rmtree(path=session.config.option.basetemp, ignore_errors=True) - reporter = session.config.pluginmanager.get_plugin("terminalreporter") - reporter.summary_stats() + reporter: Optional[TerminalReporter] = session.config.pluginmanager.get_plugin("terminalreporter") + if reporter: + reporter.summary_stats() diff --git a/pyproject.toml b/pyproject.toml index 130cc9c..ffce353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ disallow_untyped_defs = true no_implicit_optional = true show_error_codes = true warn_unused_ignores = true +ignore_missing_imports = true [tool.uv] dev-dependencies = [ diff --git a/tests/conftest.py b/tests/conftest.py index aeb08d9..7f0cc1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,12 @@ +from typing import Tuple, Any, Generator + import pytest +from pytest import FixtureRequest, Config from kubernetes.dynamic import DynamicClient from ocp_resources.namespace import Namespace from ocp_resources.resource import get_client -from tests.utils import create_ns +from utilities.infra import create_ns @pytest.fixture(scope="session") @@ -12,6 +15,99 @@ def admin_client() -> DynamicClient: @pytest.fixture(scope="class") -def model_namespace(request, admin_client: DynamicClient) -> Namespace: - with create_ns(client=admin_client, name=request.param["name"]) as ns: +def model_namespace(request: FixtureRequest, admin_client: DynamicClient) -> Generator[Namespace, Any, Any]: + with create_ns(admin_client=admin_client, name=request.param["name"]) as ns: yield ns + + +@pytest.fixture(scope="session") +def aws_access_key_id(pytestconfig: Config) -> str: + access_key = pytestconfig.option.aws_access_key_id + if not access_key: + raise ValueError( + "AWS access key id is not set. " + "Either pass with `--aws-access-key-id` or set `AWS_ACCESS_KEY_ID` environment variable" + ) + return access_key + + +@pytest.fixture(scope="session") +def aws_secret_access_key(pytestconfig: Config) -> str: + secret_access_key = pytestconfig.option.aws_secret_access_key + if not secret_access_key: + raise ValueError( + "AWS secret access key is not set. " + "Either pass with `--aws-secret-access-key` or set `AWS_SECRET_ACCESS_KEY` environment variable" + ) + return secret_access_key + + +@pytest.fixture(scope="session") +def valid_aws_config(aws_access_key_id: str, aws_secret_access_key: str) -> Tuple[str, str]: + return aws_access_key_id, aws_secret_access_key + + +@pytest.fixture(scope="session") +def ci_s3_bucket_name(pytestconfig: Config) -> str: + bucket_name = pytestconfig.option.ci_s3_bucket_name + if not bucket_name: + raise ValueError( + "CI S3 bucket name is not set. " + "Either pass with `--ci-s3-bucket-name` or set `CI_S3_BUCKET_NAME` environment variable" + ) + return bucket_name + + +@pytest.fixture(scope="session") +def ci_s3_bucket_region(pytestconfig: pytest.Config) -> str: + ci_bucket_region = pytestconfig.option.ci_s3_bucket_region + if not ci_bucket_region: + raise ValueError( + "Region for the ci s3 bucket is not defined." + "Either pass with `--ci-s3-bucket-region` or set `CI_S3_BUCKET_REGION` environment variable" + ) + return ci_bucket_region + + +@pytest.fixture(scope="session") +def ci_s3_bucket_endpoint(pytestconfig: pytest.Config) -> str: + ci_bucket_endpoint = pytestconfig.option.ci_s3_bucket_endpoint + if not ci_bucket_endpoint: + raise ValueError( + "Endpoint for the ci s3 bucket is not defined." + "Either pass with `--ci-s3-bucket-endpoint` or set `CI_S3_BUCKET_ENDPOINT` environment variable" + ) + return ci_bucket_endpoint + + +@pytest.fixture(scope="session") +def models_s3_bucket_name(pytestconfig: pytest.Config) -> str: + models_bucket = pytestconfig.option.models_s3_bucket_name + if not models_bucket: + raise ValueError( + "Bucket name for the models bucket is not defined." + "Either pass with `--models-s3-bucket-name` or set `MODELS_S3_BUCKET_NAME` environment variable" + ) + return models_bucket + + +@pytest.fixture(scope="session") +def models_s3_bucket_region(pytestconfig: pytest.Config) -> str: + models_bucket_region = pytestconfig.option.models_s3_bucket_region + if not models_bucket_region: + raise ValueError( + "region for the models bucket is not defined." + "Either pass with `--models-s3-bucket-region` or set `MODELS_S3_BUCKET_REGION` environment variable" + ) + return models_bucket_region + + +@pytest.fixture(scope="session") +def models_s3_bucket_endpoint(pytestconfig: pytest.Config) -> str: + models_bucket_endpoint = pytestconfig.option.models_s3_bucket_endpoint + if not models_bucket_endpoint: + raise ValueError( + "endpoint for the models bucket is not defined." + "Either pass with `--models-s3-bucket-endpoint` or set `MODELS_S3_BUCKET_ENDPOINT` environment variable" + ) + return models_bucket_endpoint diff --git a/tests/model_serving/model_server/private_endpoint/__init__.py b/tests/model_serving/model_server/private_endpoint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/model_serving/model_server/private_endpoint/conftest.py b/tests/model_serving/model_server/private_endpoint/conftest.py new file mode 100644 index 0000000..512002e --- /dev/null +++ b/tests/model_serving/model_server/private_endpoint/conftest.py @@ -0,0 +1,191 @@ +import json +import pytest +from typing import Generator, Any +from ocp_resources.inference_service import InferenceService +from ocp_resources.secret import Secret +from ocp_resources.namespace import Namespace +from ocp_resources.pod import Pod +from simple_logger.logger import get_logger +from ocp_resources.serving_runtime import ServingRuntime +from kubernetes.dynamic import DynamicClient + +from utilities.serving_runtime import ServingRuntimeFromTemplate +from tests.model_serving.model_server.utils import create_isvc +from tests.model_serving.model_server.private_endpoint.utils import ( + create_sidecar_pod, + get_kserve_predictor_deployment, + b64_encoded_string, +) +from utilities.infra import create_ns +from utilities.constants import KServeDeploymentType, ModelStoragePath, ModelFormat + + +LOGGER = get_logger(name=__name__) + + +@pytest.fixture(scope="session") +def endpoint_namespace(admin_client: DynamicClient) -> Generator[Namespace, None, None]: + with create_ns(admin_client=admin_client, name="endpoint-namespace") as ns: + yield ns + + +@pytest.fixture(scope="session") +def diff_namespace(admin_client: DynamicClient) -> Generator[Namespace, None, None]: + with create_ns(admin_client=admin_client, name="diff-namespace") as ns: + yield ns + + +@pytest.fixture(scope="session") +def endpoint_sr( + admin_client: DynamicClient, + endpoint_namespace: Namespace, +) -> Generator[ServingRuntime, None, None]: + with ServingRuntimeFromTemplate( + client=admin_client, + name="flan-example-sr", + namespace=endpoint_namespace.name, + template_name="caikit-tgis-serving-template", + ) as model_runtime: + yield model_runtime + + +@pytest.fixture(scope="session") +def endpoint_s3_secret( + admin_client: DynamicClient, + endpoint_namespace: Namespace, + aws_access_key_id: str, + aws_secret_access_key: str, + models_s3_bucket_name: str, + models_s3_bucket_region: str, + models_s3_bucket_endpoint: str, +) -> Generator[Secret, None, None]: + data = { + "AWS_ACCESS_KEY_ID": b64_encoded_string(aws_access_key_id), + "AWS_DEFAULT_REGION": b64_encoded_string(models_s3_bucket_region), + "AWS_S3_BUCKET": b64_encoded_string(models_s3_bucket_name), + "AWS_S3_ENDPOINT": b64_encoded_string(models_s3_bucket_endpoint), + "AWS_SECRET_ACCESS_KEY": b64_encoded_string(aws_secret_access_key), + } + with Secret( + client=admin_client, + namespace=endpoint_namespace.name, + name="endpoint-s3-secret", + data_dict=data, + wait_for_resource=True, + ) as secret: + yield secret + + +@pytest.fixture(scope="session") +def endpoint_isvc( + admin_client: DynamicClient, + endpoint_sr: ServingRuntime, + endpoint_s3_secret: Secret, + storage_config_secret: Secret, + endpoint_namespace: Namespace, +) -> Generator[InferenceService, None, None]: + with create_isvc( + client=admin_client, + name="test", + namespace=endpoint_namespace.name, + deployment_mode=KServeDeploymentType.SERVERLESS, + storage_key="endpoint-s3-secret", + storage_path=ModelStoragePath.FLAN_T5_SMALL, + model_format=ModelFormat.CAIKIT, + runtime=endpoint_sr.name, + ) as isvc: + yield isvc + + +@pytest.fixture(scope="session") +def storage_config_secret( + admin_client: DynamicClient, + endpoint_namespace: Namespace, + endpoint_s3_secret: Secret, + aws_access_key_id: str, + aws_secret_access_key: str, + models_s3_bucket_name: str, + models_s3_bucket_region: str, + models_s3_bucket_endpoint: str, +) -> Generator[Secret, None, None]: + secret = { + "access_key_id": aws_access_key_id, + "bucket": models_s3_bucket_name, + "default_bucket": models_s3_bucket_name, + "endpoint_url": models_s3_bucket_endpoint, + "region": models_s3_bucket_region, + "secret_access_key": aws_secret_access_key, + "type": "s3", + } + data = {"endpoint-s3-secret": b64_encoded_string(json.dumps(secret))} + with Secret( + client=admin_client, + namespace=endpoint_namespace.name, + data_dict=data, + wait_for_resource=True, + name="storage-config", + ) as storage_config: + yield storage_config + + +@pytest.fixture() +def endpoint_pod_with_istio_sidecar( + admin_client: DynamicClient, endpoint_namespace: Namespace +) -> Generator[Pod, Any, Any]: + with create_sidecar_pod( + admin_client=admin_client, + namespace=endpoint_namespace.name, + use_istio=True, + pod_name="test-with-istio", + ) as pod: + yield pod + + +@pytest.fixture() +def endpoint_pod_without_istio_sidecar( + admin_client: DynamicClient, endpoint_namespace: Namespace +) -> Generator[Pod, Any, Any]: + with create_sidecar_pod( + admin_client=admin_client, + namespace=endpoint_namespace.name, + use_istio=False, + pod_name="test", + ) as pod: + yield pod + + +@pytest.fixture() +def diff_pod_with_istio_sidecar( + admin_client: DynamicClient, + diff_namespace: Namespace, +) -> Generator[Pod, Any, Any]: + with create_sidecar_pod( + admin_client=admin_client, + namespace=diff_namespace.name, + use_istio=True, + pod_name="test-with-istio", + ) as pod: + yield pod + + +@pytest.fixture() +def diff_pod_without_istio_sidecar( + admin_client: DynamicClient, + diff_namespace: Namespace, +) -> Generator[Pod, Any, Any]: + with create_sidecar_pod( + admin_client=admin_client, + namespace=diff_namespace.name, + use_istio=False, + pod_name="test", + ) as pod: + yield pod + + +@pytest.fixture() +def ready_predictor(admin_client: DynamicClient, endpoint_isvc: InferenceService) -> None: + get_kserve_predictor_deployment( + namespace=endpoint_isvc.namespace, + client=admin_client, + name_prefix=endpoint_isvc.name, + ) diff --git a/tests/model_serving/model_server/private_endpoint/test_kserve_private_endpoint.py b/tests/model_serving/model_server/private_endpoint/test_kserve_private_endpoint.py new file mode 100644 index 0000000..f6e901b --- /dev/null +++ b/tests/model_serving/model_server/private_endpoint/test_kserve_private_endpoint.py @@ -0,0 +1,90 @@ +from typing import Self + +from simple_logger.logger import get_logger +from ocp_resources.namespace import Namespace +from ocp_resources.inference_service import InferenceService +from ocp_resources.pod import Pod +from ocp_resources.deployment import Deployment +from tests.model_serving.model_server.private_endpoint.utils import curl_from_pod +from utilities.constants import CurlOutput, ModelEndpoint + + +LOGGER = get_logger(name=__name__) + + +class TestKserveInternalEndpoint: + "Tests the internal endpoint of a kserve predictor" + + def test_deploy_model_state_loaded( + self: Self, endpoint_namespace: Namespace, endpoint_isvc: InferenceService, ready_predictor: Deployment + ) -> None: + "Verifies that the predictor gets to state Loaded" + assert endpoint_isvc.instance.status.modelStatus.states.activeModelState == "Loaded" + + def test_deploy_model_url( + self: Self, endpoint_namespace: Namespace, endpoint_isvc: InferenceService, ready_predictor: Deployment + ) -> None: + "Verifies that the internal endpoint has the expected formatting" + assert ( + endpoint_isvc.instance.status.address.url + == f"https://{endpoint_isvc.name}.{endpoint_namespace.name}.svc.cluster.local" + ) + + def test_curl_with_istio_same_ns( + self: Self, + endpoint_isvc: InferenceService, + endpoint_pod_with_istio_sidecar: Pod, + ) -> None: + "Verifies the response from the health endpoint, sending a request from a pod in the same ns and part of the Istio Service Mesh" + + curl_stdout = curl_from_pod( + isvc=endpoint_isvc, + pod=endpoint_pod_with_istio_sidecar, + endpoint=ModelEndpoint.HEALTH, + ) + assert curl_stdout == CurlOutput.HEALTH_OK + + def test_curl_with_istio_diff_ns( + self: Self, + endpoint_isvc: InferenceService, + diff_pod_with_istio_sidecar: Pod, + ) -> None: + "Verifies the response from the health endpoint, sending a request from a pod in a different ns and part of the Istio Service Mesh" + + curl_stdout = curl_from_pod( + isvc=endpoint_isvc, + pod=diff_pod_with_istio_sidecar, + endpoint=ModelEndpoint.HEALTH, + protocol="https", + ) + assert curl_stdout == CurlOutput.HEALTH_OK + + def test_curl_outside_istio_same_ns( + self: Self, + endpoint_isvc: InferenceService, + endpoint_pod_without_istio_sidecar: Pod, + ) -> None: + "Verifies the response from the health endpoint, sending a request from a pod in the same ns and not part of the Istio Service Mesh" + + curl_stdout = curl_from_pod( + isvc=endpoint_isvc, + pod=endpoint_pod_without_istio_sidecar, + endpoint=ModelEndpoint.HEALTH, + protocol="https", + ) + assert curl_stdout == CurlOutput.HEALTH_OK + + def test_curl_outside_istio_diff_ns( + self: Self, + endpoint_isvc: InferenceService, + diff_pod_without_istio_sidecar: Pod, + ) -> None: + "Verifies the response from the health endpoint, sending a request from a pod in a different ns and not part of the Istio Service Mesh" + + curl_stdout = curl_from_pod( + isvc=endpoint_isvc, + pod=diff_pod_without_istio_sidecar, + endpoint=ModelEndpoint.HEALTH, + protocol="https", + ) + assert curl_stdout == CurlOutput.HEALTH_OK diff --git a/tests/model_serving/model_server/private_endpoint/utils.py b/tests/model_serving/model_server/private_endpoint/utils.py new file mode 100644 index 0000000..c6d573c --- /dev/null +++ b/tests/model_serving/model_server/private_endpoint/utils.py @@ -0,0 +1,121 @@ +import shlex +import base64 +from typing import Optional, Any, Generator +from urllib.parse import urlparse +from contextlib import contextmanager + +from ocp_resources.pod import Pod +from ocp_resources.deployment import Deployment +from kubernetes.dynamic.client import DynamicClient +from kubernetes.dynamic.exceptions import ResourceNotFoundError, ResourceNotUniqueError +from ocp_resources.inference_service import InferenceService +from pyhelper_utils.shell import run_command +from simple_logger.logger import get_logger + + +LOGGER = get_logger(name=__name__) + + +class ProtocolNotSupported(Exception): + def __init__(self, protocol: str): + self.protocol = protocol + + def __str__(self) -> str: + return f"Protocol {self.protocol} is not supported" + + +class InvalidStorageArgument(Exception): + def __init__( + self, + storageUri: Optional[str], + storage_key: Optional[str], + storage_path: Optional[str], + ): + self.storageUri = storageUri + self.storage_key = storage_key + self.storage_path = storage_path + + def __str__(self) -> str: + msg = f""" + You've passed the following parameters: + "storageUri": {self.storageUri} + "storage_key": {self.storage_key} + "storage_path: {self.storage_path} + In order to create a valid ISVC you need to specify either a storageUri value + or both a storage key and a storage path. + """ + return msg + + +def get_kserve_predictor_deployment(client: DynamicClient, namespace: str, name_prefix: str) -> Deployment: + deployments = list( + Deployment.get( + label_selector=f"serving.kserve.io/inferenceservice={name_prefix}", + client=client, + namespace=namespace, + ) + ) + + if len(deployments) == 1: + deployment = deployments[0] + if deployment.exists: + deployment.wait_for_replicas() + return deployment + elif len(deployments) > 1: + raise ResourceNotUniqueError(f"Multiple predictor deployments found in namespace {namespace}") + else: + raise ResourceNotFoundError(f"Predictor deployment not found in namespace {namespace}") + + +def curl_from_pod( + isvc: InferenceService, + pod: Pod, + endpoint: str, + protocol: str = "http", +) -> str: + if protocol not in ("https", "http"): + raise ProtocolNotSupported(protocol) + host = isvc.instance.status.address.url + if protocol == "http": + parsed = urlparse(host) + host = parsed._replace(scheme="http").geturl() + return pod.execute(command=shlex.split(f"curl -k {host}/{endpoint}"), ignore_rc=True) + + +@contextmanager +def create_sidecar_pod( + admin_client: DynamicClient, + namespace: str, + use_istio: bool, + pod_name: str, +) -> Generator[Pod, Any, Any]: + cmd = f"oc run {pod_name} -n {namespace} --image=registry.access.redhat.com/rhel7/rhel-tools" + if use_istio: + cmd = f'{cmd} --annotations=sidecar.istio.io/inject="true"' + + cmd += " -- sleep infinity" + + _, _, err = run_command(command=shlex.split(cmd), check=False) + if err: + LOGGER.error(msg=err) + + pod = Pod(name=pod_name, namespace=namespace, client=admin_client) + pod.wait_for_condition(condition="Ready", status="True") + yield pod + pod.clean_up() + + +def b64_encoded_string(string_to_encode: str) -> str: + """Returns openshift compliant base64 encoding of a string + + encodes the input string to bytes-like, encodes the bytes-like to base 64, + decodes the b64 to a string and returns it. This is needed for openshift + resources expecting b64 encoded values in the yaml. + + Args: + string_to_encode: The string to encode in base64 + + Returns: + A base64 encoded string that is compliant with openshift's yaml format + """ + return base64.b64encode(string_to_encode.encode()).decode() diff --git a/tests/model_serving/model_server/storage/pvc/conftest.py b/tests/model_serving/model_server/storage/pvc/conftest.py index 76d1c33..84acf68 100644 --- a/tests/model_serving/model_server/storage/pvc/conftest.py +++ b/tests/model_serving/model_server/storage/pvc/conftest.py @@ -1,7 +1,8 @@ import shlex -from typing import List, Optional, Tuple +from typing import List, Generator, Any import pytest +from pytest import FixtureRequest from kubernetes.dynamic import DynamicClient from kubernetes.dynamic.exceptions import ResourceNotFoundError from ocp_resources.deployment import Deployment @@ -10,79 +11,27 @@ from ocp_resources.persistent_volume_claim import PersistentVolumeClaim from ocp_resources.pod import Pod from ocp_resources.resource import ResourceEditor -from ocp_resources.service_mesh_member import ServiceMeshMember from ocp_resources.serving_runtime import ServingRuntime from ocp_resources.storage_class import StorageClass from ocp_utilities.infra import get_pods_by_name_prefix from tests.model_serving.model_server.storage.constants import NFS_STR -from tests.model_serving.model_server.storage.pvc.utils import create_isvc +from tests.model_serving.model_server.utils import create_isvc +from utilities.constants import KServeDeploymentType from utilities.serving_runtime import ServingRuntimeFromTemplate -@pytest.fixture(scope="session") -def aws_access_key_id(pytestconfig) -> Optional[str]: - access_key = pytestconfig.option.aws_access_key_id - if not access_key: - raise ValueError( - "AWS access key id is not set. " - "Either pass with `--aws-access-key-id` or set `AWS_ACCESS_KEY_ID` environment variable" - ) - - return access_key - - -@pytest.fixture(scope="session") -def aws_secret_access_key(pytestconfig) -> Optional[str]: - secret_access_key = pytestconfig.option.aws_secret_access_key - if not secret_access_key: - raise ValueError( - "AWS secret access key is not set. " - "Either pass with `--aws-secret-access-key` or set `AWS_SECRET_ACCESS_KEY` environment variable" - ) - - return secret_access_key - - -@pytest.fixture(scope="session") -def valid_aws_config(aws_access_key_id: str, aws_secret_access_key: str) -> Tuple[str, str]: - return aws_access_key_id, aws_secret_access_key - - @pytest.fixture(scope="class") -def service_mesh_member(admin_client: DynamicClient, model_namespace: Namespace) -> ServiceMeshMember: - with ServiceMeshMember( - client=admin_client, - name="default", - namespace=model_namespace.name, - control_plane_ref={"name": "data-science-smcp", "namespace": "istio-system"}, - ) as smm: - yield smm - - -@pytest.fixture(scope="session") -def ci_s3_bucket_name(pytestconfig) -> str: - bucket_name = pytestconfig.option.ci_s3_bucket_name - if not bucket_name: - raise ValueError( - "CI S3 bucket name is not set. " - "Either pass with `--ci-s3-bucket-name` or set `CI_S3_BUCKET_NAME` environment variable" - ) - - return bucket_name - - -@pytest.fixture(scope="class") -def ci_s3_storage_uri(request, ci_s3_bucket_name) -> str: +def ci_s3_storage_uri(request: FixtureRequest, ci_s3_bucket_name: str) -> str: return f"s3://{ci_s3_bucket_name}/{request.param['model-dir']}/" @pytest.fixture(scope="class") def model_pvc( - request, + request: FixtureRequest, admin_client: DynamicClient, model_namespace: Namespace, -) -> PersistentVolumeClaim: +) -> Generator[PersistentVolumeClaim, Any, Any]: access_mode = "ReadWriteOnce" pvc_kwargs = { "name": "model-pvc", @@ -146,12 +95,11 @@ def downloaded_model_data( @pytest.fixture(scope="class") def serving_runtime( - request, + request: FixtureRequest, admin_client: DynamicClient, - service_mesh_member: ServiceMeshMember, model_namespace: Namespace, downloaded_model_data: str, -) -> ServingRuntime: +) -> Generator[ServingRuntime, Any, Any]: with ServingRuntimeFromTemplate( client=admin_client, name=request.param["name"], @@ -163,13 +111,13 @@ def serving_runtime( @pytest.fixture(scope="class") def inference_service( - request, + request: FixtureRequest, admin_client: DynamicClient, model_namespace: Namespace, serving_runtime: ServingRuntime, model_pvc: PersistentVolumeClaim, downloaded_model_data: str, -) -> InferenceService: +) -> Generator[InferenceService, Any, Any]: isvc_kwargs = { "client": admin_client, "name": request.param["name"], @@ -177,7 +125,7 @@ def inference_service( "runtime": serving_runtime.name, "storage_uri": f"pvc://{model_pvc.name}/{downloaded_model_data}", "model_format": serving_runtime.instance.spec.supportedModelFormats[0].name, - "deployment_mode": request.param.get("deployment-mode", "Serverless"), + "deployment_mode": request.param.get("deployment-mode", KServeDeploymentType.SERVERLESS), } if min_replicas := request.param.get("min-replicas"): @@ -227,12 +175,16 @@ def predictor_pods_scope_class( @pytest.fixture() -def first_predictor_pod(predictor_pods_scope_function) -> Pod: +def first_predictor_pod(predictor_pods_scope_function: List[Pod]) -> Pod: return predictor_pods_scope_function[0] @pytest.fixture() -def patched_isvc(request, inference_service: InferenceService, first_predictor_pod: Pod) -> InferenceService: +def patched_isvc( + request: FixtureRequest, + inference_service: InferenceService, + first_predictor_pod: Pod, +) -> Generator[InferenceService, Any, Any]: with ResourceEditor( patches={ inference_service: { @@ -247,6 +199,6 @@ def patched_isvc(request, inference_service: InferenceService, first_predictor_p @pytest.fixture(scope="module") -def skip_if_no_nfs_storage_class(admin_client): +def skip_if_no_nfs_storage_class(admin_client: DynamicClient) -> None: if not StorageClass(client=admin_client, name=NFS_STR).exists: pytest.skip(f"StorageClass {NFS_STR} is missing from the cluster") diff --git a/tests/model_serving/model_server/storage/pvc/test_kserve_pvc_rwx.py b/tests/model_serving/model_server/storage/pvc/test_kserve_pvc_rwx.py index 06540e3..7c364bd 100644 --- a/tests/model_serving/model_server/storage/pvc/test_kserve_pvc_rwx.py +++ b/tests/model_serving/model_server/storage/pvc/test_kserve_pvc_rwx.py @@ -1,5 +1,6 @@ import shlex from typing import List +from utilities.constants import KServeDeploymentType import pytest @@ -24,7 +25,7 @@ {"model-dir": "test-dir"}, {"access-modes": "ReadWriteMany", "storage-class-name": NFS_STR}, KSERVE_OVMS_SERVING_RUNTIME_PARAMS, - INFERENCE_SERVICE_PARAMS | {"deployment-mode": "Serverless", "min-replicas": 2}, + INFERENCE_SERVICE_PARAMS | {"deployment-mode": KServeDeploymentType.SERVERLESS, "min-replicas": 2}, ) ], indirect=True, diff --git a/tests/model_serving/model_server/storage/pvc/utils.py b/tests/model_serving/model_server/storage/pvc/utils.py deleted file mode 100644 index 7feaea0..0000000 --- a/tests/model_serving/model_server/storage/pvc/utils.py +++ /dev/null @@ -1,46 +0,0 @@ -from contextlib import contextmanager - -from kubernetes.dynamic import DynamicClient -from ocp_resources.inference_service import InferenceService - - -@contextmanager -def create_isvc( - client: DynamicClient, - name: str, - namespace: str, - deployment_mode: str, - storage_uri: str, - model_format: str, - runtime: str, - min_replicas: int = 1, - wait: bool = True, -) -> InferenceService: - with InferenceService( - client=client, - name=name, - namespace=namespace, - annotations={ - "serving.knative.openshift.io/enablePassthrough": "true", - "sidecar.istio.io/inject": "true", - "sidecar.istio.io/rewriteAppHTTPProbers": "true", - "serving.kserve.io/deploymentMode": deployment_mode, - }, - predictor={ - "minReplicas": min_replicas, - "model": { - "modelFormat": {"name": model_format}, - "version": "1", - "runtime": runtime, - "storageUri": storage_uri, - }, - }, - ) as inference_service: - if wait: - inference_service.wait_for_condition( - condition=inference_service.Condition.READY, - status=inference_service.Condition.Status.TRUE, - timeout=10 * 60, - ) - - yield inference_service diff --git a/tests/model_serving/model_server/utils.py b/tests/model_serving/model_server/utils.py new file mode 100644 index 0000000..cb17b9b --- /dev/null +++ b/tests/model_serving/model_server/utils.py @@ -0,0 +1,65 @@ +from contextlib import contextmanager +from typing import Optional, Generator, Any, Dict + +from kubernetes.dynamic import DynamicClient +from ocp_resources.inference_service import InferenceService +from tests.model_serving.model_server.private_endpoint.utils import InvalidStorageArgument + + +@contextmanager +def create_isvc( + client: DynamicClient, + name: str, + namespace: str, + deployment_mode: str, + model_format: str, + runtime: str, + storage_uri: Optional[str] = None, + storage_key: Optional[str] = None, + storage_path: Optional[str] = None, + min_replicas: int = 1, + wait: bool = True, +) -> Generator[InferenceService, Any, Any]: + predictor_dict: Dict[str, Any] = { + "minReplicas": min_replicas, + "model": { + "modelFormat": {"name": model_format}, + "version": "1", + "runtime": runtime, + }, + } + _check_storage_arguments(storage_uri, storage_key, storage_path) + if storage_uri: + predictor_dict["model"]["storageUri"] = storage_uri + elif storage_key: + predictor_dict["model"]["storage"] = {"key": storage_key, "path": storage_path} + + with InferenceService( + client=client, + name=name, + namespace=namespace, + annotations={ + "serving.knative.openshift.io/enablePassthrough": "true", + "sidecar.istio.io/inject": "true", + "sidecar.istio.io/rewriteAppHTTPProbers": "true", + "serving.kserve.io/deploymentMode": deployment_mode, + }, + predictor=predictor_dict, + wait_for_resource=wait, + ) as inference_service: + if wait: + inference_service.wait_for_condition( + condition=inference_service.Condition.READY, + status=inference_service.Condition.Status.TRUE, + timeout=10 * 60, + ) + yield inference_service + + +def _check_storage_arguments( + storage_uri: Optional[str], + storage_key: Optional[str], + storage_path: Optional[str], +) -> None: + if (storage_uri and storage_path) or (not storage_uri and not storage_key) or (storage_key and not storage_path): + raise InvalidStorageArgument(storage_uri, storage_key, storage_path) diff --git a/tests/trustyai/conftest.py b/tests/trustyai/conftest.py index 9e69ed1..b781e54 100644 --- a/tests/trustyai/conftest.py +++ b/tests/trustyai/conftest.py @@ -2,6 +2,8 @@ import pytest import yaml +from pytest import FixtureRequest +from typing import Generator, Any from kubernetes.dynamic import DynamicClient from ocp_resources.config_map import ConfigMap from ocp_resources.deployment import Deployment @@ -14,7 +16,7 @@ from tests.trustyai.constants import TRUSTYAI_SERVICE, MODELMESH_SERVING from tests.trustyai.utils import update_configmap_data -from tests.utils import create_ns +from utilities.infra import create_ns MINIO: str = "minio" OPENDATAHUB_IO: str = "opendatahub.io" @@ -44,8 +46,8 @@ def trustyai_service_with_pvc_storage( @pytest.fixture(scope="class") -def ns_with_modelmesh_enabled(request, admin_client: DynamicClient): - with create_ns(client=admin_client, name=request.param["name"], labels={"modelmesh-enabled": "true"}) as ns: +def ns_with_modelmesh_enabled(request: FixtureRequest, admin_client: DynamicClient) -> Generator[Namespace, Any, Any]: + with create_ns(admin_client=admin_client, name=request.param["name"], labels={"modelmesh-enabled": "true"}) as ns: yield ns diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 43729de..0000000 --- a/tests/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -from contextlib import contextmanager -from typing import Any, Dict, Optional - -from kubernetes.dynamic import DynamicClient -from ocp_resources.namespace import Namespace - -TIMEOUT_2MIN = 2 * 60 - - -@contextmanager -def create_ns( - client: DynamicClient, - name: str, - labels: Optional[Dict[str, Any]] = None, - wait_for_resource: bool = True, -) -> Namespace: - with Namespace( - client=client, - name=name, - label=labels, - wait_for_resource=wait_for_resource, - ) as ns: - yield ns diff --git a/utilities/constants.py b/utilities/constants.py index b2662e9..282f327 100644 --- a/utilities/constants.py +++ b/utilities/constants.py @@ -1 +1,22 @@ APPLICATIONS_NAMESPACE: str = "redhat-ods-applications" + + +class KServeDeploymentType: + SERVERLESS: str = "Serverless" + RAW_DEPLOYMENT: str = "RawDeployment" + + +class ModelFormat: + CAIKIT: str = "caikit" + + +class ModelStoragePath: + FLAN_T5_SMALL: str = f"flan-t5-small/flan-t5-small-{ModelFormat.CAIKIT}" + + +class CurlOutput: + HEALTH_OK: str = "OK" + + +class ModelEndpoint: + HEALTH: str = "health" diff --git a/utilities/infra.py b/utilities/infra.py new file mode 100644 index 0000000..8ed0cd4 --- /dev/null +++ b/utilities/infra.py @@ -0,0 +1,24 @@ +from typing import Generator, Dict, Optional +from contextlib import contextmanager + +from kubernetes.dynamic import DynamicClient +from ocp_resources.namespace import Namespace + + +@contextmanager +def create_ns( + name: str, + admin_client: DynamicClient, + teardown: bool = True, + delete_timeout: int = 6 * 10, + labels: Optional[Dict[str, str]] = None, +) -> Generator[Namespace, None, None]: + with Namespace( + client=admin_client, + name=name, + label=labels, + teardown=teardown, + delete_timeout=delete_timeout, + ) as ns: + ns.wait_for_status(status=Namespace.Status.ACTIVE, timeout=2 * 10) + yield ns