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

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