bazel: Add wrapper that extracts resultstore links

Add a wrapper script that extracts resultstore links to a separate file
that the recipe can read, while still allowing stdout to be visible as
it is generated.

Bug: b/368050432, b/363338443
Change-Id: Ibc487340576694b619230178890702c6603c6b90
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/238173
Reviewed-by: Ted Pudlik <tpudlik@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/recipe_modules/bazel/__init__.py b/recipe_modules/bazel/__init__.py
index e9d0090..4a70df7 100644
--- a/recipe_modules/bazel/__init__.py
+++ b/recipe_modules/bazel/__init__.py
@@ -23,6 +23,7 @@
     'recipe_engine/context',
     'recipe_engine/defer',
     'recipe_engine/file',
+    'recipe_engine/json',
     'recipe_engine/path',
     'recipe_engine/platform',
     'recipe_engine/properties',
diff --git a/recipe_modules/bazel/api.py b/recipe_modules/bazel/api.py
index 381c077..84773ad 100644
--- a/recipe_modules/bazel/api.py
+++ b/recipe_modules/bazel/api.py
@@ -173,7 +173,17 @@
                     assert program in programs, f'{program} not in {programs}'
                     assert programs[program]
                     for args in programs[program]:
-                        cmd = [self.ensure(), *args, *base_args]
+                        json_path = self.api.path.mkdtemp() / 'metadata.json'
+
+                        cmd = [
+                            self.api.bazel.resource('wrapper.py'),
+                            '--json',
+                            self.api.json.output(leak_to=json_path),
+                            '--',
+                            self.ensure(),
+                            *args,
+                            *base_args,
+                        ]
                         defer(
                             self.api.step,
                             shlex.join(args),
@@ -181,6 +191,25 @@
                             **kwargs,
                         )
 
+                        self.api.path.mock_add_file(json_path)
+                        if self.api.path.isfile(json_path):
+                            with self.api.step.nest('resultstore link') as pres:
+                                data = self.api.file.read_json(
+                                    'read',
+                                    json_path,
+                                    test_data={
+                                        'resultstore': 'https://result.store/',
+                                    },
+                                )
+                                if 'resultstore' in data:
+                                    pres.links['resultstore'] = data[
+                                        'resultstore'
+                                    ]
+                                else:  # pragma: no cover
+                                    pres.step_summary_text = (
+                                        'no resultstore link found'
+                                    )
+
 
 def nwise(iterable, n):
     # nwise('ABCDEFG', 3) → ABC BCD CDE DEF EFG
diff --git a/recipe_modules/bazel/resources/wrapper.py b/recipe_modules/bazel/resources/wrapper.py
new file mode 100755
index 0000000..4c55c05
--- /dev/null
+++ b/recipe_modules/bazel/resources/wrapper.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""Invoke Bazel, and write a JSON file maybe pointing to ResultStore."""
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+
+
+def run(*, cmd, json_path):
+    # Always write this file, even if we never see a resultstore link.
+    with open(json_path, 'w') as outs:
+        json.dump({}, outs)
+
+    proc = subprocess.Popen(
+        cmd,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+    for line in proc.stdout:
+        line = line.decode()
+        print(line, end='')
+        if match := re.search(
+            r'Streaming build results to:\s*(https?://.*?)\s*$',
+            line,
+        ):
+            with open(json_path, 'w') as outs:
+                json.dump(dict(resultstore=match.group(1)), outs)
+                break
+
+    for line in proc.stdout:
+        line = line.decode()
+        print(line, end='')
+
+    return proc.wait()
+
+
+def parse(argv=None):
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--json', dest='json_path', required=True)
+    parser.add_argument('cmd', nargs='+')
+    return vars(parser.parse_args(argv))
+
+
+def main(argv=None):
+    return run(**parse(argv))
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/recipes/bazel.expected/simple.json b/recipes/bazel.expected/simple.json
index 7297bc3..5a78107 100644
--- a/recipes/bazel.expected/simple.json
+++ b/recipes/bazel.expected/simple.json
@@ -1208,6 +1208,10 @@
   },
   {
     "cmd": [
+      "RECIPE_MODULE[pigweed::bazel]/resources/wrapper.py",
+      "--json",
+      "[CLEANUP]/tmp_tmp_3/metadata.json",
+      "--",
       "[CLEANUP]/tmp_tmp_2/bazelisk",
       "build",
       "//...",
@@ -1240,11 +1244,68 @@
     },
     "name": "default.build //...",
     "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output (read error)@JSON file was missing or unreadable:@@@",
+      "@@@STEP_LOG_LINE@json.output (read error)@  [CLEANUP]/tmp_tmp_3/metadata.json@@@",
+      "@@@STEP_LOG_END@json.output (read error)@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "default.resultstore link",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LINK@resultstore@https://result.store/@@@"
     ]
   },
   {
     "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/tmp_tmp_3/metadata.json",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/co",
+    "env": {
+      "BUILDBUCKET_ID": "0",
+      "BUILDBUCKET_NAME": "project:bucket:builder",
+      "BUILD_NUMBER": "0",
+      "CCACHE_DIR": "[CACHE]/ccache",
+      "CLICOLOR": "0",
+      "CLICOLOR_FORCE": "0",
+      "CTCACHE_DIR": "[CACHE]/clang_tidy",
+      "GCC_COLORS": "",
+      "GOCACHE": "[CACHE]/go",
+      "NO_COLOR": "1",
+      "PIP_CACHE_DIR": "[CACHE]/pip",
+      "PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED": "1",
+      "PW_ENVSETUP_DISABLE_SPINNER": "1",
+      "PW_PRESUBMIT_DISABLE_SUBPROCESS_CAPTURE": "1",
+      "PW_TEST_VAR": "test_value",
+      "PW_USE_COLOR": "",
+      "TEST_TMPDIR": "[CACHE]/bazel",
+      "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
+    },
+    "infra_step": true,
+    "name": "default.resultstore link.read",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LOG_LINE@metadata.json@{@@@",
+      "@@@STEP_LOG_LINE@metadata.json@  \"resultstore\": \"https://result.store/\"@@@",
+      "@@@STEP_LOG_LINE@metadata.json@}@@@",
+      "@@@STEP_LOG_END@metadata.json@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "RECIPE_MODULE[pigweed::bazel]/resources/wrapper.py",
+      "--json",
+      "[CLEANUP]/tmp_tmp_4/metadata.json",
+      "--",
       "[CLEANUP]/tmp_tmp_2/bazelisk",
       "test",
       "//...",
@@ -1277,7 +1338,60 @@
     },
     "name": "default.test //...",
     "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@"
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output (read error)@JSON file was missing or unreadable:@@@",
+      "@@@STEP_LOG_LINE@json.output (read error)@  [CLEANUP]/tmp_tmp_4/metadata.json@@@",
+      "@@@STEP_LOG_END@json.output (read error)@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "default.resultstore link (2)",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LINK@resultstore@https://result.store/@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/tmp_tmp_4/metadata.json",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/co",
+    "env": {
+      "BUILDBUCKET_ID": "0",
+      "BUILDBUCKET_NAME": "project:bucket:builder",
+      "BUILD_NUMBER": "0",
+      "CCACHE_DIR": "[CACHE]/ccache",
+      "CLICOLOR": "0",
+      "CLICOLOR_FORCE": "0",
+      "CTCACHE_DIR": "[CACHE]/clang_tidy",
+      "GCC_COLORS": "",
+      "GOCACHE": "[CACHE]/go",
+      "NO_COLOR": "1",
+      "PIP_CACHE_DIR": "[CACHE]/pip",
+      "PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED": "1",
+      "PW_ENVSETUP_DISABLE_SPINNER": "1",
+      "PW_PRESUBMIT_DISABLE_SUBPROCESS_CAPTURE": "1",
+      "PW_TEST_VAR": "test_value",
+      "PW_USE_COLOR": "",
+      "TEST_TMPDIR": "[CACHE]/bazel",
+      "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
+    },
+    "infra_step": true,
+    "name": "default.resultstore link (2).read",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LOG_LINE@metadata.json@{@@@",
+      "@@@STEP_LOG_LINE@metadata.json@  \"resultstore\": \"https://result.store/\"@@@",
+      "@@@STEP_LOG_LINE@metadata.json@}@@@",
+      "@@@STEP_LOG_END@metadata.json@@@"
     ]
   },
   {