From a00090e0c6e64cdae3bc7ee58d868f260c96e692 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@esss.se> Date: Wed, 11 Jul 2018 16:14:39 +0200 Subject: [PATCH] Add Ansible groups views JIRA INFRA-412 --- app/network/forms.py | 23 +++++++ app/network/views.py | 85 +++++++++++++++++++++++++ app/static/js/groups.js | 43 +++++++++++++ app/templates/_helpers.html | 16 +++++ app/templates/base-fluid.html | 2 + app/templates/network/create_group.html | 18 ++++++ app/templates/network/create_host.html | 1 + app/templates/network/edit_group.html | 27 ++++++++ app/templates/network/edit_host.html | 1 + app/templates/network/groups.html | 35 ++++++++++ app/templates/network/view_group.html | 34 ++++++++++ app/templates/network/view_host.html | 5 ++ app/utils.py | 5 +- 13 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 app/static/js/groups.js create mode 100644 app/templates/network/create_group.html create mode 100644 app/templates/network/edit_group.html create mode 100644 app/templates/network/groups.html create mode 100644 app/templates/network/view_group.html diff --git a/app/network/forms.py b/app/network/forms.py index be0d235..f349683 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -144,10 +144,16 @@ class HostForm(CSEntryForm): "Ansible vars", description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html", ) + ansible_groups = SelectMultipleField( + "Ansible groups", coerce=utils.coerce_to_str_or_none + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.device_type_id.choices = utils.get_model_choices(models.DeviceType) + self.ansible_groups.choices = utils.get_model_choices( + models.AnsibleGroup, attr="name" + ) class InterfaceForm(CSEntryForm): @@ -219,3 +225,20 @@ class CreateVMForm(CSEntryForm): 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"]) + + +class AnsibleGroupForm(CSEntryForm): + name = StringField( + "name", + validators=[validators.InputRequired(), Unique(models.AnsibleGroup)], + filters=[utils.lowercase_field], + ) + vars = YAMLField( + "Ansible vars", + description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html", + ) + hosts = SelectMultipleField("Hosts", coerce=utils.coerce_to_str_or_none) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.hosts.choices = utils.get_model_choices(models.Host, attr="name") diff --git a/app/network/views.py b/app/network/views.py index 5487392..4104bac 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -31,6 +31,7 @@ from .forms import ( NetworkScopeForm, DomainForm, CreateVMForm, + AnsibleGroupForm, ) from ..extensions import db from ..decorators import login_groups_accepted @@ -63,11 +64,15 @@ def create_host(): del form.host_id if form.validate_on_submit(): network_id = form.network_id.data + ansible_groups = [ + models.AnsibleQuery.query.get(id_) for id_ in form.ansible_groups.data + ] host = models.Host( name=form.name.data, device_type_id=form.device_type_id.data, description=form.description.data or None, ansible_vars=form.ansible_vars.data or None, + ansible_groups=ansible_groups, ) # The total number of tags will always be quite small # It's more efficient to retrieve all of them in one query @@ -135,11 +140,20 @@ def view_host(name): def edit_host(name): host = models.Host.query.filter_by(name=name).first_or_404() form = HostForm(request.form, obj=host) + # Passing ansible_groups as kwarg to the HostForm doesn't work because + # obj takes precedence (but host.ansible_groups contain AnsibleGroup instances and not id) + # We need to update the default values. Calling process is required. + # See https://stackoverflow.com/questions/5519729/wtforms-how-to-select-options-in-selectmultiplefield + form.ansible_groups.default = [group.id for group in host.ansible_groups] + form.ansible_groups.process(request.form) if form.validate_on_submit(): host.name = form.name.data host.device_type_id = form.device_type_id.data host.description = form.description.data or None host.ansible_vars = form.ansible_vars.data or None + host.ansible_groups = [ + models.AnsibleGroup.query.get(id_) for id_ in form.ansible_groups.data + ] current_app.logger.debug(f"Trying to update: {host!r}") try: db.session.commit() @@ -281,6 +295,70 @@ def delete_interface(): return redirect(url_for("network.view_host", name=hostname)) +@bp.route("/groups") +@login_required +def list_ansible_groups(): + return render_template("network/groups.html") + + +@bp.route("/groups/view/<name>", methods=("GET", "POST")) +@login_required +def view_ansible_group(name): + group = models.AnsibleGroup.query.filter_by(name=name).first_or_404() + return render_template("network/view_group.html", group=group) + + +@bp.route("/groups/edit/<name>", methods=("GET", "POST")) +@login_groups_accepted("admin") +def edit_ansible_group(name): + group = models.AnsibleGroup.query.filter_by(name=name).first_or_404() + form = AnsibleGroupForm(request.form, obj=group) + # Passing hosts as kwarg to the AnsibleGroupForm doesn't work because + # obj takes precedence (but group.hosts contain Host instances and not id) + # We need to update the default values. Calling process is required. + # See https://stackoverflow.com/questions/5519729/wtforms-how-to-select-options-in-selectmultiplefield + form.hosts.default = [host.id for host in group.hosts] + form.hosts.process(request.form) + if form.validate_on_submit(): + group.name = form.name.data + group.vars = form.vars.data or None + group.hosts = [models.Host.query.get(id_) for id_ in form.hosts.data] + current_app.logger.debug(f"Trying to update: {group!r}") + try: + db.session.commit() + except sa.exc.IntegrityError as e: + db.session.rollback() + current_app.logger.warning(f"{e}") + flash(f"{e}", "error") + else: + flash(f"Group {group} updated!", "success") + return redirect(url_for("network.view_ansible_group", name=group.name)) + return render_template("network/edit_group.html", form=form) + + +@bp.route("/groups/create", methods=("GET", "POST")) +@login_groups_accepted("admin") +def create_ansible_group(): + form = AnsibleGroupForm() + if form.validate_on_submit(): + hosts = [models.Host.query.get(id_) for id_ in form.hosts.data] + group = models.AnsibleGroup( + name=form.name.data, vars=form.vars.data or None, hosts=hosts + ) + current_app.logger.debug(f"Trying to create: {group!r}") + db.session.add(group) + try: + db.session.commit() + except sa.exc.IntegrityError as e: + db.session.rollback() + current_app.logger.warning(f"{e}") + flash(f"{e}", "error") + else: + flash(f"Group {group} created!", "success") + return redirect(url_for("network.view_ansible_group", name=group.name)) + return render_template("network/create_group.html", form=form) + + @bp.route("/domains") @login_required def list_domains(): @@ -528,6 +606,13 @@ def retrieve_domains(): return jsonify(data=data) +@bp.route("/_retrieve_groups") +@login_required +def retrieve_groups(): + data = [group.to_dict() for group in models.AnsibleGroup.query.all()] + return jsonify(data=data) + + @bp.route("/_generate_random_mac") @login_required def generate_random_mac(): diff --git a/app/static/js/groups.js b/app/static/js/groups.js new file mode 100644 index 0000000..fc05e51 --- /dev/null +++ b/app/static/js/groups.js @@ -0,0 +1,43 @@ +$(document).ready(function() { + + if( $("#groupForm").length || $("#editGroupForm").length ) { + var groupVarsEditor = CodeMirror.fromTextArea(vars, { + lineNumbers: true, + mode: "yaml" + }); + groupVarsEditor.setSize(null, 120); + } + + var groups_table = $("#groups_table").DataTable({ + "ajax": function(data, callback, settings) { + $.getJSON( + $SCRIPT_ROOT + "/network/_retrieve_groups", + function(json) { + callback(json); + }); + }, + "paging": false, + "columns": [ + { data: 'name', + render: function(data, type, row) { + // render funtion to create link to group view page + if ( data === null ) { + return data; + } + var url = $SCRIPT_ROOT + "/network/groups/view/" + data; + return '<a href="' + url + '">' + data + '</a>'; + } + }, + { data: 'vars', + render: function(data, type, row) { + if ( data === "null" ) { + return ""; + } + return '<pre>' + data + '</pre>'; + } + }, + { data: 'hosts' } + ] + }); + +}); diff --git a/app/templates/_helpers.html b/app/templates/_helpers.html index c6f049a..987584c 100644 --- a/app/templates/_helpers.html +++ b/app/templates/_helpers.html @@ -6,6 +6,12 @@ <a href="{{ url_for('network.view_host', name=name) }}">{{ name }}</a> {%- endmacro %} +{% macro link_to_hosts(hosts) -%} + {% for host in hosts %} + {{ link_to_host(host.name) }} + {% endfor %} +{%- endmacro %} + {% macro link_to_item(ics_id) -%} {% if ics_id %} <a href="{{ url_for('inventory.view_item', ics_id=ics_id) }}">{{ ics_id }}</a> @@ -34,6 +40,16 @@ {% endfor %} {%- endmacro %} +{% macro link_to_ansible_group(group) -%} + <a href="{{ url_for('network.view_ansible_group', name=group.name) }}">{{ group.name }}</a> +{%- endmacro %} + +{% macro link_to_ansible_groups(groups) -%} + {% for group in groups %} + {{ link_to_ansible_group(group) }} + {% endfor %} +{%- endmacro %} + {% macro render_field(field) -%} {% set field_class = kwargs.pop('class_', '') + ' form-control' %} {% set label_size = kwargs.pop('label_size', '2') %} diff --git a/app/templates/base-fluid.html b/app/templates/base-fluid.html index 44b29d3..36d4295 100644 --- a/app/templates/base-fluid.html +++ b/app/templates/base-fluid.html @@ -25,6 +25,8 @@ href="{{ url_for('network.list_scopes') }}">Network Scopes</a> <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/domains")) }}" href="{{ url_for('network.list_domains') }}">Domains</a> + <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/network/groups")) }}" + href="{{ url_for('network.list_ansible_groups') }}">Ansible groups</a> {% elif path.startswith("/task") %} <a class="list-group-item list-group-item-action {{ is_active(path.startswith("/task/tasks")) }}" href="{{ url_for('task.list_tasks') }}">Tasks</a> diff --git a/app/templates/network/create_group.html b/app/templates/network/create_group.html new file mode 100644 index 0000000..41e6f64 --- /dev/null +++ b/app/templates/network/create_group.html @@ -0,0 +1,18 @@ +{% extends "network/groups.html" %} +{% from "_helpers.html" import render_field %} + +{% block title %}Register Ansible group - CSEntry{% endblock %} + +{% block groups_main %} + <form id="groupForm" method="POST"> + {{ form.hidden_tag() }} + {{ render_field(form.name, class_="text-lowercase") }} + {{ render_field(form.vars) }} + {{ render_field(form.hosts, class_="selectpicker") }} + <div class="form-group row"> + <div class="col-sm-10"> + <button type="submit" class="btn btn-primary">Submit</button> + </div> + </div> + </form> +{%- endblock %} diff --git a/app/templates/network/create_host.html b/app/templates/network/create_host.html index f811580..85cb94c 100644 --- a/app/templates/network/create_host.html +++ b/app/templates/network/create_host.html @@ -17,6 +17,7 @@ {{ render_field(form.cnames_string) }} {{ render_field(form.tags, class_="selectpicker") }} {{ render_field(form.ansible_vars) }} + {{ render_field(form.ansible_groups, class_="selectpicker") }} <div class="form-group row"> <div class="col-sm-10"> <button type="submit" class="btn btn-primary">Submit</button> diff --git a/app/templates/network/edit_group.html b/app/templates/network/edit_group.html new file mode 100644 index 0000000..155d1c0 --- /dev/null +++ b/app/templates/network/edit_group.html @@ -0,0 +1,27 @@ +{% extends "network/groups.html" %} +{% from "_helpers.html" import render_field %} + +{% block title %}Edit Ansible group - CSEntry{% endblock %} + +{% block groups_nav %} + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('network.view_ansible_group', name=form.name.data) }}">View group</a> + </li> + <li class="nav-item"> + <a class="nav-link active" href="{{ url_for('network.edit_ansible_group', name=form.name.data) }}">Edit group</a> + </li> +{% endblock %} + +{% block groups_main %} + <form id="editGroupForm" method="POST"> + {{ form.hidden_tag() }} + {{ render_field(form.name, class_="text-lowercase") }} + {{ render_field(form.vars) }} + {{ render_field(form.hosts, class_="selectpicker") }} + <div class="form-group row"> + <div class="col-sm-10"> + <button type="submit" class="btn btn-primary">Submit</button> + </div> + </div> + </form> +{%- endblock %} diff --git a/app/templates/network/edit_host.html b/app/templates/network/edit_host.html index eadd244..97f6ecc 100644 --- a/app/templates/network/edit_host.html +++ b/app/templates/network/edit_host.html @@ -22,6 +22,7 @@ {{ render_field(form.device_type_id) }} {{ render_field(form.description) }} {{ render_field(form.ansible_vars) }} + {{ render_field(form.ansible_groups, class_="selectpicker") }} <div class="form-group row"> <div class="col-sm-10"> <button type="submit" class="btn btn-primary">Submit</button> diff --git a/app/templates/network/groups.html b/app/templates/network/groups.html new file mode 100644 index 0000000..4792ded --- /dev/null +++ b/app/templates/network/groups.html @@ -0,0 +1,35 @@ +{% extends "base-fluid.html" %} +{% from "_helpers.html" import is_active %} + +{% block title %}Ansible Groups - CSEntry{% endblock %} + +{% block main %} + {% set path = request.path %} + <ul class="nav nav-tabs"> + <li class="nav-item"> + <a class="nav-link {{ is_active(path.endswith("/network/groups")) }}" href="{{ url_for('network.list_ansible_groups') }}">List groups</a> + </li> + <li class="nav-item"> + <a class="nav-link {{ is_active(path.startswith("/network/groups/create")) }}" href="{{ url_for('network.create_ansible_group') }}">Register new group</a> + </li> + {% block groups_nav %}{% endblock %} + </ul> + + <br> + + {% block groups_main %} + <table id="groups_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%"> + <thead> + <tr> + <th>Name</th> + <th>Vars</th> + <th>Hosts</th> + </tr> + </thead> + </table> + {%- endblock %} +{%- endblock %} + +{% block csentry_scripts %} + <script src="{{ url_for('static', filename='js/groups.js') }}"></script> +{% endblock %} diff --git a/app/templates/network/view_group.html b/app/templates/network/view_group.html new file mode 100644 index 0000000..71c4cf7 --- /dev/null +++ b/app/templates/network/view_group.html @@ -0,0 +1,34 @@ +{% extends "network/groups.html" %} +{% from "_helpers.html" import link_to_hosts %} + +{% block title %}View Ansible group - CSEntry{% endblock %} + +{% block groups_nav %} + <li class="nav-item"> + <a class="nav-link active" href="{{ url_for('network.view_ansible_group', name=group.name) }}">View group</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('network.edit_ansible_group', name=group.name) }}">Edit group</a> + </li> +{% endblock %} + +{% block groups_main %} + <div class="row"> + <div class="col-sm-9"> + <dl class="row"> + <dt class="col-sm-3">Name</dt> + <dd class="col-sm-9">{{ group.name }}</dd> + <dt class="col-sm-3">Variables</dt> + <dd class="col-sm-9"><pre>{{ group.vars | toyaml }}</pre></dd> + <dt class="col-sm-3">hosts</dt> + <dd class="col-sm-9">{{ link_to_hosts(group.hosts) }}</dd> + <dt class="col-sm-3">Created by</dt> + <dd class="col-sm-9">{{ group.user }}</dd> + <dt class="col-sm-3">Created at</dt> + <dd class="col-sm-9">{{ group.created_at | datetimeformat }}</dd> + <dt class="col-sm-3">Updated at</dt> + <dd class="col-sm-9">{{ group.updated_at | datetimeformat }}</dd> + </dl> + </div> + </div> +{%- endblock %} diff --git a/app/templates/network/view_host.html b/app/templates/network/view_host.html index 8e3cd00..7eef111 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, + link_to_ansible_groups, delete_button_with_confirmation, render_field, submit_button_with_confirmation %} {% block title %}View Host - CSEntry{% endblock %} @@ -42,6 +43,10 @@ <dt class="col-sm-3">Ansible vars</dt> <dd class="col-sm-9"><pre>{{ host.ansible_vars | toyaml }}</pre></dd> {% endif %} + {% if host.ansible_groups %} + <dt class="col-sm-3">Ansible groups</dt> + <dd class="col-sm-9">{{ link_to_ansible_groups(host.ansible_groups) }}</dd> + {% endif %} </dl> </div> {% if host.device_type.name.startswith('Virtual') and current_user.is_admin %} diff --git a/app/utils.py b/app/utils.py index 8191098..04a6d17 100644 --- a/app/utils.py +++ b/app/utils.py @@ -231,7 +231,10 @@ def pretty_yaml(value): Function used as a jinja2 filter """ - return yaml.safe_dump(value, default_flow_style=False) + if value: + return yaml.safe_dump(value, default_flow_style=False) + else: + return "" def trigger_core_services_update(): -- GitLab