Merge lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad

Proposed by Colin Watson on 2015-03-03
Status: Merged
Merged at revision: 17373
Proposed branch: lp:~cjwatson/launchpad/git-xmlrpc
Merge into: lp:launchpad
Diff against target: 1255 lines (+1007/-7)
17 files modified
lib/lp/code/errors.py (+1/-1)
lib/lp/code/githosting.py (+52/-0)
lib/lp/code/interfaces/gitapi.py (+51/-0)
lib/lp/code/interfaces/gitnamespace.py (+7/-0)
lib/lp/code/interfaces/gitrepository.py (+1/-0)
lib/lp/code/model/gitlookup.py (+2/-0)
lib/lp/code/model/gitnamespace.py (+9/-0)
lib/lp/code/model/tests/test_gitlookup.py (+6/-0)
lib/lp/code/xmlrpc/codehosting.py (+2/-1)
lib/lp/code/xmlrpc/git.py (+234/-0)
lib/lp/code/xmlrpc/tests/test_git.py (+584/-0)
lib/lp/systemhomes.py (+8/-1)
lib/lp/xmlrpc/application.py (+7/-1)
lib/lp/xmlrpc/configure.zcml (+18/-1)
lib/lp/xmlrpc/faults.py (+21/-1)
lib/lp/xmlrpc/interfaces.py (+3/-1)
setup.py (+1/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/git-xmlrpc
Reviewer Review Type Date Requested Status
William Grant code 2015-03-03 Approve on 2015-03-04
Review via email: mp+251541@code.launchpad.net

Commit message

Add a private XML-RPC endpoint for Git-related operations needed by the hosting service.

Description of the change

Add a private XML-RPC endpoint for Git-related operations needed by the hosting service.

I noticed a bug in GitLookup along the way, and there's a subtlety in getting hold of the hosting path because it relies on the SERIAL id column, so we have to use currval to grab that before committing the transaction in order that we can roll back the GitRepository row creation if we fail to create the repository on the hosting service.

To post a comment you must log in.
William Grant (wgrant) :
review: Needs Fixing (code)
Colin Watson (cjwatson) wrote :

I think I've fixed all this now, aside from one of your comments to which I've replied inline.

William Grant (wgrant) :
review: Approve (code)
Colin Watson (cjwatson) :

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 2015-02-26 12:10:20 +0000
3+++ lib/lp/code/errors.py 2015-03-04 11:12:53 +0000
4@@ -367,7 +367,7 @@
5 """
6
7
8-class GitRepositoryCreationFault(GitRepositoryCreationException):
9+class GitRepositoryCreationFault(Exception):
10 """Raised when there is a hosting fault creating a Git repository."""
11
12
13
14=== added file 'lib/lp/code/githosting.py'
15--- lib/lp/code/githosting.py 1970-01-01 00:00:00 +0000
16+++ lib/lp/code/githosting.py 2015-03-04 11:12:53 +0000
17@@ -0,0 +1,52 @@
18+# Copyright 2015 Canonical Ltd. This software is licensed under the
19+# GNU Affero General Public License version 3 (see the file LICENSE).
20+
21+"""Communication with the Git hosting service."""
22+
23+__metaclass__ = type
24+__all__ = [
25+ 'GitHostingClient',
26+ ]
27+
28+import json
29+from urlparse import urljoin
30+
31+import requests
32+
33+from lp.code.errors import GitRepositoryCreationFault
34+
35+
36+class GitHostingClient:
37+ """A client for the internal API provided by the Git hosting system."""
38+
39+ def __init__(self, endpoint):
40+ self.endpoint = endpoint
41+
42+ def _makeSession(self):
43+ session = requests.Session()
44+ session.trust_env = False
45+ return session
46+
47+ @property
48+ def timeout(self):
49+ # XXX cjwatson 2015-03-01: The hardcoded timeout at least means that
50+ # we don't lock tables indefinitely if the hosting service falls
51+ # over, but is there some more robust way to do this?
52+ return 5.0
53+
54+ def create(self, path):
55+ try:
56+ # XXX cjwatson 2015-03-01: Once we're on requests >= 2.4.2, we
57+ # should just use post(json=) and drop the explicit Content-Type
58+ # header.
59+ response = self._makeSession().post(
60+ urljoin(self.endpoint, "repo"),
61+ headers={"Content-Type": "application/json"},
62+ data=json.dumps({"repo_path": path, "bare_repo": True}),
63+ timeout=self.timeout)
64+ except Exception as e:
65+ raise GitRepositoryCreationFault(
66+ "Failed to create Git repository: %s" % unicode(e))
67+ if response.status_code != 200:
68+ raise GitRepositoryCreationFault(
69+ "Failed to create Git repository: %s" % response.text)
70
71=== added file 'lib/lp/code/interfaces/gitapi.py'
72--- lib/lp/code/interfaces/gitapi.py 1970-01-01 00:00:00 +0000
73+++ lib/lp/code/interfaces/gitapi.py 2015-03-04 11:12:53 +0000
74@@ -0,0 +1,51 @@
75+# Copyright 2015 Canonical Ltd. This software is licensed under the
76+# GNU Affero General Public License version 3 (see the file LICENSE).
77+
78+"""Interfaces for internal Git APIs."""
79+
80+__metaclass__ = type
81+__all__ = [
82+ 'IGitAPI',
83+ 'IGitApplication',
84+ ]
85+
86+from zope.interface import Interface
87+
88+from lp.services.webapp.interfaces import ILaunchpadApplication
89+
90+
91+class IGitApplication(ILaunchpadApplication):
92+ """Git application root."""
93+
94+
95+class IGitAPI(Interface):
96+ """The Git XML-RPC interface to Launchpad.
97+
98+ Published at "git" on the private XML-RPC server.
99+
100+ The Git pack frontend uses this to translate user-visible paths to
101+ internal ones, and to notify Launchpad of ref changes.
102+ """
103+
104+ def translatePath(path, permission, requester_id, can_authenticate):
105+ """Translate 'path' so that the Git pack frontend can access it.
106+
107+ If the repository does not exist and write permission was requested,
108+ register a new repository if possible.
109+
110+ :param path: The path being translated. This should be a string
111+ representing an absolute path to a Git repository.
112+ :param permission: "read" or "write".
113+ :param requester_id: The database ID of the person requesting the
114+ path translation, or None for an anonymous request.
115+ :param can_authenticate: True if the frontend can request
116+ authentication, otherwise False.
117+
118+ :returns: A `PathTranslationError` fault if 'path' cannot be
119+ translated; a `PermissionDenied` fault if the requester cannot
120+ see or create the repository; otherwise, a dict containing at
121+ least the following keys::
122+ "path", whose value is the repository's storage path;
123+ "writable", whose value is True if the requester can push to
124+ this repository, otherwise False.
125+ """
126
127=== modified file 'lib/lp/code/interfaces/gitnamespace.py'
128--- lib/lp/code/interfaces/gitnamespace.py 2015-02-26 17:16:57 +0000
129+++ lib/lp/code/interfaces/gitnamespace.py 2015-03-04 11:12:53 +0000
130@@ -86,6 +86,13 @@
131 class IGitNamespacePolicy(Interface):
132 """Methods relating to Git repository creation and validation."""
133
134+ has_defaults = Attribute(
135+ "True iff the target of this namespace may have a default repository.")
136+
137+ allow_push_to_set_default = Attribute(
138+ "True iff this namespace permits automatically setting a default "
139+ "repository on push.")
140+
141 def getAllowedInformationTypes(who):
142 """Get the information types that a repository in this namespace can
143 have.
144
145=== modified file 'lib/lp/code/interfaces/gitrepository.py'
146--- lib/lp/code/interfaces/gitrepository.py 2015-02-26 11:34:47 +0000
147+++ lib/lp/code/interfaces/gitrepository.py 2015-03-04 11:12:53 +0000
148@@ -7,6 +7,7 @@
149
150 __all__ = [
151 'GitIdentityMixin',
152+ 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
153 'git_repository_name_validator',
154 'IGitRepository',
155 'IGitRepositorySet',
156
157=== modified file 'lib/lp/code/model/gitlookup.py'
158--- lib/lp/code/model/gitlookup.py 2015-02-27 10:22:24 +0000
159+++ lib/lp/code/model/gitlookup.py 2015-03-04 11:12:53 +0000
160@@ -342,6 +342,8 @@
161 return None
162 if repository is not None:
163 return repository
164+ if IPerson.providedBy(target):
165+ return None
166 repository_set = getUtility(IGitRepositorySet)
167 if owner is None:
168 return repository_set.getDefaultRepository(target)
169
170=== modified file 'lib/lp/code/model/gitnamespace.py'
171--- lib/lp/code/model/gitnamespace.py 2015-02-26 17:16:57 +0000
172+++ lib/lp/code/model/gitnamespace.py 2015-03-04 11:12:53 +0000
173@@ -190,6 +190,9 @@
174
175 implements(IGitNamespace, IGitNamespacePolicy)
176
177+ has_defaults = False
178+ allow_push_to_set_default = False
179+
180 def __init__(self, person):
181 self.owner = person
182
183@@ -247,6 +250,9 @@
184
185 implements(IGitNamespace, IGitNamespacePolicy)
186
187+ has_defaults = True
188+ allow_push_to_set_default = True
189+
190 def __init__(self, person, project):
191 self.owner = person
192 self.project = project
193@@ -307,6 +313,9 @@
194
195 implements(IGitNamespace, IGitNamespacePolicy)
196
197+ has_defaults = True
198+ allow_push_to_set_default = False
199+
200 def __init__(self, person, distro_source_package):
201 self.owner = person
202 self.distro_source_package = distro_source_package
203
204=== modified file 'lib/lp/code/model/tests/test_gitlookup.py'
205--- lib/lp/code/model/tests/test_gitlookup.py 2015-02-27 10:22:24 +0000
206+++ lib/lp/code/model/tests/test_gitlookup.py 2015-03-04 11:12:53 +0000
207@@ -117,6 +117,12 @@
208 project = self.factory.makeProduct()
209 self.assertIsNone(self.lookup.getByPath(project.name))
210
211+ def test_bare_person(self):
212+ # If `getByPath` is given a path to a person but nothing further, it
213+ # returns None even if the person exists.
214+ owner = self.factory.makePerson()
215+ self.assertIsNone(self.lookup.getByPath("~%s" % owner.name))
216+
217
218 class TestGetByUrl(TestCaseWithFactory):
219 """Test `IGitLookup.getByUrl`."""
220
221=== modified file 'lib/lp/code/xmlrpc/codehosting.py'
222--- lib/lp/code/xmlrpc/codehosting.py 2012-11-26 08:33:03 +0000
223+++ lib/lp/code/xmlrpc/codehosting.py 2015-03-04 11:12:53 +0000
224@@ -1,4 +1,4 @@
225-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
226+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
227 # GNU Affero General Public License version 3 (see the file LICENSE).
228
229 """Implementations of the XML-RPC APIs for codehosting."""
230@@ -7,6 +7,7 @@
231 __all__ = [
232 'CodehostingAPI',
233 'datetime_from_tuple',
234+ 'run_with_login',
235 ]
236
237
238
239=== added file 'lib/lp/code/xmlrpc/git.py'
240--- lib/lp/code/xmlrpc/git.py 1970-01-01 00:00:00 +0000
241+++ lib/lp/code/xmlrpc/git.py 2015-03-04 11:12:53 +0000
242@@ -0,0 +1,234 @@
243+# Copyright 2015 Canonical Ltd. This software is licensed under the
244+# GNU Affero General Public License version 3 (see the file LICENSE).
245+
246+"""Implementations of the XML-RPC APIs for Git."""
247+
248+__metaclass__ = type
249+__all__ = [
250+ 'GitAPI',
251+ ]
252+
253+import sys
254+
255+from storm.store import Store
256+import transaction
257+from zope.component import getUtility
258+from zope.error.interfaces import IErrorReportingUtility
259+from zope.interface import implements
260+from zope.security.interfaces import Unauthorized
261+
262+from lp.app.errors import NameLookupFailed
263+from lp.app.validators import LaunchpadValidationError
264+from lp.code.errors import (
265+ GitRepositoryCreationException,
266+ GitRepositoryCreationForbidden,
267+ GitRepositoryCreationFault,
268+ GitRepositoryExists,
269+ InvalidNamespace,
270+ )
271+from lp.code.githosting import GitHostingClient
272+from lp.code.interfaces.codehosting import LAUNCHPAD_ANONYMOUS
273+from lp.code.interfaces.gitapi import IGitAPI
274+from lp.code.interfaces.gitlookup import (
275+ IGitLookup,
276+ IGitTraverser,
277+ )
278+from lp.code.interfaces.gitnamespace import (
279+ get_git_namespace,
280+ split_git_unique_name,
281+ )
282+from lp.code.interfaces.gitrepository import IGitRepositorySet
283+from lp.code.xmlrpc.codehosting import run_with_login
284+from lp.registry.errors import (
285+ InvalidName,
286+ NoSuchSourcePackageName,
287+ )
288+from lp.registry.interfaces.person import NoSuchPerson
289+from lp.registry.interfaces.product import (
290+ InvalidProductName,
291+ NoSuchProduct,
292+ )
293+from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
294+from lp.services.config import config
295+from lp.services.webapp import LaunchpadXMLRPCView
296+from lp.services.webapp.authorization import check_permission
297+from lp.services.webapp.errorlog import ScriptRequest
298+from lp.xmlrpc import faults
299+from lp.xmlrpc.helpers import return_fault
300+
301+
302+class GitAPI(LaunchpadXMLRPCView):
303+ """See `IGitAPI`."""
304+
305+ implements(IGitAPI)
306+
307+ def __init__(self, *args, **kwargs):
308+ super(GitAPI, self).__init__(*args, **kwargs)
309+ self.hosting_client = GitHostingClient(
310+ config.codehosting.internal_git_api_endpoint)
311+
312+ def _performLookup(self, path):
313+ repository = getUtility(IGitLookup).getByPath(path)
314+ if repository is None:
315+ return None
316+ try:
317+ hosting_path = repository.getInternalPath()
318+ except Unauthorized:
319+ raise faults.PermissionDenied()
320+ writable = check_permission("launchpad.Edit", repository)
321+ return {"path": hosting_path, "writable": writable}
322+
323+ def _getGitNamespaceExtras(self, path, requester):
324+ """Get the namespace, repository name, and callback for the path.
325+
326+ If the path defines a full Git repository path including the owner
327+ and repository name, then the namespace that is returned is the
328+ namespace for the owner and the repository target specified.
329+
330+ If the path uses a shortcut name, then we only allow the requester
331+ to create a repository if they have permission to make the newly
332+ created repository the default for the shortcut target. If there is
333+ an existing default repository, then GitRepositoryExists is raised.
334+ The repository name that is used is determined by the namespace as
335+ the first unused name starting with the leaf part of the namespace
336+ name. In this case, the repository owner will be set to the
337+ namespace owner, and distribution source package namespaces are
338+ currently disallowed due to the complexities of ownership there.
339+ """
340+ try:
341+ namespace_name, repository_name = split_git_unique_name(path)
342+ except InvalidNamespace:
343+ namespace_name = path
344+ repository_name = None
345+ owner, target, repository = getUtility(IGitTraverser).traverse_path(
346+ namespace_name)
347+ # split_git_unique_name should have left us without a repository name.
348+ assert repository is None
349+ if owner is None:
350+ repository_owner = requester
351+ else:
352+ repository_owner = owner
353+ namespace = get_git_namespace(target, repository_owner)
354+ if repository_name is None and not namespace.has_defaults:
355+ raise InvalidNamespace(path)
356+ if owner is None and not namespace.allow_push_to_set_default:
357+ raise GitRepositoryCreationForbidden(
358+ "Cannot automatically set the default repository for this "
359+ "target; push to a named repository instead.")
360+ if repository_name is None:
361+ def default_func(new_repository):
362+ repository_set = getUtility(IGitRepositorySet)
363+ if owner is None:
364+ repository_set.setDefaultRepository(
365+ target, new_repository)
366+ else:
367+ repository_set.setDefaultRepositoryForOwner(
368+ owner, target, new_repository)
369+
370+ repository_name = namespace.findUnusedName(target.name)
371+ return namespace, repository_name, default_func
372+ else:
373+ return namespace, repository_name, None
374+
375+ def _reportError(self, path, exception, hosting_path=None):
376+ properties = [
377+ ("path", path),
378+ ("error-explanation", unicode(exception)),
379+ ]
380+ if hosting_path is not None:
381+ properties.append(("hosting_path", hosting_path))
382+ request = ScriptRequest(properties)
383+ getUtility(IErrorReportingUtility).raising(sys.exc_info(), request)
384+ raise faults.OopsOccurred("creating a Git repository", request.oopsid)
385+
386+ def _createRepository(self, requester, path):
387+ try:
388+ namespace, repository_name, default_func = (
389+ self._getGitNamespaceExtras(path, requester))
390+ except InvalidNamespace:
391+ raise faults.PermissionDenied(
392+ "'%s' is not a valid Git repository path." % path)
393+ except NoSuchPerson as e:
394+ raise faults.NotFound("User/team '%s' does not exist." % e.name)
395+ except (NoSuchProduct, InvalidProductName) as e:
396+ raise faults.NotFound("Project '%s' does not exist." % e.name)
397+ except NoSuchSourcePackageName as e:
398+ try:
399+ getUtility(ISourcePackageNameSet).new(e.name)
400+ except InvalidName:
401+ raise faults.InvalidSourcePackageName(e.name)
402+ return self._createRepository(requester, path)
403+ except NameLookupFailed as e:
404+ raise faults.NotFound(unicode(e))
405+ except GitRepositoryCreationForbidden as e:
406+ raise faults.PermissionDenied(unicode(e))
407+
408+ try:
409+ repository = namespace.createRepository(
410+ requester, repository_name)
411+ except LaunchpadValidationError as e:
412+ # Despite the fault name, this just passes through the exception
413+ # text so there's no need for a new Git-specific fault.
414+ raise faults.InvalidBranchName(e)
415+ except GitRepositoryExists as e:
416+ # We should never get here, as we just tried to translate the
417+ # path and found nothing (not even an inaccessible private
418+ # repository). Log an OOPS for investigation.
419+ self._reportError(path, e)
420+ except GitRepositoryCreationException as e:
421+ raise faults.PermissionDenied(unicode(e))
422+
423+ try:
424+ if default_func:
425+ try:
426+ default_func(repository)
427+ except Unauthorized:
428+ raise faults.PermissionDenied(
429+ "You cannot set the default Git repository for '%s'." %
430+ path)
431+
432+ # Flush to make sure that repository.id is populated.
433+ Store.of(repository).flush()
434+ assert repository.id is not None
435+
436+ hosting_path = repository.getInternalPath()
437+ try:
438+ self.hosting_client.create(hosting_path)
439+ except GitRepositoryCreationFault as e:
440+ # The hosting service failed. Log an OOPS for investigation.
441+ self._reportError(path, e, hosting_path=hosting_path)
442+ except Exception:
443+ # We don't want to keep the repository we created.
444+ transaction.abort()
445+ raise
446+
447+ @return_fault
448+ def _translatePath(self, requester, path, permission, can_authenticate):
449+ if requester == LAUNCHPAD_ANONYMOUS:
450+ requester = None
451+ try:
452+ result = self._performLookup(path)
453+ if (result is None and requester is not None and
454+ permission == "write"):
455+ self._createRepository(requester, path)
456+ result = self._performLookup(path)
457+ if result is None:
458+ raise faults.PathTranslationError(path)
459+ if permission != "read" and not result["writable"]:
460+ raise faults.PermissionDenied()
461+ return result
462+ except faults.PermissionDenied:
463+ # Turn "permission denied" for anonymous HTTP requests into
464+ # "authorisation required", so that the user-agent has a chance
465+ # to try HTTP basic auth.
466+ if can_authenticate and requester is None:
467+ raise faults.Unauthorized()
468+ raise
469+
470+ def translatePath(self, path, permission, requester_id, can_authenticate):
471+ """See `IGitAPI`."""
472+ if requester_id is None:
473+ requester_id = LAUNCHPAD_ANONYMOUS
474+ return run_with_login(
475+ requester_id, self._translatePath,
476+ path.strip("/"), permission, can_authenticate)
477
478=== added file 'lib/lp/code/xmlrpc/tests/test_git.py'
479--- lib/lp/code/xmlrpc/tests/test_git.py 1970-01-01 00:00:00 +0000
480+++ lib/lp/code/xmlrpc/tests/test_git.py 2015-03-04 11:12:53 +0000
481@@ -0,0 +1,584 @@
482+# Copyright 2015 Canonical Ltd. This software is licensed under the
483+# GNU Affero General Public License version 3 (see the file LICENSE).
484+
485+"""Tests for the internal Git API."""
486+
487+__metaclass__ = type
488+
489+from zope.component import getUtility
490+from zope.security.proxy import removeSecurityProxy
491+
492+from lp.app.enums import InformationType
493+from lp.code.errors import GitRepositoryCreationFault
494+from lp.code.interfaces.codehosting import (
495+ LAUNCHPAD_ANONYMOUS,
496+ LAUNCHPAD_SERVICES,
497+ )
498+from lp.code.interfaces.gitcollection import IAllGitRepositories
499+from lp.code.interfaces.gitrepository import (
500+ GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE,
501+ IGitRepositorySet,
502+ )
503+from lp.code.xmlrpc.git import GitAPI
504+from lp.services.webapp.escaping import html_escape
505+from lp.testing import (
506+ ANONYMOUS,
507+ login,
508+ person_logged_in,
509+ TestCaseWithFactory,
510+ )
511+from lp.testing.layers import (
512+ AppServerLayer,
513+ LaunchpadFunctionalLayer,
514+ )
515+from lp.xmlrpc import faults
516+
517+
518+class FakeGitHostingClient:
519+ """A GitHostingClient lookalike that just logs calls."""
520+
521+ def __init__(self):
522+ self.calls = []
523+
524+ def create(self, path):
525+ self.calls.append(("create", path))
526+
527+
528+class BrokenGitHostingClient:
529+ """A GitHostingClient lookalike that pretends the remote end is down."""
530+
531+ def create(self, path):
532+ raise GitRepositoryCreationFault("nothing here")
533+
534+
535+class TestGitAPIMixin:
536+ """Helper methods for `IGitAPI` tests, and security-relevant tests."""
537+
538+ def setUp(self):
539+ super(TestGitAPIMixin, self).setUp()
540+ self.git_api = GitAPI(None, None)
541+ self.git_api.hosting_client = FakeGitHostingClient()
542+
543+ def assertPathTranslationError(self, requester, path, permission="read",
544+ can_authenticate=False):
545+ """Assert that the given path cannot be translated."""
546+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
547+ requester = requester.id
548+ fault = self.git_api.translatePath(
549+ path, permission, requester, can_authenticate)
550+ self.assertEqual(faults.PathTranslationError(path.strip("/")), fault)
551+
552+ def assertPermissionDenied(self, requester, path,
553+ message="Permission denied.",
554+ permission="read", can_authenticate=False):
555+ """Assert that looking at the given path returns PermissionDenied."""
556+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
557+ requester = requester.id
558+ fault = self.git_api.translatePath(
559+ path, permission, requester, can_authenticate)
560+ self.assertEqual(faults.PermissionDenied(message), fault)
561+
562+ def assertUnauthorized(self, requester, path,
563+ message="Authorisation required.",
564+ permission="read", can_authenticate=False):
565+ """Assert that looking at the given path returns Unauthorized."""
566+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
567+ requester = requester.id
568+ fault = self.git_api.translatePath(
569+ path, permission, requester, can_authenticate)
570+ self.assertEqual(faults.Unauthorized(message), fault)
571+
572+ def assertNotFound(self, requester, path, message, permission="read",
573+ can_authenticate=False):
574+ """Assert that looking at the given path returns NotFound."""
575+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
576+ requester = requester.id
577+ fault = self.git_api.translatePath(
578+ path, permission, requester, can_authenticate)
579+ self.assertEqual(faults.NotFound(message), fault)
580+
581+ def assertInvalidSourcePackageName(self, requester, path, name,
582+ permission="read",
583+ can_authenticate=False):
584+ """Assert that looking at the given path returns
585+ InvalidSourcePackageName."""
586+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
587+ requester = requester.id
588+ fault = self.git_api.translatePath(
589+ path, permission, requester, can_authenticate)
590+ self.assertEqual(faults.InvalidSourcePackageName(name), fault)
591+
592+ def assertInvalidBranchName(self, requester, path, message,
593+ permission="read", can_authenticate=False):
594+ """Assert that looking at the given path returns InvalidBranchName."""
595+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
596+ requester = requester.id
597+ fault = self.git_api.translatePath(
598+ path, permission, requester, can_authenticate)
599+ self.assertEqual(faults.InvalidBranchName(Exception(message)), fault)
600+
601+ def assertOopsOccurred(self, requester, path,
602+ permission="read", can_authenticate=False):
603+ """Assert that looking at the given path OOPSes."""
604+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
605+ requester = requester.id
606+ fault = self.git_api.translatePath(
607+ path, permission, requester, can_authenticate)
608+ self.assertIsInstance(fault, faults.OopsOccurred)
609+ prefix = (
610+ "An unexpected error has occurred while creating a Git "
611+ "repository. Please report a Launchpad bug and quote: ")
612+ self.assertStartsWith(fault.faultString, prefix)
613+ return fault.faultString[len(prefix):].rstrip(".")
614+
615+ def assertTranslates(self, requester, path, repository, writable,
616+ permission="read", can_authenticate=False):
617+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
618+ requester = requester.id
619+ translation = self.git_api.translatePath(
620+ path, permission, requester, can_authenticate)
621+ login(ANONYMOUS)
622+ self.assertEqual(
623+ {"path": repository.getInternalPath(), "writable": writable},
624+ translation)
625+
626+ def assertCreates(self, requester, path, can_authenticate=False):
627+ if requester in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
628+ requester_id = requester
629+ else:
630+ requester_id = requester.id
631+ translation = self.git_api.translatePath(
632+ path, "write", requester_id, can_authenticate)
633+ login(ANONYMOUS)
634+ repository = getUtility(IGitRepositorySet).getByPath(
635+ requester, path.lstrip("/"))
636+ self.assertIsNotNone(repository)
637+ self.assertEqual(requester, repository.registrant)
638+ self.assertEqual(
639+ {"path": repository.getInternalPath(), "writable": True},
640+ translation)
641+ self.assertEqual(
642+ [("create", repository.getInternalPath())],
643+ self.git_api.hosting_client.calls)
644+ return repository
645+
646+ def test_translatePath_private_repository(self):
647+ requester = self.factory.makePerson()
648+ repository = removeSecurityProxy(
649+ self.factory.makeGitRepository(
650+ owner=requester, information_type=InformationType.USERDATA))
651+ path = u"/%s" % repository.unique_name
652+ self.assertTranslates(requester, path, repository, True)
653+
654+ def test_translatePath_cannot_see_private_repository(self):
655+ requester = self.factory.makePerson()
656+ repository = removeSecurityProxy(
657+ self.factory.makeGitRepository(
658+ information_type=InformationType.USERDATA))
659+ path = u"/%s" % repository.unique_name
660+ self.assertPermissionDenied(requester, path)
661+
662+ def test_translatePath_anonymous_cannot_see_private_repository(self):
663+ repository = removeSecurityProxy(
664+ self.factory.makeGitRepository(
665+ information_type=InformationType.USERDATA))
666+ path = u"/%s" % repository.unique_name
667+ self.assertPermissionDenied(
668+ LAUNCHPAD_ANONYMOUS, path, can_authenticate=False)
669+ self.assertUnauthorized(
670+ LAUNCHPAD_ANONYMOUS, path, can_authenticate=True)
671+
672+ def test_translatePath_team_unowned(self):
673+ requester = self.factory.makePerson()
674+ team = self.factory.makeTeam(self.factory.makePerson())
675+ repository = self.factory.makeGitRepository(owner=team)
676+ path = u"/%s" % repository.unique_name
677+ self.assertTranslates(requester, path, repository, False)
678+ self.assertPermissionDenied(requester, path, permission="write")
679+
680+ def test_translatePath_create_personal_team_denied(self):
681+ # translatePath refuses to create a personal repository for a team
682+ # of which the requester is not a member.
683+ requester = self.factory.makePerson()
684+ team = self.factory.makeTeam()
685+ message = "%s is not a member of %s" % (
686+ requester.displayname, team.displayname)
687+ self.assertPermissionDenied(
688+ requester, u"/~%s/+git/random" % team.name, message=message,
689+ permission="write")
690+
691+ def test_translatePath_create_other_user(self):
692+ # Creating a repository for another user fails.
693+ requester = self.factory.makePerson()
694+ other_person = self.factory.makePerson()
695+ project = self.factory.makeProduct()
696+ name = self.factory.getUniqueString()
697+ path = u"/~%s/%s/+git/%s" % (other_person.name, project.name, name)
698+ message = "%s cannot create Git repositories owned by %s" % (
699+ requester.displayname, other_person.displayname)
700+ self.assertPermissionDenied(
701+ requester, path, message=message, permission="write")
702+
703+ def test_translatePath_create_project_not_owner(self):
704+ # Somebody without edit permission on the project cannot create a
705+ # repository and immediately set it as the default for that project.
706+ requester = self.factory.makePerson()
707+ project = self.factory.makeProduct()
708+ path = u"/%s" % project.name
709+ message = "You cannot set the default Git repository for '%s'." % (
710+ path.strip("/"))
711+ initial_count = getUtility(IAllGitRepositories).count()
712+ self.assertPermissionDenied(
713+ requester, path, message=message, permission="write")
714+ # No repository was created.
715+ login(ANONYMOUS)
716+ self.assertEqual(
717+ initial_count, getUtility(IAllGitRepositories).count())
718+
719+ def test_translatePath_create_project_not_team_owner_default(self):
720+ # A non-owner member of a team cannot immediately set a
721+ # newly-created team-owned repository as that team's default for a
722+ # project.
723+ requester = self.factory.makePerson()
724+ team = self.factory.makeTeam(members=[requester])
725+ project = self.factory.makeProduct()
726+ path = u"/~%s/%s" % (team.name, project.name)
727+ message = "You cannot set the default Git repository for '%s'." % (
728+ path.strip("/"))
729+ initial_count = getUtility(IAllGitRepositories).count()
730+ self.assertPermissionDenied(
731+ requester, path, message=message, permission="write")
732+ # No repository was created.
733+ login(ANONYMOUS)
734+ self.assertEqual(
735+ initial_count, getUtility(IAllGitRepositories).count())
736+
737+ def test_translatePath_create_package_not_team_owner_default(self):
738+ # A non-owner member of a team cannot immediately set a
739+ # newly-created team-owned repository as that team's default for a
740+ # package.
741+ requester = self.factory.makePerson()
742+ team = self.factory.makeTeam(members=[requester])
743+ dsp = self.factory.makeDistributionSourcePackage()
744+ path = u"/~%s/%s/+source/%s" % (
745+ team.name, dsp.distribution.name, dsp.sourcepackagename.name)
746+ message = "You cannot set the default Git repository for '%s'." % (
747+ path.strip("/"))
748+ initial_count = getUtility(IAllGitRepositories).count()
749+ self.assertPermissionDenied(
750+ requester, path, message=message, permission="write")
751+ # No repository was created.
752+ login(ANONYMOUS)
753+ self.assertEqual(
754+ initial_count, getUtility(IAllGitRepositories).count())
755+
756+
757+class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory):
758+ """Tests for the implementation of `IGitAPI`."""
759+
760+ layer = LaunchpadFunctionalLayer
761+
762+ def test_translatePath_cannot_translate(self):
763+ # Sometimes translatePath will not know how to translate a path.
764+ # When this happens, it returns a Fault saying so, including the
765+ # path it couldn't translate.
766+ requester = self.factory.makePerson()
767+ self.assertPathTranslationError(requester, u"/untranslatable")
768+
769+ def test_translatePath_repository(self):
770+ requester = self.factory.makePerson()
771+ repository = self.factory.makeGitRepository()
772+ path = u"/%s" % repository.unique_name
773+ self.assertTranslates(requester, path, repository, False)
774+
775+ def test_translatePath_repository_with_no_leading_slash(self):
776+ requester = self.factory.makePerson()
777+ repository = self.factory.makeGitRepository()
778+ path = repository.unique_name
779+ self.assertTranslates(requester, path, repository, False)
780+
781+ def test_translatePath_repository_with_trailing_slash(self):
782+ requester = self.factory.makePerson()
783+ repository = self.factory.makeGitRepository()
784+ path = u"/%s/" % repository.unique_name
785+ self.assertTranslates(requester, path, repository, False)
786+
787+ def test_translatePath_repository_with_trailing_segments(self):
788+ requester = self.factory.makePerson()
789+ repository = self.factory.makeGitRepository()
790+ path = u"/%s/junk" % repository.unique_name
791+ self.assertPathTranslationError(requester, path)
792+
793+ def test_translatePath_no_such_repository(self):
794+ requester = self.factory.makePerson()
795+ path = u"/%s/+git/no-such-repository" % requester.name
796+ self.assertPathTranslationError(requester, path)
797+
798+ def test_translatePath_no_such_repository_non_ascii(self):
799+ requester = self.factory.makePerson()
800+ path = u"/%s/+git/\N{LATIN SMALL LETTER I WITH DIAERESIS}" % (
801+ requester.name)
802+ self.assertPathTranslationError(requester, path)
803+
804+ def test_translatePath_anonymous_public_repository(self):
805+ repository = self.factory.makeGitRepository()
806+ path = u"/%s" % repository.unique_name
807+ self.assertTranslates(
808+ LAUNCHPAD_ANONYMOUS, path, repository, False,
809+ can_authenticate=False)
810+ self.assertTranslates(
811+ LAUNCHPAD_ANONYMOUS, path, repository, False,
812+ can_authenticate=True)
813+
814+ def test_translatePath_owned(self):
815+ requester = self.factory.makePerson()
816+ repository = self.factory.makeGitRepository(owner=requester)
817+ path = u"/%s" % repository.unique_name
818+ self.assertTranslates(
819+ requester, path, repository, True, permission="write")
820+
821+ def test_translatePath_team_owned(self):
822+ requester = self.factory.makePerson()
823+ team = self.factory.makeTeam(requester)
824+ repository = self.factory.makeGitRepository(owner=team)
825+ path = u"/%s" % repository.unique_name
826+ self.assertTranslates(
827+ requester, path, repository, True, permission="write")
828+
829+ def test_translatePath_shortened_path(self):
830+ # translatePath translates the shortened path to a repository.
831+ requester = self.factory.makePerson()
832+ repository = self.factory.makeGitRepository()
833+ with person_logged_in(repository.target.owner):
834+ getUtility(IGitRepositorySet).setDefaultRepository(
835+ repository.target, repository)
836+ path = u"/%s" % repository.target.name
837+ self.assertTranslates(requester, path, repository, False)
838+
839+ def test_translatePath_create_project(self):
840+ # translatePath creates a project repository that doesn't exist, if
841+ # it can.
842+ requester = self.factory.makePerson()
843+ project = self.factory.makeProduct()
844+ self.assertCreates(
845+ requester, u"/~%s/%s/+git/random" % (requester.name, project.name))
846+
847+ def test_translatePath_create_package(self):
848+ # translatePath creates a package repository that doesn't exist, if
849+ # it can.
850+ requester = self.factory.makePerson()
851+ dsp = self.factory.makeDistributionSourcePackage()
852+ self.assertCreates(
853+ requester,
854+ u"/~%s/%s/+source/%s/+git/random" % (
855+ requester.name,
856+ dsp.distribution.name, dsp.sourcepackagename.name))
857+
858+ def test_translatePath_create_personal(self):
859+ # translatePath creates a personal repository that doesn't exist, if
860+ # it can.
861+ requester = self.factory.makePerson()
862+ self.assertCreates(requester, u"/~%s/+git/random" % requester.name)
863+
864+ def test_translatePath_create_personal_team(self):
865+ # translatePath creates a personal repository for a team of which
866+ # the requester is a member.
867+ requester = self.factory.makePerson()
868+ team = self.factory.makeTeam(members=[requester])
869+ self.assertCreates(requester, u"/~%s/+git/random" % team.name)
870+
871+ def test_translatePath_anonymous_cannot_create(self):
872+ # Anonymous users cannot create repositories.
873+ project = self.factory.makeProject()
874+ self.assertPathTranslationError(
875+ LAUNCHPAD_ANONYMOUS, u"/%s" % project.name,
876+ permission="write", can_authenticate=False)
877+ self.assertPathTranslationError(
878+ LAUNCHPAD_ANONYMOUS, u"/%s" % project.name,
879+ permission="write", can_authenticate=True)
880+
881+ def test_translatePath_create_invalid_namespace(self):
882+ # Trying to create a repository at a path that isn't valid for Git
883+ # repositories returns a PermissionDenied fault.
884+ requester = self.factory.makePerson()
885+ path = u"/~%s" % requester.name
886+ message = "'%s' is not a valid Git repository path." % path.strip("/")
887+ self.assertPermissionDenied(
888+ requester, path, message=message, permission="write")
889+
890+ def test_translatePath_create_no_such_person(self):
891+ # Creating a repository for a non-existent person fails.
892+ requester = self.factory.makePerson()
893+ self.assertNotFound(
894+ requester, u"/~nonexistent/+git/random",
895+ "User/team 'nonexistent' does not exist.", permission="write")
896+
897+ def test_translatePath_create_no_such_project(self):
898+ # Creating a repository for a non-existent project fails.
899+ requester = self.factory.makePerson()
900+ self.assertNotFound(
901+ requester, u"/~%s/nonexistent/+git/random" % requester.name,
902+ "Project 'nonexistent' does not exist.", permission="write")
903+
904+ def test_translatePath_create_no_such_person_or_project(self):
905+ # If neither the person nor the project are found, then the missing
906+ # person is reported in preference.
907+ requester = self.factory.makePerson()
908+ self.assertNotFound(
909+ requester, u"/~nonexistent/nonexistent/+git/random",
910+ "User/team 'nonexistent' does not exist.", permission="write")
911+
912+ def test_translatePath_create_invalid_project(self):
913+ # Creating a repository with an invalid project name fails.
914+ requester = self.factory.makePerson()
915+ self.assertNotFound(
916+ requester, u"/_bad_project/+git/random",
917+ "Project '_bad_project' does not exist.", permission="write")
918+
919+ def test_translatePath_create_missing_sourcepackagename(self):
920+ # If translatePath is asked to create a repository for a missing
921+ # source package, it will create the source package.
922+ requester = self.factory.makePerson()
923+ distro = self.factory.makeDistribution()
924+ repository_name = self.factory.getUniqueString()
925+ path = u"/~%s/%s/+source/new-package/+git/%s" % (
926+ requester.name, distro.name, repository_name)
927+ repository = self.assertCreates(requester, path)
928+ self.assertEqual(
929+ "new-package", repository.target.sourcepackagename.name)
930+
931+ def test_translatePath_create_invalid_sourcepackagename(self):
932+ # Creating a repository for an invalid source package name fails.
933+ requester = self.factory.makePerson()
934+ distro = self.factory.makeDistribution()
935+ repository_name = self.factory.getUniqueString()
936+ path = u"/~%s/%s/+source/new package/+git/%s" % (
937+ requester.name, distro.name, repository_name)
938+ self.assertInvalidSourcePackageName(
939+ requester, path, "new package", permission="write")
940+
941+ def test_translatePath_create_bad_name(self):
942+ # Creating a repository with an invalid name fails.
943+ requester = self.factory.makePerson()
944+ project = self.factory.makeProduct()
945+ invalid_name = "invalid name!"
946+ path = u"/~%s/%s/+git/%s" % (
947+ requester.name, project.name, invalid_name)
948+ # LaunchpadValidationError unfortunately assumes its output is
949+ # always HTML, so it ends up double-escaped in XML-RPC faults.
950+ message = html_escape(
951+ "Invalid Git repository name '%s'. %s" %
952+ (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE))
953+ self.assertInvalidBranchName(
954+ requester, path, message, permission="write")
955+
956+ def test_translatePath_create_unicode_name(self):
957+ # Creating a repository with a non-ASCII invalid name fails.
958+ requester = self.factory.makePerson()
959+ project = self.factory.makeProduct()
960+ invalid_name = u"invalid\N{LATIN SMALL LETTER E WITH ACUTE}"
961+ path = u"/~%s/%s/+git/%s" % (
962+ requester.name, project.name, invalid_name)
963+ # LaunchpadValidationError unfortunately assumes its output is
964+ # always HTML, so it ends up double-escaped in XML-RPC faults.
965+ message = html_escape(
966+ "Invalid Git repository name '%s'. %s" %
967+ (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE))
968+ self.assertInvalidBranchName(
969+ requester, path, message, permission="write")
970+
971+ def test_translatePath_create_project_default(self):
972+ # A repository can be created and immediately set as the default for
973+ # a project.
974+ requester = self.factory.makePerson()
975+ project = self.factory.makeProduct(owner=requester)
976+ repository = self.assertCreates(requester, u"/%s" % project.name)
977+ self.assertTrue(repository.target_default)
978+ self.assertFalse(repository.owner_default)
979+
980+ def test_translatePath_create_package_default_denied(self):
981+ # A repository cannot (yet) be created and immediately set as the
982+ # default for a package.
983+ requester = self.factory.makePerson()
984+ dsp = self.factory.makeDistributionSourcePackage()
985+ path = u"/%s/+source/%s" % (
986+ dsp.distribution.name, dsp.sourcepackagename.name)
987+ message = (
988+ "Cannot automatically set the default repository for this target; "
989+ "push to a named repository instead.")
990+ self.assertPermissionDenied(
991+ requester, path, message=message, permission="write")
992+
993+ def test_translatePath_create_project_owner_default(self):
994+ # A repository can be created and immediately set as its owner's
995+ # default for a project.
996+ requester = self.factory.makePerson()
997+ project = self.factory.makeProduct()
998+ repository = self.assertCreates(
999+ requester, u"/~%s/%s" % (requester.name, project.name))
1000+ self.assertFalse(repository.target_default)
1001+ self.assertTrue(repository.owner_default)
1002+
1003+ def test_translatePath_create_project_team_owner_default(self):
1004+ # The owner of a team can create a team-owned repository and
1005+ # immediately set it as that team's default for a project.
1006+ requester = self.factory.makePerson()
1007+ team = self.factory.makeTeam(owner=requester)
1008+ project = self.factory.makeProduct()
1009+ repository = self.assertCreates(
1010+ requester, u"/~%s/%s" % (team.name, project.name))
1011+ self.assertFalse(repository.target_default)
1012+ self.assertTrue(repository.owner_default)
1013+
1014+ def test_translatePath_create_package_owner_default(self):
1015+ # A repository can be created and immediately set as its owner's
1016+ # default for a package.
1017+ requester = self.factory.makePerson()
1018+ dsp = self.factory.makeDistributionSourcePackage()
1019+ path = u"/~%s/%s/+source/%s" % (
1020+ requester.name, dsp.distribution.name, dsp.sourcepackagename.name)
1021+ repository = self.assertCreates(requester, path)
1022+ self.assertFalse(repository.target_default)
1023+ self.assertTrue(repository.owner_default)
1024+
1025+ def test_translatePath_create_package_team_owner_default(self):
1026+ # The owner of a team can create a team-owned repository and
1027+ # immediately set it as that team's default for a package.
1028+ requester = self.factory.makePerson()
1029+ team = self.factory.makeTeam(owner=requester)
1030+ dsp = self.factory.makeDistributionSourcePackage()
1031+ path = u"/~%s/%s/+source/%s" % (
1032+ team.name, dsp.distribution.name, dsp.sourcepackagename.name)
1033+ repository = self.assertCreates(requester, path)
1034+ self.assertFalse(repository.target_default)
1035+ self.assertTrue(repository.owner_default)
1036+
1037+ def test_translatePath_create_broken_hosting_service(self):
1038+ # If the hosting service is down, trying to create a repository
1039+ # fails and doesn't leave junk around in the Launchpad database.
1040+ self.git_api.hosting_client = BrokenGitHostingClient()
1041+ requester = self.factory.makePerson()
1042+ initial_count = getUtility(IAllGitRepositories).count()
1043+ oops_id = self.assertOopsOccurred(
1044+ requester, u"/~%s/+git/random" % requester.name,
1045+ permission="write")
1046+ login(ANONYMOUS)
1047+ self.assertEqual(
1048+ initial_count, getUtility(IAllGitRepositories).count())
1049+ # The error report OOPS ID should match the fault, and the traceback
1050+ # text should show the underlying exception.
1051+ self.assertEqual(1, len(self.oopses))
1052+ self.assertEqual(oops_id, self.oopses[0]["id"])
1053+ self.assertIn(
1054+ "GitRepositoryCreationFault: nothing here",
1055+ self.oopses[0]["tb_text"])
1056+
1057+
1058+class TestGitAPISecurity(TestGitAPIMixin, TestCaseWithFactory):
1059+ """Slow tests for `IGitAPI`.
1060+
1061+ These use AppServerLayer to check that `run_with_login` is behaving
1062+ itself properly.
1063+ """
1064+
1065+ layer = AppServerLayer
1066
1067=== modified file 'lib/lp/systemhomes.py'
1068--- lib/lp/systemhomes.py 2013-06-20 05:50:00 +0000
1069+++ lib/lp/systemhomes.py 2015-03-04 11:12:53 +0000
1070@@ -1,4 +1,4 @@
1071-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
1072+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
1073 # GNU Affero General Public License version 3 (see the file LICENSE).
1074
1075 """Content classes for the 'home pages' of the subsystems of Launchpad."""
1076@@ -47,6 +47,7 @@
1077 from lp.code.interfaces.codeimportscheduler import (
1078 ICodeImportSchedulerApplication,
1079 )
1080+from lp.code.interfaces.gitapi import IGitApplication
1081 from lp.hardwaredb.interfaces.hwdb import (
1082 IHWDBApplication,
1083 IHWDeviceSet,
1084@@ -92,6 +93,12 @@
1085 title = "Code Import Scheduler"
1086
1087
1088+class GitApplication:
1089+ implements(IGitApplication)
1090+
1091+ title = "Git API"
1092+
1093+
1094 class PrivateMaloneApplication:
1095 """ExternalBugTracker authentication token end-point."""
1096 implements(IPrivateMaloneApplication)
1097
1098=== modified file 'lib/lp/xmlrpc/application.py'
1099--- lib/lp/xmlrpc/application.py 2013-01-07 02:40:55 +0000
1100+++ lib/lp/xmlrpc/application.py 2015-03-04 11:12:53 +0000
1101@@ -1,4 +1,4 @@
1102-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
1103+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
1104 # GNU Affero General Public License version 3 (see the file LICENSE).
1105
1106 """XML-RPC API to the application roots."""
1107@@ -24,6 +24,7 @@
1108 from lp.code.interfaces.codeimportscheduler import (
1109 ICodeImportSchedulerApplication,
1110 )
1111+from lp.code.interfaces.gitapi import IGitApplication
1112 from lp.registry.interfaces.mailinglist import IMailingListApplication
1113 from lp.registry.interfaces.person import (
1114 ICanonicalSSOApplication,
1115@@ -80,6 +81,11 @@
1116 """See `IPrivateApplication`."""
1117 return getUtility(IFeatureFlagApplication)
1118
1119+ @property
1120+ def git(self):
1121+ """See `IPrivateApplication`."""
1122+ return getUtility(IGitApplication)
1123+
1124
1125 class ISelfTest(Interface):
1126 """XMLRPC external interface for testing the XMLRPC external interface."""
1127
1128=== modified file 'lib/lp/xmlrpc/configure.zcml'
1129--- lib/lp/xmlrpc/configure.zcml 2012-10-31 14:29:13 +0000
1130+++ lib/lp/xmlrpc/configure.zcml 2015-03-04 11:12:53 +0000
1131@@ -1,4 +1,4 @@
1132-<!-- Copyright 2009-2010 Canonical Ltd. This software is licensed under the
1133+<!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the
1134 GNU Affero General Public License version 3 (see the file LICENSE).
1135 -->
1136
1137@@ -48,6 +48,19 @@
1138 />
1139
1140 <securedutility
1141+ class="lp.systemhomes.GitApplication"
1142+ provides="lp.code.interfaces.gitapi.IGitApplication">
1143+ <allow interface="lp.code.interfaces.gitapi.IGitApplication"/>
1144+ </securedutility>
1145+
1146+ <xmlrpc:view
1147+ for="lp.code.interfaces.gitapi.IGitApplication"
1148+ interface="lp.code.interfaces.gitapi.IGitAPI"
1149+ class="lp.code.xmlrpc.git.GitAPI"
1150+ permission="zope.Public"
1151+ />
1152+
1153+ <securedutility
1154 class="lp.systemhomes.PrivateMaloneApplication"
1155 provides="lp.bugs.interfaces.malone.IPrivateMaloneApplication">
1156 <allow interface="lp.bugs.interfaces.malone.IPrivateMaloneApplication"/>
1157@@ -207,4 +220,8 @@
1158 <class class="lp.xmlrpc.faults.InvalidSourcePackageName">
1159 <require like_class="xmlrpclib.Fault" />
1160 </class>
1161+
1162+ <class class="lp.xmlrpc.faults.Unauthorized">
1163+ <require like_class="xmlrpclib.Fault" />
1164+ </class>
1165 </configure>
1166
1167=== modified file 'lib/lp/xmlrpc/faults.py'
1168--- lib/lp/xmlrpc/faults.py 2012-10-31 19:13:34 +0000
1169+++ lib/lp/xmlrpc/faults.py 2015-03-04 11:12:53 +0000
1170@@ -9,6 +9,7 @@
1171 __metaclass__ = type
1172
1173 __all__ = [
1174+ 'AccountSuspended',
1175 'BadStatus',
1176 'BranchAlreadyRegistered',
1177 'BranchCreationForbidden',
1178@@ -20,8 +21,9 @@
1179 'InvalidBranchIdentifier',
1180 'InvalidBranchName',
1181 'InvalidBranchUniqueName',
1182+ 'InvalidBranchUrl',
1183+ 'InvalidPath',
1184 'InvalidProductName',
1185- 'InvalidBranchUrl',
1186 'InvalidSourcePackageName',
1187 'OopsOccurred',
1188 'NoBranchWithID',
1189@@ -30,16 +32,22 @@
1190 'NoSuchBug',
1191 'NoSuchCodeImportJob',
1192 'NoSuchDistribution',
1193+ 'NoSuchDistroSeries',
1194 'NoSuchPackage',
1195 'NoSuchPerson',
1196 'NoSuchPersonWithName',
1197 'NoSuchProduct',
1198 'NoSuchProductSeries',
1199+ 'NoSuchSourcePackageName',
1200 'NoSuchTeamMailingList',
1201+ 'NotFound',
1202 'NotInTeam',
1203 'NoUrlForBranch',
1204+ 'PathTranslationError',
1205+ 'PermissionDenied',
1206 'RequiredParameterMissing',
1207 'TeamEmailAddress',
1208+ 'Unauthorized',
1209 'UnexpectedStatusReport',
1210 ]
1211
1212@@ -502,3 +510,15 @@
1213 def __init__(self, email, openid_identifier):
1214 LaunchpadFault.__init__(
1215 self, email=email, openid_identifier=openid_identifier)
1216+
1217+
1218+# American English spelling to line up with httplib etc.
1219+class Unauthorized(LaunchpadFault):
1220+ """Permission was denied, but authorisation may help."""
1221+
1222+ error_code = 410
1223+ msg_template = (
1224+ "%(message)s")
1225+
1226+ def __init__(self, message="Authorisation required."):
1227+ LaunchpadFault.__init__(self, message=message)
1228
1229=== modified file 'lib/lp/xmlrpc/interfaces.py'
1230--- lib/lp/xmlrpc/interfaces.py 2012-01-15 21:06:58 +0000
1231+++ lib/lp/xmlrpc/interfaces.py 2015-03-04 11:12:53 +0000
1232@@ -1,4 +1,4 @@
1233-# Copyright 2011 Canonical Ltd. This software is licensed under the
1234+# Copyright 2011-2015 Canonical Ltd. This software is licensed under the
1235 # GNU Affero General Public License version 3 (see the file LICENSE).
1236
1237 """Interfaces for the Launchpad application."""
1238@@ -34,3 +34,5 @@
1239 """Canonical SSO XML-RPC end point.""")
1240
1241 featureflags = Attribute("""Feature flag information endpoint""")
1242+
1243+ git = Attribute("Git end point.")
1244
1245=== modified file 'setup.py'
1246--- setup.py 2015-01-06 12:47:59 +0000
1247+++ setup.py 2015-03-04 11:12:53 +0000
1248@@ -79,6 +79,7 @@
1249 'python-openid',
1250 'pytz',
1251 'rabbitfixture',
1252+ 'requests',
1253 's4',
1254 'setproctitle',
1255 'setuptools',