]> git.ipfire.org Git - people/jschlag/pbs.git/blame - src/hub/handlers.py
Refactor uploads
[people/jschlag/pbs.git] / src / hub / handlers.py
CommitLineData
f6e6ff79
MT
1#!/usr/bin/python
2
3import base64
4import hashlib
c2902b29 5import json
f6e6ff79 6import logging
c2902b29 7import time
f6e6ff79 8import tornado.web
f6e6ff79 9
2c909128
MT
10from .. import builds
11from .. import builders
12from .. import uploads
13from .. import users
14
c2902b29
MT
15class LongPollMixin(object):
16 def initialize(self):
17 self._start_time = time.time()
f6e6ff79 18
c2902b29
MT
19 def add_timeout(self, timeout, callback):
20 deadline = time.time() + timeout
f6e6ff79 21
c2902b29 22 return self.application.ioloop.add_timeout(deadline, callback)
f6e6ff79 23
c2902b29
MT
24 def on_connection_close(self):
25 logging.debug("Connection closed unexpectedly")
f6e6ff79 26
c2902b29
MT
27 def connection_closed(self):
28 return self.request.connection.stream.closed()
f6e6ff79 29
c2902b29
MT
30 @property
31 def runtime(self):
32 return time.time() - self._start_time
f6e6ff79 33
f6e6ff79 34
c2902b29
MT
35class BaseHandler(LongPollMixin, tornado.web.RequestHandler):
36 @property
37 def backend(self):
f6e6ff79 38 """
4b9167ef 39 Shortcut handler to pakfire instance
f6e6ff79 40 """
4b9167ef 41 return self.application.backend
f6e6ff79 42
c2902b29 43 def get_basic_auth_credentials(self):
f6e6ff79
MT
44 """
45 This handles HTTP Basic authentication.
46 """
47 auth_header = self.request.headers.get("Authorization", None)
48
49 # If no authentication information was provided, we stop here.
50 if not auth_header:
c2902b29 51 return None, None
f6e6ff79
MT
52
53 # No basic auth? We cannot handle that.
54 if not auth_header.startswith("Basic "):
55 raise tornado.web.HTTPError(400, "Can only handle Basic auth.")
56
f6e6ff79 57 try:
c2902b29
MT
58 # Decode the authentication information.
59 auth_header = base64.decodestring(auth_header[6:])
60
f6e6ff79
MT
61 name, password = auth_header.split(":", 1)
62 except:
63 raise tornado.web.HTTPError(400, "Authorization data was malformed")
64
c2902b29 65 return name, password
f6e6ff79 66
c2902b29
MT
67 def get_current_user(self):
68 name, password = self.get_basic_auth_credentials()
69 if name is None:
70 return
f6e6ff79 71
c2902b29
MT
72 builder = self.backend.builders.auth(name, password)
73 if builder:
74 return builder
f6e6ff79 75
c2902b29
MT
76 user = self.backend.users.auth(name, password)
77 if user:
78 return user
f6e6ff79
MT
79
80 @property
81 def builder(self):
2c909128 82 if isinstance(self.current_user, builders.Builder):
c2902b29 83 return self.current_user
f6e6ff79 84
c2902b29
MT
85 @property
86 def user(self):
2c909128 87 if isinstance(self.current_user, users.User):
c2902b29 88 return self.current_user
f6e6ff79 89
c2902b29
MT
90 def get_argument_int(self, *args, **kwargs):
91 arg = self.get_argument(*args, **kwargs)
f6e6ff79 92
c2902b29
MT
93 try:
94 return int(arg)
95 except (TypeError, ValueError):
96 return None
f6e6ff79 97
c2902b29
MT
98 def get_argument_float(self, *args, **kwargs):
99 arg = self.get_argument(*args, **kwargs)
f6e6ff79 100
c2902b29
MT
101 try:
102 return float(arg)
103 except (TypeError, ValueError):
104 return None
f6e6ff79 105
c2902b29
MT
106 def get_argument_json(self, *args, **kwargs):
107 arg = self.get_argument(*args, **kwargs)
f6e6ff79 108
c2902b29
MT
109 if arg:
110 return json.loads(arg)
f6e6ff79 111
c2902b29
MT
112
113class NoopHandler(BaseHandler):
114 def get(self):
115 if self.builder:
116 self.write("Welcome to the Pakfire hub, %s!" % self.builder.hostname)
117 elif self.user:
118 self.write("Welcome to the Pakfire hub, %s!" % self.user.name)
f6e6ff79 119 else:
c2902b29 120 self.write("Welcome to the Pakfire hub!")
f6e6ff79 121
f6e6ff79 122
c2902b29
MT
123class ErrorTestHandler(BaseHandler):
124 def get(self, error_code=200):
125 """
126 For testing a client.
f6e6ff79 127
c2902b29
MT
128 This just returns a HTTP response with the given code.
129 """
130 try:
131 error_code = int(error_code)
132 except ValueError:
133 error_code = 200
f6e6ff79 134
c2902b29 135 raise tornado.web.HTTPError(error_code)
f6e6ff79 136
f6e6ff79 137
c2902b29 138# Uploads
f6e6ff79 139
c2902b29
MT
140class UploadsCreateHandler(BaseHandler):
141 """
142 Create a new upload object in the database and return a unique ID
143 to the uploader.
144 """
f6e6ff79
MT
145
146 @tornado.web.authenticated
c2902b29
MT
147 def get(self):
148 # XXX Check permissions
149
150 filename = self.get_argument("filename")
151 filesize = self.get_argument_int("filesize")
152 filehash = self.get_argument("hash")
153
2f64fe68
MT
154 with self.db.transaction():
155 upload = self.backend.uploads.create(filename, filesize,
156 filehash, user=self.user, builder=self.builder)
f6e6ff79 157
2f64fe68 158 self.finish(upload.uuid)
f6e6ff79 159
c2902b29
MT
160
161class UploadsSendChunkHandler(BaseHandler):
f6e6ff79 162 @tornado.web.authenticated
c2902b29
MT
163 def post(self, upload_id):
164 upload = self.backend.uploads.get_by_uuid(upload_id)
f6e6ff79
MT
165 if not upload:
166 raise tornado.web.HTTPError(404, "Invalid upload id.")
167
168 if not upload.builder == self.builder:
169 raise tornado.web.HTTPError(403, "Uploading an other host's file.")
170
c2902b29
MT
171 chksum = self.get_argument("chksum")
172 data = self.get_argument("data")
173
174 # Decode data.
175 data = base64.b64decode(data)
176
177 # Calculate hash and compare.
178 h = hashlib.new("sha512")
179 h.update(data)
180
181 if not chksum == h.hexdigest():
182 raise tornado.web.HTTPError(400, "Checksum mismatch")
183
184 # Append the data to file.
2f64fe68
MT
185 with self.db.transaction():
186 upload.append(data)
c2902b29 187
f6e6ff79 188
c2902b29 189class UploadsFinishedHandler(BaseHandler):
f6e6ff79 190 @tornado.web.authenticated
c2902b29
MT
191 def get(self, upload_id):
192 upload = self.backend.uploads.get_by_uuid(upload_id)
f6e6ff79
MT
193 if not upload:
194 raise tornado.web.HTTPError(404, "Invalid upload id.")
195
196 if not upload.builder == self.builder:
197 raise tornado.web.HTTPError(403, "Uploading an other host's file.")
198
199 # Validate the uploaded data to its hash.
200 ret = upload.validate()
201
202 # If the validation was successfull, we mark the upload
203 # as finished and send True to the client.
204 if ret:
205 upload.finished()
c2902b29
MT
206 self.finish("OK")
207
208 return
f6e6ff79
MT
209
210 # In case the download was corrupted or incomplete, we delete it
211 # and tell the client to start over.
2f64fe68
MT
212 with self.db.transaction():
213 upload.remove()
f6e6ff79 214
c2902b29
MT
215 self.finish("ERROR: CORRUPTED OR INCOMPLETE FILE")
216
217
218class UploadsDestroyHandler(BaseHandler):
f6e6ff79 219 @tornado.web.authenticated
c2902b29
MT
220 def get(self, upload_id):
221 upload = self.backend.uploads.get_by_uuid(upload_id)
f6e6ff79
MT
222 if not upload:
223 raise tornado.web.HTTPError(404, "Invalid upload id.")
224
225 if not upload.builder == self.builder:
226 raise tornado.web.HTTPError(403, "Removing an other host's file.")
227
228 # Remove the upload from the database and trash the data.
2f64fe68
MT
229 with self.db.transaction():
230 upload.remove()
f6e6ff79
MT
231
232
c2902b29 233# Builds
f6e6ff79 234
c2902b29
MT
235class BuildsCreateHandler(BaseHandler):
236 @tornado.web.authenticated
237 def get(self):
238 # Get the upload ID of the package file.
239 upload_id = self.get_argument("upload_id")
f6e6ff79 240
c2902b29
MT
241 # Get the identifier of the distribution we build for.
242 distro_ident = self.get_argument("distro")
f6e6ff79 243
c2902b29
MT
244 # Get a list of arches to build for.
245 arches = self.get_argument("arches", None)
246 if arches == "":
247 arches = None
f6e6ff79 248
c2902b29
MT
249 # Process build type.
250 build_type = self.get_argument("build_type")
251 if build_type == "release":
252 check_for_duplicates = True
253 elif build_type == "scratch":
254 check_for_duplicates = False
255 else:
256 raise tornado.web.HTTPError(400, "Invalid build type")
f6e6ff79 257
c2902b29
MT
258 ## Check if the user has permission to create a build.
259 # Users only have the permission to create scratch builds.
260 if self.user and not build_type == "scratch":
261 raise tornado.web.HTTPError(403, "Users are only allowed to upload scratch builds")
f6e6ff79 262
c2902b29
MT
263 # Get previously uploaded file to create this build from.
264 upload = self.backend.uploads.get_by_uuid(upload_id)
265 if not upload:
266 raise tornado.web.HTTPError(400, "Upload does not exist: %s" % upload_id)
f6e6ff79 267
c2902b29
MT
268 # Check if the uploaded file belongs to this user/builder.
269 if self.user and not upload.user == self.user:
270 raise tornado.web.HTTPError(400, "Upload does not belong to this user.")
f6e6ff79 271
c2902b29
MT
272 elif self.builder and not upload.builder == self.builder:
273 raise tornado.web.HTTPError(400, "Upload does not belong to this builder.")
f6e6ff79 274
c2902b29
MT
275 # Get distribution this package should be built for.
276 distro = self.backend.distros.get_by_ident(distro_ident)
277 if not distro:
278 distro = self.backend.distros.get_default()
f6e6ff79 279
c2902b29
MT
280 # Open the package that was uploaded earlier and add it to
281 # the database. Create a new build object from the uploaded package.
282 args = {
283 "arches" : arches,
284 "check_for_duplicates" : check_for_duplicates,
285 "distro" : distro,
286 "type" : build_type,
287 }
288 if self.user:
289 args["owner"] = self.user
f6e6ff79 290
c2902b29 291 try:
2c909128 292 pkg, build = builds.import_from_package(self.backend, upload.path, **args)
f6e6ff79 293
c2902b29
MT
294 except:
295 # Raise any exception.
296 raise
f6e6ff79 297
c2902b29
MT
298 else:
299 # Creating the build will move the file to the build directory,
300 # so we can safely remove the uploaded file.
301 upload.remove()
f6e6ff79 302
c2902b29
MT
303 # Send the build ID back to the user.
304 self.finish(build.uuid)
f6e6ff79 305
f6e6ff79 306
c2902b29
MT
307class BuildsGetHandler(BaseHandler):
308 def get(self, build_uuid):
309 build = self.backend.builds.get_by_uuid(build_uuid)
310 if not build:
311 raise tornado.web.HTTPError(404, "Could not find build: %s" % build_uuid)
f6e6ff79 312
c2902b29
MT
313 ret = {
314 "distro" : build.distro.identifier,
315 "jobs" : [j.uuid for j in build.jobs],
316 "name" : build.name,
317 "package" : build.pkg.uuid,
318 "priority" : build.priority,
d31d17af 319 "score" : build.score,
c2902b29
MT
320 "severity" : build.severity,
321 "state" : build.state,
322 "sup_arches" : build.supported_arches,
323 "time_created" : build.created.isoformat(),
324 "type" : build.type,
325 "uuid" : build.uuid,
f6e6ff79
MT
326 }
327
328 # If the build is in a repository, update that bit.
329 if build.repo:
c2902b29 330 ret["repo"] = build.repo.identifier
f6e6ff79 331
c2902b29 332 self.finish(ret)
f6e6ff79 333
f6e6ff79 334
c2902b29 335# Jobs
f6e6ff79 336
ba9f9092
MT
337class JobsBaseHandler(BaseHandler):
338 def job2json(self, job):
f6e6ff79 339 ret = {
22b715d7 340 "arch" : job.arch,
c2902b29 341 "build" : job.build.uuid,
c2902b29
MT
342 "duration" : job.duration,
343 "name" : job.name,
344 "packages" : [p.uuid for p in job.packages],
345 "state" : job.state,
346 "time_created" : job.time_created.isoformat(),
4f90cf84 347 "type" : "test" if job.test else "release",
c2902b29 348 "uuid" : job.uuid,
f6e6ff79
MT
349 }
350
ba9f9092
MT
351 if job.builder:
352 ret["builder"] = job.builder.hostname
353
c2902b29
MT
354 if job.time_started:
355 ret["time_started"] = job.time_started.isoformat()
f6e6ff79 356
c2902b29
MT
357 if job.time_finished:
358 ret["time_finished"] = job.time_finished.isoformat()
f6e6ff79 359
ba9f9092
MT
360 return ret
361
362
363class JobsGetActiveHandler(JobsBaseHandler):
364 def get(self):
365 # Get list of all active jobs.
366 jobs = self.backend.jobs.get_active()
367
368 args = {
369 "jobs" : [self.job2json(j) for j in jobs],
370 }
371
372 self.finish(args)
373
374
375class JobsGetLatestHandler(JobsBaseHandler):
376 def get(self):
377 limit = self.get_argument_int("limit", 5)
378
379 # Get the latest jobs.
380 jobs = self.backend.jobs.get_latest(age="24 HOUR", limit=limit)
381
382 args = {
383 "jobs" : [self.job2json(j) for j in jobs],
384 }
385
386 self.finish(args)
387
388
389class JobsGetQueueHandler(JobsBaseHandler):
390 def get(self):
391 limit = self.get_argument_int("limit", 5)
392
393 # Get the job queue.
fd43d5e1
MT
394 jobs = []
395 for job in self.backend.jobqueue:
396 jobs.append(job)
397
398 limit -= 1
399 if not limit: break
ba9f9092
MT
400
401 args = {
402 "jobs" : [self.job2json(j) for j in jobs],
403 }
404
405 self.finish(args)
406
407
408class JobsGetHandler(JobsBaseHandler):
409 def get(self, job_uuid):
410 job = self.backend.jobs.get_by_uuid(job_uuid)
411 if not job:
412 raise tornado.web.HTTPError(404, "Could not find job: %s" % job_uuid)
413
ba9f9092 414 ret = self.job2json(job)
c2902b29 415 self.finish(ret)
f6e6ff79 416
f6e6ff79 417
c2902b29 418# Packages
f6e6ff79 419
c2902b29
MT
420class PackagesGetHandler(BaseHandler):
421 def get(self, package_uuid):
422 pkg = self.backend.packages.get_by_uuid(package_uuid)
f6e6ff79 423 if not pkg:
c2902b29 424 raise tornado.web.HTTPError(404, "Could not find package: %s" % package_uuid)
f6e6ff79
MT
425
426 ret = {
22b715d7 427 "arch" : pkg.arch,
c2902b29
MT
428 "build_id" : pkg.build_id,
429 "build_host" : pkg.build_host,
430 "build_time" : pkg.build_time.isoformat(),
431 "description" : pkg.description,
432 "epoch" : pkg.epoch,
433 "filesize" : pkg.filesize,
f6e6ff79
MT
434 "friendly_name" : pkg.friendly_name,
435 "friendly_version" : pkg.friendly_version,
436 "groups" : pkg.groups,
c2902b29 437 "hash_sha512" : pkg.hash_sha512,
f6e6ff79 438 "license" : pkg.license,
c2902b29
MT
439 "name" : pkg.name,
440 "release" : pkg.release,
f6e6ff79 441 "size" : pkg.size,
c2902b29
MT
442 "summary" : pkg.summary,
443 "type" : pkg.type,
444 "url" : pkg.url,
445 "uuid" : pkg.uuid,
446 "version" : pkg.version,
f6e6ff79
MT
447
448 # Dependencies.
449 "prerequires" : pkg.prerequires,
450 "requires" : pkg.requires,
451 "provides" : pkg.provides,
452 "obsoletes" : pkg.obsoletes,
453 "conflicts" : pkg.conflicts,
f6e6ff79
MT
454 }
455
c2902b29
MT
456 if pkg.type == "source":
457 ret["supported_arches"] = pkg.supported_arches
458
2c909128 459 if isinstance(pkg.maintainer, users.User):
f6e6ff79
MT
460 ret["maintainer"] = "%s <%s>" % (pkg.maintainer.realname, pkg.maintainer.email)
461 elif pkg.maintainer:
462 ret["maintainer"] = pkg.maintainer
463
464 if pkg.distro:
c2902b29 465 ret["distro"] = pkg.distro.identifier
f6e6ff79 466
c2902b29 467 self.finish(ret)
f6e6ff79
MT
468
469
c2902b29 470# Builders
f6e6ff79 471
c2902b29
MT
472class BuildersBaseHandler(BaseHandler):
473 def prepare(self):
474 # The request must come from an authenticated buider.
475 if not self.builder:
476 raise tornado.web.HTTPError(403)
f6e6ff79 477
f6e6ff79 478
c2902b29 479class BuildersInfoHandler(BuildersBaseHandler):
f6e6ff79 480 @tornado.web.authenticated
c2902b29
MT
481 def post(self):
482 args = {
483 # CPU info
484 "cpu_model" : self.get_argument("cpu_model", None),
485 "cpu_count" : self.get_argument("cpu_count", None),
486 "cpu_arch" : self.get_argument("cpu_arch", None),
487 "cpu_bogomips" : self.get_argument("cpu_bogomips", None),
488
489 # Pakfire
490 "pakfire_version" : self.get_argument("pakfire_version", None),
491 "host_key" : self.get_argument("host_key", None),
492
493 # OS
494 "os_name" : self.get_argument("os_name", None),
495 }
496 self.builder.update_info(**args)
f6e6ff79 497
f6e6ff79 498
c2902b29
MT
499class BuildersKeepaliveHandler(BuildersBaseHandler):
500 @tornado.web.authenticated
501 def post(self):
502 args = {
503 # Load average
504 "loadavg1" : self.get_argument_float("loadavg1", None),
505 "loadavg5" : self.get_argument_float("loadavg5", None),
506 "loadavg15" : self.get_argument_float("loadavg15", None),
507
508 # Memory
509 "mem_total" : self.get_argument_int("mem_total", None),
510 "mem_free" : self.get_argument_int("mem_free", None),
511
512 # swap
513 "swap_total" : self.get_argument_int("swap_total", None),
514 "swap_free" : self.get_argument_int("swap_free", None),
515
516 # Disk space
517 "space_free" : self.get_argument_int("space_free", None),
518 }
519 self.builder.update_keepalive(**args)
f6e6ff79 520
c2902b29 521 self.finish("OK")
f6e6ff79 522
f6e6ff79 523
c2902b29
MT
524class BuildersJobsQueueHandler(BuildersBaseHandler):
525 @tornado.web.asynchronous
526 @tornado.web.authenticated
527 def get(self):
528 self.callback()
f6e6ff79 529
c2902b29
MT
530 def callback(self):
531 # Break if the connection has been closed in the mean time.
532 if self.connection_closed():
533 logging.warning("Connection closed")
f6e6ff79
MT
534 return
535
c2902b29
MT
536 # Check if there is a job for us.
537 job = self.builder.get_next_job()
538
539 # Got no job, wait and try again.
f6e6ff79 540 if not job:
c2902b29
MT
541 # Check if we have been running for too long.
542 if self.runtime >= self.max_runtime:
543 logging.debug("Exceeded max. runtime. Finishing request.")
544 return self.finish()
f6e6ff79 545
c2902b29
MT
546 # Try again in a jiffy.
547 self.add_timeout(self.heartbeat, self.callback)
548 return
f6e6ff79
MT
549
550 try:
551 # Set job to dispatching state.
552 job.state = "dispatching"
553
554 # Set our build host.
555 job.builder = self.builder
556
557 ret = {
558 "id" : job.uuid,
22b715d7 559 "arch" : job.arch,
c2902b29
MT
560 "source_url" : job.build.source_download,
561 "source_hash_sha512" : job.build.source_hash_sha512,
4f90cf84 562 "type" : "test" if job.test else "release",
f6e6ff79
MT
563 "config" : job.get_config(),
564 }
565
566 # Send build information to the builder.
c2902b29 567 self.finish(ret)
f6e6ff79
MT
568 except:
569 # If anything went wrong, we reset the state.
570 job.state = "pending"
571 raise
572
c2902b29
MT
573 @property
574 def heartbeat(self):
575 return 15 # 15 seconds
576
577 @property
578 def max_runtime(self):
579 timeout = self.get_argument_int("timeout", None)
580 if timeout:
581 return timeout - self.heartbeat
582
583 return 300 # 5 min
584
585
586class BuildersJobsStateHandler(BuildersBaseHandler):
587 @tornado.web.authenticated
588 def post(self, job_uuid, state):
589 job = self.backend.jobs.get_by_uuid(job_uuid)
f6e6ff79
MT
590 if not job:
591 raise tornado.web.HTTPError(404, "Invalid job id.")
592
593 if not job.builder == self.builder:
594 raise tornado.web.HTTPError(403, "Altering another builder's build.")
595
596 # Save information to database.
597 job.state = state
c2902b29
MT
598
599 message = self.get_argument("message", None)
f6e6ff79
MT
600 job.update_message(message)
601
c2902b29
MT
602 self.finish("OK")
603
604
605class BuildersJobsBuildrootHandler(BuildersBaseHandler):
606 @tornado.web.authenticated
607 def post(self, job_uuid):
608 job = self.backend.jobs.get_by_uuid(job_uuid)
609 if not job:
610 raise tornado.web.HTTPError(404, "Invalid job id.")
611
612 if not job.builder == self.builder:
613 raise tornado.web.HTTPError(403, "Altering another builder's build.")
f6e6ff79 614
c2902b29
MT
615 # Get buildroot.
616 buildroot = self.get_argument_json("buildroot", None)
617 if buildroot:
618 job.save_buildroot(buildroot)
619
620 self.finish("OK")
621
622
623class BuildersJobsAddFileHandler(BuildersBaseHandler):
624 @tornado.web.authenticated
625 def post(self, job_uuid, upload_id):
626 type = self.get_argument("type")
f6e6ff79
MT
627 assert type in ("package", "log")
628
629 # Fetch job we are working on and check if it is actually ours.
c2902b29 630 job = self.backend.jobs.get_by_uuid(job_uuid)
f6e6ff79
MT
631 if not job:
632 raise tornado.web.HTTPError(404, "Invalid job id.")
633
634 if not job.builder == self.builder:
635 raise tornado.web.HTTPError(403, "Altering another builder's job.")
636
637 # Fetch uploaded file object and check we uploaded it ourself.
c2902b29 638 upload = self.backend.uploads.get_by_uuid(upload_id)
f6e6ff79
MT
639 if not upload:
640 raise tornado.web.HTTPError(404, "Invalid upload id.")
641
642 if not upload.builder == self.builder:
643 raise tornado.web.HTTPError(403, "Using an other host's file.")
644
645 # Remove all files that have to be deleted, first.
c2902b29 646 self.backend.cleanup_files()
f6e6ff79
MT
647
648 try:
649 job.add_file(upload.path)
650
651 finally:
652 # Finally, remove the uploaded file.
653 upload.remove()
654
c2902b29 655 self.finish("OK")