Module to calculate archive locations and upload to GCS.

This module has methods to flatten out the archives generated by builds
to ensure a single file is passed to the provenance generation at the
time.

Bug: https://github.com/flutter/flutter/issues/113193
Change-Id: Ic3c6319a6d2663e3c487ccae69aaa9d04acb1cf0
Reviewed-on: https://flutter-review.googlesource.com/c/recipes/+/35162
Reviewed-by: Yusuf Mohsinally <mohsinally@google.com>
Commit-Queue: Godofredo Contreras <godofredoc@google.com>
diff --git a/recipe_modules/archives/__init__.py b/recipe_modules/archives/__init__.py
new file mode 100644
index 0000000..35fefba
--- /dev/null
+++ b/recipe_modules/archives/__init__.py
@@ -0,0 +1,11 @@
+# Copyright 2022 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+DEPS = [
+    'depot_tools/gsutil',
+    'flutter/repo_util',
+    'recipe_engine/buildbucket',
+    'recipe_engine/file',
+    'recipe_engine/path',
+]
diff --git a/recipe_modules/archives/api.py b/recipe_modules/archives/api.py
new file mode 100644
index 0000000..f3590eb
--- /dev/null
+++ b/recipe_modules/archives/api.py
@@ -0,0 +1,150 @@
+# Copyright 2022 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import attr
+import re
+
+from recipe_engine import recipe_api
+
+
+@attr.s
+class ArchivePaths(object):
+  """Paths for an archive config."""
+  local = attr.ib(type=str)
+  remote = attr.ib(type=str)
+
+
+DEFAULT_BUCKET = 'flutter_archives_v2'
+ANDROID_ARTIFACTS_BUCKET = 'download.flutter.io'
+# Used for mock paths
+DIRECTORY = 'DIRECTORY'
+
+
+class ArchivesApi(recipe_api.RecipeApi):
+  """Api to handle archives from engine_v2 recipes."""
+
+  def _full_path_list(self, checkout, archive_config):
+    """Calculates the local paths using an archive_config.
+
+    Args:
+      checkout: (Path) the checkout path of the engine repository.
+      archive_config: (dict) a dictionary with the archive files generated by
+        a given build.
+
+    Returns:
+      A list of strings with the expected local files as described
+      by the archive configuration.
+    """
+    results = []
+    self.m.path.mock_add_paths(
+        self.m.path['start_dir'].join(
+            'out/android_profile/zip_archives/download.flutter.io'),
+        DIRECTORY
+    )
+    for include_path in archive_config.get('include_paths', []):
+      full_include_path = self.m.path.abspath(checkout.join(include_path))
+      if self.m.path.isdir(full_include_path):
+        test_data = [
+            (
+             'io/flutter/x86_debug/'
+             '1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584/'
+             'x86_debug-1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584.jar'),
+            (
+             'io/flutter/x86_debug/'
+             '1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584/'
+             'x86_debug-1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584.pom'),
+
+        ]
+        paths = self.m.file.glob_paths(
+                'Expand directory', checkout.join(include_path),
+                '**', test_data=test_data
+        )
+        paths = [self.m.path.abspath(p) for p in paths]
+        results.extend(paths)
+      else:
+        results.append(full_include_path)
+    return results
+
+  def _split_dst_parts(self, dst):
+    """Splits gsutil uri into a bucket and path sections.
+
+    Args:
+      dst: (str) a gcs path like gs://bucket/a/b/c.
+
+    Returns:
+      A tuple with the bucket as the first item and the path to the
+      object as the second parameter.
+    """
+    matches = re.match('gs://(\w+)/(.+)', dst)
+    return (matches.group(1), matches.group(2))
+
+  def upload_artifact(self, src, dst):
+    """Uploads a local object to a gcs destination.
+
+    This method also ensures the directoy structure is recreated in the
+    destination.
+
+    Args:
+      src: (str) a string with the object local path.
+      dst: (str) a string with the destination path in gcs.
+    """
+    bucket, path = self._split_dst_parts(dst)
+    dir_part = self.m.path.dirname(path)
+    archive_dir = self.m.path.mkdtemp()
+    self.m.file.ensure_directory('Ensure %s' % dir_part, archive_dir.join(dir_part))
+    self.m.file.copy('Copy %s' % dst, src, archive_dir.join(dir_part))
+    self.m.gsutil.upload(
+        source='%s/*' % archive_dir,
+        bucket=bucket,
+        dest='',
+        args=['-r'],
+        name=path,
+    )
+
+  def engine_v2_gcs_paths(self, checkout, archive_config, bucket=DEFAULT_BUCKET):
+    """Calculates engine v2 GCS paths from an archive config.
+
+    Args:
+      checkout: (Path) the engine repository checkout folder.
+      archive_config: (dict) the archive configuration for a recipes v2 build.
+      bucket: (str) the bucket used to calculate the object destination.
+
+    Returns:
+      A list of ArchivePaths with expected local and remote locations for the
+      generated artifacts.
+    """
+    results = []
+    file_list = self._full_path_list(checkout, archive_config)
+    for include_path in file_list:
+      is_android_artifact = ANDROID_ARTIFACTS_BUCKET in include_path
+      dir_part = self.m.path.dirname(include_path)
+      full_base_path = self.m.path.abspath(checkout.join(archive_config.get('base_path','')))
+      rel_path = self.m.path.relpath(dir_part, full_base_path)
+      rel_path = '' if rel_path == '.' else rel_path
+      base_name = self.m.path.basename(include_path)
+      is_monorepo = self.m.buildbucket.gitiles_commit.project == 'monorepo'
+
+      if is_monorepo:
+        commit = self.m.repo_util.get_commit(checkout.join('../../monorepo'))
+        artifact_prefix = 'monorepo/'
+      else:
+        commit = self.m.repo_util.get_commit(checkout.join('flutter'))
+        artifact_prefix = ''
+
+      if is_android_artifact:
+        # We are not using a slash in the first parameter becase artifact_prefix
+        # already includes the slash.
+        artifact_path = '%s%s/%s' % (
+            artifact_prefix, rel_path, base_name)
+      else:
+        artifact_path = '%sflutter_infra_release/flutter%s/%s/%s' % (
+            artifact_prefix, commit, rel_path, base_name)
+
+      results.append(
+          ArchivePaths(
+              include_path,
+              'gs://%s/%s' % (bucket, artifact_path)
+          )
+      )
+    return results
diff --git a/recipe_modules/archives/examples/full.expected/basic.json b/recipe_modules/archives/examples/full.expected/basic.json
new file mode 100644
index 0000000..715e413
--- /dev/null
+++ b/recipe_modules/archives/examples/full.expected/basic.json
@@ -0,0 +1,122 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "glob",
+      "[START_DIR]/out/android_profile/zip_archives/download.flutter.io",
+      "**"
+    ],
+    "infra_step": true,
+    "name": "Expand directory",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@glob@[START_DIR]/out/android_profile/zip_archives/download.flutter.io/io/flutter/x86_debug/1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584/x86_debug-1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584.jar@@@",
+      "@@@STEP_LOG_LINE@glob@[START_DIR]/out/android_profile/zip_archives/download.flutter.io/io/flutter/x86_debug/1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584/x86_debug-1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584.pom@@@",
+      "@@@STEP_LOG_END@glob@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "git rev-parse"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "git rev-parse (2)"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "git rev-parse (3)"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "git rev-parse (4)"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/flutter",
+    "infra_step": true,
+    "name": "git rev-parse (5)"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CLEANUP]/tmp_tmp_1/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile"
+    ],
+    "infra_step": true,
+    "name": "Ensure flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/out/android_profile/zip_archives/android-arm-profile/artifacts.zip",
+      "[CLEANUP]/tmp_tmp_1/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile"
+    ],
+    "infra_step": true,
+    "name": "Copy gs://flutter_archives_v2/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile/artifacts.zip"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cp",
+      "-r",
+      "[CLEANUP]/tmp_tmp_1/*",
+      "gs://flutter_archives_v2/"
+    ],
+    "infra_step": true,
+    "name": "gsutil flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile/artifacts.zip",
+    "~followup_annotations": [
+      "@@@STEP_LINK@gsutil.upload@https://console.cloud.google.com/storage/browser/flutter_archives_v2/@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/archives/examples/full.expected/monorepo_gcs.json b/recipe_modules/archives/examples/full.expected/monorepo_gcs.json
new file mode 100644
index 0000000..ca03f20
--- /dev/null
+++ b/recipe_modules/archives/examples/full.expected/monorepo_gcs.json
@@ -0,0 +1,230 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "glob",
+      "[START_DIR]/out/android_profile/zip_archives/download.flutter.io",
+      "**"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "Expand directory",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@glob@[START_DIR]/out/android_profile/zip_archives/download.flutter.io/io/flutter/x86_debug/1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584/x86_debug-1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584.jar@@@",
+      "@@@STEP_LOG_LINE@glob@[START_DIR]/out/android_profile/zip_archives/download.flutter.io/io/flutter/x86_debug/1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584/x86_debug-1.0.0-0005149dca9b248663adcde4bdd7c6c915a76584.pom@@@",
+      "@@@STEP_LOG_END@glob@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/../../monorepo",
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "git rev-parse"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/../../monorepo",
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "git rev-parse (2)"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/../../monorepo",
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "git rev-parse (3)"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/../../monorepo",
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "git rev-parse (4)"
+  },
+  {
+    "cmd": [
+      "git",
+      "rev-parse",
+      "HEAD"
+    ],
+    "cwd": "[START_DIR]/../../monorepo",
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "git rev-parse (5)"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CLEANUP]/tmp_tmp_1/monorepo/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "Ensure monorepo/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[START_DIR]/out/android_profile/zip_archives/android-arm-profile/artifacts.zip",
+      "[CLEANUP]/tmp_tmp_1/monorepo/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "Copy gs://flutter_archives_v2/monorepo/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile/artifacts.zip"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cp",
+      "-r",
+      "[CLEANUP]/tmp_tmp_1/*",
+      "gs://flutter_archives_v2/"
+    ],
+    "infra_step": true,
+    "luci_context": {
+      "realm": {
+        "name": "dart:ci.sandbox"
+      },
+      "resultdb": {
+        "current_invocation": {
+          "name": "invocations/build:8945511751514863184",
+          "update_token": "token"
+        },
+        "hostname": "rdbhost"
+      }
+    },
+    "name": "gsutil monorepo/flutter_infra_release/flutter12345abcde12345abcde12345abcde12345abcde/android-arm-profile/artifacts.zip",
+    "~followup_annotations": [
+      "@@@STEP_LINK@gsutil.upload@https://console.cloud.google.com/storage/browser/flutter_archives_v2/@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/archives/examples/full.py b/recipe_modules/archives/examples/full.py
new file mode 100644
index 0000000..a712ced
--- /dev/null
+++ b/recipe_modules/archives/examples/full.py
@@ -0,0 +1,54 @@
+# Copyright 2022 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+from recipe_engine.post_process import DoesNotRun, Filter, StatusFailure
+
+DEPS = [
+    'flutter/archives',
+    'recipe_engine/buildbucket',
+    'recipe_engine/path',
+    'recipe_engine/raw_io',
+]
+
+
+def RunSteps(api):
+  checkout = api.path['start_dir']
+  config = {
+      "name": "android_profile",
+      "type": "gcs",
+      "base_path": "out/android_profile/zip_archives/",
+      "include_paths": [
+          "out/android_profile/zip_archives/android-arm-profile/artifacts.zip",
+          "out/android_profile/zip_archives/android-arm-profile/linux-x64.zip",
+          "out/android_profile/zip_archives/android-arm-profile/symbols.zip",
+          "out/android_profile/zip_archives/download.flutter.io"
+      ]
+  }
+  results = api.archives.engine_v2_gcs_paths(checkout, config)
+  api.archives.upload_artifact(results[0].local, results[0].remote)
+
+
+def GenTests(api):
+  yield api.test(
+      'basic',
+      api.step_data(
+          'git rev-parse',
+          stdout=api.raw_io
+          .output_text('12345abcde12345abcde12345abcde12345abcde\n')
+      )
+  )
+  yield api.test(
+      'monorepo_gcs',
+      api.buildbucket.ci_build(
+          project='dart',
+          bucket='ci.sandbox',
+          git_repo='https://dart.googlesource.com/monorepo',
+          git_ref='refs/heads/main'
+      ),
+      api.step_data(
+          'git rev-parse',
+          stdout=api.raw_io
+          .output_text('12345abcde12345abcde12345abcde12345abcde\n')
+      )
+  )