diff --git a/app/api/network.py b/app/api/network.py index 3c77322fdd0d697c64bf2df5c6a3e6ba0d2f30a7..2eb6ba4482ded08c88401e174b3f19166999acd1 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -133,6 +133,30 @@ def create_interface(): ) +@bp.route("/groups") +@jwt_required +def get_ansible_groups(): + """Return ansible groups + + .. :quickref: Network; Get Ansible groups + """ + return get_generic_model(models.AnsibleGroup, order_by=models.AnsibleGroup.name) + + +@bp.route("/groups", methods=["POST"]) +@jwt_required +@jwt_groups_accepted("admin") +def create_ansible_groups(): + """Create a new Ansible group + + .. :quickref: Network; Create new Ansible group + + :jsonparam name: group name + :jsonparam vars: (optional) Ansible variables + """ + return create_generic_model(models.AnsibleGroup, mandatory_fields=("name",)) + + @bp.route("/hosts") @jwt_required def get_hosts(): @@ -155,6 +179,8 @@ def create_host(): :jsonparam device_type: Physical|Virtual|... :jsonparam description: (optional) description :jsonparam items: (optional) list of items ICS id linked to the host + :jsonparam ansible_vars: (optional) Ansible variables + :jsonparam ansible_groups: (optional) list of Ansible groups names """ return create_generic_model(models.Host, mandatory_fields=("name", "device_type")) diff --git a/app/factory.py b/app/factory.py index 85e70078b6ffd8dbb89f7bbd830947f2a928c359..b7b62d9cda5317f8c10fc0e602a905d5565ae401 100644 --- a/app/factory.py +++ b/app/factory.py @@ -149,6 +149,7 @@ def create_app(config=None): admin.add_view(AdminModelView(models.NetworkScope, db.session)) admin.add_view(NetworkAdmin(models.Network, db.session, endpoint="networks")) admin.add_view(AdminModelView(models.DeviceType, db.session)) + admin.add_view(AdminModelView(models.AnsibleGroup, db.session)) admin.add_view(AdminModelView(models.Host, db.session)) admin.add_view(AdminModelView(models.Interface, db.session)) admin.add_view(AdminModelView(models.Mac, db.session)) diff --git a/app/models.py b/app/models.py index f7cda977e6643f8bc51ceea647eff76f6af3b637..72d119aad6445148002c5aef9c956ce189188d7f 100644 --- a/app/models.py +++ b/app/models.py @@ -745,6 +745,39 @@ class DeviceType(db.Model): } +# Table required for Many-to-Many relationships between Ansible groups and hosts +ansible_groups_hosts_table = db.Table( + "ansible_groups_hosts", + db.Column( + "ansible_group_id", + db.Integer, + db.ForeignKey("ansible_group.id"), + primary_key=True, + ), + db.Column("host_id", db.Integer, db.ForeignKey("host.id"), primary_key=True), +) + + +class AnsibleGroup(CreatedMixin, db.Model): + __tablename__ = "ansible_group" + name = db.Column(CIText, nullable=False, unique=True) + vars = db.Column(postgresql.JSONB) + + def __str__(self): + return str(self.name) + + def to_dict(self): + d = super().to_dict() + d.update( + { + "name": self.name, + "vars": json.dumps(self.vars), + "hosts": [str(host) for host in self.hosts], + } + ) + return d + + class Host(CreatedMixin, db.Model): name = db.Column(db.Text, nullable=False, unique=True) description = db.Column(db.Text) @@ -755,6 +788,12 @@ class Host(CreatedMixin, db.Model): interfaces = db.relationship("Interface", backref="host") items = db.relationship("Item", backref="host") + ansible_groups = db.relationship( + "AnsibleGroup", + secondary=ansible_groups_hosts_table, + lazy=True, + backref=db.backref("hosts"), + ) def __init__(self, **kwargs): # Automatically convert device_type as an instance of its class if passed as a string @@ -768,6 +807,12 @@ class Host(CreatedMixin, db.Model): utils.convert_to_model(item, Item, filter="ics_id") for item in kwargs["items"] ] + # Automatically convert ansible groups to a list of instances if passed as a list of strings + if "ansible_groups" in kwargs: + kwargs["ansible_groups"] = [ + utils.convert_to_model(group, AnsibleGroup) + for group in kwargs["ansible_groups"] + ] super().__init__(**kwargs) @property @@ -814,6 +859,7 @@ class Host(CreatedMixin, db.Model): "items": [str(item) for item in self.items], "interfaces": [str(interface) for interface in self.interfaces], "ansible_vars": json.dumps(self.ansible_vars), + "ansible_groups": [str(group) for group in self.ansible_groups], } ) return d diff --git a/migrations/versions/924d15deb321_add_ansible_group_table.py b/migrations/versions/924d15deb321_add_ansible_group_table.py new file mode 100644 index 0000000000000000000000000000000000000000..1c337eea35e628914a8325c1b66127e06d9c9382 --- /dev/null +++ b/migrations/versions/924d15deb321_add_ansible_group_table.py @@ -0,0 +1,57 @@ +"""Add ansible_group table + +Revision ID: 924d15deb321 +Revises: d67f43bbd675 +Create Date: 2018-07-11 11:38:38.262279 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import citext + +# revision identifiers, used by Alembic. +revision = "924d15deb321" +down_revision = "d67f43bbd675" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "ansible_group", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("name", citext.CIText(), nullable=False), + sa.Column("vars", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["user_account.id"], + name=op.f("fk_ansible_group_user_id_user_account"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_ansible_group")), + sa.UniqueConstraint("name", name=op.f("uq_ansible_group_name")), + ) + op.create_table( + "ansible_groups_hosts", + sa.Column("ansible_group_id", sa.Integer(), nullable=False), + sa.Column("host_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["ansible_group_id"], + ["ansible_group.id"], + name=op.f("fk_ansible_groups_hosts_ansible_group_id_ansible_group"), + ), + sa.ForeignKeyConstraint( + ["host_id"], ["host.id"], name=op.f("fk_ansible_groups_hosts_host_id_host") + ), + sa.PrimaryKeyConstraint( + "ansible_group_id", "host_id", name=op.f("pk_ansible_groups_hosts") + ), + ) + + +def downgrade(): + op.drop_table("ansible_groups_hosts") + op.drop_table("ansible_group") diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index b9eb65706976b7cb36310f8ab6a0ac95a32b3ea5..0774d78e9cc4c657d47ca635572198ae22b2bc6b 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -28,6 +28,7 @@ register(factories.NetworkScopeFactory) register(factories.NetworkFactory) register(factories.InterfaceFactory) register(factories.DeviceTypeFactory) +register(factories.AnsibleGroupFactory) register(factories.HostFactory) register(factories.MacFactory) register(factories.DomainFactory) diff --git a/tests/functional/factories.py b/tests/functional/factories.py index 571d071b51e255ef79d70fb0badc216600f66aae..ace920c5d6b41f3764fbc44a230554ff2b872a42 100644 --- a/tests/functional/factories.py +++ b/tests/functional/factories.py @@ -162,6 +162,16 @@ class DeviceTypeFactory(factory.alchemy.SQLAlchemyModelFactory): name = factory.Sequence(lambda n: f"Type{n}") +class AnsibleGroupFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = models.AnsibleGroup + sqlalchemy_session = common.Session + sqlalchemy_session_persistence = "commit" + + name = factory.Sequence(lambda n: f"group{n}") + user = factory.SubFactory(UserFactory) + + class HostFactory(factory.alchemy.SQLAlchemyModelFactory): class Meta: model = models.Host diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 09d10b2e1f98b766539b824bd42121c0926cf537..fd2f294fb7356b970960994053334c83e91dc05d 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -26,6 +26,7 @@ ENDPOINT_MODEL = { "network/networks": models.Network, "network/interfaces": models.Interface, "network/hosts": models.Host, + "network/groups": models.AnsibleGroup, "network/macs": models.Mac, "network/domains": models.Domain, "network/cnames": models.Cname, @@ -1088,6 +1089,47 @@ def test_create_mac_invalid_address(address, client, user_token): ) +def test_get_ansible_groups(client, ansible_group_factory, readonly_token): + # Create some Ansible groups + group1 = ansible_group_factory(vars={"foo": "hello"}) + group2 = ansible_group_factory() + response = get(client, f"{API_URL}/network/groups", token=readonly_token) + assert response.status_code == 200 + assert len(response.json) == 2 + check_input_is_subset_of_response(response, (group1.to_dict(), group2.to_dict())) + + +def test_create_ansible_group(client, admin_token): + # check that name is mandatory + response = post(client, f"{API_URL}/network/groups", data={}, token=admin_token) + check_response_message(response, "Missing mandatory field 'name'", 422) + + data = {"name": "mygroup"} + response = post(client, f"{API_URL}/network/groups", data=data, token=admin_token) + assert response.status_code == 201 + assert {"id", "name", "vars", "hosts", "created_at", "updated_at", "user"} == set( + response.json.keys() + ) + assert response.json["name"] == data["name"] + + # Check that name shall be unique + response = post(client, f"{API_URL}/network/groups", data=data, token=admin_token) + check_response_message( + response, + "(psycopg2.IntegrityError) duplicate key value violates unique constraint", + 422, + ) + + +def test_create_ansible_group_with_vars(client, admin_token): + data = {"name": "mygroup", "vars": {"foo": "hello", "mylist": [1, 2, 3]}} + response = post(client, f"{API_URL}/network/groups", data=data, token=admin_token) + assert response.status_code == 201 + assert response.json["vars"] == '{"foo": "hello", "mylist": [1, 2, 3]}' + group = models.AnsibleGroup.query.filter_by(name="mygroup").first() + assert group.vars == data["vars"] + + def test_get_hosts(client, host_factory, readonly_token): # Create some hosts host1 = host_factory() @@ -1133,6 +1175,7 @@ def test_create_host(client, device_type_factory, user_token): "items", "interfaces", "ansible_vars", + "ansible_groups", "created_at", "updated_at", "user", @@ -1182,6 +1225,23 @@ def test_create_host_with_ansible_vars(client, device_type_factory, user_token): assert host.ansible_vars == {"foo": "hello", "mylist": [1, 2, 3]} +def test_create_host_with_ansible_groups( + client, device_type_factory, ansible_group_factory, user_token +): + device_type = device_type_factory(name="VirtualMachine") + group1 = ansible_group_factory(name="mygroup") + group2 = ansible_group_factory(name="another") + data = { + "name": "my-host", + "device_type": device_type.name, + "ansible_groups": [group1.name, group2.name], + } + response = post(client, f"{API_URL}/network/hosts", data=data, token=user_token) + assert response.status_code == 201 + host = models.Host.query.filter_by(name="my-host").first() + assert host.ansible_groups == [group1, group2] + + def test_create_host_as_consultant( client, item_factory, device_type_factory, consultant_token ): diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index e000d184bc2377459822eb1dae55c1f73546d495..62982642fe21aa678ee312bd532b8a2efaaf4b1b 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -166,3 +166,14 @@ def test_tag_validation(tag_factory): with pytest.raises(ValidationError) as excinfo: tag = tag_factory(name="My tag") assert "'My tag' is an invalid tag name" in str(excinfo.value) + + +def test_ansible_groups(ansible_group_factory, host_factory): + group1 = ansible_group_factory() + group2 = ansible_group_factory() + host1 = host_factory(ansible_groups=[group1]) + assert host1.ansible_groups == [group1] + assert group1.hosts == [host1] + host1.ansible_groups.append(group2) + assert host1.ansible_groups == [group1, group2] + assert group2.hosts == [host1]