Do not break the VM list when a VM's OpenNebula template was deleted

The "my virtual machines" page showed "We could not load your VMs due to a
backend connection error" for any user owning a VM whose template had been
removed from OpenNebula. OpenNebulaManager.get_template() converted every
failure, including WrongIdError for a missing template, into
ConnectionRefusedError, and VirtualMachineSerializer.get_configuration()
let that propagate, so one VM with a stale template id took down the whole
list. The only log output was an empty ERROR line, because WrongIdError
has no message.

* Add opennebula_api.exceptions.TemplateNotFoundError and raise it from
  get_template() for a missing template; keep ConnectionRefusedError for
  genuine backend failures and log them with a traceback.
* Add OpenNebulaManager.get_template_names() and resolve_template_id() so
  the serializer fetches the template pool once per list instead of
  constructing an OpenNebulaManager and fetching the pool for every VM.
* get_configuration() now falls back to the template name embedded in the
  VM name ("<template>-<vm id>") when the template is missing or the pool
  cannot be fetched, and logs why.
* Replace str.lstrip('public-') with a real prefix strip (lstrip removes a
  character set, so "public-ubuntu" became "ntu") and also strip
  "ipv6only-".
* ProcessVMSelectionMixin shows "configuration is no longer available"
  instead of an unhandled error when ordering a retired template.
* VirtualMachinesPlanListView logs the exception behind the banner.
* Add mock-based unit tests for the fallback, the UPDATED_TEMPLATES
  mapping, the single pool fetch and the new exception.

The UPDATED_TEMPLATES setting keeps working as an operational override for
mapping retired template ids to their replacements.
This commit is contained in:
PCoder 2026-10-19 20:53:32 +05:30
commit cb16114aba
6 changed files with 268 additions and 23 deletions

View file

@ -1,7 +1,10 @@
from django.contrib import messages
from django.shortcuts import redirect
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from opennebula_api.exceptions import TemplateNotFoundError
from opennebula_api.serializers import VirtualMachineTemplateSerializer
from opennebula_api.models import OpenNebulaManager
@ -14,7 +17,15 @@ class ProcessVMSelectionMixin(object):
template_id = int(request.POST.get('vm_template_id'))
configuration_id = int(request.POST.get('configuration'))
template = OpenNebulaManager().get_template(template_id)
try:
template = OpenNebulaManager().get_template(template_id)
except TemplateNotFoundError:
messages.error(
request,
_('The selected configuration is no longer available. '
'Please choose another one.')
)
return redirect(reverse('hosting:index'))
data = VirtualMachineTemplateSerializer(template).data
configuration = HostingPlan.objects.get(id=configuration_id)

View file

