summaryrefslogtreecommitdiffstats
path: root/tests/test_subcmds_sync.py
diff options
context:
space:
mode:
authorRahul Yadav <yadavrah@google.com>2026-10-01 16:34:47 +0000
committergerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-10-14 03:30:36 -0700
commit3448df395478c2375d880e33bfc705a9ed36272b (patch)
tree4529203b698304d03297b800e28f0bf92436e4c4 /tests/test_subcmds_sync.py
parenta110680ff3e0d3e8504edf4457dff759801ad011 (diff)
downloadgit-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.py182
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
20from pathlib import Path 20from pathlib import Path
21import shutil 21import shutil
22import tempfile 22import tempfile
23import threading
23import time 24import time
24from typing import Dict, List, Optional, Tuple, Type 25from typing import Any, Dict, Iterator, List, Optional, Tuple, Type
25import unittest 26import unittest
26from unittest import mock 27from unittest import mock
28import xmlrpc.client
29import xmlrpc.server
27 30
28import pytest 31import 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
3139class 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
3217def _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
3235class 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}])