mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
The delete ran in the request: undefine, then the VM's record, then its disk files. A request that died in between (a gunicorn worker past its timeout, OOM, a restart), or a database write that failed after the undefine, left a record that later showed as removed outside the panel, and the disk files the user asked to delete were never deleted. Deleting a VM is now an instance.delete job. It holds the compute exclusively, as the request did, so no other VM attaches a file it is about to delete. The job chooses the files before it stops the VM (a host that cannot be checked still stops nothing) and records them first. It then stops and undefines the VM, removes its record, and deletes the files, each with its own Logs row. Recovery decides from libvirt: a VM still on its host means the delete failed; a VM gone means libvirt did it, so recovery removes the record and deletes the recorded files that are still there. The page queues the job and returns to the VM page, where the job panel follows it and opens the VM list when it ends; disks that were kept are the result's warning. The API's delete answers 202 with the job and needs a submission_key. backing_path moves to wvmConnect, as recovery has no domain to ask.
2682 lines
111 KiB
Python
Executable file
2682 lines
111 KiB
Python
Executable file
try:
|
|
import crypt_r as crypt
|
|
except ImportError:
|
|
import crypt
|
|
import difflib
|
|
import functools
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
from ipaddress import ip_address
|
|
|
|
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, lock_ownership, 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 import transaction
|
|
from django.db.models import Prefetch, prefetch_related_objects
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.urls import reverse
|
|
from django.template.defaultfilters import filesizeformat
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext as _
|
|
from django.utils.translation import gettext_lazy, gettext_noop
|
|
from django.views.decorators.http import require_POST
|
|
from django.views.decorators.debug import sensitive_post_parameters
|
|
from libvirt import (VIR_DOMAIN_PAUSED,
|
|
VIR_DOMAIN_RUNNING,
|
|
VIR_DOMAIN_SHUTOFF,
|
|
VIR_DOMAIN_XML_INACTIVE,
|
|
VIR_ERR_NO_DOMAIN,
|
|
VIR_ERR_NO_SUPPORT,
|
|
libvirtError)
|
|
from logs.audit import audit, audit_vm, changed_fields, failed_vm
|
|
from logs.audit import snapshot as audit_snapshot
|
|
from operations import claims, jobs
|
|
from operations.utils import HOST_BUSY, busy_message, summary
|
|
from webvirtcloud.middleware import error_text, same_origin_referer
|
|
from vrtManager import util
|
|
from vrtManager.connection import CONNECT_TIMEOUT
|
|
from vrtManager.create import cpu_choices, wvmCreate
|
|
from vrtManager.instance import USAGE_MAX_WINDOW, wvmInstances
|
|
from vrtManager.interface import wvmInterface
|
|
from vrtManager.nodedevs import device_names, hostdev_problems, node_devices, pci_problems
|
|
from vrtManager.storage import wvmStorage
|
|
from vrtManager.util import randomPasswd
|
|
|
|
from instances.models import Instance
|
|
|
|
from . import addresses, report, utils
|
|
from .api.serializers import DestroyOptionsSerializer
|
|
from .forms import ConsoleForm, FlavorForm, NewVMForm
|
|
from .models import Flavor
|
|
from .states import POWER_STATES, STATES, UNKNOWN, allowed_actions, state_label
|
|
|
|
# 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
|
|
|
|
# a user who sees no hosts makes the panel read only those of their own VMs
|
|
computes = _listed_computes(request.user, list(Compute.objects.all().order_by("name")))
|
|
for compute in computes:
|
|
utils.refr(compute)
|
|
|
|
show_hosts = request.user.is_superuser or request.user.has_perm("instances.view_instances")
|
|
own_hosts_down = False
|
|
if request.user.is_superuser and app_settings.VIEW_INSTANCES_LIST_STYLE == "grouped":
|
|
# each host's VMs by name: the database keeps no order of its own
|
|
prefetch_related_objects(computes, Prefetch("instance_set", queryset=Instance.objects.order_by("name")),
|
|
"instance_set__userinstance_set__user")
|
|
instances = [inst for compute in computes for inst in compute.instance_set.all()]
|
|
elif show_hosts:
|
|
instances = list(Instance.objects.order_by("name").prefetch_related("userinstance_set__user"))
|
|
else:
|
|
instances = list(Instance.objects.filter(
|
|
userinstance__user=request.user
|
|
).order_by("name").prefetch_related("userinstance_set__user"))
|
|
# other users get no host names or connection errors, only whether
|
|
# a host of their own VMs is down
|
|
own_hosts_down = any(c.status is not True for c in computes)
|
|
utils.read_list_rows(instances)
|
|
|
|
return render(
|
|
request,
|
|
"allinstances.html",
|
|
{"computes": computes, "instances": instances, "show_hosts": show_hosts, "own_hosts_down": own_hosts_down,
|
|
"reachable_computes": [c for c in computes if c.status is True],
|
|
"unreachable_computes": [c for c in computes if c.status is not True],
|
|
"states": [label for label, _color in STATES.values()] + [UNKNOWN[0]],
|
|
"states_url": reverse("instances:states")},
|
|
)
|
|
|
|
|
|
def _listed_computes(user, computes):
|
|
"""The hosts of the VM list: every host for a user who sees hosts, else
|
|
only those of the user's own VMs."""
|
|
if user.is_superuser or user.has_perm("instances.view_instances"):
|
|
return computes
|
|
own = set(Instance.objects.filter(userinstance__user=user).values_list("compute_id", flat=True))
|
|
return [c for c in computes if c.pk in own]
|
|
|
|
|
|
def states(request):
|
|
"""The VM lists' poll: utils.vm_states of the list's hosts; ?compute=id
|
|
for one host's list (administrators)."""
|
|
computes = list(Compute.objects.all())
|
|
if request.GET.get("compute"):
|
|
if not request.user.is_superuser:
|
|
raise PermissionDenied
|
|
computes = [get_object_or_404(Compute, pk=request.GET["compute"])]
|
|
response = JsonResponse({"computes": utils.vm_states(request.user, _listed_computes(request.user, computes))})
|
|
response["Cache-Control"] = "no-store"
|
|
return response
|
|
|
|
|
|
def _address_scope(user):
|
|
"""What the address search and the VM report read for the user: whether
|
|
the user sees hosts, the registered VMs by (compute id, UUID), and the
|
|
targets. Administrators read every compute, VMs the panel has not
|
|
registered included; other users only their own VMs. A VM is its compute
|
|
and UUID: one UUID may be defined on two hosts."""
|
|
show_hosts = user.is_superuser or user.has_perm("instances.view_instances")
|
|
if show_hosts:
|
|
known = {(inst.compute_id, inst.uuid): inst for inst in Instance.objects.all()}
|
|
return show_hosts, known, [addresses.Target(compute, None) for compute in Compute.objects.all()]
|
|
known = {(inst.compute_id, inst.uuid): inst for inst in
|
|
Instance.objects.filter(userinstance__user=user).select_related("compute")}
|
|
scopes = {}
|
|
for inst in known.values():
|
|
scopes.setdefault(inst.compute_id, addresses.Target(inst.compute, set())).uuids.add(inst.uuid)
|
|
return show_hosts, known, list(scopes.values())
|
|
|
|
|
|
def find_address(request):
|
|
"""
|
|
The running VMs using an IP address or an address in a network, or the
|
|
VMs with a NIC of a MAC address, shut-off ones too
|
|
(instances/addresses.py), as JSON for the Instances page's dialog.
|
|
Administrators search every compute, VMs the panel has not registered
|
|
included, and see the hosts; other users only their own VMs, without
|
|
hosts or host errors.
|
|
"""
|
|
query = request.GET.get("q", "")
|
|
mac = addresses.parse_mac(query)
|
|
if mac is None:
|
|
try:
|
|
network = addresses.parse_query(query)
|
|
except ValueError:
|
|
return JsonResponse({"error": _("Enter an IP address, a network such as 192.0.2.0/24, or a MAC address.")},
|
|
status=400)
|
|
show_hosts, known, targets = _address_scope(request.user)
|
|
try:
|
|
result = addresses.find_mac(targets, mac) if mac else addresses.find(targets, network)
|
|
except addresses.Busy:
|
|
return JsonResponse({"error": _("Too many searches are still running; try again shortly.")}, status=409)
|
|
|
|
hosts = {target.compute.id: target.compute.name for target in targets}
|
|
matches = []
|
|
for match in result["matches"]:
|
|
inst = known.get((match["compute_id"], match["uuid"]))
|
|
if inst is None and not show_hosts:
|
|
continue
|
|
row = {key: match[key] for key in ("address", "mac", "sources", "conflict")}
|
|
row.update(vm=inst.name if inst else match["name"], registered=inst is not None,
|
|
url=reverse("instances:instance", args=[inst.id]) + "#network" if inst else None)
|
|
if show_hosts:
|
|
row["host"] = hosts[match["compute_id"]]
|
|
matches.append(row)
|
|
coverage = {key: result[key] for key in ("checked", "no_agent", "unread")}
|
|
if show_hosts:
|
|
coverage.update(hosts_down=result["hosts_down"], hosts_unfinished=result["hosts_unfinished"])
|
|
else:
|
|
coverage["some_unchecked"] = bool(result["hosts_down"] or result["hosts_unfinished"])
|
|
return JsonResponse({"matches": matches, "coverage": coverage, "read_at": timezone.now().isoformat()})
|
|
|
|
|
|
@superuser_only
|
|
def vm_report(request):
|
|
"""
|
|
Every VM on every compute (shut-off ones and those the panel has not
|
|
registered too), with its state, host, OS, size, addresses and owners,
|
|
as an Excel workbook; a second sheet says what could not be read
|
|
(instances/addresses.py inventory). Linked from the Administration menu.
|
|
"""
|
|
known, targets = _address_scope(request.user)[1:]
|
|
try:
|
|
result = addresses.inventory(targets)
|
|
except addresses.Busy:
|
|
messages.error(request, _("Too many searches are still running; try again shortly."))
|
|
return redirect("instances:index")
|
|
|
|
hosts = {target.compute.id: target.compute.name for target in targets}
|
|
owners = {}
|
|
for owner in UserInstance.objects.select_related("user", "instance"):
|
|
owners.setdefault((owner.instance.compute_id, owner.instance.uuid), []).append(owner.user.username)
|
|
header = ["VM", "State", "Host", "OS", "OS family", "vCPUs", "Memory (MiB)", "IP addresses",
|
|
"Address sources", "MAC addresses", "Owners", "In the panel"]
|
|
rows = []
|
|
for vm in result["vms"]:
|
|
key = (vm["compute_id"], vm["uuid"])
|
|
# a link-local address (fe80::) is not one to report: every NIC has its own
|
|
reported = [(address, sources) for address, sources in vm["addresses"]
|
|
if not ip_address(address).is_link_local]
|
|
found = {source for _address, sources in reported for source in sources}
|
|
row = {"VM": vm["name"], "State": str(state_label(vm["state"])[0]), "Host": hosts[vm["compute_id"]],
|
|
"OS": vm["os"], "OS family": vm["os_family"], "vCPUs": vm["vcpus"], "Memory (MiB)": vm["memory"],
|
|
"IP addresses": ", ".join(address for address, _sources in reported),
|
|
"Address sources": ", ".join(s for s in addresses.SOURCES.values() if s in found),
|
|
"MAC addresses": ", ".join(vm["macs"]), "Owners": ", ".join(sorted(owners.get(key, []))),
|
|
"In the panel": "yes" if key in known else "no"}
|
|
rows.append(list(row.values()))
|
|
|
|
notes = [f"Created: {timezone.localtime():%Y-%m-%d %H:%M}",
|
|
f"VMs listed: {len(rows)}",
|
|
f"Running VMs read: {result['checked']}",
|
|
f"Without a connected guest agent (a static address on them cannot be seen): {result['no_agent']}",
|
|
f"Guest agents not read (a paused VM, no answer in time, or an error): {result['unread']}"]
|
|
if result["hosts_down"]:
|
|
notes.append("Hosts not reached: " + ", ".join(result["hosts_down"]))
|
|
if result["hosts_unfinished"]:
|
|
notes.append("Hosts not finished in time: " + ", ".join(result["hosts_unfinished"]))
|
|
response = HttpResponse(report.workbook("VMs", header, rows, "Coverage", notes),
|
|
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
|
response["Content-Disposition"] = f'attachment; filename="vms-{timezone.localtime():%Y%m%d-%H%M}.xlsx"'
|
|
return response
|
|
|
|
|
|
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 isinstance(compute.connection, util.HostBusy):
|
|
msg = _(HOST_BUSY)
|
|
elif 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"))
|
|
try:
|
|
instance.proxy
|
|
except libvirtError as err:
|
|
if err.get_error_code() != VIR_ERR_NO_DOMAIN:
|
|
raise
|
|
# removed outside the panel: the list's sync forgets its record
|
|
messages.error(request, _("'%(name)s' is no longer on its host.") % {"name": instance.name})
|
|
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()
|
|
# the add-owner choices: users who are not owners yet; a superuser can use every VM
|
|
users = User.objects.filter(is_superuser=False).exclude(userinstance__instance=instance).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.
|
|
# a template is cloned, not run: no console settings or host devices on its page
|
|
can_manage_console = utils.can_manage_console(request.user, instance) and not instance.is_template
|
|
console_form = None
|
|
if can_manage_console:
|
|
console_form = ConsoleForm(instance=instance, is_superuser=request.user.is_superuser)
|
|
console_listener_addresses = settings.QEMU_CONSOLE_LISTENER_ADDRESSES
|
|
ipv4_addresses = [address for net in instance.networks for address in net["ipv4"] or []]
|
|
try:
|
|
userinstance = UserInstance.objects.get(instance=instance, user=request.user)
|
|
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
|
|
power_actions = allowed_actions(request.user, instance, instance.status)
|
|
power_states = POWER_STATES # the power tab names any other state above its strip
|
|
# a remote viewer on the user's computer reaches a VNC or SPICE console that listens beyond the host's loopback
|
|
vdi_reachable = instance.console_type in ("vnc", "spice") and not util.is_loopback(instance.console_listener_address)
|
|
# root's password and keys change in a shut-off VM's disk (gstfsd on the host), or through a running VM's agent
|
|
guest_access_ready = instance.status == 5 or (instance.status == 1 and instance.guest_agent_ready)
|
|
# 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
|
|
)
|
|
|
|
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
|
|
|
|
# userinstances = UserInstance.objects.filter(instance=instance).order_by('user__username')
|
|
userinstances = instance.userinstance_set.order_by("user__username")
|
|
# Host resources
|
|
vcpu_host = len(instance.proxy.get_max_cpus()) # the host's logical CPUs, not the VM's maximum
|
|
memory_host = instance.proxy.get_max_memory()
|
|
bus_host = instance.proxy.get_disk_bus_types(instance.arch, instance.machine)
|
|
cdrom_buses = cdrom_bus_choices(instance)
|
|
tpm_support = bool(instance.proxy.get_tpm_model(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()
|
|
|
|
usb_hostdevs, pci_hostdevs, other_hostdevs = instance.proxy.get_hostdevs()
|
|
if usb_hostdevs or pci_hostdevs:
|
|
names = device_names(instance.proxy)
|
|
for row in usb_hostdevs:
|
|
row["name"] = names.get((row["vendor"], row["product"]), "")
|
|
for row in pci_hostdevs:
|
|
row["name"] = names.get(row["address"], "")
|
|
# owners see the devices their VM has; only superusers change them
|
|
show_hostdevs = not instance.is_template and (
|
|
request.user.is_superuser or bool(usb_hostdevs or pci_hostdevs or other_hostdevs))
|
|
show_settings = show_settings or show_hostdevs
|
|
|
|
# what keeps the Migrate tab from migrating the VM to any destination
|
|
if computes_count == 1:
|
|
migrate_refusal = _("There is no other host to migrate to.")
|
|
elif instance.status not in (1, 3, 5):
|
|
migrate_refusal = _("A VM migrates when it is running, paused or shut off.")
|
|
elif instance.snapshots:
|
|
migrate_refusal = _("A VM with snapshots cannot be migrated: its snapshots stay on this host. Delete them first.")
|
|
elif usb_hostdevs or pci_hostdevs or other_hostdevs:
|
|
migrate_refusal = _("A VM with host devices cannot be migrated: the destination may not have them. Remove them first.")
|
|
else:
|
|
migrate_refusal = None
|
|
|
|
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, OSError):
|
|
return _("Unknown") # not "None DRBD": the compute did not tell
|
|
if remoteDrbdStatus.returncode != 0:
|
|
return _("Unknown")
|
|
|
|
# the VM's resources are its name or its name_SUFFIX, at the start of a line
|
|
instanceFindDrbd = re.compile(
|
|
"^" + re.escape(instance.name) + "(?:_[A-Z]+)? role:(.+?)\n disk:(.+?)\n",
|
|
re.IGNORECASE | re.MULTILINE,
|
|
)
|
|
instanceDrbd = instanceFindDrbd.findall(remoteDrbdStatus.stdout or "")
|
|
if instanceDrbd:
|
|
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": [{"mac": nic["mac"], "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)
|
|
try:
|
|
return JsonResponse(instance.proxy.osinfo())
|
|
except libvirtError as err:
|
|
return JsonResponse({"error": error_text(request, err)}, status=400)
|
|
|
|
|
|
@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)
|
|
# the keys of the VM's owners, owner by owner
|
|
instance_keys = list(UserSSHKey.objects.filter(user__userinstance__instance=instance)
|
|
.order_by("user__userinstance__id", "id").values_list("keypublic", flat=True))
|
|
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 _power_instance(user, pk):
|
|
"""The VM to power on, off or cycle: a VM an administrator paused stays
|
|
paused until an administrator resumes it."""
|
|
instance = get_instance(user, pk, perm_type="power")
|
|
if not user.is_superuser and instance.proxy.get_status() == VIR_DOMAIN_PAUSED:
|
|
raise PermissionDenied
|
|
return instance
|
|
|
|
|
|
def from_script(request):
|
|
"""A form a page's script sends and answers itself: the create page's
|
|
flavor forms (it swaps in the reply), the XML editor's dialog (JSON)."""
|
|
return request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
|
|
|
|
|
def _busy(request, pk, busy):
|
|
if hasattr(request, "accepted_renderer") or from_script(request):
|
|
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") or from_script(request):
|
|
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, without blocking sibling VMs. Only a user who may
|
|
change the VM somehow (power, the least right) takes its claim: a
|
|
refused request must not hold up others."""
|
|
@functools.wraps(func)
|
|
def wrapper(request, pk, *args, **kwargs):
|
|
inst = get_instance(request.user, pk, perm_type="power")
|
|
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, text, **values):
|
|
"""The message for a change the quota refuses: text names the change
|
|
after %(quota)s, the quota it would exceed."""
|
|
if quota == utils.QUOTA_UNVERIFIED:
|
|
return _(utils.QUOTA_UNVERIFIED_TEXT)
|
|
return util.render(text, {"quota": utils.quota_message(quota), **values}, _)
|
|
|
|
|
|
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.
|
|
Only a user who may delete the VM (destroy's right; the other views are
|
|
superuser-only) takes the claim: a refused request must not hold up others."""
|
|
@functools.wraps(func)
|
|
def wrapper(request, pk, *args, **kwargs):
|
|
inst = get_instance(request.user, pk, perm_type="delete")
|
|
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)
|
|
]
|
|
|
|
|
|
# libvirt's words when a running q35 VM has no free root port for a PCIe device
|
|
PCI_SLOTS_FULL = "No more available PCI slots"
|
|
# a disk on the scsi bus needs no slot of its own when the VM already has a SCSI controller
|
|
DISK_NO_PCI_SLOT = gettext_lazy("The running VM has no free PCI slot for another disk on this bus: choose the scsi bus "
|
|
"if the VM has a SCSI controller, or shut the VM down and add the disk then; Add PCIe slots "
|
|
"on the Disks tab gives it free ones from its next start. A new volume stays in its pool, "
|
|
"to attach as an existing volume.")
|
|
SCSI_NO_PCI_SLOT = gettext_lazy("The running VM has no free PCI slot for the SCSI controller the disk needs: shut the VM "
|
|
"down and add the disk then; Add PCIe slots on the Disks tab gives it free ones from its next "
|
|
"start. A new volume stays in its pool, to attach as an existing volume.")
|
|
|
|
|
|
def no_pci_slot(request, err, advice):
|
|
"""True, with advice shown, when libvirt refused a running VM's new device
|
|
for want of a PCI slot: q35 gives each PCIe device a root port of its own
|
|
and a running VM gets no new one."""
|
|
if PCI_SLOTS_FULL not in str(err):
|
|
return False
|
|
messages.error(request, advice)
|
|
return True
|
|
|
|
|
|
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 = _power_instance(request.user, pk)
|
|
if instance.is_template:
|
|
messages.warning(request, _("Templates cannot be started."))
|
|
else:
|
|
with audit_vm(request, instance, "power on"):
|
|
instance.proxy.start()
|
|
|
|
return get_safe_redirect(
|
|
request, default=reverse("instances:instance", args=[instance.id])
|
|
)
|
|
|
|
|
|
@require_POST
|
|
@serialize_instance_mutation
|
|
def powercycle(request, pk):
|
|
instance = _power_instance(request.user, pk)
|
|
if instance.is_template:
|
|
messages.warning(request, _("Templates cannot be started."))
|
|
else:
|
|
with audit_vm(request, instance, "power cycle"):
|
|
instance.proxy.force_shutdown()
|
|
instance.proxy.start()
|
|
return get_safe_redirect(
|
|
request, default=reverse("instances:instance", args=[instance.id])
|
|
)
|
|
|
|
|
|
@require_POST
|
|
@serialize_instance_mutation
|
|
def poweroff(request, pk):
|
|
instance = _power_instance(request.user, pk)
|
|
with audit_vm(request, instance, "power off"):
|
|
instance.proxy.shutdown()
|
|
|
|
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)
|
|
with audit_vm(request, instance, "suspend"):
|
|
instance.proxy.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)
|
|
with audit_vm(request, instance, "resume"):
|
|
instance.proxy.resume()
|
|
return get_safe_redirect(
|
|
request, default=reverse("instances:instance", args=[instance.id])
|
|
)
|
|
|
|
|
|
@require_POST
|
|
@serialize_instance_mutation
|
|
def force_off(request, pk):
|
|
instance = _power_instance(request.user, pk)
|
|
with audit_vm(request, instance, "force off"):
|
|
instance.proxy.force_shutdown()
|
|
return get_safe_redirect(
|
|
request, default=reverse("instances:instance", args=[instance.id])
|
|
)
|
|
|
|
|
|
def destroy(request, pk):
|
|
if request.method in ["POST", "DELETE"]:
|
|
get_instance(request.user, pk, perm_type="delete")
|
|
options = DestroyOptionsSerializer(data=request.POST)
|
|
options.is_valid(raise_exception=True)
|
|
try:
|
|
op = jobs.submit("instance.delete", request.user, {"instance": pk, **options.validated_data},
|
|
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))
|
|
return redirect(reverse("instances:destroy", args=[pk]))
|
|
jobs.remember_job(request, op)
|
|
return redirect(reverse("instances:instance", args=[pk]))
|
|
|
|
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"),
|
|
"autoconverge": request.POST.get("autoconverge"), "compress": request.POST.get("compress"),
|
|
"delete_source": request.POST.get("delete_source"),
|
|
"disk_pools": {key.removeprefix("pool-"): value for key, value in request.POST.items()
|
|
if key.startswith("pool-")}}
|
|
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:
|
|
jobs.remember_job(request, op)
|
|
return get_safe_redirect(request, default=reverse("instances:instance", args=[pk]))
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
def move_disk(request, pk):
|
|
get_instance(request.user, pk)
|
|
raw = {"instance": pk, "dev": request.POST.get("dev", ""), "pool": request.POST.get("pool", "")}
|
|
try:
|
|
op = jobs.submit("disk.move", 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:
|
|
jobs.remember_job(request, op)
|
|
return _back(request, pk, "disks")
|
|
|
|
|
|
@superuser_only
|
|
def migrate_targets(request, pk):
|
|
"""The Migrate tab's destinations that cannot be chosen, {id: why}: one
|
|
that cannot be reached, or the VM's own host under another record.
|
|
Loaded when the tab opens, so the page does not wait for the hosts."""
|
|
from instances.jobs import migration_target
|
|
|
|
instance = get_instance(request.user, pk)
|
|
refused = {}
|
|
for target in Compute.objects.exclude(pk=instance.compute_id):
|
|
try:
|
|
migration_target(instance, target.pk)
|
|
except libvirtError as err:
|
|
refused[target.pk] = error_text(request, err)
|
|
return JsonResponse(refused)
|
|
|
|
|
|
@superuser_only
|
|
def migrate_plan(request, pk):
|
|
"""The disks of the Migrate tab for the chosen destination, loaded when
|
|
one is chosen: each with the pools of the destination it can be copied
|
|
into, or the storage both hosts share."""
|
|
instance = get_instance(request.user, pk)
|
|
compute_id = request.GET.get("compute_id", "")
|
|
if not compute_id.isdigit():
|
|
raise Http404
|
|
target = get_object_or_404(Compute, pk=compute_id)
|
|
dest = None
|
|
try:
|
|
dest = wvmInstances(target.hostname, target.login, target.password, target.type)
|
|
pools = [{"name": name, "free": pool.info()[3]} for name, (pool, directory) in sorted(dest.file_pools().items())]
|
|
domain = instance.proxy.instance
|
|
disks, pending = dest.migration_plan(instance.proxy, domain.XMLDesc(0), domain.XMLDesc(VIR_DOMAIN_XML_INACTIVE))
|
|
context = {"disks": disks, "pending": pending, "pools": pools, "shut_off": instance.proxy.get_status() == 5}
|
|
except libvirtError as err:
|
|
context = {"error": error_text(request, err)}
|
|
finally:
|
|
if dest is not None:
|
|
dest.close()
|
|
return render(request, "instances/migrate_plan.html", context)
|
|
|
|
|
|
GSTFSD_PORT = 16510
|
|
GSTFSD_TIMEOUT = 120 # gstfsd answers within its 100 s limit for a request (GSTFSD_DEADLINE)
|
|
GSTFSD_MAX_FRAME = 64 * 1024 # a challenge or a reply of gstfsd is far shorter
|
|
|
|
|
|
def gstfsd_mac(key, kind, challenge, nonce, payload):
|
|
"""The HMAC-SHA256 gstfsd signs and checks with (see conf/daemon/gstfsd)."""
|
|
message = "\n".join(("gstfsd-v1", kind, challenge, nonce, payload)).encode()
|
|
return hmac.new(bytes.fromhex(key), message, hashlib.sha256).hexdigest()
|
|
|
|
|
|
def gstfsd_request(compute, data):
|
|
"""Send one request to the compute's gstfsd, signed with its key, and
|
|
return the reply gstfsd signed for it; an error reply or a failed or
|
|
unsigned exchange raises OperationError with the reason."""
|
|
if not compute.gstfsd_key:
|
|
raise util.OperationError(_("Set the gstfsd key of %(compute)s first (Computes, edit the host)") % {
|
|
"compute": compute.name})
|
|
try:
|
|
# the host name may resolve to IPv4 or IPv6
|
|
# a host that is down is told in seconds, as libvirt's; the change itself may take GSTFSD_TIMEOUT
|
|
s = socket.create_connection((compute.hostname, GSTFSD_PORT), timeout=CONNECT_TIMEOUT)
|
|
except OSError as err:
|
|
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
|
|
try:
|
|
s.settimeout(GSTFSD_TIMEOUT)
|
|
stream = s.makefile("rb")
|
|
first = json.loads(stream.readline(GSTFSD_MAX_FRAME))
|
|
if "error" in first: # busy: gstfsd took no request
|
|
raise ValueError(first["error"])
|
|
challenge = first["challenge"]
|
|
nonce, request = secrets.token_hex(32), json.dumps(data)
|
|
s.sendall(json.dumps({"nonce": nonce, "request": request,
|
|
"mac": gstfsd_mac(compute.gstfsd_key, "request", challenge, nonce, request)}).encode())
|
|
# gstfsd sends one reply and closes the connection
|
|
answer = stream.read(GSTFSD_MAX_FRAME + 1)
|
|
if len(answer) > GSTFSD_MAX_FRAME:
|
|
raise ValueError("the reply is too long")
|
|
frame = json.loads(answer)
|
|
reply = json.loads(frame["reply"])
|
|
except (OSError, ValueError, KeyError, TypeError) as err:
|
|
raise util.OperationError(_("gstfsd error: %(err)s") % {"err": err}) from err
|
|
finally:
|
|
s.close()
|
|
given = frame.get("mac")
|
|
if not (isinstance(given, str) and re.fullmatch(r"[0-9a-f]{64}", given) and hmac.compare_digest(
|
|
given, gstfsd_mac(compute.gstfsd_key, "reply", challenge, nonce, frame["reply"]))):
|
|
# gstfsd refused the request (a wrong key): its error says so; any
|
|
# other reply that is not signed is no answer of gstfsd
|
|
refused = isinstance(reply, dict) and reply.get("return") == "error"
|
|
raise util.OperationError(_("gstfsd error: %(err)s") % {
|
|
"err": reply.get("message") if refused else _("the reply is not signed with the key")})
|
|
if reply["return"] != "success":
|
|
raise util.OperationError(reply["message"])
|
|
return reply
|
|
|
|
|
|
# root password and SSH key: gstfsd changes a shut-off VM's disk, the QEMU
|
|
# guest agent a running VM
|
|
AGENT_OR_SHUT_OFF = gettext_lazy("Shut the VM down, or start its QEMU guest agent, and try again")
|
|
|
|
|
|
# libvirt's value; bindings built against older libvirt (Ubuntu 24.04's) lack
|
|
# the name, and the code comes from the host's libvirtd
|
|
VIR_ERR_AGENT_COMMAND_TIMEOUT = 112
|
|
|
|
|
|
def access_error(request, err):
|
|
"""The error of a root password or SSH key change. A command that timed
|
|
out was sent to the agent, which still runs it when it answers again."""
|
|
text = error_text(request, err)
|
|
if isinstance(err, libvirtError) and err.get_error_code() == VIR_ERR_AGENT_COMMAND_TIMEOUT:
|
|
text += " " + _("The change may still be made when the agent answers again.")
|
|
return text
|
|
|
|
|
|
@sensitive_post_parameters()
|
|
@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()}
|
|
|
|
status = instance.proxy.get_status()
|
|
if status == 5 or (status == 1 and instance.guest_agent_ready):
|
|
try:
|
|
with audit_vm(request, instance, "reset root password"):
|
|
if status == 5:
|
|
gstfsd_request(instance.compute, data)
|
|
else:
|
|
instance.proxy.set_user_password("root", passwd_hash)
|
|
except (util.OperationError, libvirtError) as err:
|
|
messages.error(request, access_error(request, err))
|
|
else:
|
|
messages.success(request, _("Reset root password"))
|
|
else:
|
|
messages.error(request, AGENT_OR_SHUT_OFF)
|
|
else:
|
|
messages.error(request, _("Enter a password"))
|
|
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(),
|
|
}
|
|
|
|
status = instance.proxy.get_status()
|
|
if status == 5 or (status == 1 and instance.guest_agent_ready):
|
|
try:
|
|
with audit_vm(request, instance, f"install SSH key {publickey.keyname}"):
|
|
if status == 5:
|
|
gstfsd_request(instance.compute, data)
|
|
else:
|
|
instance.proxy.add_authorized_key("root", publickey.keypublic)
|
|
except (util.OperationError, libvirtError) as err:
|
|
messages.error(request, access_error(request, err))
|
|
else:
|
|
messages.success(request, _("Installed new SSH public key %(keyname)s") % {
|
|
"keyname": publickey.keyname
|
|
})
|
|
else:
|
|
messages.error(request, AGENT_OR_SHUT_OFF)
|
|
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) or int(new_cur_vcpu) < 1:
|
|
messages.error(request, _("Enter a whole number of vCPUs"))
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
|
|
# a running VM would get them only at its next start, past the quota counted now
|
|
if instance.proxy.instance.isActive():
|
|
messages.error(request, _("Shut off the VM to change its vCPUs"))
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resize")
|
|
if int(new_cur_vcpu) > int(new_vcpu):
|
|
messages.error(request, _("The current vCPUs cannot be more than the maximum"))
|
|
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, gettext_noop("%(quota)s: cannot resize the CPU of '%(instance_name)s'."),
|
|
instance_name=instance.name)
|
|
messages.error(request, msg)
|
|
else:
|
|
cur_vcpu = new_cur_vcpu
|
|
vcpu = new_vcpu
|
|
with audit_vm(request, instance, f"set vCPUs to {cur_vcpu} (maximum {vcpu})"):
|
|
instance.proxy.resize_cpu(cur_vcpu, vcpu)
|
|
messages.success(request, _("vCPUs set to %(cur)s (maximum %(max)s)") % {"cur": cur_vcpu, "max": vcpu})
|
|
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()
|
|
|
|
new_memory = request.POST.get("memory", "")
|
|
new_cur_memory = request.POST.get("cur_memory", "")
|
|
# 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) or int(new_cur_memory) < 1:
|
|
messages.error(request, _("Enter a whole number for memory"))
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resizevm_mem")
|
|
if int(new_cur_memory) > int(new_memory):
|
|
messages.error(request, _("The current memory cannot be more than the maximum"))
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resizevm_mem")
|
|
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, gettext_noop("%(quota)s: cannot resize the memory of '%(instance_name)s'."),
|
|
instance_name=instance.name)
|
|
messages.error(request, msg)
|
|
else:
|
|
with audit_vm(request, instance, f"set memory to {new_cur_memory} MiB (maximum {new_memory} MiB)"):
|
|
instance.proxy.resize_mem(new_cur_memory, new_memory)
|
|
messages.success(request, _("Memory set to %(cur)s MiB (maximum %(max)s MiB)") % {
|
|
"cur": new_cur_memory, "max": new_memory})
|
|
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resizevm_mem")
|
|
|
|
|
|
@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:
|
|
# an empty field leaves the disk as it is; a disk outside a pool has no size and no field
|
|
size = request.POST.get("disk_size_" + disk["dev"], "")
|
|
if not size or disk["size"] is None:
|
|
continue
|
|
if not _is_int(size):
|
|
messages.error(request, _("Enter disk sizes as whole GiB"))
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resizevm_disk")
|
|
# a disk does not shrink: one smaller size refuses them all
|
|
if int(size) << 30 < disk["size"]:
|
|
messages.error(request, _("%(dev)s is %(size)s: a disk cannot shrink") % {
|
|
"dev": disk["dev"], "size": filesizeformat(disk["size"])})
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resizevm_disk")
|
|
if int(size) << 30 > disk["size"]:
|
|
disk["size_new"] = int(size) << 30
|
|
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]) + "#resizevm_disk")
|
|
|
|
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, gettext_noop("%(quota)s: cannot resize the disks of '%(instance_name)s'."),
|
|
instance_name=instance.name)
|
|
messages.error(request, msg)
|
|
else:
|
|
for disk in disks_new:
|
|
try:
|
|
with audit_vm(request, instance, f"resize disk {disk['dev']} to {disk['size_new'] >> 30} GiB"):
|
|
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
|
|
messages.success(request, _("Disk is resized: %(dev)s") % {"dev": disk["dev"]})
|
|
|
|
return redirect(reverse("instances:instance", args=[instance.id]) + "#resizevm_disk")
|
|
|
|
|
|
@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)
|
|
|
|
try:
|
|
with audit_vm(request, instance, f"attach new disk {storage}/{name}"):
|
|
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,
|
|
)
|
|
except libvirtError as err:
|
|
# the new volume stays in the pool, to attach from Existing volume: it is never deleted unasked
|
|
if not no_pci_slot(request, err, SCSI_NO_PCI_SLOT if bus == "scsi" else DISK_NO_PCI_SLOT):
|
|
raise
|
|
return _back(request, pk, "disks")
|
|
|
|
|
|
# the PCIe slots the Add slots button adds at a time
|
|
PCIE_SLOTS_ADDED = 4
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def add_pcie_slots(request, pk):
|
|
"""Free PCIe slots for devices added later: a running VM gets them at its next start."""
|
|
instance = get_instance(request.user, pk, perm_type="change")
|
|
if instance.pcie_slots is None:
|
|
messages.error(request, _("The VM's machine has no PCIe slots to add"))
|
|
return _back(request, pk, "disks")
|
|
with audit_vm(request, instance, f"add {PCIE_SLOTS_ADDED} PCIe slots"):
|
|
instance.proxy.add_pcie_slots(PCIE_SLOTS_ADDED)
|
|
if instance.proxy.get_status() == 5:
|
|
messages.success(request, _("%(count)s PCIe slots added") % {"count": PCIE_SLOTS_ADDED})
|
|
else:
|
|
messages.success(request, _("%(count)s PCIe slots added: the VM gets them at its next start")
|
|
% {"count": PCIE_SLOTS_ADDED})
|
|
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()
|
|
|
|
# a form sent twice would attach the file twice, and the VM would not start again
|
|
if source in {disk["path"] for disk in instance.disks + instance.config_disks}:
|
|
messages.error(request, _("%(volume)s is already a disk of this VM") % {"volume": name})
|
|
return _back(request, pk, "disks")
|
|
|
|
target_dev = utils.get_new_disk_dev(
|
|
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
|
|
)
|
|
|
|
try:
|
|
with audit_vm(request, instance, f"attach disk {storage}/{name} as {target_dev}"):
|
|
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,
|
|
)
|
|
except libvirtError as err:
|
|
if not no_pci_slot(request, err, SCSI_NO_PCI_SLOT if bus == "scsi" else DISK_NO_PCI_SLOT):
|
|
raise
|
|
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)
|
|
|
|
with audit_vm(request, instance, f"edit disk {target_dev}"):
|
|
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."))
|
|
|
|
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")
|
|
|
|
volume = f"{disk['storage']}/{disk['image']}"
|
|
with audit_vm(request, instance, f"detach disk {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):
|
|
failed_vm(request, instance, f"delete volume {volume}", "the guest has not released the disk")
|
|
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:
|
|
with audit_vm(request, instance, f"delete volume {volume}"):
|
|
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},
|
|
)
|
|
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", "")
|
|
with audit_vm(request, instance, f"detach disk {dev}"):
|
|
instance.proxy.detach_disk(dev)
|
|
|
|
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")
|
|
buses = cdrom_bus_choices(instance)
|
|
bus = request.POST.get("bus", buses[0] if buses else "")
|
|
image = request.POST.get("media", "")
|
|
if bus not in buses:
|
|
messages.error(request, _("This VM cannot take a CD-ROM on the %(bus)s bus now") % {"bus": bus})
|
|
return _back(request, pk, "disks")
|
|
if bus == "usb" and not image:
|
|
messages.error(request, _("A USB CD-ROM needs an ISO image: libvirt has no empty USB drive"))
|
|
return _back(request, pk, "disks")
|
|
path = instance.proxy.get_iso_path(image) if image else ""
|
|
# a form sent twice (a double click) adds the drive once
|
|
if path and path in {media["path"] for media in instance.media + instance.config_media}:
|
|
messages.error(request, _("%(image)s is already in a CD-ROM of this VM") % {"image": image})
|
|
return _back(request, pk, "disks")
|
|
|
|
target = utils.get_new_disk_dev(
|
|
instance.media + instance.config_media, instance.disks + instance.config_disks, bus
|
|
)
|
|
try:
|
|
with audit_vm(request, instance, f"add CD-ROM {target}" + (f" with {image}" if image else "")):
|
|
instance.proxy.attach_disk(
|
|
target,
|
|
path,
|
|
disk_device="cdrom",
|
|
cache_mode="none",
|
|
target_bus=bus,
|
|
readonly=True,
|
|
)
|
|
except (libvirtError, util.OperationError) as err:
|
|
messages.error(request, err)
|
|
|
|
return _back(request, pk, "disks")
|
|
|
|
|
|
def cdrom_bus_choices(instance):
|
|
"""The buses a CD-ROM can be added on now, the default first. A running VM
|
|
takes USB and SCSI drives only; Windows sees USB, SATA and IDE ones
|
|
without a driver."""
|
|
if instance.status != 5:
|
|
order = ("usb", "scsi")
|
|
elif instance.machine == "pc":
|
|
order = ("ide", "sata", "scsi", "usb")
|
|
else:
|
|
order = ("sata", "scsi", "usb")
|
|
offered = instance.proxy.get_disk_bus_types(instance.arch, instance.machine)
|
|
return [bus for bus in order if bus in offered]
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def detach_cdrom(request, pk, dev):
|
|
instance = get_instance(request.user, pk, perm_type="change")
|
|
with audit_vm(request, instance, f"remove CD-ROM {dev}"):
|
|
instance.proxy.detach_disk(dev)
|
|
|
|
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:
|
|
with audit_vm(request, instance, f"eject media from {dev}"):
|
|
instance.proxy.umount_iso(dev)
|
|
except libvirtError as err:
|
|
messages.error(request, err)
|
|
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:
|
|
with audit_vm(request, instance, f"insert media {image} into {dev}"):
|
|
instance.proxy.mount_iso(dev, image)
|
|
except libvirtError as err:
|
|
messages.error(request, err)
|
|
return redirect(same_origin_referer(request, reverse("instances:instance", args=[pk])) + "#disks")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
def refresh_iso(request, pk):
|
|
instance = get_instance(request.user, pk, perm_type="change")
|
|
for pool, err in instance.proxy.refresh_iso_pools().items():
|
|
messages.error(request, _("Pool %(pool)s was not read again: %(err)s") % {"pool": pool, "err": err})
|
|
return _back(request, 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", "")})
|
|
|
|
|
|
@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:
|
|
with audit_vm(request, instance, f"delete snapshot {snap_name}"):
|
|
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, "snapshots")
|
|
|
|
|
|
@require_POST
|
|
def revert_snapshot(request, pk):
|
|
return _snapshot_job(request, pk, "snapshot.revert", {"name": request.POST.get("name", "")})
|
|
|
|
|
|
@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"})
|
|
|
|
|
|
@require_POST
|
|
def revert_external_snapshot(request, pk):
|
|
return _snapshot_job(request, pk, "snapshot.revert", {"name": request.POST.get("name", ""), "external": True})
|
|
|
|
|
|
def _snapshot_job(request, pk, kind, params):
|
|
"""Queue a snapshot job on the VM: the job panel shows it; 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:
|
|
jobs.remember_job(request, op)
|
|
return _back(request, pk, "snapshots")
|
|
|
|
|
|
@require_POST
|
|
def delete_external_snapshot(request, pk):
|
|
return _snapshot_job(request, pk, "snapshot.delete", {"name": request.POST.get("name", "")})
|
|
|
|
|
|
@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", "") == "True"
|
|
with audit_vm(request, instance, f"{'enable' if enabled else 'disable'} vCPU {id}"):
|
|
instance.proxy.set_vcpu(id, 1 if enabled else 0)
|
|
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
|
|
with audit_vm(request, instance, f"{'enable' if status else 'disable'} vCPU hot-plug"):
|
|
instance.proxy.set_vcpu_hotplug(status)
|
|
return _back(request, pk, "resize")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def set_autostart(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
with audit_vm(request, instance, "enable autostart"):
|
|
instance.proxy.set_autostart(1)
|
|
return _back(request, pk, "boot_opt")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def unset_autostart(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
with audit_vm(request, instance, "disable autostart"):
|
|
instance.proxy.set_autostart(0)
|
|
return _back(request, pk, "boot_opt")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def set_bootmenu(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
with audit_vm(request, instance, "enable boot menu"):
|
|
instance.proxy.set_bootmenu(1)
|
|
return _back(request, pk, "boot_opt")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def unset_bootmenu(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
with audit_vm(request, instance, "disable boot menu"):
|
|
instance.proxy.set_bootmenu(0)
|
|
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 not bootorder:
|
|
messages.error(request, _("Add at least one device to the boot order."))
|
|
return _back(request, pk, "boot_opt")
|
|
order_list = {}
|
|
for idx, val in enumerate(bootorder.split(",")):
|
|
dev_type, dev = val.split(":", 1)
|
|
order_list[idx] = {"type": dev_type, "dev": dev}
|
|
try:
|
|
with audit_vm(request, instance, "change boot order"):
|
|
instance.proxy.set_bootorder(order_list)
|
|
except util.OperationError as err:
|
|
messages.error(request, err)
|
|
return _back(request, pk, "boot_opt")
|
|
|
|
if instance.proxy.get_status() != 5:
|
|
messages.success(request, _("Boot order changed. It applies at the next start."))
|
|
else:
|
|
messages.success(request, _("Boot order changed successfully."))
|
|
return _back(request, pk, "boot_opt")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def change_xml(request, pk):
|
|
"""
|
|
The XML editor's dialog: without confirm, the lines the edited XML changes
|
|
in the persistent definition, to review; with confirm, the edited XML is
|
|
defined. A running VM uses it at its next start.
|
|
Only over the definition the editor was opened with (base), so a change
|
|
made since is not overwritten, and only with the VM's UUID, by which
|
|
libvirt finds the VM to define. A form posts its line breaks as CRLF. A
|
|
refusal comes back to the dialog and the edited XML stays in the editor.
|
|
"""
|
|
instance = get_instance(request.user, pk)
|
|
new_xml = request.POST.get("inst_xml", "").replace("\r\n", "\n")
|
|
if not new_xml.strip():
|
|
return JsonResponse({"error": _("The XML is empty")}, status=400)
|
|
try:
|
|
uuid = util.get_xml_path(new_xml, "/domain/uuid")
|
|
except util.etree.Error as err:
|
|
return JsonResponse({"error": _("The XML is not well-formed: %(error)s") % {"error": err}}, status=400)
|
|
if (uuid or "").strip().lower() != instance.uuid:
|
|
return JsonResponse({"error": _("The XML must keep the VM's UUID %(uuid)s.") % {"uuid": instance.uuid}},
|
|
status=400)
|
|
current = instance.inst_xml
|
|
if request.POST.get("base", "").replace("\r\n", "\n") != current:
|
|
return JsonResponse({"error": _("The definition has changed since the editor was opened. "
|
|
"Reload the page to edit the current one.")}, status=409)
|
|
if "confirm" not in request.POST:
|
|
diff = difflib.unified_diff(current.splitlines(), new_xml.splitlines(), "current", "new", lineterm="")
|
|
return JsonResponse({"diff": list(diff)})
|
|
shut_off = instance.proxy.get_status() == 5
|
|
try:
|
|
with audit_vm(request, instance, "change XML"):
|
|
instance.proxy._defineXML(new_xml)
|
|
except libvirtError as err:
|
|
return JsonResponse({"error": error_text(request, err)}, status=400)
|
|
if shut_off:
|
|
messages.success(request, _("The XML definition was saved."))
|
|
else:
|
|
messages.success(request, _("The XML definition was saved; the VM uses it from its next start."))
|
|
return JsonResponse({"saved": True})
|
|
|
|
@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":
|
|
with audit_vm(request, instance, "add guest agent channel"):
|
|
instance.proxy.add_guest_agent()
|
|
if status == "False":
|
|
with audit_vm(request, instance, "remove guest agent channel"):
|
|
instance.proxy.remove_guest_agent()
|
|
return _back(request, pk, "options")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def set_rng(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
enabled = bool(request.POST.get("rng"))
|
|
try:
|
|
with audit_vm(request, instance, "add RNG device" if enabled else "remove RNG device"):
|
|
instance.proxy.set_rng(enabled)
|
|
except util.OperationError as err:
|
|
messages.error(request, err)
|
|
return _back(request, pk, "options")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def set_tpm(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
enabled = bool(request.POST.get("tpm"))
|
|
try:
|
|
with audit_vm(request, instance, "add TPM" if enabled else "remove TPM"):
|
|
instance.proxy.set_tpm(enabled)
|
|
except util.OperationError as err:
|
|
messages.error(request, err)
|
|
return _back(request, pk, "options")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def set_on_crash(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
action = request.POST.get("on_crash", "")
|
|
try:
|
|
with audit_vm(request, instance, f"set crash action {action}"):
|
|
instance.proxy.set_on_crash(action)
|
|
except util.OperationError as err:
|
|
messages.error(request, err)
|
|
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")
|
|
with audit_vm(request, instance, f"set video model {video_model}"):
|
|
instance.proxy.set_video_model(video_model)
|
|
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)
|
|
|
|
# no source: the NIC keeps the one it has, which the host's lists may not show
|
|
raw_source = request.POST.get(f"net-source-{num}", "")
|
|
(source, source_type) = utils.get_network_tuple(raw_source) if raw_source else (None, None)
|
|
try:
|
|
with audit_vm(request, instance, f"change interface {old_mac}"):
|
|
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.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)
|
|
|
|
if instance.proxy.get_status() != 5:
|
|
messages.success(request, _("Network Device Config is changed. Please shutdown instance to activate."))
|
|
else:
|
|
messages.success(request, _("Network device changed"))
|
|
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")
|
|
# the form fills in a random MAC: a form sent twice would add a second NIC with it
|
|
if mac and mac.lower() in {net["mac"].lower() for net in instance.networks + instance.config_networks}:
|
|
messages.error(request, _("The VM already has a network device with MAC %(mac)s") % {"mac": mac})
|
|
return _back(request, pk, "network")
|
|
nwfilter = request.POST.get("add-net-nwfilter")
|
|
(source, source_type) = utils.get_network_tuple(network)
|
|
model = request.POST.get("add-net-model")
|
|
|
|
try:
|
|
with audit_vm(request, instance, f"add interface on {source}"):
|
|
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)
|
|
except libvirtError as err:
|
|
if not no_pci_slot(request, err, _("The running VM has no free PCI slot for another network device: "
|
|
"shut it down and add the device then; Add PCIe slots on the Disks "
|
|
"tab gives it free ones from its next start.")):
|
|
raise
|
|
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", "")
|
|
|
|
with audit_vm(request, instance, f"remove interface {mac_address}"):
|
|
instance.proxy.delete_network(mac_address)
|
|
return _back(request, pk, "network")
|
|
|
|
|
|
def _host_devices(instance):
|
|
"""The host's PCI and USB devices, each with why it cannot be given to
|
|
the VM (pci_problems, hostdev_problems; none: it can). A USB device is
|
|
named by its address when the host has more than one of its model, else
|
|
by model; a PCI device is checked alone on a running VM, as it is
|
|
hot-plugged, and with the managed PCI devices of the VM's next start on
|
|
a shut-off one, as libvirt takes them from the host along with it."""
|
|
pci, usb = node_devices(instance.proxy)
|
|
own_pci = instance.proxy.get_hostdevs()[1]
|
|
# libvirt takes a hot-plugged device alone from the host, and a group's managed devices as the VM starts
|
|
if instance.proxy.instance.isActive():
|
|
managed = set()
|
|
else:
|
|
managed = {row["address"] for row in own_pci if row["managed"] and row["next"]}
|
|
by_address = {device["address"]: device for device in pci}
|
|
# the problems in the page's language (vrtManager.nodedevs gives messages)
|
|
def shown(problems):
|
|
return [util.render(m["text"], m["values"], _) for m in problems]
|
|
|
|
for device in pci:
|
|
device["problems"] = shown(pci_problems(instance.name, device, by_address, managed | {device["address"]}))
|
|
if device["address"] in {row["address"] for row in own_pci}:
|
|
device["problems"].append(_("The VM has it"))
|
|
for device in usb:
|
|
address = (device["bus"], device["device"]) if device["same_model"] else None
|
|
device["selector"] = (*device["model"], address)
|
|
device["problems"] = shown(hostdev_problems(instance.name, pci, [device], set(), [device["selector"]]))
|
|
if instance.name in device["vms"]:
|
|
device["problems"].append(_("The VM has it"))
|
|
return pci, usb
|
|
|
|
|
|
@superuser_only
|
|
def host_devices(request, pk):
|
|
"""The add forms of the Host devices tab, loaded when the tab opens:
|
|
their choices scan every VM of the host."""
|
|
instance = get_instance(request.user, pk)
|
|
try:
|
|
pci, usb = _host_devices(instance)
|
|
domain = instance.proxy.instance
|
|
state = domain.info()[0]
|
|
context = {"instance": instance, "host_pci": pci, "host_usb": usb, "running": state == VIR_DOMAIN_RUNNING,
|
|
# paused, or with a saved state the VM would start from without the change
|
|
"pci_frozen": state not in (VIR_DOMAIN_RUNNING, VIR_DOMAIN_SHUTOFF) or domain.hasManagedSaveImage(0)}
|
|
except libvirtError as err:
|
|
context = {"instance": instance, "error": err}
|
|
return render(request, "instances/host_devices_form.html", context)
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_compute_mutation
|
|
def add_usb_hostdev(request, pk):
|
|
"""Gives the VM a USB device of its host; checked under the compute's
|
|
claim, so no other VM gets it meanwhile."""
|
|
instance = get_instance(request.user, pk)
|
|
chosen = request.POST.get("usb_device", "")
|
|
device = next((d for d in _host_devices(instance)[1] if f"{d['bus']}/{d['device']}" == chosen), None)
|
|
if device is None:
|
|
messages.error(request, _("The host has no USB device %(device)s") % {"device": chosen})
|
|
elif device["problems"]:
|
|
messages.error(request, "; ".join(device["problems"]))
|
|
else:
|
|
vendor, product, address = device["selector"]
|
|
with audit_vm(request, instance, f"add USB device {device['ids']} ({device['product']})"):
|
|
instance.proxy.add_usb_hostdev(vendor, product, address)
|
|
return _back(request, pk, "hostdevs")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_compute_mutation
|
|
def add_pci_hostdev(request, pk):
|
|
"""Gives the VM a PCI device of its host, hot-plugged while it runs;
|
|
checked under the compute's claim, so no other VM gets it or its IOMMU
|
|
group meanwhile."""
|
|
instance = get_instance(request.user, pk)
|
|
chosen = request.POST.get("pci_device", "")
|
|
device = next((d for d in _host_devices(instance)[0] if d["address"] == chosen), None)
|
|
if device is None:
|
|
messages.error(request, _("The host has no PCI device %(device)s") % {"device": chosen})
|
|
elif device["problems"]:
|
|
messages.error(request, "; ".join(device["problems"]))
|
|
else:
|
|
with audit_vm(request, instance, f"add PCI device {device['address']} ({device['product']})"):
|
|
instance.proxy.add_pci_hostdev(device["address"])
|
|
return _back(request, pk, "hostdevs")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def delete_pci_hostdev(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
address = request.POST.get("address", "")
|
|
with audit_vm(request, instance, f"remove PCI device {address}") as entry:
|
|
if instance.proxy.delete_pci_hostdev(address):
|
|
entry.action = f"request the removal of PCI device {address}"
|
|
messages.warning(request, _("Removal of PCI device %(address)s requested: the guest has not let it go yet; "
|
|
"it stays in the running VM until it does or the VM stops.") % {"address": address})
|
|
return _back(request, pk, "hostdevs")
|
|
|
|
|
|
@require_POST
|
|
@superuser_only
|
|
@serialize_instance_mutation
|
|
def delete_usb_hostdev(request, pk):
|
|
instance = get_instance(request.user, pk)
|
|
vendor, product = request.POST.get("vendor", ""), request.POST.get("product", "")
|
|
bus, _sep, device = request.POST.get("address", "").partition("/")
|
|
address = (int(bus), int(device)) if bus.isdigit() and device.isdigit() else None
|
|
with audit_vm(request, instance, f"remove USB device {vendor[2:]}:{product[2:]}"):
|
|
instance.proxy.delete_usb_hostdev((vendor, product, address))
|
|
return _back(request, pk, "hostdevs")
|
|
|
|
|
|
@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"
|
|
with audit_vm(request, instance, f"set link {state} on interface {mac_address}"):
|
|
instance.proxy.set_link_state(mac_address, state)
|
|
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")
|
|
|
|
with audit_vm(request, instance, f"set {qos_dir} QoS on interface {mac}"):
|
|
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")
|
|
with audit_vm(request, instance, f"remove {qos_dir} QoS from interface {mac}"):
|
|
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)
|
|
|
|
action = f"add owner {user.username}"
|
|
# the check and the insert in one step: two owners added at once must not
|
|
# both find the VM without one; the row is written after the transaction
|
|
with audit_vm(request, instance, action) as entry, transaction.atomic():
|
|
lock_ownership()
|
|
taken = (app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False"
|
|
and UserInstance.objects.filter(instance=instance).exists())
|
|
if taken:
|
|
entry.skip = True
|
|
else:
|
|
entry.skip = not UserInstance.objects.get_or_create(instance=instance, user=user)[1]
|
|
if taken:
|
|
failed_vm(request, instance, action, "only one owner is allowed")
|
|
messages.error(request, _("Only one owner is allowed and the one already added"))
|
|
elif entry.skip:
|
|
messages.warning(request, _("%(user)s is already an owner") % {"user": user})
|
|
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:
|
|
jobs.remember_job(request, op)
|
|
return redirect(reverse("instances:instance", args=[pk]))
|
|
|
|
|
|
@sensitive_post_parameters()
|
|
@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, instance=instance, is_superuser=request.user.is_superuser)
|
|
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")
|
|
console_type = form.cleaned_data["console_type"]
|
|
if console_type not in ("", instance.config_console_type):
|
|
with audit_vm(request, instance, f"set console type {console_type}"):
|
|
instance.proxy.set_console_type(console_type)
|
|
# the VNC settings, if the form takes them
|
|
if "keymap" in form.fields:
|
|
action = form.cleaned_data["password_action"]
|
|
if action != "keep":
|
|
if action == "generate":
|
|
password = randomPasswd(8) # the length VNC allows
|
|
elif action == "set":
|
|
password = form.cleaned_data["password"]
|
|
else:
|
|
password = ""
|
|
try:
|
|
with audit_vm(request, instance, "set console password" if password else "remove console password"):
|
|
if not instance.proxy.set_console_passwd(password):
|
|
raise util.OperationError("The VM has no graphics device")
|
|
except util.OperationError:
|
|
msg = _(
|
|
"Error setting console password. "
|
|
+ "You should check that your instance have an graphic device."
|
|
)
|
|
messages.error(request, msg)
|
|
|
|
if "keymap" in form.changed_data:
|
|
# "auto" removes the keymap: the client's keys go through as they are
|
|
keymap = form.cleaned_data["keymap"]
|
|
# False: no graphics device, nothing changed
|
|
with audit_vm(request, instance, "remove console keymap" if keymap == "auto" else f"set console keymap {keymap}") as entry:
|
|
entry.skip = instance.proxy.set_console_keymap(keymap) is False
|
|
|
|
if "listen_on" in form.changed_data:
|
|
with audit_vm(request, instance, f"set console listen address {form.cleaned_data['listen_on']}") as entry:
|
|
entry.skip = instance.proxy.set_console_listener_addr(form.cleaned_data["listen_on"]) is False
|
|
|
|
return get_safe_redirect(
|
|
request, default=reverse("instances:instance", args=[instance.id]) + "#vncsettings"
|
|
)
|
|
|
|
|
|
@require_POST
|
|
@serialize_instance_mutation
|
|
def change_options(request, pk):
|
|
"""The title and description of the persistent definition: they apply
|
|
at the next start, so a running VM's are changed too."""
|
|
instance = get_instance(request.user, pk, perm_type="change")
|
|
options = {key: request.POST.get(key, "") for key in ("title", "description") if key in request.POST}
|
|
with audit_vm(request, instance, "edit options"):
|
|
instance.proxy.set_options(options)
|
|
return get_safe_redirect(
|
|
request, default=reverse("instances:instance", args=[instance.id]) + "#options"
|
|
)
|
|
|
|
|
|
@require_POST
|
|
@serialize_user_quota
|
|
@serialize_instance_mutation
|
|
def set_template(request, pk):
|
|
"""Marks a shut-off VM as a template, or a template as a VM again; only
|
|
superusers and staff. 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."""
|
|
instance = get_instance(request.user, pk, perm_type="change")
|
|
if not (request.user.is_superuser or request.user.is_staff):
|
|
raise PermissionDenied
|
|
is_template = bool(request.POST.get("is_template"))
|
|
if instance.proxy.get_status() != 5:
|
|
messages.error(request, _("Shut the VM down to change whether it is a template."))
|
|
return _back(request, pk, "options")
|
|
if instance.is_template and not is_template and not request.user.is_superuser:
|
|
quota_msg = utils.check_user_quota(request.user, 1, instance.vcpu, instance.memory,
|
|
utils.disk_gib(instance.disks))
|
|
if quota_msg:
|
|
messages.error(request, quota_refused(quota_msg, gettext_noop("%(quota)s: '%(instance_name)s' stays a template."),
|
|
instance_name=instance.name))
|
|
return _back(request, pk, "options")
|
|
with audit_vm(request, instance, "mark as template" if is_template else "unmark as template"):
|
|
instance.is_template = is_template
|
|
instance.save(update_fields=["is_template"])
|
|
return _back(request, pk, "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
|
|
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 audit_vm(request, instance, "download console file"), 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")
|
|
# as the console page: without the permission the viewer asks for the password
|
|
if request.user.has_perm("instances.passwordless_console"):
|
|
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
|
|
|
|
# a refused definition comes back in the editor, its reason beside it
|
|
xml_error = None
|
|
if request.method == "POST" and "create_xml" in request.POST:
|
|
xml = request.POST.get("dom_xml", "")
|
|
try:
|
|
name = util.get_xml_path(xml, "/domain/name")
|
|
if not name:
|
|
xml_error = _("The XML has no name")
|
|
elif name in instances:
|
|
xml_error = _("A virtual machine with this name already exists")
|
|
else:
|
|
with libvirt_compute_lock(compute):
|
|
with audit(request, compute, f"VM {name}", "define from XML", instance=name) as entry:
|
|
conn._defineXML(xml)
|
|
utils.refr(compute)
|
|
instance = compute.instance_set.get(name=name)
|
|
entry.uuid = instance.uuid
|
|
return redirect(reverse("instances:instance", args=[instance.id]))
|
|
except util.etree.Error as err:
|
|
xml_error = _("The XML is not well-formed: %(error)s") % {"error": err}
|
|
except libvirtError as err:
|
|
xml_error = _("libvirt Error - %(exception)s") % {"exception": error_text(request, err)}
|
|
|
|
return render(request, "create_instance_w1.html", locals())
|
|
|
|
|
|
# the create form's disk sources: (value, title, help)
|
|
DISK_SOURCES = [
|
|
("new", gettext_lazy("New disk"), gettext_lazy("An empty disk of the size you set")),
|
|
("existing", gettext_lazy("Existing volumes"), gettext_lazy("Volumes already in a pool")),
|
|
("template", gettext_lazy("Clone from template"), gettext_lazy("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()
|
|
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)
|
|
firmwares = conn.get_firmwares(arch, machine)
|
|
tpm_support = bool(conn.get_tpm_model(arch, machine))
|
|
|
|
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={
|
|
# a default this host does not offer is no choice: the select asks for one
|
|
"vcpu": 1, "memory": 512, "firmware": default_firmware,
|
|
"vcpu_mode": default_cpu_mode if default_cpu_mode in cpu_choices(dom_caps) else "",
|
|
"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,
|
|
)
|
|
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())
|
|
|
|
|
|
def flavors_reply(request, flavor_form):
|
|
return render(request, "create_flavors_reply.html",
|
|
{"flavors": Flavor.objects.order_by("id"), "flavor_form": flavor_form})
|
|
|
|
|
|
@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()
|
|
if from_script(request):
|
|
return flavors_reply(request, FlavorForm())
|
|
messages.success(request, _("Flavor Created"))
|
|
return get_safe_redirect(request, default=reverse("instances:index"))
|
|
if from_script(request):
|
|
return flavors_reply(request, form)
|
|
|
|
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()
|
|
if from_script(request):
|
|
return flavors_reply(request, FlavorForm())
|
|
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.")},
|
|
)
|