Skip to content
Merged
214 changes: 210 additions & 4 deletions src/sagemaker/hyperpod/cli/commands/cluster_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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 import create_boto3_client

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -292,8 +293,11 @@ def list_cluster_stacks(region, debug, status):

@click.command("cluster-stack")
@click.argument("stack-name", required=True)
@click.option("--retain-resources", help="Comma-separated list of resources to retain during deletion")
Comment thread
mohamedzeidan2021 marked this conversation as resolved.
Outdated
@click.option("--region", required=True, help="AWS region (required)")
@click.option("--debug", is_flag=True, help="Enable debug logging")
def delete(stack_name: str, debug: bool) -> None:
@_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:
"""Delete a HyperPod cluster stack.

Removes the specified CloudFormation stack and all associated AWS resources.
Expand All @@ -305,12 +309,214 @@ def delete(stack_name: str, debug: bool) -> None:
.. code-block:: bash

# Delete a cluster stack
hyp delete hyp-cluster my-stack-name
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
Comment thread
mufaddal-rohawala marked this conversation as resolved.
"""
logger = setup_logging(logging.getLogger(__name__), debug)

logger.info(f"Deleting stack: {stack_name}")
logger.info("This feature is not yet implemented.")
try:
Comment thread
mufaddal-rohawala marked this conversation as resolved.
# Parse retain resources
retain_list = []
if retain_resources:
retain_list = [r.strip() for r in retain_resources.split(',') if r.strip()]

# Get stack resources for warning display
cf_client = create_boto3_client('cloudformation', region_name=region)

try:
resources_response = cf_client.list_stack_resources(StackName=stack_name)
resources = resources_response.get('StackResourceSummaries', [])
except Exception as e:
if "does not exist" in str(e):
click.secho(f"❌ Stack '{stack_name}' not found", fg='red')
return
raise

if not resources:
click.secho(f"❌ No resources found in stack '{stack_name}'", fg='red')
return

# Validate retain resources exist in stack
if retain_list:
existing_resource_names = {r.get('LogicalResourceId', '') for r in resources}
valid_retain_resources = []
invalid_retain_resources = []

for resource in retain_list:
if resource in existing_resource_names:
valid_retain_resources.append(resource)
else:
invalid_retain_resources.append(resource)

# Show warning for non-existent resources
if invalid_retain_resources:
click.secho(f"⚠️ Warning: The following {len(invalid_retain_resources)} resources don't exist in the stack:", fg='yellow')
for resource in invalid_retain_resources:
click.secho(f" - {resource} (not found)", fg='yellow')
click.echo()

# Update retain_list to only include valid resources
retain_list = valid_retain_resources

# Categorize resources (excluding retained ones from deletion display)
resource_categories = {
'EC2 Instances': [],
'Networking': [],
'IAM': [],
'Storage': [],
'Other': []
}

for resource in resources:
resource_type = resource.get('ResourceType', '')
resource_name = resource.get('LogicalResourceId', '')
physical_id = resource.get('PhysicalResourceId', '')

# Skip resources that will be retained
if resource_name in retain_list:
continue

if 'EC2::Instance' in resource_type:
resource_categories['EC2 Instances'].append(f" - {resource_name} ({physical_id})")
elif any(net_type in resource_type for net_type in ['VPC', 'SecurityGroup', 'InternetGateway', 'Subnet', 'RouteTable']):
resource_categories['Networking'].append(f" - {resource_name}")
elif 'IAM' in resource_type:
resource_categories['IAM'].append(f" - {resource_name}")
elif any(storage_type in resource_type for storage_type in ['S3', 'EFS', 'EBS']):
resource_categories['Storage'].append(f" - {resource_name}")
else:
resource_categories['Other'].append(f" - {resource_name}")

# Count total resources (excluding retained ones)
total_resources = sum(len(category) for category in resource_categories.values())
retained_count = len(retain_list)

# Display warning
click.secho(f"⚠ WARNING: This will delete the following {total_resources} resources:", fg='yellow')
click.echo()

for category, items in resource_categories.items():
if items:
click.echo(f"{category} ({len(items)}):")
for item in items:
click.echo(item)
click.echo()

if retain_list:
click.secho(f"The following {retained_count} resources will be RETAINED:", fg='green')
for resource in retain_list:
click.secho(f" ✓ {resource} (retained)", fg='green')
click.echo()

# Confirmation prompt
if not click.confirm("Continue?", default=False):
click.echo("Operation cancelled.")
return

# Perform deletion
delete_params = {'StackName': stack_name}
if retain_list:
delete_params['RetainResources'] = retain_list

logger.info(f"Deleting stack: {stack_name} with params: {delete_params}")

try:
cf_client.delete_stack(**delete_params)

click.secho(f"✓ Stack '{stack_name}' deletion initiated successfully", fg='green')

if retain_list:
click.echo()
click.secho(f"Successfully retained as requested ({len(retain_list)}):", fg='green')
for resource in retain_list:
click.secho(f" ✓ {resource} (retained)", fg='green')
click.echo()
click.secho("💡 Retained resources will remain as standalone AWS resources", fg='cyan')
click.secho(" You can access them directly via AWS Console/CLI using their physical resource IDs", fg='cyan')

except Exception as delete_error:
# Handle termination protection specifically
if "TerminationProtection is enabled" in str(delete_error):
click.secho("❌ Stack deletion blocked: Termination Protection is enabled", fg='red')
click.echo()
click.secho("To delete this stack, first disable termination protection:", fg='yellow')
click.secho(f"aws cloudformation update-termination-protection --no-enable-termination-protection --stack-name {stack_name} --region {region or 'us-west-2'}", fg='cyan')
click.echo()
click.secho("Then retry the delete command.", fg='yellow')
raise click.ClickException("Termination protection must be disabled before deletion")

# Handle CloudFormation retain-resources limitation
if retain_list and "specify which resources to retain only when the stack is in the DELETE_FAILED state" in str(delete_error):
click.secho("❌ CloudFormation limitation: --retain-resources only works on failed deletions", fg='red')
click.echo()
click.secho("💡 Recommended workflow:", fg='yellow')
click.secho("1. First try deleting without --retain-resources:", fg='cyan')
click.secho(f" hyp delete cluster-stack {stack_name} --region {region or 'us-west-2'}", fg='cyan')
click.echo()
click.secho("2. If deletion fails, the stack will be in DELETE_FAILED state", fg='cyan')
click.secho("3. Then retry with --retain-resources to keep specific resources:", fg='cyan')
click.secho(f" hyp delete cluster-stack {stack_name} --retain-resources {retain_resources} --region {region or 'us-west-2'}", fg='cyan')
click.echo()
click.secho("⚠️ Alternative: Delete without retention and manually preserve important data first", fg='yellow')
return # Exit gracefully without raising exception

# Handle partial deletion failures
click.secho("✗ Stack deletion failed", fg='red')

# Try to get current stack resources to show what was deleted
try:
current_resources = cf_client.list_stack_resources(StackName=stack_name)
current_resource_names = {r['LogicalResourceId'] for r in current_resources.get('StackResourceSummaries', [])}
original_resource_names = {r['LogicalResourceId'] for r in resources}

deleted_resources = original_resource_names - current_resource_names
failed_resources = current_resource_names - set(retain_list) if retain_list else current_resource_names

if deleted_resources:
click.echo()
click.secho(f"Successfully deleted ({len(deleted_resources)}):", fg='green')
for resource in deleted_resources:
click.secho(f" ✓ {resource}", fg='green')

if failed_resources:
click.echo()
click.secho(f"Failed to delete ({len(failed_resources)}):", fg='red')
for resource in failed_resources:
click.secho(f" ✗ {resource} (DependencyViolation: has dependent resources)", fg='red')

if retain_list:
click.echo()
click.secho(f"Successfully retained as requested ({len(retain_list)}):", fg='green')
for resource in retain_list:
click.secho(f" ✓ {resource} (retained)", fg='green')

click.echo()
click.secho("💡 Note: Some resources may have dependencies preventing deletion", fg='yellow')
click.secho(" Check the AWS CloudFormation console for detailed dependency information", fg='cyan')

except:
# If we can't get current resources, show generic error
click.secho(f"Error: {delete_error}", fg='red')

raise click.ClickException(str(delete_error))

except Exception as e:
logger.error(f"Failed to delete stack: {e}")
if debug:
logger.exception("Detailed error information:")

if "does not exist" in str(e):
click.secho(f"❌ Stack '{stack_name}' not found", fg='red')
elif "AccessDenied" in str(e):
click.secho("❌ Access denied. Check AWS permissions", fg='red')
else:
click.secho(f"❌ Error deleting stack: {e}", fg='red')

# Only raise exception for truly unexpected errors, not user-facing ones
if not any(msg in str(e) for msg in ["does not exist", "AccessDenied"]):
raise click.ClickException(str(e))

@click.command("cluster")
@click.option("--cluster-name", required=True, help="The name of the cluster to update")
Expand Down
3 changes: 2 additions & 1 deletion src/sagemaker/hyperpod/cli/hyp_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sagemaker.hyperpod.cli.commands.cluster import list_cluster, set_cluster_context, get_cluster_context, \
get_monitoring
from sagemaker.hyperpod.cli.commands.cluster_stack import create_cluster_stack, describe_cluster_stack, \
list_cluster_stacks, update_cluster
list_cluster_stacks, update_cluster, delete_cluster_stack
from sagemaker.hyperpod.cli.commands.training import (
pytorch_create,
list_jobs,
Expand Down Expand Up @@ -190,6 +190,7 @@ def exec():
delete.add_command(pytorch_delete)
delete.add_command(js_delete)
delete.add_command(custom_delete)
delete.add_command(delete_cluster_stack)

list_pods.add_command(pytorch_list_pods)
list_pods.add_command(js_list_pods)
Expand Down
Loading
Loading