1
0
Fork 0
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:
catborise 2026-10-13 19:33:48 +03:00
parent 079e2ca5ca
commit 9c125aedd2
7 changed files with 218 additions and 2 deletions

View file

View 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)

View 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)

View 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)

View file

@ -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):

View file

@ -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)",

View file

@ -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')