diff --git a/instances/api/serializers.py b/instances/api/serializers.py index 56b9185a..ce4d2451 100644 --- a/instances/api/serializers.py +++ b/instances/api/serializers.py @@ -96,6 +96,7 @@ class MigrateSerializer(serializers.ModelSerializer): autoconverge = serializers.BooleanField(default=True) compress = serializers.BooleanField(default=False) unsafe = serializers.BooleanField(default=False) + submission_key = serializers.CharField(write_only=True) class Meta: model = MigrateInstance @@ -107,4 +108,5 @@ class MigrateSerializer(serializers.ModelSerializer): "autoconverge", "compress", "unsafe", + "submission_key", ] diff --git a/instances/api/viewsets.py b/instances/api/viewsets.py index 7dd0f01b..6300c304 100644 --- a/instances/api/viewsets.py +++ b/instances/api/viewsets.py @@ -5,7 +5,6 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from django.urls import reverse from instances.models import Flavor, Instance -from instances.utils import migrate_instance from instances.views import destroy as instance_destroy from instances.views import ( force_off, @@ -184,22 +183,17 @@ class MigrateViewSet(viewsets.ViewSet): serializer_class = MigrateSerializer queryset = "" - @extend_schema(responses=StatusSerializer) + @extend_schema(responses={202: OperationSerializer, 400: dict}) def create(self, request): serializer = MigrateSerializer(data=request.data) - if serializer.is_valid(): - instance = serializer.validated_data["instance"] - target_host = serializer.validated_data["target_compute"] - options = { - name: serializer.validated_data.get(name, False) - for name in ("live", "unsafe", "offline", "autoconverge", "compress") - } - # migrate_instance logs the outcome; it returns when it is done - mode = migrate_instance(target_host, instance, request.user, **options) - - return Response({"status": f"instance is migrated ({mode})"}) - else: + if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + data = serializer.validated_data + raw = {"instance": data["instance"].pk, "target": data["target_compute"].pk, + **{name: data.get(name, False) for name in ("live", "unsafe", "offline", "autoconverge", "compress")}} + op = jobs.submit("instance.migrate", request.user, raw, data["submission_key"]) + return Response(OperationSerializer(op, context={"request": request}).data, status=status.HTTP_202_ACCEPTED, + headers={"Location": reverse("operation-detail", args=[op.pk])}) class FlavorViewSet(viewsets.ModelViewSet): diff --git a/instances/jobs.py b/instances/jobs.py index cf94ffb8..e5c16417 100644 --- a/instances/jobs.py +++ b/instances/jobs.py @@ -77,9 +77,9 @@ def _recovery_proxy(op): raise # unreachable: recovery waits and retries -def _libvirt_busy(proxy): +def _libvirt_busy(dom): """libvirt still runs a job on the VM that the worker no longer waits for.""" - return proxy.instance.isActive() and proxy.instance.jobInfo()[0] != VIR_DOMAIN_JOB_NONE + return dom.isActive() and dom.jobInfo()[0] != VIR_DOMAIN_JOB_NONE def _log(op, state, message): @@ -326,7 +326,7 @@ class CreateSnapshot(Kind): proxy = _recovery_proxy(op) if proxy is None: return Outcome(Operation.UNCERTAIN, error=GONE) - if _libvirt_busy(proxy): + if _libvirt_busy(proxy.instance): return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still taking the snapshot")) if op.params["name"] in proxy.instance.snapshotListNames(0): return Outcome(Operation.SUCCEEDED) @@ -378,7 +378,7 @@ class RevertSnapshot(Kind): proxy = _recovery_proxy(op) if proxy is None: return Outcome(Operation.UNCERTAIN, error=GONE) - if _libvirt_busy(proxy): + if _libvirt_busy(proxy.instance): return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still reverting the VM")) if _current(proxy) == op.params["name"] != op.intent.get("was_current"): return Outcome(Operation.SUCCEEDED) @@ -580,3 +580,85 @@ class CreateInstance(Kind): compute, _sep, name = op.resource_label.rpartition("/") message = "Instance is created" return compute, name, message if state == Operation.SUCCEEDED else f"{message} ({state})" + + +MIGRATE_OPTIONS = ("live", "unsafe", "offline", "autoconverge", "compress") + + +def _target(instance, target_pk): + """The destination; refused when the VM is already there or it cannot be reached.""" + target = Compute.objects.get(pk=target_pk) + if target.pk == instance.compute_id: + raise util.OperationError(_("The instance is already on %(compute)s") % {"compute": target.name}) + if not target.status: + raise util.OperationError(_("%(compute)s is not reachable") % {"compute": target.name}) + return target + + +def _domain(compute_pk, uuid): + """The VM on the compute, or None when it has none there.""" + c = Compute.objects.get(pk=compute_pk) + try: + return wvmConnect(c.hostname, c.login, c.password, c.type).wvm.lookupByUUIDString(uuid) + except libvirtError as err: + if err.get_error_code() != VIR_ERR_NO_DOMAIN: + raise # unreachable: recovery waits and retries + return None + + +@register +class MigrateInstance(Kind): + name = "instance.migrate" + label = gettext_noop("Migrate VM") + + def normalize(self, raw): + return {"instance": int(raw["instance"]), "target": int(raw["target"]), + **{k: bool(raw.get(k)) for k in MIGRATE_OPTIONS}} + + def validate(self, user, params): + if not user.is_superuser: + raise PermissionDenied + _target(_instance(params["instance"]), params["target"]) + + def plan(self, user, params): + instance = _instance(params["instance"]) + keys = vm_claims(Compute.objects.get(pk=params["target"]), instance.name) + keys.update({claims.compute_key(instance.compute_id): Claim.SHARED, + claims.reconcile_key(instance.compute_id): Claim.EXCLUSIVE, + claims.instance_key(instance.uuid): Claim.EXCLUSIVE}) + return Plan(claims=keys, resource=claims.instance_key(instance.uuid), + label=f"{instance.compute.name}/{instance.name}", + intent={"compute": instance.compute_id, "uuid": instance.uuid}) + + def precheck(self, op, ctx): + if op.user is None or not op.user.is_active or not op.user.is_superuser: + raise PermissionDenied + instance = _instance(op.params["instance"]) + if instance.compute_id != op.intent["compute"]: + raise util.OperationError("The VM moved to another compute after the job was queued") + _target(instance, op.params["target"]) + + def run(self, op, ctx): + p = op.params + instance = _instance(p["instance"]) + ctx.record_intent() + ctx.set_phase(gettext_noop("Migrating the VM")) + # migrate_instance logs the outcome + utils.migrate_instance(Compute.objects.get(pk=p["target"]), instance, op.user, + **{k: p[k] for k in MIGRATE_OPTIONS}) + return {} + + def reconcile(self, op): + source = _domain(op.intent["compute"], op.intent["uuid"]) + dest = _domain(op.params["target"], op.intent["uuid"]) + if source is not None and _libvirt_busy(source): + return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still migrating the VM")) + if source is None and dest is not None: + instance = Instance.objects.filter(pk=op.params["instance"], compute_id=op.intent["compute"]).first() + if instance is not None: # the job stopped before it moved the record + utils.record_migration(instance, Compute.objects.get(pk=op.params["target"])) + return Outcome(Operation.SUCCEEDED) + if source is not None and dest is None: + return Outcome(Operation.FAILED, error="The VM stayed on the source host") + return Outcome(Operation.UNCERTAIN, error="The VM is on both computes or on neither; " + "check them, then resolve this job.") diff --git a/instances/templates/instances/settings_tab.html b/instances/templates/instances/settings_tab.html index aebce899..2c527829 100644 --- a/instances/templates/instances/settings_tab.html +++ b/instances/templates/instances/settings_tab.html @@ -580,7 +580,7 @@
{% trans "For migration both host servers must have equal settings and OS type" %}