1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/instances/views.py
catborise ab5ed59ea1 feat(instances): a new VM is made on one form in three steps
The create page had three tabs (flavor, custom, template), each with its
own copy of the same create form; every flavor rendered one more copy in
a dialog, and fixes had to be made in each copy. All of them posted the
same form to the same job, differing only in where the disk comes from.

The page is now one form in sections: name and size, disk, networks and
an Advanced section. The disk section offers three sources as radio
cards (a new disk, existing volumes, a copy of a template); only the
chosen source's fields are shown, and the others are disabled, so they
are neither sent nor validated. A template's copy goes into the
template's pool unless another is picked. Flavors are presets that fill
vCPU, memory and disk size and pick a new disk; they are added and
deleted in a dialog outside the form. Every source uses the
per-interface network rows, so a new disk no longer requires a MAC:
libvirt gives an interface without one its address, as it did for the
other sources. A refused form comes back with its source, volumes,
template and networks, and the focus on its first error. Without a
storage pool or a network the page shows why instead of the form, and a
host that cannot be read is a message, not a 500.

The host dialog and the machine page share the form's look: hosts are
rows with their CPU and memory use, the machine page is a card with
Cancel/Next and the XML way is a collapsible section. All three steps
show the same step indicator, done steps linking back.

The create job refuses more than one disk source (the page sends one; a
crafted request could send two, and the job would have used the first).
A field with help text is described by its help and its error.

Frontend review fixes: the presets and the disclosure buttons show a
focus ring for keyboard users; a disclosure spans its card and its
chevron turns while open; a preset says it picks a new disk; numeric
fields bring a numeric keyboard and MAC fields no autocorrection; the
machine page's Next waits for a machine type. Tom Select's control
keeps the text colour of the select it replaces: its own fixed colour
made typed text invisible on dark Bootswatch themes, on every page that
uses it.
2026-10-23 23:26:28 +03:00

2201 lines
82 KiB
Python
Executable file

try:
import crypt_r as crypt
except ImportError:
import crypt
import functools
import json
import os
import re
import socket
import subprocess
import time
from bisect import insort
from accounts.models import UserInstance, UserSSHKey
from admin.decorators import superuser_only
from appsettings.models import AppSettings
from appsettings.settings import app_settings
from computes.models import Compute
from computes.validators import validate_hostname, validate_login
from computes.utils import libvirt_compute_lock, libvirt_instance_lock, user_quota_lock
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.models import User
from django.core import signing
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import Http404, HttpResponse, JsonResponse
from django.db.models import prefetch_related_objects
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_noop as _
from django.views.decorators.http import require_POST
from libvirt import (VIR_DOMAIN_UNDEFINE_KEEP_NVRAM,
VIR_DOMAIN_UNDEFINE_MANAGED_SAVE,
VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA,
VIR_DOMAIN_UNDEFINE_NVRAM,
VIR_ERR_NO_SUPPORT,
libvirtError)
from logs.audit import audit, changed_fields, failed
from logs.audit import snapshot as audit_snapshot
from logs.views import addlogmsg
from operations import claims, jobs
from operations.utils import busy_message, summary
from webvirtcloud.middleware import error_text, same_origin_referer
from vrtManager import util
from vrtManager.create import wvmCreate
from vrtManager.instance import USAGE_MAX_WINDOW
from vrtManager.interface import wvmInterface
from vrtManager.storage import wvmStorage
from vrtManager.util import randomPasswd
from instances.models import Instance
from . import utils
from .forms import ConsoleForm, FlavorForm, NewVMForm
from .models import Flavor
# the VM page waits this long (seconds) for the compute's drbdadm status
DRBD_STATUS_TIMEOUT = 10
# the stats tab sends the sample of each answer back with its next poll
STATS_SAMPLE_SIGNER = signing.TimestampSigner(salt="instances.stats-sample")
def index(request):
instances = None
computes = list(Compute.objects.all().order_by("name"))
for compute in computes:
utils.refr(compute)
prefetch_related_objects(computes, "instance_set__userinstance_set")
show_hosts = request.user.is_superuser or request.user.has_perm("instances.view_instances")
own_hosts_down = False
if show_hosts:
instances = Instance.objects.all().prefetch_related("userinstance_set")
else:
instances = Instance.objects.filter(
userinstance__user=request.user
).prefetch_related("userinstance_set")
# other users get no host names or connection errors, only whether
# a host of their own VMs is down
own = set(instances.values_list("compute_id", flat=True))
own_hosts_down = any(c.status is not True for c in computes if c.pk in own)
return render(
request,
"allinstances.html",
{"computes": computes, "instances": instances, "show_hosts": show_hosts, "own_hosts_down": own_hosts_down},
)
def instance(request, pk):
instance: Instance = get_instance(request.user, pk)
compute: Compute = instance.compute
# the page reads the VM from libvirt throughout; without the host it would
# be the error page, with a connection error that names the host
if compute.status is not True:
if request.user.is_superuser or request.user.has_perm("instances.view_instances"):
msg = _("The host %(host)s of '%(name)s' cannot be reached: %(error)s") % {
"host": compute.name,
"name": instance.name,
"error": compute.connection_error,
}
else:
msg = _("The host of '%(name)s' cannot be reached right now.") % {"name": instance.name}
messages.error(request, msg + _(" Jobs on it can be followed under Jobs in the menu."))
return redirect(reverse("instances:index"))
# The page only reads the VM, and its getters read the same XML some 30
# times: libvirt is asked once per kind of XML.
with instance.proxy.cached_reads():
return _instance_page(request, pk, instance, compute)
def _instance_page(request, pk, instance, compute):
computes = Compute.objects.all().order_by("name")
computes_count = computes.count()
users = User.objects.all().order_by("username")
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
keymaps = settings.QEMU_KEYMAPS
# The console form renders the VNC password, so only users who may manage
# console settings get the form (and the password) at all.
can_manage_console = utils.can_manage_console(request.user, instance)
console_form = None
if can_manage_console:
console_form = ConsoleForm(
initial={
"console_type": instance.console_type,
"listen_on": instance.config_console_listener_address,
"password": instance.config_console_passwd,
"keymap": instance.config_console_keymap,
},
can_set_listener=request.user.is_superuser,
)
console_listener_addresses = settings.QEMU_CONSOLE_LISTENER_ADDRESSES
bottom_bar = app_settings.VIEW_INSTANCE_DETAIL_BOTTOM_BAR
try:
userinstance = UserInstance.objects.get(
instance__compute_id=compute.id,
instance__name=instance.name,
user__id=request.user.id,
)
except UserInstance.DoesNotExist:
userinstance = None
# The page offers only what the backend allows this user (get_instance
# perm_type="change", utils.can_open_console); is_staff and the global
# view_instances permission are read-only.
can_open_console = utils.can_open_console(request.user, instance)
can_power = request.user.is_superuser or userinstance is not None
# get_instance(perm_type="delete"): a template only for superusers and staff owners
can_delete = request.user.is_superuser or bool(
userinstance and userinstance.is_delete and (request.user.is_staff or not instance.is_template)
)
# get_instance(perm_type="change"): a template only for superusers and staff owners
can_change = request.user.is_superuser or bool(
userinstance and userinstance.is_change and (request.user.is_staff or not instance.is_template)
)
can_clone = request.user.has_perm("instances.clone_instances") and (instance.is_template or can_change)
can_snapshot = can_change and request.user.has_perm("instances.snapshot_instances")
show_settings = (
request.user.is_superuser or can_manage_console or can_clone or can_change or instance.guest_agent_ready
)
memory_range = [256, 512, 768, 1024, 2048, 3072, 4096, 6144, 8192, 16384]
if instance.memory not in memory_range:
insort(memory_range, instance.memory)
if instance.cur_memory not in memory_range:
insort(memory_range, instance.cur_memory)
clone_free_names = utils.get_clone_free_names()
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_cache = app_settings.INSTANCE_VOLUME_DEFAULT_CACHE
default_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)
# clone_instance_auto_name = app_settings.CLONE_INSTANCE_AUTO_NAME
# try:
# instance = Instance.objects.get(compute=compute, name=vname)
# if instance.uuid != uuid:
# instance.uuid = uuid
# instance.save()
# msg = _(f"Fixing UUID {uuid}")
# addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
# except Instance.DoesNotExist:
# instance = Instance(compute=compute, name=vname, uuid=uuid)
# instance.save()
# msg = _("Instance does not exist: Creating new instance")
# addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
# userinstances = UserInstance.objects.filter(instance=instance).order_by('user__username')
userinstances = instance.userinstance_set.order_by("user__username")
# Host resources
vcpu_host = len(instance.vcpu_range)
memory_host = instance.proxy.get_max_memory()
bus_host = instance.proxy.get_disk_bus_types(instance.arch, instance.machine)
networks_host = sorted(instance.proxy.get_networks())
interfaces_host = sorted(instance.proxy.get_ifaces())
nwfilters_host = instance.proxy.get_nwfilters()
storages_host = sorted(instance.proxy.get_storages(True))
net_models_host = instance.proxy.get_network_models()
if app_settings.VM_DRBD_STATUS == "True":
instance.drbd = drbd_status(request, pk)
instance.save()
job_resource = claims.instance_key(instance.uuid)
return render(request, "instance.html", locals(),)
def status(request, pk):
instance = get_instance(request.user, pk)
return JsonResponse({"status": instance.proxy.get_status()})
def _valid_ssh_target(compute):
"""The login and hostname reach ssh as a command-line argument; a compute
whose values would inject an ssh option (an existing or bypassed row that
the field validators did not cover) is skipped rather than run."""
try:
validate_login(compute.login)
validate_hostname(compute.hostname)
except ValidationError:
return False
return True
def drbd_status(request, pk):
instance = get_instance(request.user, pk)
result = "None DRBD"
if instance.compute.type == 2 and _valid_ssh_target(instance.compute):
conn = instance.compute.login + "@" + instance.compute.hostname
try:
remoteDrbdStatus = subprocess.run(
["ssh", "--", conn, "sudo", "/usr/sbin/drbdadm", "status"],
stdout=subprocess.PIPE,
text=True,
timeout=DRBD_STATUS_TIMEOUT,
)
except subprocess.TimeoutExpired:
return result
if remoteDrbdStatus.stdout:
instanceFindDrbd = re.compile(
re.escape(instance.name) + "[_]*[A-Z]* role:(.+?)\n disk:(.+?)\n",
re.IGNORECASE,
)
instanceDrbd = instanceFindDrbd.findall(remoteDrbdStatus.stdout)
primaryCount = 0
secondaryCount = 0
statusDisk = "OK"
for disk in instanceDrbd:
if disk[0] == "Primary":
primaryCount = primaryCount + 1
elif disk[0] == "Secondary":
secondaryCount = secondaryCount + 1
if disk[1] != "UpToDate":
statusDisk = "NOK"
if primaryCount > 0 and secondaryCount > 0:
statusRole = "NOK"
else:
if primaryCount > secondaryCount:
statusRole = "Primary"
else:
statusRole = "Secondary"
result = statusRole + "/" + statusDisk
return result
def _stats_sample(instance, token):
"""The usage sample in a token of this VM's stats on its current compute,
or None when there is none, it is altered or too old."""
if not token:
return None
try:
data = STATS_SAMPLE_SIGNER.unsign_object(token, max_age=USAGE_MAX_WINDOW)
except signing.BadSignature:
return None
if data.get("vm") != [instance.id, instance.compute_id, instance.uuid]:
return None
return data["sample"]
def stats(request, pk):
instance = get_instance(request.user, pk)
usage = instance.proxy.usage(_stats_sample(instance, request.GET.get("sample")))
mem_usage = instance.proxy.mem_usage()
def mega(value):
return None if value is None else value / 10**6
result = {
"cpudata": None if usage["cpu"] is None else round(usage["cpu"], 1),
"memdata": mem_usage,
# MB/s and Mbit/s
"blkdata": [{"dev": disk["dev"], "data": [mega(disk["rd"]), mega(disk["wr"])]} for disk in usage["disks"]],
"netdata": [{"dev": nic["dev"], "data": [mega(nic["rx"]), mega(nic["tx"])]} for nic in usage["nics"]],
"timeline": time.strftime("%H:%M:%S"),
}
if "sample" in usage:
result["window"] = None if usage["window"] is None else round(usage["window"], 1)
result["sample"] = STATS_SAMPLE_SIGNER.sign_object(
{"vm": [instance.id, instance.compute_id, instance.uuid], "sample": usage["sample"]}, compress=True
)
return JsonResponse(result)
def osinfo(request, pk):
instance = get_instance(request.user, pk)
results = instance.proxy.osinfo()
return JsonResponse(results)
@superuser_only
def guess_mac_address(request, vname):
data = {"vname": vname}
mac = utils.get_dhcp_mac_address(vname)
if not mac:
mac = utils.get_random_mac_address()
data["mac"] = mac
return JsonResponse(data)
@superuser_only
def random_mac_address(request):
data = dict()
data["mac"] = utils.get_random_mac_address()
return JsonResponse(data)
@superuser_only
def guess_clone_name(request):
dhcp_file = utils.dhcpd_conf()
prefix = app_settings.CLONE_INSTANCE_DEFAULT_PREFIX
if os.path.isfile(dhcp_file):
instance_names = [
i.name for i in Instance.objects.filter(name__startswith=prefix)
]
with open(dhcp_file, "r") as f:
for line in f:
line = line.strip()
if f"host {prefix}" in line:
fqdn = line.split(" ")[1]
hostname = fqdn.split(".")[0]
if hostname.startswith(prefix) and hostname not in instance_names:
return JsonResponse({"name": hostname})
return JsonResponse({})
def sshkeys(request, pk):
"""
:param request:
:param vname:
:return:
"""
instance = get_instance(request.user, pk)
instance_keys = []
userinstances = UserInstance.objects.filter(instance=instance)
for ui in userinstances:
keys = UserSSHKey.objects.filter(user=ui.user)
for k in keys:
instance_keys.append(k.keypublic)
if request.GET.get("plain", ""):
response = "\n".join(instance_keys)
response += "\n"
return HttpResponse(response, content_type="text/plain; charset=utf-8")
return JsonResponse(instance_keys, safe=False)
def get_safe_redirect(request, default=None):
return redirect(same_origin_referer(request, default or reverse("instances:index")))
def _is_int(value):
try:
int(value)
except (TypeError, ValueError):
return False
return True
def _back(request, pk, tab):
"""Back to the page the form was on, at the given tab; the VM page if
the Referer is missing or from another site."""
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#" + tab)
def get_instance(user, pk, perm_type="view"):
"""
Check that instance is available for user, if not raise 404 or PermissionDenied.
perm_type:
- 'view': superuser, has_perm("instances.view_instances"), or UserInstance owner
- 'power': superuser or UserInstance owner
- 'change': superuser or (UserInstance owner and is_change)
- 'delete': superuser or (UserInstance owner and is_delete)
A template is changed or deleted only by superusers and staff owners.
"""
valid_perms = {"view", "power", "change", "delete"}
if perm_type not in valid_perms:
raise PermissionDenied
instance = get_object_or_404(Instance, pk=pk)
user_instances = user.userinstance_set.filter(instance=instance)
has_owner_rel = user_instances.exists()
if not (
user.is_superuser
or user.has_perm("instances.view_instances")
or has_owner_rel
):
raise Http404()
if user.is_superuser or perm_type == "view":
return instance
user_inst = user_instances.first()
if perm_type == "power":
if not has_owner_rel:
raise PermissionDenied
elif perm_type == "change":
# A template is changed only by superusers (above) and staff owners
if not (user_inst and user_inst.is_change) or (instance.is_template and not user.is_staff):
raise PermissionDenied
elif perm_type == "delete":
if not (user_inst and user_inst.is_delete) or (instance.is_template and not user.is_staff):
raise PermissionDenied
else:
raise PermissionDenied
return instance
def _busy(request, pk, busy):
if hasattr(request, "accepted_renderer"):
blocker = busy.blocker
return JsonResponse({"detail": busy_message(busy, request.user),
"operation": summary(blocker, request.user) if blocker else None}, status=409)
messages.error(request, busy_message(busy, request.user))
return get_safe_redirect(request, default=reverse("instances:instance", args=[pk]))
def _moved(request, pk):
if hasattr(request, "accepted_renderer"):
return JsonResponse({"detail": "The VM was moved meanwhile; try again."}, status=409)
messages.error(request, _("The VM was moved meanwhile; try again."))
return get_safe_redirect(request, default=reverse("instances:instance", args=[pk]))
def _moved_meanwhile(pk, inst):
"""A migration may have moved the VM before the request took its
compute's claim: the claim held is then the old compute's. The retry locks
the new one (or finds the record merged away)."""
return not Instance.objects.filter(pk=pk, compute_id=inst.compute_id).exists()
def serialize_instance_mutation(func):
"""Serialize one VM, after checking visibility, without blocking sibling VMs."""
@functools.wraps(func)
def wrapper(request, pk, *args, **kwargs):
inst = get_instance(request.user, pk)
try:
with libvirt_instance_lock(inst):
if _moved_meanwhile(pk, inst):
return _moved(request, pk)
return func(request, pk, *args, **kwargs)
except claims.Busy as busy:
return _busy(request, pk, busy)
return wrapper
def quota_refused(quota_msg, refused):
"""The message for a refused change: refused is the action's own
'User %(quota_msg)s quota reached, ...' text, already formatted."""
if quota_msg == utils.QUOTA_UNVERIFIED:
return _("The quota cannot be checked because a host of your virtual machines cannot be reached. Nothing was changed.")
return refused
def serialize_user_quota(func):
"""Outermost lock for quota-checked changes: concurrent requests of one
user (also on different computes) must not pass the check together."""
@functools.wraps(func)
def wrapper(request, pk, *args, **kwargs):
if request.user.is_superuser: # quotas do not apply
return func(request, pk, *args, **kwargs)
try:
with user_quota_lock(request.user):
return func(request, pk, *args, **kwargs)
except claims.Busy as busy:
return _busy(request, pk, busy)
return wrapper
def serialize_compute_mutation(func):
"""Hold the whole compute: disk deletions must not race another VM attaching that disk."""
@functools.wraps(func)
def wrapper(request, pk, *args, **kwargs):
inst = get_instance(request.user, pk)
if request.method in ("GET", "HEAD"): # e.g. the destroy confirmation page
return func(request, pk, *args, **kwargs)
try:
with libvirt_compute_lock(inst.compute):
if _moved_meanwhile(pk, inst):
return _moved(request, pk)
return func(request, pk, *args, **kwargs)
except claims.Busy as busy:
return _busy(request, pk, busy)
return wrapper
DISK_FORMAT_RE = re.compile(r"^[a-z0-9]+$")
DISK_SERIAL_RE = re.compile(r"^[A-Za-z0-9_.+-]*$")
VOLUME_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.+-]*$")
def invalid_disk_options(
instance,
bus=None,
cache=None,
io=None,
discard=None,
zeroes=None,
format=None,
serial=None,
):
"""
Return the names of the given disk options whose values are not allowed.
These values end up in libvirt disk XML, so they must come from the
hypervisor's own lists (or match a strict pattern). None means "not given".
"""
proxy = instance.proxy
checks = {
"bus": (
bus,
lambda v: v in proxy.get_disk_bus_types(instance.arch, instance.machine),
),
"cache": (cache, lambda v: v in proxy.get_cache_modes()),
"io": (io, lambda v: v in proxy.get_io_modes()),
"discard": (discard, lambda v: v in proxy.get_discard_modes()),
"detect_zeroes": (zeroes, lambda v: v in proxy.get_detect_zeroes_modes()),
"format": (format, lambda v: bool(DISK_FORMAT_RE.fullmatch(v))),
"serial": (serial, lambda v: bool(DISK_SERIAL_RE.fullmatch(v))),
}
return [
name
for name, (value, is_valid) in checks.items()
if value is not None and not is_valid(value)
]
def reject_disk_options(request, pk, invalid):
messages.error(
request,
_("Invalid disk options: %(options)s") % {"options": ", ".join(invalid)},
)
return _back(request, pk, "disks")
@require_POST
@serialize_instance_mutation
def poweron(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
if instance.is_template:
messages.warning(request, _("Templates cannot be started."))
else:
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power On")
)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@serialize_instance_mutation
def powercycle(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
if instance.is_template:
messages.warning(request, _("Templates cannot be started."))
else:
instance.proxy.force_shutdown()
instance.proxy.start()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Cycle")
)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@serialize_instance_mutation
def poweroff(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.shutdown()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Power Off")
)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@superuser_only
@serialize_instance_mutation
def suspend(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.suspend()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Suspend"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@superuser_only
@serialize_instance_mutation
def resume(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.resume()
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Resume"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@require_POST
@serialize_instance_mutation
def force_off(request, pk):
instance = get_instance(request.user, pk, perm_type="power")
instance.proxy.force_shutdown()
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Force Off")
)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id])
)
@serialize_compute_mutation
def destroy(request, pk):
if request.method in ["POST", "DELETE"]:
instance = get_instance(request.user, pk, perm_type="delete")
with libvirt_instance_lock(instance):
proxy = instance.proxy
# Paused and other active states must stop too, or the VM keeps
# running as a transient domain after the undefine.
if proxy.instance.isActive():
proxy.force_shutdown()
to_delete, shared = [], []
if request.POST.get("delete_disk", ""):
to_delete, shared = proxy.split_disk_paths_by_use()
# Undefine before deleting any disk: if it fails, nothing is lost.
# Snapshot metadata goes with the domain; internal snapshot data
# stays in the disk images that are kept.
flags = VIR_DOMAIN_UNDEFINE_MANAGED_SAVE | VIR_DOMAIN_UNDEFINE_SNAPSHOTS_METADATA
if request.POST.get("delete_nvram", ""):
flags |= VIR_DOMAIN_UNDEFINE_NVRAM
else:
flags |= VIR_DOMAIN_UNDEFINE_KEEP_NVRAM
proxy.delete(flags)
instance.delete()
for path in to_delete:
try:
proxy.get_volume_by_path(path).delete(0)
except libvirtError as err:
messages.error(request, _("Disk %(path)s was not deleted: %(err)s") % {"path": path, "err": error_text(request, err)})
for path in shared:
messages.warning(request, _("Disk %(path)s is used by another VM and was kept") % {"path": path})
addlogmsg(
request.user.username, instance.compute.name, instance.name, _("Destroy")
)
return redirect(reverse("instances:index"))
instance = get_instance(request.user, pk, perm_type="delete")
try:
userinstance = instance.userinstance_set.get(user=request.user)
except UserInstance.DoesNotExist:
userinstance = UserInstance(
is_delete=request.user.is_superuser
)
return render(
request,
"instances/destroy_instance_form.html",
{
"instance": instance,
"userinstance": userinstance,
# get_instance(perm_type="delete") above already refused the rest
"can_delete": True,
},
)
@require_POST
@superuser_only
def migrate(request, pk):
get_instance(request.user, pk)
compute_id = request.POST.get("compute_id", "")
if not compute_id.isdigit():
raise Http404
target_host = get_object_or_404(Compute, pk=compute_id)
raw = {"instance": pk, "target": target_host.pk, "live": request.POST.get("live_migrate"),
"unsafe": request.POST.get("unsafe_migrate"), "offline": request.POST.get("offline_migrate"),
"autoconverge": request.POST.get("autoconverge"), "compress": request.POST.get("compress")}
try:
op = jobs.submit("instance.migrate", request.user, raw, request.POST.get("submission_key", ""))
except claims.Busy as busy:
return _busy(request, pk, busy)
except (jobs.SubmissionConflict, libvirtError) as err:
messages.error(request, error_text(request, err))
else:
messages.info(request, _("The VM is being migrated; follow it in the job panel."))
jobs.remember_job(request, op)
return get_safe_redirect(request, default=reverse("instances:instance", args=[pk]))
GSTFSD_PORT = 16510
GSTFSD_TIMEOUT = 120 # gstfsd answers within its 100 s limit for a request (GSTFSD_DEADLINE)
def gstfsd_request(hostname, data):
"""Send one request to gstfsd and return its JSON reply, or an error reply."""
try:
# the host name may resolve to IPv4 or IPv6
s = socket.create_connection((hostname, GSTFSD_PORT), timeout=GSTFSD_TIMEOUT)
except OSError as err:
return {"return": "error", "message": _("gstfsd error: %(err)s") % {"err": err}}
try:
s.sendall(json.dumps(data).encode())
# gstfsd sends one reply and closes the connection
return json.loads(b"".join(iter(lambda: s.recv(4096), b"")))
except (OSError, ValueError) as err:
return {"return": "error", "message": _("gstfsd error: %(err)s") % {"err": err}}
finally:
s.close()
@require_POST
@serialize_instance_mutation
def set_root_pass(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if request.method == "POST":
passwd = request.POST.get("passwd", None)
if passwd:
passwd_hash = crypt.crypt(passwd, crypt.mksalt(crypt.METHOD_SHA512))
# gstfsd opens the domain by name: send the host's current one.
data = {"action": "password", "passwd": passwd_hash, "vname": instance.proxy.instance.name()}
if instance.proxy.get_status() == 5:
result = gstfsd_request(instance.compute.hostname, data)
if result["return"] == "success":
msg = _("Reset root password")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
else:
messages.error(request, result["message"])
else:
msg = _("Please shutdown down your instance and then try again")
messages.error(request, msg)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#access"
)
@require_POST
@serialize_instance_mutation
def add_public_key(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if request.method == "POST":
sshkeyid = request.POST.get("sshkeyid", "")
if not _is_int(sshkeyid):
messages.error(request, _("Select an SSH public key"))
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#access"
)
publickey = get_object_or_404(UserSSHKey, id=sshkeyid, user=request.user)
data = {
"action": "publickey",
"key": publickey.keypublic,
"vname": instance.proxy.instance.name(),
}
if instance.proxy.get_status() == 5:
result = gstfsd_request(instance.compute.hostname, data)
if result["return"] == "error":
msg = result["message"]
else:
msg = _("Installed new SSH public key %(keyname)s") % {
"keyname": publickey.keyname
}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
if result["return"] == "success":
messages.success(request, msg)
else:
messages.error(request, msg)
else:
msg = _("Please shutdown down your instance and then try again")
messages.error(request, msg)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#access"
)
@require_POST
@serialize_user_quota
@serialize_instance_mutation
def resizevm_cpu(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
vcpu = instance.proxy.get_vcpu()
new_vcpu = request.POST.get("vcpu", "")
new_cur_vcpu = request.POST.get("cur_vcpu", "")
if not _is_int(new_vcpu) or not _is_int(new_cur_vcpu):
messages.error(request, _("Enter a whole number of vCPUs"))
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
quota_msg = utils.check_user_quota(
request.user, 0, int(new_vcpu) - vcpu, 0, 0
)
if not request.user.is_superuser and quota_msg:
msg = quota_refused(quota_msg, _(
"User %(quota_msg)s quota reached, cannot resize CPU of '%(instance_name)s'!"
) % {
"quota_msg": quota_msg,
"instance_name": instance.name,
})
messages.error(request, msg)
else:
cur_vcpu = new_cur_vcpu
vcpu = new_vcpu
instance.proxy.resize_cpu(cur_vcpu, vcpu)
msg = _("CPU is resized: %(old)s to %(new)s") % {
"old": cur_vcpu,
"new": vcpu,
}
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@require_POST
@serialize_user_quota
@serialize_instance_mutation
def resize_memory(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
memory = instance.proxy.get_memory()
cur_memory = instance.proxy.get_cur_memory()
new_memory = request.POST.get("memory", "")
new_memory_custom = request.POST.get("memory_custom", "")
if new_memory_custom:
new_memory = new_memory_custom
new_cur_memory = request.POST.get("cur_memory", "")
new_cur_memory_custom = request.POST.get("cur_memory_custom", "")
if new_cur_memory_custom:
new_cur_memory = new_cur_memory_custom
# The quota charges the maximum. An active VM keeps it (resize_mem changes
# only the current memory there); pass the current maximum, so a VM that
# shuts down meanwhile does not get an unchecked one.
if instance.proxy.instance.isActive():
new_memory = memory
if not _is_int(new_memory) or not _is_int(new_cur_memory):
messages.error(request, _("Enter a whole number for memory"))
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
quota_msg = utils.check_user_quota(request.user, 0, 0, int(new_memory) - memory, 0)
if not request.user.is_superuser and quota_msg:
msg = quota_refused(quota_msg, _(
"User %(quota_msg)s quota reached, cannot resize memory of '%(instance_name)s'!"
) % {
"quota_msg": quota_msg,
"instance_name": instance.name,
})
messages.error(request, msg)
else:
instance.proxy.resize_mem(new_cur_memory, new_memory)
msg = _(
"Memory is resized: current/max: %(old_cur)s/%(old_max)s to %(new_cur)s/%(new_max)s"
) % {
"old_cur": cur_memory,
"old_max": memory,
"new_cur": new_cur_memory,
"new_max": new_memory,
}
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@require_POST
@serialize_user_quota
@serialize_instance_mutation
def resize_disk(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
disks = instance.proxy.get_disk_devices()
disks_new = list()
for disk in disks:
input_disk_size = (
int(request.POST.get("disk_size_" + disk["dev"], "0")) * 1073741824
)
if input_disk_size > disk["size"] + (64 << 20):
disk["size_new"] = input_disk_size
disks_new.append(disk)
if not disks_new:
messages.warning(request, _("No disks were selected or valid for resizing."))
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
disk_sum = sum([disk["size"] >> 30 for disk in disks_new])
disk_new_sum = sum([disk["size_new"] >> 30 for disk in disks_new])
quota_msg = utils.check_user_quota(
request.user, 0, 0, 0, disk_new_sum - disk_sum
)
if not request.user.is_superuser and quota_msg:
msg = quota_refused(quota_msg, _(
"User %(quota_msg)s quota reached, cannot resize disks of '%(instance_name)s'!"
) % {
"quota_msg": quota_msg,
"instance_name": instance.name,
})
messages.error(request, msg)
else:
for disk in disks_new:
try:
instance.proxy.resize_disk([disk])
except libvirtError as err:
# an LVM pool, for one, cannot resize its volumes
if err.get_error_code() != VIR_ERR_NO_SUPPORT:
raise
messages.error(request, _("The storage pool of %(dev)s cannot resize its volumes") % {"dev": disk["dev"]})
continue
msg = _("Disk is resized: %(dev)s") % {"dev": disk["dev"]}
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
messages.success(request, msg)
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
@require_POST
@superuser_only
@serialize_instance_mutation
def add_new_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
storage = request.POST.get("storage", "")
name = request.POST.get("name", "")
format = request.POST.get("format", app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT)
size = request.POST.get("size", 0)
meta_prealloc = True if request.POST.get("meta_prealloc", False) else False
bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS)
cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE)
invalid = invalid_disk_options(instance, bus=bus, cache=cache, format=format)
if not VOLUME_NAME_RE.fullmatch(name):
invalid.append("name")
if invalid:
return reject_disk_options(request, pk, invalid)
conn_create = wvmCreate(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
)
# a name either definition uses is taken: on an active VM a disk can be
# pending in the persistent one only
target_dev = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
source = conn_create.create_volume(
storage,
name,
size,
format,
meta_prealloc,
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID),
int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID),
)
conn_pool = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
pool_type = conn_pool.get_type()
disk_type = conn_pool.get_volume_type(os.path.basename(source))
# the pool decides: only a dir pool keeps the asked format, the others create raw
format = util.vol_driver_type(conn_pool.get_volume_format_type(os.path.basename(source)), pool_type)
if pool_type == "rbd":
source_info = conn_pool.get_rbd_source()
else: # add more disk types to handle different pool and disk types
source_info = None
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format,
cache_mode=cache,
)
msg = _("Attach new disk: %(name)s (%(format)s)") % {
"name": name,
"format": format,
}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def add_existing_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
storage = request.POST.get("selected_storage", "")
name = request.POST.get("vols", "")
bus = request.POST.get("bus", app_settings.INSTANCE_VOLUME_DEFAULT_BUS)
cache = request.POST.get("cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE)
invalid = invalid_disk_options(instance, bus=bus, cache=cache)
if invalid:
return reject_disk_options(request, pk, invalid)
conn_create = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
storage,
)
# Only volumes that really exist in the selected pool; this also rules
# out path traversal through the volume name.
if name not in conn_create.get_volumes():
return reject_disk_options(request, pk, ["vols"])
pool_type = conn_create.get_type()
format_type = util.vol_driver_type(conn_create.get_volume_format_type(name), pool_type)
disk_type = conn_create.get_volume_type(name)
if pool_type == "rbd":
source_info = conn_create.get_rbd_source()
path = conn_create.get_source_name()
source = f"{path}/{name}"
else:
source_info = None
# an iSCSI volume's path is not the pool target plus its name
source = conn_create.get_volume(name).path()
target_dev = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
instance.proxy.attach_disk(
target_dev,
source,
source_info=source_info,
pool_type=pool_type,
disk_type=disk_type,
target_bus=bus,
format_type=format_type,
cache_mode=cache,
)
msg = _("Attach Existing disk: %(target_dev)s") % {"target_dev": target_dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def edit_volume(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if "edit_volume" in request.POST:
target_dev = request.POST.get("dev", "")
new_path = request.POST.get("vol_path", "")
shareable = bool(request.POST.get("vol_shareable", False))
readonly = bool(request.POST.get("vol_readonly", False))
disks = instance.proxy.get_disk_devices(config=True)
current = next((disk for disk in disks if disk["dev"] == target_dev), {})
new_bus = request.POST.get("vol_bus", current.get("bus"))
serial = request.POST.get("vol_serial", "")
format = request.POST.get("vol_format", "")
cache = request.POST.get(
"vol_cache", app_settings.INSTANCE_VOLUME_DEFAULT_CACHE
)
io = request.POST.get("vol_io_mode", app_settings.INSTANCE_VOLUME_DEFAULT_IO)
discard = request.POST.get(
"vol_discard_mode", app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD
)
zeroes = request.POST.get(
"vol_detect_zeroes", app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES
)
# The form shows "None" (or nothing) for a disk without driver type.
if format in ("", "None"):
format = current.get("format") or ""
invalid = invalid_disk_options(
instance,
bus=new_bus,
cache=cache,
io=io,
discard=discard,
zeroes=zeroes,
# "": the disk has no driver type and keeps having none.
format=format or None,
serial=serial,
)
if not current:
invalid.append("dev")
if invalid:
return reject_disk_options(request, pk, invalid)
instance.proxy.edit_disk(
target_dev,
new_path,
readonly,
shareable,
new_bus,
serial,
format,
cache,
io,
discard,
zeroes,
)
if not instance.proxy.get_status() == 5:
messages.success(
request,
_(
"Volume changes are applied. "
+ "But it will be activated after shutdown"
),
)
else:
messages.success(request, _("Volume is changed successfully."))
msg = _("Edit disk: %(target_dev)s") % {"target_dev": target_dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_compute_mutation
def delete_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("dev", "")
# Resolve the volume from the VM's own XML; never trust the posted
# storage/name, which could point at any volume on the compute.
disk = next((d for d in instance.disks if d["dev"] == dev), None)
if disk is None or not disk["storage"] or not disk["image"]:
messages.error(
request,
_("Disk %(dev)s is not a storage volume of this instance") % {"dev": dev},
)
return _back(request, pk, "disks")
# the page lists the persistent disks: delete only a volume both definitions use there
if not any(d["dev"] == dev and d["path"] == disk["path"] for d in instance.config_disks):
messages.error(
request,
_("Disk %(dev)s has a pending change; delete it once the VM is shut down") % {"dev": dev},
)
return _back(request, pk, "disks")
conn_delete = wvmStorage(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
disk["storage"],
)
if disk["path"] in instance.proxy.paths_used_by_other_domains():
messages.error(
request,
_("Volume %(vol)s is used by another VM; detach it instead") % {"vol": disk["image"]},
)
return _back(request, pk, "disks")
msg = _("Delete disk: %(dev)s") % {"dev": dev}
instance.proxy.detach_disk(dev)
# Never delete a volume the guest may still be writing to.
if not instance.proxy.wait_disk_detached(dev):
messages.warning(
request,
_("The guest has not released disk %(dev)s yet; volume %(vol)s was kept. "
"Delete it from the storage pool once the VM is shut down.")
% {"dev": dev, "vol": disk["image"]},
)
return _back(request, pk, "disks")
try:
conn_delete.del_volume(disk["image"])
except util.OperationError as err: # a file of the VM's own snapshots
messages.warning(
request,
_("Disk %(dev)s was detached, but its volume was kept: %(err)s") % {"dev": dev, "err": err},
)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def detach_vol(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("dev", "")
instance.proxy.detach_disk(dev)
msg = _("Detach disk: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def add_cdrom(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
bus = request.POST.get("bus", "ide" if instance.machine == "pc" else "sata")
invalid = invalid_disk_options(instance, bus=bus)
if invalid:
return reject_disk_options(request, pk, invalid)
target = utils.get_new_disk_dev(
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
)
instance.proxy.attach_disk(
target,
"",
disk_device="cdrom",
cache_mode="none",
target_bus=bus,
readonly=True,
)
msg = _("Add CD-ROM: %(target)s") % {"target": target}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def detach_cdrom(request, pk, dev):
instance = get_instance(request.user, pk, perm_type="change")
# dev = request.POST.get('detach_cdrom', '')
instance.proxy.detach_disk(dev)
msg = _("Detach CD-ROM: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def unmount_iso(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
dev = request.POST.get("umount_iso", "")
try:
instance.proxy.umount_iso(dev)
except libvirtError as err:
messages.error(request, err)
else:
msg = _("Unmount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
@require_POST
@superuser_only
@serialize_instance_mutation
def mount_iso(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
image = request.POST.get("media", "")
dev = request.POST.get("mount_iso", "")
try:
instance.proxy.mount_iso(dev, image)
except libvirtError as err:
messages.error(request, err)
else:
msg = _("Mount media: %(dev)s") % {"dev": dev}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
@require_POST
def snapshot(request, pk):
return _snapshot_job(request, pk, "snapshot.create",
{"name": request.POST.get("name", ""), "description": request.POST.get("description", "")},
_("The snapshot is being taken; follow it in the job panel."))
@require_POST
@serialize_instance_mutation
def delete_snapshot(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if not request.user.has_perm("instances.snapshot_instances"):
raise PermissionDenied
snap_name = request.POST.get("name", "")
try:
instance.proxy.snapshot_delete(snap_name)
except libvirtError as err:
messages.error(request, _("Snapshot was not deleted: %(err)s") % {"err": error_text(request, err)})
return _back(request, pk, "managesnapshot")
msg = _("Delete snapshot: %(snap)s") % {"snap": snap_name}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "managesnapshot")
@require_POST
def revert_snapshot(request, pk):
return _snapshot_job(request, pk, "snapshot.revert", {"name": request.POST.get("name", "")},
_("The VM is being reverted; follow it in the job panel."))
@require_POST
def create_external_snapshot(request, pk):
return _snapshot_job(request, pk, "snapshot.create",
{"name": request.POST.get("name", ""), "description": request.POST.get("description", ""),
"external": True, "memory": request.POST.get("memory") == "on",
"quiesce": request.POST.get("quiesce") == "on"},
_("The snapshot is being taken; follow it in the job panel."))
@require_POST
def revert_external_snapshot(request, pk):
return _snapshot_job(request, pk, "snapshot.revert", {"name": request.POST.get("name", ""), "external": True},
_("The VM is being reverted; follow it in the job panel."))
def _snapshot_job(request, pk, kind, params, queued):
"""Queue a snapshot job on the VM; refusals become messages."""
get_instance(request.user, pk, perm_type="change")
if not request.user.has_perm("instances.snapshot_instances"):
raise PermissionDenied
try:
op = jobs.submit(kind, request.user, {"instance": pk, **params}, request.POST.get("submission_key", ""))
except claims.Busy as busy:
messages.error(request, busy_message(busy, request.user))
except jobs.SubmissionConflict as err:
messages.error(request, str(err))
except libvirtError as err:
messages.error(request, error_text(request, err))
else:
messages.info(request, queued)
jobs.remember_job(request, op)
return _back(request, pk, "managesnapshot")
@require_POST
def delete_external_snapshot(request, pk):
return _snapshot_job(request, pk, "snapshot.delete", {"name": request.POST.get("name", "")},
_("The snapshot is being deleted; follow it in the job panel."))
@require_POST
@superuser_only
@serialize_instance_mutation
def set_vcpu(request, pk):
instance = get_instance(request.user, pk)
id = request.POST.get("id", "")
enabled = request.POST.get("set_vcpu", "")
if enabled == "True":
instance.proxy.set_vcpu(id, 1)
else:
instance.proxy.set_vcpu(id, 0)
msg = _("VCPU %(id)s is enabled=%(enabled)s") % {"id": id, "enabled": enabled}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "resize")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_vcpu_hotplug(request, pk):
instance = get_instance(request.user, pk)
status = True if request.POST.get("vcpu_hotplug", "False") == "True" else False
msg = _("VCPU Hot-plug is enabled=%(status)s") % {"status": status}
instance.proxy.set_vcpu_hotplug(status)
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "resize")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_autostart(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_autostart(1)
msg = _("Set autostart")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt")
@require_POST
@superuser_only
@serialize_instance_mutation
def unset_autostart(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_autostart(0)
msg = _("Unset autostart")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_bootmenu(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_bootmenu(1)
msg = _("Enable boot menu")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt")
@require_POST
@superuser_only
@serialize_instance_mutation
def unset_bootmenu(request, pk):
instance = get_instance(request.user, pk)
instance.proxy.set_bootmenu(0)
msg = _("Disable boot menu")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_bootorder(request, pk):
instance = get_instance(request.user, pk)
bootorder = request.POST.get("bootorder", "")
if bootorder:
order_list = {}
for idx, val in enumerate(bootorder.split(",")):
dev_type, dev = val.split(":", 1)
order_list[idx] = {"type": dev_type, "dev": dev}
try:
instance.proxy.set_bootorder(order_list)
except util.OperationError as err:
messages.error(request, err)
return _back(request, pk, "boot_opt")
msg = _("Set boot order")
if not instance.proxy.get_status() == 5:
messages.success(
request,
_(
"Boot menu changes applied. "
+ "But it will be activated after shutdown"
),
)
else:
messages.success(request, _("Boot order changed successfully."))
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "boot_opt")
@require_POST
@superuser_only
@serialize_instance_mutation
def change_xml(request, pk):
instance = get_instance(request.user, pk)
new_xml = request.POST.get("inst_xml", "")
# The editor is filled while the VM is shut off; once it runs, the posted
# XML would overwrite changes made since.
if new_xml and instance.proxy.get_status() != 5:
messages.error(request, _("Power off the instance to change its XML"))
elif new_xml:
instance.proxy._defineXML(new_xml)
msg = _("Change instance XML")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "xmledit")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_guest_agent(request, pk):
instance = get_instance(request.user, pk)
status = request.POST.get("guest_agent")
if status == "True":
instance.proxy.add_guest_agent()
if status == "False":
instance.proxy.remove_guest_agent()
msg = _("Set Guest Agent: %(status)s") % {"status": status}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "options")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_video_model(request, pk):
instance = get_instance(request.user, pk)
video_model = request.POST.get("video_model", "vga")
instance.proxy.set_video_model(video_model)
msg = _("Set Video Model: %(model)s") % {"model": video_model}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "options")
@require_POST
@superuser_only
@serialize_instance_mutation
def change_network(request, pk):
instance = get_instance(request.user, pk)
back = same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#network"
# Each NIC has its own form; its fields end with that NIC's index.
nums = [key[len("net-old-mac-"):] for key in request.POST if key.startswith("net-old-mac-")]
if len(nums) != 1:
messages.error(request, _("Select one network interface to change"))
return redirect(back)
num = nums[0]
old_mac = request.POST[f"net-old-mac-{num}"]
mac = request.POST.get(f"net-mac-{num}", "").strip() or old_mac
try:
util.validate_macaddr(mac)
except ValueError as err:
messages.error(request, err)
return redirect(back)
(source, source_type) = utils.get_network_tuple(request.POST.get(f"net-source-{num}", ""))
if source_type == "iface":
iface = wvmInterface(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
source,
)
source_type = iface.get_type()
try:
instance.proxy.change_network(
old_mac,
mac,
source,
source_type,
request.POST.get(f"net-model-{num}"),
request.POST.get(f"net-nwfilter-{num}", ""),
)
except libvirtError as err:
messages.error(request, err)
return redirect(back)
msg = _("Change network: %(mac)s") % {"mac": old_mac}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
msg = _("Network Device Config is changed. Please shutdown instance to activate.")
if instance.proxy.get_status() != 5:
messages.success(request, msg)
return redirect(back)
@require_POST
@superuser_only
@serialize_instance_mutation
def add_network(request, pk):
instance = get_instance(request.user, pk)
network = request.POST.get("add-net-network")
if not network:
messages.error(request, _("No network selected"))
return _back(request, pk, "network")
mac = request.POST.get("add-net-mac")
nwfilter = request.POST.get("add-net-nwfilter")
(source, source_type) = utils.get_network_tuple(network)
model = request.POST.get("add-net-model")
if source_type == "iface":
iface = wvmInterface(
instance.compute.hostname,
instance.compute.login,
instance.compute.password,
instance.compute.type,
source,
)
source_type = iface.get_type()
instance.proxy.add_network(mac, source, source_type, model=model, nwfilter=nwfilter)
msg = _("Add network: %(mac)s") % {"mac": mac}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "network")
@require_POST
@superuser_only
@serialize_instance_mutation
def delete_network(request, pk):
instance = get_instance(request.user, pk)
mac_address = request.POST.get("delete_network", "")
instance.proxy.delete_network(mac_address)
msg = _("Delete Network: %(mac)s") % {"mac": mac_address}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "network")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_link_state(request, pk):
instance = get_instance(request.user, pk)
mac_address = request.POST.get("mac", "")
state = request.POST.get("set_link_state")
state = "down" if state == "up" else "up"
instance.proxy.set_link_state(mac_address, state)
msg = _("Set Link State: %(state)s") % {"state": state}
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return _back(request, pk, "network")
@require_POST
@superuser_only
@serialize_instance_mutation
def set_qos(request, pk):
instance = get_instance(request.user, pk)
qos_dir = request.POST.get("qos_direction", "")
average = request.POST.get("qos_average") or 0
peak = request.POST.get("qos_peak") or 0
burst = request.POST.get("qos_burst") or 0
mac_key = [key for key in request.POST if "mac" in key]
mac = request.POST.get(mac_key[0]) if mac_key else None
if not mac or qos_dir not in ("inbound", "outbound"):
messages.error(request, _("Select a network interface and a QoS direction"))
return _back(request, pk, "network")
if not all(_is_int(rate) for rate in (average, peak, burst)):
messages.error(request, _("QoS rates must be whole numbers"))
return _back(request, pk, "network")
instance.proxy.set_qos(mac, qos_dir, average, peak, burst)
if instance.proxy.get_status() == 5:
messages.success(
request, _("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()}
)
else:
messages.success(
request,
_("%(qos_dir)s QoS is set. A running VM gets it after it is powered off and on.")
% {"qos_dir": qos_dir.capitalize()},
)
return _back(request, pk, "network")
@require_POST
@superuser_only
@serialize_instance_mutation
def unset_qos(request, pk):
instance = get_instance(request.user, pk)
qos_dir = request.POST.get("qos_direction", "")
mac = request.POST.get("net-mac")
if not mac or qos_dir not in ("inbound", "outbound"):
messages.error(request, _("Select a network interface and a QoS direction"))
return _back(request, pk, "network")
instance.proxy.unset_qos(mac, qos_dir)
if instance.proxy.get_status() == 5:
messages.success(
request, _("%(qos_dir)s QoS is deleted") % {"qos_dir": qos_dir.capitalize()}
)
else:
messages.success(
request,
_("%(qos_dir)s QoS is deleted. A running VM keeps the old limit until it is powered off and on.")
% {"qos_dir": qos_dir.capitalize()},
)
return _back(request, pk, "network")
@require_POST
@superuser_only
def add_owner(request, pk):
instance = get_instance(request.user, pk)
user_id = request.POST.get("user_id", "")
if not user_id.isdigit():
raise Http404
user = get_object_or_404(User, pk=user_id)
check_inst = 0
if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False":
check_inst = UserInstance.objects.filter(instance=instance).count()
subject = f"VM {instance.name} owner {user.username}"
if check_inst > 0:
failed(request, instance.compute, subject, "add", "only one owner is allowed", instance=instance.name)
messages.error(
request, _("Only one owner is allowed and the one already added")
)
else:
with audit(request, instance.compute, subject, "add", instance=instance.name) as entry:
entry.skip = not UserInstance.objects.get_or_create(instance=instance, user=user)[1]
if entry.skip:
messages.warning(request, _("%(user)s is already an owner") % {"user": user})
return _back(request, pk, "users")
@require_POST
@superuser_only
def del_owner(request, pk):
instance = get_instance(request.user, pk)
userinstance_id = request.POST.get("userinstance", "")
if not userinstance_id.isdigit():
raise Http404
userinstance = get_object_or_404(UserInstance, pk=userinstance_id, instance=instance)
with audit(request, instance.compute, f"VM {instance.name} owner {userinstance.user.username}", "remove",
instance=instance.name):
userinstance.delete()
return _back(request, pk, "users")
# Buses for the empty CD-ROM of a new VM: fdc and virtio have no ejectable
# media, and libvirt refuses an empty usb disk.
CDROM_BUSES = ("ide", "sata", "scsi")
def _clone_source(user, pk):
"""Cloning copies the source disks, so it needs change permission on the
source VM. Templates are meant to be deployed from, so viewing is enough."""
instance = get_instance(user, pk)
if not instance.is_template:
instance = get_instance(user, pk, perm_type="change")
return instance
@require_POST
@permission_required("instances.clone_instances", raise_exception=True)
@serialize_user_quota
def clone(request, pk):
_clone_source(request.user, pk) # 403/404 first
try:
op = jobs.submit("instance.clone", request.user, {"instance": pk, **request.POST.dict()},
request.POST.get("submission_key", ""))
except claims.Busy as busy:
return _busy(request, pk, busy)
except (jobs.SubmissionConflict, libvirtError, ValueError) as err:
messages.error(request, error_text(request, err))
else:
messages.info(request, _("The clone is being made; follow it in the job panel."))
jobs.remember_job(request, op)
return redirect(reverse("instances:instance", args=[pk]))
@require_POST
@serialize_instance_mutation
def update_console(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
if not utils.can_manage_console(request.user, instance):
raise PermissionDenied
form = ConsoleForm(request.POST or None, can_set_listener=request.user.is_superuser)
if form.is_valid() and form.cleaned_data["console_type"] not in ("", instance.console_type):
instance.proxy.set_console_type(form.cleaned_data["console_type"])
addlogmsg(request.user.username, instance.compute.name, instance.name, _("Set console type"))
# the other fields are VNC settings; a new VNC console gets them too
if form.is_valid() and form.cleaned_data["console_type"] != "pty":
if (
"generate_password" in form.changed_data
or "clear_password" in form.changed_data
or "password" in form.changed_data
):
if form.cleaned_data["generate_password"]:
password = randomPasswd()
elif form.cleaned_data["clear_password"]:
password = ""
else:
password = form.cleaned_data["password"]
# A console that listens beyond localhost (set by an
# administrator) keeps its password unless an administrator
# removes it. The definition being changed is the persistent one.
if (
not password
and not request.user.is_superuser
and not instance.proxy.console_listens_locally()
):
messages.error(
request,
_("The console listens beyond localhost; only an administrator can remove its password."),
)
elif not instance.proxy.set_console_passwd(password):
msg = _(
"Error setting console password. "
+ "You should check that your instance have an graphic device."
)
messages.error(request, msg)
else:
msg = _("Set VNC password")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
if "keymap" in form.changed_data or "clear_keymap" in form.changed_data:
if form.cleaned_data["clear_keymap"]:
instance.proxy.set_console_keymap("")
else:
instance.proxy.set_console_keymap(form.cleaned_data["keymap"])
msg = _("Set VNC keymap")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
if "listen_on" in form.changed_data:
instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"])
msg = _("Set VNC listen address")
addlogmsg(
request.user.username, instance.compute.name, instance.name, msg
)
if not form.is_valid():
messages.error(request, _("Console settings were not saved: %(errors)s") % {"errors": form.errors.as_text()})
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#vncsettings"
)
@require_POST
@serialize_user_quota
@serialize_instance_mutation
def change_options(request, pk):
instance = get_instance(request.user, pk, perm_type="change")
try:
userinstance = instance.userinstance_set.get(user=request.user)
except UserInstance.DoesNotExist:
userinstance = UserInstance(is_change=False)
if request.user.is_superuser or userinstance.is_change:
# Only superusers and staff may (un)mark templates. The
# checkbox is disabled for everyone else, and a disabled checkbox is
# not submitted, so for them the flag must be left alone.
if request.user.is_superuser or request.user.is_staff:
is_template = bool(request.POST.get("is_template", False))
# A template is not counted and cannot be started: making it a
# VM again adds it to the owner's usage, so the quota applies.
if instance.is_template and not is_template and not request.user.is_superuser:
disk_sum = sum(int(disk.get("size") or 0) >> 30 for disk in instance.disks)
quota_msg = utils.check_user_quota(request.user, 1, instance.vcpu, instance.memory, disk_sum)
if quota_msg:
messages.error(request, quota_refused(quota_msg, _(
"User %(quota_msg)s quota reached, '%(instance_name)s' stays a template!"
) % {"quota_msg": quota_msg, "instance_name": instance.name}))
is_template = True
instance.is_template = is_template
instance.save(update_fields=["is_template"])
options = {}
for post in request.POST:
if post in ["title", "description"]:
options[post] = request.POST.get(post, "")
instance.proxy.set_options(options)
msg = _("Edit options")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
return get_safe_redirect(
request, default=reverse("instances:instance", args=[instance.id]) + "#options"
)
def getvvfile(request, pk):
instance = get_instance(request.user, pk)
# The .vv file contains the VNC password: same rule as the console.
if not utils.can_open_console(request.user, instance):
raise PermissionDenied
msg = _("Send console.vv file")
addlogmsg(request.user.username, instance.compute.name, instance.name, msg)
response = HttpResponse(
content="",
content_type="application/x-virt-viewer",
status=200,
reason=None,
charset="utf-8",
)
# the VM found by its UUID; the stored name may be stale
with instance.proxy.cached_reads() as proxy:
listen = proxy.get_console_listener_addr()
response.writelines("[virt-viewer]\n")
response.writelines(f"type={proxy.get_console_type()}\n")
response.writelines(f"host={proxy.host if listen == '0.0.0.0' else listen}\n")
response.writelines(f"port={proxy.get_console_port()}\n")
response.writelines(f"title={proxy.instance.name()}\n")
response.writelines(f"password={proxy.get_console_passwd() or ''}\n")
response.writelines("enable-usbredir=1\n")
response.writelines("disable-effects=all\n")
response.writelines("secure-attention=ctrl+alt+ins\n")
response.writelines("release-cursor=ctrl+alt\n")
response.writelines("fullscreen=1\n")
response.writelines("delete-this-file=1\n")
response["Content-Disposition"] = 'attachment; filename="console.vv"'
return response
@superuser_only
def create_instance_select_type(request, compute_id):
"""
:param request:
:param compute_id:
:return:
"""
conn = None
storages = list()
networks = list()
hypervisors = list()
meta_prealloc = False
compute = get_object_or_404(Compute, pk=compute_id)
conn = wvmCreate(compute.hostname, compute.login, compute.password, compute.type)
instances = conn.get_instances()
all_hypervisors = conn.get_hypervisors_machines()
# Supported hypervisors by webvirtcloud: i686, x86_64(for now)
supported_arch = [
"x86_64",
"i686",
"aarch64",
"armv7l",
"ppc64",
"ppc64le",
"s390x",
]
hypervisors = [hpv for hpv in all_hypervisors.keys() if hpv in supported_arch]
default_machine = app_settings.INSTANCE_MACHINE_DEFAULT_TYPE
default_arch = app_settings.INSTANCE_ARCH_DEFAULT_TYPE
if request.method == "POST":
if "create_xml" in request.POST:
xml = request.POST.get("dom_xml", "")
try:
name = util.get_xml_path(xml, "/domain/name")
except util.etree.Error:
name = None
if name in instances:
error_msg = _("A virtual machine with this name already exists")
messages.error(request, error_msg)
else:
with libvirt_compute_lock(compute):
conn._defineXML(xml)
utils.refr(compute)
instance = compute.instance_set.get(name=name)
return redirect(reverse("instances:instance", args=[instance.id]))
return render(request, "create_instance_w1.html", locals())
# the create form's disk sources: (value, title, help)
DISK_SOURCES = [
("new", _("New disk"), _("An empty disk of the size you set")),
("existing", _("Existing volumes"), _("Volumes already in a pool")),
("template", _("Clone from template"), _("A copy of a template volume")),
]
def refused_lists(post):
"""The volume pool, volumes and networks of a refused create form as
submitted, for the page to rebuild them."""
images = post["images"].split(",") if post.get("images") else []
networks = post["networks"].split(",") if post.get("networks") else []
macs = post.get("mac", "").split(",")
return {
"pool": post.get("storage-control", ""),
"disks": [{"volume": image, "device": post.get(f"device{i}", ""), "bus": post.get(f"bus{i}", "")}
for i, image in enumerate(images)],
"networks": [{"name": network, "mac": macs[i] if i < len(macs) else ""}
for i, network in enumerate(networks)],
}
@superuser_only
def create_instance(request, compute_id, arch, machine):
"""
:param request:
:param compute_id:
:param arch:
:param machine:
:return:
"""
conn = None
storages = list()
networks = list()
hypervisors = list()
firmwares = list()
meta_prealloc = False
compute = get_object_or_404(Compute, pk=compute_id)
flavors = Flavor.objects.filter().order_by("id")
appsettings = AppSettings.objects.all()
vm_form = None
flavor_form = FlavorForm()
refused = False
disk_sources = DISK_SOURCES
disk_source = request.POST.get("disk_source", "new")
if disk_source not in [value for value, _title, _help in DISK_SOURCES]:
disk_source = "new"
try:
conn = wvmCreate(
compute.hostname,
compute.login,
compute.password,
compute.type
)
default_firmware = app_settings.INSTANCE_FIRMWARE_DEFAULT_TYPE
default_cpu_mode = app_settings.INSTANCE_CPU_DEFAULT_MODE
instances = conn.get_instances()
videos = conn.get_video_models(arch, machine)
default_video = app_settings.INSTANCE_VIDEO_DEFAULT_TYPE
cache_modes = sorted(conn.get_cache_modes().items())
default_cache = app_settings.INSTANCE_VOLUME_DEFAULT_CACHE
default_io = app_settings.INSTANCE_VOLUME_DEFAULT_IO
default_zeroes = app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES
default_discard = app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD
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)
default_scsi_disk_model = app_settings.INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER
listener_addr = settings.QEMU_CONSOLE_LISTENER_ADDRESSES
disk_devices = conn.get_disk_device_types(arch, machine)
disk_buses = conn.get_disk_bus_types(arch, machine)
cdrom_buses = [bus for bus in disk_buses if bus in CDROM_BUSES]
default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS
networks = sorted(conn.get_networks())
nwfilters = conn.get_nwfilters()
net_models_host = conn.get_network_models()
default_nic_type = app_settings.INSTANCE_NIC_DEFAULT_TYPE
storages = sorted(conn.get_storages(only_actives=True))
default_cdrom = app_settings.INSTANCE_CDROM_ADD
input_device_buses = ["default", "virtio", "usb"]
default_input_device_bus = app_settings.INSTANCE_INPUT_DEFAULT_DEVICE
dom_caps = conn.get_dom_capabilities(arch, machine)
caps = conn.get_capabilities(arch)
virtio_support = conn.is_supports_virtio(arch, machine)
# Add BIOS
label = conn.label_for_firmware_path(arch, None)
if label:
firmwares.append(label)
# Add UEFI
loader_path = conn.find_uefi_path_for_arch(arch, machine)
label = conn.label_for_firmware_path(arch, loader_path)
if label:
firmwares.append(label)
firmwares = list(set(firmwares))
if not storages:
raise util.OperationError(_("You haven't defined any storage pools"))
if not networks:
raise util.OperationError(_("You haven't defined any network pools"))
vm_form = NewVMForm(initial={
"vcpu": 1, "memory": 512, "firmware": default_firmware, "vcpu_mode": default_cpu_mode,
"cache_mode": default_cache, "net_model": default_nic_type, "listener_addr": "0.0.0.0",
"add_cdrom": default_cdrom, "add_input": default_input_device_bus, "video": default_video,
"qemu_ga": True, "virtio": bool(virtio_support),
})
if conn:
if request.method == "POST":
if "create" in request.POST:
# the submitted form comes back bound if the page shows it again
form = vm_form = NewVMForm(request.POST)
refused = True
if form.is_valid():
data = form.cleaned_data
images = data["images"].split(",") if data["images"] else []
raw = {**data, "compute": compute.id, "arch": arch, "machine": machine,
"devices": [[request.POST.get(f"device{i}", ""), request.POST.get(f"bus{i}", "")]
for i in range(len(images))]}
try:
op = jobs.submit("instance.create", request.user, raw,
request.POST.get("submission_key", ""))
except claims.Busy as busy:
messages.error(request, busy_message(busy, request.user))
except jobs.SubmissionConflict as err:
messages.error(request, str(err))
else:
if op.params["machine"] != machine:
messages.warning(
request,
"Changing machine type from '%s' to 'q35' "
"which is required for UEFI secure boot." % machine,
)
messages.info(request, _("The VM is being created; follow it in the job panel."))
jobs.remember_job(request, op)
return redirect(reverse("instances", args=[compute.id]))
conn.close()
except libvirtError as lib_err:
messages.error(request, lib_err)
if refused:
submitted_lists = refused_lists(request.POST)
if disk_source == "template":
template_restore = {"pool": request.POST.get("template_pool", ""), "template": request.POST.get("template", "")}
return render(request, "create_instance_w2.html", locals())
@superuser_only
def flavor_create(request):
form = FlavorForm(request.POST or None)
if form.is_valid():
with audit(request, None, f"Flavor {form.cleaned_data['label']}", "add"):
form.save()
messages.success(request, _("Flavor Created"))
return get_safe_redirect(request, default=reverse("instances:index"))
return render(
request,
"common/form.html",
{"form": form, "title": _("Create Flavor")},
)
@superuser_only
def flavor_update(request, pk):
flavor = get_object_or_404(Flavor, pk=pk)
subject, before = f"Flavor {flavor.label}", audit_snapshot(flavor)
form = FlavorForm(request.POST or None, instance=flavor)
if form.is_valid():
with audit(request, None, subject, "change") as entry:
form.save()
fields = changed_fields(before, audit_snapshot(flavor))
entry.action = "change " + ", ".join(fields)
entry.skip = not fields
messages.success(request, _("Flavor Updated"))
return get_safe_redirect(request, default=reverse("instances:index"))
return render(
request,
"common/form.html",
{"form": form, "title": _("Update Flavor")},
)
@superuser_only
def flavor_delete(request, pk):
flavor = get_object_or_404(Flavor, pk=pk)
if request.method == "POST":
with audit(request, None, f"Flavor {flavor.label}", "delete"):
flavor.delete()
messages.success(request, _("Flavor Deleted"))
return get_safe_redirect(request, default=reverse("instances:index"))
return render(
request,
"common/confirm_delete.html",
{"object": flavor, "consequence": _("Only the size template goes; VMs made from it keep their size.")},
)