Repository: arenadata/adcm (language: python)

Issue:
ADCM-2746 - Add checkbox in group-config for activatable type
https://arenadata.atlassian.net/browse/ADCM-2746


Discussion / hints:

Fix patch:
diff --git a/python/api/group_config/serializers.py b/python/api/group_config/serializers.py
index 84060a39ad..29fe6caec4 100644
--- a/python/api/group_config/serializers.py
+++ b/python/api/group_config/serializers.py
@@ -10,8 +10,6 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from collections.abc import Mapping
-
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.db.transaction import atomic
@@ -25,10 +23,9 @@
     MultiHyperlinkedIdentityField,
     UIConfigField,
 )
-from cm.adcm_config import config_is_ro, ui_config
+from cm.adcm_config import ui_config
 from cm.api import update_obj_config
 from cm.errors import AdcmEx
-from cm.logger import log
 from cm.models import GroupConfig, Host, ObjectConfig, ConfigLog
 
 
@@ -256,46 +253,6 @@ class Meta:
         fields = ('id', 'date', 'description', 'config', 'attr', 'url')
         extra_kwargs = {'config': {'required': True}}
 
-    def validate(self, attrs):
-        def check_value_unselected_field(cc, nc, gk, spec, obj):
-            """
-            Check value unselected field
-
-            :param cc: Current config
-            :param nc: New config
-            :param gk: group_keys from attr
-            :param spec: Config specification
-            :param obj: Parent object (Cluster, Service, Component Provider or Host)
-            """
-            for k, v in gk.items():
-                if isinstance(v, Mapping):
-                    check_value_unselected_field(cc[k], nc[k], gk[k], spec[k]['fields'], obj)
-                else:
-                    if spec[k]['type'] in ['list', 'map', 'string']:
-                        if config_is_ro(obj, k, spec[k]['limits']) or (
-                            k in cc and k in nc and bool(cc[k]) is False and nc[k] is None
-                        ):
-                            continue
-
-                    if not v and k in cc and k in nc and cc[k] != nc[k]:
-                        msg = (
-                            f"Value of {k} field is different in current and new config."
-                            f" Current: ({cc[k]}),new: ({nc[k]})"
-                        )
-                        log.info(msg)
-                        raise AdcmEx('GROUP_CONFIG_CHANGE_UNSELECTED_FIELD', msg)
-
-        obj_ref = self.context['obj_ref']
-        config_spec = obj_ref.object.get_config_spec()
-        parent_obj = obj_ref.object.object
-        current_config = ConfigLog.objects.get(id=obj_ref.current).config
-        new_config = attrs.get('config')
-        group_keys = attrs.get('attr', {}).get('group_keys', {})
-        check_value_unselected_field(
-            current_config, new_config, group_keys, config_spec, parent_obj
-        )
-        return super().validate(attrs)
-
     @atomic
     def create(self, validated_data):
         object_config = self.context.get('obj_ref')
diff --git a/python/cm/adcm_config.py b/python/cm/adcm_config.py
index 8bde1cde7a..3ae1c0ea98 100644
--- a/python/cm/adcm_config.py
+++ b/python/cm/adcm_config.py
@@ -10,10 +10,13 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import collections
+# pylint: disable=too-many-lines
+
 import copy
 import json
 import os
+from collections import OrderedDict
+from collections.abc import Mapping
 from typing import Any, Tuple, Optional
 
 import yspec.checker
@@ -80,6 +83,28 @@ def to_flat_dict(conf, spec):
     return flat
 
 
+def group_keys_to_flat(origin: dict, spec: dict):
+    """
+    Convert `group_keys` and `custom_group_keys` to flat structure as `<field>/`
+     and `<group>/<field>`
+    """
+    result = {}
+    for k, v in origin.items():
+        if isinstance(v, Mapping):
+            key = f'{k}/'
+            if key in spec and spec[key].type != 'group':
+                result[key] = v
+            else:
+                if 'fields' not in v or 'value' not in origin[k]:
+                    err('ATTRIBUTE_ERROR', 'invalid format `group_keys` field')
+                result[key] = v['value']
+                for _k, _v in origin[k]['fields'].items():
+                    result[f'{k}/{_k}'] = _v
+        else:
+            result[f'{k}/'] = v
+    return result
+
+
 def get_default(c, proto=None):  # pylint: disable=too-many-branches
     value = c.default
     if c.default == '':
@@ -185,7 +210,7 @@ def load_social_auth():
 
 def get_prototype_config(proto: Prototype, action: Action = None) -> Tuple[dict, dict, dict, dict]:
     spec = {}
-    flat_spec = collections.OrderedDict()
+    flat_spec = OrderedDict()
     conf = {}
     attr = {}
     flist = ('default', 'required', 'type', 'limits')
@@ -435,13 +460,14 @@ def group_is_activatable(spec):
     return False
 
 
-def ui_config(obj, cl):
+def ui_config(obj, cl):  # pylint: disable=too-many-locals
     conf = []
     _, spec, _, _ = get_prototype_config(obj.prototype)
     obj_conf = cl.config
     obj_attr = cl.attr
     flat_conf = to_flat_dict(obj_conf, spec)
-    flat_group_keys = to_flat_dict(obj_attr.get('group_keys', {}), spec)
+    group_keys = obj_attr.get('group_keys', {})
+    custom_group_keys = obj_attr.get('custom_group_keys', {})
     slist = ('name', 'subname', 'type', 'description', 'display_name', 'required')
     for key in spec:
         item = obj_to_dict(spec[key], slist)
@@ -460,11 +486,19 @@ def ui_config(obj, cl):
             item['value'] = flat_conf[key]
         else:
             item['value'] = get_default(spec[key], obj.prototype)
-        if flat_group_keys:
+        if group_keys:
             if spec[key].type == 'group':
-                item['group'] = any((v for k, v in flat_group_keys.items() if k.startswith(key)))
+                k = key.split('/')[0]
+                item['group'] = group_keys[k]['value']
+                item['custom_group'] = custom_group_keys[k]['value']
             else:
-                item['group'] = flat_group_keys[key]
+                k1, k2 = key.split('/')
+                if k2:
+                    item['group'] = group_keys[k1]['fields'][k2]
+                    item['custom_group'] = custom_group_keys[k1]['fields'][k2]
+                else:
+                    item['group'] = group_keys[k1]
+                    item['custom_group'] = custom_group_keys[k1]
         conf.append(item)
     return conf
 
@@ -554,70 +588,125 @@ def restore_read_only(obj, spec, conf, old_conf):  # # pylint: disable=too-many-
     return conf
 
 
-def check_json_config(proto, obj, new_conf, old_conf=None, attr=None):
+def check_json_config(
+    proto, obj, new_config, current_config=None, new_attr=None, current_attr=None
+):
     spec, flat_spec, _, _ = get_prototype_config(proto)
-    check_attr(proto, obj, attr, flat_spec)
-    cm.variant.process_variant(obj, spec, new_conf)
-    return check_config_spec(proto, obj, spec, flat_spec, new_conf, old_conf, attr)
+    check_attr(proto, obj, new_attr, flat_spec)
+    if isinstance(obj, GroupConfig):
+        config_spec = obj.get_config_spec()
+        group_keys = new_attr.get('group_keys', {})
+        check_value_unselected_field(
+            current_config, new_config, current_attr, new_attr, group_keys, config_spec, obj.object
+        )
+    cm.variant.process_variant(obj, spec, new_config)
+    return check_config_spec(proto, obj, spec, flat_spec, new_config, current_config, new_attr)
 
 
-def check_structure_for_group_attr(group_attr, spec, key_name):
+def check_structure_for_group_attr(group_keys, spec, key_name):
     """Check structure for `group_keys` and `custom_group_keys` field in attr"""
-    flat_group_attr = to_flat_dict(group_attr, spec)
+    flat_group_attr = group_keys_to_flat(group_keys, spec)
     for key, value in flat_group_attr.items():
         if key not in spec:
-            msg = 'invalid field in `{}`'
-            err('ATTRIBUTE_ERROR', msg.format(key_name))
-        if not isinstance(value, bool):
-            msg = 'invalid type `{}` field in `{}`'
-            err('ATTRIBUTE_ERROR', msg.format(key, key_name))
+            err('ATTRIBUTE_ERROR', f'invalid `{key}` field in `{key_name}`')
+        if spec[key].type == 'group':
+            if not (
+                isinstance(value, bool)
+                and 'activatable' in spec[key].limits
+                or value is None
+                and 'activatable' not in spec[key].limits
+            ):
+                err('ATTRIBUTE_ERROR', f'invalid type `value` field in `{key}`')
+        else:
+            if not isinstance(value, bool):
+                err('ATTRIBUTE_ERROR', f'invalid type `{key}` field in `{key_name}`')
     for key, value in spec.items():
         if value.type != 'group':
             if key not in flat_group_attr:
-                msg = f'there is no `{key}` field in `{key_name}`'
-                err('ATTRIBUTE_ERROR', msg)
+                err('ATTRIBUTE_ERROR', f'there is no `{key}` field in `{key_name}`')
     return flat_group_attr
 
 
-def check_custom_group_keys_attr(proto, custom_group_keys, spec):
-    """Check `custom_group_keys` field in attr"""
-    flat_custom_group_keys = check_structure_for_group_attr(
-        custom_group_keys, spec, 'custom_group_keys'
-    )
-    for key, value in flat_custom_group_keys.items():
-        group_customization = spec[key].group_customization
-        if group_customization is None:
-            group_customization = proto.config_group_customization
-        if value != group_customization:
-            msg = '`custom_group_keys` field cannot be changed, read-only'
-            err('ATTRIBUTE_ERROR', msg)
-
-
 def check_agreement_group_attr(group_keys, custom_group_keys, spec):
     """Check agreement group_keys and custom_group_keys"""
-    flat_group_keys = to_flat_dict(group_keys, spec)
-    flat_custom_group_keys = to_flat_dict(custom_group_keys, spec)
+    flat_group_keys = group_keys_to_flat(group_keys, spec)
+    flat_custom_group_keys = group_keys_to_flat(custom_group_keys, spec)
     for key, value in flat_custom_group_keys.items():
         if not value and flat_group_keys[key]:
-            msg = f'the `{key}` parameter cannot be included in the group'
-            err('ATTRIBUTE_ERROR', msg)
+            err('ATTRIBUTE_ERROR', f'the `{key}` field cannot be included in the group')
+
+
+def check_value_unselected_field(
+    current_config, new_config, current_attr, new_attr, group_keys, spec, obj
+):
+    """
+    Check value unselected field
+
+    :param current_config: Current config
+    :param new_config: New config
+    :param current_attr: Current attr
+    :param new_attr: New attr
+    :param group_keys: group_keys from attr
+    :param spec: Config specification
+    :param obj: Parent object (Cluster, Service, Component Provider or Host)
+    """
+    for k, v in group_keys.items():
+        if isinstance(v, Mapping):
+            if (
+                'activatable' in spec[k]['limits']
+                and not v['value']
+                and current_attr[k]['active'] != new_attr[k]['active']
+            ):
+                msg = (
+                    f"Value of `{k}` activatable group is different in current and new attr."
+                    f" Current: ({current_attr[k]['active']}), New: ({new_attr[k]['active']})"
+                )
+                log.info(msg)
+                err('GROUP_CONFIG_CHANGE_UNSELECTED_FIELD', msg)
+            check_value_unselected_field(
+                current_config[k],
+                new_config[k],
+                current_attr,
+                new_attr,
+                group_keys[k]['fields'],
+                spec[k]['fields'],
+                obj,
+            )
+        else:
+            if spec[k]['type'] in ['list', 'map', 'string']:
+                if config_is_ro(obj, k, spec[k]['limits']) or (
+                    k in current_config
+                    and k in new_config
+                    and bool(current_config[k]) is False
+                    and new_config[k] is None
+                ):
+                    continue
 
