diff --git a/computes/api/serializers.py b/computes/api/serializers.py index a90e5d3a..2d2867e9 100644 --- a/computes/api/serializers.py +++ b/computes/api/serializers.py @@ -22,4 +22,4 @@ class ComputeSerializer(serializers.ModelSerializer): class Meta: model = Compute - fields = ["id", "name", "hostname", "login", "password", "type", "details"] + fields = ["id", "name", "hostname", "login", "password", "type", "details", "migration_address"] diff --git a/computes/forms.py b/computes/forms.py index dcf39daa..c67f2530 100644 --- a/computes/forms.py +++ b/computes/forms.py @@ -50,4 +50,4 @@ class SocketComputeForm(forms.ModelForm): class Meta: model = Compute - fields = ["name", "details", "hostname", "type"] + fields = ["name", "details", "hostname", "type", "migration_address"] diff --git a/computes/migrations/0004_compute_migration_address.py b/computes/migrations/0004_compute_migration_address.py new file mode 100644 index 00000000..7855d937 --- /dev/null +++ b/computes/migrations/0004_compute_migration_address.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.17 on 2026-10-06 08:53 + +import computes.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("computes", "0003_auto_20200615_0637"), + ] + + operations = [ + migrations.AddField( + model_name="compute", + name="migration_address", + field=models.CharField( + blank=True, + default="", + help_text="IP address or host name the other hosts send migrating VMs to; empty: the host's own host name, which they must be able to resolve", + max_length=64, + validators=[computes.validators.validate_migration_address], + verbose_name="migration address", + ), + ), + ] diff --git a/computes/models.py b/computes/models.py index a868ba85..67896a98 100644 --- a/computes/models.py +++ b/computes/models.py @@ -1,8 +1,11 @@ +from ipaddress import ip_address + from django.db.models import CharField, IntegerField, Model from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from libvirt import virConnect +from computes.validators import validate_migration_address from vrtManager.connection import connection_manager from vrtManager.hostdetails import wvmHostDetails @@ -14,6 +17,30 @@ class Compute(Model): password = CharField(_("password"), max_length=14, blank=True, null=True) details = CharField(_("details"), max_length=64, null=True, blank=True) type = IntegerField() + migration_address = CharField( + _("migration address"), + max_length=64, + blank=True, + default="", + validators=[validate_migration_address], + help_text=_( + "IP address or host name the other hosts send migrating VMs to; " + "empty: the host's own host name, which they must be able to resolve" + ), + ) + + @property + def migration_uri(self): + """The native migration URI for VMs migrated to this host, or None for + libvirt's default.""" + if not self.migration_address: + return None + try: + if ip_address(self.migration_address).version == 6: + return f"tcp://[{self.migration_address}]" + except ValueError: + pass + return f"tcp://{self.migration_address}" @cached_property def status(self): diff --git a/computes/test_migration_address.py b/computes/test_migration_address.py new file mode 100644 index 00000000..69063f0e --- /dev/null +++ b/computes/test_migration_address.py @@ -0,0 +1,73 @@ +"""A compute's migration address: where other hosts send a migrating VM's +memory. Empty means libvirt's default (the destination's own hostname).""" + +from unittest.mock import MagicMock, patch + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.urls import reverse + +from computes.models import Compute +from computes.validators import validate_migration_address +from instances.models import Instance +from instances.utils import migrate_instance +from logs.models import Logs + + +class MigrationAddressTests(TestCase): + def test_accepted_and_refused_values(self): + for value in ("192.0.2.12", "kvm102.example.org", "kvm-102", "2001:db8::12", + "kvm102.example.org."): + with self.subTest(value=value): + validate_migration_address(value) + for value in ("tcp://10.0.0.1", "10.0.0.1:49152", "[2001:db8::12]", "host name", "-bad", "a..b", "10.0.0.1/24", "x;y", + "2001:db8::12%eth0", "2001:db8::12%bad]:49152", "a.."): + with self.subTest(value=value), self.assertRaises(ValidationError): + validate_migration_address(value) + + def test_migration_uri(self): + cases = {"": None, "192.0.2.12": "tcp://192.0.2.12", "kvm102.example.org": "tcp://kvm102.example.org", + "2001:db8::12": "tcp://[2001:db8::12]"} + for address, uri in cases.items(): + with self.subTest(address=address): + self.assertEqual(Compute(migration_address=address).migration_uri, uri) + + def test_the_compute_form_saves_it(self): + self.client.force_login(get_user_model().objects.create_superuser("ma-admin", "ma@example.com", "pw")) + compute = Compute.objects.create(name="ma", hostname="10.0.0.9", login="root", password="", type=2) + data = {"name": "ma", "hostname": "10.0.0.9", "login": "root", "type": 2, "details": "", "migration_address": "198.51.100.9"} + self.client.post(reverse("compute_update", args=[compute.id]), data) + compute.refresh_from_db() + self.assertEqual(compute.migration_address, "198.51.100.9") + self.client.post(reverse("compute_update", args=[compute.id]), {**data, "migration_address": "tcp://x"}) + compute.refresh_from_db() + self.assertEqual(compute.migration_address, "198.51.100.9") + + def test_a_migration_uses_the_destination_address(self): + source = Compute.objects.create(name="ma-src", hostname="10.0.0.8", login="root", password="", type=2) + dest = Compute.objects.create(name="ma-dst", hostname="kvm9", login="root", password="", type=2, + migration_address="198.51.100.9") + vm = MagicMock(compute=source, autostart=False) + vm.name = "vm" + with patch("instances.utils.connection_manager.host_is_up", return_value=True), \ + patch("instances.utils.wvmInstances") as instances, \ + patch("instances.utils.wvmInstance"), \ + patch("instances.utils.Instance") as model: + model.objects.filter.return_value.first.return_value = None + migrate_instance(dest, vm, None, live=True) + self.assertEqual(instances.return_value.moveto.call_args.kwargs["uri"], "tcp://198.51.100.9") + + def test_the_log_names_the_migration_address(self): + self.client.force_login(get_user_model().objects.create_superuser("ma-log", "ml@example.com", "pw")) + source = Compute.objects.create(name="ml-src", hostname="198.51.100.8", login="root", password="", type=2) + cases = {"": "to 198.51.100.9", "203.0.113.9": "to 198.51.100.9 via 203.0.113.9"} + for address, text in cases.items(): + with self.subTest(address=address): + dest = Compute.objects.create(name=f"ml-dst{address}", hostname="198.51.100.9", login="root", + password="", type=2, migration_address=address) + vm = Instance.objects.create(compute=source, name="vm", uuid=f"u-{address}") + with patch("instances.views.utils.migrate_instance"): + self.client.post(reverse("instances:migrate", args=[vm.id]), + {"compute_id": dest.id, "live_migrate": "true"}) + self.assertTrue(Logs.objects.latest("id").message.endswith(text), Logs.objects.latest("id").message) diff --git a/computes/validators.py b/computes/validators.py index dbb79eea..ee5bc5b2 100644 --- a/computes/validators.py +++ b/computes/validators.py @@ -1,4 +1,5 @@ import re +from ipaddress import ip_address from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -24,3 +25,17 @@ def validate_name(value): have_symbol = wrong_name.match("[^a-zA-Z0-9._-]+") if have_symbol: raise ValidationError(_("The hostname must not contain any special characters")) + + +_hostname = re.compile(r"[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?") + + +def validate_migration_address(value): + """An IP address (no IPv6 scope) or a host name: no scheme, port or brackets.""" + try: + if not getattr(ip_address(value), "scope_id", None): + return + except ValueError: + if _hostname.fullmatch(value): + return + raise ValidationError(_("Enter an IP address or a host name, without a scheme or port")) diff --git a/instances/utils.py b/instances/utils.py index 247383ab..c1b34507 100644 --- a/instances/utils.py +++ b/instances/utils.py @@ -191,6 +191,7 @@ def migrate_instance( autoconverge, compress, postcopy, + uri=new_compute.migration_uri, ) finally: if conn_migrate is not None: diff --git a/instances/views.py b/instances/views.py index fee05f44..5807bb1f 100755 --- a/instances/views.py +++ b/instances/views.py @@ -687,6 +687,8 @@ def migrate(request, pk): "hostname": target_host.hostname, "method": migration_method, } + if target_host.migration_address: + msg = _("%(msg)s via %(address)s") % {"msg": msg, "address": target_host.migration_address} addlogmsg(request.user.username, current_host, instance.name, msg) return get_safe_redirect( diff --git a/vrtManager/instance.py b/vrtManager/instance.py index 34961fd1..c3d4545e 100644 --- a/vrtManager/instance.py +++ b/vrtManager/instance.py @@ -117,7 +117,10 @@ class wvmInstances(wvmConnect): autoconverge=False, compress=False, postcopy=False, + uri=None, ): + """uri: the native migration URI (tcp://address) on the destination; + None lets libvirt use the destination's own hostname.""" # The source definition always goes: one left behind points at the # same (shared) disks as the migrated VM and could be started again. flags = VIR_MIGRATE_PERSIST_DEST | VIR_MIGRATE_UNDEFINE_SOURCE @@ -144,7 +147,7 @@ class wvmInstances(wvmConnect): "Destination host emulator is different. Cannot be migrated" ) - dom.migrate(self.wvm, flags, None, None, 0) + dom.migrate(self.wvm, flags, None, uri, 0) def graphics_type(self, name): inst = self.get_instance(name) diff --git a/vrtManager/test_migration.py b/vrtManager/test_migration.py index 50e9f942..6e265e23 100644 --- a/vrtManager/test_migration.py +++ b/vrtManager/test_migration.py @@ -50,3 +50,11 @@ class MigrationFlagsTestCase(unittest.TestCase): flags = source.instance.calls[0][0] self.assertTrue(flags & libvirt.VIR_MIGRATE_UNDEFINE_SOURCE) self.assertTrue(flags & libvirt.VIR_MIGRATE_PERSIST_DEST) + + def test_the_migration_uri_is_passed_on(self): + source = FakeSource(1) + destination().moveto(source, "vm", live=True, unsafe=False, offline=False, uri="tcp://192.0.2.12") + self.assertEqual(source.instance.calls[0][1], "tcp://192.0.2.12") + source = FakeSource(1) + destination().moveto(source, "vm", live=True, unsafe=False, offline=False) + self.assertIsNone(source.instance.calls[0][1])