@@ -619,14 +619,113 @@ def clean(ctx, locks=True, stacks=False, output=False, skip_destroy=False):
619619 _clean_output ()
620620
621621
622+ def _get_pulumi_backend_url (ctx : Context ) -> str | None :
623+ """
624+ Get the Pulumi backend URL using 'pulumi whoami --json'.
625+ Returns the backend URL or None if it cannot be determined.
626+ """
627+ res = ctx .run (
628+ "pulumi whoami --json" ,
629+ hide = True ,
630+ warn = True ,
631+ env = _get_default_env (),
632+ )
633+ if res is None or res .exited != 0 :
634+ return None
635+ try :
636+ whoami = json .loads (res .stdout )
637+ return whoami .get ("url" )
638+ except json .JSONDecodeError :
639+ return None
640+
641+
642+ def _list_stacks_from_s3 (backend_url : str , project : str = "e2eci" ) -> list [dict ]:
643+ """
644+ List Pulumi stacks directly from S3 backend.
645+ Much faster than 'pulumi stack ls' for buckets with many stacks.
646+
647+ Args:
648+ backend_url: S3 backend URL (e.g., 's3://bucket-name' or 's3://bucket-name/path')
649+ project: Pulumi project name (default: 'e2eci')
650+
651+ Returns:
652+ List of stack dictionaries with 'name' key matching pulumi stack ls format
653+ """
654+ import boto3
655+ from botocore .exceptions import ClientError
656+
657+ # Parse S3 URL: s3://bucket-name/optional/path
658+ s3_path = backend_url .removeprefix ("s3://" )
659+ parts = s3_path .split ("/" , 1 )
660+ bucket_name = parts [0 ]
661+ base_prefix = parts [1 ] if len (parts ) > 1 else ""
662+ bucket_name = bucket_name .split ("?" )[0 ] # Remove query parameters for AWS url
663+
664+ # Pulumi stores stacks at: {base_prefix}/.pulumi/stacks/{project}/
665+ stacks_prefix = f"{ base_prefix } /.pulumi/stacks/{ project } /" .lstrip ("/" )
666+
667+ try :
668+ s3_client = boto3 .client ('s3' )
669+ stacks = []
670+
671+ paginator = s3_client .get_paginator ('list_objects_v2' )
672+ page_iterator = paginator .paginate (Bucket = bucket_name , Prefix = stacks_prefix )
673+
674+ for page in page_iterator :
675+ for obj in page .get ('Contents' , []):
676+ key = obj ['Key' ]
677+ # Stack files are {stack_name}.json (skip .json.bak files)
678+ if key .endswith ('.json' ):
679+ # Extract stack name from path
680+ filename = key .split ('/' )[- 1 ]
681+ stack_name = filename .removesuffix ('.json' )
682+ stacks .append (
683+ {
684+ 'name' : f"organization/{ project } /{ stack_name } " ,
685+ 'lastUpdate' : obj .get ('LastModified' , '' ).isoformat () if obj .get ('LastModified' ) else None ,
686+ }
687+ )
688+
689+ return stacks
690+
691+ except ClientError as e :
692+ print (f"Failed to list stacks from S3: { e } " )
693+ return []
694+
695+
696+ def list_stacks (ctx : Context , project : str = "e2eci" ) -> list [dict ]:
697+ """
698+ List Pulumi stacks. Uses S3 SDK for S3 backends (much faster),
699+ falls back to pulumi CLI for other backends.
700+
701+ Args:
702+ ctx: Invoke context
703+ project: Pulumi project name (default: 'e2eci')
704+
705+ Returns:
706+ List of stack dictionaries with 'name' key
707+ """
708+ backend_url = _get_pulumi_backend_url (ctx )
709+
710+ if backend_url and backend_url .startswith ("s3://" ):
711+ return _list_stacks_from_s3 (backend_url , project )
712+
713+ # Fallback to pulumi CLI for non-S3 backends (local, etc.)
714+ res = ctx .run (
715+ "pulumi stack ls --all --json" ,
716+ hide = True ,
717+ warn = True ,
718+ )
719+ if res is None or res .exited != 0 :
720+ return []
721+ return json .loads (res .stdout )
722+
723+
622724@task
623- def cleanup_remote_stacks (ctx , stack_regex , pulumi_backend ):
725+ def cleanup_remote_stacks (ctx , stack_regex ):
624726 """
625727 Clean up remote stacks created by the pipeline
626728 """
627- if not running_in_ci ():
628- raise Exit ("This task should be run in CI only" , 1 )
629-
630729 remote_stack_cleaning = os .getenv ("REMOTE_STACK_CLEANING" ) == "true"
631730 if remote_stack_cleaning :
632731 print ("Using remote stack cleaning" )
@@ -635,35 +734,22 @@ def cleanup_remote_stacks(ctx, stack_regex, pulumi_backend):
635734
636735 stack_regex = re .compile (stack_regex )
637736
638- # Ideally we'd use the pulumi CLI to list all the stacks. However we have way too much stacks in the bucket so the commands hang forever.
639- # Once the bucket is cleaned up we can switch to the pulumi CLI
640- res = ctx .run (
641- "pulumi stack ls --all --json" ,
642- hide = True ,
643- warn = True ,
644- )
645- if res .exited != 0 :
646- print (f"Failed to list stacks in { pulumi_backend } :" , res .stdout , res .stderr )
737+ # Use S3 SDK for listing stacks (much faster than pulumi CLI)
738+ stacks = list_stacks (ctx )
739+ if not stacks :
740+ print ("No stacks found or failed to list stacks" )
647741 return
648742 to_delete_stacks = set ()
649- stacks = json .loads (res .stdout )
650- print (stacks )
651743 for stack in stacks :
652- stack_id = (
653- stack .get ("name" , "" )
654- .split ("/" )[- 1 ]
655- .replace (".json.bak" , "" )
656- .replace (".json" , "" )
657- .replace (".pulumi/stacks/e2eci" , "" )
658- )
659- if stack_regex .match (stack_id ):
660- to_delete_stacks .add (f"organization/e2eci/{ stack_id } " )
744+ if stack_regex .match (stack ["name" ].split ("/" )[- 1 ]):
745+ to_delete_stacks .add (stack ["name" ])
661746
662747 if len (to_delete_stacks ) == 0 :
663748 print ("No stacks to delete" )
664749 return
665750
666751 print ("About to delete the following stacks:" , to_delete_stacks )
752+
667753 with multiprocessing .Pool (len (to_delete_stacks )) as pool :
668754 destroy_func = destroy_remote_stack_api if remote_stack_cleaning else destroy_remote_stack_local
669755 res = pool .map (destroy_func , to_delete_stacks )
0 commit comments