From d7c3c84ca04f1d8b2eb3367cfff92418b130b77f Mon Sep 17 00:00:00 2001 From: Carpoon <carpoon@carpoon.hu> Date: Thu, 14 Dec 2023 00:50:41 +0100 Subject: [PATCH] VM import export funtionailty VM import export funtionailty Extend Export disk functions --- circle/dashboard/templates/_import_vm_choose.html | 34 ++++++++++++++++++++++++++++++++++ circle/dashboard/templates/dashboard/_disk-list-element.html | 21 ++++++++------------- circle/dashboard/templates/dashboard/_template-choose.html | 6 ++++++ circle/dashboard/templates/dashboard/template-import.html | 14 ++++++++++++++ circle/dashboard/templates/dashboard/vm-detail.html | 11 +++++++++++ circle/dashboard/templates/dashboard/vm-detail/exports.html | 25 +++++++++++++++++++++++++ circle/dashboard/views/__init__.py | 1 + circle/dashboard/views/template.py | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- circle/dashboard/views/vm.py | 23 +++++++++++++++++++++-- circle/storage/models.py | 28 ++++++++++++++++++++++++---- circle/vm/fixtures/ova.xml.j2 | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ circle/vm/models/__init__.py | 4 +++- circle/vm/models/instance.py | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- circle/vm/operations.py | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ circle/vm/tasks/vm_tasks.py | 6 +++++- 15 files changed, 883 insertions(+), 41 deletions(-) create mode 100644 circle/dashboard/templates/_import_vm_choose.html create mode 100644 circle/dashboard/templates/dashboard/template-import.html create mode 100644 circle/dashboard/templates/dashboard/vm-detail/exports.html create mode 100644 circle/vm/fixtures/ova.xml.j2 diff --git a/circle/dashboard/templates/_import_vm_choose.html b/circle/dashboard/templates/_import_vm_choose.html new file mode 100644 index 0000000..bda5f3e --- /dev/null +++ b/circle/dashboard/templates/_import_vm_choose.html @@ -0,0 +1,34 @@ +{% load i18n %} + +<form action="{% url "dashboard.views.template-import" %}" method="POST" + id="template-choose-form"> + {% csrf_token %} + <div class="template-choose-list"> + {% for t in templates %} + <div class="panel panel-default template-choose-list-element"> + <input type="radio" name="parent" value="{{ t.pk }}"/> + {{ t.name }} - {{ t.system }} + <small>Cores: {{ t.num_cores }} RAM: {{ t.ram_size }}</small> + <div class="clearfix"></div> + </div> + {% endfor %} + <button type="submit" id="template-choose-next-button" class="btn btn-success pull-right">{% trans "Next" %}</button> + <div class="clearfix"></div> + </div> +</form> + +<script> + $(function() { + $(".template-choose-list-element").click(function() { + $("input", $(this)).prop("checked", true); + }); + $(".template-choose-list-element").hover( + function() { + $("small", $(this)).stop().fadeIn(200); + }, + function() { + $("small", $(this)).stop().fadeOut(200); + } + ); + }); +</script> diff --git a/circle/dashboard/templates/dashboard/_disk-list-element.html b/circle/dashboard/templates/dashboard/_disk-list-element.html index 209dfad..b890d42 100644 --- a/circle/dashboard/templates/dashboard/_disk-list-element.html +++ b/circle/dashboard/templates/dashboard/_disk-list-element.html @@ -13,20 +13,15 @@ <a href="{{ op.export_disk.get_url }}?disk={{ d.pk }}" class="btn btn-xs btn-{{ op.export_disk.effect }} operation disk-export-btn {% if op.export_disk.disabled %}disabled{% endif %}"> - <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans "Export" %} - </a> - <a href="/image-dl/{{ d.filename }}.vmdk" - class="btn btn-xs btn-{{ op.export_disk.effect }} operation disk-export-btn"> - <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans "VMDK" %} - </a> - <a href="/image-dl/{{ d.filename }}.vdi" - class="btn btn-xs btn-{{ op.export_disk.effect }} operation disk-export-btn"> - <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans "VDI" %} - </a> - <a href="/image-dl/{{ d.filename }}.vdi" + <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans "Export" %} + </a> + + {% for export in d.exporteddisk_set.all %} + <a href="/image-dl/{{ export.filename }}" class="btn btn-xs btn-{{ op.export_disk.effect }} operation disk-export-btn"> - <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans "VPC" %} - </a> + <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans export.format %} + </a> + {% endfor %} {% endif %} {% else %} <small class="btn-xs"> diff --git a/circle/dashboard/templates/dashboard/_template-choose.html b/circle/dashboard/templates/dashboard/_template-choose.html index 2085439..eb83a1f 100644 --- a/circle/dashboard/templates/dashboard/_template-choose.html +++ b/circle/dashboard/templates/dashboard/_template-choose.html @@ -26,6 +26,12 @@ {% trans "Create a new base VM without disk" %} </div> {% endif %} + {% if perms.vm.import_template %} + <div class="panel panel-default template-choose-list-element"> + <input type="radio" name="parent" value="import_vm"/> + {% trans "Import a VM" %} + </div> + {% endif %} <button type="submit" id="template-choose-next-button" class="btn btn-success pull-right">{% trans "Next" %}</button> <div class="clearfix"></div> </div> diff --git a/circle/dashboard/templates/dashboard/template-import.html b/circle/dashboard/templates/dashboard/template-import.html new file mode 100644 index 0000000..036ec07 --- /dev/null +++ b/circle/dashboard/templates/dashboard/template-import.html @@ -0,0 +1,14 @@ +{% load crispy_forms_tags %} +{% load i18n %} + +<p class="text-muted"> + {% trans "Import a previously exported VM from the user store." %} +</p> +<p class="alert alert-info"> + {% trans "Please don't forget to add network interfaces, as those won't be imported!" %} +</p> + +<form method="POST" action="{% url "dashboard.views.template-import" %}"> + {% csrf_token %} + {% crispy form %} +</form> diff --git a/circle/dashboard/templates/dashboard/vm-detail.html b/circle/dashboard/templates/dashboard/vm-detail.html index b1bbf74..efa4bb0 100644 --- a/circle/dashboard/templates/dashboard/vm-detail.html +++ b/circle/dashboard/templates/dashboard/vm-detail.html @@ -231,6 +231,13 @@ <i class="fa fa-cloud-upload fa-2x"></i><br> {% trans "Cloud-init" %}</a> </li> + {% if perms.vm.export_vm %} + <li> + <a href="#exports" data-toggle="pill" data-target="#_exports" class="text-center"> + <i class="fa fa fa-cloud-download fa-2x"></i><br> + {% trans "Exports" %}</a> + </li> + {% endif %} <li> <a href="#activity" data-toggle="pill" data-target="#_activity" class="text-center" data-activity-url="{% url "dashboard.views.vm-activity-list" instance.pk %}"> @@ -253,6 +260,10 @@ <hr class="js-hidden"/> <div class="not-tab-pane" id="_activity">{% include "dashboard/vm-detail/activity.html" %}</div> <hr class="js-hidden"/> + {% if perms.vm.export_vm %} + <div class="not-tab-pane" id="_exports">{% include "dashboard/vm-detail/exports.html" %}</div> + <hr class="js-hidden"/> + {% endif %} </div> </div> </div> diff --git a/circle/dashboard/templates/dashboard/vm-detail/exports.html b/circle/dashboard/templates/dashboard/vm-detail/exports.html new file mode 100644 index 0000000..cf2edb1 --- /dev/null +++ b/circle/dashboard/templates/dashboard/vm-detail/exports.html @@ -0,0 +1,25 @@ +{% load i18n %} +{% load sizefieldtags %} +{% load crispy_forms_tags %} +{% load static %} +<div> + <h3> + {% trans "Exports" %} + </h3> + <div class="clearfix"></div> + + {% if not instance.exportedvm_set.all %} + {% trans "No exports are created." %} + {% endif %} + {% for export in instance.exportedvm_set.all %} + <h4 class="list-group-item-heading dashboard-vm-details-network-h3"> + <i class="fa fa-file"></i> {{ export.name }} exportend on {{ export.created }} + <a href="/image-dl/{{ export.filename }}" class="btn btn-info operation disk-export-btn"> + <i class="fa fa-{{ op.export_disk.icon }} fa-fw-12"></i> {% trans "Download" %} + </a> + <a href="{% url "dashboard.views.exportedvm-delete" pk=export.pk %}" class="btn btn-danger operation disk-export-btn"> + <i class="fa fa-times fa-fw-12"></i> {% trans "Delete" %} + </a> + </h4> + {% endfor %} +</div> diff --git a/circle/dashboard/views/__init__.py b/circle/dashboard/views/__init__.py index 233f37f..2fc1c32 100644 --- a/circle/dashboard/views/__init__.py +++ b/circle/dashboard/views/__init__.py @@ -16,3 +16,4 @@ from .storage import * from request import * from .message import * from .autocomplete import * +from .exam import * diff --git a/circle/dashboard/views/template.py b/circle/dashboard/views/template.py index bd6b4a5..700d214 100644 --- a/circle/dashboard/views/template.py +++ b/circle/dashboard/views/template.py @@ -14,14 +14,23 @@ # # You should have received a copy of the GNU General Public License along # with CIRCLE. If not, see <http://www.gnu.org/licenses/>. - - +import os +import re +import subprocess +import sys from datetime import timedelta import json import logging +from glob import glob +from hashlib import sha256, sha1 +from shutil import move, rmtree from string import Template +from tarfile import TarFile +from time import sleep, time +from xml.etree.cElementTree import parse as XMLparse from xml.dom import NotFoundErr +from django.conf import settings from django.contrib import messages from django.contrib.auth.models import User from django.contrib.messages.views import SuccessMessageMixin @@ -35,19 +44,21 @@ from django.utils.translation import ugettext as _, ugettext_noop from django.views.generic import ( TemplateView, CreateView, UpdateView, ) +#import magic +from ..store_api import Store, NoStoreException from braces.views import ( LoginRequiredMixin, PermissionRequiredMixin, ) from django_tables2 import SingleTableView from vm.models import ( - InstanceTemplate, InterfaceTemplate, Instance, Lease, InstanceActivity + InstanceTemplate, InterfaceTemplate, Instance, Lease, InstanceActivity, ExportedVM, OS_TYPES ) -from storage.models import Disk +from storage.models import Disk, DataStore from ..forms import ( - TemplateForm, TemplateListSearchForm, AclUserOrGroupAddForm, LeaseForm, + TemplateForm, TemplateListSearchForm, AclUserOrGroupAddForm, LeaseForm, TemplateImportForm, ) from ..tables import TemplateListTable, LeaseListTable @@ -107,6 +118,8 @@ class TemplateChoose(LoginRequiredMixin, TemplateView): template = request.POST.get("parent") if template == "base_vm": return redirect(reverse("dashboard.views.template-create")) + elif template == "import_vm": + return redirect(reverse("dashboard.views.template-import")) elif template is None: messages.warning(request, _("Select an option to proceed.")) return redirect(reverse("dashboard.views.template-choose")) @@ -193,6 +206,281 @@ class TemplateCreate(SuccessMessageMixin, CreateView): return reverse_lazy("dashboard.views.template-list") +class TemplateImport(LoginRequiredMixin, TemplateView): + form_class = TemplateImportForm + + def get_template_names(self): + if self.request.is_ajax(): + return ['dashboard/_modal.html'] + else: + return ['dashboard/nojs-wrapper.html'] + + def get(self, request, form=None, *args, **kwargs): + if not request.user.has_perm('vm.import_template'): + raise PermissionDenied() + try: + Store(request.user) + except NoStoreException: + raise PermissionDenied + + if form is None: + form = self.form_class(user=request.user) + context = self.get_context_data(**kwargs) + context.update({ + 'template': 'dashboard/template-import.html', + 'box_title': _('Import a VM to a template'), + 'form': form, + 'ajax_title': True, + }) + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + if not request.user.has_module_perms('vm.import_template'): + raise PermissionDenied() + try: + store = Store(request.user) + except NoStoreException: + raise PermissionDenied() + + form = self.form_class(request.POST, user=request.user) + if form.is_valid(): + datastore = DataStore.objects.filter(name=settings.EXPORT_DATASTORE).get() + ova_path = form.cleaned_data["ova_path"] + template_name = form.cleaned_data["template_name"] + url, port = store.request_ssh_download(ova_path) + ova_filename = re.split('[:/]', url)[-1] + tmp_path = os.path.join(datastore.path, "tmp", ova_filename + str(time())) + ova_file = os.path.join(tmp_path, ova_filename) + try: + os.mkdir(os.path.join(datastore.path, "tmp")) + except FileExistsError: + pass + try: + os.mkdir(tmp_path) + except FileExistsError: + pass + if settings.STORE_SSH_MODE == "scp": + cmdline = ['scp', '-B', '-P', str(port), url, ova_file] + if settings.STORE_IDENTITY_FILE is not None and settings.STORE_IDENTITY_FILE != "": + cmdline.append("-i") + cmdline.append(settings.STORE_IDENTITY_FILE) + elif settings.STORE_SSH_MODE == "rsync": + cmdline = ["rsync", "-qLS", url, ova_file] + cmdline.append("-e") + if settings.STORE_IDENTITY_FILE is not None: + cmdline.append("ssh -i %s -p %s" % (settings.STORE_IDENTITY_FILE, str(port))) + else: + cmdline.append("ssh -p %s" % str(port)) + else: + logger.error("Invalid mode for disk export: %s" % settings.STORE_SSH_MODE) + raise Exception("Invalid mode for disk export: %s" % settings.STORE_SSH_MODE) + + logger.debug("Calling file transfer with command line: %s" % str(cmdline)) + try: + # let's try the file transfer 5 times, it may be an intermittent network issue + for i in range(4, -1, -1): + proc = subprocess.Popen(cmdline) + while proc.poll() is None: + sleep(2) + if proc.returncode == 0: + break + else: + logger.error("Copy over ssh failed with return code: %s, will try %s more time(s)..." % (str(proc.returncode), str(i))) + if proc.stdout is not None: + logger.info(proc.stdout.read()) + if proc.stdout is not None: + logger.error(proc.stderr.read()) + with TarFile.open(name=ova_file, mode="r") as tar: + if sys.version_info >= (3, 12): + tar.extractall(path=tmp_path, filter='data') + else: + for tarinfo in tar: + if tarinfo.name.startswith("..") or tarinfo.name.startswith("/") or tarinfo.name.find(): + raise Exception("import template: invalid path in tar file") + if tarinfo.isreg(): + tar.extract(tarinfo, path=tmp_path, set_attrs=False) + elif tarinfo.isdir(): + tar.extract(tarinfo, path=tmp_path, set_attrs=False) + else: + raise Exception("import template: invalid file type in tar file") + os.unlink(ova_file) + mf = glob(os.path.join(tmp_path, "*.mf")) + if len(mf) > 1: + logger.error("import template: Invalid ova: multiple mf files!") + messages.error("import template: Invalid ova: multiple mf files!") + raise Exception("Invalid ova: multiple mf files") + elif len(mf) == 1: + with open(os.path.join(tmp_path, mf[0]), 'r') as mf_file: + def compute_sha256(file_name): + hash_sha256 = sha256() + with open(file_name, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha256.update(chunk) + return hash_sha256.hexdigest() + + def compute_sha1(file_name): + hash_sha1 = sha1() + with open(file_name, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha1.update(chunk) + return hash_sha1.hexdigest() + + for line in mf_file: + if line.startswith("SHA1"): + line_split = line.split("=") + filename = line_split[0][4:].strip()[1:-1] + hash_value = line_split[1].strip() + if compute_sha1(os.path.join(tmp_path, filename)) != hash_value: + logger.error("import template: mf: hash check failed!") + messages.error("import template: mf: hash check failed!") + raise Exception("import template: mf: hash check failed!") + else: + logger.info("%s passed hash test" % filename) + elif line.startswith("SHA256"): + line_split = line.split("=") + filename = line_split[0][6:].strip()[1:-1] + hash_value = line_split[1].strip() + if compute_sha256(os.path.join(tmp_path, filename)) != hash_value: + logger.error("import template: mf: hash check failed!") + messages.error("import template: mf: hash check failed!") + else: + logger.info("%s passed hash test" % filename) + else: + logger.error("import template: mf: Invalid hash algorythm!") + messages.error("import template: mf: Invalid hash algorythm!") + os.unlink(os.path.join(tmp_path, mf[0])) + ovf = glob(os.path.join(tmp_path, "*.ovf")) + if len(ovf) != 1: + logger.error("import template: Invalid ova: multiple ovf files!") + messages.error("import template: Invalid ova: multiple ovf files!") + raise Exception("Invalid ova: multiple ovf files") + xml = XMLparse(ovf[0]) + xml_root = xml.getroot() + + files = {} + disks = {} + disks_circle = [] + + xml_references = xml_root.findall("{http://schemas.dmtf.org/ovf/envelope/2}References") + if len(xml_references) == 1: + logger.error(xml_references) + for xml_reference in xml_references[0].findall("{http://schemas.dmtf.org/ovf/envelope/2}File"): + files[xml_reference.get("{http://schemas.dmtf.org/ovf/envelope/2}id")] = xml_reference.get("{http://schemas.dmtf.org/ovf/envelope/2}href") + logger.error(files) + + xml_disk_section = xml_root.findall("{http://schemas.dmtf.org/ovf/envelope/2}DiskSection") + if len(xml_disk_section) == 1: + for disk in xml_disk_section[0].findall("{http://schemas.dmtf.org/ovf/envelope/2}Disk"): + disks[disk.get("{http://schemas.dmtf.org/ovf/envelope/2}diskId")] = {"name": files[disk.get("{http://schemas.dmtf.org/ovf/envelope/2}fileRef")], "type": disk.get("{http://schemas.dmtf.org/ovf/envelope/2}format") } + logger.error(disks) + + xml_VirtualSystem = xml_root.findall("{http://schemas.dmtf.org/ovf/envelope/2}VirtualSystem")[0] + ovf_os = xml_VirtualSystem.findall("{http://schemas.dmtf.org/ovf/envelope/2}OperatingSystemSection")[0].findall("{http://schemas.dmtf.org/ovf/envelope/2}Description")[0].text + logger.error(ovf_os) + arch_id = xml_VirtualSystem.findall("{http://schemas.dmtf.org/ovf/envelope/2}OperatingSystemSection")[0].get("{http://schemas.dmtf.org/ovf/envelope/2}id") + if arch_id == "102": + arch = "x86_64" + elif arch_id == 0 or arch_id == 1: + arch = "i686" + else: + os_type: str = OS_TYPES[int(arch_id)] + if os_type.endswith("64-Bit"): + arch = "x86_64" + else: + arch = "i686" + try: + ovf_description = xml_VirtualSystem.findall("{http://schemas.dmtf.org/ovf/envelope/2}Description")[0].text + except Exception as e: + logger.error("Couldn't load description from ovf: %s" % e) + ovf_description = "" + + xml_hardware = xml_VirtualSystem.findall("{http://schemas.dmtf.org/ovf/envelope/2}VirtualHardwareSection")[0] + for item in xml_hardware.iter(): + if item.tag == "{http://schemas.dmtf.org/ovf/envelope/2}Item": + resource_type = item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData}ResourceType")[0].text + # CPU + if resource_type == "3": + cores = int(item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData}VirtualQuantity")[0].text) + logger.info("import ovf: cores: %s" % cores) + # memory + if resource_type == "4": + memory = int(item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData}VirtualQuantity")[0].text) + try: + unit = item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData}VirtualQuantityUnits")[0].text.lower() + except: + try: + unit = item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData}AllocationUnits")[0].text.lower() + except: + raise Exception("No unit for memory.") + unit_split = unit.split("*") + unit_base = unit_split[0].strip() + if unit_base in ["bytes", "kilobytes", "megabytes", "gigayytes"]: + if unit_base == "kilobytes": + memory = memory * 1000 + elif unit_base == "megabytes": + memory = memory * 1000 * 1000 + elif unit_base == "gigabytes": + memory = memory * 1000 * 1000 * 1000 + else: + raise Exception("Invalid unit for memory.") + if len(unit_split) == 2: + unit_numbers = unit_split[1].strip().split("^") + memory = memory * (int(unit_numbers[0].strip()) ** int(unit_numbers[1].strip())) + memory = int(memory / 1024 / 1024) + logger.info("import ovf: memory: %s MiB" % memory) + elif item.tag == "{http://schemas.dmtf.org/ovf/envelope/2}StorageItem": + if item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_StorageAllocationSettingData.xsd}ResourceType")[0].text.lower() == str(17): + disk_no = item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_StorageAllocationSettingData.xsd}HostResource")[0].text.split("/")[-1] + disk_name = item.findall("{http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_StorageAllocationSettingData.xsd}Caption")[0].text + datastore = DataStore.objects.filter(name='default').get() + circle_disk = Disk.create(datastore=datastore, type="qcow2-norm", name=disk_name) + imported_path = os.path.join(tmp_path, disks[disk_no]["name"]) + #with magic.Magic() as m: + # ftype = m.id_filename(imported_path) + #if 'qcow' in ftype.lower(): + # move(imported_path, circle_disk.filename) + #else: + logger.debug("Calling qemu-img with command line: %s" % str(cmdline)) + cmdline = ['ionice', '-c', 'idle', + 'qemu-img', 'convert', + '-m', '4', '-O', 'qcow2', + imported_path, + os.path.join(datastore.path, circle_disk.filename)] + circle_disk.is_ready = True + circle_disk.save() + logger.debug("Calling qemu-img with command line: %s" % str(cmdline)) + subprocess.check_output(cmdline) + disks_circle.append(circle_disk) + + template = InstanceTemplate(name=template_name, + access_method='ssh', + description=ovf_description, + system=ovf_os, + num_cores=cores, + num_cores_max=cores, + ram_size=memory, + max_ram_size=memory, + arch=arch, + priority=0, + owner=self.request.user, + lease=form.cleaned_data["lease"], + ) + template.save() + for disk in disks_circle: + template.disks.add(disk) + template.save() + return redirect(template.get_absolute_url()) + except Exception as e: + logger.error(e) + raise + finally: + if os.path.exists(ova_path): + os.unlink(ova_path) + if os.path.exists(tmp_path): + rmtree(tmp_path) + else: + return self.get(request, form, *args, **kwargs) + class TemplateREST(APIView): authentication_classes = [TokenAuthentication,BasicAuthentication] @@ -684,3 +972,19 @@ class TransferTemplateOwnershipView(TransferOwnershipView): 'class="btn btn-success btn-small">Accept</a>') token_url = 'dashboard.views.template-transfer-ownership-confirm' template = "dashboard/template-tx-owner.html" + + +class ExportedVMDelete(DeleteViewBase): + model = ExportedVM + success_message = _("Exported VM successfully deleted.") + + def get_success_url(self): + #return reverse("dashboard.views.vm-list") + return reverse_lazy("dashboard.views.detail", kwargs={'pk': self.vm_pk}) + + def check_auth(self): + if not self.get_object().vm.has_level(self.request.user, self.level): + raise PermissionDenied() + + def delete_obj(self, request, *args, **kwargs): + self.get_object().delete() diff --git a/circle/dashboard/views/vm.py b/circle/dashboard/views/vm.py index 0e2f747..0791e37 100644 --- a/circle/dashboard/views/vm.py +++ b/circle/dashboard/views/vm.py @@ -49,7 +49,7 @@ from braces.views import SuperuserRequiredMixin, LoginRequiredMixin from storage.tasks import storage_tasks from vm.tasks.local_tasks import abortable_async_downloaddisk_operation from vm.operations import (DeployOperation, DestroyOperation, DownloadDiskOperation, RemovePortOperation, ShutdownOperation, RenewOperation, - ResizeDiskOperation, RemoveDiskOperation, SleepOperation, WakeUpOperation, AddPortOperation, SaveAsTemplateOperation, + ResizeDiskOperation, RemoveDiskOperation, SleepOperation, WakeUpOperation, AddPortOperation, SaveAsTemplateOperation, ExportVmOperation, ) from common.models import ( @@ -77,7 +77,7 @@ from ..forms import ( VmMigrateForm, VmDeployForm, VmPortRemoveForm, VmPortAddForm, VmRemoveInterfaceForm, - VmRenameForm, + VmRenameForm, VmExportViewForm, ) from django.views.generic.edit import FormMixin from request.models import TemplateAccessType, LeaseType @@ -168,6 +168,7 @@ class DownloadPersistentDiskREST(APIView): return JsonResponse(serializer.data, status=201) return JsonResponse(serializer.errors, status=400) + class VlanREST(APIView): authentication_classes = [TokenAuthentication,BasicAuthentication] permission_classes = [IsAdminUser] @@ -209,6 +210,7 @@ class HotplugMemSetREST(APIView): serializer = InstanceSerializer(instance) return JsonResponse(serializer.data, status=201) + class HotplugVCPUSetREST(APIView): authentication_classes = [TokenAuthentication,BasicAuthentication] permission_classes = [IsAdminUser] @@ -223,6 +225,7 @@ class HotplugVCPUSetREST(APIView): serializer = InstanceSerializer(instance) return JsonResponse(serializer.data, status=201) + class InterfaceREST(APIView): authentication_classes = [TokenAuthentication,BasicAuthentication] permission_classes = [IsAdminUser] @@ -466,6 +469,7 @@ class DownloadDiskREST(APIView): return JsonResponse(serializer.data, status=201) return JsonResponse(serializer.errors, status=400) + class CreateTemplateREST(APIView): authentication_classes = [TokenAuthentication,BasicAuthentication] permission_classes = [IsAdminUser] @@ -483,6 +487,7 @@ class CreateTemplateREST(APIView): return JsonResponse(serializer.data, status=201) return JsonResponse(serializer.errors, status=400) + class CreateDiskREST(APIView): authentication_classes = [TokenAuthentication,BasicAuthentication] permission_classes = [IsAdminUser] @@ -988,6 +993,16 @@ class VmPortAddView(FormOperationMixin, VmOperationView): return val +class VmExportView(FormOperationMixin, VmOperationView): + op = 'export_vm' + form_class = VmExportViewForm + + def get_form_kwargs(self): + op = self.get_op() + val = super(VmExportView, self).get_form_kwargs() + return val + + class VmSaveView(FormOperationMixin, VmOperationView): op = 'save_as_template' icon = 'save' @@ -1004,6 +1019,7 @@ class VmSaveView(FormOperationMixin, VmOperationView): val['clone'] = True return val + class CIDataUpdate(VmOperationView): op = 'cloudinit_change' icon = "cloud-upload" @@ -1278,6 +1294,9 @@ vm_ops = OrderedDict([ ('export_disk', VmDiskModifyView.factory( op='export_disk', form_class=VmDiskExportForm, icon='download', effect='info')), + ('export_vm', VmExportView.factory( + op='export_vm', form_class=VmExportViewForm, + icon='download', effect="info")), ('resize_disk', VmDiskModifyView.factory( op='resize_disk', form_class=VmDiskResizeForm, icon='arrows-alt', effect="warning")), diff --git a/circle/storage/models.py b/circle/storage/models.py index 04ee130..1448323 100644 --- a/circle/storage/models.py +++ b/circle/storage/models.py @@ -18,8 +18,8 @@ # with CIRCLE. If not, see <http://www.gnu.org/licenses/>. - import logging +import os import uuid import time @@ -29,6 +29,7 @@ from celery.result import allow_join_result from celery.exceptions import TimeoutError from django.core.exceptions import ObjectDoesNotExist from django.urls import reverse +from django.utils import timezone from django.db.models import (Model, BooleanField, CharField, DateTimeField, IntegerField, ForeignKey) from django.db import models @@ -37,6 +38,8 @@ from django.utils.translation import ugettext_lazy as _, ugettext_noop from model_utils.models import TimeStampedModel from os.path import join from sizefield.models import FileSizeField +from django.db.models.signals import pre_delete +from django.dispatch import receiver from common.models import ( WorkerNotFound, HumanReadableException, humanize_exception, join_activity_code, method_cache @@ -160,7 +163,7 @@ class Disk(TimeStampedModel): ('download_disk', _('Can download a disk.')), ('resize_disk', _('Can resize a disk.')), ('import_disk', _('Can import a disk.')), - ('export_disk', _('Can export a disk.')) + ('export_disk', _('Can export a disk.')), ) class DiskError(HumanReadableException): @@ -550,13 +553,14 @@ class Disk(TimeStampedModel): queue=queue_name) return self._run_abortable_task(remote, task) - def export_disk_to_datastore(self, task, disk_format, datastore): + def export_disk_to_datastore(self, task, disk_format, datastore, folder="exports"): queue_name = self.get_remote_queue_name('storage', priority='slow') remote = storage_tasks.export_disk_to_datastore.apply_async( kwargs={ "disk_desc": self.get_disk_desc(), "disk_format": disk_format, "datastore": datastore, + "folder": folder, }, queue=queue_name) return self._run_abortable_task(remote, task) @@ -669,4 +673,20 @@ class StorageActivity(ActivityModel): act = cls(activity_code=activity_code, parent=None, started=timezone.now(), task_uuid=task_uuid, user=user) act.save() - return act \ No newline at end of file + return act + + +class ExportedDisk(Model): + FORMAT = Disk.EXPORT_FORMATS + name = CharField(blank=True, max_length=100, verbose_name=_("name")) + format = models.CharField(max_length=5, choices=FORMAT) + filename = CharField(max_length=256, unique=True, verbose_name=_("filename")) + datastore = ForeignKey(DataStore, verbose_name=_("datastore"), help_text=_("The datastore that holds the exported disk."), on_delete=models.CASCADE) + disk = ForeignKey(Disk, verbose_name=_("disk"), help_text=_("The disk that the export was made from."), on_delete=models.CASCADE) + created = DateTimeField(blank=True, default=timezone.now, null=False, editable=False) + + +@receiver(pre_delete, sender=ExportedDisk) +def delete_repo(sender, instance, **kwargs): + if os.path.exists(os.path.join(instance.datastore.path, "exports", instance.filename)): + os.unlink(os.path.join(instance.datastore.path, "exports", instance.filename)) diff --git a/circle/vm/fixtures/ova.xml.j2 b/circle/vm/fixtures/ova.xml.j2 new file mode 100644 index 0000000..43c664d --- /dev/null +++ b/circle/vm/fixtures/ova.xml.j2 @@ -0,0 +1,76 @@ +<?xml version="1.0"?> +<Envelope ovf:version="2.0" xml:lang="en-US" xmlns="http://schemas.dmtf.org/ovf/envelope/2" xmlns:ovf="http://schemas.dmtf.org/ovf/envelope/2" xmlns:rasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_ResourceAllocationSettingData" xmlns:vssd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_VirtualSystemSettingData" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:epasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_EthernetPortAllocationSettingData.xsd" xmlns:sasd="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/CIM_StorageAllocationSettingData.xsd"> + <References> +{%- for disk in disks %} + <File ovf:id="file{{ loop.index }}" ovf:href="{{ disk.filename }}.vmdk"/> +{%- endfor %} + </References> + <DiskSection> +{%- for disk in disks %} + <Disk ovf:capacity="{{ disk.size }}" ovf:diskId="disk{{ loop.index }}" ovf:fileRef="file{{ loop.index }}" ovf:format="http://www.vmware.com/interfaces/specifications/vmdk.html#streamOptimized"/> +{%- endfor %} + </DiskSection> + <NetworkSection> + <Info>Logical networks used in the package</Info> + <Network ovf:name="NAT"> + <Description>Logical network used by this appliance.</Description> + </Network> + </NetworkSection> + <VirtualSystem ovf:id="{{ name }}"> + <Description>{{ vm.description }}</Description> + <name>{{ name }}</name> + <OperatingSystemSection ovf:id="{{ os_id }}"> + <Info>Specifies the operating system installed</Info> + <Description>{{ vm.system }}</Description> + </OperatingSystemSection> + <VirtualHardwareSection> + <Info>Virtual hardware requirements for a virtual machine</Info> + <Item> + <rasd:Description>Virtual CPU</rasd:Description> + <rasd:InstanceID>1</rasd:InstanceID> + <rasd:ResourceType>3</rasd:ResourceType> + <rasd:VirtualQuantity>{{ vm.num_cores }}</rasd:VirtualQuantity> + <rasd:VirtualQuantityUnit>Count</rasd:VirtualQuantityUnit> + </Item> + <Item> + <rasd:AllocationUnits>MegaBytes</rasd:AllocationUnits> + <rasd:Description>Memory Size</rasd:Description> + <rasd:InstanceID>2</rasd:InstanceID> + <rasd:ResourceType>4</rasd:ResourceType> + <rasd:VirtualQuantity>{{ vm.ram_size }}</rasd:VirtualQuantity> + </Item> + <Item> + <rasd:Address>0</rasd:Address> + <rasd:InstanceID>3</rasd:InstanceID> + <rasd:ResourceSubType>PIIX4</rasd:ResourceSubType> + <rasd:ResourceType>5</rasd:ResourceType> + </Item> + <Item> + <rasd:Address>1</rasd:Address> + <rasd:InstanceID>4</rasd:InstanceID> + <rasd:ResourceSubType>PIIX4</rasd:ResourceSubType> + <rasd:ResourceType>5</rasd:ResourceType> + </Item> +{%- set ns = namespace(InstanceID = 4) %}{%- for disk in disks %}{%- set ns.InstanceID = ns.InstanceID + 1 %} + <StorageItem> + <sasd:AddressOnParent>{{ (loop.index - 1) % 2}}</sasd:AddressOnParent> + <sasd:Caption>{{ disk.name }}</sasd:Caption> + <sasd:Description>Disk Image</sasd:Description> + <sasd:Parent>{% if loop.index < 3 %}3{% else %}4{% endif %}</sasd:Parent> + <sasd:HostResource>/disk/disk{{ loop.index }}</sasd:HostResource> + <sasd:InstanceID>{{ ns.InstanceID }}</sasd:InstanceID> + <sasd:ResourceType>17</sasd:ResourceType> + </StorageItem> +{%- endfor %} +{%- for interface in interfaces %}{%- set ns.InstanceID = ns.InstanceID + 1 %} + <EthernetPortItem> + <epasd:AutomaticAllocation>true</epasd:AutomaticAllocation> + <epasd:Caption>{{ interface.vlan.name }}</epasd:Caption> + <epasd:Connection>NAT</epasd:Connection> + <epasd:InstanceID>{{ ns.InstanceID }}</epasd:InstanceID> + <epasd:ResourceType>10</epasd:ResourceType> + </EthernetPortItem> +{%- endfor %} + </VirtualHardwareSection> + </VirtualSystem> +</Envelope> diff --git a/circle/vm/models/__init__.py b/circle/vm/models/__init__.py index 14722fb..1d214d2 100644 --- a/circle/vm/models/__init__.py +++ b/circle/vm/models/__init__.py @@ -12,6 +12,8 @@ from .instance import Instance from .instance import post_state_changed from .instance import pre_state_changed from .instance import pwgen +from .instance import ExportedVM +from .instance import OS_TYPES from .network import InterfaceTemplate from .network import Interface from .node import Node @@ -21,5 +23,5 @@ __all__ = [ 'NamedBaseResourceConfig', 'VirtualMachineDescModel', 'InstanceTemplate', 'Instance', 'post_state_changed', 'pre_state_changed', 'InterfaceTemplate', 'Interface', 'Trait', 'Node', 'NodeActivity', 'Lease', 'node_activity', - 'pwgen' + 'pwgen', 'ExportedVM', 'OS_TYPES', ] diff --git a/circle/vm/models/instance.py b/circle/vm/models/instance.py index 3d20112..c68be7f 100644 --- a/circle/vm/models/instance.py +++ b/circle/vm/models/instance.py @@ -14,7 +14,7 @@ # # You should have received a copy of the GNU General Public License along # with CIRCLE. If not, see <http://www.gnu.org/licenses/>. - +import os.path import random, string from contextlib import contextmanager from datetime import timedelta @@ -27,13 +27,15 @@ from urllib import request from warnings import warn from xml.dom.minidom import Text +from django.db.models.signals import pre_delete +from django.dispatch import receiver import django.conf from django.contrib.auth.models import User from django.core import signing from django.core.exceptions import PermissionDenied from django.db.models import (BooleanField, CharField, DateTimeField, IntegerField, ForeignKey, Manager, - ManyToManyField, SET_NULL, TextField) + ManyToManyField, SET_NULL, TextField, Model) from django.db import IntegrityError from django.dispatch import Signal from django.urls import reverse @@ -64,6 +66,8 @@ from .activity import (ActivityInProgressError, InstanceActivity) from .common import BaseResourceConfigModel, Lease, Variable from .network import Interface from .node import Node, Trait +from storage.models import DataStore +import subprocess logger = getLogger(__name__) @@ -78,6 +82,131 @@ ACCESS_PROTOCOLS = django.conf.settings.VM_ACCESS_PROTOCOLS ACCESS_METHODS = [(key, name) for key, (name, port, transport) in list(ACCESS_PROTOCOLS.items())] +# CIM operationg system types 2.54.0 +OS_TYPES = { + 0: "Unknown", + 1: "Other", + 2: "MACOS", + 3: "ATTUNIX", + 4: "DGUX", + 5: "DECNT", + 6: "Tru64 UNIX", + 7: "OpenVMS", + 8: "HPUX", + 9: "AIX", + 10: "MVS", + 11: "OS400", + 12: "OS/2", + 13: "JavaVM", + 14: "MSDOS", + 15: "WIN3x", + 16: "WIN95", + 17: "WIN98", + 18: "WINNT", + 19: "WINCE", + 20: "NCR3000", + 21: "NetWare", + 22: "OSF", + 23: "DC/OS", + 24: "Reliant UNIX", + 25: "SCO UnixWare", + 26: "SCO OpenServer", + 27: "Sequent", + 28: "IRIX", + 29: "Solaris", + 30: "SunOS", + 31: "U6000", + 32: "ASERIES", + 33: "HP NonStop OS", + 34: "HP NonStop OSS", + 35: "BS2000", + 36: "LINUX", + 37: "Lynx", + 38: "XENIX", + 39: "VM", + 40: "Interactive UNIX", + 41: "BSDUNIX", + 42: "FreeBSD", + 43: "NetBSD", + 44: "GNU Hurd", + 45: "OS9", + 46: "MACH Kernel", + 47: "Inferno", + 48: "QNX", + 49: "EPOC", + 50: "IxWorks", + 51: "VxWorks", + 52: "MiNT", + 53: "BeOS", + 54: "HP MPE", + 55: "NextStep", + 56: "PalmPilot", + 57: "Rhapsody", + 58: "Windows 2000", + 59: "Dedicated", + 60: "OS/390", + 61: "VSE", + 62: "TPF", + 63: "Windows (R) Me", + 64: "Caldera Open UNIX", + 65: "OpenBSD", + 66: "Not Applicable", + 67: "Windows XP", + 68: "z/OS", + 69: "Microsoft Windows Server 2003", + 70: "Microsoft Windows Server 2003 64-Bit", + 71: "Windows XP 64-Bit", + 72: "Windows XP Embedded", + 73: "Windows Vista", + 74: "Windows Vista 64-Bit", + 75: "Windows Embedded for Point of Service", + 76: "Microsoft Windows Server 2008", + 77: "Microsoft Windows Server 2008 64-Bit", + 78: "FreeBSD 64-Bit", + 79: "RedHat Enterprise Linux", + 80: "RedHat Enterprise Linux 64-Bit", + 81: "Solaris 64-Bit", + 82: "SUSE", + 83: "SUSE 64-Bit", + 84: "SLES", + 85: "SLES 64-Bit", + 86: "Novell OES", + 87: "Novell Linux Desktop", + 88: "Sun Java Desktop System", + 89: "Mandriva", + 90: "Mandriva 64-Bit", + 91: "TurboLinux", + 92: "TurboLinux 64-Bit", + 93: "Ubuntu", + 94: "Ubuntu 64-Bit", + 95: "Debian", + 96: "Debian 64-Bit", + 97: "Linux 2.4.x", + 98: "Linux 2.4.x 64-Bit", + 99: "Linux 2.6.x", + 100: "Linux 2.6.x 64-Bit", + 101: "Linux 64-Bit", + 102: "Other 64-Bit", + 103: "Microsoft Windows Server 2008 R2", + 104: "VMware ESXi", + 105: "Microsoft Windows 7", + 106: "CentOS 32-bit", + 107: "CentOS 64-bit", + 108: "Oracle Linux 32-bit", + 109: "Oracle Linux 64-bit", + 110: "eComStation 32-bitx", + 111: "Microsoft Windows Server 2011", + 113: "Microsoft Windows Server 2012", + 114: "Microsoft Windows 8", + 115: "Microsoft Windows 8 64-bit", + 116: "Microsoft Windows Server 2012 R2", + 117: "Microsoft Windows Server 2016", + 118: "Microsoft Windows 8.1", + 119: "Microsoft Windows 8.1 64-bit", + 120: "Microsoft Windows 10", + 121: "Microsoft Windows 10 64-bit", +} + CI_META_DATA_DEF = """ instance-id: {{ hostname }} local-hostname: {{ hostname }} @@ -216,6 +345,7 @@ class InstanceTemplate(AclBase, VirtualMachineDescModel, TimeStampedModel): ordering = ('name', ) permissions = ( ('create_template', _('Can create an instance template.')), + ('import_template', _('Can import an instance template.')), ('create_base_template', _('Can create an instance template (base).')), ('change_template_resources', @@ -397,6 +527,7 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin, ('set_resources', _('Can change resources of a new VM.')), ('create_vm', _('Can create a new VM.')), ('redeploy', _('Can redeploy a VM.')), + ('export_vm', _('Can export a vm.')), ('config_ports', _('Can configure port forwards.')), ('recover', _('Can recover a destroyed VM.')), ('emergency_change_state', _('Can change VM state to NOSTATE.')), @@ -634,20 +765,37 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin, missing_users = [] instances = [] for user_id in users: - try: - user_instances.append(User.objects.get(profile__org_id=user_id)) - except User.DoesNotExist: + if isinstance(user_id, User): + user_instances.append(user_id) + else: try: - user_instances.append(User.objects.get(username=user_id)) + user_instances.append(User.objects.get(profile__org_id=user_id)) except User.DoesNotExist: - missing_users.append(user_id) + try: + user_instances.append(User.objects.get(username=user_id)) + except User.DoesNotExist: + missing_users.append(user_id) for user in user_instances: instance = cls.create_from_template(template, user, **kwargs) if admin: - instance.set_level(User.objects.get(username=admin), 'owner') + if hasattr(admin, '__iter__') and not isinstance(admin, str): + for admin_user in admin: + if isinstance(admin_user, User): + instance.set_level(admin_user, 'owner') + else: + instance.set_level(User.objects.get(username=admin_user), 'owner') + else: + instance.set_level(User.objects.get(username=admin), 'owner') if operator: - instance.set_level(User.objects.get(username=operator), 'operator') + if hasattr(operator, '__iter__') and not isinstance(operator, str): + for operator_user in operator: + if isinstance(operator_user, User): + instance.set_level(operator_user, 'operator') + else: + instance.set_level(User.objects.get(username=operator_user), 'operator') + else: + instance.set_level(User.objects.get(username=operator), 'operator') instance.deploy._async(user=user) instances.append(instance) @@ -1165,3 +1313,23 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin, user=user, concurrency_check=concurrency_check, readable_name=readable_name, resultant_state=resultant_state) return activitycontextimpl(act, on_abort=on_abort, on_commit=on_commit) + + +class ExportedVM(Model): + name = CharField(blank=True, max_length=100, verbose_name=_("name")) + filename = CharField(max_length=256, unique=True, verbose_name=_("filename")) + datastore = ForeignKey(DataStore, verbose_name=_("datastore"), help_text=_("The datastore that holds the exported VM."), on_delete=models.CASCADE) + vm = ForeignKey(Instance, verbose_name=_("disk"), help_text=_("The vm that the export was made from."), on_delete=models.CASCADE) + created = DateTimeField(blank=True, default=timezone.now, null=False, editable=False) + + def has_level(self, user, level): + self.vm.has_level(user, level) + + def __str__(self): + return self.name + + +@receiver(pre_delete, sender=ExportedVM) +def delete_repo(sender, instance, **kwargs): + if os.path.exists(os.path.join(instance.datastore.path, "exports", instance.filename)): + os.unlink(os.path.join(instance.datastore.path, "exports", instance.filename)) diff --git a/circle/vm/operations.py b/circle/vm/operations.py index 570d8ad..54a6fdc 100644 --- a/circle/vm/operations.py +++ b/circle/vm/operations.py @@ -14,9 +14,10 @@ # # You should have received a copy of the GNU General Public License along # with CIRCLE. If not, see <http://www.gnu.org/licenses/>. - - - +from hashlib import sha256 +from shutil import rmtree, move +import subprocess +import tarfile from io import StringIO from base64 import encodestring from hashlib import md5 @@ -28,6 +29,9 @@ from urllib.parse import urlsplit import os import time +from uuid import uuid4 + +from jinja2 import Environment, FileSystemLoader from celery.contrib.abortable import AbortableAsyncResult from celery.exceptions import TimeLimitExceeded, TimeoutError from django.conf import settings @@ -47,18 +51,19 @@ from dashboard.store_api import Store, NoStoreException from firewall.models import Host from manager.scheduler import SchedulerError from monitor.client import Client -from storage.models import Disk +from storage.models import Disk, ExportedDisk from storage.tasks import storage_tasks from storage.models import DataStore from .models import ( Instance, InstanceActivity, InstanceTemplate, Interface, Node, - NodeActivity, pwgen + NodeActivity, pwgen, ExportedVM ) from .tasks import agent_tasks, vm_tasks from .tasks.local_tasks import ( abortable_async_instance_operation, abortable_async_node_operation, ) from django.conf import settings +from time import sleep try: # Python 2: "unicode" is built-in @@ -411,7 +416,6 @@ class ImportDiskOperation(InstanceOperation): def _operation(self, user, name, disk_path, task): store = Store(user) download_link, port = store.request_ssh_download(disk_path) - ogging.debug(settings) disk = Disk.import_disk(task, user, name, download_link, port, settings.STORE_IDENTITY_FILE, settings.STORE_SSH_MODE) self.instance.disks.add(disk) @@ -441,7 +445,10 @@ class ExportDiskOperation(InstanceOperation): store.ssh_upload_finished(file_name, exported_name + '.' + disk_format) elif export_target == "datastore": datastore = DataStore.objects.filter(name=settings.EXPORT_DATASTORE).get() + export_filename = os.path.join(disk.filename + '.' + disk_format) disk.export_disk_to_datastore(task, disk_format, datastore.path) + export = ExportedDisk(name=exported_name, format=disk_format, filename=export_filename, datastore=datastore, disk=disk) + export.save() else: raise Exception("Invalid export target") @@ -1991,3 +1998,159 @@ class DetachNetwork(DetachMixin, AbstractNetworkOperation): id = "_detach_network" name = _("detach network") task = vm_tasks.detach_network + + +@register_operation +class ExportVmOperation(InstanceOperation): + id = 'export_vm' + name = _("export vm") + description = _("Export the virtual machine.") + required_perms = ('vm.export_vm',) + accept_states = ('STOPPED') + acl_level = "operator" + concurrency_check = True + superuser_required = False + + def _operation(self, user, task, activity, exported_name, export_target): + if export_target == "user_store": + filename = exported_name + elif export_target == "datastore": + filename = str(uuid4()) + else: + raise Exception("Invalid export target") + datastore = DataStore.objects.filter(name=settings.EXPORT_DATASTORE).get() + tmp_folder = str(uuid4()) + tmp_path = os.path.join(datastore.path, "tmp", tmp_folder) + try: + os.mkdir(os.path.join(datastore.path, "tmp")) + except FileExistsError: + pass + try: + try: + os.mkdir(tmp_path) + except FileExistsError: + pass + for disk in self.instance.disks.all(): + if not disk.ci_disk: + with activity.sub_activity( + 'exporting_disk', + readable_name=create_readable(ugettext_noop("exporting disk %(name)s"), name=disk.name) + ): + disk.export_disk_to_datastore(task, "vmdk", datastore.path, folder=os.path.join("tmp", tmp_folder)) + + with (activity.sub_activity( + 'generate_ovf', + readable_name=create_readable(ugettext_noop("generate ovf")) + )): + loader = FileSystemLoader(searchpath="./vm/fixtures/") + env = Environment(loader=loader) + j2template = env.get_template("ova.xml.j2") + # see CIM os types, instance.OS_TYPES + if self.instance.arch == "x86_64": + os_id = "102" + elif self.instance.arch == "i686": + os_id = "1" + else: + os_id = "0" + + output_from_parsed_template = j2template.render( + name=exported_name, + disks=self.instance.disks.all(), + vm=self.instance, + interfaces=self.instance.interface_set.all(), + os_id=os_id + ) + # to save the results + with open(os.path.join(tmp_path, exported_name + ".ovf"), "w") as fh: + fh.write(output_from_parsed_template) + + def compute_sha256(file_name): + hash_sha256 = sha256() + with open(file_name, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_sha256.update(chunk) + return hash_sha256.hexdigest() + + with activity.sub_activity( + 'generate_mf', + readable_name=create_readable(ugettext_noop("create %s.mf checksums" % exported_name)) + ): + with open(os.path.join(tmp_path, exported_name + ".mf"), "w") as mf: + for disk in self.instance.disks.all(): + if not disk.ci_disk: + mf.write("SHA256 (%s) = %s\n" % (os.path.basename(disk.path) + ".vmdk", compute_sha256(os.path.join(tmp_path, os.path.basename(disk.path) + ".vmdk")))) + mf.write("SHA256 (%s) = %s\n" % (exported_name + ".ovf", compute_sha256(os.path.join(tmp_path, exported_name + ".ovf")))) + + with activity.sub_activity( + 'generate_ova', + readable_name=create_readable(ugettext_noop("create %s.ova archive" % exported_name)) + ): + with tarfile.open(os.path.join(tmp_path, filename + ".ova"), "w") as tar: + for disk in self.instance.disks.all(): + if not disk.ci_disk: + tar.add(os.path.join(tmp_path, os.path.basename(disk.path) + ".vmdk"), arcname=os.path.basename(disk.path) + ".vmdk") + tar.add(os.path.join(tmp_path, exported_name + ".mf"), arcname=exported_name + ".mf") + tar.add(os.path.join(tmp_path, exported_name + ".ovf"), arcname=exported_name + ".ovf") + + if export_target == "user_store": + with activity.sub_activity( + 'upload to store', + readable_name=create_readable(ugettext_noop("upload %s.ova to store" % exported_name)) + ): + store = Store(user) + upload_link, port = store.request_ssh_upload() + file_path = os.path.join(tmp_path, filename + ".ova") + exported_path = filename + ".ova" + mode = settings.STORE_SSH_MODE + identity = settings.STORE_IDENTITY_FILE + if mode == "scp": + cmdline = ['scp', '-B', '-P', str(port), file_path, upload_link] + if identity is not None and identity != "": + cmdline.append("-i") + cmdline.append(identity) + elif mode == "rsync": + cmdline = ["rsync", "-qSL", file_path, upload_link] + cmdline.append("-e") + if identity is not None: + cmdline.append("ssh -i %s -p %s" % (identity, str(port))) + else: + cmdline.append("ssh -p %s" % str(port)) + else: + logger.error("Invalid mode for disk export: %s" % mode) + raise Exception() + + logger.debug("Calling file transfer with command line. %s" % str(cmdline)) + try: + # let's try the file transfer 5 times, it may be an intermittent network issue + for i in range(4, -1, -1): + proc = subprocess.Popen(cmdline) + while proc.poll() is None: + if task.is_aborted(): + raise Exception() + sleep(2) + if proc.returncode == 0: + break + else: + logger.error("Copy over ssh failed with return code: %s, will try %s more time(s)..." % (str(proc.returncode), str(i))) + if proc.stdout is not None: + logger.info(proc.stdout.read()) + if proc.stdout is not None: + logger.error(proc.stderr.read()) + except Exception: + proc.terminate() + logger.info("Export of disk %s aborted" % self.name) + + store.ssh_upload_finished(exported_path, exported_path) + elif export_target == "datastore": + with activity.sub_activity( + 'save to exports', + readable_name=create_readable(ugettext_noop("save %s.ova to exports" % exported_name)) + ): + exported_path = os.path.join(datastore.path, "exports", filename + ".ova") + move(os.path.join(tmp_path, filename + ".ova"), exported_path) + export = ExportedVM(name=exported_name, filename=filename + ".ova", datastore=datastore, vm=self.instance) + export.save() + else: + raise Exception("Invalid export target") + finally: + rmtree(tmp_path) diff --git a/circle/vm/tasks/vm_tasks.py b/circle/vm/tasks/vm_tasks.py index abd07da..475bf2d 100644 --- a/circle/vm/tasks/vm_tasks.py +++ b/circle/vm/tasks/vm_tasks.py @@ -193,4 +193,8 @@ def hotplug_memset(params): @celery.task(name='vmdriver.hotplug_vcpuset') def hotplug_vcpuset(params): - pass \ No newline at end of file + pass + +@celery.task(name='vmdriver.export_vm') +def export_vm(params): + pass -- libgit2 0.26.0