Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import os
import re
import shutil
import stat
import subprocess
from tempfile import TemporaryDirectory
from typing import Any, Dict, List, Optional
Expand DownExpand Up@@ -57,6 +58,17 @@
SM_STUDIO_LOCAL_MODE = "SM_STUDIO_LOCAL_MODE"


def _rmtree(path):
"""Remove a directory tree, handling root-owned files from Docker containers."""
def _onerror(func, path, exc_info):
if isinstance(exc_info[1], PermissionError):
os.chmod(path, stat.S_IRWXU)
func(path)
else:
raise exc_info[1]
shutil.rmtree(path, onerror=_onerror)


class _LocalContainer(BaseModel):
"""A local training job class for local mode model trainer.

Expand DownExpand Up@@ -209,12 +221,12 @@ def train(
# Print our Job Complete line
logger.info("Local training job completed, output artifacts saved to %s", artifacts)

shutil.rmtree(os.path.join(self.container_root, "input"))
shutil.rmtree(os.path.join(self.container_root, "shared"))
_rmtree(os.path.join(self.container_root, "input"))
_rmtree(os.path.join(self.container_root, "shared"))
for host in self.hosts:
shutil.rmtree(os.path.join(self.container_root, host))
_rmtree(os.path.join(self.container_root, host))
for folder in self._temporary_folders:
shutil.rmtree(os.path.join(self.container_root, folder))
_rmtree(os.path.join(self.container_root, folder))
return artifacts

def retrieve_artifacts(
Expand Down
3 changes: 3 additions & 0 deletions sagemaker-core/src/sagemaker/core/processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -487,6 +487,9 @@ def _normalize_outputs(self, outputs=None):
# If the output's s3_uri is not an s3_uri, create one.
parse_result = urlparse(output.s3_output.s3_uri)
if parse_result.scheme != "s3":
if getattr(self.sagemaker_session, "local_mode", False) and parse_result.scheme == "file":
normalized_outputs.append(output)
continue
if _pipeline_config:
s3_uri = Join(
on="/",
Expand Down
255 changes: 255 additions & 0 deletions sagemaker-core/tests/unit/test_processing.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -238,6 +238,261 @@ def test_normalize_outputs_invalid_type(self, mock_session):
processor._normalize_outputs(["invalid"])




class TestBugConditionFileUriReplacedInLocalMode:
"""Bug condition exploration test: file:// URIs should be preserved in local mode.

**Validates: Requirements 1.1, 1.2, 2.1, 2.2**

EXPECTED TO FAIL on unfixed code — failure confirms the bug exists.
The bug is that _normalize_outputs() replaces file:// URIs with s3:// paths
even when the session is a LocalSession (local_mode=True).
"""

@pytest.fixture
def local_mock_session(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.mark.parametrize(
"file_uri",
[
"file:///tmp/output",
"file:///home/user/results",
"file:///data/processed",
],
)
def test_normalize_outputs_preserves_file_uri_in_local_mode(self, local_mock_session, file_uri):
"""file:// URIs must be preserved when local_mode=True.

On unfixed code, _normalize_outputs replaces file:// URIs with
s3://default-bucket/prefix/job-name/output/output-1, which is the bug.
"""
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=local_mock_session,
)
processor._current_job_name = "test-job"

s3_output = ProcessingS3Output(
s3_uri=file_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == file_uri, (
f"Expected file:// URI to be preserved as '{file_uri}' in local mode, "
f"but got '{result[0].s3_output.s3_uri}'"
)


class TestPreservationNonLocalFileBehavior:
"""Preservation property tests: Non-local-file behavior must remain unchanged.

**Validates: Requirements 3.1, 3.2, 3.3, 3.4**

These tests capture baseline behavior on UNFIXED code. They MUST PASS on both
unfixed and fixed code, confirming no regressions are introduced by the fix.
"""

@pytest.fixture
def session_local_mode_true(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = True
return session

@pytest.fixture
def session_local_mode_false(self):
session = Mock()
session.boto_session = Mock()
session.boto_session.region_name = "us-west-2"
session.sagemaker_client = Mock()
session.default_bucket = Mock(return_value="default-bucket")
session.default_bucket_prefix = "prefix"
session.expand_role = Mock(side_effect=lambda x: x)
session.sagemaker_config = {}
session.local_mode = False
return session

def _make_processor(self, session):
processor = Processor(
role="arn:aws:iam::123456789012:role/SageMakerRole",
image_uri="test-image:latest",
instance_count=1,
instance_type="ml.m5.xlarge",
sagemaker_session=session,
)
processor._current_job_name = "test-job"
return processor

# --- Requirement 3.1: S3 URIs pass through unchanged regardless of local_mode ---

@pytest.mark.parametrize(
"s3_uri,local_mode_fixture",
[
("s3://my-bucket/path", "session_local_mode_true"),
("s3://my-bucket/path", "session_local_mode_false"),
("s3://another-bucket/deep/nested/path", "session_local_mode_true"),
("s3://another-bucket/deep/nested/path", "session_local_mode_false"),
],
)
def test_s3_uri_preserved_regardless_of_local_mode(self, s3_uri, local_mode_fixture, request):
"""S3 URIs must pass through unchanged regardless of local_mode setting.

**Validates: Requirements 3.1**
"""
session = request.getfixturevalue(local_mode_fixture)
processor = self._make_processor(session)

s3_output = ProcessingS3Output(
s3_uri=s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="my-output", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri == s3_uri

# --- Requirement 3.2: Non-S3 URIs with local_mode=False replaced with S3 paths ---

@pytest.mark.parametrize(
"non_s3_uri",
[
"/local/output/path",
"http://example.com/output",
"ftp://server/output",
],
)
def test_non_s3_uri_replaced_when_not_local_mode(self, non_s3_uri, session_local_mode_false):
"""Non-S3 URIs in non-local sessions are replaced with auto-generated S3 paths.

