build_image: Add argument parse support

Add support for argument parsing in build_image.py. Based on the
arguments populate the BuildConfig and call the Build API from image
service.

BUG=b:233756416
TEST=build_image --board betty

Change-Id: Idde3cd5c09155e31791c81d3ee7deec905fedf75
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/3665285
Tested-by: Ram Chandrasekar <rchandrasekar@google.com>
Commit-Queue: Ram Chandrasekar <rchandrasekar@google.com>
Auto-Submit: Ram Chandrasekar <rchandrasekar@google.com>
Reviewed-by: Cindy Lin <xcl@google.com>
diff --git a/scripts/build_image.py b/scripts/build_image.py
index eea6533..efe3e64 100644
--- a/scripts/build_image.py
+++ b/scripts/build_image.py
@@ -21,31 +21,301 @@
 
 Note if you want to build an image with custom size partitions, either consider
 adding a new disk layout in build_library/legacy_disk_layout.json OR use
-adjust_part. See build_image help, but here are a few examples:
+adjust-part. Here are a few examples:
 
-adjust_part='STATE:+1G' -- add one GB to the size the stateful partition
-adjust_part='ROOT-A:-1G' -- remove one GB from the primary rootfs partition
-adjust_part='STATE:=1G' --  make the stateful partition 1 GB
+adjust-part='STATE:+1G' -- add one GB to the size the stateful partition
+adjust-part='ROOT-A:-1G' -- remove one GB from the primary rootfs partition
+adjust-part='STATE:=1G' --  make the stateful partition 1 GB
 """
 
+import argparse
+import os
 from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, Tuple
 
 from chromite.lib import commandline
 from chromite.lib import constants
 from chromite.lib import cros_build_lib
+from chromite.service import image
+
+
+def build_shell_bool_style_args(parser: commandline.ArgumentParser,
+                                name: str,
+                                default_val: bool,
+                                help_str: str,
+                                deprecation_note: str,
+                                alternate_name: Optional[str] = None) -> None:
+  """Build the shell boolean input argument equivalent.
+
+  There are two cases which we will need to handle,
+  case 1: A shell boolean arg, which doesn't need to be re-worded in python.
+  case 2: A shell boolean arg, which needs to be re-worded in python.
+  Example below.
+  For Case 1, for a given input arg name 'argA', we create three python
+  arguments.
+  --argA, --noargA, --no-argA. The arguments --argA and --no-argA will be
+  retained after deprecating --noargA.
+  For Case 2, for a given input arg name 'arg_A' we need to use alternate
+  argument name 'arg-A'. we create four python arguments in this case.
+  --arg_A, --noarg_A, --arg-A, --no-arg-A. The first two arguments will be
+  deprecated later.
+  TODO(b/232566937): Remove the creation of --noargA in case 1 and --arg_A and
+  --noarg_A in case 2.
+
+  Args:
+    parser: The parser to update.
+    name: The input argument name. This will be used as 'dest' variable name.
+    default_val: The default value to assign.
+    help_str: The help string for the input argument.
+    deprecation_note: A deprecation note to use.
+    alternate_name: Alternate argument to be used after deprecation.
+  """
+  arg = f'--{name}'
+  shell_narg = f'--no{name}'
+  py_narg = f'--no-{name}'
+  alt_arg = f'--{alternate_name}' if alternate_name else None
+  alt_py_narg = f'--no-{alternate_name}' if alternate_name else None
+  default_val_str = f'{help_str} (Default: %(default)s).'
+
+  if alternate_name:
+    parser.add_argument(
+        alt_arg,
+        action='store_true',
+        default=default_val,
+        dest=name,
+        help=default_val_str)
+    parser.add_argument(
+        alt_py_narg,
+        action='store_false',
+        dest=name,
+        help="Don't " + help_str.lower())
+
+  parser.add_argument(
+      arg,
+      action='store_true',
+      default=default_val,
+      dest=name,
+      deprecated=deprecation_note % alt_arg if alternate_name else None,
+      help=default_val_str if not alternate_name else argparse.SUPPRESS)
+  parser.add_argument(
+      shell_narg,
+      action='store_false',
+      dest=name,
+      deprecated=deprecation_note %
+      (alt_py_narg if alternate_name else py_narg),
+      help=argparse.SUPPRESS)
+
+  if not alternate_name:
+    parser.add_argument(
+        py_narg,
+        action='store_false',
+        dest=name,
+        help="Don't " + help_str.lower())
+
+
+def build_shell_string_style_args(parser: commandline.ArgumentParser, name: str,
+                                  default_val: Optional[str], help_str: str,
+                                  deprecation_note: str,
+                                  alternate_name: str) -> None:
+  """Build the shell string input argument equivalent.
+
+  Args:
+    parser: The parser to update.
+    name: The input argument name. This will be used as 'dest' variable name.
+    default_val: The default value to assign.
+    help_str: The help string for the input argument.
+    deprecation_note: A deprecation note to use.
+    alternate_name: Alternate argument to be used after deprecation.
+  """
+  default_val_str = (f'{help_str} (Default: %(default)s).'
+                     if default_val else help_str)
+
+  parser.add_argument(
+      f'--{alternate_name}',
+      dest=f'{name}',
+      default=default_val,
+      help=default_val_str)
+  parser.add_argument(
+      f'--{name}',
+      deprecated=deprecation_note % f'--{alternate_name}',
+      help=argparse.SUPPRESS)
+
+
+def get_parser() -> commandline.ArgumentParser:
+  """Creates the cmdline argparser, populates the options and description.
+
+  Returns:
+    Argument parser.
+  """
+  deprecation_note = 'Argument will be removed January, 2023. Use %s instead.'
+  parser = commandline.ArgumentParser(description=__doc__)
+
+  parser.add_argument(
+      '-b',
+      '--board',
+      '--build-target',
+      dest='board',
+      default=cros_build_lib.GetDefaultBoard(),
+      help='The board to build images for.')
+  build_shell_string_style_args(
+      parser, 'adjust_part', None,
+      'Adjustments to apply to partition table (LABEL:[+-=]SIZE) '
+      'e.g. ROOT-A:+1G.', deprecation_note, 'adjust-partition')
+  build_shell_string_style_args(
+      parser, 'output_root',
+      Path(constants.DEFAULT_BUILD_ROOT) / 'images',
+      'Directory in which to place image result directories '
+      '(named by version).', deprecation_note, 'output-root')
+  build_shell_string_style_args(
+      parser, 'builder_path', None,
+      'The build name to be installed on DUT during hwtest.', deprecation_note,
+      'builder-path')
+  build_shell_string_style_args(parser, 'disk_layout', 'default',
+                                'The disk layout type to use for this image.',
+                                deprecation_note, 'disk-layout')
+
+  # Kernel related options.
+  group = parser.add_argument_group('Kernel Options')
+  build_shell_string_style_args(
+      group, 'enable_serial', None,
+      'Enable serial port for printks. Example values: ttyS0.',
+      deprecation_note, 'enable-serial')
+  group.add_argument(
+      '--kernel-loglevel',
+      type=int,
+      default=7,
+      help='The loglevel to add to the kernel command line. '
+      '(Default: %(default)s).')
+  group.add_argument(
+      '--loglevel',
+      dest='kernel_loglevel',
+      type=int,
+      deprecated=deprecation_note % 'kernel-loglevel',
+      help=argparse.SUPPRESS)
+
+  # Bootloader related options.
+  group = parser.add_argument_group('Bootloader Options')
+  build_shell_string_style_args(
+      group, 'boot_args', 'noinitrd',
+      'Additional boot arguments to pass to the commandline.', deprecation_note,
+      'boot-args')
+  build_shell_bool_style_args(group, 'enable_bootcache', False,
+                              'Make all bootloaders to use boot cache.',
+                              deprecation_note, 'enable-bootcache')
+  build_shell_bool_style_args(
+      group, 'enable_rootfs_verification', True,
+      'Make all bootloaders to use kernel based root-fs integrity checking.',
+      deprecation_note, 'enable-rootfs-verification')
+
+  # Advanced options.
+  group = parser.add_argument_group('Advanced Options')
+  group.add_argument(
+      '--build-attempt',
+      type=int,
+      default=1,
+      help='The build attempt for this image build. (Default: %(default)s).')
+  group.add_argument(
+      '--build_attempt',
+      type=int,
+      deprecated=deprecation_note % 'build-attempt',
+      help=argparse.SUPPRESS)
+  build_shell_string_style_args(
+      group, 'build_root',
+      Path(constants.DEFAULT_BUILD_ROOT) / 'images',
+      'Directory in which to compose the image, before copying it to '
+      'output_root.', deprecation_note, 'build-root')
+  group.add_argument(
+      '-j',
+      '--jobs',
+      dest='jobs',
+      type=int,
+      default=os.cpu_count(),
+      help='Number of packages to build in parallel at maximum. '
+      '(Default: %(default)s).')
+  build_shell_bool_style_args(group, 'replace', False,
+                              'Overwrite existing output, if any.',
+                              deprecation_note)
+  group.add_argument(
+      '--symlink',
+      default='latest',
+      help='Symlink name to use for this image. (Default: %(default)s).')
+  group.add_argument(
+      '--version',
+      default=None,
+      help='Overrides version number in name to this version.')
+  build_shell_string_style_args(group, 'output_suffix', None,
+                                'Add custom suffix to output directory.',
+                                deprecation_note, 'output-suffix')
+  build_shell_bool_style_args(group, 'eclean', True,
+                              'Call eclean before building the image.',
+                              deprecation_note)
+
+  parser.add_argument(
+      'images',
+      nargs='*',
+      default=['dev'],
+      help='list of images to build. (Default: %(default)s).')
+
+  return parser
+
+
+def parse_args(
+    argv: List[str]
+) -> Tuple[commandline.ArgumentParser, commandline.ArgumentNamespace]:
+  """Parse and validate CLI arguments.
+
+  Args:
+    argv: Arguments passed via CLI.
+
+  Returns:
+    Tuple having the below two,
+    Argument Parser
+    Validated argument namespace.
+  """
+  parser = get_parser()
+  opts = parser.parse_args(argv)
+
+  opts.build_run_config = image.BuildConfig(
+      adjust_partition=opts.adjust_part,
+      output_root=opts.output_root,
+      builder_path=opts.builder_path,
+      disk_layout=opts.disk_layout,
+      enable_serial=opts.enable_serial,
+      kernel_loglevel=opts.kernel_loglevel,
+      boot_args=opts.boot_args,
+      enable_bootcache=opts.enable_bootcache,
+      enable_rootfs_verification=opts.enable_rootfs_verification,
+      build_attempt=opts.build_attempt,
+      build_root=opts.build_root,
+      jobs=opts.jobs,
+      replace=opts.replace,
+      symlink=opts.symlink,
+      version=opts.version,
+      output_dir_suffix=opts.output_suffix,
+      eclean=opts.eclean,
+  )
+  opts.Freeze()
+
+  return parser, opts
 
 
 def main(argv: Optional[List[str]] = None) -> Optional[int]:
   commandline.RunInsideChroot()
+  parser, opts = parse_args(argv)
 
-  cmd = [
-      'bash',
-      Path(constants.CROSUTILS_DIR) / 'build_image.sh',
-      '--script-is-run-only-by-chromite-and-not-users',
+  # If the opts.board is not set, then it means user hasn't specified a default
+  # board in 'src/scripts/.default_board' and didn't specify it as input
+  # argument.
+  if not opts.board:
+    parser.error('--board is required')
+
+  invalid_image = [
+      x for x in opts.images if x not in constants.IMAGE_TYPE_TO_NAME
   ]
-  cmd.extend(argv)
-  try:
-    cros_build_lib.sudo_run(cmd, print_cmd=False)
-  except cros_build_lib.RunCommandError as e:
-    cros_build_lib.Die(e)
+  if invalid_image:
+    parser.error(f'Invalid image type argument(s) {invalid_image}')
+
+  result = image.Build(opts.board, opts.images, opts.build_run_config)
+  if result.run_error:
+    cros_build_lib.Die(
+        f'Error running build_image. Exit Code : {result.return_code}')