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