Merge lp:~cjwatson/launchpad/export-git-repositories-new into lp:launchpad

Proposed by Colin Watson
Status: Rejected
Rejected by: Colin Watson
Proposed branch: lp:~cjwatson/launchpad/export-git-repositories-new
Merge into: lp:launchpad
Diff against target: 496 lines (+185/-80)
9 files modified
lib/lp/code/errors.py (+5/-1)
lib/lp/code/interfaces/gitnamespace.py (+6/-2)
lib/lp/code/interfaces/gitrepository.py (+15/-2)
lib/lp/code/model/githosting.py (+2/-2)
lib/lp/code/model/gitnamespace.py (+42/-2)
lib/lp/code/model/gitrepository.py (+21/-6)
lib/lp/code/model/tests/test_gitrepository.py (+66/-0)
lib/lp/code/xmlrpc/git.py (+27/-64)
lib/lp/code/xmlrpc/tests/test_git.py (+1/-1)
To merge this branch: bzr merge lp:~cjwatson/launchpad/export-git-repositories-new
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+373267@code.launchpad.net

Commit message

Export IGitRepositorySet.new on the webservice.

Description of the change

For example, this allows snapcraft to create a temporary repository, issue an access token to it, and push code to it, all without needing to configure an SSH key.

In order to make this possible, I had to do some preliminary refactoring to push on-disk repository creation down from the XML-RPC endpoint to the model.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) wrote :

Unmerged revisions

19055. By Colin Watson

Fix GitRepositorySet.new, and export it on the webservice.

This was previously untested, and had been broken since r18222.

Now that namespace.createRepository can create the repository on the hosting
service, we can safely export this to create a bare repository via the API.
For example, this allows snapcraft to create a temporary repository, issue
an access token to it, and push code to it, all without needing to configure
an SSH key.

19054. By Colin Watson

Push Git hosting creation down from XML-RPC endpoint to model.

Having the creation of the actual repository on disk be done by the XML-RPC
endpoint makes it difficult to expose other methods of creating
repositories.

I rearranged how target and owner defaults are set. We have to create the
repository on the hosting service as the last step to avoid problems with
rolling back transactions, so pushing this part down to the model also
requires pushing down target/owner default handling, and the callback
mechanism previously in place wasn't very suited to that.

