Add a script for building cuttlefish hybrid device

The cuttlefish hybrid device mixes a physical device's
system with a cuttlefish's vendor.

Bug: 300076119
Test: make build_cf_hybrid_device &&
      build_cf_hybrid_device --build_id 123456 \
        --otatools_zip $WORKSPACE/otatools.zip \
	--target chd-target-name \
	--output_dir $WORKSPACE \
	--framework_target_files_zip \
	$WORKSPACE/device-target_files-*.zip \
	--vendor_target_files_zip \
	$WORKSPACE/cf-target_files-*.zip
Change-Id: Iab4a0174bb139d950ef9bf8e5a361a2c6a8cc51e
diff --git a/cuttlefish/Android.bp b/cuttlefish/Android.bp
new file mode 100644
index 0000000..1427963
--- /dev/null
+++ b/cuttlefish/Android.bp
@@ -0,0 +1,34 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+python_library_host {
+  name: "build_chd_lib",
+  srcs: [
+    "build_chd_utils.py",
+  ],
+}
+
+python_binary_host {
+  name: "build_cf_hybrid_device",
+  srcs: [
+    "build_cf_hybrid_device.py",
+  ],
+  libs: [
+    "build_chd_lib",
+  ],
+}
diff --git a/cuttlefish/build_cf_hybrid_device.py b/cuttlefish/build_cf_hybrid_device.py
new file mode 100644
index 0000000..a62e919
--- /dev/null
+++ b/cuttlefish/build_cf_hybrid_device.py
@@ -0,0 +1,113 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2023 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+import argparse
+import glob
+import os
+import subprocess
+import tempfile
+
+from build_chd_utils import copy_files
+from build_chd_utils import unzip_otatools
+
+"""Test command:
+
+WORKSPACE=out/dist && \
+python3 tools/treble/cuttlefish/build_cf_hybrid_device.py \
+    --build_id 123456 \
+    --otatools_zip $WORKSPACE/otatools.zip \
+    --target chd-target \
+    --output_dir $WORKSPACE \
+    --framework_target_files_zip $WORKSPACE/device-target_files-*.zip \
+    --vendor_target_files_zip $WORKSPACE/cf_arm64_only_phone-target_files-*.zip
+"""
+
+
+def _parse_args():
+  """Parse the arguments for building cuttlefish hybrid devices.
+
+  Returns:
+    An object of argparse.Namespace.
+  """
+  parser = argparse.ArgumentParser()
+
+  parser.add_argument('--build_id', required=True,
+                      help='Build id.')
+  parser.add_argument('--target', required=True,
+                      help='Target name of the cuttlefish hybrid build.')
+  parser.add_argument('--otatools_zip', required=True,
+                      help='Path to the otatools.zip.')
+  parser.add_argument('--output_dir', required=True,
+                      help='Path to the output directory of the hybrid build.')
+  parser.add_argument('--framework_target_files_zip', required=True,
+                      help='glob pattern of framework target_files zip.')
+  parser.add_argument('--vendor_target_files_zip', required=True,
+                      help='glob pattern of vendor target_files zip.')
+  parser.add_argument('--copy_file', action='append', default=[],
+                      help='The file to be copied to output directory. '
+                           'The format is <src glob pattern>:<dst path>.')
+  return parser.parse_args()
+
+
+def run(temp_dir):
+  args = _parse_args()
+
+  # unzip otatools
+  otatools = os.path.join(temp_dir, 'otatools')
+  unzip_otatools(args.otatools_zip, otatools)
+
+  # get framework and vendor target files
+  matched_framework_target_files = glob.glob(args.framework_target_files_zip)
+  if not matched_framework_target_files:
+    raise ValueError('framework target files zip '
+                     f'{args.framework_target_files_zip} not found.')
+  matched_vendor_target_files = glob.glob(args.vendor_target_files_zip)
+  if not matched_vendor_target_files:
+    raise ValueError('vendor target files zip '
+                     f'{args.vendor_target_files_zip} not found.')
+
+  # merge target files
+  framework_target_files = matched_framework_target_files[0]
+  vendor_target_files = matched_vendor_target_files[0]
+  merged_target_files = os.path.join(
+      args.output_dir,
+      f'{args.target}-target_files-{args.build_id}.zip')
+  command = [
+      os.path.join(otatools, 'bin', 'merge_target_files'),
+      '--path', otatools,
+      '--framework-target-files', framework_target_files,
+      '--vendor-target-files', vendor_target_files,
+      '--output-target-files', merged_target_files,
+      '--avb-resolve-rollback-index-location-conflict'
+  ]
+  subprocess.run(command, check=True)
+
+  # create images from the merged target files
+  img_zip_path = os.path.join(args.output_dir,
+                              f'{args.target}-img-{args.build_id}.zip')
+  command = [
+      os.path.join(otatools, 'bin', 'img_from_target_files'),
+      merged_target_files,
+      img_zip_path]
+  subprocess.run(command, check=True)
+
+  # copy files
+  copy_files(args.copy_file, args.output_dir)
+
+
+if __name__ == '__main__':
+  with tempfile.TemporaryDirectory() as temp_dir:
+    run(temp_dir)
diff --git a/cuttlefish/build_chd_utils.py b/cuttlefish/build_chd_utils.py
new file mode 100644
index 0000000..77a8eff
--- /dev/null
+++ b/cuttlefish/build_chd_utils.py
@@ -0,0 +1,78 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2023 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not
+# use this file except in compliance with the License. You may obtain a copy of
+# the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations under
+# the License.
+
+import glob
+import os
+import shutil
+import zipfile
+
+
+def unzip_otatools(otatools_zip_path, output_dir):
+  """Unzip otatools to a directory and set the permissions for execution.
+
+  Args:
+    otatools_zip_path: The path to otatools zip archive.
+    output_dir: The root directory of the unzip output.
+  """
+  with zipfile.ZipFile(otatools_zip_path, 'r') as zf:
+    zf.extractall(path=output_dir)
+
+  for f in glob.glob(os.path.join(output_dir, 'bin', '*')):
+    os.chmod(f, 0o777)
+
+
+def _parse_copy_file_pair(copy_file_pair):
+  """Convert a string to a source path and a destination path.
+
+  Args:
+    copy_file_pair: A string in the format of <src glob pattern>:<dst path>.
+
+  Returns:
+    The source path and the destination path.
+
+  Raises:
+    ValueError if the input string is in a wrong format.
+  """
+  split_pair = copy_file_pair.split(':', 1)
+  if len(split_pair) != 2:
+    raise ValueError(f'{copy_file_pair} is not a <src>:<dst> pair.')
+  src_list = glob.glob(split_pair[0])
+  if len(src_list) != 1:
+    raise ValueError(f'{copy_file_pair} has more than one matched src files: '
+                     f'{" ".join(src_list)}.')
+  return src_list[0], split_pair[1]
+
+
+def copy_files(copy_files_list, output_dir):
+  """Copy files to the output directory.
+
+  Args:
+    copy_files_list: A list of copy file pairs, where a pair defines the src
+                     glob pattern and the dst path.
+    output_dir: The root directory of the copy dst.
+
+  Raises:
+    FileExistsError if the dst file already exists.
+  """
+  for pair in copy_files_list:
+    src, dst = _parse_copy_file_pair(pair)
+    # this line does not change dst if dst is absolute.
+    dst = os.path.join(output_dir, dst)
+    os.makedirs(os.path.dirname(dst), exist_ok=True)
+    print(f'Copying {src} to {dst}')
+    if os.path.exists(dst):
+      raise FileExistsError(dst)
+    shutil.copyfile(src, dst)