mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(instances): VM create runs as a job, also in the API
This commit is contained in:
parent
fbf82da5a9
commit
6f0c730071
15 changed files with 372 additions and 319 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load operations %}
|
||||||
{% load bootstrap_icons %}
|
{% load bootstrap_icons %}
|
||||||
{% block title %}{% trans "Instances" %} - {{ compute.name }}{% endblock %}
|
{% block title %}{% trans "Instances" %} - {{ compute.name }}{% endblock %}
|
||||||
{% block style %}
|
{% block style %}
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
{% endblock page_heading_extra %}
|
{% endblock page_heading_extra %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div id="job-panel" aria-live="polite"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
|
|
@ -110,6 +112,8 @@
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block script %}
|
{% block script %}
|
||||||
|
<script src="{% static 'js/poll.js' %}"></script>
|
||||||
|
{% job_panel_script job_resource %}
|
||||||
<script src="{% static "js/sortable.min.js" %}"></script>
|
<script src="{% static "js/sortable.min.js" %}"></script>
|
||||||
<script src="{% static 'js/filter-table.js' %}"></script>
|
<script src="{% static 'js/filter-table.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -8,6 +8,7 @@ from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from instances.models import Instance
|
from instances.models import Instance
|
||||||
from libvirt import libvirtError
|
from libvirt import libvirtError
|
||||||
|
from operations import claims
|
||||||
from vrtManager.connection import wvmConnect
|
from vrtManager.connection import wvmConnect
|
||||||
from vrtManager.hostdetails import wvmHostDetails
|
from vrtManager.hostdetails import wvmHostDetails
|
||||||
|
|
||||||
|
|
@ -80,7 +81,8 @@ def instances(request, compute_id):
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
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)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ class CreateInstanceSerializer(serializers.ModelSerializer):
|
||||||
cache_mode = serializers.CharField(initial="none")
|
cache_mode = serializers.CharField(initial="none")
|
||||||
virtio = serializers.BooleanField(initial=True)
|
virtio = serializers.BooleanField(initial=True)
|
||||||
qemu_ga = serializers.BooleanField(initial=True)
|
qemu_ga = serializers.BooleanField(initial=True)
|
||||||
|
submission_key = serializers.CharField(write_only=True)
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
try:
|
try:
|
||||||
|
|
@ -82,9 +83,9 @@ class CreateInstanceSerializer(serializers.ModelSerializer):
|
||||||
"meta_prealloc",
|
"meta_prealloc",
|
||||||
"virtio",
|
"virtio",
|
||||||
"qemu_ga",
|
"qemu_ga",
|
||||||
"console_pass",
|
|
||||||
"video",
|
"video",
|
||||||
"listener_addr",
|
"listener_addr",
|
||||||
|
"submission_key",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
from admin.permissions import IsSuperUser
|
from admin.permissions import IsSuperUser
|
||||||
from appsettings.settings import app_settings
|
|
||||||
from computes import utils
|
from computes import utils
|
||||||
from computes.models import Compute
|
from computes.models import Compute
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from instances.models import Flavor, Instance
|
from instances.models import Flavor, Instance
|
||||||
from instances.utils import migrate_instance
|
from instances.utils import migrate_instance
|
||||||
from instances.views import destroy as instance_destroy
|
from instances.views import destroy as instance_destroy
|
||||||
|
|
@ -17,12 +17,11 @@ from instances.views import (
|
||||||
suspend,
|
suspend,
|
||||||
)
|
)
|
||||||
from drf_spectacular.utils import extend_schema
|
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 import permissions, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from vrtManager import util
|
|
||||||
from vrtManager.create import wvmCreate
|
|
||||||
from webvirtcloud.serializers import StatusSerializer
|
from webvirtcloud.serializers import StatusSerializer
|
||||||
|
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
|
@ -227,94 +226,15 @@ class CreateInstanceViewSet(viewsets.ViewSet):
|
||||||
serializer_class = CreateInstanceSerializer
|
serializer_class = CreateInstanceSerializer
|
||||||
queryset = ""
|
queryset = ""
|
||||||
|
|
||||||
@extend_schema(responses=StatusSerializer)
|
@extend_schema(responses={202: OperationSerializer, 400: dict})
|
||||||
def create(self, request, compute_pk=None, arch=None, machine=None):
|
def create(self, request, compute_pk=None, arch=None, machine=None):
|
||||||
serializer = CreateInstanceSerializer(
|
serializer = CreateInstanceSerializer(data=request.data,
|
||||||
data=request.data,
|
context={"compute_pk": compute_pk, "arch": arch, "machine": machine})
|
||||||
context={"compute_pk": compute_pk, "arch": arch, "machine": machine},
|
if not serializer.is_valid():
|
||||||
)
|
|
||||||
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:
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
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])})
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ class NewVMForm(forms.ModelForm):
|
||||||
# virtio = forms.BooleanField(required=False)
|
# virtio = forms.BooleanField(required=False)
|
||||||
# qemu_ga = forms.BooleanField(required=False)
|
# qemu_ga = forms.BooleanField(required=False)
|
||||||
# mac = forms.CharField(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')})
|
# graphics = forms.CharField(error_messages={'required': _('Please select a graphics type')})
|
||||||
# video = forms.CharField(error_messages={'required': _('Please select a video driver')})
|
# 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)
|
# listener_addr = forms.ChoiceField(required=True, widget=forms.RadioSelect, choices=QEMU_CONSOLE_LISTENER_ADDRESSES)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from libvirt import (VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_SNAPSHOT_
|
||||||
libvirtError)
|
libvirtError)
|
||||||
from vrtManager import util
|
from vrtManager import util
|
||||||
from vrtManager.connection import wvmConnect
|
from vrtManager.connection import wvmConnect
|
||||||
|
from vrtManager.create import wvmCreate
|
||||||
from vrtManager.instance import wvmInstance
|
from vrtManager.instance import wvmInstance
|
||||||
|
|
||||||
from accounts.models import UserInstance
|
from accounts.models import UserInstance
|
||||||
|
|
@ -281,3 +282,150 @@ class CloneInstance(Kind):
|
||||||
compute, _sep, source = op.resource_label.rpartition("/")
|
compute, _sep, source = op.resource_label.rpartition("/")
|
||||||
message = f"Create a clone of '{source}'"
|
message = f"Create a clone of '{source}'"
|
||||||
return compute, op.intent["name"], message if state == Operation.SUCCEEDED else f"{message} ({state})"
|
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})"
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,6 @@ class CreateInstance(models.Model):
|
||||||
virtio = models.BooleanField(default=True)
|
virtio = models.BooleanField(default=True)
|
||||||
qemu_ga = models.BooleanField(default=False)
|
qemu_ga = models.BooleanField(default=False)
|
||||||
mac = models.CharField(max_length=17, blank=True)
|
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_cdrom = models.CharField(max_length=16)
|
||||||
add_input = models.CharField(max_length=16)
|
add_input = models.CharField(max_length=16)
|
||||||
video = models.CharField(
|
video = models.CharField(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load operations %}
|
||||||
{% load bootstrap_icons %}
|
{% load bootstrap_icons %}
|
||||||
{% block title %}{% trans "Create new instance" %}{% endblock %}
|
{% block title %}{% trans "Create new instance" %}{% endblock %}
|
||||||
|
|
||||||
|
|
@ -97,7 +98,7 @@
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form method="post" role="form" aria-label="Create instance from flavor form">{% csrf_token %}
|
<form method="post" role="form" aria-label="Create instance from flavor form">{% csrf_token %}{% submission_key %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-4 col-form-label">{% trans "Name" %}</label>
|
<label class="col-sm-4 col-form-label">{% trans "Name" %}</label>
|
||||||
<div class="col-sm-7">
|
<div class="col-sm-7">
|
||||||
|
|
@ -240,12 +241,6 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<label class="col-sm-4 col-form-label">{% trans "Console Password" %}</label>
|
|
||||||
<div class="col-sm-7">
|
|
||||||
<input class="form-control" type="password" name="console_pass" placeholder="{% trans "Console Password" %}" maxlength="64">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-4 col-form-label">{% trans "Add CD-Rom" %}</label>
|
<label class="col-sm-4 col-form-label">{% trans "Add CD-Rom" %}</label>
|
||||||
<div class="col-sm-7">
|
<div class="col-sm-7">
|
||||||
|
|
@ -323,7 +318,7 @@
|
||||||
|
|
||||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="addCustom">
|
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="addCustom">
|
||||||
<div>
|
<div>
|
||||||
<form method="post" role="form" aria-label="Create custom instance form">{% csrf_token %}
|
<form method="post" role="form" aria-label="Create custom instance form">{% csrf_token %}{% submission_key %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
||||||
<div class="col-sm-7">
|
<div class="col-sm-7">
|
||||||
|
|
@ -493,12 +488,6 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Console Password" %}</label>
|
|
||||||
<div class="col-sm-7">
|
|
||||||
<input type="password" class="form-control" name="console_pass" placeholder="{% trans "Console Password" %}" maxlength="64">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Add CD-Rom" %}</label>
|
<label class="col-sm-3 col-form-label">{% trans "Add CD-Rom" %}</label>
|
||||||
<div class="col-sm-7">
|
<div class="col-sm-7">
|
||||||
|
|
@ -543,7 +532,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-grid col-sm-7 offset-3">
|
<div class="d-grid col-sm-7 offset-3">
|
||||||
{% if storages %}
|
{% if storages %}
|
||||||
<button type="submit" class="btn btn-primary" name="create" formnovalidate onclick="showPleaseWaitDialog()" value="1">
|
<button type="submit" class="btn btn-primary" name="create" formnovalidate value="1">
|
||||||
{% trans "Create" %}
|
{% trans "Create" %}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -560,7 +549,7 @@
|
||||||
|
|
||||||
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="add-from-temp">
|
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="add-from-temp">
|
||||||
<div>
|
<div>
|
||||||
<form method="post" role="form" aria-label="Create instance from template form">{% csrf_token %}
|
<form method="post" role="form" aria-label="Create instance from template form">{% csrf_token %}{% submission_key %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
||||||
<div class="col-sm-7">
|
<div class="col-sm-7">
|
||||||
|
|
@ -733,12 +722,6 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Console Password" %}</label>
|
|
||||||
<div class="col-sm-7">
|
|
||||||
<input type="password" class="form-control" name="console_pass" placeholder="{% trans "Console Password" %}" maxlength="64">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Add CD-Rom" %}</label>
|
<label class="col-sm-3 col-form-label">{% trans "Add CD-Rom" %}</label>
|
||||||
<div class="col-sm-7">
|
<div class="col-sm-7">
|
||||||
|
|
@ -783,7 +766,7 @@
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-grid col-sm-7 offset-3">
|
<div class="d-grid col-sm-7 offset-3">
|
||||||
{% if storages %}
|
{% if storages %}
|
||||||
<button type="submit" class="btn btn-primary" name="create" value="1" formnovalidate onclick="showPleaseWaitDialog()">
|
<button type="submit" class="btn btn-primary" name="create" value="1" formnovalidate>
|
||||||
{% trans "Create" %}
|
{% trans "Create" %}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,61 @@
|
||||||
"""Regression tests for the instance creation request."""
|
"""Regression tests for the instance creation request."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from computes.models import Compute
|
from computes.models import Compute
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from instances.api.serializers import CreateInstanceSerializer
|
from instances.api.serializers import CreateInstanceSerializer
|
||||||
|
from instances.jobs import CreateInstance
|
||||||
from instances.models import Instance
|
from instances.models import Instance
|
||||||
from instances.utils import nic_macs
|
from instances.utils import nic_macs
|
||||||
|
from operations.jobs import Context
|
||||||
|
from operations.models import Operation, acquire
|
||||||
|
|
||||||
|
|
||||||
|
wvm_create = MagicMock() # the page and the job reach the same host
|
||||||
|
|
||||||
|
|
||||||
|
def run(op):
|
||||||
|
"""The queued job, run as the worker runs it."""
|
||||||
|
acquire("wvc-test:1:a", "wvc-test")
|
||||||
|
Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING, worker="wvc-test:1:a")
|
||||||
|
op.refresh_from_db()
|
||||||
|
CreateInstance().run(op, Context(op, SimpleNamespace(identity="wvc-test:1:a")))
|
||||||
|
|
||||||
|
|
||||||
|
@patch("instances.jobs.wvmCreate", new=wvm_create)
|
||||||
class CreateInstanceViewTests(TestCase):
|
class CreateInstanceViewTests(TestCase):
|
||||||
@patch("instances.views.wvmCreate")
|
@patch("instances.views.wvmCreate", new=wvm_create)
|
||||||
@patch("instances.views.NewVMForm")
|
@patch("instances.views.NewVMForm")
|
||||||
def test_successful_create_redirects_after_saving_instance(self, form_class, connection_class):
|
def test_successful_create_redirects_to_the_compute_instances_with_a_queued_job(self, form_class):
|
||||||
response, conn = self.create(form_class, connection_class)
|
response, conn = self.create(form_class)
|
||||||
|
|
||||||
instance = Instance.objects.get(name="new-vm")
|
|
||||||
self.assertRedirects(
|
self.assertRedirects(
|
||||||
response, reverse("instances:instance", args=[instance.id]),
|
response, reverse("instances", args=[Compute.objects.get().id]),
|
||||||
fetch_redirect_response=False,
|
fetch_redirect_response=False,
|
||||||
)
|
)
|
||||||
|
op = Operation.objects.get()
|
||||||
|
self.assertEqual((op.kind, op.state), ("instance.create", Operation.QUEUED))
|
||||||
|
conn.create_instance.assert_not_called()
|
||||||
|
run(op)
|
||||||
conn.create_instance.assert_called_once()
|
conn.create_instance.assert_called_once()
|
||||||
|
self.assertEqual(conn.create_instance.call_args.kwargs["volumes"][0]["bus"], "virtio")
|
||||||
|
self.assertTrue(Instance.objects.filter(name="new-vm").exists())
|
||||||
|
|
||||||
@patch("instances.views.wvmCreate")
|
@patch("instances.views.wvmCreate", new=wvm_create)
|
||||||
@patch("instances.views.NewVMForm")
|
@patch("instances.views.NewVMForm")
|
||||||
def test_secure_boot_loader_on_q35_requests_secure_boot(self, form_class, connection_class):
|
def test_secure_boot_loader_on_q35_requests_secure_boot(self, form_class):
|
||||||
self.create(form_class, connection_class, firmware="UEFI x86_64: /usr/share/edk2/ovmf/OVMF_CODE.secboot.fd")
|
_, conn = self.create(form_class, firmware="UEFI x86_64: /usr/share/edk2/ovmf/OVMF_CODE.secboot.fd")
|
||||||
firmware = connection_class.return_value.create_instance.call_args.kwargs["firmware"]
|
run(Operation.objects.get())
|
||||||
|
firmware = conn.create_instance.call_args.kwargs["firmware"]
|
||||||
self.assertEqual(firmware["secure"], "yes")
|
self.assertEqual(firmware["secure"], "yes")
|
||||||
|
|
||||||
@patch("instances.views.wvmCreate")
|
@patch("instances.views.wvmCreate", new=wvm_create)
|
||||||
@patch("instances.views.NewVMForm")
|
@patch("instances.views.NewVMForm")
|
||||||
def test_invalid_devices_are_refused_before_anything_is_created(self, form_class, connection_class):
|
def test_invalid_devices_are_refused_before_anything_is_created(self, form_class):
|
||||||
for data in (
|
for data in (
|
||||||
{"add_cdrom": "virtio"}, # no ejectable media
|
{"add_cdrom": "virtio"}, # no ejectable media
|
||||||
{"add_cdrom": "fdc"},
|
{"add_cdrom": "fdc"},
|
||||||
|
|
@ -43,14 +65,13 @@ class CreateInstanceViewTests(TestCase):
|
||||||
):
|
):
|
||||||
with self.subTest(data):
|
with self.subTest(data):
|
||||||
Instance.objects.all().delete()
|
Instance.objects.all().delete()
|
||||||
response, conn = self.create(form_class, connection_class, **data)
|
response, conn = self.create(form_class, **data)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertFalse(Instance.objects.exists())
|
self.assertFalse(Operation.objects.exists())
|
||||||
conn.create_volume.assert_not_called()
|
conn.create_volume.assert_not_called()
|
||||||
conn.create_instance.assert_not_called()
|
conn.create_instance.assert_not_called()
|
||||||
connection_class.reset_mock()
|
|
||||||
|
|
||||||
def create(self, form_class, connection_class, **data):
|
def create(self, form_class, **data):
|
||||||
user = get_user_model().objects.filter(username="create-test-admin").first() or (
|
user = get_user_model().objects.filter(username="create-test-admin").first() or (
|
||||||
get_user_model().objects.create_superuser(
|
get_user_model().objects.create_superuser(
|
||||||
username="create-test-admin", password="password", email="create@example.com"
|
username="create-test-admin", password="password", email="create@example.com"
|
||||||
|
|
@ -61,7 +82,8 @@ class CreateInstanceViewTests(TestCase):
|
||||||
name="create-test-compute", hostname="localhost", type=4,
|
name="create-test-compute", hostname="localhost", type=4,
|
||||||
login="", password="",
|
login="", password="",
|
||||||
)
|
)
|
||||||
conn = connection_class.return_value
|
wvm_create.reset_mock()
|
||||||
|
conn = wvm_create.return_value
|
||||||
conn.get_instances.return_value = []
|
conn.get_instances.return_value = []
|
||||||
conn.get_video_models.return_value = ["vga"]
|
conn.get_video_models.return_value = ["vga"]
|
||||||
conn.get_cache_modes.return_value = {"default": "Default"}
|
conn.get_cache_modes.return_value = {"default": "Default"}
|
||||||
|
|
@ -86,7 +108,7 @@ class CreateInstanceViewTests(TestCase):
|
||||||
"firmware": "BIOS", "net_model": "virtio", "memory": 1024,
|
"firmware": "BIOS", "net_model": "virtio", "memory": 1024,
|
||||||
"vcpu": 1, "vcpu_mode": "host-model", "networks": "default",
|
"vcpu": 1, "vcpu_mode": "host-model", "networks": "default",
|
||||||
"virtio": True, "listener_addr": "0.0.0.0", "nwfilter": "",
|
"virtio": True, "listener_addr": "0.0.0.0", "nwfilter": "",
|
||||||
"graphics": "vnc", "video": "vga", "console_pass": "",
|
"graphics": "vnc", "video": "vga",
|
||||||
"mac": "52:54:00:00:00:01", "qemu_ga": False,
|
"mac": "52:54:00:00:00:01", "qemu_ga": False,
|
||||||
"add_cdrom": "sata", "add_input": "default",
|
"add_cdrom": "sata", "add_input": "default",
|
||||||
**data,
|
**data,
|
||||||
|
|
@ -94,7 +116,7 @@ class CreateInstanceViewTests(TestCase):
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse("instances:create_instance", args=[compute.id, "x86_64", "q35"]),
|
reverse("instances:create_instance", args=[compute.id, "x86_64", "q35"]),
|
||||||
{"create": "1"},
|
{"create": "1", "device0": "disk", "bus0": "virtio", "submission_key": "key-create01"},
|
||||||
)
|
)
|
||||||
return response, conn
|
return response, conn
|
||||||
|
|
||||||
|
|
@ -140,7 +162,7 @@ class NicMacsTestCase(TestCase):
|
||||||
"name": "api-vm", "vcpu": 1, "vcpu_mode": "host-model", "memory": 512,
|
"name": "api-vm", "vcpu": 1, "vcpu_mode": "host-model", "memory": 512,
|
||||||
"networks": "default", "mac": "52:54:00:00:00:01,52:54:00:00:00:02", "nwfilter": "",
|
"networks": "default", "mac": "52:54:00:00:00:01,52:54:00:00:00:02", "nwfilter": "",
|
||||||
"storage": "default", "hdd_size": 1, "cache_mode": "none", "meta_prealloc": False,
|
"storage": "default", "hdd_size": 1, "cache_mode": "none", "meta_prealloc": False,
|
||||||
"virtio": True, "qemu_ga": True, "console_pass": "", "video": "vga", "listener_addr": "0.0.0.0",
|
"virtio": True, "qemu_ga": True, "video": "vga", "listener_addr": "0.0.0.0",
|
||||||
})
|
})
|
||||||
self.assertFalse(serializer.is_valid())
|
self.assertFalse(serializer.is_valid())
|
||||||
self.assertIn("mac", serializer.errors)
|
self.assertIn("mac", serializer.errors)
|
||||||
|
|
|
||||||
78
instances/tests/test_create_job.py
Normal file
78
instances/tests/test_create_job.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""VM create runs as a job, from the page and from the API."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import libvirt
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TransactionTestCase
|
||||||
|
|
||||||
|
from appsettings.settings import get_settings
|
||||||
|
from computes.models import Compute
|
||||||
|
from instances.jobs import CreateInstance
|
||||||
|
from instances.models import Instance
|
||||||
|
from operations.jobs import Context
|
||||||
|
from operations.models import Operation, acquire
|
||||||
|
|
||||||
|
BODY = {"name": "api-vm", "vcpu": 1, "vcpu_mode": "host-model", "memory": 512, "networks": "default", "mac": "",
|
||||||
|
"nwfilter": "", "storage": "default", "hdd_size": 1, "cache_mode": "none", "meta_prealloc": False,
|
||||||
|
"virtio": True, "qemu_ga": True, "video": "vga", "listener_addr": "0.0.0.0", "submission_key": "key-api00001"}
|
||||||
|
|
||||||
|
|
||||||
|
class CreateJobTestCase(TransactionTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
get_settings()
|
||||||
|
admin = get_user_model().objects.create_superuser("create_admin", "a@example.com", "pw")
|
||||||
|
self.client.force_login(admin)
|
||||||
|
self.compute = Compute.objects.create(name="create-c", hostname="192.0.2.40", login="root", password="", type=1)
|
||||||
|
patcher = patch("instances.jobs.wvmCreate")
|
||||||
|
self.conn = patcher.start().return_value
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
self.conn.get_instances.return_value = []
|
||||||
|
self.conn.get_cache_modes.return_value = {"none": "Disable cache"}
|
||||||
|
self.conn.get_disk_bus_types.return_value = ["virtio", "sata"]
|
||||||
|
self.conn.create_volume.return_value = "/srv/default/api-vm.qcow2"
|
||||||
|
|
||||||
|
def test_the_api_queues_the_create_and_the_worker_defines_and_records_it(self):
|
||||||
|
url = f"/api/v1/computes/{self.compute.pk}/instances/create/x86_64/q35/"
|
||||||
|
response = self.client.post(url, BODY, content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 202)
|
||||||
|
op = Operation.objects.get()
|
||||||
|
self.assertEqual(response["Location"], f"/api/v1/operations/{op.pk}/")
|
||||||
|
self.conn.create_instance.assert_not_called()
|
||||||
|
|
||||||
|
acquire("wvc-test:1:a", "wvc-test")
|
||||||
|
Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING, worker="wvc-test:1:a")
|
||||||
|
op.refresh_from_db()
|
||||||
|
CreateInstance().run(op, Context(op, SimpleNamespace(identity="wvc-test:1:a")))
|
||||||
|
self.assertEqual(self.conn.create_instance.call_args.kwargs["uuid"], op.intent["uuid"])
|
||||||
|
self.assertTrue(Instance.objects.filter(uuid=op.intent["uuid"], name="api-vm").exists())
|
||||||
|
|
||||||
|
def test_a_refused_create_queues_nothing(self):
|
||||||
|
url = f"/api/v1/computes/{self.compute.pk}/instances/create/x86_64/q35/"
|
||||||
|
response = self.client.post(url, {**BODY, "cache_mode": "writeback"}, content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertFalse(Operation.objects.exists())
|
||||||
|
|
||||||
|
def test_a_resubmitted_key_with_other_fields_is_a_conflict(self):
|
||||||
|
url = f"/api/v1/computes/{self.compute.pk}/instances/create/x86_64/q35/"
|
||||||
|
self.client.post(url, BODY, content_type="application/json")
|
||||||
|
response = self.client.post(url, {**BODY, "memory": 1024}, content_type="application/json")
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
self.assertEqual(Operation.objects.count(), 1)
|
||||||
|
|
||||||
|
@patch("instances.jobs.wvmConnect")
|
||||||
|
def test_a_create_that_left_nothing_failed(self, conn):
|
||||||
|
op = Operation(params={**BODY, "template": ""},
|
||||||
|
intent={"compute": self.compute.pk, "uuid": "abababab-0000-1111-2222-333333333333",
|
||||||
|
"name": "api-vm"})
|
||||||
|
missing = libvirt.libvirtError("no domain")
|
||||||
|
missing.err = (libvirt.VIR_ERR_NO_DOMAIN, libvirt.VIR_FROM_QEMU, "no domain")
|
||||||
|
conn.return_value.wvm.lookupByUUIDString.side_effect = missing
|
||||||
|
pool = MagicMock()
|
||||||
|
pool.listVolumes.return_value = ["other.qcow2"]
|
||||||
|
conn.return_value.wvm.listAllStoragePools.return_value = [pool]
|
||||||
|
self.assertEqual(CreateInstance().reconcile(op).state, Operation.FAILED)
|
||||||
|
pool.listVolumes.return_value = ["api-vm.qcow2"]
|
||||||
|
self.assertEqual(CreateInstance().reconcile(op).state, Operation.UNCERTAIN)
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from appsettings.settings import get_settings
|
||||||
from computes.models import Compute
|
from computes.models import Compute
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
|
||||||
from libvirt import libvirtError
|
from libvirt import libvirtError
|
||||||
|
|
||||||
FORM = {
|
from instances.jobs import CreateInstance
|
||||||
|
from operations import jobs
|
||||||
|
from operations.jobs import Context
|
||||||
|
from operations.models import Operation, acquire
|
||||||
|
|
||||||
|
PARAMS = {
|
||||||
"name": "rb-vm", "firmware": "BIOS", "vcpu": 1, "vcpu_mode": "host-model", "memory": 128,
|
"name": "rb-vm", "firmware": "BIOS", "vcpu": 1, "vcpu_mode": "host-model", "memory": 128,
|
||||||
"hdd_size": 1, "storage": "default", "mac": "52:54:00:aa:04:01", "networks": "default",
|
"hdd_size": 1, "storage": "default", "mac": "52:54:00:aa:04:01", "networks": "default",
|
||||||
"nwfilter": "", "net_model": "virtio", "cache_mode": "none", "video": "vga",
|
"nwfilter": "", "net_model": "virtio", "cache_mode": "none", "video": "vga",
|
||||||
"listener_addr": "0.0.0.0", "console_pass": "", "add_cdrom": "None", "add_input": "None",
|
"listener_addr": "0.0.0.0", "add_cdrom": "None", "add_input": "None", "virtio": True, "qemu_ga": False,
|
||||||
"create": True,
|
"arch": "x86_64", "machine": "q35",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -19,30 +25,32 @@ class CreateRollbackTestCase(TestCase):
|
||||||
"""Volumes are removed only while the VM is not defined yet."""
|
"""Volumes are removed only while the VM is not defined yet."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
admin = get_user_model().objects.create_superuser("rb_admin", "r@example.com", "pw")
|
get_settings()
|
||||||
self.client.force_login(admin)
|
self.admin = get_user_model().objects.create_superuser("rb_admin", "r@example.com", "pw")
|
||||||
self.client.raise_request_exception = False
|
self.compute = Compute.objects.create(name="rb", hostname="192.0.2.41", login="", password="", type=4)
|
||||||
self.compute = Compute.objects.create(name="rb", hostname="127.0.0.1", login="", password="", type=4)
|
|
||||||
|
|
||||||
def post(self, **conn_setup):
|
def run_job(self, **conn_setup):
|
||||||
with patch("instances.views.wvmCreate") as wvm:
|
with patch("instances.jobs.wvmCreate") as wvm:
|
||||||
conn = wvm.return_value
|
conn = wvm.return_value
|
||||||
conn.get_cache_modes.return_value = {"none": "Disable cache"}
|
conn.get_cache_modes.return_value = {"none": "Disable cache"}
|
||||||
conn.get_instances.return_value = []
|
conn.get_instances.return_value = []
|
||||||
conn.get_storages.return_value = ["default"]
|
|
||||||
conn.get_networks.return_value = ["default"]
|
|
||||||
conn.create_volume.return_value = "/pool/rb-vm.qcow2"
|
conn.create_volume.return_value = "/pool/rb-vm.qcow2"
|
||||||
|
op = jobs.submit("instance.create", self.admin, {**PARAMS, "compute": self.compute.pk}, "key-rollback1")
|
||||||
|
acquire("wvc-test:1:a", "wvc-test")
|
||||||
|
Operation.objects.filter(pk=op.pk).update(state=Operation.RUNNING, worker="wvc-test:1:a")
|
||||||
|
op.refresh_from_db()
|
||||||
for attr, value in conn_setup.items():
|
for attr, value in conn_setup.items():
|
||||||
getattr(conn, attr).side_effect = value
|
getattr(conn, attr).side_effect = value
|
||||||
self.client.post(reverse("instances:create_instance", args=[self.compute.id, "x86_64", "q35"]), FORM)
|
with self.assertRaises(Exception):
|
||||||
|
CreateInstance().run(op, Context(op, SimpleNamespace(identity="wvc-test:1:a")))
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
def test_failed_definition_removes_the_new_volume(self):
|
def test_failed_definition_removes_the_new_volume(self):
|
||||||
conn = self.post(create_instance=libvirtError("rejected"))
|
conn = self.run_job(create_instance=libvirtError("rejected"))
|
||||||
conn.delete_volume.assert_called_once_with("/pool/rb-vm.qcow2")
|
conn.delete_volume.assert_called_once_with("/pool/rb-vm.qcow2")
|
||||||
|
|
||||||
def test_failure_after_the_definition_keeps_the_volume(self):
|
def test_failure_after_the_definition_keeps_the_volume(self):
|
||||||
with patch("instances.views.Instance.objects.get_or_create", side_effect=RuntimeError("db down")):
|
with patch("instances.jobs.Instance.objects.get_or_create", side_effect=RuntimeError("db down")):
|
||||||
conn = self.post()
|
conn = self.run_job()
|
||||||
conn.create_instance.assert_called_once()
|
conn.create_instance.assert_called_once()
|
||||||
conn.delete_volume.assert_not_called()
|
conn.delete_volume.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Live checks that a failed VM creation leaves no volumes behind.
|
"""Live checks of VM creation: the queued job defines the VM, and a failed
|
||||||
|
one leaves no volumes behind.
|
||||||
|
|
||||||
Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
Runs only with TEST_LIBVIRT_HOST set (see instances/tests/livetest.py).
|
||||||
"""
|
"""
|
||||||
|
|
@ -9,17 +10,22 @@ import unittest
|
||||||
import libvirt
|
import libvirt
|
||||||
from computes.models import Compute
|
from computes.models import Compute
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TransactionTestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from vrtManager.connection import connection_manager
|
from vrtManager.connection import connection_manager
|
||||||
|
|
||||||
|
from operations.models import Operation
|
||||||
|
from operations.tests import fakes
|
||||||
|
from operations.worker import Worker
|
||||||
|
|
||||||
from . import livetest
|
from . import livetest
|
||||||
|
from .test_live_jobs import wait_for
|
||||||
|
|
||||||
P = livetest.PREFIX
|
P = livetest.PREFIX
|
||||||
|
|
||||||
|
|
||||||
class LiveCreateTestCase(TestCase):
|
class LiveCreateTestCase(TransactionTestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
if not livetest.enabled():
|
if not livetest.enabled():
|
||||||
|
|
@ -70,15 +76,26 @@ class LiveCreateTestCase(TestCase):
|
||||||
"cache_mode": "none",
|
"cache_mode": "none",
|
||||||
"video": "vga",
|
"video": "vga",
|
||||||
"listener_addr": "0.0.0.0",
|
"listener_addr": "0.0.0.0",
|
||||||
"console_pass": "",
|
|
||||||
"add_cdrom": "None",
|
"add_cdrom": "None",
|
||||||
"add_input": "None",
|
"add_input": "None",
|
||||||
"create": True,
|
"create": True,
|
||||||
|
"submission_key": "key-" + name,
|
||||||
}
|
}
|
||||||
data.update(overrides)
|
data.update(overrides)
|
||||||
return self.client.post(
|
response = self.client.post(
|
||||||
reverse("instances:create_instance", args=[self.compute.id, "x86_64", "q35"]), data
|
reverse("instances:create_instance", args=[self.compute.id, "x86_64", "q35"]), data
|
||||||
)
|
)
|
||||||
|
self.work()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def work(self):
|
||||||
|
"""Run the queued jobs with an in-thread worker."""
|
||||||
|
worker = Worker(concurrency=1, tick=0.1, heartbeat_every=1, progress_every=1, exit_on_lost_lease=False)
|
||||||
|
thread = fakes.start_worker(worker)
|
||||||
|
try:
|
||||||
|
self.assertTrue(wait_for(lambda: not Operation.objects.filter(state__in=Operation.ACTIVE).exists(), 180))
|
||||||
|
finally:
|
||||||
|
fakes.stop_worker(worker, thread)
|
||||||
|
|
||||||
def volumes(self):
|
def volumes(self):
|
||||||
pool = self.conn.storagePoolLookupByName(livetest.POOL)
|
pool = self.conn.storagePoolLookupByName(livetest.POOL)
|
||||||
|
|
@ -184,15 +201,16 @@ class LiveCreateTestCase(TestCase):
|
||||||
"meta_prealloc": False,
|
"meta_prealloc": False,
|
||||||
"virtio": True,
|
"virtio": True,
|
||||||
"qemu_ga": False,
|
"qemu_ga": False,
|
||||||
"console_pass": "",
|
|
||||||
"listener_addr": "0.0.0.0",
|
"listener_addr": "0.0.0.0",
|
||||||
"video": "no-such-model",
|
"video": "no-such-model",
|
||||||
"add_cdrom": "None",
|
"add_cdrom": "None",
|
||||||
"add_input": "None",
|
"add_input": "None",
|
||||||
|
"submission_key": "key-cr-api",
|
||||||
},
|
},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400, response.content)
|
self.assertEqual(response.status_code, 202, response.content)
|
||||||
|
self.work()
|
||||||
self.assertEqual(self.volumes(), set(), "a volume was left behind")
|
self.assertEqual(self.volumes(), set(), "a volume was left behind")
|
||||||
self.assertFalse(self.domain_exists(P + "cr-api"))
|
self.assertFalse(self.domain_exists(P + "cr-api"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ from django.test import RequestFactory, TestCase
|
||||||
from vrtManager.create import wvmCreate
|
from vrtManager.create import wvmCreate
|
||||||
from vrtManager.util import randomUUID
|
from vrtManager.util import randomUUID
|
||||||
|
|
||||||
|
from operations.models import Operation
|
||||||
|
|
||||||
from . import livetest
|
from . import livetest
|
||||||
|
from .test_create_instance import run
|
||||||
from instances.models import Flavor, Instance
|
from instances.models import Flavor, Instance
|
||||||
from instances.utils import refr
|
from instances.utils import refr
|
||||||
|
|
||||||
|
|
@ -193,15 +196,16 @@ class InstancesTestCase(TestCase):
|
||||||
"graphics": "vnc",
|
"graphics": "vnc",
|
||||||
"video": "vga",
|
"video": "vga",
|
||||||
"listener_addr": "0.0.0.0",
|
"listener_addr": "0.0.0.0",
|
||||||
"console_pass": "",
|
|
||||||
"qemu_ga": False,
|
"qemu_ga": False,
|
||||||
"virtio": True,
|
"virtio": True,
|
||||||
"add_cdrom": "None",
|
"add_cdrom": "None",
|
||||||
"add_input": "None",
|
"add_input": "None",
|
||||||
"create": True,
|
"create": True,
|
||||||
|
"submission_key": "key-create-destroy",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
run(Operation.objects.get(kind="instance.create"))
|
||||||
|
|
||||||
instance_qs: Instance = Instance.objects.filter(name="wvc-test-new")
|
instance_qs: Instance = Instance.objects.filter(name="wvc-test-new")
|
||||||
self.assertEqual(len(instance_qs), 1)
|
self.assertEqual(len(instance_qs), 1)
|
||||||
|
|
|
||||||
|
|
@ -2162,164 +2162,29 @@ def create_instance(request, compute_id, arch, machine):
|
||||||
form = NewVMForm(request.POST)
|
form = NewVMForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
meta_prealloc = bool(data["meta_prealloc"])
|
|
||||||
|
|
||||||
# Everything is checked before any storage is allocated.
|
|
||||||
if data["name"] in 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 data["hdd_size"] and not data["mac"]:
|
if data["hdd_size"] and not data["mac"]:
|
||||||
raise util.OperationError(_("No Virtual Machine MAC has been entered"))
|
raise util.OperationError(_("No Virtual Machine MAC has been entered"))
|
||||||
if not (data["hdd_size"] or data["template"] or data["images"]):
|
images = data["images"].split(",") if data["images"] else []
|
||||||
raise util.OperationError(_("First you need to create or select an image"))
|
raw = {**data, "compute": compute.id, "arch": arch, "machine": machine,
|
||||||
if (
|
"devices": [[request.POST.get(f"device{i}", ""), request.POST.get(f"bus{i}", "")]
|
||||||
not data["hdd_size"]
|
for i in range(len(images))]}
|
||||||
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:
|
try:
|
||||||
utils.nic_macs(data["mac"], data["networks"])
|
op = jobs.submit("instance.create", request.user, raw,
|
||||||
except ValueError as err:
|
request.POST.get("submission_key", ""))
|
||||||
raise util.OperationError(str(err))
|
except claims.Busy as busy:
|
||||||
|
messages.error(request, busy_message(busy, request.user))
|
||||||
firmware = dict()
|
except jobs.SubmissionConflict as err:
|
||||||
if "UEFI" in data["firmware"]:
|
messages.error(request, str(err))
|
||||||
if ":" not in data["firmware"]:
|
|
||||||
raise util.OperationError(_("Invalid firmware"))
|
|
||||||
firmware["loader"] = data["firmware"].split(":")[1].strip()
|
|
||||||
firmware["secure"] = "no"
|
|
||||||
firmware["readonly"] = "yes"
|
|
||||||
firmware["type"] = "pflash"
|
|
||||||
if "secboot" in firmware["loader"]:
|
|
||||||
# A secure boot loader needs secure='yes' and SMM, on q35 only
|
|
||||||
if machine != "q35":
|
|
||||||
messages.warning(
|
|
||||||
request,
|
|
||||||
"Changing machine type from '%s' to 'q35' "
|
|
||||||
"which is required for UEFI secure boot." % machine,
|
|
||||||
)
|
|
||||||
machine = "q35"
|
|
||||||
firmware["secure"] = "yes"
|
|
||||||
|
|
||||||
if data["net_model"] == "default":
|
|
||||||
data["net_model"] = "virtio"
|
|
||||||
|
|
||||||
def volume(path, device="disk", bus=default_bus):
|
|
||||||
return {
|
|
||||||
"path": path,
|
|
||||||
"type": conn.get_volume_format_type(path),
|
|
||||||
"device": device,
|
|
||||||
"bus": bus,
|
|
||||||
"scsi_model": default_scsi_disk_model if bus == "scsi" else None,
|
|
||||||
"cache_mode": data["cache_mode"],
|
|
||||||
"discard_mode": default_discard,
|
|
||||||
"detect_zeroes_mode": default_zeroes,
|
|
||||||
"io_mode": default_io,
|
|
||||||
}
|
|
||||||
|
|
||||||
created = [] # volumes this request allocated
|
|
||||||
defined = False
|
|
||||||
volume_list = []
|
|
||||||
uuid = util.randomUUID()
|
|
||||||
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 idx, vol in enumerate(data["images"].split(",")):
|
|
||||||
volume_list.append(volume(
|
|
||||||
conn.get_volume_path(vol),
|
|
||||||
request.POST.get("device" + str(idx), ""),
|
|
||||||
request.POST.get("bus" + str(idx), ""),
|
|
||||||
))
|
|
||||||
|
|
||||||
with libvirt_compute_lock(compute):
|
|
||||||
conn.create_instance(
|
|
||||||
name=data["name"],
|
|
||||||
memory=data["memory"],
|
|
||||||
vcpu=data["vcpu"],
|
|
||||||
vcpu_mode=data["vcpu_mode"],
|
|
||||||
uuid=uuid,
|
|
||||||
arch=arch,
|
|
||||||
machine=machine,
|
|
||||||
firmware=firmware,
|
|
||||||
volumes=volume_list,
|
|
||||||
networks=data["networks"],
|
|
||||||
virtio=data["virtio"],
|
|
||||||
listener_addr=data["listener_addr"],
|
|
||||||
nwfilter=data["nwfilter"],
|
|
||||||
net_model=data["net_model"],
|
|
||||||
video=data["video"],
|
|
||||||
console_pass=data["console_pass"],
|
|
||||||
mac=data["mac"],
|
|
||||||
qemu_ga=data["qemu_ga"],
|
|
||||||
add_cdrom=data["add_cdrom"],
|
|
||||||
add_input=data["add_input"],
|
|
||||||
)
|
|
||||||
defined = True # from here on the VM uses the volumes
|
|
||||||
create_instance = Instance.objects.get_or_create(
|
|
||||||
compute_id=compute_id, uuid=uuid, defaults={"name": data["name"]}
|
|
||||||
)[0]
|
|
||||||
if create_instance.name != data["name"]:
|
|
||||||
create_instance.name = data["name"]
|
|
||||||
create_instance.save(update_fields=["name"])
|
|
||||||
except Exception as err:
|
|
||||||
leftovers = []
|
|
||||||
for path in [] if defined else created:
|
|
||||||
try:
|
|
||||||
conn.delete_volume(path)
|
|
||||||
except libvirtError:
|
|
||||||
leftovers.append(path)
|
|
||||||
if leftovers:
|
|
||||||
messages.error(
|
|
||||||
request,
|
|
||||||
_("Remove these volumes by hand: %(paths)s") % {"paths": ", ".join(leftovers)},
|
|
||||||
)
|
|
||||||
if not isinstance(err, libvirtError):
|
|
||||||
raise # unexpected: the volumes are gone, the error stays visible
|
|
||||||
messages.error(request, err)
|
|
||||||
else:
|
else:
|
||||||
msg = _("Instance is created")
|
if op.params["machine"] != machine:
|
||||||
messages.success(request, msg)
|
messages.warning(
|
||||||
addlogmsg(
|
request,
|
||||||
request.user.username,
|
"Changing machine type from '%s' to 'q35' "
|
||||||
create_instance.compute.name,
|
"which is required for UEFI secure boot." % machine,
|
||||||
create_instance.name,
|
)
|
||||||
msg,
|
messages.info(request, _("The VM is being created; follow it in the job panel."))
|
||||||
)
|
jobs.remember_job(request, op)
|
||||||
return redirect(reverse("instances:instance", args=[create_instance.id]))
|
return redirect(reverse("instances", args=[compute.id]))
|
||||||
conn.close()
|
conn.close()
|
||||||
except libvirtError as lib_err:
|
except libvirtError as lib_err:
|
||||||
messages.error(request, lib_err)
|
messages.error(request, lib_err)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from django.http import JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from libvirt import libvirtError
|
from libvirt import libvirtError
|
||||||
from operations import claims
|
from operations import claims, jobs
|
||||||
from operations.utils import busy_message, public_error_text, summary
|
from operations.utils import busy_message, public_error_text, summary
|
||||||
|
|
||||||
API_PREFIX = "/api/"
|
API_PREFIX = "/api/"
|
||||||
|
|
@ -37,6 +37,8 @@ class ExceptionMiddleware:
|
||||||
return JsonResponse({"detail": busy_message(exception, request.user),
|
return JsonResponse({"detail": busy_message(exception, request.user),
|
||||||
"operation": summary(exception.blocker, request.user) if exception.blocker else None},
|
"operation": summary(exception.blocker, request.user) if exception.blocker else None},
|
||||||
status=409)
|
status=409)
|
||||||
|
if isinstance(exception, jobs.SubmissionConflict):
|
||||||
|
return JsonResponse({"detail": str(exception)}, status=409)
|
||||||
if isinstance(exception, TimeoutError):
|
if isinstance(exception, TimeoutError):
|
||||||
return JsonResponse({"detail": "Operation is busy. Please retry."}, status=409)
|
return JsonResponse({"detail": "Operation is busy. Please retry."}, status=409)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue