1
0
Fork 0
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:
catborise 2026-11-03 18:18:51 +03:00
parent 50f861b2aa
commit 4da64c46cd
11 changed files with 698 additions and 567 deletions

View file

@ -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"]):

View file

@ -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):

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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()