**Validates: Requirements 3.2**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri=non_s3_uri,
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
assert result[0].s3_output.s3_uri.startswith("s3://default-bucket/")

# --- Requirement 3.3: Pipeline variable URIs skip normalization ---

def test_pipeline_variable_uri_skips_normalization(self, session_local_mode_false):
"""Pipeline variable URIs skip normalization entirely.

**Validates: Requirements 3.3**
"""
processor = self._make_processor(session_local_mode_false)

s3_output = ProcessingS3Output(
s3_uri="s3://bucket/output",
local_path="/opt/ml/processing/output",
s3_upload_mode="EndOfJob",
)
outputs = [ProcessingOutput(output_name="output-1", s3_output=s3_output)]

with patch("sagemaker.core.processing.is_pipeline_variable", return_value=True):
result = processor._normalize_outputs(outputs)

assert len(result) == 1
# Pipeline variable outputs are appended as-is without URI modification
assert result[0].s3_output.s3_uri == "s3://bucket/output"

# --- Requirement 3.4: Non-ProcessingOutput objects raise TypeError ---

@pytest.mark.parametrize(
"invalid_output",
[
["a string"],
[42],
[{"key": "value"}],
],
)
def test_non_processing_output_raises_type_error(self, invalid_output, session_local_mode_false):
"""Non-ProcessingOutput objects must raise TypeError.

**Validates: Requirements 3.4**
"""
processor = self._make_processor(session_local_mode_false)

with pytest.raises(TypeError, match="must be provided as ProcessingOutput objects"):
processor._normalize_outputs(invalid_output)

# --- Output name auto-generation ---

def test_multiple_outputs_with_s3_uris_preserved(self, session_local_mode_false):
"""Multiple outputs with S3 URIs are all preserved unchanged.

**Validates: Requirements 3.1, 3.2**
"""
processor = self._make_processor(session_local_mode_false)

outputs = [
ProcessingOutput(
output_name="first-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/first",
local_path="/opt/ml/processing/output1",
s3_upload_mode="EndOfJob",
),
),
ProcessingOutput(
output_name="second-output",
s3_output=ProcessingS3Output(
s3_uri="s3://my-bucket/second",
local_path="/opt/ml/processing/output2",
s3_upload_mode="EndOfJob",
),
),
]

with patch("sagemaker.core.workflow.utilities._pipeline_config", None):
result = processor._normalize_outputs(outputs)

assert len(result) == 2
assert result[0].output_name == "first-output"
assert result[1].output_name == "second-output"
# S3 URIs should be preserved since they already have s3:// scheme
assert result[0].s3_output.s3_uri == "s3://my-bucket/first"
assert result[1].s3_output.s3_uri == "s3://my-bucket/second"


class TestProcessorStartNew:
def test_start_new_with_pipeline_session(self, mock_session):
from sagemaker.core.workflow.pipeline_context import PipelineSession
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,13 +3,16 @@
from __future__ import absolute_import
from pathlib import Path
import logging
import os
from datetime import datetime, timedelta
from typing import Dict, Type
import base64
import time
import subprocess
import docker

from sagemaker.core.local.utils import check_for_studio

from sagemaker.serve.model_server.tensorflow_serving.server import LocalTensorflowServing
from sagemaker.serve.spec.inference_spec import InferenceSpec
from sagemaker.serve.builder.schema_builder import SchemaBuilder
Expand All@@ -33,6 +36,25 @@
+ "Please increase container_timeout_seconds or review your inference code."
)

STUDIO_DOCKER_SOCKET_PATHS = [
"/docker/proxy/docker.sock",
"/var/run/docker.sock",
]


def _get_docker_client():
"""Get a Docker client, handling SageMaker Studio's non-standard socket path."""
if os.environ.get("DOCKER_HOST"):
return docker.from_env()
try:
if check_for_studio():
for socket_path in STUDIO_DOCKER_SOCKET_PATHS:
if os.path.exists(socket_path):
return docker.DockerClient(base_url=f"unix://{socket_path}")
except (NotImplementedError, Exception):
pass
return docker.from_env()


class LocalContainerMode(
LocalTorchServe,
Expand DownExpand Up@@ -212,7 +234,7 @@ def _pull_image(self, image: str):

# Check if Docker is available first
try:
self.client = docker.from_env()
self.client = _get_docker_client()
self.client.ping() # Test Docker connection
except Exception as e:
raise RuntimeError(
Expand Down
13 changes: 11 additions & 2 deletions sagemaker-serve/src/sagemaker/serve/model_builder.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -133,7 +133,7 @@
ENDPOINT_CONFIG_ASYNC_KMS_KEY_ID_PATH,
MODEL_CONTAINERS_PATH,
)
from sagemaker.serve.constants import SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.serve.constants import LOCAL_MODES, SUPPORTED_MODEL_SERVERS, Framework
from sagemaker.core.workflow.pipeline_context import PipelineSession, runnable_by_pipeline
from sagemaker.core import fw_utils
from sagemaker.core.helper.session_helper import container_def
Expand DownExpand Up@@ -1287,7 +1287,16 @@ def _build_for_passthrough(self) -> Model:
if not self.image_uri:
raise ValueError("image_uri is required for pass-through cases")

self.s3_upload_path = None
self.secret_key = ""

if self.model_path and self.model_path.startswith("s3://"):
self.s3_upload_path = self.model_path
else:
self.s3_upload_path = None

if self.mode in LOCAL_MODES:
self._prepare_for_mode()

return self._create_model()

def _build_default_async_inference_config(self, async_inference_config):
Expand Down
Loading
Loading