From 5e76c0b123864b4d2997d7b7b14b092da63917e2 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Wed, 11 Jul 2018 15:07:24 +0200
Subject: [PATCH] Add ansible_group table

- add ansible_groups_hosts_table for many-to-many relationship between
groups and hosts
- add new api endpoint /network/groups

JIRA INFRA-412
---
 app/api/network.py                            | 26 ++++++++
 app/factory.py                                |  1 +
 app/models.py                                 | 46 ++++++++++++++
 .../924d15deb321_add_ansible_group_table.py   | 57 ++++++++++++++++++
 tests/functional/conftest.py                  |  1 +
 tests/functional/factories.py                 | 10 ++++
 tests/functional/test_api.py                  | 60 +++++++++++++++++++
 tests/functional/test_models.py               | 11 ++++
 8 files changed, 212 insertions(+)
 create mode 100644 migrations/versions/924d15deb321_add_ansible_group_table.py

diff --git a/app/api/network.py b/app/api/network.py
index 3c77322..2eb6ba4 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 85e7007..b7b62d9 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 f7cda97..72d119a 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 0000000..1c337ee
--- /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 b9eb657..0774d78 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 571d071..ace920c 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 09d10b2..fd2f294 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 e000d18..6298264 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]
-- 
GitLab