Skip to content

Commit 6b31d0f

Browse files
authored
Support adding artifacts for container jobs (#936)
2 parents 9e380a8 + 580fc7b commit 6b31d0f

File tree

7 files changed

+80
-57
lines changed

7 files changed

+80
-57
lines changed

ads/jobs/builders/infrastructure/dsc_job.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def _create_with_oci_api(self) -> None:
312312
logger.debug(oci_model)
313313
res = self.client.create_job(oci_model)
314314
self.update_from_oci_model(res.data)
315-
if self.lifecycle_state == "ACTIVE":
315+
if not self.artifact:
316316
return
317317
try:
318318
if issubclass(self.artifact.__class__, Artifact):
@@ -487,7 +487,9 @@ def run(self, **kwargs) -> DataScienceJobRun:
487487
oci.data_science.models.DefaultJobConfigurationDetails().swagger_types.keys()
488488
)
489489
env_config_swagger_types = {}
490-
if hasattr(oci.data_science.models, "OcirContainerJobEnvironmentConfigurationDetails"):
490+
if hasattr(
491+
oci.data_science.models, "OcirContainerJobEnvironmentConfigurationDetails"
492+
):
491493
env_config_swagger_types = (
492494
oci.data_science.models.OcirContainerJobEnvironmentConfigurationDetails().swagger_types.keys()
493495
)
@@ -501,7 +503,7 @@ def run(self, **kwargs) -> DataScienceJobRun:
501503
value = kwargs.pop(key)
502504
if key in [
503505
ContainerRuntime.CONST_CMD,
504-
ContainerRuntime.CONST_ENTRYPOINT
506+
ContainerRuntime.CONST_ENTRYPOINT,
505507
] and isinstance(value, str):
506508
value = ContainerRuntimeHandler.split_args(value)
507509
env_config_kwargs[key] = value
@@ -535,9 +537,13 @@ def run(self, **kwargs) -> DataScienceJobRun:
535537

536538
if env_config_kwargs:
537539
env_config_kwargs["jobEnvironmentType"] = "OCIR_CONTAINER"
538-
env_config_override = kwargs.get("job_environment_configuration_override_details", {})
540+
env_config_override = kwargs.get(
541+
"job_environment_configuration_override_details", {}
542+
)
539543
env_config_override.update(env_config_kwargs)
540-
kwargs["job_environment_configuration_override_details"] = env_config_override
544+
kwargs["job_environment_configuration_override_details"] = (
545+
env_config_override
546+
)
541547

542548
wait = kwargs.pop("wait", False)
543549
run = DataScienceJobRun(**kwargs, **self.auth).create()

ads/jobs/builders/infrastructure/dsc_job_runtime.py

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,9 @@ def _translate_config(self, runtime: Runtime) -> dict:
181181
"jobType": self.data_science_job.job_type,
182182
}
183183
if runtime.maximum_runtime_in_minutes:
184-
job_configuration_details[
185-
"maximum_runtime_in_minutes"
186-
] = runtime.maximum_runtime_in_minutes
184+
job_configuration_details["maximum_runtime_in_minutes"] = (
185+
runtime.maximum_runtime_in_minutes
186+
)
187187
job_configuration_details["environment_variables"] = self._translate_env(
188188
runtime
189189
)
@@ -310,7 +310,7 @@ def extract(self, dsc_job):
310310
for extraction in extractions:
311311
runtime_spec.update(extraction(dsc_job))
312312
return self.RUNTIME_CLASS(self._format_env_var(runtime_spec))
313-
313+
314314
def _extract_properties(self, dsc_job) -> dict:
315315
"""Extract the job runtime properties from data science job.
316316
@@ -968,23 +968,10 @@ def translate(self, runtime: Runtime) -> dict:
968968
payload["job_environment_configuration_details"] = job_env_config
969969
return payload
970970

971-
def _translate_artifact(self, runtime: Runtime):
972-
"""Specifies a dummy script as the job artifact.
973-
runtime is not used in this method.
974-
975-
Parameters
976-
----------
977-
runtime : Runtime
978-
This is not used.
979-
980-
Returns
981-
-------
982-
str
983-
Path to the dummy script.
984-
"""
985-
return os.path.join(
986-
os.path.dirname(__file__), "../../templates", "container.py"
987-
)
971+
def _translate_artifact(self, runtime: ContainerRuntime):
972+
"""Additional artifact for the container"""
973+
if runtime.artifact_uri:
974+
return ScriptArtifact(runtime.artifact_uri, runtime)
988975

989976
def _translate_env_config(self, runtime: Runtime) -> dict:
990977
"""Converts runtime properties to ``OcirContainerJobEnvironmentConfigurationDetails`` payload required by OCI Data Science job.
@@ -1007,7 +994,7 @@ def _translate_env_config(self, runtime: Runtime) -> dict:
1007994
property = runtime.get_spec(key, None)
1008995
if key in [
1009996
ContainerRuntime.CONST_CMD,
1010-
ContainerRuntime.CONST_ENTRYPOINT
997+
ContainerRuntime.CONST_ENTRYPOINT,
1011998
] and isinstance(property, str):
1012999
property = self.split_args(property)
10131000
if property is not None:
@@ -1063,7 +1050,7 @@ def _extract_envs(self, dsc_job):
10631050
spec[ContainerRuntime.CONST_ENV_VAR] = envs
10641051

