Feature 10906: Support for Anti-Affinity groups 82/11882/1
authorAlexis Romero <garomero@indra.es>
Wed, 13 Apr 2022 16:03:30 +0000 (18:03 +0200)
committerAlexis Romero <garomero@indra.es>
Wed, 13 Apr 2022 16:06:05 +0000 (18:06 +0200)
Change-Id: Id14d9a02d4d66f678affb3e80a9169fc57eb452d
Signed-off-by: Alexis Romero <garomero@indra.es>
13 files changed:
NG-RO/osm_ng_ro/ns.py
NG-RO/osm_ng_ro/ns_thread.py
NG-RO/osm_ng_ro/tests/test_ns_thread.py [new file with mode: 0644]
NG-RO/osm_ng_ro/validation.py
RO-VIM-aws/osm_rovim_aws/vimconn_aws.py
RO-VIM-azure/osm_rovim_azure/vimconn_azure.py
RO-VIM-gcp/osm_rovim_gcp/vimconn_gcp.py
RO-VIM-openstack/osm_rovim_openstack/vimconn_openstack.py
RO-VIM-openvim/osm_rovim_openvim/vimconn_openvim.py
RO-VIM-vmware/osm_rovim_vmware/vimconn_vmware.py
RO-plugin/osm_ro_plugin/vim_dummy.py
RO-plugin/osm_ro_plugin/vimconn.py
releasenotes/notes/feature_10906_anti-affinity-00437de83b4f71e0.yaml [new file with mode: 0644]

index 08ee7c4..e8326bf 100644 (file)
@@ -693,6 +693,21 @@ class Ns(object):
 
                 return extra_dict
 
+            def _process_affinity_group_params(target_affinity_group, vim_info, target_record_id):
+                extra_dict = {}
+
+                affinity_group_data = {
+                    "name": target_affinity_group["name"],
+                    "type": target_affinity_group["type"],
+                    "scope": target_affinity_group["scope"],
+                }
+
+                extra_dict["params"] = {
+                    "affinity_group_data": affinity_group_data,
+                }
+
+                return extra_dict
+
             def _ip_profile_2_ro(ip_profile):
                 if not ip_profile:
                     return None
@@ -908,6 +923,19 @@ class Ns(object):
                         == "persistent-storage:persistent-storage"
                     ]
 
