Merge ~pappacena/launchpad:mp-refs-privacy-change into launchpad:master
- Git
- lp:~pappacena/launchpad
- mp-refs-privacy-change
- Merge into master
Proposed by
Thiago F. Pappacena
Status: | Needs review |
---|---|
Proposed branch: | ~pappacena/launchpad:mp-refs-privacy-change |
Merge into: | launchpad:master |
Prerequisite: | ~pappacena/launchpad:create-mp-refs |
Diff against target: |
582 lines (+309/-16) 10 files modified
lib/lp/code/configure.zcml (+9/-0) lib/lp/code/interfaces/gitjob.py (+16/-1) lib/lp/code/model/branchmergeproposal.py (+9/-7) lib/lp/code/model/gitjob.py (+63/-1) lib/lp/code/model/gitrepository.py (+11/-1) lib/lp/code/model/tests/test_branchmergeproposal.py (+4/-4) lib/lp/code/model/tests/test_gitjob.py (+127/-1) lib/lp/code/model/tests/test_gitrepository.py (+62/-0) lib/lp/services/config/schema-lazr.conf (+4/-0) lib/lp/testing/fakemethod.py (+4/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Needs Information | ||
Review via email: mp+390940@code.launchpad.net |
Commit message
Background job to add/remove merge proposal's virtual refs when a repository has its privacy changed
Description of the change
To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote : | # |
Added one comment about the complication on private-to-public, just to make sure I understood the scenario. Meanwhile, I'll work out the other details in the MP.
Revision history for this message
Colin Watson (cjwatson) : | # |
Unmerged commits
- dcdb5f4... by Thiago F. Pappacena
-
Merge branch 'create-mp-refs' into mp-refs-
privacy- change - f3bda00... by Thiago F. Pappacena
-
Fixing test
- 9a2f2f7... by Thiago F. Pappacena
-
Retry strategy for virtual refs sync job
- cca2a0f... by Thiago F. Pappacena
-
Merge branch 'create-mp-refs' into mp-refs-
privacy- change - 2ca86e6... by Thiago F. Pappacena
-
Fixing test
- ebd11bb... by Thiago F. Pappacena
-
Adding job to reconcile mp virtual refs
- 3c2ffb2... by Thiago F. Pappacena
-
Trigger MP reconciliation job when changing repository privacy setting
- 30c88c7... by Thiago F. Pappacena
-
Merge branch 'githosting-
copy-and- delete- refs' into create-mp-refs - 7e11cb0... by Thiago F. Pappacena
-
Refactoring virtual refs creation and preparing privacy code
- 1f7f371... by Thiago F. Pappacena
-
Adding feature flag and triggering copy on MP creation
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml |
2 | index 898e645..4adc8d0 100644 |
3 | --- a/lib/lp/code/configure.zcml |
4 | +++ b/lib/lp/code/configure.zcml |
5 | @@ -1111,6 +1111,11 @@ |
6 | provides="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource"> |
7 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJobSource" /> |
8 | </securedutility> |
9 | + <securedutility |
10 | + component="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob" |
11 | + provides="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource"> |
12 | + <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJobSource" /> |
13 | + </securedutility> |
14 | <class class="lp.code.model.gitjob.GitRefScanJob"> |
15 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
16 | <allow interface="lp.code.interfaces.gitjob.IGitRefScanJob" /> |
17 | @@ -1123,6 +1128,10 @@ |
18 | <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
19 | <allow interface="lp.code.interfaces.gitjob.IGitRepositoryModifiedMailJob" /> |
20 | </class> |
21 | + <class class="lp.code.model.gitjob.GitRepositoryVirtualRefsSyncJob"> |
22 | + <allow interface="lp.code.interfaces.gitjob.IGitJob" /> |
23 | + <allow interface="lp.code.interfaces.gitjob.IGitRepositoryVirtualRefsSyncJob" /> |
24 | + </class> |
25 | |
26 | <lp:help-folder folder="help" name="+help-code" /> |
27 | |
28 | diff --git a/lib/lp/code/interfaces/gitjob.py b/lib/lp/code/interfaces/gitjob.py |
29 | index 4f31b19..7c3c038 100644 |
30 | --- a/lib/lp/code/interfaces/gitjob.py |
31 | +++ b/lib/lp/code/interfaces/gitjob.py |
32 | @@ -1,4 +1,4 @@ |
33 | -# Copyright 2015 Canonical Ltd. This software is licensed under the |
34 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
35 | # GNU Affero General Public License version 3 (see the file LICENSE). |
36 | |
37 | """GitJob interfaces.""" |
38 | @@ -11,6 +11,8 @@ __all__ = [ |
39 | 'IGitRefScanJobSource', |
40 | 'IGitRepositoryModifiedMailJob', |
41 | 'IGitRepositoryModifiedMailJobSource', |
42 | + 'IGitRepositoryVirtualRefsSyncJob', |
43 | + 'IGitRepositoryVirtualRefsSyncJobSource', |
44 | 'IReclaimGitRepositorySpaceJob', |
45 | 'IReclaimGitRepositorySpaceJobSource', |
46 | ] |
47 | @@ -93,3 +95,16 @@ class IGitRepositoryModifiedMailJobSource(IJobSource): |
48 | :param repository_delta: An `IGitRepositoryDelta` describing the |
49 | changes. |
50 | """ |
51 | + |
52 | + |
53 | +class IGitRepositoryVirtualRefsSyncJob(IRunnableJob): |
54 | + """A job to synchronize all MPs virtual refs related to this repository.""" |
55 | + |
56 | + |
57 | +class IGitRepositoryVirtualRefsSyncJobSource(IJobSource): |
58 | + |
59 | + def create(repository): |
60 | + """Send email about repository modifications. |
61 | + |
62 | + :param repository: The `IGitRepository` that needs sync. |
63 | + """ |
64 | diff --git a/lib/lp/code/model/branchmergeproposal.py b/lib/lp/code/model/branchmergeproposal.py |
65 | index 8a29a99..bd804a8 100644 |
66 | --- a/lib/lp/code/model/branchmergeproposal.py |
67 | +++ b/lib/lp/code/model/branchmergeproposal.py |
68 | @@ -1247,7 +1247,7 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
69 | self.target_git_repository.getInternalPath(), |
70 | GIT_MP_VIRTUAL_REF_FORMAT.format(mp=self))] |
71 | |
72 | - def copyGitHostingVirtualRefs(self): |
73 | + def copyGitHostingVirtualRefs(self, logger=logger): |
74 | """Requests virtual refs copy operations on GitHosting in order to |
75 | keep them up-to-date with current MP's state. |
76 | |
77 | @@ -1257,10 +1257,10 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
78 | hosting_client = getUtility(IGitHostingClient) |
79 | hosting_client.copyRefs( |
80 | self.source_git_repository.getInternalPath(), |
81 | - copy_operations) |
82 | + copy_operations, logger=logger) |
83 | return copy_operations |
84 | |
85 | - def deleteGitHostingVirtualRefs(self, except_refs=None): |
86 | + def deleteGitHostingVirtualRefs(self, except_refs=None, logger=None): |
87 | """Deletes on git code hosting service all virtual refs, except |
88 | those ones in the given list.""" |
89 | if self.source_git_ref is None: |
90 | @@ -1278,14 +1278,16 @@ class BranchMergeProposal(SQLBase, BugLinkTargetMixin): |
91 | if ref not in (except_refs or []): |
92 | hosting_client = getUtility(IGitHostingClient) |
93 | hosting_client.deleteRef( |
94 | - self.target_git_repository.getInternalPath(), ref) |
95 | + self.target_git_repository.getInternalPath(), ref, |
96 | + logger=logger) |
97 | |
98 | - def syncGitHostingVirtualRefs(self): |
99 | + def syncGitHostingVirtualRefs(self, logger=None): |
100 | """Requests all copies and deletion of virtual refs to make git code |
101 | hosting in sync with this MP.""" |
102 | - operations = self.copyGitHostingVirtualRefs() |
103 | + operations = self.copyGitHostingVirtualRefs(logger=logger) |
104 | copied_refs = [i.target_ref for i in operations] |
105 | - self.deleteGitHostingVirtualRefs(except_refs=copied_refs) |
106 | + self.deleteGitHostingVirtualRefs( |
107 | + except_refs=copied_refs, logger=logger) |
108 | |
109 | def scheduleDiffUpdates(self, return_jobs=True): |
110 | """See `IBranchMergeProposal`.""" |
111 | diff --git a/lib/lp/code/model/gitjob.py b/lib/lp/code/model/gitjob.py |
112 | index 3c041da..d845f59 100644 |
113 | --- a/lib/lp/code/model/gitjob.py |
114 | +++ b/lib/lp/code/model/gitjob.py |
115 | @@ -1,4 +1,4 @@ |
116 | -# Copyright 2015-2018 Canonical Ltd. This software is licensed under the |
117 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
118 | # GNU Affero General Public License version 3 (see the file LICENSE). |
119 | |
120 | __metaclass__ = type |
121 | @@ -45,6 +45,8 @@ from lp.code.interfaces.gitjob import ( |
122 | IGitRefScanJobSource, |
123 | IGitRepositoryModifiedMailJob, |
124 | IGitRepositoryModifiedMailJobSource, |
125 | + IGitRepositoryVirtualRefsSyncJob, |
126 | + IGitRepositoryVirtualRefsSyncJobSource, |
127 | IReclaimGitRepositorySpaceJob, |
128 | IReclaimGitRepositorySpaceJobSource, |
129 | ) |
130 | @@ -100,6 +102,13 @@ class GitJobType(DBEnumeratedType): |
131 | modifications. |
132 | """) |
133 | |
134 | + SYNC_MP_VIRTUAL_REFS = DBItem(3, """ |
135 | + Sync merge proposals virtual refs |
136 | + |
137 | + This job runs against a repository to synchronize the virtual refs |
138 | + from all merge proposals related to this repository. |
139 | + """) |
140 | + |
141 | |
142 | @implementer(IGitJob) |
143 | class GitJob(StormBase): |
144 | @@ -404,3 +413,56 @@ class GitRepositoryModifiedMailJob(GitJobDerived): |
145 | def run(self): |
146 | """See `IGitRepositoryModifiedMailJob`.""" |
147 | self.getMailer().sendAll() |
148 | + |
149 | + |
150 | +@implementer(IGitRepositoryVirtualRefsSyncJob) |
151 | +@provider(IGitRepositoryVirtualRefsSyncJobSource) |
152 | +class GitRepositoryVirtualRefsSyncJob(GitJobDerived): |
153 | + """A Job that scans a Git repository for its current list of references.""" |
154 | + class_job_type = GitJobType.SYNC_MP_VIRTUAL_REFS |
155 | + |
156 | + class RetryException(Exception): |
157 | + pass |
158 | + |
159 | + max_retries = 5 |
160 | + |
161 | + retry_error_types = (RetryException, ) |
162 | + |
163 | + config = config.IGitRepositoryVirtualRefsSyncJobSource |
164 | + |
165 | + @classmethod |
166 | + def create(cls, repository): |
167 | + metadata = {"synced_mp_ids": []} |
168 | + git_job = GitJob(repository, cls.class_job_type, metadata) |
169 | + job = cls(git_job) |
170 | + job.celeryRunOnCommit() |
171 | + return job |
172 | + |
173 | + @property |
174 | + def synced_mp_ids(self): |
175 | + return self.metadata["synced_mp_ids"] |
176 | + |
177 | + def add_synced_mp_id(self, mp_id): |
178 | + self.metadata["synced_mp_ids"].append(mp_id) |
179 | + |
180 | + def run(self): |
181 | + log.info( |
182 | + "Starting to re-sync virtual refs from repository %s", |
183 | + self.repository) |
184 | + failed = False |
185 | + for mp in self.repository.landing_targets: |
186 | + if mp.id in self.synced_mp_ids: |
187 | + continue |
188 | + try: |
189 | + log.info("Re-syncing virtual refs from MP %s", mp) |
190 | + mp.syncGitHostingVirtualRefs(logger=log) |
191 | + self.synced_mp_ids.append(mp.id) |
192 | + except Exception as e: |
193 | + log.info( |
194 | + "Re-syncing virtual refs from MP %s failed: %s", mp, e) |
195 | + failed = True |
196 | + log.info( |
197 | + "Finished re-syncing virtual refs from repository %s%s", |
198 | + self.repository, " with failures" if failed else "") |
199 | + if failed: |
200 | + raise GitRepositoryVirtualRefsSyncJob.RetryException() |
201 | diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py |
202 | index e2451fa..6379fce 100644 |
203 | --- a/lib/lp/code/model/gitrepository.py |
204 | +++ b/lib/lp/code/model/gitrepository.py |
205 | @@ -116,7 +116,10 @@ from lp.code.interfaces.gitcollection import ( |
206 | IGitCollection, |
207 | ) |
208 | from lp.code.interfaces.githosting import IGitHostingClient |
209 | -from lp.code.interfaces.gitjob import IGitRefScanJobSource |
210 | +from lp.code.interfaces.gitjob import ( |
211 | + IGitRefScanJobSource, |
212 | + IGitRepositoryVirtualRefsSyncJobSource, |
213 | + ) |
214 | from lp.code.interfaces.gitlookup import IGitLookup |
215 | from lp.code.interfaces.gitnamespace import ( |
216 | get_git_namespace, |
217 | @@ -866,6 +869,7 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
218 | raise CannotChangeInformationType("Forbidden by project policy.") |
219 | # XXX cjwatson 2019-03-29: Check privacy rules on snaps that use |
220 | # this repository. |
221 | + was_private = self.private |
222 | self.information_type = information_type |
223 | self._reconcileAccess() |
224 | if (information_type in PRIVATE_INFORMATION_TYPES and |
225 | @@ -883,6 +887,12 @@ class GitRepository(StormBase, WebhookTargetMixin, GitIdentityMixin): |
226 | # subscriptions. |
227 | getUtility(IRemoveArtifactSubscriptionsJobSource).create(user, [self]) |
228 | |
229 | + # If privacy changed, we need to re-sync all virtual refs from |
230 | + # all MPs to avoid disclosing private code, or to add the virtual |
231 | + # refs to the now public code. |
232 | + if was_private != self.private: |
233 | + getUtility(IGitRepositoryVirtualRefsSyncJobSource).create(self) |
234 | + |
235 | def setName(self, new_name, user): |
236 | """See `IGitRepository`.""" |
237 | self.namespace.moveRepository(self, user, new_name=new_name) |
238 | diff --git a/lib/lp/code/model/tests/test_branchmergeproposal.py b/lib/lp/code/model/tests/test_branchmergeproposal.py |
239 | index 54cf516..277650c 100644 |
240 | --- a/lib/lp/code/model/tests/test_branchmergeproposal.py |
241 | +++ b/lib/lp/code/model/tests/test_branchmergeproposal.py |
242 | @@ -285,7 +285,7 @@ class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): |
243 | self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
244 | args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
245 | arg_path, arg_copy_operations = args |
246 | - self.assertEqual({}, kwargs) |
247 | + self.assertEqual({'logger': None}, kwargs) |
248 | self.assertEqual(mp.source_git_repository.getInternalPath(), arg_path) |
249 | self.assertEqual(1, len(arg_copy_operations)) |
250 | self.assertThat(arg_copy_operations[0], MatchesStructure( |
251 | @@ -357,7 +357,7 @@ class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): |
252 | # Check lp.code.subscribers.branchmergeproposal.merge_proposal_created. |
253 | self.assertEqual(1, self.hosting_fixture.copyRefs.call_count) |
254 | args, kwargs = self.hosting_fixture.copyRefs.calls[0] |
255 | - self.assertEquals({}, kwargs) |
256 | + self.assertEquals({'logger': None}, kwargs) |
257 | self.assertEqual(args[0], source_repo.getInternalPath()) |
258 | self.assertEqual(1, len(args[1])) |
259 | self.assertThat(args[1][0], MatchesStructure( |
260 | @@ -384,7 +384,7 @@ class TestGitBranchMergeProposalVirtualRefs(TestCaseWithFactory): |
261 | self.assertEqual(0, self.hosting_fixture.copyRefs.call_count) |
262 | self.assertEqual(1, self.hosting_fixture.deleteRef.call_count) |
263 | args, kwargs = self.hosting_fixture.deleteRef.calls[0] |
264 | - self.assertEqual({}, kwargs) |
265 | + self.assertEqual({'logger': None}, kwargs) |
266 | self.assertEqual( |
267 | (target_repo.getInternalPath(), "refs/merge/%s/head" % mp.id), |
268 | args) |
269 | @@ -1658,7 +1658,7 @@ class TestBranchMergeProposalDeletion(TestCaseWithFactory): |
270 | args = hosting_fixture.deleteRef.calls[0] |
271 | self.assertEqual(( |
272 | (proposal.target_git_repository.getInternalPath(), |
273 | - 'refs/merge/%s/head' % proposal.id), {}), args) |
274 | + 'refs/merge/%s/head' % proposal.id), {'logger': None}), args) |
275 | |
276 | |
277 | class TestBranchMergeProposalBugs(WithVCSScenarios, TestCaseWithFactory): |
278 | diff --git a/lib/lp/code/model/tests/test_gitjob.py b/lib/lp/code/model/tests/test_gitjob.py |
279 | index 5964944..c92be62 100644 |
280 | --- a/lib/lp/code/model/tests/test_gitjob.py |
281 | +++ b/lib/lp/code/model/tests/test_gitjob.py |
282 | @@ -1,4 +1,4 @@ |
283 | -# Copyright 2015-2019 Canonical Ltd. This software is licensed under the |
284 | +# Copyright 2015-2020 Canonical Ltd. This software is licensed under the |
285 | # GNU Affero General Public License version 3 (see the file LICENSE). |
286 | |
287 | """Tests for `GitJob`s.""" |
288 | @@ -20,13 +20,16 @@ from testtools.matchers import ( |
289 | ContainsDict, |
290 | Equals, |
291 | MatchesDict, |
292 | + MatchesListwise, |
293 | MatchesSetwise, |
294 | MatchesStructure, |
295 | ) |
296 | import transaction |
297 | +from zope.component import getUtility |
298 | from zope.interface import providedBy |
299 | from zope.security.proxy import removeSecurityProxy |
300 | |
301 | +from lp.app.enums import InformationType |
302 | from lp.code.adapters.gitrepository import GitRepositoryDelta |
303 | from lp.code.enums import ( |
304 | GitGranteeType, |
305 | @@ -38,8 +41,10 @@ from lp.code.interfaces.branchmergeproposal import ( |
306 | from lp.code.interfaces.gitjob import ( |
307 | IGitJob, |
308 | IGitRefScanJob, |
309 | + IGitRepositoryVirtualRefsSyncJobSource, |
310 | IReclaimGitRepositorySpaceJob, |
311 | ) |
312 | +from lp.code.interfaces.gitrepository import GIT_CREATE_MP_VIRTUAL_REF |
313 | from lp.code.model.gitjob import ( |
314 | describe_repository_delta, |
315 | GitJob, |
316 | @@ -49,9 +54,11 @@ from lp.code.model.gitjob import ( |
317 | ReclaimGitRepositorySpaceJob, |
318 | ) |
319 | from lp.code.tests.helpers import GitHostingFixture |
320 | +from lp.services.compat import mock |
321 | from lp.services.config import config |
322 | from lp.services.database.constants import UTC_NOW |
323 | from lp.services.features.testing import FeatureFixture |
324 | +from lp.services.job.interfaces.job import JobStatus |
325 | from lp.services.job.runner import JobRunner |
326 | from lp.services.utils import seconds_since_epoch |
327 | from lp.services.webapp import canonical_url |
328 | @@ -484,5 +491,124 @@ class TestDescribeRepositoryDelta(TestCaseWithFactory): |
329 | snapshot, repository) |
330 | |
331 | |
332 | +class TestGitRepositoryVirtualRefsSyncJob(TestCaseWithFactory): |
333 | + """Tests for `GitRepositoryVirtualRefsSyncJob`.""" |
334 | + |
335 | + layer = ZopelessDatabaseLayer |
336 | + |
337 | + def runJobs(self): |
338 | + with dbuser("branchscanner"): |
339 | + job_set = JobRunner.fromReady( |
340 | + getUtility(IGitRepositoryVirtualRefsSyncJobSource)) |
341 | + job_set.runAll() |
342 | + return job_set |
343 | + |
344 | + def test_changing_repo_to_private_deletes_refs(self): |
345 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
346 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
347 | + mp = self.factory.makeBranchMergeProposalForGit() |
348 | + source_repo = mp.source_git_repository |
349 | + target_repo = mp.target_git_repository |
350 | + source_repo.transitionToInformationType( |
351 | + InformationType.PRIVATESECURITY, source_repo.owner, False) |
352 | + |
353 | + hosting_fixture.copyRefs.resetCalls() |
354 | + hosting_fixture.deleteRef.resetCalls() |
355 | + jobs = self.runJobs() |
356 | + self.assertEqual(1, len(jobs.completed_jobs)) |
357 | + |
358 | + self.assertEqual(0, hosting_fixture.copyRefs.call_count) |
359 | + self.assertEqual(1, hosting_fixture.deleteRef.call_count) |
360 | + args, kwargs = hosting_fixture.deleteRef.calls[0] |
361 | + self.assertEqual( |
362 | + (target_repo.getInternalPath(), 'refs/merge/%s/head' % mp.id), |
363 | + args) |
364 | + |
365 | + def test_changing_repo_to_public_recreates_refs(self): |
366 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
367 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
368 | + mp = self.factory.makeBranchMergeProposalForGit() |
369 | + source_repo = mp.source_git_repository |
370 | + source_repo.transitionToInformationType( |
371 | + InformationType.PRIVATESECURITY, source_repo.owner, False) |
372 | + self.runJobs() |
373 | + |
374 | + # Move it back to public. |
375 | + hosting_fixture.copyRefs.resetCalls() |
376 | + hosting_fixture.deleteRef.resetCalls() |
377 | + source_repo.transitionToInformationType( |
378 | + InformationType.PUBLIC, source_repo.owner, False) |
379 | + jobs = self.runJobs() |
380 | + self.assertEqual(1, len(jobs.completed_jobs)) |
381 | + |
382 | + self.assertEqual(1, hosting_fixture.copyRefs.call_count) |
383 | + self.assertEqual(0, hosting_fixture.deleteRef.call_count) |
384 | + args, kwargs = hosting_fixture.copyRefs.calls[0] |
385 | + self.assertEqual({'logger': mock.ANY}, kwargs) |
386 | + self.assertEqual(2, len(args)) |
387 | + repo, operations = args |
388 | + self.assertEqual(repo, mp.source_git_repository.getInternalPath()) |
389 | + self.assertThat(operations, MatchesListwise([ |
390 | + MatchesStructure( |
391 | + source_ref=Equals(mp.source_git_commit_sha1), |
392 | + target_repo=Equals(mp.target_git_repository.getInternalPath()), |
393 | + target_ref=Equals("refs/merge/%s/head" % mp.id), |
394 | + ) |
395 | + ])) |
396 | + |
397 | + @mock.patch("lp.code.model.branchmergeproposal.BranchMergeProposal." |
398 | + "syncGitHostingVirtualRefs") |
399 | + def test_changing_repo_retry_skips_successful_syncs( |
400 | + self, syncGitHostingVirtualRefs): |
401 | + # Makes sure that successful syncs are not retried on failures. |
402 | + self.useFixture(FeatureFixture({GIT_CREATE_MP_VIRTUAL_REF: "on"})) |
403 | + hosting_fixture = self.useFixture(GitHostingFixture()) |
404 | + mp1 = self.factory.makeBranchMergeProposalForGit() |
405 | + source_repo = mp1.source_git_repository |
406 | + |
407 | + mp2 = self.factory.makeBranchMergeProposalForGit( |
408 | + source_ref=mp1.source_git_ref) |
409 | + mp3 = self.factory.makeBranchMergeProposalForGit( |
410 | + source_ref=mp1.source_git_ref) |
411 | + |
412 | + # The 1st call to syncGitHostingVirtualRefs (mp1) should succeed. |
413 | + # The 2nd call (mp2, first try) should fail |
414 | + # The 3rd call (mp3) should succeed. |
415 | + # Then, 4th call (mp2 again) should succeed. |
416 | + syncGitHostingVirtualRefs.reset_mock() |
417 | + syncGitHostingVirtualRefs.side_effect = [None, Exception(), None, None] |
418 | + |
419 | + # Make source repo private, to trigger the job. |
420 | + hosting_fixture.copyRefs.resetCalls() |
421 | + hosting_fixture.deleteRef.resetCalls() |
422 | + source_repo.transitionToInformationType( |
423 | + InformationType.PRIVATESECURITY, source_repo.owner, False) |
424 | + jobs = self.runJobs() |
425 | + # Should have no completed jobs, since the job should be retried. |
426 | + self.assertEqual( |
427 | + 0, len(jobs.completed_jobs), |
428 | + "No job should have been finished.") |
429 | + self.assertEqual( |
430 | + 1, len(jobs.incomplete_jobs), "Job retry should be pending.") |
431 | + self.assertEqual( |
432 | + 3, syncGitHostingVirtualRefs.call_count, |
433 | + "Even with a failure on mp2, syncGitHostingVirtualRefs should " |
434 | + "have been called for every branch merge proposal.") |
435 | + job = removeSecurityProxy(jobs.incomplete_jobs[0]) |
436 | + self.assertEqual([mp1.id, mp3.id], job.synced_mp_ids) |
437 | + |
438 | + # Run the job again. |
439 | + syncGitHostingVirtualRefs.reset_mock() |
440 | + JobRunner(jobs.incomplete_jobs).runAll() |
441 | + self.assertEqual( |
442 | + JobStatus.COMPLETED, jobs.incomplete_jobs[0].status, |
443 | + "The job should have completed this time, since mp2 sync should " |
444 | + "not have raised exception.") |
445 | + self.assertEqual( |
446 | + 1, syncGitHostingVirtualRefs.call_count, |
447 | + "mp2.syncGitHostingVirtualRefs should be the only call to this " |
448 | + "method, since the sync should have been skipped for mp1 and mp3.") |
449 | + self.assertEqual([mp1.id, mp3.id, mp2.id], job.synced_mp_ids) |
450 | + |
451 | # XXX cjwatson 2015-03-12: We should test that the jobs work via Celery too, |
452 | # but that isn't feasible until we have a proper turnip fixture. |
453 | diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py |
454 | index b0116e1..c561b3c 100644 |
455 | --- a/lib/lp/code/model/tests/test_gitrepository.py |
456 | +++ b/lib/lp/code/model/tests/test_gitrepository.py |
457 | @@ -18,6 +18,7 @@ from datetime import ( |
458 | import email |
459 | from functools import partial |
460 | import hashlib |
461 | +import itertools |
462 | import json |
463 | |
464 | from breezy import urlutils |
465 | @@ -89,6 +90,7 @@ from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository |
466 | from lp.code.interfaces.gitjob import ( |
467 | IGitRefScanJobSource, |
468 | IGitRepositoryModifiedMailJobSource, |
469 | + IGitRepositoryVirtualRefsSyncJobSource, |
470 | ) |
471 | from lp.code.interfaces.gitlookup import IGitLookup |
472 | from lp.code.interfaces.gitnamespace import ( |
473 | @@ -147,6 +149,7 @@ from lp.registry.interfaces.personociproject import IPersonOCIProjectFactory |
474 | from lp.registry.interfaces.personproduct import IPersonProductFactory |
475 | from lp.registry.tests.test_accesspolicy import get_policies_for_artifact |
476 | from lp.services.authserver.xmlrpc import AuthServerAPIView |
477 | +from lp.services.compat import mock |
478 | from lp.services.config import config |
479 | from lp.services.database.constants import UTC_NOW |
480 | from lp.services.database.interfaces import IStore |
481 | @@ -181,6 +184,7 @@ from lp.testing import ( |
482 | verifyObject, |
483 | ) |
484 | from lp.testing.dbuser import dbuser |
485 | +from lp.testing.fixture import ZopeUtilityFixture |
486 | from lp.testing.layers import ( |
487 | DatabaseFunctionalLayer, |
488 | LaunchpadFunctionalLayer, |
489 | @@ -4662,3 +4666,61 @@ class TestGitRepositoryMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory): |
490 | ["Caveat check for '%s' failed." % |
491 | find_caveats_by_name(macaroon2, "lp.expires")[0].caveat_id], |
492 | issuer, macaroon2, repository, user=repository.owner) |
493 | + |
494 | + |
495 | +class TestGitRepositoryPrivacyChangeSyncVirtualRefs(TestCaseWithFactory): |
496 | + layer = DatabaseFunctionalLayer |
497 | + |
498 | + def assertChangePrivacyTriggersSync( |
499 | + self, from_list, to_list, should_trigger_sync=True): |
500 | + """Runs repository.transitionToInformationType from every item in |
501 | + `from_list` to each item in `to_list`, and checks if the virtual |
502 | + refs sync was triggered or not, depending on `should_trigger_sync`.""" |
503 | + sync_job = mock.Mock() |
504 | + self.useFixture(ZopeUtilityFixture( |
505 | + sync_job, IGitRepositoryVirtualRefsSyncJobSource)) |
506 | + |
507 | + admin = self.factory.makeAdministrator() |
508 | + login_person(admin) |
509 | + for from_type, to_type in itertools.product(from_list, to_list): |
510 | + if from_type == to_type: |
511 | + continue |
512 | + repository = self.factory.makeGitRepository() |
513 | + naked_repo = removeSecurityProxy(repository) |
514 | + naked_repo.information_type = from_type |
515 | + # Skip access policy reconciliation. |
516 | + naked_repo._reconcileAccess = mock.Mock() |
517 | + naked_repo.transitionToInformationType(to_type, admin, False) |
518 | + |
519 | + if should_trigger_sync: |
520 | + sync_job.create.assert_called_with(repository) |
521 | + else: |
522 | + self.assertEqual( |
523 | + 0, sync_job.create.call_count, |
524 | + "Changing from %s to %s should't trigger vrefs sync" |
525 | + % (from_type, to_type)) |
526 | + sync_job.reset_mock() |
527 | + |
528 | + def test_setting_repo_public_triggers_ref_sync_job(self): |
529 | + self.assertChangePrivacyTriggersSync( |
530 | + PRIVATE_INFORMATION_TYPES, |
531 | + PUBLIC_INFORMATION_TYPES, |
532 | + should_trigger_sync=True) |
533 | + |
534 | + def test_setting_repo_private_triggers_ref_sync_job(self): |
535 | + self.assertChangePrivacyTriggersSync( |
536 | + PUBLIC_INFORMATION_TYPES, |
537 | + PRIVATE_INFORMATION_TYPES, |
538 | + should_trigger_sync=True) |
539 | + |
540 | + def test_keeping_repo_private_dont_trigger_ref_sync_job(self): |
541 | + self.assertChangePrivacyTriggersSync( |
542 | + PRIVATE_INFORMATION_TYPES, |
543 | + PRIVATE_INFORMATION_TYPES, |
544 | + should_trigger_sync=False) |
545 | + |
546 | + def test_keeping_repo_public_dont_trigger_ref_sync_job(self): |
547 | + self.assertChangePrivacyTriggersSync( |
548 | + PUBLIC_INFORMATION_TYPES, |
549 | + PUBLIC_INFORMATION_TYPES, |
550 | + should_trigger_sync=False) |
551 | diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf |
552 | index bb8afcd..3921da2 100644 |
553 | --- a/lib/lp/services/config/schema-lazr.conf |
554 | +++ b/lib/lp/services/config/schema-lazr.conf |
555 | @@ -1828,6 +1828,10 @@ module: lp.code.interfaces.gitjob |
556 | dbuser: send-branch-mail |
557 | crontab_group: MAIN |
558 | |
559 | +[IGitRepositoryVirtualRefsSyncJobSource] |
560 | +module: lp.code.interfaces.gitjob |
561 | +dbuser: branchscanner |
562 | + |
563 | [IInitializeDistroSeriesJobSource] |
564 | module: lp.soyuz.interfaces.distributionjob |
565 | dbuser: initializedistroseries |
566 | diff --git a/lib/lp/testing/fakemethod.py b/lib/lp/testing/fakemethod.py |
567 | index 4bba8d3..b4895ce 100644 |
568 | --- a/lib/lp/testing/fakemethod.py |
569 | +++ b/lib/lp/testing/fakemethod.py |
570 | @@ -1,4 +1,4 @@ |
571 | -# Copyright 2009-2011 Canonical Ltd. This software is licensed under the |
572 | +# Copyright 2009-2020 Canonical Ltd. This software is licensed under the |
573 | # GNU Affero General Public License version 3 (see the file LICENSE). |
574 | |
575 | __metaclass__ = type |
576 | @@ -57,3 +57,6 @@ class FakeMethod: |
577 | def extract_kwargs(self): |
578 | """Return just the calls' keyword-arguments dicts.""" |
579 | return [kwargs for args, kwargs in self.calls] |
580 | + |
581 | + def resetCalls(self): |
582 | + self.calls = [] |
There's an unfortunate complication around private-to-public transitions that I fear is going to require quite a bit more work to get right (assuming I haven't got the wrong end of the stick somewhere). Details below.