import argparse
import copy
import json
import os
import os.path
import subprocess
import sys
import tempfile
from collections import OrderedDict
DATA_FILE = './coreos-builds-bisect-data.json'
BUILDS_JSON_FILE = './builds/builds.json'
class colors:
CLEAR = '\033[0m'
CYAN = '\033[96m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
def fatal(msg):
print(colors.RED + msg + colors.CLEAR, file=sys.stderr)
sys.exit(1)
def log_color(msg, color):
print(color + msg + colors.CLEAR)
sys.stdout.flush()
def log(msg):
log_color(msg, colors.CYAN)
def log_success(msg):
log_color(msg, colors.GREEN)
def log_warn(msg):
log_color(msg, colors.YELLOW)
"""
Initialize build ID order dict. The array will be a list of
commits in descending order. Each entry will be a dict with
key of commitid and value = a dict of version, heuristic
(TESTED, GIVEN, ASSUMED), and status (GOOD/BAD/UNKNOWN)
builds = {
'39.20230813.91.0' => {
'heuristic', 'TESTED',
'status': 'GOOD',
},
}
"""
def initialize_builds_info(buildsjson, arch, badbuild, goodbuild):
with open(buildsjson, 'r') as f:
builds = json.load(f, object_pairs_hook=OrderedDict)
builds = builds['builds']
info = OrderedDict()
for build in builds:
if arch in build['arches']:
info.update({ build['id']: { 'status': 'UNKNOWN',
'heuristic': 'ASSUMED'}})
info[badbuild]['status'] = 'BAD'
info[badbuild]['heuristic'] = 'GIVEN'
info[goodbuild]['status'] = 'GOOD'
info[goodbuild]['heuristic'] = 'GIVEN'
in_range = False
for key, _ in copy.deepcopy(info).items():
if key == badbuild:
in_range = True
if not in_range:
info.pop(key)
if key == goodbuild:
in_range = False
return info
def load_data(datafile):
with open(datafile, 'r') as f:
data = json.load(f, object_pairs_hook=OrderedDict)
return data
def write_data(datafile, data):
dirname = os.path.dirname(datafile)
(_, tmpfile) = tempfile.mkstemp(
dir=dirname,
prefix="coreos-builds-bisect")
with open(tmpfile, 'w') as f:
json.dump(data, f, indent=4)
os.rename(tmpfile, datafile)
def verify_script(testscript):
if not testscript:
fatal("Must provide a --testscript to run")
if not (os.path.isfile(testscript)
and os.access(testscript, os.X_OK)):
fatal(f"provided test script: {testscript} is not an executable file")
def bisect(args):
badbuild = args.badbuild
goodbuild = args.goodbuild
testscript = args.testscript
datafile = args.datafile
builds_info = load_data(datafile)
while True:
unknowns = []
lastbad = None
firstgood = None
for buildid in builds_info.keys():
status = builds_info[buildid]['status']
if status == 'INCONCLUSIVE':
pass
if status == 'BAD':
lastbad = buildid
elif status == 'UNKNOWN':
unknowns.append(buildid)
elif status == 'GOOD':
firstgood = buildid
break
if len(unknowns) == 0:
log("BISECT TEST RESULTS:")
if firstgood is None:
log("No good builds were found in the history!")
return 0
if builds_info[firstgood]['heuristic'] == 'GIVEN':
log_warn("WARNING: The good build detected was the one given by the user.")
log_warn("WARNING: Are you sure this build is good?")
log(f"Last known good build: {firstgood}")
log(f"First known bad build: {lastbad}")
return 0
newbuildid = unknowns[len(unknowns)//2]
revisions = len(unknowns)
steps = 0
while revisions > 0:
revisions //= 2
steps += 1
log(f"Executing test for new build: {newbuildid}. ~{steps} steps left")
ec = subprocess.call([testscript, newbuildid])
if ec == 99:
log_warn(f"{newbuildid} results were inconclusive")
result = 'inconclusive'
elif ec == 0:
log_success(f"{newbuildid} passed")
result = 'pass'
else:
log_warn(f"{newbuildid} failed")
result = 'fail'
builds_info[newbuildid]['heuristic'] = 'TESTED'
if result == 'inconclusive':
builds_info[newbuildid]['status'] = 'INCONCLUSIVE'
elif result == 'pass':
for b in reversed(builds_info.keys()):
if builds_info[b]['status'] == 'UNKNOWN':
builds_info[b]['status'] = 'GOOD'
if b == newbuildid:
break
else:
for b in builds_info.keys():
if builds_info[b]['status'] == 'UNKNOWN':
builds_info[b]['status'] = 'BAD'
if b == newbuildid:
break
write_data(datafile, builds_info)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--arch", dest='arch',
help="What architecture to target", action='store')
parser.add_argument("--buildsjson", dest='buildsjson',
help="Path to builds.json file",
action='store', default=BUILDS_JSON_FILE)
parser.add_argument("--bad", dest='badbuild',
help="Known Bad Build", action='store')
parser.add_argument("--good", dest='goodbuild',
help="Known Good Build", action='store')
parser.add_argument("--testscript", help="A test script to run",
action='store')
parser.add_argument("--resume", help="Resume a running bisection",
action='store_true')
parser.add_argument("--datafile", help="data file to use for state",
action='store', default=DATA_FILE)
args = parser.parse_args()
log(f"Using data file at: {args.datafile}")
verify_script(args.testscript)
if not args.arch:
cp = subprocess.run(['arch'], capture_output=True)
args.arch = cp.stdout.decode('utf-8').strip()
log(f"Targetting architecture: {args.arch}")
if args.resume:
if not os.path.exists(args.datafile):
fatal(f"Datafile at {args.datafile} must pre-exist with --resume")
else:
if not args.badbuild or not args.goodbuild:
fatal("Must specify both a bad and a good build via --bad and --good")
if not os.path.exists(args.buildsjson):
fatal(f"A builds.json file does not exist at {args.buildsjson}")
if os.path.exists(args.datafile) and not args.resume:
log(f"A datafile exists at {args.datafile} but --resume not specified")
log("If you want to resume a bisect pass --resume")
fatal("If you want to start a new bisect delete the datafile")
builds_info = initialize_builds_info(args.buildsjson,
args.arch,
args.badbuild,
args.goodbuild)
write_data(args.datafile, builds_info)
bisect(args)
if __name__ == '__main__':
main()