import os import time from functools import wraps from typing import NamedTuple from unittest import mock import boto3 import botocore import numpy as np import pytest from click.testing import CliRunner from moto.core import DEFAULT_ACCOUNT_ID from sklearn.linear_model import LogisticRegression import mlflow import mlflow.pyfunc import mlflow.sagemaker as mfs import mlflow.sagemaker.cli as mfscli import mlflow.sklearn from mlflow.exceptions import MlflowException from mlflow.models import Model from mlflow.protos.databricks_pb2 import ( INTERNAL_ERROR, INVALID_PARAMETER_VALUE, RESOURCE_DOES_NOT_EXIST, ErrorCode, ) from mlflow.store.artifact.s3_artifact_repo import S3ArtifactRepository from mlflow.tracking.artifact_utils import _download_artifact_from_uri from tests.helper_functions import set_boto_credentials # noqa: F401 from tests.sagemaker.mock import TransformJob, TransformJobOperation, mock_sagemaker class TrainedModel(NamedTuple): model_path: str run_id: str model_uri: str @pytest.fixture def pretrained_model(): model_path = "model" with mlflow.start_run(): X = np.array([-2, -1, 0, 1, 2, 1]).reshape(-1, 1) y = np.array([0, 0, 1, 1, 1, 0]) lr = LogisticRegression(solver="lbfgs") lr.fit(X, y) mlflow.sklearn.log_model(lr, name=model_path) run_id = mlflow.active_run().info.run_id model_uri = "runs:/" + run_id + "/" + model_path return TrainedModel(model_path, run_id, model_uri) @pytest.fixture def sagemaker_client(): return boto3.client("sagemaker", region_name="us-west-2") def get_sagemaker_backend(region_name): return mock_sagemaker.backends[DEFAULT_ACCOUNT_ID][region_name] def mock_sagemaker_aws_services(fn): from moto import mock_ecr, mock_iam, mock_s3, mock_sts @mock_ecr @mock_iam @mock_s3 @mock_sagemaker @mock_sts @wraps(fn) def mock_wrapper(*args, **kwargs): # Create an ECR repository for the `mlflow-pyfunc` SageMaker docker image ecr_client = boto3.client("ecr", region_name="us-west-2") ecr_client.create_repository(repositoryName=mfs.DEFAULT_IMAGE_NAME) # Create the moto IAM role role_policy = """ { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "*", "Resource": "*" } ] } """ iam_client = boto3.client("iam", region_name="us-west-2") iam_client.create_role(RoleName="moto", AssumeRolePolicyDocument=role_policy) return fn(*args, **kwargs) return mock_wrapper def test_batch_deployment_with_unsupported_flavor_raises_exception(pretrained_model): unsupported_flavor = "this is not a valid flavor" match = "The specified flavor: `this is not a valid flavor` is not supported for deployment" with pytest.raises(MlflowException, match=match) as exc: mfs.deploy_transform_job( job_name="bad_flavor", model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", flavor=unsupported_flavor, ) assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) def test_batch_deployment_of_model_with_no_supported_flavors_raises_exception(pretrained_model): logged_model_path = _download_artifact_from_uri(pretrained_model.model_uri) model_config_path = os.path.join(logged_model_path, "MLmodel") model_config = Model.load(model_config_path) del model_config.flavors[mlflow.pyfunc.FLAVOR_NAME] model_config.save(path=model_config_path) match = "The specified model does not contain any of the supported flavors for deployment" with pytest.raises(MlflowException, match=match) as exc: mfs.deploy_transform_job( job_name="missing-flavor", model_uri=logged_model_path, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", flavor=None, ) assert exc.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST) def test_deploy_sagemaker_transform_job_in_asynchronous_mode_without_archiving_throws_exception( pretrained_model, ): with pytest.raises(MlflowException, match="Resources must be archived") as exc: mfs.deploy_transform_job( job_name="test-job", model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", archive=False, synchronous=False, ) assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @mock_sagemaker_aws_services def test_deploy_creates_sagemaker_transform_job_and_s3_resources_with_expected_names_from_local( pretrained_model, sagemaker_client ): job_name = "test-job" mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", archive=True, ) region_name = sagemaker_client.meta.region_name s3_client = boto3.client("s3", region_name=region_name) default_bucket = mfs._get_default_s3_bucket(region_name) transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) model_name = transform_job_description["ModelName"] assert model_name in [model["ModelName"] for model in sagemaker_client.list_models()["Models"]] object_names = [ entry["Key"] for entry in s3_client.list_objects(Bucket=default_bucket)["Contents"] ] assert any(model_name in object_name for object_name in object_names) assert job_name in [ transform_job["TransformJobName"] for transform_job in sagemaker_client.list_transform_jobs()["TransformJobSummaries"] ] @mock_sagemaker_aws_services def test_deploy_cli_creates_sagemaker_transform_job_and_s3_resources_with_expected_names_from_local( pretrained_model, sagemaker_client ): job_name = "test-job" result = CliRunner(env={"LC_ALL": "en_US.UTF-8", "LANG": "en_US.UTF-8"}).invoke( mfscli.commands, [ "deploy-transform-job", "--job-name", job_name, "--model-uri", pretrained_model.model_uri, "--input-data-type", "Some Data Type", "--input-uri", "Some Input Uri", "--content-type", "Some Content Type", "--output-path", "Some Output Path", "--archive", ], ) assert result.exit_code == 0 region_name = sagemaker_client.meta.region_name s3_client = boto3.client("s3", region_name=region_name) default_bucket = mfs._get_default_s3_bucket(region_name) transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) model_name = transform_job_description["ModelName"] assert model_name in [model["ModelName"] for model in sagemaker_client.list_models()["Models"]] object_names = [ entry["Key"] for entry in s3_client.list_objects(Bucket=default_bucket)["Contents"] ] assert any(model_name in object_name for object_name in object_names) assert job_name in [ transform_job["TransformJobName"] for transform_job in sagemaker_client.list_transform_jobs()["TransformJobSummaries"] ] @mock_sagemaker_aws_services def test_deploy_creates_sagemaker_transform_job_and_s3_resources_with_expected_names_from_s3( pretrained_model, sagemaker_client ): local_model_path = _download_artifact_from_uri(pretrained_model.model_uri) artifact_path = "model" region_name = sagemaker_client.meta.region_name default_bucket = mfs._get_default_s3_bucket(region_name) s3_artifact_repo = S3ArtifactRepository(f"s3://{default_bucket}") s3_artifact_repo.log_artifacts(local_model_path, artifact_path=artifact_path) model_s3_uri = f"s3://{default_bucket}/{pretrained_model.model_path}" job_name = "test-job" mfs.deploy_transform_job( job_name=job_name, model_uri=model_s3_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", archive=True, ) transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) model_name = transform_job_description["ModelName"] assert model_name in [model["ModelName"] for model in sagemaker_client.list_models()["Models"]] s3_client = boto3.client("s3", region_name=region_name) object_names = [ entry["Key"] for entry in s3_client.list_objects(Bucket=default_bucket)["Contents"] ] assert any(model_name in object_name for object_name in object_names) assert job_name in [ transform_job["TransformJobName"] for transform_job in sagemaker_client.list_transform_jobs()["TransformJobSummaries"] ] @mock_sagemaker_aws_services def test_deploy_cli_creates_sagemaker_transform_job_and_s3_resources_with_expected_names_from_s3( pretrained_model, sagemaker_client ): local_model_path = _download_artifact_from_uri(pretrained_model.model_uri) artifact_path = "model" region_name = sagemaker_client.meta.region_name default_bucket = mfs._get_default_s3_bucket(region_name) s3_artifact_repo = S3ArtifactRepository(f"s3://{default_bucket}") s3_artifact_repo.log_artifacts(local_model_path, artifact_path=artifact_path) model_s3_uri = f"s3://{default_bucket}/{pretrained_model.model_path}" job_name = "test-job" result = CliRunner(env={"LC_ALL": "en_US.UTF-8", "LANG": "en_US.UTF-8"}).invoke( mfscli.commands, [ "deploy-transform-job", "--job-name", job_name, "--model-uri", model_s3_uri, "--input-data-type", "Some Data Type", "--input-uri", "Some Input Uri", "--content-type", "Some Content Type", "--output-path", "Some Output Path", "--archive", ], ) assert result.exit_code == 0 region_name = sagemaker_client.meta.region_name s3_client = boto3.client("s3", region_name=region_name) default_bucket = mfs._get_default_s3_bucket(region_name) transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) model_name = transform_job_description["ModelName"] assert model_name in [model["ModelName"] for model in sagemaker_client.list_models()["Models"]] object_names = [ entry["Key"] for entry in s3_client.list_objects(Bucket=default_bucket)["Contents"] ] assert any(model_name in object_name for object_name in object_names) assert job_name in [ transform_job["TransformJobName"] for transform_job in sagemaker_client.list_transform_jobs()["TransformJobSummaries"] ] @mock_sagemaker_aws_services def test_deploying_sagemaker_transform_job_with_preexisting_name_in_create_mode_throws_exception( pretrained_model, ): job_name = "test-job" mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", ) with pytest.raises( MlflowException, match="a batch transform job with the same name already exists" ) as exc: mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", ) assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @mock_sagemaker_aws_services def test_deploy_in_synchronous_mode_waits_for_transform_job_creation_to_complete_before_returning( pretrained_model, sagemaker_client ): transform_job_creation_latency = 10 get_sagemaker_backend(sagemaker_client.meta.region_name).set_transform_job_update_latency( transform_job_creation_latency ) job_name = "test-job" deployment_start_time = time.time() mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", synchronous=True, ) deployment_end_time = time.time() assert (deployment_end_time - deployment_start_time) >= transform_job_creation_latency transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) assert transform_job_description["TransformJobStatus"] == TransformJob.STATUS_COMPLETED @mock_sagemaker_aws_services def test_deploy_create_in_asynchronous_mode_returns_before_transform_job_creation_completes( pretrained_model, sagemaker_client ): transform_job_creation_latency = 10 get_sagemaker_backend(sagemaker_client.meta.region_name).set_transform_job_update_latency( transform_job_creation_latency ) job_name = "test-job" deployment_start_time = time.time() mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", archive=True, synchronous=False, ) deployment_end_time = time.time() assert (deployment_end_time - deployment_start_time) < transform_job_creation_latency transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) assert transform_job_description["TransformJobStatus"] == TransformJob.STATUS_IN_PROGRESS @mock_sagemaker_aws_services def test_deploy_in_throw_exception_after_transform_job_creation_fails( pretrained_model, sagemaker_client ): transform_job_creation_latency = 10 sagemaker_backend = get_sagemaker_backend(sagemaker_client.meta.region_name) sagemaker_backend.set_transform_job_update_latency(transform_job_creation_latency) boto_caller = botocore.client.BaseClient._make_api_call def fail_transform_job_creations(self, operation_name, operation_kwargs): """ Processes all boto3 client operations according to the following rules: - If the operation is a transform job creation, create the transform job and set its status to ``TransformJob.STATUS_FAILED``. - Else, execute the client operation as normal """ result = boto_caller(self, operation_name, operation_kwargs) if operation_name == "CreateTransformJob": transform_job_name = operation_kwargs["TransformJobName"] sagemaker_backend.set_transform_job_latest_operation( transform_job_name=transform_job_name, operation=TransformJobOperation.create_unsuccessful( latency_seconds=transform_job_creation_latency ), ) return result with ( mock.patch("botocore.client.BaseClient._make_api_call", new=fail_transform_job_creations), pytest.raises(MlflowException, match="batch transform job failed") as exc, ): mfs.deploy_transform_job( job_name="test-job", model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", ) assert exc.value.error_code == ErrorCode.Name(INTERNAL_ERROR) @mock_sagemaker_aws_services def test_attempting_to_terminate_in_asynchronous_mode_without_archiving_throws_exception( pretrained_model, ): job_name = "test-job" mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", ) with pytest.raises(MlflowException, match="Resources must be archived") as exc: mfs.terminate_transform_job( job_name=job_name, archive=False, synchronous=False, ) assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @mock_sagemaker_aws_services def test_terminate_in_sync_mode_waits_for_transform_job_termination_to_complete_before_returning( pretrained_model, sagemaker_client ): transform_job_termination_latency = 10 get_sagemaker_backend(sagemaker_client.meta.region_name).set_transform_job_update_latency( transform_job_termination_latency ) job_name = "test-job" termination_start_time = time.time() mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", archive=True, synchronous=True, ) mfs.terminate_transform_job(job_name=job_name, synchronous=True) termination_end_time = time.time() assert (termination_end_time - termination_start_time) >= transform_job_termination_latency transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) assert transform_job_description["TransformJobStatus"] == TransformJob.STATUS_STOPPED @mock_sagemaker_aws_services def test_terminate_in_asynchronous_mode_returns_before_transform_job_termination_completes( pretrained_model, sagemaker_client ): transform_job_termination_latency = 10 get_sagemaker_backend(sagemaker_client.meta.region_name).set_transform_job_update_latency( transform_job_termination_latency ) job_name = "test-job" termination_start_time = time.time() mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, s3_input_data_type="Some Data Type", s3_input_uri="Some Input Uri", content_type="Some Content Type", s3_output_path="Some Output Path", archive=True, synchronous=False, ) mfs.terminate_transform_job(job_name=job_name, archive=True, synchronous=False) termination_end_time = time.time() assert (termination_end_time - termination_start_time) < transform_job_termination_latency transform_job_description = sagemaker_client.describe_transform_job(TransformJobName=job_name) assert transform_job_description["TransformJobStatus"] == TransformJob.STATUS_STOPPING