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