Skip to content

Commit 4406c82

Browse files
Optimize Pulumi cleanup because of numerous stacks on S3 backend (#45134)
### What does this PR do? Update the way we retrieve the stacks to delete, from `pulumi stack ls -all` to direct AWS sdk calls. ### Motivation `pulumi stack ls --all` is horribly slow and consume a lot of memory to retrieve all the stacks. #incident-48040 ### Describe how you validated your changes ### Additional Notes Co-authored-by: kevin.fairise <[email protected]>
1 parent 00c73a4 commit 4406c82

2 files changed

Lines changed: 111 additions & 25 deletions

File tree

.gitlab/.post/cleanup.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ new-e2e-cleanup-on-failure:
2222
else
2323
STACK_REGEX="ci-init-$CI_PIPELINE_ID-.*"
2424
fi
25-
- dda inv -- -e new-e2e-tests.cleanup-remote-stacks --stack-regex "$STACK_REGEX" --pulumi-backend=dd-pulumi-state
25+
- dda inv -- -e new-e2e-tests.cleanup-remote-stacks --stack-regex "$STACK_REGEX"
2626
variables:
2727
E2E_PUBLIC_KEY_PATH: /tmp/agent-qa-ssh-key.pub
2828
E2E_PRIVATE_KEY_PATH: /tmp/agent-qa-ssh-key

tasks/new_e2e_tests.py

Lines changed: 110 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)