diff --git a/readthedocs/builds/migrations/0041_track_task_id.py b/readthedocs/builds/migrations/0041_track_task_id.py new file mode 100644 index 00000000000..e7bcd23143c --- /dev/null +++ b/readthedocs/builds/migrations/0041_track_task_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-26 20:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('builds', '0040_remove_old_jsonfields'), + ] + + operations = [ + migrations.AddField( + model_name='build', + name='task_id', + field=models.CharField(blank=True, max_length=36, null=True, verbose_name='Celery task id'), + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 59b877fbb41..2d81fbc973d 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -682,6 +682,13 @@ class Build(models.Model): help_text='Build steps stored outside the database.', ) + task_id = models.CharField( + _('Celery task id'), + max_length=36, + null=True, + blank=True, + ) + # Managers objects = BuildQuerySet.as_manager() # Only include BRANCH, TAG, UNKNOWN type Version builds. diff --git a/readthedocs/builds/views.py b/readthedocs/builds/views.py index 0042e8fe7d7..6749fcc738b 100644 --- a/readthedocs/builds/views.py +++ b/readthedocs/builds/views.py @@ -1,5 +1,7 @@ """Views for builds app.""" +import signal + import structlog import textwrap from urllib.parse import urlparse @@ -14,12 +16,15 @@ from django.views.generic import DetailView, ListView from requests.utils import quote +from readthedocs.builds.constants import BUILD_STATE_TRIGGERED, BUILD_STATE_FINISHED from readthedocs.builds.filters import BuildListFilter from readthedocs.builds.models import Build, Version from readthedocs.core.permissions import AdminPermission from readthedocs.core.utils import trigger_build -from readthedocs.doc_builder.exceptions import BuildAppError +from readthedocs.doc_builder.exceptions import BuildAppError, BuildCancelled from readthedocs.projects.models import Project +from readthedocs.worker import app + log = structlog.get_logger(__name__) @@ -148,6 +153,46 @@ class BuildDetail(BuildBase, DetailView): pk_url_kwarg = 'build_pk' + @method_decorator(login_required) + def post(self, request, project_slug, build_pk): + project = get_object_or_404(Project, slug=project_slug) + build = get_object_or_404(Build, pk=build_pk) + + if not AdminPermission.is_admin(request.user, project): + return HttpResponseForbidden() + + # NOTE: `terminate=True` is required for the child to attend our call + # immediately when it's running the build. Otherwise, it finishes the + # task. However, to revoke a task that has not started yet, we don't + # need it. + if build.state == BUILD_STATE_TRIGGERED: + # Since the task won't be executed at all, we need to update the + # Build object here. + terminate = False + build.state = BUILD_STATE_FINISHED + build.success = False + build.error = BuildCancelled.message + build.length = 0 + build.save() + else: + # In this case, we left the update of the Build object to the task + # itself to be executed in the `on_failure` handler. + terminate = True + + log.warning( + 'Canceling build.', + project_slug=project.slug, + version_slug=build.version.slug, + build_id=build.pk, + build_task_id=build.task_id, + terminate=terminate, + ) + app.control.revoke(build.task_id, signal=signal.SIGINT, terminate=terminate) + + return HttpResponseRedirect( + reverse('builds_detail', args=[project.slug, build.pk]), + ) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['project'] = self.project diff --git a/readthedocs/core/utils/__init__.py b/readthedocs/core/utils/__init__.py index e3fe6e52237..4ed4ce4fe22 100644 --- a/readthedocs/core/utils/__init__.py +++ b/readthedocs/core/utils/__init__.py @@ -229,7 +229,18 @@ def trigger_build(project, version=None, commit=None): # Build was skipped return (None, None) - return (update_docs_task.apply_async(), build) + task = update_docs_task.apply_async() + + # FIXME: I'm using `isinstance` here because I wasn't able to mock this + # properly when running tests and it fails when trying to save a + # `mock.Mock` object in the database. + # + # Store the task_id in the build object to be able to cancel it later. + if isinstance(task.id, (str, int)): + build.task_id = task.id + build.save() + + return task, build def send_email( diff --git a/readthedocs/doc_builder/exceptions.py b/readthedocs/doc_builder/exceptions.py index aeca5c2145c..f1221266dfc 100644 --- a/readthedocs/doc_builder/exceptions.py +++ b/readthedocs/doc_builder/exceptions.py @@ -59,6 +59,10 @@ class DuplicatedBuildError(BuildUserError): status = BUILD_STATUS_DUPLICATED +class BuildCancelled(BuildUserError): + message = gettext_noop('Build cancelled by user.') + + class MkDocsYAMLParseError(BuildUserError): GENERIC_WITH_PARSE_EXCEPTION = gettext_noop( 'Problem parsing MkDocs YAML configuration. {exception}', diff --git a/readthedocs/projects/tasks/builds.py b/readthedocs/projects/tasks/builds.py index d09524884ba..305a969b4df 100644 --- a/readthedocs/projects/tasks/builds.py +++ b/readthedocs/projects/tasks/builds.py @@ -52,6 +52,7 @@ BuildUserError, BuildMaxConcurrencyError, DuplicatedBuildError, + BuildCancelled, ProjectBuildsSkippedError, YAMLParseError, MkDocsYAMLParseError, @@ -240,6 +241,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task): ProjectBuildsSkippedError, ConfigError, YAMLParseError, + BuildCancelled, BuildUserError, RepositoryError, MkDocsYAMLParseError, @@ -248,6 +250,7 @@ class UpdateDocsTask(SyncRepositoryMixin, Task): # Do not send notifications on failure builds for these exceptions. exceptions_without_notifications = ( + BuildCancelled, DuplicatedBuildError, ProjectBuildsSkippedError, ) @@ -265,10 +268,16 @@ def _setup_sigterm(self): def sigterm_received(*args, **kwargs): log.warning('SIGTERM received. Waiting for build to stop gracefully after it finishes.') + def sigint_received(*args, **kwargs): + log.warning('SIGINT received. Canceling the build running.') + raise BuildCancelled + # Do not send the SIGTERM signal to children (pip is automatically killed when # receives SIGTERM and make the build to fail one command and stop build) signal.signal(signal.SIGTERM, sigterm_received) + signal.signal(signal.SIGINT, sigint_received) + def _check_concurrency_limit(self): try: response = api_v2.build.concurrent.get(project__slug=self.data.project.slug) diff --git a/readthedocs/templates/builds/build_detail.html b/readthedocs/templates/builds/build_detail.html index 3aca7c45d0f..b3f4748cc35 100644 --- a/readthedocs/templates/builds/build_detail.html +++ b/readthedocs/templates/builds/build_detail.html @@ -30,6 +30,13 @@ {% block content %}
+ {% if build.state != "finished" and request.user|is_admin:project %} +
+ {% csrf_token %} + +
+ {% endif %} +