diff options
| author | Rahul Yadav <yadavrah@google.com> | 2026-10-01 16:34:47 +0000 |
|---|---|---|
| committer | gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-10-14 03:30:36 -0700 |
| commit | 3448df395478c2375d880e33bfc705a9ed36272b (patch) | |
| tree | 4529203b698304d03297b800e28f0bf92436e4c4 /tests/test_subcmds_sync.py | |
| parent | a110680ff3e0d3e8504edf4457dff759801ad011 (diff) | |
| download | git-repo-3448df395478c2375d880e33bfc705a9ed36272b.tar.gz | |
sync: Add GetApprovedManifestV2 with named request options
Smart sync calls GetApprovedManifest(branch[, target]) over XML-RPC,
which only supports positional arguments. Adding new request options
that way does not scale.
Add a GetApprovedManifestV2 method that takes a single struct of named
options and returns a struct:
request: {branch, target?, best_effort?}
response: {success, manifest_xml?, error_message?}
Servers must ignore unknown options, so new options (e.g. criteria for
selecting a good build) can be added without another method.
The first new option is `best_effort`. repo sets it when smart sync was
enabled implicitly by the manifest's sync-smartsync="true" default
rather than by -s. The server may decline a best_effort request (e.g.
for rollout or cohort gating) by returning success=false. The client
still owns the fallback policy: declined implicit requests fall back to
a regular sync, while explicit -s requests fail as before.
If the server does not implement GetApprovedManifestV2, repo falls back
to the legacy GetApprovedManifest call.
Bug: 545137090
Test: ./run_tests
Change-Id: I9297b728df26516012cb93dffc91d39c1fb0458f
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/636623
Reviewed-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Rahul Yadav <yadavrah@google.com>
Tested-by: Rahul Yadav <yadavrah@google.com>
Diffstat (limited to 'tests/test_subcmds_sync.py')
| -rw-r--r-- | tests/test_subcmds_sync.py | 182 |
1 files changed, 165 insertions, 17 deletions
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index 8beb6b8e2..e7e8013e0 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py | |||
| @@ -20,10 +20,13 @@ import os | |||
| 20 | from pathlib import Path | 20 | from pathlib import Path |
| 21 | import shutil | 21 | import shutil |
| 22 | import tempfile | 22 | import tempfile |
| 23 | import threading | ||
| 23 | import time | 24 | import time |
| 24 | from typing import Dict, List, Optional, Tuple, Type | 25 | from typing import Any, Dict, Iterator, List, Optional, Tuple, Type |
| 25 | import unittest | 26 | import unittest |
| 26 | from unittest import mock | 27 | from unittest import mock |
| 28 | import xmlrpc.client | ||
| 29 | import xmlrpc.server | ||
| 27 | 30 | ||
| 28 | import pytest | 31 | import pytest |
| 29 | 32 | ||
| @@ -1653,6 +1656,7 @@ class SyncCommand(unittest.TestCase): | |||
| 1653 | ) as mock_warn: | 1656 | ) as mock_warn: |
| 1654 | self.cmd.Execute(self.opt, []) | 1657 | self.cmd.Execute(self.opt, []) |
| 1655 | mock_setup.assert_called_once() | 1658 | mock_setup.assert_called_once() |
| 1659 | self.assertTrue(mock_setup.call_args.kwargs.get("best_effort")) | ||
| 1656 | self.assertFalse(self.opt.smart_sync) | 1660 | self.assertFalse(self.opt.smart_sync) |
| 1657 | self.assertFalse(mock_warn.called) | 1661 | self.assertFalse(mock_warn.called) |
| 1658 | 1662 | ||
| @@ -1689,9 +1693,11 @@ class SyncCommand(unittest.TestCase): | |||
| 1689 | self.cmd, | 1693 | self.cmd, |
| 1690 | "_SmartSyncSetup", | 1694 | "_SmartSyncSetup", |
| 1691 | side_effect=sync.SmartSyncError("unreachable"), | 1695 | side_effect=sync.SmartSyncError("unreachable"), |
| 1692 | ): | 1696 | ) as mock_setup: |
| 1693 | with self.assertRaises(sync.SmartSyncError): | 1697 | with self.assertRaises(sync.SmartSyncError): |
| 1694 | self.cmd.Execute(self.opt, []) | 1698 | self.cmd.Execute(self.opt, []) |
| 1699 | mock_setup.assert_called_once() | ||
| 1700 | self.assertFalse(mock_setup.call_args.kwargs.get("best_effort")) | ||
| 1695 | 1701 | ||
| 1696 | def test_implicit_smart_sync_unparsable_manifest(self) -> None: | 1702 | def test_implicit_smart_sync_unparsable_manifest(self) -> None: |
| 1697 | """Ensure an unparsable smart sync manifest falls back to ToT.""" | 1703 | """Ensure an unparsable smart sync manifest falls back to ToT.""" |
| @@ -2830,10 +2836,10 @@ class TestSmartSyncSetupRemoteHelper(unittest.TestCase): | |||
| 2830 | 2836 | ||
| 2831 | # Mock XML-RPC server call | 2837 | # Mock XML-RPC server call |
| 2832 | mock_server = mock.MagicMock() | 2838 | mock_server = mock.MagicMock() |
| 2833 | mock_server.GetApprovedManifest.return_value = [ | 2839 | mock_server.GetApprovedManifestV2.return_value = { |
| 2834 | True, | 2840 | "success": True, |
| 2835 | "<manifest></manifest>", | 2841 | "manifest_xml": "<manifest></manifest>", |
| 2836 | ] | 2842 | } |
| 2837 | mock_server_class.return_value = mock_server | 2843 | mock_server_class.return_value = mock_server |
| 2838 | 2844 | ||
| 2839 | # Mock manifest project branch | 2845 | # Mock manifest project branch |
| @@ -2885,7 +2891,10 @@ class TestSmartSyncSetupRemoteHelper(unittest.TestCase): | |||
| 2885 | self.manifest.manifest_server_helper = None | 2891 | self.manifest.manifest_server_helper = None |
| 2886 | 2892 | ||
| 2887 | mock_server = mock.MagicMock() | 2893 | mock_server = mock.MagicMock() |
| 2888 | mock_server.GetApprovedManifest.return_value = [True, "<manifest>"] | 2894 | mock_server.GetApprovedManifestV2.return_value = { |
| 2895 | "success": True, | ||
| 2896 | "manifest_xml": "<manifest>", | ||
| 2897 | } | ||
| 2889 | mock_server_class.return_value = mock_server | 2898 | mock_server_class.return_value = mock_server |
| 2890 | 2899 | ||
| 2891 | self.cmd._GetBranch = mock.MagicMock(return_value="main") | 2900 | self.cmd._GetBranch = mock.MagicMock(return_value="main") |
| @@ -3065,10 +3074,10 @@ class TestSmartSyncSetupBranch(unittest.TestCase): | |||
| 3065 | self.manifest.manifest_server_helper = None | 3074 | self.manifest.manifest_server_helper = None |
| 3066 | self.smart_sync_manifest_path = "/fake/path/to/manifest.xml" | 3075 | self.smart_sync_manifest_path = "/fake/path/to/manifest.xml" |
| 3067 | self.mock_server = mock.MagicMock() | 3076 | self.mock_server = mock.MagicMock() |
| 3068 | self.mock_server.GetApprovedManifest.return_value = [ | 3077 | self.mock_server.GetApprovedManifestV2.return_value = { |
| 3069 | True, | 3078 | "success": True, |
| 3070 | "<manifest></manifest>", | 3079 | "manifest_xml": "<manifest></manifest>", |
| 3071 | ] | 3080 | } |
| 3072 | self.cmd._GetBranch = mock.MagicMock(return_value="main") | 3081 | self.cmd._GetBranch = mock.MagicMock(return_value="main") |
| 3073 | self.cmd._ReloadManifest = mock.MagicMock() | 3082 | self.cmd._ReloadManifest = mock.MagicMock() |
| 3074 | self.cmd._ResolveManifestServerTransport = mock.MagicMock( | 3083 | self.cmd._ResolveManifestServerTransport = mock.MagicMock( |
| @@ -3089,14 +3098,16 @@ class TestSmartSyncSetupBranch(unittest.TestCase): | |||
| 3089 | self.cmd._GetBranch.assert_called_once_with( | 3098 | self.cmd._GetBranch.assert_called_once_with( |
| 3090 | self.manifest.manifestProject | 3099 | self.manifest.manifestProject |
| 3091 | ) | 3100 | ) |
| 3092 | self.mock_server.GetApprovedManifest.assert_called_once_with("main") | 3101 | self.mock_server.GetApprovedManifestV2.assert_called_once_with( |
| 3102 | {"branch": "main"} | ||
| 3103 | ) | ||
| 3093 | 3104 | ||
| 3094 | def test_sync_branch(self) -> None: | 3105 | def test_sync_branch(self) -> None: |
| 3095 | """SYNC_BRANCH replaces the manifest branch.""" | 3106 | """SYNC_BRANCH replaces the manifest branch.""" |
| 3096 | self._RunSetup({"SYNC_BRANCH": "git_main-abfs"}) | 3107 | self._RunSetup({"SYNC_BRANCH": "git_main-abfs"}) |
| 3097 | self.cmd._GetBranch.assert_not_called() | 3108 | self.cmd._GetBranch.assert_not_called() |
| 3098 | self.mock_server.GetApprovedManifest.assert_called_once_with( | 3109 | self.mock_server.GetApprovedManifestV2.assert_called_once_with( |
| 3099 | "git_main-abfs" | 3110 | {"branch": "git_main-abfs"} |
| 3100 | ) | 3111 | ) |
| 3101 | 3112 | ||
| 3102 | def test_sync_branch_with_target(self) -> None: | 3113 | def test_sync_branch_with_target(self) -> None: |
| @@ -3107,8 +3118,11 @@ class TestSmartSyncSetupBranch(unittest.TestCase): | |||
| 3107 | "SYNC_TARGET": "cf_x86_64_phone-trunk_staging-eng", | 3118 | "SYNC_TARGET": "cf_x86_64_phone-trunk_staging-eng", |
| 3108 | } | 3119 | } |
| 3109 | ) | 3120 | ) |
| 3110 | self.mock_server.GetApprovedManifest.assert_called_once_with( | 3121 | self.mock_server.GetApprovedManifestV2.assert_called_once_with( |
| 3111 | "git_main-abfs", "cf_x86_64_phone-trunk_staging-eng" | 3122 | { |
| 3123 | "branch": "git_main-abfs", | ||
| 3124 | "target": "cf_x86_64_phone-trunk_staging-eng", | ||
| 3125 | } | ||
| 3112 | ) | 3126 | ) |
| 3113 | 3127 | ||
| 3114 | def test_empty_sync_branch(self) -> None: | 3128 | def test_empty_sync_branch(self) -> None: |
| @@ -3117,4 +3131,138 @@ class TestSmartSyncSetupBranch(unittest.TestCase): | |||
| 3117 | self.cmd._GetBranch.assert_called_once_with( | 3131 | self.cmd._GetBranch.assert_called_once_with( |
| 3118 | self.manifest.manifestProject | 3132 | self.manifest.manifestProject |
| 3119 | ) | 3133 | ) |
| 3120 | self.mock_server.GetApprovedManifest.assert_called_once_with("main") | 3134 | self.mock_server.GetApprovedManifestV2.assert_called_once_with( |
| 3135 | {"branch": "main"} | ||
| 3136 | ) | ||
| 3137 | |||
| 3138 | |||
| 3139 | class GetApprovedManifestTests(unittest.TestCase): | ||
| 3140 | """Tests for _GetApprovedManifest.""" | ||
| 3141 | |||
| 3142 | def test_request_with_target_and_best_effort(self) -> None: | ||
| 3143 | server = mock.MagicMock() | ||
| 3144 | server.GetApprovedManifestV2.return_value = { | ||
| 3145 | "success": True, | ||
| 3146 | "manifest_xml": "<manifest/>", | ||
| 3147 | } | ||
| 3148 | result = sync._GetApprovedManifest(server, "main", "tgt", True) | ||
| 3149 | self.assertEqual(result, (True, "<manifest/>")) | ||
| 3150 | server.GetApprovedManifestV2.assert_called_once_with( | ||
| 3151 | {"branch": "main", "target": "tgt", "best_effort": True} | ||
| 3152 | ) | ||
| 3153 | server.GetApprovedManifest.assert_not_called() | ||
| 3154 | |||
| 3155 | def test_request_omits_unset_options(self) -> None: | ||
| 3156 | server = mock.MagicMock() | ||
| 3157 | server.GetApprovedManifestV2.return_value = { | ||
| 3158 | "success": True, | ||
| 3159 | "manifest_xml": "<manifest/>", | ||
| 3160 | } | ||
| 3161 | sync._GetApprovedManifest(server, "main", None, False) | ||
| 3162 | server.GetApprovedManifestV2.assert_called_once_with({"branch": "main"}) | ||
| 3163 | |||
| 3164 | def test_declined(self) -> None: | ||
| 3165 | server = mock.MagicMock() | ||
| 3166 | server.GetApprovedManifestV2.return_value = { | ||
| 3167 | "success": False, | ||
| 3168 | "error_message": "not in rollout", | ||
| 3169 | } | ||
| 3170 | result = sync._GetApprovedManifest(server, "main", None, True) | ||
| 3171 | self.assertEqual(result, (False, "not in rollout")) | ||
| 3172 | |||
| 3173 | def test_invalid_response(self) -> None: | ||
| 3174 | server = mock.MagicMock() | ||
| 3175 | server.GetApprovedManifestV2.return_value = [True, "<manifest/>"] | ||
| 3176 | with self.assertRaises(sync.SmartSyncError): | ||
| 3177 | sync._GetApprovedManifest(server, "main", None, False) | ||
| 3178 | |||
| 3179 | def test_success_missing_manifest(self) -> None: | ||
| 3180 | server = mock.MagicMock() | ||
| 3181 | server.GetApprovedManifestV2.return_value = {"success": True} | ||
| 3182 | with self.assertRaises(sync.SmartSyncError): | ||
| 3183 | sync._GetApprovedManifest(server, "main", None, False) | ||
| 3184 | |||
| 3185 | def test_success_non_string_manifest(self) -> None: | ||
| 3186 | server = mock.MagicMock() | ||
| 3187 | for value in (None, 42, ["<manifest/>"]): | ||
| 3188 | with self.subTest(manifest_xml=value): | ||
| 3189 | server.GetApprovedManifestV2.return_value = { | ||
| 3190 | "success": True, | ||
| 3191 | "manifest_xml": value, | ||
| 3192 | } | ||
| 3193 | with self.assertRaises(sync.SmartSyncError): | ||
| 3194 | sync._GetApprovedManifest(server, "main", None, False) | ||
| 3195 | |||
| 3196 | def test_fallback_on_method_not_found_code(self) -> None: | ||
| 3197 | server = mock.MagicMock() | ||
| 3198 | server.GetApprovedManifestV2.side_effect = xmlrpc.client.Fault( | ||
| 3199 | -32601, "no such method" | ||
| 3200 | ) | ||
| 3201 | server.GetApprovedManifest.return_value = [True, "<manifest/>"] | ||
| 3202 | result = sync._GetApprovedManifest(server, "main", "tgt", True) | ||
| 3203 | self.assertEqual(result, (True, "<manifest/>")) | ||
| 3204 | server.GetApprovedManifest.assert_called_once_with("main", "tgt") | ||
| 3205 | |||
| 3206 | def test_other_fault_is_not_retried(self) -> None: | ||
| 3207 | server = mock.MagicMock() | ||
| 3208 | server.GetApprovedManifestV2.side_effect = xmlrpc.client.Fault( | ||
| 3209 | 1, "internal error" | ||
| 3210 | ) | ||
| 3211 | with self.assertRaises(xmlrpc.client.Fault): | ||
| 3212 | sync._GetApprovedManifest(server, "main", "tgt", False) | ||
| 3213 | server.GetApprovedManifest.assert_not_called() | ||
| 3214 | |||
| 3215 | |||
| 3216 | @contextlib.contextmanager | ||
| 3217 | def _LiveManifestServer(**funcs: Any) -> Iterator[xmlrpc.client.ServerProxy]: | ||
| 3218 | """Runs a local XML-RPC server exposing |funcs|.""" | ||
| 3219 | server = xmlrpc.server.SimpleXMLRPCServer( | ||
| 3220 | ("127.0.0.1", 0), logRequests=False | ||
| 3221 | ) | ||
| 3222 | for name, func in funcs.items(): | ||
| 3223 | server.register_function(func, name) | ||
| 3224 | thread = threading.Thread(target=server.serve_forever, daemon=True) | ||
| 3225 | thread.start() | ||
| 3226 | try: | ||
| 3227 | host, port = server.server_address | ||
| 3228 | yield xmlrpc.client.ServerProxy(f"http://{host}:{port}/") | ||
| 3229 | finally: | ||
| 3230 | server.shutdown() | ||
| 3231 | server.server_close() | ||
| 3232 | thread.join() | ||
| 3233 | |||
| 3234 | |||
| 3235 | class GetApprovedManifestLiveTests(unittest.TestCase): | ||
| 3236 | """Tests _GetApprovedManifest against a real XML-RPC server.""" | ||
| 3237 | |||
| 3238 | def test_legacy_server_fallback(self) -> None: | ||
| 3239 | calls = [] | ||
| 3240 | |||
| 3241 | def legacy(*args: str) -> List[Any]: | ||
| 3242 | calls.append(args) | ||
| 3243 | return [True, "<manifest/>"] | ||
| 3244 | |||
| 3245 | with _LiveManifestServer(GetApprovedManifest=legacy) as proxy: | ||
| 3246 | self.assertEqual( | ||
| 3247 | sync._GetApprovedManifest(proxy, "main", "tgt", True), | ||
| 3248 | (True, "<manifest/>"), | ||
| 3249 | ) | ||
| 3250 | self.assertEqual( | ||
| 3251 | sync._GetApprovedManifest(proxy, "main", None, True), | ||
| 3252 | (True, "<manifest/>"), | ||
| 3253 | ) | ||
| 3254 | self.assertEqual(calls, [("main", "tgt"), ("main",)]) | ||
| 3255 | |||
| 3256 | def test_v2_server(self) -> None: | ||
| 3257 | requests = [] | ||
| 3258 | |||
| 3259 | def v2(request: Dict[str, Any]) -> Dict[str, Any]: | ||
| 3260 | requests.append(request) | ||
| 3261 | return {"success": False, "error_message": "declined"} | ||
| 3262 | |||
| 3263 | with _LiveManifestServer(GetApprovedManifestV2=v2) as proxy: | ||
| 3264 | self.assertEqual( | ||
| 3265 | sync._GetApprovedManifest(proxy, "main", None, True), | ||
| 3266 | (False, "declined"), | ||
| 3267 | ) | ||
| 3268 | self.assertEqual(requests, [{"branch": "main", "best_effort": True}]) | ||
