mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
feat(operations): jobs API
This commit is contained in:
parent
079e2ca5ca
commit
9c125aedd2
7 changed files with 218 additions and 2 deletions
0
operations/api/__init__.py
Normal file
0
operations/api/__init__.py
Normal file
43
operations/api/serializers.py
Normal file
43
operations/api/serializers.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from django.utils.translation import gettext as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from operations.jobs import KINDS, label_of
|
||||
from operations.models import Operation
|
||||
from operations.utils import shown_label
|
||||
|
||||
|
||||
class OperationSerializer(serializers.ModelSerializer):
|
||||
label = serializers.SerializerMethodField()
|
||||
resource_label = serializers.SerializerMethodField()
|
||||
cancellable = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Operation
|
||||
fields = ["id", "kind", "label", "username", "resource", "resource_label", "state", "phase",
|
||||
"progress_done", "progress_total", "result", "error", "created_at", "started_at",
|
||||
"finished_at", "cancel_requested", "cancellable", "resolution", "resolution_note"]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_label(self, op) -> str:
|
||||
return str(_(label_of(op.kind)))
|
||||
|
||||
def get_resource_label(self, op) -> str:
|
||||
return shown_label(op, self.context["request"].user)
|
||||
|
||||
def get_cancellable(self, op) -> bool:
|
||||
kind = KINDS.get(op.kind)
|
||||
return op.state == Operation.QUEUED or bool(kind and kind.cancellable and op.state == Operation.RUNNING)
|
||||
|
||||
|
||||
class OperationSummarySerializer(serializers.Serializer):
|
||||
kind = serializers.CharField()
|
||||
label = serializers.CharField()
|
||||
state = serializers.CharField()
|
||||
since = serializers.DateTimeField()
|
||||
|
||||
|
||||
class ResourceJobsSerializer(serializers.Serializer):
|
||||
jobs = OperationSerializer(many=True)
|
||||
others = OperationSummarySerializer(many=True)
|
||||
short_operation = serializers.BooleanField()
|
||||
worker_last_seen = serializers.DateTimeField(allow_null=True)
|
||||
58
operations/api/viewsets.py
Normal file
58
operations/api/viewsets.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import mixins, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.response import Response
|
||||
|
||||
from operations import jobs
|
||||
from operations.models import Claim, Operation, last_seen
|
||||
from operations.utils import may_see, summary, visible
|
||||
|
||||
from .serializers import OperationSerializer, ResourceJobsSerializer
|
||||
|
||||
SHOWN_STATES = Operation.ACTIVE + (Operation.UNCERTAIN,)
|
||||
|
||||
|
||||
class Pages(PageNumberPagination):
|
||||
page_size = 50
|
||||
|
||||
|
||||
class OperationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
serializer_class = OperationSerializer
|
||||
pagination_class = Pages
|
||||
|
||||
def get_queryset(self):
|
||||
if getattr(self, "swagger_fake_view", False): # schema generation has no user
|
||||
return Operation.objects.none()
|
||||
user = self.request.user
|
||||
qs = Operation.objects.all() if user.is_superuser else Operation.objects.filter(user=user)
|
||||
fields = ("state", "kind", "resource", "username") if user.is_superuser else ("state", "kind", "resource")
|
||||
for field in fields:
|
||||
if value := self.request.query_params.get(field):
|
||||
qs = qs.filter(**{field: value})
|
||||
return qs
|
||||
|
||||
@extend_schema(request=None, responses=OperationSerializer)
|
||||
@action(detail=True, methods=["post"])
|
||||
def cancel(self, request, pk=None):
|
||||
op = self.get_object()
|
||||
return Response(OperationSerializer(jobs.cancel(op.pk, request.user), context={"request": request}).data)
|
||||
|
||||
@extend_schema(parameters=[OpenApiParameter("key", str, required=True)], responses=ResourceJobsSerializer)
|
||||
@action(detail=False, methods=["get"])
|
||||
def resource(self, request):
|
||||
key = request.query_params.get("key", "")
|
||||
if not key:
|
||||
raise ValidationError({"key": "required"})
|
||||
if not visible(request.user, key):
|
||||
raise Http404
|
||||
active = list(Operation.objects.filter(claims__resource=key, state__in=SHOWN_STATES).distinct())
|
||||
mine = [op for op in active if may_see(op, request.user)]
|
||||
others = [summary(op, request.user) for op in active if not may_see(op, request.user)]
|
||||
short = Claim.objects.filter(resource=key, operation__isnull=True, expires_at__gt=timezone.now()).exists()
|
||||
return Response(ResourceJobsSerializer(
|
||||
{"jobs": mine, "others": others, "short_operation": short, "worker_last_seen": last_seen()},
|
||||
context={"request": request}).data)
|
||||
88
operations/tests/test_api.py
Normal file
88
operations/tests/test_api.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from operations import claims, jobs
|
||||
from operations.models import Claim, Operation
|
||||
from operations.tests import fakes
|
||||
|
||||
|
||||
class OperationsApiTestCase(TransactionTestCase):
|
||||
def setUp(self):
|
||||
fakes.reset()
|
||||
U = get_user_model()
|
||||
self.owner = U.objects.create_user("api_owner", "o@example.com", "pw")
|
||||
self.other = U.objects.create_user("api_other", "x@example.com", "pw")
|
||||
compute = Compute.objects.create(name="api-c", hostname="192.0.2.20", login="root", password="", type=1)
|
||||
self.vm = Instance.objects.create(compute=compute, name="api-vm", uuid="u1")
|
||||
self.vm2 = Instance.objects.create(compute=compute, name="api-vm2", uuid="u2")
|
||||
UserInstance.objects.create(user=self.other, instance=self.vm)
|
||||
UserInstance.objects.create(user=self.owner, instance=self.vm2)
|
||||
self.op = jobs.submit("test.noop", self.owner, {"uuid": "u1"}, "key-00000001")
|
||||
|
||||
def test_users_list_only_their_own_jobs(self):
|
||||
self.client.force_login(self.other)
|
||||
self.assertEqual(self.client.get("/api/v1/operations/").json()["count"], 0)
|
||||
self.assertEqual(self.client.get(f"/api/v1/operations/{self.op.pk}/").status_code, 404)
|
||||
self.client.force_login(self.owner)
|
||||
self.assertEqual(self.client.get("/api/v1/operations/").json()["count"], 1)
|
||||
|
||||
def test_the_resource_view_shows_others_jobs_as_summaries(self):
|
||||
self.client.force_login(self.other)
|
||||
data = self.client.get("/api/v1/operations/resource/", {"key": "instance:u1"}).json()
|
||||
self.assertEqual(data["jobs"], [])
|
||||
[summary] = data["others"]
|
||||
self.assertEqual(summary["kind"], "test.noop")
|
||||
for private in ("id", "user", "username", "params"):
|
||||
self.assertNotIn(private, summary)
|
||||
|
||||
def test_a_resource_the_user_cannot_see_is_refused(self):
|
||||
stranger = get_user_model().objects.create_user("api_s", "s@example.com", "pw")
|
||||
self.client.force_login(stranger)
|
||||
self.assertEqual(self.client.get("/api/v1/operations/resource/", {"key": "instance:u1"}).status_code, 404)
|
||||
|
||||
def test_a_short_operation_is_flagged(self):
|
||||
claims.admit({"instance:u2": Claim.EXCLUSIVE}, holder="another-request")
|
||||
self.client.force_login(self.owner)
|
||||
self.assertTrue(self.client.get("/api/v1/operations/resource/", {"key": "instance:u2"}).json()["short_operation"])
|
||||
|
||||
def test_cancel_through_the_api(self):
|
||||
self.client.force_login(self.owner)
|
||||
response = self.client.post(f"/api/v1/operations/{self.op.pk}/cancel/")
|
||||
self.assertEqual((response.status_code, response.json()["state"]), (200, "cancelled"))
|
||||
|
||||
def test_a_job_that_cannot_stop_answers_400(self):
|
||||
Operation.objects.filter(pk=self.op.pk).update(state=Operation.RUNNING)
|
||||
self.client.force_login(self.owner)
|
||||
self.assertEqual(self.client.post(f"/api/v1/operations/{self.op.pk}/cancel/").status_code, 400)
|
||||
|
||||
def _labels_seen_by(self, user, label="wvc-test-host/wvc-test-vm"):
|
||||
op = jobs.submit("test.noop", self.owner, {"uuid": "u2"}, "key-00000002")
|
||||
Operation.objects.filter(pk=op.pk).update(resource_label=label)
|
||||
self.client.force_login(user)
|
||||
listed = [row["resource_label"] for row in self.client.get("/api/v1/operations/").json()["results"]
|
||||
if row["id"] == str(op.pk)]
|
||||
detail = self.client.get(f"/api/v1/operations/{op.pk}/").json()["resource_label"]
|
||||
[job] = self.client.get("/api/v1/operations/resource/", {"key": "instance:u2"}).json()["jobs"]
|
||||
cancelled = self.client.post(f"/api/v1/operations/{op.pk}/cancel/").json()["resource_label"]
|
||||
return listed + [detail, job["resource_label"], cancelled]
|
||||
|
||||
def test_a_plain_owner_sees_only_the_vm_name(self):
|
||||
self.assertEqual(self._labels_seen_by(self.owner), ["wvc-test-vm"] * 4)
|
||||
|
||||
def test_a_compute_name_with_a_slash_stays_hidden(self):
|
||||
# libvirt refuses "/" in VM names; compute names may contain it
|
||||
self.assertEqual(self._labels_seen_by(self.owner, "wvc-test-region/wvc-test-host/wvc-test-vm"),
|
||||
["wvc-test-vm"] * 4)
|
||||
|
||||
def test_users_who_may_see_hosts_see_the_compute_name(self):
|
||||
self.owner.user_permissions.add(Permission.objects.get(codename="view_instances",
|
||||
content_type__app_label="instances"))
|
||||
self.assertEqual(self._labels_seen_by(self.owner), ["wvc-test-host/wvc-test-vm"] * 4)
|
||||
|
||||
def test_superusers_see_the_compute_name(self):
|
||||
admin = get_user_model().objects.create_superuser("api_admin", "a@example.com", "pw")
|
||||
self.assertEqual(self._labels_seen_by(admin), ["wvc-test-host/wvc-test-vm"] * 4)
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
do not see the hosts get "host unreachable" instead of a connection error's
|
||||
text (which names the host). What a refused request says about the job holding
|
||||
the resource: details for its owner and superusers, a minimal summary for
|
||||
everyone else. Worker settings from the Settings page. The worker reads them
|
||||
itself (the settings middleware only runs in gunicorn)."""
|
||||
everyone else; and who may see jobs on a resource page. Worker settings from
|
||||
the Settings page. The worker reads them itself (the settings middleware only
|
||||
runs in gunicorn)."""
|
||||
|
||||
import re
|
||||
|
||||
|
|
@ -14,7 +15,9 @@ from django.utils.translation import gettext as _
|
|||
from django.utils.translation import gettext_lazy
|
||||
from libvirt import libvirtError
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from appsettings.models import AppSettings
|
||||
from instances.models import Instance
|
||||
from vrtManager.util import ConnectionFailed
|
||||
|
||||
from . import jobs
|
||||
|
|
@ -83,6 +86,26 @@ def may_see(op, user):
|
|||
return user.is_superuser or (op.user_id is not None and op.user_id == user.pk)
|
||||
|
||||
|
||||
def shown_label(op, user):
|
||||
"""The job's "<compute>/<vm>" label, without the compute for users the app hides hosts from.
|
||||
A compute name may contain "/", a VM name may not (libvirt refuses it)."""
|
||||
if user.is_superuser or user.has_perm("instances.view_instances"):
|
||||
return op.resource_label
|
||||
return op.resource_label.rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def visible(user, key):
|
||||
if user.is_superuser:
|
||||
return True
|
||||
kind, _sep, rest = key.partition(":")
|
||||
if kind != "instance":
|
||||
return False # compute, storage and network pages are superuser-only
|
||||
instances = Instance.objects.filter(uuid=rest)
|
||||
if user.has_perm("instances.view_instances"):
|
||||
return instances.exists()
|
||||
return UserInstance.objects.filter(user=user, instance__in=instances).exists()
|
||||
|
||||
|
||||
def summary(op, user):
|
||||
base = {"kind": op.kind, "label": str(_(jobs.label_of(op.kind))), "state": op.state, "since": op.created_at}
|
||||
if may_see(op, user):
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ ALLOWED_FOR_ANY_USER = {
|
|||
"compute-instance-list": "API: filtered to the user's own instances",
|
||||
"instance-flavor-list": "API: flavor catalogue",
|
||||
"instance-flavor-detail": "API: flavor catalogue",
|
||||
"operation-list": "API: filtered to the user's own jobs",
|
||||
"operation-resource": "API: checks per resource who may see it",
|
||||
"schema": "API docs: any authenticated user (SERVE_PERMISSIONS)",
|
||||
"schema-json": "API docs: any authenticated user (SERVE_PERMISSIONS)",
|
||||
"schema-redoc": "API docs: any authenticated user (SERVE_PERMISSIONS)",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from rest_framework_nested import routers
|
|||
from computes.api.viewsets import ComputeArchitecturesView, ComputeViewSet
|
||||
from networks.api.viewsets import NetworkViewSet
|
||||
from interfaces.api.viewsets import InterfaceViewSet
|
||||
from operations.api.viewsets import OperationViewSet
|
||||
from storages.api.viewsets import StorageViewSet, VolumeViewSet
|
||||
from instances.api.viewsets import FlavorViewSet, \
|
||||
InstancesViewSet, \
|
||||
|
|
@ -17,6 +18,7 @@ router.register(r'computes', ComputeViewSet)
|
|||
router.register(r'migrate', MigrateViewSet, basename='instance-migrate')
|
||||
router.register(r'flavor', FlavorViewSet, basename='instance-flavor')
|
||||
router.register(r'instances', InstancesViewSet, basename='instance')
|
||||
router.register(r'operations', OperationViewSet, basename='operation')
|
||||
|
||||
compute_router = routers.NestedSimpleRouter(router, r'computes', lookup='compute', use_regex_path=False)
|
||||
compute_router.register(r'instances', InstanceViewSet, basename='compute-instance')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue