From 58bab5d0818e50b0052bfdcad7d6cbd0251a99b2 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Thu, 1 Mar 2018 13:23:30 +0100
Subject: [PATCH] Add Create VM button

Allow to create a VM or Virtual IOC
---
 app/models.py                        | 14 ++++++++
 app/network/forms.py                 | 11 ++++++
 app/network/views.py                 | 21 +++++++++---
 app/settings.py                      |  8 +++++
 app/tasks.py                         | 33 ++++++++++++++++++
 app/templates/_helpers.html          | 32 ++++++++++++++++--
 app/templates/network/view_host.html | 50 +++++++++++++++++++---------
 7 files changed, 147 insertions(+), 22 deletions(-)

diff --git a/app/models.py b/app/models.py
index 2dd4a6b..fdc1f1a 100644
--- a/app/models.py
+++ b/app/models.py
@@ -646,6 +646,13 @@ class Host(CreatedMixin, db.Model):
                                for item in kwargs['items']]
         super().__init__(**kwargs)
 
+    @property
+    def is_ioc(self):
+        for interface in self.interfaces:
+            if interface.is_ioc:
+                return True
+        return False
+
     def __str__(self):
         return str(self.name)
 
@@ -734,6 +741,13 @@ class Interface(CreatedMixin, db.Model):
     def address(self):
         return ipaddress.ip_address(self.ip)
 
+    @property
+    def is_ioc(self):
+        for tag in self.tags:
+            if tag.name == 'IOC':
+                return True
+        return False
+
     def __str__(self):
         return str(self.name)
 
