mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
feat(computes): a compute names the address migrations reach it on
Migration is client-driven: libvirt sends the VM's memory to the destination's own host name unless a migration URI is given, and the app never gave one. Where the hosts cannot resolve each other's names (or the names point at a management network) every migration failed with "Unable to resolve address '<host name>' service '49152'". A compute now has an optional migration address (an IP address or a host name, no scheme or port), editable in the compute form and the API. A VM migrating to that compute is sent to tcp://<address> (bracketed for IPv6). Empty, the default for existing computes, keeps the previous behavior. The compute's host name is not reused, because for SSH computes it can be an ssh_config alias. The migration log entry names the address when one is set.
This commit is contained in:
parent
25b3671c33
commit
5a523befbd
10 changed files with 158 additions and 3 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -50,4 +50,4 @@ class SocketComputeForm(forms.ModelForm):
|
|||
|
||||
class Meta:
|
||||
model = Compute
|
||||
fields = ["name", "details", "hostname", "type"]
|
||||
fields = ["name", "details", "hostname", "type", "migration_address"]
|
||||
|
|
|
|||
26
computes/migrations/0004_compute_migration_address.py
Normal file
26
computes/migrations/0004_compute_migration_address.py
Normal file
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
73
computes/test_migration_address.py
Normal file
73
computes/test_migration_address.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ def migrate_instance(
|
|||
autoconverge,
|
||||
compress,
|
||||
postcopy,
|
||||
uri=new_compute.migration_uri,
|
||||
)
|
||||
finally:
|
||||
if conn_migrate is not None:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue