api: Output failed package data in SdkService/Update response.

If a host package failed to compile in the SDK update call, then return
the package name and logs so that it can be surfaced by the builders.

BUG=b:271120919
TEST=./run_tests
TEST=led https://chromeos-swarming.appspot.com/task?id=640ec62cfcc82c10

Change-Id: I20825ebd4e2d74fbd08accc5488818fbe6d19e28
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/4772528
Reviewed-by: Alex Klein <saklein@chromium.org>
Reviewed-by: Lee Presland <zland@google.com>
Commit-Queue: Navil Perez <navil@google.com>
Tested-by: Navil Perez <navil@google.com>
diff --git a/api/controller/sdk_unittest.py b/api/controller/sdk_unittest.py
index 9a885f3..956e1df 100644
--- a/api/controller/sdk_unittest.py
+++ b/api/controller/sdk_unittest.py
@@ -4,12 +4,15 @@
 
 """SDK tests."""
 
+import datetime
 import os
 from pathlib import Path
-from typing import List, Optional
+from typing import List, Optional, Union
 from unittest import mock
 
 from chromite.api import api_config
+from chromite.api import controller
+from chromite.api.controller import controller_util
 from chromite.api.controller import sdk as sdk_controller
 from chromite.api.gen.chromite.api import sdk_pb2
 from chromite.api.gen.chromiumos import common_pb2
@@ -17,6 +20,9 @@
 from chromite.lib import constants
 from chromite.lib import cros_build_lib
 from chromite.lib import cros_test_lib
+from chromite.lib import osutils
+from chromite.lib import sysroot_lib
+from chromite.lib.parser import package_info
 from chromite.service import sdk as sdk_service
 
 
@@ -294,7 +300,9 @@
         patch.assert_called_once_with("/test/path")
 
 
-class SdkUpdateTest(cros_test_lib.MockTestCase, api_config.ApiConfigMixin):
+class SdkUpdateTest(
+    cros_test_lib.MockTempDirTestCase, api_config.ApiConfigMixin
+):
     """Update tests."""
 
     def setUp(self):
@@ -302,6 +310,12 @@
         # We need to run the command inside the chroot.
         self.PatchObject(cros_build_lib, "IsInsideChroot", return_value=True)
 
+        self.portage_dir = os.path.join(self.tempdir, "portage_logdir")
+        self.PatchObject(
+            sysroot_lib.Sysroot, "portage_logdir", new=self.portage_dir
+        )
+        osutils.SafeMakedirs(self.portage_dir)
+
         self.response = sdk_pb2.UpdateResponse()
 
     def _GetRequest(self, build_source=False, targets=None):
@@ -315,6 +329,31 @@
 
         return request
 
+    def _CreatePortageLogFile(
+        self,
+        log_path: Union[str, os.PathLike],
+        pkg_info: package_info.PackageInfo,
+        timestamp: datetime.datetime,
+    ) -> str:
+        """Creates a log file to test for individual packages built by Portage.
+
+        Args:
+            log_path: The PORTAGE_LOGDIR path.
+            pkg_info: name components for log file.
+            timestamp: Timestamp used to name the file.
+        """
+        path = os.path.join(
+            log_path,
+            f"{pkg_info.category}:{pkg_info.pvr}:"
+            f'{timestamp.strftime("%Y%m%d-%H%M%S")}.log',
+        )
+        osutils.WriteFile(
+            path,
+            f"Test log file for package {pkg_info.category}/"
+            f"{pkg_info.package} written to {path}",
+        )
+        return path
+
     def testValidateOnly(self):
         """Verify a validate-only call does not execute any logic."""
         patch = self.PatchObject(sdk_service, "Update")
@@ -338,17 +377,70 @@
     def testSuccess(self):
         """Successful call output handling test."""
         expected_version = 1
-        self.PatchObject(sdk_service, "Update", return_value=expected_version)
+        expected_return = sdk_service.UpdateResult(
+            return_code=0, version=expected_version
+        )
+        self.PatchObject(sdk_service, "Update", return_value=expected_return)
         request = self._GetRequest()
 
         sdk_controller.Update(request, self.response, self.api_config)
 
         self.assertEqual(expected_version, self.response.version.version)
 
+    def testNonPackageFailure(self):
+        """Test output handling when the call fails."""
+        expected_return = sdk_service.UpdateResult(return_code=1)
+        self.PatchObject(sdk_service, "Update", return_value=expected_return)
+
+        rc = sdk_controller.Update(
+            self._GetRequest(), self.response, self.api_config
+        )
+        self.assertEqual(controller.RETURN_CODE_COMPLETED_UNSUCCESSFULLY, rc)
+
+    def testPackageFailure(self):
+        """Test output handling when the call fails with a package failure."""
+        pkgs = ["cat/pkg-1.0-r1", "foo/bar-2.0-r1"]
+        cpvrs = [package_info.parse(pkg) for pkg in pkgs]
+        new_logs = {}
+        for i, pkg in enumerate(pkgs):
+            self._CreatePortageLogFile(
+                self.portage_dir,
+                cpvrs[i],
+                datetime.datetime(2021, 6, 9, 13, 37, 0),
+            )
+            new_logs[pkg] = self._CreatePortageLogFile(
+                self.portage_dir,
+                cpvrs[i],
+                datetime.datetime(2021, 6, 9, 16, 20, 0),
+            )
+
+        expected_return = sdk_service.UpdateResult(
+            return_code=1,
+            failed_pkgs=cpvrs,
+        )
+        self.PatchObject(sdk_service, "Update", return_value=expected_return)
+
+        rc = sdk_controller.Update(
+            self._GetRequest(), self.response, self.api_config
+        )
+        self.assertEqual(
+            controller.RETURN_CODE_UNSUCCESSFUL_RESPONSE_AVAILABLE, rc
+        )
+        self.assertTrue(self.response.failed_package_data)
+
+        expected_failed_pkgs = [("cat", "pkg"), ("foo", "bar")]
+        failed_pkgs = []
+        for data in self.response.failed_package_data:
+            failed_pkgs.append((data.name.category, data.name.package_name))
+            package = controller_util.deserialize_package_info(data.name)
+            self.assertEqual(data.log_path.path, new_logs[package.cpvr])
+        self.assertCountEqual(expected_failed_pkgs, failed_pkgs)
+
     def testArgumentHandling(self):
         """Test the proto argument handling."""
+        expected_return = sdk_service.UpdateResult(return_code=0, version=1)
         args = sdk_service.UpdateArguments()
-        self.PatchObject(sdk_service, "Update", return_value=1)
+        self.PatchObject(sdk_service, "Update", return_value=expected_return)
         args_patch = self.PatchObject(
             sdk_service, "UpdateArguments", return_value=args
         )