Merge ~cjwatson/launchpad:sync-branches into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: c4d60c0fbc0f4b14e024c8fa680ee728cd7ae5bc
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:sync-branches
Merge into: launchpad:master
Diff against target: 294 lines (+276/-0)
3 files modified
lib/lp/codehosting/scripts/sync_branches.py (+86/-0)
lib/lp/codehosting/scripts/tests/test_sync_branches.py (+177/-0)
scripts/sync-branches.py (+13/-0)
Reviewer Review Type Date Requested Status
Ioana Lasc (community) Approve
Review via email: mp+401762@code.launchpad.net

Commit message

Add script to sync branches from production to staging

Description of the change

We currently have a "codehosting_branch_mirror.py" script on (qa)staging that rsyncs a few branches from production for testing. This is a Python script that uses Launchpad's virtualenv, imports our modules, and talks to our database. As a result, it can easily be broken by changes in Launchpad: in particular, it was broken by moving to Python 3.

Bring a version of this script into our tree so that we can make sure it stays working. I modified it considerably, mainly to allow selecting branches on the command line, and added tests.

To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) wrote :

looks good!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/codehosting/scripts/sync_branches.py b/lib/lp/codehosting/scripts/sync_branches.py
2new file mode 100644
3index 0000000..d138a28
4--- /dev/null
5+++ b/lib/lp/codehosting/scripts/sync_branches.py
6@@ -0,0 +1,86 @@
7+# Copyright 2007-2021 Canonical Ltd. This software is licensed under the
8+# GNU Affero General Public License version 3 (see the file LICENSE).
9+
10+"""Sync branches from production to a staging environment."""
11+
12+from __future__ import absolute_import, print_function, unicode_literals
13+
14+__metaclass__ = type
15+__all__ = ['SyncBranchesScript']
16+
17+import os.path
18+try:
19+ from shlex import quote as shell_quote
20+except ImportError:
21+ from pipes import quote as shell_quote
22+import subprocess
23+
24+from zope.component import getUtility
25+
26+from lp.code.interfaces.branch import IBranchSet
27+from lp.codehosting.vfs import branch_id_to_path
28+from lp.services.config import config
29+from lp.services.scripts.base import (
30+ LaunchpadScript,
31+ LaunchpadScriptFailure,
32+ )
33+
34+
35+# We don't want to spend too long syncing branches.
36+BRANCH_LIMIT = 35
37+REMOTE_SERVER = "bazaar.launchpad.net"
38+
39+
40+class SyncBranchesScript(LaunchpadScript):
41+ """Sync branches from production to a staging environment."""
42+
43+ usage = "%prog [options] BRANCH_NAME [...]"
44+ description = __doc__ + "\n" + (
45+ 'Branch names may be given in any of the forms accepted for lp: '
46+ 'URLs, but without the leading "lp:".')
47+
48+ def _syncBranch(self, branch):
49+ branch_path = branch_id_to_path(branch.id)
50+ branch_dir = os.path.join(
51+ config.codehosting.mirrored_branches_root, branch_path)
52+ if not os.path.exists(branch_dir):
53+ os.makedirs(branch_dir)
54+ args = [
55+ "rsync", "-a", "--delete-after",
56+ "%s::mirrors/%s/" % (REMOTE_SERVER, branch_path),
57+ "%s/" % branch_dir,
58+ ]
59+ try:
60+ subprocess.check_output(args, universal_newlines=True)
61+ except subprocess.CalledProcessError as e:
62+ if "No such file or directory" in e.output:
63+ self.logger.warning(
64+ "Branch %s (%s) not found, ignoring",
65+ branch.identity, branch_path)
66+ else:
67+ raise LaunchpadScriptFailure(
68+ "There was an error running: %s\n"
69+ "Status: %s\n"
70+ "Output: %s" % (
71+ " ".join(shell_quote(arg) for arg in args),
72+ e.returncode, e.output.rstrip("\n")))
73+ else:
74+ self.logger.info("Rsynced %s (%s)", branch.identity, branch_path)
75+
76+ def main(self):
77+ branches = []
78+ for branch_name in self.args:
79+ branch = getUtility(IBranchSet).getByPath(branch_name)
80+ if branch is not None:
81+ branches.append(branch)
82+ else:
83+ self.logger.warning("Branch %s does not exist", branch_name)
84+
85+ self.logger.info("There are %d branches to rsync", len(branches))
86+
87+ if len(branches) > BRANCH_LIMIT:
88+ raise LaunchpadScriptFailure(
89+ "Refusing to rsync more than %d branches" % BRANCH_LIMIT)
90+
91+ for branch in branches:
92+ self._syncBranch(branch)
93diff --git a/lib/lp/codehosting/scripts/tests/test_sync_branches.py b/lib/lp/codehosting/scripts/tests/test_sync_branches.py
94new file mode 100644
95index 0000000..9340e88
96--- /dev/null
97+++ b/lib/lp/codehosting/scripts/tests/test_sync_branches.py
98@@ -0,0 +1,177 @@
99+# Copyright 2021 Canonical Ltd. This software is licensed under the
100+# GNU Affero General Public License version 3 (see the file LICENSE).
101+
102+"""Test syncing branches from production to a staging environment."""
103+
104+from __future__ import absolute_import, print_function, unicode_literals
105+
106+__metaclass__ = type
107+
108+import os.path
109+import subprocess
110+from textwrap import dedent
111+
112+from fixtures import (
113+ MockPatch,
114+ TempDir,
115+ )
116+from testtools.matchers import (
117+ DirExists,
118+ Equals,
119+ Matcher,
120+ MatchesListwise,
121+ Not,
122+ )
123+
124+from lp.codehosting.scripts.sync_branches import SyncBranchesScript
125+from lp.codehosting.vfs import branch_id_to_path
126+from lp.services.config import config
127+from lp.services.log.logger import BufferLogger
128+from lp.services.scripts.base import LaunchpadScriptFailure
129+from lp.testing import TestCaseWithFactory
130+from lp.testing.layers import ZopelessDatabaseLayer
131+
132+
133+class BranchDirectoryCreated(Matcher):
134+
135+ def match(self, branch):
136+ return DirExists().match(
137+ os.path.join(
138+ config.codehosting.mirrored_branches_root,
139+ branch_id_to_path(branch.id)))
140+
141+
142+class BranchSyncProcessMatches(MatchesListwise):
143+
144+ def __init__(self, branch):
145+ branch_path = branch_id_to_path(branch.id)
146+ super(BranchSyncProcessMatches, self).__init__([
147+ Equals(([
148+ "rsync", "-a", "--delete-after",
149+ "bazaar.launchpad.net::mirrors/%s/" % branch_path,
150+ "%s/" % os.path.join(
151+ config.codehosting.mirrored_branches_root, branch_path),
152+ ],)),
153+ Equals({"universal_newlines": True}),
154+ ])
155+
156+
157+class TestSyncBranches(TestCaseWithFactory):
158+
159+ layer = ZopelessDatabaseLayer
160+
161+ def setUp(self):
162+ super(TestSyncBranches, self).setUp()
163+ self.tempdir = self.useFixture(TempDir()).path
164+ self.pushConfig("codehosting", mirrored_branches_root=self.tempdir)
165+ self.mock_check_output = self.useFixture(
166+ MockPatch("subprocess.check_output")).mock
167+ self.logger = BufferLogger()
168+
169+ def _runScript(self, branch_names):
170+ script = SyncBranchesScript(
171+ "sync-branches", test_args=branch_names, logger=self.logger)
172+ script.main()
173+
174+ def test_unknown_branch(self):
175+ branch = self.factory.makeBranch()
176+ self._runScript(
177+ [branch.unique_name, branch.unique_name + "-nonexistent"])
178+ self.assertIn(
179+ "WARNING Branch %s-nonexistent does not exist\n" % (
180+ branch.unique_name),
181+ self.logger.getLogBuffer())
182+ # Other branches are synced anyway.
183+ self.assertThat(branch, BranchDirectoryCreated())
184+ self.assertThat(
185+ self.mock_check_output.call_args_list,
186+ MatchesListwise([
187+ BranchSyncProcessMatches(branch),
188+ ]))
189+
190+ def test_too_many_branches(self):
191+ branches = [self.factory.makeBranch() for _ in range(36)]
192+ self.assertRaisesWithContent(
193+ LaunchpadScriptFailure, "Refusing to rsync more than 35 branches",
194+ self._runScript, [branch.unique_name for branch in branches])
195+ for branch in branches:
196+ self.assertThat(branch, Not(BranchDirectoryCreated()))
197+ self.assertEqual([], self.mock_check_output.call_args_list)
198+
199+ def test_branch_storage_missing(self):
200+ branches = [self.factory.makeBranch() for _ in range(2)]
201+ branch_paths = [branch_id_to_path(branch.id) for branch in branches]
202+
203+ def check_output_side_effect(args, **kwargs):
204+ if "%s/%s/" % (self.tempdir, branch_paths[0]) in args:
205+ raise subprocess.CalledProcessError(
206+ 23, args,
207+ output=(
208+ 'rsync: change_dir "/%s" (in mirrors) failed: '
209+ 'No such file or directory (2)' % branch_paths[0]))
210+ else:
211+ return None
212+
213+ self.mock_check_output.side_effect = check_output_side_effect
214+ self._runScript([branch.unique_name for branch in branches])
215+ branch_displays = [
216+ "%s (%s)" % (branch.identity, branch_path)
217+ for branch, branch_path in zip(branches, branch_paths)]
218+ self.assertEqual(
219+ dedent("""\
220+ INFO There are 2 branches to rsync
221+ WARNING Branch {} not found, ignoring
222+ INFO Rsynced {}
223+ """).format(*branch_displays),
224+ self.logger.getLogBuffer())
225+ self.assertThat(
226+ branches,
227+ MatchesListwise([BranchDirectoryCreated() for _ in branches]))
228+ self.assertThat(
229+ self.mock_check_output.call_args_list,
230+ MatchesListwise([
231+ BranchSyncProcessMatches(branch) for branch in branches
232+ ]))
233+
234+ def test_branch_other_rsync_error(self):
235+ branch = self.factory.makeBranch()
236+ self.mock_check_output.side_effect = subprocess.CalledProcessError(
237+ 1, [], output="rsync exploded\n")
238+ self.assertRaisesWithContent(
239+ LaunchpadScriptFailure,
240+ "There was an error running: "
241+ "rsync -a --delete-after "
242+ "bazaar.launchpad.net::mirrors/{}/ {}/{}/\n"
243+ "Status: 1\n"
244+ "Output: rsync exploded".format(
245+ branch_id_to_path(branch.id),
246+ self.tempdir, branch_id_to_path(branch.id)),
247+ self._runScript, [branch.unique_name])
248+ self.assertThat(branch, BranchDirectoryCreated())
249+ self.assertThat(
250+ self.mock_check_output.call_args_list,
251+ MatchesListwise([BranchSyncProcessMatches(branch)]))
252+
253+ def test_success(self):
254+ branches = [self.factory.makeBranch() for _ in range(3)]
255+ branch_paths = [branch_id_to_path(branch.id) for branch in branches]
256+ self._runScript([branch.unique_name for branch in branches])
257+ branch_displays = [
258+ "%s (%s)" % (branch.identity, branch_path)
259+ for branch, branch_path in zip(branches, branch_paths)]
260+ self.assertEqual(
261+ dedent("""\
262+ INFO There are 3 branches to rsync
263+ INFO Rsynced {}
264+ INFO Rsynced {}
265+ INFO Rsynced {}
266+ """).format(*branch_displays),
267+ self.logger.getLogBuffer())
268+ self.assertThat(
269+ branches,
270+ MatchesListwise([BranchDirectoryCreated() for _ in branches]))
271+ self.assertThat(
272+ self.mock_check_output.call_args_list,
273+ MatchesListwise([
274+ BranchSyncProcessMatches(branch) for branch in branches
275+ ]))
276diff --git a/scripts/sync-branches.py b/scripts/sync-branches.py
277new file mode 100755
278index 0000000..b370bd1
279--- /dev/null
280+++ b/scripts/sync-branches.py
281@@ -0,0 +1,13 @@
282+#!/usr/bin/python2 -S
283+# Copyright 2021 Canonical Ltd. All rights reserved.
284+
285+"""Sync branches from production to a staging environment."""
286+
287+import _pythonpath
288+
289+from lp.codehosting.scripts.sync_branches import SyncBranchesScript
290+
291+
292+if __name__ == "__main__":
293+ script = SyncBranchesScript("sync-branches", dbuser="ro")
294+ script.lock_and_run()

Subscribers

People subscribed via source and target branches

to status/vote changes: