diff --git a/app/models.py b/app/models.py index c234a4b6c8b9c0c1407f31f90f5ce6fca275860b..b757935c767fdc688addb470d5470062ba90e7bc 100644 --- a/app/models.py +++ b/app/models.py @@ -775,12 +775,35 @@ ansible_groups_hosts_table = db.Table( ) +class AnsibleGroupType(Enum): + STATIC = "STATIC" + NETWORK_SCOPE = "NETWORK_SCOPE" + NETWORK = "NETWORK" + DEVICE_TYPE = "DEVICE_TYPE" + + def __str__(self): + return self.name + + @classmethod + def choices(cls): + return [(item, item.name) for item in AnsibleGroupType] + + @classmethod + def coerce(cls, value): + return value if type(value) == AnsibleGroupType else AnsibleGroupType[value] + + class AnsibleGroup(CreatedMixin, db.Model): __tablename__ = "ansible_group" # Define id here so that it can be used in the primary and secondary join id = db.Column(db.Integer, primary_key=True) name = db.Column(CIText, nullable=False, unique=True) vars = db.Column(postgresql.JSONB) + type = db.Column( + db.Enum(AnsibleGroupType, name="ansible_group_type"), + default=AnsibleGroupType.STATIC, + nullable=False, + ) children = db.relationship( "AnsibleGroup", @@ -793,12 +816,53 @@ class AnsibleGroup(CreatedMixin, db.Model): def __str__(self): return str(self.name) + @property + def is_dynamic(self): + return self.type != AnsibleGroupType.STATIC + + @property + def hosts(self): + if self.type == AnsibleGroupType.STATIC: + return self._hosts + if self.type == AnsibleGroupType.NETWORK_SCOPE: + return ( + Host.query.join(Host.interfaces) + .join(Interface.network) + .join(Network.scope) + .filter(NetworkScope.name == self.name) + .order_by(Host.name) + .all() + ) + if self.type == AnsibleGroupType.NETWORK: + return ( + Host.query.join(Host.interfaces) + .join(Interface.network) + .filter(Network.vlan_name == self.name) + .order_by(Host.name) + .all() + ) + if self.type == AnsibleGroupType.DEVICE_TYPE: + return ( + Host.query.join(Host.device_type) + .filter(DeviceType.name == self.name) + .order_by(Host.name) + .all() + ) + + @hosts.setter + def hosts(self, value): + # For dynamic group type, _hosts can only be set to [] + if self.is_dynamic and value: + raise AttributeError("can't set dynamic hosts") + self._hosts = value + def to_dict(self): d = super().to_dict() d.update( { "name": self.name, "vars": self.vars, + "type": self.type.name, "hosts": [str(host) for host in self.hosts], "children": [str(child) for child in self.children], } @@ -820,7 +884,7 @@ class Host(CreatedMixin, db.Model): "AnsibleGroup", secondary=ansible_groups_hosts_table, lazy=True, - backref=db.backref("hosts"), + backref=db.backref("_hosts"), ) def __init__(self, **kwargs): diff --git a/app/network/forms.py b/app/network/forms.py index 208b05d707fb418134c658cd7cd0cb9d51fc8b7f..d0fe5c212a59104ad6f0ca9998436304e3d71962 100644 --- a/app/network/forms.py +++ b/app/network/forms.py @@ -151,8 +151,11 @@ class HostForm(CSEntryForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.device_type_id.choices = utils.get_model_choices(models.DeviceType) + ansible_group_query = models.AnsibleGroup.query.filter( + models.AnsibleGroup.type == models.AnsibleGroupType.STATIC + ) self.ansible_groups.choices = utils.get_model_choices( - models.AnsibleGroup, attr="name" + models.AnsibleGroup, attr="name", query=ansible_group_query ) @@ -241,6 +244,12 @@ class AnsibleGroupForm(CSEntryForm): "Ansible vars", description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html", ) + type = SelectField( + "Type", + choices=models.AnsibleGroupType.choices(), + default=models.AnsibleGroupType.STATIC, + coerce=models.AnsibleGroupType.coerce, + ) hosts = SelectMultipleField("Hosts", coerce=utils.coerce_to_str_or_none) def __init__(self, *args, **kwargs): diff --git a/app/network/views.py b/app/network/views.py index 7b4f0c6495ba5a9aa4748715556db3a9e69373f7..5932fa7c24b0aa6f207c8ec754ac7fe0763e6179 100644 --- a/app/network/views.py +++ b/app/network/views.py @@ -349,6 +349,7 @@ def edit_ansible_group(name): if form.validate_on_submit(): group.name = form.name.data group.vars = form.vars.data or None + group.type = form.type.data group.hosts = [models.Host.query.get(id_) for id_ in form.hosts.data] current_app.logger.debug(f"Trying to update: {group!r}") try: @@ -370,7 +371,10 @@ def create_ansible_group(): 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 + name=form.name.data, + vars=form.vars.data or None, + type=form.type.data, + hosts=hosts, ) current_app.logger.debug(f"Trying to create: {group!r}") db.session.add(group) diff --git a/app/static/js/groups.js b/app/static/js/groups.js index 3695cf4f7f2d86219ca72b905286c17f74826982..ec1bbf3721b36f7383507f88320e0d5c0a90fa79 100644 --- a/app/static/js/groups.js +++ b/app/static/js/groups.js @@ -1,5 +1,26 @@ $(document).ready(function() { + function group_type_update() { + if( $("#type option:selected").text() == "STATIC" ) { + $("#hosts").prop("disabled", false); + $("#hosts").selectpicker('refresh'); + } else { + $("#hosts").selectpicker('deselectAll'); + $("#hosts").prop("disabled", true); + $("#hosts").selectpicker('refresh'); + } + } + + // Disable or enable hosts on group type update + $("#type").on('change', function() { + group_type_update(); + }); + + // Disable or enable hosts on page load depending on group type + if( $("#groupForm").length || $("#editGroupForm").length ) { + group_type_update(); + } + if( $("#groupForm").length || $("#editGroupForm").length ) { var groupVarsEditor = CodeMirror.fromTextArea(vars, { lineNumbers: true, @@ -36,6 +57,7 @@ $(document).ready(function() { return '<pre style="white-space: pre-wrap">' + JSON.stringify(data, null, 2) + '</pre>'; } }, + { data: 'type' }, { data: 'hosts' } ] }); diff --git a/app/templates/network/create_group.html b/app/templates/network/create_group.html index 41e6f644fd7328f14eff97beb1f259e4b682b621..89161907e0bc0ea6aa67ea17c9eb9a38bb0fbf3c 100644 --- a/app/templates/network/create_group.html +++ b/app/templates/network/create_group.html @@ -8,6 +8,7 @@ {{ form.hidden_tag() }} {{ render_field(form.name, class_="text-lowercase") }} {{ render_field(form.vars) }} + {{ render_field(form.type) }} {{ render_field(form.hosts, class_="selectpicker") }} <div class="form-group row"> <div class="col-sm-10"> diff --git a/app/templates/network/edit_group.html b/app/templates/network/edit_group.html index 155d1c0c23feecb3ce3f24b02b6ea7f6ca2a3502..f19aaca4d2cca315c035d2e3b47a40e40f7e86ac 100644 --- a/app/templates/network/edit_group.html +++ b/app/templates/network/edit_group.html @@ -17,6 +17,7 @@ {{ form.hidden_tag() }} {{ render_field(form.name, class_="text-lowercase") }} {{ render_field(form.vars) }} + {{ render_field(form.type) }} {{ render_field(form.hosts, class_="selectpicker") }} <div class="form-group row"> <div class="col-sm-10"> diff --git a/app/templates/network/groups.html b/app/templates/network/groups.html index 4792deda0c73015a9049e7c78ec483512f85d3cb..af1f517ecfd4c87c02917447bad2418005a3fb78 100644 --- a/app/templates/network/groups.html +++ b/app/templates/network/groups.html @@ -23,6 +23,7 @@ <tr> <th>Name</th> <th>Vars</th> + <th>Type</th> <th>Hosts</th> </tr> </thead> diff --git a/app/templates/network/view_group.html b/app/templates/network/view_group.html index 71c4cf740b6b62e9bbf215f05b930e24961f105b..457879ef5f891af1d52c764b23822a27750fde7b 100644 --- a/app/templates/network/view_group.html +++ b/app/templates/network/view_group.html @@ -20,6 +20,8 @@ <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">Type</dt> + <dd class="col-sm-9">{{ group.type.name }}</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> diff --git a/migrations/versions/5698c505d70e_add_ansible_group_type.py b/migrations/versions/5698c505d70e_add_ansible_group_type.py new file mode 100644 index 0000000000000000000000000000000000000000..5a218deb041ed2bfa16322cfae02db64fad7f519 --- /dev/null +++ b/migrations/versions/5698c505d70e_add_ansible_group_type.py @@ -0,0 +1,49 @@ +"""Add Ansible group type + +Revision ID: 5698c505d70e +Revises: 78283a288a05 +Create Date: 2018-07-31 19:57:14.175703 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = "5698c505d70e" +down_revision = "78283a288a05" +branch_labels = None +depends_on = None + + +def upgrade(): + ansible_group_type = postgresql.ENUM( + "STATIC", "NETWORK_SCOPE", "NETWORK", "DEVICE_TYPE", name="ansible_group_type" + ) + ansible_group_type.create(op.get_bind()) + # WARNING! If the database is not empty, we can't set type to nullable=False before adding it to existing rows. + op.add_column( + "ansible_group", + sa.Column( + "type", + sa.Enum( + "STATIC", + "NETWORK_SCOPE", + "NETWORK", + "DEVICE_TYPE", + name="ansible_group_type", + ), + nullable=True, + ), + ) + # Set type to STATIC for existing rows + ansible_group = sa.sql.table("ansible_group", sa.sql.column("type")) + op.execute(ansible_group.update().values(type="STATIC")) + # Add the nullable=False constraint + op.alter_column("ansible_group", "type", nullable=False) + + +def downgrade(): + op.drop_column("ansible_group", "type") + op.execute("DROP TYPE ansible_group_type") diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 677fc357bc4ab587356d40d4bd55f7e9a7139518..f47b8f357f9416e21c08a52015b49d18afdac057 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -1163,6 +1163,7 @@ def test_create_ansible_group(client, admin_token): "id", "name", "vars", + "type", "hosts", "children", "created_at", diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index 2a40e1fbd3647f51d5ab0464bb245acfc73b099d..96aa143c2759dac25255fe949dbcddbedda1f804 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -12,6 +12,7 @@ This module defines models tests. import ipaddress import pytest from wtforms import ValidationError +from app import models def test_user_groups(user_factory): @@ -179,6 +180,16 @@ def test_ansible_groups(ansible_group_factory, host_factory): assert group2.hosts == [host1] +def test_ansible_group_is_dynamic(ansible_group_factory): + group1 = ansible_group_factory() + assert group1.type == models.AnsibleGroupType.STATIC + assert not group1.is_dynamic + group2 = ansible_group_factory(type=models.AnsibleGroupType.NETWORK_SCOPE) + assert group2.is_dynamic + group3 = ansible_group_factory(type=models.AnsibleGroupType.NETWORK) + assert group3.is_dynamic + + def test_ansible_groups_children(ansible_group_factory, host_factory): group1 = ansible_group_factory() group2 = ansible_group_factory() @@ -197,3 +208,82 @@ def test_host_model(model_factory, item_factory, host_factory): model1 = model_factory(name="EX3400") item_factory(model=model1, host_id=host1.id) assert host1.model == "EX3400" + + +def test_ansible_dynamic_device_type_group( + ansible_group_factory, device_type_factory, host_factory +): + device_type1 = device_type_factory(name="type1") + device_type2 = device_type_factory(name="type2") + host1_t1 = host_factory(name="host1", device_type=device_type1) + host2_t1 = host_factory(name="host2", device_type=device_type1) + host1_t2 = host_factory(device_type=device_type2) + group_t1 = ansible_group_factory( + name="type1", type=models.AnsibleGroupType.DEVICE_TYPE + ) + group_t2 = ansible_group_factory( + name="type2", type=models.AnsibleGroupType.DEVICE_TYPE + ) + group_t3 = ansible_group_factory( + name="unknown", type=models.AnsibleGroupType.DEVICE_TYPE + ) + assert group_t1.hosts == [host1_t1, host2_t1] + assert group_t2.hosts == [host1_t2] + assert group_t3.hosts == [] + + +def test_ansible_dynamic_network_group( + ansible_group_factory, network_factory, interface_factory, host_factory +): + network1 = network_factory(vlan_name="network1") + network2 = network_factory(vlan_name="network2") + interface1_n1 = interface_factory(network=network1) + interface2_n1 = interface_factory(network=network1) + interface1_n2 = interface_factory(network=network2) + host1_n1 = host_factory(name="host1", interfaces=[interface1_n1]) + host2_n1 = host_factory(name="host2", interfaces=[interface2_n1]) + host1_n2 = host_factory(interfaces=[interface1_n2]) + group_n1 = ansible_group_factory( + name="network1", type=models.AnsibleGroupType.NETWORK + ) + group_n2 = ansible_group_factory( + name="network2", type=models.AnsibleGroupType.NETWORK + ) + group_n3 = ansible_group_factory( + name="unknown", type=models.AnsibleGroupType.NETWORK + ) + assert group_n1.hosts == [host1_n1, host2_n1] + assert group_n2.hosts == [host1_n2] + assert group_n3.hosts == [] + + +def test_ansible_dynamic_network_scope_group( + ansible_group_factory, + network_scope_factory, + network_factory, + interface_factory, + host_factory, +): + scope1 = network_scope_factory(name="scope1") + scope2 = network_scope_factory(name="scope2") + network1_s1 = network_factory(scope=scope1) + network2_s1 = network_factory(scope=scope1) + network1_s2 = network_factory(scope=scope2) + interface1_s1 = interface_factory(network=network1_s1) + interface2_s1 = interface_factory(network=network2_s1) + interface1_s2 = interface_factory(network=network1_s2) + host1_s1 = host_factory(name="host1", interfaces=[interface1_s1]) + host2_s1 = host_factory(name="host2", interfaces=[interface2_s1]) + host1_s2 = host_factory(interfaces=[interface1_s2]) + group_s1 = ansible_group_factory( + name="scope1", type=models.AnsibleGroupType.NETWORK_SCOPE + ) + group_s2 = ansible_group_factory( + name="scope2", type=models.AnsibleGroupType.NETWORK_SCOPE + ) + group_s3 = ansible_group_factory( + name="unknown", type=models.AnsibleGroupType.NETWORK_SCOPE + ) + assert group_s1.hosts == [host1_s1, host2_s1] + assert group_s2.hosts == [host1_s2] + assert group_s3.hosts == []