76
87
job_info = jenkins.get_job_info(job_name)
77
88
config = get_cid_config(job_info)
78
89
if job_name != BUILD_REVISION and config is None:
79
logging.info('Skipping {}; no ci-director config'.format(
90
if get_cid_config(job_info, cloud_health=True) is None:
91
logging.info('Skipping {}; no ci-director config'.format(
82
94
builds = sorted(b['number'] for b in job_info['builds'])
83
95
builds = [b for b in builds if b > job_last]
84
96
yield job_info, builds
87
def iter_build_data(jenkins, job_name, builds):
88
"""Iterate through the build data for a job.
90
jenkins is a jenkins.Jenkins instance. job_name is the name of the job.
91
builds is a list of build numbers.
93
Yield pairs of (revision_build, build_info) for each build.
95
Handle "build-revision" specially; it is its own revision-build. Skip any
96
other builds without an explicit build-revision.
99
def iter_build_info(jenkins, job_name, builds):
98
100
for build in builds:
99
101
logging.info('Scanning {} #{}'.format(job_name, build))
100
build_info = jenkins.get_build_info(job_name, build)
101
if job_name == BUILD_REVISION:
102
revision_build = build_info['number']
104
build_params = get_build_parameters(build_info)
105
revision_build = build_params.get('revision_build', '')
107
revision_build = int(revision_build)
110
yield revision_build, build_info
102
yield jenkins.get_build_info(job_name, build)
105
def get_revision_build(job_name, build_info):
106
if job_name == BUILD_REVISION:
107
return build_info['number']
109
build_params = get_build_parameters(build_info)
110
revision_build = build_params.get('revision_build', '')
112
return int(revision_build)
117
def update_job_configs(jobs_by_name, builds_by_key):
118
# Ensure config data is provided even for jobs with no builds.
119
active_jobs = set(n for n, j in jobs_by_name.items()
120
if j['color'] != 'disabled')
123
for job_name, job_info in jobs_by_name.items():
124
if job_info['color'] != 'disabled':
125
active_jobs.add(job_name)
126
config = get_cid_config(job_info, cloud_health=True)
128
config = get_cid_config(job_info, cloud_health=False)
129
configs[job_name] = config
131
for key_jobs in builds_by_key.values():
132
# Include now-disabled jobs that have builds.
133
for job_name in active_jobs.union(key_jobs.keys()):
134
rb_job = key_jobs.setdefault(job_name, {'builds': {}})
135
rb_job['config'] = configs[job_name]
113
138
def scan_new_builds(last_completed, jenkins):
129
154
the current Jenkins state.
131
156
by_revision_build = {}
132
158
new_last_completed = {}
159
revision_build_jobs = {}
134
161
for job_info, builds in iter_job_builds(jenkins, last_completed):
135
all_job_info.append(job_info)
162
is_cloud = bool(get_cid_config(job_info, cloud_health=True) is not
136
164
job_name = job_info['name']
166
cloud_jobs[job_name] = job_info
168
revision_build_jobs[job_name] = job_info
137
169
last_completed_build = job_info['lastCompletedBuild']
138
170
if last_completed_build is not None:
139
171
new_last_completed[job_name] = last_completed_build['number']
140
build_data = iter_build_data(jenkins, job_name, builds)
141
for revision_build, build_info in build_data:
142
by_job = by_revision_build.setdefault(revision_build, {})
173
build_iter = iter_build_info(jenkins, job_name, builds)
174
for build_info in build_iter:
176
date = datetime_from_timestamp(build_info['timestamp'])
177
date_str = date.date().isoformat()
178
by_job = cloud_health.setdefault(date_str, {})
180
revision_build = get_revision_build(job_name, build_info)
181
if revision_build is None:
183
by_job = by_revision_build.setdefault(revision_build, {})
143
184
by_build_number = by_job.setdefault(job_name, {})
144
185
builds = by_build_number.setdefault('builds', {})
145
186
builds[str(build_info['number'])] = build_info
146
# Ensure config data is provided even for jobs with no builds.
147
for job_info in all_job_info:
148
job_name = job_info['name']
149
config = get_cid_config(job_info)
150
for rb_jobs in by_revision_build.values():
151
if job_name not in rb_jobs and not job_info['color'] != 'disabled':
153
rb_job = rb_jobs.setdefault(job_name, {'builds': {}})
154
rb_job['config'] = config
155
return by_revision_build, new_last_completed
187
update_job_configs(revision_build_jobs, by_revision_build)
188
update_job_configs(cloud_jobs, cloud_health)
189
return by_revision_build, cloud_health, new_last_completed
158
192
def json_dump_pretty(content, json_file):
238
272
"""JobSource for use with BaseResultJudge."""
241
def from_build_data(cls, build_data):
275
def from_build_data(cls, build_data, last_build_sickly):
242
276
"""Create OutcomeJobSource from build data.
244
278
build_data is formatted as emitted by
245
279
OutcomeState.update_revision_builds.
247
jobs = [OutcomeJob.from_json(j, b) for j, b in build_data.items()]
281
jobs = [OutcomeJob.from_json(j, b, j not in last_build_sickly)
282
for j, b in build_data.items()]
248
283
for job in topo_sort(jobs):
249
284
job.blocked = find_blockers(job, jobs)
260
295
"""Raised when attempting to write to a directory that is not locked."""
300
def __init__(self, outcome_state, filename_template):
301
self.outcome_state = outcome_state
302
self.filename_template = filename_template
305
def for_revision_builds(cls, state):
306
return BuildFiles(state, 'revision-build-{}.json')
309
def for_cloud_health(cls, state):
310
return BuildFiles(state, 'cloud-health-{}.json')
312
def filename(self, file_key):
313
return self.filename_template.format(file_key)
316
def merge_revision_build_data(recorded_builds, new_builds):
317
"""Merge data from new_builds into recorded_builds.
319
The job config data is updated only if new_builds has builds for that
322
for job, job_dict in new_builds.items():
323
old_job_dict = recorded_builds.setdefault(
325
old_job_dict['builds'].update(job_dict['builds'])
326
if len(job_dict['builds']) > 0:
327
if 'config' not in job_dict:
328
print('No config'.format(job_dict))
330
old_job_dict['config'] = job_dict['config']
332
def load_json(self, file_key):
333
filename = self.filename(file_key)
334
return self.outcome_state.load_json(filename)
336
def update_files(self, build_data):
337
for revision_build, new_builds in build_data.items():
338
recorded_builds = self.load_json(revision_build)
339
if recorded_builds is None:
340
recorded_builds = new_builds
342
self.merge_revision_build_data(recorded_builds, new_builds)
343
filename = self.filename(revision_build)
344
self.outcome_state.write_json(filename, recorded_builds)
345
yield revision_build, recorded_builds
348
def load_cloud_health(cloud_health_builds, build_days):
350
for day in build_days:
351
file_key = day.isoformat()
352
result[file_key] = cloud_health_builds.load_json(file_key)
356
def get_substrate(config):
357
parsed_tags = parse_tags(config['tags'])
358
return get_substrate_from_parsed(parsed_tags)
361
def find_cloud_health_builds(cloud_health_builds, substrate, start, end):
362
for jobs in cloud_health_builds.values():
363
for job in jobs.values():
364
if get_substrate(job['config']) != substrate:
366
for buildnum, build_info in job['builds'].items():
367
duration = build_info.get('duration')
368
# A build with no duration has no result, so it's not
369
# interesting anyhow.
372
build_start = datetime_from_timestamp(build_info['timestamp'])
373
build_end = datetime_from_timestamp(
374
build_info['timestamp'] + duration)
375
if start > build_end:
377
if end < build_start:
382
def get_health_match_data(by_revision_build):
383
"""Return data on the last build for matching cloud health.
385
Provides a dict with keys 'substrate', 'start', 'end'. 'start' and 'end'
386
are extended by an hour to include cloud health builds that ended at most
387
an hour before the revision build or started at most an hour after the
390
Provides the days which may contain relevant cloud health as a set of
391
datetime.date objects.
395
for revision_build, jobs in by_revision_build.items():
397
for job_name, job in jobs.items():
398
if len(job['builds']) == 0:
400
if job['config'] is None:
403
job['builds'].values(), key=lambda b: b['number'])[-1]
404
duration = last_build.get('duration')
407
start = datetime_from_timestamp(last_build['timestamp'])
408
end = start + timedelta(milliseconds=duration)
409
start -= timedelta(hours=1)
410
lb_days.add(start.date())
411
lb_days.add(end.date())
412
rb_data[job_name] = {
413
'substrate': get_substrate(job['config']),
417
lb_data[revision_build] = rb_data
418
return lb_data, lb_days
421
def find_last_build_sickly(health_match_data, cloud_health_build_dict):
422
last_build_sickly = {}
423
for revision_build, jobs in health_match_data.items():
425
for job, lb_info in jobs.items():
426
ch_builds = find_cloud_health_builds(
427
cloud_health_build_dict, lb_info['substrate'],
428
lb_info['start'], lb_info['end'])
429
results = set(ch_build.get('result') for ch_build in ch_builds)
430
results.discard(SUCCESS)
433
last_build_sickly[revision_build] = sickly_jobs
434
return last_build_sickly
263
437
class OutcomeState:
264
438
"""Maintain information about job outcomes."""
299
473
self.last_completed_file = None
300
474
self._lock_cxt.__exit__(exc_type, exc_val, exc_tb)
302
def write_json(self, filename, content):
476
def load_json(self, relative_path):
478
with open(self.outcome_filename(relative_path)) as build_file:
479
return json.load(build_file)
481
if e.errno != errno.ENOENT:
485
def write_json(self, relative_path, content, make_dir=False):
303
486
"""Write a json file relative to the outcome_dir."""
304
487
if self.last_completed_file is None:
305
488
raise NotLocked('Write with no lock')
489
filename = self.outcome_filename(relative_path)
491
parent = os.path.dirname(filename)
306
493
with open(filename, 'w') as json_file:
307
494
json_dump_pretty(content, json_file)
309
def revision_build_filename(self, revision_build):
310
return self.outcome_filename(
311
'revision-build-{}.json'.format(revision_build))
314
def merge_revision_build_data(recorded_builds, new_builds):
315
"""Merge data from new_builds into recorded_builds.
317
The job config data is updated only if new_builds has builds for that
320
for job, job_dict in new_builds.items():
321
old_job_dict = recorded_builds.setdefault(
323
old_job_dict['builds'].update(job_dict['builds'])
324
if len(job_dict['builds']) > 0:
325
old_job_dict['config'] = job_dict['config']
327
def update_revision_builds(self, by_revision_build):
328
"""Use the supplied data to update information about revision builds
330
New data about known builds will override existing data, but old
331
builds will otherwise be preserved.
333
for revision_build, new_builds in by_revision_build.items():
334
filename = self.revision_build_filename(revision_build)
336
with open(filename) as json_file:
337
recorded_builds = json.load(json_file)
339
if e.errno != errno.ENOENT:
341
recorded_builds = new_builds
343
self.merge_revision_build_data(recorded_builds, new_builds)
344
self.write_json(filename, recorded_builds)
345
yield revision_build, recorded_builds
347
496
def mongo_filename(self, revision_build):
348
return os.path.join(self.outcome_filename(
349
'version-{}'.format(revision_build)), 'result.json')
498
'version-{}'.format(revision_build), 'result.json')
352
def make_mongo_data(revision_build, build_data):
501
def make_mongo_data(revision_build, build_data, last_build_sickly):
353
502
"""Make data formatted for juju-reports' mongodb.
355
504
revision_build is the revision build number.
436
585
outcome_dir = os.path.join(os.environ['HOME'],
437
586
'.config/ci-director-outcome')
438
587
with OutcomeState(outcome_dir) as state:
439
by_revision_build, new_last_completed = scan_new_builds(
588
by_revision_build, cloud_health, new_last_completed = scan_new_builds(
440
589
state.data, jenkins)
441
for revision_build, build_data in state.update_revision_builds(
443
state.write_mongo_data(revision_build, build_data)
590
cloud_health_builds = BuildFiles.for_cloud_health(state)
591
list(cloud_health_builds.update_files(cloud_health))
592
revision_build_files = BuildFiles.for_revision_builds(state)
593
by_all_revision_build = dict(
594
revision_build_files.update_files(by_revision_build))
595
health_match_data, build_days = get_health_match_data(
596
by_all_revision_build)
597
cloud_health_build_dict = load_cloud_health(cloud_health_builds,
599
last_build_sickly = find_last_build_sickly(health_match_data,
600
cloud_health_build_dict)
601
for revision_build, build_data in by_all_revision_build.items():
602
state.write_mongo_data(revision_build, build_data,
603
last_build_sickly[revision_build])
444
604
state.data.update(new_last_completed)