launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18014
[Merge] lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad.
Commit message:
Add a private XML-RPC endpoint for Git-related operations needed by the hosting service.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1032731 in Launchpad itself: "Support for Launchpad-hosted Git repositories"
https://bugs.launchpad.net/launchpad/+bug/1032731
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-xmlrpc/+merge/251541
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.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-xmlrpc into lp:launchpad.
=== added file 'lib/lp/code/githosting.py'
--- lib/lp/code/githosting.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/githosting.py 2015-03-03 02:01:47 +0000
@@ -0,0 +1,48 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Communication with the Git hosting service."""
+
+__metaclass__ = type
+__all__ = [
+ 'GitHostingClient',
+ ]
+
+import json
+from urlparse import urljoin
+
+import requests
+
+from lp.code.errors import GitRepositoryCreationFault
+
+
+class GitHostingClient:
+ """A client for the internal API provided by the Git hosting system."""
+
+ def __init__(self, endpoint):
+ self.endpoint = endpoint
+
+ def _makeSession(self):
+ session = requests.Session()
+ session.trust_env = False
+ return session
+
+ @property
+ def timeout(self):
+ # XXX cjwatson 2015-03-01: The hardcoded timeout at least means that
+ # we don't lock tables indefinitely if the hosting service falls
+ # over, but is there some more robust way to do this?
+ return 5.0
+
+ def create(self, path):
+ # XXX cjwatson 2015-03-01: Once we're on requests >= 2.4.2, we
+ # should just use post(json=) and drop the explicit Content-Type
+ # header.
+ response = self._makeSession().post(
+ urljoin(self.endpoint, "repo"),
+ headers={"Content-Type": "application/json"},
+ data=json.dumps({"repo_path": path, "bare_repo": True}),
+ timeout=self.timeout)
+ if response.status_code != 200:
+ raise GitRepositoryCreationFault(
+ "Failed to create Git repository: %s" % response.text)
=== added file 'lib/lp/code/interfaces/gitapi.py'
--- lib/lp/code/interfaces/gitapi.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/interfaces/gitapi.py 2015-03-03 02:01:47 +0000
@@ -0,0 +1,52 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for internal Git APIs."""
+
+__metaclass__ = type
+__all__ = [
+ 'IGitAPI',
+ 'IGitApplication',
+ ]
+
+from zope.interface import Interface
+
+from lp.services.webapp.interfaces import ILaunchpadApplication
+
+
+class IGitApplication(ILaunchpadApplication):
+ """Git application root."""
+
+
+class IGitAPI(Interface):
+ """The Git XML-RPC interface to Launchpad.
+
+ Published at "git" on the private XML-RPC server.
+
+ The Git pack frontend uses this to translate user-visible paths to
+ internal ones, and to notify Launchpad of ref changes.
+ """
+
+ def translatePath(path, permission, requester_id, can_authenticate):
+ """Translate 'path' so that the Git pack frontend can access it.
+
+ If the repository does not exist and write permission was requested,
+ register a new repostory if possible.
+
+ :param path: The path being translated. This should be a
+ URL-escaped string representing an absolute path to a Git
+ repository.
+ :param permission: "read" or "write".
+ :param requester_id: The database ID of the person requesting the
+ path translation, or None for an anonymous request.
+ :param can_authenticate: True if the requester is anonymous but
+ could authenticate, otherwise False.
+
+ :returns: A `PathTranslationError` fault if 'path' cannot be
+ translated; a `PermissionDenied` fault if the requester cannot
+ see or create the repository; otherwise, a dict containing at
+ least the following keys::
+ "path", whose value is the repository's storage path;
+ "writable", whose value is True if the requester can push to
+ this repository, otherwise False.
+ """
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2015-02-26 11:34:47 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2015-03-03 02:01:47 +0000
@@ -7,6 +7,7 @@
__all__ = [
'GitIdentityMixin',
+ 'GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE',
'git_repository_name_validator',
'IGitRepository',
'IGitRepositorySet',
@@ -191,6 +192,12 @@
If the user is a Launchpad admin, any type is acceptable.
"""
+ def getInternalPathForId(repository_id):
+ """Get the internal path to the repository with this ID.
+
+ This is used on the storage backend.
+ """
+
def getInternalPath():
"""Get the internal path to this repository.
=== modified file 'lib/lp/code/model/gitlookup.py'
--- lib/lp/code/model/gitlookup.py 2015-02-27 10:22:24 +0000
+++ lib/lp/code/model/gitlookup.py 2015-03-03 02:01:47 +0000
@@ -342,6 +342,8 @@
return None
if repository is not None:
return repository
+ if IPerson.providedBy(target):
+ return None
repository_set = getUtility(IGitRepositorySet)
if owner is None:
return repository_set.getDefaultRepository(target)
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2015-02-26 17:32:56 +0000
+++ lib/lp/code/model/gitrepository.py 2015-03-03 02:01:47 +0000
@@ -232,10 +232,16 @@
def display_name(self):
return self.git_identity
+ @staticmethod
+ def getInternalPathForId(repository_id):
+ """See `IGitRepository`."""
+ # This may need to change later to improve support for sharding.
+ return str(repository_id)
+
def getInternalPath(self):
"""See `IGitRepository`."""
# This may need to change later to improve support for sharding.
- return str(self.id)
+ return self.getInternalPathForId(self.id)
def getCodebrowseUrl(self):
"""See `IGitRepository`."""
=== modified file 'lib/lp/code/model/tests/test_gitlookup.py'
--- lib/lp/code/model/tests/test_gitlookup.py 2015-02-27 10:22:24 +0000
+++ lib/lp/code/model/tests/test_gitlookup.py 2015-03-03 02:01:47 +0000
@@ -117,6 +117,12 @@
project = self.factory.makeProduct()
self.assertIsNone(self.lookup.getByPath(project.name))
+ def test_bare_person(self):
+ # If `getByPath` is given a path to a person but nothing further, it
+ # returns None even if the person exists.
+ owner = self.factory.makePerson()
+ self.assertIsNone(self.lookup.getByPath("~%s" % owner.name))
+
class TestGetByUrl(TestCaseWithFactory):
"""Test `IGitLookup.getByUrl`."""
=== modified file 'lib/lp/code/xmlrpc/codehosting.py'
--- lib/lp/code/xmlrpc/codehosting.py 2012-11-26 08:33:03 +0000
+++ lib/lp/code/xmlrpc/codehosting.py 2015-03-03 02:01:47 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Implementations of the XML-RPC APIs for codehosting."""
@@ -7,6 +7,7 @@
__all__ = [
'CodehostingAPI',
'datetime_from_tuple',
+ 'run_with_login',
]
=== added file 'lib/lp/code/xmlrpc/git.py'
--- lib/lp/code/xmlrpc/git.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/xmlrpc/git.py 2015-03-03 02:01:47 +0000
@@ -0,0 +1,220 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Implementations of the XML-RPC APIs for Git."""
+
+__metaclass__ = type
+__all__ = [
+ 'GitAPI',
+ ]
+
+from bzrlib.urlutils import (
+ escape,
+ unescape,
+ )
+from storm.store import Store
+import transaction
+from zope.component import getUtility
+from zope.interface import implements
+from zope.security.interfaces import Unauthorized
+
+from lp.app.errors import NameLookupFailed
+from lp.app.validators import LaunchpadValidationError
+from lp.code.errors import (
+ GitRepositoryCreationException,
+ GitRepositoryCreationForbidden,
+ InvalidNamespace,
+ )
+from lp.code.githosting import GitHostingClient
+from lp.code.interfaces.codehosting import LAUNCHPAD_ANONYMOUS
+from lp.code.interfaces.gitapi import IGitAPI
+from lp.code.interfaces.gitlookup import (
+ IGitLookup,
+ IGitTraverser,
+ )
+from lp.code.interfaces.gitnamespace import (
+ get_git_namespace,
+ split_git_unique_name,
+ )
+from lp.code.interfaces.gitrepository import IGitRepositorySet
+from lp.code.xmlrpc.codehosting import run_with_login
+from lp.registry.errors import (
+ InvalidName,
+ NoSuchSourcePackageName,
+ )
+from lp.registry.interfaces.distributionsourcepackage import (
+ IDistributionSourcePackage,
+ )
+from lp.registry.interfaces.person import (
+ IPerson,
+ NoSuchPerson,
+ )
+from lp.registry.interfaces.product import (
+ InvalidProductName,
+ NoSuchProduct,
+ )
+from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.services.config import config
+from lp.services.webapp import LaunchpadXMLRPCView
+from lp.services.webapp.authorization import check_permission
+from lp.xmlrpc import faults
+from lp.xmlrpc.helpers import return_fault
+
+
+class GitAPI(LaunchpadXMLRPCView):
+ """See `IGitAPI`."""
+
+ implements(IGitAPI)
+
+ def __init__(self, *args, **kwargs):
+ super(GitAPI, self).__init__(*args, **kwargs)
+ self.hosting_client = GitHostingClient(
+ config.codehosting.internal_git_api_endpoint)
+
+ def _performLookup(self, path):
+ repository = getUtility(IGitLookup).getByPath(path)
+ if repository is None:
+ return None
+ try:
+ hosting_path = repository.getInternalPath()
+ except Unauthorized:
+ raise faults.PermissionDenied()
+ writable = check_permission("launchpad.Edit", repository)
+ return {"path": hosting_path, "writable": writable}
+
+ def _getGitNamespaceExtras(self, path, requester):
+ """Get the namespace, repository name, and callback for the path.
+
+ If the path defines a full Git repository path including the owner
+ and repository name, then the namespace that is returned is the
+ namespace for the owner and the repository target specified.
+
+ If the path uses a shortcut name, then we only allow the requester
+ to create a repository if they have permission to make the newly
+ created repository the default for the shortcut target. If there is
+ an existing default repository, then GitRepositoryExists is raised.
+ The repository name that is used is determined by the namespace as
+ the first unused name starting with the leaf part of the namespace
+ name. In this case, the repository owner will be set to the
+ namespace owner, and distribution source package namespaces are
+ currently disallowed due to the complexities of ownership there.
+ """
+ try:
+ namespace_name, repository_name = split_git_unique_name(path)
+ except InvalidNamespace:
+ namespace_name = path
+ repository_name = None
+ owner, target, repository = getUtility(IGitTraverser).traverse_path(
+ namespace_name)
+ # split_git_unique_name should have left us without a repository name.
+ assert repository is None
+ if repository_name is None and IPerson.providedBy(target):
+ raise InvalidNamespace(path)
+ if owner is None:
+ if IDistributionSourcePackage.providedBy(target):
+ raise GitRepositoryCreationForbidden(
+ "Cannot create default package repository; push to a "
+ "per-owner repository instead.")
+ repository_owner = requester
+ else:
+ repository_owner = owner
+ namespace = get_git_namespace(target, repository_owner)
+ if repository_name is None:
+ def default_func(new_repository):
+ repository_set = getUtility(IGitRepositorySet)
+ if owner is None:
+ repository_set.setDefaultRepository(
+ target, new_repository)
+ else:
+ repository_set.setDefaultRepositoryForOwner(
+ owner, target, new_repository)
+
+ repository_name = namespace.findUnusedName(target.name)
+ return namespace, repository_name, default_func
+ else:
+ return namespace, repository_name, None
+
+ def _createRepository(self, requester, path):
+ try:
+ namespace, repository_name, default_func = (
+ self._getGitNamespaceExtras(path, requester))
+ except (ValueError, InvalidNamespace):
+ raise faults.PermissionDenied(
+ "Cannot create Git repository at '%s'" % path)
+ except NoSuchPerson as e:
+ raise faults.NotFound("User/team '%s' does not exist." % e.name)
+ except NoSuchProduct as e:
+ raise faults.NotFound("Project '%s' does not exist." % e.name)
+ except InvalidProductName as e:
+ raise faults.InvalidProductName(escape(e.name))
+ except NoSuchSourcePackageName as e:
+ try:
+ getUtility(ISourcePackageNameSet).new(e.name)
+ except InvalidName:
+ raise faults.InvalidSourcePackageName(e.name)
+ return self._createRepository(requester, path)
+ except NameLookupFailed as e:
+ raise faults.NotFound(str(e))
+ except GitRepositoryCreationForbidden as e:
+ raise faults.PermissionDenied(str(e))
+
+ try:
+ repository = namespace.createRepository(
+ requester, repository_name)
+ except LaunchpadValidationError as e:
+ msg = e.args[0]
+ if isinstance(msg, unicode):
+ msg = msg.encode('utf-8')
+ raise faults.PermissionDenied(msg)
+ except GitRepositoryCreationException as e:
+ raise faults.PermissionDenied(str(e))
+
+ try:
+ if default_func:
+ try:
+ default_func(repository)
+ except Unauthorized:
+ raise faults.PermissionDenied(
+ "Cannot create default Git repository at '%s'." % path)
+
+ # The transaction hasn't been committed yet (and shouldn't be
+ # until the non-transactional work is complete), so
+ # repository.id will not yet have been filled in, but we need it
+ # to create the hosting path.
+ store = Store.of(repository)
+ repository_id = store.execute(
+ """SELECT currval('gitrepository_id_seq')""").get_one()[0]
+
+ hosting_path = repository.getInternalPathForId(repository_id)
+ # XXX cjwatson 2015-02-27: Turn any exceptions into proper faults.
+ self.hosting_client.create(hosting_path)
+ except Exception:
+ # We don't want to keep the repository we created.
+ transaction.abort()
+ raise
+
+ @return_fault
+ def _translatePath(self, requester, path, permission, can_authenticate):
+ if requester == LAUNCHPAD_ANONYMOUS:
+ requester = None
+ result = self._performLookup(path)
+ if result is None and requester is not None and permission == "write":
+ self._createRepository(requester, path)
+ result = self._performLookup(path)
+ if result is None:
+ raise faults.PathTranslationError(path)
+ if permission != "read" and not result["writable"]:
+ # XXX 2015-02-03 cjwatson: We should make use of
+ # can_authenticate to distinguish between "read-only to
+ # authenticated user" and "might be able to write if logged
+ # in".
+ raise faults.PermissionDenied()
+ return result
+
+ def translatePath(self, path, permission, requester_id, can_authenticate):
+ """See `IGitAPI`."""
+ if requester_id is None:
+ requester_id = LAUNCHPAD_ANONYMOUS
+ return run_with_login(
+ requester_id, self._translatePath,
+ unescape(path).strip("/"), permission, can_authenticate)
=== added file 'lib/lp/code/xmlrpc/tests/test_git.py'
--- lib/lp/code/xmlrpc/tests/test_git.py 1970-01-01 00:00:00 +0000
+++ lib/lp/code/xmlrpc/tests/test_git.py 2015-03-03 02:01:47 +0000
@@ -0,0 +1,489 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for the internal Git API."""
+
+__metaclass__ = type
+
+from bzrlib.urlutils import escape
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.code.interfaces.codehosting import (
+ LAUNCHPAD_ANONYMOUS,
+ LAUNCHPAD_SERVICES,
+ )
+from lp.code.interfaces.gitrepository import (
+ GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE,
+ IGitRepositorySet,
+ )
+from lp.code.xmlrpc.git import GitAPI
+from lp.services.webapp.escaping import html_escape
+from lp.testing import (
+ ANONYMOUS,
+ login,
+ person_logged_in,
+ TestCaseWithFactory,
+ )
+from lp.testing.layers import LaunchpadFunctionalLayer
+from lp.xmlrpc import faults
+
+
+class FakeGitHostingClient:
+
+ def __init__(self):
+ self.calls = []
+
+ def create(self, path):
+ self.calls.append(("create", path))
+
+
+class TestGitAPI(TestCaseWithFactory):
+ """Tests for the implementation of `IGitAPI`."""
+
+ layer = LaunchpadFunctionalLayer
+
+ def setUp(self):
+ super(TestGitAPI, self).setUp()
+ self.git_api = GitAPI(None, None)
+ self.git_api.hosting_client = FakeGitHostingClient()
+
+ def assertPathTranslationError(self, requester, path, permission="read",
+ can_authenticate=False):
+ """Assert that the given path cannot be translated."""
+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester = requester.id
+ fault = self.git_api.translatePath(
+ escape(path), permission, requester, can_authenticate)
+ self.assertEqual(faults.PathTranslationError(path.strip("/")), fault)
+
+ def assertPermissionDenied(self, requester, path,
+ message="Permission denied.",
+ permission="read", can_authenticate=False):
+ """Assert that looking at the given path gives permission denied."""
+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester = requester.id
+ fault = self.git_api.translatePath(
+ escape(path), permission, requester, can_authenticate)
+ self.assertEqual(faults.PermissionDenied(message), fault)
+
+ def assertNotFound(self, requester, path, message, permission="read",
+ can_authenticate=False):
+ """Assert that looking at the given path returns NotFound."""
+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester = requester.id
+ fault = self.git_api.translatePath(
+ escape(path), permission, requester, can_authenticate)
+ self.assertEqual(faults.NotFound(message), fault)
+
+ def assertInvalidProductName(self, requester, path, name,
+ permission="read", can_authenticate=False):
+ """Assert that looking at the given path returns InvalidProductName."""
+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester = requester.id
+ fault = self.git_api.translatePath(
+ escape(path), permission, requester, can_authenticate)
+ self.assertEqual(faults.InvalidProductName(name), fault)
+
+ def assertInvalidSourcePackageName(self, requester, path, name,
+ permission="read",
+ can_authenticate=False):
+ """Assert that looking at the given path returns
+ InvalidSourcePackageName."""
+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester = requester.id
+ fault = self.git_api.translatePath(
+ escape(path), permission, requester, can_authenticate)
+ self.assertEqual(faults.InvalidSourcePackageName(name), fault)
+
+ def assertTranslates(self, requester, path, repository, writable,
+ permission="read"):
+ if requester not in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester = requester.id
+ translation = self.git_api.translatePath(
+ escape(path), permission, requester, False)
+ login(ANONYMOUS)
+ self.assertEqual(
+ {"path": repository.getInternalPath(), "writable": writable},
+ translation)
+
+ def test_translatePath_cannot_translate(self):
+ # Sometimes translatePath will not know how to translate a path.
+ # When this happens, it returns a Fault saying so, including the
+ # path it couldn't translate.
+ requester = self.factory.makePerson()
+ self.assertPathTranslationError(requester, u"/untranslatable")
+
+ def test_translatePath_repository(self):
+ requester = self.factory.makePerson()
+ repository = self.factory.makeGitRepository()
+ path = u"/%s" % repository.unique_name
+ self.assertTranslates(requester, path, repository, False)
+
+ def test_translatePath_repository_with_no_leading_slash(self):
+ requester = self.factory.makePerson()
+ repository = self.factory.makeGitRepository()
+ path = repository.unique_name
+ self.assertTranslates(requester, path, repository, False)
+
+ def test_translatePath_repository_with_trailing_slash(self):
+ requester = self.factory.makePerson()
+ repository = self.factory.makeGitRepository()
+ path = u"/%s/" % repository.unique_name
+ self.assertTranslates(requester, path, repository, False)
+
+ def test_translatePath_repository_with_trailing_segments(self):
+ requester = self.factory.makePerson()
+ repository = self.factory.makeGitRepository()
+ path = u"/%s/junk" % repository.unique_name
+ self.assertPathTranslationError(requester, path)
+
+ def test_translatePath_no_such_repository(self):
+ requester = self.factory.makePerson()
+ path = u"/%s/+git/no-such-repository" % requester.name
+ self.assertPathTranslationError(requester, path)
+
+ def test_translatePath_no_such_repository_non_ascii(self):
+ requester = self.factory.makePerson()
+ path = u"/%s/+git/\N{LATIN SMALL LETTER I WITH DIAERESIS}" % (
+ requester.name)
+ self.assertPathTranslationError(requester, path)
+
+ def test_translatePath_private_repository(self):
+ requester = self.factory.makePerson()
+ repository = removeSecurityProxy(
+ self.factory.makeGitRepository(
+ owner=requester, information_type=InformationType.USERDATA))
+ path = u"/%s" % repository.unique_name
+ self.assertTranslates(requester, path, repository, True)
+
+ def test_translatePath_cannot_see_private_repository(self):
+ requester = self.factory.makePerson()
+ repository = removeSecurityProxy(
+ self.factory.makeGitRepository(
+ information_type=InformationType.USERDATA))
+ path = u"/%s" % repository.unique_name
+ self.assertPermissionDenied(requester, path)
+
+ def test_translatePath_anonymous_cannot_see_private_repository(self):
+ repository = removeSecurityProxy(
+ self.factory.makeGitRepository(
+ information_type=InformationType.USERDATA))
+ path = u"/%s" % repository.unique_name
+ self.assertPermissionDenied(LAUNCHPAD_ANONYMOUS, path)
+
+ def test_translatePath_anonymous_public_repository(self):
+ repository = self.factory.makeGitRepository()
+ path = u"/%s" % repository.unique_name
+ self.assertTranslates(LAUNCHPAD_ANONYMOUS, path, repository, False)
+
+ def test_translatePath_owned(self):
+ requester = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(owner=requester)
+ path = u"/%s" % repository.unique_name
+ self.assertTranslates(
+ requester, path, repository, True, permission="write")
+
+ def test_translatePath_team_owned(self):
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(requester)
+ repository = self.factory.makeGitRepository(owner=team)
+ path = u"/%s" % repository.unique_name
+ self.assertTranslates(
+ requester, path, repository, True, permission="write")
+
+ def test_translatePath_team_unowned(self):
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(self.factory.makePerson())
+ repository = self.factory.makeGitRepository(owner=team)
+ path = u"/%s" % repository.unique_name
+ self.assertTranslates(requester, path, repository, False)
+ self.assertPermissionDenied(requester, path, permission="write")
+
+ def test_translatePath_shortened_path(self):
+ # translatePath translates the shortened path to a repository.
+ requester = self.factory.makePerson()
+ repository = self.factory.makeGitRepository()
+ with person_logged_in(repository.target.owner):
+ getUtility(IGitRepositorySet).setDefaultRepository(
+ repository.target, repository)
+ path = u"/%s" % repository.target.name
+ self.assertTranslates(requester, path, repository, False)
+
+ def assertCreates(self, requester, path):
+ if requester in (LAUNCHPAD_ANONYMOUS, LAUNCHPAD_SERVICES):
+ requester_id = requester
+ else:
+ requester_id = requester.id
+ translation = self.git_api.translatePath(
+ escape(path), "write", requester_id, False)
+ login(ANONYMOUS)
+ repository = getUtility(IGitRepositorySet).getByPath(
+ requester, path.lstrip("/"))
+ self.assertIsNotNone(repository)
+ self.assertEqual(requester, repository.registrant)
+ self.assertEqual(
+ {"path": repository.getInternalPath(), "writable": True},
+ translation)
+ self.assertEqual(
+ [("create", repository.getInternalPath())],
+ self.git_api.hosting_client.calls)
+ return repository
+
+ def test_translatePath_create_project(self):
+ # translatePath creates a project repository that doesn't exist, if
+ # it can.
+ requester = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ self.assertCreates(
+ requester, u"/~%s/%s/+git/random" % (requester.name, project.name))
+
+ def test_translatePath_create_package(self):
+ # translatePath creates a package repository that doesn't exist, if
+ # it can.
+ requester = self.factory.makePerson()
+ dsp = self.factory.makeDistributionSourcePackage()
+ self.assertCreates(
+ requester,
+ u"/~%s/%s/+source/%s/+git/random" % (
+ requester.name,
+ dsp.distribution.name, dsp.sourcepackagename.name))
+
+ def test_translatePath_create_personal(self):
+ # translatePath creates a personal repository that doesn't exist, if
+ # it can.
+ requester = self.factory.makePerson()
+ self.assertCreates(requester, u"/~%s/+git/random" % requester.name)
+
+ def test_translatePath_create_personal_team(self):
+ # translatePath creates a personal repository for a team of which
+ # the requester is a member.
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(members=[requester])
+ self.assertCreates(requester, u"/~%s/+git/random" % team.name)
+
+ def test_translatePath_create_personal_team_denied(self):
+ # translatePath refuses to create a personal repository for a team
+ # of which the requester is not a member.
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam()
+ message = "%s is not a member of %s" % (
+ requester.displayname, team.displayname)
+ self.assertPermissionDenied(
+ requester, u"/~%s/+git/random" % team.name, message=message,
+ permission="write")
+
+ def test_translatePath_anonymous_cannot_create(self):
+ # Anonymous users cannot create repositories.
+ project = self.factory.makeProject()
+ self.assertPathTranslationError(
+ LAUNCHPAD_ANONYMOUS, u"/%s" % project.name, permission="write")
+
+ def test_translatePath_create_invalid_namespace(self):
+ # Trying to create a repository at a path that isn't valid for Git
+ # repositories returns a PermissionDenied fault.
+ requester = self.factory.makePerson()
+ path = u"/~%s" % requester.name
+ self.assertPermissionDenied(
+ requester, path,
+ message="Cannot create Git repository at '%s'" % path.strip("/"),
+ permission="write")
+
+ def test_translatePath_create_no_such_person(self):
+ # Creating a repository for a non-existent person fails.
+ requester = self.factory.makePerson()
+ self.assertNotFound(
+ requester, u"/~nonexistent/+git/random",
+ "User/team 'nonexistent' does not exist.", permission="write")
+
+ def test_translatePath_create_no_such_project(self):
+ # Creating a repository for a non-existent project fails.
+ requester = self.factory.makePerson()
+ self.assertNotFound(
+ requester, u"/~%s/nonexistent/+git/random" % requester.name,
+ "Project 'nonexistent' does not exist.", permission="write")
+
+ def test_translatePath_create_no_such_person_or_project(self):
+ # If neither the person nor the project are found, then the missing
+ # person is reported in preference.
+ requester = self.factory.makePerson()
+ self.assertNotFound(
+ requester, u"/~nonexistent/nonexistent/+git/random",
+ "User/team 'nonexistent' does not exist.", permission="write")
+
+ def test_translatePath_create_invalid_project(self):
+ # Creating a repository with an invalid project name fails.
+ requester = self.factory.makePerson()
+ self.assertInvalidProductName(
+ requester, u"/_bad_project/+git/random", "_bad_project",
+ permission="write")
+
+ def test_translatePath_create_missing_sourcepackagename(self):
+ # If translatePath is asked to create a repository for a missing
+ # source package, it will create the source package.
+ requester = self.factory.makePerson()
+ distro = self.factory.makeDistribution()
+ repository_name = self.factory.getUniqueString()
+ path = u"/~%s/%s/+source/new-package/+git/%s" % (
+ requester.name, distro.name, repository_name)
+ repository = self.assertCreates(requester, path)
+ self.assertEqual(
+ "new-package", repository.target.sourcepackagename.name)
+
+ def test_translatePath_create_invalid_sourcepackagename(self):
+ # Creating a repository for an invalid source package name fails.
+ requester = self.factory.makePerson()
+ distro = self.factory.makeDistribution()
+ repository_name = self.factory.getUniqueString()
+ path = u"/~%s/%s/+source/new package/+git/%s" % (
+ requester.name, distro.name, repository_name)
+ self.assertInvalidSourcePackageName(
+ requester, path, "new package", permission="write")
+
+ def test_translatePath_create_other_user(self):
+ # Creating a repository for another user fails.
+ requester = self.factory.makePerson()
+ other_person = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ name = self.factory.getUniqueString()
+ path = u"/~%s/%s/+git/%s" % (other_person.name, project.name, name)
+ message = "%s cannot create Git repositories owned by %s" % (
+ requester.displayname, other_person.displayname)
+ self.assertPermissionDenied(
+ requester, path, message=message, permission="write")
+
+ def test_translatePath_create_bad_name(self):
+ # Creating a repository with an invalid name fails.
+ requester = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ invalid_name = "invalid name!"
+ path = u"/~%s/%s/+git/%s" % (
+ requester.name, project.name, invalid_name)
+ # LaunchpadValidationError unfortunately assumes its output is
+ # always HTML, so it ends up double-escaped in XML-RPC faults.
+ message = html_escape(
+ "Invalid Git repository name '%s'. %s" %
+ (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE))
+ self.assertPermissionDenied(
+ requester, path, message=message, permission="write")
+
+ def test_translatePath_create_unicode_name(self):
+ # Creating a repository with a non-ASCII invalid name fails.
+ requester = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ invalid_name = u"invalid\N{LATIN SMALL LETTER E WITH ACUTE}"
+ path = u"/~%s/%s/+git/%s" % (
+ requester.name, project.name, invalid_name)
+ # LaunchpadValidationError unfortunately assumes its output is
+ # always HTML, so it ends up double-escaped in XML-RPC faults.
+ message = html_escape(
+ "Invalid Git repository name '%s'. %s" %
+ (invalid_name, GIT_REPOSITORY_NAME_VALIDATION_ERROR_MESSAGE))
+ self.assertPermissionDenied(
+ requester, path, message=message.encode("UTF-8"),
+ permission="write")
+
+ def test_translatePath_create_project_default(self):
+ # A repository can be created and immediately set as the default for
+ # a project.
+ requester = self.factory.makePerson()
+ project = self.factory.makeProduct(owner=requester)
+ repository = self.assertCreates(requester, u"/%s" % project.name)
+ self.assertTrue(repository.target_default)
+ self.assertFalse(repository.owner_default)
+
+ def test_translatePath_create_project_not_owner(self):
+ # Somebody without edit permission on the project cannot create a
+ # repository and immediately set it as the default for that project.
+ requester = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ path = u"/%s" % project.name
+ message = "Cannot create default Git repository at '%s'." % (
+ path.strip("/"))
+ self.assertPermissionDenied(
+ requester, path, message=message, permission="write")
+
+ def test_translatePath_create_package_default_denied(self):
+ # A repository cannot (yet) be created and immediately set as the
+ # default for a package.
+ requester = self.factory.makePerson()
+ dsp = self.factory.makeDistributionSourcePackage()
+ path = u"/%s/+source/%s" % (
+ dsp.distribution.name, dsp.sourcepackagename.name)
+ message = (
+ "Cannot create default package repository; push to a per-owner "
+ "repository instead.")
+ self.assertPermissionDenied(
+ requester, path, message=message, permission="write")
+
+ def test_translatePath_create_project_owner_default(self):
+ # A repository can be created and immediately set as its owner's
+ # default for a project.
+ requester = self.factory.makePerson()
+ project = self.factory.makeProduct()
+ repository = self.assertCreates(
+ requester, u"/~%s/%s" % (requester.name, project.name))
+ self.assertFalse(repository.target_default)
+ self.assertTrue(repository.owner_default)
+
+ def test_translatePath_create_project_team_owner_default(self):
+ # The owner of a team can create a team-owned repository and
+ # immediately set it as that team's default for a project.
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(owner=requester)
+ project = self.factory.makeProduct()
+ repository = self.assertCreates(
+ requester, u"/~%s/%s" % (team.name, project.name))
+ self.assertFalse(repository.target_default)
+ self.assertTrue(repository.owner_default)
+
+ def test_translatePath_create_project_not_team_owner_default(self):
+ # A non-owner member of a team cannot immediately set a
+ # newly-created team-owned repository as that team's default for a
+ # project.
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(members=[requester])
+ project = self.factory.makeProduct()
+ path = u"/~%s/%s" % (team.name, project.name)
+ message = "Cannot create default Git repository at '%s'." % (
+ path.strip("/"))
+ self.assertPermissionDenied(
+ requester, path, message=message, permission="write")
+
+ def test_translatePath_create_package_owner_default(self):
+ # A repository can be created and immediately set as its owner's
+ # default for a package.
+ requester = self.factory.makePerson()
+ dsp = self.factory.makeDistributionSourcePackage()
+ path = u"/~%s/%s/+source/%s" % (
+ requester.name, dsp.distribution.name, dsp.sourcepackagename.name)
+ repository = self.assertCreates(requester, path)
+ self.assertFalse(repository.target_default)
+ self.assertTrue(repository.owner_default)
+
+ def test_translatePath_create_package_team_owner_default(self):
+ # The owner of a team can create a team-owned repository and
+ # immediately set it as that team's default for a package.
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(owner=requester)
+ dsp = self.factory.makeDistributionSourcePackage()
+ path = u"/~%s/%s/+source/%s" % (
+ team.name, dsp.distribution.name, dsp.sourcepackagename.name)
+ repository = self.assertCreates(requester, path)
+ self.assertFalse(repository.target_default)
+ self.assertTrue(repository.owner_default)
+
+ def test_translatePath_create_package_not_team_owner_default(self):
+ # A non-owner member of a team cannot immediately set a
+ # newly-created team-owned repository as that team's default for a
+ # package.
+ requester = self.factory.makePerson()
+ team = self.factory.makeTeam(members=[requester])
+ dsp = self.factory.makeDistributionSourcePackage()
+ path = u"/~%s/%s/+source/%s" % (
+ team.name, dsp.distribution.name, dsp.sourcepackagename.name)
+ message = "Cannot create default Git repository at '%s'." % (
+ path.strip("/"))
+ self.assertPermissionDenied(
+ requester, path, message=message, permission="write")
=== modified file 'lib/lp/systemhomes.py'
--- lib/lp/systemhomes.py 2013-06-20 05:50:00 +0000
+++ lib/lp/systemhomes.py 2015-03-03 02:01:47 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Content classes for the 'home pages' of the subsystems of Launchpad."""
@@ -47,6 +47,7 @@
from lp.code.interfaces.codeimportscheduler import (
ICodeImportSchedulerApplication,
)
+from lp.code.interfaces.gitapi import IGitApplication
from lp.hardwaredb.interfaces.hwdb import (
IHWDBApplication,
IHWDeviceSet,
@@ -92,6 +93,12 @@
title = "Code Import Scheduler"
+class GitApplication:
+ implements(IGitApplication)
+
+ title = "Git API"
+
+
class PrivateMaloneApplication:
"""ExternalBugTracker authentication token end-point."""
implements(IPrivateMaloneApplication)
=== modified file 'lib/lp/xmlrpc/application.py'
--- lib/lp/xmlrpc/application.py 2013-01-07 02:40:55 +0000
+++ lib/lp/xmlrpc/application.py 2015-03-03 02:01:47 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009-2011 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""XML-RPC API to the application roots."""
@@ -24,6 +24,7 @@
from lp.code.interfaces.codeimportscheduler import (
ICodeImportSchedulerApplication,
)
+from lp.code.interfaces.gitapi import IGitApplication
from lp.registry.interfaces.mailinglist import IMailingListApplication
from lp.registry.interfaces.person import (
ICanonicalSSOApplication,
@@ -80,6 +81,11 @@
"""See `IPrivateApplication`."""
return getUtility(IFeatureFlagApplication)
+ @property
+ def git(self):
+ """See `IPrivateApplication`."""
+ return getUtility(IGitApplication)
+
class ISelfTest(Interface):
"""XMLRPC external interface for testing the XMLRPC external interface."""
=== modified file 'lib/lp/xmlrpc/configure.zcml'
--- lib/lp/xmlrpc/configure.zcml 2012-10-31 14:29:13 +0000
+++ lib/lp/xmlrpc/configure.zcml 2015-03-03 02:01:47 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2009-2010 Canonical Ltd. This software is licensed under the
+<!-- Copyright 2009-2015 Canonical Ltd. This software is licensed under the
GNU Affero General Public License version 3 (see the file LICENSE).
-->
@@ -48,6 +48,19 @@
/>
<securedutility
+ class="lp.systemhomes.GitApplication"
+ provides="lp.code.interfaces.gitapi.IGitApplication">
+ <allow interface="lp.code.interfaces.gitapi.IGitApplication"/>
+ </securedutility>
+
+ <xmlrpc:view
+ for="lp.code.interfaces.gitapi.IGitApplication"
+ interface="lp.code.interfaces.gitapi.IGitAPI"
+ class="lp.code.xmlrpc.git.GitAPI"
+ permission="zope.Public"
+ />
+
+ <securedutility
class="lp.systemhomes.PrivateMaloneApplication"
provides="lp.bugs.interfaces.malone.IPrivateMaloneApplication">
<allow interface="lp.bugs.interfaces.malone.IPrivateMaloneApplication"/>
=== modified file 'lib/lp/xmlrpc/interfaces.py'
--- lib/lp/xmlrpc/interfaces.py 2012-01-15 21:06:58 +0000
+++ lib/lp/xmlrpc/interfaces.py 2015-03-03 02:01:47 +0000
@@ -1,4 +1,4 @@
-# Copyright 2011 Canonical Ltd. This software is licensed under the
+# Copyright 2011-2015 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Interfaces for the Launchpad application."""
@@ -34,3 +34,5 @@
"""Canonical SSO XML-RPC end point.""")
featureflags = Attribute("""Feature flag information endpoint""")
+
+ git = Attribute("Git end point.")
=== modified file 'setup.py'
--- setup.py 2015-01-06 12:47:59 +0000
+++ setup.py 2015-03-03 02:01:47 +0000
@@ -79,6 +79,7 @@
'python-openid',
'pytz',
'rabbitfixture',
+ 'requests',
's4',
'setproctitle',
'setuptools',
Follow ups