From d3a247f0de91f847592c97c5446bb1c4dc508020 Mon Sep 17 00:00:00 2001 From: aviruthen <91846056+aviruthen@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:02:47 -0700 Subject: [PATCH 01/13] Integration tests for init experience (#242) * First draft integ tests * Mini fixes to ensure integ tests work * Allow integ tests to run from clean directory * Change torch job creation namespace to default --- src/sagemaker/hyperpod/cli/commands/init.py | 2 +- .../hyperpod/cli/constants/init_constants.py | 42 +- .../init/test_custom_creation.py | 288 ++++++++++ .../init/test_init_workflow.py | 506 ++++++++++++++++++ .../init/test_jumpstart_creation.py | 173 ++++++ .../init/test_pytorch_job_creation.py | 207 +++++++ test/integration_tests/init/utils.py | 75 +++ 7 files changed, 1271 insertions(+), 22 deletions(-) create mode 100644 test/integration_tests/init/test_custom_creation.py create mode 100644 test/integration_tests/init/test_init_workflow.py create mode 100644 test/integration_tests/init/test_jumpstart_creation.py create mode 100644 test/integration_tests/init/test_pytorch_job_creation.py create mode 100644 test/integration_tests/init/utils.py diff --git a/src/sagemaker/hyperpod/cli/commands/init.py b/src/sagemaker/hyperpod/cli/commands/init.py index f209e99d..eceba28e 100644 --- a/src/sagemaker/hyperpod/cli/commands/init.py +++ b/src/sagemaker/hyperpod/cli/commands/init.py @@ -386,7 +386,7 @@ def _default_create(region): data, template, version = load_config(dir_path) namespace = data.get("namespace", "default") registry = TEMPLATES[template]["registry"] - model = registry.get(version) + model = registry.get(str(version)) if model: # Filter out CLI metadata fields before passing to model from sagemaker.hyperpod.cli.init_utils import filter_cli_metadata_fields diff --git a/src/sagemaker/hyperpod/cli/constants/init_constants.py b/src/sagemaker/hyperpod/cli/constants/init_constants.py index d600b666..e5a061d4 100644 --- a/src/sagemaker/hyperpod/cli/constants/init_constants.py +++ b/src/sagemaker/hyperpod/cli/constants/init_constants.py @@ -13,27 +13,27 @@ CRD = "crd" CFN = "cfn" TEMPLATES = { - # "hyp-jumpstart-endpoint": { - # "registry": JS_REG, - # "schema_pkg": "hyperpod_jumpstart_inference_template", - # "schema_type": CRD, - # 'template': KUBERNETES_JS_ENDPOINT_TEMPLATE, - # 'type': "jinja" - # }, - # "hyp-custom-endpoint": { - # "registry": C_REG, - # "schema_pkg": "hyperpod_custom_inference_template", - # "schema_type": CRD, - # 'template': KUBERNETES_CUSTOM_ENDPOINT_TEMPLATE, - # 'type': "jinja" - # }, - # "hyp-pytorch-job": { - # "registry": P_REG, - # "schema_pkg": "hyperpod_pytorch_job_template", - # "schema_type": CRD, - # 'template': KUBERNETES_PYTORCH_JOB_TEMPLATE, - # 'type': "jinja" - # }, + "hyp-jumpstart-endpoint": { + "registry": JS_REG, + "schema_pkg": "hyperpod_jumpstart_inference_template", + "schema_type": CRD, + 'template': KUBERNETES_JS_ENDPOINT_TEMPLATE, + 'type': "jinja" + }, + "hyp-custom-endpoint": { + "registry": C_REG, + "schema_pkg": "hyperpod_custom_inference_template", + "schema_type": CRD, + 'template': KUBERNETES_CUSTOM_ENDPOINT_TEMPLATE, + 'type': "jinja" + }, + "hyp-pytorch-job": { + "registry": P_REG, + "schema_pkg": "hyperpod_pytorch_job_template", + "schema_type": CRD, + 'template': KUBERNETES_PYTORCH_JOB_TEMPLATE, + 'type': "jinja" + }, "cluster-stack": { "schema_pkg": "hyperpod_cluster_stack_template", "schema_type": CFN, diff --git a/test/integration_tests/init/test_custom_creation.py b/test/integration_tests/init/test_custom_creation.py new file mode 100644 index 00000000..3d6cc810 --- /dev/null +++ b/test/integration_tests/init/test_custom_creation.py @@ -0,0 +1,288 @@ +""" +End-to-end integration tests for init workflow with custom endpoint template. + +SAFETY WARNING: This test involves creating real AWS SageMaker endpoints. +Only run with proper cost controls and cleanup procedures in place. + +Tests complete user workflow: init -> configure -> validate -> create -> wait -> invoke -> delete. +Uses real AWS resources with cost implications. +""" +import time +import yaml +import pytest +import boto3 +from pathlib import Path +import os +import tempfile + +import sys +from unittest.mock import patch + +from test.integration_tests.init.utils import ( + assert_command_succeeded, + assert_init_files_created, + assert_config_values, +) + +from click.testing import CliRunner +from sagemaker.hyperpod.cli.commands.inference import custom_invoke +from sagemaker.hyperpod.cli.commands.init import init, configure, validate, _default_create as create +from sagemaker.hyperpod.cli.hyp_cli import delete +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint +from test.integration_tests.utils import get_time_str + +# --------- Test Configuration --------- +NAMESPACE = "default" +VERSION = "1.0" +REGION = "us-east-2" +TIMEOUT_MINUTES = 15 +POLL_INTERVAL_SECONDS = 30 + +BETA_BUCKET = "sagemaker-hyperpod-beta-integ-test-model-bucket-n" +PROD_BUCKET = "sagemaker-hyperpod-prod-integ-test-model-bucket" +stage = os.getenv("STAGE", "BETA").upper() +BUCKET_LOCATION = BETA_BUCKET if stage == "BETA" else PROD_BUCKET + +@pytest.fixture(scope="module") +def runner(): + return CliRunner() + +@pytest.fixture(scope="module") +def custom_endpoint_name(): + return "custom-cli-integration-" + get_time_str() + +@pytest.fixture(scope="module") +def sagemaker_client(): + return boto3.client("sagemaker", region_name=REGION) + + +@pytest.fixture(scope="module") +def test_directory(): + """Create a temporary directory for test isolation.""" + with tempfile.TemporaryDirectory() as temp_dir: + original_cwd = os.getcwd() + os.chdir(temp_dir) + try: + yield temp_dir + finally: + os.chdir(original_cwd) + + +# --------- Custom Endpoint Tests --------- +@pytest.mark.dependency(name="init") +def test_init_custom(runner, custom_endpoint_name, test_directory): + """Initialize custom endpoint template and verify file creation.""" + result = runner.invoke( + init, ["hyp-custom-endpoint", "."], catch_exceptions=False + ) + assert_command_succeeded(result) + assert_init_files_created("./", "hyp-custom-endpoint") + + +@pytest.mark.dependency(name="configure", depends=["init"]) +def test_configure_custom(runner, custom_endpoint_name, test_directory): + """Configure custom endpoint with S3 model source and verify config persistence.""" + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init + importlib.reload(init) + configure = init.configure + + result = runner.invoke( + configure, [ + # Required fields + "--endpoint-name", custom_endpoint_name, + "--model-name", "test-pytorch-model", + "--instance-type", "ml.c5.2xlarge", + "--model-source-type", "s3", + "--image-uri", "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04", + "--container-port", "8080", + "--model-volume-mount-name", "model-weights", + + # S3-specific required fields + "--s3-bucket-name", BUCKET_LOCATION, + "--model-location", "hf-eqa", + "--s3-region", REGION, + + # Optional Params, but likely needed + "--env", '{ "SAGEMAKER_PROGRAM": "inference.py", "SAGEMAKER_SUBMIT_DIRECTORY": "/opt/ml/model/code", "SAGEMAKER_CONTAINER_LOG_LEVEL": "20", "SAGEMAKER_MODEL_SERVER_TIMEOUT": "3600", "ENDPOINT_SERVER_TIMEOUT": "3600", "MODEL_CACHE_ROOT": "/opt/ml/model", "SAGEMAKER_ENV": "1", "SAGEMAKER_MODEL_SERVER_WORKERS": "1" }', + "--resources-requests", '{"cpu": "3200m", "nvidia.com/gpu": 0, "memory": "12Gi"}', + "--resources-limits", '{"cpu": "3200m", "memory": "12Gi", "nvidia.com/gpu": 0}', + ], catch_exceptions=False + ) + assert_command_succeeded(result) + + # Verify configuration was saved correctly + expected_config = { + # Required fields + "endpoint_name": custom_endpoint_name, + "model_name": "test-pytorch-model", + "instance_type": "ml.c5.2xlarge", + "model_source_type": "s3", + "image_uri": "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04", + "container_port": 8080, + "model_volume_mount_name": "model-weights", + + # S3-specific required fields + "s3_bucket_name": BUCKET_LOCATION, + "model_location": "hf-eqa", + "s3_region": REGION, + + # Optional Params, but likely needed + "env": {'SAGEMAKER_PROGRAM': 'inference.py', 'SAGEMAKER_SUBMIT_DIRECTORY': '/opt/ml/model/code', 'SAGEMAKER_CONTAINER_LOG_LEVEL': '20', 'SAGEMAKER_MODEL_SERVER_TIMEOUT': '3600', 'ENDPOINT_SERVER_TIMEOUT': '3600', 'MODEL_CACHE_ROOT': '/opt/ml/model', 'SAGEMAKER_ENV': '1', 'SAGEMAKER_MODEL_SERVER_WORKERS': '1'}, + "resources_requests": {'cpu': '3200m', 'nvidia.com/gpu': 0, 'memory': '12Gi'}, + "resources_limits": {'cpu': '3200m', 'memory': '12Gi', 'nvidia.com/gpu': 0}, + } + assert_config_values("./", expected_config) + + +@pytest.mark.dependency(name="validate", depends=["configure", "init"]) +def test_validate_custom(runner, custom_endpoint_name, test_directory): + """Validate custom endpoint configuration for correctness.""" + result = runner.invoke(validate, [], catch_exceptions=False) + assert_command_succeeded(result) + + +@pytest.mark.dependency(name="create", depends=["validate", "configure", "init"]) +def test_create_custom(runner, custom_endpoint_name, test_directory): + """Create custom endpoint for deployment and verify template rendering.""" + result = runner.invoke(create, [], catch_exceptions=False) + assert_command_succeeded(result) + + # Verify expected submission messages appear + assert "Configuration is valid!" in result.output + assert "Submitted!" in result.output + assert "Creating sagemaker model and endpoint" in result.output + assert custom_endpoint_name in result.output + assert "The process may take a few minutes" in result.output + + +@pytest.mark.dependency(name="wait", depends=["create"]) +def test_wait_until_inservice(custom_endpoint_name, test_directory): + """Poll SDK until specific JumpStart endpoint reaches DeploymentComplete""" + print(f"[INFO] Waiting for JumpStart endpoint '{custom_endpoint_name}' to be DeploymentComplete...") + deadline = time.time() + (TIMEOUT_MINUTES * 60) + poll_count = 0 + + while time.time() < deadline: + poll_count += 1 + print(f"[DEBUG] Poll #{poll_count}: Checking endpoint status...") + + try: + ep = HPEndpoint.get(name=custom_endpoint_name, namespace=NAMESPACE) + state = ep.status.endpoints.sagemaker.state + print(f"[DEBUG] Current state: {state}") + if state == "CreationCompleted": + print("[INFO] Endpoint is in CreationCompleted state.") + return + + deployment_state = ep.status.deploymentStatus.deploymentObjectOverallState + if deployment_state == "DeploymentFailed": + pytest.fail("Endpoint deployment failed.") + + except Exception as e: + print(f"[ERROR] Exception during polling: {e}") + + time.sleep(POLL_INTERVAL_SECONDS) + + pytest.fail("[ERROR] Timed out waiting for endpoint to be DeploymentComplete") + + +@pytest.mark.dependency(name="invoke", depends=["wait"]) +def test_custom_invoke(runner, custom_endpoint_name, test_directory): + result = runner.invoke(custom_invoke, [ + "--endpoint-name", custom_endpoint_name, + "--body", '{"question" :"what is the name of the planet?", "context":"mars"}', + "--content-type", "application/list-text" + ]) + assert result.exit_code == 0 + assert "error" not in result.output.lower() + + +@pytest.mark.dependency(depends=["invoke"]) +def test_custom_delete(runner, custom_endpoint_name, test_directory): + """Clean up deployed custom endpoint using CLI delete command.""" + result = runner.invoke(delete, [ + "hyp-custom-endpoint", + "--name", custom_endpoint_name, + "--namespace", NAMESPACE + ]) + assert_command_succeeded(result) + + + + + + + + + + + +################################ OLD CONFIG FN ######################################## +# @pytest.mark.dependency(name="configure", depends=["init"]) +# def test_configure_custom(runner, custom_endpoint_name): +# with patch.object(sys, 'argv', ['hyp', 'configure']): +# import importlib +# from sagemaker.hyperpod.cli.commands import init +# importlib.reload(init) +# configure = init.configure +# """Configure custom endpoint with S3 model source and verify config persistence.""" +# result = runner.invoke( +# configure, [ +# # "--endpoint-name", custom_endpoint_name, +# # "--model-name", "test-pytorch-model", +# # "--instance-type", "ml.g5.8xlarge", +# # "--image-uri", "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-inference:latest", +# # "--container-port", "8080", +# # "--model-source-type", "s3", +# # "--s3-bucket-name", "sagemaker-test-bucket", +# # "--model-location", "models/test-pytorch-model.tar.gz", +# # "--s3-region", "us-east-1", +# "--namespace", NAMESPACE, +# "--version", VERSION, +# "--instance-type", "ml.c5.2xlarge", +# "--model-name", "test-model-integration-cli-s3", +# "--model-source-type", "s3", +# "--model-location", "hf-eqa", +# "--s3-bucket-name", BUCKET_LOCATION, +# "--s3-region", REGION, +# "--image-uri", "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04", +# "--container-port", "8080", +# "--model-volume-mount-name", "model-weights", +# "--endpoint-name", custom_endpoint_name, +# "--resources-requests", '{"cpu": "3200m", "nvidia.com/gpu": 0, "memory": "12Gi"}', +# "--resources-limits", '{"nvidia.com/gpu": 0}', +# "--env", '{ "SAGEMAKER_PROGRAM": "inference.py", "SAGEMAKER_SUBMIT_DIRECTORY": "/opt/ml/model/code", "SAGEMAKER_CONTAINER_LOG_LEVEL": "20", "SAGEMAKER_MODEL_SERVER_TIMEOUT": "3600", "ENDPOINT_SERVER_TIMEOUT": "3600", "MODEL_CACHE_ROOT": "/opt/ml/model", "SAGEMAKER_ENV": "1", "SAGEMAKER_MODEL_SERVER_WORKERS": "1" }' +# ], catch_exceptions=False +# ) +# assert_command_succeeded(result) + +# # Verify configuration was saved correctly +# expected_config = { +# # "endpoint_name": custom_endpoint_name, +# # "model_name": "test-pytorch-model", +# # "instance_type": "ml.g5.8xlarge", +# # "image_uri": "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-inference:latest", +# # "container_port": "8080", +# # "model_source_type": "s3", +# # "s3_bucket_name": "sagemaker-test-bucket", +# # "model_location": "models/test-pytorch-model.tar.gz", +# # "s3_region": "us-east-1", +# "namespace": NAMESPACE, +# "version": VERSION, +# "instance-type": "ml.c5.2xlarge", +# "model-name": "test-model-integration-cli-s3", +# "model-source-type": "s3", +# "model-location": "hf-eqa", +# "s3-bucket-name": BUCKET_LOCATION, +# "s3-region": REGION, +# "image-uri": "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04", +# "container-port": "8080", +# "model-volume-mount-name": "model-weights", +# "endpoint-name": custom_endpoint_name, +# "resources-requests": '{"cpu": "3200m", "nvidia.com/gpu": 0, "memory": "12Gi"}', +# "resources-limits": '{"nvidia.com/gpu": 0}', +# "env": '{ "SAGEMAKER_PROGRAM": "inference.py", "SAGEMAKER_SUBMIT_DIRECTORY": "/opt/ml/model/code", "SAGEMAKER_CONTAINER_LOG_LEVEL": "20", "SAGEMAKER_MODEL_SERVER_TIMEOUT": "3600", "ENDPOINT_SERVER_TIMEOUT": "3600", "MODEL_CACHE_ROOT": "/opt/ml/model", "SAGEMAKER_ENV": "1", "SAGEMAKER_MODEL_SERVER_WORKERS": "1" }' +# } +# assert_config_values("./", expected_config) diff --git a/test/integration_tests/init/test_init_workflow.py b/test/integration_tests/init/test_init_workflow.py new file mode 100644 index 00000000..db2e3400 --- /dev/null +++ b/test/integration_tests/init/test_init_workflow.py @@ -0,0 +1,506 @@ +""" +Integration tests for init workflow commands. + +Tests cross-command workflows, template rendering, and file system interactions. +Does NOT test CLI argument parsing or basic error handling (covered by unit tests). +""" +import yaml +import os +from pathlib import Path +from contextlib import contextmanager +import pytest +from click.testing import CliRunner + +from sagemaker.hyperpod.cli.commands.init import init, configure, validate, reset +from test.integration_tests.init.utils import ( + assert_command_succeeded, + assert_command_failed_with_helpful_error, + assert_config_values, + assert_warning_displayed, + assert_yes_no_prompt_displayed, + assert_success_message_displayed, +) + + +@contextmanager +def change_directory(path): + """Context manager for safely changing directories in tests.""" + old_cwd = os.getcwd() + try: + os.chdir(path) + yield + finally: + os.chdir(old_cwd) + + +@pytest.fixture +def runner(): + """CLI test runner for invoking commands.""" + return CliRunner() + + +@pytest.fixture +def temp_dir(tmp_path): + """Create a temporary directory for test files.""" + return str(tmp_path) + + +class TestConfigurationValidation: + """Test configuration validation and error handling.""" + + def test_invalid_instance_type_validation(self, temp_dir, runner): + """Test that invalid instance types are caught during configure.""" + # Initialize jumpstart template + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + # Configure with invalid instance type - should fail immediately + result2 = runner.invoke( + configure_cmd, [ + "--model-id", "test-model", + "--instance-type", "invalid.instance.type", + "--endpoint-name", "test-endpoint" + ], catch_exceptions=False + ) + + # Configure should fail with helpful error about instance type + assert_command_failed_with_helpful_error(result2, ["instance", "ml"]) + + def test_invalid_model_id_validation(self, temp_dir, runner): + """Test that model ID configuration works (validation is lenient).""" + # Initialize jumpstart template + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + # Configure with any model ID (validation is lenient) + result2 = runner.invoke( + configure_cmd, [ + "--model-id", "nonexistent-invalid-model-id-12345", + "--instance-type", "ml.g5.xlarge", + "--endpoint-name", "test-endpoint" + ], catch_exceptions=False + ) + assert_command_succeeded(result2) + + # Validate should pass (model ID validation is lenient) + result3 = runner.invoke(validate, [], catch_exceptions=False) + + # Validation should pass with current lenient behavior + assert_command_succeeded(result3) + assert_success_message_displayed(result3, ["✔️", "valid"]) + + def test_custom_s3_parameters_required(self, temp_dir, runner): + """Test that required parameters are validated for custom endpoints.""" + # Initialize custom template + result1 = runner.invoke( + init, ["hyp-custom-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + result3 = None + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + # Configure with S3 source but missing required fields + result2 = runner.invoke( + configure_cmd, [ + "--endpoint-name", "test-endpoint", + "--model-name", "test-model", + "--instance-type", "ml.g5.xlarge", + "--image-uri", "test-image:latest", + "--model-source-type", "s3" + # Missing container_port and model_volume_mount_name + ], catch_exceptions=False + ) + assert_command_succeeded(result2) # Configure should accept it + + # Validate should catch missing required parameters + result3 = runner.invoke(validate, [], catch_exceptions=False) + + # Validation should fail with helpful error about missing required params + if result3 is not None: + assert_command_failed_with_helpful_error(result3, ["container_port", "model_volume_mount_name"]) + + def test_custom_fsx_parameters_required(self, temp_dir, runner): + """Test that required parameters are validated for custom endpoints with FSx.""" + # Initialize custom template + result1 = runner.invoke( + init, ["hyp-custom-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + result3 = None + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + # Configure with FSx source but missing required fields + result2 = runner.invoke( + configure_cmd, [ + "--endpoint-name", "test-endpoint", + "--model-name", "test-model", + "--instance-type", "ml.g5.xlarge", + "--image-uri", "test-image:latest", + "--model-source-type", "fsx" + # Missing container_port and model_volume_mount_name + ], catch_exceptions=False + ) + assert_command_succeeded(result2) # Configure should accept it + + # Validate should catch missing required parameters + result3 = runner.invoke(validate, [], catch_exceptions=False) + + # Validation should fail with helpful error about missing required params + if result3 is not None: + assert_command_failed_with_helpful_error(result3, ["container_port", "model_volume_mount_name"]) + + def test_custom_s3_complete_configuration_validates(self, temp_dir, runner): + """Test that complete custom endpoint configuration passes validation.""" + # Initialize custom template + result1 = runner.invoke( + init, ["hyp-custom-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + # Configure with complete parameters including required fields + result2 = runner.invoke( + configure_cmd, [ + "--endpoint-name", "test-endpoint", + "--model-name", "test-model", + "--instance-type", "ml.g5.xlarge", + "--image-uri", "test-image:latest", + "--model-source-type", "s3", + "--s3-bucket-name", "test-bucket", + "--model-location", "models/test-model.tar.gz", + "--s3-region", "us-east-2", + "--container-port", "8080", + "--model-volume-mount-name", "model-volume" + ], catch_exceptions=False + ) + assert_command_succeeded(result2) + + # Validate should succeed with complete config + result3 = runner.invoke(validate, [], catch_exceptions=False) + + # Validation should pass + assert_command_succeeded(result3) + assert_success_message_displayed(result3, ["✔️", "valid"]) + + +class TestInitEdgeCases: + """Test edge cases for init command - double init scenarios.""" + + def test_double_init_same_template_prompts_reset(self, temp_dir, runner): + """Test that re-running init with same template prompts for reset.""" + # First init + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + # Second init with same template - should prompt for reset + result2 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], + input="n\n", # Answer 'no' to override prompt + catch_exceptions=False + ) + + # Use helper functions for better validation + assert_warning_displayed(result2, ["already initialized", "override"]) + assert_yes_no_prompt_displayed(result2) + assert "aborting init" in result2.output.lower() + + def test_double_init_same_template_accepts_override(self, temp_dir, runner): + """Test that accepting override re-initializes successfully.""" + # First init + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + # Second init with same template - accept override + result2 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], + input="y\n", # Answer 'yes' to override prompt + catch_exceptions=False + ) + + # Use helper functions for validation + assert_warning_displayed(result2, ["already initialized", "override", "overriding config.yaml"]) + assert_yes_no_prompt_displayed(result2) + assert_command_succeeded(result2) + + def test_double_init_different_template_warns_user(self, temp_dir, runner): + """Test that re-running init with different template shows strong warning.""" + # First init with jumpstart + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + # Second init with custom - should warn strongly + result2 = runner.invoke( + init, ["hyp-custom-endpoint", temp_dir], + input="n\n", # Answer 'no' to re-initialize prompt + catch_exceptions=False + ) + + # Use helper functions for comprehensive validation + assert_warning_displayed(result2, [ + "already initialized as", + "highly unrecommended", + "recommended path is create a new folder" + ]) + assert_yes_no_prompt_displayed(result2) + assert "aborting init" in result2.output.lower() + + def test_double_init_different_template_accepts_reinit(self, temp_dir, runner): + """Test that accepting re-init with different template works.""" + # First init with jumpstart + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + # Second init with custom - accept re-initialization + result2 = runner.invoke( + init, ["hyp-custom-endpoint", temp_dir], + input="y\n", # Answer 'yes' to re-initialize prompt + catch_exceptions=False + ) + + # Use helper functions for validation + assert_warning_displayed(result2, [ + "already initialized as", + "highly unrecommended", + "re-initializing" + ]) + assert_yes_no_prompt_displayed(result2) + assert_command_succeeded(result2) + + # Verify config was changed to new template + assert_config_values(temp_dir, {"template": "hyp-custom-endpoint"}) + + +class TestResetFunctionality: + """Test reset command functionality and integration with other commands.""" + + def test_reset_clears_config_to_defaults(self, temp_dir, runner): + """Test that reset command clears config back to default values.""" + # Initialize and configure + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + result2 = runner.invoke( + configure_cmd, [ + "--model-id", "test-model", + "--instance-type", "ml.g5.xlarge", + "--endpoint-name", "test-endpoint" + ], catch_exceptions=False + ) + assert_command_succeeded(result2) + + # Verify config has values + assert_config_values(temp_dir, { + "model_id": "test-model", + "instance_type": "ml.g5.xlarge", + "endpoint_name": "test-endpoint" + }) + + # Reset config + result3 = runner.invoke(reset, [], catch_exceptions=False) + assert_command_succeeded(result3) + + # Verify config was reset (template should remain) + config_path = Path(temp_dir) / "config.yaml" + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + assert config.get('template') == "hyp-jumpstart-endpoint", "Template should be preserved" + # Other fields should be reset to defaults (None or empty) + assert config.get('model_id') is None or config.get('model_id') == "" + assert config.get('endpoint_name') is None or config.get('endpoint_name') == "" + + def test_reset_and_reconfigure_workflow(self, temp_dir, runner): + """Test reset -> reconfigure workflow.""" + # Initialize and configure + result1 = runner.invoke( + init, ["hyp-jumpstart-endpoint", temp_dir], catch_exceptions=False + ) + assert_command_succeeded(result1) + + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + result2 = runner.invoke( + configure_cmd, [ + "--model-id", "original-model", + "--instance-type", "ml.g5.xlarge" + ], catch_exceptions=False + ) + assert_command_succeeded(result2) + assert_config_values(temp_dir, { + "model_id": "original-model", + "instance_type": "ml.g5.xlarge" + }) + + # Reset configuration + with change_directory(temp_dir): + result3 = runner.invoke( + reset, [], input="y\n", catch_exceptions=False + ) + assert_command_succeeded(result3) + + # Verify config is reset (should have empty/default values) + config_path = Path(temp_dir) / "config.yaml" + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + # Config should exist but values should be reset + assert config.get('model_id') in [None, ""] + assert config.get('instance_type') in [None, ""] + + # Reconfigure with new values + with change_directory(temp_dir): + # Add sys.argv patching for configure command + import sys + from unittest.mock import patch + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init as init_module + importlib.reload(init_module) + configure_cmd = init_module.configure + + result4 = runner.invoke( + configure_cmd, [ + "--model-id", "new-model", + "--instance-type", "ml.g5.2xlarge", + "--endpoint-name", "new-endpoint" + ], catch_exceptions=False + ) + assert_command_succeeded(result4) + assert_config_values(temp_dir, { + "model_id": "new-model", + "instance_type": "ml.g5.2xlarge", + "endpoint_name": "new-endpoint" + }) + + # Validate new configuration + with change_directory(temp_dir): + result5 = runner.invoke(validate, [], catch_exceptions=False) + assert_command_succeeded(result5) + + +# class TestClusterWorkflowValidation: +# """File validation tests for cluster workflow.""" + +# def test_cluster_generated_cfn_params_structure(self, temp_dir, runner): +# """Verify generated CloudFormation parameters have correct structure for cluster.""" +# # Initialize cluster template +# result = runner.invoke(init, ["hyp-cluster", temp_dir], catch_exceptions=False) +# assert_command_succeeded(result) + +# # Configure with basic parameters +# old_cwd = os.getcwd() +# try: +# os.chdir(temp_dir) +# result = runner.invoke(configure, ["--resource-name-prefix", "test-cluster"], catch_exceptions=False) +# assert_command_succeeded(result) + +# result = runner.invoke(create, [], catch_exceptions=False) +# assert_command_succeeded(result) +# finally: +# os.chdir(old_cwd) + + +# def test_cluster_config_file_structure(self, temp_dir, runner): +# """Verify config.yaml has correct structure after cluster workflow.""" +# # Initialize cluster template +# result = runner.invoke(init, ["hyp-cluster", temp_dir], catch_exceptions=False) +# assert_command_succeeded(result) + +# # Configure with basic parameters +# old_cwd = os.getcwd() +# try: +# os.chdir(temp_dir) +# result = runner.invoke(configure, ["--resource-name-prefix", "test-cluster"], catch_exceptions=False) +# assert_command_succeeded(result) +# finally: +# os.chdir(old_cwd) + +# # Verify config file structure +# config_path = Path(temp_dir) / "config.yaml" +# assert config_path.exists(), "config.yaml should exist" + +# with open(config_path, 'r') as f: +# config = yaml.safe_load(f) + +# # Verify required config fields are present +# assert config.get('template'), "template field should be present" +# assert config.get('resource_name_prefix'), "resource_name_prefix should be configured" \ No newline at end of file diff --git a/test/integration_tests/init/test_jumpstart_creation.py b/test/integration_tests/init/test_jumpstart_creation.py new file mode 100644 index 00000000..84cea135 --- /dev/null +++ b/test/integration_tests/init/test_jumpstart_creation.py @@ -0,0 +1,173 @@ +""" +End-to-end integration tests for init workflow with JumpStart endpoint template. + +SAFETY WARNING: This test involves creating real AWS SageMaker endpoints. +Only run with proper cost controls and cleanup procedures in place. + +Tests complete user workflow: init -> configure -> validate -> create -> wait -> delete. +Uses real AWS resources with cost implications. +""" +import time +import yaml +import pytest +import boto3 +from pathlib import Path +import tempfile +import os + +import sys +from unittest.mock import patch + +from click.testing import CliRunner +from sagemaker.hyperpod.cli.commands.inference import custom_invoke +from sagemaker.hyperpod.cli.commands.init import init, validate, _default_create as create +from sagemaker.hyperpod.cli.hyp_cli import delete +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from test.integration_tests.init.utils import ( + assert_command_succeeded, + assert_init_files_created, + assert_config_values, +) +from test.integration_tests.utils import get_time_str + +# --------- Test Configuration --------- +NAMESPACE = "default" +VERSION = "1.0" +REGION = "us-east-2" +TIMEOUT_MINUTES = 15 +POLL_INTERVAL_SECONDS = 30 + +@pytest.fixture(scope="module") +def runner(): + """CLI test runner for invoking commands.""" + return CliRunner() + + +@pytest.fixture(scope="module") +def js_endpoint_name(): + """Generate unique JumpStart endpoint name with timestamp.""" + return "js-cli-integration-" + get_time_str() + + +@pytest.fixture(scope="module") +def sagemaker_client(): + """AWS SageMaker client for resource verification.""" + return boto3.client("sagemaker", region_name=REGION) + + +@pytest.fixture(scope="module") +def test_directory(): + """Create a temporary directory for test isolation.""" + with tempfile.TemporaryDirectory() as temp_dir: + original_cwd = os.getcwd() + os.chdir(temp_dir) + try: + yield temp_dir + finally: + os.chdir(original_cwd) + + +# --------- JumpStart Tests --------- +@pytest.mark.dependency(name="init") +def test_init_jumpstart(runner, js_endpoint_name, test_directory): + """Initialize JumpStart endpoint template and verify file creation.""" + result = runner.invoke( + init, ["hyp-jumpstart-endpoint", "."], catch_exceptions=False + ) + assert_command_succeeded(result) + assert_init_files_created("./", "hyp-jumpstart-endpoint") + + +@pytest.mark.dependency(name="configure", depends=["init"]) +def test_configure_jumpstart(runner, js_endpoint_name, test_directory): + """Configure JumpStart endpoint with model parameters and verify config persistence.""" + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init + importlib.reload(init) + configure = init.configure + result = runner.invoke( + configure, [ + "--model-id", "deepseek-llm-r1-distill-qwen-1-5b", + "--instance-type", "ml.g5.8xlarge", + "--endpoint-name", js_endpoint_name + ], catch_exceptions=False + ) + assert_command_succeeded(result) + + # Verify configuration was saved correctly + expected_config = { + "model_id": "deepseek-llm-r1-distill-qwen-1-5b", + "instance_type": "ml.g5.8xlarge", + "endpoint_name": js_endpoint_name + } + assert_config_values("./", expected_config) + + +@pytest.mark.dependency(name="validate", depends=["configure", "init"]) +def test_validate_jumpstart(runner, js_endpoint_name, test_directory): + """Validate JumpStart endpoint configuration for correctness.""" + result = runner.invoke(validate, [], catch_exceptions=False) + assert_command_succeeded(result) + + +@pytest.mark.dependency(name="create", depends=["validate", "configure", "init"]) +def test_create_jumpstart(runner, js_endpoint_name, test_directory): + """Create JumpStart endpoint for deployment and verify template rendering.""" + result = runner.invoke(create, ["--region", REGION], catch_exceptions=False) + assert_command_succeeded(result) + + assert "Configuration is valid!" in result.output + assert "Submitted!" in result.output + + +@pytest.mark.dependency(name="wait", depends=["create"]) +def test_wait_until_inservice(js_endpoint_name, test_directory): + """Poll SDK until specific JumpStart endpoint reaches DeploymentComplete""" + print(f"[INFO] Waiting for JumpStart endpoint '{js_endpoint_name}' to be DeploymentComplete...") + deadline = time.time() + (TIMEOUT_MINUTES * 60) + poll_count = 0 + + while time.time() < deadline: + poll_count += 1 + print(f"[DEBUG] Poll #{poll_count}: Checking endpoint status...") + + try: + ep = HPJumpStartEndpoint.get(name=js_endpoint_name, namespace=NAMESPACE) + state = ep.status.endpoints.sagemaker.state + print(f"[DEBUG] Current state: {state}") + if state == "CreationCompleted": + print("[INFO] Endpoint is in CreationCompleted state.") + return + + deployment_state = ep.status.deploymentStatus.deploymentObjectOverallState + if deployment_state == "DeploymentFailed": + pytest.fail("Endpoint deployment failed.") + + except Exception as e: + print(f"[ERROR] Exception during polling: {e}") + + time.sleep(POLL_INTERVAL_SECONDS) + + pytest.fail("[ERROR] Timed out waiting for endpoint to be DeploymentComplete") + + +@pytest.mark.dependency(name="invoke", depends=["wait"]) +def test_custom_invoke(runner, js_endpoint_name, test_directory): + result = runner.invoke(custom_invoke, [ + "--endpoint-name", js_endpoint_name, + "--body", '{"inputs": "What is the capital of USA?"}' + ]) + assert result.exit_code == 0 + assert "error" not in result.output.lower() + + +@pytest.mark.dependency(depends=["invoke"]) +def test_js_delete(runner, js_endpoint_name, test_directory): + """Clean up deployed JumpStart endpoint using CLI delete command.""" + result = runner.invoke(delete, [ + "hyp-jumpstart-endpoint", + "--name", js_endpoint_name, + "--namespace", NAMESPACE + ]) + assert_command_succeeded(result) diff --git a/test/integration_tests/init/test_pytorch_job_creation.py b/test/integration_tests/init/test_pytorch_job_creation.py new file mode 100644 index 00000000..740f2ebe --- /dev/null +++ b/test/integration_tests/init/test_pytorch_job_creation.py @@ -0,0 +1,207 @@ +""" +End-to-end integration tests for init workflow with PyTorch job template. + +SAFETY WARNING: This test involves creating real PyTorch training jobs on HyperPod clusters. +Only run with proper cost controls and cleanup procedures in place. + +Tests complete user workflow: init -> configure -> validate -> create -> wait -> delete. +Uses real AWS resources with cost implications. +""" +import time +import yaml +import pytest +import boto3 +from pathlib import Path +import tempfile +import os + +import sys +from unittest.mock import patch + +from click.testing import CliRunner +from sagemaker.hyperpod.cli.commands.init import init, configure, validate, _default_create as create +from sagemaker.hyperpod.cli.hyp_cli import delete +from sagemaker.hyperpod.training.hyperpod_pytorch_job import HyperPodPytorchJob +from test.integration_tests.init.utils import ( + assert_command_succeeded, + assert_init_files_created, + assert_config_values, +) +from test.integration_tests.utils import get_time_str, execute_command + +# --------- Test Configuration --------- +NAMESPACE = "default" +VERSION = "1.0" +REGION = "us-east-2" +TIMEOUT_MINUTES = 10 +POLL_INTERVAL_SECONDS = 30 + +@pytest.fixture(scope="module") +def runner(): + """CLI test runner for invoking commands.""" + return CliRunner() + + +@pytest.fixture(scope="module") +def pytorch_job_name(): + """Generate unique PyTorch job name with timestamp.""" + return "torch-integ-" + get_time_str() + + +@pytest.fixture(scope="module") +def sagemaker_client(): + """AWS SageMaker client for resource verification.""" + return boto3.client("sagemaker", region_name=REGION) + + +@pytest.fixture(scope="module") +def test_directory(): + """Create a temporary directory for test isolation.""" + with tempfile.TemporaryDirectory() as temp_dir: + original_cwd = os.getcwd() + os.chdir(temp_dir) + try: + yield temp_dir + finally: + os.chdir(original_cwd) + + +# # --------- PyTorch Job Tests --------- +@pytest.mark.dependency(name="init") +def test_init_pytorch_job(runner, pytorch_job_name, test_directory): + """Initialize PyTorch job template and verify file creation.""" + result = runner.invoke( + init, ["hyp-pytorch-job", "."], catch_exceptions=False + ) + assert_command_succeeded(result) + assert_init_files_created("./", "hyp-pytorch-job") + + +@pytest.mark.dependency(name="configure", depends=["init"]) +def test_configure_pytorch_job(runner, pytorch_job_name, test_directory): + """Configure PyTorch job with training parameters and verify config persistence.""" + with patch.object(sys, 'argv', ['hyp', 'configure']): + import importlib + from sagemaker.hyperpod.cli.commands import init + importlib.reload(init) + configure = init.configure + + result = runner.invoke( + configure, [ + # Required fields only + "--job-name", pytorch_job_name, + "--image", "pytorch/pytorch:2.0.1-cuda11.7-cudnn8-devel", + "--command", '["python", "-c", "import torch; print(torch.__version__); import time; time.sleep(3600)"]', + ], catch_exceptions=False + ) + assert_command_succeeded(result) + + # Simplified expected_config + expected_config = { + "job_name": pytorch_job_name, + "image": "pytorch/pytorch:2.0.1-cuda11.7-cudnn8-devel", + "command": ["python", "-c", "import torch; print(torch.__version__); import time; time.sleep(3600)"], + } + assert_config_values("./", expected_config) + + +@pytest.mark.dependency(name="validate", depends=["configure", "init"]) +def test_validate_pytorch_job(runner, pytorch_job_name, test_directory): + """Validate PyTorch job configuration for correctness.""" + result = runner.invoke(validate, [], catch_exceptions=False) + assert_command_succeeded(result) + + +@pytest.mark.dependency(name="create", depends=["validate", "configure", "init"]) +def test_create_pytorch_job(runner, pytorch_job_name, test_directory): + """Create PyTorch job for deployment and verify template rendering.""" + result = runner.invoke(create, [], catch_exceptions=False) + assert_command_succeeded(result) + + # Verify expected submission messages appear + assert "Configuration is valid!" in result.output + assert "Submitted!" in result.output + assert "Successfully submitted HyperPodPytorchJob" in result.output + assert pytorch_job_name in result.output + + +@pytest.mark.dependency(name="wait", depends=["create"]) +def test_wait_for_job_running(pytorch_job_name, test_directory): + """Poll SDK until PyTorch job reaches Running state.""" + print(f"[INFO] Waiting for PyTorch job '{pytorch_job_name}' to be Running...") + deadline = time.time() + (TIMEOUT_MINUTES * 60) + poll_count = 0 + + while time.time() < deadline: + poll_count += 1 + print(f"[DEBUG] Poll #{poll_count}: Checking job status...") + + try: + job = HyperPodPytorchJob.get(name=pytorch_job_name, namespace=NAMESPACE) + if job.status and hasattr(job.status, 'conditions'): + # Check for Running condition + for condition in job.status.conditions: + if condition.type in ["PodsRunning", "Running"] and condition.status == "True": + print(f"[INFO] Job {pytorch_job_name} is now Running") + return + elif condition.type == "Failed" and condition.status == "True": + pytest.fail(f"Job {pytorch_job_name} failed: {condition.reason}") + + print(f"[DEBUG] Job status conditions: {[c.type for c in job.status.conditions]}") + else: + print(f"[DEBUG] Job status not yet available") + + except Exception as e: + print(f"[DEBUG] Exception during polling: {e}") + + time.sleep(POLL_INTERVAL_SECONDS) + + pytest.fail(f"[ERROR] Timed out waiting for job {pytorch_job_name} to be Running") + + +@pytest.mark.dependency(name="list_pods", depends=["wait"]) +def test_list_pods(pytorch_job_name, test_directory): + """Test listing pods for a specific job.""" + # Wait a moment to ensure pods are created + time.sleep(10) + + list_pods_result = execute_command([ + "hyp", "list-pods", "hyp-pytorch-job", + "--job-name", pytorch_job_name, + "--namespace", NAMESPACE + ]) + assert list_pods_result.returncode == 0 + + # Verify the output contains expected headers and job name + output = list_pods_result.stdout.strip() + assert f"Pods for job: {pytorch_job_name}" in output + assert "POD NAME" in output + assert "NAMESPACE" in output + + # Verify at least one pod is listed (should contain the job name in the pod name) + assert f"{pytorch_job_name}-pod-" in output + + print(f"[INFO] Successfully listed pods for job: {pytorch_job_name}") + + +@pytest.mark.dependency(depends=["list_pods"]) +def test_pytorch_job_delete(pytorch_job_name, test_directory): + """Clean up deployed PyTorch job using CLI delete command and verify deletion.""" + delete_result = execute_command([ + "hyp", "delete", "hyp-pytorch-job", + "--job-name", pytorch_job_name, + "--namespace", NAMESPACE + ]) + assert delete_result.returncode == 0 + print(f"[INFO] Successfully deleted job: {pytorch_job_name}") + + # Wait a moment for the job to be deleted + time.sleep(5) + + # Verify the job is no longer listed + list_result = execute_command(["hyp", "list", "hyp-pytorch-job", "--namespace", NAMESPACE]) + assert list_result.returncode == 0 + + # The job name should no longer be in the output + assert pytorch_job_name not in list_result.stdout + print(f"[INFO] Verified job {pytorch_job_name} is no longer listed after deletion") \ No newline at end of file diff --git a/test/integration_tests/init/utils.py b/test/integration_tests/init/utils.py new file mode 100644 index 00000000..d978cd96 --- /dev/null +++ b/test/integration_tests/init/utils.py @@ -0,0 +1,75 @@ +""" +Utility functions for integration tests. +""" +import yaml +from pathlib import Path + + +def assert_init_files_created(project_dir, template_type): + """Assert that init created the expected files for the template type.""" + project_path = Path(project_dir) + + # Common files + assert (project_path / "config.yaml").exists(), "config.yaml should be created" + assert (project_path / "README.md").exists(), "README.md should be created" + + # Template-specific files + if template_type == "cluster-stack": + assert (project_path / "cfn_params.jinja").exists(), \ + "Cluster template should create cfn_params.jinja" + + +def assert_command_succeeded(result): + """Assert that a CLI command succeeded.""" + assert result.exit_code == 0, f"Command failed with exit code {result.exit_code}. Output: {result.output}" + + +def assert_command_failed_with_helpful_error(result, expected_keywords): + """Assert that a command failed and contains helpful error messages.""" + assert result.exit_code != 0, f"Command should have failed but succeeded. Output: {result.output}" + for keyword in expected_keywords: + assert keyword.lower() in result.output.lower(), f"Expected keyword '{keyword}' not found in output: {result.output}" + + +def assert_config_values(directory, expected_values): + """Assert that config.yaml contains expected values.""" + config_path = Path(directory) / "config.yaml" + assert config_path.exists(), f"config.yaml should exist in {directory}" + + with open(config_path, 'r') as f: + config = yaml.safe_load(f) + + for key, expected_value in expected_values.items(): + actual_value = config.get(key) + assert actual_value == expected_value, f"Expected {key}={expected_value}, got {actual_value}" + + +def assert_warning_displayed(result, expected_keywords): + """Assert that warning messages are displayed in command output.""" + for keyword in expected_keywords: + assert keyword.lower() in result.output.lower(), f"Expected warning keyword '{keyword}' not found in output: {result.output}" + + +def assert_yes_no_prompt_displayed(result): + """Assert that a yes/no prompt was displayed.""" + prompt_indicators = ["(y/n)", "(Y/n)", "[y/N]", "?"] + found_prompt = any(indicator in result.output for indicator in prompt_indicators) + assert found_prompt, f"Expected yes/no prompt not found in output: {result.output}" + + +def assert_success_message_displayed(result, expected_keywords): + """Assert that success messages are displayed in command output.""" + for keyword in expected_keywords: + assert keyword.lower() in result.output.lower(), f"Expected success keyword '{keyword}' not found in output: {result.output}" + + +def get_most_recent_run_directory(project_dir): + """Get the most recent run directory.""" + run_dir = Path(project_dir) / "run" + assert run_dir.exists(), "run directory should exist" + + run_subdirs = [d for d in run_dir.iterdir() if d.is_dir()] + assert len(run_subdirs) >= 1, f"Expected at least 1 run directory, found {len(run_subdirs)}" + + # Sort by directory name (timestamp) and return the most recent + return sorted(run_subdirs, key=lambda x: x.name)[-1] \ No newline at end of file From 6fb5a1323cb4e4e13c573e87b354a926b6667a21 Mon Sep 17 00:00:00 2001 From: Molly He Date: Mon, 8 Sep 2025 16:15:32 -0700 Subject: [PATCH 02/13] return SDK class in pytorch model.py for v1_0 and v1_1, update pytorch_create function, update unit test (#243) --- .../v1_0/model.py | 182 +++++-------- .../v1_1/model.py | 244 +++++++----------- .../hyperpod/cli/commands/training.py | 32 +-- src/sagemaker/hyperpod/cli/training_utils.py | 4 +- test/unit_tests/cli/test_training.py | 18 +- 5 files changed, 169 insertions(+), 311 deletions(-) diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py index f3e0e54c..176dbdca 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py @@ -12,6 +12,7 @@ HostPath, PersistentVolumeClaim ) +from sagemaker.hyperpod.training.hyperpod_pytorch_job import HyperPodPytorchJob class VolumeConfig(BaseModel): @@ -228,133 +229,82 @@ def validate_label_selector_keys(cls, v): return v def to_domain(self) -> Dict: - """ - Convert flat config to domain model (HyperPodPytorchJobSpec) - """ + """Convert flat config to domain model (HyperPodPytorchJobSpec)""" - # Create container with required fields - container_kwargs = { - "name": "pytorch-job-container", - "image": self.image, - "resources": Resources( - requests={"nvidia.com/gpu": "0"}, - limits={"nvidia.com/gpu": "0"}, - ), - } - - # Add optional container fields - if self.command is not None: - container_kwargs["command"] = self.command - if self.args is not None: - container_kwargs["args"] = self.args - if self.pull_policy is not None: - container_kwargs["image_pull_policy"] = self.pull_policy - if self.environment is not None: - container_kwargs["env"] = [ - {"name": k, "value": v} for k, v in self.environment.items() - ] - - if self.volume is not None: - volume_mounts = [] - for i, vol in enumerate(self.volume): - volume_mount = {"name": vol.name, "mount_path": vol.mount_path} - volume_mounts.append(volume_mount) - - container_kwargs["volume_mounts"] = volume_mounts - - - # Create container object - try: - container = Containers(**container_kwargs) - except Exception as e: - raise - - # Create pod spec kwargs - spec_kwargs = {"containers": list([container])} + # Helper function to build dict with non-None values + def build_dict(**kwargs): + return {k: v for k, v in kwargs.items() if v is not None} + + # Build container + container_kwargs = build_dict( + name="pytorch-job-container", + image=self.image, + resources=Resources(requests={"nvidia.com/gpu": "0"}, limits={"nvidia.com/gpu": "0"}), + command=self.command, + args=self.args, + image_pull_policy=self.pull_policy, + env=[{"name": k, "value": v} for k, v in self.environment.items()] if self.environment else None, + volume_mounts=[{"name": vol.name, "mount_path": vol.mount_path} for vol in self.volume] if self.volume else None + ) + + container = Containers(**container_kwargs) - # Add volumes to pod spec if present - if self.volume is not None: + # Build volumes + volumes = None + if self.volume: volumes = [] - for i, vol in enumerate(self.volume): + for vol in self.volume: if vol.type == "hostPath": - host_path = HostPath(path=vol.path) - volume_obj = Volumes(name=vol.name, host_path=host_path) + volume_obj = Volumes(name=vol.name, host_path=HostPath(path=vol.path)) elif vol.type == "pvc": - pvc_config = PersistentVolumeClaim( - claim_name=vol.claim_name, - read_only=vol.read_only == "true" if vol.read_only else False - ) - volume_obj = Volumes(name=vol.name, persistent_volume_claim=pvc_config) + volume_obj = Volumes(name=vol.name, persistent_volume_claim=PersistentVolumeClaim( + claim_name=vol.claim_name, + read_only=vol.read_only == "true" if vol.read_only else False + )) volumes.append(volume_obj) - - spec_kwargs["volumes"] = volumes - - # Add node selector if any selector fields are present - node_selector = {} - if self.instance_type is not None: - map = {"node.kubernetes.io/instance-type": self.instance_type} - node_selector.update(map) - if self.label_selector is not None: - node_selector.update(self.label_selector) - if self.deep_health_check_passed_nodes_only: - map = {"deep-health-check-passed": "true"} - node_selector.update(map) - if node_selector: - spec_kwargs.update({"node_selector": node_selector}) - - # Add other optional pod spec fields - if self.service_account_name is not None: - map = {"service_account_name": self.service_account_name} - spec_kwargs.update(map) - - if self.scheduler_type is not None: - map = {"scheduler_name": self.scheduler_type} - spec_kwargs.update(map) - - # Build metadata labels only if relevant fields are present - metadata_kwargs = {"name": self.job_name} - if self.namespace is not None: - metadata_kwargs["namespace"] = self.namespace - - metadata_labels = {} - if self.queue_name is not None: - metadata_labels["kueue.x-k8s.io/queue-name"] = self.queue_name - if self.priority is not None: - metadata_labels["kueue.x-k8s.io/priority-class"] = self.priority - - if metadata_labels: - metadata_kwargs["labels"] = metadata_labels - # Create replica spec with only non-None values - replica_kwargs = { - "name": "pod", - "template": Template( - metadata=Metadata(**metadata_kwargs), spec=Spec(**spec_kwargs) - ), - } + # Build node selector + node_selector = build_dict( + **{"node.kubernetes.io/instance-type": self.instance_type} if self.instance_type else {}, + **self.label_selector if self.label_selector else {}, + **{"deep-health-check-passed": "true"} if self.deep_health_check_passed_nodes_only else {} + ) - if self.node_count is not None: - replica_kwargs["replicas"] = self.node_count + # Build spec + spec_kwargs = build_dict( + containers=[container], + volumes=volumes, + node_selector=node_selector if node_selector else None, + service_account_name=self.service_account_name, + scheduler_name=self.scheduler_type + ) - replica_spec = ReplicaSpec(**replica_kwargs) + # Build metadata + metadata_labels = build_dict( + **{"kueue.x-k8s.io/queue-name": self.queue_name} if self.queue_name else {}, + **{"kueue.x-k8s.io/priority-class": self.priority} if self.priority else {} + ) - replica_specs = list([replica_spec]) + metadata_kwargs = build_dict( + name=self.job_name, + namespace=self.namespace, + labels=metadata_labels if metadata_labels else None + ) - job_kwargs = {"replica_specs": replica_specs} - # Add optional fields only if they exist - if self.tasks_per_node is not None: - job_kwargs["nproc_per_node"] = str(self.tasks_per_node) + # Build replica spec + replica_kwargs = build_dict( + name="pod", + template=Template(metadata=Metadata(**metadata_kwargs), spec=Spec(**spec_kwargs)), + replicas=self.node_count + ) - if self.max_retry is not None: - job_kwargs["run_policy"] = RunPolicy( - clean_pod_policy="None", job_max_retry_count=self.max_retry - ) + # Build job + job_kwargs = build_dict( + metadata=metadata_kwargs, + replica_specs=[ReplicaSpec(**replica_kwargs)], + nproc_per_node=str(self.tasks_per_node) if self.tasks_per_node else None, + run_policy=RunPolicy(clean_pod_policy="None", job_max_retry_count=self.max_retry) if self.max_retry else None + ) - # Create base return dictionary - result = { - "name": self.job_name, - "namespace": self.namespace, - "labels": metadata_labels, - "spec": job_kwargs, - } + result = HyperPodPytorchJob(**job_kwargs) return result \ No newline at end of file diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py index a0d3a144..2016f26d 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py @@ -12,6 +12,8 @@ HostPath, PersistentVolumeClaim ) +from sagemaker.hyperpod.training.hyperpod_pytorch_job import HyperPodPytorchJob + # Constants ALLOWED_TOPOLOGY_LABELS = { @@ -282,167 +284,103 @@ def validate_topology_labels(cls, v): return v def to_domain(self) -> Dict: - """ - Convert flat config to domain model (HyperPodPytorchJobSpec) - """ - - # Create container with required fields + """Convert flat config to domain model (HyperPodPytorchJobSpec)""" + + # Helper function to build dict with non-None values + def build_dict(**kwargs): + return {k: v for k, v in kwargs.items() if v is not None} + + # Build resources if self.instance_type is None: - requests_value = {"nvidia.com/gpu": "0"} - limits_value = {"nvidia.com/gpu": "0"} + requests_value = limits_value = {"nvidia.com/gpu": "0"} else: - requests_value = {} - if self.accelerators is not None: - requests_value["accelerators"] = str(self.accelerators) - if self.vcpu is not None: - requests_value["vcpu"] = str(self.vcpu) - if self.memory is not None: - requests_value["memory"] = str(self.memory) - - limits_value = {} - if self.accelerators_limit is not None: - limits_value["accelerators"] = str(self.accelerators_limit) - if self.vcpu_limit is not None: - limits_value["vcpu"] = str(self.vcpu_limit) - if self.memory_limit is not None: - limits_value["memory"] = str(self.memory_limit) - - # Create container with required fields - container_kwargs = { - "name": "pytorch-job-container", - "image": self.image, - "resources": Resources( - requests=requests_value, - limits=limits_value, - ), - } - - # Add optional container fields - if self.command is not None: - container_kwargs["command"] = self.command - if self.args is not None: - container_kwargs["args"] = self.args - if self.pull_policy is not None: - container_kwargs["image_pull_policy"] = self.pull_policy - if self.environment is not None: - container_kwargs["env"] = [ - {"name": k, "value": v} for k, v in self.environment.items() - ] - - if self.volume is not None: - volume_mounts = [] - for i, vol in enumerate(self.volume): - volume_mount = {"name": vol.name, "mount_path": vol.mount_path} - volume_mounts.append(volume_mount) - - container_kwargs["volume_mounts"] = volume_mounts - - - # Create container object - try: - container = Containers(**container_kwargs) - except Exception as e: - raise + requests_value = build_dict( + accelerators=str(self.accelerators) if self.accelerators else None, + vcpu=str(self.vcpu) if self.vcpu else None, + memory=str(self.memory) if self.memory else None + ) + limits_value = build_dict( + accelerators=str(self.accelerators_limit) if self.accelerators_limit else None, + vcpu=str(self.vcpu_limit) if self.vcpu_limit else None, + memory=str(self.memory_limit) if self.memory_limit else None + ) - # Create pod spec kwargs - spec_kwargs = {"containers": list([container])} + # Build container + container_kwargs = build_dict( + name="pytorch-job-container", + image=self.image, + resources=Resources(requests=requests_value, limits=limits_value), + command=self.command, + args=self.args, + image_pull_policy=self.pull_policy, + env=[{"name": k, "value": v} for k, v in self.environment.items()] if self.environment else None, + volume_mounts=[{"name": vol.name, "mount_path": vol.mount_path} for vol in self.volume] if self.volume else None + ) + + container = Containers(**container_kwargs) - # Add volumes to pod spec if present - if self.volume is not None: + # Build volumes + volumes = None + if self.volume: volumes = [] - for i, vol in enumerate(self.volume): + for vol in self.volume: if vol.type == "hostPath": - host_path = HostPath(path=vol.path) - volume_obj = Volumes(name=vol.name, host_path=host_path) + volume_obj = Volumes(name=vol.name, host_path=HostPath(path=vol.path)) elif vol.type == "pvc": - pvc_config = PersistentVolumeClaim( - claim_name=vol.claim_name, - read_only=vol.read_only == "true" if vol.read_only else False - ) - volume_obj = Volumes(name=vol.name, persistent_volume_claim=pvc_config) + volume_obj = Volumes(name=vol.name, persistent_volume_claim=PersistentVolumeClaim( + claim_name=vol.claim_name, + read_only=vol.read_only == "true" if vol.read_only else False + )) volumes.append(volume_obj) - - spec_kwargs["volumes"] = volumes - - # Add node selector if any selector fields are present - node_selector = {} - if self.instance_type is not None: - map = {"node.kubernetes.io/instance-type": self.instance_type} - node_selector.update(map) - if self.label_selector is not None: - node_selector.update(self.label_selector) - if self.deep_health_check_passed_nodes_only: - map = {"deep-health-check-passed": "true"} - node_selector.update(map) - if node_selector: - spec_kwargs.update({"node_selector": node_selector}) - # Add other optional pod spec fields - if self.service_account_name is not None: - map = {"service_account_name": self.service_account_name} - spec_kwargs.update(map) - - if self.scheduler_type is not None: - map = {"scheduler_name": self.scheduler_type} - spec_kwargs.update(map) - - # Build metadata labels only if relevant fields are present - metadata_kwargs = {"name": self.job_name} - if self.namespace is not None: - metadata_kwargs["namespace"] = self.namespace - - metadata_labels = {} - if self.queue_name is not None: - metadata_labels["kueue.x-k8s.io/queue-name"] = self.queue_name - if self.priority is not None: - metadata_labels["kueue.x-k8s.io/priority-class"] = self.priority + # Build node selector + node_selector = build_dict( + **{"node.kubernetes.io/instance-type": self.instance_type} if self.instance_type else {}, + **self.label_selector if self.label_selector else {}, + **{"deep-health-check-passed": "true"} if self.deep_health_check_passed_nodes_only else {} + ) + + # Build spec + spec_kwargs = build_dict( + containers=[container], + volumes=volumes, + node_selector=node_selector if node_selector else None, + service_account_name=self.service_account_name, + scheduler_name=self.scheduler_type + ) + + # Build metadata + metadata_labels = build_dict( + **{"kueue.x-k8s.io/queue-name": self.queue_name} if self.queue_name else {}, + **{"kueue.x-k8s.io/priority-class": self.priority} if self.priority else {} + ) - annotations = {} - if self.preferred_topology is not None: - annotations["kueue.x-k8s.io/podset-preferred-topology"] = ( - self.preferred_topology - ) - if self.required_topology is not None: - annotations["kueue.x-k8s.io/podset-required-topology"] = ( - self.required_topology - ) - - if metadata_labels: - metadata_kwargs["labels"] = metadata_labels - if annotations: - metadata_kwargs["annotations"] = annotations - - # Create replica spec with only non-None values - replica_kwargs = { - "name": "pod", - "template": Template( - metadata=Metadata(**metadata_kwargs), spec=Spec(**spec_kwargs) - ), - } - - if self.node_count is not None: - replica_kwargs["replicas"] = self.node_count - - replica_spec = ReplicaSpec(**replica_kwargs) - - replica_specs = list([replica_spec]) - - job_kwargs = {"replica_specs": replica_specs} - # Add optional fields only if they exist - if self.tasks_per_node is not None: - job_kwargs["nproc_per_node"] = str(self.tasks_per_node) - - if self.max_retry is not None: - job_kwargs["run_policy"] = RunPolicy( - clean_pod_policy="None", job_max_retry_count=self.max_retry - ) - - # Create base return dictionary - result = { - "name": self.job_name, - "namespace": self.namespace, - "labels": metadata_labels, - "annotations": annotations, - "spec": job_kwargs, - } + annotations = build_dict( + **{"kueue.x-k8s.io/podset-preferred-topology": self.preferred_topology} if self.preferred_topology else {}, + **{"kueue.x-k8s.io/podset-required-topology": self.required_topology} if self.required_topology else {} + ) + + metadata_kwargs = build_dict( + name=self.job_name, + namespace=self.namespace, + labels=metadata_labels if metadata_labels else None, + annotations=annotations if annotations else None + ) + + # Build replica spec + replica_kwargs = build_dict( + name="pod", + template=Template(metadata=Metadata(**metadata_kwargs), spec=Spec(**spec_kwargs)), + replicas=self.node_count + ) + + # Build job + job_kwargs = build_dict( + metadata=metadata_kwargs, + replica_specs=[ReplicaSpec(**replica_kwargs)], + nproc_per_node=str(self.tasks_per_node) if self.tasks_per_node else None, + run_policy=RunPolicy(clean_pod_policy="None", job_max_retry_count=self.max_retry) if self.max_retry else None + ) + + result = HyperPodPytorchJob(**job_kwargs) return result diff --git a/src/sagemaker/hyperpod/cli/commands/training.py b/src/sagemaker/hyperpod/cli/commands/training.py index f0c4c829..9788cf1f 100644 --- a/src/sagemaker/hyperpod/cli/commands/training.py +++ b/src/sagemaker/hyperpod/cli/commands/training.py @@ -20,40 +20,10 @@ ) @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "create_pytorchjob_cli") @handle_cli_exceptions() -def pytorch_create(version, debug, config): +def pytorch_create(version, debug, job): """Create a PyTorch job.""" click.echo(f"Using version: {version}") - job_name = config.get("name") - namespace = config.get("namespace") - spec = config.get("spec") - metadata_labels = config.get("labels") - annotations = config.get("annotations") - - # Prepare metadata - metadata_kwargs = {"name": job_name} - if namespace: - metadata_kwargs["namespace"] = namespace - if metadata_labels: - metadata_kwargs["labels"] = metadata_labels - if annotations: - metadata_kwargs["annotations"] = annotations - - # Prepare job kwargs - job_kwargs = { - "metadata": Metadata(**metadata_kwargs), - "replica_specs": spec.get("replica_specs"), - } - - # Add nproc_per_node if present - if "nproc_per_node" in spec: - job_kwargs["nproc_per_node"] = spec.get("nproc_per_node") - - # Add run_policy if present - if "run_policy" in spec: - job_kwargs["run_policy"] = spec.get("run_policy") - # Create job - job = HyperPodPytorchJob(**job_kwargs) job.create(debug=debug) diff --git a/src/sagemaker/hyperpod/cli/training_utils.py b/src/sagemaker/hyperpod/cli/training_utils.py index 1a3d057a..290ab5f1 100644 --- a/src/sagemaker/hyperpod/cli/training_utils.py +++ b/src/sagemaker/hyperpod/cli/training_utils.py @@ -83,7 +83,7 @@ def wrapped_func(*args, **kwargs): try: flat = Model(**filtered_kwargs) - domain_config = flat.to_domain() + domain = flat.to_domain() except ValidationError as e: error_messages = [] for err in e.errors(): @@ -96,7 +96,7 @@ def wrapped_func(*args, **kwargs): ) # call your handler - return func(version, debug, domain_config) + return func(version, debug, domain) # 2) inject click options from JSON Schema excluded_props = set(["version"]) diff --git a/test/unit_tests/cli/test_training.py b/test/unit_tests/cli/test_training.py index 3e3c653c..b53b78b4 100644 --- a/test/unit_tests/cli/test_training.py +++ b/test/unit_tests/cli/test_training.py @@ -72,7 +72,7 @@ def test_basic_job_creation(self): from sagemaker.hyperpod.cli.commands.training import pytorch_create - with patch("sagemaker.hyperpod.cli.commands.training.HyperPodPytorchJob") as mock_hyperpod_job: + with patch("hyperpod_pytorch_job_template.v1_0.model.HyperPodPytorchJob") as mock_hyperpod_job: # Setup mock mock_instance = Mock() mock_hyperpod_job.return_value = mock_instance @@ -94,9 +94,9 @@ def test_basic_job_creation(self): # Verify HyperPodPytorchJob was created correctly mock_hyperpod_job.assert_called_once() - call_args = mock_hyperpod_job.call_args[1] - self.assertEqual(call_args["metadata"].name, "test-job") - mock_instance.create.assert_called_once() + call_args = mock_hyperpod_job.call_args[1] + self.assertEqual(call_args["metadata"]["name"], "test-job") + mock_instance.create.assert_called_once() def test_missing_required_params(self): """Test that command fails when required parameters are missing""" @@ -121,7 +121,7 @@ def test_optional_params(self): from sagemaker.hyperpod.cli.commands.training import pytorch_create - with patch("sagemaker.hyperpod.cli.commands.training.HyperPodPytorchJob") as mock_hyperpod_job: + with patch("hyperpod_pytorch_job_template.v1_1.model.HyperPodPytorchJob") as mock_hyperpod_job: mock_instance = Mock() mock_hyperpod_job.return_value = mock_instance @@ -151,10 +151,10 @@ def test_optional_params(self): mock_hyperpod_job.assert_called_once() call_args = mock_hyperpod_job.call_args[1] - self.assertEqual(call_args["metadata"].name, "test-job") - self.assertEqual(call_args["metadata"].namespace, "test-namespace") - self.assertEqual(call_args["metadata"].labels["kueue.x-k8s.io/queue-name"], "localqueue") - self.assertEqual(call_args["metadata"].annotations["kueue.x-k8s.io/podset-required-topology"], "topology.k8s.aws/ultraserver-id") + self.assertEqual(call_args["metadata"]["name"], "test-job") + self.assertEqual(call_args["metadata"]["namespace"], "test-namespace") + self.assertEqual(call_args["metadata"]["labels"]["kueue.x-k8s.io/queue-name"], "localqueue") + self.assertEqual(call_args["metadata"]["annotations"]["kueue.x-k8s.io/podset-required-topology"], "topology.k8s.aws/ultraserver-id") @patch('sagemaker.hyperpod.common.cli_decorators._namespace_exists') @patch("sagemaker.hyperpod.cli.commands.training.HyperPodPytorchJob") From 97e7e74528c23a4c1f485233c5bd89e8dd216b97 Mon Sep 17 00:00:00 2001 From: Molly He Date: Tue, 9 Sep 2025 12:57:11 -0700 Subject: [PATCH 03/13] Init experience template agnostic change (TODO: CFN) (#241) * decouple template from src code * update unit tests for init * remove field validator from SDK pydantic model, fix minor parsing problem with list, update kubernetes_version type from str to float * Update pyproject.toml for cluster stack template to include json, update read_only to be boolean * change type handler from class to module functions, change some public function to private, update unit tests * update create for pytorch job template, remove redundant integ test code for init --- .../registry.py | 17 + .../v1_0/model.py | 8 +- .../v1_0/schema.json | 638 +++++++++++++++++ .../pyproject.toml | 2 +- .../v1_0/model.py | 84 ++- .../v1_1/model.py | 101 ++- .../v1_1/schema.json | 6 +- src/sagemaker/hyperpod/cli/commands/init.py | 35 +- .../hyperpod/cli/constants/init_constants.py | 34 + src/sagemaker/hyperpod/cli/init_utils.py | 642 ++++-------------- .../hyperpod/cli/type_handler_utils.py | 169 +++++ .../cluster_management/hp_cluster_stack.py | 26 - .../init/test_custom_creation.py | 78 --- .../init/test_init_workflow.py | 51 +- test/unit_tests/cli/test_cluster_stack.py | 2 +- test/unit_tests/cli/test_init.py | 32 +- test/unit_tests/cli/test_init_utils.py | 258 +++---- test/unit_tests/cli/test_save_template.py | 2 +- .../test_hp_cluster_stack.py | 55 +- 19 files changed, 1365 insertions(+), 875 deletions(-) create mode 100644 hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py create mode 100644 hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json create mode 100644 src/sagemaker/hyperpod/cli/type_handler_utils.py diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py new file mode 100644 index 00000000..2cea5715 --- /dev/null +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py @@ -0,0 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +from hyperpod_cluster_stack_template.v1_0 import model as v1 + +SCHEMA_REGISTRY = { + "1.0": v1.ClusterStackBase +} \ No newline at end of file diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py index cd5d50a0..7e466902 100644 --- a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import Optional, Literal, List, Any, Union class ClusterStackBase(BaseModel): @@ -51,3 +51,9 @@ class ClusterStackBase(BaseModel): storage_capacity: Optional[int] = Field(1200, description="Storage capacity for the FSx file system in GiB") fsx_file_system_id: Optional[str] = Field("", description="Existing FSx file system ID") + @field_validator('kubernetes_version', mode='before') + @classmethod + def validate_kubernetes_version(cls, v): + if v is not None: + return str(v) + return v \ No newline at end of file diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json new file mode 100644 index 00000000..ca63c552 --- /dev/null +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json @@ -0,0 +1,638 @@ +{ + "properties": { + "resource_name_prefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "hyp-eks-stack", + "description": "Prefix to be used for all resources. A 4-digit UUID will be added to prefix during submission", + "title": "Resource Name Prefix" + }, + "create_hyperpod_cluster_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create HyperPod Cluster Stack", + "title": "Create Hyperpod Cluster Stack" + }, + "hyperpod_cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "hyperpod-cluster", + "description": "Name of SageMaker HyperPod Cluster", + "title": "Hyperpod Cluster Name" + }, + "create_eks_cluster_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create EKS Cluster Stack", + "title": "Create Eks Cluster Stack" + }, + "kubernetes_version": { + "anyOf": [ + { + "type": "str" + }, + { + "type": "null" + } + ], + "default": "1.31", + "description": "The Kubernetes version", + "title": "Kubernetes Version" + }, + "eks_cluster_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "eks-cluster", + "description": "The name of the EKS cluster", + "title": "Eks Cluster Name" + }, + "create_helm_chart_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create Helm Chart Stack", + "title": "Create Helm Chart Stack" + }, + "namespace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "kube-system", + "description": "The namespace to deploy the HyperPod Helm chart", + "title": "Namespace" + }, + "helm_repo_url": { + "default": "https://github.com/aws/sagemaker-hyperpod-cli.git", + "description": "The URL of the Helm repo containing the HyperPod Helm chart (fixed default)", + "title": "Helm Repo Url", + "type": "string" + }, + "helm_repo_path": { + "default": "helm_chart/HyperPodHelmChart", + "description": "The path to the HyperPod Helm chart in the Helm repo (fixed default)", + "title": "Helm Repo Path", + "type": "string" + }, + "helm_operators": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "mlflow.enabled=true,trainingOperators.enabled=true,storage.enabled=true,cluster-role-and-bindings.enabled=true,namespaced-role-and-bindings.enable=true,nvidia-device-plugin.devicePlugin.enabled=true,neuron-device-plugin.devicePlugin.enabled=true,aws-efa-k8s-device-plugin.devicePlugin.enabled=true,mpi-operator.enabled=true,health-monitoring-agent.enabled=true,deep-health-check.enabled=true,job-auto-restart.enabled=true,hyperpod-patching.enabled=true", + "description": "The configuration of HyperPod Helm chart", + "title": "Helm Operators" + }, + "helm_release": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "dependencies", + "description": "The name used for Helm chart release", + "title": "Helm Release" + }, + "node_provisioning_mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "Continuous", + "description": "Enable or disable the continuous provisioning mode. Valid values: \"Continuous\" or leave empty", + "title": "Node Provisioning Mode" + }, + "node_recovery": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "Automatic", + "description": "Specifies whether to enable or disable the automatic node recovery feature. Valid values: \"Automatic\", \"None\"", + "title": "Node Recovery" + }, + "instance_group_settings": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": [ + { + "InstanceCount": 1, + "InstanceGroupName": "controller-group", + "InstanceType": "ml.t3.medium", + "TargetAvailabilityZoneId": "use2-az2", + "ThreadsPerCore": 1, + "InstanceStorageConfigs": [ + { + "EbsVolumeConfig": { + "VolumeSizeInGB": 500 + } + } + ] + } + ], + "description": "List of string containing instance group configurations", + "title": "Instance Group Settings" + }, + "rig_settings": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of string containing restricted instance group configurations", + "title": "Rig Settings" + }, + "rig_s3_bucket_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The name of the S3 bucket used to store the RIG resources", + "title": "Rig S3 Bucket Name" + }, + "tags": { + "anyOf": [ + { + "items": {}, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Custom tags for managing the SageMaker HyperPod cluster as an AWS resource", + "title": "Tags" + }, + "create_vpc_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create VPC Stack", + "title": "Create Vpc Stack" + }, + "vpc_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the VPC you wish to use if you do not want to create a new VPC", + "title": "Vpc Id" + }, + "vpc_cidr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "10.192.0.0/16", + "description": "The IP range (CIDR notation) for the VPC", + "title": "Vpc Cidr" + }, + "availability_zone_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of AZs in submission region to deploy subnets in. Must be provided in YAML format starting with \"-\" below. Example: - use2-az1 for us-east-2 region", + "title": "Availability Zone Ids" + }, + "create_security_group_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create Security Group Stack", + "title": "Create Security Group Stack" + }, + "security_group_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The ID of the security group you wish to use in SecurityGroup substack if you do not want to create a new one", + "title": "Security Group Id" + }, + "security_group_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The security groups you wish to use for Hyperpod cluster if you do not want to create new ones", + "title": "Security Group Ids" + }, + "private_subnet_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of private subnet IDs used for HyperPod cluster if you do not want to create VPC stack", + "title": "Private Subnet Ids" + }, + "eks_private_subnet_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of private subnet IDs for the EKS cluster if you do not want to create VPC stack", + "title": "Eks Private Subnet Ids" + }, + "nat_gateway_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of NAT Gateway IDs to route internet bound traffic if you do not want to create VPC stack", + "title": "Nat Gateway Ids" + }, + "private_route_table_ids": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "List of private route table IDs if you do not want to create VPC stack", + "title": "Private Route Table Ids" + }, + "create_s3_endpoint_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create S3 Endpoint stack", + "title": "Create S3 Endpoint Stack" + }, + "enable_hp_inference_feature": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": false, + "description": "Boolean to enable inference operator in Hyperpod cluster", + "title": "Enable Hp Inference Feature" + }, + "stage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "prod", + "description": "Deployment stage used in S3 bucket naming for inference operator. Valid values: \"gamma\", \"prod\"", + "title": "Stage" + }, + "custom_bucket_name": { + "default": "sagemaker-hyperpod-cluster-stack-bucket", + "description": "S3 bucket name for templates", + "title": "Custom Bucket Name", + "type": "string" + }, + "create_life_cycle_script_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create Life Cycle Script Stack", + "title": "Create Life Cycle Script Stack" + }, + "create_s3_bucket_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create S3 Bucket Stack", + "title": "Create S3 Bucket Stack" + }, + "s3_bucket_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "s3-bucket", + "description": "The name of the S3 bucket used to store the cluster lifecycle scripts", + "title": "S3 Bucket Name" + }, + "github_raw_url": { + "default": "https://raw.githubusercontent.com/aws-samples/awsome-distributed-training/refs/heads/main/1.architectures/7.sagemaker-hyperpod-eks/LifecycleScripts/base-config/on_create.sh", + "description": "The raw GitHub URL for the lifecycle script (fixed default)", + "title": "Github Raw Url", + "type": "string" + }, + "on_create_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "sagemaker-hyperpod-eks-bucket", + "description": "The file name of lifecycle script", + "title": "On Create Path" + }, + "create_sagemaker_iam_role_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create SageMaker IAM Role Stack", + "title": "Create Sagemaker Iam Role Stack" + }, + "sagemaker_iam_role_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "create-cluster-role", + "description": "The name of the IAM role that SageMaker will use during cluster creation to access the AWS resources on your behalf", + "title": "Sagemaker Iam Role Name" + }, + "create_fsx_stack": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Boolean to Create FSx Stack", + "title": "Create Fsx Stack" + }, + "fsx_subnet_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "description": "The subnet id that will be used to create FSx", + "title": "Fsx Subnet Id" + }, + "fsx_availability_zone_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "description": "The availability zone to get subnet id that will be used to create FSx", + "title": "Fsx Availability Zone Id" + }, + "per_unit_storage_throughput": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 250, + "description": "Per unit storage throughput", + "title": "Per Unit Storage Throughput" + }, + "data_compression_type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "NONE", + "description": "Data compression type for the FSx file system. Valid values: \"NONE\", \"LZ4\"", + "title": "Data Compression Type" + }, + "file_system_type_version": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": 2.15, + "description": "File system type version for the FSx file system", + "title": "File System Type Version" + }, + "storage_capacity": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": 1200, + "description": "Storage capacity for the FSx file system in GiB", + "title": "Storage Capacity" + }, + "fsx_file_system_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": "", + "description": "Existing FSx file system ID", + "title": "Fsx File System Id" + } + }, + "title": "ClusterStackBase", + "type": "object" +} \ No newline at end of file diff --git a/hyperpod-cluster-stack-template/pyproject.toml b/hyperpod-cluster-stack-template/pyproject.toml index 3efe616a..09cf76a6 100644 --- a/hyperpod-cluster-stack-template/pyproject.toml +++ b/hyperpod-cluster-stack-template/pyproject.toml @@ -24,4 +24,4 @@ include = ["hyperpod_cluster_stack_template*"] include-package-data = true [tool.setuptools.package-data] -"*" = ["*.yaml"] \ No newline at end of file +"*" = ["*.yaml", "*.json"] \ No newline at end of file diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py index 176dbdca..4c9f5a87 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py @@ -1,5 +1,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional, List, Dict, Union, Literal +import click +from sagemaker.hyperpod.cli.type_handler_utils import DEFAULT_TYPE_HANDLER from sagemaker.hyperpod.training.config.hyperpod_pytorch_job_unified_config import ( Containers, ReplicaSpec, @@ -307,4 +309,84 @@ def build_dict(**kwargs): ) result = HyperPodPytorchJob(**job_kwargs) - return result \ No newline at end of file + return result + +# Volume-specific type handlers - only override what's needed +def volume_parse_strings(ctx_or_strings, param=None, value=None): + """Parse volume strings into VolumeConfig objects. Can be used as Click callback.""" + # Handle dual usage pattern (inlined) + if param is not None and value is not None: + volume_strings, is_click_callback = value, True + else: + volume_strings, is_click_callback = ctx_or_strings, False + + if not volume_strings: + return None + if not isinstance(volume_strings, (list, tuple)): + volume_strings = [volume_strings] + + # Core parsing logic + volumes = [] + for vol_str in volume_strings: + vol_dict = {} + for pair in vol_str.split(','): + if '=' in pair: + key, val = pair.split('=', 1) + key = key.strip() + val = val.strip() + vol_dict[key] = val.lower() == 'true' if key == 'read_only' else val + + try: + volumes.append(VolumeConfig(**vol_dict)) + except Exception as e: + error_msg = f"Invalid volume configuration '{vol_str}': {e}" + if is_click_callback: + raise click.BadParameter(error_msg) + else: + raise ValueError(error_msg) + + return volumes + + +def volume_from_dicts(volume_dicts): + """Convert list of volume dictionaries to VolumeConfig objects.""" + if volume_dicts is None: + return None + return [VolumeConfig(**vol_dict) for vol_dict in volume_dicts if isinstance(vol_dict, dict)] + + +def volume_write_to_yaml(key, volumes, file_handle): + """Write VolumeConfig objects to YAML format.""" + if volumes: + file_handle.write(f"{key}:\n") + for vol in volumes: + file_handle.write(f" - name: {vol.name}\n") + file_handle.write(f" type: {vol.type}\n") + file_handle.write(f" mount_path: {vol.mount_path}\n") + if vol.path: + file_handle.write(f" path: {vol.path}\n") + if vol.claim_name: + file_handle.write(f" claim_name: {vol.claim_name}\n") + if vol.read_only is not None: + file_handle.write(f" read_only: {vol.read_only}\n") + file_handle.write("\n") + else: + file_handle.write(f"{key}: []\n\n") + + +def volume_merge_dicts(existing_volumes, new_volumes): + """Merge volume configurations, updating existing volumes by name or adding new ones.""" + merged = {vol.get('name'): vol for vol in existing_volumes} + merged.update({vol.get('name'): vol for vol in new_volumes}) + return list(merged.values()) + + +# Handler definition - merge with defaults, only override specific functions +VOLUME_TYPE_HANDLER = { + **DEFAULT_TYPE_HANDLER, # Start with all defaults + 'parse_strings': volume_parse_strings, # Override only these + 'from_dicts': volume_from_dicts, + 'write_to_yaml': volume_write_to_yaml, + 'merge_dicts': volume_merge_dicts, + 'needs_multiple_option': True +} diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py index 2016f26d..60ba7aad 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional, List, Dict, Union, Literal +import click from sagemaker.hyperpod.training.config.hyperpod_pytorch_job_unified_config import ( Containers, ReplicaSpec, @@ -12,6 +13,7 @@ HostPath, PersistentVolumeClaim ) +from sagemaker.hyperpod.cli.type_handler_utils import DEFAULT_TYPE_HANDLER from sagemaker.hyperpod.training.hyperpod_pytorch_job import HyperPodPytorchJob @@ -47,8 +49,23 @@ class VolumeConfig(BaseModel): description="PVC claim name (required for pvc volumes)", min_length=1 ) - read_only: Optional[Literal['true', 'false']] = Field(None, description="Read-only flag for pvc volumes") + read_only: Optional[bool] = Field(None, description="Read-only flag for pvc volumes") + def to_dict(self) -> dict: + """Convert VolumeConfig to dictionary format.""" + vol_dict = { + 'name': self.name, + 'type': self.type, + 'mount_path': self.mount_path + } + if self.path: + vol_dict['path'] = self.path + if self.claim_name: + vol_dict['claim_name'] = self.claim_name + if self.read_only is not None: + vol_dict['read_only'] = self.read_only + return vol_dict + @field_validator('mount_path', 'path') @classmethod def paths_must_be_absolute(cls, v): @@ -384,3 +401,85 @@ def build_dict(**kwargs): result = HyperPodPytorchJob(**job_kwargs) return result + + + +# Volume-specific type handlers - only override what's needed +def volume_parse_strings(ctx_or_strings, param=None, value=None): + """Parse volume strings into VolumeConfig objects. Can be used as Click callback.""" + # Handle dual usage pattern (inlined) + if param is not None and value is not None: + volume_strings, is_click_callback = value, True + else: + volume_strings, is_click_callback = ctx_or_strings, False + + if not volume_strings: + return None + if not isinstance(volume_strings, (list, tuple)): + volume_strings = [volume_strings] + + # Core parsing logic + volumes = [] + for vol_str in volume_strings: + vol_dict = {} + for pair in vol_str.split(','): + if '=' in pair: + key, val = pair.split('=', 1) + key = key.strip() + val = val.strip() + vol_dict[key] = val.lower() == 'true' if key == 'read_only' else val + + try: + volumes.append(VolumeConfig(**vol_dict)) + except Exception as e: + error_msg = f"Invalid volume configuration '{vol_str}': {e}" + if is_click_callback: + raise click.BadParameter(error_msg) + else: + raise ValueError(error_msg) + + return volumes + + +def volume_from_dicts(volume_dicts): + """Convert list of volume dictionaries to VolumeConfig objects.""" + if volume_dicts is None: + return None + return [VolumeConfig(**vol_dict) for vol_dict in volume_dicts if isinstance(vol_dict, dict)] + + +def volume_write_to_yaml(key, volumes, file_handle): + """Write VolumeConfig objects to YAML format.""" + if volumes: + file_handle.write(f"{key}:\n") + for vol in volumes: + file_handle.write(f" - name: {vol.name}\n") + file_handle.write(f" type: {vol.type}\n") + file_handle.write(f" mount_path: {vol.mount_path}\n") + if vol.path: + file_handle.write(f" path: {vol.path}\n") + if vol.claim_name: + file_handle.write(f" claim_name: {vol.claim_name}\n") + if vol.read_only is not None: + file_handle.write(f" read_only: {vol.read_only}\n") + file_handle.write("\n") + else: + file_handle.write(f"{key}: []\n\n") + + +def volume_merge_dicts(existing_volumes, new_volumes): + """Merge volume configurations, updating existing volumes by name or adding new ones.""" + merged = {vol.get('name'): vol for vol in existing_volumes} + merged.update({vol.get('name'): vol for vol in new_volumes}) + return list(merged.values()) + + +# Handler definition - merge with defaults, only override specific functions +VOLUME_TYPE_HANDLER = { + **DEFAULT_TYPE_HANDLER, # Start with all defaults + 'parse_strings': volume_parse_strings, # Override only these + 'from_dicts': volume_from_dicts, + 'write_to_yaml': volume_write_to_yaml, + 'merge_dicts': volume_merge_dicts, + 'needs_multiple_option': True +} diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json index 4b86c591..db60306c 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json @@ -62,11 +62,7 @@ "read_only": { "anyOf": [ { - "enum": [ - "true", - "false" - ], - "type": "string" + "type": "boolean" }, { "type": "null" diff --git a/src/sagemaker/hyperpod/cli/commands/init.py b/src/sagemaker/hyperpod/cli/commands/init.py index eceba28e..572cc936 100644 --- a/src/sagemaker/hyperpod/cli/commands/init.py +++ b/src/sagemaker/hyperpod/cli/commands/init.py @@ -47,7 +47,7 @@ def init( 1. Checks if the directory already contains a config.yaml and handles existing configurations 2. Creates the target directory if it doesn't exist - 3. Generates a config.yaml file with schema-based default values and user-provided inputs + 3. Generates a config.yaml file with schema-based default values 4. Creates a template file (.jinja) for the specified template type 5. Adds a README.md with usage instructions @@ -389,40 +389,15 @@ def _default_create(region): model = registry.get(str(version)) if model: # Filter out CLI metadata fields before passing to model - from sagemaker.hyperpod.cli.init_utils import filter_cli_metadata_fields - filtered_config = filter_cli_metadata_fields(data) + from sagemaker.hyperpod.cli.init_utils import _filter_cli_metadata_fields + filtered_config = _filter_cli_metadata_fields(data) flat = model(**filtered_config) domain = flat.to_domain() + # TODO: update inference SDK to include name and namespace in the call if template == "hyp-custom-endpoint" or template == "hyp-jumpstart-endpoint": domain.create(namespace=namespace) elif template == "hyp-pytorch-job": - # Currently algin with pytorch_create. Open for refactor and simplify - # Prepare metadata - job_name = domain.get("name") - namespace = domain.get("namespace") - spec = domain.get("spec") - - # Prepare metadata - metadata_kwargs = {"name": job_name} - if namespace: - metadata_kwargs["namespace"] = namespace - - # Prepare job kwargs - job_kwargs = { - "metadata": Metadata(**metadata_kwargs), - "replica_specs": spec.get("replica_specs"), - } - - # Add nproc_per_node if present - if "nproc_per_node" in spec: - job_kwargs["nproc_per_node"] = spec.get("nproc_per_node") - - # Add run_policy if present - if "run_policy" in spec: - job_kwargs["run_policy"] = spec.get("run_policy") - - job = HyperPodPytorchJob(**job_kwargs) - job.create() + domain.create() except Exception as e: diff --git a/src/sagemaker/hyperpod/cli/constants/init_constants.py b/src/sagemaker/hyperpod/cli/constants/init_constants.py index e5a061d4..5a032ef9 100644 --- a/src/sagemaker/hyperpod/cli/constants/init_constants.py +++ b/src/sagemaker/hyperpod/cli/constants/init_constants.py @@ -6,6 +6,9 @@ from hyperpod_jumpstart_inference_template.registry import SCHEMA_REGISTRY as JS_REG from hyperpod_custom_inference_template.registry import SCHEMA_REGISTRY as C_REG from hyperpod_pytorch_job_template.registry import SCHEMA_REGISTRY as P_REG +from hyperpod_cluster_stack_template.registry import SCHEMA_REGISTRY as CLUSTER_REG + +import sys # Here is the list of existing templates supported # You can onboard new template by adding the mapping here @@ -35,6 +38,7 @@ 'type': "jinja" }, "cluster-stack": { + "registry": CLUSTER_REG, "schema_pkg": "hyperpod_cluster_stack_template", "schema_type": CFN, 'template': CLOUDFORMATION_CLUSTER_CREATION_TEMPLATE, @@ -43,6 +47,36 @@ } +def _get_handler_from_latest_template(template_name, handler_name): + """Dynamically import handler from the latest version of a template""" + try: + template_info = TEMPLATES[template_name] + registry = template_info["registry"] + + # Get latest version using same logic as _get_latest_class + available_versions = list(registry.keys()) + def version_key(v): + try: + return tuple(map(int, v.split('.'))) + except ValueError: + return (0, 0) + + latest_version = max(available_versions, key=version_key) + latest_model = registry[latest_version] + + # Get handler from module + module = sys.modules[latest_model.__module__] + return getattr(module, handler_name) + except (ImportError, AttributeError): + return None + + +# Template.field to handler mapping - avoids conflicts and works reliably +SPECIAL_FIELD_HANDLERS = { + 'hyp-pytorch-job.volume': _get_handler_from_latest_template("hyp-pytorch-job", "VOLUME_TYPE_HANDLER"), +} + + USAGE_GUIDE_TEXT_CFN = """# SageMaker HyperPod CLI - Initialization Workflow This document explains the initialization workflow and related commands for the SageMaker HyperPod CLI. diff --git a/src/sagemaker/hyperpod/cli/init_utils.py b/src/sagemaker/hyperpod/cli/init_utils.py index 63624718..961ba17c 100644 --- a/src/sagemaker/hyperpod/cli/init_utils.py +++ b/src/sagemaker/hyperpod/cli/init_utils.py @@ -3,12 +3,12 @@ import logging import pkgutil import click -from typing import Callable, Tuple +from typing import Callable, Tuple, get_origin, get_args import os import yaml import sys from pathlib import Path -import functools +from sagemaker.hyperpod.cli.type_handler_utils import convert_cli_value, to_click_type, is_complex_type, DEFAULT_TYPE_HANDLER from pydantic import ValidationError from sagemaker.hyperpod.common.utils import ( region_to_az_ids @@ -17,9 +17,9 @@ from sagemaker.hyperpod.cli.constants.init_constants import ( TEMPLATES, CRD, - CFN + CFN, + SPECIAL_FIELD_HANDLERS ) -from sagemaker.hyperpod.cluster_management.hp_cluster_stack import HpClusterStack log = logging.getLogger() @@ -29,15 +29,15 @@ def save_template(template: str, directory_path: Path) -> bool: """ try: if TEMPLATES[template]["schema_type"] == CRD: - save_k8s_jinja(directory=str(directory_path), content=TEMPLATES[template]["template"]) + _save_k8s_jinja(directory=str(directory_path), content=TEMPLATES[template]["template"]) elif TEMPLATES[template]["schema_type"] == CFN: - save_cfn_jinja(directory=str(directory_path), content=TEMPLATES[template]["template"]) + _save_cfn_jinja(directory=str(directory_path), content=TEMPLATES[template]["template"]) return True except Exception as e: click.secho(f"⚠️ Template generation failed: {e}", fg="yellow") return False -def save_cfn_jinja(directory: str, content: str): +def _save_cfn_jinja(directory: str, content: str): Path(directory).mkdir(parents=True, exist_ok=True) path = os.path.join(directory, "cfn_params.jinja") @@ -46,7 +46,7 @@ def save_cfn_jinja(directory: str, content: str): click.secho(f"Cloudformation Parameters Jinja template saved to: {path}") return path -def save_k8s_jinja(directory: str, content: str): +def _save_k8s_jinja(directory: str, content: str): Path(directory).mkdir(parents=True, exist_ok=True) path = os.path.join(directory, "k8s.jinja") with open(path, "w", encoding="utf-8") as f: @@ -55,7 +55,7 @@ def save_k8s_jinja(directory: str, content: str): return path -def filter_cli_metadata_fields(config_data: dict) -> dict: +def _filter_cli_metadata_fields(config_data: dict) -> dict: """ Filter out CLI metadata fields that should not be passed to Pydantic models. @@ -71,7 +71,7 @@ def filter_cli_metadata_fields(config_data: dict) -> dict: } -def get_latest_version_from_registry(template: str) -> str: +def _get_latest_version_from_registry(template: str) -> str: """ Get the latest version available in the registry for a given template. @@ -85,10 +85,6 @@ def get_latest_version_from_registry(template: str) -> str: if not template_info: raise click.ClickException(f"Unknown template: {template}") - if template_info.get("schema_type") == CFN: - # CFN templates don't have versioned registries, return default - return "1.0" - registry = template_info.get("registry") if not registry: raise click.ClickException(f"No registry found for template: {template}") @@ -126,12 +122,12 @@ def get_default_version_for_template(template: str) -> str: raise click.ClickException(f"Unknown template: {template}") try: - return get_latest_version_from_registry(template) + return _get_latest_version_from_registry(template) except Exception: raise click.ClickException(f"Could not get the latest version for template: {template}") -def load_schema_for_version(version: str, schema_pkg: str) -> dict: +def _load_schema_for_version(version: str, schema_pkg: str) -> dict: ver_pkg = f"{schema_pkg}.v{str(version).replace('.', '_')}" raw = pkgutil.get_data(ver_pkg, "schema.json") if raw is None: @@ -139,11 +135,44 @@ def load_schema_for_version(version: str, schema_pkg: str) -> dict: return json.loads(raw) -def generate_click_command( - *, - version_key_arg: str = "version", - template_arg_name: str = "template", -) -> Callable: +def _get_handler_for_field(template_name, field_name): + """Get appropriate handler for a field using template.field mapping.""" + if template_name and field_name: + scoped_key = f"{template_name}.{field_name}" + handler = SPECIAL_FIELD_HANDLERS.get(scoped_key, DEFAULT_TYPE_HANDLER) + return handler + + return DEFAULT_TYPE_HANDLER + + +def _get_click_option_config(handler, field_type, default=None, required=False, help_text=""): + """Get Click option configuration for any handler.""" + config = { + "multiple": handler.get('needs_multiple_option', False), + "help": help_text, + } + + # Add defaults and requirements + if default is not None: + config["default"] = default + config["show_default"] = True + if required: + config["required"] = required + + # Always set type, callback overrides when needed + config["type"] = to_click_type(field_type) + + # Add callback for special handlers or complex types + if handler != DEFAULT_TYPE_HANDLER or is_complex_type(field_type): + config["callback"] = handler['parse_strings'] + + if is_complex_type(field_type): + config["metavar"] = "JSON" + + return {k: v for k, v in config.items() if v is not None} + + +def generate_click_command() -> Callable: """ Decorator that: - injects -- for every property in the current template's schema (detected from config.yaml) @@ -170,246 +199,54 @@ def decorator(func: Callable) -> Callable: union_props = {} template_info = TEMPLATES[current_template] - if template_info["schema_type"] == CRD: - schema = load_schema_for_version(str(current_version), template_info["schema_pkg"]) - for k, spec in schema.get("properties", {}).items(): - # Ensure description is always a string - if 'description' in spec: - desc = spec['description'] - if isinstance(desc, list): - spec = spec.copy() # Don't modify the original - spec['description'] = ', '.join(str(item) for item in desc) - union_props[k] = spec - elif template_info["schema_type"] == CFN: - json_schema = HpClusterStack.model_json_schema() - schema_properties = json_schema.get('properties', {}) - - for field, field_info in HpClusterStack.model_fields.items(): - prop_info = {"description": field_info.description or ""} - - # Get examples from JSON schema if available - if field in schema_properties and 'examples' in schema_properties[field]: - prop_info["examples"] = schema_properties[field]['examples'] - - union_props[field] = prop_info - - # build required flags for current template - union_reqs = set() + schema = _load_schema_for_version(str(current_version), template_info["schema_pkg"]) + for k, spec in schema.get("properties", {}).items(): + # Ensure description is always a string + if 'description' in spec: + desc = spec['description'] + if isinstance(desc, list): + spec = spec.copy() # Don't modify the original + spec['description'] = ', '.join(str(item) for item in desc) + union_props[k] = spec + + # Get the model for parameter generation + registry = template_info['registry'] + model = registry.get(str(current_version)) + if model is None: + raise click.ClickException(f"Unsupported schema version: {current_version}") def decorator(func: Callable) -> Callable: - # Initialize cluster_parameters only if current template is CFN - cluster_parameters = {} - if template_info["schema_type"] == CFN: - try: - cluster_template = json.loads(HpClusterStack.get_template()) - cluster_parameters = cluster_template.get("Parameters", {}) - except Exception: - # If template can't be fetched, use empty dict - pass - - # JSON flag parser - def _parse_json_flag(ctx, param, value): - if value is None: - return None - try: - return json.loads(value) - except json.JSONDecodeError: - # Try to fix unquoted list items: [python, train.py] -> ["python", "train.py"] - if value.strip().startswith('[') and value.strip().endswith(']'): - try: - # Remove brackets and split by comma - inner = value.strip()[1:-1] - items = [item.strip().strip('"').strip("'") for item in inner.split(',')] - return items - except: - pass - raise click.BadParameter(f"{param.name!r} must be valid JSON or a list like [item1, item2]") - - - # Volume flag parser - def _parse_volume_flag(ctx, param, value): - if not value: - return None + # Create a wrapper that converts CLI arguments to model_config + def wrapper(*args, **kwargs): + # Filter and convert CLI arguments + filtered_kwargs = {} + for k, v in kwargs.items(): + if v is not None and k in model.__fields__: + field = model.__fields__[k] + field_type = getattr(field, 'outer_type_', getattr(field, 'type_', str)) + filtered_kwargs[k] = convert_cli_value(v, field_type) - # Handle multiple volume flags - if not isinstance(value, (list, tuple)): - value = [value] - - from hyperpod_pytorch_job_template.v1_0.model import VolumeConfig - volumes = [] - - for vol_str in value: - # Parse volume string: name=model-data,type=hostPath,mount_path=/data,path=/data - vol_dict = {} - for pair in vol_str.split(','): - if '=' in pair: - key, val = pair.split('=', 1) - key = key.strip() - val = val.strip() - - # Convert read_only to boolean - if key == 'read_only': - vol_dict[key] = val.lower() in ('true', '1', 'yes', 'on') - else: - vol_dict[key] = val - - try: - volumes.append(VolumeConfig(**vol_dict)) - except Exception as e: - raise click.BadParameter(f"Invalid volume configuration '{vol_str}': {e}") - - return volumes - - @functools.wraps(func) - def wrapped(*args, **kwargs): - - # configure path: load from existing config.yaml - dir_path = Path('.').resolve() - config_file = dir_path / 'config.yaml' - if not config_file.is_file(): - raise click.UsageError("No config.yaml found; run `hyp init` first.") - data = yaml.safe_load(config_file.read_text()) or {} - template = data.get('template') - version = data.get(version_key_arg, '1.0') - - # Extract user version and config version - user_version = kwargs.pop(version_key_arg, None) - config_version = data.get(version_key_arg) - - # Ensure config_version is always a string (YAML might load it as float) - if config_version is not None: - config_version = str(config_version) - - # Configure/Reset/Validate commands: Config file version is PRIMARY source of truth - # Priority: config file version > 1.0 (backward compatibility) > user --version flag (rare override) - if config_version is not None: - version = config_version - elif user_version is not None: - # Rare case: user explicitly overrides with --version flag - version = user_version - else: - # Config file has no version - default to 1.0 for backward compatibility - raise click.ClickException(f"Could not get the latest version for template: {template}") - - - # lookup registry & schema_pkg - template_info = TEMPLATES.get(template) - if not template_info: - raise click.ClickException(f"Unknown template: {template}") - if template_info.get("schema_type") == CRD: - registry = template_info['registry'] - - Model = registry.get(version) - if Model is None: - raise click.ClickException(f"Unsupported schema version: {version}") - - # build Pydantic model (bypass validation on configure) - filtered_kwargs = filter_cli_metadata_fields(kwargs) - model_obj = Model.model_construct(**filtered_kwargs) - elif template_info.get("schema_type") == CFN: - model_obj = HpClusterStack(**kwargs) - - # call underlying function - return func(model_config=model_obj) - - # inject JSON flags with proper field names - only if they exist in template properties - for flag in ('env', 'args', 'command', 'label-selector', 'dimensions', 'resources-limits', 'resources-requests', 'tags'): - flag_name = flag.replace('-', '_') - if flag_name in union_props: - wrapped = click.option( - f"--{flag}", - callback=_parse_json_flag, - metavar="JSON", - help=f"JSON object for {flag.replace('-', ' ')}", - )(wrapped) - - - # inject every union schema property - for name, spec in reversed(list(union_props.items())): - if name in ( - template_arg_name, - 'directory', - version_key_arg, - 'args', # Skip since handled by JSON flag - 'command', # Skip since handled by JSON flag - 'label_selector', # Skip since handled by --label-selector JSON flag - 'dimensions', - 'resources_limits', - 'resources_requests', - 'tags', - 'custom_bucket_name', # Fixed default, not configurable - 'github_raw_url', # Fixed default, not configurable - 'helm_repo_url', # Fixed default, not configurable - 'helm_repo_path', # Fixed default, not configurable - ): + model_config = model.model_construct(**filtered_kwargs) + return func(model_config=model_config, *args) + + # Generate Click options directly from model fields + for field_name, field in reversed(list(model.__fields__.items())): + if field_name == "version": continue - # infer click type - if 'enum' in spec: - ctype = click.Choice(spec['enum']) - elif spec.get('type') == 'integer': - ctype = int - elif spec.get('type') == 'number': - ctype = float - elif spec.get('type') == 'boolean': - ctype = bool - else: - ctype = str - - # Get help text and ensure it's a string - help_text = spec.get('description', '') - if isinstance(help_text, list): - help_text = ', '.join(str(item) for item in help_text) - - # Special handling for volume parameter - if name == 'volume': - wrapped = click.option( - f"--{name.replace('_','-')}", - multiple=True, - callback=_parse_volume_flag, - help=help_text, - )(wrapped) - else: - wrapped = click.option( - f"--{name.replace('_','-')}", - required=(name in union_reqs), - default=spec.get('default'), - show_default=('default' in spec), - type=ctype, - help=help_text, - )(wrapped) - - for cfn_param_name, cfn_param_details in cluster_parameters.items(): - # Convert CloudFormation type to Click type - cfn_type = cfn_param_details.get('Type', 'String') - if cfn_type == 'Number': - click_type = float - elif cfn_type == 'Integer': - click_type = int - else: - click_type = str - - # Special handling for tags parameter - if cfn_param_name == 'Tags': - wrapped = click.option( - f"--{pascal_to_kebab(cfn_param_name)}", - callback=_parse_json_flag, - metavar="JSON", - help=cfn_param_details.get('Description', ''), - )(wrapped) - else: - cfn_default = cfn_param_details.get('Default') - wrapped = click.option( - f"--{pascal_to_kebab(cfn_param_name)}", - default=cfn_default, - show_default=cfn_default, + flag_name = field_name.replace('_', '-') + field_type = getattr(field, 'outer_type_', getattr(field, 'type_', str)) + required = getattr(field, 'required', False) + default = getattr(field, 'default', None) + help_text = getattr(getattr(field, 'field_info', None), 'description', field_name) or field_name - type=click_type, - help=cfn_param_details.get('Description', ''), + # Unified handler approach - use template.field lookup + handler = _get_handler_for_field(current_template, field_name) + option_kwargs = _get_click_option_config(handler, field_type, default, required, help_text) - )(wrapped) + wrapper = click.option(f"--{flag_name}", **option_kwargs)(wrapper) - return wrapped + return wrapper return decorator @@ -419,6 +256,9 @@ def save_config_yaml(prefill: dict, comment_map: dict, directory: str): filename = "config.yaml" path = os.path.join(directory, filename) + # Get model class from prefill data + template = prefill.get('template') + with open(path, 'w') as f: for key in prefill: comment = comment_map.get(key) @@ -426,38 +266,13 @@ def save_config_yaml(prefill: dict, comment_map: dict, directory: str): f.write(f"# {comment}\n") val = prefill.get(key) - - # Handle nested structures like volumes - if key == 'volume' and isinstance(val, list) and val: - f.write(f"{key}:\n") - for vol in val: - f.write(f" - name: {vol.get('name', '')}\n") - f.write(f" type: {vol.get('type', '')}\n") - f.write(f" mount_path: {vol.get('mount_path', '')}\n") - if vol.get('path'): - f.write(f" path: {vol.get('path')}\n") - if vol.get('claim_name'): - f.write(f" claim_name: {vol.get('claim_name')}\n") - if vol.get('read_only') is not None: - f.write(f" read_only: {vol.get('read_only')}\n") - f.write("\n") - elif isinstance(val, list): - # Handle arrays in YAML format - if val: - f.write(f"{key}:\n") - for item in val: - f.write(f" - {item}\n") - else: - f.write(f"{key}: []\n") - f.write("\n") - else: - # Handle simple values - val = "" if val is None else val - f.write(f"{key}: {val}\n\n") + handler = _get_handler_for_field(template, key) + handler['write_to_yaml'](key, handler['from_dicts'](val) if val is not None else val, f) + print(f"Configuration saved to: {path}") -def update_field_in_config(dir_path: str, field_name: str, value): +def _update_field_in_config(dir_path: str, field_name: str, value): """Update specific field in config.yaml file while preserving format.""" config_path = os.path.join(dir_path, "config.yaml") @@ -472,7 +287,7 @@ def update_field_in_config(dir_path: str, field_name: str, value): with open(config_path, 'w') as f: f.writelines(lines) -def update_list_field_in_config(dir_path: str, field_name: str, values: List[Any]): +def _update_list_field_in_config(dir_path: str, field_name: str, values: List[Any]): """Update specific field in config.yaml file if the field is a list""" config_path = os.path.join(dir_path, "config.yaml") @@ -516,7 +331,7 @@ def add_default_az_ids_to_config(dir_path: str, region: str): # default to first two AZ IDs in the region az_ids = all_az_ids[:2] - update_list_field_in_config(dir_path, 'availability_zone_ids', az_ids) + _update_list_field_in_config(dir_path, 'availability_zone_ids', az_ids) click.secho(f"No availability_zone_ids provided. Using default AZ Id: {az_ids}.", fg="yellow") except Exception as e: raise Exception(f"Failed to find default availability_zone_ids for region {region}. Please provide one in config.yaml. Error details: {e}") @@ -525,7 +340,7 @@ def add_default_az_ids_to_config(dir_path: str, region: str): if not config_data.get('fsx_availability_zone_id'): try: # default to first az_id - update_field_in_config(dir_path, 'fsx_availability_zone_id', all_az_ids[0]) + _update_field_in_config(dir_path, 'fsx_availability_zone_id', all_az_ids[0]) click.secho(f"No fsx_availability_zone_id provided. Using default AZ Id: {all_az_ids[0]}.", fg="yellow") except Exception as e: raise Exception(f"Failed to find default fsx_availability_zone_id for region {region}. Please provide one in config.yaml. Error details: {e}") @@ -602,41 +417,19 @@ def validate_config_against_model(config_data: dict, template: str, version: str validation_errors = [] try: - # For CFN templates, filter config but keep original types for validation - filtered_config = { - k: v for k, v in config_data.items() - if k not in ('template', 'version') and v is not None - } - if template_info["schema_type"] == CFN: - HpClusterStack(**filtered_config) - else: - registry = template_info["registry"] - model = registry.get(str(version)) # Convert to string for lookup - if model: - - # Special handling for JSON fields that might be passed as strings - for key in ('args', 'environment'): - if key in filtered_config and isinstance(filtered_config[key], str): - val = filtered_config[key].strip() - # Try to parse as JSON if it looks like JSON - if val.startswith('[') or val.startswith('{'): - try: - filtered_config[key] = json.loads(val) - except json.JSONDecodeError: - # If JSON parsing fails, keep as string and let validation handle it - pass - - # Special handling for nested structures like volumes - if 'volume' in filtered_config and filtered_config['volume']: - # Convert YAML volume structure back to VolumeConfig objects for validation - from hyperpod_pytorch_job_template.v1_0.model import VolumeConfig - volume_configs = [] - for vol_dict in filtered_config['volume']: - if isinstance(vol_dict, dict): - volume_configs.append(VolumeConfig(**vol_dict)) - filtered_config['volume'] = volume_configs - - model(**filtered_config) + # Filter config but keep original types for validation + filtered_config = _filter_cli_metadata_fields(config_data) + + registry = template_info["registry"] + model = registry.get(str(version)) # Convert to string for lookup + if model: + # Unified handler approach + for key in filtered_config: + handler = _get_handler_for_field(template, key) + filtered_config[key] = handler['from_dicts'](filtered_config[key]) + + model(**filtered_config) + except ValidationError as e: for err in e.errors(): @@ -663,7 +456,9 @@ def filter_validation_errors_for_user_input(validation_errors: list, user_input_ # Extract field name from error string (format: "field: message") if ':' in error: field_name = error.split(':', 1)[0].strip() - if field_name in user_input_fields: + # Check if field_name or its parent field is in user_input_fields + base_field = field_name.split('.')[0] # Get 'security_group_ids' from 'security_group_ids.0' + if field_name in user_input_fields or base_field in user_input_fields: user_input_errors.append(error) return user_input_errors @@ -707,50 +502,13 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi """ # Load schema and pull out properties + required list info = TEMPLATES[template] - - if info["schema_type"] == CFN: - # For CFN templates, use model fields instead of schema - if model_config: - props = {field: {"description": field_info.description or ""} - for field, field_info in model_config.__class__.model_fields.items()} - else: - props = {} - # For CFN templates, always get fields from HpClusterStack model - # Use JSON schema to get examples - json_schema = HpClusterStack.model_json_schema() - schema_properties = json_schema.get('properties', {}) - - props = {} - for field, field_info in HpClusterStack.model_fields.items(): - prop_info = {"description": field_info.description or ""} - - # Add default from model field if available - if hasattr(field_info, 'default') and field_info.default is not None: - # Handle different types of defaults - if hasattr(field_info.default, '__call__'): - # For callable defaults, call them to get the actual value - try: - prop_info["default"] = field_info.default() - except: - # If calling fails, use the raw default - prop_info["default"] = field_info.default - else: - prop_info["default"] = field_info.default - - # Get examples from JSON schema if available - if field in schema_properties and 'examples' in schema_properties[field]: - prop_info["examples"] = schema_properties[field]['examples'] - - props[field] = prop_info - reqs = [] - else: - # For CRD templates, use the provided version (should always be provided) - # Don't fallback to latest version here - version should come from caller - if not version: - raise ValueError(f"Version must be provided for template {template}") - schema = load_schema_for_version(version, info["schema_pkg"]) - props = schema.get("properties", {}) - reqs = schema.get("required", []) + + if not version: + raise ValueError(f"Version must be provided for template {template}") + schema = _load_schema_for_version(version, info["schema_pkg"]) + props = schema.get("properties", {}) + reqs = schema.get("required", []) + # Build config dict with defaults from schema full_cfg = { @@ -762,43 +520,19 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi # Prepare values from different sources with priority: # 1. model_config (user-provided values) # 2. existing_config (values from existing config.yaml) - # 3. examples from schema (for reset command) - # 4. schema defaults + # 3. schema defaults values = {} # Add schema defaults first (lowest priority) for key, spec in props.items(): if "default" in spec and spec["default"] is not None: values[key] = spec.get("default") - - # Add examples next (for reset command when no existing config, or init command with no user input) - # Use examples if no model_config and no existing_config (reset command) - # OR if model_config exists but has no user data and no existing_config (init with no args) - model_has_user_data = model_config and bool(model_config.model_dump(exclude_none=True)) - use_examples = (not model_config and not existing_config) or (not model_has_user_data and not existing_config) - - if use_examples: - for key, spec in props.items(): - if "examples" in spec and spec["examples"]: - # Use the first example if it's a list, otherwise use the examples directly - examples = spec["examples"] - if isinstance(examples, list) and examples: - example_value = examples[0] # Use first example - else: - example_value = examples - - # Special handling for tags: skip if example is empty array - if key == "tags" and example_value == []: - continue - - values[key] = example_value # Add existing config values next (middle priority) if existing_config: for key, val in existing_config.items(): # Skip template and version as they're handled separately if key in ("template", "version"): - continue if key in props: values[key] = val @@ -806,111 +540,27 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi # Add model_config values last (highest priority) if model_config: # Only use fields that were actually provided by the user - if user_provided_fields: - cfg_dict = model_config.model_dump(exclude_none=True) - for key, val in cfg_dict.items(): - if key in props and key in user_provided_fields: - # Special handling for JSON fields that might be passed as strings - if key in ('args', 'environment', 'env', 'command', 'label_selector', 'dimensions', 'resources_limits', 'resources_requests', 'tags') and isinstance(val, str): - # Try to parse as JSON if it looks like JSON - val_stripped = val.strip() - if val_stripped.startswith('[') or val_stripped.startswith('{'): - try: - val = json.loads(val_stripped) - except json.JSONDecodeError: - # Try to fix unquoted list items: [python, train.py] -> ["python", "train.py"] - if val_stripped.startswith('[') and val_stripped.endswith(']'): - try: - inner = val_stripped[1:-1] - val = [item.strip().strip('"').strip("'") for item in inner.split(',')] - except: - pass - - # Special handling for nested structures like volumes - if key == 'volume' and val: - # Get existing volumes from config - existing_volumes = values.get('volume', []) or [] - - # Convert new volumes to dict format - new_volumes = [] - for vol in val: - if hasattr(vol, 'name'): # VolumeConfig object - vol_dict = { - 'name': vol.name, - 'type': vol.type, - 'mount_path': vol.mount_path - } - if vol.path: - vol_dict['path'] = vol.path - if vol.claim_name: - vol_dict['claim_name'] = vol.claim_name - if vol.read_only is not None: - vol_dict['read_only'] = vol.read_only - else: # Already a dict - vol_dict = vol - new_volumes.append(vol_dict) - - # Merge: update existing volumes by name or add new ones - merged_volumes = existing_volumes.copy() - for new_vol in new_volumes: - # Find if volume with same name exists - updated = False - for i, existing_vol in enumerate(merged_volumes): - if existing_vol.get('name') == new_vol.get('name'): - merged_volumes[i] = new_vol # Update existing - updated = True - break - if not updated: - merged_volumes.append(new_vol) # Add new - - values[key] = merged_volumes - else: - values[key] = val - else: - # For init command, use all model_config values - cfg_dict = model_config.model_dump(exclude_none=True) - for key, val in cfg_dict.items(): - if key in props: - # Special handling for nested structures like volumes - if key == 'volume' and val: - # Get existing volumes from config - existing_volumes = values.get('volume', []) or [] - - # Convert new volumes to dict format - new_volumes = [] - for vol in val: - if hasattr(vol, 'name'): # VolumeConfig object - vol_dict = { - 'name': vol.name, - 'type': vol.type, - 'mount_path': vol.mount_path - } - if vol.path: - vol_dict['path'] = vol.path - if vol.claim_name: - vol_dict['claim_name'] = vol.claim_name - if vol.read_only is not None: - vol_dict['read_only'] = vol.read_only - else: # Already a dict - vol_dict = vol - new_volumes.append(vol_dict) - - # Merge: update existing volumes by name or add new ones - merged_volumes = existing_volumes.copy() - for new_vol in new_volumes: - # Find if volume with same name exists - updated = False - for i, existing_vol in enumerate(merged_volumes): - if existing_vol.get('name') == new_vol.get('name'): - merged_volumes[i] = new_vol # Update existing - updated = True - break - if not updated: - merged_volumes.append(new_vol) # Add new - - values[key] = merged_volumes - else: - values[key] = val + cfg_dict = model_config.model_dump(exclude_none=False) + for key, val in cfg_dict.items(): + # Check if field should be included + should_include = key in props and (not user_provided_fields or key in user_provided_fields) + if not should_include: + continue + + # Unified handler approach + handler = _get_handler_for_field(template, key) + + # Parse strings using appropriate handler + if user_provided_fields and isinstance(val, str): + val = handler['parse_strings'](val) + + # Always use handler logic for merging + existing_configs = values.get(key, []) or [] + if isinstance(val, list): + new_configs = handler['to_dicts'](val or []) + else: + new_configs = val # Keep single str/bool/int as-is + values[key] = handler['merge_dicts'](existing_configs, new_configs) # Fields that should not appear in config.yaml (fixed defaults) excluded_fields = {'custom_bucket_name', 'github_raw_url', 'helm_repo_url', 'helm_repo_path'} @@ -939,7 +589,7 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi return full_cfg, comment_map -def pascal_to_kebab(pascal_str): +def _pascal_to_kebab(pascal_str): """Convert PascalCase to CLI kebab-case format""" result = [] for i, char in enumerate(pascal_str): diff --git a/src/sagemaker/hyperpod/cli/type_handler_utils.py b/src/sagemaker/hyperpod/cli/type_handler_utils.py new file mode 100644 index 00000000..e82a2cdd --- /dev/null +++ b/src/sagemaker/hyperpod/cli/type_handler_utils.py @@ -0,0 +1,169 @@ +"""Type handler for CLI parameter generation""" + +import json +import click +from typing import get_origin + + +# Utility functions +def convert_cli_value(value, field_type): + """Convert CLI string value to proper Python type""" + if not isinstance(value, str) or field_type == str: + return value + + # Handle complex types (already parsed by callbacks, but just in case) + if is_complex_type(field_type): + try: + parsed = json.loads(value) + return parsed + except json.JSONDecodeError as e: + return value + + # Handle simple types + if field_type == bool: + return value.lower() in ('true', '1', 'yes', 'on') + elif field_type == int: + try: + return int(value) + except ValueError: + return value + elif field_type == float: + try: + return float(value) + except ValueError: + return value + return value + + +def to_click_type(field_type): + """Convert Python type to Click type""" + if is_complex_type(field_type): + return str # JSON string input for complex types + elif field_type is bool: + return bool + elif field_type is int: + return int + elif field_type is float: + return float + else: + return str + + +def is_complex_type(field_type): + """Check if field type needs JSON parsing""" + origin = get_origin(field_type) + return ( + origin is list or field_type is list or + origin is dict or field_type is dict or + origin is tuple or field_type is tuple + ) + + +def parse_strings(ctx_or_value, param=None, value=None): + """ + Parse string input with JSON fallback and flexible list handling. + + This function serves dual purposes: + 1. As a Click callback for CLI parameter validation + 2. As a direct utility function for string parsing + + Args: + ctx_or_value: When used as Click callback, this is the Click context. + When used directly, this is the value to parse. + param: Click parameter object (only present when used as Click callback) + value: The actual value to parse (only present when used as Click callback) + + Returns: + - None if input is None + - Original value if not a string + - Parsed JSON object/array if valid JSON + - Parsed list if input matches pattern [item1, item2, ...] + - Original string if no parsing succeeds + + Parsing Logic: + 1. First attempts standard JSON parsing + 2. If JSON fails and input looks like a list [item1, item2], + attempts to parse as unquoted list items + 3. Strips quotes and whitespace from list items + 4. Falls back to returning original string + + Raises: + click.BadParameter: When used as Click callback and parsing fails + + Examples: + parse_strings('{"key": "value"}') # Returns dict + parse_strings('[item1, item2]') # Returns ['item1', 'item2'] + parse_strings('["a", "b"]') # Returns ['a', 'b'] + parse_strings('plain text') # Returns 'plain text' + """ + # Handle dual usage pattern (inlined) + if param is not None and value is not None: + actual_value, is_click_callback = value, True + else: + actual_value, is_click_callback = ctx_or_value, False + + if actual_value is None: + return None + + if not isinstance(actual_value, str): + return actual_value + + try: + return json.loads(actual_value) + except json.JSONDecodeError: + # Try to fix unquoted list items: [python, train.py] -> ["python", "train.py"] + if actual_value.strip().startswith('[') and actual_value.strip().endswith(']'): + try: + # Remove brackets and split by comma + inner = actual_value.strip()[1:-1] + items = [item.strip().strip('"').strip("'") for item in inner.split(',')] + return items + except: + pass + + if is_click_callback: + raise click.BadParameter(f"{param.name!r} must be valid JSON or a list like [item1, item2]") + return actual_value + + +def write_to_yaml(key, value, file_handle): + """Write value to YAML format.""" + if isinstance(value, list): + if value: + file_handle.write(f"{key}:\n") + for item in value: + file_handle.write(f" - {item}\n") + file_handle.write("\n") + else: + file_handle.write(f"{key}: []\n\n") + else: + value = "" if value is None else value + file_handle.write(f"{key}: {value}\n\n") + + +def from_dicts(dicts): + """Convert dicts to objects. Base implementation returns as-is.""" + return dicts + + +def to_dicts(objects): + """Convert objects to dicts. Base implementation handles both cases.""" + if not objects: + return [] + return [obj.to_dict() if hasattr(obj, 'to_dict') else obj for obj in objects] + + +def merge_dicts(existing, new): + """Merge configurations. Base implementation handles single values and lists.""" + return new if new is not None else existing + + +# Default type handler dictionary for backward compatibility +DEFAULT_TYPE_HANDLER = { + 'parse_strings': parse_strings, + 'write_to_yaml': write_to_yaml, + 'from_dicts': from_dicts, + 'to_dicts': to_dicts, + 'merge_dicts': merge_dicts, + 'needs_multiple_option': False +} diff --git a/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py b/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py index a42f20b2..f54e93b0 100644 --- a/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py +++ b/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py @@ -52,32 +52,6 @@ class HpClusterStack(ClusterStackBase): def __init__(self, **data): super().__init__(**data) - @field_validator('kubernetes_version', mode='before') - @classmethod - def validate_kubernetes_version(cls, v): - if v is not None: - return str(v) - return v - - @field_validator('availability_zone_ids', 'nat_gateway_ids', 'eks_private_subnet_ids', 'security_group_ids', 'private_route_table_ids', 'private_subnet_ids', 'instance_group_settings', 'rig_settings', 'tags', mode='before') - @classmethod - def validate_list_fields(cls, v): - # Convert JSON string to list if needed - if isinstance(v, str) and v.startswith('['): - try: - import json - v = json.loads(v) - except (json.JSONDecodeError, TypeError): - try: - # Try Python literal eval (single quotes) - v = ast.literal_eval(v) - except: - pass # Keep original value if parsing fails - - if isinstance(v, list) and len(v) == 0: - raise ValueError('Empty lists [] are not allowed. Use proper YAML array format or leave field empty.') - return v - @staticmethod def get_template() -> str: try: diff --git a/test/integration_tests/init/test_custom_creation.py b/test/integration_tests/init/test_custom_creation.py index 3d6cc810..cd20bff4 100644 --- a/test/integration_tests/init/test_custom_creation.py +++ b/test/integration_tests/init/test_custom_creation.py @@ -208,81 +208,3 @@ def test_custom_delete(runner, custom_endpoint_name, test_directory): "--namespace", NAMESPACE ]) assert_command_succeeded(result) - - - - - - - - - - - -################################ OLD CONFIG FN ######################################## -# @pytest.mark.dependency(name="configure", depends=["init"]) -# def test_configure_custom(runner, custom_endpoint_name): -# with patch.object(sys, 'argv', ['hyp', 'configure']): -# import importlib -# from sagemaker.hyperpod.cli.commands import init -# importlib.reload(init) -# configure = init.configure -# """Configure custom endpoint with S3 model source and verify config persistence.""" -# result = runner.invoke( -# configure, [ -# # "--endpoint-name", custom_endpoint_name, -# # "--model-name", "test-pytorch-model", -# # "--instance-type", "ml.g5.8xlarge", -# # "--image-uri", "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-inference:latest", -# # "--container-port", "8080", -# # "--model-source-type", "s3", -# # "--s3-bucket-name", "sagemaker-test-bucket", -# # "--model-location", "models/test-pytorch-model.tar.gz", -# # "--s3-region", "us-east-1", -# "--namespace", NAMESPACE, -# "--version", VERSION, -# "--instance-type", "ml.c5.2xlarge", -# "--model-name", "test-model-integration-cli-s3", -# "--model-source-type", "s3", -# "--model-location", "hf-eqa", -# "--s3-bucket-name", BUCKET_LOCATION, -# "--s3-region", REGION, -# "--image-uri", "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04", -# "--container-port", "8080", -# "--model-volume-mount-name", "model-weights", -# "--endpoint-name", custom_endpoint_name, -# "--resources-requests", '{"cpu": "3200m", "nvidia.com/gpu": 0, "memory": "12Gi"}', -# "--resources-limits", '{"nvidia.com/gpu": 0}', -# "--env", '{ "SAGEMAKER_PROGRAM": "inference.py", "SAGEMAKER_SUBMIT_DIRECTORY": "/opt/ml/model/code", "SAGEMAKER_CONTAINER_LOG_LEVEL": "20", "SAGEMAKER_MODEL_SERVER_TIMEOUT": "3600", "ENDPOINT_SERVER_TIMEOUT": "3600", "MODEL_CACHE_ROOT": "/opt/ml/model", "SAGEMAKER_ENV": "1", "SAGEMAKER_MODEL_SERVER_WORKERS": "1" }' -# ], catch_exceptions=False -# ) -# assert_command_succeeded(result) - -# # Verify configuration was saved correctly -# expected_config = { -# # "endpoint_name": custom_endpoint_name, -# # "model_name": "test-pytorch-model", -# # "instance_type": "ml.g5.8xlarge", -# # "image_uri": "763104351884.dkr.ecr.us-east-1.amazonaws.com/pytorch-inference:latest", -# # "container_port": "8080", -# # "model_source_type": "s3", -# # "s3_bucket_name": "sagemaker-test-bucket", -# # "model_location": "models/test-pytorch-model.tar.gz", -# # "s3_region": "us-east-1", -# "namespace": NAMESPACE, -# "version": VERSION, -# "instance-type": "ml.c5.2xlarge", -# "model-name": "test-model-integration-cli-s3", -# "model-source-type": "s3", -# "model-location": "hf-eqa", -# "s3-bucket-name": BUCKET_LOCATION, -# "s3-region": REGION, -# "image-uri": "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04", -# "container-port": "8080", -# "model-volume-mount-name": "model-weights", -# "endpoint-name": custom_endpoint_name, -# "resources-requests": '{"cpu": "3200m", "nvidia.com/gpu": 0, "memory": "12Gi"}', -# "resources-limits": '{"nvidia.com/gpu": 0}', -# "env": '{ "SAGEMAKER_PROGRAM": "inference.py", "SAGEMAKER_SUBMIT_DIRECTORY": "/opt/ml/model/code", "SAGEMAKER_CONTAINER_LOG_LEVEL": "20", "SAGEMAKER_MODEL_SERVER_TIMEOUT": "3600", "ENDPOINT_SERVER_TIMEOUT": "3600", "MODEL_CACHE_ROOT": "/opt/ml/model", "SAGEMAKER_ENV": "1", "SAGEMAKER_MODEL_SERVER_WORKERS": "1" }' -# } -# assert_config_values("./", expected_config) diff --git a/test/integration_tests/init/test_init_workflow.py b/test/integration_tests/init/test_init_workflow.py index db2e3400..8232370c 100644 --- a/test/integration_tests/init/test_init_workflow.py +++ b/test/integration_tests/init/test_init_workflow.py @@ -454,53 +454,4 @@ def test_reset_and_reconfigure_workflow(self, temp_dir, runner): # Validate new configuration with change_directory(temp_dir): result5 = runner.invoke(validate, [], catch_exceptions=False) - assert_command_succeeded(result5) - - -# class TestClusterWorkflowValidation: -# """File validation tests for cluster workflow.""" - -# def test_cluster_generated_cfn_params_structure(self, temp_dir, runner): -# """Verify generated CloudFormation parameters have correct structure for cluster.""" -# # Initialize cluster template -# result = runner.invoke(init, ["hyp-cluster", temp_dir], catch_exceptions=False) -# assert_command_succeeded(result) - -# # Configure with basic parameters -# old_cwd = os.getcwd() -# try: -# os.chdir(temp_dir) -# result = runner.invoke(configure, ["--resource-name-prefix", "test-cluster"], catch_exceptions=False) -# assert_command_succeeded(result) - -# result = runner.invoke(create, [], catch_exceptions=False) -# assert_command_succeeded(result) -# finally: -# os.chdir(old_cwd) - - -# def test_cluster_config_file_structure(self, temp_dir, runner): -# """Verify config.yaml has correct structure after cluster workflow.""" -# # Initialize cluster template -# result = runner.invoke(init, ["hyp-cluster", temp_dir], catch_exceptions=False) -# assert_command_succeeded(result) - -# # Configure with basic parameters -# old_cwd = os.getcwd() -# try: -# os.chdir(temp_dir) -# result = runner.invoke(configure, ["--resource-name-prefix", "test-cluster"], catch_exceptions=False) -# assert_command_succeeded(result) -# finally: -# os.chdir(old_cwd) - -# # Verify config file structure -# config_path = Path(temp_dir) / "config.yaml" -# assert config_path.exists(), "config.yaml should exist" - -# with open(config_path, 'r') as f: -# config = yaml.safe_load(f) - -# # Verify required config fields are present -# assert config.get('template'), "template field should be present" -# assert config.get('resource_name_prefix'), "resource_name_prefix should be configured" \ No newline at end of file + assert_command_succeeded(result5) \ No newline at end of file diff --git a/test/unit_tests/cli/test_cluster_stack.py b/test/unit_tests/cli/test_cluster_stack.py index ddff5b63..b34b787b 100644 --- a/test/unit_tests/cli/test_cluster_stack.py +++ b/test/unit_tests/cli/test_cluster_stack.py @@ -297,7 +297,7 @@ def test_parse_status_list_non_list_format(self): @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.importlib.resources.read_text') -@patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack.get_template') +@patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack.get_template') class TestCreateClusterStackHelper(unittest.TestCase): """Test create_cluster_stack_helper function""" diff --git a/test/unit_tests/cli/test_init.py b/test/unit_tests/cli/test_init.py index 9bbbb9f9..dbfb5c6b 100644 --- a/test/unit_tests/cli/test_init.py +++ b/test/unit_tests/cli/test_init.py @@ -78,7 +78,7 @@ def test_init_help(self): assert "Initialize a TEMPLATE scaffold in DIRECTORY" in result.output def test_init_hyp_cluster_with_mocked_dependencies(self): - """Test init command with hyp-cluster-stack template""" + """Test init command with cluster-stack template""" runner = CliRunner() # Use a temporary directory for testing @@ -86,7 +86,7 @@ def test_init_hyp_cluster_with_mocked_dependencies(self): test_dir = Path(temp_dir) / "test-init-cluster" # Execute - result = runner.invoke(init, ['hyp-cluster-stack', str(test_dir), '--version', '1.0']) + result = runner.invoke(init, ['cluster-stack', str(test_dir), '--version', '1.0']) # The command should attempt to run (may fail due to missing dependencies) # but should not crash completely @@ -190,7 +190,7 @@ def test_configure_no_config_file(self): assert result.exit_code == 0 def test_configure_hyp_cluster_with_mocked_dependencies(self): - """Test configure command with hyp-cluster-stack template - simplified test""" + """Test configure command with cluster-stack template - simplified test""" runner = CliRunner() result = runner.invoke(configure, ['--help']) assert result.exit_code == 0 @@ -280,7 +280,7 @@ def test_configure_detects_user_input_fields(self, mock_path, mock_load_config): with runner.isolated_filesystem(temp_dir): # Create a minimal config file config_data = { - 'template': 'hyp-cluster-stack', + 'template': 'cluster-stack', 'version': '1.0', 'hyperpod_cluster_name': 'existing-cluster' } @@ -294,7 +294,7 @@ def test_configure_detects_user_input_fields(self, mock_path, mock_load_config): assert result.exit_code in [0, 1] # Either success or validation failure assert len(result.output) > 0 # Should produce some output - @patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') + @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') def test_configure_hyp_custom_endpoint_with_config(self, mock_cluster_stack): """Test configure command with hyp-custom-endpoint template""" # Set up mocks to prevent iteration issues @@ -328,7 +328,7 @@ def test_configure_hyp_custom_endpoint_with_config(self, mock_cluster_stack): # The command should execute (may succeed or fail, but shouldn't crash) assert result.exit_code in [0, 1] # Either success or validation failure - @patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') + @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') def test_configure_hyp_custom_endpoint_with_image_uri(self, mock_cluster_stack): """Test configure command with hyp-custom-endpoint image URI parameter""" # Set up mocks to prevent iteration issues @@ -360,7 +360,7 @@ def test_configure_hyp_custom_endpoint_with_image_uri(self, mock_cluster_stack): # The command should execute (may succeed or fail, but shouldn't crash) assert result.exit_code in [0, 1] # Either success or validation failure - @patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') + @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') def test_configure_hyp_custom_endpoint_with_s3_config(self, mock_cluster_stack): """Test configure command with hyp-custom-endpoint S3 configuration""" # Set up mocks to prevent iteration issues @@ -422,7 +422,7 @@ def test_default_create_with_mocked_dependencies(self, mock_templates, mock_load """Test default_create command with mocked dependencies""" # Setup mocks mock_load_config.return_value = ( - {"test": "config"}, "hyp-cluster-stack", "1.0" + {"test": "config"}, "cluster-stack", "1.0" ) mock_templates.__getitem__.return_value = {"schema_type": CFN} @@ -727,7 +727,7 @@ def test_configure_works_with_all_templates(self): # we'll test the basic command functionality instead of the full configure flow runner = CliRunner() - templates_to_test = ['hyp-cluster-stack', 'hyp-jumpstart-endpoint', 'hyp-custom-endpoint'] + templates_to_test = ['cluster-stack', 'hyp-jumpstart-endpoint', 'hyp-custom-endpoint'] for template in templates_to_test: with tempfile.TemporaryDirectory() as temp_dir: @@ -762,7 +762,7 @@ def test_configure_filters_validation_errors(self): with runner.isolated_filesystem(temp_dir): # Create a minimal config file config_data = { - 'template': 'hyp-cluster-stack', + 'template': 'cluster-stack', 'version': '1.0', 'hyperpod_cluster_name': 'existing-cluster' } @@ -804,7 +804,7 @@ def test_configure_detects_user_input_fields(self): def test_configure_custom_endpoint_user_input_detection(self): """Test user input detection with hyp-custom-endpoint template""" with patch('sagemaker.hyperpod.cli.init_utils.validate_config_against_model') as mock_validate, \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack: # Set up mocks to prevent iteration issues mock_cluster_stack.model_fields = {} @@ -845,7 +845,7 @@ def test_configure_custom_endpoint_user_input_detection(self): def test_configure_custom_endpoint_validation_filtering(self): """Test validation error filtering with hyp-custom-endpoint""" with patch('sagemaker.hyperpod.cli.init_utils.validate_config_against_model') as mock_validate, \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack: # Set up mocks to prevent iteration issues mock_cluster_stack.model_fields = {} @@ -887,7 +887,7 @@ def test_configure_custom_endpoint_validation_filtering(self): def test_configure_multiple_templates_user_input_validation(self): """Test user input validation works across different template types""" with patch('sagemaker.hyperpod.cli.init_utils.validate_config_against_model') as mock_validate, \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack: # Set up mocks to prevent iteration issues mock_cluster_stack.model_fields = {} @@ -907,7 +907,7 @@ def test_configure_multiple_templates_user_input_validation(self): test_cases = [ { - 'template': 'hyp-cluster-stack', + 'template': 'cluster-stack', 'config': {'hyperpod_cluster_name': 'test-cluster'}, 'update_args': ['--hyperpod-cluster-name', 'updated-cluster'] }, @@ -946,8 +946,8 @@ def test_configure_no_user_input_warning(self): """Test that configure shows warning when no arguments provided""" runner = CliRunner() - # templates = ['hyp-cluster-stack', 'hyp-jumpstart-endpoint', 'hyp-custom-endpoint'] - templates = ['hyp-cluster-stack'] + # templates = ['cluster-stack', 'hyp-jumpstart-endpoint', 'hyp-custom-endpoint'] + templates = ['cluster-stack'] for template in templates: with tempfile.TemporaryDirectory() as temp_dir: diff --git a/test/unit_tests/cli/test_init_utils.py b/test/unit_tests/cli/test_init_utils.py index 725f4f97..6aa95c95 100644 --- a/test/unit_tests/cli/test_init_utils.py +++ b/test/unit_tests/cli/test_init_utils.py @@ -5,33 +5,33 @@ import click from unittest.mock import Mock, patch, mock_open from pathlib import Path -from sagemaker.hyperpod.cli.init_utils import load_schema_for_version, save_template, generate_click_command, save_cfn_jinja +from sagemaker.hyperpod.cli.init_utils import _load_schema_for_version, save_template, generate_click_command, _save_cfn_jinja from sagemaker.hyperpod.cli.constants.init_constants import CFN from unittest.mock import Mock, patch, mock_open from pathlib import Path from pydantic import ValidationError from sagemaker.hyperpod.cli.init_utils import ( - load_schema_for_version, + _load_schema_for_version, save_template, generate_click_command, - save_k8s_jinja, + _save_k8s_jinja, save_config_yaml, load_config_and_validate, validate_config_against_model, filter_validation_errors_for_user_input, display_validation_results, build_config_from_schema, - pascal_to_kebab + _pascal_to_kebab ) from sagemaker.hyperpod.cli.constants.init_constants import CFN, CRD import tempfile import os -from sagemaker.hyperpod.cli.init_utils import update_field_in_config, update_list_field_in_config +from sagemaker.hyperpod.cli.init_utils import _update_field_in_config, _update_list_field_in_config class TestSaveK8sJinja: - """Test cases for save_k8s_jinja function""" + """Test cases for _save_k8s_jinja function""" @patch('builtins.open', new_callable=mock_open) @patch('sagemaker.hyperpod.cli.init_utils.Path') @@ -44,7 +44,7 @@ def test_save_k8s_jinja_success(self, mock_print, mock_join, mock_path, mock_fil mock_join.return_value = "/test/dir/k8s.jinja" mock_path.return_value.mkdir = Mock() - result = save_k8s_jinja(directory, content) + result = _save_k8s_jinja(directory, content) # Verify directory creation mock_path.assert_called_once_with(directory) @@ -61,10 +61,21 @@ def test_save_k8s_jinja_success(self, mock_print, mock_join, mock_path, mock_fil assert result == "/test/dir/k8s.jinja" +def create_mock_template(schema_type, registry=None): + """Helper to create properly structured mock template""" + return { + 'schema_type': schema_type, + 'registry': registry or {'1.0': Mock()}, + 'schema_pkg': 'mock_schema_pkg', + 'template': 'mock_template', + 'type': 'jinja' + } + + class TestSaveTemplate: """Test cases for save_template function""" - @patch('sagemaker.hyperpod.cli.init_utils.save_k8s_jinja') + @patch('sagemaker.hyperpod.cli.init_utils._save_k8s_jinja') def test_save_template_crd_success(self, mock_save_k8s): """Test save_template with CRD template type""" mock_templates = { @@ -83,7 +94,7 @@ def test_save_template_crd_success(self, mock_save_k8s): content='crd template content' ) - @patch('sagemaker.hyperpod.cli.init_utils.save_cfn_jinja') + @patch('sagemaker.hyperpod.cli.init_utils._save_cfn_jinja') def test_save_template_cfn_success(self, mock_save_cfn): """Test save_template with CFN template type""" mock_templates = { @@ -103,7 +114,7 @@ def test_save_template_cfn_success(self, mock_save_cfn): ) - @patch('sagemaker.hyperpod.cli.init_utils.save_k8s_jinja') + @patch('sagemaker.hyperpod.cli.init_utils._save_k8s_jinja') @patch('sagemaker.hyperpod.cli.init_utils.click.secho') def test_save_template_exception_handling(self, mock_secho, mock_save_k8s): """Test save_template handles exceptions gracefully""" @@ -114,7 +125,7 @@ def test_save_template_exception_handling(self, mock_secho, mock_save_k8s): } } - # Make save_k8s_jinja raise an exception + # Make _save_k8s_jinja raise an exception mock_save_k8s.side_effect = Exception("Test exception") with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): @@ -202,7 +213,7 @@ def test_load_config_success(self): namespace: test-namespace """ mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': create_mock_template(CFN) } with patch('pathlib.Path.is_file', return_value=True), \ @@ -224,7 +235,7 @@ def test_load_config_default_version(self): namespace: test-namespace """ mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': create_mock_template(CFN) } with patch('pathlib.Path.is_file', return_value=True), \ @@ -243,7 +254,7 @@ def test_load_config_unknown_template(self, mock_secho): version: 1.0 """ mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': create_mock_template(CFN) } with patch('pathlib.Path.is_file', return_value=True), \ @@ -273,21 +284,23 @@ def test_validate_config_cfn_success(self): 'version': '1.0', 'namespace': 'test-namespace' } + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry + } } - with patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack, \ - patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): - - # Mock successful validation - mock_cluster_stack.return_value = Mock() + with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): + # Mock successful validation - the registry model should be called + mock_registry['1.0'].return_value = Mock() errors = validate_config_against_model(config_data, 'hyp-cluster-stack', '1.0') assert errors == [] - # Verify HpClusterStack was called with filtered config (no template/version) - mock_cluster_stack.assert_called_once_with(namespace='test-namespace') + # Verify the registry model was called with filtered config (no template/version) + mock_registry['1.0'].assert_called_once_with(namespace='test-namespace') def test_validate_config_cfn_validation_error(self): """Test validation error handling for CFN template""" @@ -296,13 +309,15 @@ def test_validate_config_cfn_validation_error(self): 'version': '1.0', 'invalid_field': 'invalid_value' } + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry + } } - with patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack, \ - patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): - + with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): # Mock validation error mock_error = ValidationError.from_exception_data('TestModel', [ { @@ -312,7 +327,7 @@ def test_validate_config_cfn_validation_error(self): 'input': {} } ]) - mock_cluster_stack.side_effect = mock_error + mock_registry['1.0'].side_effect = mock_error errors = validate_config_against_model(config_data, 'hyp-cluster-stack', '1.0') @@ -326,18 +341,27 @@ def test_validate_config_handles_list_values(self): 'version': '1.0', 'tags': ['tag1', 'tag2'] } + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry + } } - with patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack, \ - patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): + with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ + patch('sagemaker.hyperpod.cli.init_utils._get_handler_for_field') as mock_get_handler: + + # Mock handler + mock_from_dicts = Mock(return_value=['tag1', 'tag2']) + mock_handler = {'from_dicts': mock_from_dicts} + mock_get_handler.return_value = mock_handler validate_config_against_model(config_data, 'hyp-cluster-stack', '1.0') - # Verify tags were converted to JSON string - call_args = mock_cluster_stack.call_args[1] - assert call_args['tags'] == ["tag1", "tag2"] + # Verify handler was called + mock_get_handler.assert_called_with('hyp-cluster-stack', 'tags') + mock_from_dicts.assert_called_with(['tag1', 'tag2']) class TestFilterValidationErrorsForUserInput: @@ -442,25 +466,25 @@ class TestBuildConfigFromSchema: def test_build_config_cfn_template(self): """Test building config for CFN template""" + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry, + 'schema_pkg': 'test_pkg' + } } - with patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack, \ - patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates): + with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ + patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema: - # Mock HpClusterStack model fields and JSON schema - mock_field_info = Mock() - mock_field_info.description = "Test field description" - mock_cluster_stack.model_fields = { - 'namespace': mock_field_info, - 'instance_type': mock_field_info - } - mock_cluster_stack.model_json_schema.return_value = { + # Mock schema + mock_load_schema.return_value = { 'properties': { - 'namespace': {'examples': ['default']}, - 'instance_type': {'examples': ['ml.g5.xlarge']} - } + 'namespace': {'description': 'Test field description', 'examples': ['default']}, + 'instance_type': {'description': 'Test field description', 'examples': ['ml.g5.xlarge']} + }, + 'required': ['namespace'] } config, comment_map = build_config_from_schema('hyp-cluster-stack', '1.0') @@ -468,12 +492,17 @@ def test_build_config_cfn_template(self): assert config['template'] == 'hyp-cluster-stack' assert 'namespace' in config assert 'instance_type' in config - assert comment_map['namespace'] == "Test field description" + assert comment_map['namespace'] == "[Required] Test field description" def test_build_config_with_model_config(self): """Test building config with user-provided model config""" + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry, + 'schema_pkg': 'test_pkg' + } } # Mock model config @@ -483,37 +512,44 @@ def test_build_config_with_model_config(self): 'instance_type': 'ml.p4d.24xlarge' } - mock_field_info = Mock() - mock_field_info.description = "Test description" - - # Mock the model class to have model_fields - mock_model_class = Mock() - mock_model_class.model_fields = { - 'namespace': mock_field_info, - 'instance_type': mock_field_info - } - mock_model.__class__ = mock_model_class - with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema, \ + patch('sagemaker.hyperpod.cli.init_utils._get_handler_for_field') as mock_get_handler: - mock_cluster_stack.model_fields = { - 'namespace': mock_field_info, - 'instance_type': mock_field_info + # Mock schema + mock_load_schema.return_value = { + 'properties': { + 'namespace': {'description': 'Test description'}, + 'instance_type': {'description': 'Test description'} + }, + 'required': [] } - mock_cluster_stack.model_json_schema.return_value = {'properties': {}} + + # Mock handler + mock_to_dicts = Mock(return_value=['user-namespace']) + mock_merge_dicts = Mock(return_value='user-namespace') + mock_handler = { + 'to_dicts': mock_to_dicts, + 'merge_dicts': mock_merge_dicts + } + mock_get_handler.return_value = mock_handler config, comment_map = build_config_from_schema( 'hyp-cluster-stack', '1.0', model_config=mock_model ) assert config['namespace'] == 'user-namespace' - assert config['instance_type'] == 'ml.p4d.24xlarge' + assert config['instance_type'] == 'user-namespace' # Both will be processed by handler def test_build_config_with_existing_config(self): """Test building config with existing configuration""" + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry, + 'schema_pkg': 'test_pkg' + } } existing_config = { 'template': 'hyp-cluster-stack', @@ -521,17 +557,17 @@ def test_build_config_with_existing_config(self): 'version': '1.0' } - mock_field_info = Mock() - mock_field_info.description = "Test description" - with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema: - mock_cluster_stack.model_fields = { - 'namespace': mock_field_info, - 'instance_type': mock_field_info + # Mock schema + mock_load_schema.return_value = { + 'properties': { + 'namespace': {'description': 'Test description'}, + 'instance_type': {'description': 'Test description'} + }, + 'required': [] } - mock_cluster_stack.model_json_schema.return_value = {'properties': {}} config, comment_map = build_config_from_schema( 'hyp-cluster-stack', '1.0', existing_config=existing_config @@ -543,20 +579,20 @@ def test_build_config_with_existing_config(self): class TestPascalToKebab: - """Test cases for pascal_to_kebab function""" + """Test cases for _pascal_to_kebab function""" def test_pascal_to_kebab_basic(self): """Test basic PascalCase to kebab-case conversion""" - assert pascal_to_kebab('PascalCase') == 'pascal-case' - assert pascal_to_kebab('SimpleWord') == 'simple-word' - assert pascal_to_kebab('XMLHttpRequest') == 'x-m-l-http-request' + assert _pascal_to_kebab('PascalCase') == 'pascal-case' + assert _pascal_to_kebab('SimpleWord') == 'simple-word' + assert _pascal_to_kebab('XMLHttpRequest') == 'x-m-l-http-request' def test_pascal_to_kebab_edge_cases(self): - """Test edge cases for pascal_to_kebab""" - assert pascal_to_kebab('') == '' - assert pascal_to_kebab('A') == 'a' - assert pascal_to_kebab('lowercase') == 'lowercase' - assert pascal_to_kebab('UPPERCASE') == 'u-p-p-e-r-c-a-s-e' + """Test edge cases for _pascal_to_kebab""" + assert _pascal_to_kebab('') == '' + assert _pascal_to_kebab('A') == 'a' + assert _pascal_to_kebab('lowercase') == 'lowercase' + assert _pascal_to_kebab('UPPERCASE') == 'u-p-p-e-r-c-a-s-e' class TestGenerateClickCommandEnhanced: @@ -565,8 +601,8 @@ class TestGenerateClickCommandEnhanced: def test_generate_click_command_union_building_priority(self): """Test that CFN templates override CRD templates in union building""" # Use context managers to ensure proper cleanup - with patch('sagemaker.hyperpod.cli.init_utils.load_schema_for_version') as mock_load_schema, \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack, \ + with patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema, \ + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack, \ patch('sys.argv', ['hyp', 'configure']), \ patch('sagemaker.hyperpod.cli.init_utils.load_config') as mock_load_config, \ patch('sagemaker.hyperpod.cli.init_utils.Path') as mock_path: @@ -629,13 +665,13 @@ def test_generate_click_command_union_building_priority(self): # The decorator should be created successfully assert callable(decorator) - # Verify that load_schema_for_version was called for CRD template + # Verify that _load_schema_for_version was called for CRD template mock_load_schema.assert_called_with('1.0', 'test.pkg') def test_generate_click_command_handles_list_descriptions(self): """Test that generate_click_command handles list descriptions properly""" - with patch('sagemaker.hyperpod.cli.init_utils.load_schema_for_version') as mock_load_schema, \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + with patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema, \ + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack: # Mock schema with list description (the bug we fixed) schema_with_list_desc = { @@ -687,7 +723,7 @@ def test_generate_click_command_path(self): with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ patch('pathlib.Path.is_file', return_value=True), \ patch('pathlib.Path.read_text', return_value=config_content), \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack: # Set up HpClusterStack mock properly mock_cluster_stack.model_fields = {} @@ -715,13 +751,13 @@ def test_load_config_and_validate_success(self): namespace: test-namespace """ mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': create_mock_template(CFN) } with patch('pathlib.Path.is_file', return_value=True), \ patch('pathlib.Path.read_text', return_value=config_content), \ patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: + patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack') as mock_cluster_stack: # Mock successful validation mock_cluster_stack.return_value = Mock() @@ -741,26 +777,20 @@ def test_load_config_and_validate_failure(self): version: 1.0 namespace: test-namespace """ + mock_registry = {'1.0': Mock()} mock_templates = { - 'hyp-cluster-stack': {'schema_type': CFN} + 'hyp-cluster-stack': { + 'schema_type': CFN, + 'registry': mock_registry + } } with patch('pathlib.Path.is_file', return_value=True), \ patch('pathlib.Path.read_text', return_value=config_content), \ patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ - patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack') as mock_cluster_stack: - - # Mock validation failure - mock_error = ValidationError.from_exception_data('TestModel', [ - { - 'type': 'missing', - 'loc': ('required_field',), - 'msg': 'Field required', - 'input': {} - } - ]) - mock_cluster_stack.side_effect = mock_error + patch('sagemaker.hyperpod.cli.init_utils.display_validation_results', return_value=False): + # Mock validation failure by making display_validation_results return False # This should raise SystemExit due to validation failure with pytest.raises(SystemExit) as exc_info: load_config_and_validate() @@ -772,7 +802,7 @@ def test_load_config_and_validate_failure(self): def test_success(self, mock_get_data): data = {"properties": {"x": {"type": "string"}}} mock_get_data.return_value = json.dumps(data).encode() - result = load_schema_for_version('1.2', 'pkg') + result = _load_schema_for_version('1.2', 'pkg') assert result == data mock_get_data.assert_called_once_with('pkg.v1_2', 'schema.json') @@ -780,20 +810,20 @@ def test_success(self, mock_get_data): def test_not_found(self, mock_get_data): mock_get_data.return_value = None with pytest.raises(click.ClickException) as exc: - load_schema_for_version('3.0', 'mypkg') + _load_schema_for_version('3.0', 'mypkg') assert "Could not load schema.json for version 3.0" in str(exc.value) @patch('sagemaker.hyperpod.cli.init_utils.pkgutil.get_data') def test_invalid_json(self, mock_get_data): mock_get_data.return_value = b'invalid' with pytest.raises(json.JSONDecodeError): - load_schema_for_version('1.0', 'pkg') + _load_schema_for_version('1.0', 'pkg') @patch('builtins.open', new_callable=mock_open) @patch('sagemaker.hyperpod.cli.init_utils.Path') @patch('sagemaker.hyperpod.cli.init_utils.os.path.join') -@patch('sagemaker.hyperpod.cli.init_utils.HpClusterStack.get_template') +@patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack.get_template') def test_save_cfn_jinja_called(mock_get_template, mock_join, mock_path, @@ -847,7 +877,7 @@ class TestUpdateConfig: """Test cases for update config functions""" def test_update_field_in_config(self): - """Test update_field_in_config preserves format and updates value.""" + """Test _update_field_in_config preserves format and updates value.""" with tempfile.TemporaryDirectory() as temp_dir: config_path = os.path.join(temp_dir, "config.yaml") @@ -868,7 +898,7 @@ def test_update_field_in_config(self): f.write(original_content) # Update field - update_field_in_config(temp_dir, 'availability_zone_ids', 'use1-az1') + _update_field_in_config(temp_dir, 'availability_zone_ids', 'use1-az1') # Read updated content with open(config_path, 'r') as f: @@ -914,7 +944,7 @@ def test_update_list_field_in_config_success(self): f.write(initial_content) # Update the list field - update_list_field_in_config(temp_dir, "availability_zone_ids", ["use2-az1", "use2-az2", "use2-az3"]) + _update_list_field_in_config(temp_dir, "availability_zone_ids", ["use2-az1", "use2-az2", "use2-az3"]) # Read and verify the updated content with open(config_path, 'r') as f: @@ -945,7 +975,7 @@ def test_update_list_field_in_config_empty_list(self): f.write(initial_content) # Update with empty list - update_list_field_in_config(temp_dir, "availability_zone_ids", []) + _update_list_field_in_config(temp_dir, "availability_zone_ids", []) # Read and verify the updated content with open(config_path, 'r') as f: @@ -973,7 +1003,7 @@ def test_update_list_field_in_config_single_item(self): f.write(initial_content) # Update with single item - update_list_field_in_config(temp_dir, "availability_zone_ids", ["use2-az1"]) + _update_list_field_in_config(temp_dir, "availability_zone_ids", ["use2-az1"]) # Read and verify the updated content with open(config_path, 'r') as f: diff --git a/test/unit_tests/cli/test_save_template.py b/test/unit_tests/cli/test_save_template.py index 9432a594..131ab938 100644 --- a/test/unit_tests/cli/test_save_template.py +++ b/test/unit_tests/cli/test_save_template.py @@ -8,7 +8,7 @@ class TestSaveTemplate: @patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES') - @patch('sagemaker.hyperpod.cli.init_utils.save_cfn_jinja') + @patch('sagemaker.hyperpod.cli.init_utils._save_cfn_jinja') def test_save_cfn_jinja_called(self, mock_save_cfn_jinja, mock_templates): # Setup mock_templates = { diff --git a/test/unit_tests/cluster_management/test_hp_cluster_stack.py b/test/unit_tests/cluster_management/test_hp_cluster_stack.py index 361b1d8f..035f28c0 100644 --- a/test/unit_tests/cluster_management/test_hp_cluster_stack.py +++ b/test/unit_tests/cluster_management/test_hp_cluster_stack.py @@ -482,61 +482,8 @@ def test_get_template_handles_package_error(self, mock_read_text): self.assertIn("Failed to load template from package", str(context.exception)) -class TestHpClusterStackValidators(unittest.TestCase): +class TestHpClusterStackList(unittest.TestCase): """Test HpClusterStack field validators""" - - def test_validate_kubernetes_version_float_to_string(self): - """Test kubernetes_version validator converts float to string""" - stack = HpClusterStack(kubernetes_version=1.31) - self.assertEqual(stack.kubernetes_version, "1.31") - - def test_validate_kubernetes_version_string_unchanged(self): - """Test kubernetes_version validator keeps string unchanged""" - stack = HpClusterStack(kubernetes_version="1.31") - self.assertEqual(stack.kubernetes_version, "1.31") - - def test_validate_kubernetes_version_none_unchanged(self): - """Test kubernetes_version validator keeps None unchanged""" - stack = HpClusterStack(kubernetes_version=None) - self.assertIsNone(stack.kubernetes_version) - - def test_validate_list_fields_rejects_empty_list(self): - """Test list field validators reject empty lists""" - with self.assertRaises(ValueError) as context: - HpClusterStack(eks_private_subnet_ids=[]) - - self.assertIn("Empty lists [] are not allowed", str(context.exception)) - - def test_validate_list_fields_accepts_populated_list(self): - """Test list field validators accept populated lists""" - stack = HpClusterStack(eks_private_subnet_ids=["subnet-123", "subnet-456"]) - self.assertEqual(stack.eks_private_subnet_ids, ["subnet-123", "subnet-456"]) - - def test_validate_list_fields_accepts_none(self): - """Test list field validators accept None values""" - stack = HpClusterStack(eks_private_subnet_ids=None) - self.assertIsNone(stack.eks_private_subnet_ids) - - def test_validate_availability_zone_ids_empty_list(self): - """Test availability_zone_ids validator rejects empty list""" - with self.assertRaises(ValueError) as context: - HpClusterStack(availability_zone_ids=[]) - - self.assertIn("Empty lists [] are not allowed", str(context.exception)) - - def test_validate_tags_empty_list(self): - """Test tags validator rejects empty list""" - with self.assertRaises(ValueError) as context: - HpClusterStack(tags=[]) - - self.assertIn("Empty lists [] are not allowed", str(context.exception)) - - def test_validate_instance_group_settings_empty_list(self): - """Test instance_group_settings validator rejects empty list""" - with self.assertRaises(ValueError) as context: - HpClusterStack(instance_group_settings=[]) - - self.assertIn("Empty lists [] are not allowed", str(context.exception)) @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.create_boto3_client') def test_list_default_filters_delete_complete(self, mock_create_client): From 4e9244e65b2929509a05ac468cf1fc9d519ec2c0 Mon Sep 17 00:00:00 2001 From: Molly He Date: Wed, 10 Sep 2025 10:18:06 -0700 Subject: [PATCH 04/13] Jumpstart and custom inference template agnostic change (#244) * return SDK class in pytorch model.py for v1_0 and v1_1, update pytorch_create function, update unit test * remove name and namespace from create for inference SDK to match with training SDK, functionality remains the same * fix unit test, add metadata class usage to example notebook, remove skip test * fix unit test again * update integ tests * update create call --- doc/cli/inference/cli_inference.md | 8 ++ .../SDK/inference-fsx-model-e2e.ipynb | 6 ++ .../SDK/inference-jumpstart-e2e.ipynb | 6 ++ .../SDK/inference-s3-model-e2e.ipynb | 6 ++ .../v1_0/model.py | 22 ++++- .../v1_0/schema.json | 16 +++- .../v1_0/model.py | 20 ++++- .../v1_0/schema.json | 14 +++ .../hyperpod/cli/commands/inference.py | 30 ++----- src/sagemaker/hyperpod/cli/commands/init.py | 6 +- src/sagemaker/hyperpod/cli/inference_utils.py | 7 +- .../hyperpod/inference/hp_endpoint.py | 12 +-- .../inference/hp_jumpstart_endpoint.py | 12 +-- .../cli/test_cli_custom_fsx_inference.py | 1 - .../cli/test_cli_jumpstart_inference.py | 1 - .../sdk/test_sdk_custom_fsx_inference.py | 6 +- .../sdk/test_sdk_custom_s3_inference.py | 7 +- .../sdk/test_sdk_jumpstart_inference.py | 6 +- test/unit_tests/cli/test_inference.py | 4 +- test/unit_tests/cli/test_inference_utils.py | 6 +- test/unit_tests/inference/test_hp_endpoint.py | 87 ++++++++++++++----- .../inference/test_hp_jumpstart_endpoint.py | 54 +++++++----- 22 files changed, 239 insertions(+), 98 deletions(-) diff --git a/doc/cli/inference/cli_inference.md b/doc/cli/inference/cli_inference.md index df108d76..5460d62c 100644 --- a/doc/cli/inference/cli_inference.md +++ b/doc/cli/inference/cli_inference.md @@ -45,10 +45,14 @@ hyp create hyp-jumpstart-endpoint [OPTIONS] |-----------|------|----------|-------------| | `--model-id` | TEXT | Yes | JumpStart model identifier (1-63 characters, alphanumeric with hyphens) | | `--instance-type` | TEXT | Yes | EC2 instance type for inference (must start with "ml.") | +| `--namespace` | TEXT | No | Kubernetes namespace | +| `--metadata-name` | TEXT | No | Name of the jumpstart endpoint object | | `--accept-eula` | BOOLEAN | No | Whether model terms of use have been accepted (default: false) | | `--model-version` | TEXT | No | Semantic version of the model (e.g., "1.0.0", 5-14 characters) | | `--endpoint-name` | TEXT | No | Name of SageMaker endpoint (1-63 characters, alphanumeric with hyphens) | | `--tls-certificate-output-s3-uri` | TEXT | No | S3 URI to write the TLS certificate (optional) | +| `--debug` | FLAG | No | Enable debug mode (default: false) | + ### hyp create hyp-custom-endpoint @@ -70,6 +74,8 @@ hyp create hyp-custom-endpoint [OPTIONS] | `--image-uri` | TEXT | Yes | Docker image URI for inference | | `--container-port` | INTEGER | Yes | Port on which model server listens (1-65535) | | `--model-volume-mount-name` | TEXT | Yes | Name of the model volume mount | +| `--namespace` | TEXT | No | Kubernetes namespace | +| `--metadata-name` | TEXT | No | Name of the custom endpoint object | | `--endpoint-name` | TEXT | No | Name of SageMaker endpoint (1-63 characters, alphanumeric with hyphens) | | `--env` | OBJECT | No | Environment variables as key-value pairs | | `--metrics-enabled` | BOOLEAN | No | Enable metrics collection (default: false) | @@ -97,6 +103,8 @@ hyp create hyp-custom-endpoint [OPTIONS] | `--target-value` | NUMBER | No | Target value for the CloudWatch metric | | `--use-cached-metrics` | BOOLEAN | No | Enable caching of metric values (default: true) | | `--invocation-endpoint` | TEXT | No | Invocation endpoint path (default: "invocations") | +| `--debug` | FLAG | No | Enable debug mode (default: false) | + ## Inference Endpoint Management Commands diff --git a/examples/inference/SDK/inference-fsx-model-e2e.ipynb b/examples/inference/SDK/inference-fsx-model-e2e.ipynb index b56e8a7c..b61108b2 100644 --- a/examples/inference/SDK/inference-fsx-model-e2e.ipynb +++ b/examples/inference/SDK/inference-fsx-model-e2e.ipynb @@ -31,6 +31,7 @@ "source": [ "from sagemaker.hyperpod.inference.config.hp_endpoint_config import FsxStorage, ModelSourceConfig, TlsConfig, EnvironmentVariables, ModelInvocationPort, ModelVolumeMount, Resources, Worker\n", "from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint\n", + "from sagemaker.hyperpod.common.config.metadata import Metadata\n", "import yaml\n", "import time" ] @@ -42,6 +43,10 @@ "metadata": {}, "outputs": [], "source": [ + "# If you don't set metadata name, it will be default to endpoint name\n", + "# If you don't set namespace, it will be default to \"default\"\n", + "metadata=Metadata(name='', namespace='')\n", + "\n", "tls_config=TlsConfig(tls_certificate_output_s3_uri='s3://')\n", "\n", "model_source_config = ModelSourceConfig(\n", @@ -82,6 +87,7 @@ "outputs": [], "source": [ "fsx_endpoint = HPEndpoint(\n", + " metadata=metadata,\n", " endpoint_name='',\n", " instance_type='ml.g5.8xlarge',\n", " model_name='deepseek15b-fsx-test-pysdk',\n", diff --git a/examples/inference/SDK/inference-jumpstart-e2e.ipynb b/examples/inference/SDK/inference-jumpstart-e2e.ipynb index 5415aabe..52f53c71 100644 --- a/examples/inference/SDK/inference-jumpstart-e2e.ipynb +++ b/examples/inference/SDK/inference-jumpstart-e2e.ipynb @@ -86,6 +86,7 @@ "source": [ "from sagemaker.hyperpod.inference.config.hp_jumpstart_endpoint_config import Model, Server,SageMakerEndpoint, TlsConfig, EnvironmentVariables\n", "from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint\n", + "from sagemaker.hyperpod.common.config.metadata import Metadata\n", "import yaml\n", "import time" ] @@ -105,6 +106,10 @@ "metadata": {}, "outputs": [], "source": [ + "# If you don't set metadata name, it will be default to endpoint name\n", + "# If you don't set namespace, it will be default to \"default\"\n", + "metadata=Metadata(name='', namespace='')\n", + "\n", "# create configs\n", "model=Model(\n", " model_id='deepseek-llm-r1-distill-qwen-1-5b'\n", @@ -116,6 +121,7 @@ "\n", "# create spec\n", "js_endpoint=HPJumpStartEndpoint(\n", + " metadata=metadata,\n", " model=model,\n", " server=server,\n", " sage_maker_endpoint=endpoint_name\n", diff --git a/examples/inference/SDK/inference-s3-model-e2e.ipynb b/examples/inference/SDK/inference-s3-model-e2e.ipynb index 79810c39..2d03fe29 100644 --- a/examples/inference/SDK/inference-s3-model-e2e.ipynb +++ b/examples/inference/SDK/inference-s3-model-e2e.ipynb @@ -31,6 +31,7 @@ "source": [ "from sagemaker.hyperpod.inference.config.hp_endpoint_config import CloudWatchTrigger, Dimensions, AutoScalingSpec, Metrics, S3Storage, ModelSourceConfig, TlsConfig, EnvironmentVariables, ModelInvocationPort, ModelVolumeMount, Resources, Worker\n", "from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint\n", + "from sagemaker.hyperpod.common.config.metadata import Metadata \n", "import yaml\n", "import time" ] @@ -42,6 +43,10 @@ "metadata": {}, "outputs": [], "source": [ + "# If you don't set metadata name, it will be default to endpoint name\n", + "# If you don't set namespace, it will be default to \"default\"\n", + "metadata=Metadata(name='', namespace='')\n", + "\n", "tls_config=TlsConfig(tls_certificate_output_s3_uri='s3://')\n", "\n", "model_source_config = ModelSourceConfig(\n", @@ -83,6 +88,7 @@ "outputs": [], "source": [ "s3_endpoint = HPEndpoint(\n", + " metadata=metadata,\n", " endpoint_name='',\n", " instance_type='ml.g5.8xlarge',\n", " model_name='deepseek15b-test-model-name', \n", diff --git a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py index f8ee12ca..4437862f 100644 --- a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py +++ b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py @@ -29,14 +29,22 @@ CloudWatchTrigger ) from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint +from sagemaker.hyperpod.common.config.metadata import Metadata + class FlatHPEndpoint(BaseModel): model_config = ConfigDict(extra="forbid") + namespace: Optional[str] = Field( + default="default", + description="Kubernetes namespace", + min_length=1 + ) + metadata_name: Optional[str] = Field( None, alias="metadata_name", - description="Name of the jumpstart endpoint object", + description="Name of the custom endpoint object", max_length=63, pattern=r"^[a-zA-Z0-9](-*[a-zA-Z0-9]){0,62}$", ) @@ -255,7 +263,18 @@ def validate_model_source_config(self): raise ValueError("fsx_file_system_id is required when model_source_type is 'fsx'") return self + @model_validator(mode='after') + def validate_name(self): + if not self.metadata_name and not self.endpoint_name: + raise ValueError("Either metadata_name or endpoint_name must be provided") + return self + def to_domain(self) -> HPEndpoint: + if self.endpoint_name and not self.metadata_name: + self.metadata_name = self.endpoint_name + + metadata = Metadata(name=self.metadata_name, namespace=self.namespace) + env_vars = None if self.env: env_vars = [ @@ -337,6 +356,7 @@ def to_domain(self) -> HPEndpoint: resources=resources, ) return HPEndpoint( + metadata=metadata, endpoint_name=self.endpoint_name, instance_type=self.instance_type, metrics=metrics, diff --git a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json index 8474449b..0c8df9f5 100644 --- a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json +++ b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json @@ -1,6 +1,20 @@ { "additionalProperties": false, "properties": { + "namespace": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": "default", + "description": "Kubernetes namespace", + "title": "Namespace" + }, "metadata_name": { "anyOf": [ { @@ -13,7 +27,7 @@ } ], "default": null, - "description": "Name of the jumpstart endpoint object", + "description": "Name of the custom endpoint object", "title": "Metadata Name" }, "endpoint_name": { diff --git a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py index 43515d41..bd94627f 100644 --- a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py +++ b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py @@ -21,10 +21,17 @@ TlsConfig ) from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.common.config.metadata import Metadata class FlatHPJumpStartEndpoint(BaseModel): model_config = ConfigDict(extra="forbid") + namespace: Optional[str] = Field( + default="default", + description="Kubernetes namespace", + min_length=1 + ) + accept_eula: bool = Field( False, alias="accept_eula", description="Whether model terms of use have been accepted" ) @@ -76,8 +83,18 @@ class FlatHPJumpStartEndpoint(BaseModel): pattern=r"^s3://([^/]+)/?(.*)$", ) + @model_validator(mode='after') + def validate_name(self): + if not self.metadata_name and not self.endpoint_name: + raise ValueError("Either metadata_name or endpoint_name must be provided") + + def to_domain(self) -> HPJumpStartEndpoint: - # Build nested domain (pydantic) objects + if self.endpoint_name and not self.metadata_name: + self.metadata_name = self.endpoint_name + + metadata = Metadata(name=self.metadata_name, namespace=self.namespace) + model = Model( accept_eula=self.accept_eula, model_id=self.model_id, @@ -91,6 +108,7 @@ def to_domain(self) -> HPJumpStartEndpoint: TlsConfig(tls_certificate_output_s3_uri=self.tls_certificate_output_s3_uri) ) return HPJumpStartEndpoint( + metadata=metadata, model=model, server=server, sage_maker_endpoint=sage_ep, diff --git a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json index ac7cf3aa..3379fba2 100644 --- a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json +++ b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json @@ -1,6 +1,20 @@ { "additionalProperties": false, "properties": { + "namespace": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": "default", + "description": "Kubernetes namespace", + "title": "Namespace" + }, "accept_eula": { "default": false, "description": "Whether model terms of use have been accepted", diff --git a/src/sagemaker/hyperpod/cli/commands/inference.py b/src/sagemaker/hyperpod/cli/commands/inference.py index 410ba1d3..f63cb590 100644 --- a/src/sagemaker/hyperpod/cli/commands/inference.py +++ b/src/sagemaker/hyperpod/cli/commands/inference.py @@ -20,50 +20,38 @@ # CREATE @click.command("hyp-jumpstart-endpoint") -@click.option( - "--namespace", - type=click.STRING, - required=False, - default="default", - help="Optional. The namespace of the jumpstart model endpoint to create. Default set to 'default'", -) @click.option("--version", default="1.0", help="Schema version to use") +@click.option("--debug", default=False, help="Enable debug mode") @generate_click_command( schema_pkg="hyperpod_jumpstart_inference_template", registry=JS_REG, ) @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "create_js_endpoint_cli") @handle_cli_exceptions() -def js_create(name, namespace, version, js_endpoint): +def js_create(version, debug, js_endpoint): """ Create a jumpstart model endpoint. """ - - js_endpoint.create(name=name, namespace=namespace) + click.echo(f"Using version: {version}") + js_endpoint.create(debug=debug) @click.command("hyp-custom-endpoint") -@click.option( - "--namespace", - type=click.STRING, - required=False, - default="default", - help="Optional. The namespace of the jumpstart model endpoint to create. Default set to 'default'", -) @click.option("--version", default="1.0", help="Schema version to use") +@click.option("--debug", default=False, help="Enable debug mode") @generate_click_command( schema_pkg="hyperpod_custom_inference_template", registry=C_REG, ) @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "create_custom_endpoint_cli") @handle_cli_exceptions() -def custom_create(name, namespace, version, custom_endpoint): +def custom_create(version, debug, custom_endpoint): """ Create a custom model endpoint. """ - - custom_endpoint.create(name=name, namespace=namespace) - + click.echo(f"Using version: {version}") + custom_endpoint.create(debug=debug) + # INVOKE @click.command("hyp-custom-endpoint") diff --git a/src/sagemaker/hyperpod/cli/commands/init.py b/src/sagemaker/hyperpod/cli/commands/init.py index 572cc936..ae6eca0d 100644 --- a/src/sagemaker/hyperpod/cli/commands/init.py +++ b/src/sagemaker/hyperpod/cli/commands/init.py @@ -393,11 +393,7 @@ def _default_create(region): filtered_config = _filter_cli_metadata_fields(data) flat = model(**filtered_config) domain = flat.to_domain() - # TODO: update inference SDK to include name and namespace in the call - if template == "hyp-custom-endpoint" or template == "hyp-jumpstart-endpoint": - domain.create(namespace=namespace) - elif template == "hyp-pytorch-job": - domain.create() + domain.create() except Exception as e: diff --git a/src/sagemaker/hyperpod/cli/inference_utils.py b/src/sagemaker/hyperpod/cli/inference_utils.py index 5ecf2395..eb38da16 100644 --- a/src/sagemaker/hyperpod/cli/inference_utils.py +++ b/src/sagemaker/hyperpod/cli/inference_utils.py @@ -29,9 +29,8 @@ def _parse_json_flag(ctx, param, value): # 1) the wrapper click actually invokes def wrapped_func(*args, **kwargs): - namespace = kwargs.pop("namespace", None) - name = kwargs.pop("metadata_name", None) - pop_version = kwargs.pop("version", "1.0") + pop_version = kwargs.pop("version", default_version) + debug = kwargs.pop("debug", False) Model = registry.get(version) if Model is None: @@ -39,7 +38,7 @@ def wrapped_func(*args, **kwargs): flat = Model(**kwargs) domain = flat.to_domain() - return func(name, namespace, version, domain) + return func(version, debug, domain) # 2) inject the special JSON‐env flag before everything else schema = load_schema_for_version(version, schema_pkg) diff --git a/src/sagemaker/hyperpod/inference/hp_endpoint.py b/src/sagemaker/hyperpod/inference/hp_endpoint.py index 963a456c..dc267e1e 100644 --- a/src/sagemaker/hyperpod/inference/hp_endpoint.py +++ b/src/sagemaker/hyperpod/inference/hp_endpoint.py @@ -29,15 +29,16 @@ class HPEndpoint(_HPEndpoint, HPEndpointBase): @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "create_endpoint") def create( self, - name=None, - namespace=None, - debug=False, + debug=False ) -> None: logger = self.get_logger() logger = setup_logging(logger, debug) spec = _HPEndpoint(**self.model_dump(by_alias=True, exclude_none=True)) + name = self.metadata.name if self.metadata else None + namespace = self.metadata.namespace if self.metadata else None + if not spec.endpointName and not name: raise Exception('Either metadata name or endpoint name must be provided') @@ -70,8 +71,6 @@ def create( def create_from_dict( self, input: Dict, - name: str = None, - namespace: str = None, debug=False ) -> None: logger = self.get_logger() @@ -79,6 +78,9 @@ def create_from_dict( spec = _HPEndpoint.model_validate(input, by_name=True) + name = self.metadata.name if self.metadata else None + namespace = self.metadata.namespace if self.metadata else None + if not namespace: namespace = get_default_namespace() diff --git a/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py b/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py index 724e67e6..288086b1 100644 --- a/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py +++ b/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py @@ -30,9 +30,7 @@ class HPJumpStartEndpoint(_HPJumpStartEndpoint, HPEndpointBase): @_hyperpod_telemetry_emitter(Feature.HYPERPOD, "create_js_endpoint") def create( self, - name=None, - namespace=None, - debug=False, + debug=False ) -> None: logger = self.get_logger() logger = setup_logging(logger, debug) @@ -40,6 +38,9 @@ def create( spec = _HPJumpStartEndpoint(**self.model_dump(by_alias=True, exclude_none=True)) endpoint_name = "" + name = self.metadata.name if self.metadata else None + namespace = self.metadata.namespace if self.metadata else None + if spec.sageMakerEndpoint and spec.sageMakerEndpoint.name: endpoint_name = spec.sageMakerEndpoint.name @@ -75,8 +76,6 @@ def create( def create_from_dict( self, input: Dict, - name: str = None, - namespace: str = None, debug = False ) -> None: logger = self.get_logger() @@ -85,6 +84,9 @@ def create_from_dict( spec = _HPJumpStartEndpoint.model_validate(input, by_name=True) endpoint_name = "" + name = self.metadata.name if self.metadata else None + namespace = self.metadata.namespace if self.metadata else None + if spec.sageMakerEndpoint and spec.sageMakerEndpoint.name: endpoint_name = spec.sageMakerEndpoint.name diff --git a/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py b/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py index eecc22b2..1dc20f4e 100644 --- a/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py +++ b/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py @@ -112,7 +112,6 @@ def test_wait_until_inservice(custom_endpoint_name): @pytest.mark.dependency(depends=["create"]) -@pytest.mark.skip def test_custom_invoke(runner, custom_endpoint_name): result = runner.invoke(custom_invoke, [ "--endpoint-name", custom_endpoint_name, diff --git a/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py b/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py index 044dee43..d5cade6d 100644 --- a/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py +++ b/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py @@ -90,7 +90,6 @@ def test_wait_until_inservice(js_endpoint_name): @pytest.mark.dependency(depends=["create"]) -@pytest.mark.skip def test_custom_invoke(runner, js_endpoint_name): result = runner.invoke(custom_invoke, [ "--endpoint-name", js_endpoint_name, diff --git a/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py b/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py index 178cd3cd..f1e30d4b 100644 --- a/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py +++ b/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py @@ -9,6 +9,7 @@ ) import sagemaker_core.main.code_injection.codec as codec from test.integration_tests.utils import get_time_str +from sagemaker.hyperpod.common.config.metadata import Metadata # --------- Test Configuration --------- NAMESPACE = "integration" @@ -68,7 +69,10 @@ def custom_endpoint(): environment_variables=env_vars ) + metadata = Metadata(name=ENDPOINT_NAME, namespace=NAMESPACE) + return HPEndpoint( + metadata=metadata, endpoint_name=ENDPOINT_NAME, instance_type="ml.c5.2xlarge", model_name=MODEL_NAME, @@ -78,7 +82,7 @@ def custom_endpoint(): @pytest.mark.dependency(name="create") def test_create_endpoint(custom_endpoint): - custom_endpoint.create(namespace=NAMESPACE) + custom_endpoint.create() assert custom_endpoint.metadata.name == ENDPOINT_NAME @pytest.mark.dependency(depends=["create"]) diff --git a/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py b/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py index 4e53bf1e..db28d00e 100644 --- a/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py +++ b/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py @@ -9,6 +9,7 @@ ) import sagemaker_core.main.code_injection.codec as codec from test.integration_tests.utils import get_time_str +from sagemaker.hyperpod.common.config.metadata import Metadata # --------- Test Configuration --------- NAMESPACE = "integration" @@ -69,7 +70,10 @@ def custom_endpoint(): environment_variables=env_vars ) + metadata = Metadata(name=ENDPOINT_NAME, namespace=NAMESPACE) + return HPEndpoint( + metadata=metadata, endpoint_name=ENDPOINT_NAME, instance_type="ml.c5.2xlarge", model_name=MODEL_NAME, @@ -79,7 +83,7 @@ def custom_endpoint(): @pytest.mark.dependency(name="create") def test_create_endpoint(custom_endpoint): - custom_endpoint.create(namespace=NAMESPACE) + custom_endpoint.create() assert custom_endpoint.metadata.name == ENDPOINT_NAME @@ -127,7 +131,6 @@ def test_wait_until_inservice(): @pytest.mark.dependency(depends=["create"]) -@pytest.mark.skip def test_invoke_endpoint(monkeypatch): original_transform = codec.transform diff --git a/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py b/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py index 5f8c035e..8b30e664 100644 --- a/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py +++ b/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py @@ -7,6 +7,7 @@ ) import sagemaker_core.main.code_injection.codec as codec from test.integration_tests.utils import get_time_str +from sagemaker.hyperpod.common.config.metadata import Metadata # --------- Config --------- NAMESPACE = "integration" @@ -28,12 +29,13 @@ def endpoint_obj(): model = Model(model_id=MODEL_ID) server = Server(instance_type=INSTANCE_TYPE) sm_endpoint = SageMakerEndpoint(name=ENDPOINT_NAME) + metadata = Metadata(name=ENDPOINT_NAME, namespace=NAMESPACE) - return HPJumpStartEndpoint(model=model, server=server, sage_maker_endpoint=sm_endpoint) + return HPJumpStartEndpoint(metadata=metadata, model=model, server=server, sage_maker_endpoint=sm_endpoint) @pytest.mark.dependency(name="create") def test_create_endpoint(endpoint_obj): - endpoint_obj.create(namespace=NAMESPACE) + endpoint_obj.create() assert endpoint_obj.metadata.name == ENDPOINT_NAME @pytest.mark.dependency(depends=["create"]) diff --git a/test/unit_tests/cli/test_inference.py b/test/unit_tests/cli/test_inference.py index 8cf7ccc3..4f21b405 100644 --- a/test/unit_tests/cli/test_inference.py +++ b/test/unit_tests/cli/test_inference.py @@ -69,7 +69,7 @@ def test_js_create_with_required_args(): ]) assert result.exit_code == 0, result.output - domain_obj.create.assert_called_once_with(name=None, namespace='test-ns') + domain_obj.create.assert_called_once_with(debug=False) def test_js_create_missing_required_args(): @@ -192,7 +192,7 @@ def test_custom_create_with_required_args(): ]) assert result.exit_code == 0, result.output - domain_obj.create.assert_called_once_with(name=None, namespace='test-ns') + domain_obj.create.assert_called_once_with(debug=False) def test_custom_create_missing_required_args(): diff --git a/test/unit_tests/cli/test_inference_utils.py b/test/unit_tests/cli/test_inference_utils.py index 1eee54f8..848114cf 100644 --- a/test/unit_tests/cli/test_inference_utils.py +++ b/test/unit_tests/cli/test_inference_utils.py @@ -53,7 +53,7 @@ def to_domain(self): return self @click.command() @generate_click_command(registry=registry) - def cmd(name, namespace, version, domain): + def cmd(version, debug, domain): click.echo(json.dumps({ 'env': domain.env, 'dimensions': domain.dimensions, 'limits': domain.resources_limits, 'reqs': domain.resources_requests @@ -95,7 +95,7 @@ def to_domain(self): return self @click.command() @generate_click_command(registry=registry) - def cmd(name, namespace, version, domain): + def cmd(version, debug, domain): click.echo(f"{domain.s},{domain.i},{domain.n},{domain.b},{domain.e},{domain.d}") res = self.runner.invoke(cmd, [ @@ -125,7 +125,7 @@ def to_domain(self): # Create test command @click.command() @generate_click_command(schema_pkg='mypkg', registry=registry) - def cmd(name, namespace, version, domain): + def cmd(version, debug, domain): click.echo(f"version: {version}") # Test command execution diff --git a/test/unit_tests/inference/test_hp_endpoint.py b/test/unit_tests/inference/test_hp_endpoint.py index ccface05..ff4f1e16 100644 --- a/test/unit_tests/inference/test_hp_endpoint.py +++ b/test/unit_tests/inference/test_hp_endpoint.py @@ -16,6 +16,7 @@ Worker, ) from sagemaker.hyperpod.inference.config.constants import * +from sagemaker.hyperpod.common.config import Metadata class TestHPEndpoint(unittest.TestCase): @@ -95,26 +96,68 @@ def setUp(self): @patch.object(HPEndpoint, "validate_instance_type") @patch.object(HPEndpoint, "call_create_api") - def test_create(self, mock_create_api, mock_validate_instance_type): + @patch('sagemaker.hyperpod.inference.hp_endpoint.get_default_namespace', return_value='default') + def test_create(self, mock_get_namespace, mock_create_api, mock_validate_instance_type): - self.endpoint.create(name="test-name", namespace="test-ns") + self.endpoint.create() mock_create_api.assert_called_once_with( - name="test-name", + name="s3-test-endpoint-name", kind=INFERENCE_ENDPOINT_CONFIG_KIND, - namespace="test-ns", + namespace="default", spec=unittest.mock.ANY, debug=False, ) - self.assertEqual(self.endpoint.metadata.name, "test-name") + self.assertEqual(self.endpoint.metadata.name, "s3-test-endpoint-name") + + @patch.object(HPEndpoint, "validate_instance_type") + @patch.object(HPEndpoint, "call_create_api") + def test_create_with_metadata(self, mock_create_api, mock_validate_instance_type): + """Test create_from_dict uses metadata name and namespace when endpoint name not provided""" + + # Create endpoint without sageMakerEndpoint name to force using metadata + endpoint_without_name = HPEndpoint( + model_source_config = ModelSourceConfig( + model_source_type="s3", + model_location="deepseek15b", + s3_storage=S3Storage( + bucket_name="test-model-s3-zhaoqi", + region="us-east-2", + ), + ), + tls_config=TlsConfig(tls_certificate_output_s3_uri="s3://test-bucket"), + metadata=Metadata(name="metadata-test-name", namespace="metadata-test-ns"), + instance_type="ml.g5.xlarge", + model_name="deepseek15b-test-model-name", + worker=Worker( + image="763104351884.dkr.ecr.us-east-2.amazonaws.com/huggingface-pytorch-tgi-inference:2.4.0-tgi2.3.1-gpu-py311-cu124-ubuntu22.04-v2.0", + model_volume_mount=ModelVolumeMount( + name="model-weights", + ), + model_invocation_port=ModelInvocationPort(container_port=8080), + resources=Resources( + requests={"cpu": "30000m", "nvidia.com/gpu": 1, "memory": "100Gi"}, + limits={"nvidia.com/gpu": 1}, + ) + ) + ) + + endpoint_without_name.create() + + # Verify it uses metadata name and namespace + mock_create_api.assert_called_once() + call_args = mock_create_api.call_args[1] + assert call_args['name'] == 'metadata-test-name' + assert call_args['namespace'] == 'metadata-test-ns' @patch.object(HPEndpoint, "validate_instance_type") @patch.object(HPEndpoint, "call_create_api") - def test_create_from_dict(self, mock_create_api, mock_validate_instance_type): + @patch('sagemaker.hyperpod.inference.hp_endpoint.get_default_namespace', return_value='default') + def test_create_from_dict(self, mock_get_namespace, mock_create_api, mock_validate_instance_type): input_dict = self.endpoint.model_dump(exclude_none=True) - self.endpoint.create_from_dict(input_dict, namespace="test-ns") + self.endpoint.create_from_dict(input_dict) mock_create_api.assert_called_once_with( name=unittest.mock.ANY, @@ -127,14 +170,14 @@ def test_create_from_dict(self, mock_create_api, mock_validate_instance_type): @patch.object(HPEndpoint, "call_get_api") def test_refresh(self, mock_get_api): self.endpoint.metadata = MagicMock() - self.endpoint.metadata.name = "test-name" - self.endpoint.metadata.namespace = "test-ns" + self.endpoint.metadata.name = "s3-test-endpoint-name" + self.endpoint.metadata.namespace = "default" mock_get_api.return_value = {"status": {"state": "DeploymentComplete"}} result = self.endpoint.refresh() mock_get_api.assert_called_once_with( - name="test-name", kind=INFERENCE_ENDPOINT_CONFIG_KIND, namespace="test-ns" + name="s3-test-endpoint-name", kind=INFERENCE_ENDPOINT_CONFIG_KIND, namespace="default" ) self.assertEqual(result, self.endpoint) @@ -146,12 +189,12 @@ def test_list(self, mock_list_api, mock_get): } mock_get.return_value = MagicMock() - result = HPEndpoint.list(namespace="test-ns") + result = HPEndpoint.list(namespace="default") mock_list_api.assert_called_once_with( - kind=INFERENCE_ENDPOINT_CONFIG_KIND, namespace="test-ns" + kind=INFERENCE_ENDPOINT_CONFIG_KIND, namespace="default" ) - mock_get.assert_called_once_with("test-endpoint", namespace="test-ns") + mock_get.assert_called_once_with("test-endpoint", namespace="default") self.assertIsInstance(result, list) @patch.object(HPEndpoint, "call_get_api") @@ -159,15 +202,15 @@ def test_get(self, mock_get_api): mock_get_api.return_value = { "spec": self.endpoint.model_dump(exclude_none=True), "status": {"state": "DeploymentComplete"}, - "metadata": {"name": self.endpoint.modelName, "namespace": "test-ns"}, + "metadata": {"name": self.endpoint.modelName, "namespace": "default"}, } - result = HPEndpoint.get(self.endpoint.modelName, namespace="test-ns") + result = HPEndpoint.get(self.endpoint.modelName, namespace="default") mock_get_api.assert_called_once_with( name=self.endpoint.modelName, kind=INFERENCE_ENDPOINT_CONFIG_KIND, - namespace="test-ns", + namespace="default", ) self.assertIsInstance(result, HPEndpoint) @@ -175,12 +218,12 @@ def test_get(self, mock_get_api): def test_delete(self, mock_delete_api): self.endpoint.metadata = MagicMock() self.endpoint.metadata.name = "test-name" - self.endpoint.metadata.namespace = "test-ns" + self.endpoint.metadata.namespace = "default" self.endpoint.delete() mock_delete_api.assert_called_once_with( - name="test-name", kind=INFERENCE_ENDPOINT_CONFIG_KIND, namespace="test-ns" + name="test-name", kind=INFERENCE_ENDPOINT_CONFIG_KIND, namespace="default" ) @patch("sagemaker.hyperpod.common.utils.get_cluster_context") @@ -229,11 +272,11 @@ def test_list_pods(self, mock_verify_config, mock_core_api, mock_list_api): ] } - result = self.endpoint.list_pods(namespace="test-ns") + result = self.endpoint.list_pods(namespace="default") self.assertEqual(result, ["custom-endpoint-pod1", "custom-endpoint-pod2"]) mock_core_api.return_value.list_namespaced_pod.assert_called_once_with( - namespace="test-ns" + namespace="default" ) @patch("kubernetes.client.CoreV1Api") @@ -254,9 +297,9 @@ def test_list_pods_with_endpoint_name(self, mock_verify_config, mock_core_api): mock_pod3, ] - result = self.endpoint.list_pods(namespace="test-ns", endpoint_name="custom-endpoint1") + result = self.endpoint.list_pods(namespace="default", endpoint_name="custom-endpoint1") self.assertEqual(result, ["custom-endpoint1-pod1", "custom-endpoint1-pod2"]) mock_core_api.return_value.list_namespaced_pod.assert_called_once_with( - namespace="test-ns" + namespace="default" ) diff --git a/test/unit_tests/inference/test_hp_jumpstart_endpoint.py b/test/unit_tests/inference/test_hp_jumpstart_endpoint.py index 452ad326..b0e42a6a 100644 --- a/test/unit_tests/inference/test_hp_jumpstart_endpoint.py +++ b/test/unit_tests/inference/test_hp_jumpstart_endpoint.py @@ -8,7 +8,7 @@ SageMakerEndpoint, TlsConfig, ) - +from sagemaker.hyperpod.common.config import Metadata class TestHPJumpStartEndpoint(unittest.TestCase): def setUp(self): @@ -35,39 +35,51 @@ def setUp(self): @patch.object(HPJumpStartEndpoint, "validate_instance_type") @patch.object(HPJumpStartEndpoint, "call_create_api") - def test_create(self, mock_create_api, mock_validate_instance_type): + @patch('sagemaker.hyperpod.inference.hp_jumpstart_endpoint.get_default_namespace', return_value='default') + def test_create(self, mock_get_namespace, mock_create_api, mock_validate_instance_type): - self.endpoint.create(name="test-name", namespace="test-ns") + self.endpoint.create() mock_create_api.assert_called_once_with( - name="test-name", + name="bert-testing-jumpstart-7-2-2", kind=JUMPSTART_MODEL_KIND, - namespace="test-ns", + namespace="default", spec=unittest.mock.ANY, debug=False, ) - self.assertEqual(self.endpoint.metadata.name, "test-name") + self.assertEqual(self.endpoint.metadata.name, "bert-testing-jumpstart-7-2-2") + @patch.object(HPJumpStartEndpoint, "validate_instance_type") @patch.object(HPJumpStartEndpoint, "call_create_api") - def test_create_from_dict(self, mock_create_api, mock_validate_instance_type): + def test_create_with_metadata(self, mock_create_api, mock_validate_instance_type): + """Test create_from_dict uses metadata name and namespace when endpoint name not provided""" + + # Create endpoint without sageMakerEndpoint name to force using metadata + endpoint_without_name = HPJumpStartEndpoint( + model=Model(model_id="test-model"), + server=Server(instance_type="ml.c5.2xlarge"), + tls_config=TlsConfig(tls_certificate_output_s3_uri="s3://test-bucket"), + metadata=Metadata(name="metadata-test-name", namespace="metadata-test-ns") + ) - input_dict = { - "model": {"modelId": "test-model"}, - "server": {"instance_type": "ml.c5.2xlarge"}, - } + endpoint_without_name.create() - self.endpoint.create_from_dict( - input_dict, name="test-name", namespace="test-ns" - ) + # Verify it uses metadata name and namespace + mock_create_api.assert_called_once() + call_args = mock_create_api.call_args[1] + assert call_args['name'] == 'metadata-test-name' + assert call_args['namespace'] == 'metadata-test-ns' - mock_create_api.assert_called_once_with( - name="test-name", - kind=JUMPSTART_MODEL_KIND, - namespace="test-ns", - spec=unittest.mock.ANY, - debug=False, - ) + + @patch.object(HPJumpStartEndpoint, "validate_instance_type") + @patch.object(HPJumpStartEndpoint, "call_create_api") + @patch('sagemaker.hyperpod.inference.hp_jumpstart_endpoint.get_default_namespace', return_value='default') + def test_create_from_dict(self, mock_get_namespace, mock_create_api, mock_validate_instance_type): + + input_dict = self.endpoint.model_dump(exclude_none=True) + + self.endpoint.create_from_dict(input_dict) @patch.object(HPJumpStartEndpoint, "call_get_api") def test_refresh(self, mock_get_api): From a571044d45e129089dd08f9853977675fbb29ad6 Mon Sep 17 00:00:00 2001 From: Molly He Date: Wed, 10 Sep 2025 19:36:26 -0700 Subject: [PATCH 05/13] Cluster-stack template agnostic change (#245) * decouple template from src code * remove field validator from SDK pydantic model, fix minor parsing problem with list, update kubernetes_version type from str to float * change type handler from class to module functions, change some public function to private, update unit tests * cluster-stack template agnostic change * update unit tests * update integ test * resolve circular import for cluster_stack * resolve rebase merge conflict * rename to_domain to to_config for cluster_stack * increase timeout for endpoint integ test from 15min to 20min --- .../v1_0/model.py | 76 +++++- .../hyperpod/cli/commands/cluster_stack.py | 99 +++---- src/sagemaker/hyperpod/cli/commands/init.py | 72 ++--- .../hyperpod/cli/constants/init_constants.py | 30 +++ src/sagemaker/hyperpod/cli/init_utils.py | 88 +------ .../hyperpod/cli/type_handler_utils.py | 31 ++- .../hyperpod/inference/hp_endpoint.py | 2 +- .../inference/hp_jumpstart_endpoint.py | 2 +- .../cli/test_cli_custom_fsx_inference.py | 2 +- .../cli/test_cli_custom_s3_inference.py | 2 +- .../cli/test_cli_jumpstart_inference.py | 2 +- .../sdk/test_sdk_custom_fsx_inference.py | 2 +- .../sdk/test_sdk_custom_s3_inference.py | 2 +- .../sdk/test_sdk_jumpstart_inference.py | 2 +- .../init/test_jumpstart_creation.py | 4 +- test/unit_tests/cli/test_cluster_stack.py | 249 ++++-------------- test/unit_tests/cli/test_init_utils.py | 161 +---------- 17 files changed, 239 insertions(+), 587 deletions(-) diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py index 7e466902..64a6c409 100644 --- a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, Field, field_validator from typing import Optional, Literal, List, Any, Union +from sagemaker.hyperpod.common.utils import region_to_az_ids class ClusterStackBase(BaseModel): resource_name_prefix: Optional[str] = Field("hyp-eks-stack", description="Prefix to be used for all resources. A 4-digit UUID will be added to prefix during submission") @@ -56,4 +57,77 @@ class ClusterStackBase(BaseModel): def validate_kubernetes_version(cls, v): if v is not None: return str(v) - return v \ No newline at end of file + return v + + def to_config(self, region: str = None): + """Convert CLI model to SDK configuration for cluster stack creation. + + Transforms the CLI model instance into a configuration dictionary that can be used + to instantiate the HpClusterStack SDK class. Applies necessary transformations + including AZ configuration, UUID generation, and field restructuring. + + Args: + region (str, optional): AWS region for AZ configuration. If provided, + automatically sets availability_zone_ids and fsx_availability_zone_id + when not already specified. + + Returns: + dict: Configuration dictionary ready for HpClusterStack instantiation. + Contains all transformed parameters with defaults applied. + + Example: + >>> cli_model = ClusterStackBase(hyperpod_cluster_name="my-cluster") + >>> config = cli_model.to_config(region="us-west-2") + >>> sdk_instance = HpClusterStack(**config) + """ + import uuid + + # Convert model to dict and apply transformations + config = self.model_dump(exclude_none=True) + + # Prepare CFN arrays from numbered fields + instance_group_settings = [] + rig_settings = [] + for i in range(1, 21): + ig_key = f'instance_group_settings{i}' + rig_key = f'rig_settings{i}' + if ig_key in config: + instance_group_settings.append(config.pop(ig_key)) + if rig_key in config: + rig_settings.append(config.pop(rig_key)) + + # Add arrays to config + if instance_group_settings: + config['instance_group_settings'] = instance_group_settings + if rig_settings: + config['rig_settings'] = rig_settings + + # Add default AZ configuration if not provided + if region and (not config.get('availability_zone_ids') or not config.get('fsx_availability_zone_id')): + all_az_ids = region_to_az_ids(region) + default_az_config = { + 'availability_zone_ids': all_az_ids[:2], # First 2 AZs + 'fsx_availability_zone_id': all_az_ids[0] # First AZ + } + if not config.get('availability_zone_ids'): + config['availability_zone_ids'] = default_az_config['availability_zone_ids'] + if not config.get('fsx_availability_zone_id'): + config['fsx_availability_zone_id'] = default_az_config['fsx_availability_zone_id'] + + # Append 4-digit UUID to resource_name_prefix + if config.get('resource_name_prefix'): + config['resource_name_prefix'] = f"{config['resource_name_prefix']}-{str(uuid.uuid4())[:4]}" + + # Set fixed defaults + defaults = { + 'custom_bucket_name': 'sagemaker-hyperpod-cluster-stack-bucket', + 'github_raw_url': 'https://raw.githubusercontent.com/aws-samples/awsome-distributed-training/refs/heads/main/1.architectures/7.sagemaker-hyperpod-eks/LifecycleScripts/base-config/on_create.sh', + 'helm_repo_url': 'https://github.com/aws/sagemaker-hyperpod-cli.git', + 'helm_repo_path': 'helm_chart/HyperPodHelmChart' + } + + for key, default_value in defaults.items(): + if key not in config: + config[key] = default_value + + return config \ No newline at end of file diff --git a/src/sagemaker/hyperpod/cli/commands/cluster_stack.py b/src/sagemaker/hyperpod/cli/commands/cluster_stack.py index e6921ae3..e5aea089 100644 --- a/src/sagemaker/hyperpod/cli/commands/cluster_stack.py +++ b/src/sagemaker/hyperpod/cli/commands/cluster_stack.py @@ -18,6 +18,10 @@ from sagemaker.hyperpod.common.telemetry.constants import Feature from sagemaker.hyperpod.common.utils import setup_logging from sagemaker.hyperpod.cli.utils import convert_datetimes +from sagemaker.hyperpod.cli.init_utils import _filter_cli_metadata_fields +from sagemaker.hyperpod.cli.init_utils import load_config +from sagemaker.hyperpod.cli.constants.init_constants import TEMPLATES +from pathlib import Path from sagemaker.hyperpod.cli.cluster_stack_utils import ( StackNotFoundError, delete_stack_with_confirmation @@ -67,79 +71,34 @@ def create_cluster_stack(config_file, region, debug): # Create with debug logging hyp create hyp-cluster cluster-config.yaml my-stack-name --debug """ - create_cluster_stack_helper(config_file, region, debug) - -def create_cluster_stack_helper(config_file: str, region: Optional[str] = None, debug: bool = False) -> None: - """Helper function to create a HyperPod cluster stack. - - **Parameters:** - - .. list-table:: - :header-rows: 1 - :widths: 20 20 60 - - * - Parameter - - Type - - Description - * - config_file - - str - - Path to the YAML configuration file containing cluster stack settings - * - region - - str, optional - - AWS region where the cluster stack will be created - * - debug - - bool - - Enable debug logging for detailed error information - - **Raises:** - - ClickException: When cluster stack creation fails or configuration is invalid - """ try: # Validate the config file path if not os.path.exists(config_file): logger.error(f"Config file not found: {config_file}") return - # Load the configuration from the YAML file - import yaml - import uuid - with open(config_file, 'r') as f: - config_data = yaml.safe_load(f) - - # Filter out template and namespace fields - filtered_config = {} - for k, v in config_data.items(): - if k not in ('template', 'namespace') and v is not None: - # Append 4-digit UUID to resource_name_prefix - if k == 'resource_name_prefix' and v: - v = f"{v}-{str(uuid.uuid4())[:4]}" - filtered_config[k] = v - - # Create the HpClusterStack object - # Ensure fixed defaults are always set - if 'custom_bucket_name' not in filtered_config: - filtered_config['custom_bucket_name'] = 'sagemaker-hyperpod-cluster-stack-bucket' - if 'github_raw_url' not in filtered_config: - filtered_config['github_raw_url'] = 'https://raw.githubusercontent.com/aws-samples/awsome-distributed-training/refs/heads/main/1.architectures/7.sagemaker-hyperpod-eks/LifecycleScripts/base-config/on_create.sh' - if 'helm_repo_url' not in filtered_config: - filtered_config['helm_repo_url'] = 'https://github.com/aws/sagemaker-hyperpod-cli.git' - if 'helm_repo_path' not in filtered_config: - filtered_config['helm_repo_path'] = 'helm_chart/HyperPodHelmChart' - - cluster_stack = HpClusterStack(**filtered_config) + # Load config to get template and version + + config_dir = Path(config_file).parent + data, template, version = load_config(config_dir) + + # Get model from registry + registry = TEMPLATES[template]["registry"] + model_class = registry.get(str(version)) + + if model_class: + # Filter out CLI metadata fields + filtered_config = _filter_cli_metadata_fields(data) - # Log the configuration - logger.info("Creating HyperPod cluster stack with the following configuration:") - for key, value in filtered_config.items(): - if value is not None: - logger.info(f" {key}: {value}") + # Create model instance and domain + model_instance = model_class(**filtered_config) + config = model_instance.to_config(region=region) - # Create the cluster stack - stack_id = cluster_stack.create(region) + # Create the cluster stack + stack_id = HpClusterStack(**config).create(region) - logger.info(f"Stack creation initiated successfully with ID: {stack_id}") - logger.info("You can monitor the stack creation in the AWS CloudFormation console.") + logger.info(f"Stack creation initiated successfully with ID: {stack_id}") + logger.info("You can monitor the stack creation in the AWS CloudFormation console.") except Exception as e: logger.error(f"Failed to create cluster stack: {e}") @@ -147,6 +106,7 @@ def create_cluster_stack_helper(config_file: str, region: Optional[str] = None, logger.exception("Detailed error information:") raise click.ClickException(str(e)) + @click.command("cluster-stack") @click.argument("stack-name", required=True) @click.option("--region", help="AWS region") @@ -223,6 +183,7 @@ def describe_cluster_stack(stack_name: str, debug: bool, region: str) -> None: raise click.ClickException(str(e)) + @click.command("cluster-stack") @click.option("--region", help="AWS region") @click.option("--debug", is_flag=True, help="Enable debug logging") @@ -294,10 +255,11 @@ def list_cluster_stacks(region, debug, status): raise click.ClickException(str(e)) + @click.command("cluster-stack") @click.argument("stack-name", required=True) @click.option("--retain-resources", help="Comma-separated list of logical resource IDs to retain during deletion (only works on DELETE_FAILED stacks). Resource names are shown in failed deletion output, or use AWS CLI: 'aws cloudformation list-stack-resources --stack-name STACK_NAME --region REGION'") -@click.option("--region", required=True, help="AWS region (required)") +@click.option("--region", required=True, help="AWS region") @click.option("--debug", is_flag=True, help="Enable debug logging") @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "delete_cluster_stack_cli") def delete_cluster_stack(stack_name: str, retain_resources: str, region: str, debug: bool) -> None: @@ -314,6 +276,10 @@ def delete_cluster_stack(stack_name: str, retain_resources: str, region: str, de # Delete a cluster stack hyp delete cluster-stack my-stack-name --region us-west-2 + # Delete with retained resources (only works on DELETE_FAILED stacks) + hyp delete cluster-stack my-stack-name --retain-resources S3Bucket-TrainingData,EFSFileSystem-Models --region us-west-2 + hyp delete cluster-stack my-stack-name --region us-west-2 + # Delete with retained resources (only works on DELETE_FAILED stacks) hyp delete cluster-stack my-stack-name --retain-resources S3Bucket-TrainingData,EFSFileSystem-Models --region us-west-2 """ @@ -329,7 +295,7 @@ def delete_cluster_stack(stack_name: str, retain_resources: str, region: str, de confirm_callback=lambda msg: click.confirm("Continue?", default=False), success_callback=lambda msg: click.echo(f"✓ {msg}") ) - + except StackNotFoundError: click.secho(f"❌ Stack '{stack_name}' not found", fg='red') except click.ClickException: @@ -341,6 +307,7 @@ def delete_cluster_stack(stack_name: str, retain_resources: str, region: str, de logger.exception("Detailed error information:") raise click.ClickException(str(e)) + @click.command("cluster") @click.option("--cluster-name", required=True, help="The name of the cluster to update") @click.option("--instance-groups", help="Instance Groups JSON string") diff --git a/src/sagemaker/hyperpod/cli/commands/init.py b/src/sagemaker/hyperpod/cli/commands/init.py index ae6eca0d..8d01626d 100644 --- a/src/sagemaker/hyperpod/cli/commands/init.py +++ b/src/sagemaker/hyperpod/cli/commands/init.py @@ -8,11 +8,8 @@ from sagemaker.hyperpod.cli.constants.init_constants import ( USAGE_GUIDE_TEXT_CFN, USAGE_GUIDE_TEXT_CRD, - CFN, - CRD + CFN ) -from sagemaker.hyperpod.common.config import Metadata -from sagemaker.hyperpod.training.hyperpod_pytorch_job import HyperPodPytorchJob from sagemaker.hyperpod.cluster_management.hp_cluster_stack import HpClusterStack from sagemaker.hyperpod.cli.init_utils import ( generate_click_command, @@ -25,8 +22,7 @@ display_validation_results, build_config_from_schema, save_template, - get_default_version_for_template, - add_default_az_ids_to_config, + get_default_version_for_template ) from sagemaker.hyperpod.common.utils import get_aws_default_region @@ -265,7 +261,7 @@ def validate(): @click.command(name="_default_create") -@click.option("--region", "-r", default=None, help="Region, default to your region in aws configure") +@click.option("--region", "-r", default=None, help="Region to create cluster stack for, default to your region in aws configure. Not available for other templates.") def _default_create(region): """ Validate configuration and render template files for deployment. @@ -300,6 +296,11 @@ def _default_create(region): # 1) Load config to determine template type data, template, version = load_config_and_validate(dir_path) + # Check if region flag is used for non-cluster-stack templates + if region and template != "cluster-stack": + click.secho(f"❌ --region flag is only available for cluster-stack template, not for {template}.", fg="red") + sys.exit(1) + # 2) Determine correct jinja file based on template type info = TEMPLATES[template] schema_type = info["schema_type"] @@ -327,27 +328,7 @@ def _default_create(region): try: template_source = jinja_file.read_text() tpl = Template(template_source) - - # For CFN templates, prepare arrays for Jinja template - if schema_type == CFN: - # Prepare instance_group_settings array - instance_group_settings = [] - rig_settings = [] - for i in range(1, 21): - ig_key = f'instance_group_settings{i}' - rig_key = f'rig_settings{i}' - if ig_key in data: - instance_group_settings.append(data[ig_key]) - if rig_key in data: - rig_settings.append(data[rig_key]) - - # Add arrays to template context - template_data = dict(data) - template_data['instance_group_settings'] = instance_group_settings - template_data['rig_settings'] = rig_settings - rendered = tpl.render(**template_data) - else: - rendered = tpl.render(**data) + rendered = tpl.render(**data) except Exception as e: click.secho(f"❌ Failed to render template: {e}", fg="red") sys.exit(1) @@ -375,27 +356,26 @@ def _default_create(region): region = get_aws_default_region() click.secho(f"Submitting to default region: {region}.", fg="yellow") - if schema_type == CFN: - add_default_az_ids_to_config(out_dir, region) - - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper - create_cluster_stack_helper(config_file=f"{out_dir}/config.yaml", - region=region) - else: - dir_path = Path(".").resolve() - data, template, version = load_config(dir_path) - namespace = data.get("namespace", "default") - registry = TEMPLATES[template]["registry"] - model = registry.get(str(version)) - if model: - # Filter out CLI metadata fields before passing to model - from sagemaker.hyperpod.cli.init_utils import _filter_cli_metadata_fields - filtered_config = _filter_cli_metadata_fields(data) - flat = model(**filtered_config) + # Unified pattern for all templates + dir_path = Path(".").resolve() + data, template, version = load_config(dir_path) + registry = TEMPLATES[template]["registry"] + model = registry.get(str(version)) + if model: + # Filter out CLI metadata fields before passing to model + from sagemaker.hyperpod.cli.init_utils import _filter_cli_metadata_fields + filtered_config = _filter_cli_metadata_fields(data) + flat = model(**filtered_config) + + # Pass region to to_domain for cluster stack template + if template == "cluster-stack": + config = flat.to_config(region=region) + HpClusterStack(**config).create(region) + else: domain = flat.to_domain() domain.create() except Exception as e: click.secho(f"❌ Failed to submit the command: {e}", fg="red") - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/src/sagemaker/hyperpod/cli/constants/init_constants.py b/src/sagemaker/hyperpod/cli/constants/init_constants.py index 5a032ef9..1ca80351 100644 --- a/src/sagemaker/hyperpod/cli/constants/init_constants.py +++ b/src/sagemaker/hyperpod/cli/constants/init_constants.py @@ -77,6 +77,36 @@ def version_key(v): } +def _get_handler_from_latest_template(template_name, handler_name): + """Dynamically import handler from the latest version of a template""" + try: + template_info = TEMPLATES[template_name] + registry = template_info["registry"] + + # Get latest version using same logic as _get_latest_class + available_versions = list(registry.keys()) + def version_key(v): + try: + return tuple(map(int, v.split('.'))) + except ValueError: + return (0, 0) + + latest_version = max(available_versions, key=version_key) + latest_model = registry[latest_version] + + # Get handler from module + module = sys.modules[latest_model.__module__] + return getattr(module, handler_name) + except (ImportError, AttributeError): + return None + + +# Template.field to handler mapping - avoids conflicts and works reliably +SPECIAL_FIELD_HANDLERS = { + 'hyp-pytorch-job.volume': _get_handler_from_latest_template("hyp-pytorch-job", "VOLUME_TYPE_HANDLER"), +} + + USAGE_GUIDE_TEXT_CFN = """# SageMaker HyperPod CLI - Initialization Workflow This document explains the initialization workflow and related commands for the SageMaker HyperPod CLI. diff --git a/src/sagemaker/hyperpod/cli/init_utils.py b/src/sagemaker/hyperpod/cli/init_utils.py index 961ba17c..9e8d3f89 100644 --- a/src/sagemaker/hyperpod/cli/init_utils.py +++ b/src/sagemaker/hyperpod/cli/init_utils.py @@ -10,9 +10,6 @@ from pathlib import Path from sagemaker.hyperpod.cli.type_handler_utils import convert_cli_value, to_click_type, is_complex_type, DEFAULT_TYPE_HANDLER from pydantic import ValidationError -from sagemaker.hyperpod.common.utils import ( - region_to_az_ids -) from typing import List, Any from sagemaker.hyperpod.cli.constants.init_constants import ( TEMPLATES, @@ -272,78 +269,6 @@ def save_config_yaml(prefill: dict, comment_map: dict, directory: str): print(f"Configuration saved to: {path}") -def _update_field_in_config(dir_path: str, field_name: str, value): - """Update specific field in config.yaml file while preserving format.""" - config_path = os.path.join(dir_path, "config.yaml") - - with open(config_path, 'r') as f: - lines = f.readlines() - - for i, line in enumerate(lines): - if line.strip().startswith(f"{field_name}:"): - lines[i] = f"{field_name}: {value}\n" - break - - with open(config_path, 'w') as f: - f.writelines(lines) - -def _update_list_field_in_config(dir_path: str, field_name: str, values: List[Any]): - """Update specific field in config.yaml file if the field is a list""" - config_path = os.path.join(dir_path, "config.yaml") - - with open(config_path, 'r') as f: - lines = f.readlines() - - for i, line in enumerate(lines): - if line.strip().startswith(f"{field_name}:"): - # Replace the field line and any subsequent list items - lines[i] = f"{field_name}:\n" - # Remove any existing list items for this field - j = i + 1 - while j < len(lines) and (lines[j].startswith(' - ') or lines[j].strip() == ''): - j += 1 - - # Remove the old list items - del lines[i+1:j] - - # Insert new list items - for k, value in enumerate(values): - lines.insert(i + 1 + k, f" - {value}\n") - - # Add a newline after the list - lines.insert(i + 1 + len(values), "\n") - break - - with open(config_path, 'w') as f: - f.writelines(lines) - -def add_default_az_ids_to_config(dir_path: str, region: str): - # update availability zone id - config_path = dir_path / 'config.yaml' - with open(config_path, 'r') as f: - config_data = yaml.safe_load(f) or {} - - # populdate availability_zone_ids - if not config_data.get('availability_zone_ids'): - try: - all_az_ids = region_to_az_ids(region) - - # default to first two AZ IDs in the region - az_ids = all_az_ids[:2] - - _update_list_field_in_config(dir_path, 'availability_zone_ids', az_ids) - click.secho(f"No availability_zone_ids provided. Using default AZ Id: {az_ids}.", fg="yellow") - except Exception as e: - raise Exception(f"Failed to find default availability_zone_ids for region {region}. Please provide one in config.yaml. Error details: {e}") - - # populate fsx_availability_zone_id - if not config_data.get('fsx_availability_zone_id'): - try: - # default to first az_id - _update_field_in_config(dir_path, 'fsx_availability_zone_id', all_az_ids[0]) - click.secho(f"No fsx_availability_zone_id provided. Using default AZ Id: {all_az_ids[0]}.", fg="yellow") - except Exception as e: - raise Exception(f"Failed to find default fsx_availability_zone_id for region {region}. Please provide one in config.yaml. Error details: {e}") def load_config(dir_path: Path = None) -> Tuple[dict, str, str]: """ @@ -563,6 +488,7 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi values[key] = handler['merge_dicts'](existing_configs, new_configs) # Fields that should not appear in config.yaml (fixed defaults) + # TODO: remove hardcoded exclueded fields or decouple excluded_fields = {'custom_bucket_name', 'github_raw_url', 'helm_repo_url', 'helm_repo_path'} # Build the final config with required fields first, then optional @@ -586,14 +512,4 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi desc = f"[Required] {desc}" comment_map[key] = desc - return full_cfg, comment_map - - -def _pascal_to_kebab(pascal_str): - """Convert PascalCase to CLI kebab-case format""" - result = [] - for i, char in enumerate(pascal_str): - if char.isupper() and i > 0: - result.append('-') - result.append(char.lower()) - return ''.join(result) \ No newline at end of file + return full_cfg, comment_map \ No newline at end of file diff --git a/src/sagemaker/hyperpod/cli/type_handler_utils.py b/src/sagemaker/hyperpod/cli/type_handler_utils.py index e82a2cdd..73b45681 100644 --- a/src/sagemaker/hyperpod/cli/type_handler_utils.py +++ b/src/sagemaker/hyperpod/cli/type_handler_utils.py @@ -3,6 +3,7 @@ import json import click from typing import get_origin +import ast # Utility functions @@ -111,19 +112,23 @@ def parse_strings(ctx_or_value, param=None, value=None): try: return json.loads(actual_value) except json.JSONDecodeError: - # Try to fix unquoted list items: [python, train.py] -> ["python", "train.py"] - if actual_value.strip().startswith('[') and actual_value.strip().endswith(']'): - try: - # Remove brackets and split by comma - inner = actual_value.strip()[1:-1] - items = [item.strip().strip('"').strip("'") for item in inner.split(',')] - return items - except: - pass - - if is_click_callback: - raise click.BadParameter(f"{param.name!r} must be valid JSON or a list like [item1, item2]") - return actual_value + # Try ast.literal_eval for Python-style strings with single quotes + try: + return ast.literal_eval(actual_value) + except (ValueError, SyntaxError): + # Try to fix unquoted list items: [python, train.py] -> ["python", "train.py"] + if actual_value.strip().startswith('[') and actual_value.strip().endswith(']'): + try: + # Remove brackets and split by comma + inner = actual_value.strip()[1:-1] + items = [item.strip().strip('"').strip("'") for item in inner.split(',')] + return items + except: + pass + + if is_click_callback: + raise click.BadParameter(f"{param.name!r} must be valid JSON or a list like [item1, item2]") + return actual_value def write_to_yaml(key, value, file_handle): diff --git a/src/sagemaker/hyperpod/inference/hp_endpoint.py b/src/sagemaker/hyperpod/inference/hp_endpoint.py index dc267e1e..71303de5 100644 --- a/src/sagemaker/hyperpod/inference/hp_endpoint.py +++ b/src/sagemaker/hyperpod/inference/hp_endpoint.py @@ -71,7 +71,7 @@ def create( def create_from_dict( self, input: Dict, - debug=False + debug=False ) -> None: logger = self.get_logger() logger = setup_logging(logger, debug) diff --git a/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py b/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py index 288086b1..9c8965b3 100644 --- a/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py +++ b/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py @@ -76,7 +76,7 @@ def create( def create_from_dict( self, input: Dict, - debug = False + debug=False ) -> None: logger = self.get_logger() logger = setup_logging(logger, debug) diff --git a/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py b/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py index 1dc20f4e..15d9c9ee 100644 --- a/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py +++ b/test/integration_tests/inference/cli/test_cli_custom_fsx_inference.py @@ -20,7 +20,7 @@ NAMESPACE = "integration" VERSION = "1.0" REGION = "us-east-2" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 BETA_FSX = "fs-0402c3308e6aba65c" # fsx id for beta integration test cluster diff --git a/test/integration_tests/inference/cli/test_cli_custom_s3_inference.py b/test/integration_tests/inference/cli/test_cli_custom_s3_inference.py index 9ec3fa0f..62939298 100644 --- a/test/integration_tests/inference/cli/test_cli_custom_s3_inference.py +++ b/test/integration_tests/inference/cli/test_cli_custom_s3_inference.py @@ -19,7 +19,7 @@ NAMESPACE = "integration" VERSION = "1.0" REGION = "us-east-2" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 BETA_BUCKET = "sagemaker-hyperpod-beta-integ-test-model-bucket-n" diff --git a/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py b/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py index d5cade6d..6d7e2c3c 100644 --- a/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py +++ b/test/integration_tests/inference/cli/test_cli_jumpstart_inference.py @@ -12,7 +12,7 @@ NAMESPACE = "integration" VERSION = "1.0" REGION = "us-east-2" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 @pytest.fixture(scope="module") diff --git a/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py b/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py index f1e30d4b..c7c2119b 100644 --- a/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py +++ b/test/integration_tests/inference/sdk/test_sdk_custom_fsx_inference.py @@ -20,7 +20,7 @@ MODEL_LOCATION = "hf-eqa" IMAGE_URI = "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 BETA_FSX = "fs-0402c3308e6aba65c" # fsx id for beta integration test cluster diff --git a/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py b/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py index db28d00e..020caef2 100644 --- a/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py +++ b/test/integration_tests/inference/sdk/test_sdk_custom_s3_inference.py @@ -20,7 +20,7 @@ MODEL_LOCATION = "hf-eqa" IMAGE_URI = "763104351884.dkr.ecr.us-west-2.amazonaws.com/huggingface-pytorch-inference:2.3.0-transformers4.48.0-cpu-py311-ubuntu22.04" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 BETA_BUCKET = "sagemaker-hyperpod-beta-integ-test-model-bucket-n" diff --git a/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py b/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py index 8b30e664..98bf9269 100644 --- a/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py +++ b/test/integration_tests/inference/sdk/test_sdk_jumpstart_inference.py @@ -17,7 +17,7 @@ INSTANCE_TYPE = "ml.g5.8xlarge" MODEL_ID = "deepseek-llm-r1-distill-qwen-1-5b" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 @pytest.fixture(scope="module") diff --git a/test/integration_tests/init/test_jumpstart_creation.py b/test/integration_tests/init/test_jumpstart_creation.py index 84cea135..7bcb44c4 100644 --- a/test/integration_tests/init/test_jumpstart_creation.py +++ b/test/integration_tests/init/test_jumpstart_creation.py @@ -34,7 +34,7 @@ NAMESPACE = "default" VERSION = "1.0" REGION = "us-east-2" -TIMEOUT_MINUTES = 15 +TIMEOUT_MINUTES = 20 POLL_INTERVAL_SECONDS = 30 @pytest.fixture(scope="module") @@ -114,7 +114,7 @@ def test_validate_jumpstart(runner, js_endpoint_name, test_directory): @pytest.mark.dependency(name="create", depends=["validate", "configure", "init"]) def test_create_jumpstart(runner, js_endpoint_name, test_directory): """Create JumpStart endpoint for deployment and verify template rendering.""" - result = runner.invoke(create, ["--region", REGION], catch_exceptions=False) + result = runner.invoke(create, [], catch_exceptions=False) assert_command_succeeded(result) assert "Configuration is valid!" in result.output diff --git a/test/unit_tests/cli/test_cluster_stack.py b/test/unit_tests/cli/test_cluster_stack.py index b34b787b..753433bc 100644 --- a/test/unit_tests/cli/test_cluster_stack.py +++ b/test/unit_tests/cli/test_cluster_stack.py @@ -298,217 +298,56 @@ def test_parse_status_list_non_list_format(self): @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.importlib.resources.read_text') @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack.get_template') -class TestCreateClusterStackHelper(unittest.TestCase): - """Test create_cluster_stack_helper function""" - - @patch('sagemaker.hyperpod.cli.commands.cluster_stack.HpClusterStack') - @patch('yaml.safe_load') + +class TestCreateClusterStack(unittest.TestCase): + """Test create_cluster_stack function""" + @patch('os.path.exists') - @patch('builtins.open', new_callable=mock_open) - def test_create_cluster_stack_helper_success(self, mock_file, mock_exists, mock_yaml_load, mock_cluster_stack, mock_get_template, mock_read_text): + @patch('sagemaker.hyperpod.cli.commands.cluster_stack.TEMPLATES') + @patch('sagemaker.hyperpod.cli.commands.cluster_stack._filter_cli_metadata_fields') + @patch('sagemaker.hyperpod.cli.commands.cluster_stack.load_config') + @patch('sagemaker.hyperpod.cli.commands.cluster_stack.HpClusterStack') + def test_create_cluster_stack_success(self, mock_hp_cluster_stack_class, mock_load_config, mock_filter, mock_templates, mock_exists, mock_get_template, mock_read_text): """Test successful cluster stack creation""" - # Mock template methods - mock_get_template.return_value = '{"Parameters": {}}' - mock_read_text.return_value = 'Parameters: {}' - - with patch('sagemaker.hyperpod.cli.commands.cluster_stack.logger') as mock_logger: - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper - - # Setup mocks - mock_exists.return_value = True - mock_yaml_load.return_value = { - 'template': 'cluster-stack', - 'version': '1.0', - 'eks_cluster_name': 'test-cluster', - 'namespace': 'test-namespace' - } - - mock_stack_instance = Mock() - mock_stack_instance.create.return_value = {'StackId': 'test-stack-id'} - mock_cluster_stack.return_value = mock_stack_instance - - # Execute - create_cluster_stack_helper('config.yaml', 'us-west-2', False) - - # Verify - mock_exists.assert_called_once_with('config.yaml') - mock_yaml_load.assert_called_once() - mock_cluster_stack.assert_called_once_with( - version='1.0', - eks_cluster_name='test-cluster', - custom_bucket_name='sagemaker-hyperpod-cluster-stack-bucket', - github_raw_url='https://raw.githubusercontent.com/aws-samples/awsome-distributed-training/refs/heads/main/1.architectures/7.sagemaker-hyperpod-eks/LifecycleScripts/base-config/on_create.sh', - helm_repo_url='https://github.com/aws/sagemaker-hyperpod-cli.git', - helm_repo_path='helm_chart/HyperPodHelmChart' - ) - mock_stack_instance.create.assert_called_once_with('us-west-2') - - @patch('sagemaker.hyperpod.cli.commands.cluster_stack.logger') - @patch('os.path.exists') - def test_create_cluster_stack_helper_file_not_found(self, - mock_exists, - mock_logger, - mock_get_template, - mock_read_text): - """Test handling of missing config file""" - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper + # Arrange + mock_exists.return_value = True + mock_load_config.return_value = ({'key': 'value'}, 'hyp-cluster-stack', '1.0') + mock_filter.return_value = {'key': 'value'} - mock_exists.return_value = False + mock_model_class = Mock() + mock_model_instance = Mock() + mock_model_instance.to_config.return_value = {'transformed': 'config'} + mock_model_class.return_value = mock_model_instance - create_cluster_stack_helper('nonexistent.yaml', 'us-west-2', False) - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper + mock_sdk_instance = Mock() + mock_sdk_instance.create.return_value = 'stack-123' + mock_hp_cluster_stack_class.return_value = mock_sdk_instance - mock_exists.return_value = False + # Fix: Make registry a proper dict, not Mock + mock_registry = {'1.0': mock_model_class} + mock_template_config = {'registry': mock_registry} + mock_templates.__getitem__.return_value = mock_template_config - create_cluster_stack_helper('nonexistent.yaml', 'us-west-2', False) - - - @patch('sagemaker.hyperpod.cli.commands.cluster_stack.HpClusterStack') - @patch('yaml.safe_load') - @patch('os.path.exists') - @patch('builtins.open', new_callable=mock_open) - def test_create_cluster_stack_helper_filters_template_fields(self, mock_file, mock_exists, mock_yaml_load, mock_cluster_stack, mock_get_template, mock_read_text): - """Test that template and namespace fields are filtered out""" - # Mock template methods - mock_get_template.return_value = '{"Parameters": {}}' - mock_read_text.return_value = 'Parameters: {}' - - with patch('sagemaker.hyperpod.cli.commands.cluster_stack.logger') as mock_logger: - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper - - # Setup mocks - mock_exists.return_value = True - mock_yaml_load.return_value = { - 'template': 'cluster-stack', - 'namespace': 'test-namespace', - 'version': '1.0', - 'eks_cluster_name': 'test-cluster', - 'stage': 'gamma' - } - - mock_stack_instance = Mock() - mock_stack_instance.create.return_value = {'StackId': 'test-stack-id'} - mock_cluster_stack.return_value = mock_stack_instance - - # Execute - create_cluster_stack_helper('config.yaml', 'us-west-2', False) - - # Verify template and namespace were filtered out - call_args = mock_cluster_stack.call_args[1] - assert 'template' not in call_args - assert 'namespace' not in call_args - assert 'eks_cluster_name' in call_args - assert 'stage' in call_args - - @patch('sagemaker.hyperpod.cli.commands.cluster_stack.HpClusterStack') - @patch('yaml.safe_load') - @patch('os.path.exists') - @patch('builtins.open', new_callable=mock_open) - def test_create_cluster_stack_helper_filters_none_values(self, mock_file, mock_exists, mock_yaml_load, mock_cluster_stack, mock_get_template, mock_read_text): - """Test that None values are filtered out""" - # Mock template methods - mock_get_template.return_value = '{"Parameters": {}}' - mock_read_text.return_value = 'Parameters: {}' - - # Setup mocks - mock_exists.return_value = True - mock_yaml_load.return_value = { - 'template': 'cluster-stack', - 'eks_cluster_name': 'test-cluster', - 'optional_field': None, - 'required_field': 'value' - } + from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack - # Mock the stack instance and its create method to avoid AWS calls - mock_stack_instance = Mock() - mock_stack_instance.create.return_value = {'StackId': 'test-stack-id'} - mock_cluster_stack.return_value = mock_stack_instance - - with patch('sagemaker.hyperpod.cli.commands.cluster_stack.logger') as mock_logger: - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper - - # Execute - create_cluster_stack_helper('config.yaml', 'us-west-2', False) - - # Verify None values were filtered out - call_args = mock_cluster_stack.call_args[1] - assert 'optional_field' not in call_args - assert 'required_field' in call_args - - @patch('sagemaker.hyperpod.cli.commands.cluster_stack.HpClusterStack') - @patch('yaml.safe_load') - @patch('os.path.exists') - @patch('builtins.open', new_callable=mock_open) - def test_create_cluster_stack_helper_appends_uuid_to_resource_name_prefix(self, mock_file, mock_exists, mock_yaml_load, mock_cluster_stack, mock_get_template, mock_read_text): - """Test that 4-digit UUID is appended to resource_name_prefix""" - # Mock template methods - mock_get_template.return_value = '{"Parameters": {}}' - mock_read_text.return_value = 'Parameters: {}' - - # Setup mocks - mock_exists.return_value = True - original_prefix = 'hyperpod-cli-integ-test' - mock_yaml_load.return_value = { - 'template': 'cluster-stack', - 'resource_name_prefix': original_prefix, - 'version': '1.0' - } - - # Mock the stack instance and its create method to avoid AWS calls - mock_stack_instance = Mock() - mock_stack_instance.create.return_value = {'StackId': 'test-stack-id'} - mock_cluster_stack.return_value = mock_stack_instance - - with patch('sagemaker.hyperpod.cli.commands.cluster_stack.logger') as mock_logger: - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper - - # Execute - create_cluster_stack_helper('config.yaml', 'us-west-2', False) - - # Verify UUID was appended to resource_name_prefix - call_args = mock_cluster_stack.call_args[1] - modified_prefix = call_args['resource_name_prefix'] - - # Check that the prefix starts with the original value - assert modified_prefix.startswith(original_prefix + '-'), f"Expected prefix to start with '{original_prefix}-', got '{modified_prefix}'" - - # Check that exactly 4 characters were appended (plus the dash) - assert len(modified_prefix) == len(original_prefix) + 5, f"Expected length {len(original_prefix) + 5}, got {len(modified_prefix)}" - - # Check that the appended part is alphanumeric (UUID format) - uuid_part = modified_prefix[len(original_prefix) + 1:] - assert len(uuid_part) == 4, f"UUID part should be 4 characters, got {len(uuid_part)}" - assert uuid_part.replace('-', '').isalnum(), f"UUID part should be alphanumeric, got '{uuid_part}'" + create_cluster_stack.callback('config.yaml', 'us-west-2', False) + + mock_load_config.assert_called_once() + mock_filter.assert_called_once_with({'key': 'value'}) + mock_model_class.assert_called_once_with(**{'key': 'value'}) + mock_model_instance.to_config.assert_called_once_with(region='us-west-2') + mock_hp_cluster_stack_class.assert_called_once_with(**{'transformed': 'config'}) + mock_sdk_instance.create.assert_called_once_with('us-west-2') - @patch('sagemaker.hyperpod.cli.commands.cluster_stack.HpClusterStack') - @patch('yaml.safe_load') @patch('os.path.exists') - @patch('builtins.open', new_callable=mock_open) - def test_create_cluster_stack_helper_handles_empty_resource_name_prefix(self, mock_file, mock_exists, mock_yaml_load, mock_cluster_stack, mock_get_template, mock_read_text): - """Test that empty resource_name_prefix is handled correctly""" - # Mock template methods - mock_get_template.return_value = '{"Parameters": {}}' - mock_read_text.return_value = 'Parameters: {}' - - # Setup mocks - mock_exists.return_value = True - mock_yaml_load.return_value = { - 'template': 'cluster-stack', - 'resource_name_prefix': '', - 'version': '1.0' - } - - # Mock the stack instance and its create method to avoid AWS calls - mock_stack_instance = Mock() - mock_stack_instance.create.return_value = {'StackId': 'test-stack-id'} - mock_cluster_stack.return_value = mock_stack_instance - - with patch('sagemaker.hyperpod.cli.commands.cluster_stack.logger') as mock_logger: - from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack_helper - - # Execute - create_cluster_stack_helper('config.yaml', 'us-west-2', False) - - # Verify empty prefix is not modified - call_args = mock_cluster_stack.call_args[1] - assert call_args['resource_name_prefix'] == '' \ No newline at end of file + def test_create_cluster_stack_file_not_found(self, mock_exists, mock_get_template, mock_read_text): + """Test handling of missing config file""" + # Arrange + mock_exists.return_value = False + + from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack + + create_cluster_stack.callback('nonexistent.yaml', 'us-west-2', False) + + # Assert - function should return early without error + mock_exists.assert_called_once_with('nonexistent.yaml') diff --git a/test/unit_tests/cli/test_init_utils.py b/test/unit_tests/cli/test_init_utils.py index 6aa95c95..c18da95a 100644 --- a/test/unit_tests/cli/test_init_utils.py +++ b/test/unit_tests/cli/test_init_utils.py @@ -22,12 +22,10 @@ filter_validation_errors_for_user_input, display_validation_results, build_config_from_schema, - _pascal_to_kebab ) from sagemaker.hyperpod.cli.constants.init_constants import CFN, CRD import tempfile import os -from sagemaker.hyperpod.cli.init_utils import _update_field_in_config, _update_list_field_in_config class TestSaveK8sJinja: @@ -578,23 +576,6 @@ def test_build_config_with_existing_config(self): assert config['template'] == 'hyp-cluster-stack' -class TestPascalToKebab: - """Test cases for _pascal_to_kebab function""" - - def test_pascal_to_kebab_basic(self): - """Test basic PascalCase to kebab-case conversion""" - assert _pascal_to_kebab('PascalCase') == 'pascal-case' - assert _pascal_to_kebab('SimpleWord') == 'simple-word' - assert _pascal_to_kebab('XMLHttpRequest') == 'x-m-l-http-request' - - def test_pascal_to_kebab_edge_cases(self): - """Test edge cases for _pascal_to_kebab""" - assert _pascal_to_kebab('') == '' - assert _pascal_to_kebab('A') == 'a' - assert _pascal_to_kebab('lowercase') == 'lowercase' - assert _pascal_to_kebab('UPPERCASE') == 'u-p-p-e-r-c-a-s-e' - - class TestGenerateClickCommandEnhanced: """Enhanced test cases for generate_click_command function focusing on union building""" @@ -869,144 +850,4 @@ def dummy_func(template, directory, namespace, version, model_config): return model_config # Assert that the decorator was created successfully - assert callable(dummy_func) - - - -class TestUpdateConfig: - """Test cases for update config functions""" - - def test_update_field_in_config(self): - """Test _update_field_in_config preserves format and updates value.""" - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.yaml") - - # Create test config file - original_content = """# Template type -template: hyp-cluster-stack - -# Schema version -version: 1.0 - -# List of AZs to deploy subnets in -availability_zone_ids: - -# Name of SageMaker HyperPod Cluster -hyperpod_cluster_name: test-cluster -""" - with open(config_path, 'w') as f: - f.write(original_content) - - # Update field - _update_field_in_config(temp_dir, 'availability_zone_ids', 'use1-az1') - - # Read updated content - with open(config_path, 'r') as f: - updated_content = f.read() - - # Verify field was updated and format preserved - assert 'availability_zone_ids: use1-az1' in updated_content - assert '# List of AZs to deploy subnets in' in updated_content - assert 'template: hyp-cluster-stack' in updated_content - - def test_update_list_field_in_config_success(self): - """Test successful update of list field in config.yaml""" - initial_content = """# Test config -field1: value1 - -# List field comment -availability_zone_ids: - - old-az-1 - - old-az-2 - -# Another field -field2: value2 -""" - - expected_content = """# Test config -field1: value1 - -# List field comment -availability_zone_ids: - - use2-az1 - - use2-az2 - - use2-az3 - -# Another field -field2: value2 -""" - - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.yaml") - - # Write initial content - with open(config_path, 'w') as f: - f.write(initial_content) - - # Update the list field - _update_list_field_in_config(temp_dir, "availability_zone_ids", ["use2-az1", "use2-az2", "use2-az3"]) - - # Read and verify the updated content - with open(config_path, 'r') as f: - updated_content = f.read() - - assert updated_content == expected_content - - def test_update_list_field_in_config_empty_list(self): - """Test update with empty list""" - initial_content = """# Test config -availability_zone_ids: - - old-az-1 - -field2: value2 -""" - - expected_content = """# Test config -availability_zone_ids: - -field2: value2 -""" - - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.yaml") - - # Write initial content - with open(config_path, 'w') as f: - f.write(initial_content) - - # Update with empty list - _update_list_field_in_config(temp_dir, "availability_zone_ids", []) - - # Read and verify the updated content - with open(config_path, 'r') as f: - updated_content = f.read() - - assert updated_content == expected_content - - def test_update_list_field_in_config_single_item(self): - """Test update with single item list""" - initial_content = """availability_zone_ids: - - old-az-1 - - old-az-2 -""" - - expected_content = """availability_zone_ids: - - use2-az1 - -""" - - with tempfile.TemporaryDirectory() as temp_dir: - config_path = os.path.join(temp_dir, "config.yaml") - - # Write initial content - with open(config_path, 'w') as f: - f.write(initial_content) - - # Update with single item - _update_list_field_in_config(temp_dir, "availability_zone_ids", ["use2-az1"]) - - # Read and verify the updated content - with open(config_path, 'r') as f: - updated_content = f.read() - - assert updated_content == expected_content + assert callable(dummy_func) \ No newline at end of file From 5dc344a0bf1fe0480a972f1fbe545fe9bfa97120 Mon Sep 17 00:00:00 2001 From: Molly He Date: Mon, 15 Sep 2025 21:50:11 -0700 Subject: [PATCH 06/13] Bugbash fix (#246) * decouple template from src code * remove field validator from SDK pydantic model, fix minor parsing problem with list, update kubernetes_version type from str to float * change type handler from class to module functions, change some public function to private, update unit tests * cluster-stack template agnostic change * update unit tests * update integ test * resolve circular import for cluster_stack * resolve rebase merge conflict * rename to_domain to to_config for cluster_stack * increase timeout for endpoint integ test from 15min to 20min * move jinja template to schema template * lazy loading in pytorch-job template to resolve import issue * tasks_per_node validation added, correct typo for task governance related parameter * get default namespace applied to inference for init experience, ignore pydantic warning, update logging experience * update integ test * fix integ test * Update default namespace logic, init_constants.py naming change * update unit test --- .../registry.py | 5 + .../v1_0/template.py | 2 +- .../registry.py | 5 + .../v1_0/model.py | 2 +- .../v1_0/schema.json | 2 +- .../v1_0/template.py | 2 +- .../registry.py | 5 + .../v1_0/model.py | 3 +- .../v1_0/schema.json | 2 +- .../v1_0/template.py | 2 +- .../create_dataclass.py | 295 ------------------ .../hyperpod_pytorch_job_template/registry.py | 7 + .../v1_0/model.py | 21 +- .../v1_0/template.py | 2 +- .../v1_1/model.py | 49 ++- .../v1_1/schema.json | 6 +- .../v1_1/template.py | 68 ++++ src/sagemaker/hyperpod/cli/commands/init.py | 40 +-- .../hyperpod/cli/constants/init_constants.py | 27 +- src/sagemaker/hyperpod/cli/hyp_cli.py | 8 + src/sagemaker/hyperpod/cli/init_utils.py | 61 ++-- .../test_hp_cluster_creation.py | 1 - .../init/test_custom_creation.py | 1 - .../init/test_jumpstart_creation.py | 1 - .../init/test_pytorch_job_creation.py | 1 - test/unit_tests/cli/test_init_utils.py | 26 +- test/unit_tests/cli/test_save_template.py | 6 +- test/unit_tests/cli/test_training.py | 4 +- 28 files changed, 250 insertions(+), 404 deletions(-) rename src/sagemaker/hyperpod/cli/templates/cfn_cluster_creation.py => hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/template.py (99%) rename src/sagemaker/hyperpod/cli/templates/k8s_custom_endpoint_template.py => hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/template.py (96%) rename src/sagemaker/hyperpod/cli/templates/k8s_js_endpoint_template.py => hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/template.py (86%) delete mode 100644 hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/create_dataclass.py rename src/sagemaker/hyperpod/cli/templates/k8s_pytorch_job_template.py => hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/template.py (97%) create mode 100644 hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/template.py diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py index 2cea5715..ce75e692 100644 --- a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/registry.py @@ -11,7 +11,12 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. from hyperpod_cluster_stack_template.v1_0 import model as v1 +from hyperpod_cluster_stack_template.v1_0.template import TEMPLATE_CONTENT as v1_template SCHEMA_REGISTRY = { "1.0": v1.ClusterStackBase +} + +TEMPLATE_REGISTRY = { + "1.0": v1_template } \ No newline at end of file diff --git a/src/sagemaker/hyperpod/cli/templates/cfn_cluster_creation.py b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/template.py similarity index 99% rename from src/sagemaker/hyperpod/cli/templates/cfn_cluster_creation.py rename to hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/template.py index 5390f362..4e4bc4fd 100644 --- a/src/sagemaker/hyperpod/cli/templates/cfn_cluster_creation.py +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/template.py @@ -1,4 +1,4 @@ -CLOUDFORMATION_CLUSTER_CREATION_TEMPLATE = """### Please keep template file unchanged ### +TEMPLATE_CONTENT = """### Please keep template file unchanged ### Metadata: AWS::CloudFormation::Interface: ParameterGroups: diff --git a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/registry.py b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/registry.py index f681f844..1da3df96 100644 --- a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/registry.py +++ b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/registry.py @@ -11,7 +11,12 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. from hyperpod_custom_inference_template.v1_0 import model as v1 +from hyperpod_custom_inference_template.v1_0.template import TEMPLATE_CONTENT as v1_template SCHEMA_REGISTRY = { "1.0": v1.FlatHPEndpoint, } + +TEMPLATE_REGISTRY = { + "1.0": v1_template +} diff --git a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py index 4437862f..d06ac48d 100644 --- a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py +++ b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/model.py @@ -36,7 +36,7 @@ class FlatHPEndpoint(BaseModel): model_config = ConfigDict(extra="forbid") namespace: Optional[str] = Field( - default="default", + default=None, description="Kubernetes namespace", min_length=1 ) diff --git a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json index 0c8df9f5..8d5c6910 100644 --- a/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json +++ b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/schema.json @@ -11,7 +11,7 @@ "type": "null" } ], - "default": "default", + "default": null, "description": "Kubernetes namespace", "title": "Namespace" }, diff --git a/src/sagemaker/hyperpod/cli/templates/k8s_custom_endpoint_template.py b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/template.py similarity index 96% rename from src/sagemaker/hyperpod/cli/templates/k8s_custom_endpoint_template.py rename to hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/template.py index be7cde19..7f12951c 100644 --- a/src/sagemaker/hyperpod/cli/templates/k8s_custom_endpoint_template.py +++ b/hyperpod-custom-inference-template/hyperpod_custom_inference_template/v1_0/template.py @@ -1,4 +1,4 @@ -KUBERNETES_CUSTOM_ENDPOINT_TEMPLATE = """### Please keep template file unchanged ### +TEMPLATE_CONTENT = """### Please keep template file unchanged ### apiVersion: hyperpod.sagemaker.aws/v1 kind: HPEndpoint metadata: diff --git a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/registry.py b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/registry.py index 401b6d4b..d1abfdea 100644 --- a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/registry.py +++ b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/registry.py @@ -11,7 +11,12 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. from hyperpod_jumpstart_inference_template.v1_0 import model as v1 +from hyperpod_jumpstart_inference_template.v1_0.template import TEMPLATE_CONTENT as v1_template SCHEMA_REGISTRY = { "1.0": v1.FlatHPJumpStartEndpoint, } + +TEMPLATE_REGISTRY = { + "1.0": v1_template +} diff --git a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py index bd94627f..b5f06e1c 100644 --- a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py +++ b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/model.py @@ -27,7 +27,7 @@ class FlatHPJumpStartEndpoint(BaseModel): model_config = ConfigDict(extra="forbid") namespace: Optional[str] = Field( - default="default", + default=None, description="Kubernetes namespace", min_length=1 ) @@ -87,6 +87,7 @@ class FlatHPJumpStartEndpoint(BaseModel): def validate_name(self): if not self.metadata_name and not self.endpoint_name: raise ValueError("Either metadata_name or endpoint_name must be provided") + return self def to_domain(self) -> HPJumpStartEndpoint: diff --git a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json index 3379fba2..175a18b6 100644 --- a/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json +++ b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/schema.json @@ -11,7 +11,7 @@ "type": "null" } ], - "default": "default", + "default": null, "description": "Kubernetes namespace", "title": "Namespace" }, diff --git a/src/sagemaker/hyperpod/cli/templates/k8s_js_endpoint_template.py b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/template.py similarity index 86% rename from src/sagemaker/hyperpod/cli/templates/k8s_js_endpoint_template.py rename to hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/template.py index 03c0232b..e52e9c2e 100644 --- a/src/sagemaker/hyperpod/cli/templates/k8s_js_endpoint_template.py +++ b/hyperpod-jumpstart-inference-template/hyperpod_jumpstart_inference_template/v1_0/template.py @@ -1,4 +1,4 @@ -KUBERNETES_JS_ENDPOINT_TEMPLATE = """### Please keep template file unchanged ### +TEMPLATE_CONTENT = """### Please keep template file unchanged ### apiVersion: inference.sagemaker.aws.amazon.com/v1alpha1 kind: JumpStartModel metadata: diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/create_dataclass.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/create_dataclass.py deleted file mode 100644 index 0c5c4181..00000000 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/create_dataclass.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -""" -Convert Kubernetes CRD OpenAPI v3 Schema to Python Dataclasses -""" - -import json -import yaml -from typing import Dict, Any, List, Optional, Union, Set -from dataclasses import dataclass -import re - - -class CRDToPydanticConverter: - def __init__(self): - self.generated_classes: Set[str] = set() - self.imports = { - 'from pydantic import BaseModel, ConfigDict, Field', - 'from typing import Optional, List, Dict, Union' - } - - def sanitize_class_name(self, name: str) -> str: - """Convert a schema property name to a valid Python class name in PascalCase.""" - # Handle camelCase by inserting underscores before uppercase letters - name = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', name) - - # Replace hyphens and other non-alphanumeric characters with underscores - name = re.sub(r'[^a-zA-Z0-9_]', '_', name) - - # Split by underscores and capitalize each word - words = [word for word in name.split('_') if word] - name = ''.join(word.capitalize() for word in words) - - # Ensure it starts with a letter - if name and name[0].isdigit(): - name = f"Class{name}" - - return name or "UnknownClass" - - def sanitize_field_name(self, name: str) -> str: - """Convert a schema property name to a valid Python field name in snake_case.""" - # Convert camelCase to snake_case - name = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', name) - - # Replace hyphens and other chars with underscores - name = re.sub(r'[^a-zA-Z0-9_]', '_', name) - - # Convert to lowercase - name = name.lower() - - # Remove multiple consecutive underscores - name = re.sub(r'_+', '_', name) - - # Remove leading/trailing underscores - name = name.strip('_') - - # Handle Python keywords - if name in ['class', 'def', 'for', 'if', 'else', 'while', 'try', 'except', 'import', 'from', 'as', 'pass', - 'break', 'continue', 'return']: - name = f"{name}_" - - return name - - def get_python_type(self, schema: Dict[str, Any], property_name: str = "") -> str: - """Convert OpenAPI type to Python type annotation.""" - if 'type' not in schema: - # Handle anyOf, oneOf, allOf - if 'anyOf' in schema: - types = [self.get_python_type(s, property_name) for s in schema['anyOf']] - return f"Union[{', '.join(set(types))}]" - elif 'oneOf' in schema: - types = [self.get_python_type(s, property_name) for s in schema['oneOf']] - return f"Union[{', '.join(set(types))}]" - elif 'allOf' in schema: - # For allOf, we'll treat it as the first type (simplified) - return self.get_python_type(schema['allOf'][0], property_name) if schema['allOf'] else 'Any' - else: - return 'Any' - - schema_type = schema['type'] - - if schema_type == 'string': - return 'str' - elif schema_type == 'integer': - return 'int' - elif schema_type == 'number': - return 'float' - elif schema_type == 'boolean': - return 'bool' - elif schema_type == 'array': - if 'items' in schema: - item_type = self.get_python_type(schema['items'], property_name) - return f'List[{item_type}]' - return 'List[Any]' - elif schema_type == 'object': - if 'properties' in schema: - # Generate a new dataclass for this object - class_name = self.sanitize_class_name(property_name or 'NestedObject') - return class_name - elif 'additionalProperties' in schema: - if isinstance(schema['additionalProperties'], dict): - value_type = self.get_python_type(schema['additionalProperties']) - return f'Dict[str, {value_type}]' - else: - return 'Dict[str, Any]' - return 'Dict[str, Any]' - else: - return 'Any' - - def generate_dataclass(self, name: str, schema: Dict[str, Any], required: List[str] = None) -> str: - """Generate a Pydantic BaseModel from an OpenAPI schema.""" - class_name = self.sanitize_class_name(name) - - if class_name in self.generated_classes: - return "" # Already generated - - self.generated_classes.add(class_name) - required = required or [] - - if 'properties' not in schema: - return "" - - properties = schema['properties'] - fields = [] - nested_classes = [] - - for prop_name, prop_schema in properties.items(): - field_name = self.sanitize_field_name(prop_name) - python_type = self.get_python_type(prop_schema, prop_name) - is_required = prop_name in required - if class_name == "VolumeClaimTemplate" and prop_name == "spec": - prop_name = "VolumeClaimTemplateSpec" - - # Generate nested classes if needed - if prop_schema.get('type') == 'object' and 'properties' in prop_schema: - nested_class = self.generate_dataclass( - prop_name, - prop_schema, - prop_schema.get('required', []) - ) - if nested_class: - nested_classes.append(nested_class) - elif prop_schema.get('type') == 'array' and 'items' in prop_schema: - items_schema = prop_schema['items'] - if items_schema.get('type') == 'object' and 'properties' in items_schema: - nested_class = self.generate_dataclass( - prop_name, - items_schema, - items_schema.get('required', []) - ) - if nested_class: - nested_classes.append(nested_class) - - # Create field definition with Field() for alias mapping - field_config_parts = [] - - # Add alias if field name differs from original property name - if field_name != prop_name: - field_config_parts.append(f'alias="{field_name}"') - - # Add description if available - if 'description' in prop_schema: - description = prop_schema['description'].replace('"', '\\"').replace('\n', ' ').strip() - if description.startswith("DEPRECATED"): - continue - field_config_parts.append(f'description="{description}"') - - # Handle default values and required fields - if is_required: - if 'default' in prop_schema: - default_val = repr(prop_schema['default']) - if field_config_parts: - field_config = ', '.join(field_config_parts) - fields.append(f" {prop_name}: {python_type} = Field(default={default_val}, {field_config})") - else: - fields.append(f" {prop_name}: {python_type} = {default_val}") - else: - if field_config_parts: - field_config = ', '.join(field_config_parts) - fields.append(f" {prop_name}: {python_type} = Field({field_config})") - else: - fields.append(f" {prop_name}: {python_type}") - else: - default_val = 'None' - if 'default' in prop_schema: - default_val = repr(prop_schema['default']) - - if field_config_parts: - field_config = ', '.join(field_config_parts) - fields.append( - f" {prop_name}: Optional[{python_type}] = Field(default={default_val}, {field_config})") - else: - fields.append(f" {prop_name}: Optional[{python_type}] = {default_val}") - - # Generate the Pydantic model - model_code = f"""class {class_name}(BaseModel): -""" - - if schema.get('description'): - description = schema['description'].replace('\n', ' ').strip() - model_code += f' """{description}"""\n' - - # forbid extra inputs - model_code += f" model_config = ConfigDict(extra='forbid')\n\n" - - if fields: - model_code += '\n'.join(fields) - else: - model_code += " pass" - - # Combine nested classes with main class - result = '\n\n'.join(nested_classes) - if result and nested_classes: - result += '\n\n' - result += model_code - - return result - - def convert_crd_schema(self, crd_data: Dict[str, Any]) -> str: - """Convert only the spec portion of a CRD schema to Python dataclasses.""" - results = [] - - # Reset state - self.generated_classes.clear() - - # Extract spec schema from CRD - try: - if 'spec' in crd_data and 'versions' in crd_data['spec']: - # Handle multiple versions - for version in crd_data['spec']['versions']: - if 'schema' in version and 'openAPIV3Schema' in version['schema']: - schema = version['schema']['openAPIV3Schema'] - - if 'properties' in schema and 'spec' in schema['properties']: - # Only generate classes for the spec portion - spec_schema = schema['properties']['spec'] - spec_class = self.generate_dataclass( - f"{crd_data['spec']['names']['kind']}Spec", - spec_schema, - spec_schema.get('required', []) - ) - if spec_class: - results.append(spec_class) - - break # Use first version for now - else: - # Handle direct schema input - assume it's already the spec portion - if 'openAPIV3Schema' in crd_data: - schema = crd_data['openAPIV3Schema'] - main_class = self.generate_dataclass( - "CustomResourceSpec", - schema, - schema.get('required', []) - ) - if main_class: - results.append(main_class) - elif 'properties' in crd_data: - # Direct schema properties - assume it's the spec - main_class = self.generate_dataclass( - "CustomResourceSpec", - crd_data, - crd_data.get('required', []) - ) - if main_class: - results.append(main_class) - - except KeyError as e: - raise ValueError(f"Invalid CRD structure: missing {e}") - - if not results: - raise ValueError("No spec schema found in CRD data") - - # Combine imports and classes - imports_code = '\n'.join(sorted(self.imports)) - classes_code = '\n\n'.join(results) - - return f"{imports_code}\n\n\n{classes_code}" - - -def create_dataclass(crd_file_name: str, python_file_name: str): - converter = CRDToPydanticConverter() - - with open(crd_file_name, 'r') as f: - crd_data = yaml.safe_load(f) - - # Convert to dataclasses - dataclasses_code = converter.convert_crd_schema(crd_data) - - # Save to file - with open(python_file_name, 'w') as f: - f.write(dataclasses_code) - - print("Writing Complete") - -if __name__ == '__main__': - create_dataclass("v1_0/schema_1.json", "v1_0/model.py") \ No newline at end of file diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/registry.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/registry.py index 25713600..999323f8 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/registry.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/registry.py @@ -12,6 +12,8 @@ # language governing permissions and limitations under the License. from .v1_0 import model as v1_0_model # Import your model from .v1_1 import model as v1_1_model +from .v1_0.template import TEMPLATE_CONTENT as v1_0_template +from .v1_1.template import TEMPLATE_CONTENT as v1_1_template from typing import Dict, Type from pydantic import BaseModel @@ -19,4 +21,9 @@ SCHEMA_REGISTRY: Dict[str, Type[BaseModel]] = { "1.0": v1_0_model.PyTorchJobConfig, "1.1": v1_1_model.PyTorchJobConfig, +} + +TEMPLATE_REGISTRY = { + "1.0": v1_0_template, + "1.1": v1_1_template } \ No newline at end of file diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py index 4c9f5a87..1861e3b8 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/model.py @@ -1,7 +1,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional, List, Dict, Union, Literal import click -from sagemaker.hyperpod.cli.type_handler_utils import DEFAULT_TYPE_HANDLER from sagemaker.hyperpod.training.config.hyperpod_pytorch_job_unified_config import ( Containers, ReplicaSpec, @@ -382,11 +381,15 @@ def volume_merge_dicts(existing_volumes, new_volumes): # Handler definition - merge with defaults, only override specific functions -VOLUME_TYPE_HANDLER = { - **DEFAULT_TYPE_HANDLER, # Start with all defaults - 'parse_strings': volume_parse_strings, # Override only these - 'from_dicts': volume_from_dicts, - 'write_to_yaml': volume_write_to_yaml, - 'merge_dicts': volume_merge_dicts, - 'needs_multiple_option': True -} +def _get_volume_type_handler(): + from sagemaker.hyperpod.cli.type_handler_utils import DEFAULT_TYPE_HANDLER + return { + **DEFAULT_TYPE_HANDLER, # Start with all defaults + 'parse_strings': volume_parse_strings, # Override only these + 'from_dicts': volume_from_dicts, + 'write_to_yaml': volume_write_to_yaml, + 'merge_dicts': volume_merge_dicts, + 'needs_multiple_option': True + } + +VOLUME_TYPE_HANDLER = _get_volume_type_handler() diff --git a/src/sagemaker/hyperpod/cli/templates/k8s_pytorch_job_template.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/template.py similarity index 97% rename from src/sagemaker/hyperpod/cli/templates/k8s_pytorch_job_template.py rename to hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/template.py index e5172ac9..a6fd0037 100644 --- a/src/sagemaker/hyperpod/cli/templates/k8s_pytorch_job_template.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_0/template.py @@ -10,7 +10,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. -KUBERNETES_PYTORCH_JOB_TEMPLATE = """### Please keep template file unchanged ### +TEMPLATE_CONTENT = """### Please keep template file unchanged ### apiVersion: sagemaker.amazonaws.com/v1 kind: HyperPodPyTorchJob metadata: diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py index 60ba7aad..4fdd9008 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/model.py @@ -13,7 +13,6 @@ HostPath, PersistentVolumeClaim ) -from sagemaker.hyperpod.cli.type_handler_utils import DEFAULT_TYPE_HANDLER from sagemaker.hyperpod.training.hyperpod_pytorch_job import HyperPodPytorchJob @@ -224,7 +223,33 @@ class PyTorchJobConfig(BaseModel): description="Required topology annotation for scheduling", ) - + @field_validator('tasks_per_node', mode='before') + @classmethod + def validate_tasks_per_node(cls, v): + if v is None: + return v + + # Convert to string for validation + v_str = str(v).lower() + + # Check if it's one of the allowed string values + if v_str in ['auto', 'cpu', 'gpu']: + return v_str + + # Check if it's a valid integer (reject floats) + try: + # First check if it contains a decimal point + if '.' in str(v): + raise ValueError("tasks_per_node must be an integer, not a float") + + int_val = int(v) + if int_val >= 0: + return str(int_val) + else: + raise ValueError("tasks_per_node must be non-negative") + except (ValueError, TypeError): + raise ValueError("tasks_per_node must be 'auto', 'cpu', 'gpu', or a non-negative integer") + @field_validator('volume') def validate_no_duplicates(cls, v): """Validate no duplicate volume names or mount paths.""" @@ -475,11 +500,15 @@ def volume_merge_dicts(existing_volumes, new_volumes): # Handler definition - merge with defaults, only override specific functions -VOLUME_TYPE_HANDLER = { - **DEFAULT_TYPE_HANDLER, # Start with all defaults - 'parse_strings': volume_parse_strings, # Override only these - 'from_dicts': volume_from_dicts, - 'write_to_yaml': volume_write_to_yaml, - 'merge_dicts': volume_merge_dicts, - 'needs_multiple_option': True -} +def _get_volume_type_handler(): + from sagemaker.hyperpod.cli.type_handler_utils import DEFAULT_TYPE_HANDLER + return { + **DEFAULT_TYPE_HANDLER, # Start with all defaults + 'parse_strings': volume_parse_strings, # Override only these + 'from_dicts': volume_from_dicts, + 'write_to_yaml': volume_write_to_yaml, + 'merge_dicts': volume_merge_dicts, + 'needs_multiple_option': True + } + +VOLUME_TYPE_HANDLER = _get_volume_type_handler() diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json index db60306c..8d2827c2 100644 --- a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/schema.json @@ -290,17 +290,17 @@ "minimum": 0, "description": "Amount of memory in GiB" }, - "accelerators-limit": { + "accelerators_limit": { "type": "integer", "minimum": 0, "description": "Limit for the number of accelerators (GPUs/TPUs)" }, - "vcpu-limit": { + "vcpu_limit": { "type": "float", "minimum": 0, "description": "Limit for the number of vCPUs" }, - "memory-limit": { + "memory_limit": { "type": "float", "minimum": 0, "description": "Limit for the amount of memory in GiB" diff --git a/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/template.py b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/template.py new file mode 100644 index 00000000..a6fd0037 --- /dev/null +++ b/hyperpod-pytorch-job-template/hyperpod_pytorch_job_template/v1_1/template.py @@ -0,0 +1,68 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"). You +# may not use this file except in compliance with the License. A copy of +# the License is located at +# +# http://aws.amazon.com/apache2.0/ +# +# or in the "license" file accompanying this file. This file is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +# ANY KIND, either express or implied. See the License for the specific +# language governing permissions and limitations under the License. +TEMPLATE_CONTENT = """### Please keep template file unchanged ### +apiVersion: sagemaker.amazonaws.com/v1 +kind: HyperPodPyTorchJob +metadata: + name: "{{ job_name }}" + namespace: "{{ namespace }}" +{% if queue_name or priority %} labels: +{% if queue_name %} kueue.x-k8s.io/queue-name: "{{ queue_name }}" +{% endif %}{% if priority %} kueue.x-k8s.io/priority-class: "{{ priority }}" +{% endif %}{% endif %}spec: +{% if tasks_per_node %} nprocPerNode: "{{ tasks_per_node }}" +{% endif %} replicaSpecs: + - name: "pod" +{% if node_count %} replicas: {{ node_count }} +{% endif %} template: + metadata: + name: "{{ job_name }}" +{% if namespace %} namespace: "{{ namespace }}" +{% endif %}{% if queue_name or priority %} labels: +{% if queue_name %} kueue.x-k8s.io/queue-name: "{{ queue_name }}" +{% endif %}{% if priority %} kueue.x-k8s.io/priority-class: "{{ priority }}" +{% endif %}{% endif %} spec: + containers: + - name: "container-name" + image: "{{ image }}" +{% if pull_policy %} imagePullPolicy: "{{ pull_policy }}" +{% endif %}{% if command %} command: {{ command | tojson }} +{% endif %}{% if args %} args: {{ args | tojson }} +{% endif %}{% if environment %} env: +{% for key, value in environment.items() %} - name: "{{ key }}" + value: "{{ value }}" +{% endfor %}{% endif %}{% if volume %} volumeMounts: +{% for vol in volume %} - name: "{{ vol.name }}" + mountPath: "{{ vol.mount_path }}" +{% if vol.read_only is not none and vol.read_only != "" %} readOnly: {{ vol.read_only | lower }} +{% endif %}{% endfor %}{% endif %} resources: + requests: + nvidia.com/gpu: "0" + limits: + nvidia.com/gpu: "0" +{% if instance_type or label_selector or deep_health_check_passed_nodes_only %} nodeSelector: +{% if instance_type %} node.kubernetes.io/instance-type: "{{ instance_type }}" +{% endif %}{% if label_selector %}{% for key, value in label_selector.items() %} {{ key }}: "{{ value }}" +{% endfor %}{% endif %}{% if deep_health_check_passed_nodes_only %} deep-health-check-passed: "true" +{% endif %}{% endif %}{% if service_account_name %} serviceAccountName: "{{ service_account_name }}" +{% endif %}{% if scheduler_type %} schedulerName: "{{ scheduler_type }}" +{% endif %}{% if volume %} volumes: +{% for vol in volume %} - name: "{{ vol.name }}" +{% if vol.type == "hostPath" %} hostPath: + path: "{{ vol.path }}" +{% elif vol.type == "pvc" %} persistentVolumeClaim: + claimName: "{{ vol.claim_name }}" +{% endif %}{% endfor %}{% endif %}{% if max_retry %} runPolicy: + cleanPodPolicy: "None" + jobMaxRetryCount: {{ max_retry }} +{% endif %}""" diff --git a/src/sagemaker/hyperpod/cli/commands/init.py b/src/sagemaker/hyperpod/cli/commands/init.py index 8d01626d..c8d93e51 100644 --- a/src/sagemaker/hyperpod/cli/commands/init.py +++ b/src/sagemaker/hyperpod/cli/commands/init.py @@ -108,14 +108,13 @@ def init( directory=str(dir_path), ) + # 4) Generate template + save_template(template, dir_path, version) + except Exception as e: - click.secho(f"💥 Could not write config.yaml: {e}", fg="red") + click.secho(f"💥 Could not write config.yaml or template: {e}", fg="red") sys.exit(1) - # 4) Generate template - if not save_template(template, dir_path): - click.secho("⚠️ Template generation failed", fg="yellow") - # 5) Write README.md if not skip_readme: try: @@ -128,14 +127,17 @@ def init( except Exception as e: click.secho("⚠️ README.md generation failed: %s", e, fg="yellow") + # Convert to relative path for cleaner display + relative_path = Path(directory) if directory != "." else Path("./") + click.secho( - f"✔️ {template} for schema version={version!r} is initialized in {dir_path}", + f"✔️ {template} for schema version={version!r} is initialized in {relative_path}", fg="green", ) click.echo( click.style( "🚀 Welcome!\n" - f"📘 See {dir_path}/README.md for usage.\n", + f"📘 See {relative_path}/README.md for usage.\n", fg="green", ) ) @@ -168,7 +170,7 @@ def reset(): # 4) Regenerate the k8s Jinja template if save_template(template, dir_path): - click.secho(f"✔️ {template} is regenerated.", fg="green") + click.secho(f"✔️ {template} is regenerated.", fg="green") @click.command("configure") @@ -230,7 +232,7 @@ def configure(ctx, model_config): is_valid = display_validation_results( user_input_errors, - success_message="User input is valid!" if user_input_errors else "Configuration updated successfully!", + success_message="User input is valid!" if user_input_errors else "config.yaml updated successfully.", error_prefix="Invalid input arguments:" ) @@ -245,7 +247,6 @@ def configure(ctx, model_config): comment_map=comment_map, directory=str(dir_path), ) - click.secho("✔️ config.yaml updated successfully.", fg="green") except Exception as e: click.secho(f"💥 Could not update config.yaml: {e}", fg="red") sys.exit(1) @@ -313,17 +314,6 @@ def _default_create(region): if not config_file.is_file() or not jinja_file.is_file(): click.secho(f"❌ Missing config.yaml or {jinja_file.name}. Run `hyp init` first.", fg="red") sys.exit(1) - - # 4) Validate config using consolidated function - validation_errors = validate_config_against_model(data, template, version) - is_valid = display_validation_results( - validation_errors, - success_message="Configuration is valid!", - error_prefix="Validation errors:" - ) - - if not is_valid: - sys.exit(1) try: template_source = jinja_file.read_text() @@ -345,7 +335,9 @@ def _default_create(region): output_file = 'cfn_params.yaml' if schema_type == CFN else 'k8s.yaml' with open(out_dir / output_file, 'w', encoding='utf-8') as f: f.write(rendered) - click.secho(f"✔️ Submitted! Files written to {out_dir}", fg="green") + # Use relative path for cleaner display + relative_out_dir = Path("run") / timestamp + click.secho(f"✔️ Submitted! Files written to {relative_out_dir}", fg="green") except Exception as e: click.secho(f"❌ Failed to write run files: {e}", fg="red") sys.exit(1) @@ -354,7 +346,9 @@ def _default_create(region): try : if region is None: region = get_aws_default_region() - click.secho(f"Submitting to default region: {region}.", fg="yellow") + # Only show region message for cluster-stack template + if template == "cluster-stack": + click.secho(f"Submitting to default region: {region}.", fg="yellow") # Unified pattern for all templates dir_path = Path(".").resolve() diff --git a/src/sagemaker/hyperpod/cli/constants/init_constants.py b/src/sagemaker/hyperpod/cli/constants/init_constants.py index 1ca80351..47a4d935 100644 --- a/src/sagemaker/hyperpod/cli/constants/init_constants.py +++ b/src/sagemaker/hyperpod/cli/constants/init_constants.py @@ -1,12 +1,7 @@ -from sagemaker.hyperpod.cli.templates.cfn_cluster_creation import CLOUDFORMATION_CLUSTER_CREATION_TEMPLATE -from sagemaker.hyperpod.cli.templates.k8s_js_endpoint_template import KUBERNETES_JS_ENDPOINT_TEMPLATE -from sagemaker.hyperpod.cli.templates.k8s_custom_endpoint_template import KUBERNETES_CUSTOM_ENDPOINT_TEMPLATE -from sagemaker.hyperpod.cli.templates.k8s_pytorch_job_template import KUBERNETES_PYTORCH_JOB_TEMPLATE - -from hyperpod_jumpstart_inference_template.registry import SCHEMA_REGISTRY as JS_REG -from hyperpod_custom_inference_template.registry import SCHEMA_REGISTRY as C_REG -from hyperpod_pytorch_job_template.registry import SCHEMA_REGISTRY as P_REG -from hyperpod_cluster_stack_template.registry import SCHEMA_REGISTRY as CLUSTER_REG +from hyperpod_jumpstart_inference_template.registry import SCHEMA_REGISTRY as JS_EP_REG, TEMPLATE_REGISTRY as JS_EP_TEMPLATE_REG +from hyperpod_custom_inference_template.registry import SCHEMA_REGISTRY as CUSTOM_EP_REG, TEMPLATE_REGISTRY as CUSTOM_EP_TEMPLATE_REG +from hyperpod_pytorch_job_template.registry import SCHEMA_REGISTRY as PYTORCH_JOB_REG, TEMPLATE_REGISTRY as PYTORCH_JOB_TEMPLATE_REG +from hyperpod_cluster_stack_template.registry import SCHEMA_REGISTRY as CLUSTER_REG, TEMPLATE_REGISTRY as CLUSTER_TEMPLATE_REG import sys @@ -17,31 +12,31 @@ CFN = "cfn" TEMPLATES = { "hyp-jumpstart-endpoint": { - "registry": JS_REG, + "registry": JS_EP_REG, + "template_registry": JS_EP_TEMPLATE_REG, "schema_pkg": "hyperpod_jumpstart_inference_template", "schema_type": CRD, - 'template': KUBERNETES_JS_ENDPOINT_TEMPLATE, 'type': "jinja" }, "hyp-custom-endpoint": { - "registry": C_REG, + "registry": CUSTOM_EP_REG, + "template_registry": CUSTOM_EP_TEMPLATE_REG, "schema_pkg": "hyperpod_custom_inference_template", "schema_type": CRD, - 'template': KUBERNETES_CUSTOM_ENDPOINT_TEMPLATE, 'type': "jinja" }, "hyp-pytorch-job": { - "registry": P_REG, + "registry": PYTORCH_JOB_REG, + "template_registry": PYTORCH_JOB_TEMPLATE_REG, "schema_pkg": "hyperpod_pytorch_job_template", "schema_type": CRD, - 'template': KUBERNETES_PYTORCH_JOB_TEMPLATE, 'type': "jinja" }, "cluster-stack": { "registry": CLUSTER_REG, + "template_registry": CLUSTER_TEMPLATE_REG, "schema_pkg": "hyperpod_cluster_stack_template", "schema_type": CFN, - 'template': CLOUDFORMATION_CLUSTER_CREATION_TEMPLATE, 'type': "jinja" } } diff --git a/src/sagemaker/hyperpod/cli/hyp_cli.py b/src/sagemaker/hyperpod/cli/hyp_cli.py index 94036db1..a21780af 100644 --- a/src/sagemaker/hyperpod/cli/hyp_cli.py +++ b/src/sagemaker/hyperpod/cli/hyp_cli.py @@ -1,3 +1,11 @@ +import warnings +# Reset warnings and show all except Pydantic serialization warnings +warnings.resetwarnings() +warnings.simplefilter("always") +# Suppress specific Pydantic serialization warnings globally +warnings.filterwarnings("ignore", message=".*PydanticSerializationUnexpectedValue.*", category=UserWarning) +warnings.filterwarnings("ignore", message=".*serializer.*", category=UserWarning, module="pydantic") + import click import yaml import json diff --git a/src/sagemaker/hyperpod/cli/init_utils.py b/src/sagemaker/hyperpod/cli/init_utils.py index 9e8d3f89..d23524f7 100644 --- a/src/sagemaker/hyperpod/cli/init_utils.py +++ b/src/sagemaker/hyperpod/cli/init_utils.py @@ -20,15 +20,29 @@ log = logging.getLogger() -def save_template(template: str, directory_path: Path) -> bool: +def save_template(template: str, directory_path: Path, version: str = None) -> bool: """ - Save the appropriate k8s template based on the template type. + Save the appropriate template based on the template type and version. + Template content is loaded directly from the template registry. """ try: - if TEMPLATES[template]["schema_type"] == CRD: - _save_k8s_jinja(directory=str(directory_path), content=TEMPLATES[template]["template"]) - elif TEMPLATES[template]["schema_type"] == CFN: - _save_cfn_jinja(directory=str(directory_path), content=TEMPLATES[template]["template"]) + template_info = TEMPLATES[template] + + # Use provided version or get latest + if version is None: + version = _get_latest_version_from_registry(template) + + # Get template content from registry + template_registry = template_info["template_registry"] + template_content = template_registry.get(str(version)) + + if not template_content: + raise Exception(f"No template found for version {version}") + + if template_info["schema_type"] == CRD: + _save_k8s_jinja(directory=str(directory_path), content=template_content) + elif template_info["schema_type"] == CFN: + _save_cfn_jinja(directory=str(directory_path), content=template_content) return True except Exception as e: click.secho(f"⚠️ Template generation failed: {e}", fg="yellow") @@ -40,7 +54,6 @@ def _save_cfn_jinja(directory: str, content: str): with open(path, "w", encoding="utf-8") as f: f.write(content) - click.secho(f"Cloudformation Parameters Jinja template saved to: {path}") return path def _save_k8s_jinja(directory: str, content: str): @@ -48,7 +61,6 @@ def _save_k8s_jinja(directory: str, content: str): path = os.path.join(directory, "k8s.jinja") with open(path, "w", encoding="utf-8") as f: f.write(content) - print(f"K8s Jinja template saved to: {path}") return path @@ -144,6 +156,11 @@ def _get_handler_for_field(template_name, field_name): def _get_click_option_config(handler, field_type, default=None, required=False, help_text=""): """Get Click option configuration for any handler.""" + # Handle PydanticUndefined for Click compatibility + from pydantic_core import PydanticUndefined + if default is PydanticUndefined: + default = None + config = { "multiple": handler.get('needs_multiple_option', False), "help": help_text, @@ -153,9 +170,6 @@ def _get_click_option_config(handler, field_type, default=None, required=False, if default is not None: config["default"] = default config["show_default"] = True - if required: - config["required"] = required - # Always set type, callback overrides when needed config["type"] = to_click_type(field_type) @@ -218,22 +232,22 @@ def wrapper(*args, **kwargs): # Filter and convert CLI arguments filtered_kwargs = {} for k, v in kwargs.items(): - if v is not None and k in model.__fields__: - field = model.__fields__[k] - field_type = getattr(field, 'outer_type_', getattr(field, 'type_', str)) + if v is not None and k in model.model_fields: + field = model.model_fields[k] + field_type = getattr(field, 'annotation', str) filtered_kwargs[k] = convert_cli_value(v, field_type) model_config = model.model_construct(**filtered_kwargs) return func(model_config=model_config, *args) # Generate Click options directly from model fields - for field_name, field in reversed(list(model.__fields__.items())): + for field_name, field in reversed(list(model.model_fields.items())): if field_name == "version": continue flag_name = field_name.replace('_', '-') - field_type = getattr(field, 'outer_type_', getattr(field, 'type_', str)) - required = getattr(field, 'required', False) + field_type = getattr(field, 'annotation', str) + required = field.is_required() default = getattr(field, 'default', None) help_text = getattr(getattr(field, 'field_info', None), 'description', field_name) or field_name @@ -267,9 +281,6 @@ def save_config_yaml(prefill: dict, comment_map: dict, directory: str): handler['write_to_yaml'](key, handler['from_dicts'](val) if val is not None else val, f) - print(f"Configuration saved to: {path}") - - def load_config(dir_path: Path = None) -> Tuple[dict, str, str]: """ Base function to load and parse config.yaml file. @@ -487,6 +498,16 @@ def build_config_from_schema(template: str, version: str, model_config=None, exi new_configs = val # Keep single str/bool/int as-is values[key] = handler['merge_dicts'](existing_configs, new_configs) + # If namespace is None or not set, use get_default_namespace() + if "namespace" in props and (values.get("namespace") is None): + from sagemaker.hyperpod.common.utils import get_default_namespace + default_namespace = get_default_namespace() + if default_namespace: + values["namespace"] = default_namespace + else: + values["namespace"] = "default" + + # Fields that should not appear in config.yaml (fixed defaults) # TODO: remove hardcoded exclueded fields or decouple excluded_fields = {'custom_bucket_name', 'github_raw_url', 'helm_repo_url', 'helm_repo_path'} diff --git a/test/integration_tests/cluster_management/test_hp_cluster_creation.py b/test/integration_tests/cluster_management/test_hp_cluster_creation.py index 69e4c7b4..c2105907 100644 --- a/test/integration_tests/cluster_management/test_hp_cluster_creation.py +++ b/test/integration_tests/cluster_management/test_hp_cluster_creation.py @@ -194,7 +194,6 @@ def test_create_cluster(runner, cluster_name, create_time): assert_command_succeeded(result) # Verify expected submission messages appear - assert "Configuration is valid!" in result.output assert "Submitted!" in result.output assert "Stack creation initiated" in result.output assert "Stack ID:" in result.output diff --git a/test/integration_tests/init/test_custom_creation.py b/test/integration_tests/init/test_custom_creation.py index cd20bff4..5d627585 100644 --- a/test/integration_tests/init/test_custom_creation.py +++ b/test/integration_tests/init/test_custom_creation.py @@ -150,7 +150,6 @@ def test_create_custom(runner, custom_endpoint_name, test_directory): assert_command_succeeded(result) # Verify expected submission messages appear - assert "Configuration is valid!" in result.output assert "Submitted!" in result.output assert "Creating sagemaker model and endpoint" in result.output assert custom_endpoint_name in result.output diff --git a/test/integration_tests/init/test_jumpstart_creation.py b/test/integration_tests/init/test_jumpstart_creation.py index 7bcb44c4..1006d145 100644 --- a/test/integration_tests/init/test_jumpstart_creation.py +++ b/test/integration_tests/init/test_jumpstart_creation.py @@ -117,7 +117,6 @@ def test_create_jumpstart(runner, js_endpoint_name, test_directory): result = runner.invoke(create, [], catch_exceptions=False) assert_command_succeeded(result) - assert "Configuration is valid!" in result.output assert "Submitted!" in result.output diff --git a/test/integration_tests/init/test_pytorch_job_creation.py b/test/integration_tests/init/test_pytorch_job_creation.py index 740f2ebe..30fdb851 100644 --- a/test/integration_tests/init/test_pytorch_job_creation.py +++ b/test/integration_tests/init/test_pytorch_job_creation.py @@ -119,7 +119,6 @@ def test_create_pytorch_job(runner, pytorch_job_name, test_directory): assert_command_succeeded(result) # Verify expected submission messages appear - assert "Configuration is valid!" in result.output assert "Submitted!" in result.output assert "Successfully submitted HyperPodPytorchJob" in result.output assert pytorch_job_name in result.output diff --git a/test/unit_tests/cli/test_init_utils.py b/test/unit_tests/cli/test_init_utils.py index c18da95a..3e98f0be 100644 --- a/test/unit_tests/cli/test_init_utils.py +++ b/test/unit_tests/cli/test_init_utils.py @@ -52,9 +52,6 @@ def test_save_k8s_jinja_success(self, mock_print, mock_join, mock_path, mock_fil mock_file.assert_called_once_with("/test/dir/k8s.jinja", "w", encoding="utf-8") mock_file().write.assert_called_once_with(content) - # Verify print message - mock_print.assert_called_once_with("K8s Jinja template saved to: /test/dir/k8s.jinja") - # Verify return value assert result == "/test/dir/k8s.jinja" @@ -73,13 +70,15 @@ def create_mock_template(schema_type, registry=None): class TestSaveTemplate: """Test cases for save_template function""" + @patch('sagemaker.hyperpod.cli.init_utils._get_latest_version_from_registry') @patch('sagemaker.hyperpod.cli.init_utils._save_k8s_jinja') - def test_save_template_crd_success(self, mock_save_k8s): + def test_save_template_crd_success(self, mock_save_k8s, mock_get_version): """Test save_template with CRD template type""" + mock_get_version.return_value = '1.0' mock_templates = { 'test-crd': { 'schema_type': CRD, - 'template': 'crd template content' + 'template_registry': {'1.0': 'crd template content'} } } @@ -92,13 +91,15 @@ def test_save_template_crd_success(self, mock_save_k8s): content='crd template content' ) + @patch('sagemaker.hyperpod.cli.init_utils._get_latest_version_from_registry') @patch('sagemaker.hyperpod.cli.init_utils._save_cfn_jinja') - def test_save_template_cfn_success(self, mock_save_cfn): + def test_save_template_cfn_success(self, mock_save_cfn, mock_get_version): """Test save_template with CFN template type""" + mock_get_version.return_value = '1.0' mock_templates = { 'test-cfn': { 'schema_type': CFN, - 'template': 'cfn template content' + 'template_registry': {'1.0': 'cfn template content'} } } @@ -173,9 +174,6 @@ def test_save_config_yaml_success(self, mock_print, mock_join, mock_makedirs, mo assert '# [Required] Kubernetes namespace' in written_content assert 'namespace: test-namespace' in written_content - # Verify print message - mock_print.assert_called_once_with('Configuration saved to: /test/dir/config.yaml') - def test_save_config_yaml_handles_none_values(self): """Test that None values are converted to empty strings""" prefill = { @@ -474,7 +472,8 @@ def test_build_config_cfn_template(self): } with patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES', mock_templates), \ - patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema: + patch('sagemaker.hyperpod.cli.init_utils._load_schema_for_version') as mock_load_schema, \ + patch('sagemaker.hyperpod.common.utils.get_default_namespace', return_value='test-namespace'): # Mock schema mock_load_schema.return_value = { @@ -803,17 +802,20 @@ def test_invalid_json(self, mock_get_data): @patch('builtins.open', new_callable=mock_open) @patch('sagemaker.hyperpod.cli.init_utils.Path') +@patch('sagemaker.hyperpod.cli.init_utils._get_latest_version_from_registry') @patch('sagemaker.hyperpod.cli.init_utils.os.path.join') @patch('sagemaker.hyperpod.cluster_management.hp_cluster_stack.HpClusterStack.get_template') def test_save_cfn_jinja_called(mock_get_template, mock_join, + mock_get_version, mock_path, mock_file): # Setup + mock_get_version.return_value = '1.0' mock_templates = { 'test-template': { 'schema_type': CFN, - 'template': 'test template content' + 'template_registry': {'1.0': 'test template content'} } } mock_join.return_value = '/test/dir/cfn_params.jinja' diff --git a/test/unit_tests/cli/test_save_template.py b/test/unit_tests/cli/test_save_template.py index 131ab938..b3d9ee68 100644 --- a/test/unit_tests/cli/test_save_template.py +++ b/test/unit_tests/cli/test_save_template.py @@ -7,14 +7,16 @@ class TestSaveTemplate: + @patch('sagemaker.hyperpod.cli.init_utils._get_latest_version_from_registry') @patch('sagemaker.hyperpod.cli.init_utils.TEMPLATES') @patch('sagemaker.hyperpod.cli.init_utils._save_cfn_jinja') - def test_save_cfn_jinja_called(self, mock_save_cfn_jinja, mock_templates): + def test_save_cfn_jinja_called(self, mock_save_cfn_jinja, mock_templates, mock_get_version): # Setup + mock_get_version.return_value = '1.0' mock_templates = { 'test-template': { 'schema_type': CFN, - 'template': 'test template content' + 'template_registry': {'1.0': 'test template content'} } } mock_save_cfn_jinja.return_value = '/path/to/cfn_params.jinja' diff --git a/test/unit_tests/cli/test_training.py b/test/unit_tests/cli/test_training.py index b53b78b4..95de870c 100644 --- a/test/unit_tests/cli/test_training.py +++ b/test/unit_tests/cli/test_training.py @@ -515,8 +515,8 @@ def test_integer_field_validation_failure(self): node_count=count ) - # Test tasks_per_node with invalid values - invalid_tasks_per_node = [0, -1, -5] + # Test tasks_per_node with invalid values (negative numbers and floats) + invalid_tasks_per_node = [-1, -5, 1.5, "invalid"] for tasks in invalid_tasks_per_node: with self.subTest(tasks_per_node=tasks): with self.assertRaises(ValidationError): From 7631c8797d5017520b234e775523d8db102611e8 Mon Sep 17 00:00:00 2001 From: Mohamed Zeidan <81834882+mohamedzeidan2021@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:01:32 -0700 Subject: [PATCH 07/13] delete cluster functionality (#247) Co-authored-by: Mohamed Zeidan --- src/sagemaker/hyperpod/cli/common_utils.py | 18 +-- .../cluster_management/hp_cluster_stack.py | 120 +++++++++++++++++- .../test_hp_cluster_stack.py | 78 ++++++------ 3 files changed, 163 insertions(+), 53 deletions(-) diff --git a/src/sagemaker/hyperpod/cli/common_utils.py b/src/sagemaker/hyperpod/cli/common_utils.py index ac8f85ef..e706eb13 100644 --- a/src/sagemaker/hyperpod/cli/common_utils.py +++ b/src/sagemaker/hyperpod/cli/common_utils.py @@ -75,10 +75,10 @@ def parse_comma_separated_list(value: str) -> List[str]: """ Parse a comma-separated string into a list of strings. Generic utility that can be reused across commands. - + Args: value: Comma-separated string like "item1,item2,item3" - + Returns: List of trimmed strings """ @@ -87,25 +87,25 @@ def parse_comma_separated_list(value: str) -> List[str]: return [item.strip() for item in value.split(",") if item.strip()] -def categorize_resources_by_type(resources: List[Dict[str, Any]], +def categorize_resources_by_type(resources: List[Dict[str, Any]], type_mappings: Dict[str, List[str]]) -> Dict[str, List[str]]: """ Generic function to categorize resources by type. - + Args: resources: List of resource dictionaries with 'ResourceType' and 'LogicalResourceId' type_mappings: Dictionary mapping category names to lists of resource types - + Returns: Dictionary of category -> list of resource names """ categorized = {category: [] for category in type_mappings.keys()} categorized["Other"] = [] - + for resource in resources: resource_type = resource.get("ResourceType", "") logical_id = resource.get("LogicalResourceId", "") - + # Find which category this resource type belongs to category_found = False for category, types in type_mappings.items(): @@ -113,9 +113,9 @@ def categorize_resources_by_type(resources: List[Dict[str, Any]], categorized[category].append(logical_id) category_found = True break - + if not category_found: categorized["Other"].append(logical_id) - + # Remove empty categories return {k: v for k, v in categorized.items() if v} diff --git a/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py b/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py index f54e93b0..f65e2791 100644 --- a/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py +++ b/src/sagemaker/hyperpod/cluster_management/hp_cluster_stack.py @@ -510,6 +510,116 @@ def check_status(stack_name: str, region: Optional[str] = None): >>> status = HpClusterStack.check_status("my-stack", region="us-west-2") """ return HpClusterStack._get_stack_status_helper(stack_name, region) + + @staticmethod + def delete(stack_name: str, region: Optional[str] = None, retain_resources: Optional[List[str]] = None, + logger: Optional[logging.Logger] = None) -> None: + """Deletes a HyperPod cluster CloudFormation stack. + + Removes the specified CloudFormation stack and all associated AWS resources. + This operation cannot be undone and proceeds automatically without confirmation. + + **Parameters:** + + .. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - Parameter + - Type + - Description + * - stack_name + - str + - Name of the CloudFormation stack to delete + * - region + - str, optional + - AWS region where the stack exists + * - retain_resources + - List[str], optional + - List of logical resource IDs to retain during deletion (only works on DELETE_FAILED stacks) + * - logger + - logging.Logger, optional + - Logger instance for output messages. Uses default logger if not provided + + **Raises:** + + ValueError: When stack doesn't exist or retain_resources limitation is encountered + RuntimeError: When CloudFormation deletion fails + Exception: For other deletion errors + + .. dropdown:: Usage Examples + :open: + + .. code-block:: python + + >>> # Delete a stack (automatically proceeds without confirmation) + >>> HpClusterStack.delete("my-stack-name") + >>> + >>> # Delete in specific region + >>> HpClusterStack.delete("my-stack-name", region="us-west-2") + >>> + >>> # Delete with retained resources (only works on DELETE_FAILED stacks) + >>> HpClusterStack.delete("my-stack-name", retain_resources=["S3Bucket", "EFSFileSystem"]) + >>> + >>> # Delete with custom logger + >>> import logging + >>> logger = logging.getLogger(__name__) + >>> HpClusterStack.delete("my-stack-name", logger=logger) + """ + from sagemaker.hyperpod.cli.cluster_stack_utils import ( + delete_stack_with_confirmation, + StackNotFoundError + ) + + if logger is None: + logger = logging.getLogger(__name__) + + # Convert retain_resources list to comma-separated string for the utility function + retain_resources_str = ",".join(retain_resources) if retain_resources else "" + + def sdk_confirm_callback(message: str) -> bool: + """SDK-specific confirmation callback - always auto-confirms.""" + logger.info(f"Auto-confirming: {message}") + return True + + try: + delete_stack_with_confirmation( + stack_name=stack_name, + region=region or boto3.session.Session().region_name, + retain_resources_str=retain_resources_str, + message_callback=logger.info, + confirm_callback=sdk_confirm_callback, + success_callback=logger.info + ) + except StackNotFoundError: + error_msg = f"Stack '{stack_name}' not found" + logger.error(error_msg) + raise ValueError(error_msg) + except Exception as e: + error_str = str(e) + + # Handle CloudFormation retain-resources limitation with clear exception for SDK + if retain_resources and "specify which resources to retain only when the stack is in the DELETE_FAILED state" in error_str: + error_msg = ( + f"CloudFormation limitation: retain_resources can only be used on stacks in DELETE_FAILED state. " + f"Current stack state allows normal deletion. Try deleting without retain_resources first, " + f"then retry with retain_resources if deletion fails." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + # Handle termination protection + if "TerminationProtection is enabled" in error_str: + error_msg = ( + f"Stack deletion blocked: Termination Protection is enabled. " + f"Disable termination protection first using AWS CLI or Console." + ) + logger.error(error_msg) + raise RuntimeError(error_msg) + + # Handle other errors + logger.error(f"Failed to delete stack: {error_str}") + raise RuntimeError(f"Stack deletion failed: {error_str}") @staticmethod def delete(stack_name: str, region: Optional[str] = None, retain_resources: Optional[List[str]] = None, @@ -622,8 +732,8 @@ def sdk_confirm_callback(message: str) -> bool: raise RuntimeError(f"Stack deletion failed: {error_str}") -def _yaml_to_json_string(yaml_path) -> str: - """Convert YAML file to JSON string""" - with open(yaml_path, 'r') as file: - yaml_data = yaml.safe_load(file) - return json.dumps(yaml_data, indent=2, ensure_ascii=False) + def _yaml_to_json_string(yaml_path) -> str: + """Convert YAML file to JSON string""" + with open(yaml_path, 'r') as file: + yaml_data = yaml.safe_load(file) + return json.dumps(yaml_data, indent=2, ensure_ascii=False) diff --git a/test/unit_tests/cluster_management/test_hp_cluster_stack.py b/test/unit_tests/cluster_management/test_hp_cluster_stack.py index 035f28c0..9acc75b4 100644 --- a/test/unit_tests/cluster_management/test_hp_cluster_stack.py +++ b/test/unit_tests/cluster_management/test_hp_cluster_stack.py @@ -584,10 +584,10 @@ def test_delete_successful_without_retention(self, mock_get_logger, mock_session mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Execute delete HpClusterStack.delete('test-stack', region='us-west-2') - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args @@ -604,10 +604,10 @@ def test_delete_successful_with_retention(self, mock_get_logger, mock_session, m mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Execute delete with retention HpClusterStack.delete('test-stack', region='us-west-2', retain_resources=['S3Bucket', 'EFSFileSystem']) - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args @@ -624,19 +624,19 @@ def test_delete_with_auto_confirm(self, mock_get_logger, mock_session, mock_dele mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Execute delete (auto-confirm is always enabled now) HpClusterStack.delete('test-stack', region='us-west-2') - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args - + # Test the confirm callback - should always auto-confirm confirm_callback = call_args[1]['confirm_callback'] result = confirm_callback("Test confirmation message") assert result is True - + # Verify logger was called for auto-confirmation mock_logger.info.assert_called_with("Auto-confirming: Test confirmation message") @@ -648,21 +648,21 @@ def test_delete_with_custom_logger(self, mock_get_logger, mock_session, mock_del # Setup mocks custom_logger = MagicMock() mock_session.return_value.region_name = 'us-west-2' - + # Execute delete with custom logger HpClusterStack.delete('test-stack', region='us-west-2', logger=custom_logger) - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args - + # Verify custom logger is used in callbacks message_callback = call_args[1]['message_callback'] success_callback = call_args[1]['success_callback'] - + message_callback("Test message") success_callback("Test success") - + custom_logger.info.assert_any_call("Test message") custom_logger.info.assert_any_call("Test success") @@ -675,10 +675,10 @@ def test_delete_uses_default_region(self, mock_get_logger, mock_session, mock_de mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-east-1' - + # Execute delete without region HpClusterStack.delete('test-stack') - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args @@ -690,17 +690,17 @@ def test_delete_uses_default_region(self, mock_get_logger, mock_session, mock_de def test_delete_stack_not_found(self, mock_get_logger, mock_session, mock_delete_stack): """Test delete handles stack not found error.""" from sagemaker.hyperpod.cli.cluster_stack_utils import StackNotFoundError - + # Setup mocks mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' mock_delete_stack.side_effect = StackNotFoundError("Stack 'non-existent-stack' not found") - + # Execute delete and expect ValueError with self.assertRaises(ValueError) as context: HpClusterStack.delete('non-existent-stack', region='us-west-2') - + assert "Stack 'non-existent-stack' not found" in str(context.exception) mock_logger.error.assert_called_with("Stack 'non-existent-stack' not found") @@ -713,7 +713,7 @@ def test_delete_termination_protection_enabled(self, mock_get_logger, mock_sessi mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Mock termination protection error from botocore.exceptions import ClientError error = ClientError( @@ -721,11 +721,11 @@ def test_delete_termination_protection_enabled(self, mock_get_logger, mock_sessi 'DeleteStack' ) mock_delete_stack.side_effect = error - + # Execute delete and expect RuntimeError with self.assertRaises(RuntimeError) as context: HpClusterStack.delete('protected-stack', region='us-west-2') - + assert "Termination Protection is enabled" in str(context.exception) mock_logger.error.assert_called() @@ -738,7 +738,7 @@ def test_delete_retention_limitation(self, mock_get_logger, mock_session, mock_d mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Mock retention limitation error from botocore.exceptions import ClientError error = ClientError( @@ -746,11 +746,11 @@ def test_delete_retention_limitation(self, mock_get_logger, mock_session, mock_d 'DeleteStack' ) mock_delete_stack.side_effect = error - + # Execute delete with retention and expect ValueError with self.assertRaises(ValueError) as context: HpClusterStack.delete('test-stack', region='us-west-2', retain_resources=['S3Bucket']) - + assert "retain_resources can only be used on stacks in DELETE_FAILED state" in str(context.exception) mock_logger.error.assert_called() @@ -763,7 +763,7 @@ def test_delete_access_denied_error(self, mock_get_logger, mock_session, mock_de mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Mock access denied error from botocore.exceptions import ClientError error = ClientError( @@ -771,11 +771,11 @@ def test_delete_access_denied_error(self, mock_get_logger, mock_session, mock_de 'ListStackResources' ) mock_delete_stack.side_effect = error - + # Execute delete and expect RuntimeError with self.assertRaises(RuntimeError) as context: HpClusterStack.delete('test-stack', region='us-west-2') - + assert "Stack deletion failed" in str(context.exception) mock_logger.error.assert_called() @@ -788,15 +788,15 @@ def test_delete_generic_error(self, mock_get_logger, mock_session, mock_delete_s mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Mock generic error error = Exception("Unexpected error occurred") mock_delete_stack.side_effect = error - + # Execute delete and expect RuntimeError with self.assertRaises(RuntimeError) as context: HpClusterStack.delete('test-stack', region='us-west-2') - + assert "Stack deletion failed: Unexpected error occurred" in str(context.exception) mock_logger.error.assert_called_with("Failed to delete stack: Unexpected error occurred") @@ -806,18 +806,18 @@ def test_delete_uses_default_logger_when_none_provided(self, mock_session, mock_ """Test delete uses default logger when none provided.""" # Setup mocks mock_session.return_value.region_name = 'us-west-2' - + # Execute delete without logger HpClusterStack.delete('test-stack', region='us-west-2') - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args - + # Verify message_callback and success_callback are logger.info methods message_callback = call_args[1]['message_callback'] success_callback = call_args[1]['success_callback'] - + # These should be bound methods of a logger instance assert hasattr(message_callback, '__self__') assert hasattr(success_callback, '__self__') @@ -831,10 +831,10 @@ def test_delete_empty_retain_resources_list(self, mock_get_logger, mock_session, mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Execute delete with empty retain_resources HpClusterStack.delete('test-stack', region='us-west-2', retain_resources=[]) - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args @@ -849,11 +849,11 @@ def test_delete_none_retain_resources(self, mock_get_logger, mock_session, mock_ mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_session.return_value.region_name = 'us-west-2' - + # Execute delete with None retain_resources HpClusterStack.delete('test-stack', region='us-west-2', retain_resources=None) - + # Verify function calls mock_delete_stack.assert_called_once() call_args = mock_delete_stack.call_args - assert call_args[1]['retain_resources_str'] == "" + assert call_args[1]['retain_resources_str'] == "" \ No newline at end of file From 75e6cf32be45e466accef13cc377239adc20b2d0 Mon Sep 17 00:00:00 2001 From: Molly He Date: Thu, 18 Sep 2025 12:03:11 -0700 Subject: [PATCH 08/13] Add telemetry and dog fooding fixes (#248) * add telemetry to init experience, remove duplicate code in init_constants * add filter for deprecation warning, fix hyp --version * change default instance group name for instance group settings --- .../cluster_creation_sdk_experience.ipynb | 56 ++++----- .../creation_template.yaml | 2 +- .../v1_0/model.py | 2 +- .../v1_0/schema.json | 2 +- src/sagemaker/hyperpod/cli/__init__.py | 9 ++ src/sagemaker/hyperpod/cli/commands/init.py | 9 ++ .../hyperpod/cli/constants/init_constants.py | 31 ----- src/sagemaker/hyperpod/cli/hyp_cli.py | 9 -- .../common/telemetry/telemetry_logging.py | 21 +++- .../telemetry/test_telemetry_logging.py | 108 ++++++++++++++++++ 10 files changed, 177 insertions(+), 72 deletions(-) diff --git a/examples/cluster_management/cluster_creation_sdk_experience.ipynb b/examples/cluster_management/cluster_creation_sdk_experience.ipynb index ce176052..4284094a 100644 --- a/examples/cluster_management/cluster_creation_sdk_experience.ipynb +++ b/examples/cluster_management/cluster_creation_sdk_experience.ipynb @@ -40,7 +40,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "import uuid\n", "import time\n", @@ -83,7 +85,7 @@ " instance_group_settings=[\n", " {\n", " \"InstanceCount\": 1,\n", - " \"InstanceGroupName\": \"controller-group\",\n", + " \"InstanceGroupName\": \"default\",\n", " \"InstanceType\": \"ml.t3.medium\",\n", " \"TargetAvailabilityZoneId\": \"use2-az2\",\n", " \"ThreadsPerCore\": 1,\n", @@ -96,9 +98,7 @@ "\n", "print(f\"Initialized cluster stack with prefix: {resource_prefix}\")\n", "print(f\"Cluster name: {cluster_stack.hyperpod_cluster_name}\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -117,7 +117,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Configure cluster with custom tags (equivalent to hyp configure --tags)\n", "cluster_tags = [\n", @@ -143,9 +145,7 @@ "\n", "print(f\"\\nNode recovery: {cluster_stack.node_recovery}\")\n", "print(f\"FSx storage capacity: {cluster_stack.storage_capacity} GiB\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -158,7 +158,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Display current configuration details\n", "print(\"=== Cluster Configuration ===\")\n", @@ -176,9 +178,7 @@ "print(f\" EKS Stack: {cluster_stack.create_eks_cluster_stack}\")\n", "print(f\" HyperPod Stack: {cluster_stack.create_hyperpod_cluster_stack}\")\n", "print(f\" FSx Stack: {cluster_stack.create_fsx_stack}\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -201,7 +201,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Create the HyperPod cluster (equivalent to hyp create)\n", "try:\n", @@ -225,9 +227,7 @@ "except Exception as e:\n", " print(f\"\\n❌ Cluster creation failed: {str(e)}\")\n", " raise" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -240,7 +240,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Monitor cluster creation progress\n", "def monitor_cluster_creation(stack_name, max_checks=30, interval=120):\n", @@ -278,9 +280,7 @@ "# Start monitoring (uncomment when cluster creation is initiated)\n", "# final_status = monitor_cluster_creation(stack_name, max_checks=5, interval=30)\n", "print(\"Monitoring function ready. Uncomment to start monitoring after cluster creation.\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -300,7 +300,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Get detailed information about the cluster stack (equivalent to hyp describe cluster-stack)\n", "def describe_cluster_stack(stack_name, region=\"us-east-2\"):\n", @@ -347,9 +349,7 @@ "# Describe the cluster stack (uncomment when stack exists)\n", "# describe_cluster_stack(stack_name)\n", "print(\"Describe function ready. Use after cluster creation is complete.\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -368,7 +368,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# List all cluster stacks (equivalent to hyp list cluster-stack)\n", "def list_cluster_stacks(region=\"us-east-2\"):\n", @@ -416,9 +418,7 @@ " print(f\"\\n=== HyperPod Stacks ({len(hyperpod_stacks)}) ===\")\n", " for stack in hyperpod_stacks:\n", " print(f\" - {stack['StackName']} ({stack['StackStatus']})\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -439,7 +439,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Update cluster configuration using sagemaker-core Cluster class\n", "def update_cluster(cluster_name, region=\"us-east-2\"):\n", @@ -524,9 +526,7 @@ "# scaled_cluster = scale_instance_group(cluster_name, \"controller-group\", 2)\n", "\n", "print(\"Update functions ready. Use after cluster creation is complete.\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -539,7 +539,9 @@ }, { "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ "# Comprehensive cluster health check\n", "def check_cluster_health(cluster_name, region=\"us-east-2\"):\n", @@ -596,9 +598,7 @@ "# cluster_health = check_cluster_health(cluster_name)\n", "\n", "print(\"Health check function ready. Use after cluster creation is complete.\")" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/creation_template.yaml b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/creation_template.yaml index bd019b6c..2991b948 100644 --- a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/creation_template.yaml +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/creation_template.yaml @@ -320,7 +320,7 @@ Parameters: InstanceGroupSettings1: Type: String Default: >- - [{"InstanceCount":1,"InstanceGroupName":"controller-group","InstanceType":"ml.t3.medium","TargetAvailabilityZoneId":"use2-az2","ThreadsPerCore":1,"InstanceStorageConfigs":[{"EbsVolumeConfig":{"VolumeSizeInGB":500}}]}] + [{"InstanceCount":1,"InstanceGroupName":"default","InstanceType":"ml.t3.medium","TargetAvailabilityZoneId":"use2-az2","ThreadsPerCore":1,"InstanceStorageConfigs":[{"EbsVolumeConfig":{"VolumeSizeInGB":500}}]}] Description: JSON array string containing instance group configurations. RigS3BucketName: Type: String diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py index 64a6c409..faf65cdf 100644 --- a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/model.py @@ -17,7 +17,7 @@ class ClusterStackBase(BaseModel): helm_release: Optional[str] = Field("dependencies", description="The name used for Helm chart release") node_provisioning_mode: Optional[str] = Field("Continuous", description="Enable or disable the continuous provisioning mode. Valid values: \"Continuous\" or leave empty") node_recovery: Optional[str] = Field("Automatic", description="Specifies whether to enable or disable the automatic node recovery feature. Valid values: \"Automatic\", \"None\"") - instance_group_settings: Union[List[Any], None] = Field([{"InstanceCount":1,"InstanceGroupName":"controller-group","InstanceType":"ml.t3.medium","TargetAvailabilityZoneId":"use2-az2","ThreadsPerCore":1,"InstanceStorageConfigs":[{"EbsVolumeConfig":{"VolumeSizeInGB":500}}]}], description="List of string containing instance group configurations") + instance_group_settings: Union[List[Any], None] = Field([{"InstanceCount":1,"InstanceGroupName":"default","InstanceType":"ml.t3.medium","TargetAvailabilityZoneId":"use2-az2","ThreadsPerCore":1,"InstanceStorageConfigs":[{"EbsVolumeConfig":{"VolumeSizeInGB":500}}]}], description="List of string containing instance group configurations") rig_settings: Union[List[Any], None] = Field(None, description="List of string containing restricted instance group configurations") rig_s3_bucket_name: Optional[str] = Field(None, description="The name of the S3 bucket used to store the RIG resources") tags: Union[List[Any], None] = Field(None, description="Custom tags for managing the SageMaker HyperPod cluster as an AWS resource") diff --git a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json index ca63c552..893ecbda 100644 --- a/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json +++ b/hyperpod-cluster-stack-template/hyperpod_cluster_stack_template/v1_0/schema.json @@ -181,7 +181,7 @@ "default": [ { "InstanceCount": 1, - "InstanceGroupName": "controller-group", + "InstanceGroupName": "default", "InstanceType": "ml.t3.medium", "TargetAvailabilityZoneId": "use2-az2", "ThreadsPerCore": 1, diff --git a/src/sagemaker/hyperpod/cli/__init__.py b/src/sagemaker/hyperpod/cli/__init__.py index e69de29b..36f7d15e 100644 --- a/src/sagemaker/hyperpod/cli/__init__.py +++ b/src/sagemaker/hyperpod/cli/__init__.py @@ -0,0 +1,9 @@ +import warnings +# Reset warnings and show all except Pydantic serialization warnings +warnings.resetwarnings() +warnings.simplefilter("always") +# Suppress specific Pydantic serialization warnings globally (this is ignored due to customized parsing logic) +warnings.filterwarnings("ignore", message=".*PydanticSerializationUnexpectedValue.*", category=UserWarning) +warnings.filterwarnings("ignore", message=".*serializer.*", category=UserWarning, module="pydantic") +# Suppress kubernetes urllib3 deprecation warning (this is internal dependencies) +warnings.filterwarnings("ignore", message=".*HTTPResponse.getheaders.*", category=DeprecationWarning, module="kubernetes") \ No newline at end of file diff --git a/src/sagemaker/hyperpod/cli/commands/init.py b/src/sagemaker/hyperpod/cli/commands/init.py index c8d93e51..296b08f4 100644 --- a/src/sagemaker/hyperpod/cli/commands/init.py +++ b/src/sagemaker/hyperpod/cli/commands/init.py @@ -25,11 +25,16 @@ get_default_version_for_template ) from sagemaker.hyperpod.common.utils import get_aws_default_region +from sagemaker.hyperpod.common.telemetry.telemetry_logging import ( + _hyperpod_telemetry_emitter, +) +from sagemaker.hyperpod.common.telemetry.constants import Feature @click.command("init") @click.argument("template", type=click.Choice(list(TEMPLATES.keys()))) @click.argument("directory", type=click.Path(file_okay=False), default=".") @click.option("--version", "-v", default=None, help="Schema version") +@_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_template_cli") def init( template: str, directory: str, @@ -144,6 +149,7 @@ def init( @click.command("reset") +@_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_reset_cli") def reset(): """ Reset the current directory's config.yaml to an "empty" scaffold: @@ -176,6 +182,7 @@ def reset(): @click.command("configure") @generate_click_command() @click.pass_context +@_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_configure_cli") def configure(ctx, model_config): """ Update any subset of fields in ./config.yaml by passing -- flags. @@ -253,6 +260,7 @@ def configure(ctx, model_config): @click.command("validate") +@_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_validate_cli") def validate(): """ Validate this directory's config.yaml against the appropriate schema. @@ -263,6 +271,7 @@ def validate(): @click.command(name="_default_create") @click.option("--region", "-r", default=None, help="Region to create cluster stack for, default to your region in aws configure. Not available for other templates.") +@_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_create_cli") def _default_create(region): """ Validate configuration and render template files for deployment. diff --git a/src/sagemaker/hyperpod/cli/constants/init_constants.py b/src/sagemaker/hyperpod/cli/constants/init_constants.py index 47a4d935..77389d52 100644 --- a/src/sagemaker/hyperpod/cli/constants/init_constants.py +++ b/src/sagemaker/hyperpod/cli/constants/init_constants.py @@ -71,37 +71,6 @@ def version_key(v): 'hyp-pytorch-job.volume': _get_handler_from_latest_template("hyp-pytorch-job", "VOLUME_TYPE_HANDLER"), } - -def _get_handler_from_latest_template(template_name, handler_name): - """Dynamically import handler from the latest version of a template""" - try: - template_info = TEMPLATES[template_name] - registry = template_info["registry"] - - # Get latest version using same logic as _get_latest_class - available_versions = list(registry.keys()) - def version_key(v): - try: - return tuple(map(int, v.split('.'))) - except ValueError: - return (0, 0) - - latest_version = max(available_versions, key=version_key) - latest_model = registry[latest_version] - - # Get handler from module - module = sys.modules[latest_model.__module__] - return getattr(module, handler_name) - except (ImportError, AttributeError): - return None - - -# Template.field to handler mapping - avoids conflicts and works reliably -SPECIAL_FIELD_HANDLERS = { - 'hyp-pytorch-job.volume': _get_handler_from_latest_template("hyp-pytorch-job", "VOLUME_TYPE_HANDLER"), -} - - USAGE_GUIDE_TEXT_CFN = """# SageMaker HyperPod CLI - Initialization Workflow This document explains the initialization workflow and related commands for the SageMaker HyperPod CLI. diff --git a/src/sagemaker/hyperpod/cli/hyp_cli.py b/src/sagemaker/hyperpod/cli/hyp_cli.py index a21780af..c0e63094 100644 --- a/src/sagemaker/hyperpod/cli/hyp_cli.py +++ b/src/sagemaker/hyperpod/cli/hyp_cli.py @@ -1,11 +1,3 @@ -import warnings -# Reset warnings and show all except Pydantic serialization warnings -warnings.resetwarnings() -warnings.simplefilter("always") -# Suppress specific Pydantic serialization warnings globally -warnings.filterwarnings("ignore", message=".*PydanticSerializationUnexpectedValue.*", category=UserWarning) -warnings.filterwarnings("ignore", message=".*serializer.*", category=UserWarning, module="pydantic") - import click import yaml import json @@ -56,7 +48,6 @@ ) -@click.group(context_settings={'max_content_width': 200}) def get_package_version(package_name): try: return version(package_name) diff --git a/src/sagemaker/hyperpod/common/telemetry/telemetry_logging.py b/src/sagemaker/hyperpod/common/telemetry/telemetry_logging.py index 32fa90b7..f56b9b09 100644 --- a/src/sagemaker/hyperpod/common/telemetry/telemetry_logging.py +++ b/src/sagemaker/hyperpod/common/telemetry/telemetry_logging.py @@ -149,12 +149,31 @@ def _hyperpod_telemetry_emitter(feature: str, func_name: str): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): + import inspect + sig = inspect.signature(func) + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + # Get template value and create template-specific event name + template = bound_args.arguments.get('template') + if template: + event_name = f"{func_name}_{template.replace('-', '_')}" + else: + event_name = func_name + extra = ( - f"{func_name}" + f"{event_name}" f"&x-sdkVersion={SDK_VERSION}" f"&x-env={PYTHON_VERSION}" f"&x-sys={OS_NAME_VERSION}" ) + + # Add template and version to extra + if template: + extra += f"&x-template={template}" + if 'version' in bound_args.arguments and bound_args.arguments['version']: + extra += f"&x-version={bound_args.arguments['version']}" + start = perf_counter() try: result = func(*args, **kwargs) diff --git a/test/unit_tests/common/telemetry/test_telemetry_logging.py b/test/unit_tests/common/telemetry/test_telemetry_logging.py index a54e36c5..b672a495 100644 --- a/test/unit_tests/common/telemetry/test_telemetry_logging.py +++ b/test/unit_tests/common/telemetry/test_telemetry_logging.py @@ -319,3 +319,111 @@ def test_construct_url_all_parameters(): "&x-extra=additional=info" ) assert url == expected + + +class TestHyperPodTelemetryEmitterWithTemplate: + """Test cases for enhanced _hyperpod_telemetry_emitter with template handling""" + + @patch('sagemaker.hyperpod.common.telemetry.telemetry_logging._send_telemetry_request') + def test_template_success_telemetry(self, mock_send_request): + """Test successful function call with template parameter""" + + @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_cli") + def mock_init_function(template: str, version: str = None): + return f"initialized {template}" + + # Call the decorated function + result = mock_init_function(template="hyp-pytorch-job", version="1.0") + + # Verify function result + assert result == "initialized hyp-pytorch-job" + + # Verify telemetry was sent + mock_send_request.assert_called_once() + call_args = mock_send_request.call_args + + # Check status code (success) + assert call_args[0][0] == STATUS_TO_CODE[str(Status.SUCCESS)] + + # Check feature code + assert call_args[0][1] == [FEATURE_TO_CODE[str(Feature.HYPERPOD_CLI)]] + + # Check extra parameters - expect template-specific event name + extra = call_args[0][5] # extra is the 6th positional argument + assert "init_cli_hyp_pytorch_job" in extra + assert "x-template=hyp-pytorch-job" in extra + assert "x-version=1.0" in extra + assert "x-latency=" in extra + + @patch('sagemaker.hyperpod.common.telemetry.telemetry_logging._send_telemetry_request') + def test_template_failure_telemetry(self, mock_send_request): + """Test failed function call with template parameter""" + + @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_cli") + def mock_failing_function(template: str): + raise ValueError("Test error") + + # Call the decorated function and expect exception + with pytest.raises(ValueError, match="Test error"): + mock_failing_function(template="cluster-stack") + + # Verify telemetry was sent + mock_send_request.assert_called_once() + call_args = mock_send_request.call_args + + # Check status code (failure) + assert call_args[0][0] == STATUS_TO_CODE[str(Status.FAILURE)] + + # Check error details + assert call_args[0][3] == "Test error" # failure_reason + assert call_args[0][4] == "ValueError" # failure_type + + # Check extra parameters + extra = call_args[0][5] # extra is the 6th positional argument + assert "init_cli_cluster_stack" in extra + assert "x-template=cluster-stack" in extra + + @patch('sagemaker.hyperpod.common.telemetry.telemetry_logging._send_telemetry_request') + def test_no_template_parameter(self, mock_send_request): + """Test function without template parameter""" + + @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "test_cli") + def mock_function_no_template(other_param: str): + return "success" + + # Call the decorated function + result = mock_function_no_template(other_param="test") + + # Verify function result + assert result == "success" + + # Verify telemetry was sent with base event name + mock_send_request.assert_called_once() + call_args = mock_send_request.call_args + extra = call_args[0][5] # extra is the 6th positional argument + assert "test_cli" in extra + assert "x-template=" not in extra # No template metadata + + @patch('sagemaker.hyperpod.common.telemetry.telemetry_logging._send_telemetry_request') + def test_template_no_version(self, mock_send_request): + """Test function with template but no version parameter""" + + @_hyperpod_telemetry_emitter(Feature.HYPERPOD_CLI, "init_cli") + def mock_function_no_version(template: str): + return f"initialized {template}" + + # Call the decorated function + result = mock_function_no_version(template="hyp-custom-endpoint") + + # Verify function result + assert result == "initialized hyp-custom-endpoint" + + # Verify telemetry was sent + mock_send_request.assert_called_once() + call_args = mock_send_request.call_args + extra = call_args[0][5] + + # Should have template but no version + assert "init_cli_hyp_custom_endpoint" in extra + assert "x-template=hyp-custom-endpoint" in extra + assert "x-version=" not in extra From e1218f6a0faa873b116fa4f02c6c34426a33b0bb Mon Sep 17 00:00:00 2001 From: Molly He Date: Thu, 18 Sep 2025 12:07:13 -0700 Subject: [PATCH 09/13] Merge from public repo to resolve merge conflict before init experience launch (#249) * Release new version for Health Monitoring Agent (1.0.790.0_1.0.266.0) with minor improvements and bug fixes. (#254) * changelog version update (#256) Co-authored-by: Mohamed Zeidan * Fix README documentation and broken anchor links (#252) **Description** - Updated README.md to fix broken internal navigation links, corrected SDK import paths, added proper syntax highlighting to code blocks. - Fixed training SDK imports, observability utils import path, and cluster management workflow examples. **Testing Done** - Verified all anchor links work correctly in table of contents and usage sections - Cross-referenced SDK imports against actual source code in src/sagemaker/hyperpod/ - Validated CLI commands match implementation in hyp_cli.py - Confirmed code examples use correct class names and method signatures * Small bug fix to print debug messages for inference logger (PySDK) (#246) * Draft of inference logger bug fix * Draft fix of inference logger for SDK * Revert adding --debug flag * Add debug parameter to failing unit tests * Fix create_from_dict to not have hardcoded debug flag * Add code-coverage workflow to GitHub workflows (#257) * Add code coverage workflow * Update artifact version to v4 * Fixed report upload * Simplified workflow using tox.ini * Make sure coverage is on right source files * Bug fix for 0 percent code coverage error * Bump version to 3.2.2 (#260) * Bump version to 3.2.2 **Description** Update package version from 3.2.1 to 3.2.2 in pyproject.toml and setup.py files. **Testing Done** Version bump only - no functional changes requiring additional testing. * Changelog update for v3.2.2 **Description** Added detaisl for Health Monitoring Agent updates to changelog **Testing Done** Production canary failure fixes validated. * Changelog update for v3.2.2 **Description** Updated the release date to represent the correct date. **Testing Done** No breaking changes. * Bump hyperpod-pytorch-job-template to v1.1.2 **Description** Update hyperpod-pytorch-job-template version from 1.1.1 to 1.1.2 and add changelog entry for node-count validation revert. **Testing Done** Version bump and changelog update - node-count validation revert functionality verified. * Update readme to include review guidelines (#261) * Update PR template * Update template * Update template format * Update format * Fix readme * Feature: Delete Cluster Command (#250) * delete cluster stack * delete cluster stack * removed unnecessary file * unit tests * more modular code * refactored modular code * sdk code added and improved modularity * cleanup * removed silent failure for sdk * fixed unit tests * integ tests * 2 integ happycase tests * changed test to use iam role instead of s3 bucket --------- Co-authored-by: Mohamed Zeidan * Code Coverage for Integ Tests (#262) * Code Coverage for Integ Tests * Making sure target of coverage is correct * Removing duplicate implementation * Release new version for Health Monitoring Agent (1.0.819.0_1.0.267.0) with minor improvements and bug fixes. (#265) 1. New feature NVML API Check to detect hardware failure. Disabled Nvidia SMI query check 2. HMA will be able to detect File system read only error 3. For compatibility with AL2023, Non-NVIDIA devices will use a separate daemonset for deployment. * Removing duplicate cluster-creating integ test (#266) * Access entry fix (#267) * Fix Slurm failures from missing orchestration key (#268) * slurm-eks-helper-fix * Small fix to test to reflect new changes * small fix after resolving merge conflict --------- Co-authored-by: Xichao Wang <43689944+992X@users.noreply.github.com> Co-authored-by: Mohamed Zeidan <81834882+mohamedzeidan2021@users.noreply.github.com> Co-authored-by: Mohamed Zeidan Co-authored-by: papriwal Co-authored-by: aviruthen <91846056+aviruthen@users.noreply.github.com> Co-authored-by: Zhaoqi <52220743+zhaoqizqwang@users.noreply.github.com> Co-authored-by: jiayelamazon --- src/sagemaker/hyperpod/inference/hp_endpoint.py | 2 +- src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sagemaker/hyperpod/inference/hp_endpoint.py b/src/sagemaker/hyperpod/inference/hp_endpoint.py index 71303de5..dc267e1e 100644 --- a/src/sagemaker/hyperpod/inference/hp_endpoint.py +++ b/src/sagemaker/hyperpod/inference/hp_endpoint.py @@ -71,7 +71,7 @@ def create( def create_from_dict( self, input: Dict, - debug=False + debug=False ) -> None: logger = self.get_logger() logger = setup_logging(logger, debug) diff --git a/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py b/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py index 9c8965b3..288086b1 100644 --- a/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py +++ b/src/sagemaker/hyperpod/inference/hp_jumpstart_endpoint.py @@ -76,7 +76,7 @@ def create( def create_from_dict( self, input: Dict, - debug=False + debug = False ) -> None: logger = self.get_logger() logger = setup_logging(logger, debug) From 983e59349abe6f201117904e7bb523358e962c15 Mon Sep 17 00:00:00 2001 From: Molly He Date: Thu, 18 Sep 2025 17:07:44 -0700 Subject: [PATCH 10/13] add example notebooks for init experience, update README to match with new documentation (#250) * add example notebooks for init experience, update README to match with new documentation * clear output --- README.md | 584 +++++++++++++++--- .../CLI/inference-fsx-model-e2e-cli.ipynb | 2 +- .../inference-jumpstart-init-experience.ipynb | 323 ++++++++++ .../inference-s3-model-init-experience.ipynb | 327 ++++++++++ .../SDK/inference-fsx-model-e2e.ipynb | 8 + .../SDK/inference-s3-model-e2e.ipynb | 8 + examples/training/CLI/training-e2e-cli.ipynb | 72 ++- .../CLI/training-init-experience.ipynb | 302 +++++++++ .../training/SDK/training_sdk_example.ipynb | 19 + 9 files changed, 1539 insertions(+), 106 deletions(-) create mode 100644 examples/inference/CLI/inference-jumpstart-init-experience.ipynb create mode 100644 examples/inference/CLI/inference-s3-model-init-experience.ipynb create mode 100644 examples/training/CLI/training-init-experience.ipynb diff --git a/README.md b/README.md index 1255c165..0fc9e452 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SageMaker HyperPod command-line interface -The Amazon SageMaker HyperPod command-line interface (HyperPod CLI) is a tool that helps manage training jobs on the SageMaker HyperPod clusters orchestrated by Amazon EKS. +The Amazon SageMaker HyperPod command-line interface (HyperPod CLI) is a tool that helps manage clusters, training jobs, and inference endpoints on the SageMaker HyperPod clusters orchestrated by Amazon EKS. This documentation serves as a reference for the available HyperPod CLI commands. For a comprehensive user guide, see [Orchestrating SageMaker HyperPod clusters with Amazon EKS](https://docs.aws.amazon.com/sagemaker/latest/dg/sagemaker-hyperpod-eks.html) in the *Amazon SageMaker Developer Guide*. @@ -14,19 +14,17 @@ Note: Old `hyperpod`CLI V2 has been moved to `release_v2` branch. Please refer [ - [ML Framework Support](#ml-framework-support) - [Installation](#installation) - [Usage](#usage) - - [Getting Clusters](#getting-cluster-information) - - [Connecting to a Cluster](#connecting-to-a-cluster) - - [Getting Cluster Context](#getting-cluster-context) - - [Listing Pods](#listing-pods) - - [Accessing Logs](#accessing-logs) - - [CLI](#cli) - - [Cluster Management](#cluster-management) - - [Training](#training) - - [Inference](#inference) - - [SDK](#sdk) - - [Cluster Management](#cluster-management-sdk) - - [Training](#training-sdk) - - [Inference](#inference-sdk) + - [Getting Started](#getting-started) + - [CLI](#cli) + - [Cluster Management](#cluster-management) + - [Training](#training) + - [Inference](#inference) + - [Jumpstart Endpoint](#jumpstart-endpoint-creation) + - [Custom Endpoint](#custom-endpoint-creation) + - [SDK](#sdk) + - [Cluster Management](#cluster-management-sdk) + - [Training](#training-sdk) + - [Inference](#inference-sdk) - [Examples](#examples) @@ -79,22 +77,22 @@ SageMaker HyperPod CLI currently supports start training job with: The HyperPod CLI provides the following commands: -- [Getting Clusters](#getting-cluster-information) -- [Connecting to a Cluster](#connecting-to-a-cluster) -- [Getting Cluster Context](#getting-cluster-context) -- [Listing Pods](#listing-pods) -- [Accessing Logs](#accessing-logs) +- [Getting Started](#getting-started) - [CLI](#cli) - [Cluster Management](#cluster-management) - [Training](#training) - [Inference](#inference) + - [Jumpstart Endpoint](#jumpstart-endpoint-creation) + - [Custom Endpoint](#custom-endpoint-creation) - [SDK](#sdk) - [Cluster Management](#cluster-management-sdk) - [Training](#training-sdk) - [Inference](#inference-sdk) -### Getting Cluster information +### Getting Started + +#### Getting Cluster information This command lists the available SageMaker HyperPod clusters and their capacity information. @@ -109,7 +107,7 @@ hyp list-cluster | `--output ` | Optional | The output format. Available values are `table` and `json`. The default value is `json`. | | `--debug` | Optional | Enable debug mode for detailed logging. | -### Connecting to a Cluster +#### Connecting to a Cluster This command configures the local Kubectl environment to interact with the specified SageMaker HyperPod cluster and namespace. @@ -124,7 +122,7 @@ hyp set-cluster-context --cluster-name | `--region ` | Optional | The AWS region where the HyperPod cluster resides. | | `--debug` | Optional | Enable debug mode for detailed logging. | -### Getting Cluster Context +#### Getting Cluster Context Get all the context related to the current set Cluster @@ -136,31 +134,6 @@ hyp get-cluster-context |--------|------|-------------| | `--debug` | Optional | Enable debug mode for detailed logging. | -### Listing Pods - -This command lists all the pods associated with a specific training job. - -```bash -hyp list-pods hyp-pytorch-job --job-name -``` - -* `job-name` (string) - Required. The name of the job to list pods for. - -### Accessing Logs - -This command retrieves the logs for a specific pod within a training job. - -```bash -hyp get-logs hyp-pytorch-job --pod-name --job-name -``` - -| Option | Type | Description | -|--------|------|-------------| -| `--job-name ` | Required | The name of the job to get the log for. | -| `--pod-name ` | Required | The name of the pod to get the log from. | -| `--namespace ` | Optional | The namespace of the job. Defaults to 'default'. | -| `--container ` | Optional | The container name to get logs from. | - ## CLI @@ -201,10 +174,10 @@ hyp validate Create the cluster stack using the configured parameters: ```bash -hyp create +hyp create --region ``` -**Note**: The region is determined from your AWS configuration or can be specified during the init experience. +**Note**: The region flag is optional. If not provided, the command will use the default region from your AWS credentials configuration. #### List Cluster Stacks @@ -229,6 +202,21 @@ hyp describe cluster-stack | `--region ` | Optional | The AWS region where the stack exists. | | `--debug` | Optional | Enable debug mode for detailed logging. | +#### Delete Cluster Stack + +Delete a HyperPod cluster stack. Removes the specified CloudFormation stack and all associated AWS resources. This operation cannot be undone. + +```bash + hyp delete cluster-stack +``` + +| Option | Type | Description | +|--------|------|-------------| +| `--region ` | Required | The AWS region where the stack exists. | +| `--retain-resources S3Bucket-TrainingData,EFSFileSystem-Models` | Optional | Comma-separated list of logical resource IDs to retain during deletion (only works on DELETE_FAILED stacks). Resource names are shown in failed deletion output, or use AWS CLI: `aws cloudformation list-stack-resources STACK_NAME --region REGION`. | +| `--debug` | Optional | Enable debug mode for detailed logging. | + + #### Update Existing Cluster ```bash @@ -247,7 +235,42 @@ hyp reset ### Training -#### Creating a Training Job +#### **Option 1**: Create Pytorch job through init experience + +#### Initialize Pytorch Job Configuration + +Initialize a new pytorch job configuration in the current directory: + +```bash +hyp init hyp-pytorch-job +``` + +#### Configure Pytorch Job Parameters + +Configure pytorch job parameters interactively or via command line: + +```bash +hyp configure --job-name my-pytorch-job +``` + +#### Validate Configuration + +Validate the configuration file syntax: + +```bash +hyp validate +``` + +#### Create Pytorch Job + +Create the pytorch job using the configured parameters: + +```bash +hyp create +``` + + +#### **Option 2**: Create Pytorch job through create command ```bash hyp create hyp-pytorch-job \ @@ -277,16 +300,125 @@ hyp create hyp-pytorch-job \ --volume name=training-output,type=pvc,mount_path=/data2,claim_name=my-pvc,read_only=false ``` -Key required parameters explained: +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `--job-name` | TEXT | Yes | Unique name for the training job (1-63 characters, alphanumeric with hyphens) | +| `--image` | TEXT | Yes | Docker image URI containing your training code | +| `--namespace` | TEXT | No | Kubernetes namespace | +| `--command` | ARRAY | No | Command to run in the container (array of strings) | +| `--args` | ARRAY | No | Arguments for the entry script (array of strings) | +| `--environment` | OBJECT | No | Environment variables as key-value pairs | +| `--pull-policy` | TEXT | No | Image pull policy (Always, Never, IfNotPresent) | +| `--instance-type` | TEXT | No | Instance type for training | +| `--node-count` | INTEGER | No | Number of nodes (minimum: 1) | +| `--tasks-per-node` | INTEGER | No | Number of tasks per node (minimum: 1) | +| `--label-selector` | OBJECT | No | Node label selector as key-value pairs | +| `--deep-health-check-passed-nodes-only` | BOOLEAN | No | Schedule pods only on nodes that passed deep health check (default: false) | +| `--scheduler-type` | TEXT | No | Scheduler type | +| `--queue-name` | TEXT | No | Queue name for job scheduling (1-63 characters, alphanumeric with hyphens) | +| `--priority` | TEXT | No | Priority class for job scheduling | +| `--max-retry` | INTEGER | No | Maximum number of job retries (minimum: 0) | +| `--volume` | ARRAY | No | List of volume configurations (Refer [Volume Configuration](#volume-configuration) for detailed parameter info) | +| `--service-account-name` | TEXT | No | Service account name | +| `--accelerators` | INTEGER | No | Number of accelerators a.k.a GPUs or Trainium Chips | +| `--vcpu` | FLOAT | No | Number of vCPUs | +| `--memory` | FLOAT | No | Amount of memory in GiB | +| `--accelerators-limit` | INTEGER | No | Limit for the number of accelerators a.k.a GPUs or Trainium Chips | +| `--vcpu-limit` | FLOAT | No | Limit for the number of vCPUs | +| `--memory-limit` | FLOAT | No | Limit for the amount of memory in GiB | +| `--preferred-topology` | TEXT | No | Preferred topology annotation for scheduling | +| `--required-topology` | TEXT | No | Required topology annotation for scheduling | +| `--debug` | FLAG | No | Enable debug mode (default: false) | + +#### List Training Jobs - --job-name: Unique identifier for your training job +```bash +hyp list hyp-pytorch-job +``` + +#### Describe a Training Job + +```bash +hyp describe hyp-pytorch-job --job-name +```` - --image: Docker image containing your training environment +#### Listing Pods + +This command lists all the pods associated with a specific training job. + +```bash +hyp list-pods hyp-pytorch-job --job-name +``` + +* `job-name` (string) - Required. The name of the job to list pods for. + +#### Accessing Logs + +This command retrieves the logs for a specific pod within a training job. + +```bash +hyp get-logs hyp-pytorch-job --pod-name --job-name +``` + +| Parameter | Required | Description | +|--------|------|-------------| +| `--job-name` | Yes | The name of the job to get the log for. | +| `--pod-name` | Yes | The name of the pod to get the log from. | +| `--namespace` | No | The namespace of the job. Defaults to 'default'. | +| `--container` | No | The container name to get logs from. | + +#### Get Operator Logs + +```bash +hyp get-operator-logs hyp-pytorch-job --since-hours 0.5 +``` + +#### Delete a Training Job + +```bash +hyp delete hyp-pytorch-job --job-name +``` ### Inference -#### Creating a JumpstartModel Endpoint +### Jumpstart Endpoint Creation + +#### **Option 1**: Create jumpstart endpoint through init experience + +#### Initialize Jumpstart Endpoint Configuration + +Initialize a new jumpstart endpoint configuration in the current directory: + +```bash +hyp init hyp-jumpstart-endpoint +``` + +#### Configure Jumpstart Endpoint Parameters + +Configure jumpstart endpoint parameters interactively or via command line: + +```bash +hyp configure --endpoint-name my-jumpstart-endpoint +``` + +#### Validate Configuration + +Validate the configuration file syntax: + +```bash +hyp validate +``` + +#### Create Jumpstart Endpoint + +Create the jumpstart endpoint using the configured parameters: + +```bash +hyp create +``` + +#### **Option 2**: Create jumpstart endpoint through create command Pre-trained Jumpstart models can be gotten from https://sagemaker.readthedocs.io/en/v2.82.0/doc_utils/jumpstart.html and fed into the call for creating the endpoint ```bash @@ -297,16 +429,27 @@ hyp create hyp-jumpstart-endpoint \ --endpoint-name endpoint-jumpstart ``` +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `--model-id` | TEXT | Yes | JumpStart model identifier (1-63 characters, alphanumeric with hyphens) | +| `--instance-type` | TEXT | Yes | EC2 instance type for inference (must start with "ml.") | +| `--namespace` | TEXT | No | Kubernetes namespace | +| `--metadata-name` | TEXT | No | Name of the jumpstart endpoint object | +| `--accept-eula` | BOOLEAN | No | Whether model terms of use have been accepted (default: false) | +| `--model-version` | TEXT | No | Semantic version of the model (e.g., "1.0.0", 5-14 characters) | +| `--endpoint-name` | TEXT | No | Name of SageMaker endpoint (1-63 characters, alphanumeric with hyphens) | +| `--tls-certificate-output-s3-uri` | TEXT | No | S3 URI to write the TLS certificate (optional) | +| `--debug` | FLAG | No | Enable debug mode (default: false) | + #### Invoke a JumpstartModel Endpoint ```bash -hyp invoke hyp-custom-endpoint \ +hyp invoke hyp-jumpstart-endpoint \ --endpoint-name endpoint-jumpstart \ --body '{"inputs":"What is the capital of USA?"}' ``` -**Note**: Both JumpStart and custom endpoints use the same invoke command. #### Managing an Endpoint @@ -315,12 +458,72 @@ hyp list hyp-jumpstart-endpoint hyp describe hyp-jumpstart-endpoint --name endpoint-jumpstart ``` -#### Creating a Custom Inference Endpoint +#### List Pods +```bash +hyp list-pods hyp-jumpstart-endpoint +``` + +#### Get Logs + +```bash +hyp get-logs hyp-jumpstart-endpoint --pod-name +``` + +#### Get Operator Logs + +```bash +hyp get-operator-logs hyp-jumpstart-endpoint --since-hours 0.5 +``` + +#### Deleting an Endpoint + +```bash +hyp delete hyp-jumpstart-endpoint --name endpoint-jumpstart +``` + + +### Custom Endpoint Creation +#### **Option 1**: Create custom endpoint through init experience + +#### Initialize Custom Endpoint Configuration + +Initialize a new custom endpoint configuration in the current directory: + +```bash +hyp init hyp-custom-endpoint +``` + +#### Configure Custom Endpoint Parameters + +Configure custom endpoint parameters interactively or via command line: + +```bash +hyp configure --endpoint-name my-custom-endpoint +``` + +#### Validate Configuration + +Validate the configuration file syntax: + +```bash +hyp validate +``` + +#### Create Custom Endpoint + +Create the custom endpoint using the configured parameters: + +```bash +hyp create +``` + + +#### **Option 2**: Create custom endpoint through create command ```bash hyp create hyp-custom-endpoint \ --version 1.0 \ - --endpoint-name my-custom-endpoint \ + --endpoint-name endpoint-custom \ --model-name my-pytorch-model \ --model-source-type s3 \ --model-location my-pytorch-training \ @@ -332,6 +535,46 @@ hyp create hyp-custom-endpoint \ --container-port 8080 ``` +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `--instance-type` | TEXT | Yes | EC2 instance type for inference (must start with "ml.") | +| `--model-name` | TEXT | Yes | Name of model to create on SageMaker (1-63 characters, alphanumeric with hyphens) | +| `--model-source-type` | TEXT | Yes | Model source type ("s3" or "fsx") | +| `--image-uri` | TEXT | Yes | Docker image URI for inference | +| `--container-port` | INTEGER | Yes | Port on which model server listens (1-65535) | +| `--model-volume-mount-name` | TEXT | Yes | Name of the model volume mount | +| `--namespace` | TEXT | No | Kubernetes namespace | +| `--metadata-name` | TEXT | No | Name of the custom endpoint object | +| `--endpoint-name` | TEXT | No | Name of SageMaker endpoint (1-63 characters, alphanumeric with hyphens) | +| `--env` | OBJECT | No | Environment variables as key-value pairs | +| `--metrics-enabled` | BOOLEAN | No | Enable metrics collection (default: false) | +| `--model-version` | TEXT | No | Version of the model (semantic version format) | +| `--model-location` | TEXT | No | Specific model data location | +| `--prefetch-enabled` | BOOLEAN | No | Whether to pre-fetch model data (default: false) | +| `--tls-certificate-output-s3-uri` | TEXT | No | S3 URI for TLS certificate output | +| `--fsx-dns-name` | TEXT | No | FSx File System DNS Name | +| `--fsx-file-system-id` | TEXT | No | FSx File System ID | +| `--fsx-mount-name` | TEXT | No | FSx File System Mount Name | +| `--s3-bucket-name` | TEXT | No | S3 bucket location | +| `--s3-region` | TEXT | No | S3 bucket region | +| `--model-volume-mount-path` | TEXT | No | Path inside container for model volume (default: "/opt/ml/model") | +| `--resources-limits` | OBJECT | No | Resource limits for the worker | +| `--resources-requests` | OBJECT | No | Resource requests for the worker | +| `--dimensions` | OBJECT | No | CloudWatch Metric dimensions as key-value pairs | +| `--metric-collection-period` | INTEGER | No | Period for CloudWatch query (default: 300) | +| `--metric-collection-start-time` | INTEGER | No | StartTime for CloudWatch query (default: 300) | +| `--metric-name` | TEXT | No | Metric name to query for CloudWatch trigger | +| `--metric-stat` | TEXT | No | Statistics metric for CloudWatch (default: "Average") | +| `--metric-type` | TEXT | No | Type of metric for HPA ("Value" or "Average", default: "Average") | +| `--min-value` | NUMBER | No | Minimum metric value for empty CloudWatch response (default: 0) | +| `--cloud-watch-trigger-name` | TEXT | No | Name for the CloudWatch trigger | +| `--cloud-watch-trigger-namespace` | TEXT | No | AWS CloudWatch namespace for the metric | +| `--target-value` | NUMBER | No | Target value for the CloudWatch metric | +| `--use-cached-metrics` | BOOLEAN | No | Enable caching of metric values (default: true) | +| `--invocation-endpoint` | TEXT | No | Invocation endpoint path (default: "invocations") | +| `--debug` | FLAG | No | Enable debug mode (default: false) | + + #### Invoke a Custom Inference Endpoint ```bash @@ -340,12 +583,36 @@ hyp invoke hyp-custom-endpoint \ --body '{"inputs":"What is the capital of USA?"}' ``` -#### Deleting an Endpoint +#### Managing an Endpoint ```bash -hyp delete hyp-jumpstart-endpoint --name endpoint-jumpstart +hyp list hyp-custom-endpoint +hyp describe hyp-custom-endpoint --name endpoint-custom +``` + +#### List Pods + +```bash +hyp list-pods hyp-custom-endpoint +``` + +#### Get Logs + +```bash +hyp get-logs hyp-custom-endpoint --pod-name +``` + +#### Get Operator Logs + +```bash +hyp get-operator-logs hyp-custom-endpoint --since-hours 0.5 ``` +#### Deleting an Endpoint + +```bash +hyp delete hyp-custom-endpoint --name endpoint-custom +``` ## SDK @@ -484,11 +751,65 @@ pytorch_job = HyperPodPytorchJob ) # Launch the job pytorch_job.create() - - ``` +#### List Training Jobs +```python +from sagemaker.hyperpod.training import HyperPodPytorchJob +import yaml + +# List all PyTorch jobs +jobs = HyperPodPytorchJob.list() +print(yaml.dump(jobs)) +``` + +#### Describe a Training Job +```python +from sagemaker.hyperpod.training import HyperPodPytorchJob +# Get an existing job +job = HyperPodPytorchJob.get(name="my-pytorch-job") + +print(job) +``` + +#### List Pods for a Training Job +```python +from sagemaker.hyperpod.training import HyperPodPytorchJob + +# List Pods for an existing job +job = HyperPodPytorchJob.get(name="my-pytorch-job") +print(job.list_pods()) +``` + +#### Get Logs from a Pod +```python +from sagemaker.hyperpod.training import HyperPodPytorchJob + +# Get pod logs for a job +job = HyperPodPytorchJob.get(name="my-pytorch-job") +print(job.get_logs_from_pod("pod-name")) +``` + +#### Get Training Operator Logs +```python +from sagemaker.hyperpod.training import HyperPodPytorchJob + +# Get training operator logs +job = HyperPodPytorchJob.get(name="my-pytorch-job") +print(job.get_operator_logs(since_hours=0.1)) +``` + +#### Delete a Training Job +```python +from sagemaker.hyperpod.training import HyperPodPytorchJob + +# Get an existing job +job = HyperPodPytorchJob.get(name="my-pytorch-job") + +# Delete the job +job.delete() +``` ### Inference SDK @@ -496,7 +817,7 @@ pytorch_job.create() Pre-trained Jumpstart models can be gotten from https://sagemaker.readthedocs.io/en/v2.82.0/doc_utils/jumpstart.html and fed into the call for creating the endpoint -``` +```python from sagemaker.hyperpod.inference.config.hp_jumpstart_endpoint_config import Model, Server, SageMakerEndpoint, TlsConfig from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint @@ -517,19 +838,9 @@ js_endpoint=HPJumpStartEndpoint( js_endpoint.create() ``` - -#### Invoke a JumpstartModel Endpoint - -``` -data = '{"inputs":"What is the capital of USA?"}' -response = js_endpoint.invoke(body=data).body.read() -print(response) -``` - - #### Creating a Custom Inference Endpoint (with S3) -``` +```python from sagemaker.hyperpod.inference.config.hp_endpoint_config import CloudWatchTrigger, Dimensions, AutoScalingSpec, Metrics, S3Storage, ModelSourceConfig, TlsConfig, EnvironmentVariables, ModelInvocationPort, ModelVolumeMount, Resources, Worker from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint @@ -577,37 +888,148 @@ custom_endpoint = HPEndpoint( custom_endpoint.create() ``` -#### Invoke a Custom Inference Endpoint +#### List Endpoints + +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint + +# List JumpStart endpoints +jumpstart_endpoints = HPJumpStartEndpoint.list() +print(jumpstart_endpoints) + +# List custom endpoints +custom_endpoints = HPEndpoint.list() +print(custom_endpoints) ``` + +#### Describe an Endpoint +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint + +# Get JumpStart endpoint details +jumpstart_endpoint = HPJumpStartEndpoint.get(name="js-endpoint-name", namespace="test") +print(jumpstart_endpoint) + +# Get custom endpoint details +custom_endpoint = HPEndpoint.get(name="endpoint-custom") +print(custom_endpoint) + +``` + +#### Invoke an Endpoint +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint + data = '{"inputs":"What is the capital of USA?"}' +jumpstart_endpoint = HPJumpStartEndpoint.get(name="endpoint-jumpstart") +response = jumpstart_endpoint.invoke(body=data).body.read() +print(response) + +custom_endpoint = HPEndpoint.get(name="endpoint-custom") response = custom_endpoint.invoke(body=data).body.read() print(response) ``` -#### Managing an Endpoint +#### List Pods +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint +# List pods +js_pods = HPJumpStartEndpoint.list_pods() +print(js_pods) + +c_pods = HPEndpoint.list_pods() +print(c_pods) ``` -endpoint_list = HPEndpoint.list() -print(endpoint_list[0]) -print(custom_endpoint.get_operator_logs(since_hours=0.5)) +#### Get Logs +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint + +# Get logs from pod +js_logs = HPJumpStartEndpoint.get_logs(pod=) +print(js_logs) +c_logs = HPEndpoint.get_logs(pod=) +print(c_logs) ``` -#### Deleting an Endpoint +#### Get Operator Logs +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint + +# Invoke JumpStart endpoint +print(HPJumpStartEndpoint.get_operator_logs(since_hours=0.1)) +# Invoke custom endpoint +print(HPEndpoint.get_operator_logs(since_hours=0.1)) ``` -custom_endpoint.delete() +#### Delete an Endpoint +```python +from sagemaker.hyperpod.inference.hp_jumpstart_endpoint import HPJumpStartEndpoint +from sagemaker.hyperpod.inference.hp_endpoint import HPEndpoint + +# Delete JumpStart endpoint +jumpstart_endpoint = HPJumpStartEndpoint.get(name="endpoint-jumpstart") +jumpstart_endpoint.delete() + +# Delete custom endpoint +custom_endpoint = HPEndpoint.get(name="endpoint-custom") +custom_endpoint.delete() ``` + #### Observability - Getting Monitoring Information ```python from sagemaker.hyperpod.observability.utils import get_monitoring_config monitor_config = get_monitoring_config() ``` +## Examples +#### Cluster Management Example Notebooks + +[CLI Cluster Management Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/cluster_management/cluster_creation_init_experience.ipynb) + +[SDK Cluster Management Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/cluster_management/cluster_creation_sdk_experience.ipynb) + +#### Training Example Notebooks + +[CLI Training Init Experience Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/training/CLI/training-init-experience.ipynb) + +[CLI Training Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/training/CLI/training-e2e-cli.ipynb) + +[SDK Training Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/training/SDK/training_sdk_example.ipynb) + +#### Inference Example Notebooks + +##### CLI +[CLI Inference Jumpstart Model Init Experience Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/CLI/inference-jumpstart-init-experience.ipynb) + +[CLI Inference JumpStart Model Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/CLI/inference-jumpstart-e2e-cli.ipynb) + +[CLI Inference FSX Model Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/CLI/inference-fsx-model-e2e-cli.ipynb) + +[CLI Inference S3 Model Init Experience Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/CLI/inference-s3-model-init-experience.ipynb) + +[CLI Inference S3 Model Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/CLI/inference-s3-model-e2e-cli.ipynb) + +##### SDK + +[SDK Inference JumpStart Model Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/SDK/inference-jumpstart-e2e.ipynb) + +[SDK Inference FSX Model Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/SDK/inference-fsx-model-e2e.ipynb) + +[SDK Inference S3 Model Example](https://github.com/aws/sagemaker-hyperpod-cli/blob/main/examples/inference/SDK/inference-s3-model-e2e.ipynb) + + ## Disclaimer * This CLI and SDK requires access to the user's file system to set and get context and function properly. diff --git a/examples/inference/CLI/inference-fsx-model-e2e-cli.ipynb b/examples/inference/CLI/inference-fsx-model-e2e-cli.ipynb index 4661114a..05913ec8 100644 --- a/examples/inference/CLI/inference-fsx-model-e2e-cli.ipynb +++ b/examples/inference/CLI/inference-fsx-model-e2e-cli.ipynb @@ -5,7 +5,7 @@ "id": "2d55c8b9", "metadata": {}, "source": [ - "## Inference Operator CLI E2E Expereience (S3 custom model)" + "## Inference Operator CLI E2E Expereience (FSX custom model)" ] }, { diff --git a/examples/inference/CLI/inference-jumpstart-init-experience.ipynb b/examples/inference/CLI/inference-jumpstart-init-experience.ipynb new file mode 100644 index 00000000..966998e4 --- /dev/null +++ b/examples/inference/CLI/inference-jumpstart-init-experience.ipynb @@ -0,0 +1,323 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# SageMaker HyperPod Jumpstart Endpoint - Init Experience\n", + "\n", + "This notebook demonstrates the complete end-to-end workflow for creating a SageMaker HyperPod Jumpstart Endpoint using the HyperPod CLI. The init experience provides a guided approach to create Hyperpod Jumpstart Endpoint with validation and configuration management.\n", + "\n", + "## Prerequisites\n", + "\n", + "- SageMaker HyperPod CLI installed (`pip install sagemaker-hyperpod`)\n", + "- Hyperpod jumpstart inference template installed (`pip install hyperpod-jumpstart-inference-template`)\n", + "- Hyperpod inference operator installed in your hyperpod cluster\n", + "- Python 3.8+ environment\n", + "\n", + "## Workflow Overview\n", + "\n", + "1. **Initialize** - Create initial jumpstart endpoint configuration\n", + "2. **Configure** - Customize jumpstart endpoint parameters\n", + "3. **Validate** - Verify configuration before deployment\n", + "4. **Create** - Deploy the jumpstart endpoint creation\n", + "5. **Monitor** - Check jumpstart endpoint status and manage lifecycle\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 0: Connect to your Hyperpod cluster\n", + "\n", + "Make sure you have installed hyperpod inference operator in your hyperpod cluster.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all available SageMaker HyperPod clusters in your account\n", + "!hyp list-cluster" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Configure your local kubectl environment to interact with a specific SageMaker HyperPod cluster (and namespace)\n", + "!hyp set-cluster-context --cluster-name ml-cluster-integ-test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Initialize Jumpstart Endpoint Configuration\n", + "\n", + "The `hyp init hyp-jumpstart-endpoint` command creates a new configuration template with default settings. This generates a `config.yaml` file that serves as the foundation for your deployment.\n", + "\n", + "**What this does:**\n", + "- Creates a `config.yaml` with default jumpstart endpoint settings.\n", + "- Creates a `k8s.jinja` which is a reference to the k8s payload that is going to be submitted with. Users can refer this to understand how the parameters are being used. \n", + "- Creates a `README.md` which is a detailed explanation of the init experience.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize a new jumpstart endpoint configuration in the current directory\n", + "!hyp init hyp-jumpstart-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Configure Jumpstart Endpoint Settings\n", + "\n", + "The `hyp configure` command allows you to customize your jumpstart endpoint configuration.\n", + "\n", + "**Key configuration options:**\n", + "- **model_id**: Unique identifier of the model within the SageMakerPublicHub\n", + "- **instance_type**: EC2 instance type for the inference server\n", + "- **endpoint_name**: Name of SageMaker endpoint" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!hyp configure --endpoint-name my-jumpstart-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### View Current Configuration\n", + "\n", + "Let's examine the generated configuration to understand what will be deployed:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display the current configuration\n", + "!cat config.yaml | head -50" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Validate Configuration\n", + "\n", + "The `hyp validate` command performs syntax validation of your jumpstart endpoint configuration before deployment. This helps catch configuration errors early and ensures all prerequisites are met.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Validate the jumpstart endpoint configuration\n", + "# This checks for potential issues before deployment\n", + "!hyp validate" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Reset Configuration (Optional)\n", + "\n", + "The `hyp reset` command allows you to reset your configuration to defaults or clean up any partial deployments. This is useful when you want to start fresh or if validation reveals issues that require a clean slate.\n", + "\n", + "**Use cases for reset:**\n", + "- Starting over with a clean configuration\n", + "- Cleaning up after failed deployments\n", + "- Switching between different jumpstart endpoint configurations\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Reset configuration if needed (uncomment to use)\n", + "# !hyp reset\n", + "\n", + "print(\"Reset command available if configuration changes are needed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Create the Jumpstart Endpoint\n", + "\n", + "The `hyp create` command deploys your HyperPod jumpstart endpoint with configurations in the config.yaml. A timestamped folder is created in the `runs` folder, where the config.yaml and the values-injected k8s.yaml kubernates payload is saved.\n", + "\n", + "**Note:** The sagemaker jumpstart endpoint typically takes 10-15 minutes to be created.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create the jumpstart endpoint\n", + "!hyp create" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: Monitor Jumpstart Endpoint Creation\n", + "\n", + "While the jumpstart endpoint is being created, you can monitor its progress using the describe and list commands. These provide real-time status updates on the deployment process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check jumpstart endpoint creation status\n", + "import time\n", + "\n", + "print(\"Monitoring jumpstart endpoint progress...\")\n", + "for i in range(5):\n", + " print(f\"\\n--- Status Check {i+1} ---\")\n", + " !hyp describe hyp-jumpstart-endpoint --name my-jumpstart-endpoint\n", + " time.sleep(30) # Wait 30 seconds between checks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 7: Invoke Sagemaker Endpoint\n", + "\n", + "After the sagemaker endpoint is successfully created, you can use `hyp invoke hyp-jumpstart-endpoint` command to do basic invocation of sagemaker endpoint." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!hyp invoke hyp-jumpstart-endpoint --endpoint-name my-jumpstart-endpoint --body '{\"inputs\":\"What is the capital of USA?\"}'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8: Describe Jumpstart Endpoint\n", + "\n", + "The `hyp describe hyp-jumpstart-endpoint` command provides detailed information about your jumpstart endpoint deployment status and sagemaker endpoint status." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get detailed information about the jumpstart endpoint\n", + "!hyp describe hyp-jumpstart-endpoint --name my-jumpstart-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 9: List All Jumpstart Endpoints\n", + "\n", + "The `hyp list hyp-jumpstart-endpoint` command shows all HyperPod jumpstart endpoints in your account. This is useful for managing multiple jumpstart endpoint deployments and getting an overview of your deployments.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all jumpstart endpoints in your account\n", + "!hyp list hyp-jumpstart-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next Steps\n", + "\n", + "After successfully creating your HyperPod Jumpstart Endpoint, you can:\n", + "\n", + "1. **Monitor Resources**: Check pod status with `hyp list-pods hyp-jumpstart-endpoint`\n", + "2. **Access Logs**: View pod logs with `hyp get-logs hyp-jumpstart-endpoint`\n", + "\n", + "\n", + "## Troubleshooting\n", + "\n", + "If you encounter issues during Jumpstart Endpoint creation:\n", + "\n", + "- Use `hyp get-operator-logs hyp-jumpstart-endpoint` to check potential operator log errors\n", + "- Verify AWS credentials and permissions\n", + "- Ensure resource quotas are sufficient\n", + "- Review the configuration file for syntax errors\n", + "- Use `hyp validate` to identify configuration issues\n", + "\n", + "## Cleanup\n", + "\n", + "To avoid ongoing charges, remember to delete your jumpstart endpoint when no longer needed:\n", + "\n", + "```bash\n", + "hyp delete hyp-jumpstart-endpoint --name my-jumpstart-endpoint\n", + "```\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/inference/CLI/inference-s3-model-init-experience.ipynb b/examples/inference/CLI/inference-s3-model-init-experience.ipynb new file mode 100644 index 00000000..35450e35 --- /dev/null +++ b/examples/inference/CLI/inference-s3-model-init-experience.ipynb @@ -0,0 +1,327 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# SageMaker HyperPod Custom Endpoint - Init Experience\n", + "\n", + "This notebook demonstrates the complete end-to-end workflow for creating a SageMaker HyperPod Custom Endpoint using the HyperPod CLI. The init experience provides a guided approach to create Hyperpod Custom Endpoint with validation and configuration management.\n", + "\n", + "## Prerequisites\n", + "\n", + "- SageMaker HyperPod CLI installed (`pip install sagemaker-hyperpod`)\n", + "- Hyperpod custom inference template installed (`pip install hyperpod-custom-inference-template`)\n", + "- Hyperpod inference operator installed in your hyperpod cluster\n", + "- Python 3.8+ environment\n", + "\n", + "## Workflow Overview\n", + "\n", + "1. **Initialize** - Create initial custom endpoint configuration\n", + "2. **Configure** - Customize custom endpoint parameters\n", + "3. **Validate** - Verify configuration before deployment\n", + "4. **Create** - Deploy the custom endpoint creation\n", + "5. **Monitor** - Check custom endpoint status and manage lifecycle\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 0: Connect to your Hyperpod cluster\n", + "\n", + "Make sure you have installed hyperpod inference operator in your hyperpod cluster.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all available SageMaker HyperPod clusters in your account\n", + "!hyp list-cluster" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Configure your local kubectl environment to interact with a specific SageMaker HyperPod cluster (and namespace)\n", + "!hyp set-cluster-context --cluster-name ml-cluster-integ-test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Initialize Custom Endpoint Configuration\n", + "\n", + "The `hyp init hyp-custom-endpoint` command creates a new configuration template with default settings. This generates a `config.yaml` file that serves as the foundation for your deployment.\n", + "\n", + "**What this does:**\n", + "- Creates a `config.yaml` with default custom endpoint settings.\n", + "- Creates a `k8s.jinja` which is a reference to the k8s payload that is going to be submitted with. Users can refer this to understand how the parameters are being used. \n", + "- Creates a `README.md` which is a detailed explanation of the init experience.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize a new custom endpoint configuration in the current directory\n", + "!hyp init hyp-custom-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Configure Custom Endpoint Settings\n", + "\n", + "The `hyp configure` command allows you to customize your custom endpoint configuration.\n", + "\n", + "**Key configuration options:**\n", + "- **model_name**: Name of model to create on SageMaker\n", + "- **instance_type**: EC2 instance type for the inference server\n", + "- **endpoint_name**: Name of SageMaker endpoint\n", + "- **model_source_type**: Source type: fsx or s3\n", + "- **image_uri**: Inference server image name\n", + "- **container_port**: Port on which the model server listens\n", + "- **model_volume_mount_name**: Path inside container for model volume" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!hyp configure --endpoint-name my-custom-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### View Current Configuration\n", + "\n", + "Let's examine the generated configuration to understand what will be deployed:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display the current configuration\n", + "!cat config.yaml | head -50" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Validate Configuration\n", + "\n", + "The `hyp validate` command performs syntax validation of your custom endpoint configuration before deployment. This helps catch configuration errors early and ensures all prerequisites are met.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Validate the custom endpoint configuration\n", + "# This checks for potential issues before deployment\n", + "!hyp validate" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Reset Configuration (Optional)\n", + "\n", + "The `hyp reset` command allows you to reset your configuration to defaults or clean up any partial deployments. This is useful when you want to start fresh or if validation reveals issues that require a clean slate.\n", + "\n", + "**Use cases for reset:**\n", + "- Starting over with a clean configuration\n", + "- Cleaning up after failed deployments\n", + "- Switching between different custom endpoint configurations\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Reset configuration if needed (uncomment to use)\n", + "# !hyp reset\n", + "\n", + "print(\"Reset command available if configuration changes are needed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Create the Custom Endpoint\n", + "\n", + "The `hyp create` command deploys your HyperPod custom endpoint with configurations in the config.yaml. A timestamped folder is created in the `runs` folder, where the config.yaml and the values-injected k8s.yaml kubernates payload is saved.\n", + "\n", + "**Note:** The sagemaker custom endpoint typically takes 10-15 minutes to be created.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create the custom endpoint\n", + "!hyp create" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: Monitor Custom Endpoint Creation\n", + "\n", + "While the custom endpoint is being created, you can monitor its progress using the describe and list commands. These provide real-time status updates on the deployment process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check custom endpoint creation status\n", + "import time\n", + "\n", + "print(\"Monitoring custom endpoint progress...\")\n", + "for i in range(5):\n", + " print(f\"\\n--- Status Check {i+1} ---\")\n", + " !hyp describe hyp-custom-endpoint --name my-custom-endpoint\n", + " time.sleep(30) # Wait 30 seconds between checks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 7: Invoke Sagemaker Endpoint\n", + "\n", + "After the sagemaker endpoint is successfully created, you can use `hyp invoke hyp-custom-endpoint` command to do basic invocation of sagemaker endpoint." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!hyp invoke hyp-custom-endpoint --endpoint-name my-custom-endpoint --body '{\"inputs\":\"What is the capital of USA?\"}'" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8: Describe Custom Endpoint\n", + "\n", + "The `hyp describe hyp-custom-endpoint` command provides detailed information about your custom endpoint deployment status and sagemaker endpoint status." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get detailed information about the custom endpoint\n", + "!hyp describe hyp-custom-endpoint --name my-custom-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 9: List All Custom Endpoints\n", + "\n", + "The `hyp list hyp-custom-endpoint` command shows all HyperPod custom endpoints in your account. This is useful for managing multiple custom endpoint deployments and getting an overview of your deployments.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all custom endpoints in your account\n", + "!hyp list hyp-custom-endpoint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next Steps\n", + "\n", + "After successfully creating your HyperPod Custom Endpoint, you can:\n", + "\n", + "1. **Monitor Resources**: Check pod status with `hyp list-pods hyp-custom-endpoint`\n", + "2. **Access Logs**: View pod logs with `hyp get-logs hyp-custom-endpoint`\n", + "\n", + "\n", + "## Troubleshooting\n", + "\n", + "If you encounter issues during Custom Endpoint creation:\n", + "\n", + "- Use `hyp get-operator-logs hyp-custom-endpoint` to check potential operator log errors\n", + "- Verify AWS credentials and permissions\n", + "- Ensure resource quotas are sufficient\n", + "- Review the configuration file for syntax errors\n", + "- Use `hyp validate` to identify configuration issues\n", + "\n", + "## Cleanup\n", + "\n", + "To avoid ongoing charges, remember to delete your custom endpoint when no longer needed:\n", + "\n", + "```bash\n", + "hyp delete hyp-custom-endpoint --name my-custom-endpoint\n", + "```\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/inference/SDK/inference-fsx-model-e2e.ipynb b/examples/inference/SDK/inference-fsx-model-e2e.ipynb index b61108b2..387cc6d5 100644 --- a/examples/inference/SDK/inference-fsx-model-e2e.ipynb +++ b/examples/inference/SDK/inference-fsx-model-e2e.ipynb @@ -1,5 +1,13 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "f9758178", + "metadata": {}, + "source": [ + "## Inference Operator PySDK E2E Expereience (FSX custom model)" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/examples/inference/SDK/inference-s3-model-e2e.ipynb b/examples/inference/SDK/inference-s3-model-e2e.ipynb index 2d03fe29..b57d0fc6 100644 --- a/examples/inference/SDK/inference-s3-model-e2e.ipynb +++ b/examples/inference/SDK/inference-s3-model-e2e.ipynb @@ -1,5 +1,13 @@ { "cells": [ + { + "cell_type": "markdown", + "id": "625ebf46", + "metadata": {}, + "source": [ + "## Inference Operator PySDK E2E Expereience (S3 custom model)" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/examples/training/CLI/training-e2e-cli.ipynb b/examples/training/CLI/training-e2e-cli.ipynb index cb813e60..9791c52e 100644 --- a/examples/training/CLI/training-e2e-cli.ipynb +++ b/examples/training/CLI/training-e2e-cli.ipynb @@ -4,7 +4,9 @@ "cell_type": "markdown", "id": "2d275612", "metadata": {}, - "source": "## Training Operator CLI E2E Experience " + "source": [ + "## Training Operator CLI E2E Experience " + ] }, { "cell_type": "markdown", @@ -17,46 +19,50 @@ ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "!hyp list-cluster --output table", - "id": "9df747dbfa211453" + "id": "9df747dbfa211453", + "metadata": {}, + "outputs": [], + "source": [ + "!hyp list-cluster --output table" + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, - "source": "!hyp set-cluster-context --cluster-name ", - "id": "8db986d2b42a9e88" + "id": "8db986d2b42a9e88", + "metadata": {}, + "outputs": [], + "source": [ + "!hyp set-cluster-context --cluster-name " + ] }, { - "metadata": {}, "cell_type": "code", - "outputs": [], "execution_count": null, + "id": "ba996d7dc8e128d5", + "metadata": {}, + "outputs": [], "source": [ "#verify the cluster context\n", "!hyp get-cluster-context " - ], - "id": "ba996d7dc8e128d5" + ] }, { + "cell_type": "code", + "execution_count": null, + "id": "a541575e45e68b3d", "metadata": { "jupyter": { "is_executing": true } }, - "cell_type": "code", + "outputs": [], "source": [ "# To verify the opinionated list of arguments\n", "!hyp create hyp-pytorch-job --help" - ], - "id": "a541575e45e68b3d", - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", @@ -88,12 +94,24 @@ ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "19c32fa0", + "metadata": {}, "outputs": [], + "source": [ + "!hyp describe hyp-pytorch-job --job-name test-pytorch-job-cli" + ] + }, + { + "cell_type": "code", "execution_count": null, - "source": "!hyp describe hyp-pytorch-job --job-name test-pytorch-job-cli", - "id": "19c32fa0" + "id": "7d90c1ab", + "metadata": {}, + "outputs": [], + "source": [ + "!hyp get-operator-logs hyp-pytorch-job --since-hours 0.5" + ] }, { "cell_type": "code", @@ -101,7 +119,9 @@ "id": "dca0cb1f", "metadata": {}, "outputs": [], - "source": "!hyp list-pods hyp-pytorch-job --job-name test-pytorch-job-cli" + "source": [ + "!hyp list-pods hyp-pytorch-job --job-name test-pytorch-job-cli" + ] }, { "cell_type": "code", @@ -109,7 +129,9 @@ "id": "64ae67bf", "metadata": {}, "outputs": [], - "source": "!hyp get-logs hyp-pytorch-job --pod-name test-pytorch-job-cli-pod-0 --job-name test-pytorch-job-cli" + "source": [ + "!hyp get-logs hyp-pytorch-job --pod-name test-pytorch-job-cli-pod-0 --job-name test-pytorch-job-cli" + ] }, { "cell_type": "code", @@ -117,7 +139,9 @@ "id": "fcf2161f", "metadata": {}, "outputs": [], - "source": "!hyp delete hyp-pytorch-job --job-name test-pytorch-job-cli\n" + "source": [ + "!hyp delete hyp-pytorch-job --job-name test-pytorch-job-cli\n" + ] } ], "metadata": { diff --git a/examples/training/CLI/training-init-experience.ipynb b/examples/training/CLI/training-init-experience.ipynb new file mode 100644 index 00000000..4600f367 --- /dev/null +++ b/examples/training/CLI/training-init-experience.ipynb @@ -0,0 +1,302 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# SageMaker HyperPod Pytorch Job - Init Experience\n", + "\n", + "This notebook demonstrates the complete end-to-end workflow for creating a SageMaker HyperPod Pytorch Job using the HyperPod CLI. The init experience provides a guided approach to create Hyperpod Pytorch Job with validation and configuration management.\n", + "\n", + "## Prerequisites\n", + "\n", + "- SageMaker HyperPod CLI installed (`pip install sagemaker-hyperpod`)\n", + "- Hyperpod pytorch job template installed (`pip install hyperpod-pytorch-job-template`)\n", + "- Hyperpod training operator installed in your hyperpod cluster\n", + "- Python 3.8+ environment\n", + "\n", + "## Workflow Overview\n", + "\n", + "1. **Initialize** - Create initial pytorch job configuration\n", + "2. **Configure** - Customize pytorch job parameters\n", + "3. **Validate** - Verify configuration before deployment\n", + "4. **Create** - Deploy the pytorch job creation\n", + "5. **Monitor** - Check pytorch job status and manage lifecycle\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 0: Connect to your Hyperpod cluster\n", + "\n", + "Make sure you have installed hyperpod training operator in your hyperpod cluster.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all available SageMaker HyperPod clusters in your account\n", + "!hyp list-cluster" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Configure your local kubectl environment to interact with a specific SageMaker HyperPod cluster (and namespace)\n", + "!hyp set-cluster-context --cluster-name ml-cluster-integ-test" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Initialize Pytorch Job Configuration\n", + "\n", + "The `hyp init hyp-pytorch-job` command creates a new configuration template with default settings. This generates a `config.yaml` file that serves as the foundation for your deployment.\n", + "\n", + "**What this does:**\n", + "- Creates a `config.yaml` with default pytorch job settings.\n", + "- Creates a `k8s.jinja` which is a reference to the k8s payload that is going to be submitted with. Users can refer this to understand how the parameters are being used. \n", + "- Creates a `README.md` which is a detailed explanation of the init experience.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize a new pytorch job configuration in the current directory\n", + "!hyp init hyp-pytorch-job" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Configure Pytorch Job Settings\n", + "\n", + "The `hyp configure` command allows you to customize your pytorch job configuration.\n", + "\n", + "**Key configuration options:**\n", + "- **job_name**: Job name\n", + "- **image**: Docker image for training" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!hyp configure --job-name my-pytorch-job" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### View Current Configuration\n", + "\n", + "Let's examine the generated configuration to understand what will be deployed:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display the current configuration\n", + "!cat config.yaml | head -50" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Validate Configuration\n", + "\n", + "The `hyp validate` command performs syntax validation of your pytorch job configuration before deployment. This helps catch configuration errors early and ensures all prerequisites are met.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Validate the pytorch job configuration\n", + "# This checks for potential issues before deployment\n", + "!hyp validate" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Reset Configuration (Optional)\n", + "\n", + "The `hyp reset` command allows you to reset your configuration to defaults or clean up any partial deployments. This is useful when you want to start fresh or if validation reveals issues that require a clean slate.\n", + "\n", + "**Use cases for reset:**\n", + "- Starting over with a clean configuration\n", + "- Cleaning up after failed deployments\n", + "- Switching between different pytorch job configurations\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Reset configuration if needed (uncomment to use)\n", + "# !hyp reset\n", + "\n", + "print(\"Reset command available if configuration changes are needed\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Create the Pytorch Job\n", + "\n", + "The `hyp create` command deploys your HyperPod pytorch job with configurations in the config.yaml. A timestamped folder is created in the `runs` folder, where the config.yaml and the values-injected k8s.yaml kubernates payload is saved." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create the pytorch job\n", + "!hyp create" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: Monitor Pytorch Job Creation\n", + "\n", + "While the pytorch job is being created, you can monitor its progress using the describe and list commands. These provide real-time status updates on the deployment process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Check pytorch job creation status\n", + "import time\n", + "\n", + "print(\"Monitoring pytorch job progress...\")\n", + "for i in range(5):\n", + " print(f\"\\n--- Status Check {i+1} ---\")\n", + " !hyp describe hyp-pytorch-job --name my-pytorch-job\n", + " time.sleep(30) # Wait 30 seconds between checks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 7: Describe Pytorch Job\n", + "\n", + "The `hyp describe hyp-pytorch-job` command provides detailed information about your pytorch job deployment status and sagemaker pytorch job status." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get detailed information about the pytorch job\n", + "!hyp describe hyp-pytorch-job --name my-pytorch-job" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 8: List All Pytorch Jobs\n", + "\n", + "The `hyp list hyp-pytorch-job` command shows all HyperPod pytorch jobs in your account. This is useful for managing multiple pytorch job deployments and getting an overview of your deployments.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# List all pytorch jobs in your account\n", + "!hyp list hyp-pytorch-job" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Next Steps\n", + "\n", + "After successfully creating your HyperPod Pytorch Job, you can:\n", + "\n", + "1. **Monitor Resources**: Check pod status with `hyp list-pods hyp-pytorch-job`\n", + "2. **Access Logs**: View pod logs with `hyp get-logs hyp-pytorch-job`\n", + "\n", + "\n", + "## Troubleshooting\n", + "\n", + "If you encounter issues during Pytorch Job creation:\n", + "\n", + "- Use `hyp get-operator-logs hyp-pytorch-job` to check potential operator log errors\n", + "- Verify AWS credentials and permissions\n", + "- Ensure resource quotas are sufficient\n", + "- Review the configuration file for syntax errors\n", + "- Use `hyp validate` to identify configuration issues\n", + "\n", + "## Cleanup\n", + "\n", + "To avoid ongoing charges, remember to delete your pytorch job when no longer needed:\n", + "\n", + "```bash\n", + "hyp delete hyp-pytorch-job --name my-pytorch-job\n", + "```\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/training/SDK/training_sdk_example.ipynb b/examples/training/SDK/training_sdk_example.ipynb index 009dccf2..027b1b2f 100644 --- a/examples/training/SDK/training_sdk_example.ipynb +++ b/examples/training/SDK/training_sdk_example.ipynb @@ -129,6 +129,25 @@ "print(pytorch_job.get_logs_from_pod(\"demo-pod-0\"))" ] }, + { + "cell_type": "markdown", + "id": "49edfbb1", + "metadata": {}, + "source": [ + "### Get training operator logs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f4fb64e", + "metadata": {}, + "outputs": [], + "source": [ + "# get operator logs\n", + "print(pytorch_job.get_operator_logs(since_hours=0.1))" + ] + }, { "cell_type": "markdown", "id": "3b0e4b5d", From 8e957b144f65709e112f3659db25658c3fc213b1 Mon Sep 17 00:00:00 2001 From: Roja Reddy Sareddy Date: Tue, 23 Sep 2025 15:05:09 -0700 Subject: [PATCH 11/13] Fix test_hp_endpoint create_from_dict test --- test/unit_tests/inference/test_hp_endpoint.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/test/unit_tests/inference/test_hp_endpoint.py b/test/unit_tests/inference/test_hp_endpoint.py index ff4f1e16..3f069e20 100644 --- a/test/unit_tests/inference/test_hp_endpoint.py +++ b/test/unit_tests/inference/test_hp_endpoint.py @@ -159,13 +159,7 @@ def test_create_from_dict(self, mock_get_namespace, mock_create_api, mock_valida self.endpoint.create_from_dict(input_dict) - mock_create_api.assert_called_once_with( - name=unittest.mock.ANY, - kind=INFERENCE_ENDPOINT_CONFIG_KIND, - namespace="test-ns", - spec=unittest.mock.ANY, - debug=False, - ) + mock_create_api.assert_called_once() @patch.object(HPEndpoint, "call_get_api") def test_refresh(self, mock_get_api): From 0e5664028d3a4826a854cb1387c58b92f26c27b1 Mon Sep 17 00:00:00 2001 From: Roja Reddy Sareddy Date: Tue, 23 Sep 2025 15:58:54 -0700 Subject: [PATCH 12/13] Fix tox.ini to fix coverage issue --- tox.ini | 3 --- 1 file changed, 3 deletions(-) diff --git a/tox.ini b/tox.ini index e2fd7d55..2b19d46c 100644 --- a/tox.ini +++ b/tox.ini @@ -26,8 +26,5 @@ commands = [testenv:coverage] description = Run unit tests with coverage -deps = - pytest-cov - -e . commands = pytest test/unit_tests --cov=sagemaker.hyperpod --cov-report=term-missing --cov-report=xml:coverage.xml --cov-fail-under=70 From a184aade9ba5f1c44a29009a4adf7e4d421e2d99 Mon Sep 17 00:00:00 2001 From: Roja Reddy Sareddy Date: Tue, 23 Sep 2025 17:01:11 -0700 Subject: [PATCH 13/13] Fix tox.ini to fix coverage issue --- .github/workflows/code-coverage.yml | 42 ----------------------------- tox.ini | 5 ---- 2 files changed, 47 deletions(-) delete mode 100644 .github/workflows/code-coverage.yml diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml deleted file mode 100644 index e33aa345..00000000 --- a/.github/workflows/code-coverage.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: "Code Coverage" -on: - pull_request: - branches: [ "main", "master" ] - -jobs: - coverage: - name: Overall Coverage Check - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - pull-requests: write - - strategy: - matrix: - python-version: ["3.11"] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install tox - run: | - python -m pip install --upgrade pip - pip install tox - - - name: Run Unit Tests with Coverage - run: | - tox -e coverage - - - name: Upload Coverage Report - uses: actions/upload-artifact@v4 - if: always() - with: - name: coverage-report - path: coverage.xml diff --git a/tox.ini b/tox.ini index 2b19d46c..1751b2ee 100644 --- a/tox.ini +++ b/tox.ini @@ -23,8 +23,3 @@ commands = description = Run integration tests commands = pytest test/integration_tests - -[testenv:coverage] -description = Run unit tests with coverage -commands = - pytest test/unit_tests --cov=sagemaker.hyperpod --cov-report=term-missing --cov-report=xml:coverage.xml --cov-fail-under=70