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