1+ '''
2+ Created on Sep 14, 2022
3+
4+ @author: mkoishi
5+
6+ Find and delete older versions and additionally delete unmapped codelocations, empty versions and empty projects.
7+
8+ Copyright (C) 2022 Synopsys, Inc.
9+ http://www.synopsys.com/
10+
11+ Licensed to the Apache Software Foundation (ASF) under one
12+ or more contributor license agreements. See the NOTICE file
13+ distributed with this work for additional information
14+ regarding copyright ownership. The ASF licenses this file
15+ to you under the Apache License, Version 2.0 (the
16+ "License"); you may not use this file except in compliance
17+ with the License. You may obtain a copy of the License at
18+
19+ http://www.apache.org/licenses/LICENSE-2.0
20+
21+ Unless required by applicable law or agreed to in writing,
22+ software distributed under the License is distributed on an
23+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
24+ KIND, either express or implied. See the License for the
25+ specific language governing permissions and limitations
26+ under the License.
27+ '''
28+ import argparse
29+ import logging
30+ import sys
31+ import json
32+ import traceback
33+ from requests import HTTPError , RequestException
34+
35+ from blackduck import Client
36+
37+ import arrow
38+
39+ excluded_phases_defaults = ['RELEASED' , 'ARCHIVED' ]
40+ delete_longer_age = ['PRERELEASE' ]
41+
42+ program_description = \
43+ '''Find and delete older versions and additionally delete unmapped codelocations, empty versions and empty projects.
44+
45+ Find and delete older project-versions system-wide based on version age excluding versions whose phase is equal to RELEASED or ARCHIVED.
46+ Excluded phases can be overwritten by parameters.
47+ Threshold version age is 30 days for PRE-RELEASE phase or 14 days for other phases. Threshold version ages can be overwritten by parameters.
48+ Codelocations are deleted if mapped version is deleted by age unless 'do_not_delete_code_locations' parameter is given.
49+ Versions with no codelocations and components are deleted unless its phase is equal to RELEASED or ARCHIVED.
50+ Projects which are destined to be empty because of deleting the last version of the project are also deleted.
51+
52+ USAGE:
53+ API Toekn and hub URL need to be placed in the .restconfig.json file
54+ {
55+ "baseurl": "https://hub-hostname",
56+ "api_token": "<API token goes here>",
57+ "insecure": true,
58+ "debug": false
59+ }
60+ '''
61+
62+ class VersionCounter :
63+ '''Manage version counter for project. This is used for Test Mode and simulates totalCount of version in project.
64+ Since actual version deletions never occur in Test Mode, we are unable to rely on the totalCount.
65+ '''
66+ def __init__ (self ):
67+ self .version_counter = 0
68+
69+ def decrese_version_counter (self ):
70+ self .version_counter -= 1
71+
72+ def read_version_counter (self ):
73+ return self .version_counter
74+
75+ def reset_version_counter (self , version_number ):
76+ self .version_counter = version_number
77+
78+ number_of_projects = 0
79+ number_of_deleted_projects = 0
80+ number_of_failed_to_delete_projects = 0
81+ number_of_versions = 0
82+ number_of_deleted_versions = 0
83+ number_of_failed_to_delete_versions = 0
84+ number_of_deleted_codelocations = 0
85+ number_of_failed_to_delete_codelocations = 0
86+ version_counter = VersionCounter ()
87+
88+ def log_config ():
89+ # TODO: debug option in .restconfig file to be reflected
90+ logging .basicConfig (format = '%(asctime)s:%(levelname)s:%(module)s: %(message)s' , stream = sys .stderr , level = logging .DEBUG )
91+ logging .getLogger ("requests" ).setLevel (logging .WARNING )
92+ logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
93+ logging .getLogger ("blackduck" ).setLevel (logging .WARNING )
94+
95+ def parse_parameter ():
96+ parser = argparse .ArgumentParser (description = program_description , formatter_class = argparse .RawTextHelpFormatter )
97+ parser .add_argument ("-e" ,
98+ "--excluded_phases" ,
99+ nargs = '+' ,
100+ default = excluded_phases_defaults ,
101+ help = f"Set the phases to exclude from deletion (defaults to { excluded_phases_defaults } )" )
102+ parser .add_argument ("-al" ,
103+ "--age_longer" ,
104+ type = int ,
105+ default = 30 ,
106+ help = f"Project-versions older than this age (days) with { delete_longer_age } phase will be deleted unless their phase is in the list of excluded phases { excluded_phases_defaults } . Default is 30 days" )
107+ parser .add_argument ("-as" ,
108+ "--age_shorter" ,
109+ type = int ,
110+ default = 14 ,
111+ help = f"Project-versions older than this age (days) with other than { delete_longer_age } phase will be deleted unless their phase is in the list of excluded phases { excluded_phases_defaults } . Default is 14 days" )
112+ parser .add_argument ("-d" ,
113+ "--delete" ,
114+ action = 'store_true' ,
115+ help = f"Because this script can, and will, delete project-versions we require the caller to explicitly "
116+ "ask to delete things. Otherwise, the script runs in a 'test mode' and just says what it would do." )
117+ parser .add_argument ("-ncl" ,
118+ "--do_not_delete_code_locations" ,
119+ action = 'store_true' ,
120+ help = f"By default the script will delete code locations mapped to project versions being deleted. "
121+ "Pass this flag if you do not want to delete code locations." )
122+ parser .add_argument ("-t" ,
123+ "--timeout" ,
124+ type = int ,
125+ default = 15 ,
126+ help = f"Timeout for REST-API. Some API may take longer than the default 15 seconds" )
127+ parser .add_argument ("-r" ,
128+ "--retries" ,
129+ type = int ,
130+ default = 3 ,
131+ help = f"Retries for REST-API. Some API may need more retries than the default 3 times" )
132+ return parser .parse_args ()
133+
134+ def traverse_projects_versions (hub_client , args ):
135+ global number_of_projects
136+ global number_of_versions
137+
138+ # TODO: Wish to have get_resource('projects') and get_resource('versions') retry for HTTP failure
139+ for project in hub_client .get_resource ('projects' ):
140+ versions = []
141+ # It must receive and collect all versions from the returned generator to next coming sort and filtering
142+ for ver in hub_client .get_resource ('versions' , project ):
143+ number_of_versions += 1
144+ versions .append (ver )
145+
146+ sorted_versions = sorted (versions , key = lambda i : i ['createdAt' ])
147+ un_released_versions = list (filter (lambda v : v ['phase' ] not in args .excluded_phases , sorted_versions ))
148+ excluded = ' or ' .join (args .excluded_phases )
149+ logging .debug (f"Found { len (un_released_versions )} versions in project { project ['name' ]} which are not in phase { excluded } " )
150+
151+ if not args .delete :
152+ version_number = hub_client .get_metadata ('versions' , project )['totalCount' ]
153+ version_counter .reset_version_counter (version_number )
154+
155+ for version in un_released_versions :
156+ delete_aged_version (hub_client , args , project , version )
157+
158+ number_of_projects += 1
159+
160+ print_report (args )
161+
162+ def delete_aged_version (hub_client , args , project , version ):
163+ global number_of_deleted_projects
164+ global number_of_deleted_versions
165+ global number_of_failed_to_delete_versions
166+ global number_of_deleted_codelocations
167+
168+ version_age = (arrow .now () - arrow .get (version ['createdAt' ])).days
169+ age = args .age_longer if version ['phase' ] in delete_longer_age else args .age_shorter
170+
171+ if version_age > age :
172+ if args .delete :
173+ logging .debug (f"Deleting version { version ['versionName' ]} with phase { version ['phase' ]} from project { project ['name' ]} because it is { version_age } days old which is greater than { age } days" )
174+ else :
175+ logging .info (f"In test-mode. Version { version ['versionName' ]} with phase { version ['phase' ]} from project { project ['name' ]} would be deleted because it is { version_age } days old which is greater than { age } days. Use '--delete' to actually delete it." )
176+ if not args .do_not_delete_code_locations :
177+ if args .delete :
178+ logging .debug (f"Deleting code locations for version { version ['versionName' ]} from project { project ['name' ]} " )
179+ else :
180+ logging .info (f"In test-mode. Codelocations for version { version ['versionName' ]} from project { project ['name' ]} would be deleted." )
181+ delete_version_codelocations (hub_client , args , version )
182+ delete_version (hub_client , args , project , version )
183+ elif is_version_empty (hub_client , version ):
184+ if args .delete :
185+ logging .debug (f"Deleting version { version ['versionName' ]} with phase { version ['phase' ]} from project { project ['name' ]} because it is empty version" )
186+ else :
187+ logging .info (f"In test-mode. Version { version ['versionName' ]} with phase { version ['phase' ]} from project { project ['name' ]} would be deleted because it is empty. Use '--delete' to actually delete it." )
188+ delete_version (hub_client , args , project , version )
189+
190+ def delete_version (hub_client , args , project , version ):
191+ global number_of_deleted_versions
192+ global number_of_failed_to_delete_versions
193+
194+ try :
195+ if is_last_version_of_project (hub_client , args , project ):
196+ delete_empty_project (hub_client , args , project )
197+ return
198+ if args .delete :
199+ url = version ['_meta' ]['href' ]
200+ response = hub_client .session .delete (url )
201+ if response .status_code == 204 :
202+ logging .info (f"Successfully deleted version { version ['versionName' ]} with phase { version ['phase' ]} from project { project ['name' ]} " )
203+ number_of_deleted_versions += 1
204+ else :
205+ logging .error (f"Failed to delete version { version ['versionName' ]} from project { project ['name' ]} . status code { response .status_code } " )
206+ number_of_failed_to_delete_versions += 1
207+ else :
208+ version_counter .decrese_version_counter ()
209+ number_of_deleted_versions += 1
210+ # We continue if the raised exception is about REST request
211+ except RequestException as err :
212+ logging .error (f"Failed to delete version { version ['versionName' ]} . Reason is " + str (err ))
213+ number_of_failed_to_delete_versions += 1
214+ except Exception as err :
215+ raise err
216+
217+ def is_last_version_of_project (hub_client , args , project ):
218+ try :
219+ if args .delete :
220+ versions = hub_client .get_metadata ('versions' , project )
221+ if versions ['totalCount' ] == 1 :
222+ return True
223+ else :
224+ if version_counter .read_version_counter () == 1 :
225+ return True
226+ except RequestException as err :
227+ logging .error (f"Failed to get versions data from project { project ['name' ]} . Reason is " + str (err ))
228+ except Exception as err :
229+ raise err
230+
231+ return False
232+
233+ def is_version_empty (hub_client , version ):
234+ try :
235+ components = hub_client .get_metadata ('components' , version )
236+ codelocations = hub_client .get_metadata ('codelocations' , version )
237+ if components ['totalCount' ] == 0 and codelocations ['totalCount' ] == 0 :
238+ return True
239+ except RequestException as err :
240+ logging .error (f"Failed to get components and codelocations data from { version ['versionName' ]} . Reason is " + str (err ))
241+ except Exception as err :
242+ raise err
243+
244+ return False
245+
246+ def delete_empty_project (hub_client , args , project ):
247+ global number_of_deleted_projects
248+ global number_of_failed_to_delete_projects
249+ global number_of_deleted_versions
250+ global number_of_failed_to_delete_versions
251+
252+ try :
253+ if args .delete :
254+ url = project ['_meta' ]['href' ]
255+ response = hub_client .session .delete (url )
256+ if response .status_code == 204 :
257+ number_of_deleted_projects += 1
258+ number_of_deleted_versions += 1
259+ logging .info (f"Successfully deleted empty project { project ['name' ]} " )
260+ else :
261+ logging .error (f"Failed to delete empty project { project ['name' ]} . status code { response .status_code } " )
262+ number_of_failed_to_delete_projects += 1
263+ number_of_failed_to_delete_versions += 1
264+ else :
265+ logging .info (f"In test-mode. project { project ['name' ]} would be deleted because it is empty project" )
266+ number_of_deleted_projects += 1
267+ number_of_deleted_versions += 1
268+ # We continue if the raised exception is about REST request
269+ except RequestException as err :
270+ logging .error (f"Failed to delete project { project ['name' ]} . Reason is " + str (err ))
271+ number_of_failed_to_delete_projects += 1
272+ number_of_failed_to_delete_versions += 1
273+ except Exception as err :
274+ raise err
275+
276+ def delete_version_codelocations (hub_client , args , version ):
277+ global number_of_deleted_codelocations
278+ global number_of_failed_to_delete_codelocations
279+
280+ try :
281+ codelocations = hub_client .get_resource ('codelocations' , version )
282+ for codelocation in codelocations :
283+ if args .delete :
284+ response = hub_client .session .delete (codelocation ['_meta' ]['href' ])
285+ if response .status_code == 204 :
286+ logging .info (f"Successfully deleted codelocation { codelocation ['name' ]} from version { version ['versionName' ]} " )
287+ number_of_deleted_codelocations += 1
288+ else :
289+ logging .error (f"Failed to delete codelocation { codelocation ['name' ]} from version { version ['versionName' ]} . status code { response .status_code } " )
290+ number_of_failed_to_delete_codelocations += 1
291+ else :
292+ number_of_deleted_codelocations += 1
293+ # We continue if the raised exception is about REST request
294+ except (RequestException ) as err :
295+ logging .error (f"Failed to delete codelocation from version { version ['versionName' ]} . Reason is " + str (err ))
296+ number_of_failed_to_delete_codelocations += 1
297+ except Exception as err :
298+ raise err
299+
300+ def print_report (args ):
301+ logging .info (f"General Statistics Report" )
302+ if not args .delete :
303+ logging .info (f"This is the test mode" )
304+ logging .info (f"Total number of projects: { number_of_projects } " )
305+ logging .info (f"Total number of deleted projects: { number_of_deleted_projects } " )
306+ logging .info (f"Total number of failed to delete projects: { number_of_failed_to_delete_projects } " )
307+ logging .info (f"Total number of versions: { number_of_versions } " )
308+ logging .info (f"Total number of deleted_versions: { number_of_deleted_versions } " )
309+ logging .info (f"Total number of failed to delete versions: { number_of_failed_to_delete_versions } " )
310+ logging .info (f"Total number of deleted_codelocations: { number_of_deleted_codelocations } " )
311+ logging .info (f"Total number of failed to delete codelocations: { number_of_failed_to_delete_codelocations } " )
312+
313+ def main ():
314+ log_config ()
315+ args = parse_parameter ()
316+ try :
317+ with open ('.restconfig.json' ,'r' ) as f :
318+ config = json .load (f )
319+ hub_client = Client (token = config ['api_token' ],
320+ base_url = config ['baseurl' ],
321+ verify = not config ['insecure' ],
322+ timeout = args .timeout ,
323+ retries = args .retries )
324+
325+ traverse_projects_versions (hub_client , args )
326+ except HTTPError as err :
327+ hub_client .http_error_handler (err )
328+ except Exception as err :
329+ logging .error (f"Failed to perform the task. See the stack trace" )
330+ traceback .print_exc ()
331+
332+ if __name__ == '__main__' :
333+ sys .exit (main ())
0 commit comments