I had to adjust the permission check in
GitRepositorySet.setDefaultRepository slightly, because pushing down
target/owner default handling is easier if the Unauthorized exception
message is more accurate. setDefaultRepositoryForOwner already raised
exceptions with accurate messages.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/errors.py'
2--- lib/lp/code/errors.py 2018-08-17 11:46:36 +0000
3+++ lib/lp/code/errors.py 2019-09-26 15:27:30 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Errors used in the lp/code modules."""
10@@ -432,6 +432,10 @@
11 class GitRepositoryCreationFault(Exception):
12 """Raised when there is a hosting fault creating a Git repository."""
13
14+ def __init__(self, message, path):
15+ super(GitRepositoryCreationFault, self).__init__(message)
16+ self.path = path
17+
18
19 class GitRepositoryScanFault(Exception):
20 """Raised when there is a fault scanning a repository."""
21
22=== modified file 'lib/lp/code/interfaces/gitnamespace.py'
23--- lib/lp/code/interfaces/gitnamespace.py 2018-07-10 11:26:57 +0000
24+++ lib/lp/code/interfaces/gitnamespace.py 2019-09-26 15:27:30 +0000
25@@ -1,4 +1,4 @@
26-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
27+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
28 # GNU Affero General Public License version 3 (see the file LICENSE).
29
30 """Interface for a Git repository namespace."""
31@@ -32,10 +32,14 @@
32 name = Attribute(
33 "The name of the namespace. This is prepended to the repository name.")
34
35+ owner = Attribute("The `IPerson` who owns this namespace.")
36+
37 target = Attribute("The `IHasGitRepositories` for this namespace.")
38
39 def createRepository(repository_type, registrant, name,
40- information_type=None, date_created=None):
41+ information_type=None, date_created=None,
42+ target_default=False, owner_default=False,
43+ with_hosting=False):
44 """Create and return an `IGitRepository` in this namespace."""
45
46 def isNameUsed(name):
47
48=== modified file 'lib/lp/code/interfaces/gitrepository.py'
49--- lib/lp/code/interfaces/gitrepository.py 2019-05-11 11:16:16 +0000
50+++ lib/lp/code/interfaces/gitrepository.py 2019-09-26 15:27:30 +0000
51@@ -25,6 +25,7 @@
52 export_as_webservice_collection,
53 export_as_webservice_entry,
54 export_destructor_operation,
55+ export_factory_operation,
56 export_operation_as,
57 export_read_operation,
58 export_write_operation,
59@@ -927,10 +928,21 @@
60
61 export_as_webservice_collection(IGitRepository)
62
63- def new(registrant, owner, target, name, information_type=None,
64- date_created=None):
65+ @call_with(
66+ repository_type=GitRepositoryType.HOSTED,
67+ registrant=REQUEST_USER,
68+ with_hosting=True)
69+ @operation_parameters(
70+ information_type=copy_field(
71+ IGitRepositoryView["information_type"], required=False))
72+ @export_factory_operation(IGitRepository, ["owner", "target", "name"])
73+ @operation_for_version("devel")
74+ def new(repository_type, registrant, owner, target, name,
75+ information_type=None, date_created=None, with_hosting=False):
76 """Create a Git repository and return it.
77
78+ :param repository_type: The `GitRepositoryType` of the new
79+ repository.
80 :param registrant: The `IPerson` who registered the new repository.
81 :param owner: The `IPerson` who owns the new repository.
82 :param target: The `IProduct`, `IDistributionSourcePackage`, or
83@@ -939,6 +951,7 @@
84 :param information_type: Set the repository's information type to
85 one different from the target's default. The type must conform
86 to the target's code sharing policy. (optional)
87+ :param with_hosting: Create the repository on the hosting service.
88 """
89
90 # Marker for references to Git URL layouts: ##GITNAMESPACE##
91
92=== modified file 'lib/lp/code/model/githosting.py'
93--- lib/lp/code/model/githosting.py 2018-11-22 16:35:28 +0000
94+++ lib/lp/code/model/githosting.py 2019-09-26 15:27:30 +0000
95@@ -1,4 +1,4 @@
96-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
97+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
98 # GNU Affero General Public License version 3 (see the file LICENSE).
99
100 """Communication with the Git hosting service."""
101@@ -98,7 +98,7 @@
102 self._post("/repo", json=request)
103 except requests.RequestException as e:
104 raise GitRepositoryCreationFault(
105- "Failed to create Git repository: %s" % unicode(e))
106+ "Failed to create Git repository: %s" % unicode(e), path)
107
108 def getProperties(self, path):
109 """See `IGitHostingClient`."""
110
111=== modified file 'lib/lp/code/model/gitnamespace.py'
112--- lib/lp/code/model/gitnamespace.py 2018-07-10 16:36:54 +0000
113+++ lib/lp/code/model/gitnamespace.py 2019-09-26 15:27:30 +0000
114@@ -1,4 +1,4 @@
115-# Copyright 2015-2018 Canonical Ltd. This software is licensed under the
116+# Copyright 2015-2019 Canonical Ltd. This software is licensed under the
117 # GNU Affero General Public License version 3 (see the file LICENSE).
118
119 """Implementations of `IGitNamespace`."""
120@@ -39,6 +39,7 @@
121 GitRepositoryCreatorNotMemberOfOwnerTeam,
122 GitRepositoryCreatorNotOwner,
123 GitRepositoryExists,
124+ GitTargetError,
125 )
126 from lp.code.interfaces.gitcollection import IAllGitRepositories
127 from lp.code.interfaces.gitnamespace import (
128@@ -72,8 +73,11 @@
129
130 def createRepository(self, repository_type, registrant, name,
131 reviewer=None, information_type=None,
132- date_created=DEFAULT, description=None):
133+ date_created=DEFAULT, description=None,
134+ target_default=False, owner_default=False,
135+ with_hosting=False):
136 """See `IGitNamespace`."""
137+ repository_set = getUtility(IGitRepositorySet)
138
139 self.validateRegistrant(registrant)
140 self.validateRepositoryName(name)
141@@ -102,6 +106,42 @@
142
143 notify(ObjectCreatedEvent(repository))
144
145+ if target_default:
146+ repository_set.setDefaultRepository(self.target, repository)
147+ if owner_default:
148+ repository_set.setDefaultRepositoryForOwner(
149+ self.owner, self.target, repository, registrant)
150+
151+ if with_hosting:
152+ # Ask the hosting service to create the repository on disk. Do
153+ # this as late as possible, since if this succeeds it can't
154+ # easily be rolled back with the rest of the transaction.
155+
156+ # Flush to make sure that repository.id is populated.
157+ IStore(repository).flush()
158+ assert repository.id is not None
159+
160+ # If repository has target_default, clone from default.
161+ clone_from_repository = None
162+ try:
163+ default = repository_set.getDefaultRepository(
164+ repository.target)
165+ if default is not None and default.visibleByUser(registrant):
166+ clone_from_repository = default
167+ else:
168+ default = repository_set.getDefaultRepositoryForOwner(
169+ repository.owner, repository.target)
170+ if (default is not None and
171+ default.visibleByUser(registrant)):
172+ clone_from_repository = default
173+ except GitTargetError:
174+ pass # Ignore Personal repositories.
175+ if clone_from_repository == repository:
176+ clone_from_repository = None
177+
178+ repository._createOnHostingService(
179+ clone_from_repository=clone_from_repository)
180+
181 return repository
182
183 def isNameUsed(self, repository_name):
184
185=== modified file 'lib/lp/code/model/gitrepository.py'
186--- lib/lp/code/model/gitrepository.py 2019-09-16 09:05:01 +0000
187+++ lib/lp/code/model/gitrepository.py 2019-09-26 15:27:30 +0000
188@@ -197,7 +197,7 @@
189 cachedproperty,
190 get_property_cache,
191 )
192-from lp.services.webapp.authorization import available_with_permission
193+from lp.services.webapp.authorization import check_permission
194 from lp.services.webapp.interfaces import ILaunchBag
195 from lp.services.webhooks.interfaces import IWebhookSet
196 from lp.services.webhooks.model import WebhookTargetMixin
197@@ -342,6 +342,16 @@
198 self.owner_default = False
199 self.target_default = False
200
201+ def _createOnHostingService(self, clone_from_repository=None):
202+ """Create this repository on the hosting service."""
203+ hosting_path = self.getInternalPath()
204+ if clone_from_repository is not None:
205+ clone_from_path = clone_from_repository.getInternalPath()
206+ else:
207+ clone_from_path = None
208+ getUtility(IGitHostingClient).create(
209+ hosting_path, clone_from=clone_from_path)
210+
211 @property
212 def valid_webhook_event_types(self):
213 return ["git:push:0.1", "merge-proposal:0.1"]
214@@ -1648,13 +1658,15 @@
215 class GitRepositorySet:
216 """See `IGitRepositorySet`."""
217
218- def new(self, registrant, owner, target, name, information_type=None,
219- date_created=DEFAULT, description=None):
220+ def new(self, repository_type, registrant, owner, target, name,
221+ information_type=None, date_created=DEFAULT, description=None,
222+ with_hosting=False):
223 """See `IGitRepositorySet`."""
224 namespace = get_git_namespace(target, owner)
225 return namespace.createRepository(
226- registrant, name, information_type=information_type,
227- date_created=date_created, description=description)
228+ repository_type, registrant, name,
229+ information_type=information_type, date_created=date_created,
230+ description=description, with_hosting=with_hosting)
231
232 def getByPath(self, user, path):
233 """See `IGitRepositorySet`."""
234@@ -1724,13 +1736,16 @@
235 "Personal repositories cannot be defaults for any target.")
236 return IStore(GitRepository).find(GitRepository, *clauses).one()
237
238- @available_with_permission('launchpad.Edit', 'target')
239 def setDefaultRepository(self, target, repository):
240 """See `IGitRepositorySet`."""
241 if IPerson.providedBy(target):
242 raise GitTargetError(
243 "Cannot set a default Git repository for a person, only "
244 "for a project or a package.")
245+ if not check_permission("launchpad.Edit", target):
246+ raise Unauthorized(
247+ "You cannot set the default Git repository for %s." %
248+ target.display_name)
249 if repository is not None and repository.target != target:
250 raise GitTargetError(
251 "Cannot set default Git repository to one attached to "
252
253=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
254--- lib/lp/code/model/tests/test_gitrepository.py 2019-09-16 09:05:01 +0000
255+++ lib/lp/code/model/tests/test_gitrepository.py 2019-09-26 15:27:30 +0000
256@@ -26,6 +26,7 @@
257 from storm.store import Store
258 from testtools.matchers import (
259 AnyMatch,
260+ ContainsDict,
261 EndsWith,
262 Equals,
263 Is,
264@@ -2990,6 +2991,35 @@
265 super(TestGitRepositorySet, self).setUp()
266 self.repository_set = getUtility(IGitRepositorySet)
267
268+ def test_new(self):
269+ # By default, GitRepositorySet.new creates a new repository in the
270+ # database but not on the hosting service.
271+ hosting_fixture = self.useFixture(GitHostingFixture())
272+ owner = self.factory.makePerson()
273+ target = self.factory.makeProduct()
274+ name = self.factory.getUniqueUnicode()
275+ repository = self.repository_set.new(
276+ GitRepositoryType.HOSTED, owner, owner, target, name)
277+ self.assertThat(repository, MatchesStructure.byEquality(
278+ registrant=owner, owner=owner, target=target, name=name))
279+ self.assertEqual(0, hosting_fixture.create.call_count)
280+
281+ def test_new_with_hosting(self):
282+ # GitRepositorySet.new(with_hosting=True) creates a new repository
283+ # in both the database and the hosting service.
284+ hosting_fixture = self.useFixture(GitHostingFixture())
285+ owner = self.factory.makePerson()
286+ target = self.factory.makeProduct()
287+ name = self.factory.getUniqueUnicode()
288+ repository = self.repository_set.new(
289+ GitRepositoryType.HOSTED, owner, owner, target, name,
290+ with_hosting=True)
291+ self.assertThat(repository, MatchesStructure.byEquality(
292+ registrant=owner, owner=owner, target=target, name=name))
293+ self.assertEqual(
294+ [((repository.getInternalPath(),), {"clone_from": None})],
295+ hosting_fixture.create.calls)
296+
297 def test_provides_IGitRepositorySet(self):
298 # GitRepositorySet instances provide IGitRepositorySet.
299 verifyObject(IGitRepositorySet, self.repository_set)
300@@ -3370,6 +3400,42 @@
301 "git+ssh://git.launchpad.test/~person/project/+git/repository",
302 repository["git_ssh_url"])
303
304+ def assertNewWorks(self, target_db):
305+ hosting_fixture = self.useFixture(GitHostingFixture())
306+ if IPerson.providedBy(target_db):
307+ owner_db = target_db
308+ else:
309+ owner_db = self.factory.makePerson()
310+ owner_url = api_url(owner_db)
311+ target_url = api_url(target_db)
312+ name = "repository"
313+ webservice = webservice_for_person(
314+ owner_db, permission=OAuthPermission.WRITE_PUBLIC)
315+ webservice.default_api_version = "devel"
316+ response = webservice.named_post(
317+ "/+git", "new", owner=owner_url, target=target_url, name=name)
318+ self.assertEqual(201, response.status)
319+ repository = webservice.get(response.getHeader("Location")).jsonBody()
320+ self.assertThat(repository, ContainsDict({
321+ "repository_type": Equals("Hosted"),
322+ "registrant_link": EndsWith(owner_url),
323+ "owner_link": EndsWith(owner_url),
324+ "target_link": EndsWith(target_url),
325+ "name": Equals(name),
326+ "owner_default": Is(False),
327+ "target_default": Is(False),
328+ }))
329+ self.assertEqual(1, hosting_fixture.create.call_count)
330+
331+ def test_new_project(self):
332+ self.assertNewWorks(self.factory.makeProduct())
333+
334+ def test_new_package(self):
335+ self.assertNewWorks(self.factory.makeDistributionSourcePackage())
336+
337+ def test_new_person(self):
338+ self.assertNewWorks(self.factory.makePerson())
339+
340 def assertGetRepositoriesWorks(self, target_db):
341 if IPerson.providedBy(target_db):
342 owner_db = target_db
343
344=== modified file 'lib/lp/code/xmlrpc/git.py'
345--- lib/lp/code/xmlrpc/git.py 2019-09-10 09:58:52 +0000
346+++ lib/lp/code/xmlrpc/git.py 2019-09-26 15:27:30 +0000
347@@ -13,7 +13,6 @@
348 from pymacaroons import Macaroon
349 import six
350 from six.moves import xmlrpc_client
351-from storm.store import Store
352 import transaction
353 from zope.component import (
354 ComponentLookupError,
355@@ -36,7 +35,6 @@
356 GitRepositoryCreationFault,
357 GitRepositoryCreationForbidden,
358 GitRepositoryExists,
359- GitTargetError,
360 InvalidNamespace,
361 )
362 from lp.code.interfaces.codehosting import (
363@@ -44,7 +42,6 @@
364 LAUNCHPAD_SERVICES,
365 )
366 from lp.code.interfaces.gitapi import IGitAPI
367-from lp.code.interfaces.githosting import IGitHostingClient
368 from lp.code.interfaces.gitjob import IGitRefScanJobSource
369 from lp.code.interfaces.gitlookup import (
370 IGitLookup,
371@@ -282,20 +279,15 @@
372 if repository_name is None and not namespace.has_defaults:
373 raise InvalidNamespace(path)
374 if repository_name is None:
375- def default_func(new_repository):
376- if owner is None:
377- self.repository_set.setDefaultRepository(
378- target, new_repository)
379- if (owner is not None or
380- self.repository_set.getDefaultRepositoryForOwner(
381- repository_owner, target) is None):
382- self.repository_set.setDefaultRepositoryForOwner(
383- repository_owner, target, new_repository, requester)
384-
385 repository_name = namespace.findUnusedName(target.name)
386- return namespace, repository_name, default_func
387+ target_default = owner is None
388+ owner_default = (
389+ owner is None or
390+ self.repository_set.getDefaultRepositoryForOwner(
391+ repository_owner, target) is None)
392+ return namespace, repository_name, target_default, owner_default
393 else:
394- return namespace, repository_name, None
395+ return namespace, repository_name, False, False
396
397 def _reportError(self, path, exception, hosting_path=None):
398 properties = [
399@@ -310,7 +302,7 @@
400
401 def _createRepository(self, requester, path, clone_from=None):
402 try:
403- namespace, repository_name, default_func = (
404+ namespace, repository_name, target_default, owner_default = (
405 self._getGitNamespaceExtras(path, requester))
406 except InvalidNamespace:
407 raise faults.PermissionDenied(
408@@ -331,56 +323,27 @@
409 raise faults.PermissionDenied(unicode(e))
410
411 try:
412- repository = namespace.createRepository(
413- GitRepositoryType.HOSTED, requester, repository_name)
414- except LaunchpadValidationError as e:
415- # Despite the fault name, this just passes through the exception
416- # text so there's no need for a new Git-specific fault.
417- raise faults.InvalidBranchName(e)
418- except GitRepositoryExists as e:
419- # We should never get here, as we just tried to translate the
420- # path and found nothing (not even an inaccessible private
421- # repository). Log an OOPS for investigation.
422- self._reportError(path, e)
423- except GitRepositoryCreationException as e:
424- raise faults.PermissionDenied(unicode(e))
425-
426- try:
427- if default_func:
428- try:
429- default_func(repository)
430- except Unauthorized:
431- raise faults.PermissionDenied(
432- "You cannot set the default Git repository for '%s'." %
433- path)
434-
435- # Flush to make sure that repository.id is populated.
436- Store.of(repository).flush()
437- assert repository.id is not None
438-
439- # If repository has target_default, clone from default.
440- target_path = None
441- try:
442- default = self.repository_set.getDefaultRepository(
443- repository.target)
444- if default is not None and default.visibleByUser(requester):
445- target_path = default.getInternalPath()
446- else:
447- default = self.repository_set.getDefaultRepositoryForOwner(
448- repository.owner, repository.target)
449- if (default is not None and
450- default.visibleByUser(requester)):
451- target_path = default.getInternalPath()
452- except GitTargetError:
453- pass # Ignore Personal repositories.
454-
455- hosting_path = repository.getInternalPath()
456- try:
457- getUtility(IGitHostingClient).create(
458- hosting_path, clone_from=target_path)
459+ try:
460+ namespace.createRepository(
461+ GitRepositoryType.HOSTED, requester, repository_name,
462+ target_default=target_default, owner_default=owner_default,
463+ with_hosting=True)
464+ except LaunchpadValidationError as e:
465+ # Despite the fault name, this just passes through the
466+ # exception text so there's no need for a new Git-specific
467+ # fault.
468+ raise faults.InvalidBranchName(e)
469+ except GitRepositoryExists as e:
470+ # We should never get here, as we just tried to translate
471+ # the path and found nothing (not even an inaccessible
472+ # private repository). Log an OOPS for investigation.
473+ self._reportError(path, e)
474+ except (GitRepositoryCreationException, Unauthorized) as e:
475+ raise faults.PermissionDenied(unicode(e))
476 except GitRepositoryCreationFault as e:
477 # The hosting service failed. Log an OOPS for investigation.
478- self._reportError(path, e, hosting_path=hosting_path)
479+ self._reportError(path, e, hosting_path=e.path)
480+ raise
481 except Exception:
482 # We don't want to keep the repository we created.
483 transaction.abort()
484
485=== modified file 'lib/lp/code/xmlrpc/tests/test_git.py'
486--- lib/lp/code/xmlrpc/tests/test_git.py 2019-09-05 11:29:00 +0000
487+++ lib/lp/code/xmlrpc/tests/test_git.py 2019-09-26 15:27:30 +0000
488@@ -908,7 +908,7 @@
489 # If the hosting service is down, trying to create a repository
490 # fails and doesn't leave junk around in the Launchpad database.
491 self.hosting_fixture.create.failure = GitRepositoryCreationFault(
492- "nothing here")
493+ "nothing here", path="123")
494 requester = self.factory.makePerson()
495 initial_count = getUtility(IAllGitRepositories).count()
496 oops_id = self.assertOopsOccurred(