← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:ci-build-api into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:ci-build-api into launchpad:master.

Commit message:
Export CIBuild on the webservice API

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/422031

This should be enough to traverse from revision status reports to any associated CI builds, to perform build farm management tasks such as cancelling/retrying/rescoring builds, and to fetch any files associated with CI builds.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build-api into launchpad:master.
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index ed9dedf..7cd0d87 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -15,7 +15,13 @@ __all__ = [
 
 import http.client
 
-from lazr.restful.declarations import error_status
+from lazr.restful.declarations import (
+    error_status,
+    export_read_operation,
+    exported,
+    exported_as_webservice_entry,
+    operation_for_version,
+    )
 from lazr.restful.fields import Reference
 from zope.schema import (
     Bool,
@@ -84,27 +90,27 @@ class CIBuildAlreadyRequested(Exception):
 class ICIBuildView(IPackageBuildView, IPrivacy):
     """`ICIBuild` attributes that require launchpad.View."""
 
-    git_repository = Reference(
+    git_repository = exported(Reference(
         IGitRepository,
         title=_("The Git repository for this CI build."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
-    commit_sha1 = TextLine(
+    commit_sha1 = exported(TextLine(
         title=_("The Git commit ID for this CI build."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    distro_arch_series = Reference(
+    distro_arch_series = exported(Reference(
         IDistroArchSeries,
         title=_(
             "The series and architecture that this CI build should run on."),
-        required=True, readonly=True)
+        required=True, readonly=True))
 
-    arch_tag = TextLine(
-        title=_("Architecture tag"), required=True, readonly=True)
+    arch_tag = exported(TextLine(
+        title=_("Architecture tag"), required=True, readonly=True))
 
-    score = Int(
+    score = exported(Int(
         title=_("Score of the related build farm job (if any)."),
-        required=False, readonly=True)
+        required=False, readonly=True))
 
     eta = Datetime(
         title=_("The datetime when the build job is estimated to complete."),
@@ -118,13 +124,13 @@ class ICIBuildView(IPackageBuildView, IPrivacy):
             "The date when the build completed or is estimated to complete."),
         readonly=True)
 
-    stages = List(
-        title=_("A list of stages in this build's configured pipeline."))
+    stages = exported(List(
+        title=_("A list of stages in this build's configured pipeline.")))
 
-    results = Dict(
+    results = exported(Dict(
         title=_(
             "A mapping from job IDs to result tokens, retrieved from the "
-            "builder."))
+            "builder.")))
 
     def getConfiguration(logger=None):
         """Fetch a CI build's .launchpad.yaml from code hosting, if possible.
@@ -163,6 +169,14 @@ class ICIBuildView(IPackageBuildView, IPrivacy):
         :return: The corresponding `ILibraryFileAlias`.
         """
 
+    @export_read_operation()
+    @operation_for_version("devel")
+    def getFileUrls():
+        """URLs for all the files produced by this build.
+
+        :return: A collection of URLs for this build.
+        """
+
 
 class ICIBuildEdit(IBuildFarmJobEdit):
     """`ICIBuild` methods that require launchpad.Edit."""
@@ -172,6 +186,7 @@ class ICIBuildAdmin(IBuildFarmJobAdmin):
     """`ICIBuild` methods that require launchpad.Admin."""
 
 
+@exported_as_webservice_entry(as_of="devel", singular_name="ci_build")
 class ICIBuild(ICIBuildView, ICIBuildEdit, ICIBuildAdmin, IPackageBuild):
     """A build record for a pipeline of CI jobs."""
 
diff --git a/lib/lp/code/interfaces/revisionstatus.py b/lib/lp/code/interfaces/revisionstatus.py
index 708cc40..cf9c856 100644
--- a/lib/lp/code/interfaces/revisionstatus.py
+++ b/lib/lp/code/interfaces/revisionstatus.py
@@ -124,10 +124,10 @@ class IRevisionStatusReportEditableAttributes(Interface):
         title=_('Result of the report'),  readonly=True,
         required=False, vocabulary=RevisionStatusResult))
 
-    ci_build = Reference(
+    ci_build = exported(Reference(
         title=_("The CI build that produced this report."),
         # Really ICIBuild, patched in _schema_circular_imports.py.
-        schema=Interface, required=False, readonly=True)
+        schema=Interface, required=False, readonly=True))
 
     @mutator_for(result)
     @operation_parameters(result=copy_field(result))
@@ -260,6 +260,9 @@ class IRevisionStatusArtifactSet(Interface):
     def findByReport(report):
         """Returns the set of artifacts for a given report."""
 
+    def findByCIBuild(ci_build):
+        """Return all `RevisionStatusArtifact`s for a CI build."""
+
     def getByRepositoryAndID(repository, id):
         """Returns the artifact for a given repository and ID."""
 
@@ -270,6 +273,8 @@ class IRevisionStatusArtifact(Interface):
     report = Attribute(
         "The `RevisionStatusReport` that this artifact is linked to.")
 
+    library_file_id = Int(
+        title=_("LibraryFileAlias ID"), required=True, readonly=True)
     library_file = Attribute(
         "The `LibraryFileAlias` object containing information for "
         "a revision status report.")
diff --git a/lib/lp/code/interfaces/webservice.py b/lib/lp/code/interfaces/webservice.py
index bfe20b3..a51ce3d 100644
--- a/lib/lp/code/interfaces/webservice.py
+++ b/lib/lp/code/interfaces/webservice.py
@@ -21,6 +21,7 @@ __all__ = [
     'IBranchMergeProposal',
     'IBranchSet',
     'IBranchSubscription',
+    'ICIBuild',
     'ICodeImport',
     'ICodeReviewComment',
     'ICodeReviewVoteReference',
@@ -56,6 +57,7 @@ from lp.code.interfaces.branch import (
     )
 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
 from lp.code.interfaces.branchsubscription import IBranchSubscription
+from lp.code.interfaces.cibuild import ICIBuild
 from lp.code.interfaces.codeimport import ICodeImport
 from lp.code.interfaces.codereviewcomment import ICodeReviewComment
 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index f2b44a0..d789a95 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -8,6 +8,7 @@ __all__ = [
     ]
 
 from datetime import timedelta
+from operator import attrgetter
 
 from lazr.lifecycle.event import ObjectCreatedEvent
 import pytz
@@ -53,7 +54,10 @@ from lp.code.interfaces.cibuild import (
     )
 from lp.code.interfaces.githosting import IGitHostingClient
 from lp.code.interfaces.gitrepository import IGitRepository
-from lp.code.interfaces.revisionstatus import IRevisionStatusReportSet
+from lp.code.interfaces.revisionstatus import (
+    IRevisionStatusArtifactSet,
+    IRevisionStatusReportSet,
+    )
 from lp.code.model.gitref import GitRef
 from lp.code.model.lpcraft import load_configuration
 from lp.registry.interfaces.pocket import PackagePublishingPocket
@@ -443,6 +447,15 @@ class CIBuild(PackageBuildMixin, StormBase):
 
         raise NotFoundError(filename)
 
+    def getFileUrls(self):
+        artifacts = getUtility(IRevisionStatusArtifactSet).findByCIBuild(self)
+        load_related(LibraryFileAlias, artifacts, ["library_file_id"])
+        artifacts = sorted(
+            artifacts, key=attrgetter("library_file.filename", "id"))
+        return [
+            ProxiedLibraryFileAlias(artifact.library_file, artifact).http_url
+            for artifact in artifacts]
+
     def verifySuccessfulUpload(self) -> bool:
         """See `IPackageBuild`."""
         # We have no interesting checks to perform here.
diff --git a/lib/lp/code/model/revisionstatus.py b/lib/lp/code/model/revisionstatus.py
index 78fc85e..87edccc 100644
--- a/lib/lp/code/model/revisionstatus.py
+++ b/lib/lp/code/model/revisionstatus.py
@@ -284,6 +284,13 @@ class RevisionStatusArtifactSet:
             RevisionStatusArtifact,
             RevisionStatusArtifact.report == report)
 
+    def findByCIBuild(self, ci_build):
+        """See `IRevisionStatusArtifactSet`."""
+        return IStore(RevisionStatusArtifact).find(
+            RevisionStatusArtifact,
+            RevisionStatusArtifact.report == RevisionStatusReport.id,
+            RevisionStatusReport.ci_build == ci_build)
+
     def getByRepositoryAndID(self, repository, id):
         return IStore(RevisionStatusArtifact).find(
             RevisionStatusArtifact,
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index 80558cf..899b09b 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -10,13 +10,16 @@ from datetime import (
 import hashlib
 from textwrap import dedent
 from unittest.mock import Mock
+from urllib.request import urlopen
 
 from fixtures import MockPatchObject
 from pymacaroons import Macaroon
 import pytz
 from storm.locals import Store
 from testtools.matchers import (
+    ContainsDict,
     Equals,
+    Is,
     MatchesListwise,
     MatchesSetwise,
     MatchesStructure,
@@ -61,17 +64,27 @@ from lp.code.tests.helpers import GitHostingFixture
 from lp.registry.interfaces.series import SeriesStatus
 from lp.services.authserver.xmlrpc import AuthServerAPIView
 from lp.services.config import config
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
 from lp.services.log.logger import BufferLogger
 from lp.services.macaroons.interfaces import 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 (
+    ANONYMOUS,
+    api_url,
+    login,
+    logout,
     person_logged_in,
     StormStatementRecorder,
     TestCaseWithFactory,
     )
-from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.layers import (
+    LaunchpadFunctionalLayer,
+    LaunchpadZopelessLayer,
+    )
 from lp.testing.matchers import HasQueryCount
+from lp.testing.pages import webservice_for_person
 from lp.xmlrpc.interfaces import IPrivateApplication
 
 
@@ -1006,6 +1019,159 @@ class TestDetermineDASesToBuild(TestCaseWithFactory):
         )
 
 
+class TestCIBuildWebservice(TestCaseWithFactory):
+
+    layer = LaunchpadFunctionalLayer
+
+    def setUp(self):
+        super().setUp()
+        self.person = self.factory.makePerson()
+        self.webservice = webservice_for_person(
+            self.person, permission=OAuthPermission.WRITE_PRIVATE)
+        self.webservice.default_api_version = "devel"
+        login(ANONYMOUS)
+
+    def getURL(self, obj):
+        return self.webservice.getAbsoluteUrl(api_url(obj))
+
+    def test_properties(self):
+        # The basic properties of a CI build are sensible.
+        db_build = self.factory.makeCIBuild()
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        with person_logged_in(self.person):
+            self.assertThat(build, ContainsDict({
+                "git_repository_link": Equals(
+                    self.getURL(db_build.git_repository)),
+                "commit_sha1": Equals(db_build.commit_sha1),
+                "distro_arch_series_link": Equals(
+                    self.getURL(db_build.distro_arch_series)),
+                "arch_tag": Equals(
+                    db_build.distro_arch_series.architecturetag),
+                "score": Is(None),
+                "stages": Equals([[["test", 0]]]),
+                "results": Equals({}),
+                "can_be_rescored": Is(False),
+                "can_be_retried": Is(False),
+                "can_be_cancelled": Is(False),
+                }))
+
+    def test_public(self):
+        # A CI build with a public repository is itself public.
+        db_build = self.factory.makeCIBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(200, unpriv_webservice.get(build_url).status)
+
+    def test_private(self):
+        # A CI build with a private repository is private.
+        db_repository = self.factory.makeGitRepository(
+            owner=self.person, information_type=InformationType.USERDATA)
+        with person_logged_in(self.person):
+            db_build = self.factory.makeCIBuild(git_repository=db_repository)
+            build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        self.assertEqual(200, self.webservice.get(build_url).status)
+        self.assertEqual(401, unpriv_webservice.get(build_url).status)
+
+    def test_cancel(self):
+        # The owner of a build's repository can cancel the build.
+        db_repository = self.factory.makeGitRepository(owner=self.person)
+        db_build = self.factory.makeCIBuild(git_repository=db_repository)
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        unpriv_webservice = webservice_for_person(
+            self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+        unpriv_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertTrue(build["can_be_cancelled"])
+        response = unpriv_webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(401, response.status)
+        response = self.webservice.named_post(build["self_link"], "cancel")
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertFalse(build["can_be_cancelled"])
+        with person_logged_in(self.person):
+            self.assertEqual(BuildStatus.CANCELLED, db_build.status)
+
+    def test_rescore(self):
+        # Buildd administrators can rescore builds.
+        db_build = self.factory.makeCIBuild()
+        db_build.queueBuild()
+        build_url = api_url(db_build)
+        buildd_admin = self.factory.makePerson(
+            member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+        buildd_admin_webservice = webservice_for_person(
+            buildd_admin, permission=OAuthPermission.WRITE_PUBLIC)
+        buildd_admin_webservice.default_api_version = "devel"
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(2600, build["score"])
+        self.assertTrue(build["can_be_rescored"])
+        response = self.webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(401, response.status)
+        response = buildd_admin_webservice.named_post(
+            build["self_link"], "rescore", score=5000)
+        self.assertEqual(200, response.status)
+        build = self.webservice.get(build_url).jsonBody()
+        self.assertEqual(5000, build["score"])
+
+    def assertCanOpenRedirectedUrl(self, browser, url):
+        browser.open(url)
+        self.assertEqual(303, int(browser.headers["Status"].split(" ", 1)[0]))
+        urlopen(browser.headers["Location"]).close()
+
+    def test_logs(self):
+        # API clients can fetch the build and upload logs.
+        db_build = self.factory.makeCIBuild()
+        db_build.setLog(self.factory.makeLibraryFileAlias("buildlog.txt.gz"))
+        db_build.storeUploadLog("uploaded")
+        build_url = api_url(db_build)
+        logout()
+        build = self.webservice.get(build_url).jsonBody()
+        browser = self.getNonRedirectingBrowser(user=self.person)
+        browser.raiseHttpErrors = False
+        self.assertIsNotNone(build["build_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["build_log_url"])
+        self.assertIsNotNone(build["upload_log_url"])
+        self.assertCanOpenRedirectedUrl(browser, build["upload_log_url"])
+
+    def test_getFileUrls(self):
+        # API clients can fetch files attached to builds.
+        db_build = self.factory.makeCIBuild()
+        db_reports = [
+            self.factory.makeRevisionStatusReport(ci_build=db_build)
+            for _ in range(2)]
+        db_artifacts = []
+        for db_report in db_reports:
+            for _ in range(2):
+                db_artifacts.append(self.factory.makeRevisionStatusArtifact(
+                    lfa=self.factory.makeLibraryFileAlias(), report=db_report))
+        build_url = api_url(db_build)
+        file_urls = [
+            ProxiedLibraryFileAlias(
+                db_artifact.library_file, db_artifact).http_url
+            for db_artifact in db_artifacts]
+        logout()
+        response = self.webservice.named_get(build_url, "getFileUrls")
+        self.assertEqual(200, response.status)
+        self.assertContentEqual(file_urls, response.jsonBody())
+        browser = self.getNonRedirectingBrowser(user=self.person)
+        browser.raiseHttpErrors = False
+        for file_url in file_urls:
+            self.assertCanOpenRedirectedUrl(browser, file_url)
+
+
 class TestCIBuildMacaroonIssuer(MacaroonTestMixin, TestCaseWithFactory):
     """Test CIBuild macaroon issuing and verification."""