1717
1818import logging
1919import math
20+ import re
2021import sys
2122import time
2223from collections import OrderedDict , deque
2324from datetime import datetime
24- from typing import Dict , List , Optional
25+ from typing import Dict , List , Optional , Union
2526
2627import botocore
2728
@@ -183,6 +184,7 @@ def create_changeset(
183184 "Parameters" : parameter_values ,
184185 "Description" : "Created by SAM CLI at {0} UTC" .format (datetime .utcnow ().isoformat ()),
185186 "Tags" : tags ,
187+ "IncludeNestedStacks" : True ,
186188 }
187189
188190 kwargs = self ._process_kwargs (kwargs , s3_uploader , capabilities , role_arn , notification_arns )
@@ -243,27 +245,73 @@ def describe_changeset(self, change_set_id, stack_name, **kwargs):
243245 :param kwargs: Other arguments to pass to pprint_columns()
244246 :return: dictionary of changes described in the changeset.
245247 """
248+ # Display changes for parent stack first
249+ changeset = self ._display_changeset_changes (change_set_id , stack_name , is_parent = True , ** kwargs )
250+
251+ if not changeset :
252+ # There can be cases where there are no changes,
253+ # but could be an an addition of a SNS notification topic.
254+ pprint_columns (
255+ columns = ["-" , "-" , "-" , "-" ],
256+ width = kwargs ["width" ],
257+ margin = kwargs ["margin" ],
258+ format_string = DESCRIBE_CHANGESET_FORMAT_STRING ,
259+ format_args = kwargs ["format_args" ],
260+ columns_dict = DESCRIBE_CHANGESET_DEFAULT_ARGS .copy (),
261+ )
262+
263+ return changeset
264+
265+ def _display_changeset_changes (
266+ self , change_set_id : str , stack_name : str , is_parent : bool = False , ** kwargs
267+ ) -> Union [Dict [str , List ], bool ]:
268+ """
269+ Display changes for a changeset, including nested stack changes
270+
271+ :param change_set_id: ID of the changeset
272+ :param stack_name: Name of the CloudFormation stack
273+ :param is_parent: Whether this is the parent stack
274+ :param kwargs: Other arguments to pass to pprint_columns()
275+ :return: dictionary of changes or False if no changes
276+ """
246277 paginator = self ._client .get_paginator ("describe_change_set" )
247278 response_iterator = paginator .paginate (ChangeSetName = change_set_id , StackName = stack_name )
248- changes = {"Add" : [], "Modify" : [], "Remove" : []}
279+ changes : Dict [ str , List ] = {"Add" : [], "Modify" : [], "Remove" : []}
249280 changes_showcase = {"Add" : "+ Add" , "Modify" : "* Modify" , "Remove" : "- Delete" }
250- changeset = False
281+ changeset_found = False
282+ nested_changesets = []
283+
251284 for item in response_iterator :
252- cf_changes = item .get ("Changes" )
285+ cf_changes = item .get ("Changes" , [] )
253286 for change in cf_changes :
254- changeset = True
255- resource_props = change .get ("ResourceChange" )
287+ changeset_found = True
288+ resource_props = change .get ("ResourceChange" , {} )
256289 action = resource_props .get ("Action" )
290+ resource_type = resource_props .get ("ResourceType" )
291+ logical_id = resource_props .get ("LogicalResourceId" )
292+
293+ # Check if this is a nested stack with its own changeset
294+ nested_changeset_id = resource_props .get ("ChangeSetId" )
295+ if resource_type == "AWS::CloudFormation::Stack" and nested_changeset_id :
296+ nested_changesets .append (
297+ {"changeset_id" : nested_changeset_id , "logical_id" : logical_id , "action" : action }
298+ )
299+
300+ replacement = resource_props .get ("Replacement" )
257301 changes [action ].append (
258302 {
259- "LogicalResourceId" : resource_props .get ("LogicalResourceId" ),
260- "ResourceType" : resource_props .get ("ResourceType" ),
261- "Replacement" : (
262- "N/A" if resource_props .get ("Replacement" ) is None else resource_props .get ("Replacement" )
263- ),
303+ "LogicalResourceId" : logical_id ,
304+ "ResourceType" : resource_type ,
305+ "Replacement" : "N/A" if replacement is None else replacement ,
264306 }
265307 )
266308
309+ # Print stack header if it's a nested stack
310+ if not is_parent :
311+ sys .stdout .write (f"\n [Nested Stack: { stack_name } ]\n " )
312+ sys .stdout .flush ()
313+
314+ # Display changes for this stack
267315 for k , v in changes .items ():
268316 for value in v :
269317 row_color = self .deploy_color .get_changeset_action_color (action = k )
@@ -282,19 +330,54 @@ def describe_changeset(self, change_set_id, stack_name, **kwargs):
282330 color = row_color ,
283331 )
284332
285- if not changeset :
286- # There can be cases where there are no changes,
287- # but could be an an addition of a SNS notification topic.
288- pprint_columns (
289- columns = ["-" , "-" , "-" , "-" ],
290- width = kwargs ["width" ],
291- margin = kwargs ["margin" ],
292- format_string = DESCRIBE_CHANGESET_FORMAT_STRING ,
293- format_args = kwargs ["format_args" ],
294- columns_dict = DESCRIBE_CHANGESET_DEFAULT_ARGS .copy (),
295- )
333+ # Recursively display nested stack changes
334+ for nested in nested_changesets :
335+ try :
336+ # For nested changesets, the changeset_id is already a full ARN
337+ # We can use it directly without needing the stack name
338+ nested_response = self ._client .describe_change_set (ChangeSetName = nested ["changeset_id" ])
339+
340+ # Display nested stack header
341+ sys .stdout .write (f"\n [Nested Stack: { nested ['logical_id' ]} ]\n " )
342+ sys .stdout .flush ()
343+
344+ # Display nested changes
345+ nested_cf_changes = nested_response .get ("Changes" , [])
346+ if nested_cf_changes :
347+ for change in nested_cf_changes :
348+ resource_props = change .get ("ResourceChange" , {})
349+ action = resource_props .get ("Action" )
350+ replacement = resource_props .get ("Replacement" )
351+ row_color = self .deploy_color .get_changeset_action_color (action = action )
352+ pprint_columns (
353+ columns = [
354+ changes_showcase .get (action , action ),
355+ resource_props .get ("LogicalResourceId" ),
356+ resource_props .get ("ResourceType" ),
357+ "N/A" if replacement is None else replacement ,
358+ ],
359+ width = kwargs ["width" ],
360+ margin = kwargs ["margin" ],
361+ format_string = DESCRIBE_CHANGESET_FORMAT_STRING ,
362+ format_args = kwargs ["format_args" ],
363+ columns_dict = DESCRIBE_CHANGESET_DEFAULT_ARGS .copy (),
364+ color = row_color ,
365+ )
366+ else :
367+ pprint_columns (
368+ columns = ["-" , "-" , "-" , "-" ],
369+ width = kwargs ["width" ],
370+ margin = kwargs ["margin" ],
371+ format_string = DESCRIBE_CHANGESET_FORMAT_STRING ,
372+ format_args = kwargs ["format_args" ],
373+ columns_dict = DESCRIBE_CHANGESET_DEFAULT_ARGS .copy (),
374+ )
375+ except Exception as e :
376+ LOG .debug ("Failed to describe nested changeset %s: %s" , nested ["changeset_id" ], e )
377+ sys .stdout .write (f"\n [Nested Stack: { nested ['logical_id' ]} ] - Unable to fetch changes: { str (e )} \n " )
378+ sys .stdout .flush ()
296379
297- return changes
380+ return changes if changeset_found else False
298381
299382 def wait_for_changeset (self , changeset_id , stack_name ):
300383 """
@@ -330,8 +413,48 @@ def wait_for_changeset(self, changeset_id, stack_name):
330413 ):
331414 raise deploy_exceptions .ChangeEmptyError (stack_name = stack_name )
332415
416+ # Check if this is a nested stack changeset error
417+ if status == "FAILED" and "Nested change set" in reason :
418+ # Try to fetch detailed error from nested changeset
419+ detailed_error = self ._get_nested_changeset_error (reason )
420+ if detailed_error :
421+ reason = detailed_error
422+
333423 raise ChangeSetError (stack_name = stack_name , msg = f"ex: { ex } Status: { status } . Reason: { reason } " ) from ex
334424
425+ def _get_nested_changeset_error (self , status_reason : str ) -> Optional [str ]:
426+ """
427+ Extract and fetch detailed error from nested changeset
428+
429+ :param status_reason: The status reason from parent changeset
430+ :return: Detailed error message or None
431+ """
432+ try :
433+ # Extract nested changeset ARN from status reason
434+ # Format: "Nested change set arn:aws:cloudformation:... was not successfully created: Currently in FAILED."
435+ match = re .search (r"arn:aws:cloudformation:[^:]+:[^:]+:changeSet/([^/]+)/([a-f0-9-]+)" , status_reason )
436+ if match :
437+ nested_changeset_id = match .group (0 )
438+ nested_stack_name = match .group (1 )
439+
440+ # Fetch nested changeset details
441+ try :
442+ response = self ._client .describe_change_set (
443+ ChangeSetName = nested_changeset_id , StackName = nested_stack_name
444+ )
445+ nested_status = response .get ("Status" )
446+ nested_reason = response .get ("StatusReason" , "" )
447+
448+ if nested_status == "FAILED" and nested_reason :
449+ return f"Nested stack '{ nested_stack_name } ' changeset failed: { nested_reason } "
450+ except Exception as e :
451+ LOG .debug ("Failed to fetch nested changeset details: %s" , e )
452+
453+ except Exception as e :
454+ LOG .debug ("Failed to parse nested changeset error: %s" , e )
455+
456+ return None
457+
335458 def execute_changeset (self , changeset_id , stack_name , disable_rollback ):
336459 """
337460 Calls CloudFormation to execute changeset
0 commit comments