Skip to content

Commit 8189811

Browse files
committed
write python script for cleanup
1 parent 540ab91 commit 8189811

File tree

8 files changed

+391
-425
lines changed

8 files changed

+391
-425
lines changed
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
import subprocess, shlex
2+
import shutil
3+
import json
4+
import re
5+
import os
6+
from dateutil import parser
7+
from datetime import datetime, timedelta
8+
from github import Github, Auth
9+
from kubernetes import client, config
10+
import logging
11+
12+
namespace_patterns_str = os.environ.get("NAMESPACE_PATTERNS", "")
13+
NAMESPACE_REGEXES = namespace_patterns_str.split() if namespace_patterns_str else []
14+
15+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
16+
MAX_AGE_HOURS = int(os.environ.get("MAX_AGE_HOURS", "720"))
17+
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"
18+
19+
exemption_label_str = os.environ.get("EXEMPTION_LABEL", "")
20+
if exemption_label_str and "=" in exemption_label_str:
21+
EXEMPTION_ANNOTATION = exemption_label_str.split("=", 1)[0]
22+
else:
23+
EXEMPTION_ANNOTATION = "renku.io/cleanup-exempt"
24+
25+
26+
logger = logging.getLogger(__name__)
27+
logger.setLevel(logging.INFO)
28+
console_handler = logging.StreamHandler()
29+
console_handler.setLevel(logging.INFO)
30+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
31+
console_handler.setFormatter(formatter)
32+
logger.addHandler(console_handler)
33+
34+
pr_repositories_str = os.environ.get("PR_REPOSITORIES", "")
35+
NAMESPACE_PATTERN_TO_REPO_MAP = {}
36+
if pr_repositories_str:
37+
for mapping in pr_repositories_str.split():
38+
if ":" in mapping:
39+
pattern, repo = mapping.split(":", 1)
40+
NAMESPACE_PATTERN_TO_REPO_MAP[pattern] = repo
41+
42+
43+
class CIDeployment:
44+
def __init__(self, name, namespace, revision, updated, status, chart, app_version):
45+
self.name = name
46+
self.namespace = namespace
47+
self.revision = revision
48+
self.updated = updated
49+
self.status = status
50+
self.chart = chart
51+
self.app_version = app_version
52+
self.repo = None
53+
self.pr_number = None
54+
self.pr_is_open = None
55+
56+
57+
class NamespaceChecker:
58+
def __init__(self):
59+
try:
60+
config.load_incluster_config()
61+
except config.ConfigException:
62+
config.load_kube_config()
63+
self.v1 = client.CoreV1Api()
64+
65+
def is_namespace_exempt(self, namespace_name):
66+
try:
67+
namespace = self.v1.read_namespace(namespace_name)
68+
if namespace.metadata.annotations:
69+
exempt_value = namespace.metadata.annotations.get(EXEMPTION_ANNOTATION)
70+
return exempt_value == "true"
71+
return False
72+
except Exception as e:
73+
logger.error(
74+
f"Error checking namespace annotations for {namespace_name}: {e}"
75+
)
76+
return True
77+
78+
79+
class GithubPRChecker:
80+
def __init__(self, github_token):
81+
self.g = Github(auth=Auth.Token(github_token))
82+
83+
def is_pr_open(self, repo_name, pr_number):
84+
try:
85+
repo = self.g.get_repo(repo_name)
86+
pr = repo.get_pull(pr_number)
87+
return pr.state == "open"
88+
except Exception as e:
89+
logger.error(f"Error checking PR status for {repo_name}#{pr_number}: {e}")
90+
return True
91+
92+
93+
class ShellExecution:
94+
def __init__(self, command):
95+
self.command = command
96+
97+
def execute(self, dry_run=True):
98+
try:
99+
args = shlex.split(self.command)
100+
path = shutil.which(args[0])
101+
if path is None:
102+
raise FileNotFoundError(f"Command not found: {self.command.split()[0]}")
103+
else:
104+
args[0] = path
105+
106+
logger.debug(f"Executing with resolved path: {args}")
107+
108+
if dry_run:
109+
return "Dry run enabled. No action taken.", "", 0
110+
111+
result = subprocess.run(
112+
args,
113+
timeout=900,
114+
encoding="utf-8",
115+
capture_output=True,
116+
check=False,
117+
)
118+
119+
return result.stdout, result.stderr, result.returncode
120+
except subprocess.TimeoutExpired:
121+
return "", "Command timed out", -1
122+
except FileNotFoundError as e:
123+
return "", str(e), -1
124+
except Exception as e:
125+
return "", str(e), -1
126+
127+
128+
class CIDeploymentsManager:
129+
def __init__(self):
130+
self.deployments = []
131+
132+
def get_deployments(self):
133+
command = "helm list --all-namespaces -o json"
134+
shell_exec = ShellExecution(command)
135+
stdout, stderr, returncode = shell_exec.execute(dry_run=False)
136+
137+
if returncode != 0:
138+
raise RuntimeError(
139+
f"helm command failed with return code {returncode}: {stderr}"
140+
)
141+
142+
if not stdout:
143+
raise RuntimeError(f"helm command returned empty output. stderr: {stderr}")
144+
145+
input_dict = json.loads(stdout)
146+
output_set = set()
147+
for ns_regex in NAMESPACE_REGEXES:
148+
output_dict = filter(
149+
lambda ns: re.match(ns_regex, ns["namespace"]), input_dict
150+
)
151+
for item in output_dict:
152+
last_activity = parser.parse(item["updated"][:19])
153+
item = CIDeployment(
154+
name=item["name"],
155+
namespace=item["namespace"],
156+
revision=item["revision"],
157+
updated=last_activity,
158+
status=item["status"],
159+
chart=item["chart"],
160+
app_version=item["app_version"],
161+
)
162+
output_set.add(item)
163+
self.deployments = list(output_set)
164+
165+
def filter_by_age(self, deployments, hours):
166+
threshold_time = datetime.now() - timedelta(hours=hours)
167+
return [dep for dep in deployments if dep.updated < threshold_time]
168+
169+
def filter_by_closed_prs(self, deployments):
170+
pr_checker = GithubPRChecker(GITHUB_TOKEN)
171+
filtered = []
172+
for dep in deployments:
173+
if dep.repo and dep.pr_number:
174+
if not pr_checker.is_pr_open(dep.repo, int(dep.pr_number)):
175+
dep.pr_is_open = False
176+
filtered.append(dep)
177+
else:
178+
dep.pr_is_open = True
179+
else:
180+
filtered.append(dep)
181+
return filtered
182+
183+
def filter_exempt_namespaces(self, deployments):
184+
ns_checker = NamespaceChecker()
185+
filtered = []
186+
for dep in deployments:
187+
if ns_checker.is_namespace_exempt(dep.namespace):
188+
logger.info(f"Skipping exempt namespace: {dep.namespace}")
189+
else:
190+
filtered.append(dep)
191+
return filtered
192+
193+
def get_deletable_deployments(self, max_age_hours):
194+
old = self.filter_by_age(self.deployments, max_age_hours)
195+
closed_pr = self.filter_by_closed_prs(self.deployments)
196+
candidates = list(set(old).union(set(closed_pr)))
197+
return self.filter_exempt_namespaces(candidates)
198+
199+
def print_deployments(self, deployments):
200+
for dep in deployments:
201+
logger.debug(f"\nName: {dep.name}")
202+
logger.debug(f" Namespace: {dep.namespace}")
203+
logger.debug(f" Updated: {dep.updated}")
204+
logger.debug(f" Repo: {dep.repo}")
205+
logger.debug(f" PR: {dep.pr_number}")
206+
logger.debug(f" PR Open: {dep.pr_is_open}")
207+
208+
def exclude_deployments(self, names_to_exclude):
209+
self.deployments = [
210+
dep for dep in self.deployments if dep.name not in names_to_exclude
211+
]
212+
213+
def match_namespaces_to_repos(self):
214+
for dep in self.deployments:
215+
for pattern, repo in NAMESPACE_PATTERN_TO_REPO_MAP.items():
216+
if re.match(pattern, dep.namespace):
217+
dep.repo = repo
218+
break
219+
220+
def assign_pr_numbers(self):
221+
for dep in self.deployments:
222+
potential_pr = dep.namespace.split("-")[-1]
223+
try:
224+
pr_num = int(potential_pr)
225+
dep.pr_number = pr_num
226+
except ValueError:
227+
logger.info(
228+
f"Warning: Could not parse PR number from namespace {dep.namespace}, skipping PR assignment"
229+
)
230+
dep.pr_number = None
231+
232+
def run_cleanup(self, max_age_hours=None, dry_run=None):
233+
if max_age_hours is None:
234+
max_age_hours = MAX_AGE_HOURS
235+
if dry_run is None:
236+
dry_run = DRY_RUN
237+
238+
logger.debug(
239+
f"Starting cleanup with max_age_hours={max_age_hours}, dry_run={dry_run}"
240+
)
241+
if dry_run:
242+
logger.info("DRY RUN MODE: No actual deletions will be performed")
243+
244+
logger.debug("Getting CI deployments")
245+
self.get_deployments()
246+
logger.debug(f"Found {len(self.deployments)} CI deployments")
247+
self.match_namespaces_to_repos()
248+
self.assign_pr_numbers()
249+
250+
logger.debug("Determining deletable CI deployments")
251+
deployments_to_delete = self.get_deletable_deployments(max_age_hours)
252+
253+
logger.info(f"Total CI deployments to delete: {len(deployments_to_delete)}")
254+
self.print_deployments(deployments=deployments_to_delete)
255+
256+
successful_deletions = []
257+
failed_deletions = []
258+
259+
for deployment in deployments_to_delete:
260+
remover = CIDeploymentRemover(deployment, dry_run=dry_run)
261+
stdout, stderr, returncode = remover.remove_with_rdu()
262+
263+
if returncode == 0:
264+
successful_deletions.append(deployment.namespace)
265+
else:
266+
failed_deletions.append((deployment.namespace, returncode, stderr))
267+
268+
self.print_summary(
269+
deployments_to_delete, successful_deletions, failed_deletions
270+
)
271+
272+
return successful_deletions, failed_deletions
273+
274+
def print_summary(self, all_deployments, successful, failed):
275+
logger.info("=" * 80)
276+
logger.info("CLEANUP SUMMARY")
277+
logger.info("=" * 80)
278+
logger.info(f"Total CI deployments processed: {len(all_deployments)}")
279+
logger.info(f"Successful deletions: {len(successful)}")
280+
logger.info(f"Failed deletions: {len(failed)}")
281+
282+
if failed:
283+
logger.error("Failed namespaces:")
284+
for namespace, returncode, stderr in failed:
285+
logger.error(f" - {namespace} (exit code: {returncode})")
286+
if stderr:
287+
logger.error(f" Error: {stderr[:200]}")
288+
289+
290+
class CIDeploymentRemover:
291+
def __init__(self, deployment, dry_run=True):
292+
self.deployment = deployment
293+
self.dry_run = dry_run
294+
295+
def remove(self):
296+
self.remove_with_rdu()
297+
298+
def remove_with_rdu(self):
299+
command = f"rdu cleanup-deployment --namespace {self.deployment.namespace} --delete-namespace --yes"
300+
logger.info(
301+
f"\n{'[DRY RUN] ' if self.dry_run else ''}Deleting namespace: {self.deployment.namespace}"
302+
)
303+
logger.debug(f" Updated: {self.deployment.updated}")
304+
logger.debug(f" Repo: {self.deployment.repo}")
305+
logger.debug(
306+
f" PR: {self.deployment.pr_number} (Open: {self.deployment.pr_is_open})"
307+
)
308+
309+
if self.dry_run:
310+
logger.info(f" Command: {command}")
311+
return "Dry run enabled. No action taken.", "", 0
312+
else:
313+
logger.debug(f" Executing: {command}")
314+
shell_exec = ShellExecution(command)
315+
stdout, stderr, returncode = shell_exec.execute(dry_run=False)
316+
317+
if returncode == 0:
318+
logger.info(
319+
f" ✓ Successfully deleted namespace: {self.deployment.namespace}"
320+
)
321+
else:
322+
logger.error(
323+
f" ✗ Failed to delete namespace: {self.deployment.namespace}"
324+
)
325+
logger.debug(f" Return code: {returncode}")
326+
if stderr:
327+
logger.error(f" Error output: {stderr}")
328+
if stdout:
329+
logger.debug(f" Standard output: {stdout}")
330+
331+
return stdout, stderr, returncode
332+
333+
334+
if __name__ == "__main__":
335+
if not GITHUB_TOKEN:
336+
logger.error("ERROR: GITHUB_TOKEN environment variable is required but not set")
337+
exit(1)
338+
339+
logger.info(f"Environment: MAX_AGE_HOURS={MAX_AGE_HOURS}, DRY_RUN={DRY_RUN}")
340+
341+
manager = CIDeploymentsManager()
342+
manager.run_cleanup()

0 commit comments

Comments
 (0)