10651052
return spec
1066-
1053+
10671054
def _extract_properties(self, dsc_job) -> dict:
10681055
"""Extract the runtime properties from data science job.
10691056
@@ -1078,10 +1065,10 @@ def _extract_properties(self, dsc_job) -> dict:
10781065
A runtime specification dictionary for initializing a runtime.
10791066
"""
10801067
spec = super()._extract_envs(dsc_job)
1081-
1068+
10821069
job_env_config = getattr(dsc_job, "job_environment_configuration_details", None)
10831070
job_env_type = getattr(job_env_config, "job_environment_type", None)
1084-
1071+
10851072
if not (job_env_config and job_env_type == "OCIR_CONTAINER"):
10861073
raise IncompatibleRuntime()
10871074

ads/jobs/builders/runtimes/artifact.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,6 @@ def build(self):
183183
if os.path.isdir(source):
184184
basename = os.path.basename(str(source).rstrip("/"))
185185
source = str(source).rstrip("/")
186-
# Runtime must have entrypoint if the source is a directory
187-
if self.runtime and not self.runtime.entrypoint:
188-
raise ValueError(
189-
"Please specify entrypoint when script source is a directory."
190-
)
191186
output = os.path.join(self.temp_dir.name, basename)
192187
shutil.make_archive(
193188
output, "zip", os.path.dirname(source), base_dir=basename

ads/jobs/builders/runtimes/container_runtime.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ class ContainerRuntime(MultiNodeRuntime):
5656
CONST_CMD = "cmd"
5757
CONST_IMAGE_DIGEST = "imageDigest"
5858
CONST_IMAGE_SIGNATURE_ID = "imageSignatureId"
59+
CONST_SCRIPT_PATH = "scriptPathURI"
5960
attribute_map = {
6061
CONST_IMAGE: CONST_IMAGE,
6162
CONST_ENTRYPOINT: CONST_ENTRYPOINT,
@@ -121,7 +122,7 @@ def with_image(
121122
def image_digest(self) -> str:
122123
"""The container image digest."""
123124
return self.get_spec(self.CONST_IMAGE_DIGEST)
124-
125+
125126
def with_image_digest(self, image_digest: str) -> "ContainerRuntime":
126127
"""Sets the digest of custom image.
127128
@@ -136,12 +137,12 @@ def with_image_digest(self, image_digest: str) -> "ContainerRuntime":
136137
The runtime instance.
137138
"""
138139
return self.set_spec(self.CONST_IMAGE_DIGEST, image_digest)
139-
140+
140141
@property
141142
def image_signature_id(self) -> str:
142143
"""The container image signature id."""
143144
return self.get_spec(self.CONST_IMAGE_SIGNATURE_ID)
144-
145+
145146
def with_image_signature_id(self, image_signature_id: str) -> "ContainerRuntime":
146147
"""Sets the signature id of custom image.
147148
@@ -217,3 +218,25 @@ def init(self, **kwargs) -> "ContainerRuntime":
217218
entrypoint=["bash", "--login", "-c"],
218219
cmd="{Container CMD. For MLflow and Operator will be auto generated}",
219220
)
221+
222+
@property
223+
def artifact_uri(self) -> str:
224+
"""The URI of the source code"""
225+
return self.get_spec(self.CONST_SCRIPT_PATH)
226+
227+
def with_artifact(self, uri: str):
228+
"""Specifies the artifact to be added to the container.
229+
230+
Parameters
231+
----------
232+
uri : str
233+
URI to the source code script, which can be any URI supported by fsspec,
234+
including http://, https:// and OCI object storage.
235+
For example: oci://your_bucket@your_namespace/path/to/script.py
236+
237+
Returns
238+
-------
239+
self
240+
The runtime instance.
241+
"""
242+
return self.set_spec(self.CONST_SCRIPT_PATH, uri)

ads/opctl/conda/cmds.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ def _create(
213213

214214
if is_in_notebook_session() or NO_CONTAINER:
215215
command = f"conda env create --prefix {pack_folder_path} --file {os.path.abspath(os.path.expanduser(env_file))}"
216-
run_command(command, shell=True)
216+
proc = run_command(command, shell=True)
217+
if proc.returncode != 0:
218+
raise RuntimeError(
219+
f"Failed to create conda environment. (exit code {proc.returncode})"
220+
)
217221
else:
218222
_check_job_image_exists(gpu)
219223
docker_pack_folder_path = os.path.join(DEFAULT_IMAGE_HOME_DIR, slug)
@@ -660,7 +664,11 @@ def _publish(
660664
command = f"python {pack_script} --conda-path {pack_folder_path}"
661665
if publish_type:
662666
command = f"CONDA_PUBLISH_TYPE={publish_type} {command}"
663-
run_command(command, shell=True)
667+
proc = run_command(command, shell=True)
668+
if proc.returncode != 0:
669+
raise RuntimeError(
670+
f"Failed to archive the conda environment. (exit code {proc.returncode})"
671+
)
664672
else:
665673
volumes = {
666674
pack_folder_path: {

tests/unitary/default_setup/jobs/test_jobs_dscjob.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,19 @@ def setup_method(self):
4141

4242
@property
4343
def sample_create_job_response(self):
44-
self.payload[
45-
"lifecycle_state"
46-
] = oci.data_science.models.Job.LIFECYCLE_STATE_ACTIVE
44+
self.payload["lifecycle_state"] = (
45+
oci.data_science.models.Job.LIFECYCLE_STATE_ACTIVE
46+
)
4747
self.payload["id"] = "ocid1.datasciencejob.oc1.iad.<unique_ocid>"
4848
return Response(
4949
data=Job(**self.payload), status=None, headers=None, request=None
5050
)
5151

5252
@property
5353
def sample_create_job_response_with_default_display_name(self):
54-
self.payload[
55-
"lifecycle_state"
56-
] = oci.data_science.models.Job.LIFECYCLE_STATE_ACTIVE
54+
self.payload["lifecycle_state"] = (
55+
oci.data_science.models.Job.LIFECYCLE_STATE_ACTIVE
56+
)
5757
random.seed(random_seed)
5858
self.payload["display_name"] = utils.get_random_name_for_resource()
5959
return Response(
@@ -62,9 +62,9 @@ def sample_create_job_response_with_default_display_name(self):
6262

6363
@property
6464
def sample_create_job_response_with_default_display_name_with_artifact(self):
65-
self.payload[
66-
"lifecycle_state"
67-
] = oci.data_science.models.Job.LIFECYCLE_STATE_ACTIVE
65+
self.payload["lifecycle_state"] = (
66+
oci.data_science.models.Job.LIFECYCLE_STATE_ACTIVE
67+
)
6868
random.seed(random_seed)
6969
self.payload["display_name"] = "my_script_name"
7070
return Response(
@@ -155,14 +155,15 @@ def test_create_job_with_default_display_name_with_artifact(
155155
job = DSCJob(artifact=artifact, **self.payload)
156156
with patch.object(
157157
DSCJob, "client", mock_client_with_default_display_name_with_artifact
158+
), patch.object(
159+
OCIModelMixin,
160+
"to_oci_model",
161+
mock_details_with_default_display_name_with_artifact,
162+
), patch.object(
163+
DSCJob, "upload_artifact"
158164
):
159-
with patch.object(
160-
OCIModelMixin,
161-
"to_oci_model",
162-
mock_details_with_default_display_name_with_artifact,
163-
):
164-
job.create()
165-
assert job.display_name == os.path.basename(str(artifact)).split(".")[0]
165+
job.create()
166+
assert job.display_name == os.path.basename(str(artifact)).split(".")[0]
166167

167168
@pytest.mark.parametrize(
168169
"test_config_details, expected_result",

tests/unitary/with_extras/opctl/test_opctl_conda.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
55

66
import pytest
7-
from mock import patch, MagicMock
7+
from unittest.mock import patch, MagicMock, PropertyMock
88
import tempfile
99
import os
1010
from pathlib import Path
@@ -24,6 +24,8 @@ class TestOpctlConda:
2424
def test_conda_create(
2525
self, mock_run_container, mock_docker, mock_run_cmd, monkeypatch
2626
):
27+
type(mock_run_cmd.return_value).returncode = PropertyMock(return_value=0)
28+
mock_run_cmd.returncode = 0
2729
with pytest.raises(FileNotFoundError):
2830
create(slug="test Abc", environment_file="environment.yaml")
2931
with tempfile.TemporaryDirectory() as td:
@@ -101,6 +103,7 @@ def test_conda_publish(
101103
mock_run_cmd,
102104
monkeypatch,
103105
):
106+
type(mock_run_cmd.return_value).returncode = PropertyMock(return_value=0)
104107
with tempfile.TemporaryDirectory() as td:
105108
with pytest.raises(FileNotFoundError):
106109
publish(

0 commit comments

Comments
 (0)