blob: 8ade2db05b8bd484cb76d006fb6c458d6616315e [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2021 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# pylint: disable=missing-function-docstring
"""Chrome OS kernel configuration tool
Script to merge all configs and run 'make oldconfig' on it to wade out bad
juju, then split the configs into distro-commmon and flavour-specific parts
See this page for more details:
http://dev.chromium.org/chromium-os/how-tos-and-troubleshooting/kernel-configuration
"""
import argparse
import difflib
import fnmatch
import glob
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
from typing import Dict, List
MODES = ['oldconfig', 'olddefconfig', 'editconfig', 'genconfig', 'checkconfig']
TOOLCHAIN_PREFIXES = {
'x86_64': 'x86_64-cros-linux-gnu',
'armel': 'armv7a-cros-linux-gnueabihf',
'arm64': 'aarch64-cros-linux-gnu',
}
def splitconfig(srcdir: str) -> Dict[str, str]:
"""Split and merge common configs
Return dict of filename-to-contents.
"""
allconfigs = {}
# Parse config files.
for config in os.listdir(srcdir):
# Only config.*
if not config.endswith('.config'):
continue
allconfigs[config] = set()
for line in open(os.path.join(srcdir, config)):
m = re.match(r'#*\s*CONFIG_(\w+)[\s=](.*)$', line)
if not m:
continue
option, value = m.groups()
allconfigs[config].add((option, value))
# Split out common config options.
common = None
for config in allconfigs:
if common is None:
common = allconfigs[config].copy()
else:
common &= allconfigs[config]
for config in allconfigs:
allconfigs[config] -= common
allconfigs['common.config'] = common
ret = {}
# Generate new splitconfigs.
for config in allconfigs:
if allconfigs[config] is None:
continue
contents = '#\n# Config options generated by splitconfig\n#\n'
for option, value in sorted(list(allconfigs[config])):
if value == 'is not set':
contents += '# CONFIG_%s %s\n' % (option, value)
else:
contents += 'CONFIG_%s=%s\n' % (option, value)
ret[config] = contents
return ret
def die(msg: str = 'Aborting'):
logging.error(msg)
sys.exit(1)
def in_chroot() -> bool:
return os.path.exists('/etc/cros_chroot_version')
def rerun_in_chroot():
cmd = ['cros_sdk', '--working-dir', '.', '--',
os.path.relpath(sys.argv[0])] + sys.argv[1:]
logging.info('Re-running in chroot')
logging.debug('Run: %s', cmd)
sys.exit(subprocess.run(cmd, check=False).returncode)
def make_toolchain_args(arch: str) -> Dict[str, str]:
ret = {
'LD': 'ld.lld',
}
if in_chroot():
prefix = TOOLCHAIN_PREFIXES[arch]
ccompiler = prefix + '-clang'
# CrOS build tooling wants us to use these, and avoid a default of
# unprefixed 'gcc' or 'clang'. Supply defaults here, but also consider
# environment varibles below.
ret['HOSTCC'] = 'x86_64-pc-linux-gnu-clang'
ret['HOSTCXX'] = 'x86_64-pc-linux-gnu-clang++'
else:
prefix = 'x86_64-linux-gnu'
ccompiler = prefix + '-gcc'
if not shutil.which(ccompiler):
if in_chroot():
die('%s not found. Try running "`sudo cros_setup_toolchains -t %s`"'
% (ccompiler, prefix))
else:
die('%s not found. Try running inside chroot.' % ccompiler)
# Propagate a few environment variables as make args.
for key in ('HOSTCC', 'HOSTCXX'):
val = os.getenv(key)
if val:
ret[key] = val
ret.update({
'CC': ccompiler,
'CXX': prefix + '-g++',
'CROSS_COMPILE': prefix + '-',
})
return ret
def getchar() -> str:
# We don't want line-buffering, and shelling out to bash is much simpler
# than messing with tty settings.
return subprocess.check_output(
['bash', '-c', 'read -n 1 -s; echo "${REPLY}"']
).decode('utf-8').strip()
def editconfig_prompt(variant: str) -> bool:
print('* %s: press <Enter> to edit, S to skip' % variant)
return getchar() not in ('S', 's')
def filter_match(target: str, pattern: str) -> bool:
return fnmatch.fnmatch(target, '*' + pattern + '*')
def is_editconfig_interactive(arch_flavour: str, prompt: bool,
pattern: str) -> bool:
if filter_match(arch_flavour, pattern):
if not prompt:
return True
return editconfig_prompt(arch_flavour)
return False
def make_cmd(builddir: str, arch: str, target: str) -> List[str]:
pairs = make_toolchain_args(arch)
pairs['ARCH'] = 'arm' if arch == 'armel' else arch
pairs['O'] = builddir
make_args = [k + '=' + v for k, v in pairs.items()]
return ['make', '-j'] + make_args + [target, 'savedefconfig']
def checkconfig(srcdir: str, outdir: str) -> int:
error = 0
for src_config in glob.glob(srcdir + '/chromeos/config/**/*.config',
recursive=True):
relative = os.path.relpath(src_config, srcdir)
out_config = os.path.join(outdir, relative)
logging.debug('Compare %s to %s', src_config, out_config)
before = open(src_config, 'r').readlines()
after = open(out_config, 'r').readlines()
if before == after:
continue
error = 1
diff = difflib.context_diff(before, after, fromfile='a/' + relative,
tofile='b/' + relative)
sys.stdout.writelines(diff)
logging.error('checkconfig failed for config %s', relative)
if error:
logging.error("""Consider running `%s olddefconfig` to normalize.
NOTE: chromeos/config changes should always be in a CL by themselves and never
squashed into the same patch as code changes. If code and config changes need
to land together, consider using Cq-Depend to make a circular dependency.""",
sys.argv[0])
else:
logging.info('All good!')
return error
def build_one_arch(args, tmpdir: str, srcdir: str, family: str,
arch: str) -> List[subprocess.Popen]:
procs = []
config_base_dir = srcdir + '/chromeos/config/' + family
config_arch_dir = config_base_dir + '/' + arch
for flavourconfig in glob.glob(os.path.join(srcdir, config_arch_dir,
'*.flavour.config')):
flavour = os.path.basename(flavourconfig)
builddir = os.path.join(tmpdir, 'build', family, arch, flavour)
os.makedirs(builddir)
# Merge base.config, common.config, <flavour>.flavour.config
conf = '\n'.join(open(os.path.join(srcdir, f)).read() for f in [
config_base_dir + '/base.config',
config_arch_dir + '/common.config',
config_arch_dir + '/' + flavour,
])
open(os.path.join(builddir, '.config'), 'w').write(conf)
interactive = False
if args.mode == 'genconfig':
mode = 'olddefconfig'
elif args.mode == 'editconfig':
interactive = is_editconfig_interactive(
family + '/' + arch + '/' + flavour,
not args.yes, args.filter)
mode = 'menuconfig' if interactive else 'olddefconfig'
elif args.mode == 'checkconfig':
mode = 'olddefconfig'
else:
if args.mode == 'oldconfig':
# 'oldconfig' may run into interactive prompts.
interactive = True
mode = args.mode
cmd = make_cmd(builddir, arch, mode)
if interactive:
logging.info('Starting interactive cmd: %s', ' '.join(cmd))
ret = subprocess.run(cmd, check=False, cwd=srcdir)
if ret.returncode:
die('cmd "%s" failed' % ' '.join(cmd))
else:
logging.info('Starting background cmd: %s', ' '.join(cmd))
procs += [subprocess.Popen(cmd, cwd=srcdir,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)]
return procs
def do_splitconfig(tmpdir: str, srcdir: str, outdir: str,
family: str, arches: List[str], save_configs: bool):
family_cfg_dir = 'chromeos/config/' + family
if save_configs:
keep_dir = 'CONFIGS/' + family
os.makedirs(keep_dir, exist_ok=True)
for arch in arches:
dest = os.path.join(tmpdir, family, arch)
family_arch_dir = family_cfg_dir + '/' + arch
os.makedirs(dest)
for flavourconfig in glob.glob(os.path.join(srcdir, family_arch_dir,
'*.flavour.config')):
flavour = os.path.basename(flavourconfig)
builddir = os.path.join(tmpdir, 'build', family, arch, flavour)
shutil.copy(os.path.join(builddir, 'defconfig'),
os.path.join(dest, flavour))
if save_configs:
shutil.copy(os.path.join(builddir, '.config'),
os.path.join(keep_dir, arch + '-' + flavour))
shutil.copy(os.path.join(builddir, 'defconfig'),
os.path.join(keep_dir, arch + '-' +
flavour + '.def'))
os.makedirs(os.path.join(outdir, family_arch_dir), exist_ok=True)
# Find per-arch common items; flavour-unique options can be written out
# immediately.
for config, contents in splitconfig(dest).items():
if config == 'common.config':
open(os.path.join(tmpdir, family, arch + '.config'),
'w').write(contents)
else:
open(os.path.join(outdir, family_arch_dir, config),
'w').write(contents)
# Find cross-arch common items; common ones go to base.config, and unique
# ones to <arch>/common.config.
for config, contents in splitconfig(tmpdir + '/' + family).items():
if config == 'common.config':
open(os.path.join(outdir, family_cfg_dir, 'base.config'),
'w').write(contents)
else:
assert config.endswith('.config')
# Python3.9: arch = config.removesuffix('.config')
arch = config[:-len('.config')]
open(os.path.join(outdir, family_cfg_dir, arch, 'common.config'),
'w').write(contents)
# Arches relevant to this family.
def family_arches(family: str) -> List[str]:
return [os.path.basename(p)
for p in glob.glob('chromeos/config/' + family + '/*')
if os.path.isdir(p)]
def doit(args, tmpdir: str) -> int:
for d in (os.getcwd(),
os.path.abspath(os.path.join(
os.path.dirname(sys.argv[0]), '..', '..'))):
if (os.path.exists(os.path.join(d, 'MAINTAINERS')) and
os.path.exists(os.path.join(d, 'Makefile'))):
srcdir = d
logging.info('Using top kernel dir: %s', srcdir)
break
else:
die('This does not appear to be a kernel source directory.')
if not in_chroot() and not args.force:
rerun_in_chroot()
assert False # Should not reach this.
# Most commands write back to source directory.
if args.mode == 'checkconfig':
outdir = tmpdir
else:
outdir = srcdir
save_configs = args.mode == 'genconfig'
procs = []
families = [os.path.basename(p)
for p in glob.glob('chromeos/config/*')
if os.path.isdir(p)]
for family in families:
for arch in family_arches(family):
if not arch in TOOLCHAIN_PREFIXES:
die('Unexpected arch: %s' % arch)
procs += build_one_arch(args, tmpdir, srcdir, family, arch)
for p in procs:
ret = p.wait()
if ret:
assert p.stderr # Remind pytype we captured stderr.
logging.error(p.stderr.read().decode('utf-8'))
die('cmd "%s" failed' % ' '.join(p.args))
logging.info('Generating splitconfigs')
for family in families:
do_splitconfig(tmpdir, srcdir, outdir,
family, family_arches(family), save_configs)
if args.mode == 'checkconfig':
return checkconfig(srcdir, outdir)
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description='Chrome OS kernel configuration script',
epilog="""
Note that Kbuild will evaluate some features depending on the
toolchain, so we try to enter the SDK chroot. This can be
overridden, with a potentially degraded experience.
""")
log_level_choices = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
parser.add_argument(
'--log_level', '-l',
choices=log_level_choices,
default='INFO'
)
parser.add_argument('--force', '-F', action='store_true',
help='Force allowing to run outside the chroot')
parser.add_argument('--filter', '-f', default='',
help='Only attempt to edit configs which match filter')
parser.add_argument('--yes', '-y', action='store_true',
help='Edit all configs which match unconditionally')
parser.add_argument('mode', choices=MODES, help='sub-command/mode')
args = parser.parse_args()
logging.basicConfig(level=args.log_level,
format='%(levelname)s - %(message)s')
with tempfile.TemporaryDirectory() as tmpdir:
return doit(args, tmpdir)
sys.exit(main())