diff --git a/ads/model/datascience_model.py b/ads/model/datascience_model.py index a71a7c631..79d36ce6f 100644 --- a/ads/model/datascience_model.py +++ b/ads/model/datascience_model.py @@ -48,6 +48,9 @@ ModelProvenanceNotFoundError, OCIDataScienceModel, ) +from ads.model.service.oci_datascience_model_artifact_signature import ( + DataScienceModelArtifactSignature, +) logger = logging.getLogger(__name__) @@ -1390,6 +1393,105 @@ def upload_artifact( self._remove_file_description_artifact() + def create_model_artifact_signature( + self, + kms_key_id: str, + kms_key_version_id: str, + signing_algorithm: str, + compartment_id: Optional[str] = None, + display_name: Optional[str] = None, + freeform_tags: Optional[Dict[str, str]] = None, + defined_tags: Optional[Dict[str, Dict[str, object]]] = None, + **kwargs: Dict, + ): + """Creates a model artifact signature for this model.""" + return DataScienceModelArtifactSignature.from_model(self.dsc_model).create( + kms_key_id=kms_key_id, + kms_key_version_id=kms_key_version_id, + signing_algorithm=signing_algorithm, + compartment_id=compartment_id, + display_name=display_name, + freeform_tags=freeform_tags, + defined_tags=defined_tags, + **kwargs, + ) + + def list_model_artifact_signatures( + self, + compartment_id: Optional[str] = None, + **kwargs: Dict, + ) -> List: + """Lists model artifact signatures for this model.""" + return DataScienceModelArtifactSignature.from_model(self.dsc_model).list( + compartment_id=compartment_id, + **kwargs, + ) + + def get_model_artifact_signature( + self, + artifact_signature_id: str, + **kwargs: Dict, + ): + """Gets a model artifact signature for this model.""" + return DataScienceModelArtifactSignature.from_model(self.dsc_model).get( + artifact_signature_id=artifact_signature_id, + **kwargs, + ) + + def update_model_artifact_signature( + self, + artifact_signature_id: str, + display_name: Optional[str] = None, + freeform_tags: Optional[Dict[str, str]] = None, + defined_tags: Optional[Dict[str, Dict[str, object]]] = None, + **kwargs: Dict, + ): + """Updates a model artifact signature for this model.""" + return DataScienceModelArtifactSignature.from_model(self.dsc_model).update( + artifact_signature_id=artifact_signature_id, + display_name=display_name, + freeform_tags=freeform_tags, + defined_tags=defined_tags, + **kwargs, + ) + + def delete_model_artifact_signature( + self, + artifact_signature_id: str, + **kwargs: Dict, + ) -> None: + """Deletes a model artifact signature for this model.""" + DataScienceModelArtifactSignature.from_model(self.dsc_model).delete( + artifact_signature_id=artifact_signature_id, + **kwargs, + ) + + def change_model_artifact_signature_compartment( + self, + artifact_signature_id: str, + compartment_id: str, + **kwargs: Dict, + ) -> None: + """Moves a model artifact signature to another compartment.""" + DataScienceModelArtifactSignature.from_model( + self.dsc_model + ).change_compartment( + artifact_signature_id=artifact_signature_id, + compartment_id=compartment_id, + **kwargs, + ) + + def verify_model_artifact_signature( + self, + artifact_signature_id: str, + **kwargs: Dict, + ): + """Verifies a model artifact signature for this model.""" + return DataScienceModelArtifactSignature.from_model(self.dsc_model).verify( + artifact_signature_id=artifact_signature_id, + **kwargs, + ) + def _remove_file_description_artifact(self): """Removes temporary model file description artifact for model by reference.""" # delete if local copy directory was created diff --git a/ads/model/deployment/model_deployment.py b/ads/model/deployment/model_deployment.py index 0e879a746..f053cafa7 100644 --- a/ads/model/deployment/model_deployment.py +++ b/ads/model/deployment/model_deployment.py @@ -1817,6 +1817,35 @@ def _drop_none_values(d: Dict) -> Dict: infrastructure.CONST_SCALING_POLICY: scaling_policy, } + if runtime.model_artifact_signature_id: + if runtime.model_group_id: + raise ValueError( + "`model_artifact_signature_id` is supported only for single-model deployments." + ) + model_configuration_details_cls = getattr( + oci.data_science.models, "ModelConfigurationDetails", None + ) + update_model_configuration_details_cls = getattr( + oci.data_science.models, "UpdateModelConfigurationDetails", None + ) + if not ( + model_configuration_details_cls + and update_model_configuration_details_cls + and hasattr( + model_configuration_details_cls(), "model_artifact_signature_id" + ) + and hasattr( + update_model_configuration_details_cls(), + "model_artifact_signature_id", + ) + ): + raise OSError( + "Model artifact signature is not supported in the installed OCI SDK." + ) + model_configuration_details[ + runtime.CONST_MODEL_ARTIFACT_SIGNATURE_ID + ] = runtime.model_artifact_signature_id + if runtime.env: if not hasattr( oci.data_science.models, diff --git a/ads/model/deployment/model_deployment_properties.py b/ads/model/deployment/model_deployment_properties.py index a09c59feb..b942ade75 100644 --- a/ads/model/deployment/model_deployment_properties.py +++ b/ads/model/deployment/model_deployment_properties.py @@ -38,6 +38,8 @@ class ModelDeploymentProperties( The model artifact OCID in model catalog. model_uri : str uri to model files, can be local or in cloud storage. + model_artifact_signature_id : str + The OCID of the model artifact signature to use for model deployment. Methods ------- @@ -88,12 +90,14 @@ class ModelDeploymentProperties( "predict_log_id", "memory_in_gbs", "ocpus", + "model_artifact_signature_id", ] def __init__( self, model_id: Optional[str] = None, model_uri: Optional[str] = None, + model_artifact_signature_id: Optional[str] = None, oci_model_deployment: Union[ data_science_models.ModelDeployment, data_science_models.CreateModelDeploymentDetails, @@ -112,6 +116,8 @@ def __init__( explicitly or as an attribute of the OCI object. model_uri: (str, optiona). Defaults to None. Uri to model files, can be local or in cloud storage. + model_artifact_signature_id: (str, optional). Defaults to None. + Model artifact signature OCID. oci_model_deployment: (Union[ModelDeployment, CreateModelDeploymentDetails, UpdateModelDeploymentDetails, Dict], optional). Defaults to None. An OCI model or Dict containing model deployment details. The OCI model can be an instance of either `ModelDeployment`, @@ -148,7 +154,8 @@ def __init__( - `predict_log_group_id`, - `predict_log_id`, - `memory_in_gbs`, - - `ocpus`. + - `ocpus`, + - `model_artifact_signature_id`. These additional arguments will be saved into appropriate properties in the OCI model. @@ -166,6 +173,7 @@ def __init__( self.model_id = model_id self.model_uri = model_uri + self.model_artifact_signature_id = model_artifact_signature_id oci_kwargs = {} self.oci_model_deployment = oci_model_deployment @@ -208,6 +216,18 @@ def __init__( if self.model_id: self._config_model_id() + # Get model artifact signature ID from OCI object + if self.model_artifact_signature_id is None: + try: + self.model_artifact_signature_id = ( + self.model_deployment_configuration_details.model_configuration_details.model_artifact_signature_id + ) + except AttributeError: + self.model_artifact_signature_id = None + + if self.model_artifact_signature_id: + self._config_model_artifact_signature_id() + # Process additional kwargs # Convert all keys to lower case kwargs = {str(k).lower(): v for k, v in kwargs.items()} @@ -229,6 +249,7 @@ def __init__( "bandwidth_mbps", "memory_in_gbs", "ocpus", + "model_artifact_signature_id", # Autoscaling-related keys. "scaling_type", "cpu_utilization", @@ -276,7 +297,8 @@ def with_instance_configuration(self, config): - instance_count: int, - bandwidth_mbps: int, - memory_in_gbs: float, - - ocpus: float + - ocpus: float, + - model_artifact_signature_id: str In addition, this method supports autoscaling for SINGLE_MODEL deployments. To enable autoscaling, set `scaling_type` to one of: @@ -314,6 +336,16 @@ def with_instance_configuration(self, config): data_science_models.ModelConfigurationDetails() ) model_configuration_details_object.model_id = self.model_id + model_artifact_signature_id = ( + config.get("model_artifact_signature_id") + or self.model_artifact_signature_id + ) + if model_artifact_signature_id: + self._validate_model_artifact_signature_sdk_support() + self.model_artifact_signature_id = model_artifact_signature_id + model_configuration_details_object.model_artifact_signature_id = ( + model_artifact_signature_id + ) # instance_configuration is required even though it can be initialized with empty values instance_configuration_object = data_science_models.InstanceConfiguration() @@ -566,6 +598,46 @@ def _config_model_id(self): else: self.with_instance_configuration({}) + def _validate_model_artifact_signature_sdk_support(self): + """Validates the installed OCI SDK supports model artifact signature fields.""" + model_configuration_details_cls = getattr( + data_science_models, "ModelConfigurationDetails", None + ) + update_model_configuration_details_cls = getattr( + data_science_models, "UpdateModelConfigurationDetails", None + ) + if not ( + model_configuration_details_cls + and update_model_configuration_details_cls + and hasattr( + model_configuration_details_cls(), "model_artifact_signature_id" + ) + and hasattr( + update_model_configuration_details_cls(), + "model_artifact_signature_id", + ) + ): + raise EnvironmentError( + "Model artifact signature is not supported in the installed OCI SDK." + ) + + def _config_model_artifact_signature_id(self): + """Sets the model artifact signature ID in model configuration details.""" + self._validate_model_artifact_signature_sdk_support() + + if ( + self.model_deployment_configuration_details + and self.model_deployment_configuration_details.model_configuration_details + ): + model_configuration_details = ( + self.model_deployment_configuration_details.model_configuration_details + ) + model_configuration_details.model_artifact_signature_id = ( + self.model_artifact_signature_id + ) + else: + self.with_instance_configuration({}) + def to_oci_model(self, oci_model): """Convert properties into an OCI data model @@ -577,6 +649,8 @@ def to_oci_model(self, oci_model): """ if self.model_id: self._config_model_id() + if self.model_artifact_signature_id: + self._config_model_artifact_signature_id() return super().to_oci_model(oci_model) diff --git a/ads/model/deployment/model_deployment_runtime.py b/ads/model/deployment/model_deployment_runtime.py index adfa48d1d..f9c5e0f5e 100644 --- a/ads/model/deployment/model_deployment_runtime.py +++ b/ads/model/deployment/model_deployment_runtime.py @@ -4,7 +4,7 @@ # Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/from typing import Dict -from typing import Dict, List +from typing import Dict, List, Optional from ads.jobs.builders.base import Builder @@ -41,6 +41,8 @@ class ModelDeploymentRuntime(Builder): The output stream ids of model deployment. model_uri: str The model uri of model deployment. + model_artifact_signature_id: str + The OCID of the model artifact signature to use for model deployment. model_group_id: str The model group id of model deployment. bucket_uri: str @@ -68,6 +70,8 @@ class ModelDeploymentRuntime(Builder): Sets the output stream ids of model deployment with_model_uri(model_uri) Sets the model uri of model deployment + with_model_artifact_signature_id(model_artifact_signature_id) + Sets the model artifact signature OCID of model deployment with_model_group_id(model_group_id) Sets the model group id of model deployment with_bucket_uri(bucket_uri) @@ -86,6 +90,7 @@ class ModelDeploymentRuntime(Builder): CONST_MODEL_ID = "modelId" CONST_MODEL_URI = "modelUri" + CONST_MODEL_ARTIFACT_SIGNATURE_ID = "modelArtifactSignatureId" CONST_MODEL_GROUP_ID = "modelGroupId" CONST_ENV = "env" CONST_ENVIRONMENT_VARIABLES = "environmentVariables" @@ -108,6 +113,7 @@ class ModelDeploymentRuntime(Builder): CONST_OUTPUT_STREAM_IDS: "output_stream_ids", CONST_DEPLOYMENT_MODE: "deployment_mode", CONST_MODEL_URI: "model_uri", + CONST_MODEL_ARTIFACT_SIGNATURE_ID: "model_artifact_signature_id", CONST_MODEL_GROUP_ID: "model_group_id", CONST_BUCKET_URI: "bucket_uri", CONST_AUTH: "auth", @@ -136,6 +142,9 @@ class ModelDeploymentRuntime(Builder): CONST_OUTPUT_STREAM_IDS: f"{STREAM_CONFIG_DETAILS_PATH}.output_stream_ids", CONST_DEPLOYMENT_MODE: "deployment_mode", CONST_MODEL_URI: f"{MODEL_CONFIG_DETAILS_PATH}.model_id", + CONST_MODEL_ARTIFACT_SIGNATURE_ID: ( + f"{MODEL_CONFIG_DETAILS_PATH}.model_artifact_signature_id" + ), CONST_MODEL_GROUP_ID: f"{MODEL_GROUP_CONFIG_DETAILS_PATH}.model_group_id", } @@ -288,6 +297,36 @@ def with_model_uri(self, model_uri: str) -> "ModelDeploymentRuntime": """ return self.set_spec(self.CONST_MODEL_URI, model_uri) + @property + def model_artifact_signature_id(self) -> Optional[str]: + """The model artifact signature OCID of model deployment. + + Returns + ------- + Optional[str] + The model artifact signature OCID of model deployment. + """ + return self.get_spec(self.CONST_MODEL_ARTIFACT_SIGNATURE_ID, None) + + def with_model_artifact_signature_id( + self, model_artifact_signature_id: str + ) -> "ModelDeploymentRuntime": + """Sets the model artifact signature OCID of model deployment. + + Parameters + ---------- + model_artifact_signature_id: str + The model artifact signature OCID of model deployment. + + Returns + ------- + ModelDeploymentRuntime + The ModelDeploymentRuntime instance (self). + """ + return self.set_spec( + self.CONST_MODEL_ARTIFACT_SIGNATURE_ID, model_artifact_signature_id + ) + @property def model_group_id(self) -> str: """The model group id of model deployment. diff --git a/ads/model/generic_model.py b/ads/model/generic_model.py index 2802def5f..5a0a87514 100644 --- a/ads/model/generic_model.py +++ b/ads/model/generic_model.py @@ -2300,6 +2300,7 @@ def deploy( deployment_memory_in_gbs: Optional[float] = None, deployment_ocpus: Optional[float] = None, deployment_image: Optional[str] = None, + deployment_model_artifact_signature_id: Optional[str] = None, **kwargs: Dict, ) -> "ModelDeployment": """ @@ -2360,6 +2361,8 @@ def deploy( The predict log OCID for the predict logs. https://docs.oracle.com/en-us/iaas/data-science/using/model_dep_using_logging.htm deployment_image: (str, optional). Defaults to None. The OCIR path of docker container image. Required for deploying model on container runtime. + deployment_model_artifact_signature_id: (str, optional). Defaults to None. + The OCID of the model artifact signature to use for model deployment. kwargs: project_id: (str, optional). Project OCID. If not specified, the value will be taken from the environment variables. @@ -2462,12 +2465,23 @@ def deploy( or self.properties.deployment_predict_log_id, deployment_image=getattr(existing_runtime, "image", None) or self.properties.deployment_image, + deployment_model_artifact_signature_id=getattr( + existing_runtime, "model_artifact_signature_id", None + ) + or self.properties.deployment_model_artifact_signature_id, deployment_instance_subnet_id=existing_infrastructure.subnet_id or self.properties.deployment_instance_subnet_id, deployment_instance_private_endpoint_id=existing_infrastructure.private_endpoint_id or self.properties.deployment_instance_private_endpoint_id, ).to_dict() + if ( + "deployment_model_artifact_signature_id" not in override_properties + and "model_artifact_signature_id" in override_properties + ): + property_dict["deployment_model_artifact_signature_id"] = ( + override_properties["model_artifact_signature_id"] + ) property_dict.update(override_properties) self.properties.with_dict(property_dict) @@ -2575,6 +2589,11 @@ def deploy( .with_model_uri(self.model_id) ) + if self.properties.deployment_model_artifact_signature_id: + runtime.with_model_artifact_signature_id( + self.properties.deployment_model_artifact_signature_id + ) + if deployment_mode == ModelDeploymentMode.STREAM: input_stream_ids = ( kwargs.pop("input_stream_ids", []) or existing_runtime.input_stream_ids @@ -2657,6 +2676,7 @@ def prepare_save_deploy( deployment_memory_in_gbs: Optional[float] = None, deployment_ocpus: Optional[float] = None, deployment_image: Optional[str] = None, + deployment_model_artifact_signature_id: Optional[str] = None, bucket_uri: Optional[str] = None, overwrite_existing_artifact: Optional[bool] = True, remove_existing_artifact: Optional[bool] = True, @@ -2757,6 +2777,8 @@ def prepare_save_deploy( Specifies the ocpus count of the model deployment instance. deployment_image: (str, optional). Defaults to None. The OCIR path of docker container image. Required for deploying model on container runtime. + deployment_model_artifact_signature_id: (str, optional). Defaults to None. + The OCID of the model artifact signature to use for model deployment. bucket_uri: (str, optional). Defaults to None. The OCI Object Storage URI where model artifacts will be copied to. The `bucket_uri` is only necessary for downloading large artifacts with @@ -2895,6 +2917,9 @@ def prepare_save_deploy( deployment_memory_in_gbs=self.properties.deployment_memory_in_gbs, deployment_ocpus=self.properties.deployment_ocpus, deployment_image=deployment_image, + deployment_model_artifact_signature_id=( + self.properties.deployment_model_artifact_signature_id + ), kwargs=kwargs, ) return self.model_deployment diff --git a/ads/model/model_properties.py b/ads/model/model_properties.py index 4be249e93..5afd5f123 100644 --- a/ads/model/model_properties.py +++ b/ads/model/model_properties.py @@ -38,6 +38,7 @@ class ModelProperties(BaseProperties): deployment_memory_in_gbs: Union[float, int] = None deployment_ocpus: Union[float, int] = None deployment_image: str = None + deployment_model_artifact_signature_id: str = None def _adjust_with_env(self) -> None: """Adjusts env variables. This method is used within `with_env` method.""" diff --git a/ads/model/service/oci_datascience_model_artifact_signature.py b/ads/model/service/oci_datascience_model_artifact_signature.py new file mode 100644 index 000000000..18bcedbd7 --- /dev/null +++ b/ads/model/service/oci_datascience_model_artifact_signature.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + +from functools import wraps +from typing import Callable, Dict, List, Optional + +import oci +import oci.data_science +import oci.pagination + +from ads.common.oci_datascience import OCIDataScienceMixin +from ads.model.service.oci_datascience_model import ModelNotSavedError + +MODEL_ARTIFACT_SIGNATURE_NEEDS_MODEL = ( + "Model needs to be saved to the Model Catalog before it can be accessed." +) +_MODEL_ARTIFACT_SIGNATURE_BASE = getattr( + oci.data_science.models, "ModelArtifactSignature", object +) + + +def validate_model_artifact_signature_sdk_support(): + """Validates the installed OCI SDK supports model artifact signature APIs.""" + required_model_classes = ( + "ChangeModelArtifactSignatureCompartmentDetails", + "CreateModelArtifactSignatureDetails", + "UpdateModelArtifactSignatureDetails", + ) + required_client_methods = ( + "change_model_artifact_signature_compartment", + "create_model_artifact_signature", + "delete_model_artifact_signature", + "get_model_artifact_signature", + "list_model_artifact_signatures", + "update_model_artifact_signature", + "verify_model_artifact_signature", + ) + if not ( + all(hasattr(oci.data_science.models, name) for name in required_model_classes) + and all( + hasattr(oci.data_science.DataScienceClient, name) + for name in required_client_methods + ) + ): + raise OSError( + "Model artifact signature is not supported in the installed OCI SDK." + ) + + +def check_for_model_id(msg: str = MODEL_ARTIFACT_SIGNATURE_NEEDS_MODEL): + """Checks that the related model is saved before managing signatures.""" + + def decorator(func: Callable): + @wraps(func) + def wrapper(self, *args, **kwargs): + if not self.model_id: + raise ModelNotSavedError(msg) + return func(self, *args, **kwargs) + + return wrapper + + return decorator + + +class DataScienceModelArtifactSignature( + OCIDataScienceMixin, _MODEL_ARTIFACT_SIGNATURE_BASE +): + """Client operations for OCI Data Science model artifact signatures.""" + + def __init__( + self, + model_id: str, + compartment_id: Optional[str] = None, + config: dict = None, + signer: oci.signer.Signer = None, + client_kwargs: dict = None, + ) -> None: + super().__init__(config=config, signer=signer, client_kwargs=client_kwargs) + self.model_id = model_id + self.compartment_id = compartment_id + + @classmethod + def from_model( + cls, model: oci.data_science.models.Model + ) -> "DataScienceModelArtifactSignature": + """Builds a model artifact signature helper from an OCI Data Science model.""" + return cls( + model_id=model.id, + compartment_id=model.compartment_id, + config=getattr(model, "config", None), + signer=getattr(model, "signer", None), + client_kwargs=getattr(model, "kwargs", None), + ) + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before an artifact signature can be created." + ) + def create( + self, + kms_key_id: str, + kms_key_version_id: str, + signing_algorithm: str, + compartment_id: Optional[str] = None, + display_name: Optional[str] = None, + freeform_tags: Optional[Dict[str, str]] = None, + defined_tags: Optional[Dict[str, Dict[str, object]]] = None, + **kwargs: Dict, + ): + """Creates a model artifact signature.""" + validate_model_artifact_signature_sdk_support() + details = oci.data_science.models.CreateModelArtifactSignatureDetails( + compartment_id=compartment_id or self.compartment_id, + display_name=display_name, + kms_key_id=kms_key_id, + kms_key_version_id=kms_key_version_id, + signing_algorithm=signing_algorithm, + freeform_tags=freeform_tags, + defined_tags=defined_tags, + ) + return self.client.create_model_artifact_signature( + create_model_artifact_signature_details=details, + model_id=self.model_id, + **kwargs, + ).data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before artifact signatures can be listed." + ) + def list( + self, + compartment_id: Optional[str] = None, + **kwargs: Dict, + ) -> List: + """Lists model artifact signatures.""" + validate_model_artifact_signature_sdk_support() + return oci.pagination.list_call_get_all_results( + self.client.list_model_artifact_signatures, + self.model_id, + compartment_id or self.compartment_id, + **kwargs, + ).data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before an artifact signature can be read." + ) + def get(self, artifact_signature_id: str, **kwargs: Dict): + """Gets a model artifact signature.""" + validate_model_artifact_signature_sdk_support() + return self.client.get_model_artifact_signature( + model_id=self.model_id, + artifact_signature_id=artifact_signature_id, + **kwargs, + ).data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before an artifact signature can be updated." + ) + def update( + self, + artifact_signature_id: str, + display_name: Optional[str] = None, + freeform_tags: Optional[Dict[str, str]] = None, + defined_tags: Optional[Dict[str, Dict[str, object]]] = None, + **kwargs: Dict, + ): + """Updates a model artifact signature.""" + validate_model_artifact_signature_sdk_support() + details = oci.data_science.models.UpdateModelArtifactSignatureDetails( + display_name=display_name, + freeform_tags=freeform_tags, + defined_tags=defined_tags, + ) + return self.client.update_model_artifact_signature( + update_model_artifact_signature_details=details, + model_id=self.model_id, + artifact_signature_id=artifact_signature_id, + **kwargs, + ).data + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before an artifact signature can be deleted." + ) + def delete(self, artifact_signature_id: str, **kwargs: Dict) -> None: + """Deletes a model artifact signature.""" + validate_model_artifact_signature_sdk_support() + self.client.delete_model_artifact_signature( + model_id=self.model_id, + artifact_signature_id=artifact_signature_id, + **kwargs, + ) + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before an artifact signature can be moved." + ) + def change_compartment( + self, + artifact_signature_id: str, + compartment_id: str, + **kwargs: Dict, + ) -> None: + """Moves a model artifact signature to another compartment.""" + validate_model_artifact_signature_sdk_support() + details = oci.data_science.models.ChangeModelArtifactSignatureCompartmentDetails( + compartment_id=compartment_id, + ) + self.client.change_model_artifact_signature_compartment( + change_model_artifact_signature_compartment_details=details, + model_id=self.model_id, + artifact_signature_id=artifact_signature_id, + **kwargs, + ) + + @check_for_model_id( + msg="Model needs to be saved to the Model Catalog before an artifact signature can be verified." + ) + def verify(self, artifact_signature_id: str, **kwargs: Dict): + """Verifies a model artifact signature.""" + validate_model_artifact_signature_sdk_support() + return self.client.verify_model_artifact_signature( + model_id=self.model_id, + artifact_signature_id=artifact_signature_id, + **kwargs, + ).data diff --git a/tests/unitary/default_setup/model/test_oci_datascience_model.py b/tests/unitary/default_setup/model/test_oci_datascience_model.py index c95464da3..c462c3c7b 100644 --- a/tests/unitary/default_setup/model/test_oci_datascience_model.py +++ b/tests/unitary/default_setup/model/test_oci_datascience_model.py @@ -33,7 +33,6 @@ MODEL_OCID = "ocid1.datasciencemodel.oc1.iad." - OCI_MODEL_PAYLOAD = { "id": MODEL_OCID, "compartment_id": "ocid1.compartment.oc1..", diff --git a/tests/unitary/default_setup/model/test_oci_datascience_model_artifact_signature.py b/tests/unitary/default_setup/model/test_oci_datascience_model_artifact_signature.py new file mode 100644 index 000000000..960237f97 --- /dev/null +++ b/tests/unitary/default_setup/model/test_oci_datascience_model_artifact_signature.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python + +# Copyright (c) 2026 Oracle and/or its affiliates. +# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + +from unittest.mock import MagicMock, patch + +import oci +import oci.data_science.models as data_science_models +import oci.pagination +import pytest +from oci.response import Response + +from ads.model.datascience_model import DataScienceModel +from ads.model.service.oci_datascience_model import ModelNotSavedError +from ads.model.service.oci_datascience_model_artifact_signature import ( + DataScienceModelArtifactSignature, +) + +MODEL_OCID = "ocid1.datasciencemodel.oc1.iad." +COMPARTMENT_OCID = "ocid1.compartment.oc1.." +MODEL_ARTIFACT_SIGNATURE_OCID = ( + "ocid1.datasciencemodelartifactsignature.oc1.iad." +) +KMS_KEY_ID = "ocid1.key.oc1.iad." +KMS_KEY_VERSION_ID = "ocid1.keyversion.oc1.iad." +SIGNING_ALGORITHM = "SHA_256_RSA_PKCS_PSS" +SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE = all( + hasattr(data_science_models, name) + for name in ( + "ChangeModelArtifactSignatureCompartmentDetails", + "CreateModelArtifactSignatureDetails", + "UpdateModelArtifactSignatureDetails", + ) +) and all( + hasattr(oci.data_science.DataScienceClient, name) + for name in ( + "change_model_artifact_signature_compartment", + "create_model_artifact_signature", + "delete_model_artifact_signature", + "get_model_artifact_signature", + "list_model_artifact_signatures", + "update_model_artifact_signature", + "verify_model_artifact_signature", + ) +) + + +def test_from_model(): + """Tests constructing a model artifact signature helper from a model.""" + model = MagicMock( + id=MODEL_OCID, + compartment_id=COMPARTMENT_OCID, + config={"key": "value"}, + signer="signer", + kwargs={"timeout": 10}, + ) + + signature = DataScienceModelArtifactSignature.from_model(model) + + assert signature.model_id == MODEL_OCID + assert signature.compartment_id == COMPARTMENT_OCID + assert signature.config == {"key": "value"} + assert signature.signer == "signer" + assert signature.kwargs == {"timeout": 10} + + +def test_create_requires_saved_model(): + """Ensures signature operations fail before SDK validation when model id is missing.""" + with pytest.raises( + ModelNotSavedError, + match="Model needs to be saved to the Model Catalog before an artifact signature can be created.", + ): + DataScienceModelArtifactSignature(model_id=None).create( + kms_key_id=KMS_KEY_ID, + kms_key_version_id=KMS_KEY_VERSION_ID, + signing_algorithm=SIGNING_ALGORITHM, + ) + + +def test_datascience_model_delegates_to_model_artifact_signature_service(): + """Tests DataScienceModel signature operations delegate to service helper.""" + dsc_model = object.__new__(DataScienceModel) + dsc_model.dsc_model = MagicMock() + model_artifact_signature = MagicMock() + + with patch( + "ads.model.datascience_model.DataScienceModelArtifactSignature.from_model", + return_value=model_artifact_signature, + ) as mock_from_model: + dsc_model.create_model_artifact_signature( + kms_key_id="kms_key_id", + kms_key_version_id="kms_key_version_id", + signing_algorithm="SHA_256_RSA_PKCS_PSS", + display_name="signature", + ) + model_artifact_signature.create.assert_called_with( + kms_key_id="kms_key_id", + kms_key_version_id="kms_key_version_id", + signing_algorithm="SHA_256_RSA_PKCS_PSS", + compartment_id=None, + display_name="signature", + freeform_tags=None, + defined_tags=None, + ) + + dsc_model.list_model_artifact_signatures(display_name="signature") + model_artifact_signature.list.assert_called_with( + compartment_id=None, + display_name="signature", + ) + + dsc_model.get_model_artifact_signature(MODEL_ARTIFACT_SIGNATURE_OCID) + model_artifact_signature.get.assert_called_with( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + ) + + dsc_model.update_model_artifact_signature( + MODEL_ARTIFACT_SIGNATURE_OCID, + display_name="signature", + ) + model_artifact_signature.update.assert_called_with( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + display_name="signature", + freeform_tags=None, + defined_tags=None, + ) + + dsc_model.delete_model_artifact_signature(MODEL_ARTIFACT_SIGNATURE_OCID) + model_artifact_signature.delete.assert_called_with( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + ) + + dsc_model.change_model_artifact_signature_compartment( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + compartment_id="new_compartment_id", + ) + model_artifact_signature.change_compartment.assert_called_with( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + compartment_id="new_compartment_id", + ) + + dsc_model.verify_model_artifact_signature(MODEL_ARTIFACT_SIGNATURE_OCID) + model_artifact_signature.verify.assert_called_with( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + ) + + assert mock_from_model.call_count == 7 + mock_from_model.assert_called_with(dsc_model.dsc_model) + + +@pytest.mark.skipif( + not SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE, + reason="OCI SDK does not include model artifact signature APIs.", +) +@patch.object(oci.pagination, "list_call_get_all_results") +def test_model_artifact_signature_operations( + mock_list_call_get_all_results, +): + """Tests model artifact signature operations.""" + signature = MagicMock(id=MODEL_ARTIFACT_SIGNATURE_OCID) + signature_response = Response( + data=signature, status=None, headers=None, request=None + ) + empty_response = Response(data=None, status=None, headers=None, request=None) + list_response = Response(data=[signature], status=None, headers=None, request=None) + + mock_client = MagicMock() + mock_client.create_model_artifact_signature = MagicMock( + return_value=signature_response + ) + mock_list_call_get_all_results.return_value = list_response + mock_client.list_model_artifact_signatures = MagicMock(return_value=list_response) + mock_client.get_model_artifact_signature = MagicMock( + return_value=signature_response + ) + mock_client.update_model_artifact_signature = MagicMock( + return_value=signature_response + ) + mock_client.delete_model_artifact_signature = MagicMock(return_value=empty_response) + mock_client.change_model_artifact_signature_compartment = MagicMock( + return_value=empty_response + ) + mock_client.verify_model_artifact_signature = MagicMock( + return_value=signature_response + ) + + model_signature = DataScienceModelArtifactSignature( + model_id=MODEL_OCID, + compartment_id=COMPARTMENT_OCID, + ) + + with patch.object(DataScienceModelArtifactSignature, "client", mock_client): + assert ( + model_signature.create( + display_name="signature", + kms_key_id=KMS_KEY_ID, + kms_key_version_id=KMS_KEY_VERSION_ID, + signing_algorithm=SIGNING_ALGORITHM, + freeform_tags={"key": "value"}, + ) + == signature + ) + create_kwargs = mock_client.create_model_artifact_signature.call_args.kwargs + create_details = create_kwargs["create_model_artifact_signature_details"] + assert create_kwargs["model_id"] == MODEL_OCID + assert create_details.compartment_id == COMPARTMENT_OCID + assert create_details.display_name == "signature" + assert create_details.kms_key_id == KMS_KEY_ID + assert create_details.kms_key_version_id == KMS_KEY_VERSION_ID + assert create_details.signing_algorithm == SIGNING_ALGORITHM + assert create_details.freeform_tags == {"key": "value"} + + assert model_signature.list(display_name="signature") == [signature] + mock_list_call_get_all_results.assert_called_with( + mock_client.list_model_artifact_signatures, + MODEL_OCID, + COMPARTMENT_OCID, + display_name="signature", + ) + + assert model_signature.get(MODEL_ARTIFACT_SIGNATURE_OCID) == signature + mock_client.get_model_artifact_signature.assert_called_with( + model_id=MODEL_OCID, + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + ) + + assert ( + model_signature.update( + MODEL_ARTIFACT_SIGNATURE_OCID, + display_name="updated-signature", + ) + == signature + ) + update_kwargs = mock_client.update_model_artifact_signature.call_args.kwargs + update_details = update_kwargs["update_model_artifact_signature_details"] + assert update_kwargs["model_id"] == MODEL_OCID + assert update_kwargs["artifact_signature_id"] == MODEL_ARTIFACT_SIGNATURE_OCID + assert update_details.display_name == "updated-signature" + + model_signature.delete(MODEL_ARTIFACT_SIGNATURE_OCID) + mock_client.delete_model_artifact_signature.assert_called_with( + model_id=MODEL_OCID, + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + ) + + model_signature.change_compartment( + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + compartment_id="new_compartment_id", + ) + change_kwargs = ( + mock_client.change_model_artifact_signature_compartment.call_args.kwargs + ) + change_details = change_kwargs[ + "change_model_artifact_signature_compartment_details" + ] + assert change_kwargs["model_id"] == MODEL_OCID + assert change_kwargs["artifact_signature_id"] == MODEL_ARTIFACT_SIGNATURE_OCID + assert change_details.compartment_id == "new_compartment_id" + + assert model_signature.verify(MODEL_ARTIFACT_SIGNATURE_OCID) == signature + mock_client.verify_model_artifact_signature.assert_called_with( + model_id=MODEL_OCID, + artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_OCID, + ) diff --git a/tests/unitary/default_setup/model_deployment/test_model_deployment.py b/tests/unitary/default_setup/model_deployment/test_model_deployment.py index 25fb0e07b..d50fccad2 100644 --- a/tests/unitary/default_setup/model_deployment/test_model_deployment.py +++ b/tests/unitary/default_setup/model_deployment/test_model_deployment.py @@ -6,6 +6,8 @@ import unittest import pytest import numpy as np +import oci +import oci.data_science.models from unittest.mock import MagicMock, Mock, patch from ads.common import auth as authutil @@ -95,6 +97,20 @@ def test_predict_with_auto_serialize_data(self, mock_sync, mock_post): class ModelDeploymentPropertiesTestCase(unittest.TestCase): MODEL_ID = "" + MODEL_ARTIFACT_SIGNATURE_ID = ( + "fakeid.datasciencemodelartifactsignature.oc1.iad.xxx" + ) + SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE = hasattr( + oci.data_science.models, "ModelConfigurationDetails" + ) and hasattr( + oci.data_science.models.ModelConfigurationDetails(), + "model_artifact_signature_id", + ) and hasattr( + oci.data_science.models, "UpdateModelConfigurationDetails" + ) and hasattr( + oci.data_science.models.UpdateModelConfigurationDetails(), + "model_artifact_signature_id", + ) def assert_model_id(self, oci_model): """Checks if the model OCID is configured correctly.""" @@ -104,6 +120,17 @@ def assert_model_id(self, oci_model): self.MODEL_ID, ) + def assert_model_artifact_signature_id(self, oci_model): + """Checks if the model artifact signature OCID is configured correctly.""" + self.assertIsNotNone(oci_model.model_deployment_configuration_details) + model_configuration_details = ( + oci_model.model_deployment_configuration_details.model_configuration_details + ) + self.assertEqual( + model_configuration_details.model_artifact_signature_id, + self.MODEL_ARTIFACT_SIGNATURE_ID, + ) + def test_setting_model_deployment_with_model_id(self): """Tests setting model deployment with model OCID.""" # User may pass in the model ID when initializing ModelDeploymentProperties @@ -148,3 +175,28 @@ def test_setting_model_deployment_with_model_id(self): oci_model.model_deployment_configuration_details.model_configuration_details.instance_configuration.model_deployment_instance_shape_config_details.ocpus, 2, ) + + @pytest.mark.skipif( + not SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE, + reason="OCI SDK does not include model artifact signature model deployment fields.", + ) + def test_setting_model_deployment_with_model_artifact_signature_id(self): + """Tests setting model deployment with model artifact signature OCID.""" + properties = ModelDeploymentProperties( + model_id=self.MODEL_ID, + model_artifact_signature_id=self.MODEL_ARTIFACT_SIGNATURE_ID, + ) + oci_model = properties.to_update_deployment() + self.assert_model_id(oci_model) + self.assert_model_artifact_signature_id(oci_model) + + properties = ModelDeploymentProperties( + model_id=self.MODEL_ID, + bandwidth_mbps=20, + memory_in_gbs=10, + ocpus=1, + model_artifact_signature_id=self.MODEL_ARTIFACT_SIGNATURE_ID, + ) + oci_model = properties.build() + self.assert_model_id(oci_model) + self.assert_model_artifact_signature_id(oci_model) diff --git a/tests/unitary/default_setup/model_deployment/test_model_deployment_v2.py b/tests/unitary/default_setup/model_deployment/test_model_deployment_v2.py index 082de56c8..5a1be5856 100644 --- a/tests/unitary/default_setup/model_deployment/test_model_deployment_v2.py +++ b/tests/unitary/default_setup/model_deployment/test_model_deployment_v2.py @@ -59,6 +59,14 @@ ) NB_SESSION_OCID = "ocid1.datasciencenotebooksession.oc1.iad.." +MODEL_ARTIFACT_SIGNATURE_ID = ( + "fakeid.datasciencemodelartifactsignature.oc1.iad.xxx" +) +SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE = hasattr( + ModelConfigurationDetails(), "model_artifact_signature_id" +) and hasattr( + UpdateModelConfigurationDetails(), "model_artifact_signature_id" +) OCI_MODEL_DEPLOYMENT_RESPONSE = oci.data_science.models.ModelDeployment( id="fakeid.datasciencemodeldeployment.oc1..xxx", @@ -438,6 +446,20 @@ def test_initialize_model_deployment(self): "logId": "fakeid.log.oc1.iad.xxx", } + def test_runtime_with_model_artifact_signature_id(self): + runtime = ModelDeploymentRuntime().with_model_artifact_signature_id( + MODEL_ARTIFACT_SIGNATURE_ID + ) + + assert runtime.model_artifact_signature_id == MODEL_ARTIFACT_SIGNATURE_ID + assert runtime.to_dict() == { + "kind": "runtime", + "type": "modelDeploymentRuntime", + "spec": { + "modelArtifactSignatureId": MODEL_ARTIFACT_SIGNATURE_ID, + }, + } + def test_initialize_model_deployment_from_spec(self): model_deployment_spec = self.initialize_model_deployment_from_spec() model_deployment_builder = self.initialize_model_deployment() @@ -576,6 +598,70 @@ def test_build_model_deployment_configuration_details(self, mock_create): }, } + @pytest.mark.skipif( + not SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE, + reason="OCI SDK does not include model artifact signature model deployment fields.", + ) + @patch.object(DataScienceModel, "create") + def test_build_model_deployment_details_with_model_artifact_signature_id( + self, mock_create + ): + dsc_model = MagicMock() + dsc_model.id = "fakeid.datasciencemodel.oc1.iad.xxx" + mock_create.return_value = dsc_model + model_deployment = copy.deepcopy(self.initialize_model_deployment()) + model_deployment.runtime.with_model_artifact_signature_id( + MODEL_ARTIFACT_SIGNATURE_ID + ) + + model_deployment_configuration_details = ( + model_deployment._build_model_deployment_configuration_details() + ) + assert ( + model_deployment_configuration_details["modelConfigurationDetails"][ + "modelArtifactSignatureId" + ] + == MODEL_ARTIFACT_SIGNATURE_ID + ) + + create_model_deployment_details = ( + model_deployment._build_model_deployment_details() + ) + create_model_configuration_details = ( + create_model_deployment_details.model_deployment_configuration_details.model_configuration_details + ) + assert ( + create_model_configuration_details.model_artifact_signature_id + == MODEL_ARTIFACT_SIGNATURE_ID + ) + + update_model_deployment_details = ( + model_deployment._update_model_deployment_details(update_type="ZDT") + ) + update_model_configuration_details = ( + update_model_deployment_details.model_deployment_configuration_details.model_configuration_details + ) + assert ( + update_model_configuration_details.model_artifact_signature_id + == MODEL_ARTIFACT_SIGNATURE_ID + ) + + def test_model_artifact_signature_id_rejected_for_model_group(self): + model_deployment = copy.deepcopy(self.initialize_model_deployment()) + model_deployment.runtime.with_model_artifact_signature_id( + MODEL_ARTIFACT_SIGNATURE_ID + ) + model_deployment.runtime.with_model_uri("ocid1.datasciencemodel.oc1.iad.xxx") + model_deployment.runtime.with_model_group_id( + "fakeid.datasciencemodelgroup.oc1.iad.xxx" + ) + + with pytest.raises( + ValueError, + match="`model_artifact_signature_id` is supported only for single-model deployments.", + ): + model_deployment._build_model_deployment_configuration_details() + @patch.object(DataScienceModel, "create") def test_build_model_deployment_configuration_details_autoscaling(self, mock_create): """Validate builder-based autoscaling payload for SINGLE_MODEL deployments.""" @@ -774,6 +860,29 @@ def test_update_from_oci_model(self): ] ) + @pytest.mark.skipif( + not SDK_SUPPORTS_MODEL_ARTIFACT_SIGNATURE, + reason="OCI SDK does not include model artifact signature model deployment fields.", + ) + def test_update_from_oci_model_with_model_artifact_signature_id(self): + oci_model_deployment_response = copy.deepcopy(OCI_MODEL_DEPLOYMENT_RESPONSE) + model_configuration_details = ( + oci_model_deployment_response.model_deployment_configuration_details.model_configuration_details + ) + model_configuration_details.model_artifact_signature_id = ( + MODEL_ARTIFACT_SIGNATURE_ID + ) + + model_deployment = self.initialize_model_deployment() + model_deployment_from_oci = model_deployment._update_from_oci_model( + oci_model_deployment_response + ) + + assert ( + model_deployment_from_oci.runtime.model_artifact_signature_id + == MODEL_ARTIFACT_SIGNATURE_ID + ) + def test_model_deployment_from_yaml(self): model_deployment_from_yaml = ModelDeployment.from_yaml( yaml_string=MODEL_DEPLOYMENT_YAML diff --git a/tests/unitary/with_extras/model/test_generic_model.py b/tests/unitary/with_extras/model/test_generic_model.py index 644460030..e8b4291d3 100644 --- a/tests/unitary/with_extras/model/test_generic_model.py +++ b/tests/unitary/with_extras/model/test_generic_model.py @@ -62,6 +62,9 @@ _COMPARTMENT_OCID = NB_SESSION_COMPARTMENT_OCID or JOB_RUN_COMPARTMENT_OCID +MODEL_ARTIFACT_SIGNATURE_ID = ( + "fakeid.datasciencemodelartifactsignature.oc1.iad.xxx" +) DSC_MODEL_PAYLOAD = { @@ -955,6 +958,28 @@ def test_deploy_success(self, mock_deploy): poll_interval=input_dict["poll_interval"], ) + @patch.object(ModelDeployment, "deploy", autospec=True) + def test_deploy_with_model_artifact_signature_id(self, mock_deploy): + test_model_id = "ocid.test_model_id" + self.generic_model.dsc_model = MagicMock(id=test_model_id) + self.generic_model.ignore_conda_error = True + mock_deploy.side_effect = lambda model_deployment, **kwargs: model_deployment + + result = self.generic_model.deploy( + display_name="test_display_name", + deployment_model_artifact_signature_id=MODEL_ARTIFACT_SIGNATURE_ID, + compartment_id="test_compartment_id", + project_id="test_project_id", + ) + + assert result.runtime.model_artifact_signature_id == MODEL_ARTIFACT_SIGNATURE_ID + mock_deploy.assert_called_with( + result, + wait_for_completion=True, + max_wait_time=1200, + poll_interval=10, + ) + @patch.object(ModelDeployment, "deploy") def test_deploy_with_default_display_name(self, mock_deploy): """Ensure that a randomly generated easy to remember name will be generated, @@ -1771,6 +1796,7 @@ def test__to_yaml(self): "deployment_memory_in_gbs": None, "deployment_ocpus": None, "deployment_image": None, + "deployment_model_artifact_signature_id": None, "kwargs": {}, }, ), @@ -1865,6 +1891,7 @@ def test__to_yaml(self): "deployment_memory_in_gbs": None, "deployment_ocpus": None, "deployment_image": None, + "deployment_model_artifact_signature_id": None, "kwargs": {}, }, ), @@ -1963,6 +1990,7 @@ def test__to_yaml(self): "deployment_memory_in_gbs": 10, "deployment_ocpus": 1, "deployment_image": None, + "deployment_model_artifact_signature_id": None, "kwargs": { "compartment_id": "ocid1.compartment.oc1..", "project_id": "ocid1.datascienceproject.oc1.iad.", @@ -2008,6 +2036,9 @@ def test__to_yaml(self): "deployment_memory_in_gbs": 10, "deployment_ocpus": 1, "deployment_image": "test_docker_image", + "deployment_model_artifact_signature_id": ( + MODEL_ARTIFACT_SIGNATURE_ID + ), "cmd": ["test_cmd"], "entrypoint": ["test_entrypoint"], "server_port": 8080, @@ -2071,6 +2102,9 @@ def test__to_yaml(self): "deployment_memory_in_gbs": 10, "deployment_ocpus": 1, "deployment_image": "test_docker_image", + "deployment_model_artifact_signature_id": ( + MODEL_ARTIFACT_SIGNATURE_ID + ), "kwargs": { "compartment_id": "ocid..", "project_id": "ocid..", @@ -2146,6 +2180,7 @@ def test_prepare_save_deploy_with_default_display_name( "deployment_access_log_id": None, "deployment_predict_log_id": None, "deployment_image": None, + "deployment_model_artifact_signature_id": None, "kwargs": {}, } random.seed(self.random_seed)