diff --git a/app/network/forms.py b/app/network/forms.py
index 37b9a7f..989f1fd 100644
--- a/app/network/forms.py
+++ b/app/network/forms.py
@@ -10,6 +10,7 @@ This module defines the network blueprint forms.
 
 """
 import ipaddress
+from flask import current_app
 from flask_login import current_user
 from wtforms import (SelectField, StringField, TextAreaField, IntegerField,
                      SelectMultipleField, BooleanField, validators)
@@ -157,3 +158,13 @@ class InterfaceForm(CSEntryForm):
 
 class HostInterfaceForm(HostForm, InterfaceForm):
     pass
+
+
+class CreateVMForm(CSEntryForm):
+    cores = SelectField('Cores', default=2, coerce=int)
+    memory = SelectField('Memory (GB)', default=2, coerce=int)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.cores.choices = utils.get_choices(current_app.config['VM_CORES_CHOICES'])
+        self.memory.choices = utils.get_choices(current_app.config['VM_MEMORY_CHOICES'])
diff --git a/app/network/views.py b/app/network/views.py
index 5fb7e3c..e6cc053 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -13,9 +13,9 @@ import ipaddress
 import sqlalchemy as sa
 from flask import (Blueprint, render_template, jsonify, session,
                    redirect, url_for, request, flash, current_app)
-from flask_login import login_required
+from flask_login import login_required, current_user
 from .forms import (HostForm, InterfaceForm, HostInterfaceForm, NetworkForm,
-                    NetworkScopeForm, DomainForm)
+                    NetworkScopeForm, DomainForm, CreateVMForm)
 from ..extensions import db
 from ..decorators import login_groups_accepted
 from .. import models, utils, helpers, tasks
@@ -79,11 +79,24 @@ def create_host():
     return render_template('network/create_host.html', form=form)
 
 
-@bp.route('/hosts/view/<name>')
+@bp.route('/hosts/view/<name>', methods=('GET', 'POST'))
 @login_required
 def view_host(name):
     host = models.Host.query.filter_by(name=name).first_or_404()
-    return render_template('network/view_host.html', host=host)
+    form = CreateVMForm()
+    if host.is_ioc:
+        form.cores.choices = utils.get_choices(current_app.config['VIOC_CORES_CHOICES'])
+        form.memory.choices = utils.get_choices(current_app.config['VIOC_MEMORY_CHOICES'])
+    if form.validate_on_submit():
+        if not current_user.is_admin:
+            flash(f'Only admin users are allowed to create a VM!', 'info')
+        else:
+            interface = host.interfaces[0]
+            job = tasks.trigger_vm_creation(name, interface, form.memory.data, form.cores.data)
+            current_app.logger.info(f'Creation of {name} requested: job {job.get_id()}')
+            flash(f'Creation of {name} requested!', 'success')
+        return redirect(url_for('network.view_host', name=name))
+    return render_template('network/view_host.html', host=host, form=form)
 
 
 @bp.route('/hosts/edit/<name>', methods=('GET', 'POST'))
diff --git a/app/settings.py b/app/settings.py
index cbfa3be..363c14c 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -70,3 +70,11 @@ DOCUMENTATION_URL = 'http://ics-infrastructure.pages.esss.lu.se/csentry/index.ht
 # AWX job templates
 AWX_JOB_ENABLED = False
 AWX_CORE_SERVICES_UPDATE = 'ics-ans-core @ DHCP test'
+AWX_CREATE_VM = 'ics-ans-deploy-proxmox-vm'
+AWX_CREATE_VIOC = 'ics-ans-deploy-vioc'
+
+VM_CORES_CHOICES = [1, 2, 4, 6, 8, 24]
+VM_MEMORY_CHOICES = [2, 4, 8, 16, 32, 128]
+VM_DEFAULT_DNS = '172.16.6.21'
+VIOC_CORES_CHOICES = [1, 3, 6]
+VIOC_MEMORY_CHOICES = [2, 4, 8]
diff --git a/app/tasks.py b/app/tasks.py
index db513ed..92d71d1 100644
--- a/app/tasks.py
+++ b/app/tasks.py
@@ -14,6 +14,39 @@ from flask import current_app
 from rq import Queue
 
 
+def trigger_vm_creation(name, interface, memory, cores):
+    """Trigger a job to create a virtual machine or virtual IOC"""
+    extra_vars = [
+        f'vmname={name}',
+        f'memory={memory}',
+        f'cores={cores}',
+        f'vcpus={cores}',
+        f'vlan_name={interface.network.vlan_name}',
+        f'vlan_id={interface.network.vlan_id}',
+        f'mac={interface.mac.address}',
+    ]
+    if interface.is_ioc:
+        job_template = current_app.config['AWX_CREATE_VIOC']
+    else:
+        job_template = current_app.config['AWX_CREATE_VM']
+        extra_vars.extend([
+            f'ip_address={interface.ip}',
+            f'domain={interface.network.domain.name}',
+            f'dns={current_app.config["VM_DEFAULT_DNS"]}',
+            f'netmask={interface.network.netmask}',
+            f'gateway={interface.network.gateway}',
+        ])
+    q = Queue()
+    current_app.logger.info(f'Launch new job to create the {name} VM: {job_template} with {extra_vars}')
+    job = q.enqueue(
+        launch_job_template,
+        job_template=job_template,
+        extra_vars=extra_vars,
+        timeout=500,
+    )
+    return job
+
+
 def trigger_core_services_update():
     """Trigger a job to update the core services on the TN (DNS and DHCP)
 
diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html
index 5bb36c4..c6f049a 100644
--- a/app/templates/_helpers.html
+++ b/app/templates/_helpers.html
@@ -36,21 +36,24 @@
 
 {% macro render_field(field) -%}
   {% set field_class = kwargs.pop('class_', '') + ' form-control' %}
+  {% set label_size = kwargs.pop('label_size', '2') %}
+  {% set input_size = kwargs.pop('input_size', '10') %}
+
   {% if field.errors %}
     {% set field_class = field_class + ' is-invalid' %}
   {% endif %}
 
   <div class="form-group row">
     {% if field.type == 'BooleanField' %}
-      <div class="col-sm-10 offset-sm-2">
+      <div class="col-sm-{{ input_size }} offset-sm-{{ label_size }}">
         <div class="form-check">
           {{ field(class_="form-check-input") }}
           {{ field.label(class_="form-check-label") }}
         </div>
       </div>
     {% else %}
-      {{ field.label(class_="col-sm-2 col-form-label") }}
-      <div class="col-sm-10">
+      {{ field.label(class_="col-sm-" + label_size + " col-form-label") }}
+      <div class="col-sm-{{ input_size }}">
         {{ field(class_=field_class, **kwargs) }}
         {% if field.description %}
           <small class="form-text text-muted">
@@ -94,6 +97,29 @@
   </div>
 {%- endmacro %}
 
