forked from openSUSE/openSUSE-release-tools
-
Notifications
You must be signed in to change notification settings - Fork 0
/
staging-installcheck.py
executable file
·434 lines (359 loc) · 17.9 KB
/
staging-installcheck.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
#!/usr/bin/python3
import argparse
import logging
import os
import re
import sys
from collections import namedtuple
from urllib.error import HTTPError
import osc.core
import yaml
from lxml import etree as ET
from osclib.comments import CommentAPI
from osclib.conf import Config
from osclib.conf import str2bool
from osclib.core import (builddepinfo, depends_on, duplicated_binaries_in_repo,
fileinfo_ext_all, repository_arch_state,
repository_path_expand, target_archs)
from osclib.repochecks import installcheck, mirror
from osclib.stagingapi import StagingAPI
from osclib.memoize import memoize
SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__))
CheckResult = namedtuple('CheckResult', ('success', 'comment'))
class InstallChecker(object):
def __init__(self, api, config):
self.api = api
self.logger = logging.getLogger('InstallChecker')
self.commentapi = CommentAPI(api.apiurl)
self.arch_whitelist = config.get('repo_checker-arch-whitelist')
if self.arch_whitelist:
self.arch_whitelist = set(self.arch_whitelist.split(' '))
self.ring_whitelist = set(config.get('repo_checker-binary-whitelist-ring', '').split(' '))
self.cycle_packages = config.get('repo_checker-allowed-in-cycles')
self.calculate_allowed_cycles()
self.ignore_duplicated = set(config.get('installcheck-ignore-duplicated-binaries', '').split(' '))
self.ignore_conflicts = set(config.get('installcheck-ignore-conflicts', '').split(' '))
self.ignore_deletes = str2bool(config.get('installcheck-ignore-deletes', 'False'))
def check_required_by(self, fileinfo, provides, requiredby, built_binaries, comments):
if requiredby.get('name') in built_binaries:
return True
result = True
# In some cases (boolean deps?) it's possible that fileinfo_ext for A
# shows that A provides cap needed by B, but fileinfo_ext for B does
# not list cap or A at all... In that case better error out and ask for
# human intervention.
dep_found = False
# In case the dep was not found, give a hint what OBS might have meant.
possible_dep = None
# extract >= and the like
provide = provides.get('dep')
provide = provide.split(' ')[0]
comments.append('{} provides {} required by {}'.format(
fileinfo.find('name').text, provide, requiredby.get('name')))
url = api.makeurl(['build', api.project, api.cmain_repo, 'x86_64', '_repository', requiredby.get('name') + '.rpm'],
{'view': 'fileinfo_ext'})
reverse_fileinfo = ET.parse(osc.core.http_GET(url)).getroot()
for require in reverse_fileinfo.findall('requires_ext'):
# extract >= and the like here too
dep = require.get('dep').split(' ')[0]
if dep != provide:
if provide in require.get('dep'):
possible_dep = require.get('dep')
continue
dep_found = True
# Whether this is provided by something being deleted
provided_found = False
# Whether this is provided by something not being deleted
alternative_found = False
for provided_by in require.findall('providedby'):
if provided_by.get('name') in built_binaries:
provided_found = True
else:
comments.append(f" also provided by {provided_by.get('name')} -> ignoring")
alternative_found = True
if not alternative_found:
result = False
if not provided_found:
comments.append(" OBS doesn't see this in the reverse resolution though. Not sure what to do.")
result = False
if not dep_found:
comments.append(" OBS doesn't see this dep in reverse though. Not sure what to do.")
if possible_dep is not None:
comments.append(f' Might be required by {possible_dep}')
return False
if result:
return True
else:
comments.append(f'Error: missing alternative provides for {provide}')
return False
@memoize(session=True)
def pkg_with_multibuild_flavors(self, package):
ret = set([package])
# Add all multibuild flavors
mainprjresult = ET.fromstringlist(osc.core.show_results_meta(self.api.apiurl, self.api.project, multibuild=True))
for pkg in mainprjresult.xpath(f"result/status[starts-with(@package,'{package}:')]"):
ret.add(pkg.get('package'))
return ret
def check_delete_request(self, req, to_ignore, to_delete, comments):
package = req.get('package')
if package in to_ignore or self.ignore_deletes:
self.logger.info(f'Delete request for package {package} ignored')
return True
pkg_flavors = self.pkg_with_multibuild_flavors(package)
built_binaries = set()
file_infos = []
for flavor in pkg_flavors:
for fileinfo in fileinfo_ext_all(self.api.apiurl, self.api.project, self.api.cmain_repo, 'x86_64', flavor):
built_binaries.add(fileinfo.find('name').text)
file_infos.append(fileinfo)
# extend the others - this asks for a refactoring, but we don't handle tons of delete requests often
for ptd in to_delete:
if ptd in pkg_flavors:
continue
for fileinfo in fileinfo_ext_all(self.api.apiurl, self.api.project, self.api.cmain_repo, 'x86_64', ptd):
built_binaries.add(fileinfo.find('name').text)
result = True
for fileinfo in file_infos:
for provides in fileinfo.findall('provides_ext'):
for requiredby in provides.findall('requiredby[@name]'):
result = result and self.check_required_by(fileinfo, provides, requiredby, built_binaries, comments)
what_depends_on = depends_on(api.apiurl, api.project, api.cmain_repo, pkg_flavors, True)
# filter out packages to be deleted
for ptd in to_delete:
if ptd in what_depends_on:
what_depends_on.remove(ptd)
if len(what_depends_on):
comments.append('{} is still a build requirement of:\n\n- {}'.format(
package, '\n- '.join(sorted(what_depends_on))))
return False
return result
def packages_to_ignore(self, project):
comments = self.commentapi.get_comments(project_name=project)
ignore_re = re.compile(r'^installcheck: ignore (?P<args>.*)$', re.MULTILINE)
# the last wins, for now we don't care who said it
args = []
for comment in comments.values():
match = ignore_re.search(comment['comment'].replace('\r', ''))
if not match:
continue
args = match.group('args').strip()
# allow space and comma to seperate
args = args.replace(',', ' ').split(' ')
return set(args)
def staging(self, project, force=False):
api = self.api
repository = self.api.cmain_repo
# fetch the build ids at the beginning - mirroring takes a while
buildids = {}
try:
architectures = self.target_archs(project, repository)
except HTTPError as e:
if e.code == 404:
# adi disappear all the time, so don't worry
return False
raise e
all_done = True
for arch in architectures:
pra = f'{project}/{repository}/{arch}'
buildid = self.buildid(project, repository, arch)
if not buildid:
self.logger.error(f'No build ID in {pra}')
return False
buildids[arch] = buildid
url = self.report_url(project, repository, arch, buildid)
try:
root = ET.parse(osc.core.http_GET(url)).getroot()
check = root.find('check[@name="installcheck"]/state')
if check is not None and check.text != 'pending':
self.logger.info(f'{pra} already "{check.text}", ignoring')
else:
all_done = False
except HTTPError:
self.logger.info(f'{pra} has no status report')
all_done = False
if all_done and not force:
return True
repository_pairs = repository_path_expand(api.apiurl, project, repository)
result_comment = []
result = True
to_ignore = self.packages_to_ignore(project)
status = api.project_status(project)
if status is None:
self.logger.error(f'no project status for {project}')
return False
# collect packages to be deleted
to_delete = set()
for req in status.findall('staged_requests/request'):
if req.get('type') == 'delete':
to_delete |= self.pkg_with_multibuild_flavors(req.get('package'))
for req in status.findall('staged_requests/request'):
if req.get('type') == 'delete':
result = self.check_delete_request(req, to_ignore, to_delete, result_comment) and result
for arch in architectures:
# hit the first repository in the target project (if existant)
target_pair = None
directories = []
for pair_project, pair_repository in repository_pairs:
# ignore repositories only inherited for config
if repository_arch_state(self.api.apiurl, pair_project, pair_repository, arch):
if not target_pair and pair_project == api.project:
target_pair = [pair_project, pair_repository]
directories.append(mirror(self.api.apiurl, pair_project, pair_repository, arch))
if not api.is_adi_project(project):
# For "leaky" ring packages in letter stagings, where the
# repository setup does not include the target project, that are
# not intended to to have all run-time dependencies satisfied.
whitelist = self.ring_whitelist
else:
whitelist = set()
whitelist |= to_ignore
ignore_conflicts = self.ignore_conflicts | to_ignore
check = self.cycle_check(project, repository, arch)
if not check.success:
self.logger.warning('Cycle check failed')
result_comment.append(check.comment)
result = False
check = self.install_check(directories, arch, whitelist, ignore_conflicts)
if not check.success:
self.logger.warning('Install check failed')
result_comment.append(check.comment)
result = False
duplicates = duplicated_binaries_in_repo(self.api.apiurl, project, repository)
# remove white listed duplicates
for arch in list(duplicates):
for binary in self.ignore_duplicated:
duplicates[arch].pop(binary, None)
if not len(duplicates[arch]):
del duplicates[arch]
if len(duplicates):
self.logger.warning('Found duplicated binaries')
result_comment.append('Found duplicated binaries')
result_comment.append(yaml.dump(duplicates, default_flow_style=False))
result = False
if result:
self.report_state('success', self.gocd_url(), project, repository, buildids)
else:
result_comment.insert(0, f'Generated from {self.gocd_url()}\n')
self.report_state('failure', self.upload_failure(project, result_comment), project, repository, buildids)
self.logger.warning(f'Not accepting {project}')
return False
return result
def upload_failure(self, project, comment):
print(project, '\n'.join(comment))
url = self.api.makeurl(['source', 'home:repo-checker', 'reports', project])
osc.core.http_PUT(url, data='\n'.join(comment))
url = self.api.apiurl.replace('api.', 'build.')
return f'{url}/package/view_file/home:repo-checker/reports/{project}'
def report_state(self, state, report_url, project, repository, buildids):
architectures = self.target_archs(project, repository)
for arch in architectures:
self.report_pipeline(state, report_url, project, repository, arch, buildids[arch])
def gocd_url(self):
if not os.environ.get('GO_SERVER_URL'):
# placeholder :)
return 'http://stephan.kulow.org/'
report_url = os.environ.get('GO_SERVER_URL').replace(':8154', '')
return report_url + '/tab/build/detail/{}/{}/{}/{}/{}#tab-console'.format(os.environ.get('GO_PIPELINE_NAME'),
os.environ.get('GO_PIPELINE_COUNTER'),
os.environ.get('GO_STAGE_NAME'),
os.environ.get('GO_STAGE_COUNTER'),
os.environ.get('GO_JOB_NAME'))
def buildid(self, project, repository, architecture):
url = self.api.makeurl(['build', project, repository, architecture], {'view': 'status'})
root = ET.parse(osc.core.http_GET(url)).getroot()
buildid = root.find('buildid')
if buildid is None:
return False
return buildid.text
def report_url(self, project, repository, architecture, buildid):
return self.api.makeurl(['status_reports', 'built', project,
repository, architecture, 'reports', buildid])
def report_pipeline(self, state, report_url, project, repository, architecture, buildid):
url = self.report_url(project, repository, architecture, buildid)
name = 'installcheck'
xml = self.check_xml(report_url, state, name)
try:
osc.core.http_POST(url, data=xml)
except HTTPError:
print('failed to post status to ' + url)
sys.exit(1)
def check_xml(self, url, state, name):
check = ET.Element('check')
if url:
se = ET.SubElement(check, 'url')
se.text = url
se = ET.SubElement(check, 'state')
se.text = state
se = ET.SubElement(check, 'name')
se.text = name
return ET.tostring(check)
def target_archs(self, project, repository):
archs = target_archs(self.api.apiurl, project, repository)
# Check for arch whitelist and use intersection.
if self.arch_whitelist:
archs = list(self.arch_whitelist.intersection(set(archs)))
# Trick to prioritize x86_64.
return sorted(archs, reverse=True)
def install_check(self, directories, arch, whitelist, ignored_conflicts):
self.logger.info(f"install check: start (whitelist:{','.join(whitelist)})")
parts = installcheck(directories, arch, whitelist, ignored_conflicts)
if len(parts):
header = f'### [install check & file conflicts for {arch}]'
return CheckResult(False, header + '\n\n' + ('\n' + ('-' * 80) + '\n\n').join(parts))
self.logger.info('install check: passed')
return CheckResult(True, None)
def calculate_allowed_cycles(self):
self.allowed_cycles = []
if self.cycle_packages:
for comma_list in self.cycle_packages.split(';'):
self.allowed_cycles.append(comma_list.split(','))
def cycle_check(self, project, repository, arch):
self.logger.info(f'cycle check: start {project}/{repository}/{arch}')
comment = []
depinfo = builddepinfo(self.api.apiurl, project, repository, arch, order=False)
for cycle in depinfo.findall('cycle'):
for package in cycle.findall('package'):
package = package.text
allowed = False
for acycle in self.allowed_cycles:
if package in acycle:
allowed = True
break
if not allowed:
cycled = [p.text for p in cycle.findall('package')]
comment.append(f"Package {package} appears in cycle {'/'.join(cycled)}")
if len(comment):
# New cycles, post comment.
self.logger.info('cycle check: failed')
return CheckResult(False, '\n'.join(comment) + '\n')
self.logger.info('cycle check: passed')
return CheckResult(True, None)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description='Do an installcheck on staging project')
parser.add_argument('-s', '--staging', type=str, default=None,
help='staging project')
parser.add_argument('-p', '--project', type=str, default='openSUSE:Factory',
help='project to check (ex. openSUSE:Factory, openSUSE:Leap:15.1)')
parser.add_argument('-d', '--debug', action='store_true', default=False,
help='enable debug information')
parser.add_argument('-A', '--apiurl', metavar='URL', help='API URL')
args = parser.parse_args()
osc.conf.get_config(override_apiurl=args.apiurl)
osc.conf.config['debug'] = args.debug
apiurl = osc.conf.config['apiurl']
config = Config.get(apiurl, args.project)
api = StagingAPI(apiurl, args.project)
staging_report = InstallChecker(api, config)
if args.debug:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
if args.staging:
if not staging_report.staging(api.prj_from_short(args.staging), force=True):
sys.exit(1)
else:
for staging in api.get_staging_projects():
if api.is_adi_project(staging):
staging_report.staging(staging)
sys.exit(0)