+                affinity_group_list = []
+
+                if target_vdu.get("affinity-or-anti-affinity-group-id"):
+                    affinity_group = {}
+                    for affinity_group_id in target_vdu["affinity-or-anti-affinity-group-id"]:
+                        affinity_group_text = (
+                            ns_preffix + ":affinity-or-anti-affinity-group." + affinity_group_id
+                        )
+
+                        extra_dict["depends_on"].append(affinity_group_text)
+                        affinity_group["affinity_group_id"] = "TASK-" + affinity_group_text
+                        affinity_group_list.append(affinity_group)
+
                 extra_dict["params"] = {
                     "name": "{}-{}-{}-{}".format(
                         indata["name"][:16],
@@ -919,6 +947,7 @@ class Ns(object):
                     "start": True,
                     "image_id": "TASK-" + image_text,
                     "flavor_id": "TASK-" + flavor_text,
+                    "affinity_group_list": affinity_group_list,
                     "net_list": net_list,
                     "cloud_config": cloud_config or None,
                     "disk_list": disk_list,
@@ -1156,6 +1185,17 @@ class Ns(object):
                         process_params=_process_flavor_params,
                     )
 
+                    step = "process NS Affinity Groups"
+                    _process_items(
+                        target_list=indata.get("affinity-or-anti-affinity-group") or [],
+                        existing_list=db_nsr.get("affinity-or-anti-affinity-group") or [],
+                        db_record="nsrs:{}:affinity-or-anti-affinity-group".format(nsr_id),
+                        db_update=db_nsr_update,
+                        db_path="affinity-or-anti-affinity-group",
+                        item="affinity-or-anti-affinity-group",
+                        process_params=_process_affinity_group_params,
+                    )
+
                     # VNF.vld
                     for vnfr_id, vnfr in db_vnfrs.items():
                         # vnfr_id need to be set as global variable for among others nested method _process_vdu_params
index 4dac9d9..106830d 100644 (file)
@@ -370,6 +370,23 @@ class VimInteractionVdu(VimInteractionBase):
             if params_copy["flavor_id"].startswith("TASK-"):
                 params_copy["flavor_id"] = task_depends[params_copy["flavor_id"]]
 
+            affinity_group_list = params_copy["affinity_group_list"]
+            for affinity_group in affinity_group_list:
+                # change task_id into affinity_group_id
+                if "affinity_group_id" in affinity_group and affinity_group[
+                    "affinity_group_id"
+                ].startswith("TASK-"):
+                    affinity_group_id = task_depends[
+                        affinity_group["affinity_group_id"]
+                    ]
+
+                    if not affinity_group_id:
+                        raise NsWorkerException(
+                            "found for {}".format(affinity_group["affinity_group_id"])
+                        )
+
+                    affinity_group["affinity_group_id"] = affinity_group_id
+
             vim_vm_id, created_items = target_vim.new_vminstance(**params_copy)
             interfaces = [iface["vim_id"] for iface in params_copy["net_list"]]
 
@@ -745,6 +762,93 @@ class VimInteractionFlavor(VimInteractionBase):
             return "FAILED", ro_vim_item_update
 
 
+class VimInteractionAffinityGroup(VimInteractionBase):
+    def delete(self, ro_task, task_index):
+        task = ro_task["tasks"][task_index]
+        task_id = task["task_id"]
+        affinity_group_vim_id = ro_task["vim_info"]["vim_id"]
+        ro_vim_item_update_ok = {
+            "vim_status": "DELETED",
+            "created": False,
+            "vim_details": "DELETED",
+            "vim_id": None,
+        }
+
+        try:
+            if affinity_group_vim_id:
+                target_vim = self.my_vims[ro_task["target_id"]]
+                target_vim.delete_affinity_group(affinity_group_vim_id)
+        except vimconn.VimConnNotFoundException:
+            ro_vim_item_update_ok["vim_details"] = "already deleted"
+        except vimconn.VimConnException as e:
+            self.logger.error(
+                "ro_task={} vim={} del-affinity-or-anti-affinity-group={}: {}".format(
+                    ro_task["_id"], ro_task["target_id"], affinity_group_vim_id, e
+                )
+            )
+            ro_vim_item_update = {
+                "vim_status": "VIM_ERROR",
+                "vim_details": "Error while deleting: {}".format(e),
+            }
+
+            return "FAILED", ro_vim_item_update
+
+        self.logger.debug(
+            "task={} {} del-affinity-or-anti-affinity-group={} {}".format(
+                task_id,
+                ro_task["target_id"],
+                affinity_group_vim_id,
+                ro_vim_item_update_ok.get("vim_details", ""),
+            )
+        )
+
+        return "DONE", ro_vim_item_update_ok
+
+    def new(self, ro_task, task_index, task_depends):
+        task = ro_task["tasks"][task_index]
+        task_id = task["task_id"]
+        created = False
+        created_items = {}
+        target_vim = self.my_vims[ro_task["target_id"]]
+
+        try:
+            affinity_group_vim_id = None
+
+            if task.get("params"):
+                affinity_group_data = task["params"]["affinity_group_data"]
+                affinity_group_vim_id = target_vim.new_affinity_group(
+                    affinity_group_data
+                )
+                created = True
+
+            ro_vim_item_update = {
+                "vim_id": affinity_group_vim_id,
+                "vim_status": "DONE",
+                "created": created,
+                "created_items": created_items,
+                "vim_details": None,
+            }
+            self.logger.debug(
+                "task={} {} new-affinity-or-anti-affinity-group={} created={}".format(
+                    task_id, ro_task["target_id"], affinity_group_vim_id, created
+                )
+            )
+
+            return "DONE", ro_vim_item_update
+        except (vimconn.VimConnException, NsWorkerException) as e:
+            self.logger.error(
+                "task={} vim={} new-affinity-or-anti-affinity-group:"
+                " {}".format(task_id, ro_task["target_id"], e)
+            )
+            ro_vim_item_update = {
+                "vim_status": "VIM_ERROR",
+                "created": created,
+                "vim_details": str(e),
+            }
+
+            return "FAILED", ro_vim_item_update
+
+
 class VimInteractionSdnNet(VimInteractionBase):
     @staticmethod
     def _match_pci(port_pci, mapping):
@@ -1180,6 +1284,9 @@ class NsWorker(threading.Thread):
             "sdn_net": VimInteractionSdnNet(
                 self.db, self.my_vims, self.db_vims, self.logger
             ),
+            "affinity-or-anti-affinity-group": VimInteractionAffinityGroup(
+                self.db, self.my_vims, self.db_vims, self.logger
+            ),
         }
         self.time_last_task_processed = None
         # lists of tasks to delete because nsrs or vnfrs has been deleted from db
diff --git a/NG-RO/osm_ng_ro/tests/test_ns_thread.py b/NG-RO/osm_ng_ro/tests/test_ns_thread.py
new file mode 100644 (file)
index 0000000..9f163a8
--- /dev/null
@@ -0,0 +1,228 @@
+#######################################################################################
+# Copyright ETSI Contributors and Others.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#######################################################################################
+
+import logging
+import unittest
+from unittest.mock import MagicMock, patch
+
+from osm_ng_ro.ns_thread import VimInteractionAffinityGroup
+
+
+class TestVimInteractionAffinityGroup(unittest.TestCase):
+    def setUp(self):
+        module_name = "osm_ro_plugin"
+        self.target_vim = MagicMock(name=f"{module_name}.vimconn.VimConnector")
+        self.task_depends = None
+
+        patches = [patch(f"{module_name}.vimconn.VimConnector", self.target_vim)]
+
+        # Enabling mocks and add cleanups
+        for mock in patches:
+            mock.start()
+            self.addCleanup(mock.stop)
+
+    def test__new_affinity_group_ok(self):
+        """
+        create affinity group with attributes set in params
+        """
+        db = "test_db"
+        logger = "test_logger"
+        my_vims = "test-vim"
+        db_vims = {
+            0: {
+                "config": {},
+            },
+        }
+
+        instance = VimInteractionAffinityGroup(db, logger, my_vims, db_vims)
+        with patch.object(instance, "my_vims", [self.target_vim]), patch.object(
+            instance, "logger", logging
+        ), patch.object(instance, "db_vims", db_vims):
+            ro_task = {
+                "target_id": 0,
+                "tasks": {
+                    "task_index_1": {
+                        "target_id": 0,
+                        "action_id": "123456",
+                        "nsr_id": "654321",
+                        "task_id": "123456:1",
+                        "status": "SCHEDULED",
+                        "action": "CREATE",
+                        "item": "test_item",
+                        "target_record": "test_target_record",
+                        "target_record_id": "test_target_record_id",
+                        # values coming from extra_dict
+                        "params": {
+                            "affinity_group_data": {
+                                "name": "affinity_group_1",
+                                "type": "affinity",
+                                "scope": "nfvi-node",
+                            }
+                        },
+                        "find_params": {},
+                        "depends_on": "test_depends_on",
+                    },
+                },
+            }
+
+            task_index = "task_index_1"
+            self.target_vim.new_affinity_group.return_value = (
+                "sample_affinity_group_id_1"
+            )
+            result = instance.new(ro_task, task_index, self.task_depends)
+            self.assertEqual(result[0], "DONE")
+            self.assertEqual(result[1].get("vim_id"), "sample_affinity_group_id_1")
+            self.assertEqual(result[1].get("created"), True)
+            self.assertEqual(result[1].get("vim_status"), "DONE")
+
+    def test__new_affinity_group_failed(self):
+        """
+        create affinity group with no attributes set in params
+        """
+        db = "test_db"
+        logger = "test_logger"
+        my_vims = "test-vim"
+        db_vims = {
+            0: {
+                "config": {},
+            },
+        }
+
+        instance = VimInteractionAffinityGroup(db, logger, my_vims, db_vims)
+        with patch.object(instance, "my_vims", [self.target_vim]), patch.object(
+            instance, "logger", logging
+        ), patch.object(instance, "db_vims", db_vims):
+            ro_task = {
+                "target_id": 0,
+                "tasks": {
+                    "task_index_2": {
+                        "target_id": 0,
+                        "action_id": "123456",
+                        "nsr_id": "654321",
+                        "task_id": "123456:1",
+                        "status": "SCHEDULED",
+                        "action": "CREATE",
+                        "item": "test_item",
+                        "target_record": "test_target_record",
+                        "target_record_id": "test_target_record_id",
+                        # values coming from extra_dict
+                        "params": {},
+                        "find_params": {},
+                        "depends_on": "test_depends_on",
+                    },
+                },
+            }
+
+            task_index = "task_index_2"
+            self.target_vim.new_affinity_group.return_value = (
+                "sample_affinity_group_id_1"
+            )
+            result = instance.new(ro_task, task_index, self.task_depends)
+            self.assertEqual(result[0], "DONE")
+            self.assertEqual(result[1].get("vim_id"), None)
+            self.assertEqual(result[1].get("created"), False)
+            self.assertEqual(result[1].get("vim_status"), "DONE")
+
+    def test__delete_affinity_group_ok(self):
+        """
+        delete affinity group with a proper vim_id
+        """
+        db = "test_db"
+        logger = "test_logger"
+        my_vims = "test-vim"
+        db_vims = {
+            0: {
+                "config": {},
+            },
+        }
+
+        instance = VimInteractionAffinityGroup(db, logger, my_vims, db_vims)
+        with patch.object(instance, "my_vims", [self.target_vim]), patch.object(
+            instance, "logger", logging
+        ), patch.object(instance, "db_vims", db_vims):
+            ro_task = {
+                "target_id": 0,
+                "tasks": {
+                    "task_index_3": {
+                        "target_id": 0,
+                        "task_id": "123456:1",
+                    },
+                },
+                "vim_info": {
+                    "created": False,
+                    "created_items": None,
+                    "vim_id": "sample_affinity_group_id_3",
+                    "vim_name": "sample_affinity_group_id_3",
+                    "vim_status": None,
+                    "vim_details": "some-details",
+                    "refresh_at": None,
+                },
+            }
+
+            task_index = "task_index_3"
+            self.target_vim.delete_affinity_group.return_value = (
+                "sample_affinity_group_id_3"
+            )
+            result = instance.delete(ro_task, task_index)
+            self.assertEqual(result[0], "DONE")
+            self.assertEqual(result[1].get("vim_details"), "DELETED")
+            self.assertEqual(result[1].get("created"), False)
+            self.assertEqual(result[1].get("vim_status"), "DELETED")
+
+    def test__delete_affinity_group_failed(self):
+        """
+        delete affinity group with missing vim_id
+        """
+        db = "test_db"
+        logger = "test_logger"
+        my_vims = "test-vim"
+        db_vims = {
+            0: {
+                "config": {},
+            },
+        }
+
+        instance = VimInteractionAffinityGroup(db, logger, my_vims, db_vims)
+        with patch.object(instance, "my_vims", [self.target_vim]), patch.object(
+            instance, "logger", logging
+        ), patch.object(instance, "db_vims", db_vims):
+            ro_task = {
+                "target_id": 0,
+                "tasks": {
+                    "task_index_4": {
+                        "target_id": 0,
+                        "task_id": "123456:1",
+                    },
+                },
+                "vim_info": {
+                    "created": False,
+                    "created_items": None,
+                    "vim_id": None,
+                    "vim_name": None,
+                    "vim_status": None,
+                    "vim_details": "some-details",
+                    "refresh_at": None,
+                },
+            }
+
+            task_index = "task_index_4"
+            self.target_vim.delete_affinity_group.return_value = ""
+            result = instance.delete(ro_task, task_index)
+            self.assertEqual(result[0], "DONE")
+            self.assertEqual(result[1].get("vim_details"), "DELETED")
+            self.assertEqual(result[1].get("created"), False)
+            self.assertEqual(result[1].get("vim_status"), "DELETED")
index 91ea613..ca8cbc2 100644 (file)
@@ -107,6 +107,7 @@ deploy_schema = {
                 "vld": deploy_item_list,
             },
         },
