Port cros_choose_profile to a chromite python script.
BUG=chromium:874986
TEST=manual tests, new unit tests
CQ-DEPEND=CL:1184973
Change-Id: Id0a014f659f3d9b9393e158adb0378b0b8df8dfc
Reviewed-on: https://chromium-review.googlesource.com/1184972
Commit-Ready: Alex Klein <saklein@chromium.org>
Tested-by: Alex Klein <saklein@chromium.org>
Reviewed-by: Mike Frysinger <vapier@chromium.org>
diff --git a/bin/cros_choose_profile b/bin/cros_choose_profile
new file mode 120000
index 0000000..72196ce
--- /dev/null
+++ b/bin/cros_choose_profile
@@ -0,0 +1 @@
+../scripts/wrapper.py
\ No newline at end of file
diff --git a/scripts/cros_choose_profile.py b/scripts/cros_choose_profile.py
new file mode 100644
index 0000000..d9b2871
--- /dev/null
+++ b/scripts/cros_choose_profile.py
@@ -0,0 +1,312 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018 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.
+
+"""Choose the profile for a board that has been or is being setup."""
+
+from __future__ import print_function
+
+import functools
+import os
+
+from chromite.lib import commandline
+from chromite.lib import cros_build_lib
+from chromite.lib import cros_logging as logging
+from chromite.lib import osutils
+from chromite.lib import sysroot_lib
+
+
+# Default value constants.
+_DEFAULT_PROFILE = 'base'
+
+
+def PathPrefixDecorator(f):
+ """Add a prefix to the path or paths returned by the decorated function.
+
+ Will not prepend the prefix if the path already starts with the prefix, so the
+ decorator may be applied to functions that have mixed sources that may
+ or may not already have applied them. This is especially useful for allowing
+ tests and CLI args a little more leniency in how paths are provided.
+ """
+ @functools.wraps(f)
+ def wrapper(*args, **kwargs):
+ result = f(*args, **kwargs)
+ prefix = PathPrefixDecorator.prefix
+
+ if not prefix or not result:
+ # Nothing to do.
+ return result
+ elif not isinstance(result, basestring):
+ # Transform each path in the collection.
+ new_result = []
+ for path in result:
+ prefixed_path = os.path.join(prefix, path.lstrip(os.sep))
+ new_result.append(path if path.startswith(prefix) else prefixed_path)
+
+ return new_result
+ elif not result.startswith(prefix):
+ # Add the prefix.
+ return os.path.join(prefix, result.lstrip(os.sep))
+
+ # An already prefixed path.
+ return result
+
+ return wrapper
+
+PathPrefixDecorator.prefix = None
+
+
+class Error(Exception):
+ """Base error for custom exceptions in this script."""
+
+
+class InvalidArgumentsError(Error):
+ """Invalid arguments."""
+
+
+class MakeProfileIsNotLinkError(Error):
+ """The make profile exists but is not a link."""
+
+
+class ProfileDirectoryNotFoundError(Error):
+ """Unable to find the profile directory."""
+
+
+def ChooseProfile(board, profile):
+ """Make the link to choose the profile, print relevant warnings.
+
+ Args:
+ board: Board - the board being used.
+ profile: Profile - the profile being used.
+
+ Raises:
+ OSError when the board's make_profile path exists and is not a link.
+ """
+ if not os.path.isfile(os.path.join(profile.directory, 'parent')):
+ logging.warning("Portage profile directory %s has no 'parent' file. "
+ 'This likely means your profile directory is invalid and '
+ 'build_packages will fail.', profile.directory)
+
+ current_profile = None
+ if os.path.exists(board.make_profile):
+ # Only try to read if it exists; we only want it to raise an error when the
+ # path exists and is not a link.
+ try:
+ current_profile = os.readlink(board.make_profile)
+ except OSError:
+ raise MakeProfileIsNotLinkError('%s is not a link.' % board.make_profile)
+
+ if current_profile == profile.directory:
+ # The existing link is what we were going to make, so nothing to do.
+ return
+ elif current_profile is not None:
+ # It exists and is changing, emit warning.
+ fmt = {'board': board.board_variant, 'profile': profile.name}
+ msg = ('You are switching profiles for a board that is already setup. This '
+ 'can cause trouble for Portage. If you experience problems with '
+ 'build_packages you may need to run:\n'
+ "\t'setup_board --board %(board)s --force --profile %(profile)s'\n"
+ '\nAlternatively, you can correct the dependency graph by using '
+ "'emerge-%(board)s -c' or 'emerge-%(board)s -C <ebuild>'.")
+ logging.warning(msg, fmt)
+
+ # Make the symlink, overwrites existing link if one already exists.
+ osutils.SafeSymlink(profile.directory, board.make_profile, sudo=True)
+
+ # Update the profile override value.
+ if profile.override:
+ board.profile_override = profile.override
+
+
+class Profile(object):
+ """Simple data container class for the profile data."""
+ def __init__(self, name, directory, override):
+ self.name = name
+ self._directory = directory
+ self.override = override
+
+ @property
+ @PathPrefixDecorator
+ def directory(self):
+ return self._directory
+
+
+def _GetProfile(opts, board):
+ """Get the profile list."""
+ # Determine the override value - which profile is being selected.
+ override = opts.profile if opts.profile else board.profile_override
+
+ profile = _DEFAULT_PROFILE
+ profile_directory = None
+
+ if override and os.path.exists(override):
+ profile_directory = os.path.abspath(override)
+ profile = os.path.basename(profile_directory)
+ elif override:
+ profile = override
+
+ if profile_directory is None:
+ # Build profile directories in reverse order so we can search from most to
+ # least specific.
+ profile_dirs = ['%s/profiles/%s' % (overlay, profile) for overlay in
+ reversed(board.overlays)]
+
+ for profile_dir in profile_dirs:
+ if os.path.isdir(profile_dir):
+ profile_directory = profile_dir
+ break
+ else:
+ searched = ', '.join(profile_dirs)
+ raise ProfileDirectoryNotFoundError(
+ 'Profile directory not found, searched in (%s).' % searched)
+
+ return Profile(profile, profile_directory, override)
+
+
+class Board(object):
+ """Manage the board arguments and configs."""
+
+ # Files located on the board.
+ MAKE_PROFILE = '%(board_root)s/etc/portage/make.profile'
+
+ def __init__(self, board=None, variant=None, board_root=None):
+ """Board constructor.
+
+ board [+ variant] is given preference when both board and board_root are
+ provided.
+
+ Preconditions:
+ Either board and build_root are not None, or board_root is not None.
+ With board + build_root [+ variant] we can construct the board root.
+ With the board root we can have the board[_variant] directory.
+
+ Args:
+ board: str|None - The board name.
+ variant: str|None - The variant name.
+ board_root: str|None - The boards fully qualified build directory path.
+ """
+ if not board and not board_root:
+ # Enforce preconditions.
+ raise InvalidArgumentsError('Either board or board_root must be '
+ 'provided.')
+ elif board:
+ # The board and variant can be specified separately, or can both be
+ # contained in the board name, separated by an underscore.
+ board_split = board.split('_')
+ variant_default = variant
+
+ self._board_root = None
+ else:
+ self._board_root = os.path.normpath(board_root)
+
+ board_split = os.path.basename(self._board_root).split('_')
+ variant_default = None
+
+ self.board = board_split.pop(0)
+ self.variant = board_split.pop(0) if board_split else variant_default
+
+ if self.variant:
+ self.board_variant = '%s_%s' % (self.board, self.variant)
+ else:
+ self.board_variant = self.board
+
+ self.make_profile = self.MAKE_PROFILE % {'board_root': self.root}
+ # This must come after the arguments required to build each variant of the
+ # build root have been processed.
+ self._sysroot_config = sysroot_lib.Sysroot(self.root)
+
+ @property
+ @PathPrefixDecorator
+ def root(self):
+ if self._board_root:
+ return self._board_root
+
+ return os.path.join(cros_build_lib.GetSysroot(self.board_variant))
+
+ @property
+ @PathPrefixDecorator
+ def overlays(self):
+ return self._sysroot_config.GetStandardField(
+ sysroot_lib.STANDARD_FIELD_BOARD_OVERLAY).split()
+
+ @property
+ def profile_override(self):
+ return self._sysroot_config.GetCachedField('PROFILE_OVERRIDE')
+
+ @profile_override.setter
+ def profile_override(self, value):
+ self._sysroot_config.SetCachedField('PROFILE_OVERRIDE', value)
+
+
+def _GetBoard(opts):
+ """Factory method to build a Board from the parsed CLI arguments."""
+ return Board(board=opts.board, variant=opts.variant,
+ board_root=opts.board_root)
+
+
+def GetParser():
+ """ArgumentParser builder and argument definitions."""
+ parser = commandline.ArgumentParser(description=__doc__)
+ parser.add_argument('-b', '--board',
+ default=os.environ.get('DEFAULT_BOARD'),
+ help='The name of the board to set up.')
+ # TODO(saklein): Remove the --board_root option after setup_board has been
+ # updated.
+ parser.add_argument('-r', '--board-root', '--board_root',
+ type='path',
+ help='Board root where the profile should be created.')
+ parser.add_argument('-p', '--profile',
+ help='The portage configuration profile to use.')
+ parser.add_argument('-v', '--variant', help='Board variant.')
+
+ group = parser.add_argument_group('Advanced options')
+ group.add_argument('--filesystem-prefix',
+ type='path',
+ help='Force filesystem accesses to be prefixed by the '
+ 'given path.')
+ return parser
+
+
+def ParseArgs(argv):
+ """Parse and validate the arguments."""
+ parser = GetParser()
+ opts = parser.parse_args(argv)
+
+ # See Board.__init__ Preconditions.
+ board_valid = opts.board is not None
+ board_root_valid = opts.board_root and os.path.exists(opts.board_root)
+
+ if not board_valid and not board_root_valid:
+ parser.error('Either board or board_root must be provided.')
+
+ PathPrefixDecorator.prefix = opts.filesystem_prefix
+ del opts.filesystem_prefix
+
+ opts.Freeze()
+ return opts
+
+
+def main(argv):
+ # Parse arguments.
+ opts = ParseArgs(argv)
+
+ # Build and validate the board and profile.
+ board = _GetBoard(opts)
+
+ if not os.path.exists(board.root):
+ cros_build_lib.Die('The board has not been setup, please run setup_board '
+ 'first.')
+
+ try:
+ profile = _GetProfile(opts, board)
+ except ProfileDirectoryNotFoundError as e:
+ cros_build_lib.Die(e.message)
+
+ # Change the profile to the selected.
+ logging.info('Selecting profile: %s for %s', profile.directory, board.root)
+
+ try:
+ ChooseProfile(board, profile)
+ except MakeProfileIsNotLinkError as e:
+ cros_build_lib.Die(e.message)
diff --git a/scripts/cros_choose_profile_unittest b/scripts/cros_choose_profile_unittest
new file mode 120000
index 0000000..b7045c5
--- /dev/null
+++ b/scripts/cros_choose_profile_unittest
@@ -0,0 +1 @@
+wrapper.py
\ No newline at end of file
diff --git a/scripts/cros_choose_profile_unittest.py b/scripts/cros_choose_profile_unittest.py
new file mode 100644
index 0000000..40a3dc5
--- /dev/null
+++ b/scripts/cros_choose_profile_unittest.py
@@ -0,0 +1,228 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018 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.
+
+"""Test cros_choose_profile."""
+
+from __future__ import print_function
+
+import os
+
+from chromite.lib import commandline
+from chromite.lib import cros_test_lib
+from chromite.lib import osutils
+from chromite.scripts import cros_choose_profile
+
+
+class ParseArgsTest(cros_test_lib.TestCase):
+ """Tests for argument parsing and validation rules."""
+
+ def testInvalidArgs(self):
+ """Test invalid argument parsing."""
+ with self.assertRaises(SystemExit):
+ cros_choose_profile.ParseArgs([])
+
+ with self.assertRaises(SystemExit):
+ cros_choose_profile.ParseArgs(['--profile', 'profile',
+ '--variant', 'variant'])
+
+
+class BoardTest(cros_test_lib.TestCase):
+ """Tests for the Board class logic."""
+
+ def setUp(self):
+ """Set up the boards with the different construction variations."""
+ # For readability's sake.
+ Board = cros_choose_profile.Board
+ self.board_variant1 = Board(board='board_variant')
+ self.board_variant2 = Board(board='board', variant='variant')
+ self.board_variant3 = Board(board_root='/build/board_variant')
+ self.board_variant4 = Board(board='board_variant',
+ board_root='/build/ignored_value')
+
+ def testBoardVariant(self):
+ """Board.{board, variant, board_variant} building tests."""
+ self.assertEqual('board', self.board_variant1.board)
+ self.assertEqual('variant', self.board_variant1.variant)
+ self.assertEqual('board_variant', self.board_variant1.board_variant)
+
+ self.assertEqual('board', self.board_variant2.board)
+ self.assertEqual('variant', self.board_variant2.variant)
+ self.assertEqual('board_variant', self.board_variant2.board_variant)
+
+ self.assertEqual('board', self.board_variant3.board)
+ self.assertEqual('variant', self.board_variant3.variant)
+ self.assertEqual('board_variant', self.board_variant3.board_variant)
+
+ self.assertEqual('board', self.board_variant4.board)
+ self.assertEqual('variant', self.board_variant4.variant)
+ self.assertEqual('board_variant', self.board_variant4.board_variant)
+
+ def testRoot(self):
+ """Board.root tests."""
+ self.assertEqual(self.board_variant1.root, self.board_variant2.root)
+ self.assertEqual(self.board_variant1.root, self.board_variant3.root)
+ self.assertEqual(self.board_variant1.root, self.board_variant4.root)
+
+
+class ProfileTest(cros_test_lib.TempDirTestCase):
+ """Tests for the Profile class and functions, and ChooseProfile."""
+
+ def setUp(self):
+ """Setup filesystem for the profile tests."""
+ # Make sure everything will use the filesystem we're setting up.
+ cros_choose_profile.PathPrefixDecorator.prefix = self.tempdir
+
+ D = cros_test_lib.Directory
+ filesystem = (
+ D('board1-overlay', (
+ D('profiles', (
+ D('base', ('parent', )),
+ D('profile1', ('parent',)),
+ D('profile2', ('parent',)),
+ )),
+ )),
+ D('build', (
+ D('board1', (
+ D('etc', (
+ D('portage', ()), # make.profile parent directory.
+ 'make.conf.board_setup',
+ )),
+ D('var', (
+ D('cache', (
+ D('edb', ('chromeos',)),
+ )),
+ )),
+ )),
+ )),
+ )
+
+ cros_test_lib.CreateOnDiskHierarchy(self.tempdir, filesystem)
+
+ # Generate the required filesystem content.
+ # Build out the file names that need content.
+ b1_build_root = '/build/board1'
+ b1_board_setup = os.path.join(b1_build_root, 'etc/make.conf.board_setup')
+ self.b1_sysroot = os.path.join(b1_build_root, 'var/cache/edb/chromeos')
+ b1_profiles = '/board1-overlay/profiles'
+
+ base_directory = os.path.join(b1_profiles, 'base')
+ base_parent = os.path.join(base_directory, 'parent')
+ p1_directory = os.path.join(b1_profiles, 'profile1')
+ p1_parent = os.path.join(p1_directory, 'parent')
+ p2_directory = os.path.join(b1_profiles, 'profile2')
+ p2_parent = os.path.join(p2_directory, 'parent')
+
+ # Contents to write to the corresponding file.
+
+ # self.profile_override is assumed to be a profile name in testGetProfile.
+ # Update code there if this changes.
+ self.profile_override = 'profile1'
+ path_contents = {
+ b1_board_setup: 'ARCH="arch"\nBOARD_OVERLAY="/board1-overlay"',
+ self.b1_sysroot: 'PROFILE_OVERRIDE="%s"' % self.profile_override,
+ base_parent: 'base parent contents',
+ p1_parent: 'profile1 parent contents',
+ p2_parent: 'profile2 parent contents',
+ }
+
+ for filepath, contents in path_contents.iteritems():
+ osutils.WriteFile(self._TempdirPath(filepath), contents)
+
+ # Mapping between profile argument and the expected parent contents.
+ self.profile_expected_parent = {
+ 'base': path_contents[base_parent],
+ 'profile1': path_contents[p1_parent],
+ 'profile2': path_contents[p2_parent],
+ }
+
+ # Mapping between the profile argument and the profile's directory.
+ self.profile_directory = {
+ 'base': base_directory,
+ 'profile1': p1_directory,
+ 'profile2': p2_directory,
+ }
+
+ # The make profile directory from which parent files are read.
+ self.board1_make_profile = '/build/board1/etc/portage/make.profile'
+
+ self.board1 = cros_choose_profile.Board(board_root=b1_build_root)
+
+ osutils.SafeSymlink(self._TempdirPath(p1_directory),
+ self._TempdirPath(self.board1_make_profile))
+
+ def tearDown(self):
+ # Reset the prefix.
+ cros_choose_profile.PathPrefixDecorator.prefix = None
+
+ def _TempdirPath(self, path):
+ """Join the tempdir base path to the given path."""
+ # lstrip leading / to prevent it returning the path without the tempdir.
+ return os.path.join(self.tempdir, path.lstrip(os.sep))
+
+ def testChooseProfile(self):
+ """ChooseProfile tests: verify profiles are properly chosen."""
+ b1_parent_path = self._TempdirPath(os.path.join(self.board1_make_profile,
+ 'parent'))
+ # Verify initial state - profile1.
+ self.assertEqual(self.profile_expected_parent['profile1'],
+ osutils.ReadFile(b1_parent_path))
+
+ for profile_name, parent in self.profile_expected_parent.iteritems():
+ # Call ChooseProfile for the given profile and check contents as specified
+ # by self.profile_expected_parent (built in setUp).
+
+ profile_dir = self.profile_directory[profile_name]
+ profile = cros_choose_profile.Profile(profile_name, profile_dir,
+ profile_name)
+
+ # Test the profile changing.
+ cros_choose_profile.ChooseProfile(self.board1, profile)
+ self.assertEqual(parent, osutils.ReadFile(b1_parent_path))
+
+ # Test the profile staying the same.
+ cros_choose_profile.ChooseProfile(self.board1, profile)
+ self.assertEqual(parent, osutils.ReadFile(b1_parent_path))
+
+ def testGetProfile(self):
+ """Test each profile parameter type behaves as expected when fetched."""
+ # pylint: disable=protected-access
+ # Test an invalid profile name.
+ args = commandline.ArgumentNamespace(profile='doesnotexist')
+ self.assertRaises(cros_choose_profile.ProfileDirectoryNotFoundError,
+ cros_choose_profile._GetProfile, args, self.board1)
+
+ # Profile values for following tests.
+ profile_name = self.profile_override
+ profile_path = self._TempdirPath(self.profile_directory[profile_name])
+
+ # Test using the profile name.
+ args = commandline.ArgumentNamespace(profile=profile_name)
+ profile = cros_choose_profile._GetProfile(args, self.board1)
+ self.assertEqual(profile_name, profile.name)
+ self.assertEqual(profile_path, profile.directory)
+ self.assertEqual(profile_name, profile.override)
+
+ # Test using the profile path.
+ args = commandline.ArgumentNamespace(profile=profile_path)
+ profile = cros_choose_profile._GetProfile(args, self.board1)
+ self.assertEqual(profile_name, profile.name)
+ self.assertEqual(profile_path, profile.directory)
+ self.assertEqual(profile_path, profile.override)
+
+ # Test using PROFILE_OVERRIDE.
+ args = commandline.ArgumentNamespace(profile=None)
+ profile = cros_choose_profile._GetProfile(args, self.board1)
+ self.assertEqual(profile_name, profile.name)
+ self.assertEqual(profile_path, profile.directory)
+ self.assertEqual(self.profile_override, profile.override)
+
+ # No override value, using default 'base'.
+ osutils.WriteFile(self._TempdirPath(self.b1_sysroot), '')
+ args = commandline.ArgumentNamespace(profile=None)
+ profile = cros_choose_profile._GetProfile(opts=args, board=self.board1)
+ self.assertEqual('base', profile.name)
+ self.assertEqual(self._TempdirPath(self.profile_directory['base']),
+ profile.directory)
+ self.assertIsNone(profile.override)