diff --git a/lifecycle/bootstrap.sh b/lifecycle/bootstrap.sh
index 46cc932fa..f36e44e37 100755
--- a/lifecycle/bootstrap.sh
+++ b/lifecycle/bootstrap.sh
@@ -4,7 +4,7 @@ printf '{"event": "Bootstrap completed", "level": "info", "logger": "bootstrap",
if [[ "$1" == "server" ]]; then
gunicorn -c /lifecycle/gunicorn.conf.py passbook.root.asgi:application
elif [[ "$1" == "worker" ]]; then
- celery -A passbook.root.celery worker --autoscale 10,3 -E -B -s /tmp/celerybeat-schedule -Q passbook,passbook_scheduled
+ celery -A passbook.root.celery worker --autoscale 3,1 -E -B -s /tmp/celerybeat-schedule -Q passbook,passbook_scheduled
elif [[ "$1" == "migrate" ]]; then
# Run system migrations first, run normal migrations after
python -m lifecycle.migrate
diff --git a/passbook/admin/tasks.py b/passbook/admin/tasks.py
index 931be54ea..a0551eb3a 100644
--- a/passbook/admin/tasks.py
+++ b/passbook/admin/tasks.py
@@ -3,6 +3,7 @@ from django.core.cache import cache
from requests import RequestException, get
from structlog import get_logger
+from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
from passbook.root.celery import CELERY_APP
LOGGER = get_logger()
@@ -10,8 +11,8 @@ VERSION_CACHE_KEY = "passbook_latest_version"
VERSION_CACHE_TIMEOUT = 2 * 60 * 60 # 2 hours
-@CELERY_APP.task()
-def update_latest_version():
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def update_latest_version(self: MonitoredTask):
"""Update latest version info"""
try:
data = get(
@@ -19,5 +20,11 @@ def update_latest_version():
).json()
tag_name = data.get("tag_name")
cache.set(VERSION_CACHE_KEY, tag_name.split("/")[1], VERSION_CACHE_TIMEOUT)
- except (RequestException, IndexError):
+ self.set_status(
+ TaskResult(
+ TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
+ )
+ )
+ except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
+ self.set_status(TaskResult(TaskResultStatus.ERROR, [str(exc)]))
diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html
index c5213a97f..ba4ce6e9f 100644
--- a/passbook/admin/templates/administration/base.html
+++ b/passbook/admin/templates/administration/base.html
@@ -146,6 +146,12 @@
{% trans 'Groups' %}
+
+
+ {% trans 'System Tasks' %}
+
+
diff --git a/passbook/admin/templates/administration/task/list.html b/passbook/admin/templates/administration/task/list.html
new file mode 100644
index 000000000..716aed06a
--- /dev/null
+++ b/passbook/admin/templates/administration/task/list.html
@@ -0,0 +1,71 @@
+{% extends "administration/base.html" %}
+
+{% load i18n %}
+{% load humanize %}
+{% load passbook_utils %}
+
+{% block content %}
+
+
+
+
+ {% trans 'System Tasks' %}
+
+
{% trans "Background tasks." %}
+
+
+
+
+
+
+
+ {% trans 'Identifier' %} |
+ {% trans 'Description' %} |
+ {% trans 'Last Status' %} |
+ {% trans 'Status' %} |
+ {% trans 'Messages' %} |
+
+
+
+ {% for key, task in object_list.items %}
+
+
+ {{ key }}
+ |
+
+
+ {{ task.task_description }}
+
+ |
+
+
+ {{ task.finish_timestamp|naturaltime }}
+
+ |
+
+
+ {% if task.result.status == task_successful %}
+ {% trans 'Successful' %}
+ {% elif task.result.status == task_warning %}
+ {% trans 'Warning' %}
+ {% elif task.result.status == task_error %}
+ {% trans 'Error' %}
+ {% else %}
+ {% trans 'Unknown' %}
+ {% endif %}
+
+ |
+
+ {% for message in task.result.messages %}
+
+ {{ message }}
+
+ {% endfor %}
+ |
+
+ {% endfor %}
+
+
+
+
+{% endblock %}
diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py
index 86a52ca40..e2c13505e 100644
--- a/passbook/admin/urls.py
+++ b/passbook/admin/urls.py
@@ -17,6 +17,7 @@ from passbook.admin.views import (
stages_bindings,
stages_invitations,
stages_prompts,
+ tasks,
tokens,
users,
)
@@ -311,4 +312,10 @@ urlpatterns = [
outposts.OutpostDeleteView.as_view(),
name="outpost-delete",
),
+ # Tasks
+ path(
+ "tasks/",
+ tasks.TaskListView.as_view(),
+ name="tasks",
+ ),
]
diff --git a/passbook/admin/views/tasks.py b/passbook/admin/views/tasks.py
new file mode 100644
index 000000000..dc8cc7586
--- /dev/null
+++ b/passbook/admin/views/tasks.py
@@ -0,0 +1,22 @@
+"""passbook Tasks List"""
+from typing import Any, Dict
+
+from django.core.cache import cache
+from django.views.generic.base import TemplateView
+
+from passbook.admin.mixins import AdminRequiredMixin
+from passbook.lib.tasks import TaskResultStatus
+
+
+class TaskListView(AdminRequiredMixin, TemplateView):
+ """Show list of all background tasks"""
+
+ template_name = "administration/task/list.html"
+
+ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
+ kwargs = super().get_context_data(**kwargs)
+ kwargs["object_list"] = cache.get_many(cache.keys("task_*"))
+ kwargs["task_successful"] = TaskResultStatus.SUCCESSFUL
+ kwargs["task_warning"] = TaskResultStatus.WARNING
+ kwargs["task_error"] = TaskResultStatus.ERROR
+ return kwargs
diff --git a/passbook/core/tasks.py b/passbook/core/tasks.py
index 16470291e..2822458d4 100644
--- a/passbook/core/tasks.py
+++ b/passbook/core/tasks.py
@@ -3,14 +3,16 @@ from django.utils.timezone import now
from structlog import get_logger
from passbook.core.models import ExpiringModel
+from passbook.lib.tasks import MonitoredTask, TaskResult, TaskResultStatus
from passbook.root.celery import CELERY_APP
LOGGER = get_logger()
-@CELERY_APP.task()
-def clean_expired_models():
+@CELERY_APP.task(bind=True, base=MonitoredTask)
+def clean_expired_models(self: MonitoredTask):
"""Remove expired objects"""
+ messages = []
for cls in ExpiringModel.__subclasses__():
cls: ExpiringModel
amount, _ = (
@@ -20,3 +22,5 @@ def clean_expired_models():
.delete()
)
LOGGER.debug("Deleted expired models", model=cls, amount=amount)
+ messages.append(f"Deleted {amount} expired {cls._meta.verbose_name_plural}")
+ self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
diff --git a/passbook/core/tests/test_tasks.py b/passbook/core/tests/test_tasks.py
index f3144f313..90dd1c2aa 100644
--- a/passbook/core/tests/test_tasks.py
+++ b/passbook/core/tests/test_tasks.py
@@ -14,5 +14,5 @@ class TestTasks(TestCase):
"""Test Token cleanup task"""
Token.objects.create(expires=now(), user=get_anonymous_user())
self.assertEqual(Token.objects.all().count(), 1)
- clean_expired_models()
+ clean_expired_models.delay()
self.assertEqual(Token.objects.all().count(), 0)
diff --git a/passbook/lib/tasks.py b/passbook/lib/tasks.py
new file mode 100644
index 000000000..bc95f320c
--- /dev/null
+++ b/passbook/lib/tasks.py
@@ -0,0 +1,88 @@
+"""Monitored tasks"""
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from typing import List, Optional
+
+from celery import Task
+from django.core.cache import cache
+
+
+class TaskResultStatus(Enum):
+ """Possible states of tasks"""
+
+ SUCCESSFUL = 1
+ WARNING = 2
+ ERROR = 4
+
+
+@dataclass
+class TaskResult:
+ """Result of a task run, this class is created by the task itself
+ and used by self.set_status"""
+
+ status: TaskResultStatus
+
+ messages: List[str] = field(default_factory=list)
+
+ error: Optional[Exception] = field(default=None)
+
+ # Optional UID used in cache for tasks that run in different instances
+ uid: Optional[str] = field(default=None)
+
+
+@dataclass
+class TaskInfo:
+ """Info about a task run"""
+
+ task_name: str
+ finish_timestamp: datetime
+
+ result: TaskResult
+
+ task_description: Optional[str] = field(default=None)
+
+ def save(self):
+ """Save task into cache"""
+ key = f"task_{self.task_name}"
+ if self.result.uid:
+ key += f"_{self.result.uid}"
+ self.task_name += f"_{self.result.uid}"
+ cache.set(key, self)
+
+
+class MonitoredTask(Task):
+ """Task which can save its state to the cache"""
+
+ _result: TaskResult
+
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self._result = TaskResult(status=TaskResultStatus.ERROR, messages=[])
+
+ def set_status(self, result: TaskResult):
+ """Set result for current run, will overwrite previous result."""
+ self._result = result
+
+ # pylint: disable=too-many-arguments
+ def after_return(self, status, retval, task_id, args, kwargs, einfo):
+ TaskInfo(
+ task_name=self.__name__,
+ task_description=self.__doc__,
+ finish_timestamp=datetime.now(),
+ result=self._result,
+ ).save()
+ return super().after_return(status, retval, task_id, args, kwargs, einfo=einfo)
+
+ # pylint: disable=too-many-arguments
+ def on_failure(self, exc, task_id, args, kwargs, einfo):
+ TaskInfo(
+ task_name=self.__name__,
+ task_description=self.__doc__,
+ finish_timestamp=datetime.now(),
+ result=self._result,
+ ).save()
+ return super().on_failure(exc, task_id, args, kwargs, einfo=einfo)
+
+ def run(self, *args, **kwargs):
+ raise NotImplementedError
diff --git a/passbook/providers/proxy/tests.py b/passbook/providers/proxy/tests.py
index 810986e0a..23d0a5aad 100644
--- a/passbook/providers/proxy/tests.py
+++ b/passbook/providers/proxy/tests.py
@@ -7,9 +7,7 @@ from django.test import TestCase
from passbook.flows.models import Flow
from passbook.outposts.models import Outpost, OutpostDeploymentType, OutpostType
-from passbook.providers.proxy.controllers.kubernetes import (
- ProxyKubernetesController,
-)
+from passbook.providers.proxy.controllers.kubernetes import ProxyKubernetesController
from passbook.providers.proxy.models import ProxyProvider
diff --git a/passbook/stages/consent/tests.py b/passbook/stages/consent/tests.py
index d262dab1a..893e9a1bc 100644
--- a/passbook/stages/consent/tests.py
+++ b/passbook/stages/consent/tests.py
@@ -127,7 +127,7 @@ class TestConsentStage(TestCase):
).exists()
)
sleep(1)
- clean_expired_models()
+ clean_expired_models.delay()
self.assertFalse(
UserConsent.objects.filter(
user=self.user, application=self.application
diff --git a/passbook/static/static/passbook/passbook.css b/passbook/static/static/passbook/passbook.css
index 645e7d314..d0d5e1997 100644
--- a/passbook/static/static/passbook/passbook.css
+++ b/passbook/static/static/passbook/passbook.css
@@ -52,6 +52,9 @@
.pf-m-success {
color: var(--pf-global--success-color--100);
}
+.pf-m-warning {
+ color: var(--pf-global--warning-color--100);
+}
.pf-m-danger {
color: var(--pf-global--danger-color--100);
}