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)