api: Implement Binhost/PrepareChromeBinhostUploads

The PrepareChromeBinhostUploads Build API endpoint calls
service/CreateChromePackageIndex to filter the set of binpkgs
to upload to only contain Chrome and follower packages.

BUG=b:277222525
TEST=./run_tests, manual

Change-Id: Ie123763b5453e6f671e923090dcc8785b0c88425
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/4422974
Reviewed-by: Yoshiki Iguchi <yoshiki@chromium.org>
Tested-by: Cindy Lin <xcl@google.com>
Commit-Queue: Cindy Lin <xcl@google.com>
diff --git a/api/controller/binhost.py b/api/controller/binhost.py
index 87168e3..8d9f533 100644
--- a/api/controller/binhost.py
+++ b/api/controller/binhost.py
@@ -18,6 +18,7 @@
 from chromite.lib import constants
 from chromite.lib import cros_build_lib
 from chromite.lib import gs
+from chromite.lib import osutils
 from chromite.lib import sysroot_lib
 from chromite.service import binhost
 
@@ -231,6 +232,79 @@
     output_proto.upload_targets.add().path = "Packages"
 
 
+def _PrepareChromeBinhostUploadsResponse(_input_proto, output_proto, _config):
+    """Add fake binhost files to a successful response."""
+    output_proto.upload_targets.add().path = (
+        "chromeos-base/chromeos-chrome-100-r1.tbz2"
+    )
+    output_proto.upload_targets.add().path = (
+        "chromeos-base/chrome-icu-100-r1.tbz2"
+    )
+    output_proto.upload_targets.add().path = (
+        "chromeos-base/chromeos-lacros-100-r1.tbz2"
+    )
+    output_proto.upload_targets.add().path = "Packages"
+
+
+@faux.success(_PrepareChromeBinhostUploadsResponse)
+@faux.empty_error
+@validate.require("uploads_dir", "uri", "sysroot.path")
+@validate.validation_complete
+def PrepareChromeBinhostUploads(
+    input_proto: binhost_pb2.PrepareChromeBinhostUploadsRequest,
+    output_proto: binhost_pb2.PrepareChromeBinhostUploadsResponse,
+    config: "api_config.ApiConfig",
+):
+    """Return a list of Chrome files to upload to the binhost.
+
+    The files will also be copied to the uploads_dir.
+    See BinhostService documentation in api/proto/binhost.proto.
+
+    Args:
+        input_proto: The input proto.
+        output_proto: The output proto.
+        config: The API call config.
+    """
+    if config.validate_only:
+        return controller.RETURN_CODE_VALID_INPUT
+
+    chroot = controller_util.ParseChroot(input_proto.chroot)
+    sysroot = sysroot_lib.Sysroot(input_proto.sysroot.path)
+
+    uri = input_proto.uri
+    # For now, we enforce that all input URIs are Google Storage buckets.
+    if not gs.PathIsGs(uri):
+        raise ValueError("Upload URI %s must be Google Storage." % uri)
+    parsed_uri = urllib.parse.urlparse(uri)
+    gs_bucket = gs.GetGsURL(parsed_uri.netloc, for_gsutil=True).rstrip("/")
+    upload_path = parsed_uri.path.lstrip("/")
+
+    # Determine the filename for the to-be-created Packages file, which will
+    # contain only Chrome packages.
+    chrome_package_index_path = os.path.join(
+        input_proto.uploads_dir, "Packages"
+    )
+    upload_targets_list = binhost.CreateChromePackageIndex(
+        chroot, sysroot, chrome_package_index_path, gs_bucket, upload_path
+    )
+
+    package_dir = chroot.full_path(sysroot.path, "packages")
+    for upload_target in upload_targets_list:
+        # Copy each package to uploads_dir/category/package
+        upload_target = upload_target.strip("/")
+        category = upload_target.split("/")[0]
+        target_dir = os.path.join(input_proto.uploads_dir, category)
+        if not os.path.exists(target_dir):
+            osutils.SafeMakedirs(target_dir)
+        full_src_pkg_path = os.path.join(package_dir, upload_target)
+        full_target_src_path = os.path.join(
+            input_proto.uploads_dir, upload_target
+        )
+        shutil.copyfile(full_src_pkg_path, full_target_src_path)
+        output_proto.upload_targets.add().path = upload_target
+    output_proto.upload_targets.add().path = "Packages"
+
+
 def _UpdatePackageIndexResponse(_input_proto, _output_proto, _config):
     """Set up a fake successful response."""
 
