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