+            if (
+                not v
+                and k in current_config
+                and k in new_config
+                and current_config[k] != new_config[k]
+            ):
+                msg = (
+                    f"Value of `{k}` field is different in current and new config."
+                    f" Current: ({current_config[k]}), New: ({new_config[k]})"
+                )
+                log.info(msg)
+                err('GROUP_CONFIG_CHANGE_UNSELECTED_FIELD', msg)
 
-def check_group_keys_attr(attr, spec, proto):
+
+def check_group_keys_attr(attr, spec, group_config):
     """Check attr for group config"""
-    if 'group_keys' not in attr or 'custom_group_keys' not in attr:
-        err('ATTRIBUTE_ERROR', "Attr must contain 'group_keys' and 'custom_group_keys' keys")
+    if 'group_keys' not in attr:
+        err('ATTRIBUTE_ERROR', "`attr` must contain 'group_keys' key")
     group_keys = attr.get('group_keys')
-    custom_group_keys = attr.get('custom_group_keys')
+    _, custom_group_keys = group_config.create_group_keys(group_config.get_config_spec())
     check_structure_for_group_attr(group_keys, spec, 'group_keys')
-    check_custom_group_keys_attr(proto, custom_group_keys, spec)
     check_agreement_group_attr(group_keys, custom_group_keys, spec)
 
 
 def check_attr(proto, obj, attr, spec):  # pylint: disable=too-many-branches
-    # TODO: refactor this func
-    if not attr:
-        return
     is_group_config = False
     if isinstance(obj, GroupConfig):
         is_group_config = True
@@ -625,32 +714,36 @@ def check_attr(proto, obj, attr, spec):  # pylint: disable=too-many-branches
     ref = proto_ref(proto)
     allowed_key = ('active',)
     if not isinstance(attr, dict):
-        err('ATTRIBUTE_ERROR', 'Attr should be a map')
-    if is_group_config:
-        check_group_keys_attr(attr, spec, proto)
+        err('ATTRIBUTE_ERROR', '`attr` should be a map')
     for key, value in attr.items():
         if key in ['group_keys', 'custom_group_keys']:
             if not is_group_config:
-                msg = 'Not allowed key "{}" for object ({})'
-                err('ATTRIBUTE_ERROR', msg.format(key, ref))
+                err('ATTRIBUTE_ERROR', f'not allowed key `{key}` for object ({ref})')
             continue
         if key + '/' not in spec:
-            msg = 'There isn\'t group "{}" in config ({})'
-            err('ATTRIBUTE_ERROR', msg.format(key, ref))
+            err('ATTRIBUTE_ERROR', f'there isn\'t `{key}` group in the config ({ref})')
         if spec[key + '/'].type != 'group':
-            msg = 'Config key "{}" is not a group ({})'
-            err('ATTRIBUTE_ERROR', msg.format(key, ref))
-        if not isinstance(value, dict):
-            msg = 'Value of attribute "{}" should be a map ({})'
-            err('ATTRIBUTE_ERROR', msg.format(key, ref))
-        for attr_key in value:
-            if attr_key not in allowed_key:
-                msg = 'Not allowed key "{}" of attribute "{}" ({})'
-                err('ATTRIBUTE_ERROR', msg.format(attr_key, key, ref))
-        if 'active' in value:
-            if not isinstance(value['active'], bool):
-                msg = 'Value of key "{}" of attribute "{}" should be boolean ({})'
-                err('ATTRIBUTE_ERROR', msg.format('active', key, ref))
+            err('ATTRIBUTE_ERROR', f'config key `{key}` is not a group ({ref})')
+    for value in spec.values():
+        key = value.name
+        if value.type == 'group' and 'activatable' in value.limits:
+            if key not in attr:
+                err('ATTRIBUTE_ERROR', f'there isn\'t `{key}` group in the `attr`')
+            if not isinstance(attr[key], dict):
+                err('ATTRIBUTE_ERROR', f'value of attribute `{key}` should be a map ({ref})')
+            for attr_key in attr[key]:
+                if attr_key not in allowed_key:
+                    err(
+                        'ATTRIBUTE_ERROR',
+                        f'not allowed key `{attr_key}` of attribute `{key}` ({ref})',
+                    )
+                if not isinstance(attr[key]['active'], bool):
+                    err(
+                        'ATTRIBUTE_ERROR',
+                        f'value of key `active` of attribute `{key}` should be boolean ({ref})',
+                    )
+    if is_group_config:
+        check_group_keys_attr(attr, spec, obj)
 
 
 def check_config_spec(
diff --git a/python/cm/api.py b/python/cm/api.py
index e55fd1a852..d9c3f65dbe 100644
--- a/python/cm/api.py
+++ b/python/cm/api.py
@@ -493,10 +493,7 @@ def update_obj_config(obj_conf, conf, attr, desc=''):
     else:
         proto = obj.prototype
     old_conf = ConfigLog.objects.get(obj_ref=obj_conf, id=obj_conf.current)
-    if not attr:
-        if old_conf.attr:
-            attr = old_conf.attr
-    new_conf = check_json_config(proto, group or obj, conf, old_conf.config, attr)
+    new_conf = check_json_config(proto, group or obj, conf, old_conf.config, attr, old_conf.attr)
     with transaction.atomic():
         cl = save_obj_config(obj_conf, new_conf, attr, desc)
         cm.issue.update_hierarchy_issues(obj)
diff --git a/python/cm/migrations/0088_fix_group_keys_structure.py b/python/cm/migrations/0088_fix_group_keys_structure.py
new file mode 100644
index 0000000000..579023816f
--- /dev/null
+++ b/python/cm/migrations/0088_fix_group_keys_structure.py
@@ -0,0 +1,130 @@
+# 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.
+
+# Generated by Django 3.2.13 on 2022-05-20 12:52
+
+from typing import Dict
+
+from django.db import migrations, models
+
+
+def create_group_keys(
+    config_spec: dict,
+    group_keys: Dict[str, bool] = None,
+    custom_group_keys: Dict[str, bool] = None,
+):
+    """
+    Returns a map of fields that are included in a group,
+    as well as a map of fields that cannot be included in a group
+    """
+    if group_keys is None:
+        group_keys = {}
+    if custom_group_keys is None:
+        custom_group_keys = {}
+    for k, v in config_spec.items():
+        if v['type'] == 'group':
+            value = None
+            if 'activatable' in v['limits']:
+                value = False
+            group_keys.setdefault(k, {'value': value, 'fields': {}})
+            custom_group_keys.setdefault(k, {'value': v['group_customization'], 'fields': {}})
+            create_group_keys(v['fields'], group_keys[k]['fields'], custom_group_keys[k]['fields'])
+        else:
+            group_keys[k] = False
+            custom_group_keys[k] = v['group_customization']
+    return group_keys, custom_group_keys
+
+
+def get_config_spec(apps, group):
+    """Return spec for config"""
+
+    PrototypeConfig = apps.get_model('cm', 'PrototypeConfig')
+    Cluster = apps.get_model('cm', 'Cluster')
+    ClusterObject = apps.get_model('cm', 'ClusterObject')
+    ServiceComponent = apps.get_model('cm', 'ServiceComponent')
+    HostProvider = apps.get_model('cm', 'HostProvider')
+    spec = {}
+    object_type = group.object_type.model
+    if object_type == 'cluster':
+        obj = Cluster.objects.get(id=group.object_id)
+    elif object_type == 'clusterobject':
+        obj = ClusterObject.objects.get(id=group.object_id)
+    elif object_type == 'servicecomponent':
+        obj = ServiceComponent.objects.get(id=group.object_id)
+    elif object_type == 'hostprovider':
+        obj = HostProvider.objects.get(id=group.object_id)
+    else:
+        raise models.ObjectDoesNotExist
+    for field in PrototypeConfig.objects.filter(
+        prototype=obj.prototype, action__isnull=True
+    ).order_by('id'):
+        group_customization = field.group_customization
+        if group_customization is None:
+            group_customization = obj.prototype.config_group_customization
+        field_spec = {
+            'type': field.type,
+            'group_customization': group_customization,
+            'limits': field.limits,
+        }
+        if field.subname == '':
+            if field.type == 'group':
+                field_spec.update({'fields': {}})
+            spec[field.name] = field_spec
+        else:
+            spec[field.name]['fields'][field.subname] = field_spec
+    return spec
+
+
+def fix_group_keys(group_keys, spec):
+    """Fix `group_keys` structure"""
+
+    correct_group_keys = {}
+    for field, info in spec.items():
+        if info['type'] == 'group':
+            correct_group_keys[field] = {}
+            if 'activatable' in info['limits']:
+                correct_group_keys[field]['value'] = False
+            else:
+                correct_group_keys[field]['value'] = None
+            correct_group_keys[field]['fields'] = {}
+            for key in info['fields'].keys():
+                correct_group_keys[field]['fields'][key] = group_keys[field][key]
+        else:
+            correct_group_keys[field] = group_keys[field]
+    return correct_group_keys
+
+
+def fix_group_keys_structure(apps, schema_editor):
+    """Fix `group_keys` structure for `group-config`"""
+
+    GroupConfig = apps.get_model('cm', 'GroupConfig')
+    ConfigLog = apps.get_model('cm', 'ConfigLog')
+
+    for group in GroupConfig.objects.all():
+        if group.config is not None:
+            spec = get_config_spec(apps, group)
+            _, custom_group_keys = create_group_keys(spec)
+            current_config_log = ConfigLog.objects.get(id=group.config.current)
+            group_keys = current_config_log.attr['group_keys']
+            correct_group_keys = fix_group_keys(group_keys, spec)
+            current_config_log.attr['group_keys'] = correct_group_keys
+            current_config_log.attr['custom_group_keys'] = custom_group_keys
+            current_config_log.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cm', '0087_maintenance_mode'),
+    ]
+
+    operations = [migrations.RunPython(fix_group_keys_structure)]
diff --git a/python/cm/models.py b/python/cm/models.py
index cdd84fc1a7..58c22edfe0 100644
--- a/python/cm/models.py
+++ b/python/cm/models.py
@@ -307,6 +307,24 @@ def object(self):
         return None
 
 
+def deep_merge(origin: dict, renovator: Mapping):
+    """
+    Merge renovator into origin
+
+    >>> o = {'a': 1, 'b': {'c': 1, 'd': 1}}
+    >>> r = {'a': 1, 'b': {'c': 2 }}
+    >>> deep_merge(o, r) == {'a': 1, 'b': {'c': 2, 'd': 1}}
+    """
+
+    for key, value in renovator.items():
+        if isinstance(value, Mapping):
+            node = origin.setdefault(key, {})
+            deep_merge(node, value)
+        else:
+            origin[key] = value
+    return origin
+
+
 class ConfigLog(ADCMModel):
     obj_ref = models.ForeignKey(ObjectConfig, on_delete=models.CASCADE)
     config = models.JSONField(default=dict)
@@ -317,22 +335,31 @@ class ConfigLog(ADCMModel):
     __error_code__ = 'CONFIG_NOT_FOUND'
 
     @transaction.atomic()
-    def save(self, *args, **kwargs):  # pylint: disable=too-many-locals
+    def save(self, *args, **kwargs):  # pylint: disable=too-many-locals,too-many-statements
         """Saving config and updating config groups"""
 
-        def update(origin: dict, renovator: dict, group_keys: dict) -> None:
+        def update_config(origin: dict, renovator: dict, group_keys: dict) -> None:
             """
-            Updating the original dictionary with a check for the presence of keys in the original
+            Updating the original config with a check for the presence of keys in the original
             """
             for key, value in group_keys.items():
                 if key in renovator:
                     if isinstance(value, Mapping):
                         origin.setdefault(key, {})
-                        update(origin[key], renovator[key], group_keys[key])
+                        update_config(origin[key], renovator[key], group_keys[key]['fields'])
                     else:
                         if value:
                             origin[key] = renovator[key]
 
+        def update_attr(origin: dict, renovator: dict, group_keys: dict) -> None:
+            """
+            Updating the original config with a check for the presence of keys in the original
+            """
+            for key, value in group_keys.items():
+                if key in renovator and isinstance(value, Mapping):
+                    if value['value'] is not None and value['value']:
+                        origin[key] = renovator[key]
+
         def clean_attr(attrs: dict, spec: dict) -> None:
             """Clean attr after upgrade cluster"""
             extra_fields = []
@@ -345,28 +372,43 @@ def clean_attr(attrs: dict, spec: dict) -> None:
             for field in extra_fields:
                 attrs.pop(field)
 
+        def clean_group_keys(group_keys, spec):
+            """Clean group_keys after update cluster"""
+            correct_group_keys = {}
+            for field, info in spec.items():
+                if info['type'] == 'group':
+                    correct_group_keys[field] = {}
+                    correct_group_keys[field]['value'] = group_keys[field]['value']
+                    correct_group_keys[field]['fields'] = {}
+                    for key in info['fields'].keys():
+                        correct_group_keys[field]['fields'][key] = group_keys[field]['fields'][key]
+                else:
+                    correct_group_keys[field] = group_keys[field]
+            return correct_group_keys
+
         DummyData.objects.filter(id=1).update(date=timezone.now())
         obj = self.obj_ref.object
         if isinstance(obj, (Cluster, ClusterObject, ServiceComponent, HostProvider)):
             # Sync group configs with object config
             for cg in obj.group_config.all():
                 # TODO: We need refactoring for upgrade cluster
-                diff = cg.get_diff_config()
+                diff_config, diff_attr = cg.get_diff_config_attr()
                 group_config = ConfigLog()
                 current_group_config = ConfigLog.objects.get(id=cg.config.current)
                 group_config.obj_ref = cg.config
                 config = deepcopy(self.config)
                 current_group_keys = current_group_config.attr['group_keys']
-                update(config, diff, current_group_keys)
+                update_config(config, diff_config, current_group_keys)
                 group_config.config = config
                 attr = deepcopy(self.attr)
-                attr.update(current_group_config.attr)
+                update_attr(attr, diff_attr, current_group_keys)
                 spec = cg.get_config_spec()
                 group_keys, custom_group_keys = cg.create_group_keys(spec)
-                attr.update({'group_keys': group_keys, 'custom_group_keys': custom_group_keys})
-                update(attr['group_keys'], current_group_keys, current_group_keys)
+                group_keys = deep_merge(group_keys, current_group_keys)
+                group_keys = clean_group_keys(group_keys, spec)
+                attr['group_keys'] = group_keys
+                attr['custom_group_keys'] = custom_group_keys
                 clean_attr(attr, spec)
-                clean_attr(attr['group_keys'], spec)
 
                 group_config.attr = attr
                 group_config.description = current_group_config.description
@@ -789,33 +831,43 @@ def create_group_keys(
             custom_group_keys = {}
         for k, v in config_spec.items():
             if v['type'] == 'group':
-                group_keys.setdefault(k, {})
-                custom_group_keys.setdefault(k, {})
-                self.create_group_keys(v['fields'], group_keys[k], custom_group_keys[k])
+                value = None
+                if 'activatable' in v['limits']:
+                    value = False
+                group_keys.setdefault(k, {'value': value, 'fields': {}})
+                custom_group_keys.setdefault(k, {'value': v['group_customization'], 'fields': {}})
+                self.create_group_keys(
+                    v['fields'], group_keys[k]['fields'], custom_group_keys[k]['fields']
+                )
             else:
                 group_keys[k] = False
                 custom_group_keys[k] = v['group_customization']
         return group_keys, custom_group_keys
 
-    def get_diff_config(self):
-        def get_diff(config, group_keys, diff=None):
-            if diff is None:
-                diff = {}
+    def get_diff_config_attr(self):
+        def get_diff(config, attr, group_keys, diff_config=None, diff_attr=None):
+            if diff_config is None:
+                diff_config = {}
+            if diff_attr is None:
+                diff_attr = {}
             for k, v in group_keys.items():
                 if isinstance(v, Mapping):
-                    diff.setdefault(k, {})
-                    get_diff(config[k], group_keys[k], diff[k])
-                    if not diff[k]:
-                        diff.pop(k)
+                    if v['value'] is not None and v['value']:
+                        diff_attr[k] = attr[k]
+                    diff_config.setdefault(k, {})
+                    get_diff(config[k], attr, group_keys[k]['fields'], diff_config[k], diff_attr)
+                    if not diff_config[k]:
+                        diff_config.pop(k)
                 else:
                     if v:
-                        diff[k] = config[k]
-            return diff
+                        diff_config[k] = config[k]
+            return diff_config, diff_attr
 
         cl = ConfigLog.obj.get(id=self.config.current)
         config = cl.config
+        attr = cl.attr
         group_keys = cl.attr.get('group_keys', {})
-        return get_diff(config, group_keys)
+        return get_diff(config, attr, group_keys)
 
     def get_group_keys(self):
         cl = ConfigLog.objects.get(id=self.config.current)
@@ -829,7 +881,9 @@ def merge_config(self, object_config: dict, group_config: dict, group_keys: dict
         for k, v in group_keys.items():
             if isinstance(v, Mapping):
                 config.setdefault(k, {})
-                self.merge_config(object_config[k], group_config[k], group_keys[k], config[k])
+                self.merge_config(
+                    object_config[k], group_config[k], group_keys[k]['fields'], config[k]
+                )
             else:
                 if v and k in group_config:
                     config[k] = group_config[k]
@@ -838,6 +892,21 @@ def merge_config(self, object_config: dict, group_config: dict, group_keys: dict
                         config[k] = object_config[k]
         return config
 
+    @staticmethod
+    def merge_attr(object_attr: dict, group_attr: dict, group_keys: dict, attr=None):
+        """Merge object attr with group attr based group_keys"""
+
+        if attr is None:
+            attr = {}
+
+        for k, v in group_keys.items():
+            if isinstance(v, Mapping) and k in object_attr:
+                if v['value']:
+                    attr[k] = group_attr[k]
+                else:
+                    attr[k] = object_attr[k]
+        return attr
+
     def get_config_attr(self):
         """Return attr for group config without group_keys and custom_group_keys params"""
         cl = ConfigLog.obj.get(id=self.config.current)
@@ -849,14 +918,14 @@ def get_config_and_attr(self):
 
         object_cl = ConfigLog.objects.get(id=self.object.config.current)
         object_config = object_cl.config
-        attr = deepcopy(object_cl.attr)
+        object_attr = object_cl.attr
         group_cl = ConfigLog.objects.get(id=self.config.current)
         group_config = group_cl.config
         group_keys = group_cl.attr.get('group_keys', {})
         group_attr = self.get_config_attr()
         config = self.merge_config(object_config, group_config, group_keys)
+        attr = self.merge_attr(object_attr, group_attr, group_keys)
         self.preparing_file_type_field(config)
-        attr.update(group_attr)
         return config, attr
 
     def host_candidate(self):
diff --git a/web/src/app/shared/components/actions/master/master.component.spec.ts b/web/src/app/shared/components/actions/master/master.component.spec.ts
index 6d5461e2f5..06d8335877 100644
--- a/web/src/app/shared/components/actions/master/master.component.spec.ts
+++ b/web/src/app/shared/components/actions/master/master.component.spec.ts
@@ -120,7 +120,8 @@ describe('MasterComponent', () => {
                 required: false,
                 description: '',
                 read_only: false,
-                activatable: false
+                activatable: false,
+                group_config: null,
               }
             ]
           },
@@ -173,7 +174,8 @@ describe('MasterComponent', () => {
                 required: false,
                 description: '',
                 read_only: false,
-                activatable: false
+                activatable: false,
+                group_config: null,
               }
             ]
           },
diff --git a/web/src/app/shared/configuration/attributes/attribute.service.ts b/web/src/app/shared/configuration/attributes/attribute.service.ts
index 9e109c06ed..c8da66cc79 100644
--- a/web/src/app/shared/configuration/attributes/attribute.service.ts
+++ b/web/src/app/shared/configuration/attributes/attribute.service.ts
@@ -69,6 +69,10 @@ export class AttributeService {
     return this._attributes.has(name) ? this._attributes.get(name) : undefined;
   }
 
+  groupCheckboxToggle(groupName, value): void {
+    this.attributes.get(ConfigAttributeNames.GROUP_KEYS).value[groupName].value = value
+  }
+
   private _createAttributes(_activeAttributes: Partial<ConfigAttributeNames>[], json: ConfigAttributesJSON, configs: AttributesOptions): Attributes {
     const isEmptyAttrs = !Object.keys(json || {}).length;
     const isActiveAttrsPresent = !!Object.keys(json || {}).filter((x: ConfigAttributeNames) => this._activeAttributes.includes(x)).length;
@@ -108,7 +112,7 @@ export const createFormForAttribute = (fb: FormBuilder, json: ConfigAttributesJS
           [key]: { value, disabled }
         };
       } else if (!isEmptyObject(value)) {
-        return { ...acc, [key]: buildFormGroup(value) };
+        return { ...acc, [key]: buildFormGroup(value['fields']) };
       }
 
     }, {});
diff --git a/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts b/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts
index eb70f3b47f..9ba97db8cb 100644
--- a/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts
+++ b/web/src/app/shared/configuration/attributes/attributes/group-keys/group-keys-wrapper.component.ts
@@ -82,7 +82,7 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
     if (!this.groupControl || !this.parameterControl) return;
 
     path.forEach((part) => {
-      enabled = enabled[part];
+      enabled = enabled[part] || enabled.fields[part];
     });
 
     if (!enabled) {
diff --git a/web/src/app/shared/configuration/fields/fields.component.ts b/web/src/app/shared/configuration/fields/fields.component.ts
index 90c743567d..152dfe486d 100644
--- a/web/src/app/shared/configuration/fields/fields.component.ts
+++ b/web/src/app/shared/configuration/fields/fields.component.ts
@@ -51,6 +51,7 @@ export class ConfigFieldsComponent extends BaseDirective {
     if (!data) return;
     this.rawConfig = data;
     this.dataOptions = this.service.getPanels(data);
+    this.service.getAttrs(data, this.dataOptions.map(a => a.name), this.dataOptions);
     this.form = this.service.toFormGroup(this.dataOptions);
     this.isAdvanced = data.config.some((a) => a.ui_options && a.ui_options.advanced);
     this.shapshot = { ...this.form.value };
diff --git a/web/src/app/shared/configuration/group-fields/group-fields.component.html b/web/src/app/shared/configuration/group-fields/group-fields.component.html
index be66cda4cd..7fd89dc62c 100644
--- a/web/src/app/shared/configuration/group-fields/group-fields.component.html
+++ b/web/src/app/shared/configuration/group-fields/group-fields.component.html
@@ -1,8 +1,19 @@
+<!--group_config?.disabled - if true, group checkbox is disabled-->
+<!--group_config?.checkboxValue - if false, group checkbox is not checked, and switcher is disabled and vice versa-->
 <mat-expansion-panel [expanded]="active" [disabled]="!active" [ngClass]="{ panel: true, advanced: isAdvanced }" #ep>
   <mat-expansion-panel-header>
     <mat-panel-title class="title">
+      <mat-checkbox
+        *ngIf="groupCheckboxExist()"
+        [disabled]="group_config?.disabled"
+        [checked]="group_config?.checkboxValue"
+        (click)="clickGroupCheckbox($event)"></mat-checkbox>
       <span>{{ panel.display_name }}</span>
-      <mat-slide-toggle *ngIf="panel.activatable" [checked]="active" (change)="activeToggle($event)">
+      <mat-slide-toggle
+        *ngIf="panel.activatable"
+        [disabled]="groupCheckboxExist() && !group_config?.checkboxValue"
+        [checked]="active"
+        (change)="activeToggle($event)">
       </mat-slide-toggle>
     </mat-panel-title>
     <mat-panel-description>
diff --git a/web/src/app/shared/configuration/group-fields/group-fields.component.ts b/web/src/app/shared/configuration/group-fields/group-fields.component.ts
index 9c16c86fd1..6696355dcd 100644
--- a/web/src/app/shared/configuration/group-fields/group-fields.component.ts
+++ b/web/src/app/shared/configuration/group-fields/group-fields.component.ts
@@ -14,8 +14,8 @@ import { AbstractControl, FormGroup } from '@angular/forms';
 import { MatExpansionPanel } from '@angular/material/expansion';
 import { MatSlideToggleChange } from '@angular/material/slide-toggle';
 import { FieldService } from '@app/shared/configuration/services/field.service';
-
 import { IFieldOptions, IPanelOptions } from '../types';
+import { AttributeService } from "@app/shared/configuration/attributes/attribute.service";
 
 @Component({
   selector: 'app-group-fields',
@@ -25,35 +25,38 @@ import { IFieldOptions, IPanelOptions } from '../types';
     '.title > mat-slide-toggle {margin-left: 20px;}',
     '.advanced {border: dotted 1px #00e676;}',
     'mat-panel-description {justify-content: flex-end;}',
+    'mat-checkbox {margin-right: 10px;}',
   ],
 })
 export class GroupFieldsComponent implements OnInit {
   active = true;
+  group_config: { [key: string]: boolean };
   @Input() panel: IPanelOptions;
   @Input() form: FormGroup;
   @ViewChild('ep') expanel: MatExpansionPanel;
 
-  constructor(private service: FieldService) {}
+  constructor(private service: FieldService, private attributesSrv: AttributeService) {}
 
   ngOnInit(): void {
     if (this.panel.activatable) this.activatable(this.panel.active);
+    this.group_config = this.panel?.group_config;
   }
 
   get isAdvanced() {
     return this.panel.ui_options && this.panel.ui_options.advanced;
   }
 
-  activeToggle(e: MatSlideToggleChange) {
+  activeToggle(e: MatSlideToggleChange): void {
     this.panel.active = e.checked;
     this.activatable(e.checked);
   }
 
-  activatable(flag: boolean) {
+  activatable(flag: boolean): void {
     this.active = flag;
     this.checkFields(this.active);
   }
 
-  checkFields(flag: boolean) {
+  checkFields(flag: boolean): void {
     this.panel.options
       .filter((a) => !('options' in a))
       .forEach((a: IFieldOptions) => {
@@ -66,7 +69,7 @@ export class GroupFieldsComponent implements OnInit {
       });
   }
 
-  updateValidator(formControl: AbstractControl, flag: boolean, a: IFieldOptions, currentFormControl?: AbstractControl) {
+  updateValidator(formControl: AbstractControl, flag: boolean, a: IFieldOptions, currentFormControl?: AbstractControl): void {
     if (formControl) {
       if (!flag) formControl.clearValidators();
       else formControl.setValidators(this.service.setValidator(a, currentFormControl));
@@ -75,4 +78,15 @@ export class GroupFieldsComponent implements OnInit {
       this.form.updateValueAndValidity();
     }
   }
+
+  groupCheckboxExist() {
+    return this.panel?.type==='group' && this.panel.activatable && this.panel?.group_config?.exist;
+  }
+
+  clickGroupCheckbox(event): void {
+    this.attributesSrv.groupCheckboxToggle(this.panel.name, !this.group_config.checkboxValue);
+    this.panel.value = this.group_config.checkboxValue = !this.group_config.checkboxValue;
+    event.preventDefault();
+    event.stopPropagation();
+  }
 }
diff --git a/web/src/app/shared/configuration/main/config.component.spec.ts b/web/src/app/shared/configuration/main/config.component.spec.ts
index 0ddd034066..f9b37f4f5e 100644
--- a/web/src/app/shared/configuration/main/config.component.spec.ts
+++ b/web/src/app/shared/configuration/main/config.component.spec.ts
@@ -45,6 +45,7 @@ const rawConfig: IConfig = {
       value: 'some string',
       description: '',
       required: false,
+      group_config: null,
     },
     {
       name: 'group',
@@ -57,6 +58,7 @@ const rawConfig: IConfig = {
       value: null,
       description: '',
       required: false,
+      group_config: null,
     },
     {
       name: 'group',
@@ -69,6 +71,7 @@ const rawConfig: IConfig = {
       value: 10,
       description: '',
       required: true,
+      group_config: null,
     },
   ],
 };
diff --git a/web/src/app/shared/configuration/main/config.component.ts b/web/src/app/shared/configuration/main/config.component.ts
index c8136aeb33..c62d824b54 100644
--- a/web/src/app/shared/configuration/main/config.component.ts
+++ b/web/src/app/shared/configuration/main/config.component.ts
@@ -30,7 +30,7 @@ import { catchError, finalize, tap } from 'rxjs/operators';
 import { ConfigFieldsComponent } from '../fields/fields.component';
 import { HistoryComponent } from '../tools/history.component';
 import { ToolsComponent } from '../tools/tools.component';
-import { IConfig } from '../types';
+import { IConfig, IConfigAttr } from '../types';
 import { historyAnime, ISearchParam, MainService } from './main.service';
 import { WorkerInstance } from '@app/core/services/cluster.service';
 import { ActivatedRoute } from '@angular/router';
@@ -151,7 +151,7 @@ export class ConfigComponent extends SocketListenerDirective implements OnChange
       const config = this.service.parseValue(this.fields.form.getRawValue(), this.rawConfig.value.config);
       const send = {
         config,
-        attr: deepmerge(deepmerge(this.rawConfig.getValue().attr, this.fields.attr), this.attributesSrv.rawAttributes()),
+        attr: deepmerge(this.mergeAttrsWithField(), this.fields.attr),
         description: this.tools.description.value,
         obj_ref: this.rawConfig.value.obj_ref
       };
@@ -199,6 +199,24 @@ export class ConfigComponent extends SocketListenerDirective implements OnChange
     this.historyComponent.reset();
   }
 
+  mergeAttrsWithField(): IConfigAttr {
+    const attr = this.rawConfig.getValue().attr;
+    const attrSrv = this.attributesSrv.rawAttributes();
+    Object.keys(attr).forEach(a => {
+      Object.keys(attr[a]).forEach((key) => {
+        if (attrSrv[a] && attrSrv[a][key]) {
+          if (attr[a][key]?.fields) {
+            attr[a][key].fields = attrSrv[a][key];
+          } else {
+            attr[a][key] = attrSrv[a][key];
+          }
+        }
+      });
+    })
+
+    return attr;
+  }
+
 
   private _getConfig(url: string): Observable<IConfig> {
     this.isLoading = true;
diff --git a/web/src/app/shared/configuration/services/field.service.ts b/web/src/app/shared/configuration/services/field.service.ts
index f1dede591d..e2f0717c76 100644
--- a/web/src/app/shared/configuration/services/field.service.ts
+++ b/web/src/app/shared/configuration/services/field.service.ts
@@ -169,6 +169,45 @@ export class FieldService {
       }, []);
   }
 
+  /**
+   * Parse and prepare attrs data for group config from backend
+   */
+  public getAttrs(data: IConfig, groups: string[], dataOptions: TFormOptions[]): void {
+    if (!data?.attr || !groups) return;
+
+    groups.forEach((group) => {
+      let disabled: boolean;
+      const i = dataOptions.findIndex(item => item.name === group);
+      const config = data.config.filter(c => c.name === group && c.type === 'group');
+
+      if (config.length === 0) return;
+
+      if (!data.attr?.custom_group_keys || !data.attr?.group_keys) {
+        dataOptions[i].group_config = {
+          'exist': false,
+        }
+
+        return;
+      }
+
+      if (config[0]?.read_only || !data.attr?.custom_group_keys[group].value) {
+        disabled = true;
+      } else if (data.attr?.custom_group_keys[group].value && data.attr?.group_keys[group].value) {
+        disabled = false;
+      }
+
+      dataOptions[i] = {
+        ...dataOptions[i],
+        active: dataOptions[i]['active'],
+        group_config: {
+          'exist': true,
+          'checkboxValue':  data.attr?.custom_group_keys[group].value && data.attr?.group_keys[group].value,
+          'disabled': disabled
+        }
+      }
+    })
+  }
+
   /**
    * Generate FormGroup
    * @param options
diff --git a/web/src/app/shared/configuration/types.ts b/web/src/app/shared/configuration/types.ts
index cce8de5b91..64596b797b 100644
--- a/web/src/app/shared/configuration/types.ts
+++ b/web/src/app/shared/configuration/types.ts
@@ -85,6 +85,7 @@ export interface IFieldStack {
   description?: string;
   limits?: ILimits;
   ui_options?: IUIoptions;
+  group_config?: { [key: string]: boolean };
 }
 
 /**
@@ -109,8 +110,8 @@ export interface IConfig {
 export interface IConfigAttr {
   [group: string]: { active?: boolean };
 
-  group_keys?: { [key: string]: boolean };
-  custom_group_keys?: { [key: string]: boolean };
+  group_keys?: { [key: string]: { [key: string]: boolean }};
+  custom_group_keys?: { [key: string]: { [key: string]: boolean }};
 }
 
 //#region Modified data for ngForm build
diff --git a/web/src/app/shared/form-elements/field.directive.ts b/web/src/app/shared/form-elements/field.directive.ts
index a5872d0b7c..9c3cbecb9c 100644
--- a/web/src/app/shared/form-elements/field.directive.ts
+++ b/web/src/app/shared/form-elements/field.directive.ts
@@ -21,6 +21,7 @@ import { BaseDirective } from '../directives';
 export class FieldDirective extends BaseDirective implements OnInit {
   @Input() form: FormGroup;
   @Input() field: IFieldOptions;
+  @Input() disabled: boolean
 
   ngOnInit() {
     this.control.markAllAsTouched();


Test patch:
diff --git a/python/cm/unit_tests/test_config_group.py b/python/cm/unit_tests/test_config_group.py
index f09cde67fa..ae39e9c66e 100644
--- a/python/cm/unit_tests/test_config_group.py
+++ b/python/cm/unit_tests/test_config_group.py
@@ -26,7 +26,11 @@ def setUp(self) -> None:
         self.cluster_attr = {'activatable_group': {'active': True}}
         self.cluster = utils.gen_cluster()
         utils.gen_prototype_config(
-            prototype=self.cluster.prototype, name='group', field_type='group', display_name='group'
+            prototype=self.cluster.prototype,
+            name='group',
+            field_type='group',
+            display_name='group',
+            group_customization=True,
         )
         utils.gen_prototype_config(
             prototype=self.cluster.prototype,
@@ -34,12 +38,15 @@ def setUp(self) -> None:
             field_type='string',
             subname='string',
             display_name='string',
+            group_customization=True,
         )
         utils.gen_prototype_config(
             prototype=self.cluster.prototype,
             name='activatable_group',
             field_type='group',
             display_name='activatable_group',
+            limits='{"activatable": true, "active": true}',
+            group_customization=True,
         )
         utils.gen_prototype_config(
             prototype=self.cluster.prototype,
@@ -47,6 +54,7 @@ def setUp(self) -> None:
             field_type='integer',
             subname='integer',
             display_name='integer',
+            group_customization=True,
         )
         self.cluster.config = utils.gen_config(config=self.cluster_config, attr=self.cluster_attr)
         self.cluster.save()
@@ -61,32 +69,45 @@ def test_create_group(self):
         """Test create groups for objects"""
         group = self.create_group('group', self.cluster.id, 'cluster')
         parent_cl = ConfigLog.objects.get(id=self.cluster.config.current)
-        parent_cl.save()
         cl = ConfigLog.objects.get(id=group.config.current)
         self.assertDictEqual(parent_cl.config, cl.config)
         self.assertDictEqual(parent_cl.attr, {'activatable_group': {'active': True}})
         cl_attr = {
             'activatable_group': {'active': True},
-            'group_keys': {'group': {'string': False}, 'activatable_group': {'integer': False}},
+            'group_keys': {
+                'group': {'value': None, 'fields': {'string': False}},
+                'activatable_group': {'value': False, 'fields': {'integer': False}},
+            },
             'custom_group_keys': {
-                'group': {'string': False},
-                'activatable_group': {'integer': False},
+                'group': {'value': True, 'fields': {'string': True}},
+                'activatable_group': {'value': True, 'fields': {'integer': True}},
             },
         }
         self.assertDictEqual(cl.attr, cl_attr)
 
-    def test_get_group_config(self):
-        """Test get_group_config() method"""
+    def test_get_diff_config_attr(self):
+        """Test get_diff_config_attr() method"""
         group = self.create_group('group', self.cluster.id, 'cluster')
-        self.assertDictEqual(group.get_diff_config(), {})
+        diff_config, diff_attr = group.get_diff_config_attr()
+        self.assertDictEqual(diff_config, {})
+        self.assertDictEqual(diff_attr, {})
         cl = ConfigLog.objects.get(id=group.config.current)
         cl.config = {'group': {'string': 'str'}, 'activatable_group': {'integer': 1}}
         cl.attr = {
             'activatable_group': {'active': True},
-            'group_keys': {'group': {'string': True}, 'activatable_group': {'integer': False}},
+            'group_keys': {
+                'group': {'value': None, 'fields': {'string': True}},
+                'activatable_group': {'value': False, 'fields': {'integer': False}},
+            },
+            'custom_group_keys': {
+                'group': {'value': True, 'fields': {'string': True}},
+                'activatable_group': {'value': True, 'fields': {'integer': True}},
+            },
         }
         cl.save()
-        self.assertDictEqual(group.get_diff_config(), {'group': {'string': 'str'}})
+        diff_config, diff_attr = group.get_diff_config_attr()
+        self.assertDictEqual(diff_config, {'group': {'string': 'str'}})
+        self.assertDictEqual(diff_attr, {})
 
     def test_get_config_spec(self):
         """Test get_config_spec() method"""
@@ -94,24 +115,24 @@ def test_get_config_spec(self):
         spec = {
             'group': {
                 'type': 'group',
-                'group_customization': False,
+                'group_customization': True,
                 'limits': {},
                 'fields': {
                     'string': {
                         'type': 'string',
-                        'group_customization': False,
+                        'group_customization': True,
                         'limits': {},
                     }
                 },
             },
             'activatable_group': {
                 'type': 'group',
-                'group_customization': False,
-                'limits': {},
+                'group_customization': True,
+                'limits': '{"activatable": true, "active": true}',
                 'fields': {
                     'integer': {
                         'type': 'integer',
-                        'group_customization': False,
+                        'group_customization': True,
                         'limits': {},
                     }
                 },
@@ -148,18 +169,36 @@ def test_create_group_keys(self):
             group_customization=True,
         )
         test_group_keys = {
-            'activatable_group': {'integer': False},
-            'group': {'string': False},
+            'activatable_group': {
+                'value': False,
+                'fields': {'integer': False},
+            },
+            'group': {
+                'value': None,
+                'fields': {'string': False},
+            },
             'level1_1': False,
             'level1_2': False,
-            'level1_3': {'level2_1': False},
+            'level1_3': {
+                'value': None,
+                'fields': {'level2_1': False},
+            },
         }
         test_custom_group_keys = {
-            'activatable_group': {'integer': False},
-            'group': {'string': False},
+            'activatable_group': {
+                'value': True,
+                'fields': {'integer': True},
+            },
+            'group': {
+                'value': True,
+                'fields': {'string': True},
+            },
             'level1_1': True,
             'level1_2': False,
-            'level1_3': {'level2_1': True},
+            'level1_3': {
+                'value': False,
+                'fields': {'level2_1': True},
+            },
         }
         group_keys, custom_group_keys = group.create_group_keys(group.get_config_spec())
         self.assertDictEqual(test_group_keys, group_keys)
@@ -168,17 +207,31 @@ def test_create_group_keys(self):
     def test_update_parent_config(self):
         """Test update parent config for group"""
         group = self.create_group('group', self.cluster.id, 'cluster')
+
         cl = ConfigLog.objects.get(id=group.config.current)
-        cl.config = {'group': {'string': 'str'}, 'activatable_group': {'integer': 1}}
+        parent_cl = ConfigLog.objects.get(id=self.cluster.config.current)
+
+        # change group.string
+
+        cl.config = {
+            'group': {'string': 'str'},
+            'activatable_group': {'integer': 1},
+        }
         cl.attr = {
             'activatable_group': {'active': True},
-            'group_keys': {'group': {'string': True}, 'activatable_group': {'integer': False}},
+            'group_keys': {
+                'group': {'value': None, 'fields': {'string': True}},
+                'activatable_group': {'value': False, 'fields': {'integer': False}},
+            },
+            'custom_group_keys': {
+                'group': {'value': True, 'fields': {'string': True}},
+                'activatable_group': {'value': True, 'fields': {'integer': True}},
+            },
         }
         cl.save()
-        parent_cl = ConfigLog.objects.get(id=self.cluster.config.current)
+
         parent_cl.config = {'group': {'string': 'string'}, 'activatable_group': {'integer': 100}}
         parent_cl.save()
-
         group.refresh_from_db()
         cl = ConfigLog.objects.get(id=group.config.current)
         self.assertDictEqual(
@@ -188,20 +241,53 @@ def test_update_parent_config(self):
         parent_cl.config = {'group': {'string': 'string'}, 'activatable_group': {'integer': 100}}
         parent_cl.attr = {'activatable_group': {'active': False}}
         parent_cl.save()
-
         group.refresh_from_db()
         cl = ConfigLog.objects.get(id=group.config.current)
         self.assertDictEqual(
             cl.config, {'group': {'string': 'str'}, 'activatable_group': {'integer': 100}}
         )
+        self.assertDictEqual(
+            cl.attr,
+            {
+                'activatable_group': {'active': False},
+                'group_keys': {
+                    'group': {'value': None, 'fields': {'string': True}},
+                    'activatable_group': {'value': False, 'fields': {'integer': False}},
+                },
+                'custom_group_keys': {
+                    'group': {'value': True, 'fields': {'string': True}},
+                    'activatable_group': {'value': True, 'fields': {'integer': True}},
+                },
+            },
+        )
+
+        cl.attr = {
+            'activatable_group': {'active': True},
+            'group_keys': {
+                'group': {'value': None, 'fields': {'string': True}},
+                'activatable_group': {'value': True, 'fields': {'integer': False}},
+            },
+            'custom_group_keys': {
+                'group': {'value': True, 'fields': {'string': True}},
+                'activatable_group': {'value': True, 'fields': {'integer': True}},
+            },
+        }
+        cl.save()
+        parent_cl.attr = {'activatable_group': {'active': False}}
+        parent_cl.save()
+        group.refresh_from_db()
+        cl = ConfigLog.objects.get(id=group.config.current)
         self.assertDictEqual(
             cl.attr,
             {
                 'activatable_group': {'active': True},
-                'group_keys': {'group': {'string': True}, 'activatable_group': {'integer': False}},
+                'group_keys': {
+                    'group': {'value': None, 'fields': {'string': True}},
+                    'activatable_group': {'value': True, 'fields': {'integer': False}},
+                },
                 'custom_group_keys': {
-                    'group': {'string': False},
-                    'activatable_group': {'integer': False},
+                    'group': {'value': True, 'fields': {'string': True}},
+                    'activatable_group': {'value': True, 'fields': {'integer': True}},
                 },
             },
         )
@@ -214,8 +300,8 @@ def test_create_config_for_group(self):
         attr.update(
             {
                 'custom_group_keys': {
-                    'group': {'string': True},
-                    'activatable_group': {'integer': True},
+                    'group': {'value': False, 'fields': {'string': False}},
+                    'activatable_group': {'value': False, 'fields': {'integer': False}},
                 }
             }
         )
@@ -223,3 +309,100 @@ def test_create_config_for_group(self):
             obj_ref=cl_current.obj_ref, config=cl_current.config, attr=attr
         )
         self.assertDictEqual(cl_current.attr, cl_new.attr)
+
+    def test_upgrade_cluster_config(self):
+        """Test upgrade cluster with GroupConfig"""
+
+        group = self.create_group('group', self.cluster.id, 'cluster')
+        cl = ConfigLog.objects.get(id=group.config.current)
+        self.assertDictEqual(
+            cl.config,
+            {
+                'group': {'string': 'string'},
+                'activatable_group': {'integer': 1},
+            },
+        )
+        self.assertDictEqual(
+            cl.attr,
+            {
+                'activatable_group': {'active': True},
+                'group_keys': {
+                    'group': {'fields': {'string': False}, 'value': None},
+                    'activatable_group': {'fields': {'integer': False}, 'value': False},
+                },
+                'custom_group_keys': {
+                    'group': {'fields': {'string': True}, 'value': True},
+                    'activatable_group': {'fields': {'integer': True}, 'value': True},
+                },
+            },
+        )
+
+        utils.gen_prototype_config(
+            prototype=self.cluster.prototype,
+            name='float',
+            field_type='float',
+            display_name='float',
+            group_customization=True,
+        )
+        utils.gen_prototype_config(
+            prototype=self.cluster.prototype,
+            name='group',
+            field_type='float',
+            subname='float',
+            display_name='float',
+            group_customization=True,
+        )
+        utils.gen_prototype_config(
+            prototype=self.cluster.prototype,
+            name='activatable_group',
+            field_type='float',
+            subname='float',
+            display_name='float',
+            group_customization=True,
+        )
+
+        parent_config = {
+            'float': 0.1,
+            'group': {'string': 'string', 'float': 0.1},
+            'activatable_group': {'integer': 1, 'float': 0.1},
+        }
+        parent_attr = {'activatable_group': {'active': True}}
+        ConfigLog.objects.create(
+            obj_ref=self.cluster.config,
+            description='upgrade',
+            config=parent_config,
+            attr=parent_attr,
+        )
+
+        group.refresh_from_db()
+        cl = ConfigLog.objects.get(id=group.config.current)
+        self.assertDictEqual(
+            cl.config,
+            {
+                'float': 0.1,
+                'group': {'string': 'string', 'float': 0.1},
+                'activatable_group': {'integer': 1, 'float': 0.1},
+            },
+        )
+        self.assertDictEqual(
+            cl.attr,
+            {
+                'activatable_group': {'active': True},
+                'group_keys': {
+                    'float': False,
+                    'group': {'fields': {'string': False, 'float': False}, 'value': None},
+                    'activatable_group': {
+                        'fields': {'integer': False, 'float': False},
+                        'value': False,
+                    },
+                },
+                'custom_group_keys': {
+                    'float': True,
+                    'group': {'fields': {'string': True, 'float': True}, 'value': True},
+                    'activatable_group': {
+                        'fields': {'integer': True, 'float': True},
+                        'value': True,
+                    },
+                },
+            },
+        )
diff --git a/tests/functional/maintenance_mode/test_inventory_modification.py b/tests/functional/maintenance_mode/test_inventory_modification.py
index 908f931301..5ad3468e99 100644
--- a/tests/functional/maintenance_mode/test_inventory_modification.py
+++ b/tests/functional/maintenance_mode/test_inventory_modification.py
@@ -68,7 +68,7 @@ def cluster_with_hc_set(request, cluster_with_mm, hosts) -> Cluster:
 @pytest.fixture()
 def config_groups(cluster_with_hc_set) -> Tuple[GroupConfig, GroupConfig, GroupConfig]:
     """Add config group to the cluster, service and one of components"""
-    changed_config = {'some_param': 42}
+    changed_config = {'attr': {'group_keys': {'some_param': True}}, 'config': {'some_param': 42}}
     host_1, host_2, host_3, *_ = cluster_with_hc_set.host_list()
     service = cluster_with_hc_set.service()
     component = service.component(name=FIRST_COMPONENT)
diff --git a/tests/functional/test_adcm_upgrade.py b/tests/functional/test_adcm_upgrade.py
index c3648fb5cc..582130249a 100644
--- a/tests/functional/test_adcm_upgrade.py
+++ b/tests/functional/test_adcm_upgrade.py
@@ -13,15 +13,28 @@
 """Tests for ADCM upgrade"""
 
 # pylint:disable=redefined-outer-name, no-self-use, too-many-arguments
+
 import random
 from contextlib import contextmanager
 from pathlib import Path
-from typing import Tuple, Union, List, Iterable, Any
+from typing import Tuple, Union, List, Iterable, Any, Collection
 
 import allure
 import pytest
 from adcm_client.base import ObjectNotFound
-from adcm_client.objects import ADCMClient, Cluster, Host, Service, Bundle, Component, Provider, Task, Job, Upgrade
+from adcm_client.objects import (
+    ADCMClient,
+    Cluster,
+    Host,
+    Service,
+    Bundle,
+    Component,
+    Provider,
+    Task,
+    Job,
+    Upgrade,
+    GroupConfig,
+)
 from adcm_pytest_plugin import params
 from adcm_pytest_plugin.docker_utils import ADCM
 from adcm_pytest_plugin.plugin import parametrized_by_adcm_version
@@ -32,6 +45,8 @@
     run_provider_action_and_assert_result,
 )
 from adcm_pytest_plugin.utils import catch_failed, get_data_dir, random_string
+
+from tests.library.assertions import dicts_are_not_equal, dicts_are_equal
 from tests.upgrade_utils import upgrade_adcm_version
 
 from tests.functional.conftest import only_clean_adcm
@@ -160,6 +175,147 @@ def test_actions_availability_after_upgrade(
     _assert_available_actions(cluster)
 
 
+class TestConfigGroupAttrFormatUpgrade:
+    """Test that "attr" of config groups are updated correctly in new ADCM versions"""
+
+    LAST_OLD_ATTR_ADCM_VERSION = ["hub.arenadata.io/adcm/adcm", "2022.04.18.13"]
+
+    @pytest.fixture()
+    def objects(self, sdk_client_fs):
+        """Create one of each object: cluster, service, component, provider"""
+        cluster_bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'with_config_groups', 'cluster'))
+        provider_bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'with_config_groups', 'provider'))
+        cluster = cluster_bundle.cluster_create('Test Awesome Cluster')
+        service = cluster.service_add(name='test_service')
+        provider = provider_bundle.provider_create('Test Awesome Provider')
+        return cluster, service, service.component(), provider
+
+    @pytest.fixture()
+    def config_groups(self, objects):
+        """Create config group for each object"""
+        return tuple(obj.group_config_create(f'{obj.__class__.__name__} group') for obj in objects)
+
+    @pytest.mark.parametrize("adcm_is_upgradable", [True], indirect=True)
+    @pytest.mark.parametrize("image", [LAST_OLD_ATTR_ADCM_VERSION], ids=repr, indirect=True)
+    def test_upgrade_to_new_config_groups_attr_format_unchecked_config(
+        self,
+        adcm_fs: ADCM,
+        sdk_client_fs: ADCMClient,
+        adcm_api_credentials: dict,
+        upgrade_target: Tuple[str, str],
+        objects,
+        config_groups,
+    ):
+        """
+        Test that newly created config groups have new "attr" structure after ADCM upgrade
+        from version with old "attr" format to a new one
+        """
+        old_attrs = self._get_attrs(config_groups)
+        new_attrs = self._get_expected_new_attr_for_groups(config_groups)
+
+        self._check_config_groups_attr_are_different_before_upgrade(objects, old_attrs, new_attrs)
+
+        upgrade_adcm_version(adcm_fs, sdk_client_fs, adcm_api_credentials, upgrade_target)
+
+        for obj in (*objects, *config_groups):
+            obj.reread()
+
+        self._check_config_groups_attr_are_correct_after_upgrade(objects, config_groups, new_attrs)
+
+    @pytest.mark.parametrize("adcm_is_upgradable", [True], indirect=True)
+    @pytest.mark.parametrize("image", [LAST_OLD_ATTR_ADCM_VERSION], ids=repr, indirect=True)
+    def test_upgrade_to_new_config_groups_attr_format_checked_config(
+        self,
+        adcm_fs: ADCM,
+        sdk_client_fs: ADCMClient,
+        adcm_api_credentials: dict,
+        upgrade_target: Tuple[str, str],
+        objects,
+        config_groups,
+    ):
+        """
+        Test that config groups with all elements added to them have new "attr" structure after ADCM upgrade
+        from version with old "attr" format to a new one
+        """
+        old_attrs = self._get_attrs(config_groups)
+
+        self._add_all_items_in_config_groups(config_groups)
+        new_attrs = self._get_expected_new_attr_for_groups(config_groups)
+
+        self._check_config_groups_attr_are_different_before_upgrade(objects, old_attrs, new_attrs)
+
+        upgrade_adcm_version(adcm_fs, sdk_client_fs, adcm_api_credentials, upgrade_target)
+
+        for obj in (*objects, *config_groups):
+            obj.reread()
+
+        self._check_config_groups_attr_are_correct_after_upgrade(objects, config_groups, new_attrs)
+
+    def _check_config_groups_attr_are_different_before_upgrade(self, objects, old_attrs, new_attrs):
+        for obj, old_attr, new_attr in zip(objects, old_attrs, new_attrs):
+            with allure.step(
+                'Check that "attr" before upgrade is different from expected in new version '
+                f'for group config of {obj.__class__.__name__}'
+            ):
+                dicts_are_not_equal(old_attr, new_attr)
+
+    def _check_config_groups_attr_are_correct_after_upgrade(self, objects, config_groups, expected_attrs):
+        for obj, group, expected_attr in zip(objects, config_groups, expected_attrs):
+            with allure.step(
+                'Check that "attr" after upgrade became the one that were expected '
+                f'for group config of {obj.__class__.__name__}'
+            ):
+                dicts_are_equal(group.config(full=True)['attr'], expected_attr)
+
+    def _get_attrs(self, groups: Collection[GroupConfig]):
+        return tuple(g.config(full=True)['attr'] for g in groups)
+
+    def _get_expected_new_attr_for_groups(self, groups: Collection[GroupConfig]):
+        new_configs_attr = []
+        for group in groups:
+            attr = group.config(full=True)['attr']
+            for key in attr['group_keys']:
+                if 'group' not in key:
+                    continue
+                attr['group_keys'][key] = {
+                    'value': False if 'activatable' in key else None,
+                    'fields': {**attr['group_keys'][key]},
+                }
+            for key in attr['custom_group_keys']:
+                if 'group' not in key:
+                    continue
+                attr['custom_group_keys'][key] = {
+                    'value': True,
+                    'fields': {**attr['custom_group_keys'][key]},
+                }
+            new_configs_attr.append(attr)
+        return tuple(new_configs_attr)
+
+    def _add_all_items_in_config_groups(self, groups: Collection[GroupConfig]):
+        for group in groups:
+            with allure.step(f'Add all config items to config group: {group.name}'):
+                group_config = group.config(full=True)
+                group.config_set_diff(
+                    {
+                        'attr': {
+                            **group_config['attr'],
+                            'group_keys': {
+                                'simple_value': True,
+                                'simple_group': {
+                                    'valingroup': True,
+                                    'readonlyval': True,
+                                },
+                                'activatable_group': {
+                                    'valingroup': True,
+                                    'readonlyval': True,
+                                },
+                            },
+                        },
+                        'config': {**group_config['config']},
+                    }
+                )
+
+
 # !===== Dirty ADCM upgrade =====!
 
 
diff --git a/tests/functional/test_adcm_upgrade_data/with_config_groups/cluster/config.yaml b/tests/functional/test_adcm_upgrade_data/with_config_groups/cluster/config.yaml
new file mode 100644
index 0000000000..558df87f75
--- /dev/null
+++ b/tests/functional/test_adcm_upgrade_data/with_config_groups/cluster/config.yaml
@@ -0,0 +1,38 @@
+- type: cluster
+  version: 1.23
+  name: test_cluster
+  config_group_customization: true
+
+  config: &config
+    - name: simple_value
+      type: string
+      default: somestring
+
+    - name: simple_group
+      type: group
+      subs: &subs
+        - name: valingroup
+          type: integer
+          required: false
+        - name: readonlyval
+          type: string
+          read_only: any
+          required: false
+
+    - name: activatable_group
+      type: group
+      activatable: true
+      active: yes
+      subs: *subs
+
+- type: service
+  version: 2.3
+  name: test_service
+  config_group_customization: true
+
+  config: *config
+
+  components:
+    test_component:
+      config_group_customization: true
+      config: *config
diff --git a/tests/functional/test_adcm_upgrade_data/with_config_groups/provider/config.yaml b/tests/functional/test_adcm_upgrade_data/with_config_groups/provider/config.yaml
new file mode 100644
index 0000000000..aa6880bfd9
--- /dev/null
+++ b/tests/functional/test_adcm_upgrade_data/with_config_groups/provider/config.yaml
@@ -0,0 +1,32 @@
+- type: provider
+  version: 1.23
+  name: test_provider
+  config_group_customization: true
+
+  config: &config
+    - name: simple_value
+      type: string
+      default: somestring
+
+    - name: simple_group
+      type: group
+      subs: &subs
+        - name: valingroup
+          type: integer
+          required: false
+        - name: readonlyval
+          type: string
+          read_only: any
+          required: false
+
+    - name: activatable_group
+      type: group
+      activatable: true
+      active: yes
+      subs: *subs
+
+- type: host
+  version: 93.9
+  name: test_host
+
+  config: *config
diff --git a/tests/functional/test_config_after_upgrade.py b/tests/functional/test_config_after_upgrade.py
index 8d6de03227..737fe95228 100644
--- a/tests/functional/test_config_after_upgrade.py
+++ b/tests/functional/test_config_after_upgrade.py
@@ -60,9 +60,9 @@ def _update_config_and_attr_for_new_field_test(
         attr["group_keys"]["new_file"] = False
         attr["group_keys"]["new_option"] = False
         attr["group_keys"]["new_text"] = False
-        attr["group_keys"]["group"]["new_text"] = False
-        del attr["group_keys"]["group"]["transport_port"]
-        attr["group_keys"]["new_group"] = {"new_port": False}
+        attr["group_keys"]["group"]["fields"]["new_text"] = False
+        del attr["group_keys"]["group"]["fields"]["transport_port"]
+        attr["group_keys"]["new_group"] = {"value": False, "fields": {"new_port": False}}
         attr["group_keys"]["new_structure"] = False
         attr["group_keys"]["new_map"] = False
         attr["group_keys"]["new_json"] = False
@@ -75,9 +75,9 @@ def _update_config_and_attr_for_new_field_test(
         attr["custom_group_keys"]["new_file"] = True
         attr["custom_group_keys"]["new_option"] = True
         attr["custom_group_keys"]["new_text"] = True
-        attr["custom_group_keys"]["group"]["new_text"] = True
-        del attr["custom_group_keys"]["group"]["transport_port"]
-        attr["custom_group_keys"]["new_group"] = {"new_port": True}
+        attr["custom_group_keys"]["group"]["fields"]["new_text"] = True
+        del attr["custom_group_keys"]["group"]["fields"]["transport_port"]
+        attr["custom_group_keys"]["new_group"] = {"value": True, "fields": {"new_port": True}}
         attr["custom_group_keys"]["new_structure"] = True
         attr["custom_group_keys"]["new_map"] = True
         attr["custom_group_keys"]["new_json"] = True
@@ -149,7 +149,7 @@ def _update_config_and_attr_for_changed_group_customisation_test(
         attr["custom_group_keys"]["password"] = False
         attr["custom_group_keys"]["list"] = False
         attr["custom_group_keys"]["option"] = False
-        attr["custom_group_keys"]["group"] = {"port": False, "transport_port": False}
+        attr["custom_group_keys"]["group"] = {"value": False, "fields": {"port": False, "transport_port": False}}
         attr["custom_group_keys"]["map"] = False
         attr["custom_group_keys"]["json"] = False
         attr["custom_group_keys"]["secrettext"] = True
diff --git a/tests/functional/test_config_groups.py b/tests/functional/test_config_groups.py
index 502408f9f0..a70dd05d5a 100644
--- a/tests/functional/test_config_groups.py
+++ b/tests/functional/test_config_groups.py
@@ -12,11 +12,12 @@
 # pylint: disable=redefined-outer-name, unused-argument, duplicate-code, no-self-use, dangerous-default-value
 
 """Tests for config groups"""
-
+import json
 from collections import OrderedDict
 from typing import (
     Union,
     Tuple,
+    Type,
 )
 
 import allure
@@ -40,6 +41,7 @@
 )
 from adcm_pytest_plugin.utils import get_data_dir
 from coreapi.exceptions import ErrorMessage
+from docker.models.containers import Container
 
 from tests.library.errorcodes import (
     GROUP_CONFIG_HOST_ERROR,
@@ -60,7 +62,7 @@
 )
 HOST_EXISTS_MESSAGE = "the host is already a member of this group"
 ATTRIBUTE_ERROR_MESSAGE = "field cannot be changed, read-only"
-GROUP_ERROR_MESSAGE = "parameter cannot be included in the group"
+GROUP_ERROR_MESSAGE = "field cannot be included in the group"
 GROUP_CONFIG_CHANGE_UNSELECTED_ERROR_MESSAGE = "field is different in current and new config"
 FIRST_COMPONENT_NAME = "first"
 SECOND_COMPONENT_NAME = "second"
@@ -429,7 +431,7 @@ class TestChangeGroupsConfig:
         "file": True,
         "option": True,
         "text": True,
-        "group": {"port": True, "transport_port": True},
+        "group": {"value": True, "fields": {"port": True, "transport_port": True}},
         "structure": True,
         "map": True,
         "json": True,
@@ -446,7 +448,7 @@ class TestChangeGroupsConfig:
         "file": False,
         "option": False,
         "text": False,
-        "group": {"port": False, "transport_port": False},
+        "group": {"value": False, "fields": {"port": False, "transport_port": False}},
         "structure": False,
         "map": False,
         "json": False,
@@ -477,6 +479,10 @@ def _add_values_to_group_config_template(
             group_config_template["attr"]["group_keys"] = {**group_keys}
         if config_attr:
             group_config_template["config"] = {**config_attr}
+        # dirty
+        for key in group_config_template["attr"].get("group_keys", {}).keys():
+            if "group" in key and key not in group_config_template["attr"]:
+                group_config_template["attr"][key] = {"active": True}
         return group_config_template
 
     @allure.step("Check group config values are equal expected")
@@ -488,7 +494,7 @@ def _check_values_in_group(self, actual_values: Union[OrderedDict, dict], expect
                 actual_values[item] == expected_values[item]
             ), f'Value is "{actual_values[item]}", but should be {expected_values[item]}'
         if actual_values["file"]:
-            assert actual_values["file"] == expected_values["file"], "File has not changed"
+            assert actual_values["file"].strip() == expected_values["file"].strip(), "File has not changed"
 
     def _get_config_from_group(self, group: GroupConfig):
         """Get config from group and add custom values to password and file"""
@@ -557,10 +563,13 @@ def test_change_group_in_cluster(self, cluster_bundle, cluster_with_two_hosts_on
             config_before = self._get_config_from_group(cluster_group)
         with allure.step("Check that without cluster group keys values are not saved in cluster group"):
             config_expected_without_groups = self._add_values_to_group_config_template()
-            config_after = cluster_group.config_set(config_expected_without_groups)
+            with pytest.raises(ErrorMessage) as e:
+                cluster_group.config_set(config_expected_without_groups)
+            ATTRIBUTE_ERROR.equal(e)
+            config_after = self._get_config_from_group(cluster_group)
             self._check_values_in_group(
-                actual_values=config_after['config'],
-                expected_values=config_expected_without_groups['config'],
+                actual_values=config_after,
+                expected_values=config_before,
             )
             config_previous = {"map": {test_host_1.fqdn: dict(config_before), test_host_2.fqdn: dict(config_before)}}
             for hosts in self.CLUSTER_HOSTS_VARIANTS:
@@ -597,10 +606,13 @@ def test_change_group_in_service(self, cluster_bundle, cluster_with_components):
             config_before = self._get_config_from_group(service_group)
         with allure.step("Check that without group keys values are not saved in service group"):
             config_expected_without_groups = self._add_values_to_group_config_template()
-            config_after = service_group.config_set(config_expected_without_groups)
+            with pytest.raises(ErrorMessage) as e:
+                service_group.config_set(config_expected_without_groups)
+            ATTRIBUTE_ERROR.equal(e)
+            config_after = service_group.config()
             self._check_values_in_group(
-                actual_values=config_after['config'],
-                expected_values=config_expected_without_groups['config'],
+                actual_values=config_after,
+                expected_values=config_before,
             )
             config_previous = {"map": {test_host_1.fqdn: dict(config_before), test_host_2.fqdn: dict(config_before)}}
             for hosts in self.CLUSTER_HOSTS_VARIANTS:
@@ -627,7 +639,7 @@ def test_change_group_in_service(self, cluster_bundle, cluster_with_components):
                     run_service_action_and_assert_result(service, action=ACTION_NAME, config=config_updated)
                     run_service_action_and_assert_result(service, action=ACTION_MULTIJOB_NAME, config=config_updated)
 
-    @pytest.mark.full()
+    @pytest.mark.full()  # pylint: disable-next=too-many-locals
     def test_change_group_in_component(self, cluster_bundle, cluster_with_components):
         """Test that groups in component are allowed change"""
 
@@ -638,10 +650,13 @@ def test_change_group_in_component(self, cluster_bundle, cluster_with_components
             config_before = self._get_config_from_group(component_group)
         with allure.step("Check that without group keys values are not saved in component group"):
             config_expected_without_groups = self._add_values_to_group_config_template()
-            config_after = component_group.config_set(config_expected_without_groups)
+            with pytest.raises(ErrorMessage) as e:
+                component_group.config_set(config_expected_without_groups)
+            ATTRIBUTE_ERROR.equal(e)
+            config_after = component_group.config()
             self._check_values_in_group(
-                actual_values=config_after['config'],
-                expected_values=config_expected_without_groups['config'],
+                actual_values=config_after,
+                expected_values=config_before,
             )
             config_previous = {"map": {test_host_1.fqdn: dict(config_before), test_host_2.fqdn: dict(config_before)}}
             for hosts in self.CLUSTER_HOSTS_VARIANTS:
@@ -690,10 +705,13 @@ def test_change_group_in_provider(self, provider_bundle, provider, create_two_ho
             config_before = self._get_config_from_group(provider_group)
         with allure.step("Check that without group keys values are not saved in provider group"):
             config_expected_without_groups = self._add_values_to_group_config_template()
-            config_after = provider_group.config_set(config_expected_without_groups)
+            with pytest.raises(ErrorMessage) as e:
+                provider_group.config_set(config_expected_without_groups)
+            ATTRIBUTE_ERROR.equal(e)
+            config_after = provider_group.config()
             self._check_values_in_group(
-                actual_values=config_after['config'],
-                expected_values=config_expected_without_groups['config'],
+                actual_values=config_after,
+                expected_values=config_before,
             )
             config_previous = {"map": {test_host_1.fqdn: dict(config_before), test_host_2.fqdn: dict(config_before)}}
             for hosts in self.CLUSTER_HOSTS_VARIANTS:
@@ -855,23 +873,16 @@ def test_changing_params_in_cluster_group_subs(self, cluster_bundle, cluster_wit
         with allure.step("Check changing sub with group_customization true"):
             config_expected = self._add_values_to_group_config_template(
                 config_attr={"group": OrderedDict([('port', 9200), ('transport_port', 9100)])},
-                group_keys={"group": {"port": False, "transport_port": True}},
-                custom_group_keys={"group": {"port": False, "transport_port": True}},
+                group_keys={"group": {"value": False, "fields": {"port": False, "transport_port": True}}},
+                custom_group_keys={"group": {"value": False, "fields": {"port": False, "transport_port": True}}},
             )
+            config_expected['attr']['group'] = {'active': True}
             cluster_group.config_set(config_expected)
             config_updated = {
                 "map": {test_host_1.fqdn: config_expected['config'], test_host_2.fqdn: dict(config_before)}
             }
             run_cluster_action_and_assert_result(cluster, action=ACTION_NAME, config=config_updated)
             run_cluster_action_and_assert_result(cluster, action=ACTION_MULTIJOB_NAME, config=config_updated)
-        with allure.step("Check changing sub with group_customization false"):
-            config_expected_wrong = self._add_values_to_group_config_template(
-                config_attr={"group": OrderedDict([('port', 9100), ('transport_port', 9300)])},
-            )
-            cluster_group.config_set(config_expected_wrong)
-            config_updated_wrong = {"map": {test_host_1.fqdn: config_before, test_host_2.fqdn: config_before}}
-            run_cluster_action_and_assert_result(cluster, action=ACTION_NAME, config=config_updated_wrong)
-            run_cluster_action_and_assert_result(cluster, action=ACTION_MULTIJOB_NAME, config=config_updated_wrong)
 
     @pytest.mark.parametrize(
         "cluster_bundle",
@@ -901,3 +912,74 @@ def test_changing_group_with_main_info_key(self, cluster_bundle, cluster_with_tw
             )
             run_cluster_action_and_assert_result(cluster, action=ACTION_NAME, timeout=30)
             run_cluster_action_and_assert_result(cluster, action=ACTION_MULTIJOB_NAME, timeout=30)
+
+
+class TestActivatableGroupInGroupConfig:
+    """Test cases with activatable group manipulations in group config"""
+
+    @pytest.mark.parametrize(
+        'cluster_bundle', [get_data_dir(__file__, 'cluster_with_activatable_group')], indirect=True
+    )
+    def test_activatable_group(self, adcm_fs, cluster_with_two_hosts_on_it):
+        """Test activatable group in config groups"""
+        NoneType = type(None)
+        first_host, _, cluster = cluster_with_two_hosts_on_it
+
+        with allure.step('Activate group in cluster config'):
+            self._change_attrs(cluster, True)
+        with allure.step('Create config group for the cluster with one host'):
+            group = _create_group_and_add_host(cluster, first_host)
+
+        with allure.step('Do not include anything in config group and expect "main_group" to be dict'):
+            self.check_new_inventory(dict, cluster, adcm_fs.container)
+
+        with allure.step('Deactivate "main_group", include it in config group, expect "null" in inventory'):
+            self._change_attrs(group, False, True, False)
+            self.check_new_inventory(NoneType, cluster, adcm_fs.container)
+
+        with allure.step(
+            'Deactivate "main_group", exclude from config group, include one of its children, expect dict in inventory'
+        ):
+            self._change_attrs(group, False, False, True)
+            self.check_new_inventory(dict, cluster, adcm_fs.container)
+
+        with allure.step('Deactivate "main_group", exclude it from config group, expect dict in inventory'):
+            self._change_attrs(group, False, False, False)
+            self.check_new_inventory(dict, cluster, adcm_fs.container)
+
+        with allure.step('Deactivate "main_group" in cluster config and expect None in inventory'):
+            self._change_attrs(cluster, False)
+            self.check_new_inventory(NoneType, cluster, adcm_fs.container)
+
+    @allure.step('Generate new inventory and expect "main_group" to be of type {expected_group_type}')
+    def check_new_inventory(self, expected_group_type: Type, cluster: Cluster, container: Container):
+        """Run action and check inventory"""
+        task = cluster.action().run()
+        task.wait()
+        inventory = self._get_task_inventory(task.id, container)
+        main_group = inventory['all']['children']['CLUSTER']['hosts'][FIRST_HOST]['cluster']['config']['main_group']
+        assert isinstance(main_group, expected_group_type), (
+            f'"main_group" should be of type "{expected_group_type}" in the inventory.\n'
+            f'Actual type is "{type(main_group)}"'
+        )
+
+    def _get_task_inventory(self, task_id: int, container: Container):
+        return json.loads(container.exec_run(['cat', f'/adcm/data/run/{task_id}/inventory.json']).output)
+
+    def _change_attrs(self, obj, group_is_on, group_is_included=None, group_element_is_included=None):
+        config = {
+            'config': {},
+            'attr': {
+                'main_group': {
+                    'active': group_is_on,
+                },
+            },
+        }
+        if group_is_included is not None and group_element_is_included is not None:
+            config['attr']['group_keys'] = {
+                'main_group': {
+                    'value': group_is_included,
+                    'fields': {'simple_value': group_element_is_included},
+                }
+            }
+        obj.config_set_diff(config)
diff --git a/tests/functional/test_config_groups_data/cluster_with_activatable_group/config.yaml b/tests/functional/test_config_groups_data/cluster_with_activatable_group/config.yaml
new file mode 100644
index 0000000000..aa28caa920
--- /dev/null
+++ b/tests/functional/test_config_groups_data/cluster_with_activatable_group/config.yaml
@@ -0,0 +1,36 @@
+- type: cluster
+  version: 4.5
+  name: cluster_with_activatable_group
+
+  config_group_customization: true
+
+  actions:
+    make_fun:
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      states:
+        available: any
+
+  config:
+    - name: as_simple_as_that
+      type: integer
+      default: 12
+
+    - name: main_group
+      type: group
+      activatable: true
+      active: false
+      subs:
+        - name: simple_value
+          type: integer
+          default: 3
+        - name: complex_value
+          type: map
+          default:
+            name: Doge
+            surname: Cheemsburger
+        - name: untouchable_value
+          type: string
+          read_only: any
+          default: donotochme
diff --git a/tests/functional/test_upgrade_cluster.py b/tests/functional/test_upgrade_cluster.py
index 04778c2dd0..b0edc4cfed 100644
--- a/tests/functional/test_upgrade_cluster.py
+++ b/tests/functional/test_upgrade_cluster.py
@@ -151,16 +151,22 @@ def test_upgrade_cluster_with_config_groups(sdk_client_fs):
     with allure.step('Assert that configs save success after upgrade'):
         cluster.config_set(
             {
-                **cluster.config(),
-                "activatable_group_with_ro": {"readonly-key": "value"},
-                "activatable_group": {"required": 10, "writable-key": "value"},
+                "attr": {"activatable_group_with_ro": {"active": True}, "activatable_group": {"active": True}},
+                "config": {
+                    **cluster.config(),
+                    "activatable_group_with_ro": {"readonly-key": "value"},
+                    "activatable_group": {"required": 10, "writable-key": "value"},
+                },
             }
         )
         service.config_set(
             {
-                **cluster.config(),
-                "activatable_group_with_ro": {"readonly-key": "value"},
-                "activatable_group": {"required": 10, "writable-key": "value"},
+                "attr": {"activatable_group_with_ro": {"active": True}, "activatable_group": {"active": True}},
+                "config": {
+                    **service.config(),
+                    "activatable_group_with_ro": {"readonly-key": "value"},
+                    "activatable_group": {"required": 10, "writable-key": "value"},
+                },
             }
         )
 
diff --git a/tests/library/assertions.py b/tests/library/assertions.py
index 61e4e95667..a723aab625 100644
--- a/tests/library/assertions.py
+++ b/tests/library/assertions.py
@@ -132,6 +132,20 @@ def dicts_are_equal(actual: dict, expected: dict, message: Union[str, Callable]
     raise AssertionError(message)
 
 
+def dicts_are_not_equal(first: dict, second: dict, message: Union[str, Callable] = '', **kwargs) -> None:
+    """
+    Check that two dicts aren't equal (direct comparison with `!=`)
+    """
+    if first != second:
+        return
+
+    allure.attach(pprint.pformat(first), name='First dictionary')
+    allure.attach(pprint.pformat(second), name='Second dictionary')
+    message = message if not callable(message) else message(**kwargs)
+    if not message:
+        message = "Two dictionaries are equal, which wasn't expected.\nCheck step attachments for more details."
+
+
 def expect_api_error(operation_name: str, operation: Callable, *args, err_: Optional[ADCMError] = None, **kwargs):
     """
     Perform "operation" and expect it to raise an API error.
diff --git a/web/src/app/shared/configuration/tests/configuration.ts b/web/src/app/shared/configuration/tests/configuration.ts
index 2e3e4a290a..3a84c23bce 100644
--- a/web/src/app/shared/configuration/tests/configuration.ts
+++ b/web/src/app/shared/configuration/tests/configuration.ts
@@ -45,6 +45,7 @@ export class FieldStack implements IFieldStack {
   description?: string;
   limits: ILimits;
   ui_options: IUIoptions;
+  group_config: { [key: string]: boolean };
 
   constructor(
     id: number,