+        "affinity-or-anti-affinity-group": deploy_item_list,
     },
     "additionalProperties": False,
 }
index 77adecd..59b4a50 100644 (file)
@@ -767,6 +767,7 @@ class vimconnector(vimconn.VimConnector):
         start,
         image_id,
         flavor_id,
+        affinity_group_list,
         net_list,
         cloud_config=None,
         disk_list=None,
index 967eb3d..d4ef953 100755 (executable)
@@ -865,6 +865,7 @@ class vimconnector(vimconn.VimConnector):
         start,
         image_id,
         flavor_id,
+        affinity_group_list,
         net_list,
         cloud_config=None,
         disk_list=None,
index f8c8145..0a4ead0 100644 (file)
@@ -864,6 +864,7 @@ class vimconnector(vimconn.VimConnector):
         start,
         image_id=None,  # <image project>:(image|image-family):<image/family id>
         flavor_id=None,
+        affinity_group_list=None,
         net_list=None,
         cloud_config=None,
         disk_list=None,
index 1083e39..a15a53c 100644 (file)
@@ -1681,6 +1681,7 @@ class vimconnector(vimconn.VimConnector):
         start,
         image_id,
         flavor_id,
+        affinity_group_list,
         net_list,
         cloud_config=None,
         disk_list=None,
