Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:allow-private-resources-sourcecraft-builds into launchpad:master.
Commit message: Allow private resources for sourcecraft builds Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/481888 -- Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:allow-private-resources-sourcecraft-builds into launchpad:master.
diff --git a/lib/lp/code/xmlrpc/tests/test_git.py b/lib/lp/code/xmlrpc/tests/test_git.py index 05155f4..9046f3e 100644 --- a/lib/lp/code/xmlrpc/tests/test_git.py +++ b/lib/lp/code/xmlrpc/tests/test_git.py @@ -48,6 +48,10 @@ from lp.code.interfaces.gitrepository import ( from lp.code.model.gitjob import GitRefScanJob from lp.code.tests.helpers import GitHostingFixture from lp.code.xmlrpc.git import GIT_ASYNC_CREATE_REPO +from lp.crafts.interfaces.craftrecipe import ( + CRAFT_RECIPE_ALLOW_CREATE, + CRAFT_RECIPE_PRIVATE_FEATURE_FLAG, +) from lp.registry.enums import TeamMembershipPolicy from lp.services.auth.enums import AccessTokenScope from lp.services.config import config @@ -2569,6 +2573,96 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory): macaroon_raw=macaroons[0].serialize(), ) + def test_translatePath_private_craft_recipe_build(self): + # A builder with a suitable macaroon can read from a repository + # associated with a running private craft recipe build. + self.useFixture( + FeatureFixture( + { + CRAFT_RECIPE_ALLOW_CREATE: "on", + CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on", + } + ) + ) + self.pushConfig( + "launchpad", internal_macaroon_secret_key="some-secret" + ) + owner = self.factory.makePerson() + with person_logged_in(owner): + refs = [ + self.factory.makeGitRefs( + owner=owner, information_type=InformationType.USERDATA + )[0] + for _ in range(2) + ] + builds = [ + self.factory.makeCraftRecipeBuild( + requester=owner, + owner=owner, + git_ref=ref, + recipe=self.factory.makeCraftRecipe( + owner=owner, + registrant=owner, + git_ref=ref, + information_type=InformationType.PROPRIETARY, + ), + ) + for ref in refs + ] + issuer = getUtility(IMacaroonIssuer, "craft-recipe-build") + macaroons = [ + removeSecurityProxy(issuer).issueMacaroon(build) + for build in builds + ] + repository = refs[0].repository + registrant = repository.registrant + path = "/%s" % repository.unique_name + self.assertUnauthorized( + LAUNCHPAD_SERVICES, + path, + permission="write", + macaroon_raw=macaroons[0].serialize(), + ) + with person_logged_in(owner): + builds[0].updateStatus(BuildStatus.BUILDING) + self.assertTranslates( + LAUNCHPAD_SERVICES, + path, + repository, + permission="read", + macaroon_raw=macaroons[0].serialize(), + private=True, + writable=False, + ) + self.assertUnauthorized( + LAUNCHPAD_SERVICES, + path, + permission="read", + macaroon_raw=macaroons[1].serialize(), + ) + self.assertUnauthorized( + LAUNCHPAD_SERVICES, + path, + permission="read", + macaroon_raw=Macaroon( + location=config.vhost.mainsite.hostname, + identifier="another", + key="another-secret", + ).serialize(), + ) + self.assertUnauthorized( + LAUNCHPAD_SERVICES, + path, + permission="read", + macaroon_raw="nonsense", + ) + self.assertUnauthorized( + registrant, + path, + permission="read", + macaroon_raw=macaroons[0].serialize(), + ) + def test_translatePath_user_macaroon(self): # A user with a suitable macaroon can write to the corresponding # repository, but not others, even if they own them. @@ -3576,6 +3670,64 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory): "nonsense", ) + def test_authenticateWithPassword_private_craft_recipe_build(self): + self.useFixture( + FeatureFixture( + { + CRAFT_RECIPE_ALLOW_CREATE: "on", + CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on", + } + ) + ) + self.pushConfig( + "launchpad", internal_macaroon_secret_key="some-secret" + ) + with person_logged_in(self.factory.makePerson()) as owner: + [ref] = self.factory.makeGitRefs( + owner=owner, information_type=InformationType.USERDATA + ) + recipe = self.factory.makeCraftRecipe( + owner=owner, + registrant=owner, + git_ref=ref, + information_type=InformationType.PROPRIETARY, + ) + build = self.factory.makeCraftRecipeBuild( + requester=owner, owner=owner, git_ref=ref, recipe=recipe + ) + issuer = getUtility(IMacaroonIssuer, "craft-recipe-build") + macaroon = removeSecurityProxy(issuer).issueMacaroon(build) + for username in ("", "+launchpad-services"): + self.assertEqual( + { + "macaroon": macaroon.serialize(), + "user": "+launchpad-services", + }, + self.assertDoesNotFault( + None, + "authenticateWithPassword", + username, + macaroon.serialize(), + ), + ) + other_macaroon = Macaroon( + identifier="another", key="another-secret" + ) + self.assertFault( + faults.Unauthorized, + None, + "authenticateWithPassword", + username, + other_macaroon.serialize(), + ) + self.assertFault( + faults.Unauthorized, + None, + "authenticateWithPassword", + username, + "nonsense", + ) + def test_authenticateWithPassword_user_macaroon(self): # A user with a suitable macaroon can authenticate using it, in # which case we return both the macaroon and the uid for use by @@ -3971,6 +4123,46 @@ class TestGitAPI(TestGitAPIMixin, TestCaseWithFactory): macaroon_raw=macaroon.serialize(), ) + def test_checkRefPermissions_private_craft_recipe_build(self): + # A builder with a suitable macaroon cannot write to a repository, + # even if it is associated with a running private craft recipe build. + self.useFixture( + FeatureFixture( + { + CRAFT_RECIPE_ALLOW_CREATE: "on", + CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on", + } + ) + ) + self.pushConfig( + "launchpad", internal_macaroon_secret_key="some-secret" + ) + with person_logged_in(self.factory.makePerson()) as owner: + [ref] = self.factory.makeGitRefs( + owner=owner, information_type=InformationType.USERDATA + ) + recipe = self.factory.makeCraftRecipe( + owner=owner, + registrant=owner, + git_ref=ref, + information_type=InformationType.PROPRIETARY, + ) + build = self.factory.makeCraftRecipeBuild( + requester=owner, owner=owner, git_ref=ref, recipe=recipe + ) + issuer = getUtility(IMacaroonIssuer, "craft-recipe-build") + macaroon = removeSecurityProxy(issuer).issueMacaroon(build) + build.updateStatus(BuildStatus.BUILDING) + repository = ref.repository + path = ref.path.encode("UTF-8") + self.assertHasRefPermissions( + LAUNCHPAD_SERVICES, + repository, + [path], + {path: []}, + macaroon_raw=macaroon.serialize(), + ) + def test_checkRefPermissions_user_macaroon(self): # A user with a suitable macaroon has their ordinary privileges on # the corresponding repository, but not others, even if they own diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml index 0ec2393..a13bd3f 100644 --- a/lib/lp/crafts/configure.zcml +++ b/lib/lp/crafts/configure.zcml @@ -102,4 +102,12 @@ <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeRequestBuildsJob" /> </class> <webservice:register module="lp.crafts.interfaces.webservice" /> + + <!-- CraftRecipeBuildMacaroonIssuer --> + <lp:securedutility + class="lp.crafts.model.craftrecipebuild.CraftRecipeBuildMacaroonIssuer" + provides="lp.services.macaroons.interfaces.IMacaroonIssuer" + name="craft-recipe-build"> + <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/> + </lp:securedutility> </configure> diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py index d84b466..8fa4baa 100644 --- a/lib/lp/crafts/model/craftrecipebuild.py +++ b/lib/lp/crafts/model/craftrecipebuild.py @@ -16,6 +16,7 @@ from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode from storm.store import EmptyResultSet from zope.component import getUtility from zope.interface import implementer +from zope.security.proxy import removeSecurityProxy from lp.app.errors import NotFoundError from lp.buildmaster.builderproxy import BUILD_METADATA_FILENAME_FORMAT @@ -27,6 +28,7 @@ from lp.buildmaster.enums import ( from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin from lp.buildmaster.model.packagebuild import PackageBuildMixin +from lp.code.interfaces.gitrepository import IGitRepository from lp.crafts.interfaces.craftrecipebuild import ( ICraftFile, ICraftRecipeBuild, @@ -47,6 +49,12 @@ from lp.services.database.interfaces import IPrimaryStore, IStore from lp.services.database.stormbase import StormBase from lp.services.librarian.browser import ProxiedLibraryFileAlias from lp.services.librarian.model import LibraryFileAlias, LibraryFileContent +from lp.services.macaroons.interfaces import ( + NO_USER, + BadMacaroonContext, + IMacaroonIssuer, +) +from lp.services.macaroons.model import MacaroonIssuerBase from lp.services.propertycache import cachedproperty, get_property_cache from lp.services.webapp.snapshot import notify_modified from lp.soyuz.model.distroarchseries import DistroArchSeries @@ -460,6 +468,71 @@ class CraftRecipeBuildSet(SpecificBuildFarmJobSourceMixin): return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData) +@implementer(IMacaroonIssuer) +class CraftRecipeBuildMacaroonIssuer(MacaroonIssuerBase): + identifier = "craft-recipe-build" + issuable_via_authserver = True + + def checkIssuingContext(self, context, **kwargs): + """See `MacaroonIssuerBase`. + + For issuing, the context is an `ICraftRecipeBuild`. + """ + if not ICraftRecipeBuild.providedBy(context): + raise BadMacaroonContext(context) + if not removeSecurityProxy(context).is_private: + raise BadMacaroonContext( + context, "Refusing to issue macaroon for public build." + ) + return removeSecurityProxy(context).id + + def checkVerificationContext(self, context, **kwargs): + """See `MacaroonIssuerBase`.""" + if not IGitRepository.providedBy(context): + raise BadMacaroonContext(context) + return context + + def verifyPrimaryCaveat( + self, verified, caveat_value, context, user=None, **kwargs + ): + """See `MacaroonIssuerBase`. + + For verification, the context is an `IGitRepository`. We check that + the repository is needed to build the `ICraftRecipeBuild` that is the + context of the macaroon, and that the context build is currently + building. + """ + # Circular import. + from lp.crafts.model.craftrecipe import CraftRecipe + + # CraftRecipeBuild builds only support free-floating macaroons for Git + # authentication, not ones bound to a user. + if user: + return False + verified.user = NO_USER + + if context is None: + # We're only verifying that the macaroon could be valid for some + # context. + return True + + try: + build_id = int(caveat_value) + except ValueError: + return False + return ( + not IStore(CraftRecipeBuild) + .find( + CraftRecipeBuild, + CraftRecipeBuild.id == build_id, + CraftRecipeBuild.recipe_id == CraftRecipe.id, + CraftRecipe.git_repository == context, + CraftRecipeBuild.status == BuildStatus.BUILDING, + ) + .is_empty() + ) + + @implementer(ICraftFile) class CraftFile(StormBase): """See `ICraftFile`.""" diff --git a/lib/lp/crafts/model/craftrecipebuildbehaviour.py b/lib/lp/crafts/model/craftrecipebuildbehaviour.py index 50cfe6d..839b4c2 100644 --- a/lib/lp/crafts/model/craftrecipebuildbehaviour.py +++ b/lib/lp/crafts/model/craftrecipebuildbehaviour.py @@ -27,8 +27,11 @@ from lp.buildmaster.interfaces.buildfarmjobbehaviour import ( from lp.buildmaster.model.buildfarmjobbehaviour import ( BuildFarmJobBehaviourBase, ) +from lp.code.interfaces.codehosting import LAUNCHPAD_SERVICES from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild from lp.registry.interfaces.series import SeriesStatus +from lp.services.config import config +from lp.services.twistedsupport import cancel_on_timeout from lp.soyuz.adapters.archivedependencies import get_sources_list_for_building @@ -72,6 +75,18 @@ class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase): "Missing chroot for %s" % build.distro_arch_series.displayname ) + def issueMacaroon(self): + """See `IBuildFarmJobBehaviour`.""" + return cancel_on_timeout( + self._authserver.callRemote( + "issueMacaroon", + "craft-recipe-build", + "CraftRecipeBuild", + self.build.id, + ), + config.builddmaster.authentication_timeout, + ) + @defer.inlineCallbacks def extraBuildArgs(self, logger=None) -> Generator[Any, Any, BuildArgs]: """ @@ -79,6 +94,28 @@ class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase): """ build = self.build args: BuildArgs = yield super().extraBuildArgs(logger=logger) + + if logger is not None: + logger.debug("Build recipe: %r", build.recipe) + logger.debug("Git ref: %r", build.recipe.git_ref) + if build.recipe.git_ref is not None: + logger.debug( + "Git ref repository URL: %r", + build.recipe.git_ref.repository_url, + ) + logger.debug("Git repository: %r", build.recipe.git_repository) + logger.debug( + "Git repository HTTPS URL: %r", + build.recipe.git_repository.git_https_url, + ) + logger.debug("Git path: %r", build.recipe.git_path) + logger.debug("Git ref name: %r", build.recipe.git_ref.name) + logger.debug( + "Recipe information type: %r", + build.recipe.information_type, + ) + logger.debug("Is private: %r", build.is_private) + yield self.startProxySession( args, use_fetch_service=build.recipe.use_fetch_service, @@ -101,12 +138,16 @@ class CraftRecipeBuildBehaviour(BuilderProxyMixin, BuildFarmJobBehaviourBase): if build.recipe.git_ref.repository_url is not None: args["git_repository"] = build.recipe.git_ref.repository_url else: - args["git_repository"] = ( - build.recipe.git_repository.git_https_url - ) - # "git clone -b" doesn't accept full ref names. If this becomes - # a problem then we could change launchpad-buildd to do "git - # clone" followed by "git checkout" instead. + repo = build.recipe.git_repository + if repo.private: + macaroon_raw = yield self.issueMacaroon() + url = repo.getCodebrowseUrl( + username=LAUNCHPAD_SERVICES, password=macaroon_raw + ) + args["git_repository"] = url + else: + args["git_repository"] = repo.git_https_url + if build.recipe.git_path != "HEAD": args["git_path"] = build.recipe.git_ref.name else: diff --git a/lib/lp/crafts/tests/test_craftrecipebuild.py b/lib/lp/crafts/tests/test_craftrecipebuild.py index c583d9c..346efba 100644 --- a/lib/lp/crafts/tests/test_craftrecipebuild.py +++ b/lib/lp/crafts/tests/test_craftrecipebuild.py @@ -7,8 +7,16 @@ from datetime import datetime, timedelta, timezone from urllib.request import urlopen import six -from testtools.matchers import ContainsDict, Equals, Is +from pymacaroons import Macaroon +from testtools.matchers import ( + ContainsDict, + Equals, + Is, + MatchesListwise, + MatchesStructure, +) from zope.component import getUtility +from zope.publisher.xmlrpc import TestRequest from zope.security.proxy import removeSecurityProxy from lp.app.enums import InformationType @@ -29,11 +37,17 @@ from lp.crafts.interfaces.craftrecipebuild import ( from lp.crafts.model.craftrecipebuild import CraftRecipeBuild from lp.registry.enums import PersonVisibility, TeamMembershipPolicy from lp.registry.interfaces.series import SeriesStatus +from lp.services.authserver.xmlrpc import AuthServerAPIView from lp.services.config import config from lp.services.database.interfaces import IStore from lp.services.database.sqlbase import flush_database_caches from lp.services.features.testing import FeatureFixture from lp.services.librarian.browser import ProxiedLibraryFileAlias +from lp.services.macaroons.interfaces import ( + BadMacaroonContext, + IMacaroonIssuer, +) +from lp.services.macaroons.testing import MacaroonTestMixin from lp.services.propertycache import clear_property_cache from lp.services.webapp.interfaces import OAuthPermission from lp.testing import ( @@ -49,6 +63,7 @@ from lp.testing.layers import LaunchpadFunctionalLayer, LaunchpadZopelessLayer from lp.testing.mail_helpers import pop_notifications from lp.testing.matchers import HasQueryCount from lp.testing.pages import webservice_for_person +from lp.xmlrpc.interfaces import IPrivateApplication expected_body = """\ * Craft Recipe: craft-1 @@ -715,3 +730,227 @@ class TestCraftRecipeBuildWebservice(TestCaseWithFactory): logout() build = self.webservice.get(build_url).jsonBody() self.assertIsNone(build["build_metadata_url"]) + + +class TestCraftRecipeBuildMacaroonIssuer( + MacaroonTestMixin, TestCaseWithFactory +): + """Test CraftRecipeBuild macaroon issuing and verification.""" + + layer = LaunchpadZopelessLayer + + def setUp(self): + super().setUp() + self.useFixture( + FeatureFixture( + { + CRAFT_RECIPE_ALLOW_CREATE: "on", + CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on", + } + ) + ) + self.pushConfig( + "launchpad", internal_macaroon_secret_key="some-secret" + ) + + def getPrivateBuild(self): + owner = self.factory.makePerson() + recipe = self.factory.makeCraftRecipe( + owner=owner, + registrant=owner, + information_type=InformationType.PROPRIETARY, + ) + build = self.factory.makeCraftRecipeBuild( + recipe=recipe, requester=owner + ) + build.recipe.git_ref.repository.transitionToInformationType( + InformationType.PRIVATESECURITY, build.recipe.registrant + ) + return build + + def test_issueMacaroon_refuses_public_craftrecipebuild(self): + build = self.factory.makeCraftRecipeBuild() + issuer = getUtility(IMacaroonIssuer, "craft-recipe-build") + self.assertRaises( + BadMacaroonContext, + removeSecurityProxy(issuer).issueMacaroon, + build, + ) + + def test_issueMacaroon_good(self): + build = self.getPrivateBuild() + issuer = getUtility(IMacaroonIssuer, "craft-recipe-build") + macaroon = removeSecurityProxy(issuer).issueMacaroon(build) + self.assertThat( + macaroon, + MatchesStructure( + location=Equals("launchpad.test"), + identifier=Equals("craft-recipe-build"), + caveats=MatchesListwise( + [ + MatchesStructure.byEquality( + caveat_id="lp.craft-recipe-build %s" % build.id + ), + ] + ), + ), + ) + + def test_issueMacaroon_via_authserver(self): + build = self.getPrivateBuild() + private_root = getUtility(IPrivateApplication) + authserver = AuthServerAPIView(private_root.authserver, TestRequest()) + macaroon = Macaroon.deserialize( + authserver.issueMacaroon( + "craft-recipe-build", "CraftRecipeBuild", build.id + ) + ) + self.assertThat( + macaroon, + MatchesStructure( + location=Equals("launchpad.test"), + identifier=Equals("craft-recipe-build"), + caveats=MatchesListwise( + [ + MatchesStructure.byEquality( + caveat_id="lp.craft-recipe-build %s" % build.id + ), + ] + ), + ), + ) + + def test_verifyMacaroon_good(self): + build = self.getPrivateBuild() + build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = issuer.issueMacaroon(build) + self.assertMacaroonVerifies( + issuer, macaroon, build.recipe.git_ref.repository + ) + + def test_verifyMacaroon_good_no_context(self): + build = self.getPrivateBuild() + build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = issuer.issueMacaroon(build) + self.assertMacaroonVerifies( + issuer, macaroon, None, require_context=False + ) + self.assertMacaroonVerifies( + issuer, + macaroon, + build.recipe.git_ref.repository, + require_context=False, + ) + + def test_verifyMacaroon_no_context_but_require_context(self): + build = self.getPrivateBuild() + build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = issuer.issueMacaroon(build) + self.assertMacaroonDoesNotVerify( + ["Expected macaroon verification context but got None."], + issuer, + macaroon, + None, + ) + + def test_verifyMacaroon_wrong_location(self): + build = self.getPrivateBuild() + build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = Macaroon( + location="another-location", key=issuer._root_secret + ) + self.assertMacaroonDoesNotVerify( + ["Macaroon has unknown location 'another-location'."], + issuer, + macaroon, + build.recipe.git_ref.repository, + ) + self.assertMacaroonDoesNotVerify( + ["Macaroon has unknown location 'another-location'."], + issuer, + macaroon, + build.recipe.git_ref.repository, + require_context=False, + ) + + def test_verifyMacaroon_wrong_key(self): + build = self.getPrivateBuild() + build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = Macaroon( + location=config.vhost.mainsite.hostname, key="another-secret" + ) + self.assertMacaroonDoesNotVerify( + ["Signatures do not match"], + issuer, + macaroon, + build.recipe.git_ref.repository, + ) + self.assertMacaroonDoesNotVerify( + ["Signatures do not match"], + issuer, + macaroon, + build.recipe.git_ref.repository, + require_context=False, + ) + + def test_verifyMacaroon_not_building(self): + build = self.getPrivateBuild() + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = issuer.issueMacaroon(build) + self.assertMacaroonDoesNotVerify( + ["Caveat check for 'lp.craft-recipe-build %s' failed." % build.id], + issuer, + macaroon, + build.recipe.git_ref.repository, + ) + + def test_verifyMacaroon_wrong_build(self): + build = self.getPrivateBuild() + build.updateStatus(BuildStatus.BUILDING) + other_build = self.getPrivateBuild() + other_build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = issuer.issueMacaroon(other_build) + self.assertMacaroonDoesNotVerify( + [ + "Caveat check for 'lp.craft-recipe-build %s' failed." + % other_build.id + ], + issuer, + macaroon, + build.recipe.git_ref.repository, + ) + + def test_verifyMacaroon_wrong_repository(self): + build = self.getPrivateBuild() + other_repository = self.factory.makeGitRepository() + build.updateStatus(BuildStatus.BUILDING) + issuer = removeSecurityProxy( + getUtility(IMacaroonIssuer, "craft-recipe-build") + ) + macaroon = issuer.issueMacaroon(build) + self.assertMacaroonDoesNotVerify( + ["Caveat check for 'lp.craft-recipe-build %s' failed." % build.id], + issuer, + macaroon, + other_repository, + ) diff --git a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py index 803eca1..802977b 100644 --- a/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py +++ b/lib/lp/crafts/tests/test_craftrecipebuildbehaviour.py @@ -16,12 +16,14 @@ from fixtures import MockPatch, TempDir from pymacaroons import Macaroon from testtools import ExpectedException from testtools.matchers import ( + AfterPreprocessing, ContainsDict, Equals, Is, IsInstance, MatchesDict, MatchesListwise, + MatchesStructure, StartsWith, ) from testtools.twistedsupport import ( @@ -68,6 +70,7 @@ from lp.crafts.interfaces.craftrecipe import ( ) from lp.crafts.model.craftrecipebuildbehaviour import CraftRecipeBuildBehaviour from lp.registry.interfaces.series import SeriesStatus +from lp.services.authserver.testing import InProcessAuthServerFixture from lp.services.config import config from lp.services.features.testing import FeatureFixture from lp.services.log.logger import BufferLogger, DevNullLogger @@ -197,6 +200,25 @@ class TestAsyncCraftRecipeBuildBehaviour( self.addCleanup(shut_down_default_process_pool) self.setUpStats() + def assertHasNoZopeSecurityProxy(self, data): + """Makes sure that data doesn't contain a security proxy. + + `data` can be a list, a tuple, a dict or an ordinary value. This + method checks `data` itself, and if it's a collection, it checks + each item in it. + """ + self.assertFalse( + isProxy(data), "%s should not be a security proxy." % data + ) + # If it's a collection, keep searching for proxies. + if isinstance(data, (list, tuple)): + for i in data: + self.assertHasNoZopeSecurityProxy(i) + elif isinstance(data, dict): + for k, v in data.items(): + self.assertHasNoZopeSecurityProxy(k) + self.assertHasNoZopeSecurityProxy(v) + def makeJob(self, **kwargs): # We need a builder in these tests, in order that requesting a proxy # token can piggyback on its reactor and pool. @@ -590,6 +612,88 @@ class TestAsyncCraftRecipeBuildBehaviour( ("ensurepresent", chroot_lfa.http_url, "", ""), worker.call_log[0] ) + @defer.inlineCallbacks + def test_extraBuildArgs_private_git_ref(self): + """Test extraBuildArgs for private recipe with git reference.""" + self.useFixture(InProcessAuthServerFixture()) + self.pushConfig( + "launchpad", internal_macaroon_secret_key="some-secret" + ) + self.useFixture( + FeatureFixture( + { + CRAFT_RECIPE_ALLOW_CREATE: "on", + CRAFT_RECIPE_PRIVATE_FEATURE_FLAG: "on", + } + ) + ) + + # Create public ref first, then transition to private + [ref] = self.factory.makeGitRefs() + ref.repository.transitionToInformationType( + InformationType.USERDATA, ref.repository.owner + ) + + owner = self.factory.makePerson() + recipe = self.factory.makeCraftRecipe( + owner=owner, + registrant=owner, + git_ref=ref, + information_type=InformationType.PROPRIETARY, + ) + job = self.makeJob(git_ref=ref, recipe=recipe) + + logger = BufferLogger() + with dbuser(config.builddmaster.dbuser): + args = yield job.extraBuildArgs(logger=logger) + + # Debug prints + print("\nDebug logs:") + print(logger.getLogBuffer()) + print("\nArgs:", args) + if "git_repository" in args: + print("\nGit URL:", args["git_repository"]) + parts = urlsplit(args["git_repository"]) + print("URL parts:", parts) + + # Asserts that nothing here is a zope proxy, to avoid errors when + # serializing it for XML-RPC call. + self.assertHasNoZopeSecurityProxy(args) + + # Print the log buffer for debugging + print("\nDebug logs:") + print(logger.getLogBuffer()) + + # Add assertions similar to snap build test + split_browse_root = urlsplit(config.codehosting.git_browse_root) + self.assertThat( + args["git_repository"], + AfterPreprocessing( + urlsplit, + MatchesStructure( + scheme=Equals(split_browse_root.scheme), + username=Equals("+launchpad-services"), + password=AfterPreprocessing( + Macaroon.deserialize, + MatchesStructure( + location=Equals(config.vhost.mainsite.hostname), + identifier=Equals("craft-recipe-build"), + caveats=MatchesListwise( + [ + MatchesStructure.byEquality( + caveat_id="lp.craft-recipe-build %s" + % job.build.id + ), + ] + ), + ), + ), + hostname=Equals(split_browse_root.hostname), + port=Equals(split_browse_root.port), + ), + ), + ) + class TestAsyncCraftRecipeBuildBehaviourFetchService( StatsMixin, TestCraftRecipeBuildBehaviourBase diff --git a/lib/lp/services/authserver/xmlrpc.py b/lib/lp/services/authserver/xmlrpc.py index 74758fe..23d8625 100644 --- a/lib/lp/services/authserver/xmlrpc.py +++ b/lib/lp/services/authserver/xmlrpc.py @@ -16,6 +16,7 @@ from zope.security.proxy import removeSecurityProxy from lp.app.errors import NotFoundError from lp.code.interfaces.cibuild import ICIBuildSet +from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuildSet from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet from lp.registry.interfaces.person import IPersonSet from lp.services.authserver.interfaces import ( @@ -60,8 +61,8 @@ class AuthServerAPIView(LaunchpadXMLRPCView): :param context_type: A string identifying the type of context. Currently only 'LibraryFileAlias', 'BinaryPackageBuild', - 'LiveFSBuild', 'SnapBuild', 'OCIRecipeBuild', and 'CIBuild' are - supported. + 'LiveFSBuild', 'SnapBuild', 'OCIRecipeBuild', 'CIBuild', and + 'CraftRecipeBuild' are supported. :param context: The context as plain data (e.g. an ID). :return: The resolved context, or None. """ @@ -86,6 +87,9 @@ class AuthServerAPIView(LaunchpadXMLRPCView): elif context_type == "CIBuild": # The context is a `CIBuild` ID. return getUtility(ICIBuildSet).getByID(context) + elif context_type == "CraftRecipeBuild": + # The context is a `CraftRecipeBuild` ID. + return getUtility(ICraftRecipeBuildSet).getByID(context) else: return None
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp