Skip to content

Commit 0732b36

Browse files
committed
batch version detail report generation
1 parent 4af291b commit 0732b36

File tree

1 file changed

+308
-0
lines changed

1 file changed

+308
-0
lines changed
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
'''
2+
Created on April 4, 2023
3+
@author: kumykov
4+
5+
Copyright (C) 2023 Synopsys, Inc.
6+
http://www.blackducksoftware.com/
7+
8+
Licensed to the Apache Software Foundation (ASF) under one
9+
or more contributor license agreements. See the NOTICE file
10+
distributed with this work for additional information
11+
regarding copyright ownership. The ASF licenses this file
12+
to you under the Apache License, Version 2.0 (the
13+
"License"); you may not use this file except in compliance
14+
with the License. You may obtain a copy of the License at
15+
16+
http://www.apache.org/licenses/LICENSE-2.0
17+
18+
Unless required by applicable law or agreed to in writing,
19+
software distributed under the License is distributed on an
20+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
21+
KIND, either express or implied. See the License for the
22+
specific language governing permissions and limitations
23+
under the License.
24+
25+
This script will trigger version report generation, wait for
26+
completion and then download version details report.
27+
Project version will be selected from an EXCEL file.
28+
Each row of a file is expected to contain a field for
29+
Project Name and Project Version.
30+
Script will iterate through the rows of a spreadsheet and
31+
process report generation sequentially, one at a time.
32+
33+
Requirements
34+
35+
- python3 version 3.8 or newer recommended
36+
- the following packages are used by the script and should be installed
37+
prior to use:
38+
argparse
39+
blackduck
40+
csv
41+
logging
42+
re
43+
openpyxl
44+
sys
45+
time
46+
- Blackduck instance
47+
- API token with sufficient privileges to perform project version phase
48+
change.
49+
50+
Install python packages with the following command:
51+
52+
pip3 install argparse blackduck csv logging re openpyxl sys time
53+
54+
Using
55+
56+
place the token into a file (token in this example) then execute:
57+
58+
python3 batch_generate_version_details_report -u https://blackduck-host -t token -nv -i excel-file-with-data
59+
60+
Projects and project versions that are listed in the file but are not
61+
present on the blackduck instance will be skipped.
62+
63+
Report filename will be generated as a combination of project name and version name
64+
65+
usage: Generate and download reports for projets in a spreadsheet
66+
67+
python3 batch_generate_version_details_report [-h] -u BASE_URL -t TOKEN_FILE -i INPUT_FILE [-nv]
68+
[-tr TRIES] [-s SLEEP_TIME] [-r REPORTS]
69+
70+
options:
71+
-h, --help show this help message and exit
72+
-u BASE_URL, --base-url BASE_URL
73+
Hub server URL e.g. https://your.blackduck.url
74+
-t TOKEN_FILE, --token-file TOKEN_FILE
75+
File containing access token
76+
-i INPUT_FILE, --input-file INPUT_FILE
77+
Project Name
78+
-nv, --no-verify Disable TLS certificate verification
79+
-tr TRIES, --tries TRIES
80+
How many times to retry downloading the report, i.e. wait for the report to be generated
81+
-s SLEEP_TIME, --sleep_time SLEEP_TIME
82+
The amount of time to sleep in-between (re-)tries to download the report
83+
-r REPORTS, --reports REPORTS
84+
Comma separated list (no spaces) of the reports to generate - ['version', 'scans', 'components',
85+
'vulnerabilities', 'source', 'cryptography', 'license_terms', 'component_additional_fields',
86+
'project_version_additional_fields', 'vulnerability_matches', 'upgrade_guidance',
87+
'license_conflicts']. Default is all reports.
88+
89+
'''
90+
91+
import csv
92+
import sys
93+
import argparse
94+
import logging
95+
import re
96+
import openpyxl
97+
import time
98+
99+
from blackduck import Client
100+
from blackduck.constants import VERSION_PHASES
101+
102+
logging.basicConfig(format='%(asctime)s:%(levelname)s:%(message)s', stream=sys.stderr, level=logging.DEBUG)
103+
logging.getLogger("requests").setLevel(logging.WARNING)
104+
logging.getLogger("urllib3").setLevel(logging.WARNING)
105+
logging.getLogger("blackduck").setLevel(logging.DEBUG)
106+
107+
# Values for the variables below should match corresponding column headers
108+
project_name_column = 'Project Name'
109+
project_version_column = 'Version Name'
110+
111+
summary_report = '''
112+
113+
Summary
114+
115+
'''
116+
117+
version_name_map = {
118+
'version': 'VERSION',
119+
'scans': 'CODE_LOCATIONS',
120+
'components': 'COMPONENTS',
121+
'vulnerabilities': 'SECURITY',
122+
'source':'FILES',
123+
'cryptography': 'CRYPTO_ALGORITHMS',
124+
'license_terms': 'LICENSE_TERM_FULFILLMENT',
125+
'component_additional_fields': 'BOM_COMPONENT_CUSTOM_FIELDS',
126+
'project_version_additional_fields': 'PROJECT_VERSION_CUSTOM_FIELDS',
127+
'vulnerability_matches': 'VULNERABILITY_MATCH',
128+
'upgrade_guidance': 'UPGRADE_GUIDANCE',
129+
'license_conflicts': 'LICENSE_CONFLICTS'
130+
}
131+
132+
all_reports = list(version_name_map.keys())
133+
134+
class FailedReportDownload(Exception):
135+
pass
136+
137+
def append_to_summary(message):
138+
global summary_report
139+
summary_report += message + '\n'
140+
141+
def process_csv_file(args):
142+
file = open(args.input_file)
143+
type(file)
144+
csvreader = csv.reader(file)
145+
project_name_idx = None
146+
project_version_idx = None
147+
for row in csvreader:
148+
row_number = csvreader.line_num
149+
if not (project_name_idx and project_version_idx):
150+
project_name_idx = row.index(project_name_column)
151+
project_version_idx = row.index(project_version_column)
152+
elif project_name_idx and project_version_idx:
153+
project_name = row[project_name_idx].strip() if project_name_idx < len(row) else ''
154+
version_name = row[project_version_idx].strip() if project_version_idx < len(row) else ''
155+
if project_name and version_name:
156+
logging.info(f"Processing row {row_number:4}: {row[project_name_idx]} : {row[project_version_idx]}")
157+
process_project_version(project_name, version_name, args)
158+
else:
159+
message = f"Processing row {row_number:}. Invalid data: Project '{project_name}' version '{version_name}', skipping"
160+
logging.info(message)
161+
append_to_summary(message)
162+
continue
163+
else:
164+
logging.info("Could not parse input file")
165+
sys.exit(1)
166+
167+
def process_excel_file(args):
168+
wb = openpyxl.load_workbook(args.input_file)
169+
ws = wb.active
170+
project_name_idx = None
171+
project_version_idx = None
172+
row_number = 0
173+
for row in ws.values:
174+
row_number += 1
175+
if not (project_name_idx and project_version_idx):
176+
project_name_idx = row.index(project_name_column)
177+
project_version_idx = row.index(project_version_column)
178+
elif project_name_idx and project_version_idx:
179+
project_name = row[project_name_idx] if project_name_idx < len(row) else ''
180+
version_name = row[project_version_idx] if project_version_idx < len(row) else ''
181+
if project_name and version_name:
182+
logging.info(f"Processing row {row_number:4}: {row[project_name_idx]} : {row[project_version_idx]}")
183+
process_project_version(project_name.strip(), version_name.strip(), args)
184+
else:
185+
message = f"Processing row {row_number:}. Invalid data: Project '{project_name}' version '{version_name}', skipping"
186+
logging.info(message)
187+
append_to_summary(message)
188+
continue
189+
else:
190+
logging.info("Could not parse input file")
191+
sys.exit(1)
192+
193+
def download_report(location, filename, retries, sleep_time):
194+
report_id = location.split("/")[-1]
195+
base_url = bd.base_url if bd.base_url.endswith("/") else bd.base_url + "/"
196+
download_url = f"{base_url}api/reports/{report_id}"
197+
198+
logging.info(f"Retrieving report list for {location}")
199+
200+
if retries:
201+
response = bd.session.get(location)
202+
report_status = response.json().get('status', 'Not Ready')
203+
if response.status_code == 200 and report_status == 'COMPLETED':
204+
response = bd.session.get(download_url, headers={'Content-Type': 'application/zip', 'Accept':'application/zip'})
205+
if response.status_code == 200:
206+
with open(filename, "wb") as f:
207+
f.write(response.content)
208+
logging.info(f"Successfully downloaded zip file to {filename} for report {report_id}")
209+
else:
210+
logging.error(f"Failed to download report")
211+
else:
212+
retries -= 1
213+
logging.debug(f"Failed to retrieve report {report_id}, report status: {report_status}")
214+
logging.debug(f"Will retry {retries} more times. Sleeping for {sleep_time} second(s)")
215+
time.sleep(sleep_time)
216+
download_report(location, filename, retries, sleep_time)
217+
else:
218+
raise FailedReportDownload(f"Failed to retrieve report {report_id} after multiple retries")
219+
220+
def process_project_version(project_name, version_name, args):
221+
params = {
222+
'q': [f"name:{project_name}"]
223+
}
224+
try:
225+
projects = [p for p in bd.get_resource('projects', params=params) if p['name'] == project_name]
226+
assert len(projects) == 1, f"There should be one, and only one project named {project_name}. We found {len(projects)}"
227+
project = projects[0]
228+
except AssertionError:
229+
message = f"Project named '{project_name}' not found. Skipping"
230+
logging.warning(message)
231+
append_to_summary(message)
232+
return
233+
234+
params = {
235+
'q': [f"versionName:{version_name}"]
236+
}
237+
238+
try:
239+
versions = [v for v in bd.get_resource('versions', project, params=params) if v['versionName'] == version_name]
240+
assert len(versions) == 1, f"There should be one, and only one version named {version_name}. We found {len(versions)}"
241+
version = versions[0]
242+
except AssertionError:
243+
message = f"Version name '{version_name}' for project {project_name} was not found, skipping"
244+
logging.warning(message)
245+
append_to_summary(message)
246+
return
247+
logging.debug(f"Found {project['name']}:{version['versionName']}")
248+
249+
reports_l = args.reports.split(",")
250+
reports_l = [version_name_map[r.lower()] for r in reports_l]
251+
252+
post_data = {
253+
'categories': reports_l,
254+
'versionId': version['_meta']['href'].split("/")[-1],
255+
'reportType': 'VERSION',
256+
'reportFormat': "CSV"
257+
}
258+
version_reports_url = bd.list_resources(version).get('versionReport')
259+
assert version_reports_url, "Ruh-roh, a version should always have a versionReport resource under it"
260+
261+
r = bd.session.post(version_reports_url, json=post_data)
262+
r.raise_for_status()
263+
location = r.headers.get('Location')
264+
assert location, "Hmm, this does not make sense. If we successfully created a report then there needs to be a location where we can get it from"
265+
266+
logging.debug(f"Created version details report for project {project_name}, version {version_name} at location {location}")
267+
report_file_name = project_name + "-" + version_name + ".zip"
268+
download_report(location, sanitize_filename(report_file_name), args.tries, args.sleep_time)
269+
270+
def sanitize_filename(filename):
271+
forbidden = '/<>:"\|?*'
272+
for c in forbidden:
273+
filename = filename.replace(c,'-')
274+
return filename
275+
276+
277+
def parse_command_args():
278+
279+
parser = argparse.ArgumentParser("Generate and download reports for projets in a spreadsheet")
280+
parser.add_argument("-u", "--base-url", required=True, help="Hub server URL e.g. https://your.blackduck.url")
281+
parser.add_argument("-t", "--token-file", required=True, help="File containing access token")
282+
parser.add_argument("-i", "--input-file", required=True, help="Project Name")
283+
parser.add_argument("-nv", "--no-verify", action='store_false', help="Disable TLS certificate verification")
284+
parser.add_argument('-tr', '--tries', default=30, type=int, help="How many times to retry downloading the report, i.e. wait for the report to be generated")
285+
parser.add_argument('-s', '--sleep_time', default=10, type=int, help="The amount of time to sleep in-between (re-)tries to download the report")
286+
parser.add_argument("-r", "--reports", default=",".join(all_reports),
287+
help=f"Comma separated list (no spaces) of the reports to generate - {list(version_name_map.keys())}. Default is all reports.")
288+
289+
return parser.parse_args()
290+
291+
def main():
292+
args = parse_command_args()
293+
with open(args.token_file, 'r') as tf:
294+
access_token = tf.readline().strip()
295+
global bd
296+
bd = Client(base_url=args.base_url, token=access_token, verify=args.no_verify, timeout=60.0, retries=4)
297+
298+
if re.match(".+xlsx?$", args.input_file):
299+
logging.info(f"Processing EXCEL file {args.input_file}")
300+
process_excel_file(args)
301+
else:
302+
logging.info(f"Processing CSV file {args.input_file}")
303+
process_csv_file(args)
304+
305+
print (summary_report)
306+
307+
if __name__ == "__main__":
308+
sys.exit(main())

0 commit comments

Comments
 (0)