@@ -1690,7 +1691,9 @@ class vimconnector(vimconn.VimConnector):
         """Adds a VM instance to VIM
         Params:
             start: indicates if VM must start or boot in pause mode. Ignored
-            image_id,flavor_id: iamge and flavor uuid
+            image_id,flavor_id: image and flavor uuid
+            affinity_group_list: list of affinity groups, each one is a dictionary.
+                Ignore if empty.
             net_list: list of interfaces, each one is a dictionary with:
                 name:
                 net_id: network uuid to connect
@@ -1915,10 +1918,19 @@ class vimconnector(vimconn.VimConnector):
                 availability_zone_index, availability_zone_list
             )
 
+            # Manage affinity groups/server groups
+            server_group_id = None
+            scheduller_hints = {}
+
+            if affinity_group_list:
+                # Only first id on the list will be used. Openstack restriction
+                server_group_id = affinity_group_list[0]["affinity_group_id"]
+                scheduller_hints["group"] = server_group_id
+
             self.logger.debug(
                 "nova.servers.create({}, {}, {}, nics={}, security_groups={}, "
                 "availability_zone={}, key_name={}, userdata={}, config_drive={}, "
-                "block_device_mapping={})".format(
+                "block_device_mapping={}, server_group={})".format(
                     name,
                     image_id,
                     flavor_id,
@@ -1929,6 +1941,7 @@ class vimconnector(vimconn.VimConnector):
                     userdata,
                     config_drive,
                     block_device_mapping,
+                    server_group_id,
                 )
             )
             server = self.nova.servers.create(
@@ -1943,6 +1956,7 @@ class vimconnector(vimconn.VimConnector):
                 userdata=userdata,
                 config_drive=config_drive,
                 block_device_mapping=block_device_mapping,
+                scheduler_hints=scheduller_hints,
             )  # , description=description)
 
             vm_start_time = time.time()
@@ -3444,3 +3458,60 @@ class vimconnector(vimconn.VimConnector):
             classification_dict[classification_id] = classification
 
         return classification_dict
+
+    def new_affinity_group(self, affinity_group_data):
+        """Adds a server group to VIM
+            affinity_group_data contains a dictionary with information, keys:
+                name: name in VIM for the server group
+                type: affinity or anti-affinity
+                scope: Only nfvi-node allowed
+        Returns the server group identifier"""
+        self.logger.debug("Adding Server Group '%s'", str(affinity_group_data))
+
+        try:
+            name = affinity_group_data["name"]
+            policy = affinity_group_data["type"]
+
+            self._reload_connection()
+            new_server_group = self.nova.server_groups.create(name, policy)
+
+            return new_server_group.id
+        except (
+            ksExceptions.ClientException,
+            nvExceptions.ClientException,
+            ConnectionError,
+            KeyError,
+        ) as e:
+            self._format_exception(e)
+
+    def get_affinity_group(self, affinity_group_id):
+        """Obtain server group details from the VIM. Returns the server group detais as a dict"""
+        self.logger.debug("Getting flavor '%s'", affinity_group_id)
+        try:
+            self._reload_connection()
+            server_group = self.nova.server_groups.find(id=affinity_group_id)
+
+            return server_group.to_dict()
+        except (
+            nvExceptions.NotFound,
+            nvExceptions.ClientException,
+            ksExceptions.ClientException,
+            ConnectionError,
+        ) as e:
+            self._format_exception(e)
+
+    def delete_affinity_group(self, affinity_group_id):
+        """Deletes a server group from the VIM. Returns the old affinity_group_id"""
+        self.logger.debug("Getting server group '%s'", affinity_group_id)
+        try:
+            self._reload_connection()
+            self.nova.server_groups.delete(affinity_group_id)
+
+            return affinity_group_id
+        except (
+            nvExceptions.NotFound,
+            ksExceptions.ClientException,
+            nvExceptions.ClientException,
+            ConnectionError,
+        ) as e:
+            self._format_exception(e)
index 79a5c7b..e88cecc 100644 (file)
@@ -953,6 +953,7 @@ class vimconnector(vimconn.VimConnector):
         start,
         image_id,
         flavor_id,
+        affinity_group_list,
         net_list,
         cloud_config=None,
         disk_list=None,
index 6756ef4..0a220d6 100644 (file)
@@ -1907,6 +1907,7 @@ class vimconnector(vimconn.VimConnector):
         start=False,
         image_id=None,
         flavor_id=None,
+        affinity_group_list=[],
         net_list=[],
         cloud_config=None,
         disk_list=None,
index f003ba9..6c59607 100644 (file)
@@ -348,6 +348,7 @@ class VimDummyConnector(vimconn.VimConnector):
         start,
         image_id,
         flavor_id,
+        affinity_group_list,
         net_list,
         cloud_config=None,
         disk_list=None,
index b4e936c..0b78013 100644 (file)
@@ -528,6 +528,29 @@ class VimConnector:
         """
         raise VimConnNotImplemented("Should have implemented this")
 
