diff --git a/app/factory.py b/app/factory.py index 71dc66c0012f31fe5ee46ffc45c2fe649ba3a371..85e70078b6ffd8dbb89f7bbd830947f2a928c359 100644 --- a/app/factory.py +++ b/app/factory.py @@ -57,6 +57,7 @@ def create_app(config=None): app.config.update(config or {}) app.jinja_env.filters["datetimeformat"] = utils.format_datetime + app.jinja_env.filters["toyaml"] = utils.pretty_yaml if not app.debug: import logging diff --git a/app/fields.py b/app/fields.py new file mode 100644 index 0000000000000000000000000000000000000000..a5ed4fb40d699e07bd2abae891f98c7a0ae8c5c7 --- /dev/null +++ b/app/fields.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +""" +app.fields +~~~~~~~~~~ + +This module defines extra WTForms fields + +:copyright: (c) 2018 European Spallation Source ERIC +:license: BSD 2-Clause, see LICENSE for more details. + +""" +import yaml +from wtforms import TextAreaField + + +class YAMLField(TextAreaField): + """This field represents an HTML ``<textarea>`` used to input YAML""" + + def _value(self): + return yaml.safe_dump(self.data, default_flow_style=False) if self.data else "" + + def process_formdata(self, valuelist): + if valuelist: + try: + self.data = yaml.safe_load(valuelist[0]) + except yaml.YAMLError: + raise ValueError("This field contains invalid YAML") + else: + self.data = None + + def pre_validate(self, form): + super().pre_validate(form) + if self.data: + try: + yaml.safe_dump(self.data) + except yaml.YAMLError: + raise ValueError("This field contains invalid YAML") diff --git a/app/models.py b/app/models.py index ed3672993ef3ea71a25caa148f74f45f9b93bb39..f7cda977e6643f8bc51ceea647eff76f6af3b637 100644 --- a/app/models.py +++ b/app/models.py @@ -10,6 +10,7 @@ This module implements the models used in the app. """ import ipaddress +import json import string import qrcode import itertools @@ -750,6 +751,7 @@ class Host(CreatedMixin, db.Model): device_type_id = db.Column( db.Integer, db.ForeignKey("device_type.id"), nullable=False ) + ansible_vars = db.Column(postgresql.JSONB) interfaces = db.relationship("Interface", backref="host") items = db.relationship("Item", backref="host") @@ -811,6 +813,7 @@ class Host(CreatedMixin, db.Model): "description": self.description, "items": [str(item) for item in self.items], "interfaces": [str(interface) for interface in self.interfaces], + "ansible_vars": json.dumps(self.ansible_vars), } ) return d diff --git a/app/network/forms.py b/app/network/forms.py index b564519571409749202c3a0d1422e7b18dd581a5..be0d235e07a5a891085e2ad0aaa66b2e41c39a7d 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -31,6 +31,7 @@ from ..validators import ( MAC_ADDRESS_RE, NoValidateSelectField, ) +from ..fields import YAMLField from .. import utils, models @@ -139,6 +140,10 @@ class HostForm(CSEntryForm): ) description = TextAreaField("Description") device_type_id = SelectField("Device Type") + ansible_vars = YAMLField( + "Ansible vars", + description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html", + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/app/network/views.py b/app/network/views.py index c0373ed2f0cb290ba1ceb8dab1a819bbd8244c8b..5487392b47164426951e294715e7b0895116b65f 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -67,6 +67,7 @@ def create_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, ) # The total number of tags will always be quite small # It's more efficient to retrieve all of them in one query @@ -138,6 +139,7 @@ def edit_host(name): 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 current_app.logger.debug(f"Trying to update: {host!r}") try: db.session.commit() diff --git a/app/static/js/hosts.js b/app/static/js/hosts.js index 9bfc6c6466416a5354234b7da4c83b0d028b1172..463fff207cf6b6297481022886609108ee7e400a 100644 --- a/app/static/js/hosts.js +++ b/app/static/js/hosts.js @@ -1,5 +1,13 @@ $(document).ready(function() { + if( $("#hostForm").length || $("#editHostForm").length ) { + var hostVarsEditor = CodeMirror.fromTextArea(ansible_vars, { + lineNumbers: true, + mode: "yaml" + }); + hostVarsEditor.setSize(null, 120); + } + function set_default_ip() { // Retrieve the first available IP for the selected network // and update the IP field diff --git a/app/templates/network/create_host.html b/app/templates/network/create_host.html index 0190418ba8d286c01a352f13865e3cdb7ba35252..f811580a789e71d32188987cd42e86e7a59832ec 100644 --- a/app/templates/network/create_host.html +++ b/app/templates/network/create_host.html @@ -16,6 +16,7 @@ {{ render_field(form.mac_address) }} {{ render_field(form.cnames_string) }} {{ render_field(form.tags, class_="selectpicker") }} + {{ render_field(form.ansible_vars) }} <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_host.html b/app/templates/network/edit_host.html index 866f12a606991e7514c9e38850b19a0323528a22..eadd2440ba633bd812d53cfa5ab444e55107a562 100644 --- a/app/templates/network/edit_host.html +++ b/app/templates/network/edit_host.html @@ -21,6 +21,7 @@ {{ render_field(form.name, class_="text-lowercase") }} {{ render_field(form.device_type_id) }} {{ render_field(form.description) }} + {{ render_field(form.ansible_vars) }} <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/view_host.html b/app/templates/network/view_host.html index 72e97a928eea85de9fe72e0e9d3871d8d6425f3d..8e3cd004217c6bceaf52dac174c2d1853ed22503 100644 --- a/app/templates/network/view_host.html +++ b/app/templates/network/view_host.html @@ -38,6 +38,10 @@ <dd class="col-sm-9">{{ host.user }}</dd> <dt class="col-sm-3">Created at</dt> <dd class="col-sm-9">{{ host.created_at | datetimeformat }}</dd> + {% if host.ansible_vars %} + <dt class="col-sm-3">Ansible vars</dt> + <dd class="col-sm-9"><pre>{{ host.ansible_vars | toyaml }}</pre></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 758e4d436d15bc2aa9ba4907d538bb9a6b8738ce..8191098f06c8d3b27d9aed0a705796b0b8b59ba2 100644 --- a/app/utils.py +++ b/app/utils.py @@ -15,6 +15,7 @@ import io import random import sqlalchemy as sa import dateutil.parser +import yaml from flask import current_app from flask.globals import _app_ctx_stack, _request_ctx_stack from flask_login import current_user @@ -225,6 +226,14 @@ def format_datetime(value, format="%Y-%m-%d %H:%M"): return value.strftime(format) +def pretty_yaml(value): + """Pretty print yaml + + Function used as a jinja2 filter + """ + return yaml.safe_dump(value, default_flow_style=False) + + def trigger_core_services_update(): """Trigger a job to update the core services on the TN (DNS and DHCP) diff --git a/migrations/versions/d67f43bbd675_add_ansible_vars_column.py b/migrations/versions/d67f43bbd675_add_ansible_vars_column.py new file mode 100644 index 0000000000000000000000000000000000000000..2912d3fe442ac50dfefff5b0d438ac9b5555910f --- /dev/null +++ b/migrations/versions/d67f43bbd675_add_ansible_vars_column.py @@ -0,0 +1,29 @@ +"""Add ansible_vars column + +Revision ID: d67f43bbd675 +Revises: a9442567c6dc +Create Date: 2018-07-10 11:39:17.955468 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d67f43bbd675" +down_revision = "a9442567c6dc" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "host", + sa.Column( + "ansible_vars", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), + ) + + +def downgrade(): + op.drop_column("host", "ansible_vars") diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index af30acc2a427e288a21bea705555301f4ffab73d..09d10b2e1f98b766539b824bd42121c0926cf537 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -1098,6 +1098,13 @@ def test_get_hosts(client, host_factory, readonly_token): check_input_is_subset_of_response(response, (host1.to_dict(), host2.to_dict())) +def test_get_hosts_with_ansible_vars(client, host_factory, readonly_token): + host_factory(ansible_vars={"foo": "hello", "mylist": [1, 2, 3]}) + response = get(client, f"{API_URL}/network/hosts", token=readonly_token) + assert response.status_code == 200 + assert response.json[0]["ansible_vars"] == '{"foo": "hello", "mylist": [1, 2, 3]}' + + def test_create_host(client, device_type_factory, user_token): device_type = device_type_factory(name="Virtual") # check that name and device_type are mandatory @@ -1125,6 +1132,7 @@ def test_create_host(client, device_type_factory, user_token): "description", "items", "interfaces", + "ansible_vars", "created_at", "updated_at", "user", @@ -1160,6 +1168,20 @@ def test_create_host_with_items(client, item_factory, device_type_factory, user_ assert models.Item.query.get(item2.id).host_id == host.id +def test_create_host_with_ansible_vars(client, device_type_factory, user_token): + device_type = device_type_factory(name="VirtualMachine") + data = { + "name": "my-host", + "device_type": device_type.name, + "ansible_vars": {"foo": "hello", "mylist": [1, 2, 3]}, + } + response = post(client, f"{API_URL}/network/hosts", data=data, token=user_token) + assert response.status_code == 201 + assert response.json["ansible_vars"] == '{"foo": "hello", "mylist": [1, 2, 3]}' + host = models.Host.query.filter_by(name="my-host").first() + assert host.ansible_vars == {"foo": "hello", "mylist": [1, 2, 3]} + + def test_create_host_as_consultant( client, item_factory, device_type_factory, consultant_token ):