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({}),
+ )
+ )