diff --git a/computes/templates/computes/instances.html b/computes/templates/computes/instances.html index 04a69094..8e3fc077 100644 --- a/computes/templates/computes/instances.html +++ b/computes/templates/computes/instances.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% load i18n %} {% load static %} +{% load operations %} {% load bootstrap_icons %} {% block title %}{% trans "Instances" %} - {{ compute.name }}{% endblock %} {% block style %} @@ -19,6 +20,7 @@ {% endblock page_heading_extra %} {% block content %} +
{% endblock %} {% block script %} + +{% job_panel_script job_resource %} {% endblock %} \ No newline at end of file diff --git a/computes/views.py b/computes/views.py index 00dade42..af585a3e 100644 --- a/computes/views.py +++ b/computes/views.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils import timezone from instances.models import Instance from libvirt import libvirtError +from operations import claims from vrtManager.connection import wvmConnect from vrtManager.hostdetails import wvmHostDetails @@ -80,7 +81,8 @@ def instances(request, compute_id): ) return render( - request, "computes/instances.html", {"compute": compute, "instances": instances} + request, "computes/instances.html", + {"compute": compute, "instances": instances, "job_resource": claims.compute_key(compute.id)}, ) diff --git a/instances/api/serializers.py b/instances/api/serializers.py index 3061f7d8..56b9185a 100644 --- a/instances/api/serializers.py +++ b/instances/api/serializers.py @@ -58,6 +58,7 @@ class CreateInstanceSerializer(serializers.ModelSerializer): cache_mode = serializers.CharField(initial="none") virtio = serializers.BooleanField(initial=True) qemu_ga = serializers.BooleanField(initial=True) + submission_key = serializers.CharField(write_only=True) def validate(self, attrs): try: @@ -82,9 +83,9 @@ class CreateInstanceSerializer(serializers.ModelSerializer): "meta_prealloc", "virtio", "qemu_ga", - "console_pass", "video", "listener_addr", + "submission_key", ] diff --git a/instances/api/viewsets.py b/instances/api/viewsets.py index aab52c88..7dd0f01b 100644 --- a/instances/api/viewsets.py +++ b/instances/api/viewsets.py @@ -1,9 +1,9 @@ from admin.permissions import IsSuperUser -from appsettings.settings import app_settings from computes import utils from computes.models import Compute from django.http import Http404 from django.shortcuts import get_object_or_404 +from django.urls import reverse from instances.models import Flavor, Instance from instances.utils import migrate_instance from instances.views import destroy as instance_destroy @@ -17,12 +17,11 @@ from instances.views import ( suspend, ) from drf_spectacular.utils import extend_schema -from libvirt import libvirtError +from operations import jobs +from operations.api.serializers import OperationSerializer from rest_framework import permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response -from vrtManager import util -from vrtManager.create import wvmCreate from webvirtcloud.serializers import StatusSerializer from .serializers import ( @@ -227,94 +226,15 @@ class CreateInstanceViewSet(viewsets.ViewSet): serializer_class = CreateInstanceSerializer queryset = "" - @extend_schema(responses=StatusSerializer) + @extend_schema(responses={202: OperationSerializer, 400: dict}) def create(self, request, compute_pk=None, arch=None, machine=None): - serializer = CreateInstanceSerializer( - data=request.data, - context={"compute_pk": compute_pk, "arch": arch, "machine": machine}, - ) - if serializer.is_valid(): - volume_list = [] - default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS - default_io = app_settings.INSTANCE_VOLUME_DEFAULT_IO - default_discard = app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD - default_zeroes = app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES - default_scsi_disk_model = ( - app_settings.INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER - ) - default_disk_format = app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT - default_disk_owner_uid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID) - default_disk_owner_gid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID) - compute = Compute.objects.get(pk=compute_pk) - conn = wvmCreate( - compute.hostname, - compute.login, - compute.password, - compute.type, - ) - - path = None # the volume this request allocated - try: - path = conn.create_volume( - serializer.validated_data["storage"], - serializer.validated_data["name"], - serializer.validated_data["hdd_size"], - default_disk_format, - serializer.validated_data["meta_prealloc"], - default_disk_owner_uid, - default_disk_owner_gid, - ) - volume = {} - volume["device"] = "disk" - volume["path"] = path - volume["type"] = conn.get_volume_format_type(path) - volume["cache_mode"] = serializer.validated_data["cache_mode"] - volume["bus"] = default_bus - if volume["bus"] == "scsi": - volume["scsi_model"] = default_scsi_disk_model - volume["discard_mode"] = default_discard - volume["detect_zeroes_mode"] = default_zeroes - volume["io_mode"] = default_io - - volume_list.append(volume) - - with utils.libvirt_compute_lock(compute): - conn.create_instance( - name=serializer.validated_data["name"], - memory=serializer.validated_data["memory"], - vcpu=serializer.validated_data["vcpu"], - vcpu_mode=serializer.validated_data["vcpu_mode"], - uuid=util.randomUUID(), - arch=arch, - machine=machine, - firmware={}, - volumes=volume_list, - networks=serializer.validated_data["networks"], - nwfilter=serializer.validated_data["nwfilter"], - virtio=serializer.validated_data["virtio"], - listener_addr=serializer.validated_data["listener_addr"], - video=serializer.validated_data["video"], - console_pass=serializer.validated_data["console_pass"], - mac=serializer.validated_data["mac"], - qemu_ga=serializer.validated_data["qemu_ga"], - ) - except Exception as err: - # A failed definition must not leave the new disk behind - if path: - try: - conn.delete_volume(path) - except libvirtError: - return Response( - {"status": f"{err}; remove {path} by hand"}, status=status.HTTP_400_BAD_REQUEST - ) - if not isinstance(err, libvirtError): - raise - return Response({"status": str(err)}, status=status.HTTP_400_BAD_REQUEST) - # After the definition: the VM uses the volume, so no rollback here. - # The refresh claims the compute itself and skips it when busy; the - # VM exists either way, so a busy compute must not refuse the request. - utils.refresh_instance_database(compute) - msg = f"Instance {serializer.validated_data['name']} is created" - return Response({"status": msg}) - else: + serializer = CreateInstanceSerializer(data=request.data, + context={"compute_pk": compute_pk, "arch": arch, "machine": machine}) + if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + data = dict(serializer.validated_data) + key = data.pop("submission_key") + op = jobs.submit("instance.create", request.user, {**data, "compute": compute_pk, "arch": arch, + "machine": machine}, key) + return Response(OperationSerializer(op, context={"request": request}).data, status=status.HTTP_202_ACCEPTED, + headers={"Location": reverse("operation-detail", args=[op.pk])}) diff --git a/instances/forms.py b/instances/forms.py index 156bc3c6..fa1e37be 100644 --- a/instances/forms.py +++ b/instances/forms.py @@ -58,7 +58,6 @@ class NewVMForm(forms.ModelForm): # virtio = forms.BooleanField(required=False) # qemu_ga = forms.BooleanField(required=False) # mac = forms.CharField(required=False) - # console_pass = forms.CharField(required=False, empty_value="", widget=forms.PasswordInput()) # graphics = forms.CharField(error_messages={'required': _('Please select a graphics type')}) # video = forms.CharField(error_messages={'required': _('Please select a video driver')}) # listener_addr = forms.ChoiceField(required=True, widget=forms.RadioSelect, choices=QEMU_CONSOLE_LISTENER_ADDRESSES) diff --git a/instances/jobs.py b/instances/jobs.py index b16f606f..9a4b6bb0 100644 --- a/instances/jobs.py +++ b/instances/jobs.py @@ -9,6 +9,7 @@ from libvirt import (VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_SNAPSHOT_ libvirtError) from vrtManager import util from vrtManager.connection import wvmConnect +from vrtManager.create import wvmCreate from vrtManager.instance import wvmInstance from accounts.models import UserInstance @@ -281,3 +282,150 @@ class CloneInstance(Kind): compute, _sep, source = op.resource_label.rpartition("/") message = f"Create a clone of '{source}'" return compute, op.intent["name"], message if state == Operation.SUCCEEDED else f"{message} ({state})" + + +@register +class CreateInstance(Kind): + name = "instance.create" + label = gettext_noop("Create VM") + time_limit = 12 * 3600 # a copy cannot be stopped: over time is only noted + + def normalize(self, raw): + # what the API leaves out means what create_instance did by default + params = {"template": "", "images": "", "firmware": "", "net_model": "virtio", "add_cdrom": "sata", + "add_input": "default", "meta_prealloc": False, "hdd_size": 0} + params.update({k: v for k, v in raw.items() if k not in ("submission_key", "create", "csrfmiddlewaretoken")}) + params.update(compute=int(raw["compute"]), devices=[list(d) for d in raw.get("devices", [])]) + if "UEFI" in params["firmware"] and "secboot" in params["firmware"].partition(":")[2]: + params["machine"] = "q35" # a secure boot loader needs SMM, on q35 only + return params + + def validate(self, user, params): + from instances.views import CDROM_BUSES + + if not user.is_superuser: + raise PermissionDenied + data = params + c = Compute.objects.get(pk=data["compute"]) + conn = wvmCreate(c.hostname, c.login, c.password, c.type) + cdrom_buses = [bus for bus in conn.get_disk_bus_types(data["arch"], data["machine"]) if bus in CDROM_BUSES] + if data["name"] in conn.get_instances(): + raise util.OperationError(_("A virtual machine with this name already exists")) + if Instance.objects.filter(name__exact=data["name"]): + raise util.OperationError(_("There is an instance with same name. Remove it and try again!")) + if not (data["hdd_size"] or data["template"] or data["images"]): + raise util.OperationError(_("First you need to create or select an image")) + if not data["hdd_size"] and data["template"] and conn.get_volume_path(data["name"] + ".img", data["storage"]): + raise util.OperationError(_("Image has already exist. Please check volumes or change instance name")) + if data["cache_mode"] not in conn.get_cache_modes(): + raise util.OperationError(_("Invalid cache mode")) + if data["add_cdrom"] != "None" and data["add_cdrom"] not in cdrom_buses: + raise util.OperationError(_("A CD-ROM cannot use the %(bus)s bus") % {"bus": data["add_cdrom"]}) + try: + utils.nic_macs(data["mac"], data["networks"]) + except ValueError as err: + raise util.OperationError(str(err)) + if "UEFI" in data["firmware"] and ":" not in data["firmware"]: + raise util.OperationError(_("Invalid firmware")) + + def plan(self, user, params): + compute = Compute.objects.get(pk=params["compute"]) + return Plan(claims=vm_claims(compute, params["name"]), resource=claims.compute_key(compute.pk), + label=f"{compute.name}/{params['name']}", + intent={"compute": compute.pk, "uuid": util.randomUUID(), "name": params["name"]}) + + def run(self, op, ctx): + data = op.params + c = Compute.objects.get(pk=data["compute"]) + conn = wvmCreate(c.hostname, c.login, c.password, c.type) + meta_prealloc = bool(data["meta_prealloc"]) + default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS + default_disk_format = app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT + default_disk_owner_uid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID) + default_disk_owner_gid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID) + + firmware = dict() + if "UEFI" in data["firmware"]: + firmware["loader"] = data["firmware"].split(":")[1].strip() + firmware["secure"] = "yes" if "secboot" in firmware["loader"] else "no" + firmware["readonly"] = "yes" + firmware["type"] = "pflash" + + def volume(path, device="disk", bus=default_bus): + return { + "path": path, + "type": conn.get_volume_format_type(path), + "device": device, + "bus": bus, + "scsi_model": app_settings.INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER if bus == "scsi" else None, + "cache_mode": data["cache_mode"], + "discard_mode": app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD, + "detect_zeroes_mode": app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES, + "io_mode": app_settings.INSTANCE_VOLUME_DEFAULT_IO, + } + + ctx.record_intent() + ctx.set_phase(gettext_noop("Copying disks")) + created = [] # volumes this job allocated + volume_list = [] + try: + if data["hdd_size"]: + path = conn.create_volume(data["storage"], data["name"], data["hdd_size"], default_disk_format, + meta_prealloc, default_disk_owner_uid, default_disk_owner_gid) + created.append(path) + volume_list.append(volume(path)) + elif data["template"]: + path = conn.clone_from_template(data["name"], conn.get_volume_path(data["template"]), data["storage"], + meta_prealloc, default_disk_owner_uid, default_disk_owner_gid) + created.append(path) + volume_list.append(volume(path)) + else: + for vol, (device, bus) in zip(data["images"].split(","), data["devices"]): + volume_list.append(volume(conn.get_volume_path(vol), device, bus)) + + ctx.set_phase(gettext_noop("Defining the VM")) + conn.create_instance( + name=data["name"], + memory=data["memory"], + vcpu=data["vcpu"], + vcpu_mode=data["vcpu_mode"], + uuid=op.intent["uuid"], + arch=data["arch"], + machine=data["machine"], + firmware=firmware, + volumes=volume_list, + networks=data["networks"], + virtio=data["virtio"], + listener_addr=data["listener_addr"], + nwfilter=data["nwfilter"], + net_model="virtio" if data["net_model"] == "default" else data["net_model"], + video=data["video"], + console_pass="", + mac=data["mac"], + qemu_ga=data["qemu_ga"], + add_cdrom=data["add_cdrom"], + add_input=data["add_input"], + ) + except Exception as err: + # the VM is not defined: its new volumes go + leftovers = [] + for path in created: + try: + conn.delete_volume(path) + except libvirtError: + leftovers.append(path) + if leftovers: + raise util.OperationError(f"{err}. Remove these volumes by hand: {', '.join(leftovers)}") from err + raise + record_vm(op, owner=False) + return {} + + def reconcile(self, op): + data = op.params + names = [data["name"] + ext for ext in ("", ".img", ".qcow", ".qcow2")] + return vm_outcome(op, owner=False, volumes=names if data["hdd_size"] or data["template"] else []) + + def log_entry(self, op, state): + compute, _sep, name = op.resource_label.rpartition("/") + message = "Instance is created" + return compute, name, message if state == Operation.SUCCEEDED else f"{message} ({state})" diff --git a/instances/models.py b/instances/models.py index 48cf8e20..99883859 100644 --- a/instances/models.py +++ b/instances/models.py @@ -373,7 +373,6 @@ class CreateInstance(models.Model): virtio = models.BooleanField(default=True) qemu_ga = models.BooleanField(default=False) mac = models.CharField(max_length=17, blank=True) - console_pass = models.CharField(max_length=64, blank=True) add_cdrom = models.CharField(max_length=16) add_input = models.CharField(max_length=16) video = models.CharField( diff --git a/instances/templates/create_instance_w2.html b/instances/templates/create_instance_w2.html index 648f8341..90952d20 100644 --- a/instances/templates/create_instance_w2.html +++ b/instances/templates/create_instance_w2.html @@ -1,6 +1,7 @@ {% extends "base.html" %} {% load i18n %} {% load static %} +{% load operations %} {% load bootstrap_icons %} {% block title %}{% trans "Create new instance" %}{% endblock %} @@ -97,7 +98,7 @@