mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
feat(locale): quota refusals and connection errors show in the reader's language
A quota refusal put the English name of the quota (cpu, memory, disk, instance) into a translated sentence, so a Turkish page read "memory kotası doldu". check_user_quota now returns which quota and its numbers (QuotaExceeded), each quota has a whole sentence, and the refusals put that sentence before what is refused; QUOTA_DEBUG adds the numbers as before. The clone job stores the refusal as a message, so the Jobs page shows it in its reader's language. The connection errors of vrtManager (did not answer within, still waited for, closed, failed with libvirt's text) were English f-strings; they are messages now, each close reason a whole one. A connection that died without a close callback raised an error reading "None"; it now names the host. Logs keep the English text.
This commit is contained in:
parent
50f861b2aa
commit
4da64c46cd
11 changed files with 698 additions and 567 deletions
|
|
@ -124,7 +124,7 @@ def clone_data(user, instance, fields):
|
|||
"""The clone's settings from the form fields and the source as it is now,
|
||||
and what it adds to the user's quota; OperationError with the reason when
|
||||
it cannot be made."""
|
||||
from instances.views import VOLUME_NAME_RE, quota_refused
|
||||
from instances.views import VOLUME_NAME_RE
|
||||
|
||||
data = dict()
|
||||
data["name"] = fields.get("name", "").strip()
|
||||
|
|
@ -187,10 +187,11 @@ def clone_data(user, instance, fields):
|
|||
if key.startswith("disk-") and value is not None and not VOLUME_NAME_RE.fullmatch(value)
|
||||
]
|
||||
|
||||
if not user.is_superuser and quota_msg == utils.QUOTA_UNVERIFIED:
|
||||
raise util.OperationError(utils.QUOTA_UNVERIFIED_TEXT)
|
||||
if not user.is_superuser and quota_msg:
|
||||
raise util.OperationError(quota_refused(quota_msg, _(
|
||||
"User '%(quota_msg)s' quota reached, cannot create '%(clone_name)s'!"
|
||||
)), quota_msg=quota_msg, clone_name=data["name"])
|
||||
raise util.OperationError(_("%(quota)s: cannot create '%(clone_name)s'."),
|
||||
quota=utils.quota_message(quota_msg), clone_name=data["name"])
|
||||
if check_instance:
|
||||
raise util.OperationError(_("Instance '%(clone_name)s' already exists!"), clone_name=data["name"])
|
||||
if not re.match(r"^[a-zA-Z0-9-]+$", data["name"]):
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ from django.contrib.messages import get_messages
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from instances.utils import QUOTA_UNVERIFIED, check_user_quota
|
||||
from instances.utils import QUOTA_UNVERIFIED, QuotaExceeded, check_user_quota, quota_message
|
||||
from vrtManager.util import render
|
||||
from libvirt import VIR_DOMAIN_PAUSED, VIR_DOMAIN_RUNNING
|
||||
from operations.models import Operation
|
||||
from vrtManager.util import OperationError
|
||||
|
|
@ -71,7 +72,7 @@ class QuotaTests(TestCase):
|
|||
"instances.utils.wvmInstance", side_effect=reader
|
||||
) as wvm:
|
||||
result = check_user_quota(self.user, *delta)
|
||||
return result.split(" ")[0], wvm.call_count # without the QUOTA_DEBUG detail
|
||||
return getattr(result, "kind", result), wvm.call_count # a QuotaExceeded names the quota
|
||||
|
||||
def test_vms_on_a_host_without_a_tcp_route_are_counted(self):
|
||||
# an ssh_config alias: the old TCP probe failed and skipped these VMs
|
||||
|
|
@ -191,12 +192,15 @@ class QuotaTests(TestCase):
|
|||
|
||||
for i in range(5):
|
||||
self.own(self.up, i)
|
||||
for value, expected in (("True", "instance (6 > 5)"), ("False", "instance")):
|
||||
with patch.object(Compute, "status", True), patch("instances.utils.wvmInstance", side_effect=vm_reader()):
|
||||
exceeded = check_user_quota(self.user, 1, 0, 0, 0)
|
||||
self.assertEqual(exceeded, QuotaExceeded("instance", 6, 5))
|
||||
for value, expected in (("True", "The quota of virtual machines is reached (6 > 5)"),
|
||||
("False", "The quota of virtual machines is reached")):
|
||||
with self.subTest(value=value):
|
||||
AppSettings.objects.filter(key="QUOTA_DEBUG").update(value=value)
|
||||
get_settings()
|
||||
with patch.object(Compute, "status", True), patch("instances.utils.wvmInstance", side_effect=vm_reader()):
|
||||
self.assertEqual(check_user_quota(self.user, 1, 0, 0, 0), expected)
|
||||
self.assertEqual(render("%(quota)s", {"quota": quota_message(exceeded)}, str), expected)
|
||||
|
||||
|
||||
class QuotaViewTests(TestCase):
|
||||
|
|
@ -228,7 +232,7 @@ class QuotaViewTests(TestCase):
|
|||
status=5)
|
||||
self.assertEqual(len(messages), 1)
|
||||
self.assertIn("a host of your virtual machines cannot be reached", messages[0])
|
||||
self.assertNotIn("quota reached", messages[0])
|
||||
self.assertNotIn("quota is reached", messages[0])
|
||||
proxy.resize_cpu.assert_not_called()
|
||||
|
||||
def test_running_vm_memory_charges_no_maximum_change(self):
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from django.contrib.messages import get_messages
|
|||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from instances.utils import QUOTA_UNVERIFIED
|
||||
from instances.utils import QUOTA_UNVERIFIED, QuotaExceeded
|
||||
|
||||
|
||||
class TemplateFlagQuotaTests(TestCase):
|
||||
|
|
@ -49,7 +49,7 @@ class TemplateFlagQuotaTests(TestCase):
|
|||
|
||||
def test_over_quota_the_template_stays_a_template(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
_, messages = self.post(self.staff, is_template=False, quota="cpu")
|
||||
_, messages = self.post(self.staff, is_template=False, quota=QuotaExceeded("cpu", 9, 8))
|
||||
self.assertTrue(self.vm.is_template)
|
||||
self.assertTrue(any("quota" in m for m in messages), messages)
|
||||
|
||||
|
|
@ -70,6 +70,6 @@ class TemplateFlagQuotaTests(TestCase):
|
|||
def test_superusers_have_no_quota(self):
|
||||
Instance.objects.filter(pk=self.vm.pk).update(is_template=True)
|
||||
admin = get_user_model().objects.create_superuser("tq-admin", "tq@example.com", "pw")
|
||||
check, _ = self.post(admin, is_template=False, quota="cpu")
|
||||
check, _ = self.post(admin, is_template=False, quota=QuotaExceeded("cpu", 9, 8))
|
||||
self.assertFalse(self.vm.is_template)
|
||||
check.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import os
|
|||
import random
|
||||
import re
|
||||
import string
|
||||
from collections import namedtuple
|
||||
|
||||
from accounts.models import UserInstance, UserAttributes
|
||||
from appsettings.settings import app_settings
|
||||
|
|
@ -9,12 +10,13 @@ from computes.models import Compute
|
|||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_noop
|
||||
from libvirt import libvirtError
|
||||
from logs.audit import reason, write
|
||||
from operations.models import Operation
|
||||
from vrtManager.connection import other_hosts, wvmConnect
|
||||
from vrtManager.instance import wvmInstance, wvmInstances
|
||||
from vrtManager.util import OperationError
|
||||
from vrtManager.util import OperationError, message
|
||||
|
||||
from .models import Instance
|
||||
|
||||
|
|
@ -73,13 +75,34 @@ def disk_gib(disks):
|
|||
|
||||
# check_user_quota could not read every VM of the user: an increase is refused
|
||||
QUOTA_UNVERIFIED = "unverified"
|
||||
QUOTA_UNVERIFIED_TEXT = gettext_noop(
|
||||
"The quota cannot be checked because a host of your virtual machines cannot be reached. Nothing was changed.")
|
||||
|
||||
# The quota a change would exceed: instance, cpu, memory or disk, the total it
|
||||
# would make and the limit
|
||||
QuotaExceeded = namedtuple("QuotaExceeded", "kind total limit")
|
||||
QUOTA_REACHED = {
|
||||
"instance": gettext_noop("The quota of virtual machines is reached"),
|
||||
"cpu": gettext_noop("The vCPU quota is reached"),
|
||||
"memory": gettext_noop("The memory quota is reached"),
|
||||
"disk": gettext_noop("The disk quota is reached"),
|
||||
}
|
||||
|
||||
|
||||
def quota_message(exceeded):
|
||||
"""The quota check's result as messages, with the numbers when QUOTA_DEBUG is on."""
|
||||
reached = message(QUOTA_REACHED[exceeded.kind])
|
||||
if app_settings.QUOTA_DEBUG != "True":
|
||||
return [reached]
|
||||
return [message(gettext_noop("%(quota)s (%(total)s > %(limit)s)"),
|
||||
quota=[reached], total=exceeded.total, limit=exceeded.limit)]
|
||||
|
||||
|
||||
@other_hosts()
|
||||
def check_user_quota(user, instance, cpu, memory, disk_size):
|
||||
"""
|
||||
Which quota the user would exceed by adding these amounts (instances,
|
||||
vCPUs, memory MiB, disk GiB) to their VMs, or "". A quota of -1 has no
|
||||
The quota the user would exceed by adding these amounts (instances,
|
||||
vCPUs, memory MiB, disk GiB) to their VMs, a QuotaExceeded, or "". A quota of -1 has no
|
||||
limit; 0 allows none. QUOTA_UNVERIFIED if a
|
||||
VM that counts cannot be read (its host is down, the VM is gone): the
|
||||
usage is unknown, so an increase is refused.
|
||||
|
|
@ -93,8 +116,6 @@ def check_user_quota(user, instance, cpu, memory, disk_size):
|
|||
if instance <= 0 and cpu <= 0 and memory <= 0 and disk_size <= 0:
|
||||
return msg
|
||||
|
||||
quota_debug = app_settings.QUOTA_DEBUG == "True"
|
||||
|
||||
user_instances = UserInstance.objects.filter(user=user, instance__is_template=False).select_related(
|
||||
"instance__compute"
|
||||
)
|
||||
|
|
@ -106,10 +127,7 @@ def check_user_quota(user, instance, cpu, memory, disk_size):
|
|||
]
|
||||
instance += user_instances.count() + len(clones)
|
||||
if ua.max_instances >= 0 and instance > ua.max_instances:
|
||||
msg = "instance"
|
||||
if quota_debug:
|
||||
msg += f" ({instance} > {ua.max_instances})"
|
||||
return msg
|
||||
return QuotaExceeded("instance", instance, ua.max_instances)
|
||||
if ua.max_cpus < 0 and ua.max_memory < 0 and ua.max_disk_size < 0:
|
||||
return msg
|
||||
|
||||
|
|
@ -141,17 +159,11 @@ def check_user_quota(user, instance, cpu, memory, disk_size):
|
|||
return QUOTA_UNVERIFIED
|
||||
|
||||
if ua.max_cpus >= 0 and cpu > ua.max_cpus:
|
||||
msg = "cpu"
|
||||
if quota_debug:
|
||||
msg += f" ({cpu} > {ua.max_cpus})"
|
||||
msg = QuotaExceeded("cpu", cpu, ua.max_cpus)
|
||||
if ua.max_memory >= 0 and memory > ua.max_memory:
|
||||
msg = "memory"
|
||||
if quota_debug:
|
||||
msg += f" ({memory} > {ua.max_memory})"
|
||||
msg = QuotaExceeded("memory", memory, ua.max_memory)
|
||||
if ua.max_disk_size >= 0 and disk_size > ua.max_disk_size:
|
||||
msg = "disk"
|
||||
if quota_debug:
|
||||
msg += f" ({disk_size} > {ua.max_disk_size})"
|
||||
msg = QuotaExceeded("disk", disk_size, ua.max_disk_size)
|
||||
return msg
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ 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
|
||||
from django.utils.translation import gettext_lazy, gettext_noop
|
||||
from django.views.decorators.http import require_POST
|
||||
from libvirt import (VIR_DOMAIN_PAUSED,
|
||||
VIR_DOMAIN_RUNNING,
|
||||
|
|
@ -657,12 +657,12 @@ def serialize_instance_mutation(func):
|
|||
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 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):
|
||||
|
|
@ -1174,12 +1174,8 @@ def resizevm_cpu(request, pk):
|
|||
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,
|
||||
})
|
||||
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
|
||||
|
|
@ -1212,12 +1208,8 @@ def resize_memory(request, pk):
|
|||
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,
|
||||
})
|
||||
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)"):
|
||||
|
|
@ -1263,12 +1255,8 @@ def resize_disk(request, pk):
|
|||
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,
|
||||
})
|
||||
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:
|
||||
|
|
@ -2393,9 +2381,8 @@ def set_template(request, pk):
|
|||
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, _(
|
||||
"User %(quota_msg)s quota reached, '%(instance_name)s' stays a template!"
|
||||
) % {"quota_msg": quota_msg, "instance_name": instance.name}))
|
||||
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
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -45,7 +45,13 @@ RETRY_AFTER = getattr(settings, "LIBVIRT_RETRY_AFTER", 30)
|
|||
# request (the job worker's, the consoles') are not counted: a job holds its
|
||||
# host for as long as it runs.
|
||||
HOST_REQUESTS = getattr(settings, "LIBVIRT_HOST_REQUESTS", 2)
|
||||
CLOSE_REASONS = {0: "error", 1: "end of file", 2: "keepalive timeout", 3: "client closed"}
|
||||
# libvirt's close reasons, one whole message each
|
||||
CLOSE_REASONS = {
|
||||
0: gettext_noop("Connection closed (error)"),
|
||||
1: gettext_noop("Connection closed (end of file)"),
|
||||
2: gettext_noop("Connection closed (keepalive timeout)"),
|
||||
3: gettext_noop("Connection closed (client closed)"),
|
||||
}
|
||||
# ssh with a connect timeout for qemu+ssh (libvirt runs the "command" binary
|
||||
# in place of ssh and never passes one itself)
|
||||
SSH_COMMAND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "libvirt-ssh")
|
||||
|
|
@ -130,8 +136,8 @@ def _count_on(connection):
|
|||
return
|
||||
_leave_host()
|
||||
if not connection.requests.acquire(timeout=CONNECT_TIMEOUT):
|
||||
raise util.ConnectionFailed(
|
||||
f"Connection Failed: {connection.host} does not answer: {HOST_REQUESTS} requests still wait for it")
|
||||
raise util.ConnectionFailed(gettext_noop("Connection Failed: %(host)s does not answer: %(requests)s requests still wait for it"),
|
||||
host=connection.host, requests=HOST_REQUESTS)
|
||||
_request.held = connection
|
||||
|
||||
|
||||
|
|
@ -199,7 +205,8 @@ class wvmConnection(object):
|
|||
if not opening.wait(CONNECT_TIMEOUT):
|
||||
with self.connection_state_lock:
|
||||
if self._opening is opening: # still running: give up waiting, not the open
|
||||
self.last_error = f"Connection Failed: {self.host} did not answer within {CONNECT_TIMEOUT} s"
|
||||
self.last_error = util.message(gettext_noop("Connection Failed: %(host)s did not answer within %(seconds)s s"),
|
||||
host=self.host, seconds=CONNECT_TIMEOUT)
|
||||
opening.abandoned = True
|
||||
|
||||
def _open(self, opening):
|
||||
|
|
@ -219,7 +226,9 @@ class wvmConnection(object):
|
|||
except libvirtError:
|
||||
pass
|
||||
except Exception as e: # libvirtError, or anything else from the transport
|
||||
error = f"Connection Failed: {str(e)}"
|
||||
# libvirt's own text stays as it is; one of ours is a message of its own
|
||||
reason = [util.message(e.text, **e.values)] if isinstance(e, util.OperationError) else str(e)
|
||||
error = util.message(gettext_noop("Connection Failed: %(error)s"), error=reason)
|
||||
finally:
|
||||
with self.connection_state_lock:
|
||||
if error is None:
|
||||
|
|
@ -276,7 +285,10 @@ class wvmConnection(object):
|
|||
# not drop its successor.
|
||||
if connection is not self.connection:
|
||||
return
|
||||
self.last_error = f"Connection closed ({CLOSE_REASONS.get(reason, reason)})"
|
||||
if reason in CLOSE_REASONS:
|
||||
self.last_error = util.message(CLOSE_REASONS[reason])
|
||||
else:
|
||||
self.last_error = util.message(gettext_noop("Connection closed (%(reason)s)"), reason=reason)
|
||||
# prevent other threads from using the connection (in the future)
|
||||
self.connection = None
|
||||
|
||||
|
|
@ -417,7 +429,10 @@ class wvmConnectionManager(object):
|
|||
connection.connect()
|
||||
live = connection.alive_connection()
|
||||
if live is None:
|
||||
raise util.ConnectionFailed(connection.last_error)
|
||||
# a connection that died without a close callback leaves no error behind
|
||||
error = connection.last_error or util.message(gettext_noop("Connection Failed: %(host)s is not connected"),
|
||||
host=connection.host)
|
||||
raise util.ConnectionFailed(error["text"], **error["values"])
|
||||
return live
|
||||
|
||||
def host_is_up(self, conn_type, hostname, timeout=1):
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ class BoundedOpenTestCase(unittest.TestCase):
|
|||
self.assertIs(self.get()[0], new)
|
||||
entry._wvmConnection__connection_close_callback(new, 2)
|
||||
self.assertIsNone(entry.connection)
|
||||
self.assertEqual(entry.last_error, "Connection closed (keepalive timeout)")
|
||||
self.assertEqual(entry.last_error, {"text": "Connection closed (keepalive timeout)", "values": {}})
|
||||
|
||||
def test_a_connection_closed_before_it_is_published_is_an_error(self):
|
||||
self.release.set()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue