Skip to content

Commit 935744e

Browse files
authored
Merge pull request #222 from blackducksoftware/koshmack/dev-delete_older_versions_system_wide_plus
An example script which provides more features than delete_older_vers…
2 parents 6f6614d + 1fb4e03 commit 935744e

File tree

1 file changed

+333
-0
lines changed

1 file changed

+333
-0
lines changed
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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

Comments
 (0)