+{% macro submit_button_with_confirmation(title, message) -%}
+  <button type="button" class="btn btn-primary" data-toggle="modal" data-target="#submitModal" }}>
+    {{ title }}
+  </button>
+  <!-- Modal -->
+  <div class="modal fade" id="submitModal" tabindex="-1" role="dialog" aria-labelledby="submit-confirmation" aria-hidden="true">
+    <div class="modal-dialog" role="document">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h6 class="modal-title">{{ message }}</h6>
+          <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">&times;</span>
+          </button>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
+          <button type="submit" class="btn btn-primary">OK</button>
+        </div>
+      </div>
+    </div>
+  </div>
+{%- endmacro %}
+
 {% macro figure(filename, description) -%}
   <figure class="figure">
     <img src="{{ url_for('static', filename='img/' +  filename) }}" class="img-fluid mx-auto d-block" alt="{{ description }}">
diff --git a/app/templates/network/view_host.html b/app/templates/network/view_host.html
index c07f214..6489328 100644
--- a/app/templates/network/view_host.html
+++ b/app/templates/network/view_host.html
@@ -1,5 +1,6 @@
 {% extends "network/hosts.html" %}
-{% from "_helpers.html" import link_to_items, link_to_stack_members, delete_button_with_confirmation %}
+{% from "_helpers.html" import link_to_items, link_to_stack_members,
+   delete_button_with_confirmation, render_field, submit_button_with_confirmation %}
 
 {% block title %}View Host - CSEntry{% endblock %}
 
@@ -16,22 +17,41 @@
 {% endblock %}
 
 {% block hosts_main %}
-  <dl class="row">
-    <dt class="col-sm-3">Hostname</dt>
-    <dd class="col-sm-9">{{ host.name }}</dd>
-    <dt class="col-sm-3">Device Type</dt>
-    <dd class="col-sm-9">{{ host.device_type }}</dd>
-    {% if host.items %}
-    <dt class="col-sm-3">Items</dt>
-    <dd class="col-sm-9">{{ link_to_items(host.items) }}</dd>
+  <div class="row">
+    <div class="col-sm-9">
+      <dl class="row">
+        <dt class="col-sm-3">Hostname</dt>
+        <dd class="col-sm-9">{{ host.name }}</dd>
+        <dt class="col-sm-3">Device Type</dt>
+        <dd class="col-sm-9">{{ host.device_type }}</dd>
+        {% if host.items %}
+        <dt class="col-sm-3">Items</dt>
+        <dd class="col-sm-9">{{ link_to_items(host.items) }}</dd>
+        {% endif %}
+        {% if host.stack_members() %}
+        <dt class="col-sm-3">Stack Members</dt>
+        <dd class="col-sm-9">{{ link_to_stack_members(host.stack_members()) }}</dd>
+        {% endif %}
+        <dt class="col-sm-3">Description</dt>
+        <dd class="col-sm-9">{{ host.description }}</dd>
+      </dl>
+    </div>
+    {% if host.device_type.name.startswith('Virtual') and current_user.is_admin %}
+    {% if host.is_ioc %}
+      {% set vm_type = 'Virtual IOC' %}
+    {% else %}
+      {% set vm_type = 'VM' %}
     {% endif %}
-    {% if host.stack_members() %}
-    <dt class="col-sm-3">Stack Members</dt>
-    <dd class="col-sm-9">{{ link_to_stack_members(host.stack_members()) }}</dd>
+    <div class="col-sm-3">
+      <form id="createVMForm" method="POST">
+        {{ form.hidden_tag() }}
+        {{ render_field(form.cores, label_size='6', input_size='6') }}
+        {{ render_field(form.memory, label_size='6', input_size='6') }}
+        {{ submit_button_with_confirmation('Create ' + vm_type, 'Do you really want to create the ' + vm_type + ' ' + host.name + '?') }}
+      </form>
+    </div>
     {% endif %}
-    <dt class="col-sm-3">Description</dt>
-    <dd class="col-sm-9">{{ host.description }}</dd>
-  </dl>
+  </div>
   <h3>Interfaces</h3>
   <table id="interfaces_table" class="table table-hover table-sm">
     <thead>
-- 
GitLab