dynamicweb/opennebula_api/tests.py
PCoder cb16114aba 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.
2026-10-19 20:53:32 +05:30

270 lines
10 KiB
Python

import random
import string
from django.conf import settings
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, strip_template_prefix,
template_name_from_vm_name
)
from utils.models import CustomUser
@skipIf(
settings.OPENNEBULA_DOMAIN is None or
settings.OPENNEBULA_DOMAIN == "test_domain",
"""OpenNebula details unavailable, so skipping
OpenNebulaManagerTestCases"""
)
class OpenNebulaManagerTestCases(TestCase):
"""This class defines the test suite for the opennebula manager model."""
def setUp(self):
"""Define the test client and other test variables."""
self.email = '{}@ungleich.ch'.format(''.join(random.choices(string.ascii_uppercase, k=10)))
self.password = ''.join(random.choices(string.ascii_uppercase + string.digits, k=20))
self.user = CustomUser.objects.create(name='test', email=self.email,
password=self.password)
self.vm_specs = {}
self.vm_specs['cpu'] = 1
self.vm_specs['memory'] = 2
self.vm_specs['disk_size'] = 10
self.manager = OpenNebulaManager()
def test_connect_to_server(self):
"""Test the opennebula manager can connect to a server."""
try:
ver = self.manager.oneadmin_client.version()
except:
ver = None
self.assertTrue(ver is not None)
def test_get_user(self):
"""Test the opennebula manager can get a existing user."""
self.manager.create_user(self.user)
user = self.manager._get_user(self.user)
name = user.name
self.assertNotEqual(name, None)
def test_create_and_delete_user(self):
"""Test the opennebula manager can create and delete a new user."""
old_count = len(self.manager._get_user_pool())
self.manager = OpenNebulaManager(email=self.email,
password=self.password)
user_pool = self.manager._get_user_pool()
new_count = len(user_pool)
# Remove the user afterwards
user = user_pool.get_by_name(self.email)
user.delete()
self.assertNotEqual(old_count, new_count)
def test_user_can_login(self):
""" Test the manager can login to a new created user"""
self.manager.create_user(self.user)
user = self.manager._get_user(self.user)
client = self.manager._get_client(self.user)
version = client.version()
# Cleanup
user.delete()
self.assertNotEqual(version, None)
def test_add_public_key_to_user(self):
""" Test the manager can add a new public key to an user """
self.manager.create_user(self.user)
user = self.manager._get_user(self.user)
public_key = 'test'
self.manager.add_public_key(self.user, public_key)
# Fetch new user information from opennebula
user.info()
user_public_key = user.template.ssh_public_key
# Cleanup
user.delete()
self.assertEqual(user_public_key, public_key)
def test_append_public_key_to_user(self):
""" Test the manager can append a new public key to an user """
self.manager.create_user(self.user)
user = self.manager._get_user(self.user)
public_key = 'test'
self.manager.add_public_key(self.user, public_key)
# Fetch new user information from opennebula
user.info()
old_public_key = user.template.ssh_public_key
self.manager.add_public_key(self.user, public_key, merge=True)
user.info()
new_public_key = user.template.ssh_public_key
# Cleanup
user.delete()
self.assertEqual(new_public_key, '{}\n{}'.format(old_public_key,
public_key))
def test_remove_public_key_to_user(self):
""" Test the manager can remove a public key from an user """
self.manager.create_user(self.user)
user = self.manager._get_user(self.user)
public_key = 'test'
self.manager.add_public_key(self.user, public_key)
self.manager.add_public_key(self.user, public_key, merge=True)
user.info()
old_public_key = user.template.ssh_public_key
self.manager.remove_public_key(self.user, public_key)
user.info()
new_public_key = user.template.ssh_public_key
# Cleanup
user.delete()
self.assertEqual(new_public_key,
old_public_key.replace('{}\n'.format(public_key), '', 1))
def test_requires_ssh_key_for_new_vm(self):
"""Test the opennebula manager requires the user to have a ssh key when
creating a new vm"""
@skipIf(
settings.OPENNEBULA_DOMAIN is None or
settings.OPENNEBULA_DOMAIN == "test_domain",
"""OpenNebula details unavailable, so skipping
VirtualMachineSerializerTestCase"""
)
class VirtualMachineSerializerTestCase(TestCase):
def setUp(self):
"""Define the test client and other test variables."""
self.manager = OpenNebulaManager(email=None, password=None)
def test_serializer_strips_of_public(self):
""" Test the serialized virtual machine object contains no
'public-'."""
for vm in self.manager.get_vms():
serialized = VirtualMachineSerializer(vm)
self.assertEqual(
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)