Skip to content

Commit 4e5853c

Browse files
mohamedzeidan2021Mohamed Zeidan
andauthored
fix: keep source_code for a pipeline-variable model URI and stop repack mutating caller source (#6490)
Two coupled defects on the ModelBuilder pipeline repack path. #6450: ModelBuilder._build_for_passthrough() recognised a model artifact only when s3_model_data_url was a str starting with "s3://". When the artifact comes from an upstream pipeline step the value is a PipelineVariable, which failed that check, so the passthrough branch fell through and cleared source_dir, entry_point, dependencies and git_config. The CreateModel request carried the pipeline expression with an empty container Environment, and no repack happened. _upload_code(repack=True) already defers a PipelineVariable artifact to a runtime repack, so the fix is to treat a PipelineVariable s3_model_data_url as a model artifact and route through that existing machinery. A concrete "s3://" URI keeps its current eager repack behaviour. #6481: _RepackModelStep wrote _repack_model.py and _repack_script_launcher.sh into the caller-provided source_dir -- once in the constructor and again on every `arguments` access -- and for an s3:// source_dir it downloaded the archive, added the files and overwrote the original S3 object. The helper files now go into a copy the SDK owns: a temporary directory for a local source_dir, a new sibling S3 key for an s3:// one. Staging runs once, so repeated serialization neither re-stages nor re-uploads, and the caller's object is read but never overwritten. The two are sequenced together because #6481 is latent today only while #6450 blocks that repack path: fixing #6450 alone would ship code that mutates customer-owned source directories and overwrites their original S3 objects. Fixes #6450 Fixes #6481 Co-authored-by: Mohamed Zeidan <zeidmo@amazon.com>
1 parent f6fe09b commit 4e5853c

5 files changed

Lines changed: 384 additions & 47 deletions

File tree

‎sagemaker-mlops/src/sagemaker/mlops/workflow/_utils.py‎

Lines changed: 91 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
INSTANCE_TYPE = "ml.m5.large"
4848
REPACK_SCRIPT = "_repack_model.py"
4949
REPACK_SCRIPT_LAUNCHER = "_repack_script_launcher.sh"
50+
# Suffix appended to an ``s3://`` source_dir object name to derive the SDK-owned
51+
# staging key. The caller's original object is never overwritten (issue #6481).
52+
STAGED_SOURCE_DIR_SUFFIX = "-sagemaker-repack.tar.gz"
5053
LAUNCH_REPACK_SCRIPT_CMD = """
5154
#!/bin/bash
5255
@@ -179,8 +182,11 @@ def __init__(
179182
self._entry_point_basename = os.path.basename(self._entry_point)
180183
self._source_dir = source_dir
181184
self._requirements = requirements
185+
# The SDK-owned copy of the source that the repack helper files are written
186+
# into. ``None`` until the first ``_prepare_for_repacking()`` call.
187+
self._staged_source_dir = None
182188

183-
# Prepare source directory with repack scripts
189+
# Stage an SDK-owned copy of the source with the repack scripts in it
184190
self._prepare_for_repacking()
185191

186192
# Handle requirements.txt like ModelTrainer
@@ -194,7 +200,7 @@ def __init__(
194200
from sagemaker.core.training.configs import SourceCode, Compute, Networking
195201

196202
source_code = SourceCode(
197-
source_dir=self._source_dir,
203+
source_dir=self._staged_source_dir,
198204
entry_script=REPACK_SCRIPT_LAUNCHER,
199205
requirements=requirements_file,
200206
)
@@ -251,11 +257,31 @@ def __init__(
251257
)
252258

253259
def _prepare_for_repacking(self):
254-
"""Prepares the source for the model trainer."""
260+
"""Stages an SDK-owned copy of the source for the model trainer.
261+
262+
The caller-provided ``source_dir`` is never modified: the repack helper
263+
files are written into a copy that the SDK owns -- a temporary directory
264+
for a local ``source_dir``, a new S3 object for an ``s3://`` one. Staging
265+
runs at most once, so repeated ``arguments`` access neither re-stages nor
266+
re-uploads (see issue #6481).
267+
"""
268+
if self._staged_source_dir is not None:
269+
return
270+
271+
if self._source_dir is not None and self._source_dir.lower().startswith("s3://"):
272+
self._staged_source_dir = self._stage_s3_source_dir()
273+
return
274+
255275
if self._source_dir is None:
276+
# _establish_source_dir already builds a directory that the SDK owns,
277+
# so the helper files can go straight into it.
256278
self._establish_source_dir()
279+
self._staged_source_dir = self._source_dir
280+
else:
281+
self._staged_source_dir = tempfile.mkdtemp()
282+
shutil.copytree(self._source_dir, self._staged_source_dir, dirs_exist_ok=True)
257283

258-
self._inject_repack_script_and_launcher()
284+
self._inject_repack_script_and_launcher(self._staged_source_dir)
259285

260286
def _establish_source_dir(self):
261287
"""If the source_dir is None, creates it for the repacking job.
@@ -272,69 +298,93 @@ def _establish_source_dir(self):
272298
shutil.copy2(self._entry_point, os.path.join(self._source_dir, self._entry_point_basename))
273299
self._entry_point = self._entry_point_basename
274300

275-
def _inject_repack_script_and_launcher(self):
301+
def _inject_repack_script_and_launcher(self, target_dir):
276302
"""Injects the _repack_model.py script and _repack_script_launcher.sh
277303
278-
into S3 or local source directory.
304+
into a local directory that the SDK owns.
279305
280306
Note: The bash file is needed because if not supplied, the SKLearn
281307
training job will auto install all dependencies listed in requirements.txt.
282308
However, this auto install behavior is not expected in _RepackModelStep,
283309
since it should just simply repack the model along with other supplied files,
284310
e.g. the requirements.txt.
285311
286-
If the source_dir is an S3 path:
312+
It performs the following:
313+
1) copies the _repack_model.py script into ``target_dir``
314+
2) creates the _repack_script_launcher.sh in ``target_dir``
315+
316+
Args:
317+
target_dir (str): A local directory owned by the SDK. This is never
318+
the caller-provided ``source_dir`` (see issue #6481).
319+
"""
320+
fname = os.path.join(os.path.dirname(__file__), REPACK_SCRIPT)
321+
shutil.copy2(fname, os.path.join(target_dir, REPACK_SCRIPT))
322+
# newline="\n" forces LF endings so the bash launcher stays runnable in the
323+
# Linux repack container even when written from a Windows host (see issue #3762).
324+
with open(
325+
os.path.join(target_dir, REPACK_SCRIPT_LAUNCHER), "w", newline="\n"
326+
) as launcher_file:
327+
launcher_file.write(LAUNCH_REPACK_SCRIPT_CMD)
328+
329+
def _stage_s3_source_dir(self):
330+
"""Stages an ``s3://`` source archive into a new, SDK-owned S3 object.
331+
332+
It performs the following:
287333
1) downloads the source_dir tar.gz
288334
2) extracts it
289335
3) copies the _repack_model.py script into the extracted directory
290336
4) creates the _repack_script_launcher.sh in the extracted dir
291337
5) rezips the directory
292-
6) overwrites the S3 source_dir with the new tar.gz
338+
6) uploads the new tar.gz under a new key, alongside the original
293339
294-
If the source_dir is a local path:
295-
1) copies the _repack_model.py script into the source dir
296-
2) creates the _repack_script_launcher.sh in the source dir
340+
The caller's object is read but never overwritten (see issue #6481). The
341+
staging key is derived from the original one, so it stays in the caller's
342+
bucket and prefix and inherits the same access and encryption policies.
343+
344+
Returns:
345+
str: The S3 URI of the staged source archive.
297346
"""
298-
fname = os.path.join(os.path.dirname(__file__), REPACK_SCRIPT)
299-
if self._source_dir.lower().startswith("s3://"):
300-
with tempfile.TemporaryDirectory() as tmp:
301-
targz_contents_dir = os.path.join(tmp, "extracted")
347+
staged_uri = self._staged_s3_source_dir_uri()
348+
with tempfile.TemporaryDirectory() as tmp:
349+
targz_contents_dir = os.path.join(tmp, "extracted")
302350

303-
old_targz_path = os.path.join(tmp, "old.tar.gz")
304-
download_file_from_url(self._source_dir, old_targz_path, self.sagemaker_session)
351+
old_targz_path = os.path.join(tmp, "old.tar.gz")
352+
download_file_from_url(self._source_dir, old_targz_path, self.sagemaker_session)
305353

306-
with tarfile.open(name=old_targz_path, mode="r:gz") as t:
307-
custom_extractall_tarfile(t, targz_contents_dir)
354+
with tarfile.open(name=old_targz_path, mode="r:gz") as t:
355+
custom_extractall_tarfile(t, targz_contents_dir)
308356

309-
shutil.copy2(fname, os.path.join(targz_contents_dir, REPACK_SCRIPT))
310-
# newline="\n" forces LF endings so the bash launcher stays runnable in the
311-
# Linux repack container even when written from a Windows host (see issue #3762).
312-
with open(
313-
os.path.join(targz_contents_dir, REPACK_SCRIPT_LAUNCHER), "w", newline="\n"
314-
) as launcher_file:
315-
launcher_file.write(LAUNCH_REPACK_SCRIPT_CMD)
357+
self._inject_repack_script_and_launcher(targz_contents_dir)
316358

317-
new_targz_path = os.path.join(tmp, "new.tar.gz")
318-
with tarfile.open(new_targz_path, mode="w:gz") as t:
319-
t.add(targz_contents_dir, arcname=os.path.sep)
359+
new_targz_path = os.path.join(tmp, "new.tar.gz")
360+
with tarfile.open(new_targz_path, mode="w:gz") as t:
361+
t.add(targz_contents_dir, arcname=os.path.sep)
320362

321-
_save_model(self._source_dir, new_targz_path, self.sagemaker_session, kms_key=None)
322-
else:
323-
shutil.copy2(fname, os.path.join(self._source_dir, REPACK_SCRIPT))
324-
# newline="\n" forces LF endings so the bash launcher stays runnable in the
325-
# Linux repack container even when written from a Windows host (see issue #3762).
326-
with open(
327-
os.path.join(self._source_dir, REPACK_SCRIPT_LAUNCHER), "w", newline="\n"
328-
) as launcher_file:
329-
launcher_file.write(LAUNCH_REPACK_SCRIPT_CMD)
363+
_save_model(staged_uri, new_targz_path, self.sagemaker_session, kms_key=None)
364+
365+
return staged_uri
366+
367+
def _staged_s3_source_dir_uri(self):
368+
"""Derives the staging S3 URI for an ``s3://`` source_dir.
369+
370+
Returns:
371+
str: The original URI with its object name suffixed, keeping the
372+
caller's bucket and prefix.
373+
"""
374+
prefix, _, object_name = self._source_dir.rpartition("/")
375+
for extension in (".tar.gz", ".tgz"):
376+
if object_name.lower().endswith(extension):
377+
object_name = object_name[: -len(extension)]
378+
break
379+
return "/".join([prefix, object_name + STAGED_SOURCE_DIR_SUFFIX])
330380

331381
@property
332382
def arguments(self) -> RequestType:
333383
"""The arguments dict that are used to call `create_training_job`.
334384
335-
This first prepares the source bundle for repackinglby placing artifacts
336-
in locations which the training container will make available to the
337-
repacking script and then gets the arguments for the training job.
385+
The source bundle is staged once, by the constructor, so repeated access
386+
here neither re-stages the source nor touches the caller's ``source_dir``
387+
(see issue #6481).
338388
"""
339389
self._prepare_for_repacking()
340390
return super(_RepackModelStep, self).arguments

0 commit comments

Comments
 (0)