From: Michael Tremer Date: Sat, 22 Apr 2023 09:40:53 +0000 (+0000) Subject: Merge Pakfire Hub into the main webapp X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=f062b0445eac43d148c7d7f0b469eaf1265ca221;p=pbs.git Merge Pakfire Hub into the main webapp This will allow us to have a hopefully slightly simpler but monolithic webapp that talks to users and builders at the same time. Signed-off-by: Michael Tremer --- diff --git a/Makefile.am b/Makefile.am index 2b0823ab..8ff17559 100644 --- a/Makefile.am +++ b/Makefile.am @@ -73,7 +73,6 @@ dist_doc_DATA = \ dist_bin_SCRIPTS = \ src/scripts/pakfire-build-service \ - src/scripts/pakfire-hub \ src/scripts/pakfire-web dist_configs_DATA = \ @@ -121,16 +120,6 @@ EXTRA_DIST += \ CLEANFILES += \ src/buildservice/constants.py -hub_PYTHON = \ - src/hub/__init__.py \ - src/hub/builds.py \ - src/hub/handlers.py \ - src/hub/jobs.py \ - src/hub/queue.py \ - src/hub/uploads.py - -hubdir = $(buildservicedir)/hub - web_PYTHON = \ src/web/__init__.py \ src/web/auth.py \ @@ -149,6 +138,7 @@ web_PYTHON = \ src/web/repos.py \ src/web/search.py \ src/web/ui_modules.py \ + src/web/uploads.py \ src/web/users.py webdir = $(buildservicedir)/web @@ -374,7 +364,6 @@ static_fontsdir = $(staticdir)/fonts if HAVE_SYSTEMD systemdsystemunit_DATA = \ - src/systemd/pakfire-hub.service \ src/systemd/pakfire-web.service CLEANFILES += \ @@ -385,7 +374,6 @@ INSTALL_DIRS += \ endif EXTRA_DIST += \ - src/systemd/pakfire-hub.service.in \ src/systemd/pakfire-web.service.in dist_database_DATA = \ diff --git a/po/POTFILES.in b/po/POTFILES.in index 9f9fc863..611cddab 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -29,12 +29,6 @@ src/buildservice/settings.py src/buildservice/sources.py src/buildservice/uploads.py src/buildservice/users.py -src/hub/__init__.py -src/hub/builds.py -src/hub/handlers.py -src/hub/jobs.py -src/hub/queue.py -src/hub/uploads.py src/static/robots.txt src/templates/base.html src/templates/bugs/modules/list.html diff --git a/src/buildservice/builders.py b/src/buildservice/builders.py index 15dc856a..ccf17403 100644 --- a/src/buildservice/builders.py +++ b/src/buildservice/builders.py @@ -214,8 +214,17 @@ class Builder(base.DataObject): online_until = property(lambda s: s.data.online_until, set_online_until) - def update_info(self, cpu_model=None, cpu_count=None, cpu_arch=None, - pakfire_version=None, os_name=None): + def log_stats(self, cpu_model=None, cpu_count=None, cpu_arch=None, pakfire_version=None, + os_name=None, cpu_user=None, cpu_nice=None, cpu_system=None, cpu_idle=None, + cpu_iowait=None, cpu_irq=None, cpu_softirq=None, cpu_steal=None, cpu_guest=None, + cpu_guest_nice=None, loadavg1=None, loadavg5=None, loadavg15=None, mem_total=None, + mem_available=None, mem_used=None, mem_free=None, mem_active=None, mem_inactive=None, + mem_buffers=None, mem_cached=None, mem_shared=None, swap_total=None, swap_used=None, + swap_free=None, **kwargs): + """ + Logs some stats about this builder + """ + # Update information self.db.execute(""" UPDATE builders @@ -236,14 +245,7 @@ class Builder(base.DataObject): self.id, ) - def log_stats(self, cpu_user, cpu_nice, cpu_system, cpu_idle, cpu_iowait, - cpu_irq, cpu_softirq, cpu_steal, cpu_guest, cpu_guest_nice, - loadavg1, loadavg5, loadavg15, mem_total, mem_available, mem_used, - mem_free, mem_active, mem_inactive, mem_buffers, mem_cached, mem_shared, - swap_total, swap_used, swap_free): - """ - Logs some stats about this builder - """ + # Log Stats self.db.execute(""" INSERT INTO builder_stats diff --git a/src/buildservice/jobqueue.py b/src/buildservice/jobqueue.py index 4e5e18b4..57d99df5 100644 --- a/src/buildservice/jobqueue.py +++ b/src/buildservice/jobqueue.py @@ -7,6 +7,9 @@ from . import base log = logging.getLogger("pakfire.buildservice.jobqueue") class JobQueue(base.Object): + # A list of all builders that have a connection + connections = [] + def __iter__(self): jobs = self.backend.jobs._get_jobs("SELECT jobs.* FROM job_queue queue \ LEFT JOIN jobs ON queue.job_id = jobs.id") @@ -35,3 +38,72 @@ class JobQueue(base.Object): builder.supported_arches, ) + async def open(self, builder, connection): + """ + Called when a builder opens a connection + """ + log.debug("Connection opened by %s" % builder) + + # Find any previous connections of this builder and close them + for c in self.connections: + if not c.builder == builder: + continue + + log.warning("Closing connection to builder %s because it is being replaced" % builder) + + # Close the previous connection + c.close(code=1000, reason="Replaced by a new connection") + + # Add this connection to the list + self.connections.append(connection) + + # Dispatch any jobs immediately + await self.dispatch_jobs() + + def close(self, builder, connection): + log.debug("Connection to %s closed" % builder) + + # Remove the connection + try: + self.connections.remove(connection) + except IndexError: + pass + + async def dispatch_jobs(self): + """ + Will be called regularly and will dispatch any pending jobs to any + available builders + """ + log.debug("Dispatching jobs...") + + # Exit if there are no builders connected + if not self.connections: + log.debug(" No connections open") + return + + # Map all connections by builder + builders = { c.builder : c for c in self.connections } + + # Process all builders and assign jobs + # We prioritize builders with fewer jobs + for builder in sorted(builders, key=lambda b: len(b.jobs)): + log.debug(" Processing builder %s" % builder) + + # Find the connection + connection = builders[builder] + + with self.backend.db.transaction(): + if not builder.is_ready(): + log.debug(" Builder %s is not ready" % builder) + continue + + # We are ready for a new job + job = self.pop(builder) + if job: + connection.assign_job(job) + continue + + log.debug(" No jobs processable for %s" % builder) + + # If there is no job for the builder, we might as well shut it down + await builder.stop() diff --git a/src/buildservice/uploads.py b/src/buildservice/uploads.py index 594e2c8a..436de66f 100644 --- a/src/buildservice/uploads.py +++ b/src/buildservice/uploads.py @@ -8,6 +8,7 @@ import os import shutil from . import base +from . import builders from . import users from .constants import * from .decorators import * @@ -49,7 +50,16 @@ class Uploads(base.Object): """, uuid, ) - def create(self, filename, size, builder=None, user=None): + def create(self, filename, size, uploader=None): + builder = None + user = None + + # Check uploader type + if isinstance(uploader, builders.Builder): + builder = uploader + elif isinstance(uploader, users.User): + user = uploader + # Check quota for users if user: # This will raise an exception if the quota has been exceeded diff --git a/src/hub/__init__.py b/src/hub/__init__.py deleted file mode 100644 index 860e707e..00000000 --- a/src/hub/__init__.py +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/python3 - -import logging -import tornado.web - -from .. import Backend -from . import builds -from . import handlers -from . import jobs -from . import queue -from . import uploads - -class Application(tornado.web.Application): - def __init__(self, **settings): - tornado.web.Application.__init__(self, [ - # Redirect stranded users - (r"/", tornado.web.RedirectHandler, { "url" : "https://pakfire.ipfire.org/" }), - - # Builds - (r"/builds", builds.CreateHandler), - (r"/builds/(.*)", handlers.BuildsGetHandler), - - # Builders - (r"/builders/info", handlers.BuildersInfoHandler), - (r"/builders/stats", handlers.BuildersStatsHandler), - - # Jobs - (r"/jobs/([0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})/builder", - jobs.BuilderHandler), - (r"/jobs/([0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})/finished", - jobs.FinishedHandler), - - (r"/jobs/active", handlers.JobsGetActiveHandler), - (r"/jobs/latest", handlers.JobsGetLatestHandler), - (r"/jobs/queue", handlers.JobsGetQueueHandler), - (r"/jobs/(.*)", handlers.JobsGetHandler), - - # Packages - (r"/packages/(.*)", handlers.PackagesGetHandler), - - # Queue - (r"/queue", queue.QueueHandler), - - # Test - (r"/test", handlers.TestHandler), - - # Uploads - (r"/uploads", uploads.IndexHandler), - ], - - # Forward any other settings - **settings, - ) - - # Launch backend - self.backend = Backend("/etc/pakfire/pbs.conf") - - logging.info("Successfully initialied application") - - # Perform some initial tasks - self.backend.run_task(self.backend.builders.sync) - self.backend.run_task(self.backend.builders.autoscale) diff --git a/src/hub/builds.py b/src/hub/builds.py deleted file mode 100644 index e7b79a0a..00000000 --- a/src/hub/builds.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/python3 -############################################################################### -# # -# Pakfire - The IPFire package management system # -# Copyright (C) 2011 Pakfire development team # -# # -# This program is free software: you can redistribute it and/or modify # -# it under the terms of the GNU General Public License as published by # -# the Free Software Foundation, either version 3 of the License, or # -# (at your option) any later version. # -# # -# This program is distributed in the hope that it will be useful, # -# but WITHOUT ANY WARRANTY; without even the implied warranty of # -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # -# GNU General Public License for more details. # -# # -# You should have received a copy of the GNU General Public License # -# along with this program. If not, see . # -# # -############################################################################### - -import tornado.web - -from ..errors import NoSuchDistroError - -from .handlers import BaseHandler -from . import queue - -class CreateHandler(BaseHandler): - async def post(self): - # Fetch the upload - upload = self.get_argument_upload("upload_id") - if not upload: - raise tornado.web.HTTPError(404, "Could not find upload") - - # Check permissions of the upload - if not upload.has_perm(self.current_user): - raise tornado.web.HTTPError(403, "No permission for using upload %s" % upload) - - # Fetch the repository - repo_name = self.get_argument("repo", None) - - with self.db.transaction(): - # Import the package - try: - package = await self.backend.packages.create(upload) - - # If the distribution that is coded into the package could not be found, - # we will send that error to the user... - except NoSuchDistroError as e: - raise tornado.web.HTTPError(404, "Could not find distribution: %s" % e) - - # Find the repository - repo = self.current_user.get_repo(package.distro, repo_name) - if not repo: - raise tornado.web.HTTPError(404, "Could not find repository") - - # Create a new build - build = await self.backend.builds.create(repo, package, owner=self.user) - - # Delete the upload - await upload.delete() - - # Send some data about the build - self.finish({ - "uuid" : build.uuid, - "name" : "%s" % build, - }) - - # Run dependency check on all jobs - await self.backend.jobs.depcheck(build.jobs) - - # Try to dispatch jobs - await queue.dispatch_jobs(self.backend) diff --git a/src/hub/handlers.py b/src/hub/handlers.py deleted file mode 100644 index 6e842a6d..00000000 --- a/src/hub/handlers.py +++ /dev/null @@ -1,371 +0,0 @@ -#!/usr/bin/python - -import json -import logging -import tornado.web - -from .. import builds -from .. import builders -from .. import users - -from ..web.auth import KerberosAuthMixin - -class AuthMixin(KerberosAuthMixin): - # Allow users to authenticate - allow_users = True - - """ - Requires a builder or user to authenticate - """ - def get_current_user(self): - # Fetch the Kerberos ticket - principal = self.get_authenticated_user() - - # Return nothing if we did not receive any credentials - if not principal: - return - - logging.debug("Searching for principal %s..." % principal) - - # Strip the realm - principal, delimiter, realm = principal.partition("@") - - # Return any builders - if principal.startswith("host/"): - hostname = principal.removeprefix("host/") - - return self.backend.builders.get_by_name(hostname) - - # End here if users are not allowed to authenticate - if not self.allow_users: - return - - # Return users - return self.backend.users.get_by_name(principal) - - -class BackendMixin(AuthMixin): - @property - def backend(self): - """ - Shortcut handler to pakfire instance - """ - return self.application.backend - - @property - def db(self): - return self.backend.db - - -class BaseHandler(BackendMixin, tornado.web.RequestHandler): - @property - def builder(self): - if isinstance(self.current_user, builders.Builder): - return self.current_user - - @property - def user(self): - if isinstance(self.current_user, users.User): - return self.current_user - - def get_argument_bool(self, *args, **kwargs): - arg = self.get_argument(*args, **kwargs) - - if arg: - return arg.lower() in ("on", "true", "1") - - def get_argument_int(self, *args, **kwargs): - arg = self.get_argument(*args, **kwargs) - - try: - return int(arg) - except (TypeError, ValueError): - return None - - def get_argument_float(self, *args, **kwargs): - arg = self.get_argument(*args, **kwargs) - - try: - return float(arg) - except (TypeError, ValueError): - return None - - def get_argument_json(self, *args, **kwargs): - arg = self.get_argument(*args, **kwargs) - - if arg: - return json.loads(arg) - - def get_argument_upload(self, *args, **kwargs): - """ - Returns an upload - """ - uuid = self.get_argument(*args, **kwargs) - - if uuid: - return self.backend.uploads.get_by_uuid(uuid) - - def get_argument_uploads(self, *args, **kwargs): - """ - Returns a list of uploads - """ - uuids = self.get_arguments(*args, **kwargs) - - # Return all uploads - return [self.backend.uploads.get_by_uuid(uuid) for uuid in uuids] - - -# Hello World - -class TestHandler(BaseHandler): - """ - This handler is for checking whether authentication works, etc... - """ - @tornado.web.authenticated - def get(self): - # Send a message which is wrapped into some JSON - self.finish({ - "message" : [ - "Hello, %s!" % self.current_user, - ], - }) - -# Builds - -class BuildsGetHandler(BaseHandler): - def get(self, build_uuid): - build = self.backend.builds.get_by_uuid(build_uuid) - if not build: - raise tornado.web.HTTPError(404, "Could not find build: %s" % build_uuid) - - ret = { - "distro" : build.distro.slug, - "jobs" : [j.uuid for j in build.jobs], - "name" : build.name, - "package" : build.pkg.uuid, - "priority" : build.priority, - "score" : build.score, - "severity" : build.severity, - "sup_arches" : build.supported_arches, - "time_created" : build.created.isoformat(), - "type" : build.type, - "uuid" : build.uuid, - } - - # If the build is in a repository, update that bit. - if build.repo: - ret["repo"] = build.repo.identifier - - self.finish(ret) - - -# Jobs - -class JobsBaseHandler(BaseHandler): - def job2json(self, job): - ret = { - "arch" : job.arch, - "build" : job.build.uuid, - "duration" : job.duration, - "name" : job.name, - "packages" : [p.uuid for p in job.packages], - "state" : job.state, - "time_created" : job.time_created.isoformat(), - "type" : "test" if job.test else "release", - "uuid" : job.uuid, - } - - if job.builder: - ret["builder"] = job.builder.hostname - - if job.time_started: - ret["time_started"] = job.time_started.isoformat() - - if job.time_finished: - ret["time_finished"] = job.time_finished.isoformat() - - return ret - - -class JobsGetActiveHandler(JobsBaseHandler): - def get(self): - # Get list of all active jobs. - jobs = self.backend.jobs.get_active() - - args = { - "jobs" : [self.job2json(j) for j in jobs], - } - - self.finish(args) - - -class JobsGetLatestHandler(JobsBaseHandler): - def get(self): - limit = self.get_argument_int("limit", 5) - - # Get the latest jobs. - jobs = self.backend.jobs.get_recently_ended(limit=limit) - - args = { - "jobs" : [self.job2json(j) for j in jobs], - } - - self.finish(args) - - -class JobsGetQueueHandler(JobsBaseHandler): - def get(self): - limit = self.get_argument_int("limit", 5) - - # Get the job queue. - jobs = [] - for job in self.backend.jobqueue: - jobs.append(job) - - limit -= 1 - if not limit: break - - args = { - "jobs" : [self.job2json(j) for j in jobs], - } - - self.finish(args) - - -class JobsGetHandler(JobsBaseHandler): - def get(self, job_uuid): - job = self.backend.jobs.get_by_uuid(job_uuid) - if not job: - raise tornado.web.HTTPError(404, "Could not find job: %s" % job_uuid) - - ret = self.job2json(job) - self.finish(ret) - - -# Packages - -class PackagesGetHandler(BaseHandler): - def get(self, package_uuid): - pkg = self.backend.packages.get_by_uuid(package_uuid) - if not pkg: - raise tornado.web.HTTPError(404, "Could not find package: %s" % package_uuid) - - ret = { - "arch" : pkg.arch, - "build_id" : pkg.build_id, - "build_host" : pkg.build_host, - "build_time" : pkg.build_time.isoformat(), - "description" : pkg.description, - "epoch" : pkg.epoch, - "filesize" : pkg.filesize, - "friendly_name" : pkg.friendly_name, - "friendly_version" : pkg.friendly_version, - "groups" : pkg.groups, - "hash_sha512" : pkg.hash_sha512, - "license" : pkg.license, - "name" : pkg.name, - "release" : pkg.release, - "size" : pkg.size, - "summary" : pkg.summary, - "type" : pkg.type, - "url" : pkg.url, - "uuid" : pkg.uuid, - "version" : pkg.version, - - # Dependencies. - "prerequires" : pkg.prerequires, - "requires" : pkg.requires, - "provides" : pkg.provides, - "obsoletes" : pkg.obsoletes, - "conflicts" : pkg.conflicts, - } - - if pkg.type == "source": - ret["supported_arches"] = pkg.supported_arches - - if pkg.distro: - ret["distro"] = pkg.distro.slug - - self.finish(ret) - - -# Builders - -class BuildersBaseHandler(BaseHandler): - def prepare(self): - # The request must come from an authenticated buider. - if not self.builder: - raise tornado.web.HTTPError(403, "Not authenticated as a builder") - - -class BuildersInfoHandler(BuildersBaseHandler): - @tornado.web.authenticated - def post(self): - args = { - # CPU info - "cpu_model" : self.get_argument("cpu_model", None), - "cpu_count" : self.get_argument("cpu_count", None), - "cpu_arch" : self.get_argument("cpu_arch", None), - - # Pakfire - "pakfire_version" : self.get_argument("pakfire_version", None), - - # OS - "os_name" : self.get_argument("os_name", None), - } - - with self.db.transaction(): - self.builder.update_info(**args) - - # Send something back - self.finish({ - "status" : "OK", - }) - - -class BuildersStatsHandler(BuildersBaseHandler): - @tornado.web.authenticated - def post(self): - args = { - # CPU - "cpu_user" : self.get_argument_float("cpu_user"), - "cpu_nice" : self.get_argument_float("cpu_nice"), - "cpu_system" : self.get_argument_float("cpu_system"), - "cpu_idle" : self.get_argument_float("cpu_idle"), - "cpu_iowait" : self.get_argument_float("cpu_iowait"), - "cpu_irq" : self.get_argument_float("cpu_irq"), - "cpu_softirq" : self.get_argument_float("cpu_softirq"), - "cpu_steal" : self.get_argument_float("cpu_steal"), - "cpu_guest" : self.get_argument_float("cpu_guest"), - "cpu_guest_nice" : self.get_argument_float("cpu_guest_nice"), - - # Load average - "loadavg1" : self.get_argument_float("loadavg1"), - "loadavg5" : self.get_argument_float("loadavg5"), - "loadavg15" : self.get_argument_float("loadavg15"), - - # Memory - "mem_total" : self.get_argument_int("mem_total"), - "mem_available" : self.get_argument_int("mem_available"), - "mem_used" : self.get_argument_int("mem_used"), - "mem_free" : self.get_argument_int("mem_free"), - "mem_active" : self.get_argument_int("mem_active"), - "mem_inactive" : self.get_argument_int("mem_inactive"), - "mem_buffers" : self.get_argument_int("mem_buffers"), - "mem_cached" : self.get_argument_int("mem_cached"), - "mem_shared" : self.get_argument_int("mem_shared"), - - # Swap - "swap_total" : self.get_argument_int("swap_total"), - "swap_used" : self.get_argument_int("swap_used"), - "swap_free" : self.get_argument_int("swap_free"), - - } - - with self.db.transaction(): - self.builder.log_stats(**args) - - # Send something back - self.finish({ - "status" : "OK", - }) diff --git a/src/hub/jobs.py b/src/hub/jobs.py deleted file mode 100644 index 67ebd7ae..00000000 --- a/src/hub/jobs.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/python3 -############################################################################### -# # -# Pakfire - The IPFire package management system # -# Copyright (C) 2011 Pakfire development team # -# # -# This program is free software: you can redistribute it and/or modify # -# it under the terms of the GNU General Public License as published by # -# the Free Software Foundation, either version 3 of the License, or # -# (at your option) any later version. # -# # -# This program is distributed in the hope that it will be useful, # -# but WITHOUT ANY WARRANTY; without even the implied warranty of # -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # -# GNU General Public License for more details. # -# # -# You should have received a copy of the GNU General Public License # -# along with this program. If not, see . # -# # -############################################################################### - -import json -import logging -import tornado.websocket - -from .handlers import BaseHandler, BackendMixin -from . import queue - -class BuilderHandler(BackendMixin, tornado.websocket.WebSocketHandler): - """ - Builders connect to this handler when they are running a build. - - We can pass information about this build around in real time. - """ - # Don't allow users to authenticate - allow_users = False - - @tornado.web.authenticated - def open(self, job_id): - self.job = self.backend.jobs.get_by_uuid(job_id) - if not self.job: - raise tornado.web.HTTPError(404, "Could not find job %s" % job_id) - - # Check if the builder matches - if not self.current_user == self.job.builder: - raise tornado.web.HTTPError(403, "Job %s belongs to %s, not %s" % \ - (self.job, self.job.builder, self.current_user)) - - logging.debug("Connection opened for %s by %s" % (self.job, self.current_user)) - - async def on_message(self, message): - # Decode JSON message - try: - message = json.loads(message) - except json.DecodeError as e: - logging.error("Could not decode JSON message", exc_info=True) - return - - # Log message - logging.debug("Received message:") - logging.debug("%s" % json.dumps(message, indent=4)) - - # Get message type - t = message.get("message") - - # Handle status messages - if t == "status": - pass - - # Handle log messages - elif t == "log": - pass - - # Unknown message - else: - logging.warning("Received a message of an unknown type: %s" % t) - - -class FinishedHandler(BaseHandler): - """ - Called after the builder has finished the job - """ - # Don't allow users to authenticate - allow_users = False - - @tornado.web.authenticated - async def post(self, uuid): - job = self.backend.jobs.get_by_uuid(uuid) - if not job: - raise tornado.web.HTTPError(404, "Could not find job %s" % uuid) - - # Has the job been successful? - success = self.get_argument_bool("success") - - # Fetch the log - logfile = self.get_argument_upload("log") - - # Fetch the packages - packages = self.get_argument_uploads("packages") - - with self.db.transaction(): - # Mark the job as finished - await job.finished(success=success, - logfile=logfile, packages=packages) - - # Try to dispatch the next job - await queue.dispatch_jobs(self.backend) diff --git a/src/hub/queue.py b/src/hub/queue.py deleted file mode 100644 index c60fe419..00000000 --- a/src/hub/queue.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/python3 -############################################################################### -# # -# Pakfire - The IPFire package management system # -# Copyright (C) 2011 Pakfire development team # -# # -# This program is free software: you can redistribute it and/or modify # -# it under the terms of the GNU General Public License as published by # -# the Free Software Foundation, either version 3 of the License, or # -# (at your option) any later version. # -# # -# This program is distributed in the hope that it will be useful, # -# but WITHOUT ANY WARRANTY; without even the implied warranty of # -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # -# GNU General Public License for more details. # -# # -# You should have received a copy of the GNU General Public License # -# along with this program. If not, see . # -# # -############################################################################### - -import logging -import tornado.websocket - -from .handlers import BackendMixin - -# A list of all builders that have a connection -connections = [] - -async def dispatch_jobs(backend): - """ - Will be called regularly and will dispatch any pending jobs to any - available builders - """ - logging.debug("Dispatching jobs...") - - # Exit if there are no builders connected - if not connections: - logging.debug(" No connections open") - return - - builders = {} - - # Map all connections by builder - for connection in connections: - builders[connection.builder] = connection - - # Process all builders and assign jobs - # We prioritize builders with fewer jobs - for builder in sorted(builders, key=lambda b: len(b.jobs)): - logging.debug(" Processing builder %s" % builder) - - # Find the connection - connection = builders[builder] - - with backend.db.transaction(): - if not builder.is_ready(): - logging.debug(" Builder %s is not ready" % builder) - continue - - # We are ready for a new job - job = backend.jobqueue.pop(builder) - if job: - connection.assign_job(job) - continue - - logging.debug(" No jobs processable for %s" % builder) - - # If there is no job for the builder, we might as well shut it down - await builder.stop() - -class QueueHandler(BackendMixin, tornado.websocket.WebSocketHandler): - """ - Builders connect to this handler which will add them to a list of connections. - - For all connections, we regularly check if we have any new build jobs, and if so, - we will send them the job. - """ - - # Don't allow users to authenticate - allow_users = False - - @property - def builder(self): - return self.current_user - - @tornado.web.authenticated - async def open(self): - logging.debug("Connection opened by %s" % self.builder) - - # Find any previous connections of this builder and close them - for connection in connections: - if not connection.builder == self.builder: - continue - - logging.warning("Closing connection to builder %s because it is being replaced" \ - % self.builder) - - # Close the previous connection - connection.close(code=1000, reason="Replaced by a new connection") - - # Add this connection to the list - connections.append(self) - - # Dispatch any jobs immediately - await dispatch_jobs(self.backend) - - def on_close(self): - logging.debug("Connection to %s closed" % self.builder) - - # Remove the connection - try: - connections.remove(self) - except IndexError: - pass - - def assign_job(self, job): - logging.debug("Sending job %s to %s" % (job, self)) - - # Assign this job - with self.db.transaction(): - job.assign(builder=self.builder) - - self.write_message({ - "message" : "job", - - # Add job information - "id" : job.uuid, - "name" : "%s" % job, - "arch" : job.arch, - - # Is this a test job? - "test" : job.test, - - # Send the pakfire configuration without using any mirrors - "conf" : "%s" % job.pakfire(mirrored=False), - - # URL to the package - "pkg" : job.pkg.download_url, - }) diff --git a/src/scripts/pakfire-hub b/src/scripts/pakfire-hub deleted file mode 100644 index 6730756d..00000000 --- a/src/scripts/pakfire-hub +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/python3 - -import asyncio -import tornado.options - -import pakfire.buildservice.hub - -tornado.options.define("debug", type=bool, default=False, help="Enable debug mode") -tornado.options.define("port", type=int, default=8000, help="Port to listen on") - -async def main(): - tornado.options.parse_command_line() - - # Initialise application - app = pakfire.buildservice.hub.Application(debug=tornado.options.options.debug) - app.listen( - tornado.options.options.port, - xheaders=True, - max_body_size=1073741824, - ) - - # Wait for forever - await asyncio.Event().wait() - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/src/systemd/pakfire-hub.service.in b/src/systemd/pakfire-hub.service.in deleted file mode 100644 index f8bf5e13..00000000 --- a/src/systemd/pakfire-hub.service.in +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Pakfire Hub -After=network.target - -[Service] -ExecStart=@bindir@/pakfire-hub --port=8001 -User=_pakfire - -[Install] -WantedBy=multi-user.target diff --git a/src/web/__init__.py b/src/web/__init__.py index 005c0c49..07b7beec 100644 --- a/src/web/__init__.py +++ b/src/web/__init__.py @@ -24,6 +24,7 @@ from . import mirrors from . import packages from . import repos from . import search +from . import uploads from . import users from .handlers import * @@ -128,12 +129,18 @@ class Application(tornado.web.Application): (r"/builds/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/unwatch", builds.UnwatchHandler), (r"/build/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/comment", builds.BuildDetailCommentHandler), + (r"/api/v1/builds", builds.APIv1IndexHandler), + + # Queue (r"/queue", jobs.QueueHandler), + (r"/api/v1/jobs/queue", jobs.APIv1QueueHandler), # Jobs (r"/jobs/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/abort", jobs.AbortHandler), (r"/jobs/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/log", jobs.LogHandler), (r"/job/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/buildroot", jobs.JobBuildrootHandler), + (r"/api/v1/jobs/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})", + jobs.APIv1DetailHandler), # Builders (r"/builders", builders.BuilderListHandler), @@ -141,6 +148,7 @@ class Application(tornado.web.Application): (r"/builders/([A-Za-z0-9\-\.]+)/delete", builders.BuilderDeleteHandler), (r"/builders/([A-Za-z0-9\-\.]+)/edit", builders.BuilderEditHandler), (r"/builders/([A-Za-z0-9\-\.]+)", builders.BuilderDetailHandler), + (r"/api/v1/builders/stats", builders.APIv1StatsHandler), # Distributions (r"/distros", distributions.IndexHandler), @@ -172,6 +180,11 @@ class Application(tornado.web.Application): # Log (r"/log", handlers.LogHandler), + + # Uploads + (r"/api/v1/uploads", uploads.APIv1IndexHandler), + (r"/api/v1/uploads/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})", + uploads.APIv1DetailHandler), ], default_handler_class=errors.Error404Handler, **settings) # Launch backend diff --git a/src/web/auth.py b/src/web/auth.py index e6b775e9..b86a3032 100644 --- a/src/web/auth.py +++ b/src/web/auth.py @@ -1,140 +1,14 @@ #!/usr/bin/python3 -import base64 -import kerberos import logging -import os import tornado.web -import tornado.websocket from . import base # Setup logging -log = logging.getLogger("pakfire.buildservice.auth") +log = logging.getLogger("pakfire.buildservice.web.auth") -class KerberosAuthMixin(object): - """ - A mixin that handles Kerberos authentication - """ - @property - def kerberos_realm(self): - return "IPFIRE.ORG" - - @property - def kerberos_service(self): - return self.settings.get("kerberos_service", "HTTP") - - def authenticate_redirect(self): - """ - Called when the application needs the user to authenticate. - - We will send a response with status code 401 and set the - WWW-Authenticate header to ask the client to either initiate - some Kerberos authentication, or to perform HTTP Basic authentication. - """ - # Ask the client to authenticate using Kerberos - self.add_header("WWW-Authenticate", "Negotiate") - - # Ask the client to authenticate using HTTP Basic Auth - self.add_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.kerberos_realm) - - # Set status to 401 - self.set_status(401) - - def get_authenticated_user(self): - auth_header = self.request.headers.get("Authorization", None) - - # No authentication header - if not auth_header: - return - - # Perform GSS API Negotiation - if auth_header.startswith("Negotiate"): - return self._auth_negotiate(auth_header) - - # Perform Basic Authentication - elif auth_header.startswith("Basic "): - return self._auth_basic(auth_header) - - # Fail on anything else - else: - raise tornado.web.HTTPError(400, "Unexpected Authentication attempt: %s" % auth_header) - - def _auth_negotiate(self, auth_header): - os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab") - - auth_value = auth_header.removeprefix("Negotiate ") - - try: - # Initialise the server session - result, context = kerberos.authGSSServerInit(self.kerberos_service) - - if not result == kerberos.AUTH_GSS_COMPLETE: - raise tornado.web.HTTPError(500, "Kerberos Initialization failed: %s" % result) - - # Check the received authentication header - result = kerberos.authGSSServerStep(context, auth_value) - - # If this was not successful, we will fall back to Basic authentication - if not result == kerberos.AUTH_GSS_COMPLETE: - return self._auth_basic(auth_header) - - if not isinstance(self, tornado.websocket.WebSocketHandler): - # Fetch the server response - response = kerberos.authGSSServerResponse(context) - - # Send the server response - self.set_header("WWW-Authenticate", "Negotiate %s" % response) - - # Return the user who just authenticated - user = kerberos.authGSSServerUserName(context) - - except kerberos.GSSError as e: - log.error("Kerberos Authentication Error: %s" % e) - - raise tornado.web.HTTPError(500, "Could not initialize the Kerberos context") - - finally: - # Cleanup - kerberos.authGSSServerClean(context) - - log.debug("Successfully authenticated %s" % user) - - return user - - def _auth_basic(self, auth_header): - os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab") - - # Remove "Basic " - auth_header = auth_header.removeprefix("Basic ") - - try: - # Decode base64 - auth_header = base64.b64decode(auth_header).decode() - - username, password = auth_header.split(":", 1) - except: - raise tornado.web.HTTPError(400, "Authorization data was malformed") - - # Check the credentials against the Kerberos database - try: - kerberos.checkPassword(username, password, - "%s/pakfire.ipfire.org" % self.kerberos_service, self.kerberos_realm) - - # Catch any authentication errors - except kerberos.BasicAuthError as e: - log.error("Could not authenticate %s: %s" % (username, e)) - return - - # Create user principal name - user = "%s@%s" % (username, self.kerberos_realm) - - log.debug("Successfully authenticated %s" % user) - - return user - - -class LoginHandler(KerberosAuthMixin, base.BaseHandler): +class LoginHandler(base.KerberosAuthMixin, base.BaseHandler): def get(self): username = self.get_authenticated_user() if not username: diff --git a/src/web/base.py b/src/web/base.py index c16d7a28..32cbf3ce 100644 --- a/src/web/base.py +++ b/src/web/base.py @@ -1,15 +1,147 @@ #!/usr/bin/python +import base64 import http.client +import json +import kerberos +import logging +import os import time import tornado.locale import tornado.web +import tornado.websocket import traceback from .. import __version__ from .. import misc +from .. import users from ..decorators import * +# Setup logging +log = logging.getLogger("pakfire.buildservice.web.base") + +class KerberosAuthMixin(object): + """ + A mixin that handles Kerberos authentication + """ + @property + def kerberos_realm(self): + return "IPFIRE.ORG" + + @property + def kerberos_service(self): + return self.settings.get("kerberos_service", "HTTP") + + def authenticate_redirect(self): + """ + Called when the application needs the user to authenticate. + + We will send a response with status code 401 and set the + WWW-Authenticate header to ask the client to either initiate + some Kerberos authentication, or to perform HTTP Basic authentication. + """ + # Ask the client to authenticate using Kerberos + self.add_header("WWW-Authenticate", "Negotiate") + + # Ask the client to authenticate using HTTP Basic Auth + self.add_header("WWW-Authenticate", "Basic realm=\"%s\"" % self.kerberos_realm) + + # Set status to 401 + self.set_status(401) + + def get_authenticated_user(self): + auth_header = self.request.headers.get("Authorization", None) + + # No authentication header + if not auth_header: + return + + # Perform GSS API Negotiation + if auth_header.startswith("Negotiate"): + return self._auth_negotiate(auth_header) + + # Perform Basic Authentication + elif auth_header.startswith("Basic "): + return self._auth_basic(auth_header) + + # Fail on anything else + else: + raise tornado.web.HTTPError(400, "Unexpected Authentication attempt: %s" % auth_header) + + def _auth_negotiate(self, auth_header): + os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab") + + auth_value = auth_header.removeprefix("Negotiate ") + + try: + # Initialise the server session + result, context = kerberos.authGSSServerInit(self.kerberos_service) + + if not result == kerberos.AUTH_GSS_COMPLETE: + raise tornado.web.HTTPError(500, "Kerberos Initialization failed: %s" % result) + + # Check the received authentication header + result = kerberos.authGSSServerStep(context, auth_value) + + # If this was not successful, we will fall back to Basic authentication + if not result == kerberos.AUTH_GSS_COMPLETE: + return self._auth_basic(auth_header) + + if not isinstance(self, tornado.websocket.WebSocketHandler): + # Fetch the server response + response = kerberos.authGSSServerResponse(context) + + # Send the server response + self.set_header("WWW-Authenticate", "Negotiate %s" % response) + + # Return the user who just authenticated + user = kerberos.authGSSServerUserName(context) + + except kerberos.GSSError as e: + log.error("Kerberos Authentication Error: %s" % e) + + raise tornado.web.HTTPError(500, "Could not initialize the Kerberos context") + + finally: + # Cleanup + kerberos.authGSSServerClean(context) + + log.debug("Successfully authenticated %s" % user) + + return user + + def _auth_basic(self, auth_header): + os.environ["KRB5_KTNAME"] = self.backend.settings.get("krb5-keytab") + + # Remove "Basic " + auth_header = auth_header.removeprefix("Basic ") + + try: + # Decode base64 + auth_header = base64.b64decode(auth_header).decode() + + username, password = auth_header.split(":", 1) + except: + raise tornado.web.HTTPError(400, "Authorization data was malformed") + + # Check the credentials against the Kerberos database + try: + kerberos.checkPassword(username, password, + "%s/pakfire.ipfire.org" % self.kerberos_service, self.kerberos_realm) + + # Catch any authentication errors + except kerberos.BasicAuthError as e: + log.error("Could not authenticate %s: %s" % (username, e)) + return + + # Create user principal name + user = "%s@%s" % (username, self.kerberos_realm) + + log.debug("Successfully authenticated %s" % user) + + return user + + class BaseHandler(tornado.web.RequestHandler): @property def backend(self): @@ -82,8 +214,9 @@ class BaseHandler(tornado.web.RequestHandler): # Collect more information about the exception if possible. if exc_info: - if self.current_user and self.current_user.is_admin(): - _traceback += traceback.format_exception(*exc_info) + if self.current_user and isinstance(self.current_user, users.User): + if self.current_user.is_admin(): + _traceback += traceback.format_exception(*exc_info) self.render("errors/error.html", code=code, message=message, traceback="".join(_traceback), **kwargs) @@ -113,8 +246,91 @@ class BaseHandler(tornado.web.RequestHandler): if slug: return self.backend.distros.get_by_slug(slug) + def get_argument_upload(self, *args, **kwargs): + """ + Returns an upload + """ + uuid = self.get_argument(*args, **kwargs) + + if uuid: + return self.backend.uploads.get_by_uuid(uuid) + + def get_argument_uploads(self, *args, **kwargs): + """ + Returns a list of uploads + """ + uuids = self.get_arguments(*args, **kwargs) + + # Return all uploads + return [self.backend.uploads.get_by_uuid(uuid) for uuid in uuids] + def get_argument_user(self, *args, **kwargs): name = self.get_argument(*args, **kwargs) if name: return self.backend.users.get_by_name(name) + +# XXX TODO +BackendMixin = BaseHandler + +class APIMixin(KerberosAuthMixin, BackendMixin): + # Generally do not permit users to authenticate against the API + allow_users = False + + # Do not perform any XSRF cookie validation on API calls + def check_xsrf_cookie(self): + pass + + def get_current_user(self): + """ + Authenticates a user or builder + """ + # Fetch the Kerberos ticket + principal = self.get_authenticated_user() + + # Return nothing if we did not receive any credentials + if not principal: + return + + logging.debug("Searching for principal %s..." % principal) + + # Strip the realm + principal, delimiter, realm = principal.partition("@") + + # Return any builders + if principal.startswith("host/"): + hostname = principal.removeprefix("host/") + + return self.backend.builders.get_by_name(hostname) + + # End here if users are not allowed to authenticate + if not self.allow_users: + return + + # Return users + return self.backend.users.get_by_name(principal) + + def get_user_locale(self): + return self.get_browser_locale() + + def write_error(self, code, **kwargs): + # Send a JSON-encoded error message + self.finish({ + "error" : True, + # XXX add error string + }) + + def _decode_json_message(self, message): + # Decode JSON message + try: + message = json.loads(message) + + except json.DecodeError as e: + log.error("Could not decode JSON message", exc_info=True) + raise e + + # Log message + log.debug("Received message:") + log.debug("%s" % json.dumps(message, indent=4)) + + return message diff --git a/src/web/builders.py b/src/web/builders.py index 4581eefa..f9606dbd 100644 --- a/src/web/builders.py +++ b/src/web/builders.py @@ -4,6 +4,33 @@ import tornado.web from . import base +class APIv1StatsHandler(base.APIMixin, tornado.websocket.WebSocketHandler): + @tornado.web.authenticated + def __open(self): + args = { + # CPU info + "cpu_model" : self.get_argument("cpu_model", None), + "cpu_count" : self.get_argument("cpu_count", None), + "cpu_arch" : self.get_argument("cpu_arch", None), + + # Pakfire + "pakfire_version" : self.get_argument("pakfire_version", None), + + # OS + "os_name" : self.get_argument("os_name", None), + } + + with self.db.transaction(): + self.builder.update_info(**args) + + def on_message(self, message): + # Decode message + message = self._decode_json_message(message) + + with self.db.transaction(): + self.current_user.log_stats(**message) + + class BuilderListHandler(base.BaseHandler): def get(self): self.render("builders/list.html", builders=self.backend.builders) diff --git a/src/web/builds.py b/src/web/builds.py index ca005c21..781c9263 100644 --- a/src/web/builds.py +++ b/src/web/builds.py @@ -2,16 +2,61 @@ import tornado.web +from ..errors import NoSuchDistroError + from . import base from . import ui_modules -class BuildBaseHandler(base.BaseHandler): - def get_build(self, uuid): - build = self.backend.builds.get_by_uuid(uuid) - if not build: - raise tornado.web.HTTPError(404, "No such build: %s" % uuid) +class APIv1IndexHandler(base.APIMixin, base.BaseHandler): + # Allow users to create builds + allow_users = True + + @tornado.web.authenticated + async def post(self): + # Fetch the upload + upload = self.get_argument_upload("upload_id") + if not upload: + raise tornado.web.HTTPError(404, "Could not find upload") + + # Check permissions of the upload + if not upload.has_perm(self.current_user): + raise tornado.web.HTTPError(403, "No permission for using upload %s" % upload) + + # Fetch the repository + repo_name = self.get_argument("repo", None) + + with self.db.transaction(): + # Import the package + try: + package = await self.backend.packages.create(upload) + + # If the distribution that is coded into the package could not be found, + # we will send that error to the user... + except NoSuchDistroError as e: + raise tornado.web.HTTPError(404, "Could not find distribution: %s" % e) + + # Find the repository + repo = self.current_user.get_repo(package.distro, repo_name) + if not repo: + raise tornado.web.HTTPError(404, "Could not find repository") + + # Create a new build + build = await self.backend.builds.create(repo, package, owner=self.current_user) + + # Delete the upload + await upload.delete() + + # Send some data about the build + self.finish({ + "uuid" : build.uuid, + "name" : "%s" % build, + }) + + # Run dependency check on all jobs + await self.backend.jobs.depcheck(build.jobs) - return build + # Try to dispatch jobs + await self.backend.jobqueue.dispatch_jobs() class IndexHandler(base.BaseHandler): diff --git a/src/web/jobs.py b/src/web/jobs.py index 83e85d7e..510ca937 100644 --- a/src/web/jobs.py +++ b/src/web/jobs.py @@ -1,10 +1,129 @@ -#!/usr/bin/python +#!/usr/bin/python3 +import logging import tornado.web +import tornado.websocket from . import base from . import ui_modules +# Setup logging +log = logging.getLogger("pakfire.buildservice.web.jobs") + +class APIv1QueueHandler(base.APIMixin, tornado.websocket.WebSocketHandler): + """ + Builders connect to this handler which will add them to a list of connections. + + For all connections, we regularly check if we have any new build jobs, and if so, + we will send them the job. + """ + + # Don't allow users to authenticate + allow_users = False + + @property + def builder(self): + return self.current_user + + @tornado.web.authenticated + async def open(self): + # Register a new connection + await self.backend.jobqueue.open(builder=self.builder, connection=self) + + def on_close(self): + # Close the connection + self.backend.jobqueue.close(builder=self.builder, connection=self) + + def assign_job(self, job): + log.debug("Sending job %s to %s" % (job, self)) + + # Assign this job + with self.db.transaction(): + job.assign(builder=self.builder) + + self.write_message({ + "message" : "job", + + # Add job information + "id" : job.uuid, + "name" : "%s" % job, + "arch" : job.arch, + + # Is this a test job? + "test" : job.test, + + # Send the pakfire configuration without using any mirrors + "conf" : "%s" % job.pakfire(mirrored=False), + + # URL to the package + "pkg" : job.pkg.download_url, + }) + + +class APIv1DetailHandler(base.APIMixin, tornado.websocket.WebSocketHandler): + """ + Builders connect to this handler when they are running a build. + + We can pass information about this build around in real time. + """ + # Don't allow users to authenticate + allow_users = False + + @tornado.web.authenticated + def open(self, job_id): + self.job = self.backend.jobs.get_by_uuid(job_id) + if not self.job: + raise tornado.web.HTTPError(404, "Could not find job %s" % job_id) + + # Check if the builder matches + if not self.current_user == self.job.builder: + raise tornado.web.HTTPError(403, "Job %s belongs to %s, not %s" % \ + (self.job, self.job.builder, self.current_user)) + + log.debug("Connection opened for %s by %s" % (self.job, self.current_user)) + + async def on_message(self, message): + message = self._decode_json_message(message) + + # Get message type + t = message.get("message") + + # Handle status messages + if t == "status": + pass + + # Handle log messages + elif t == "log": + pass + + # Handle finished message + elif t == "finished": + await self._handle_finished(**message) + + # Unknown message + else: + log.warning("Received a message of an unknown type: %s" % t) + + async def _handle_finished(self, success=False, logfile=None, packages=[], **kwargs): + """ + Called when a job has finished - whether successfully or not + """ + # Fetch the log + if logfile: + logfile = self.backend.uploads.get_by_uuid(logfile) + + # Fetch the packages + if packages: + packages = [self.backend.uploads.get_by_uuid(p) for p in packages] + + # Mark the job as finished + with self.db.transaction(): + await self.job.finished(success=success, logfile=logfile, packages=packages) + + # Try to dispatch the next job + await self.backend.jobqueue.dispatch_jobs() + + class QueueHandler(base.BaseHandler): def get(self): self.render("queue.html", queue=self.backend.jobqueue) diff --git a/src/hub/uploads.py b/src/web/uploads.py similarity index 92% rename from src/hub/uploads.py rename to src/web/uploads.py index 88797de1..20b43994 100644 --- a/src/hub/uploads.py +++ b/src/web/uploads.py @@ -22,11 +22,14 @@ import io import tornado.web -from .handlers import BaseHandler +from . import base from .. import users @tornado.web.stream_request_body -class IndexHandler(BaseHandler): +class APIv1IndexHandler(base.APIMixin, tornado.web.RequestHandler): + # Allow users to perform uploads + allow_users = True + def initialize(self): # Buffer to cache the uploaded content self.buffer = io.BytesIO() @@ -82,8 +85,7 @@ class IndexHandler(BaseHandler): upload = self.backend.uploads.create( filename, size=size, - builder=self.builder, - user=self.user, + uploader=self.current_user, ) except users.QuotaExceededError as e: @@ -104,14 +106,16 @@ class IndexHandler(BaseHandler): "expires_at" : upload.expires_at.isoformat(), }) + +class APIv1DetailHandler(base.APIMixin, tornado.web.RequestHandler): + # Allow users to perform uploads + allow_users = True + @tornado.web.authenticated - async def delete(self): + async def delete(self, uuid): """ Deletes an upload with a certain UUID """ - # Fetch the UUID - uuid = self.get_argument("id") - # Fetch the upload upload = self.backend.uploads.get_by_uuid(uuid) if not upload: