1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 11:46:49 +00:00
webvirtcloud/instances/jobs.py
catborise 46a611941e feat(instances): a running VM can be cloned
libvirt's backup copies the disks of a running or paused VM from one
instant into new volumes while it keeps running; the clone is defined
shut off as before. The VM is held for the copy, the copy can be
cancelled, and recovery reads the job's records and libvirt.
2026-10-16 16:52:38 +03:00

863 lines
39 KiB
Python

"""Jobs of the instances app (operations framework, spec section 5)."""
import math
import re
import time
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_noop
from django.utils.translation import gettext_noop as _
from libvirt import (VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE, VIR_DOMAIN_JOB_NONE, VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL,
VIR_ERR_NO_DOMAIN, VIR_ERR_NO_STORAGE_VOL, libvirtError)
from vrtManager import util
from vrtManager.connection import wvmConnect
from vrtManager.create import wvmCreate
from vrtManager.instance import wvmInstance
from accounts.models import UserInstance
from appsettings.settings import app_settings
from computes.models import Compute
from instances import utils
from instances.models import Instance
from operations import claims
from operations.jobs import Cancelled, Kind, Outcome, Plan, register
from operations.models import Claim, Operation
def _authorized(user, pk):
"""The instance views' rule: change access and the snapshot permission."""
from instances.views import get_instance
if user is None or not user.is_active:
raise PermissionDenied
instance = get_instance(user, pk, perm_type="change")
if not user.has_perm("instances.snapshot_instances"):
raise PermissionDenied
return instance
def _proxy(instance):
c = instance.compute
return wvmInstance(c.hostname, c.login, c.password, c.type, instance.name, uuid=instance.uuid)
def _instance(pk):
return Instance.objects.select_related("compute").get(pk=pk)
def _vm_plan(params):
"""A job on one VM: the VM exclusive, its compute shared."""
instance = _instance(params["instance"])
return Plan(
claims={claims.compute_key(instance.compute_id): Claim.SHARED,
claims.instance_key(instance.uuid): Claim.EXCLUSIVE},
resource=claims.instance_key(instance.uuid),
label=f"{instance.compute.name}/{instance.name}",
intent={"compute": instance.compute_id},
)
def _checked_proxy(op):
"""In the worker: the user may still change the VM, and it did not move."""
instance = _authorized(op.user, op.params["instance"])
if instance.compute_id != op.intent["compute"]:
raise util.OperationError("The VM moved to another compute after the job was queued")
return _proxy(instance)
def _recovery_proxy(op):
"""The VM for recovery; None when it left the compute the job ran on."""
instance = Instance.objects.select_related("compute").filter(pk=op.params["instance"]).first()
if instance is None or instance.compute_id != op.intent.get("compute"):
return None
try:
return _proxy(instance)
except libvirtError as err:
if err.get_error_code() == VIR_ERR_NO_DOMAIN:
return None
raise # unreachable: recovery waits and retries
def _libvirt_busy(dom):
"""libvirt still runs a job on the VM that the worker no longer waits for."""
return dom.isActive() and dom.jobInfo()[0] != VIR_DOMAIN_JOB_NONE
def _log(op, state, message):
compute, _sep, vm = op.resource_label.rpartition("/") # a VM name has no "/"
return compute, vm, message if state == Operation.SUCCEEDED else f"{message} ({state})"
def _snapshot_free_space():
"""The free space each storage pool keeps for a snapshot, from the
Settings page; an invalid value leaves the backend's default."""
values = {}
for key, argument in (("SNAPSHOT_FREE_SPACE_PERCENT", "free_percent"), ("SNAPSHOT_FREE_SPACE_MIN_GIB", "free_min_gib")):
try:
value = float(getattr(app_settings, key))
except (AttributeError, TypeError, ValueError):
continue
if math.isfinite(value) and value >= 0:
values[argument] = value
return values
CLONE_POST_KEY_RE = re.compile(r"^(clone-net-mac-\d+|disk-[a-z0-9]+|meta-[a-z0-9]+)$")
CLONE_FIELDS = ("name", "clone-title", "clone-description")
def clone_data(user, instance, fields):
"""The clone's settings from the form fields and the source as it is now;
OperationError with the reason when it cannot be made."""
from instances.views import VOLUME_NAME_RE, quota_refused
data = dict()
data["name"] = fields.get("name", "").strip()
data["clone-title"] = fields.get("clone-title", "").strip()
data["clone-description"] = fields.get("clone-description", "").strip()
# the clone copies the disks of the persistent definition
disk_sum = sum(int(disk.get("size") or 0) >> 30 for disk in instance.config_disks)
quota_msg = utils.check_user_quota(user, 1, instance.vcpu, instance.memory, disk_sum)
data["disk_owner_uid"] = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID)
data["disk_owner_gid"] = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID)
# Only superusers may choose disk names and MAC addresses; the form only
# shows those fields to them.
if user.is_superuser:
for key, value in fields.items():
if CLONE_POST_KEY_RE.fullmatch(key):
data[key] = value.strip()
if app_settings.CLONE_INSTANCE_AUTO_NAME == "True" and not data["name"]:
auto_vname = utils.get_clone_free_names()[0]
data["name"] = auto_vname
data["clone-net-mac-0"] = utils.get_dhcp_mac_address(auto_vname)
for disk in instance.config_disks:
data[f"disk-{disk['dev']}"] = utils.get_clone_disk_name(disk, instance.name, auto_vname)
if not user.is_superuser:
for disk in instance.config_disks:
data[f"disk-{disk['dev']}"] = utils.get_clone_disk_name(disk, instance.name, data["name"])
for num in range(max(1, len(instance.config_networks))):
key = f"clone-net-mac-{num}"
if not data.get(key):
data[key] = (utils.get_dhcp_mac_address(data["name"]) if num == 0 else "") or utils.get_random_mac_address()
check_instance = Instance.objects.filter(name=data["name"])
invalid_macs = [
value for key, value in data.items()
if key.startswith("clone-net-mac-") and not utils.MAC_ADDRESS_RE.fullmatch(value)
]
invalid_disks = [
value for key, value in data.items()
# None: a disk without a volume, which clone_instance does not copy.
if key.startswith("disk-") and value is not None and not VOLUME_NAME_RE.fullmatch(value)
]
if not user.is_superuser and quota_msg:
raise util.OperationError(quota_refused(quota_msg, _(
"User '%(quota_msg)s' quota reached, cannot create '%(clone_name)s'!"
) % {"quota_msg": quota_msg, "clone_name": data["name"]}))
if check_instance:
raise util.OperationError(_("Instance '%(clone_name)s' already exists!") % {"clone_name": data["name"]})
if not re.match(r"^[a-zA-Z0-9-]+$", data["name"]):
raise util.OperationError(
_("Instance name '%(clone_name)s' contains invalid characters!") % {"clone_name": data["name"]})
if "clone-net-mac-0" not in data or invalid_macs:
raise util.OperationError(_("Instance MAC '%(clone_mac)s' invalid format!") % {"clone_mac": ", ".join(invalid_macs)})
if invalid_disks:
raise util.OperationError(
_("Disk name '%(disk_name)s' contains invalid characters!") % {"disk_name": ", ".join(str(v) for v in invalid_disks)})
return data
def vm_claims(compute, name):
"""A job that adds a VM: other VMs stay usable; the VM list reconcile waits."""
return {claims.compute_key(compute.pk): Claim.SHARED, claims.reconcile_key(compute.pk): Claim.EXCLUSIVE,
claims.name_key(compute.pk, name): Claim.EXCLUSIVE}
def record_vm(op, owner):
"""The panel's records of the new VM, as the synchronous views wrote them."""
vm = Instance.objects.get_or_create(compute_id=op.intent["compute"], uuid=op.intent["uuid"],
defaults={"name": op.intent["name"]})[0]
if owner and op.user_id:
UserInstance.objects.get_or_create(instance=vm, user_id=op.user_id,
defaults={"is_delete": True, "is_change": True, "is_vnc": True})
def vm_outcome(op, owner, volumes):
"""The VM is defined last: if it exists, the job succeeded. If it does not and
none of its planned volumes exists either, the job failed and changed nothing."""
c = Compute.objects.get(pk=op.intent["compute"])
conn = wvmConnect(c.hostname, c.login, c.password, c.type).wvm
try:
conn.lookupByUUIDString(op.intent["uuid"])
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_DOMAIN:
raise # unreachable: recovery waits and retries
held = set()
for pool in conn.listAllStoragePools(VIR_CONNECT_LIST_STORAGE_POOLS_ACTIVE):
held.update(pool.listVolumes())
left = sorted(held & set(volumes))
if not left:
return Outcome(Operation.FAILED, error="The VM was not defined and no volume of it was left.")
return Outcome(Operation.UNCERTAIN,
error=f"The VM was not defined; check these volumes, then resolve this job: {', '.join(left)}")
record_vm(op, owner)
return Outcome(Operation.SUCCEEDED)
def _clonable(user, pk):
from instances.views import _clone_source
if user is None or not user.has_perm("instances.clone_instances"):
raise PermissionDenied
return _clone_source(user, pk)
def _merged(jobs):
"""(done, total) bytes of the snapshot's block jobs."""
return sum(j["cur"] for j in jobs), sum(j["end"] for j in jobs)
@register
class DeleteExternalSnapshot(Kind):
name = "snapshot.delete"
label = gettext_noop("Delete snapshot")
time_limit = 6 * 3600 # a merge cannot be stopped: over time is only noted
progress_bytes = True # libvirt block job cur/end
def normalize(self, raw):
return {"instance": int(raw["instance"]), "name": str(raw.get("name", ""))}
def validate(self, user, params):
instance = _authorized(user, params["instance"])
if params["name"] not in _proxy(instance).instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL):
raise util.OperationError(f"The VM has no external snapshot named {params['name']}")
def plan(self, user, params):
return _vm_plan(params)
def precheck(self, op, ctx):
proxy = _checked_proxy(op)
proxy._snapshot(op.params["name"], external=True)
proxy._check_snapshot_state()
def run(self, op, ctx):
proxy = _proxy(_instance(op.params["instance"]))
ctx.record_intent(before=proxy.snapshot_files_state())
ctx.set_phase(gettext_noop("Merging the snapshot"))
return {"left_behind": proxy.delete_external_snapshot(op.params["name"])}
def progress(self, op):
jobs = _proxy(_instance(op.params["instance"])).get_snapshot_jobs()
return _merged(jobs) if jobs else None
def reconcile(self, op):
instance = Instance.objects.select_related("compute").filter(pk=op.params["instance"]).first()
if instance is None or instance.compute_id != op.intent.get("compute"):
return Outcome(Operation.UNCERTAIN, error="The VM is no longer on the compute the job ran on")
try:
proxy = _proxy(instance)
listed = op.params["name"] in proxy.instance.snapshotListNames(VIR_DOMAIN_SNAPSHOT_LIST_EXTERNAL)
jobs = proxy.get_snapshot_jobs()
except util.ConnectionFailed:
raise # unreachable: recovery waits and retries
except libvirtError as err: # OperationError is a libvirtError too
if err.get_error_code() == VIR_ERR_NO_DOMAIN:
return Outcome(Operation.UNCERTAIN, error="The VM is no longer on its compute")
raise
if jobs:
return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still merging the snapshot"),
stalled=all(j["cur"] == j["end"] for j in jobs),
progress=_merged(jobs))
if not listed:
before = op.intent.get("before") # recorded with effects_started; absent only if run never began
left = proxy.left_behind_since(before) if before else []
return Outcome(Operation.SUCCEEDED, result={"left_behind": left})
return Outcome(Operation.UNCERTAIN,
error="The snapshot is still listed and no merge runs. It cannot be told whether the disks "
"changed; check the VM before deleting the snapshot again or reverting to it.")
def log_entry(self, op, state):
return _log(op, state, f"Delete external snapshot: {op.params['name']}")
GONE = "The VM is no longer on the compute the job ran on"
@register
class CreateSnapshot(Kind):
name = "snapshot.create"
label = gettext_noop("Take snapshot")
time_limit = 6 * 3600 # a snapshot cannot be stopped: over time is only noted
def normalize(self, raw):
return {"instance": int(raw["instance"]), "name": str(raw.get("name", "")),
"description": str(raw.get("description", "")), "external": bool(raw.get("external")),
"memory": bool(raw.get("memory")), "quiesce": bool(raw.get("quiesce"))}
def validate(self, user, params):
# recovery counts a listed snapshot as taken by the job
if params["name"] in _proxy(_authorized(user, params["instance"])).instance.snapshotListNames(0):
raise util.OperationError(f"A snapshot named {params['name']} already exists")
def plan(self, user, params):
return _vm_plan(params)
def precheck(self, op, ctx):
_checked_proxy(op)
def run(self, op, ctx):
p = op.params
proxy = _proxy(_instance(p["instance"]))
ctx.record_intent()
ctx.set_phase(gettext_noop("Taking the snapshot"))
if p["external"]:
proxy.create_external_snapshot(p["name"], p["description"], p["memory"], p["quiesce"],
**_snapshot_free_space())
else:
proxy.create_snapshot(p["name"], p["description"])
return {}
def reconcile(self, op):
proxy = _recovery_proxy(op)
if proxy is None:
return Outcome(Operation.UNCERTAIN, error=GONE)
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)
return Outcome(Operation.FAILED, error="The snapshot was not taken")
def log_entry(self, op, state):
kind = "external snapshot" if op.params["external"] else "snapshot"
return _log(op, state, f"Create {kind}: {op.params['name']}")
def _current(proxy):
dom = proxy.instance
return dom.snapshotCurrent(0).getName() if dom.hasCurrentSnapshot(0) else None
@register
class RevertSnapshot(Kind):
name = "snapshot.revert"
label = gettext_noop("Revert snapshot")
time_limit = 6 * 3600 # a revert cannot be stopped: over time is only noted
def normalize(self, raw):
return {"instance": int(raw["instance"]), "name": str(raw.get("name", "")),
"external": bool(raw.get("external"))}
def validate(self, user, params):
_proxy(_authorized(user, params["instance"]))._snapshot(params["name"], params["external"])
def plan(self, user, params):
return _vm_plan(params)
def precheck(self, op, ctx):
proxy = _checked_proxy(op)
if op.params["external"]:
proxy.check_external_revert(op.params["name"])
else:
proxy._snapshot(op.params["name"], False)
def run(self, op, ctx):
proxy = _proxy(_instance(op.params["instance"]))
ctx.record_intent(was_current=_current(proxy))
ctx.set_phase(gettext_noop("Reverting the VM"))
if op.params["external"]:
return {"left_behind": proxy.revert_external_snapshot(op.params["name"])}
proxy.snapshot_revert(op.params["name"])
return {}
def reconcile(self, op):
proxy = _recovery_proxy(op)
if proxy is None:
return Outcome(Operation.UNCERTAIN, error=GONE)
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)
return Outcome(Operation.UNCERTAIN, error="It cannot be told whether the VM was reverted; "
"check it, then revert again or resolve this job.")
def log_entry(self, op, state):
kind = "external snapshot" if op.params["external"] else "snapshot"
return _log(op, state, f"Revert {kind}: {op.params['name']}")
def _delete_created(proxy, op):
"""The volumes this job made; nothing else."""
for entry in op.intent.get("created", []):
try:
proxy.get_volume_by_path(entry["path"]).delete(0)
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
CLONE_STATE_REFUSED = "The VM must be running, paused or shut off to be cloned"
THAW_FAILED = "The guest's file systems could not be thawed ({error}); thaw them with: virsh domfsthaw {vm}"
def _check_clone_state(proxy, data):
"""A shut-off VM is copied as it is; a running or paused one only when a
live clone can work; other states are refused."""
status = proxy.get_status()
if status in (1, 3):
proxy.live_clone_targets(data, **_snapshot_free_space())
elif status != 5:
raise util.OperationError(CLONE_STATE_REFUSED)
@register
class CloneInstance(Kind):
"""A shut-off VM is copied volume by volume; a running or paused one with
libvirt's push backup, which copies its disks from one instant."""
name = "instance.clone"
label = gettext_noop("Clone VM")
cancellable = True # libvirt aborts a live copy; a shut-off copy runs to its end
progress_bytes = True # libvirt backup job disk_processed/disk_total
def normalize(self, raw):
fields = {k: str(v) for k, v in raw.items() if k in CLONE_FIELDS or CLONE_POST_KEY_RE.fullmatch(k)}
return {"instance": int(raw["instance"]), "fields": fields}
def plan(self, user, params):
instance = _clonable(user, params["instance"])
data = clone_data(user, instance, params["fields"])
keys = vm_claims(instance.compute, data["name"])
# the VM's definition is read again when the clone is defined
keys[claims.instance_key(instance.uuid)] = Claim.EXCLUSIVE
for disk in instance.config_disks:
if name := data.get(f"disk-{disk['dev']}"):
keys[claims.volume_key(instance.compute_id, disk["storage"], name)] = Claim.EXCLUSIVE
_check_clone_state(instance.proxy, data)
return Plan(claims=keys, resource=claims.instance_key(instance.uuid),
label=f"{instance.compute.name}/{instance.name}",
intent={"compute": instance.compute_id, "uuid": util.randomUUID(), "name": data["name"],
"clone": data})
def precheck(self, op, ctx):
instance = _clonable(op.user, op.params["instance"])
if instance.compute_id != op.intent["compute"]:
raise util.OperationError("The VM moved to another compute after the job was queued")
_check_clone_state(instance.proxy, op.intent["clone"])
def run(self, op, ctx):
instance = _instance(op.params["instance"])
proxy = instance.proxy
clone, uuid = op.intent["clone"], op.intent["uuid"]
status = proxy.get_status()
if status == 5:
ctx.record_intent(live=False)
ctx.set_phase(gettext_noop("Copying disks"))
proxy.clone_instance(clone, uuid)
record_vm(op, owner=True)
return {}
if status not in (1, 3):
raise util.OperationError(CLONE_STATE_REFUSED)
targets = proxy.live_clone_targets(clone, **_snapshot_free_space())
ctx.record_intent(live=True, targets=targets)
ctx.set_phase(gettext_noop("Copying disks"))
copies = [] # the targets at the paths libvirt gave their volumes
for t in targets:
ctx.record_intent(planned=[*op.intent.get("planned", []), {"dev": t["dev"], "path": t["path"]}])
path = proxy.create_clone_target(t, clone["disk_owner_uid"], clone["disk_owner_gid"])
ctx.record_intent(created=[*op.intent.get("created", []), {"dev": t["dev"], "path": path}])
copies.append({**t, "path": path})
# a completed result libvirt still holds is of an earlier job; after
# copy_started, one can only be of this job's backup
proxy.discard_backup_result()
ctx.record_intent(copy_started=True)
op.refresh_from_db(fields=["cancel_requested"])
if op.cancel_requested:
_delete_created(proxy, op)
raise Cancelled
freeze = status == 1 and proxy.is_agent_ready()
warning = ""
try:
try:
if freeze:
ctx.record_intent(frozen=True)
proxy.fs_freeze()
proxy.begin_clone_backup(copies)
finally:
if freeze:
try:
proxy.fs_thaw()
except libvirtError as err: # frozen stays recorded: recovery thaws again
warning = THAW_FAILED.format(error=err, vm=instance.name)
else:
ctx.record_intent(frozen=False)
except libvirtError as err:
# no backup of ours runs: the copy failed (if this read fails, recovery decides)
if proxy.backup_targets() != {c["path"] for c in copies}:
ctx.record_intent(copy_result=[False, str(err)])
raise
op.refresh_from_db(fields=["cancel_requested"])
if op.cancel_requested: # the cancel hook may have run before the backup began
try:
self.cancel(op)
except libvirtError:
pass # the hook aborted it, or it ended: its result tells
while proxy.backup_progress() is not None:
time.sleep(2)
result = proxy.backup_result()
ctx.record_intent(copy_result=list(result) if result else None)
if result is None:
raise util.OperationError("libvirt has no result of the copy") # recovery decides
success, errmsg = result
if success:
proxy.define_clone(clone, uuid, {c["dev"]: c["path"] for c in copies})
record_vm(op, owner=True)
return {"warning": warning} if warning else {}
_delete_created(proxy, op)
op.refresh_from_db(fields=["cancel_requested"])
if op.cancel_requested:
raise Cancelled
raise util.OperationError(" ".join(filter(None, [errmsg or "The copy failed", warning])))
def progress(self, op):
return _proxy(_instance(op.params["instance"])).backup_progress() if op.intent.get("live") else None
def cancel(self, op):
# a shut-off copy, or a live one not started yet (run checks the request first)
if not op.intent.get("live"):
return
proxy = _proxy(_instance(op.params["instance"]))
created = {c["path"] for c in op.intent.get("created", [])}
if created and proxy.backup_targets() == created:
proxy.abort_backup()
def reconcile(self, op):
if not op.intent.get("live"):
disks = [v for k, v in op.intent["clone"].items() if k.startswith("disk-") and v]
return vm_outcome(op, owner=True, volumes=disks)
proxy = _recovery_proxy(op)
if proxy is None:
created = ", ".join(c["path"] for c in op.intent.get("created", []))
return Outcome(Operation.UNCERTAIN, error=f"{GONE}; check these volumes, then resolve this job: {created}")
warning = ""
if op.intent.get("frozen") and proxy.get_status() == 1:
try:
proxy.fs_thaw()
except libvirtError as err:
warning = " " + THAW_FAILED.format(error=err, vm=op.resource_label.rpartition("/")[2])
outcome = self._live_outcome(proxy, op)
outcome.error += warning
return outcome
def _live_outcome(self, proxy, op):
"""The spec's recovery table: a defined clone first, then a volume
still being created, since it comes before the copy."""
if _domain(op.intent["compute"], op.intent["uuid"]) is not None:
record_vm(op, owner=True)
return Outcome(Operation.SUCCEEDED)
created = op.intent.get("created", [])
paths = [c["path"] for c in created]
ended = Operation.CANCELLED if op.cancel_requested else Operation.FAILED
done = {c["dev"] for c in created}
planned = {p["dev"] for p in op.intent.get("planned", [])}
for t in op.intent.get("targets", []):
if t["dev"] not in planned or t["dev"] in done:
continue
try:
proxy.wvm.storagePoolLookupByName(t["pool"]).storageVolLookupByName(t["name"])
except libvirtError as err:
if err.get_error_code() != VIR_ERR_NO_STORAGE_VOL:
raise
else:
paths.append(t["path"])
check = f"check these volumes, then resolve this job: {', '.join(paths)}"
if len(paths) > len(created):
return Outcome(Operation.UNCERTAIN, error=f"A volume of the clone was being created; {check}")
if not op.intent.get("copy_started"):
_delete_created(proxy, op)
return Outcome(ended, error="The job stopped before the copy started")
# recorded only once this job's backup ended or never started
result = op.intent.get("copy_result")
if result is None:
targets = proxy.backup_targets()
if targets is not None:
if targets == set(paths):
return Outcome(Operation.RUNNING, phase=gettext_noop("libvirt is still copying the disks"),
progress=proxy.backup_progress())
return Outcome(Operation.UNCERTAIN, error=f"Another backup runs on the VM; {check}")
result = proxy.backup_result()
if result is None:
return Outcome(Operation.UNCERTAIN, error=f"libvirt has no result of the copy; {check}")
success, errmsg = result
if not success:
_delete_created(proxy, op)
return Outcome(ended, error=errmsg or "The copy failed")
proxy.define_clone(op.intent["clone"], op.intent["uuid"], {c["dev"]: c["path"] for c in created})
record_vm(op, owner=True)
return Outcome(Operation.SUCCEEDED)
def log_entry(self, op, state):
compute, _sep, source = op.resource_label.rpartition("/")
message = f"Create a clone of '{source}'"
return compute, op.intent["name"], message if state == Operation.SUCCEEDED else f"{message} ({state})"
@register
class CreateInstance(Kind):
name = "instance.create"
label = gettext_noop("Create VM")
time_limit = 12 * 3600 # a copy cannot be stopped: over time is only noted
def normalize(self, raw):
# what the API leaves out means what create_instance did by default
params = {"template": "", "images": "", "firmware": "", "net_model": "virtio", "add_cdrom": "sata",
"add_input": "default", "meta_prealloc": False, "hdd_size": 0}
params.update({k: v for k, v in raw.items() if k not in ("submission_key", "create", "csrfmiddlewaretoken")})
params.update(compute=int(raw["compute"]), devices=[list(d) for d in raw.get("devices", [])])
if "UEFI" in params["firmware"] and "secboot" in params["firmware"].partition(":")[2]:
params["machine"] = "q35" # a secure boot loader needs SMM, on q35 only
return params
def validate(self, user, params):
from instances.views import CDROM_BUSES
if not user.is_superuser:
raise PermissionDenied
data = params
c = Compute.objects.get(pk=data["compute"])
conn = wvmCreate(c.hostname, c.login, c.password, c.type)
cdrom_buses = [bus for bus in conn.get_disk_bus_types(data["arch"], data["machine"]) if bus in CDROM_BUSES]
if data["name"] in conn.get_instances():
raise util.OperationError(_("A virtual machine with this name already exists"))
if Instance.objects.filter(name__exact=data["name"]):
raise util.OperationError(_("There is an instance with same name. Remove it and try again!"))
if not (data["hdd_size"] or data["template"] or data["images"]):
raise util.OperationError(_("First you need to create or select an image"))
if not data["hdd_size"] and data["template"] and conn.get_volume_path(data["name"] + ".img", data["storage"]):
raise util.OperationError(_("Image has already exist. Please check volumes or change instance name"))
if data["cache_mode"] not in conn.get_cache_modes():
raise util.OperationError(_("Invalid cache mode"))
if data["add_cdrom"] != "None" and data["add_cdrom"] not in cdrom_buses:
raise util.OperationError(_("A CD-ROM cannot use the %(bus)s bus") % {"bus": data["add_cdrom"]})
try:
utils.nic_macs(data["mac"], data["networks"])
except ValueError as err:
raise util.OperationError(str(err))
if "UEFI" in data["firmware"] and ":" not in data["firmware"]:
raise util.OperationError(_("Invalid firmware"))
def plan(self, user, params):
compute = Compute.objects.get(pk=params["compute"])
return Plan(claims=vm_claims(compute, params["name"]), resource=claims.compute_key(compute.pk),
label=f"{compute.name}/{params['name']}",
intent={"compute": compute.pk, "uuid": util.randomUUID(), "name": params["name"]})
def run(self, op, ctx):
data = op.params
c = Compute.objects.get(pk=data["compute"])
conn = wvmCreate(c.hostname, c.login, c.password, c.type)
meta_prealloc = bool(data["meta_prealloc"])
default_bus = app_settings.INSTANCE_VOLUME_DEFAULT_BUS
default_disk_format = app_settings.INSTANCE_VOLUME_DEFAULT_FORMAT
default_disk_owner_uid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_UID)
default_disk_owner_gid = int(app_settings.INSTANCE_VOLUME_DEFAULT_OWNER_GID)
firmware = dict()
if "UEFI" in data["firmware"]:
firmware["loader"] = data["firmware"].split(":")[1].strip()
firmware["secure"] = "yes" if "secboot" in firmware["loader"] else "no"
firmware["readonly"] = "yes"
firmware["type"] = "pflash"
def volume(path, device="disk", bus=default_bus):
return {
"path": path,
"type": conn.get_volume_format_type(path),
"device": device,
"bus": bus,
"scsi_model": app_settings.INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER if bus == "scsi" else None,
"cache_mode": data["cache_mode"],
"discard_mode": app_settings.INSTANCE_VOLUME_DEFAULT_DISCARD,
"detect_zeroes_mode": app_settings.INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES,
"io_mode": app_settings.INSTANCE_VOLUME_DEFAULT_IO,
}
ctx.record_intent()
ctx.set_phase(gettext_noop("Copying disks"))
created = [] # volumes this job allocated
volume_list = []
try:
if data["hdd_size"]:
path = conn.create_volume(data["storage"], data["name"], data["hdd_size"], default_disk_format,
meta_prealloc, default_disk_owner_uid, default_disk_owner_gid)
created.append(path)
volume_list.append(volume(path))
elif data["template"]:
path = conn.clone_from_template(data["name"], conn.get_volume_path(data["template"]), data["storage"],
meta_prealloc, default_disk_owner_uid, default_disk_owner_gid)
created.append(path)
volume_list.append(volume(path))
else:
for vol, (device, bus) in zip(data["images"].split(","), data["devices"]):
volume_list.append(volume(conn.get_volume_path(vol), device, bus))
ctx.set_phase(gettext_noop("Defining the VM"))
conn.create_instance(
name=data["name"],
memory=data["memory"],
vcpu=data["vcpu"],
vcpu_mode=data["vcpu_mode"],
uuid=op.intent["uuid"],
arch=data["arch"],
machine=data["machine"],
firmware=firmware,
volumes=volume_list,
networks=data["networks"],
virtio=data["virtio"],
listener_addr=data["listener_addr"],
nwfilter=data["nwfilter"],
net_model="virtio" if data["net_model"] == "default" else data["net_model"],
video=data["video"],
console_pass="",
mac=data["mac"],
qemu_ga=data["qemu_ga"],
add_cdrom=data["add_cdrom"],
add_input=data["add_input"],
)
except Exception as err:
# the VM is not defined: its new volumes go
leftovers = []
for path in created:
try:
conn.delete_volume(path)
except libvirtError:
leftovers.append(path)
if leftovers:
raise util.OperationError(f"{err}. Remove these volumes by hand: {', '.join(leftovers)}") from err
raise
record_vm(op, owner=False)
return {}
def reconcile(self, op):
data = op.params
names = [data["name"] + ext for ext in ("", ".img", ".qcow", ".qcow2")]
return vm_outcome(op, owner=False, volumes=names if data["hdd_size"] or data["template"] else [])
def log_entry(self, op, state):
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")
cancellable = True # libvirt aborts a migration; the VM keeps running on the source
progress_bytes = True # libvirt job data processed/total
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"))
try:
# 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})
except libvirtError:
op.refresh_from_db(fields=["cancel_requested"])
if op.cancel_requested:
raise Cancelled from None
raise
return {}
def progress(self, op):
info = _proxy(_instance(op.params["instance"])).instance.jobInfo()
return (info[4], info[3]) if info[3] else None
def cancel(self, op):
dom = _proxy(_instance(op.params["instance"])).instance
# the worker sends a cancel once: one that comes before libvirt began
# the migration waits for it, or for the job to end without it
while not _libvirt_busy(dom):
if not Operation.objects.filter(pk=op.pk, state__in=Operation.ACTIVE).exists():
return
time.sleep(1)
# an error (the migration ended meanwhile) goes to the worker log
dom.abortJob()
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.")