From eb06287c7aab5728c45a4edcafecc6dfb79654cb Mon Sep 17 00:00:00 2001 From: bobbyiliev Date: Wed, 9 Sep 2026 15:53:20 +0300 Subject: [PATCH 1/2] ci: delete failed EKS node groups before retrying terraform destroy --- test/terraform/mzcompose.py | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/test/terraform/mzcompose.py b/test/terraform/mzcompose.py index 311853c4c93c6..da6756bfa1ff1 100644 --- a/test/terraform/mzcompose.py +++ b/test/terraform/mzcompose.py @@ -583,10 +583,19 @@ def destroy(self, env=None) -> None: f"terraform destroy failed (attempt {attempt + 1}/3), " "waiting 60s for resources to be released..." ) + self.unblock_destroy() time.sleep(60) else: raise + def unblock_destroy(self) -> None: + """Clear state that a failed `terraform destroy` cannot clear itself. + + Called between destroy attempts, so it must tolerate a partially + destroyed deployment and must never raise: whatever it cannot fix is + the next attempt's problem, not a new failure. + """ + def test( self, c: Composition, tag: str, run_testdrive_files: bool, files: list[str] ) -> None: @@ -691,6 +700,69 @@ def __init__(self, path: Path): super().__init__(path) self.base_vars = [] + def _list_node_groups(self, cluster: str) -> list[str]: + """Node groups attached to `cluster`, empty if the cluster is gone.""" + try: + return json.loads( + spawn.capture( + [ + "aws", + "eks", + "list-nodegroups", + "--cluster-name", + cluster, + "--region", + "us-east-1", + "--output", + "json", + ] + ) + )["nodegroups"] + except (subprocess.CalledProcessError, json.JSONDecodeError): + return [] + + def unblock_destroy(self) -> None: + # A node group whose creation failed keeps its cluster undeletable + # ("ResourceInUseException: Cluster has nodegroups attached"), and + # Terraform does not retry the node group once the cluster delete + # fails, so every attempt hits the same wall. The cluster, its VPC and + # its KMS key then leak, and since each test root uses a fixed name + # prefix, the leak fails every later run on "already exists". + try: + cluster = spawn.capture( + ["terraform", "output", "-raw", "eks_cluster_name"], cwd=self.path + ).strip() + except subprocess.CalledProcessError: + cluster = "" + if not cluster: + # Nothing left in state to clean up after. + return + + for node_group in self._list_node_groups(cluster): + print(f"Deleting EKS node group {node_group} to unblock the destroy") + run_ignore_error( + [ + "aws", + "eks", + "delete-nodegroup", + "--cluster-name", + cluster, + "--nodegroup-name", + node_group, + "--region", + "us-east-1", + ] + ) + + # Deletion is asynchronous and the cluster stays undeletable until it + # finishes, so wait rather than letting the next attempt fail again. + deadline = time.time() + 600 + while node_groups := self._list_node_groups(cluster): + if time.time() > deadline: + print(f"EKS node groups still attached after 10m: {node_groups}") + break + time.sleep(15) + def setup( self, prefix: str, From e9812cc5c4f2133e042c918383e65f704fa41cda Mon Sep 17 00:00:00 2001 From: bobbyiliev Date: Wed, 9 Sep 2026 16:09:59 +0300 Subject: [PATCH 2/2] ci: bound the node group wait to fit the cleanup budget --- test/terraform/mzcompose.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/terraform/mzcompose.py b/test/terraform/mzcompose.py index da6756bfa1ff1..b86f26fcbec8c 100644 --- a/test/terraform/mzcompose.py +++ b/test/terraform/mzcompose.py @@ -755,11 +755,16 @@ def unblock_destroy(self) -> None: ) # Deletion is asynchronous and the cluster stays undeletable until it - # finishes, so wait rather than letting the next attempt fail again. - deadline = time.time() + 600 + # finishes, so give it a moment before the next attempt. Only best + # effort, and deliberately far shorter than the deletion can take: the + # next `terraform destroy` waits on a node group already in DELETING + # properly, while cleanup as a whole has ~30 minutes of step budget + # left, so overrunning here risks the timeout leaking the very cluster + # this is trying to free. + deadline = time.time() + 300 while node_groups := self._list_node_groups(cluster): if time.time() > deadline: - print(f"EKS node groups still attached after 10m: {node_groups}") + print(f"EKS node groups still deleting, leaving them: {node_groups}") break time.sleep(15)