1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/operations/utils.py
catborise 432b1505b3 fix(operations): a job's unexpected error is not shown raw; the log has its traceback
A job that failed on an exception that is not libvirt's or a refusal
showed its raw text to users (a SQL error such as "UNIQUE constraint
failed: instances_instance.uuid", a KeyError's key), and the worker
logged only that text. Users now read "The panel failed unexpectedly;
its log has the details." and the worker logs the exception with its
traceback.

A live clone whose recovery found the clone made but could not thaw the
source's file systems kept that in the error of a succeeded job, which
the job panel does not show. It is a warning of the clone's result, as
when the job itself made the clone.
2026-11-06 23:48:44 +03:00

217 lines
8.6 KiB
Python

"""Secrets never reach a job record, a response or a log line, and users who
do not see the hosts get "host unreachable" instead of a connection error's
text (which names the host). What a refused request says about the job holding
the resource: details for its owner and superusers, a minimal summary for
everyone else; and who may see jobs on a resource page. Worker settings from
the Settings page. The worker reads them itself (the settings middleware only
runs in gunicorn)."""
import re
import traceback
import libvirt
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.utils import timezone
from django.utils.timesince import timesince
from django.utils.translation import gettext as _
from django.utils.translation import pgettext
from django.utils.translation import gettext_noop
from libvirt import libvirtError
from accounts.models import UserInstance
from appsettings.models import AppSettings
from instances.models import Instance
from vrtManager.util import ConnectionFailed, HostBusy, OperationError, render
from . import jobs
_NAMES = r"(?:pass|secret|token|key)\w*"
_PATTERNS = [
(re.compile(r"(://[^:/@\s]+:)[^@\s]+@"), r"\1***@"),
# quoted values, also XML attributes: passwd='x', password="x"
(re.compile(rf"""({_NAMES}['"]?\s*[:=]\s*)(['"])(?:\\.|(?!\2)[^\\])*\\?(?:\2|\Z)""", re.I | re.S), r"\1\2***\2"),
(re.compile(rf"""({_NAMES}['"]?\s*[:=]\s*)[^\s'",;<>]+""", re.I), r"\1***"),
# <secret ...>value</secret>-like elements
(re.compile(r"(<(secret|passphrase)\b[^>]*>).*?(</\2>)", re.I | re.S), r"\1***\3"),
]
_SECRET_KEY = re.compile(r"pass|secret|token|private|key$", re.I)
# errors of the connection to the host, not of the operation: both the
# domain and the code must match (a daemon that lacks a procedure answers
# VIR_FROM_RPC / VIR_ERR_NO_SUPPORT, which the user should see)
TRANSPORT_ERROR_DOMAINS = {libvirt.VIR_FROM_RPC, libvirt.VIR_FROM_REMOTE, libvirt.VIR_FROM_SSH, libvirt.VIR_FROM_LIBSSH}
TRANSPORT_ERROR_CODES = {
libvirt.VIR_ERR_INTERNAL_ERROR, # "connection closed due to keepalive timeout"
libvirt.VIR_ERR_SYSTEM_ERROR, # "Cannot recv data: ..."
libvirt.VIR_ERR_RPC,
libvirt.VIR_ERR_NO_CONNECT,
libvirt.VIR_ERR_AUTH_FAILED,
libvirt.VIR_ERR_AUTH_UNAVAILABLE,
libvirt.VIR_ERR_AUTH_CANCELLED,
libvirt.VIR_ERR_SSH,
libvirt.VIR_ERR_LIBSSH,
}
UNREACHABLE = gettext_noop("The host of this virtual machine cannot be reached right now.")
HOST_BUSY = gettext_noop("The host of this virtual machine is busy with other requests right now: try again in a moment.")
# a job's checks in the worker (precheck) refuse with Django's exceptions, which have no text
NOT_ALLOWED = gettext_noop("You may no longer do this.")
NOT_FOUND = gettext_noop("The virtual machine is no longer in the panel.")
BOUNDS = {"WORKER_CONCURRENCY": (1, 16, 4), "JOB_RETENTION_DAYS": (1, 3650, 30), "LOG_RETENTION_DAYS": (1, 3650, 365)}
def safe_text(text):
text = str(text)
for pattern, replacement in _PATTERNS:
text = pattern.sub(replacement, text)
return text
# A text a job keeps for later (its error, its result's warning) is a list of
# messages, each our English text, marked with gettext_noop, and its values:
# words() puts them in the language of whoever reads them. A text that is not
# ours (libvirt's) is a message without values and stays as it is.
def say(text, **values):
return [{"text": safe_text(text), "values": {key: _stored(value) for key, value in values.items()}}]
def _stored(value):
"""A value as a job keeps it: messages stay messages (vrtManager.util.message)."""
if isinstance(value, list):
return [say(m["text"], **m["values"])[0] for m in value]
if isinstance(value, OperationError):
return [say(value.text, **value.values)[0]]
if isinstance(value, (int, float)) and not isinstance(value, bool):
return value # for %(x).1f and the like
return safe_text(value)
def said(exception):
"""The messages of an exception: an OperationError's text and values, else its text."""
if isinstance(exception, OperationError):
return say(exception.text, **exception.values)
return say(exception)
def words(messages):
"""messages in the active language, each a sentence of its own."""
parts = [render(m["text"], m["values"], _) for m in messages]
return " ".join(part if last or part.endswith((".", "。", "!", "?")) else part + "."
for last, part in ((i == len(parts) - 1, part) for i, part in enumerate(parts)))
def public_error(exception):
"""The error for users who do not see the hosts: a connection error names
the host and how it is reached, so they learn only that it cannot be reached."""
connection_error = isinstance(exception, ConnectionFailed) or (
isinstance(exception, libvirtError)
and exception.get_error_domain() in TRANSPORT_ERROR_DOMAINS
and exception.get_error_code() in TRANSPORT_ERROR_CODES
)
if isinstance(exception, HostBusy):
return say(HOST_BUSY)
if isinstance(exception, PermissionDenied):
return say(NOT_ALLOWED)
if isinstance(exception, Http404):
return say(NOT_FOUND)
return say(UNREACHABLE) if connection_error else said(exception)
def public_error_text(exception):
return words(public_error(exception))
# not libvirt's error or a refusal (whose text is for users) but a fault of
# the panel: its text (a SQL error, a KeyError's key) tells users nothing
UNEXPECTED = gettext_noop("The panel failed unexpectedly; its log has the details.")
REPORTED = (libvirtError, PermissionDenied, Http404, ValueError)
def traceback_of(err):
"""A fault of the panel, also one a reported error wraps, goes to the log
with its traceback, without secrets as the message (exc_info would write
them); a reported error says enough."""
fault = err.__cause__ if isinstance(err, REPORTED) else err
if fault is None or isinstance(fault, REPORTED):
return ""
return "\n" + safe_text("".join(traceback.format_exception(err)))
def job_error(err):
"""What users read of a job's exception."""
return public_error(err) if isinstance(err, REPORTED) else say(UNEXPECTED)
def check_params(params):
if isinstance(params, dict):
for key, value in params.items():
if _SECRET_KEY.search(str(key)):
raise ValueError(f"A job cannot store the field {key}")
check_params(value)
elif isinstance(params, (list, tuple)):
for value in params:
check_params(value)
def may_see(op, user):
return user.is_superuser or (op.user_id is not None and op.user_id == user.pk)
def shown_label(op, user):
"""The job's "<compute>/<vm>" label, without the compute for users the app hides hosts from.
A compute name may contain "/", a VM name may not (libvirt refuses it)."""
if user.is_superuser or user.has_perm("instances.view_instances"):
return op.resource_label
return op.resource_label.rsplit("/", 1)[-1]
def visible(user, key):
if user.is_superuser:
return True
kind, _sep, rest = key.partition(":")
if kind != "instance":
return False # compute, storage and network pages are superuser-only
instances = Instance.objects.filter(uuid=rest)
if user.has_perm("instances.view_instances"):
return instances.exists()
return UserInstance.objects.filter(user=user, instance__in=instances).exists()
def summary(op, user):
base = {"kind": op.kind, "label": str(_(jobs.label_of(op.kind))), "state": op.state, "since": op.created_at}
if may_see(op, user):
base.update({"id": str(op.id), "user": op.username})
return base
def busy_message(busy, user):
op = busy.blocker
if op is None:
return _("A short operation is in progress on this resource; try again in a moment.")
values = {"label": _(jobs.label_of(op.kind)), "state": pgettext("job state", op.state), "since": timesince(op.created_at, timezone.now())}
if may_see(op, user):
return _("%(label)s is %(state)s on this resource (started by %(user)s, %(since)s ago); "
"see the Jobs page.") % {**values, "user": op.username}
return _("%(label)s is %(state)s on this resource (since %(since)s).") % values
def _value(key):
low, high, default = BOUNDS[key]
try:
value = int(AppSettings.objects.get(key=key).value)
except (AppSettings.DoesNotExist, ValueError):
return default
return value if low <= value <= high else default
def concurrency():
return _value("WORKER_CONCURRENCY")
def retention_days():
return _value("JOB_RETENTION_DAYS")
def log_retention_days():
return _value("LOG_RETENTION_DAYS")