1# This file is part of Buildbot.  Buildbot is free software: you can
2# redistribute it and/or modify it under the terms of the GNU General Public
3# License as published by the Free Software Foundation, version 2.
4#
5# This program is distributed in the hope that it will be useful, but WITHOUT
6# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
7# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
8# details.
9#
10# You should have received a copy of the GNU General Public License along with
11# this program; if not, write to the Free Software Foundation, Inc., 51
12# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
13#
14# Copyright Buildbot Team Members
15
16import base64
17import json
18import os
19
20from twisted.internet import defer
21from twisted.protocols import basic
22from twisted.python import log
23from twisted.spread import pb
24
25from buildbot import pbutil
26from buildbot.process.properties import Properties
27from buildbot.schedulers import base
28from buildbot.util import bytes2unicode
29from buildbot.util import netstrings
30from buildbot.util import unicode2bytes
31from buildbot.util.maildir import MaildirService
32
33
34class TryBase(base.BaseScheduler):
35
36    def filterBuilderList(self, builderNames):
37        """
38        Make sure that C{builderNames} is a subset of the configured
39        C{self.builderNames}, returning an empty list if not.  If
40        C{builderNames} is empty, use C{self.builderNames}.
41
42        @returns: list of builder names to build on
43        """
44
45        # self.builderNames is the configured list of builders
46        # available for try.  If the user supplies a list of builders,
47        # it must be restricted to the configured list.  If not, build
48        # on all of the configured builders.
49        if builderNames:
50            for b in builderNames:
51                if b not in self.builderNames:
52                    log.msg("{} got with builder {}".format(self, b))
53                    log.msg(" but that wasn't in our list: {}".format(self.builderNames))
54                    return []
55        else:
56            builderNames = self.builderNames
57        return builderNames
58
59
60class BadJobfile(Exception):
61    pass
62
63
64class JobdirService(MaildirService):
65    # NOTE: tightly coupled with Try_Jobdir, below. We used to track it as a "parent"
66    # via the MultiService API, but now we just track it as the member
67    # "self.scheduler"
68    name = 'JobdirService'
69
70    def __init__(self, scheduler, basedir=None):
71        self.scheduler = scheduler
72        super().__init__(basedir)
73
74    def messageReceived(self, filename):
75        with self.moveToCurDir(filename) as f:
76            rv = self.scheduler.handleJobFile(filename, f)
77        return rv
78
79
80class Try_Jobdir(TryBase):
81
82    compare_attrs = ('jobdir',)
83
84    def __init__(self, name, builderNames, jobdir, **kwargs):
85        super().__init__(name, builderNames, **kwargs)
86        self.jobdir = jobdir
87        self.watcher = JobdirService(scheduler=self)
88
89    # TryBase used to be a MultiService and managed the JobdirService via a parent/child
90    # relationship. We stub out the addService/removeService and just keep track of
91    # JobdirService as self.watcher. We'll refactor these things later and remove
92    # the need for this.
93    def addService(self, child):
94        pass
95
96    def removeService(self, child):
97        pass
98
99    # activation handlers
100
101    @defer.inlineCallbacks
102    def activate(self):
103        yield super().activate()
104
105        if not self.enabled:
106            return
107
108        # set the watcher's basedir now that we have a master
109        jobdir = os.path.join(self.master.basedir, self.jobdir)
110        self.watcher.setBasedir(jobdir)
111        for subdir in "cur new tmp".split():
112            if not os.path.exists(os.path.join(jobdir, subdir)):
113                os.mkdir(os.path.join(jobdir, subdir))
114
115        # bridge the activate/deactivate to a startService/stopService on the
116        # child service
117        self.watcher.startService()
118
119    @defer.inlineCallbacks
120    def deactivate(self):
121        yield super().deactivate()
122
123        if not self.enabled:
124            return
125
126        # bridge the activate/deactivate to a startService/stopService on the
127        # child service
128        self.watcher.stopService()
129
130    def parseJob(self, f):
131        # jobfiles are serialized build requests. Each is a list of
132        # serialized netstrings, in the following order:
133        #  format version number:
134        #  "1" the original
135        #  "2" introduces project and repository
136        #  "3" introduces who
137        #  "4" introduces comment
138        #  "5" introduces properties and JSON serialization of values after
139        #      version
140        #  "6" sends patch_body as base64-encoded string in the patch_body_base64 attribute
141        #  jobid: arbitrary string, used to find the buildSet later
142        #  branch: branch name, "" for default-branch
143        #  baserev: revision, "" for HEAD
144        #  patch_level: usually "1"
145        #  patch_body: patch to be applied for build (as string)
146        #  patch_body_base64: patch to be applied for build (as base64-encoded bytes)
147        #  repository
148        #  project
149        #  who: user requesting build
150        #  comment: comment from user about diff and/or build
151        #  builderNames: list of builder names
152        #  properties: dict of build properties
153        p = netstrings.NetstringParser()
154        f.seek(0, 2)
155        if f.tell() > basic.NetstringReceiver.MAX_LENGTH:
156            raise BadJobfile("The patch size is greater that NetStringReceiver.MAX_LENGTH. "
157                             "Please Set this higher in the master.cfg")
158        f.seek(0, 0)
159        try:
160            p.feed(f.read())
161        except basic.NetstringParseError as e:
162            raise BadJobfile("unable to parse netstrings") from e
163        if not p.strings:
164            raise BadJobfile("could not find any complete netstrings")
165        ver = bytes2unicode(p.strings.pop(0))
166
167        v1_keys = ['jobid', 'branch', 'baserev', 'patch_level', 'patch_body']
168        v2_keys = v1_keys + ['repository', 'project']
169        v3_keys = v2_keys + ['who']
170        v4_keys = v3_keys + ['comment']
171        keys = [v1_keys, v2_keys, v3_keys, v4_keys]
172        # v5 introduces properties and uses JSON serialization
173
174        parsed_job = {}
175
176        def extract_netstrings(p, keys):
177            for i, key in enumerate(keys):
178                if key == 'patch_body':
179                    parsed_job[key] = p.strings[i]
180                else:
181                    parsed_job[key] = bytes2unicode(p.strings[i])
182
183        def postprocess_parsed_job():
184            # apply defaults and handle type casting
185            parsed_job['branch'] = parsed_job['branch'] or None
186            parsed_job['baserev'] = parsed_job['baserev'] or None
187            parsed_job['patch_level'] = int(parsed_job['patch_level'])
188            for key in 'repository project who comment'.split():
189                parsed_job[key] = parsed_job.get(key, '')
190            parsed_job['properties'] = parsed_job.get('properties', {})
191
192        if ver <= "4":
193            i = int(ver) - 1
194            extract_netstrings(p, keys[i])
195            parsed_job['builderNames'] = [bytes2unicode(s)
196                                          for s in p.strings[len(keys[i]):]]
197            postprocess_parsed_job()
198        elif ver == "5":
199            try:
200                data = bytes2unicode(p.strings[0])
201                parsed_job = json.loads(data)
202                parsed_job['patch_body'] = unicode2bytes(parsed_job['patch_body'])
203            except ValueError as e:
204                raise BadJobfile("unable to parse JSON") from e
205            postprocess_parsed_job()
206        elif ver == "6":
207            try:
208                data = bytes2unicode(p.strings[0])
209                parsed_job = json.loads(data)
210                parsed_job['patch_body'] = base64.b64decode(parsed_job['patch_body_base64'])
211                del parsed_job['patch_body_base64']
212            except ValueError as e:
213                raise BadJobfile("unable to parse JSON") from e
214            postprocess_parsed_job()
215        else:
216            raise BadJobfile("unknown version '{}'".format(ver))
217        return parsed_job
218
219    def handleJobFile(self, filename, f):
220        try:
221            parsed_job = self.parseJob(f)
222            builderNames = parsed_job['builderNames']
223        except BadJobfile:
224            log.msg("{} reports a bad jobfile in {}".format(self, filename))
225            log.err()
226            return defer.succeed(None)
227
228        # Validate/fixup the builder names.
229        builderNames = self.filterBuilderList(builderNames)
230        if not builderNames:
231            log.msg(
232                "incoming Try job did not specify any allowed builder names")
233            return defer.succeed(None)
234
235        who = ""
236        if parsed_job['who']:
237            who = parsed_job['who']
238
239        comment = ""
240        if parsed_job['comment']:
241            comment = parsed_job['comment']
242
243        sourcestamp = dict(branch=parsed_job['branch'],
244                           codebase='',
245                           revision=parsed_job['baserev'],
246                           patch_body=parsed_job['patch_body'],
247                           patch_level=parsed_job['patch_level'],
248                           patch_author=who,
249                           patch_comment=comment,
250                           # TODO: can't set this remotely - #1769
251                           patch_subdir='',
252                           project=parsed_job['project'],
253                           repository=parsed_job['repository'])
254        reason = "'try' job"
255        if parsed_job['who']:
256            reason += " by user {}".format(bytes2unicode(parsed_job['who']))
257        properties = parsed_job['properties']
258        requested_props = Properties()
259        requested_props.update(properties, "try build")
260
261        return self.addBuildsetForSourceStamps(
262            sourcestamps=[sourcestamp],
263            reason=reason,
264            external_idstring=bytes2unicode(parsed_job['jobid']),
265            builderNames=builderNames,
266            properties=requested_props)
267
268
269class RemoteBuildSetStatus(pb.Referenceable):
270
271    def __init__(self, master, bsid, brids):
272        self.master = master
273        self.bsid = bsid
274        self.brids = brids
275
276    @defer.inlineCallbacks
277    def remote_getBuildRequests(self):
278        brids = dict()
279        for builderid, brid in self.brids.items():
280            builderDict = yield self.master.data.get(('builders', builderid))
281            brids[builderDict['name']] = brid
282        return [(n, RemoteBuildRequest(self.master, n, brid))
283            for n, brid in brids.items()]
284
285
286class RemoteBuildRequest(pb.Referenceable):
287
288    def __init__(self, master, builderName, brid):
289        self.master = master
290        self.builderName = builderName
291        self.brid = brid
292        self.consumer = None
293
294    @defer.inlineCallbacks
295    def remote_subscribe(self, subscriber):
296        brdict = yield self.master.data.get(('buildrequests', self.brid))
297        if not brdict:
298            return
299        builderId = brdict['builderid']
300        # make sure we aren't double-reporting any builds
301        reportedBuilds = set([])
302
303        # subscribe to any new builds..
304        def gotBuild(key, msg):
305            if msg['buildrequestid'] != self.brid or key[-1] != 'new':
306                return None
307            if msg['buildid'] in reportedBuilds:
308                return None
309            reportedBuilds.add(msg['buildid'])
310            return subscriber.callRemote('newbuild',
311                                         RemoteBuild(
312                                             self.master, msg, self.builderName),
313                                         self.builderName)
314        self.consumer = yield self.master.mq.startConsuming(
315            gotBuild, ('builders', str(builderId), 'builds', None, None))
316        subscriber.notifyOnDisconnect(lambda _:
317                                      self.remote_unsubscribe(subscriber))
318
319        # and get any existing builds
320        builds = yield self.master.data.get(('buildrequests', self.brid, 'builds'))
321        for build in builds:
322            if build['buildid'] in reportedBuilds:
323                continue
324            reportedBuilds.add(build['buildid'])
325            yield subscriber.callRemote('newbuild',
326                                        RemoteBuild(
327                                            self.master, build, self.builderName),
328                                        self.builderName)
329
330    def remote_unsubscribe(self, subscriber):
331        if self.consumer:
332            self.consumer.stopConsuming()
333            self.consumer = None
334
335
336class RemoteBuild(pb.Referenceable):
337
338    def __init__(self, master, builddict, builderName):
339        self.master = master
340        self.builddict = builddict
341        self.builderName = builderName
342        self.consumer = None
343
344    @defer.inlineCallbacks
345    def remote_subscribe(self, subscriber, interval):
346        # subscribe to any new steps..
347        def stepChanged(key, msg):
348            if key[-1] == 'started':
349                return subscriber.callRemote('stepStarted',
350                                             self.builderName, self, msg['name'], None)
351            elif key[-1] == 'finished':
352                return subscriber.callRemote('stepFinished', self.builderName, self, msg['name'],
353                                             None, msg['results'])
354            return None
355        self.consumer = yield self.master.mq.startConsuming(
356            stepChanged,
357            ('builds', str(self.builddict['buildid']), 'steps', None, None))
358        subscriber.notifyOnDisconnect(lambda _:
359                                      self.remote_unsubscribe(subscriber))
360
361    def remote_unsubscribe(self, subscriber):
362        if self.consumer:
363            self.consumer.stopConsuming()
364            self.consumer = None
365
366    @defer.inlineCallbacks
367    def remote_waitUntilFinished(self):
368        d = defer.Deferred()
369
370        def buildEvent(key, msg):
371            if key[-1] == 'finished':
372                d.callback(None)
373        consumer = yield self.master.mq.startConsuming(
374            buildEvent,
375            ('builds', str(self.builddict['buildid']), None))
376
377        yield d  # wait for event
378        consumer.stopConsuming()
379        return self  # callers expect result=self
380
381    @defer.inlineCallbacks
382    def remote_getResults(self):
383        buildid = self.builddict['buildid']
384        builddict = yield self.master.data.get(('builds', buildid))
385        return builddict['results']
386
387    @defer.inlineCallbacks
388    def remote_getText(self):
389        buildid = self.builddict['buildid']
390        builddict = yield self.master.data.get(('builds', buildid))
391        return [builddict['state_string']]
392
393
394class Try_Userpass_Perspective(pbutil.NewCredPerspective):
395
396    def __init__(self, scheduler, username):
397        self.scheduler = scheduler
398        self.username = username
399
400    @defer.inlineCallbacks
401    def perspective_try(self, branch, revision, patch, repository, project,
402                        builderNames, who="", comment="", properties=None):
403        log.msg("user {} requesting build on builders {}".format(self.username, builderNames))
404        if properties is None:
405            properties = {}
406        # build the intersection of the request and our configured list
407        builderNames = self.scheduler.filterBuilderList(builderNames)
408        if not builderNames:
409            return None
410
411        branch = bytes2unicode(branch)
412        revision = bytes2unicode(revision)
413        patch_level = patch[0]
414        patch_body = unicode2bytes(patch[1])
415        repository = bytes2unicode(repository)
416        project = bytes2unicode(project)
417        who = bytes2unicode(who)
418        comment = bytes2unicode(comment)
419
420        reason = "'try' job"
421
422        if who:
423            reason += " by user {}".format(bytes2unicode(who))
424
425        if comment:
426            reason += " ({})".format(bytes2unicode(comment))
427
428        sourcestamp = dict(
429            branch=branch, revision=revision, repository=repository,
430            project=project, patch_level=patch_level, patch_body=patch_body,
431            patch_subdir='', patch_author=who or '',
432            patch_comment=comment or '', codebase='',
433        )           # note: no way to specify patch subdir - #1769
434
435        requested_props = Properties()
436        requested_props.update(properties, "try build")
437        (bsid, brids) = yield self.scheduler.addBuildsetForSourceStamps(
438            sourcestamps=[sourcestamp], reason=reason,
439            properties=requested_props, builderNames=builderNames)
440
441        # return a remotely-usable BuildSetStatus object
442        bss = RemoteBuildSetStatus(self.scheduler.master, bsid, brids)
443        return bss
444
445    def perspective_getAvailableBuilderNames(self):
446        # Return a list of builder names that are configured
447        # for the try service
448        # This is mostly intended for integrating try services
449        # into other applications
450        return self.scheduler.listBuilderNames()
451
452
453class Try_Userpass(TryBase):
454    compare_attrs = ('name', 'builderNames', 'port', 'userpass', 'properties')
455
456    def __init__(self, name, builderNames, port, userpass, **kwargs):
457        super().__init__(name, builderNames, **kwargs)
458        self.port = port
459        self.userpass = userpass
460        self.registrations = []
461
462    @defer.inlineCallbacks
463    def activate(self):
464        yield super().activate()
465
466        if not self.enabled:
467            return
468
469        # register each user/passwd with the pbmanager
470        def factory(mind, username):
471            return Try_Userpass_Perspective(self, username)
472        for user, passwd in self.userpass:
473            reg = yield self.master.pbmanager.register(self.port, user, passwd, factory)
474            self.registrations.append(reg)
475
476    @defer.inlineCallbacks
477    def deactivate(self):
478        yield super().deactivate()
479
480        if not self.enabled:
481            return
482
483        yield defer.gatherResults(
484            [reg.unregister() for reg in self.registrations])
485