+    def get_affinity_group(self, affinity_group_id):
+        """Obtain affinity or anti affinity group details from the VIM
+        Returns the flavor dict details {'id':<>, 'name':<>, other vim specific }
+        Raises an exception upon error or if not found
+        """
+        raise VimConnNotImplemented("Should have implemented this")
+
+    def new_affinity_group(self, affinity_group_data):
+        """Adds an affinity or anti affinity group to VIM
+            affinity_group_data contains a dictionary with information, keys:
+                name: name in VIM for the affinity or anti-affinity group
+                type: affinity or anti-affinity
+                scope: Only nfvi-node allowed
+        Returns the affinity or anti affinity group identifier
+        """
+        raise VimConnNotImplemented("Should have implemented this")
+
+    def delete_affinity_group(self, affinity_group_id):
+        """Deletes an affinity or anti affinity group from the VIM identified by its id
+        Returns the used id or raise an exception
+        """
+        raise VimConnNotImplemented("Should have implemented this")
+
     def new_image(self, image_dict):
         """Adds a tenant image to VIM
         Returns the image id or raises an exception if failed
@@ -566,6 +589,7 @@ class VimConnector:
         start,
         image_id,
         flavor_id,
+        affinity_group_list,
         net_list,
         cloud_config=None,
         disk_list=None,
@@ -576,6 +600,8 @@ class VimConnector:
         Params:
             'start': (boolean) indicates if VM must start or created in pause mode.
             'image_id','flavor_id': image and flavor VIM id to use for the VM
+            'affinity_group_list': list of affinity groups, each one is a dictionary.
+                Ignore if empty.
             'net_list': list of interfaces, each one is a dictionary with:
                 'name': (optional) name for the interface.
                 'net_id': VIM network id where this interface must be connect to. Mandatory for type==virtual
diff --git a/releasenotes/notes/feature_10906_anti-affinity-00437de83b4f71e0.yaml b/releasenotes/notes/feature_10906_anti-affinity-00437de83b4f71e0.yaml
new file mode 100644 (file)
index 0000000..47d8a77
--- /dev/null
@@ -0,0 +1,22 @@
+#######################################################################################
+# Copyright ETSI Contributors and Others.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#######################################################################################
+---
+features:
+  - |
+    Feature 10906: Support for Anti-Affinity groups
+    Support of affinity and anti-affinity groups for Openstack based VIMs (server groups)
+    Allowed at VNF level. Only nfvi-node scope allowed by an Openstack VIM.