diff --git a/api/controller/binhost_unittest.py b/api/controller/binhost_unittest.py
index 6512d37..baef497 100644
--- a/api/controller/binhost_unittest.py
+++ b/api/controller/binhost_unittest.py
@@ -12,6 +12,7 @@
 from chromite.api.gen.chromite.api import binhost_pb2
 from chromite.api.gen.chromiumos import common_pb2
 from chromite.lib import binpkg
+from chromite.lib import constants
 from chromite.lib import cros_build_lib
 from chromite.lib import cros_test_lib
 from chromite.lib import osutils
@@ -611,3 +612,87 @@
             binhost.PrepareDevInstallBinhostUploads(
                 input_proto, self.response, self.api_config
             )
+
+
+class PrepareChromeBinhostUploadsTest(
+    cros_test_lib.MockTempDirTestCase, api_config.ApiConfigMixin
+):
+    """Tests for BinhostService/PrepareChromeBinhostUploads."""
+
+    def setUp(self):
+        self.PatchObject(cros_build_lib, "IsInsideChroot", return_value=False)
+        self.create_chrome_package_index_mock = self.PatchObject(
+            binhost_service, "CreateChromePackageIndex"
+        )
+
+        self.chroot_path = self.tempdir / "chroot"
+        self.sysroot_path = "build/target"
+        self.uploads_dir = self.tempdir / "uploads_dir"
+        self.input_proto = binhost_pb2.PrepareChromeBinhostUploadsRequest()
+        self.input_proto.uri = "gs://chromeos-prebuilt/target"
+        self.input_proto.chroot.path = str(self.chroot_path)
+        self.input_proto.sysroot.path = self.sysroot_path
+        self.input_proto.uploads_dir = str(self.uploads_dir)
+        self.response = binhost_pb2.PrepareChromeBinhostUploadsResponse()
+
+        self.packages_path = self.chroot_path / self.sysroot_path / "packages"
+        self.chrome_packages_path = self.packages_path / constants.CHROME_CN
+        osutils.Touch(
+            self.chrome_packages_path / "chromeos-chrome-100-r1.tbz2",
+            makedirs=True,
+        )
+        osutils.Touch(
+            self.chrome_packages_path / "chrome-icu-100-r1.tbz2",
+            makedirs=True,
+        )
+        osutils.Touch(
+            self.chrome_packages_path / "chromeos-lacros-100-r1.tbz2",
+            makedirs=True,
+        )
+
+    def testValidateOnly(self):
+        """Check that a validate only call does not execute any logic."""
+        binhost.PrepareChromeBinhostUploads(
+            self.input_proto, self.response, self.validate_only_config
+        )
+
+        self.create_chrome_package_index_mock.assert_not_called()
+
+    def testMockCall(self):
+        """Test a mock call does not execute logic, returns mocked value."""
+        binhost.PrepareChromeBinhostUploads(
+            self.input_proto, self.response, self.mock_call_config
+        )
+
+        self.assertEqual(len(self.response.upload_targets), 4)
+        self.assertEqual(self.response.upload_targets[3].path, "Packages")
+        self.create_chrome_package_index_mock.assert_not_called()
+
+    def testChromeUpload(self):
+        """Test uploads of Chrome prebuilts."""
+        expected_upload_targets = [
+            "chromeos-base/chromeos-chrome-100-r1.tbz2",
+            "chromeos-base/chrome-icu-100-r1.tbz2",
+            "chromeos-base/chromeos-lacros-100-r1.tbz2",
+        ]
+        self.create_chrome_package_index_mock.return_value = (
+            expected_upload_targets
+        )
+
+        binhost.PrepareChromeBinhostUploads(
+            self.input_proto, self.response, self.api_config
+        )
+
+        self.assertCountEqual(
+            [target.path for target in self.response.upload_targets],
+            expected_upload_targets + ["Packages"],
+        )
+
+    def testPrepareBinhostUploadsNonGsUri(self):
+        """PrepareBinhostUploads dies when URI does not point to GS."""
+        self.input_proto.uri = "https://foo.bar"
+
+        with self.assertRaises(ValueError):
+            binhost.PrepareChromeBinhostUploads(
+                self.input_proto, self.response, self.api_config
+            )