@ -1557,6 +1557,11 @@ class VirtualMachinesPlanListView(LoginRequiredMixin, ListView):
serializer = VirtualMachineSerializer(queryset, many=True)
return serializer.data
except ConnectionRefusedError:
logger.exception(
"Could not load VMs of user %s (%s) from OpenNebula" % (
owner.username, owner.email
)
)
messages.error(self.request,
'We could not load your VMs due to a backend connection \
error. Please try again in a few minutes'

View file

@ -8,3 +8,13 @@ class UserExistsError(Exception):
class UserCredentialError(Exception):
pass
class TemplateNotFoundError(Exception):
"""Raised when an OpenNebula VM template id does not exist (any more)."""
def __init__(self, template_id):
super(TemplateNotFoundError, self).__init__(
"OpenNebula template %s does not exist" % template_id
)
self.template_id = template_id

View file

@ -9,7 +9,10 @@ from oca.pool import WrongNameError, WrongIdError
from hosting.models import HostingOrder
from utils.models import CustomUser
from utils.tasks import save_ssh_key, save_ssh_key_error_handler
from .exceptions import KeyExistsError, UserExistsError, UserCredentialError
from .exceptions import (
KeyExistsError, UserExistsError, UserCredentialError,
TemplateNotFoundError
)
logger = logging.getLogger(__name__)
@ -425,16 +428,60 @@ class OpenNebulaManager():
except:
return []
def get_template(self, template_id):
@staticmethod
def resolve_template_id(template_id):
"""
Map a (possibly retired) template id to its replacement, as
configured in settings.UPDATED_TEMPLATES_DICT. Ids without a
mapping are returned unchanged.
"""
template_id = int(template_id)
return settings.UPDATED_TEMPLATES_DICT.get(template_id, template_id)
def get_template_names(self):
"""
Return a dict {template_id: template_name} of every template
visible to oneadmin, in a single RPC call.
:raises ConnectionRefusedError: if OpenNebula is unreachable
"""
try:
template_pool = self._get_template_pool()
return dict(
(template.id, template.name) for template in template_pool
)
except ConnectionRefusedError:
raise
except Exception:
# Any other failure (socket timeout, XML-RPC fault, ...) is a
# backend problem as far as the callers are concerned
logger.exception("Could not fetch the OpenNebula template pool")
raise ConnectionRefusedError
def get_template(self, template_id):
"""
Return the oca template object for the given id.
:raises TemplateNotFoundError: if the template (after applying
settings.UPDATED_TEMPLATES_DICT) does not exist in OpenNebula
:raises ConnectionRefusedError: if OpenNebula is unreachable or
fails in any other way
"""
template_id = self.resolve_template_id(template_id)
try:
template_pool = self._get_template_pool()
if template_id in settings.UPDATED_TEMPLATES_DICT.keys():
template_id = settings.UPDATED_TEMPLATES_DICT[template_id]
return template_pool.get_by_id(template_id)
except Exception as ex:
logger.debug("Template Id we are looking for : %s" % template_id)
logger.error(str(ex))
except WrongIdError:
logger.error(
"OpenNebula template %s does not exist" % template_id
)
raise TemplateNotFoundError(template_id)
except ConnectionRefusedError:
raise
except Exception:
logger.exception(
"Could not fetch OpenNebula template %s" % template_id
)
raise ConnectionRefusedError
def create_template(self, name, cores, memory, disk_size, core_price,

View file

@ -1,12 +1,39 @@
import ipaddress
import logging
from builtins import hasattr
from rest_framework import serializers
from oca import OpenNebulaException
from .exceptions import TemplateNotFoundError
from .models import OpenNebulaManager
logger = logging.getLogger(__name__)
TEMPLATE_NAME_PREFIXES = ('public-', 'ipv6only-')
def strip_template_prefix(name):
"""
Remove the DCL naming prefix ('public-', 'ipv6only-') from a template
or VM name. str.lstrip() must not be used here: it strips a character
set, so 'public-ubuntu'.lstrip('public-') gives 'ntu'.
"""
for prefix in TEMPLATE_NAME_PREFIXES:
if name.startswith(prefix):
return name[len(prefix):]
return name
def template_name_from_vm_name(vm_name):
"""
VMs are named '<template name>-<vm id>' at creation time (see
OpenNebulaManager.create_vm), so the template name can be recovered
from the VM name when the template itself has been deleted.
"""
return vm_name.rsplit('-', 1)[0]
class VirtualMachineTemplateSerializer(serializers.Serializer):
"""Serializer to map the virtual machine template instance into JSON format."""
@ -36,10 +63,7 @@ class VirtualMachineTemplateSerializer(serializers.Serializer):
return int(obj.template.memory) / 1024
def get_name(self, obj):
if obj.name.startswith('public-'):
return obj.name.lstrip('public-')
else:
return obj.name
return strip_template_prefix(obj.name)
class VirtualMachineSerializer(serializers.Serializer):
@ -133,13 +157,45 @@ class VirtualMachineSerializer(serializers.Serializer):
price += int(disk.size) / 1024 * 0.6
return price
def _template_name(self, template_id):
"""
Resolve a template id to its name using one template pool fetch per
serializer instance. With many=True the child serializer is shared
across all VMs, so a whole VM list costs a single RPC call instead
of one OpenNebulaManager (and template pool fetch) per VM.
:raises TemplateNotFoundError: if the template no longer exists
:raises ConnectionRefusedError: if OpenNebula is unreachable
"""
if not hasattr(self, '_template_names'):
self._template_names = OpenNebulaManager().get_template_names()
template_id = OpenNebulaManager.resolve_template_id(template_id)
if template_id not in self._template_names:
raise TemplateNotFoundError(template_id)
return self._template_names[template_id]
def get_configuration(self, obj):
"""
The name of the template the VM was created from, without the DCL
prefix. A VM whose template has since been deleted from OpenNebula
must not break serialization of the whole VM list, so fall back to
the template name embedded in the VM name.
"""
template_id = obj.template.template_id
template = OpenNebulaManager().get_template(template_id)
if template.name.startswith('public-'):
return template.name.lstrip('public-')
else:
return template.name
try:
name = self._template_name(template_id)
except TemplateNotFoundError as err:
logger.warning(
"VM %s: %s, falling back to VM name" % (obj.id, err)
)
name = template_name_from_vm_name(obj.name)
except ConnectionRefusedError:
logger.error(
"VM %s: could not fetch template pool, falling back to "
"VM name" % obj.id
)
name = template_name_from_vm_name(obj.name)
return strip_template_prefix(name)
def get_ipv4(self, obj):
"""
@ -168,10 +224,7 @@ class VirtualMachineSerializer(serializers.Serializer):
return '-'
def get_name(self, obj):
if obj.name.startswith('public-'):
return obj.name.lstrip('public-')
else:
return obj.name
return strip_template_prefix(obj.name)
class VMTemplateSerializer(serializers.Serializer):

View file

@ -2,11 +2,18 @@ import random
import string
from django.conf import settings
from django.test import TestCase
from django.test import TestCase, override_settings
from unittest import skipIf
from unittest.mock import MagicMock, patch
from oca.pool import WrongIdError
from .exceptions import TemplateNotFoundError
from .models import OpenNebulaManager
from .serializers import VirtualMachineSerializer
from .serializers import (
VirtualMachineSerializer, strip_template_prefix,
template_name_from_vm_name
)
from utils.models import CustomUser
@ -149,3 +156,115 @@ class VirtualMachineSerializerTestCase(TestCase):
serialized.data.get('name'), vm.name.lstrip('public-')
)
break
class TemplateNameHelpersTestCase(TestCase):
def test_strip_template_prefix(self):
self.assertEqual(strip_template_prefix('public-Ubuntu 20.10'),
'Ubuntu 20.10')
self.assertEqual(strip_template_prefix('ipv6only-Alpine 3.21'),
'Alpine 3.21')
self.assertEqual(strip_template_prefix('Devuan Beowulf'),
'Devuan Beowulf')
def test_strip_template_prefix_is_not_lstrip(self):
# str.lstrip('public-') strips a character set and would give 'ntu'
self.assertEqual(strip_template_prefix('public-ubuntu'), 'ubuntu')
def test_template_name_from_vm_name(self):
self.assertEqual(
template_name_from_vm_name('public-Ubuntu 20.10-26623'),
'public-Ubuntu 20.10'
)
def _fake_vm(vm_id, name, template_id):
vm = MagicMock()
vm.id = vm_id
vm.name = name
vm.template.template_id = template_id
return vm
class VirtualMachineSerializerMissingTemplateTestCase(TestCase):
"""
A VM whose OpenNebula template has been deleted must not break the
serialization of the whole VM list (the symptom was the generic
"backend connection error" banner on /hosting/my-virtual-machines).
"""
def _configuration(self, vms, template_names):
serializer = VirtualMachineSerializer(vms, many=True)
with patch.object(OpenNebulaManager, '__init__', return_value=None), \
patch.object(OpenNebulaManager, 'get_template_names',
return_value=template_names) as get_names:
configurations = [
serializer.child.get_configuration(vm) for vm in vms
]
return configurations, get_names
@override_settings(UPDATED_TEMPLATES_DICT={})
def test_missing_template_falls_back_to_vm_name(self):
vms = [
_fake_vm(28344, 'public-Alpine 3.21-28344', 310),
_fake_vm(26623, 'public-Ubuntu 20.10-26623', 164),
]
configurations, _ = self._configuration(
vms, {310: 'public-Alpine 3.21'}
)
self.assertEqual(configurations, ['Alpine 3.21', 'Ubuntu 20.10'])
@override_settings(UPDATED_TEMPLATES_DICT={164: 192})
def test_updated_templates_mapping_is_applied(self):
vms = [_fake_vm(26623, 'public-Ubuntu 20.10-26623', 164)]
configurations, _ = self._configuration(
vms, {192: 'public-Ubuntu 20.10 (new)'}
)
self.assertEqual(configurations, ['Ubuntu 20.10 (new)'])
@override_settings(UPDATED_TEMPLATES_DICT={})
def test_template_pool_is_fetched_once_per_list(self):
vms = [
_fake_vm(1, 'public-Alpine 3.21-1', 310),
_fake_vm(2, 'public-Alpine 3.21-2', 310),
_fake_vm(3, 'public-Ubuntu 20.10-3', 164),
]
_, get_names = self._configuration(vms, {310: 'public-Alpine 3.21'})
self.assertEqual(get_names.call_count, 1)
@override_settings(UPDATED_TEMPLATES_DICT={})
def test_unreachable_template_pool_falls_back_to_vm_name(self):
vms = [_fake_vm(26623, 'public-Ubuntu 20.10-26623', 164)]
serializer = VirtualMachineSerializer(vms, many=True)
with patch.object(OpenNebulaManager, '__init__', return_value=None), \
patch.object(OpenNebulaManager, 'get_template_names',
side_effect=ConnectionRefusedError):
configuration = serializer.child.get_configuration(vms[0])
self.assertEqual(configuration, 'Ubuntu 20.10')
class OpenNebulaManagerGetTemplateTestCase(TestCase):
@override_settings(UPDATED_TEMPLATES_DICT={164: 192})
def test_resolve_template_id(self):
self.assertEqual(OpenNebulaManager.resolve_template_id(164), 192)
self.assertEqual(OpenNebulaManager.resolve_template_id('164'), 192)
self.assertEqual(OpenNebulaManager.resolve_template_id(310), 310)
@override_settings(UPDATED_TEMPLATES_DICT={})
def test_missing_template_raises_template_not_found(self):
pool = MagicMock()
pool.get_by_id.side_effect = WrongIdError
with patch.object(OpenNebulaManager, '__init__', return_value=None), \
patch.object(OpenNebulaManager, '_get_template_pool',
return_value=pool):
with self.assertRaises(TemplateNotFoundError) as ctx:
OpenNebulaManager().get_template(164)
self.assertEqual(ctx.exception.template_id, 164)
@override_settings(UPDATED_TEMPLATES_DICT={})
def test_unreachable_pool_still_raises_connection_error(self):
with patch.object(OpenNebulaManager, '__init__', return_value=None), \
patch.object(OpenNebulaManager, '_get_template_pool',
side_effect=ConnectionRefusedError):
with self.assertRaises(ConnectionRefusedError):
OpenNebulaManager().get_template(164)