Flutter cache module.

A module to work with caches independently of the swarming. This is a
mitigation to slow GoB checkouts.

Bug: https://github.com/flutter/flutter/issues/127433
Change-Id: Icfa3af12cc3b34ba71f2c231cb711b0409c66c9f
Reviewed-on: https://flutter-review.googlesource.com/c/recipes/+/44880
Reviewed-by: Yusuf Mohsinally <mohsinally@google.com>
Commit-Queue: Godofredo Contreras <godofredoc@google.com>
diff --git a/recipe_modules/cache/__init__.py b/recipe_modules/cache/__init__.py
new file mode 100644
index 0000000..0a76072
--- /dev/null
+++ b/recipe_modules/cache/__init__.py
@@ -0,0 +1,16 @@
+# Copyright 2023 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 = [
+    'flutter/archives',
+    'depot_tools/depot_tools',
+    'depot_tools/gsutil',
+    'recipe_engine/cas',
+    'recipe_engine/file',
+    'recipe_engine/json',
+    'recipe_engine/platform',
+    'recipe_engine/step',
+    'recipe_engine/raw_io',
+    'recipe_engine/path',
+]
diff --git a/recipe_modules/cache/api.py b/recipe_modules/cache/api.py
new file mode 100644
index 0000000..f28e77a
--- /dev/null
+++ b/recipe_modules/cache/api.py
@@ -0,0 +1,90 @@
+# Copyright 2023 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 datetime
+from recipe_engine import recipe_api
+
+DEFAULT_TTL_SECS = 60 * 60 * 2  # 2 hours.
+INFRA_BUCKET_NAME = 'flutter_archives_v2'
+
+
+class CacheApi(recipe_api.RecipeApi):
+  """Cache manager API.
+
+  This API can be use to create caches on CAS, save metadata on GCS
+  and mount caches within recipes. This is required to add caches
+  support to subbuilds using generic builders.
+  """
+
+  def _metadata(self, cache_name):
+    cloud_path = self._cache_path(cache_name)
+    result = self.m.step(
+        '%s exists' % cache_name, [
+            'python3',
+            self.m.depot_tools.gsutil_py_path,
+            'stat',
+            cloud_path,
+        ],
+        ok_ret='all'
+    )
+    # A return value of 0 means the file ALREADY exists on cloud storage
+    return result.exc_result.retcode == 0
+
+  def requires_refresh(self, cache_name):
+    """Calculates if the cache needs to be refreshed.
+
+    Args:
+      cache_name (str): The name of the cache.
+      ttl_seconds (int): Seconds from last update that the cache is still valid.
+    """
+    if not self._metadata(cache_name):
+      return True
+    cloud_path = self._cache_path(cache_name)
+    result = self.m.gsutil.cat(cloud_path, stdout=self.m.json.output()).stdout
+    last_cache = result.get('last_cache_ts_micro_seconds', 0)
+    cache_ttl = result.get('cache_ttl_microseconds', 0)
+    ms_since_epoch_now = 1684900396429444 if self._test_data.enabled else int(
+        datetime.datetime.utcnow().timestamp() * 1e6
+    )
+    return (last_cache + cache_ttl) < ms_since_epoch_now
+
+  def _cache_path(self, cache_name):
+    platform = self.m.platform.name
+    return 'gs://%s/caches/%s-%s.json' % (
+        INFRA_BUCKET_NAME, cache_name, platform
+    )
+
+  def write(self, cache_name, paths, ttl_secs):
+    cache_metadata = {}
+    ms_since_epoch_now = 1684900396429444 if self._test_data.enabled else int(
+        datetime.datetime.utcnow().timestamp() * 1e6
+    )
+    cache_metadata['last_cache_ts_micro_seconds'] = ms_since_epoch_now
+    cache_metadata['cache_ttl_microseconds'] = int(ttl_secs * 1e6)
+    cache_metadata['hashes'] = {}
+
+    for path in paths:
+      name = self.m.path.basename(path)
+      hash_value = self.m.cas.archive('Archive %s' % name, path)
+      cache_metadata['hashes'][name] = hash_value
+    platform = self.m.platform.name
+    local_cache_path = self.m.path['cleanup'].join(
+        '%s-%s.json' % (cache_name, platform)
+    )
+    self.m.file.write_json(
+        'Write cache metadata', local_cache_path, cache_metadata
+    )
+    metadata_gs_path = self._cache_path(cache_name)
+    # Max age in seconds to cache the file.
+    headers = {'Cache-Control': 'max-age=60'}
+    self.m.archives.upload_artifact(
+        local_cache_path, metadata_gs_path, metadata=headers
+    )
+
+  def mount_cache(self, cache_name, cache_root=None):
+    cache_root = cache_root or self.m.path['CACHE']
+    cloud_path = self._cache_path(cache_name)
+    metadata = self.m.gsutil.cat(cloud_path, stdout=self.m.json.output()).stdout
+    for k, v in metadata['hashes'].items():
+      self.m.cas.download('Mounting %s with hash %s' % (k, v), v, cache_root)
diff --git a/recipe_modules/cache/tests/no_refresh.expected/basic.json b/recipe_modules/cache/tests/no_refresh.expected/basic.json
new file mode 100644
index 0000000..ba5e9c0
--- /dev/null
+++ b/recipe_modules/cache/tests/no_refresh.expected/basic.json
@@ -0,0 +1,35 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "stat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "name": "builder exists"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "infra_step": true,
+    "name": "gsutil cat",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"cache_ttl_microseconds\": 12960000, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"last_cache_ts_micro_seconds\": 1684900396429444@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/cache/tests/no_refresh.py b/recipe_modules/cache/tests/no_refresh.py
new file mode 100644
index 0000000..a09d948
--- /dev/null
+++ b/recipe_modules/cache/tests/no_refresh.py
@@ -0,0 +1,31 @@
+# Copyright 2023 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 datetime
+
+DEPS = [
+    'flutter/cache',
+    'recipe_engine/assertions',
+    'recipe_engine/json',
+]
+
+
+def RunSteps(api):
+  result = api.cache.requires_refresh('builder')
+  api.assertions.assertFalse(result)
+
+
+def GenTests(api):
+  metadata = {
+      'last_cache_ts_micro_seconds':
+          1684900396429444,
+      'cache_ttl_microseconds':
+          3600 * 60 * 60
+  }
+  yield api.test(
+      'basic', api.step_data(
+          'gsutil cat',
+          stdout=api.json.output(metadata),
+      )
+  )
diff --git a/recipe_modules/cache/tests/refresh.expected/basic.json b/recipe_modules/cache/tests/refresh.expected/basic.json
new file mode 100644
index 0000000..6715a93
--- /dev/null
+++ b/recipe_modules/cache/tests/refresh.expected/basic.json
@@ -0,0 +1,255 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "stat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "name": "builder exists"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "infra_step": true,
+    "name": "gsutil cat",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "RECIPE_MODULE[recipe_engine::cas]/resources/infra.sha1",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read infra revision",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@infra.sha1@git_revision:mock_infra_git_revision@@@",
+      "@@@STEP_LOG_END@infra.sha1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "install infra/tools/luci/cas"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision"
+    ],
+    "infra_step": true,
+    "name": "install infra/tools/luci/cas.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision",
+      "-ensure-file",
+      "infra/tools/luci/cas/${platform} git_revision:mock_infra_git_revision",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "infra_step": true,
+    "name": "install infra/tools/luci/cas.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-git_revision:moc\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/cas/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "archive",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-dump-digest",
+      "/path/to/tmp/",
+      "-paths-json",
+      "[[\"[CACHE]/builder\", \".\"]]"
+    ],
+    "infra_step": true,
+    "name": "Archive builder",
+    "~followup_annotations": [
+      "@@@STEP_LINK@CAS UI@https://cas-viewer.appspot.com/projects/example-cas-server/instances/default_instance/blobs/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0/tree@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "archive",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-dump-digest",
+      "/path/to/tmp/",
+      "-paths-json",
+      "[[\"[CACHE]/git\", \".\"]]"
+    ],
+    "infra_step": true,
+    "name": "Archive git",
+    "~followup_annotations": [
+      "@@@STEP_LINK@CAS UI@https://cas-viewer.appspot.com/projects/example-cas-server/instances/default_instance/blobs/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0/tree@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\"cache_ttl_microseconds\": 60000000, \"hashes\": {\"builder\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\", \"git\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\"}, \"last_cache_ts_micro_seconds\": 1684900396429444}",
+      "[CLEANUP]/builder-linux.json"
+    ],
+    "infra_step": true,
+    "name": "Write cache metadata",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@builder-linux.json@{\"cache_ttl_microseconds\": 60000000, \"hashes\": {\"builder\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\", \"git\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\"}, \"last_cache_ts_micro_seconds\": 1684900396429444}@@@",
+      "@@@STEP_LOG_END@builder-linux.json@@@"
+    ]
+  },
+  {
+    "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/caches"
+    ],
+    "infra_step": true,
+    "name": "Ensure caches"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/builder-linux.json",
+      "[CLEANUP]/tmp_tmp_1/caches"
+    ],
+    "infra_step": true,
+    "name": "Copy [CLEANUP]/builder-linux.json to tmp location"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "-h",
+      "Cache-Control:max-age=60",
+      "cp",
+      "-r",
+      "[CLEANUP]/tmp_tmp_1/*",
+      "gs://flutter_archives_v2/"
+    ],
+    "infra_step": true,
+    "name": "gsutil Upload [CLEANUP]/builder-linux.json to gs://flutter_archives_v2/caches/builder-linux.json",
+    "~followup_annotations": [
+      "@@@STEP_LINK@gsutil.upload@https://console.cloud.google.com/storage/browser/flutter_archives_v2/@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "infra_step": true,
+    "name": "gsutil cat (2)",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"hashes\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"builder\": \"hash1\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"git\": \"hash2\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "download",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-digest",
+      "hash1",
+      "-dir",
+      "[CACHE]"
+    ],
+    "infra_step": true,
+    "name": "Mounting builder with hash hash1"
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "download",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-digest",
+      "hash2",
+      "-dir",
+      "[CACHE]"
+    ],
+    "infra_step": true,
+    "name": "Mounting git with hash hash2"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/cache/tests/refresh.expected/no_cache_file.json b/recipe_modules/cache/tests/refresh.expected/no_cache_file.json
new file mode 100644
index 0000000..24312c7
--- /dev/null
+++ b/recipe_modules/cache/tests/refresh.expected/no_cache_file.json
@@ -0,0 +1,237 @@
+[
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "stat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "name": "builder exists"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "RECIPE_MODULE[recipe_engine::cas]/resources/infra.sha1",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read infra revision",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@infra.sha1@git_revision:mock_infra_git_revision@@@",
+      "@@@STEP_LOG_END@infra.sha1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "install infra/tools/luci/cas"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision"
+    ],
+    "infra_step": true,
+    "name": "install infra/tools/luci/cas.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision",
+      "-ensure-file",
+      "infra/tools/luci/cas/${platform} git_revision:mock_infra_git_revision",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "infra_step": true,
+    "name": "install infra/tools/luci/cas.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-git_revision:moc\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/cas/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "archive",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-dump-digest",
+      "/path/to/tmp/",
+      "-paths-json",
+      "[[\"[CACHE]/builder\", \".\"]]"
+    ],
+    "infra_step": true,
+    "name": "Archive builder",
+    "~followup_annotations": [
+      "@@@STEP_LINK@CAS UI@https://cas-viewer.appspot.com/projects/example-cas-server/instances/default_instance/blobs/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0/tree@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "archive",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-dump-digest",
+      "/path/to/tmp/",
+      "-paths-json",
+      "[[\"[CACHE]/git\", \".\"]]"
+    ],
+    "infra_step": true,
+    "name": "Archive git",
+    "~followup_annotations": [
+      "@@@STEP_LINK@CAS UI@https://cas-viewer.appspot.com/projects/example-cas-server/instances/default_instance/blobs/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0/tree@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\"cache_ttl_microseconds\": 60000000, \"hashes\": {\"builder\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\", \"git\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\"}, \"last_cache_ts_micro_seconds\": 1684900396429444}",
+      "[CLEANUP]/builder-linux.json"
+    ],
+    "infra_step": true,
+    "name": "Write cache metadata",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@builder-linux.json@{\"cache_ttl_microseconds\": 60000000, \"hashes\": {\"builder\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\", \"git\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\"}, \"last_cache_ts_micro_seconds\": 1684900396429444}@@@",
+      "@@@STEP_LOG_END@builder-linux.json@@@"
+    ]
+  },
+  {
+    "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/caches"
+    ],
+    "infra_step": true,
+    "name": "Ensure caches"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/builder-linux.json",
+      "[CLEANUP]/tmp_tmp_1/caches"
+    ],
+    "infra_step": true,
+    "name": "Copy [CLEANUP]/builder-linux.json to tmp location"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "-h",
+      "Cache-Control:max-age=60",
+      "cp",
+      "-r",
+      "[CLEANUP]/tmp_tmp_1/*",
+      "gs://flutter_archives_v2/"
+    ],
+    "infra_step": true,
+    "name": "gsutil Upload [CLEANUP]/builder-linux.json to gs://flutter_archives_v2/caches/builder-linux.json",
+    "~followup_annotations": [
+      "@@@STEP_LINK@gsutil.upload@https://console.cloud.google.com/storage/browser/flutter_archives_v2/@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cat",
+      "gs://flutter_archives_v2/caches/builder-linux.json"
+    ],
+    "infra_step": true,
+    "name": "gsutil cat",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"hashes\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"builder\": \"hash1\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"git\": \"hash2\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "download",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-digest",
+      "hash1",
+      "-dir",
+      "[CACHE]"
+    ],
+    "infra_step": true,
+    "name": "Mounting builder with hash hash1"
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "download",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-digest",
+      "hash2",
+      "-dir",
+      "[CACHE]"
+    ],
+    "infra_step": true,
+    "name": "Mounting git with hash hash2"
+  },
+  {
+    "name": "$result"
+  }
+]
\ No newline at end of file
diff --git a/recipe_modules/cache/tests/refresh.py b/recipe_modules/cache/tests/refresh.py
new file mode 100644
index 0000000..a3d03ce
--- /dev/null
+++ b/recipe_modules/cache/tests/refresh.py
@@ -0,0 +1,38 @@
+# Copyright 2023 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 = [
+    'flutter/cache',
+    'recipe_engine/assertions',
+    'recipe_engine/json',
+    'recipe_engine/path',
+]
+
+
+def RunSteps(api):
+  result = api.cache.requires_refresh('builder')
+  api.assertions.assertTrue(result)
+  paths = [
+      api.path['cache'].join('builder'),
+      api.path['cache'].join('git'),
+  ]
+  api.cache.write('builder', paths, 60)
+  api.cache.mount_cache('builder', api.path['cache'])
+
+
+def GenTests(api):
+  metadata = {
+     'hashes': {'builder': 'hash1', 'git': 'hash2'}
+  }
+  yield api.test(
+      'basic',
+      api.step_data('gsutil cat', stdout=api.json.output({}),),
+      api.step_data('gsutil cat (2)', stdout=api.json.output(metadata),)
+)
+  yield api.test(
+      'no_cache_file',
+      api.step_data('builder exists', stdout=api.json.output({}), retcode=1),
+      api.step_data('gsutil cat', stdout=api.json.output(metadata),)
+)
+
diff --git a/recipes/engine_v2/cache.expected/basic.json b/recipes/engine_v2/cache.expected/basic.json
new file mode 100644
index 0000000..2398fa5
--- /dev/null
+++ b/recipes/engine_v2/cache.expected/basic.json
@@ -0,0 +1,374 @@
+[
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "rmtree",
+      "[CACHE]/builder/src/out"
+    ],
+    "infra_step": true,
+    "name": "Clobber build output"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CACHE]/builder"
+    ],
+    "infra_step": true,
+    "name": "Ensure checkout cache"
+  },
+  {
+    "cmd": [
+      "python3",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "stat",
+      "gs://flutter_archives_v2/caches/None-linux.json"
+    ],
+    "name": "None exists"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "cat",
+      "gs://flutter_archives_v2/caches/None-linux.json"
+    ],
+    "infra_step": true,
+    "name": "gsutil cat",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@json.output@{}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "Checkout source code"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::bot_update]/resources/bot_update.py",
+      "--spec-path",
+      "cache_dir = '[CACHE]/git'\nsolutions = [{'deps_file': '.DEPS.git', 'managed': False, 'name': 'src/flutter', 'url': 'https://flutter.googlesource.com/mirrors/engine'}]",
+      "--revision_mapping_file",
+      "{\"got_engine_revision\": \"src/flutter\"}",
+      "--git-cache-dir",
+      "[CACHE]/git",
+      "--cleanup-dir",
+      "[CLEANUP]/bot_update",
+      "--output_json",
+      "/path/to/tmp/json",
+      "--revision",
+      "src/flutter@HEAD"
+    ],
+    "cwd": "[CACHE]/builder",
+    "env": {
+      "ANDROID_HOME": "[CACHE]/builder/src/third_party/android_tools/sdk",
+      "ANDROID_SDK_HOME": "[CLEANUP]/tmp_tmp_1",
+      "ANDROID_USER_HOME": "[CLEANUP]/tmp_tmp_1/.android",
+      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
+      "DEPOT_TOOLS_COLLECT_METRICS": "0",
+      "ENGINE_CHECKOUT_PATH": "[CACHE]/builder",
+      "ENGINE_PATH": "[CACHE]/builder",
+      "GIT_BRANCH": "",
+      "GIT_HTTP_LOW_SPEED_LIMIT": "102400",
+      "GIT_HTTP_LOW_SPEED_TIME": "1800",
+      "LUCI_BRANCH": "",
+      "LUCI_CI": "True",
+      "LUCI_PR": "",
+      "LUCI_WORKDIR": "[START_DIR]",
+      "OS": "linux",
+      "REVISION": ""
+    },
+    "env_prefixes": {
+      "PATH": [
+        "[CACHE]/builder/src/third_party/dart/tools/sdks/dart-sdk/bin"
+      ]
+    },
+    "env_suffixes": {
+      "DEPOT_TOOLS_UPDATE": [
+        "0",
+        "0"
+      ],
+      "PATH": [
+        "RECIPE_REPO[depot_tools]",
+        "RECIPE_REPO[depot_tools]"
+      ]
+    },
+    "infra_step": true,
+    "name": "Checkout source code.bot_update",
+    "timeout": 900,
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_TEXT@Some step text@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"did_run\": true, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"fixed_revisions\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"src/flutter\": \"HEAD\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"manifest\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"src/flutter\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"repository\": \"https://fake.org/src/flutter.git\", @@@",
+      "@@@STEP_LOG_LINE@json.output@      \"revision\": \"9221bca00ddbd888260084def81f09543281b952\"@@@",
+      "@@@STEP_LOG_LINE@json.output@    }@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"patch_failure\": false, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"patch_root\": \"src/flutter\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"properties\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"got_engine_revision\": \"9221bca00ddbd888260084def81f09543281b952\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"got_engine_revision_cp\": \"refs/heads/main@{#84512}\", @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"got_revision\": \"9221bca00ddbd888260084def81f09543281b952\"@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"root\": \"src/flutter\", @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"source_manifest\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"directories\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@      \"src/flutter\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"git_checkout\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@          \"repo_url\": \"https://fake.org/src/flutter.git\", @@@",
+      "@@@STEP_LOG_LINE@json.output@          \"revision\": \"9221bca00ddbd888260084def81f09543281b952\"@@@",
+      "@@@STEP_LOG_LINE@json.output@        }@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    }, @@@",
+      "@@@STEP_LOG_LINE@json.output@    \"version\": 0@@@",
+      "@@@STEP_LOG_LINE@json.output@  }, @@@",
+      "@@@STEP_LOG_LINE@json.output@  \"step_text\": \"Some step text\"@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@",
+      "@@@SET_BUILD_PROPERTY@got_engine_revision@\"9221bca00ddbd888260084def81f09543281b952\"@@@",
+      "@@@SET_BUILD_PROPERTY@got_engine_revision_cp@\"refs/heads/main@{#84512}\"@@@",
+      "@@@SET_BUILD_PROPERTY@got_revision@\"9221bca00ddbd888260084def81f09543281b952\"@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_REPO[depot_tools]/gclient.py",
+      "runhooks"
+    ],
+    "cwd": "[CACHE]/builder",
+    "env": {
+      "ANDROID_HOME": "[CACHE]/builder/src/third_party/android_tools/sdk",
+      "ANDROID_SDK_HOME": "[CLEANUP]/tmp_tmp_1",
+      "ANDROID_USER_HOME": "[CLEANUP]/tmp_tmp_1/.android",
+      "DEPOT_TOOLS": "RECIPE_REPO[depot_tools]",
+      "ENGINE_CHECKOUT_PATH": "[CACHE]/builder",
+      "ENGINE_PATH": "[CACHE]/builder",
+      "GIT_BRANCH": "",
+      "LUCI_BRANCH": "",
+      "LUCI_CI": "True",
+      "LUCI_PR": "",
+      "LUCI_WORKDIR": "[START_DIR]",
+      "OS": "linux",
+      "REVISION": ""
+    },
+    "env_prefixes": {
+      "PATH": [
+        "[CACHE]/builder/src/third_party/dart/tools/sdks/dart-sdk/bin"
+      ]
+    },
+    "env_suffixes": {
+      "DEPOT_TOOLS_UPDATE": [
+        "0"
+      ],
+      "PATH": [
+        "RECIPE_REPO[depot_tools]",
+        "RECIPE_REPO[depot_tools]"
+      ]
+    },
+    "name": "Checkout source code.gclient runhooks",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "RECIPE_MODULE[recipe_engine::cas]/resources/infra.sha1",
+      "/path/to/tmp/"
+    ],
+    "infra_step": true,
+    "name": "read infra revision",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@infra.sha1@git_revision:mock_infra_git_revision@@@",
+      "@@@STEP_LOG_END@infra.sha1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "install infra/tools/luci/cas"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision"
+    ],
+    "infra_step": true,
+    "name": "install infra/tools/luci/cas.ensure package directory",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "cipd",
+      "ensure",
+      "-root",
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision",
+      "-ensure-file",
+      "infra/tools/luci/cas/${platform} git_revision:mock_infra_git_revision",
+      "-max-threads",
+      "0",
+      "-json-output",
+      "/path/to/tmp/json"
+    ],
+    "infra_step": true,
+    "name": "install infra/tools/luci/cas.ensure_installed",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_LOG_LINE@json.output@{@@@",
+      "@@@STEP_LOG_LINE@json.output@  \"result\": {@@@",
+      "@@@STEP_LOG_LINE@json.output@    \"\": [@@@",
+      "@@@STEP_LOG_LINE@json.output@      {@@@",
+      "@@@STEP_LOG_LINE@json.output@        \"instance_id\": \"resolved-instance_id-of-git_revision:moc\", @@@",
+      "@@@STEP_LOG_LINE@json.output@        \"package\": \"infra/tools/luci/cas/resolved-platform\"@@@",
+      "@@@STEP_LOG_LINE@json.output@      }@@@",
+      "@@@STEP_LOG_LINE@json.output@    ]@@@",
+      "@@@STEP_LOG_LINE@json.output@  }@@@",
+      "@@@STEP_LOG_LINE@json.output@}@@@",
+      "@@@STEP_LOG_END@json.output@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "archive",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-dump-digest",
+      "/path/to/tmp/",
+      "-paths-json",
+      "[[\"[CACHE]/builder\", \".\"]]"
+    ],
+    "infra_step": true,
+    "name": "Archive builder",
+    "~followup_annotations": [
+      "@@@STEP_LINK@CAS UI@https://cas-viewer.appspot.com/projects/example-cas-server/instances/default_instance/blobs/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0/tree@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "[START_DIR]/cipd_tool/infra/tools/luci/cas/git_revision%3Amock_infra_git_revision/cas",
+      "archive",
+      "-cas-instance",
+      "projects/example-cas-server/instances/default_instance",
+      "-dump-digest",
+      "/path/to/tmp/",
+      "-paths-json",
+      "[[\"[CACHE]/git\", \".\"]]"
+    ],
+    "infra_step": true,
+    "name": "Archive git",
+    "~followup_annotations": [
+      "@@@STEP_LINK@CAS UI@https://cas-viewer.appspot.com/projects/example-cas-server/instances/default_instance/blobs/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0/tree@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "{\"cache_ttl_microseconds\": 14400000000, \"hashes\": {\"builder\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\", \"git\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\"}, \"last_cache_ts_micro_seconds\": 1684900396429444}",
+      "[CLEANUP]/None-linux.json"
+    ],
+    "infra_step": true,
+    "name": "Write cache metadata",
+    "~followup_annotations": [
+      "@@@STEP_LOG_LINE@None-linux.json@{\"cache_ttl_microseconds\": 14400000000, \"hashes\": {\"builder\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\", \"git\": \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855/0\"}, \"last_cache_ts_micro_seconds\": 1684900396429444}@@@",
+      "@@@STEP_LOG_END@None-linux.json@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "ensure-directory",
+      "--mode",
+      "0777",
+      "[CLEANUP]/tmp_tmp_2/caches"
+    ],
+    "infra_step": true,
+    "name": "Ensure caches"
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/None-linux.json",
+      "[CLEANUP]/tmp_tmp_2/caches"
+    ],
+    "infra_step": true,
+    "name": "Copy [CLEANUP]/None-linux.json to tmp location"
+  },
+  {
+    "cmd": [
+      "python3",
+      "-u",
+      "RECIPE_MODULE[depot_tools::gsutil]/resources/gsutil_smart_retry.py",
+      "--",
+      "RECIPE_REPO[depot_tools]/gsutil.py",
+      "----",
+      "-h",
+      "Cache-Control:max-age=60",
+      "cp",
+      "-r",
+      "[CLEANUP]/tmp_tmp_2/*",
+      "gs://flutter_archives_v2/"
+    ],
+    "infra_step": true,
+    "name": "gsutil Upload [CLEANUP]/None-linux.json to gs://flutter_archives_v2/caches/None-linux.json",
+    "~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/recipes/engine_v2/cache.py b/recipes/engine_v2/cache.py
new file mode 100644
index 0000000..034c4ce
--- /dev/null
+++ b/recipes/engine_v2/cache.py
@@ -0,0 +1,51 @@
+# Copyright 2023 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 PB.recipes.flutter.engine.engine import InputProperties
+from PB.recipes.flutter.engine.engine import EnvProperties
+
+DEPS = [
+    'flutter/cache',
+    'flutter/repo_util',
+    'recipe_engine/path',
+    'recipe_engine/properties',
+    'recipe_engine/file',
+    'recipe_engine/json',
+]
+
+PROPERTIES = InputProperties
+ENV_PROPERTIES = EnvProperties
+
+
+def RunSteps(api, properties, env_properties):
+  # Sets the engine environment and checkouts the source code.
+  checkout = api.path['cache'].join('builder', 'src')
+  api.file.rmtree('Clobber build output', checkout.join('out'))
+  builder_root = api.path['cache'].join('builder')
+  api.file.ensure_directory('Ensure checkout cache', builder_root)
+  env, env_prefixes = api.repo_util.engine_environment(builder_root)
+  # Engine path is used inconsistently across the engine repo. We'll start
+  # with [cache]/builder and will adjust it to start using it consistently.
+  env['ENGINE_PATH'] = api.path['cache'].join('builder')
+  cache_root = api.properties.get('cache_root', 'CACHE')
+  cache_ttl = api.properties.get('cache_ttl', 3600 * 4)
+  cache_name = api.properties.get('cache_name')
+  if api.cache.requires_refresh(cache_name):
+    api.repo_util.engine_checkout(builder_root, env, env_prefixes)
+    paths = [
+        api.path[cache_root].join(p)
+        for p in api.properties.get('cache_paths', [])
+    ]
+    api.cache.write(cache_name, paths, cache_ttl)
+
+
+def GenTests(api):
+  yield api.test(
+      'basic',
+      api.properties(cache_root='cache', cache_paths=['builder', 'git']),
+      api.step_data(
+          'gsutil cat',
+          stdout=api.json.output({}),
+      )
+  )