Repository: arenadata/adcm (language: python)

Issue:
ADCM-2856 - Possibility to include config field in group-config with `config_group_customization=False` with `advanced: true` and not only
https://arenadata.atlassian.net/browse/ADCM-2856


Discussion / hints:

Fix patch:
diff --git a/go/adcm/go.mod b/go/adcm/go.mod
index 018fc429c8..04bdf7a6f1 100644
--- a/go/adcm/go.mod
+++ b/go/adcm/go.mod
@@ -3,7 +3,6 @@ module adcm
 require (
 	github.com/bouk/httprouter v0.0.0-20160817010721-ee8b3818a7f5
 	github.com/gorilla/websocket v1.4.2
-	github.com/stretchr/testify v1.6.1
 )
 
 go 1.13
diff --git a/go/adcm/status/handlers.go b/go/adcm/status/handlers.go
index d31ec29e24..a195debe9e 100644
--- a/go/adcm/status/handlers.go
+++ b/go/adcm/status/handlers.go
@@ -69,7 +69,7 @@ func showAll(h Hub, w http.ResponseWriter, r *http.Request) {
 	hostOut := map[int]Status{}
 	hosts := h.ServiceMap.getAllHosts()
 	for _, hostId := range hosts {
-		val, _ := h.HostStorage.get(ALL, hostId)
+		val, _ := h.HostStatusStorage.get(ALL, hostId)
 		hostOut[hostId] = val
 	}
 	all := struct {
@@ -279,7 +279,7 @@ func setHost(h Hub, w http.ResponseWriter, r *http.Request) {
 	clear := func() {
 		h.StatusEvent.check_host(h, hostId, clusterId)
 	}
-	res := h.HostStorage.set(ALL, hostId, status, clear)
+	res := h.HostStatusStorage.set(ALL, hostId, status, clear)
 	h.StatusEvent.check_host(h, hostId, clusterId)
 	jsonOut3(w, r, "", res)
 }
@@ -295,10 +295,82 @@ func showHost(h Hub, w http.ResponseWriter, r *http.Request) {
 		ErrOut4(w, r, "HOST_NOT_FOUND", "unknown host")
 		return
 	}
-	val, _ := h.HostStorage.get(ALL, hostId)
+	val, _ := h.HostStatusStorage.get(ALL, hostId)
 	jsonOut(w, r, val)
 }
 
+// listHost - GET method for show list Host entities
+// Response format: [{"id": 1, "maintenance_mode": false}, ...]
+func listHost(h Hub, w http.ResponseWriter, r *http.Request) {
+	allow(w, "GET, POST")
+	result := h.HostStorage.list()
+	jsonOut(w, r, result)
+}
+
+// createHost - POST method for create Host entities
+// Request format: [{"id": 1, "maintenance_mode": false}, ...]
+func createHost(h Hub, w http.ResponseWriter, r *http.Request) {
+	allow(w, "GET, POST")
+	hosts := make([]Host, 0)
+	_, err := decodeBody(w, r, &hosts)
+	if err != nil {
+		ErrOut4(w, r, "JSON_ERROR", err.Error())
+		return
+	}
+	ok := h.HostStorage.create(hosts)
+	code := http.StatusCreated
+	if !ok {
+		code = http.StatusBadRequest
+	}
+	logg.D.f("createHost: %+v", hosts)
+	jsonOut3(w, r, "", code)
+}
+
+// retrieveHost - GET method for show Host entity
+func retrieveHost(h Hub, w http.ResponseWriter, r *http.Request) {
+	allow(w, "GET, PUT")
+	hostId, ok := getPathId(w, r, "hostid")
+	if !ok {
+		return
+	}
+	_, ok = h.ServiceMap.getHostCluster(hostId)
+	if !ok {
+		ErrOut4(w, r, "HOST_NOT_FOUND", "unknown host")
+		return
+	}
+	value, ok := h.HostStorage.retrieve(hostId)
+	if !ok {
+		ErrOut4(w, r, "HOST_NOT_FOUND", "unknown host")
+		return
+	}
+	jsonOut(w, r, value)
+}
+
+// updateHost - PUT method for update Host entity
+func updateHost(h Hub, w http.ResponseWriter, r *http.Request) {
+	allow(w, "GET, PUT")
+	hostId, ok := getPathId(w, r, "hostid")
+	if !ok {
+		return
+	}
+	_, ok = h.ServiceMap.getHostCluster(hostId)
+	if !ok {
+		ErrOut4(w, r, "HOST_NOT_FOUND", "unknown host")
+		return
+	}
+	host := Host{}
+	_, err := decodeBody(w, r, &host)
+	if err != nil {
+		return
+	}
+	ok = h.HostStorage.update(hostId, host.MaintenanceMode)
+	code := http.StatusOK
+	if !ok {
+		code = http.StatusBadRequest
+	}
+	jsonOut3(w, r, "", code)
+}
+
 func setHostComp(h Hub, w http.ResponseWriter, r *http.Request) {
 	allow(w, "GET, POST")
 	hostId, ok1 := getPathId(w, r, "hostid")
diff --git a/go/adcm/status/host.go b/go/adcm/status/host.go
new file mode 100644
index 0000000000..334e365579
--- /dev/null
+++ b/go/adcm/status/host.go
@@ -0,0 +1,135 @@
+package status
+
+type Host struct {
+	Id              int  `json:"id"`
+	MaintenanceMode bool `json:"maintenance_mode"`
+}
+
+type dbHost map[int]Host
+
+type hostRequest struct {
+	command          string
+	id               int
+	maintenance_mode bool
+	hosts            []Host
+}
+
+type hostResponse struct {
+	ok    bool
+	host  Host
+	hosts []Host
+}
+
+type HostStorage struct {
+	in    chan hostRequest
+	out   chan hostResponse
+	db    dbHost
+	label string
+}
+
+func newHostStorage(db dbHost, label string) *HostStorage {
+	return &HostStorage{
+		in:    make(chan hostRequest),
+		out:   make(chan hostResponse),
+		db:    db,
+		label: label,
+	}
+}
+
+func (hs *HostStorage) run() {
+	logg.I.f("start storage %s server", hs.label)
+	for {
+		request := <-hs.in
+		logg.I.f("Storage %s command: %+v", hs.label, request.command)
+		switch request.command {
+		case "retrieve":
+			host, ok := hs.db.retrieve(request.id)
+			hs.out <- hostResponse{host: host, ok: ok}
+		case "update":
+			ok := hs.db.update(request.id, request.maintenance_mode)
+			hs.out <- hostResponse{ok: ok}
+		case "list":
+			hosts := hs.db.list()
+			hs.out <- hostResponse{hosts: hosts}
+		case "create":
+			ok := hs.db.create(request.hosts)
+			hs.out <- hostResponse{ok: ok}
+		default:
+			logg.E.f("Storage %s unknown command: %+v", hs.label, request)
+
+		}
+	}
+}
+
+func (hs *HostStorage) list() []Host {
+	request := hostRequest{command: "list"}
+	hs.in <- request
+	response := <-hs.out
+	return response.hosts
+}
+
+func (hs *HostStorage) create(hosts []Host) bool {
+	request := hostRequest{command: "create", hosts: hosts}
+	hs.in <- request
+	response := <-hs.out
+	return response.ok
+}
+
+func (hs *HostStorage) retrieve(id int) (Host, bool) {
+	request := hostRequest{command: "retrieve", id: id}
+	hs.in <- request
+	response := <-hs.out
+	return response.host, response.ok
+}
+
+func (hs *HostStorage) update(id int, maintenance_mode bool) bool {
+	request := hostRequest{command: "update", id: id, maintenance_mode: maintenance_mode}
+	hs.in <- request
+	response := <-hs.out
+	return response.ok
+}
+
+// list - return list Host entities
+func (db dbHost) list() []Host {
+	result := make([]Host, 0)
+	for _, host := range db {
+		result = append(result, host)
+	}
+	return result
+}
+
+// retrieve - return Host entity, if this exists in db, else return default entity
+func (db dbHost) retrieve(id int) (Host, bool) {
+	value, ok := db[id]
+	if ok {
+		return value, true
+	}
+	return Host{}, false
+}
+
+// update - update Host entity
+func (db dbHost) update(id int, maintenance_mode bool) bool {
+	host, ok := db[id]
+	if !ok {
+		return ok
+	}
+	host.MaintenanceMode = maintenance_mode
+	db[id] = host
+	return ok
+}
+
+// create - clear db and created Host entities
+func (db dbHost) create(hosts []Host) bool {
+	db.clear()
+	for _, host := range hosts {
+		db[host.Id] = host
+	}
+	return true
+}
+
+// clear - clear db with Hosts
+func (db dbHost) clear() {
+	for key := range db {
+		delete(db, key)
+	}
+}
diff --git a/go/adcm/status/init.go b/go/adcm/status/init.go
index 88410be021..7cfeb76f22 100644
--- a/go/adcm/status/init.go
+++ b/go/adcm/status/init.go
@@ -12,21 +12,23 @@
 package status
 
 import (
-	"github.com/bouk/httprouter"
 	"log"
 	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
 	"time"
+
+	"github.com/bouk/httprouter"
 )
 
 const httpPort = ":8020"
 const componentTimeout = 300 // seconds
 
 type Hub struct {
-	HostStorage          *Storage
+	HostStatusStorage    *Storage
 	HostComponentStorage *Storage
+	HostStorage          *HostStorage
 	ServiceMap           *ServiceServer
 	EventWS              *wsHub
 	StatusEvent          *StatusEvent
@@ -43,9 +45,12 @@ func Start(secrets *SecretConfig, logFile string, logLevel string) {
 	go hub.HostComponentStorage.run()
 	hub.HostComponentStorage.setTimeOut(componentTimeout)
 
-	hub.HostStorage = newStorage(dbMap2{}, "ClusterHost")
+	hub.HostStatusStorage = newStorage(dbMap2{}, "ClusterHost")
+	go hub.HostStatusStorage.run()
+	hub.HostStatusStorage.setTimeOut(componentTimeout)
+
+	hub.HostStorage = newHostStorage(dbHost{}, "Host")
 	go hub.HostStorage.run()
-	hub.HostStorage.setTimeOut(componentTimeout)
 
 	hub.ServiceMap = newServiceServer()
 	go hub.ServiceMap.run()
@@ -91,6 +96,11 @@ func startHTTP(httpPort string, hub Hub) {
 	router.GET("/api/v1/host/:hostid/", authWrap(hub, showHost))
 	router.POST("/api/v1/host/:hostid/", authWrap(hub, setHost))
 
+	router.GET("/api/v1/object/host/", authWrap(hub, listHost))
+	router.POST("/api/v1/object/host/", authWrap(hub, createHost))
+	router.GET("/api/v1/object/host/:hostid/", authWrap(hub, retrieveHost))
+	router.PUT("/api/v1/object/host/:hostid/", authWrap(hub, updateHost))
+
 	router.GET("/api/v1/host/:hostid/component/:compid/", authWrap(hub, showHostComp))
 	router.POST("/api/v1/host/:hostid/component/:compid/", authWrap(hub, setHostComp))
 
diff --git a/go/adcm/status/status.go b/go/adcm/status/status.go
index 858485a9a5..f02757477e 100644
--- a/go/adcm/status/status.go
+++ b/go/adcm/status/status.go
@@ -85,6 +85,10 @@ func getServiceStatus(h Hub, cluster int, service int) (Status, []hostCompStatus
 		spl := strings.Split(key, ".")
 		hostId, _ := strconv.Atoi(spl[0])
 		compId, _ := strconv.Atoi(spl[1])
+		host, ok := h.HostStorage.retrieve(hostId)
+		if ok && host.MaintenanceMode {
+			continue
+		}
 		status, ok := h.HostComponentStorage.get(hostId, compId)
 		if !ok {
 			status.Status = 16
@@ -105,6 +109,10 @@ func getComponentStatus(h Hub, compId int) (Status, map[int]Status) {
 	}
 	status := 0
 	for _, hostId := range hostList {
+		host, ok := h.HostStorage.retrieve(hostId)
+		if ok && host.MaintenanceMode {
+			continue
+		}
 		hostStatus, ok := h.HostComponentStorage.get(hostId, compId)
 		if !ok {
 			hostStatus = Status{Status: 16}
@@ -125,7 +133,11 @@ func getClusterHostStatus(h Hub, clusterId int) (int, map[int]Status) {
 	}
 	result := 0
 	for _, hostId := range hostList {
-		status, ok := h.HostStorage.get(ALL, hostId)
+		host, ok := h.HostStorage.retrieve(hostId)
+		if ok && host.MaintenanceMode {
+			continue
+		}
+		status, ok := h.HostStatusStorage.get(ALL, hostId)
 		if !ok {
 			logg.D.f("getClusterHostStatus: no status for host #%v ", hostId)
 			status = Status{Status: 16}
diff --git a/go/adcm/status/status_event.go b/go/adcm/status/status_event.go
index b880604cd2..3fb4e61b41 100644
--- a/go/adcm/status/status_event.go
+++ b/go/adcm/status/status_event.go
@@ -81,9 +81,9 @@ func (se *StatusEvent) write(key string, sh StatusHolder) {
 }
 
 func (se *StatusEvent) read(key string) StatusHolder {
-        se.mutex.Lock()
-        defer se.mutex.Unlock()
-        return se.db[key]
+	se.mutex.Lock()
+	defer se.mutex.Unlock()
+	return se.db[key]
 }
 
 func fill_hc_status(h Hub, hostId int, compId int, hc ClusterService) StatusHolder {
@@ -97,7 +97,7 @@ func fill_hc_status(h Hub, hostId int, compId int, hc ClusterService) StatusHold
 
 func fill_host_status(h Hub, hostId int, clusterId int) StatusHolder {
 	ClusterStatus := getClusterStatus(h, clusterId)
-	HostStatus, _ := h.HostStorage.get(ALL, hostId)
+	HostStatus, _ := h.HostStatusStorage.get(ALL, hostId)
 	return StatusHolder{
 		cluster: ClusterStatus,
 		host:    HostStatus,
diff --git a/go/adcm/status/storage.go b/go/adcm/status/storage.go
index dd0d9a200c..a0cbfd37a8 100644
--- a/go/adcm/status/storage.go
+++ b/go/adcm/status/storage.go
@@ -18,7 +18,7 @@ type Status struct {
 	counter int
 }
 
-type storageReq struct {
+type storageRequest struct {
 	command   string
 	key1      int
 	key2      int
@@ -27,7 +27,7 @@ type storageReq struct {
 	clearFunc func()
 }
 
-type storageResp struct {
+type storageResponse struct {
 	code   int
 	ok     bool
 	status Status
@@ -56,8 +56,8 @@ type dbStorage interface {
 }
 
 type Storage struct {
-	in      chan storageReq
-	out     chan storageResp
+	in      chan storageRequest
+	out     chan storageResponse
 	dbMap   dbStorage
 	timeout int
 	label   string
@@ -67,8 +67,8 @@ type Storage struct {
 
 func newStorage(db dbStorage, label string) *Storage {
 	return &Storage{
-		in:      make(chan storageReq),
-		out:     make(chan storageResp),
+		in:      make(chan storageRequest),
+		out:     make(chan storageResponse),
 		dbMap:   db,
 		timeout: statusTimeOut,
 		label:   label,
@@ -88,7 +88,7 @@ func (s *Storage) run() {
 		case cmdSet:
 			v := s.dbMap.set(c.key1, c.key2, c.val)
 			s.startTimer(s.dbMap, c)
-			s.out <- storageResp{code: v}
+			s.out <- storageResponse{code: v}
 		case cmdClear:
 			s.dbMap.clear(c.key1, c.key2, c.counter)
 			if c.clearFunc != nil {
@@ -96,13 +96,13 @@ func (s *Storage) run() {
 			}
 		case cmdPure:
 			s.dbMap = s.dbMap.create()
-			s.out <- storageResp{ok: true}
+			s.out <- storageResponse{ok: true}
 		case cmdGet:
 			v, ok := s.dbMap.get(c.key1, c.key2)
-			s.out <- storageResp{status: v, ok: ok}
+			s.out <- storageResponse{status: v, ok: ok}
 		case cmdGet1:
 			v, ok := s.dbMap.get1(c.key1)
-			s.out <- storageResp{map1: v, ok: ok}
+			s.out <- storageResponse{map1: v, ok: ok}
 		default:
 			logg.E.f("Storage %s unknown command: %+v", s.label, c)
 		}
@@ -112,27 +112,27 @@ func (s *Storage) run() {
 // Interface
 
 func (s *Storage) set(key1 int, key2 int, val int, clear func()) int {
-	req := storageReq{command: cmdSet, key1: key1, key2: key2, val: val, clearFunc: clear}
+	req := storageRequest{command: cmdSet, key1: key1, key2: key2, val: val, clearFunc: clear}
 	s.in <- req
 	resp := <-s.out
 	return resp.code
 }
 
 func (s *Storage) get(key1 int, key2 int) (Status, bool) {
-	req := storageReq{command: cmdGet, key1: key1, key2: key2}
+	req := storageRequest{command: cmdGet, key1: key1, key2: key2}
 	s.in <- req
 	resp := <-s.out
 	return resp.status, resp.ok
 }
 
 func (s *Storage) get1(key1 int) (map[int]Status, bool) { //nolint: unused
-	s.in <- storageReq{command: cmdGet1, key1: key1}
+	s.in <- storageRequest{command: cmdGet1, key1: key1}
 	resp := <-s.out
 	return resp.map1, resp.ok
 }
 
 func (s *Storage) pure() { //nolint: unused
-	s.in <- storageReq{command: cmdPure}
+	s.in <- storageRequest{command: cmdPure}
 	<-s.out
 }
 
@@ -188,14 +188,14 @@ func (db dbMap2) getCounter(key1 int, key2 int) int {
 	return val.counter
 }
 
-func (s *Storage) startTimer(db dbStorage, c storageReq) {
+func (s *Storage) startTimer(db dbStorage, c storageRequest) {
 	if s.timeout == 0 {
 		return
 	}
 	counter := db.getCounter(c.key1, c.key2)
 	c.counter = counter
 	c.command = cmdClear
-	go func(c storageReq) {
+	go func(c storageRequest) {
 		time.Sleep(time.Duration(s.timeout) * time.Second)
 		s.in <- c
 	}(c)
diff --git a/python/api/action/serializers.py b/python/api/action/serializers.py
index 8b73aef058..a91550b8f9 100644
--- a/python/api/action/serializers.py
+++ b/python/api/action/serializers.py
@@ -15,12 +15,11 @@
 from rest_framework import serializers
 from rest_framework.reverse import reverse
 
-import cm.job
 import cm.adcm_config
-from cm.models import PrototypeConfig, SubAction
-
-from api.utils import get_api_url_kwargs
+import cm.job
 from api.config.serializers import ConfigSerializerUI
+from api.utils import get_api_url_kwargs
+from cm.models import PrototypeConfig, SubAction
 
 
 class ActionDetailURL(serializers.HyperlinkedIdentityField):
@@ -58,6 +57,7 @@ class StackActionSerializer(serializers.Serializer):
     allow_to_terminate = serializers.BooleanField(read_only=True)
     partial_execution = serializers.BooleanField(read_only=True)
     host_action = serializers.BooleanField(read_only=True)
+    disabling_cause = serializers.CharField(read_only=True)
 
 
 class ActionSerializer(StackActionSerializer):
@@ -99,6 +99,7 @@ class StackActionDetailSerializer(StackActionSerializer):
     log_files = serializers.JSONField(required=False)
     config = serializers.SerializerMethodField()
     subs = serializers.SerializerMethodField()
+    disabling_cause = serializers.CharField(read_only=True)
 
     def get_config(self, obj):
         aconf = PrototypeConfig.objects.filter(prototype=obj.prototype, action=obj).order_by('id')
diff --git a/python/api/action/views.py b/python/api/action/views.py
index 72acd95e12..9f718333bf 100644
--- a/python/api/action/views.py
+++ b/python/api/action/views.py
@@ -26,13 +26,16 @@
     filter_actions,
     permission_denied,
     get_object_for_user,
+    set_disabling_cause,
 )
+from cm.errors import AdcmEx
 from cm.models import (
     Host,
     Action,
     TaskLog,
     HostComponent,
     get_model_by_type,
+    MaintenanceModeType,
 )
 from rbac.viewsets import DjangoOnlyObjectPermissions
 from . import serializers
@@ -61,48 +64,55 @@ class ActionList(PermissionListMixin, GenericUIView):
     filter_backends = (AdcmFilterBackend,)
     permission_required = ['cm.view_action']
 
-    def get(self, request, *args, **kwargs):  # pylint: disable=too-many-locals
-        """
-        List all actions of a specified object
-        """
-        if kwargs['object_type'] == 'host':
-            host, _ = get_obj(object_type='host', host_id=kwargs['host_id'])
-            actions = set(
-                filter_actions(
-                    host, self.filter_queryset(self.get_queryset().filter(prototype=host.prototype))
-                )
+    def _get_host_actions(self, host: Host) -> set:
+        actions = set(
+            filter_actions(
+                host, self.filter_queryset(self.get_queryset().filter(prototype=host.prototype))
             )
-            obj = host
-            objects = {'host': host}
-            hcs = HostComponent.objects.filter(host_id=kwargs['host_id'])
-            if hcs:
-                for hc in hcs:
-                    cluster, _ = get_obj(object_type='cluster', cluster_id=hc.cluster_id)
-                    service, _ = get_obj(object_type='service', service_id=hc.service_id)
-                    component, _ = get_obj(object_type='component', component_id=hc.component_id)
-                    for connect_obj in [cluster, service, component]:
-                        actions.update(
-                            filter_actions(
-                                connect_obj,
-                                self.filter_queryset(
-                                    self.get_queryset().filter(
-                                        prototype=connect_obj.prototype, host_action=True
-                                    )
-                                ),
-                            )
-                        )
-            else:
-                if host.cluster is not None:
+        )
+        hcs = HostComponent.objects.filter(host_id=host.id)
+        if hcs:
+            for hc in hcs:
+                cluster, _ = get_obj(object_type='cluster', cluster_id=hc.cluster_id)
+                service, _ = get_obj(object_type='service', service_id=hc.service_id)
+                component, _ = get_obj(object_type='component', component_id=hc.component_id)
+                for connect_obj in [cluster, service, component]:
                     actions.update(
                         filter_actions(
-                            host.cluster,
+                            connect_obj,
                             self.filter_queryset(
                                 self.get_queryset().filter(
-                                    prototype=host.cluster.prototype, host_action=True
+                                    prototype=connect_obj.prototype, host_action=True
                                 )
                             ),
                         )
                     )
+        else:
+            if host.cluster is not None:
+                actions.update(
+                    filter_actions(
+                        host.cluster,
+                        self.filter_queryset(
+                            self.get_queryset().filter(
+                                prototype=host.cluster.prototype, host_action=True
+                            )
+                        ),
+                    )
+                )
+        return actions
+
+    def get(self, request, *args, **kwargs):  # pylint: disable=too-many-locals
+        """
+        List all actions of a specified object
+        """
+        if kwargs['object_type'] == 'host':
+            host, _ = get_obj(object_type='host', host_id=kwargs['host_id'])
+            if host.maintenance_mode == MaintenanceModeType.On:
+                actions = set()
+            else:
+                actions = self._get_host_actions(host)
+            obj = host
+            objects = {'host': host}
         else:
             obj, _ = get_obj(**kwargs)
             actions = filter_actions(
@@ -116,6 +126,7 @@ def get(self, request, *args, **kwargs):  # pylint: disable=too-many-locals
         perms = [f'cm.run_action_{a.display_name}' for a in actions]
         mask = [request.user.has_perm(perm, obj) for perm in perms]
         actions = list(compress(actions, mask))
+
         serializer = self.get_serializer(
             actions, many=True, context={'request': request, 'objects': objects, 'obj': obj}
         )
@@ -146,6 +157,7 @@ def get(self, request, *args, **kwargs):
             self.get_queryset(),
             id=action_id,
         )
+        set_disabling_cause(obj, action)
         if isinstance(obj, Host) and action.host_action:
             objects = {'host': obj}
         else:
@@ -173,6 +185,20 @@ def check_action_perm(self, action, obj):
         if not self.has_action_perm(action, obj):
             permission_denied()
 
+    @staticmethod
+    def check_maintenance_mode(action, obj):
+        if isinstance(obj, Host) and obj.maintenance_mode == MaintenanceModeType.On:
+            raise AdcmEx(
+                'ACTION_ERROR',
+                msg='you cannot start an action on a host that is in maintenance mode',
+            )
+        set_disabling_cause(obj, action)
+        if action.disabling_cause == 'maintenance_mode':
+            raise AdcmEx(
+                'ACTION_ERROR',
+                msg='you cannot start the action because at least one host is in maintenance mode',
+            )
+
     def post(self, request, *args, **kwargs):
         """
         Ran specified action
@@ -185,5 +211,6 @@ def post(self, request, *args, **kwargs):
         )
         action = get_object_for_user(request.user, 'cm.view_action', Action, id=action_id)
         self.check_action_perm(action, obj)
+        self.check_maintenance_mode(action, obj)
         serializer = self.get_serializer(data=request.data)
         return create(serializer, action=action, task_object=obj)
diff --git a/python/api/base_view.py b/python/api/base_view.py
index 4116a712e6..9145afea48 100644
--- a/python/api/base_view.py
+++ b/python/api/base_view.py
@@ -46,6 +46,8 @@ class GenericUIView(GenericAPIView):
 
     permission_classes = (DjangoObjectPermissions,)
     serializer_class_post: serializers.Serializer = None
+    serializer_class_put: serializers.Serializer = None
+    serializer_class_patch: serializers.Serializer = None
     serializer_class_ui: serializers.Serializer = None
     serializer_class: serializers.Serializer = None
 
@@ -58,6 +60,12 @@ def get_serializer_class(self):
             if self.request.method == 'POST':
                 if self.serializer_class_post:
                     return self.serializer_class_post
+            elif self.request.method == 'PUT':
+                if self.serializer_class_put:
+                    return self.serializer_class_put
+            elif self.request.method == 'PATCH':
+                if self.serializer_class_patch:
+                    return self.serializer_class_patch
             elif self._is_for_ui():
                 if self.serializer_class_ui:
                     return self.serializer_class_ui
diff --git a/python/api/cluster/serializers.py b/python/api/cluster/serializers.py
index 352ee9c919..f026c3bd1b 100644
--- a/python/api/cluster/serializers.py
+++ b/python/api/cluster/serializers.py
@@ -15,8 +15,12 @@
 
 import cm.api
 import cm.job
-import cm.status_api
 from api.action.serializers import ActionShort
+from api.component.serializers import ComponentDetailSerializer
+from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer
+from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField
+from api.host.serializers import HostSerializer
+from api.serializers import StringListSerializer
 from api.utils import (
     CommonAPIURL,
     ObjectURL,
@@ -26,14 +30,16 @@
     get_upgradable_func,
     hlink,
 )
-from api.component.serializers import ComponentDetailSerializer
-from api.concern.serializers import ConcernItemSerializer, ConcernItemUISerializer
-from api.group_config.serializers import GroupConfigsHyperlinkedIdentityField
-from api.host.serializers import HostSerializer
-from api.serializers import StringListSerializer
 from cm.adcm_config import get_main_info
 from cm.errors import AdcmEx
-from cm.models import Action, Cluster, Host, Prototype, ServiceComponent
+from cm.models import (
+    Action,
+    Cluster,
+    Host,
+    Prototype,
+    ServiceComponent,
+)
+from cm.status_api import get_cluster_status, get_hc_status
 
 
 def get_cluster_id(obj):
@@ -98,7 +104,7 @@ class ClusterDetailSerializer(ClusterSerializer):
     group_config = GroupConfigsHyperlinkedIdentityField(view_name='group-config-list')
 
     def get_status(self, obj):
-        return cm.status_api.get_cluster_status(obj)
+        return get_cluster_status(obj)
 
 
 class ClusterUISerializer(ClusterDetailSerializer):
@@ -146,7 +152,7 @@ def to_representation(self, instance):
         data['service_display_name'] = instance.service.prototype.display_name
         data['service_version'] = instance.service.prototype.version
         data['monitoring'] = instance.component.prototype.monitoring
-        status = cm.status_api.get_hc_status(instance)
+        status = get_hc_status(instance)
         data['status'] = status
         return data
 
diff --git a/python/api/group_config/serializers.py b/python/api/group_config/serializers.py
index 9464458d20..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,7 +23,7 @@
     MultiHyperlinkedIdentityField,
     UIConfigField,
 )
-from cm.adcm_config import config_is_ro
+from cm.adcm_config import ui_config
 from cm.api import update_obj_config
 from cm.errors import AdcmEx
 from cm.models import GroupConfig, Host, ObjectConfig, ConfigLog
@@ -164,6 +162,9 @@ class Meta:
             'fqdn',
             'description',
             'state',
+            'maintenance_mode',
+            'bundle_id',
+            'locked',
             'url',
         )
         read_only_fields = (
@@ -173,6 +174,9 @@ class Meta:
             'prototype_id',
             'provider_id',
             'cluster_id',
+            'maintenance_mode',
+            'bundle_id',
+            'locked',
         )
 
     def create(self, validated_data):
@@ -249,48 +253,16 @@ 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']:
-                        if config_is_ro(obj, k, spec[k]['limits']) or (
-                            bool(nc[k]) is False and cc[k] is None
-                        ):
-                            continue
-
-                    if not v and k in cc and k in nc and cc[k] != nc[k]:
-                        raise AdcmEx('GROUP_CONFIG_CHANGE_UNSELECTED_FIELD')
-
-        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')
+        ui = self.context.get('ui')
         config = validated_data.get('config')
         attr = validated_data.get('attr', {})
         description = validated_data.get('description', '')
         cl = update_obj_config(object_config, config, attr, description)
+        if ui:
+            cl.config = ui_config(object_config.object.object, cl)
         if hasattr(object_config, 'adcm'):
             logrotate.run()
         return cl
diff --git a/python/api/group_config/views.py b/python/api/group_config/views.py
index 8262a3794b..1cc775ffe8 100644
--- a/python/api/group_config/views.py
+++ b/python/api/group_config/views.py
@@ -173,6 +173,7 @@ def get_serializer_context(self):
         if obj_ref_id is not None:
             obj_ref = ObjectConfig.obj.get(id=obj_ref_id)
             context.update({'obj_ref': obj_ref})
+        context['ui'] = self._is_for_ui()
         return context
 
 
diff --git a/python/api/host/serializers.py b/python/api/host/serializers.py
index 8bd05c9098..d782318f20 100644
--- a/python/api/host/serializers.py
+++ b/python/api/host/serializers.py
@@ -20,7 +20,7 @@
 from cm.adcm_config import get_main_info
 from cm.api import add_host
 from cm.errors import AdcmEx
-from cm.models import HostProvider, Prototype, Action
+from cm.models import HostProvider, Prototype, Action, MaintenanceModeType
 from cm.stack import validate_name
 from cm.status_api import get_host_status
 
@@ -31,8 +31,9 @@ class HostSerializer(serializers.Serializer):
     prototype_id = serializers.IntegerField(help_text='id of host type')
     provider_id = serializers.IntegerField()
     fqdn = serializers.CharField(help_text='fully qualified domain name')
-    description = serializers.CharField(required=False)
+    description = serializers.CharField(required=False, allow_blank=True)
     state = serializers.CharField(read_only=True)
+    maintenance_mode = serializers.ChoiceField(choices=MaintenanceModeType.choices, read_only=True)
     url = ObjectURL(read_only=True, view_name='host-details')
 
     def validate_prototype_id(self, prototype_id):
@@ -70,6 +71,17 @@ def get_status(self, obj):
         return get_host_status(obj)
 
 
+class HostUpdateSerializer(HostDetailSerializer):
+    maintenance_mode = serializers.ChoiceField(choices=MaintenanceModeType.choices)
+
+    def update(self, instance, validated_data):
+        instance.maintenance_mode = validated_data.get(
+            'maintenance_mode', instance.maintenance_mode
+        )
+        instance.save()
+        return instance
+
+
 class ClusterHostSerializer(HostSerializer):
     host_id = serializers.IntegerField(source='id')
     prototype_id = serializers.IntegerField(read_only=True)
diff --git a/python/api/host/views.py b/python/api/host/views.py
index 3de39af3cd..b47356b4f6 100644
--- a/python/api/host/views.py
+++ b/python/api/host/views.py
@@ -22,7 +22,7 @@
     get_object_for_user,
     check_custom_perm,
 )
-from cm.api import remove_host_from_cluster, delete_host, add_host_to_cluster
+from cm.api import remove_host_from_cluster, delete_host, add_host_to_cluster, load_service_map
 from cm.errors import AdcmEx
 from cm.models import (
     Cluster,
@@ -32,6 +32,7 @@
     ClusterObject,
     ServiceComponent,
     HostComponent,
+    MaintenanceModeType,
 )
 from cm.status_api import make_ui_host_status
 from rbac.viewsets import DjangoOnlyObjectPermissions
@@ -200,6 +201,8 @@ class HostDetail(PermissionListMixin, DetailView):
     queryset = Host.objects.all()
     serializer_class = serializers.HostDetailSerializer
     serializer_class_ui = serializers.HostUISerializer
+    serializer_class_put = serializers.HostUpdateSerializer
+    serializer_class_patch = serializers.HostUpdateSerializer
     permission_classes = (DjangoOnlyObjectPermissions,)
     permission_required = ['cm.view_host']
     lookup_field = 'id'
@@ -230,6 +233,49 @@ def delete(self, request, *args, **kwargs):
             delete_host(host)
         return Response(status=status.HTTP_204_NO_CONTENT)
 
+    def patch(self, request, *args, **kwargs):
+        return self.__update_host_object(request, *args, **kwargs)
+
+    def put(self, request, *args, **kwargs):
+        return self.__update_host_object(request, partial=False, *args, **kwargs)
+
+    def __update_host_object(
+        self,
+        request,
+        *args,
+        partial=True,
+        **kwargs,
+    ):
+        host = self.get_object()
+        check_custom_perm(request.user, 'change', 'host', host)
+        serializer = self.get_serializer(
+            host,
+            data=request.data,
+            context={
+                'request': request,
+                'prototype_id': kwargs.get('prototype_id', None),
+                'cluster_id': kwargs.get('cluster_id', None),
+                'provider_id': kwargs.get('provider_id', None),
+            },
+            partial=partial,
+        )
+        if serializer.is_valid(raise_exception=True):
+            self.__check_maintenance_mode_constraint(
+                host.maintenance_mode, serializer.validated_data['maintenance_mode']
+            )
+            serializer.save(**kwargs)
+            load_service_map()
+            return Response(self.get_serializer(self.get_object()).data, status=status.HTTP_200_OK)
+        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+    @staticmethod
+    def __check_maintenance_mode_constraint(old_mode, new_mode):
+        if old_mode == MaintenanceModeType.Disabled or new_mode not in (
+            MaintenanceModeType.On,
+            MaintenanceModeType.Off,
+        ):
+            raise AdcmEx('MAINTENANCE_MODE_NOT_AVAILABLE')
+
 
 class StatusList(GenericUIView):
     queryset = HostComponent.objects.all()
diff --git a/python/api/stack/urls.py b/python/api/stack/urls.py
index 1a42cb22fb..1e7b5d33a1 100644
--- a/python/api/stack/urls.py
+++ b/python/api/stack/urls.py
@@ -25,6 +25,9 @@
     path(
         'load/servicemap/', views.LoadBundle.as_view({'put': 'servicemap'}), name='load-servicemap'
     ),
+    path(
+        'load/hostmap/', views.LoadBundle.as_view({'put': 'hostmap'}), name='load-hostmap'
+    ),
     path('bundle/', include([
         path('', views.BundleList.as_view(), name='bundle'),
         path('<int:bundle_id>/', include([
diff --git a/python/api/stack/views.py b/python/api/stack/views.py
index 94764a5ccf..76f644525e 100644
--- a/python/api/stack/views.py
+++ b/python/api/stack/views.py
@@ -64,6 +64,11 @@ def servicemap(self, request):
         cm.api.load_service_map()
         return Response(status=status.HTTP_200_OK)
 
+    @decorators.action(methods=['put'], detail=False)
+    def hostmap(self, request):
+        cm.api.load_host_map()
+        return Response(status=status.HTTP_200_OK)
+
     def create(self, request, *args, **kwargs):
         """
         post:
diff --git a/python/api/utils.py b/python/api/utils.py
index ed41336535..fad8965836 100644
--- a/python/api/utils.py
+++ b/python/api/utils.py
@@ -25,7 +25,15 @@
 
 import cm.upgrade
 from cm.errors import AdcmEx
-from cm.models import Action, ADCMEntity, PrototypeConfig, ConcernType
+from cm.models import (
+    Action,
+    ADCMEntity,
+    PrototypeConfig,
+    ConcernType,
+    HostComponent,
+    MaintenanceModeType,
+    Host,
+)
 
 
 def get_object_for_user(user, perms, klass, **kwargs):
@@ -90,6 +98,36 @@ def update(serializer, **kwargs):
     return save(serializer, status.HTTP_200_OK, **kwargs)
 
 
+def set_disabling_cause(obj: ADCMEntity, action: Action) -> None:
+    action.disabling_cause = None
+    if obj.prototype.type == 'cluster':
+        mm = Host.objects.filter(cluster=obj, maintenance_mode=MaintenanceModeType.On).exists()
+        if not action.allow_in_maintenance_mode and mm:
+            action.disabling_cause = 'maintenance_mode'
+    elif obj.prototype.type == 'service':
+        mm = HostComponent.objects.filter(
+            service=obj, cluster=obj.cluster, host__maintenance_mode=MaintenanceModeType.On
+        ).exists()
+        if not action.allow_in_maintenance_mode and mm:
+            action.disabling_cause = 'maintenance_mode'
+    elif obj.prototype.type == 'component':
+        mm = HostComponent.objects.filter(
+            component=obj,
+            cluster=obj.cluster,
+            service=obj.service,
+            host__maintenance_mode=MaintenanceModeType.On,
+        ).exists()
+        if not action.allow_in_maintenance_mode and mm:
+            action.disabling_cause = 'maintenance_mode'
+    elif obj.prototype.type == 'host':
+        mm = HostComponent.objects.filter(
+            component_id__in=HostComponent.objects.filter(host=obj).values_list('component_id'),
+            host__maintenance_mode=MaintenanceModeType.On,
+        ).exists()
+        if action.host_action and not action.allow_in_maintenance_mode and mm:
+            action.disabling_cause = 'maintenance_mode'
+
+
 def filter_actions(obj: ADCMEntity, actions_set: List[Action]):
     """Filter out actions that are not allowed to run on object at that moment"""
     if obj.concerns.filter(type=ConcernType.Lock).exists():
@@ -102,6 +140,7 @@ def filter_actions(obj: ADCMEntity, actions_set: List[Action]):
             action.config = PrototypeConfig.objects.filter(
                 prototype=action.prototype, action=action
             ).order_by('id')
+            set_disabling_cause(obj, action)
     return allowed
 
 
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/adcm_schema.yaml b/python/cm/adcm_schema.yaml
index 86f1ecbae5..5e0322314a 100644
--- a/python/cm/adcm_schema.yaml
+++ b/python/cm/adcm_schema.yaml
@@ -59,6 +59,7 @@ cluster_object:
     import: import_dict
     export: export
     config_group_customization: boolean
+    allow_maintenance_mode: boolean
 
 host_object:
   <<: *common_object
@@ -699,6 +700,7 @@ common_action: &common_action
     hc_acl: action_hc_acl_list
     config: config_obj
     venv: string
+    allow_in_maintenance_mode: boolean
 
 ## Task action
 action_task_dict:
diff --git a/python/cm/api.py b/python/cm/api.py
index 256071b447..d9c3f65dbe 100644
--- a/python/cm/api.py
+++ b/python/cm/api.py
@@ -51,8 +51,8 @@
     ServiceComponent,
     TaskLog,
     Bundle,
+    MaintenanceModeType,
 )
-
 from rbac.models import re_apply_object_policy
 
 
@@ -85,6 +85,16 @@ def check_proto_type(proto, check_type):
         err('OBJ_TYPE_ERROR', msg.format(check_type, proto.type))
 
 
+def load_host_map():
+    hosts = list(Host.objects.values('id', 'maintenance_mode'))
+    for host in hosts:
+        if host['maintenance_mode'] == MaintenanceModeType.On:
+            host['maintenance_mode'] = True
+        else:
+            host['maintenance_mode'] = False
+    return cm.status_api.api_request('post', '/object/host/', hosts)
+
+
 def load_service_map():
     comps = {}
     hosts = {}
@@ -125,7 +135,8 @@ def load_service_map():
         'service': services,
         'host': hosts,
     }
-    return cm.status_api.api_post('/servicemap/', m)
+    cm.status_api.api_request('post', '/servicemap/', m)
+    load_host_map()
 
 
 def add_cluster(proto, name, desc=''):
@@ -219,6 +230,9 @@ def add_host_to_cluster(cluster, host):
         else:
             err('HOST_CONFLICT')
     with transaction.atomic():
+        DummyData.objects.filter(id=1).update(date=timezone.now())
+        if cluster.prototype.allow_maintenance_mode:
+            host.maintenance_mode = MaintenanceModeType.Off.value
         host.cluster = cluster
         host.save()
         host.add_to_concerns(ctx.lock)
@@ -335,7 +349,7 @@ def delete_service_by_name(service_name, cluster_id):
         delete_service(service, cancel_tasks=False)
 
 
-def delete_service(service, cancel_tasks=True):
+def delete_service(service: ClusterObject, cancel_tasks=True) -> None:
     if HostComponent.objects.filter(cluster=service.cluster, service=service).exists():
         err('SERVICE_CONFLICT', f'Service #{service.id} has component(s) on host(s)')
     if ClusterBind.objects.filter(source_service=service).exists():
@@ -345,6 +359,7 @@ def delete_service(service, cancel_tasks=True):
     service_id = service.id
     cluster = service.cluster
     service.delete()
+    cm.issue.update_issue_after_deleting()
     cm.issue.update_hierarchy_issues(cluster)
     re_apply_object_policy(cluster)
     cm.status_api.post_event('delete', 'service', service_id)
@@ -356,7 +371,17 @@ def delete_cluster(cluster, cancel_tasks=True):
     if cancel_tasks:
         _cancel_locking_tasks(cluster)
     cluster_id = cluster.id
+    hosts = cluster.host_set.all()
+    host_ids = [str(host.id) for host in hosts]
+    hosts.update(maintenance_mode=MaintenanceModeType.Disabled)
+    log.debug(
+        'Deleting cluster #%s. Set `%s` maintenance mode value for `%s` hosts.',
+        cluster_id,
+        MaintenanceModeType.Disabled,
+        ', '.join(host_ids),
+    )
     cluster.delete()
+    cm.issue.update_issue_after_deleting()
     cm.status_api.post_event('delete', 'cluster', cluster_id)
     load_service_map()
 
@@ -367,6 +392,7 @@ def remove_host_from_cluster(host):
     if hc:
         return err('HOST_CONFLICT', f'Host #{host.id} has component(s)')
     with transaction.atomic():
+        host.maintenance_mode = MaintenanceModeType.Disabled.value
         host.cluster = None
         host.save()
         for group in cluster.group_config.all():
@@ -467,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)
@@ -552,9 +575,19 @@ def check_sub(sub_key, sub_type, item):
 
     cm.issue.check_component_requires(host_comp_list)
     cm.issue.check_bound_components(host_comp_list)
+    check_maintanence_mode(cluster, host_comp_list)
     return host_comp_list
 
 
+def check_maintanence_mode(cluster, host_comp_list):
+    for (service, host, comp) in host_comp_list:
+        try:
+            HostComponent.objects.get(cluster=cluster, service=service, host=host, component=comp)
+        except HostComponent.DoesNotExist:
+            if host.maintenance_mode == MaintenanceModeType.On.value:
+                raise AdcmEx("INVALID_HC_HOST_IN_MM")  # pylint: disable=raise-missing-from
+
+
 def still_existed_hc(cluster, host_comp_list):
     result = []
     for (service, host, comp) in host_comp_list:
diff --git a/python/cm/bundle.py b/python/cm/bundle.py
index c6f2db3dc0..e107633379 100644
--- a/python/cm/bundle.py
+++ b/python/cm/bundle.py
@@ -417,6 +417,7 @@ def copy_stage_prototype(stage_prototypes, bundle):
                 'adcm_min_version',
                 'venv',
                 'config_group_customization',
+                'allow_maintenance_mode',
             ),
         )
         p.bundle = bundle
@@ -490,6 +491,7 @@ def copy_stage_actions(stage_actions, prototype):
             'partial_execution',
             'host_action',
             'venv',
+            'allow_in_maintenance_mode',
         ),
     )
     Action.objects.bulk_create(actions)
@@ -673,6 +675,7 @@ def update_bundle_from_stage(
             p.adcm_min_version = sp.adcm_min_version
             p.venv = sp.venv
             p.config_group_customization = sp.config_group_customization
+            p.allow_maintenance_mode = sp.allow_maintenance_mode
         except Prototype.DoesNotExist:
             p = copy_obj(
                 sp,
@@ -693,6 +696,7 @@ def update_bundle_from_stage(
                     'adcm_min_version',
                     'venv',
                     'config_group_customization',
+                    'allow_maintenance_mode',
                 ),
             )
             p.bundle = bundle
@@ -726,6 +730,7 @@ def update_bundle_from_stage(
                         'partial_execution',
                         'host_action',
                         'venv',
+                        'allow_in_maintenance_mode',
                     ),
                 )
             except Action.DoesNotExist:
@@ -756,6 +761,7 @@ def update_bundle_from_stage(
                         'partial_execution',
                         'host_action',
                         'venv',
+                        'allow_in_maintenance_mode',
                     ),
                 )
                 action.prototype = p
diff --git a/python/cm/errors.py b/python/cm/errors.py
index 5f1a9b55cf..2ec1e72dbb 100644
--- a/python/cm/errors.py
+++ b/python/cm/errors.py
@@ -157,6 +157,16 @@
         rfs.HTTP_400_BAD_REQUEST,
         ERR,
     ),
+    'MAINTENANCE_MODE_NOT_AVAILABLE': (
+        'you can\'t manage host maintenance mode',
+        rfs.HTTP_409_CONFLICT,
+        ERR,
+    ),
+    'INVALID_HC_HOST_IN_MM': (
+        'you can\'t save hc with hosts in maintenance mode',
+        rfs.HTTP_409_CONFLICT,
+        ERR,
+    ),
 }
 
 
diff --git a/python/cm/inventory.py b/python/cm/inventory.py
index df64fd69b4..542852a068 100644
--- a/python/cm/inventory.py
+++ b/python/cm/inventory.py
@@ -33,6 +33,7 @@
     PrototypeImport,
     get_object_cluster,
     GroupConfig,
+    MaintenanceModeType,
 )
 
 
@@ -178,6 +179,16 @@ def get_host_vars(host: Host, obj):
                     }
                 }
             )
+            for service in ClusterObject.objects.filter(cluster=group.object.cluster).exclude(
+                pk=group.object.id
+            ):
+                variables['services'][service.prototype.name] = get_service_variables(service)
+                for component in ServiceComponent.objects.filter(
+                    cluster=group.object.cluster, service=service
+                ):
+                    variables['services'][service.prototype.name][
+                        component.prototype.name
+                    ] = get_component_variables(component)
             for component in ServiceComponent.objects.filter(
                 cluster=group.object.cluster, service=group.object
             ):
@@ -235,10 +246,13 @@ def get_provider_config(provider_id):
 
 
 def get_host_groups(cluster, delta, action_host=None):
+    def in_mm(hc: HostComponent) -> bool:
+        return hc.host.maintenance_mode == MaintenanceModeType.On.value
+
     groups = {}
     all_hosts = HostComponent.objects.filter(cluster=cluster)
     for hc in all_hosts:
-        if action_host and hc.host.id not in action_host:
+        if in_mm(hc) or (action_host and hc.host.id not in action_host):
             continue
 
         key1 = f'{hc.service.prototype.name}.{hc.component.prototype.name}'
@@ -261,7 +275,8 @@ def get_host_groups(cluster, delta, action_host=None):
             for fqdn in delta[htype][key]:
                 host = delta[htype][key][fqdn]
                 # TODO: What is `delta`? Need calculate delta for group_config?
-                groups[lkey]['hosts'][host.fqdn] = get_obj_config(host)
+                if not host.maintenance_mode == MaintenanceModeType.On.value:
+                    groups[lkey]['hosts'][host.fqdn] = get_obj_config(host)
 
     return groups
 
@@ -269,7 +284,9 @@ def get_host_groups(cluster, delta, action_host=None):
 def get_hosts(host_list, obj, action_host=None):
     group = {}
     for host in host_list:
-        if action_host and host.id not in action_host:
+        if host.maintenance_mode == MaintenanceModeType.On.value or (
+            action_host and host.id not in action_host
+        ):
             continue
         group[host.fqdn] = get_obj_config(host)
         group[host.fqdn]['adcm_hostid'] = host.id
diff --git a/python/cm/issue.py b/python/cm/issue.py
index ef42182b47..a5a84fbe04 100644
--- a/python/cm/issue.py
+++ b/python/cm/issue.py
@@ -62,6 +62,14 @@ def check_config(obj):  # pylint: disable=too-many-branches
     return True
 
 
+def check_object_concern(obj):
+    if obj.concerns.filter(type=ConcernType.Lock).exists():
+        err('LOCK_ERROR', f'object {obj} is locked')
+
+    if obj.concerns.filter(type=ConcernType.Issue).exists():
+        err('ISSUE_INTEGRITY_ERROR', f'object {obj} has issues')
+
+
 def check_required_services(cluster):
     bundle = cluster.prototype.bundle
     for proto in Prototype.objects.filter(bundle=bundle, type='service', required=True):
@@ -314,5 +322,14 @@ def update_hierarchy_issues(obj: ADCMEntity):
     tree = Tree(obj)
     affected_nodes = tree.get_directly_affected(tree.built_from)
     for node in affected_nodes:
-        obj = node.value
-        recheck_issues(obj)
+        node_value = node.value
+        recheck_issues(node_value)
+
+
+def update_issue_after_deleting():
+    """Remove issues which have no owners after object deleting"""
+    for concern in ConcernItem.objects.exclude(type=ConcernType.Lock):
+        if concern.owner is None:
+            concern_str = str(concern)
+            concern.delete()
+            log.info('Deleted %s', concern_str)
diff --git a/python/cm/job.py b/python/cm/job.py
index 6302a57502..df5c88b1ee 100644
--- a/python/cm/job.py
+++ b/python/cm/job.py
@@ -24,7 +24,7 @@
 from django.db import transaction
 from django.utils import timezone
 
-from cm import api, inventory, adcm_config, variant, config
+from cm import api, inventory, adcm_config, variant, config, issue
 from cm.adcm_config import process_file_type
 from cm.api_context import ctx
 from cm.errors import raise_AdcmEx as err
@@ -38,7 +38,6 @@
     ActionType,
     Cluster,
     ClusterObject,
-    ConcernType,
     ConfigLog,
     DummyData,
     Host,
@@ -169,11 +168,7 @@ def check_action_state(action: Action, task_object: ADCMEntity, cluster: Cluster
     else:
         obj = task_object
 
-    if obj.concerns.filter(type=ConcernType.Lock).exists():
-        err('TASK_ERROR', 'object is locked')
-
-    if obj.concerns.filter(type=ConcernType.Issue).exists():
-        err('TASK_ERROR', 'object has issues')
+    issue.check_object_concern(obj)
 
     if action.allowed(obj):
         return
@@ -283,6 +278,10 @@ def check_hostcomponentmap(
     if not cluster:
         err('TASK_ERROR', 'Only cluster objects can have action with hostcomponentmap')
 
+    for host_comp in hc:
+        host = Host.obj.get(id=host_comp.get('host_id', 0))
+        issue.check_object_concern(host)
+
     return api.check_hc(cluster, hc)
 
 
diff --git a/python/cm/migrations/0087_maintenance_mode.py b/python/cm/migrations/0087_maintenance_mode.py
new file mode 100644
index 0000000000..756a77fa04
--- /dev/null
+++ b/python/cm/migrations/0087_maintenance_mode.py
@@ -0,0 +1,54 @@
+# 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-04-19 08:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cm', '0086_set_mitogen_to_false'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='host',
+            name='maintenance_mode',
+            field=models.CharField(
+                choices=[('disabled', 'disabled'), ('on', 'on'), ('off', 'off')],
+                default='disabled',
+                max_length=16,
+            ),
+        ),
+        migrations.AddField(
+            model_name='action',
+            name='allow_in_maintenance_mode',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='prototype',
+            name='allow_maintenance_mode',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='stageaction',
+            name='allow_in_maintenance_mode',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='stageprototype',
+            name='allow_maintenance_mode',
+            field=models.BooleanField(default=False),
+        ),
+    ]
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 ed5e36a465..58c22edfe0 100644
--- a/python/cm/models.py
+++ b/python/cm/models.py
@@ -16,9 +16,9 @@
 
 from __future__ import unicode_literals
 
+import os.path
 import signal
 import time
-import os.path
 from collections.abc import Mapping
 from copy import deepcopy
 from enum import Enum
@@ -271,6 +271,7 @@ class Prototype(ADCMModel):
     description = models.TextField(blank=True)
     config_group_customization = models.BooleanField(default=False)
     venv = models.CharField(default="default", max_length=160, blank=False)
+    allow_maintenance_mode = models.BooleanField(default=False)
 
     __error_code__ = 'PROTOTYPE_NOT_FOUND'
 
@@ -306,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)
@@ -316,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 = []
@@ -344,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
@@ -589,11 +632,22 @@ def serialized_issue(self):
         return result if result['issue'] else {}
 
 
+class MaintenanceModeType(models.TextChoices):
+    Disabled = 'disabled', 'disabled'
+    On = 'on', 'on'
+    Off = 'off', 'off'
+
+
 class Host(ADCMEntity):
     fqdn = models.CharField(max_length=160, unique=True)
     description = models.TextField(blank=True)
     provider = models.ForeignKey(HostProvider, on_delete=models.CASCADE, null=True, default=None)
     cluster = models.ForeignKey(Cluster, on_delete=models.SET_NULL, null=True, default=None)
+    maintenance_mode = models.CharField(
+        max_length=16,
+        choices=MaintenanceModeType.choices,
+        default=MaintenanceModeType.Disabled.value,
+    )
 
     __error_code__ = 'HOST_NOT_FOUND'
 
@@ -777,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)
@@ -817,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]
@@ -826,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)
@@ -837,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):
@@ -994,6 +1075,7 @@ class AbstractAction(ADCMModel):
     allow_to_terminate = models.BooleanField(default=False)
     partial_execution = models.BooleanField(default=False)
     host_action = models.BooleanField(default=False)
+    allow_in_maintenance_mode = models.BooleanField(default=False)
 
     _venv = models.CharField(default="default", db_column="venv", max_length=160, blank=False)
 
@@ -1362,6 +1444,7 @@ class StagePrototype(ADCMModel):
     monitoring = models.CharField(max_length=16, choices=MONITORING_TYPE, default='active')
     config_group_customization = models.BooleanField(default=False)
     venv = models.CharField(default="default", max_length=160, blank=False)
+    allow_maintenance_mode = models.BooleanField(default=False)
 
     __error_code__ = 'PROTOTYPE_NOT_FOUND'
 
diff --git a/python/cm/stack.py b/python/cm/stack.py
index a691f1a9ab..a98204bb69 100644
--- a/python/cm/stack.py
+++ b/python/cm/stack.py
@@ -153,6 +153,7 @@ def save_prototype(path, conf, def_type, bundle_hash):
     dict_to_obj(conf, 'venv', proto)
     dict_to_obj(conf, 'edition', proto)
     dict_to_obj(conf, 'config_group_customization', proto)
+    dict_to_obj(conf, 'allow_maintenance_mode', proto)
     fix_display_name(conf, proto)
     license_hash = get_license_hash(proto, conf, bundle_hash)
     if license_hash:
@@ -439,6 +440,7 @@ def save_action(proto, ac, bundle_hash, action_name):
     dict_to_obj(ac, 'params', action)
     dict_to_obj(ac, 'log_files', action)
     dict_to_obj(ac, 'venv', action)
+    dict_to_obj(ac, 'allow_in_maintenance_mode', action)
     fix_display_name(ac, action)
     check_action_hc(proto, ac, action_name)
     dict_to_obj(ac, 'hc_acl', action, 'hostcomponentmap')
diff --git a/python/cm/status_api.py b/python/cm/status_api.py
index 47b8f98983..3df4053a93 100644
--- a/python/cm/status_api.py
+++ b/python/cm/status_api.py
@@ -18,7 +18,14 @@
 
 from cm.config import STATUS_SECRET_KEY
 from cm.logger import log
-from cm.models import ADCMEntity, ServiceComponent, HostComponent, ClusterObject, Cluster, Host
+from cm.models import (
+    ADCMEntity,
+    ServiceComponent,
+    HostComponent,
+    ClusterObject,
+    Cluster,
+    Host,
+)
 
 API_URL = "http://localhost:8020/api/v1"
 TIMEOUT = 0.01
@@ -52,48 +59,27 @@ def set_task_status(self, task_id, status):
         self.events.append((set_task_status, (task_id, status)))
 
 
-def api_post(path, data):
-    url = API_URL + path
-    try:
-        r = requests.post(
-            url,
-            data=json.dumps(data),
-            headers={
-                'Content-Type': 'application/json',
-                'Authorization': 'Token ' + STATUS_SECRET_KEY,
-            },
-            timeout=TIMEOUT,
-        )
-        if r.status_code not in (200, 201):
-            log.error("POST %s error %d: %s", url, r.status_code, r.text)
-        return r
-    except requests.exceptions.Timeout:
-        log.error("POST request to %s timed out", url)
-        return None
-    except requests.exceptions.ConnectionError:
-        log.error("POST request to %s connection failed", url)
-        return None
-
-
-def api_get(path):
-    url = API_URL + path
+def api_request(method, url, data=None):
+    url = API_URL + url
+    kwargs = {
+        'headers': {
+            'Content-Type': 'application/json',
+            'Authorization': 'Token ' + STATUS_SECRET_KEY,
+        },
+        'timeout': TIMEOUT,
+    }
+    if data is not None:
+        kwargs['data'] = json.dumps(data)
     try:
-        r = requests.get(
-            url,
-            headers={
-                'Content-Type': 'application/json',
-                'Authorization': 'Token ' + STATUS_SECRET_KEY,
-            },
-            timeout=TIMEOUT,
-        )
-        if r.status_code not in (200, 201):
-            log.error("GET %s error %d: %s", url, r.status_code, r.text)
-        return r
+        request = requests.request(method, url, **kwargs)
+        if request.status_code not in (200, 201):
+            log.error("%s %s error %d: %s", method, url, request.status_code, request.text)
+        return request
     except requests.exceptions.Timeout:
-        log.error("GET request to %s timed out", url)
+        log.error("%s request to %s timed out", method, url)
         return None
     except requests.exceptions.ConnectionError:
-        log.error("GET request to %s connection failed", url)
+        log.error("%s request to %s connection failed", method, url)
         return None
 
 
@@ -110,7 +96,7 @@ def post_event(event, obj_type, obj_id, det_type=None, det_val=None):
         },
     }
     log.debug('post_event %s', data)
-    return api_post('/event/', data)
+    return api_request('post', '/event/', data)
 
 
 def set_job_status(job_id, status):
@@ -140,7 +126,7 @@ def change_obj_multi_state(obj_type, obj_id, multi_state):
 
 
 def get_raw_status(url):
-    r = api_get(url)
+    r = api_request('get', url)
     if r is None:
         return 32
     try:
@@ -185,9 +171,9 @@ def get_component_status(comp: ServiceComponent):
 
 def get_object_map(obj: ADCMEntity, url_type: str):
     if url_type == 'service':
-        r = api_get(f'/cluster/{obj.cluster.id}/service/{obj.id}/?view=interface')
+        r = api_request('get', f'/cluster/{obj.cluster.id}/service/{obj.id}/?view=interface')
     else:
-        r = api_get(f'/{url_type}/{obj.id}/?view=interface')
+        r = api_request('get', f'/{url_type}/{obj.id}/?view=interface')
     if r is None:
         return None
     return r.json()
diff --git a/python/cm/upgrade.py b/python/cm/upgrade.py
index a9f8bca03c..ccf62bc7a7 100644
--- a/python/cm/upgrade.py
+++ b/python/cm/upgrade.py
@@ -34,6 +34,7 @@
     PrototypeImport,
     ServiceComponent,
     Upgrade,
+    MaintenanceModeType,
 )
 
 
@@ -323,6 +324,12 @@ def bundle_switch(obj: Union[Cluster, HostProvider], upgrade: Upgrade):
 
         if obj.prototype.type == 'cluster':
             switch_services(upgrade, obj)
+            if not old_proto.allow_maintenance_mode and new_proto.allow_maintenance_mode:
+                Host.objects.filter(cluster=obj).update(maintenance_mode=MaintenanceModeType.Off)
+            elif old_proto.allow_maintenance_mode and not new_proto.allow_maintenance_mode:
+                Host.objects.filter(cluster=obj).update(
+                    maintenance_mode=MaintenanceModeType.Disabled
+                )
         elif obj.prototype.type == 'provider':
             switch_hosts(upgrade, obj)
         cm.issue.update_hierarchy_issues(obj)
diff --git a/python/rbac/roles.py b/python/rbac/roles.py
index 2565b08028..916a670ace 100644
--- a/python/rbac/roles.py
+++ b/python/rbac/roles.py
@@ -208,11 +208,14 @@ def re_apply_policy_for_jobs(action_object, task):
                     gop = GroupObjectPermission.objects.get(
                         group=group, permission__codename='view_action', object_pk=task.action.pk
                     )
-                except UserObjectPermission.DoesNotExist:
+                    model_view_gop = GroupObjectPermission.objects.get(
+                        group=group,
+                        permission__codename=f'view_{object_model}',
+                        object_pk=action_object.pk,
+                    )
+                except GroupObjectPermission.DoesNotExist:
                     continue
-                if gop in policy.group_object_perm.all() and group.has_perm(
-                    f'view_{object_model}', action_object
-                ):
+                if gop in policy.group_object_perm.all() and model_view_gop:
                     policy.role.child.add(task_role)
                     apply_jobs(task, policy, None, group)
 
diff --git a/python/rbac/upgrade/role_spec.yaml b/python/rbac/upgrade/role_spec.yaml
index 8b3a70db79..1302d65a7d 100644
--- a/python/rbac/upgrade/role_spec.yaml
+++ b/python/rbac/upgrade/role_spec.yaml
@@ -1,6 +1,6 @@
 ---
 
-version: 2
+version: 3
 
 roles:
     - name: Add host
@@ -1463,6 +1463,7 @@ roles:
         - Create host
         - Upload bundle
         - Remove bundle
+        - Manage Maintenance mode
 
     - name: Provider Administrator
       type: role
@@ -1484,3 +1485,17 @@ roles:
         - Upload bundle
         - Remove bundle
 
+    - name: Manage Maintenance mode
+      description: the ability to enable / disable the "MM" on the host
+      type: business
+      parametrized_by:
+        - host
+      module_name: rbac.roles
+      class_name: ObjectRole
+      apps:
+        - label: cm
+          models:
+            - name: host
+              codenames:
+                - view
+                - change
diff --git a/web/src/adcm-theme.scss b/web/src/adcm-theme.scss
index 7dc8fdd076..0c0a672c47 100644
--- a/web/src/adcm-theme.scss
+++ b/web/src/adcm-theme.scss
@@ -22,7 +22,7 @@ a {
 .select-table-item {
   background-color: mat-color($mat-blue-grey, 500)!important;
 
-  & button.mat-icon-button.mat-primary {
+  & button:not(.mm-button).mat-icon-button.mat-primary {
     color: mat-color($mat-green, A400);
   }
 }
diff --git a/web/src/app/abstract-directives/list.directive.ts b/web/src/app/abstract-directives/list.directive.ts
index b118013dcc..9c641ca604 100644
--- a/web/src/app/abstract-directives/list.directive.ts
+++ b/web/src/app/abstract-directives/list.directive.ts
@@ -110,6 +110,11 @@ export abstract class ListDirective extends BaseDirective implements OnInit, OnD
       .subscribe(() => this.listItemEvt.emit({ cmd: 'delete', row }));
   }
 
+  maintenanceModeToggle($event: MouseEvent, row: any): void {
+    EventHelper.stopPropagation($event);
+    this.listItemEvt.emit({ cmd: 'maintenanceModeToggle', row} )
+  }
+
   getPageIndex(): number {
     return this.paginator.pageIndex;
   }
diff --git a/web/src/app/components/cluster/host/cluster-host.component.ts b/web/src/app/components/cluster/host/cluster-host.component.ts
index ab255ac710..5d2b71bfac 100644
--- a/web/src/app/components/cluster/host/cluster-host.component.ts
+++ b/web/src/app/components/cluster/host/cluster-host.component.ts
@@ -39,6 +39,7 @@ export class ClusterHostComponent extends ConcernListDirective<IHost> {
     ListFactory.statusColumn(this),
     ListFactory.actionsButton(this),
     ListFactory.configColumn(this),
+    ListFactory.maintenanceModeColumn(this),
     {
       type: 'buttons',
       className: 'list-control',
diff --git a/web/src/app/components/cluster/services/services.component.ts b/web/src/app/components/cluster/services/services.component.ts
index fd92e60a64..cde02cfe62 100644
--- a/web/src/app/components/cluster/services/services.component.ts
+++ b/web/src/app/components/cluster/services/services.component.ts
@@ -43,6 +43,7 @@ export class ServicesComponent extends ConcernListDirective<IClusterService> {
     ListFactory.actionsButton(this),
     ListFactory.importColumn(this),
     ListFactory.configColumn(this),
+    ListFactory.deleteColumn(this),
   ] as IColumns<IClusterService>;
 
 }
diff --git a/web/src/app/components/host/host-list/host-list.component.ts b/web/src/app/components/host/host-list/host-list.component.ts
index c651efaa6a..36cdd263da 100644
--- a/web/src/app/components/host/host-list/host-list.component.ts
+++ b/web/src/app/components/host/host-list/host-list.component.ts
@@ -98,6 +98,7 @@ export class HostListComponent extends ConcernListDirective<IHost> {
     ListFactory.statusColumn(this),
     ListFactory.actionsButton(this),
     ListFactory.configColumn(this),
+    ListFactory.maintenanceModeColumn(this),
     ListFactory.deleteColumn(this),
   ] as IColumns<IHost>;
 
diff --git a/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.html b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.html
new file mode 100644
index 0000000000..b9b21d5bf8
--- /dev/null
+++ b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.html
@@ -0,0 +1,11 @@
+<div [matTooltip]="status?.tooltip" (click)="preventIfDisabled($event)">
+  <button
+    mat-icon-button
+    class="mm-button"
+    color="{{status?.color}}"
+    [disabled]="!status?.isModeActive"
+    (click)="clickCell($event, row)"
+  >
+  <mat-icon>medical_services</mat-icon>
+  </button>
+</div>
diff --git a/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.scss b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.scss
new file mode 100644
index 0000000000..e2cf1777d7
--- /dev/null
+++ b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.scss
@@ -0,0 +1,5 @@
+.mat-button.mat-on,
+.mat-icon-button.mat-on,
+.mat-stroked-button.mat-on {
+  color: #dc3545 !important;
+}
diff --git a/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.spec.ts b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.spec.ts
new file mode 100644
index 0000000000..418f170080
--- /dev/null
+++ b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.spec.ts
@@ -0,0 +1,28 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MaintenanceModeButtonComponent } from './maintenance-mode-button.component';
+import { MatIconModule } from "@angular/material/icon";
+
+describe('MaintenanceModeButtonComponent', () => {
+  let component: MaintenanceModeButtonComponent<any>;
+  let fixture: ComponentFixture<MaintenanceModeButtonComponent<any>>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [ MaintenanceModeButtonComponent ],
+      imports: [
+        MatIconModule,
+      ],
+    })
+    .compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MaintenanceModeButtonComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.ts b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.ts
new file mode 100644
index 0000000000..98787528fd
--- /dev/null
+++ b/web/src/app/components/maintenance-mode-button/maintenance-mode-button.component.ts
@@ -0,0 +1,74 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { AdwpCellComponent } from "@adwp-ui/widgets";
+
+export enum StatusType {
+  On = 'on',
+  Off = 'off',
+  Disabled = 'disabled'
+}
+
+export interface Status {
+  isButtonActive: boolean;
+  isModeActive: boolean;
+  color: string;
+  tooltip: string;
+}
+
+@Component({
+  selector: 'app-maintenance-mode-button',
+  templateUrl: './maintenance-mode-button.component.html',
+  styleUrls: ['./maintenance-mode-button.component.scss']
+})
+export class MaintenanceModeButtonComponent<T> implements AdwpCellComponent<T> {
+  status: Status;
+  maintenanceMode: string;
+
+  statuses: { [key in StatusType]: Status; } = {
+    [StatusType.On]: {
+      isButtonActive: true,
+      isModeActive: true,
+      color: 'on',
+      tooltip: 'Turn maintenance mode OFF'
+    },
+    [StatusType.Off]: {
+      isButtonActive: false,
+      isModeActive: true,
+      color: 'primary',
+      tooltip:'Turn maintenance mode ON'
+    },
+    [StatusType.Disabled]: {
+      isButtonActive: false,
+      isModeActive: false,
+      color: 'primary',
+      tooltip: 'Maintenance mode is not available'
+    }
+  }
+
+  @Input() row: T;
+  @Output() onClick = new EventEmitter();
+
+  ngOnInit(): void {
+    if (!this.row) return;
+    this.maintenanceMode = this.row['maintenance_mode'];
+    this.status = this.statuses[this.maintenanceMode];
+  }
+
+  clickCell(event: MouseEvent, row: T): void {
+    if (this.maintenanceMode !== StatusType.Disabled && this.maintenanceMode === StatusType.On) {
+      this.maintenanceMode = StatusType.Off;
+      this.status = this.statuses[StatusType.Off];
+    } else if (this.maintenanceMode !== StatusType.Disabled && this.maintenanceMode === StatusType.Off) {
+      this.maintenanceMode = StatusType.On;
+      this.status = this.statuses[StatusType.On];
+    }
+
+    this.onClick.emit({ event, value: { id: this.row['id'], maintenance_mode: this.maintenanceMode } });
+  }
+
+  preventIfDisabled(event) {
+    if (!this.status?.isModeActive) {
+      event.stopPropagation();
+      event.preventDefault();
+    }
+  }
+}
diff --git a/web/src/app/core/types/actions.ts b/web/src/app/core/types/actions.ts
index a43ec7045f..d9496772bc 100644
--- a/web/src/app/core/types/actions.ts
+++ b/web/src/app/core/types/actions.ts
@@ -12,11 +12,11 @@
 import { IConfig } from '@app/shared/configuration/types';
 
 /**
- * 
+ *
  *```
 {
-    action: 'add' | 'remove';  
-    component: string;    // name of servise to work with  
+    action: 'add' | 'remove';
+    component: string;    // name of servise to work with
     service: string;      // name of component to work with
 }
 ```
@@ -36,6 +36,7 @@ export interface IAction {
   name: string;
   description: string;
   display_name: string;
+  disabling_cause: string;
   run: string;
   config: IConfig;
   hostcomponentmap: IActionParameter[];
diff --git a/web/src/app/core/types/api.ts b/web/src/app/core/types/api.ts
index 2d7f861a93..0c5afc8ea8 100644
--- a/web/src/app/core/types/api.ts
+++ b/web/src/app/core/types/api.ts
@@ -90,6 +90,7 @@ export interface Host extends BaseEntity {
   cluster: string;
   cluster_id?: number;
   cluster_name?: string;
+  maintenance_mode?: string;
 }
 
 export interface Service extends BaseEntity {
diff --git a/web/src/app/factories/list.factory.ts b/web/src/app/factories/list.factory.ts
index c9db0783f1..b94da0b5d3 100644
--- a/web/src/app/factories/list.factory.ts
+++ b/web/src/app/factories/list.factory.ts
@@ -8,6 +8,9 @@ import { UpgradeComponent } from '@app/shared/components';
 import { ActionsButtonComponent } from '@app/components/actions-button/actions-button.component';
 import { BaseEntity } from '@app/core/types';
 import { ConcernListDirective } from '@app/abstract-directives/concern-list.directive';
+import {
+  MaintenanceModeButtonComponent
+} from "@app/components/maintenance-mode-button/maintenance-mode-button.component";
 
 export class ListFactory {
 
@@ -162,4 +165,18 @@ export class ListFactory {
     };
   }
 
+  static maintenanceModeColumn<T>(listDirective: AdwpListDirective<T>): IComponentColumn<T> {
+    return {
+      type: 'component',
+      className: 'list-control',
+      headerClassName: 'list-control',
+      component: MaintenanceModeButtonComponent,
+      instanceTaken: (componentRef: ComponentRef<MaintenanceModeButtonComponent<T>>) => {
+        componentRef.instance.onClick
+        .pipe(listDirective.takeUntil())
+        .subscribe(({event, value}) => listDirective.maintenanceModeToggle(event, value));
+      }
+    };
+  }
+
 }
diff --git a/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts b/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts
index 126a42a0bb..18dc49fb68 100644
--- a/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts
+++ b/web/src/app/shared/components/actions/action-list/menu-item/menu-item.component.ts
@@ -22,9 +22,15 @@ import { MatMenu } from '@angular/material/menu';
     </div>
     <ng-template #list>
       <ng-container *ngFor="let a of items">
-        <button *ngIf="!a.children; else branch" [appForTest]="'action_btn'" mat-menu-item [appActions]="{ cluster: cluster, actions: [a] }">
-          <span>{{ a.display_name }}</span>
-        </button>
+        <div *ngIf="!a.children; else branch" [matTooltip]="actionListItemTooltip(a.disabling_cause)">
+          <button
+            mat-menu-item
+            [disabled]="a.disabling_cause === 'maintenance_mode'"
+            [appForTest]="'action_btn'"
+            [appActions]="{ cluster: cluster, actions: [a] }">
+            <span>{{ a.display_name }}</span>
+          </button>
+        </div>
         <ng-template #branch>
           <button mat-menu-item [matMenuTriggerFor]="inner.menu">
             <span>{{ a.display_name }}</span>
@@ -39,4 +45,11 @@ export class MenuItemComponent {
   @Input() items: IAction[] = [];
   @Input() cluster: { id: number; hostcomponent: string };
   @ViewChild('menu', { static: true }) menu: MatMenu;
+
+  actionListItemTooltip(value: string | null): string | null {
+    if (value === 'maintenance_mode') {
+      return 'The Action is not available. One or more hosts in “Maintenance mode”';
+    }
+    return null;
+  }
 }
diff --git a/web/src/app/shared/components/actions/actions.directive.spec.ts b/web/src/app/shared/components/actions/actions.directive.spec.ts
index 2a63d2e977..020d5a66cb 100644
--- a/web/src/app/shared/components/actions/actions.directive.spec.ts
+++ b/web/src/app/shared/components/actions/actions.directive.spec.ts
@@ -27,6 +27,7 @@ const TestParams: ActionParameters = {
       name: 'test',
       description: '',
       display_name: 'display_name_test',
+      disabling_cause: null,
       run: 'url',
       config: null,
       hostcomponentmap: null,
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 848e6be9fb..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
@@ -72,7 +72,7 @@ describe('MasterComponent', () => {
 
   it('should be show template for current action if model.actions.length === 1 and config = null and host-map = null', () => {
     component.model = {
-      actions: [{ name: 'a1', description: '', display_name: 'display a1', run: 'url a1', ui_options: null, config: null, hostcomponentmap: null, button: null }]
+      actions: [{ name: 'a1', description: '', display_name: 'display a1', disabling_cause: null, run: 'url a1', ui_options: null, config: null, hostcomponentmap: null, button: null }]
     };
     fixture.detectChanges();
     const compHost: HTMLElement = fixture.debugElement.nativeElement;
@@ -87,8 +87,8 @@ describe('MasterComponent', () => {
   it('should be show actions list for choose current action if model.actions.length > 1', () => {
     component.model = {
       actions: [
-        { name: 'a1', description: '', display_name: 'display a1', run: 'url a1', ui_options: null, config: null, hostcomponentmap: null, button: null },
-        { name: 'a2', description: '', display_name: 'display a2', run: 'url a2', ui_options: null, config: null, hostcomponentmap: null, button: null }
+        { name: 'a1', description: '', display_name: 'display a1', disabling_cause: null, run: 'url a1', ui_options: null, config: null, hostcomponentmap: null, button: null },
+        { name: 'a2', description: '', display_name: 'display a2', disabling_cause: null, run: 'url a2', ui_options: null, config: null, hostcomponentmap: null, button: null }
       ]
     };
     fixture.detectChanges();
@@ -105,6 +105,7 @@ describe('MasterComponent', () => {
           name: 'a1',
           description: '',
           display_name: 'display a1',
+          disabling_cause: null,
           run: 'url a1',
           ui_options: null,
           config: {
@@ -119,7 +120,8 @@ describe('MasterComponent', () => {
                 required: false,
                 description: '',
                 read_only: false,
-                activatable: false
+                activatable: false,
+                group_config: null,
               }
             ]
           },
@@ -139,7 +141,7 @@ describe('MasterComponent', () => {
   it('should be show template for current action if host-map exist only', () => {
     component.model = {
       actions: [
-        { name: 'a1', description: '', display_name: 'display a1', run: 'url a1', ui_options: null, config: null, hostcomponentmap: [{ component: '', action: 'add', service: '' }], button: null }
+        { name: 'a1', description: '', display_name: 'display a1', disabling_cause: null, run: 'url a1', ui_options: null, config: null, hostcomponentmap: [{ component: '', action: 'add', service: '' }], button: null }
       ]
     };
     fixture.detectChanges();
@@ -157,6 +159,7 @@ describe('MasterComponent', () => {
           name: 'a1',
           description: '',
           display_name: 'display a1',
+          disabling_cause: null,
           run: 'url a1',
           ui_options: null,
           config: {
@@ -171,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/components/list/base-list.directive.ts b/web/src/app/shared/components/list/base-list.directive.ts
index 7712dbfb6e..90d8c331d6 100644
--- a/web/src/app/shared/components/list/base-list.directive.ts
+++ b/web/src/app/shared/components/list/base-list.directive.ts
@@ -158,13 +158,21 @@ export class BaseListDirective {
 
   socketListener(m: EventMessage): void {
     const stype = (x: string) => `${m.object.type}${m.object.details.type ? `2${m.object.details.type}` : ''}` === x;
-
-    const checkUpgradable = () => (m.event === 'create' || m.event === 'delete') && m.object.type === 'bundle' && this.typeName === 'cluster';
     const changeList = (name?: string) => stype(name ?? this.typeName) && (m.event === 'create' || m.event === 'delete' || m.event === 'add' || m.event === 'remove');
     const createHostPro = () => stype('host2provider') && m.event === 'create';
     const jobComplete = () => (m.event === 'change_job_status') && m.object.type === 'task' && m.object.details.value === 'success';
     const rewriteRow = (row: Entities) => this.service.checkItem(row).subscribe((item) => Object.keys(row).map((a) => (row[a] = item[a])));
 
+    const checkUpgradable = () => {
+      const events = ['create', 'delete'];
+      const pairs = {
+        bundle: ['cluster'],
+        service: ['service2cluster']
+      };
+
+      return events.includes(m.event) && pairs[m.object.type]?.includes(this.typeName);
+    }
+
     if (checkUpgradable() || changeList(TemporaryEntityNameConverter(this.typeName)) || createHostPro() || jobComplete()) {
       this.refresh(m.object.id);
       return;
@@ -215,6 +223,12 @@ export class BaseListDirective {
 
   onLoad() {}
 
+  maintenanceModeToggle(row) {
+    this.service.setMaintenanceMode(row)
+    .pipe(this.takeUntil())
+    .subscribe(() => {});
+  }
+
   license() {
     const row = this.row as Bundle;
 
diff --git a/web/src/app/shared/components/list/list.service.ts b/web/src/app/shared/components/list/list.service.ts
index ab2af13262..22e6dac57d 100644
--- a/web/src/app/shared/components/list/list.service.ts
+++ b/web/src/app/shared/components/list/list.service.ts
@@ -24,9 +24,9 @@ import { ICluster } from '@app/models/cluster';
 
 const COLUMNS_SET = {
   cluster: ['name', 'prototype_version', 'description', 'state', 'status', 'actions', 'import', 'upgrade', 'config', 'controls'],
-  host2cluster: ['fqdn', 'provider_name', 'state', 'status', 'actions', 'config', 'remove'],
+  host2cluster: ['fqdn', 'provider_name', 'state', 'status', 'actions', 'config', 'maintenance_mode', 'remove'],
   service2cluster: ['display_name', 'version_no_sort', 'state', 'status', 'actions', 'import', 'config'],
-  host: ['fqdn', 'provider_name', 'host2cluster', 'state', 'status', 'actions', 'config', 'controls'],
+  host: ['fqdn', 'provider_name', 'host2cluster', 'state', 'status', 'actions', 'config', 'maintenance_mode', 'controls'],
   provider: ['name', 'prototype_version', 'state', 'actions', 'upgrade', 'config', 'controls'],
   job: ['action', 'objects', 'start_date', 'finish_date', 'status'],
   task: ['id', 'start_date', 'finish_date', 'status'],
@@ -118,4 +118,8 @@ export class ListService implements IListService<Entities> {
   getLicenseInfo(url: string) {
     return this.api.get<{ text: string }>(url);
   }
+
+  setMaintenanceMode(row: Entities) {
+    return this.api.patch(`/api/v1/host/${row.id}/`, { maintenance_mode: row['maintenance_mode'] });
+  }
 }
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 56248ef34f..db977d3f4e 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
@@ -63,7 +63,7 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
   }
 
   private disableIfReadOnly() {
-    if (this.field.options.read_only) {
+    if (this.field?.options?.read_only) {
       this.groupControl.disable();
     }
   }
@@ -71,7 +71,7 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
   private _resolveAndSetupControls(attributeForm: FormGroup, parametersForm: FormGroup, fieldOptions: IFieldOptions): void {
     let attributeControl: FormGroup = attributeForm;
     let parameterControl: FormGroup = parametersForm;
-    let disabled = this._attributeSrv.attributes.get(ConfigAttributeNames.CUSTOM_GROUP_KEYS).value;
+    let enabled = this._attributeSrv.attributes.get(ConfigAttributeNames.CUSTOM_GROUP_KEYS).value;
     let text = this._attributeSrv.attributes.get(ConfigAttributeNames.CUSTOM_GROUP_KEYS).options.tooltipText;
 
     const path = fieldOptions.key?.split('/').reverse();
@@ -79,21 +79,23 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
     this.groupControl = attributeControl.get(path) as FormControl;
     this.parameterControl = parameterControl.get(path) as FormControl;
 
+    if (!this.groupControl || !this.parameterControl) return;
+
     path.forEach((part) => {
-      disabled = disabled[part];
+      enabled = this._getFieldValue(enabled, part);
     });
 
-    if (!disabled) {
-      attributeControl.disable();
-      parameterControl.disable();
+    if (!enabled) {
+      this.groupControl.disable();
+      this.parameterControl.disable();
 
       this.tooltipText = text;
     } else {
-      attributeControl.enable();
-      if (attributeControl.value) {
-        parameterControl.enable();
+      this.groupControl.enable();
+      if (this.groupControl.value) {
+        this.parameterControl.enable();
       } else {
-        parameterControl.disable();
+        this.parameterControl.disable();
       }
 
       this.tooltipText = this.wrapperOptions.tooltipText;
@@ -116,4 +118,10 @@ export class GroupKeysWrapperComponent extends BaseDirective implements Attribut
       this.field.disabled = this._disabled;
     }
   }
+
+  private _getFieldValue(attr, key) {
+    if (attr?.fields) return attr?.fields[key]
+
+    return attr[key];
+  }
 }
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..2e42fd33a7 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)="!group_config?.disabled && 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/details/details.module.ts b/web/src/app/shared/details/details.module.ts
index 83a7fd3da6..a2f65805b3 100644
--- a/web/src/app/shared/details/details.module.ts
+++ b/web/src/app/shared/details/details.module.ts
@@ -44,6 +44,9 @@ import { ClusterDetailsComponent } from '@app/components/cluster/cluster-details
 import { LogMenuItemComponent } from './left-menu-items/log-menu-item/log-menu-item.component';
 import { ConcernMenuItemComponent } from '@app/shared/details/left-menu-items/concern-menu-item/concern-menu-item.component';
 import { ConcernMenuItemPipe } from './left-menu-items/concern-menu-item/concern-menu-item.pipe';
+import {
+  MaintenanceModeButtonComponent
+} from "@app/components/maintenance-mode-button/maintenance-mode-button.component";
 
 @NgModule({
   imports: [
@@ -71,6 +74,7 @@ import { ConcernMenuItemPipe } from './left-menu-items/concern-menu-item/concern
     BundleDetailsComponent,
     JobDetailsComponent,
     ActionsButtonComponent,
+    MaintenanceModeButtonComponent,
     ObjectLinkColumnPipe,
     SortObjectsPipe,
     AdwpListModule,
@@ -88,7 +92,7 @@ import { ConcernMenuItemPipe } from './left-menu-items/concern-menu-item/concern
     SubtitleComponent,
     NavigationComponent,
     ActionsButtonComponent,
-
+    MaintenanceModeButtonComponent,
     NavItemPipe,
     ObjectLinkColumnPipe,
     SortObjectsPipe,
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();
diff --git a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html
index dba9323034..7adb2868bc 100644
--- a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html
+++ b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.html
@@ -2,7 +2,7 @@
   <div class="title-container">
     <button mat-button [disabled]="isDisabled()" class="title" [appTooltip]="model.name"
       [appTooltipShowByCondition]="true" (click)="clickToTitle()">
-      <span *ngIf="isError()" class="warn">*</span>&nbsp;<span [matTooltip]="model.notification"
+      <span *ngIf="isMandatory()" class="warn">*</span>&nbsp;<span [matTooltip]="model?.notification" [matTooltipDisabled]="isError()"
         [style.color]="model.color">{{ model.name }}</span>
     </button>
     <button mat-raised-button color="primary"
diff --git a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts
index 1e7c200473..08fb3a99ca 100644
--- a/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts
+++ b/web/src/app/shared/host-components-map/much-2-many/much-2-many.component.ts
@@ -9,10 +9,9 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
-import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { AfterViewChecked, ChangeDetectorRef, Component, EventEmitter, Input, Output } from '@angular/core';
 import { FormGroup } from '@angular/forms';
 import { isNumber } from '@app/core/types';
-
 import { CompTile, Tile } from '../types';
 
 @Component({
@@ -20,7 +19,7 @@ import { CompTile, Tile } from '../types';
   templateUrl: './much-2-many.component.html',
   styleUrls: ['./much-2-many.component.scss'],
 })
-export class Much2ManyComponent {
+export class Much2ManyComponent implements AfterViewChecked {
   isShow = false;
 
   @Output() clickToTitleEvt: EventEmitter<any> = new EventEmitter();
@@ -28,6 +27,12 @@ export class Much2ManyComponent {
   @Input() model: Tile;
   @Input() form: FormGroup;
 
+  constructor(private cdRef : ChangeDetectorRef) {}
+
+  ngAfterViewChecked() {
+    this.cdRef.detectChanges();
+  }
+
   isDisabled() {
     if (this.model.actions) return !this.model.actions.length;
     return this.model.disabled;
@@ -37,10 +42,21 @@ export class Much2ManyComponent {
     if ('service_id' in this.model && Object.keys(this.form.controls).length) {
       const sc = this.model as CompTile;
       const control = this.form.controls[`${sc.service_id}/${sc.id}`];
-      if (!control) return false;
-      sc.notification = control.errors?.error;
-      return control.invalid;
-    } else return false;
+
+      if (!control || !control?.errors?.error) return true;
+
+      sc.notification = control.errors.error || null;
+      return !control.invalid;
+    }
+    return true;
+  }
+
+  isMandatory() {
+    if (this.model?.limit) {
+      return ['+', 'odd', 1].includes(this.model?.limit[0]) && this.model?.relations.length === 0;
+    }
+
+    return false;
   }
 
   clearDisabled(rel: Tile) {
@@ -62,7 +78,7 @@ export class Much2ManyComponent {
   }
 
   setNotify() {
-    const [a, b, c] = this.model.limit,
+    const [a, b] = this.model.limit,
       lim = isNumber(b) ? b : a === 'odd' ? 1 : a === 'depend' ? 0 : a;
     return `${this.model.relations.length}${lim !== 0 ? ` / ${lim}` : ''}`;
   }
diff --git a/web/src/app/shared/host-components-map/take.service.spec.ts b/web/src/app/shared/host-components-map/take.service.spec.ts
index 1d918a5a78..966fae5854 100644
--- a/web/src/app/shared/host-components-map/take.service.spec.ts
+++ b/web/src/app/shared/host-components-map/take.service.spec.ts
@@ -131,14 +131,14 @@ describe('HostComponentsMap :: TakeService', () => {
     });
   });
 
-  it('validateConstraints fn for Component { constrant: [0, odd], relations: [{}, {}]} =>  Total amount should be odd.', () => {
+  it('validateConstraints fn for Component { constrant: [0, odd], relations: [{}, {}]} => Must be installed at least 0 components. Total amount should be odd.', () => {
     const d = { ...ctData, constraint: [0, 'odd'] };
     const mCompTile = new CompTile(d);
     (mCompTile.relations = [
       { id: 0, name: 'test', relations: [], color: 'none', disabled: false },
       { id: 1, name: 'test', relations: [], color: 'none', disabled: false },
     ]),
-      expect(service.validateConstraints(mCompTile, 0)()).toEqual({ error: 'Total amount should be odd.' });
+      expect(service.validateConstraints(mCompTile, 0)()).toEqual({ error: 'Must be installed at least 0 components. Total amount should be odd.' });
   });
 
   it('validateConstraints fn for Component { constrant: [1, odd], relations: [{}]}] tobe null', () => {
diff --git a/web/src/app/shared/host-components-map/take.service.ts b/web/src/app/shared/host-components-map/take.service.ts
index 4dde1ed032..1d2730f6a7 100644
--- a/web/src/app/shared/host-components-map/take.service.ts
+++ b/web/src/app/shared/host-components-map/take.service.ts
@@ -165,10 +165,8 @@ export class TakeService {
           case 'depend':
             return depend();
           case 'odd':
-            return countRelations % 2 && countRelations >= a1
+            return ((a1 === 0 && countRelations === 0) || countRelations % 2) && (countRelations >= a1)
               ? null
-              : a1 === 0
-              ? 'Total amount should be odd.'
               : `Must be installed at least ${a1} components. Total amount should be odd.`;
           case '+':
           default:


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/requirements-test.txt b/requirements-test.txt
index b8aebd1aae..890fa034c9 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,5 +1,5 @@
 --extra-index-url https://ci.arenadata.io/artifactory/api/pypi/python-packages/simple
-adcm_client>=2022.4.11.14
+adcm_client>=adcm_client-2022.4.26.16
 adcm-pytest-plugin~=4.17
 attr
 allure-pytest
diff --git a/tests/api/utils/data_classes.py b/tests/api/utils/data_classes.py
index 2aad23ccba..76a08cf0d0 100644
--- a/tests/api/utils/data_classes.py
+++ b/tests/api/utils/data_classes.py
@@ -241,11 +241,14 @@ class GroupConfigHostsFields(BaseClass):
         postable=True,
     )
     cluster_id = Field(name="cluster_id", f_type=PositiveInt(), default_value=AUTO_VALUE)
+    bundle_id = Field(name="bundle_id", f_type=PositiveInt(), default_value=AUTO_VALUE)
     prototype_id = Field(name="prototype_id", f_type=PositiveInt(), default_value=AUTO_VALUE)
+    maintenance_mode = Field(name="maintenance_mode", f_type=String(), default_value=AUTO_VALUE)
     provider_id = Field(name="provider_id", f_type=PositiveInt(), default_value=AUTO_VALUE)
     fqdn = Field(name="fqdn", f_type=String(), default_value=AUTO_VALUE)
     description = Field(name="description", f_type=Text(), default_value=AUTO_VALUE)
     state = Field(name="state", f_type=String(), default_value=AUTO_VALUE)
+    locked = Field(name="locked", f_type=Boolean(), default_value=AUTO_VALUE)
     url = Field(name="url", f_type=String(), default_value=AUTO_VALUE)
 
 
diff --git a/tests/conftest.py b/tests/conftest.py
index b9f982f26f..413933797f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -48,6 +48,14 @@
         'edition': 'community',
     }
 ]
+DUMMY_ACTION = {
+    'dummy_action': {
+        'type': 'job',
+        'script': './actions.yaml',
+        'script_type': 'ansible',
+        'states': {'available': 'any'},
+    }
+}
 
 CLEAN_ADCM_PARAM = pytest.param({}, id="clean_adcm")
 DUMMY_DATA_PARAM = pytest.param({"fill_dummy_data": True}, id="adcm_with_dummy_data")
diff --git a/tests/functional/maintenance_mode/__init__.py b/tests/functional/maintenance_mode/__init__.py
new file mode 100644
index 0000000000..cbb5bf83b8
--- /dev/null
+++ b/tests/functional/maintenance_mode/__init__.py
@@ -0,0 +1,21 @@
+# 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.
+"""
+Suite with maintenance mode tests
+"""
+import pytest
+
+pytestmark = [
+    pytest.mark.allure_label("API Tests", label_type="layer"),
+    pytest.mark.allure_label("Functional Tests", label_type="parentSuite"),
+    pytest.mark.allure_label("Functional Tests", label_type="page_url"),
+]
diff --git a/tests/functional/maintenance_mode/bundles/cluster_host_actions/config.yaml b/tests/functional/maintenance_mode/bundles/cluster_host_actions/config.yaml
new file mode 100644
index 0000000000..f64a076a52
--- /dev/null
+++ b/tests/functional/maintenance_mode/bundles/cluster_host_actions/config.yaml
@@ -0,0 +1,53 @@
+# 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.
+
+
+- type: cluster
+  name: maintenance_mode_host_actions
+  version: 5.1
+  allow_maintenance_mode: true
+
+  actions:
+    cluster_host_action_allowed: &job
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      host_action: true
+      allow_in_maintenance_mode: true
+      states:
+        available: any
+
+    cluster_host_action_disallowed:
+      <<: *job
+      allow_in_maintenance_mode: false
+
+- type: service
+  name: test_service
+  version: 234.4
+
+  actions:
+    service_host_action_allowed:
+      <<: *job
+
+    service_host_action_disallowed:
+      <<: *job
+      allow_in_maintenance_mode: false
+
+  components:
+    test_component:
+      actions:
+        component_host_action_allowed:
+          <<: *job
+
+        component_host_action_disallowed:
+          <<: *job
+          allow_in_maintenance_mode: false
diff --git a/tests/functional/maintenance_mode/bundles/cluster_mm_allowed/actions.yaml b/tests/functional/maintenance_mode/bundles/cluster_mm_allowed/actions.yaml
new file mode 100644
index 0000000000..1f17534f7e
--- /dev/null
+++ b/tests/functional/maintenance_mode/bundles/cluster_mm_allowed/actions.yaml
@@ -0,0 +1,21 @@
+# 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.
+---
+- name: Dummy action
+  hosts: all
+  connection: local
+  gather_facts: no
+
+  tasks:
+    - name: Dummy?
+      debug:
+        msg: "Some message"
diff --git a/tests/functional/maintenance_mode/bundles/cluster_mm_allowed/config.yaml b/tests/functional/maintenance_mode/bundles/cluster_mm_allowed/config.yaml
new file mode 100644
index 0000000000..15def6617a
--- /dev/null
+++ b/tests/functional/maintenance_mode/bundles/cluster_mm_allowed/config.yaml
@@ -0,0 +1,129 @@
+# 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.
+
+
+- type: cluster
+  name: maintenance_mode_allowed_cluster
+  version: 5.4
+  allow_maintenance_mode: true
+
+  actions: &actions
+    default_action: &job
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      states:
+        available: any
+
+    allowed_in_mm:
+      <<: *job
+      allow_in_maintenance_mode: true
+
+    not_allowed_in_mm:
+      <<: *job
+      allow_in_maintenance_mode: false
+
+  config: &config
+    - name: some_param
+      type: integer
+      default: 12
+      group_customization: true
+
+
+- type: service
+  name: test_service
+  version: 4.3
+
+  actions: *actions
+
+  config: *config
+
+  components: &components
+    first_component:
+      actions:
+        <<: *actions
+
+        see_me_on_host:
+          <<: *job
+          host_action: true
+          allow_in_maintenance_mode: true
+
+      config: *config
+
+    second_component:
+      actions: *actions
+
+      config: *config
+
+- type: service
+  name: another_service
+  version: 6.5
+
+  actions: *actions
+
+  components: *components
+
+
+- type: service
+  name: host_actions
+  version: 0.5
+
+  components:
+    component_with_host_actions:
+      actions:
+        allowed_in_mm:
+          <<: *job
+          host_action: true
+          allow_in_maintenance_mode: true
+
+        not_allowed_in_mm:
+          <<: *job
+          host_action: true
+          allow_in_maintenance_mode: false
+
+
+- type: service
+  name: hc_acl_service
+  version: 3.2
+
+  actions:
+    shrink:
+      <<: *job
+      allow_in_maintenance_mode: true
+      hc_acl:
+        - service: hc_acl_service
+          component: second_component
+          action: remove
+
+    expand:
+      <<: *job
+      allow_in_maintenance_mode: true
+      hc_acl:
+        - service: hc_acl_service
+          component: second_component
+          action: add
+
+    change:
+      <<: *job
+      allow_in_maintenance_mode: true
+      hc_acl:
+        - service: hc_acl_service
+          component: first_component
+          action: add
+        - service: hc_acl_service
+          component: first_component
+          action: remove
+
+  components:
+    first_component:
+
+    second_component:
diff --git a/tests/functional/maintenance_mode/bundles/cluster_mm_disallowed/config.yaml b/tests/functional/maintenance_mode/bundles/cluster_mm_disallowed/config.yaml
new file mode 100644
index 0000000000..7078e0a204
--- /dev/null
+++ b/tests/functional/maintenance_mode/bundles/cluster_mm_disallowed/config.yaml
@@ -0,0 +1,39 @@
+# 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.
+
+
+- type: cluster
+  name: maintenance_mode_disallowed_cluster
+  version: 7.65
+  allow_maintenance_mode: false
+
+  actions: &actions
+    default_action:
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      states:
+        available: any
+
+
+- type: service
+  name: test_service
+  version: 23.4
+
+  actions: *actions
+
+  components:
+    first_component:
+      actions: *actions
+
+    second_component:
+      actions: *actions
diff --git a/tests/functional/maintenance_mode/bundles/cluster_mm_missing/config.yaml b/tests/functional/maintenance_mode/bundles/cluster_mm_missing/config.yaml
new file mode 100644
index 0000000000..aeb7066159
--- /dev/null
+++ b/tests/functional/maintenance_mode/bundles/cluster_mm_missing/config.yaml
@@ -0,0 +1,38 @@
+# 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.
+
+
+- type: cluster
+  name: maintenance_mode_disallowed_cluster
+  version: 7.65
+
+  actions: &actions
+    default_action:
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      states:
+        available: any
+
+
+- type: service
+  name: test_service
+  version: 23.4
+
+  actions: *actions
+
+  components:
+    first_component:
+      actions: *actions
+
+    second_component:
+      actions: *actions
diff --git a/tests/functional/maintenance_mode/bundles/default_provider/config.yaml b/tests/functional/maintenance_mode/bundles/default_provider/config.yaml
new file mode 100644
index 0000000000..9f95b4e596
--- /dev/null
+++ b/tests/functional/maintenance_mode/bundles/default_provider/config.yaml
@@ -0,0 +1,29 @@
+# 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.
+
+- type: provider
+  name: default_provider
+  version: 3.4
+
+  actions: &actions
+    default_action:
+      type: job
+      script: ./actions.yaml
+      script_type: ansible
+      states:
+        available: any
+
+- type: host
+  name: default_host
+  version: 12.344
+
+  actions: *actions
diff --git a/tests/functional/maintenance_mode/conftest.py b/tests/functional/maintenance_mode/conftest.py
new file mode 100644
index 0000000000..f400861b71
--- /dev/null
+++ b/tests/functional/maintenance_mode/conftest.py
@@ -0,0 +1,142 @@
+# 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.
+
+"""
+conftest.py for maintenance mode related tests
+"""
+
+# pylint: disable=redefined-outer-name
+
+import os
+from pathlib import Path
+from typing import Tuple, Literal, Iterable, Set
+
+import allure
+import pytest
+from adcm_client.objects import ADCMClient, Cluster, Provider, Host
+
+from tests.functional.tools import AnyADCMObject
+from tests.library.utils import get_hosts_fqdn_representation
+
+BUNDLES_DIR = Path(os.path.dirname(__file__)) / 'bundles'
+
+MM_IS_ON = 'on'
+MM_IS_OFF = 'off'
+MM_IS_DISABLED = 'disabled'
+
+MaintenanceModeOnHostValue = Literal['on', 'off', 'disabled']
+
+DISABLING_CAUSE = 'maintenance_mode'
+
+PROVIDER_NAME = 'Test Default Provider'
+CLUSTER_WITH_MM_NAME = 'Test Cluster WITH Maintenance Mode'
+CLUSTER_WITHOUT_MM_NAME = 'Test Cluster WITHOUT Maintenance Mode'
+DEFAULT_SERVICE_NAME = 'test_service'
+ANOTHER_SERVICE_NAME = 'another_service'
+FIRST_COMPONENT = 'first_component'
+SECOND_COMPONENT = 'second_component'
+
+
+@pytest.fixture()
+def provider(sdk_client_fs: ADCMClient) -> Provider:
+    """Upload bundle and create default provider"""
+    bundle = sdk_client_fs.upload_from_fs(BUNDLES_DIR / 'default_provider')
+    return bundle.provider_create(PROVIDER_NAME)
+
+
+@pytest.fixture()
+def hosts(provider) -> Tuple[Host, Host, Host, Host, Host, Host]:
+    """Create 6 hosts from the default bundle"""
+    return tuple(provider.host_create(f'test-host-{i}') for i in range(6))
+
+
+@pytest.fixture()
+def cluster_with_mm(sdk_client_fs: ADCMClient) -> Cluster:
+    """
+    Upload cluster bundle with allowed MM,
+    create and return cluster with default service
+    """
+    bundle = sdk_client_fs.upload_from_fs(BUNDLES_DIR / 'cluster_mm_allowed')
+    cluster = bundle.cluster_create(CLUSTER_WITH_MM_NAME)
+    cluster.service_add(name=DEFAULT_SERVICE_NAME)
+    return cluster
+
+
+@pytest.fixture(params=['cluster_mm_disallowed'], ids=lambda x: x.strip('cluster_'))
+def cluster_without_mm(request, sdk_client_fs: ADCMClient):
+    """
+    Upload cluster bundle with disallowed MM,
+    create and return cluster with default service
+    """
+    bundle = sdk_client_fs.upload_from_fs(BUNDLES_DIR / request.param)
+    cluster = bundle.cluster_create(CLUSTER_WITHOUT_MM_NAME)
+    cluster.service_add(name=DEFAULT_SERVICE_NAME)
+    return cluster
+
+
+def turn_mm_on(host: Host):
+    """Turn maintenance mode "on" on host"""
+    with allure.step(f'Turn MM "on" on host {host.fqdn}'):
+        host.maintenance_mode_set(MM_IS_ON)
+        assert (
+            actual_mm := host.maintenance_mode
+        ) == MM_IS_ON, f'Maintenance mode of host {host.fqdn} should be {MM_IS_ON}, not {actual_mm}'
+
+
+def turn_mm_off(host: Host):
+    """Turn maintenance mode "off" on host"""
+    with allure.step(f'Turn MM "off" on host {host.fqdn}'):
+        host.maintenance_mode_set(MM_IS_OFF)
+        assert (
+            actual_mm := host.maintenance_mode
+        ) == MM_IS_OFF, f'Maintenance mode of host {host.fqdn} should be {MM_IS_OFF}, not {actual_mm}'
+
+
+def add_hosts_to_cluster(cluster: Cluster, hosts: Iterable[Host]):
+    """Add hosts to cluster"""
+    with allure.step(f'Add hosts to the cluster "{cluster.name}": {get_hosts_fqdn_representation(hosts)}'):
+        for host in hosts:
+            cluster.host_add(host)
+
+
+def remove_hosts_from_cluster(cluster: Cluster, hosts: Iterable[Host]):
+    """Remove hosts from cluster"""
+    with allure.step(f'Add hosts to the cluster "{cluster.name}": {get_hosts_fqdn_representation(hosts)}'):
+        for host in hosts:
+            cluster.host_delete(host)
+
+
+def check_hosts_mm_is(maintenance_mode: MaintenanceModeOnHostValue, *hosts: Host):
+    """Check that MM of hosts is equal to the expected one"""
+    with allure.step(
+        f'Check that "maintenance_mode" is equal to "{maintenance_mode}" '
+        f'on hosts: {get_hosts_fqdn_representation(hosts)}'
+    ):
+        for host in hosts:
+            host.reread()
+        hosts_in_wrong_mode = tuple(host for host in hosts if host.maintenance_mode != maintenance_mode)
+        if len(hosts_in_wrong_mode) == 0:
+            return
+        raise AssertionError(
+            'Some hosts have incorrect value of "maintenance_mode" flag.\n'
+            f'Hosts: {get_hosts_fqdn_representation(hosts_in_wrong_mode)}'
+        )
+
+
+def get_enabled_actions_names(adcm_object: AnyADCMObject) -> Set[str]:
+    """Get actions that aren't disabled by maintenance mode"""
+    return {action.name for action in adcm_object.action_list() if action.disabling_cause != DISABLING_CAUSE}
+
+
+def get_disabled_actions_names(adcm_object: AnyADCMObject) -> Set[str]:
+    """Get actions disabled because of maintenance mode"""
+    return {action.name for action in adcm_object.action_list() if action.disabling_cause == DISABLING_CAUSE}
diff --git a/tests/functional/maintenance_mode/test_hosts_behavior.py b/tests/functional/maintenance_mode/test_hosts_behavior.py
new file mode 100644
index 0000000000..492c555b36
--- /dev/null
+++ b/tests/functional/maintenance_mode/test_hosts_behavior.py
@@ -0,0 +1,485 @@
+# 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.
+
+"""
+Test hosts maintenance mode behaviour
+"""
+
+from typing import Iterable, Tuple, Set
+
+import allure
+import pytest
+from adcm_client.objects import Host, Cluster, Component
+
+from tests.library.assertions import sets_are_equal, is_empty, expect_api_error, expect_no_api_error
+from tests.library.errorcodes import MAINTENANCE_MODE_NOT_AVAILABLE, ACTION_ERROR, ADCMError, INVALID_HC_HOST_IN_MM
+from tests.functional.tools import AnyADCMObject, get_object_represent, build_hc_for_hc_acl_action
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.maintenance_mode.conftest import (
+    MM_IS_ON,
+    MM_IS_OFF,
+    MM_IS_DISABLED,
+    BUNDLES_DIR,
+    DISABLING_CAUSE,
+    DEFAULT_SERVICE_NAME,
+    ANOTHER_SERVICE_NAME,
+    turn_mm_on,
+    turn_mm_off,
+    add_hosts_to_cluster,
+    remove_hosts_from_cluster,
+    check_hosts_mm_is,
+    get_disabled_actions_names,
+    get_enabled_actions_names,
+)
+
+# pylint: disable=redefined-outer-name
+
+ACTION_ALLOWED_IN_MM = 'allowed_in_mm'
+ACTION_NOT_ALLOWED_IN_MM = 'not_allowed_in_mm'
+ENABLED_ACTIONS = {ACTION_ALLOWED_IN_MM}
+DISABLED_ACTIONS = {'default_action', ACTION_NOT_ALLOWED_IN_MM}
+
+
+@pytest.fixture()
+def host_actions_cluster(sdk_client_fs) -> Cluster:
+    """Upload and create cluster with host actions from cluster, service and component"""
+    bundle = sdk_client_fs.upload_from_fs(BUNDLES_DIR / 'cluster_host_actions')
+    cluster = bundle.cluster_create('Cluster with host actions')
+    cluster.service_add(name=DEFAULT_SERVICE_NAME)
+    return cluster
+
+
+@only_clean_adcm
+@pytest.mark.parametrize(
+    'cluster_without_mm',
+    ['cluster_mm_disallowed', 'cluster_mm_missing'],
+    ids=lambda x: x.strip('cluster_'),
+    indirect=True,
+)
+def test_adding_host_to_cluster(cluster_with_mm, cluster_without_mm, hosts):
+    """
+    Test that adding/removing host to/from cluster affects "maintenance_mode" flag on host
+    """
+    hosts_to_cluster_with_mm = first_host, second_host = hosts[:2]
+    hosts_to_cluster_without_mm = third_host, _ = hosts[2:4]
+    free_hosts = hosts[-2:]
+
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts)
+
+    add_hosts_to_cluster(cluster_without_mm, hosts_to_cluster_without_mm)
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts)
+
+    add_hosts_to_cluster(cluster_with_mm, hosts_to_cluster_with_mm)
+    check_hosts_mm_is(MM_IS_OFF, *hosts_to_cluster_with_mm)
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts_to_cluster_without_mm, *free_hosts)
+
+    turn_mm_on(first_host)
+    check_hosts_mm_is(MM_IS_ON, first_host)
+    check_hosts_mm_is(MM_IS_OFF, second_host)
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts_to_cluster_without_mm, *free_hosts)
+
+    expect_api_error(
+        f'turn MM "on" host {third_host.fqdn}', turn_mm_on, third_host, err_=MAINTENANCE_MODE_NOT_AVAILABLE
+    )
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts_to_cluster_without_mm)
+    expect_api_error(
+        f'turn MM "off" host {third_host.fqdn}', turn_mm_off, third_host, err_=MAINTENANCE_MODE_NOT_AVAILABLE
+    )
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts_to_cluster_without_mm)
+
+    remove_hosts_from_cluster(cluster_with_mm, hosts_to_cluster_with_mm)
+    remove_hosts_from_cluster(cluster_without_mm, hosts_to_cluster_without_mm)
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts)
+
+
+def test_mm_hosts_not_allowed_in_hc_map(cluster_with_mm, hosts):
+    """
+    Test that hosts in MM aren't allowed to be used in hostcomponent map
+    """
+    cluster = cluster_with_mm
+    first_component = cluster_with_mm.service(name=DEFAULT_SERVICE_NAME).component(name='first_component')
+    host_in_mm, regular_host, *_ = hosts
+
+    add_hosts_to_cluster(cluster, (host_in_mm, regular_host))
+    turn_mm_on(host_in_mm)
+    with allure.step('Try to set HC map with one of hosts in MM'):
+        _expect_hc_set_to_fail(cluster, [(host_in_mm, first_component)], err_=INVALID_HC_HOST_IN_MM)
+        _expect_hc_set_to_fail(
+            cluster, [(host_in_mm, first_component), (regular_host, first_component)], err_=INVALID_HC_HOST_IN_MM
+        )
+
+    with allure.step('Place component on "working" host'):
+        hc_with_regular_host = cluster.hostcomponent_set((regular_host, first_component))
+
+    with allure.step("Try to set HC map with one of hosts in MM and check that hc-map hasn't changed"):
+        _expect_hc_set_to_fail(
+            cluster, [(host_in_mm, first_component), (regular_host, first_component)], err_=INVALID_HC_HOST_IN_MM
+        )
+        cluster.reread()
+        _check_hostcomponents_are_equal(cluster.hostcomponent(), hc_with_regular_host)
+
+
+def test_actions_not_allowed_in_mm_are_disabled_due_to_host_in_mm(cluster_with_mm, hosts):
+    """
+    Test that actions that aren't allowed in maintenance mode
+    - are disabled when MM host is in vertical hierarchy (cluster-service-component)
+    - aren't disabled when MM host is on another service's component / on another component.
+    """
+    first_host, second_host, *_ = hosts
+    cluster = cluster_with_mm
+    first_service = cluster.service(name=DEFAULT_SERVICE_NAME)
+    first_component = first_service.component(name='first_component')
+    second_component = first_service.component(name='second_component')
+    second_service = cluster.service_add(name=ANOTHER_SERVICE_NAME)
+    second_service_components = second_service.component_list()
+    all_objects = [
+        cluster,
+        first_service,
+        second_service,
+        first_component,
+        second_component,
+        *second_service_components,
+    ]
+
+    add_hosts_to_cluster(cluster, (first_host, second_host))
+    cluster.hostcomponent_set(
+        (first_host, first_component),
+        (second_host, second_component),
+        (second_host, second_service_components[0]),
+        (second_host, second_service_components[1]),
+    )
+    check_all_actions_are_enabled(*all_objects)
+
+    with allure.step(f'Turn MM "on" on host {first_host.fqdn} and check actions are disabled correctly'):
+        turn_mm_on(first_host)
+        check_actions_are_disabled_on(cluster, first_service, first_component)
+        check_all_actions_are_enabled(second_component, second_service, *second_service_components)
+
+    with allure.step(f'Turn MM "off" on host {first_host.fqdn} and expect all objects\' actions to be enabled'):
+        turn_mm_off(first_host)
+        check_all_actions_are_enabled(*all_objects)
+
+
+def test_provider_and_host_actions_affected_by_mm(cluster_with_mm, provider, hosts):
+    """
+    Test that host in MM doesn't affect provider's actions,
+    but cleans action list of this host (including `host_action: true`)
+    """
+    cluster = cluster_with_mm
+    component = cluster.service(name=DEFAULT_SERVICE_NAME).component(name='first_component')
+    first_host, second_host, *_ = hosts
+    actions_on_provider = {'default_action'}
+    actions_on_host = {'default_action', 'see_me_on_host'}
+
+    def _available_actions_are(on_first_host: set, on_second_host: set, on_provider: set):
+        check_visible_actions(first_host, on_first_host)
+        check_visible_actions(second_host, on_second_host)
+        check_visible_actions(provider, on_provider)
+
+    add_hosts_to_cluster(cluster, (first_host, second_host))
+    cluster.hostcomponent_set((first_host, component), (second_host, component))
+    _available_actions_are(actions_on_host, actions_on_host, actions_on_provider)
+
+    turn_mm_on(first_host)
+    _available_actions_are(set(), actions_on_host, actions_on_provider)
+
+    turn_mm_off(first_host)
+    _available_actions_are(actions_on_host, actions_on_host, actions_on_provider)
+
+
+def test_host_actions_on_another_component_host(host_actions_cluster, hosts):
+    """
+    Test host_actions from cluster, service and component are working correctly
+    with regular host with component that is also mapped to an MM host
+    """
+    expected_enabled = {'default_action'} | {
+        f'{obj_type}_host_action_allowed' for obj_type in ('cluster', 'service', 'component')
+    }
+    expected_disabled = {f'{obj_type}_host_action_disallowed' for obj_type in ('cluster', 'service', 'component')}
+
+    host_in_mm, regular_host, *_ = hosts
+    cluster = host_actions_cluster
+    component = cluster.service().component()
+
+    add_hosts_to_cluster(cluster, (host_in_mm, regular_host))
+    cluster.hostcomponent_set((host_in_mm, component), (regular_host, component))
+
+    turn_mm_on(host_in_mm)
+
+    enabled_actions = get_enabled_actions_names(regular_host)
+    disabled_actions = get_disabled_actions_names(regular_host)
+
+    with allure.step('Check that correct actions are enabled/disabled on the host'):
+        sets_are_equal(enabled_actions, expected_enabled, f'Incorrect actions are enabled on host {regular_host.fqdn}')
+        sets_are_equal(
+            disabled_actions, expected_disabled, f'Incorrect actions are disabled on host {regular_host.fqdn}'
+        )
+
+
+def test_running_disabled_actions_is_forbidden(cluster_with_mm, hosts):
+    """
+    Test that disabled actions actually can't be launched
+    and that host's filtered action can't be launched directly via API
+    """
+    cluster = cluster_with_mm
+    service = cluster.service(name=DEFAULT_SERVICE_NAME)
+    first_component, second_component = service.component_list()
+    first_host, second_host, *_ = hosts
+
+    add_hosts_to_cluster(cluster, (first_host, second_host))
+    cluster.hostcomponent_set((first_host, first_component), (second_host, second_component))
+
+    host_action_from_itself = first_host.action(name='default_action')
+    host_action_from_component = first_host.action(name='see_me_on_host')
+
+    turn_mm_on(first_host)
+
+    expect_api_error(
+        'run not allowed in MM action on service',
+        service.action(name=ACTION_NOT_ALLOWED_IN_MM).run,
+        err_=ACTION_ERROR,
+    )
+    expect_no_api_error('run allowed in MM action on service', service.action(name=ACTION_ALLOWED_IN_MM).run)
+
+    expect_api_error('run action on host in MM', host_action_from_itself.run, err_=ACTION_ERROR)
+    expect_api_error(
+        'run action `host_action: true` on host in MM',
+        host_action_from_component.run,
+        err_=ACTION_ERROR,
+    )
+
+
+@only_clean_adcm
+def test_host_actions_with_mm(cluster_with_mm, hosts):
+    """
+    Test that host actions (`host_action: true`) are working correctly
+    with `allow_in_maintenance_mode` flag
+    """
+    allowed_action = 'allowed_in_mm'
+    not_allowed_action = 'not_allowed_in_mm'
+    cluster = cluster_with_mm
+    component = cluster.service_add(name='host_actions').component()
+    host_in_mm, regular_host, *_ = hosts
+
+    add_hosts_to_cluster(cluster, (host_in_mm, regular_host))
+    cluster.hostcomponent_set((host_in_mm, component), (regular_host, component))
+    turn_mm_on(host_in_mm)
+
+    check_visible_actions(host_in_mm, set())
+    check_visible_actions(regular_host, {allowed_action, not_allowed_action, 'default_action'})
+
+    expect_api_error(
+        'run not allowed in MM action', regular_host.action(name=not_allowed_action).run, err_=ACTION_ERROR
+    )
+    expect_no_api_error('run allowed in MM action', regular_host.action(name=allowed_action).run)
+
+
+@only_clean_adcm
+def test_hc_acl_action_with_mm(cluster_with_mm, hosts):
+    """
+    Test behaviour of actions with `hc_acl`:
+    - adding component on host in MM should be forbidden
+    - removing component from host in MM should be allowed
+    """
+    mm_host_1, mm_host_2, mm_host_3, regular_host_1, regular_host_2, *_ = hosts
+    service = cluster_with_mm.service_add(name='hc_acl_service')
+    first_component = service.component(name='first_component')
+    second_component = service.component(name='second_component')
+
+    add_hosts_to_cluster(cluster_with_mm, (mm_host_1, mm_host_2, mm_host_3, regular_host_1, regular_host_2))
+    cluster_with_mm.hostcomponent_set(
+        (mm_host_1, first_component),
+        (regular_host_1, second_component),
+        (mm_host_2, second_component),
+        (mm_host_3, first_component),
+        (mm_host_3, second_component),
+    )
+    turn_mm_on(mm_host_1)
+    turn_mm_on(mm_host_2)
+    turn_mm_on(mm_host_3)
+
+    with allure.step('Check "adding" component to a host in MM is forbidden'):
+        expect_api_error(
+            'add component to a host in MM with hc_acl action',
+            service.action(name='expand').run,
+            hc=build_hc_for_hc_acl_action(cluster_with_mm, add=[(second_component, mm_host_1)]),
+        )
+
+    with allure.step('Check "removing" component from a host in MM is allowed'):
+        expect_no_api_error(
+            'remove component from a MM host with hc_acl action',
+            service.action(name='shrink').run,
+            hc=build_hc_for_hc_acl_action(cluster_with_mm, remove=[(second_component, mm_host_2)]),
+        ).wait()
+
+    with allure.step('Check "moving" component from host in MM to a regular host in one action is allowed'):
+        expect_no_api_error(
+            'move component from MM host to regular one',
+            service.action(name='change').run,
+            hc=build_hc_for_hc_acl_action(
+                cluster_with_mm, [(first_component, mm_host_1)], [(first_component, regular_host_1)]
+            ),
+        )
+
+
+@only_clean_adcm
+def test_hosts_in_not_blocking_regular_hc_acl(cluster_with_mm, hosts):
+    """
+    Test that hosts in MM doesn't block operations on "regular" hosts
+    (for components with both type of hosts)
+    """
+    service = cluster_with_mm.service_add(name='hc_acl_service')
+    first_component = service.component(name='first_component')
+    second_component = service.component(name='second_component')
+    mm_host_1, mm_host_2, regular_host_1, regular_host_2, *_ = hosts
+
+    add_hosts_to_cluster(cluster_with_mm, (mm_host_1, mm_host_2, regular_host_1, regular_host_2))
+    cluster_with_mm.hostcomponent_set(
+        (mm_host_1, first_component),
+        (regular_host_1, first_component),
+        (regular_host_1, second_component),
+        (mm_host_2, second_component),
+    )
+
+    expect_no_api_error(
+        'add component on host not in MM with hc_acl action',
+        service.action(name='expand').run,
+        hc=build_hc_for_hc_acl_action(cluster_with_mm, add=[(second_component, regular_host_2)]),
+    ).wait()
+
+    expect_no_api_error(
+        'remove component from host not in MM with hc_acl_action',
+        service.action(name='shrink').run,
+        hc=build_hc_for_hc_acl_action(cluster_with_mm, remove=[(second_component, regular_host_2)]),
+    )
+
+
+@only_clean_adcm
+def test_state_after_mm_switch(cluster_with_mm, hosts):
+    """
+    Test that state stays the same after switch of MM flag
+    """
+    host, *_ = hosts
+    expected_state = host.state
+
+    add_hosts_to_cluster(cluster_with_mm, [host])
+    check_state(host, expected_state)
+    turn_mm_on(host)
+    check_state(host, expected_state)
+    turn_mm_off(host)
+    check_state(host, expected_state)
+    remove_hosts_from_cluster(cluster_with_mm, [host])
+    check_state(host, expected_state)
+
+
+@only_clean_adcm
+def test_set_value_not_in_enum_in_mm(cluster_with_mm, hosts):
+    """
+    Test that value 'disabled' can't be set to a host in 'on/off' mode
+    and another value than 'on', 'off', 'disabled' can't be sent in MM field of the host
+    """
+    mm_value = 'diisabled'
+    host, *_ = hosts
+
+    add_hosts_to_cluster(cluster_with_mm, [host])
+    expect_api_error('Set value "disabled" to MM', lambda: host.maintenance_mode_set('disabled'))
+    expect_api_error(f'Set value "{mm_value}" to MM', lambda: host.maintenance_mode_set(mm_value))
+
+
+def test_mm_after_cluster_deletion(cluster_with_mm, hosts):
+    """
+    Test that MM on hosts from deleted cluster is "disabled"
+    """
+    host_1, host_2, *_ = hosts
+    add_hosts_to_cluster(cluster_with_mm, [host_1, host_2])
+    turn_mm_on(host_2)
+    check_hosts_mm_is(MM_IS_OFF, host_1)
+    check_hosts_mm_is(MM_IS_ON, host_2)
+    with allure.step('Delete cluster'):
+        cluster_with_mm.delete()
+    check_hosts_mm_is(MM_IS_DISABLED, host_1, host_2)
+
+
+def check_actions_are_disabled_on(*objects) -> None:
+    """Check that correct actions are disabled on given objects"""
+    for adcm_object in objects:
+        object_representation = get_object_represent(adcm_object)
+        enabled_actions_on_object = get_enabled_actions_names(adcm_object)
+        sets_are_equal(
+            enabled_actions_on_object,
+            ENABLED_ACTIONS,
+            f'Actions should be enabled on {object_representation}.\nCheck attachment.',
+        )
+        disabled_actions_on_object = get_disabled_actions_names(adcm_object)
+        sets_are_equal(
+            disabled_actions_on_object,
+            DISABLED_ACTIONS,
+            f'Actions should be disabled on {object_representation}.\nCheck attachment.',
+        )
+
+
+def check_all_actions_are_enabled(*objects) -> None:
+    """Check that all actions are enabled on given objects"""
+    for adcm_object in objects:
+        disabled_actions_on_object = get_disabled_actions_names(adcm_object)
+        is_empty(
+            disabled_actions_on_object, f'None of actions should be disabled on {get_object_represent(adcm_object)}'
+        )
+
+
+def check_visible_actions(adcm_object: AnyADCMObject, action_names: Set[str]) -> None:
+    """Check actions are presented in object's action list"""
+    actual_names = {action.name for action in adcm_object.action_list()}
+    object_represent = get_object_represent(adcm_object)
+    with allure.step(f'Check action list of {object_represent}'):
+        if action_names:
+            sets_are_equal(
+                actual_names,
+                action_names,
+                f'Action list is incorrect for object {object_represent}.\nCheck attachments for more details.',
+            )
+        else:
+            is_empty(actual_names, 'Action list should be empty')
+
+
+def check_state(host: Host, expected_state: str) -> None:
+    """Check that state is equal to the expected state"""
+    host.reread()
+    assert (
+        actual_state := host.state
+    ) == expected_state, f'State of host {host.fqdn} should be {expected_state}, not {actual_state}'
+
+
+def _expect_hc_set_to_fail(
+    cluster: Cluster, hostcomponent: Iterable[Tuple[Host, Component]], err_: ADCMError = MAINTENANCE_MODE_NOT_AVAILABLE
+) -> None:
+    expect_api_error(
+        'set hostcomponent with one of hosts in MM mode',
+        cluster.hostcomponent_set,
+        *hostcomponent,
+        err_=err_,
+    )
+
+
+def _check_hostcomponents_are_equal(actual_hc, expected_hc) -> None:
+    """Compare hostcomponent maps directly"""
+    assert actual_hc == expected_hc, f'Hostcomponent map has changed.\nExpected:\n{expected_hc}\nActual:\n{actual_hc}'
+
+
+def _get_enabled_actions_names(adcm_object: AnyADCMObject) -> Set[str]:
+    """Get actions that aren't disabled by maintenance mode"""
+    return {action.name for action in adcm_object.action_list() if action.disabling_cause != DISABLING_CAUSE}
+
+
+def _get_disabled_actions_names(adcm_object: AnyADCMObject) -> Set[str]:
+    """Get actions disabled because of maintenance mode"""
+    return {action.name for action in adcm_object.action_list() if action.disabling_cause == DISABLING_CAUSE}
diff --git a/tests/functional/maintenance_mode/test_inventory_modification.py b/tests/functional/maintenance_mode/test_inventory_modification.py
new file mode 100644
index 0000000000..5ad3468e99
--- /dev/null
+++ b/tests/functional/maintenance_mode/test_inventory_modification.py
@@ -0,0 +1,225 @@
+# 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.
+
+"""
+Test inventory modification after host has entered maintenance mode
+"""
+
+import itertools
+from typing import Tuple, Set
+
+import allure
+import pytest
+from adcm_client.objects import Cluster, GroupConfig, Action, Component, Host
+from adcm_pytest_plugin.docker_utils import ADCM
+from adcm_pytest_plugin.utils import get_or_add_service
+
+from tests.functional.maintenance_mode.test_hosts_behavior import ACTION_ALLOWED_IN_MM
+from tests.functional.tools import create_config_group_and_add_host, get_inventory_file, build_hc_for_hc_acl_action
+from tests.functional.conftest import only_clean_adcm
+from tests.functional.maintenance_mode.conftest import (
+    DEFAULT_SERVICE_NAME,
+    FIRST_COMPONENT,
+    SECOND_COMPONENT,
+    MM_IS_ON,
+    turn_mm_on,
+    add_hosts_to_cluster,
+)
+from tests.library.assertions import sets_are_equal
+
+# pylint: disable=redefined-outer-name
+
+pytestmark = [only_clean_adcm]
+
+DEFAULT_ACTION_NAME = 'default_action'
+HC_ACL_SERVICE_NAME = 'hc_acl_service'
+
+
+@pytest.fixture(params=[pytest.param(DEFAULT_SERVICE_NAME, id='default_service')])
+def cluster_with_hc_set(request, cluster_with_mm, hosts) -> Cluster:
+    """
+    Add 4 hosts to cluster
+    first_component is placed on 4 hosts
+    second_component on 2 hosts
+    """
+    service_name = request.param
+    hosts_in_cluster = hosts[:4]
+    service = get_or_add_service(cluster_with_mm, service_name)
+    first_component = service.component(name=FIRST_COMPONENT)
+    second_component = service.component(name=SECOND_COMPONENT)
+    for host in hosts_in_cluster:
+        cluster_with_mm.host_add(host)
+    cluster_with_mm.hostcomponent_set(
+        *[(host, first_component) for host in hosts_in_cluster],
+        *[(host, second_component) for host in hosts_in_cluster[:2]],
+    )
+    return cluster_with_mm
+
+
+@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 = {'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)
+    cluster_group = create_config_group_and_add_host('Cluster Config Group', cluster_with_hc_set, host_1)
+    cluster_group.config_set_diff(changed_config)
+    service_group = create_config_group_and_add_host('Service Config Group', service, host_2)
+    service_group.config_set_diff(changed_config)
+    component_group = create_config_group_and_add_host('Component Config Group', component, host_3)
+    component_group.config_set_diff(changed_config)
+    return cluster_group, service_group, component_group
+
+
+@pytest.fixture()
+def host_not_in_config_group(cluster_with_hc_set, config_groups) -> Host:
+    """Get one host that is not in config group"""
+    hosts_in_groups = {host.fqdn for host in itertools.chain.from_iterable(group.hosts() for group in config_groups)}
+    return [host for host in cluster_with_hc_set.host_list() if host.fqdn not in hosts_in_groups][0]
+
+
+def test_hosts_in_mm_removed_from_inventory(adcm_fs, cluster_with_hc_set):
+    """Test filtering of hosts in inventory file when hosts are in MM"""
+    host, *_ = cluster_with_hc_set.host_list()
+    service = cluster_with_hc_set.service()
+    action_on_service = service.action(name=ACTION_ALLOWED_IN_MM)
+
+    inventory = run_action_and_get_inventory(action_on_service, adcm_fs)
+    check_all_hosts_are_present(inventory, cluster_with_hc_set)
+
+    turn_mm_on(host)
+
+    inventory = run_action_and_get_inventory(action_on_service, adcm_fs)
+    check_hosts_in_mm_are_absent(inventory, cluster_with_hc_set)
+
+
+def test_hosts_in_mm_removed_from_group_config(adcm_fs, cluster_with_hc_set, config_groups, host_not_in_config_group):
+    """Test filtering of hosts in inventory file when hosts are in MM and in config group"""
+    *_, component_group = config_groups
+    component: Component = cluster_with_hc_set.service().component(name=FIRST_COMPONENT)
+    action_on_component = component.action(name=ACTION_ALLOWED_IN_MM)
+
+    inventory = run_action_and_get_inventory(action_on_component, adcm_fs)
+    check_all_hosts_are_present(inventory, cluster_with_hc_set)
+
+    turn_mm_on(component_group.hosts()[0])
+    turn_mm_on(host_not_in_config_group)
+
+    inventory = run_action_and_get_inventory(action_on_component, adcm_fs)
+    check_hosts_in_mm_are_absent(inventory, cluster_with_hc_set)
+
+
+def test_hosts_filtered_when_added_to_group_config_after_entering_mm(adcm_fs, cluster_with_hc_set):
+    """Test filtering of hosts in inventory file when host entered MM and then added to config group"""
+    component: Component = cluster_with_hc_set.service().component(name=FIRST_COMPONENT)
+    host = cluster_with_hc_set.host(
+        fqdn=next(filter(lambda hc: hc['component_id'] == component.id, cluster_with_hc_set.hostcomponent()))['host']
+    )
+    action_on_component = component.action(name=ACTION_ALLOWED_IN_MM)
+
+    turn_mm_on(host)
+
+    create_config_group_and_add_host('Component Group', component, host)
+    inventory = run_action_and_get_inventory(action_on_component, adcm_fs)
+    check_hosts_in_mm_are_absent(inventory, cluster_with_hc_set)
+
+
+@pytest.mark.parametrize('cluster_with_hc_set', [HC_ACL_SERVICE_NAME], indirect=True)
+def test_host_filtering_with_hc_acl(adcm_fs, cluster_with_hc_set: Cluster, hosts):
+    """Test filtering of hosts in MM in inventory groups when action have `hc_acl` directive"""
+    cluster = cluster_with_hc_set
+    service = cluster.service(name=HC_ACL_SERVICE_NAME)
+    first_component = service.component(name=FIRST_COMPONENT)
+    host, *_ = cluster.host_list()
+    *_, free_host = hosts
+
+    add_hosts_to_cluster(cluster, [free_host])
+
+    turn_mm_on(host)
+
+    inventory = run_action_and_get_inventory(
+        service.action(name='change'),
+        adcm_fs,
+        hc=build_hc_for_hc_acl_action(cluster, add=[(first_component, free_host)], remove=[(first_component, host)]),
+    )
+    check_hosts_in_mm_are_absent(inventory, cluster, service_name=HC_ACL_SERVICE_NAME)
+    _check_add_remove_groups(inventory, add={free_host.fqdn}, remove=set())
+
+
+def run_action_and_get_inventory(action: Action, adcm: ADCM, **run_kwargs) -> dict:
+    """Run action and get inventory file contents from container"""
+    with allure.step(f'Run action {action.name}'):
+        task = action.run(**run_kwargs)
+        task.wait()
+    with allure.step(f'Get inventory of task {task.id}'):
+        return get_inventory_file(adcm, task.id)
+
+
+@allure.step('Check that all hosts are presented in inventory')
+def check_all_hosts_are_present(inventory: dict, cluster: Cluster) -> None:
+    """Expect all hosts to be presented in inventory"""
+    expected = {host.fqdn for host in cluster.host_list()}
+    expected_on_second_component = {hc['host'] for hc in cluster.hostcomponent() if hc['component'] == SECOND_COMPONENT}
+    _check_expected(inventory, expected, expected_on_second_component)
+
+
+@allure.step('Check that hosts in maintenance mode are absent in inventory')
+def check_hosts_in_mm_are_absent(inventory: dict, cluster: Cluster, service_name: str = DEFAULT_SERVICE_NAME) -> None:
+    """Expect hosts in MM are filtered out from inventory"""
+    hostnames_in_mm = {host.fqdn for host in cluster.host_list() if host.maintenance_mode == MM_IS_ON}
+    expected = {host.fqdn for host in cluster.host_list()}.difference(hostnames_in_mm)
+    expected_on_second_component = {
+        hc['host'] for hc in cluster.hostcomponent() if hc['component'] == SECOND_COMPONENT
+    }.difference(hostnames_in_mm)
+    _check_expected(inventory, expected, expected_on_second_component, service_name)
+
+
+def _check_expected(
+    inventory, expected_hosts: set, expected_on_second_component: set, service_name: str = DEFAULT_SERVICE_NAME
+):
+    children = inventory['all']['children']
+    second_component_key = f'{service_name}.{SECOND_COMPONENT}'
+
+    for group_to_check in ('CLUSTER', service_name, f'{service_name}.{FIRST_COMPONENT}'):
+        hosts_on_object = set(children[group_to_check]['hosts'].keys())
+        sets_are_equal(
+            hosts_on_object, expected_hosts, f'Wrong hosts are presented in inventory of group "{group_to_check}"'
+        )
+
+    second_component_hosts = set(children[second_component_key]['hosts'].keys())
+    sets_are_equal(
+        second_component_hosts,
+        expected_on_second_component,
+        f'Wrong hosts are presented in inventory of "{second_component_key}"',
+    )
+
+
+def _check_add_remove_groups(inventory, add: Set[str], remove: Set[str]):
+    """Check one group with "add" and one with "remove" suffix"""
+    children = inventory['all']['children']
+    add_nodes = [(k, v) for k, v in children.items() if 'add' in k]
+    if not add_nodes:
+        raise AssertionError('At least one node with "add" suffix should be presented in inventory')
+    remove_nodes = [(k, v) for k, v in children.items() if 'remove' in k]
+    if not remove_nodes:
+        raise AssertionError('At least on node with "remove" suffix should be presented in inventory')
+
+    node_name, node_value = add_nodes[0]
+    with allure.step(f'Check node {node_name}'):
+        actual_hosts = set(node_value['hosts'].keys())
+        sets_are_equal(actual_hosts, add, '"add" node is incorrect')
+
+    node_name, node_value = remove_nodes[0]
+    with allure.step(f'Check node {node_name}'):
+        actual_hosts = set(node_value['hosts'].keys())
+        sets_are_equal(actual_hosts, remove, '"remove" node is incorrect')
diff --git a/tests/functional/maintenance_mode/test_mm_bundle_validation.py b/tests/functional/maintenance_mode/test_mm_bundle_validation.py
new file mode 100644
index 0000000000..cb4ffa1de1
--- /dev/null
+++ b/tests/functional/maintenance_mode/test_mm_bundle_validation.py
@@ -0,0 +1,64 @@
+# 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.
+
+"""Test bundle upload"""
+
+import allure
+import pytest
+from coreapi.exceptions import ErrorMessage
+
+from tests.conftest import DUMMY_CLUSTER_BUNDLE, DUMMY_ACTION
+from tests.functional.conftest import only_clean_adcm
+from tests.library.errorcodes import INVALID_OBJECT_DEFINITION
+
+ALLOW_IN_MM_ACTION = {'actions': {'some_action': {**DUMMY_ACTION, 'allow_in_maintenance_mode': True}}}
+
+
+def _make_dummy_cluster_bundle(cluster_extra: dict, *services: dict):
+    return [{**DUMMY_CLUSTER_BUNDLE[0], **cluster_extra}, *services]
+
+
+def _make_dummy_provider_bundle(provider_extra: dict = None, host_extra: dict = None):
+    provider_extra = provider_extra if provider_extra else {}
+    host_extra = host_extra if host_extra else {}
+    return [
+        {'type': 'provider', 'version': 2.1, 'name': 'test_provider', **provider_extra},
+        {'type': 'host', 'version': 2.3, 'name': 'test_host', **host_extra},
+    ]
+
+
+@only_clean_adcm
+@pytest.mark.parametrize(
+    'create_bundle_archives',
+    [
+        [_make_dummy_cluster_bundle(ALLOW_IN_MM_ACTION)],
+        [_make_dummy_cluster_bundle({**ALLOW_IN_MM_ACTION, 'allow_maintenance_mode': False})],
+        [_make_dummy_provider_bundle({'allow_maintenance_mode': False})],
+        [_make_dummy_provider_bundle({}, {'allow_maintenance_mode': False})],
+    ],
+    ids=[
+        'cluster_mm_absent_action_mm_false',
+        'cluster_mm_false_action_mm_true',
+        'provider_mm_false',
+        'host_mm_false',
+    ],
+    indirect=True,
+)
+def test_bundle_validation_upload(sdk_client_fs, create_bundle_archives):
+    """
+    Test uploading bundles with invalid MM directives
+    """
+    with allure.step('Upload bundle and expect upload to fail'):
+        bundle_path, *_ = create_bundle_archives
+        with pytest.raises(ErrorMessage) as e:
+            sdk_client_fs.upload_from_fs(bundle_path)
+        INVALID_OBJECT_DEFINITION.equal(e)
diff --git a/tests/functional/maintenance_mode/test_statuses.py b/tests/functional/maintenance_mode/test_statuses.py
new file mode 100644
index 0000000000..d3bb4c0006
--- /dev/null
+++ b/tests/functional/maintenance_mode/test_statuses.py
@@ -0,0 +1,250 @@
+# 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.
+
+"""
+Test statuses aggregation when hosts are in MM
+"""
+
+from operator import not_, truth
+from typing import Tuple, Collection
+
+import allure
+import pytest
+import requests
+from adcm_client.objects import Cluster, Component, ADCMClient
+
+from tests.library.assertions import dicts_are_equal
+from tests.library.status import ADCMObjectStatusChanger
+from tests.functional.maintenance_mode.conftest import (
+    add_hosts_to_cluster,
+    FIRST_COMPONENT,
+    SECOND_COMPONENT,
+    ANOTHER_SERVICE_NAME,
+    DEFAULT_SERVICE_NAME,
+    turn_mm_on,
+)
+
+# pylint: disable=redefined-outer-name
+
+CHILDREN_KEY = 'chilren'
+
+POSITIVE_STATUS = 0
+NEGATIVE_STATUS = 16
+
+
+@pytest.fixture()
+def status_changer(sdk_client_fs, adcm_fs) -> ADCMObjectStatusChanger:
+    """Init status changer"""
+    return ADCMObjectStatusChanger(sdk_client_fs, adcm_fs)
+
+
+@pytest.fixture()
+def deployed_component(cluster_with_mm, hosts) -> Tuple[Component, Component, Component, Component]:
+    """Add components on 3 hosts"""
+    default_service = cluster_with_mm.service(name=DEFAULT_SERVICE_NAME)
+    another_service = cluster_with_mm.service_add(name=ANOTHER_SERVICE_NAME)
+    default_component_1 = default_service.component(name=FIRST_COMPONENT)
+    default_component_2 = default_service.component(name=SECOND_COMPONENT)
+    another_component_1 = another_service.component(name=FIRST_COMPONENT)
+    another_component_2 = another_service.component(name=SECOND_COMPONENT)
+    host_1, host_2, host_3, *_ = hosts
+    add_hosts_to_cluster(cluster_with_mm, (host_1, host_2, host_3))
+    cluster_with_mm.hostcomponent_set(
+        (host_1, default_component_1),
+        (host_2, default_component_1),
+        (host_2, default_component_2),
+        (host_3, default_component_2),
+        (host_3, another_component_1),
+        (host_3, another_component_2),
+    )
+    return default_component_1, default_component_2, another_component_1, another_component_2
+
+
+class TestStatusAggregationWithMM:
+    """Test status aggregation with hosts in MM"""
+
+    # pylint: disable-next=too-many-arguments
+    def test_turn_mm_after_negative_status(
+        self, status_changer, sdk_client_fs, cluster_with_mm, deployed_component, hosts
+    ):
+        """
+        Test status aggregation when components on hosts are turned "off" after MM turned "on" on host
+        """
+        cluster = cluster_with_mm
+        service = cluster.service(name=DEFAULT_SERVICE_NAME)
+        component, *_ = deployed_component
+        _, host_2, host_3, *_ = hosts
+
+        service_name = service.name
+        component_name = f'{service_name}.{component.name}'
+        host_on_component_name = f'{component_name}.{host_2.fqdn}'
+
+        self.enable_cluster(status_changer, cluster)
+
+        with allure.step(f'Disable component {component_name} on host {host_2.fqdn} and host itself'):
+            status_changer.set_host_negative_status(host_2)
+            status_changer.set_component_negative_status((host_2, component))
+            check_statuses(
+                retrieve_status(sdk_client_fs, cluster),
+                cluster.name,
+                {service.name},
+                {component_name},
+                {host_on_component_name},
+                {host_2.fqdn},
+            )
+
+        turn_mm_on(host_2)
+
+        with allure.step('Expect nothing but host and component on it to be disabled'):
+            check_statuses(
+                retrieve_status(sdk_client_fs, cluster),
+                hosts_on_components={host_on_component_name},
+                hosts={host_2.fqdn},
+            )
+
+        self._turn_off_component_not_on_mm_host(sdk_client_fs, status_changer, cluster, host_2, host_3)
+
+    # pylint: disable-next=too-many-arguments
+    def test_turn_mm_before_negative_status(
+        self, status_changer, sdk_client_fs, cluster_with_mm, deployed_component, hosts
+    ):
+        """
+        Test status aggregation when components on hosts are turned "off" before MM turned "on" on host
+        """
+        _, host_2, host_3, *_ = hosts
+        cluster = cluster_with_mm
+        service = cluster.service(name=DEFAULT_SERVICE_NAME)
+        component, *_ = deployed_component
+
+        service_name = service.name
+        component_name = f'{service_name}.{component.name}'
+        host_on_component_name = f'{component_name}.{host_2.fqdn}'
+
+        turn_mm_on(host_2)
+
+        self.enable_cluster(status_changer, cluster)
+
+        with allure.step(f'Disable component {component_name} on host {host_2.fqdn} and host itself'):
+            status_changer.set_host_negative_status(host_2)
+            status_changer.set_component_negative_status((host_2, component))
+            check_statuses(
+                retrieve_status(sdk_client_fs, cluster),
+                hosts_on_components={host_on_component_name},
+                hosts={host_2.fqdn},
+            )
+
+        self._turn_off_component_not_on_mm_host(sdk_client_fs, status_changer, cluster, host_2, host_3)
+
+    @allure.step('Turn all components in cluster "on"')  # pylint: disable-next=no-self-use
+    def enable_cluster(self, status_changer, cluster) -> None:
+        """Enable all components on all hosts of the cluster"""
+        status_changer.enable_cluster(cluster)
+
+    # pylint: disable-next=no-self-use, too-many-arguments
+    def _turn_off_component_not_on_mm_host(self, client, status_changer, cluster, host_2, host_3):
+        service = cluster.service(name=DEFAULT_SERVICE_NAME)
+        component_1 = service.component(name=FIRST_COMPONENT)
+        component_2 = service.component(name=SECOND_COMPONENT)
+        service_name = service.name
+        component_name = f'{service_name}.{component_1.name}'
+        host_on_component_name = f'{component_name}.{host_2.fqdn}'
+
+        with allure.step(
+            f'Turn "off" component "{component_2.name}" on host {host_3.fqdn} '
+            'and expect it to affect aggregation statuses'
+        ):
+            status_changer.set_component_negative_status((host_3, component_2))
+            check_statuses(
+                retrieve_status(client, cluster),
+                cluster=cluster.name,
+                services={service_name},
+                components={f'{service_name}.{component_2.name}'},
+                hosts_on_components={host_on_component_name, f'{service_name}.{component_2.name}.{host_3.fqdn}'},
+                hosts={host_2.fqdn},
+            )
+
+
+def retrieve_status(client: ADCMClient, cluster: Cluster) -> dict:
+    """Get status map for cluster"""
+    url = f'{client.url}/api/v1/cluster/{cluster.id}/status/?view=interface'
+    response = requests.get(url, headers={'Authorization': f'Token {client.api_token()}'})
+    data = response.json()
+    return {
+        'name': data['name'],
+        'status': data['status'],
+        'hosts': {host['name']: {'status': host['status']} for host in data[CHILDREN_KEY]['hosts']},
+        'services': {
+            service['name']: {
+                'status': service['status'],
+                'components': {
+                    hc['name']: {
+                        'status': hc['status'],
+                        'hosts': {host_info['name']: {'status': host_info['status']} for host_info in hc['hosts']},
+                    }
+                    for hc in service['hc']
+                },
+            }
+            for service in data[CHILDREN_KEY]['services']
+        },
+    }
+
+
+@allure.step("Check statuses of a cluster's objects")  # pylint: disable-next=too-many-arguments
+def check_statuses(
+    statuses: dict,
+    cluster: str = None,
+    services: Collection[str] = (),
+    components: Collection[str] = (),
+    hosts_on_components: Collection[str] = (),
+    hosts: Collection[str] = (),
+    default_positive: bool = True,
+) -> None:
+    """
+    If `default_positive` is True then objects' names in arguments
+    are expected to have negative status
+    otherwise the positive status is expected
+    """
+    p = not_ if default_positive else truth  # pylint: disable=invalid-name
+
+    expected_statuses = {
+        'name': statuses['name'],
+        'status': _expected_status(p(bool(cluster))),
+        'hosts': {
+            fqdn: {'status': _expected_status(p(fqdn in hosts))} for fqdn, host_dict in statuses['hosts'].items()
+        },
+        'services': {
+            service_name: {
+                'status': _expected_status(p(service_name in services)),
+                'components': {
+                    component_name: {
+                        'status': _expected_status(p(f'{service_name}.{component_name}' in components)),
+                        'hosts': {
+                            fqdn: {
+                                'status': _expected_status(
+                                    p(f'{service_name}.{component_name}.{fqdn}' in hosts_on_components)
+                                )
+                            }
+                            for fqdn, host_dict in component_dict['hosts'].items()
+                        },
+                    }
+                    for component_name, component_dict in service_dict['components'].items()
+                },
+            }
+            for service_name, service_dict in statuses['services'].items()
+        },
+    }
+
+    dicts_are_equal(statuses, expected_statuses, "Status map isn't correct.\nCheck attachments for more details.")
+
+
+def _expected_status(positive: bool = True) -> int:
+    return POSITIVE_STATUS if positive else NEGATIVE_STATUS
diff --git a/tests/functional/maintenance_mode/test_upgrade_to_mm.py b/tests/functional/maintenance_mode/test_upgrade_to_mm.py
new file mode 100644
index 0000000000..46ec89f5fe
--- /dev/null
+++ b/tests/functional/maintenance_mode/test_upgrade_to_mm.py
@@ -0,0 +1,231 @@
+# 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.
+
+"""Test upgrade to bundle with MM directives"""
+
+from typing import Set
+
+import allure
+import pytest
+
+from tests.conftest import DUMMY_ACTION
+from tests.functional.maintenance_mode.conftest import (
+    MM_IS_OFF,
+    MM_IS_DISABLED,
+    add_hosts_to_cluster,
+    check_hosts_mm_is,
+    get_enabled_actions_names,
+    get_disabled_actions_names,
+    turn_mm_on,
+)
+from tests.functional.tools import get_object_represent
+from tests.library.assertions import sets_are_equal
+
+DUMMY_ACTION_DEFINITION = DUMMY_ACTION['dummy_action']
+
+ALLOWED_ACTION = {'allowed_action': {**DUMMY_ACTION_DEFINITION, 'allow_in_maintenance_mode': True}}
+TWO_DUMMY_ACTIONS = {'first_action': {**DUMMY_ACTION_DEFINITION}, 'second_action': {**DUMMY_ACTION_DEFINITION}}
+DUMMY_ACTIONS_WITH_ALLOWED = {**TWO_DUMMY_ACTIONS, **ALLOWED_ACTION}
+
+UPGRADE = {
+    'upgrade': [
+        {
+            'name': 'An ice upgrade',
+            'versions': {'min': 0.2, 'max': 2},
+            'states': {'available': 'any', 'on_success': 'cool'},
+        }
+    ]
+}
+
+OLD_BUNDLE = [
+    {'type': 'cluster', 'name': 'just_cluster', 'version': 1, 'actions': {**TWO_DUMMY_ACTIONS}},
+    {
+        'type': 'service',
+        'name': 'just_service',
+        'version': 1.1,
+        'actions': {**TWO_DUMMY_ACTIONS},
+        'components': {'just_component': {'actions': {**TWO_DUMMY_ACTIONS}}},
+    },
+]
+
+NEW_BUNDLE = [{**OLD_BUNDLE[0], 'version': 2, **UPGRADE}, {**OLD_BUNDLE[1], 'version': 1.2}]
+
+
+@pytest.mark.parametrize(
+    'create_bundle_archives',
+    [
+        [
+            OLD_BUNDLE,
+            [
+                {
+                    **NEW_BUNDLE[0],
+                    'allow_maintenance_mode': True,
+                    'actions': {**DUMMY_ACTIONS_WITH_ALLOWED},
+                },
+                {
+                    **NEW_BUNDLE[1],
+                    'actions': {**DUMMY_ACTIONS_WITH_ALLOWED},
+                    'components': {'just_component': {'actions': {**DUMMY_ACTIONS_WITH_ALLOWED}}},
+                },
+            ],
+        ]
+    ],
+    indirect=True,
+)
+def test_allow_mm_after_upgrade(sdk_client_fs, create_bundle_archives, hosts):
+    """
+    Test that after upgrade to the bundle version where MM is allowed:
+    - hosts in cluster set to correct MM mode
+    - actions are disabled/enabled correctly
+    """
+    hosts_in_cluster = hosts[:3]
+    free_hosts = hosts[3:]
+    old_bundle, *_ = [sdk_client_fs.upload_from_fs(bundle) for bundle in create_bundle_archives]
+    old_cluster = old_bundle.cluster_create('Cluster to Upgrade')
+    service = old_cluster.service_add(name='just_service')
+    component = service.component()
+
+    add_hosts_to_cluster(old_cluster, hosts_in_cluster)
+    old_cluster.hostcomponent_set(*[(host, component) for host in hosts_in_cluster])
+    check_hosts_mm_is(MM_IS_DISABLED, *hosts)
+
+    upgrade_task = old_cluster.upgrade().do()
+    if upgrade_task:
+        upgrade_task.wait()
+
+    check_hosts_mm_is(MM_IS_OFF, *hosts_in_cluster)
+    check_hosts_mm_is(MM_IS_DISABLED, *free_hosts)
+
+    check_actions_are_disabled_correctly(set(DUMMY_ACTIONS_WITH_ALLOWED.keys()), set(), old_cluster, service, component)
+    turn_mm_on(hosts_in_cluster[0])
+    check_actions_are_disabled_correctly(
+        set(ALLOWED_ACTION.keys()), set(TWO_DUMMY_ACTIONS.keys()), old_cluster, service, component
+    )
+
+
+@pytest.mark.parametrize(
+    'create_bundle_archives',
+    [[OLD_BUNDLE, [{**NEW_BUNDLE[0], 'allow_maintenance_mode': False}, {**NEW_BUNDLE[1]}]]],
+    indirect=True,
+)
+def test_upgrade_to_mm_false(sdk_client_fs, create_bundle_archives, hosts):
+    """
+    Test upgrade from version without `allow_maintenance_mode` to `allow_maintenance_mode: false`
+    """
+    old_bundle, *_ = [sdk_client_fs.upload_from_fs(bundle) for bundle in create_bundle_archives]
+    old_cluster = old_bundle.cluster_create('Cluster to Upgrade')
+    service = old_cluster.service_add(name='just_service')
+    component = service.component()
+    cluster_hosts = [old_cluster.host_add(host) for host in hosts]
+    old_cluster.hostcomponent_set(*[(h, component) for h in cluster_hosts])
+
+    check_hosts_mm_is(MM_IS_DISABLED, *cluster_hosts)
+
+    upgrade_task = old_cluster.upgrade().do()
+    if upgrade_task:
+        upgrade_task.wait()
+
+    check_hosts_mm_is(MM_IS_DISABLED, *cluster_hosts)
+    check_actions_are_disabled_correctly(set(TWO_DUMMY_ACTIONS.keys()), set(), old_cluster, service, component)
+
+
+@pytest.mark.parametrize(
+    'create_bundle_archives',
+    [
+        [
+            [{**OLD_BUNDLE[0], 'allow_maintenance_mode': True}, {**OLD_BUNDLE[1]}],
+            [{**NEW_BUNDLE[0], 'allow_maintenance_mode': False}, {**NEW_BUNDLE[1]}],
+        ]
+    ],
+    indirect=True,
+)
+def test_upgrade_from_true_to_false_mm(sdk_client_fs, create_bundle_archives, hosts):
+    """
+    Test upgrade from version with `allow_maintenance_mode: true` to `allow_maintenance_mode: false`
+    """
+    old_bundle, *_ = [sdk_client_fs.upload_from_fs(bundle) for bundle in create_bundle_archives]
+    old_cluster = old_bundle.cluster_create('Cluster to Upgrade')
+    cluster_hosts = [old_cluster.host_add(host) for host in hosts]
+    service = old_cluster.service_add(name='just_service')
+    component = service.component()
+    old_cluster.hostcomponent_set(*[(h, component) for h in cluster_hosts])
+
+    check_hosts_mm_is(MM_IS_OFF, *cluster_hosts)
+    turn_mm_on(cluster_hosts[0])
+
+    upgrade_task = old_cluster.upgrade().do()
+    if upgrade_task:
+        upgrade_task.wait()
+
+    check_hosts_mm_is(MM_IS_DISABLED, *cluster_hosts)
+    check_actions_are_disabled_correctly(set(TWO_DUMMY_ACTIONS.keys()), set(), old_cluster, service, component)
+
+
+@pytest.mark.parametrize(
+    'create_bundle_archives',
+    [
+        [
+            [
+                {
+                    **OLD_BUNDLE[0],
+                    'allow_maintenance_mode': True,
+                    'actions': {
+                        'disabled_at_first': {**DUMMY_ACTION_DEFINITION, 'allow_in_maintenance_mode': False},
+                        'enabled_at_first': {**DUMMY_ACTION_DEFINITION, 'allow_in_maintenance_mode': True},
+                    },
+                },
+                {**OLD_BUNDLE[1]},
+            ],
+            [
+                {
+                    **NEW_BUNDLE[0],
+                    'allow_maintenance_mode': True,
+                    'actions': {
+                        'disabled_at_first': {**DUMMY_ACTION_DEFINITION, 'allow_in_maintenance_mode': True},
+                        'enabled_at_first': {**DUMMY_ACTION_DEFINITION, 'allow_in_maintenance_mode': False},
+                    },
+                },
+                {**NEW_BUNDLE[1]},
+            ],
+        ]
+    ],
+    indirect=True,
+)
+def test_allowed_actions_changed(sdk_client_fs, create_bundle_archives, hosts):
+    """
+    Test upgrade when allowed/disallowed in MM actions changed
+    """
+    old_bundle, *_ = [sdk_client_fs.upload_from_fs(bundle) for bundle in create_bundle_archives]
+    old_cluster = old_bundle.cluster_create('Cluster with allowed actions changed')
+
+    add_hosts_to_cluster(old_cluster, hosts)
+    old_cluster.hostcomponent_set((hosts[0], old_cluster.service_add(name='just_service').component()))
+    turn_mm_on(hosts[0])
+
+    check_actions_are_disabled_correctly({'enabled_at_first'}, {'disabled_at_first'}, old_cluster)
+
+    task = old_cluster.upgrade().do()
+    if task:
+        task.wait()
+
+    check_actions_are_disabled_correctly({'disabled_at_first'}, {'enabled_at_first'}, old_cluster)
+
+
+@allure.step('Check correct actions are enabled/disabled due to host in MM')
+def check_actions_are_disabled_correctly(enabled_actions: Set[str], disabled_actions: Set[str], *objects):
+    """Check that actions are disabled correctly based on their names"""
+    for adcm_object in objects:
+        object_represent = get_object_represent(adcm_object)
+        enabled = get_enabled_actions_names(adcm_object)
+        sets_are_equal(enabled, enabled_actions, f'Not all actions are enabled on {object_represent}')
+        disabled = get_disabled_actions_names(adcm_object)
+        sets_are_equal(disabled, disabled_actions, f'Not all actions are disabled on {object_represent}')
diff --git a/tests/functional/rbac/actions/test_run_actions.py b/tests/functional/rbac/actions/test_run_actions.py
index a121c3c8b8..2c4aa3843c 100644
--- a/tests/functional/rbac/actions/test_run_actions.py
+++ b/tests/functional/rbac/actions/test_run_actions.py
@@ -169,7 +169,7 @@ def _test_config_change(
                 is_denied(
                     action_owner_object,
                     business_role,
-                    config={ACTION_CONFIG_ARGUMENT: "This you seen't"},
+                    data={'config': {ACTION_CONFIG_ARGUMENT: "This you seen't"}},
                     client=user_client,
                 )
                 config_val_after = admin_object.config()[CONFIG_FIELD_TO_CHANGE]
diff --git a/tests/functional/rbac/checkers.py b/tests/functional/rbac/checkers.py
index 56eb8c101f..72e052a6e2 100644
--- a/tests/functional/rbac/checkers.py
+++ b/tests/functional/rbac/checkers.py
@@ -108,6 +108,8 @@ def __call__(self, client: ADCMClient, adcm_object: AnyADCMObject, *args, **kwar
         2. Requesting this URL with one of HTTP methods with authorization from `client` argument
         3. Raise an AssertionError if response was 500
         4. Raise an AssertionError if response wasn't 403 (because it's Forbidden checker)
+
+        P.S. kwargs are passed to API call method
         """
         resource_path = self._build_resource_path(adcm_object)
         path_without_base_url = f'{self._API_ROOT}{resource_path}'.replace('//', '/')
@@ -120,6 +122,7 @@ def __call__(self, client: ADCMClient, adcm_object: AnyADCMObject, *args, **kwar
                     'Authorization': f'Token {client.api_token()}',
                     'Content-Type': 'application/json',
                 },
+                **kwargs,
             )
         if (status_code := response.status_code) >= 500:
             raise AssertionError(f'Unhandled exception on {self.method.name} call to {url} check logs')
@@ -206,3 +209,4 @@ class Deny:  # pylint: disable=too-few-public-methods
     UpgradeProvider = ForbiddenCallChecker(Provider, '', HTTPMethod.POST, special_case='upgrade')
     UpgradeCluster = ForbiddenCallChecker(Cluster, '', HTTPMethod.POST, special_case='upgrade')
     Change = _deny_endpoint_call('', HTTPMethod.PUT)  # change user, role, etc
+    PartialChange = _deny_endpoint_call('', HTTPMethod.PATCH)  # change host (for example)
diff --git a/tests/functional/rbac/conftest.py b/tests/functional/rbac/conftest.py
index aebb9ad1c4..7b48259028 100644
--- a/tests/functional/rbac/conftest.py
+++ b/tests/functional/rbac/conftest.py
@@ -191,6 +191,12 @@ class BusinessRoles(Enum):
     EditHostConfigurations = BusinessRole(
         "Edit host configurations", methodcaller("config_set_diff", {}), Deny.ChangeConfigOf(Host)
     )
+    ManageMaintenanceMode = BusinessRole(
+        # to change specific values, pass kwargs to call to denial checker
+        "Manage Maintenance mode",
+        lambda host, mm_flag: host.maintenance_mode_set(mm_flag),
+        Deny.Change(Host),
+    )
 
     ViewImports = BusinessRole("View imports", methodcaller("imports"), Deny.ViewImportsOf((Cluster, Service)))
     ManageImports = BusinessRole(
@@ -500,7 +506,7 @@ def is_denied(
                 else:
                     raise AssertionError(f"{role.role_name} on {object_represent} should not be allowed")
         else:
-            role.check_denied(client, base_object)
+            role.check_denied(client, base_object, **kwargs)
 
 
 def extract_role_short_info(role: Role) -> RoleShortInfo:
diff --git a/tests/functional/rbac/test_access_to_objects.py b/tests/functional/rbac/test_access_to_objects.py
index 9d312c7758..71b928d858 100644
--- a/tests/functional/rbac/test_access_to_objects.py
+++ b/tests/functional/rbac/test_access_to_objects.py
@@ -18,12 +18,12 @@
 
 import os.path
 from contextlib import contextmanager
-from typing import Callable, Set, List, Iterable
+from typing import Callable, Set, List, Iterable, Union, Dict
 
 import allure
 import pytest
 from adcm_client.base import ObjectNotFound
-from adcm_client.objects import ADCMClient, Task, Cluster, Service, Component, Provider, User
+from adcm_client.objects import ADCMClient, Task, Cluster, Service, Component, Provider, User, Group
 from adcm_pytest_plugin.utils import catch_failed
 
 from tests.library.utils import lower_class_name
@@ -285,8 +285,19 @@ def finished_tasks(self, cluster, provider) -> List[Task]:
             task.wait()
         return tasks
 
+    @pytest.fixture(params=['user', 'group'], ids=['with_user', 'with_group'])
+    def user_or_group(self, request, user, clients) -> Dict[str, Union[User, Group]]:
+        """Return user or group"""
+
+        if request.param == 'user':
+            return {'user': user}
+        if request.param == 'group':
+            return {'group': clients.admin.group_create(name='somegroup', user=[{'id': user.id}])}
+        raise ValueError('param should be either "user" or "group"')
+
+    # pylint: disable-next=too-many-locals
     def test_access_to_tasks(
-        self, clients, user, cluster, provider, finished_tasks
+        self, user_or_group: dict, clients, cluster, provider, finished_tasks
     ):  # pylint: disable=too-many-arguments
         """
         Test that user:
@@ -308,7 +319,7 @@ def test_access_to_tasks(
                     clients.admin,
                     adcm_object,
                     action_business_role(adcm_object, self.MULTIJOB_ACTION, no_deny_checker=True),
-                    user=user,
+                    **user_or_group,
                 )
                 user_object = get_as_client_object(user_api, adcm_object)
                 user_task = user_object.action(display_name=self.MULTIJOB_ACTION).run()
@@ -324,7 +335,9 @@ def test_access_to_tasks(
                 self.check_no_access_granted_for_tasks(clients.user, new_tasks + finished_tasks)
 
     @pytest.mark.extra_rbac()
-    def test_access_to_tasks_on_service_add_remove(self, cluster_wo_service: Cluster, clients: SDKClients, user: User):
+    def test_access_to_tasks_on_service_add_remove(
+        self, user_or_group: dict, cluster_wo_service: Cluster, clients: SDKClients
+    ):
         """
         Test that service add/remove doesn't break permission to view task objects:
         1. Add service
@@ -340,7 +353,7 @@ def test_access_to_tasks_on_service_add_remove(self, cluster_wo_service: Cluster
             clients.admin,
             service,
             action_business_role(service, self.REGULAR_ACTION, no_deny_checker=True),
-            user=user,
+            **user_or_group,
         )
         with allure.step('Run action and check access to task objects'):
             task = _run_and_wait(service, self.REGULAR_ACTION)
@@ -356,9 +369,9 @@ def test_access_to_tasks_on_service_add_remove(self, cluster_wo_service: Cluster
             self.check_access_granted_for_tasks(clients.user, [task])
             self.check_no_access_granted_for_tasks(clients.user, [second_task])
 
-    @pytest.mark.extra_rbac()
+    @pytest.mark.extra_rbac()  # pylint: disable-next=too-many-arguments
     def test_access_to_tasks_on_cluster_host_add_remove(
-        self, cluster: Cluster, provider: Provider, clients: SDKClients, user: User
+        self, user_or_group: Callable, cluster: Cluster, provider: Provider, clients: SDKClients
     ):
         """
         Test that access to task objects is correct after host add/remove from cluster
@@ -368,7 +381,7 @@ def test_access_to_tasks_on_cluster_host_add_remove(
             clients.admin,
             host,
             action_business_role(host, self.REGULAR_ACTION, no_deny_checker=True),
-            user=user,
+            **user_or_group,
         )
         with allure.step('Run action and check that access is granted'):
             task = _run_and_wait(host, self.REGULAR_ACTION)
@@ -381,8 +394,8 @@ def test_access_to_tasks_on_cluster_host_add_remove(
             cluster.host_delete(host)
             self.check_access_granted_for_tasks(clients.user, [task, second_task])
 
-    @pytest.mark.extra_rbac()
-    def test_access_to_tasks_on_hc_map_change(self, cluster, provider, clients, user):
+    @pytest.mark.extra_rbac()  # pylint: disable-next=too-many-arguments
+    def test_access_to_tasks_on_hc_map_change(self, user_or_group: dict, cluster, provider, clients):
         """
         Test that access for task objects is correct after HC map is changed
         """
@@ -394,7 +407,7 @@ def test_access_to_tasks_on_hc_map_change(self, cluster, provider, clients, user
                     clients.admin,
                     adcm_object,
                     action_business_role(adcm_object, self.REGULAR_ACTION, no_deny_checker=True),
-                    user=user,
+                    **user_or_group,
                 )
         with allure.step('Run actions and check access to them'):
             host_task = _run_and_wait(host, self.REGULAR_ACTION)
diff --git a/tests/functional/rbac/test_built_in_roles_composition.py b/tests/functional/rbac/test_built_in_roles_composition.py
index 20761d89ac..cb6f88ed21 100644
--- a/tests/functional/rbac/test_built_in_roles_composition.py
+++ b/tests/functional/rbac/test_built_in_roles_composition.py
@@ -71,6 +71,7 @@
             BusinessRoles.RemoveBundle,
             BusinessRoles.CreateHost,
             BusinessRoles.RemoveHosts,
+            BusinessRoles.ManageMaintenanceMode,
         )
     }
 )
diff --git a/tests/functional/rbac/test_business_permissions_data/cluster/config.yaml b/tests/functional/rbac/test_business_permissions_data/cluster/config.yaml
index ac776929f6..f7333fb6f0 100644
--- a/tests/functional/rbac/test_business_permissions_data/cluster/config.yaml
+++ b/tests/functional/rbac/test_business_permissions_data/cluster/config.yaml
@@ -15,6 +15,7 @@
   version: 1.5
   edition: community
   config_group_customization: true
+  allow_maintenance_mode: true
 
   config: &common_config
   - name: boolean
diff --git a/tests/functional/rbac/test_business_permissions_data/second_cluster/config.yaml b/tests/functional/rbac/test_business_permissions_data/second_cluster/config.yaml
index 72eacfd1b3..eeecf3f823 100644
--- a/tests/functional/rbac/test_business_permissions_data/second_cluster/config.yaml
+++ b/tests/functional/rbac/test_business_permissions_data/second_cluster/config.yaml
@@ -15,6 +15,7 @@
   version: 1.6
   edition: community
   config_group_customization: true
+  allow_maintenance_mode: true
   upgrade:
     - versions:
         min: 0.4
diff --git a/tests/functional/rbac/test_complex_cases.py b/tests/functional/rbac/test_complex_cases.py
index ccd89a9413..b1ff253622 100644
--- a/tests/functional/rbac/test_complex_cases.py
+++ b/tests/functional/rbac/test_complex_cases.py
@@ -14,18 +14,113 @@
 
 # pylint: disable=no-self-use, too-many-arguments
 
-from typing import Iterable
+from typing import Iterable, Union
 
 import allure
 import pytest
-from adcm_client.objects import ADCMClient
-
-from tests.functional.rbac.conftest import create_policy, BusinessRoles as BR, as_user_objects, is_allowed, is_denied
+from adcm_client.objects import ADCMClient, Host
+
+from tests.functional.rbac.checkers import Deny
+from tests.functional.rbac.conftest import (
+    create_policy,
+    BusinessRoles as BR,
+    as_user_objects,
+    is_allowed,
+    is_denied,
+    BusinessRole,
+)
 from tests.functional.tools import AnyADCMObject
 
 pytestmark = [pytest.mark.extra_rbac()]
 
 
+class TestMaintenanceMode:
+    """Test maintenance mode flag on host objects"""
+
+    @pytest.fixture()
+    def host_in_cluster_with_mm_allowed(self, prepare_objects) -> Host:
+        """Add one host to cluster"""
+        cluster, *_, host = prepare_objects
+        return cluster.host_add(host)
+
+    @pytest.fixture()
+    def second_host_in_cluster_with_mm_allowed(self, second_objects) -> Host:
+        """Add another host to another cluster"""
+        cluster, *_, host = second_objects
+        return cluster.host_add(host)
+
+    @pytest.mark.parametrize(
+        'business_role',
+        [
+            BR.ManageMaintenanceMode,
+            BusinessRole(
+                BR.ManageMaintenanceMode.value.role_name,
+                BR.ManageMaintenanceMode.value.method_call,
+                Deny.PartialChange(Host),
+            ),
+        ],
+        ids=['by_put', 'by_patch'],
+    )
+    @pytest.mark.usefixtures('host_in_cluster_with_mm_allowed', 'second_host_in_cluster_with_mm_allowed')
+    def test_manage_maintenance_mode(self, business_role, clients, user, prepare_objects, second_objects):
+        """
+        Test that manage maintenance mode role is working correctly
+        """
+        *_, host = prepare_objects
+        *_, second_host = second_objects
+
+        with allure.step("Check that user can't change maintenance mode without permission"):
+            self.check_mm_change_is_denied(host, business_role, clients.user)
+            self.check_mm_change_is_denied(second_host, business_role, clients.user)
+
+        policy = create_policy(clients.admin, BR.ManageMaintenanceMode, [host], [user], [])
+
+        with allure.step('Check that user see only host which they can edit'):
+            user_hosts = [host.fqdn for host in clients.user.host_list()]
+            assert len(user_hosts) == 1, 'User should see only 1 host'
+            assert (
+                actual_fqdn := user_hosts[0]
+            ) == host.fqdn, f'User should see only host {host.fqdn}, not {actual_fqdn}'
+
+        with allure.step('Check that user can change maintenance mode after permission is granted'):
+            user_host, *_ = as_user_objects(clients.user, host)
+            self.check_mm_change_is_allowed(user_host, business_role)
+            self.check_mm_change_is_denied(second_host, business_role, clients.user)
+
+        policy.delete()
+
+        with allure.step("Check that user can't change maintenance mode when permission is withdrawn"):
+            self.check_mm_change_is_denied(host, business_role, clients.user, new_mm_value='off', old_mm_value='on')
+            self.check_mm_change_is_denied(second_host, business_role, clients.user)
+
+    def check_mm_change_is_denied(
+        self,
+        host: Host,
+        denial_method: Union[BR, BusinessRole],
+        user_client: ADCMClient,
+        new_mm_value: str = 'on',
+        old_mm_value: str = 'off',
+    ):
+        """
+        Check that change maintenance mode is disallowed to the user
+        and the value is the same
+        """
+        is_denied(host, denial_method, client=user_client, data={'maintenance_mode': new_mm_value})
+        host.reread()
+        assert (
+            host.maintenance_mode == old_mm_value
+        ), f'Host maintenance mode should be intact and be equal to "{old_mm_value}"'
+
+    def check_mm_change_is_allowed(self, host: Host, allow_method: Union[BR, BusinessRole], new_mm_value: str = 'on'):
+        """
+        Check that change maintenance mode is allowed to the user
+        and the value changed
+        """
+        is_allowed(host, allow_method, new_mm_value)
+        host.reread()
+        assert host.maintenance_mode == new_mm_value, f'Host maintenance mode should be "{new_mm_value}"'
+
+
 class TestTwoUsers:
     """Test permissions when two non-superusers are in play"""
 
diff --git a/tests/functional/rbac/test_upgrade_adcm.py b/tests/functional/rbac/test_upgrade_adcm.py
index 882931b6b1..ba32d653aa 100644
--- a/tests/functional/rbac/test_upgrade_adcm.py
+++ b/tests/functional/rbac/test_upgrade_adcm.py
@@ -18,7 +18,7 @@
 import allure
 import pytest
 from adcm_client.objects import ADCMClient, Bundle
-from adcm_pytest_plugin.utils import random_string
+from adcm_pytest_plugin.utils import random_string, get_data_dir
 from adcm_pytest_plugin.docker_utils import ADCM
 
 from tests.functional.rbac.action_role_utils import (
@@ -63,9 +63,15 @@ def test_rbac_init_on_upgrade(
 @allure.step('Upload bundles')
 def upload_bundles(client: ADCMClient) -> Tuple[Bundle, Bundle, Bundle, Bundle]:
     """Upload sample bundles"""
+    cluster_bundles_dir = get_data_dir(__file__)
     return tuple(
-        client.upload_from_fs(os.path.join(DATA_DIR, bundle))
-        for bundle in ("cluster", "second_cluster", "provider", "second_provider")
+        client.upload_from_fs(os.path.join(directory, bundle))
+        for directory, bundle in (
+            (cluster_bundles_dir, "cluster"),
+            (cluster_bundles_dir, "second_cluster"),
+            (DATA_DIR, "provider"),
+            (DATA_DIR, "second_provider"),
+        )
     )
 
 
diff --git a/tests/functional/rbac/test_upgrade_adcm_data/cluster/config.yaml b/tests/functional/rbac/test_upgrade_adcm_data/cluster/config.yaml
new file mode 100644
index 0000000000..ac776929f6
--- /dev/null
+++ b/tests/functional/rbac/test_upgrade_adcm_data/cluster/config.yaml
@@ -0,0 +1,83 @@
+# 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.
+
+- type: cluster
+  name: test_cluster
+  version: 1.5
+  edition: community
+  config_group_customization: true
+
+  config: &common_config
+  - name: boolean
+    type: boolean
+    required: false
+    default: true
+  import: &common_import
+    test_cluster:
+      versions:
+        min: 1.6
+        max: 1.6
+    test_service:
+      versions:
+        min: 101
+        max: 101
+
+  actions: &common_actions
+    no_config: &job
+      type: job
+      script_type: ansible
+      script: ./actions.yaml
+      display_name: "No config action"
+      states:
+        available: any
+    with_config:
+      <<: *job
+      display_name: "Action with config"
+      states:
+        available: any
+      config:
+        - name: some_param
+          type: string
+
+- type: service
+  name: test_service
+  version: 101
+  config_group_customization: true
+
+  config: *common_config
+
+  actions: *common_actions
+
+  components: &common_components
+    test_component:
+      config_group_customization: true
+      config: *common_config
+      actions: *common_actions
+    new_component:
+      config_group_customization: true
+      config: *common_config
+      actions: *common_actions
+
+  import: *common_import
+
+- type: service
+  name: new_service
+  version: 101
+  config_group_customization: true
+
+  config: *common_config
+
+  actions: *common_actions
+
+  components: *common_components
+
+  import: *common_import
diff --git a/tests/functional/rbac/test_upgrade_adcm_data/second_cluster/actions.yaml b/tests/functional/rbac/test_upgrade_adcm_data/second_cluster/actions.yaml
new file mode 100644
index 0000000000..78a96f56ba
--- /dev/null
+++ b/tests/functional/rbac/test_upgrade_adcm_data/second_cluster/actions.yaml
@@ -0,0 +1,23 @@
+# 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.
+
+---
+- name: "SeeMeInAction"
+  hosts: localhost
+  tasks:
+    - name: "Hehe"
+      assert:
+        that:
+          - 1 == {{job.config.some_param}}
+        msg: "Never gonna give you up"
+      tags:
+        - compare
diff --git a/tests/functional/rbac/test_upgrade_adcm_data/second_cluster/config.yaml b/tests/functional/rbac/test_upgrade_adcm_data/second_cluster/config.yaml
new file mode 100644
index 0000000000..55e1400689
--- /dev/null
+++ b/tests/functional/rbac/test_upgrade_adcm_data/second_cluster/config.yaml
@@ -0,0 +1,84 @@
+# 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.
+
+- type: cluster
+  name: test_cluster
+  version: 1.6
+  edition: community
+  config_group_customization: true
+
+  upgrade:
+    - versions:
+        min: 0.4
+        max: 1.5
+      name: upgrade to 1.6
+      states:
+        available: any
+        on_success: upgradable
+
+  config: &common_config
+  - name: boolean
+    type: boolean
+    required: false
+    default: true
+
+  actions: &common_actions
+    no_config: &job
+      type: job
+      script_type: ansible
+      script: ./actions.yaml
+      display_name: "No config action"
+      states:
+        available: any
+    with_config:
+      <<: *job
+      display_name: "Action with config"
+      states:
+        available: any
+      config:
+        - name: some_param
+          type: string
+
+  export:
+    - boolean
+
+- type: service
+  name: test_service
+  version: 101
+  config_group_customization: true
+
+  config: *common_config
+
+  actions: *common_actions
+
+  components:
+    test_component:
+      config_group_customization: true
+      config: *common_config
+      actions: *common_actions
+
+  export:
+    - boolean
+
+- type: service
+  name: new_service
+  version: 101
+  config_group_customization: true
+
+  config: *common_config
+
+  actions: *common_actions
+
+  components:
+    new_component:
+      config: *common_config
+      actions: *common_actions
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 7ea48e23b3..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,8 +62,8 @@
 )
 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_CONFIG_CHANGE_UNSELECTED_ERROR_MESSAGE = "you cannot change the value of an unselected field"
+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"
 FIRST_GROUP = "test_group"
@@ -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_delete_service.py b/tests/functional/test_delete_service.py
index f0d177f98c..d8f6dff492 100644
--- a/tests/functional/test_delete_service.py
+++ b/tests/functional/test_delete_service.py
@@ -11,17 +11,23 @@
 # limitations under the License.
 
 """Tests for service delete method"""
-
+import allure
 from adcm_client.objects import ADCMClient
 from adcm_pytest_plugin.utils import get_data_dir
 
 
 def test_delete_service(sdk_client_fs: ADCMClient):
-    """If host has NO component, than we can simple remove it from cluster.
-
-    :return:
+    """
+    If host has NO component, then we can simply remove it from cluster.
     """
     bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__))
     cluster = bundle.cluster_create("test")
     service = cluster.service_add(name="zookeeper")
-    service.delete()
+    cluster.reread()
+    with allure.step("Ensure there's a concern on cluster from service's config"):
+        assert len(cluster.concerns()) > 0, 'There should be a concern on cluster from config of the service'
+    with allure.step('Delete service'):
+        service.delete()
+    with allure.step('Ensure that concern is gone from cluster after service removal'):
+        cluster.reread()
+        assert len(cluster.concerns()) == 0, 'Concern on cluster should be removed alongside with the service'
diff --git a/tests/functional/test_delete_service_data/config.yaml b/tests/functional/test_delete_service_data/config.yaml
index 07035c3855..260286a443 100644
--- a/tests/functional/test_delete_service_data/config.yaml
+++ b/tests/functional/test_delete_service_data/config.yaml
@@ -12,7 +12,11 @@
 ---
 - type: service
   name: zookeeper
-  version: &zookeeper_version 3.4.10
+  version: 3.4.10
+
+  config:
+    - name: some_param
+      type: integer
 
 - type: cluster
   name: Nothing
diff --git a/tests/functional/test_inventories.py b/tests/functional/test_inventories.py
index 8b127cca3d..d6edd5ce56 100644
--- a/tests/functional/test_inventories.py
+++ b/tests/functional/test_inventories.py
@@ -13,20 +13,18 @@
 """Tests for actions inventory"""
 
 import json
-from typing import Optional
-
 from uuid import uuid4
+from typing import Optional
 
 import allure
 import pytest
-
 from _pytest.fixtures import SubRequest
 from adcm_client.objects import Provider, ADCMClient, Cluster, ADCM
 from adcm_pytest_plugin import utils
 from adcm_pytest_plugin.docker_utils import get_file_from_container
 from adcm_pytest_plugin.steps.actions import run_cluster_action_and_assert_result
 
-from tests.functional.tools import create_config_group_and_add_host, BEFORE_UPGRADE_DEFAULT_STATE
+from tests.functional.tools import create_config_group_and_add_host, BEFORE_UPGRADE_DEFAULT_STATE, get_inventory_file
 from tests.functional.conftest import only_clean_adcm
 
 # pylint: disable=redefined-outer-name
@@ -115,16 +113,14 @@ def test_before_upgrade_in_inventory(self, adcm_fs, old_cluster):
         with allure.step('Check before_upgrade state before upgrade'):
             task = old_cluster.action(name="do_nothing").run()
             task.wait()
-            self.check_before_upgrade_state_equal_to(
-                BEFORE_UPGRADE_DEFAULT_STATE, _get_inventory_file(adcm_fs, task.id)
-            )
+            self.check_before_upgrade_state_equal_to(BEFORE_UPGRADE_DEFAULT_STATE, get_inventory_file(adcm_fs, task.id))
         with allure.step('Check before_upgrade state after upgrade'):
             state_before_upgrade = old_cluster.state
             old_cluster.upgrade().do()
             old_cluster.reread()
             task = old_cluster.action(name="do_nothing").run()
             task.wait()
-            self.check_before_upgrade_state_equal_to(state_before_upgrade, _get_inventory_file(adcm_fs, task.id))
+            self.check_before_upgrade_state_equal_to(state_before_upgrade, get_inventory_file(adcm_fs, task.id))
 
     # pylint: disable-next=no-self-use
     def check_before_upgrade_state_equal_to(self, expected_state: Optional[str], inventory: dict):
@@ -136,13 +132,6 @@ def check_before_upgrade_state_equal_to(self, expected_state: Optional[str], inv
         ), f'Before upgrade state should be "{expected_state}", but actual state is "{actual_state}"'
 
 
-def _get_inventory_file(adcm_fs: ADCM, task_id: int) -> dict:
-    """Get inventory.json file from ADCM as dict"""
-    file = get_file_from_container(adcm_fs, f'/adcm/data/run/{task_id}/', 'inventory.json')
-    content = file.read().decode('utf8')
-    return json.loads(content)
-
-
 def _attach_inventory_file(request: SubRequest, inventory_content: str, name: str):
     """Attach inventory file on top level of allure report"""
     reporter = utils.allure_reporter(request.config)
diff --git a/tests/functional/test_known_bugs.py b/tests/functional/test_known_bugs.py
index a1d0c0930c..46f1ee2676 100644
--- a/tests/functional/test_known_bugs.py
+++ b/tests/functional/test_known_bugs.py
@@ -15,11 +15,13 @@
 import allure
 import pytest
 from adcm_client.objects import Cluster, Provider, Bundle
+from adcm_pytest_plugin.steps.actions import run_service_action_and_assert_result
 from adcm_pytest_plugin.utils import get_data_dir
 
 from tests.functional.conftest import only_clean_adcm
 
 # pylint: disable=redefined-outer-name
+from tests.functional.tools import create_config_group_and_add_host
 
 pytestmark = [only_clean_adcm, pytest.mark.regression]
 
@@ -46,6 +48,16 @@ def provider(sdk_client_fs) -> Provider:
     return _provider_bundle(sdk_client_fs).provider_create(name='Test Dummy Provider')
 
 
+@pytest.fixture()
+def cluster_with_services(sdk_client_fs) -> Cluster:
+    """Create cluster and add two services"""
+    bundle = sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'cluster_with_services'))
+    cluster = bundle.cluster_create(name='Test Cluster')
+    cluster.service_add(name='first_service')
+    cluster.service_add(name='second_service')
+    return cluster
+
+
 @allure.issue(url='https://arenadata.atlassian.net/browse/ADCM-2659')
 def test_database_is_locked_during_upload(sdk_client_fs, provider):
     """
@@ -56,3 +68,22 @@ def test_database_is_locked_during_upload(sdk_client_fs, provider):
             provider.host_create(f'host-{i}').action(name='dummy').run()
     with allure.step('Try to upload cluster bundle'):
         sdk_client_fs.upload_from_fs(get_data_dir(__file__, 'simple_cluster_from_dirty_upgrade'))
+
+
+@allure.issue(url='https://arenadata.atlassian.net/browse/ADCM-2800')
+def test_missing_service_outside_config_group(cluster_with_services, provider):
+    """
+    Test that all services' configs are available in inventory file when one of services has a config group
+    """
+    action_name = 'check'
+    cluster = cluster_with_services
+    first_service, second_service = cluster.service(name='first_service'), cluster.service(name='second_service')
+    component_1, component_2 = first_service.component(), second_service.component()
+    host_1, host_2 = [cluster.host_add(provider.host_create(f'test-host-{i}')) for i in range(2)]
+
+    cluster.hostcomponent_set((host_1, component_1), (host_1, component_2), (host_2, component_1))
+    create_config_group_and_add_host('config-group', first_service, host_1)
+
+    with allure.step('Run actions on services and check config dicts are available'):
+        run_service_action_and_assert_result(first_service, action_name)
+        run_service_action_and_assert_result(second_service, action_name)
diff --git a/tests/functional/test_known_bugs_data/cluster_with_services/actions.yaml b/tests/functional/test_known_bugs_data/cluster_with_services/actions.yaml
new file mode 100644
index 0000000000..a6c44c2c3b
--- /dev/null
+++ b/tests/functional/test_known_bugs_data/cluster_with_services/actions.yaml
@@ -0,0 +1,32 @@
+# 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.
+
+---
+- hosts: all
+  connection: local
+  gather_facts: false
+  tasks:
+    - name: "Print param of the first service"
+      debug:
+        msg: "{{ services.first_service.config.param }}"
+
+    - name: "Print param of the component of the first service"
+      debug:
+        msg: "{{ services.first_service.first_component.config.param }}"
+
+    - name: "Print param of the second service"
+      debug:
+        msg: "{{ services.second_service.config.param }}"
+
+    - name: "Print param of the component of second service"
+      debug:
+        msg: "{{ services.second_service.another_component.config.param }}"
diff --git a/tests/functional/test_known_bugs_data/cluster_with_services/config.yaml b/tests/functional/test_known_bugs_data/cluster_with_services/config.yaml
new file mode 100644
index 0000000000..2a01c1912a
--- /dev/null
+++ b/tests/functional/test_known_bugs_data/cluster_with_services/config.yaml
@@ -0,0 +1,56 @@
+# 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.
+
+- type: cluster
+  version: 2.3
+  name: test_cluster
+
+  config_group_customization: true
+
+  config: &config
+    - name: param
+      type: string
+      default: something
+
+  actions: &actions
+    check:
+      type: job
+      script_type: ansible
+      script: ./actions.yaml
+      states:
+        available: any
+
+
+- type: service
+  version: 2.1
+  name: first_service
+
+  config: *config
+
+  actions: *actions
+
+  components:
+    first_component: &component
+      config: *config
+      actions: *actions
+
+
+- type: service
+  version: 2.11
+  name: second_service
+
+  config: *config
+
+  actions: *actions
+
+  components:
+    another_component: *component
diff --git a/tests/functional/test_locked_objects.py b/tests/functional/test_locked_objects.py
index d1b7211e13..7b5bc23f62 100644
--- a/tests/functional/test_locked_objects.py
+++ b/tests/functional/test_locked_objects.py
@@ -17,6 +17,7 @@
 
 import allure
 import pytest
+from _pytest.outcomes import Failed
 from adcm_client.base import ObjectNotFound
 from adcm_client.objects import (
     Provider,
@@ -29,36 +30,43 @@
 )
 from adcm_pytest_plugin import utils
 from adcm_pytest_plugin.steps.asserts import assert_state
-from adcm_pytest_plugin.utils import random_string
-
-from tests.functional.tools import AnyADCMObject
+from adcm_pytest_plugin.utils import random_string, catch_failed
+from coreapi.exceptions import ErrorMessage
 
 LOCK_ACTION_NAMES = ["lock", "lock_multijob"]
 
 
 @pytest.fixture()
 def cluster(sdk_client_fs: ADCMClient) -> Cluster:
-    """Create cluster"""
+    """
+    Upload dummy cluster bundle and create cluster object
+    """
     uploaded_bundle = sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, "cluster"))
     return uploaded_bundle.cluster_prototype().cluster_create(name=random_string())
 
 
 @pytest.fixture()
 def host_provider(sdk_client_fs: ADCMClient) -> Provider:
-    """Create hostprovider"""
+    """
+    Upload dummy host provider bundle and create provider object
+    """
     provider_bundle = sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, "provider"))
     return provider_bundle.provider_prototype().provider_create(random_string())
 
 
 @pytest.fixture()
 def host(host_provider: Provider) -> Host:
-    """Create host"""
+    """
+    Create dummy host
+    """
     return host_provider.host_create(random_string())
 
 
 @pytest.fixture()
 def complete_cluster(cluster: Cluster, host: Host) -> Cluster:
-    """Add service, component and host to cluster"""
+    """
+    Prepare dummy cluster with service and component on host
+    """
     cluster.host_add(host)
     service = cluster.service_add(name="first_service")
     cluster.hostcomponent_set((host, service.component(name="first_service_component_1")))
@@ -67,7 +75,9 @@ def complete_cluster(cluster: Cluster, host: Host) -> Cluster:
 
 @pytest.fixture()
 def cluster_with_two_hosts(cluster: Cluster, host_provider: Provider) -> Tuple[Cluster, List[Host]]:
-    """Add service, component and host to cluster twice"""
+    """
+    Prepare dummy cluster with two hosts
+    """
     hosts = []
     for i in range(2):
         host = host_provider.host_create(f"host_{i}")
@@ -299,7 +309,9 @@ def test_no_horizontal_lock(self, host_provider: Provider, lock_action):
 
 
 def test_cluster_should_be_unlocked_when_ansible_task_killed(cluster: Cluster):
-    """Test that cluster is unlocked if ansible task is killed"""
+    """
+    Test cluster unlock if ansible task killed
+    """
     with allure.step("Run cluster action: lock-terminate for cluster"):
         task = cluster.action(name="lock-terminate").run()
     is_locked(cluster)
@@ -308,7 +320,9 @@ def test_cluster_should_be_unlocked_when_ansible_task_killed(cluster: Cluster):
 
 
 def test_host_should_be_unlocked_when_ansible_task_killed(complete_cluster: Cluster, host: Host):
-    """Test that host is unlocked if ansible task is killed"""
+    """
+    Test host unlock if ansible task killed
+    """
     with allure.step("Run action: lock-terminate for cluster"):
         task = complete_cluster.action(name="lock-terminate").run()
 
@@ -337,7 +351,9 @@ def test_host_should_be_unlocked_after_expand_action(
     adcm_object: str,
     expand_action: str,
 ):
-    """Test that host is unlocked after expand action"""
+    """
+    Test host should be unlocked after expand action (success or failed) on ADCM object
+    """
     action_args = {"obj_for_action": None}
     cluster, _ = cluster_with_two_hosts
     first_service = cluster.service_add(name="first_service")
@@ -375,6 +391,60 @@ def test_host_should_be_unlocked_after_shrink_action(
     _test_shrink_object_action(cluster_with_two_hosts, action_name=shrink_action, **action_args)
 
 
+@pytest.mark.parametrize("adcm_object", ["Cluster", "Service", "Component"])
+@pytest.mark.parametrize(
+    "expand_action",
+    [
+        "expand_success",
+        "expand_failed",
+    ],
+)
+def test_expand_on_clean_locked_host(
+    cluster_with_two_hosts: Tuple[Cluster, List[Host]],
+    adcm_object: str,
+    expand_action: str,
+):
+    """
+    Test attempt to expand component on locked host.
+    Action must end with "...locked host..." API error
+    """
+    obj_for_action = None
+    cluster, hosts = cluster_with_two_hosts
+    host1, host2 = hosts
+
+    dummy_service = cluster.service_add(name="first_service")
+    dummy_component = dummy_service.component(name="first_service_component_1")
+    cluster.hostcomponent_set(
+        (host1, dummy_component),
+    )
+    _lock_obj(host2, duration=30)
+
+    if adcm_object == "Cluster":
+        obj_for_action = cluster
+    elif adcm_object == "Service":
+        obj_for_action = dummy_service
+    elif adcm_object == "Component":
+        obj_for_action = dummy_component
+
+    with allure.step(f"Run {obj_for_action.__class__.__name__} action: expand on clean locked host"):
+        with catch_failed(Failed, "Expand action should throw an API error as Host is locked"):
+            with pytest.raises(ErrorMessage, match="is locked"):
+                obj_for_action.action(name=expand_action,).run(
+                    hc=[
+                        {
+                            "host_id": host1.host_id,
+                            "service_id": dummy_component.service_id,
+                            "component_id": dummy_component.component_id,
+                        },
+                        {
+                            "host_id": host2.host_id,
+                            "service_id": dummy_component.service_id,
+                            "component_id": dummy_component.component_id,
+                        },
+                    ]
+                ).wait()
+
+
 @pytest.mark.parametrize(
     "action_with_ansible_plugin",
     [
@@ -399,7 +469,9 @@ def test_host_should_be_unlocked_after_service_action_with_ansible_plugin(
     cluster_with_two_hosts: Tuple[Cluster, List[Host]],
     action_with_ansible_plugin: str,
 ):
-    """Test that host is unlocked after service action in ansible plugin"""
+    """
+    Test host should be unlocked after Service action with ansible plugin (simple job or multi-job)
+    """
     cluster, _ = cluster_with_two_hosts
     dummy_service = cluster.service_add(name="first_service")
     _test_object_action_with_ansible_plugin(
@@ -427,7 +499,9 @@ def test_host_should_be_unlocked_after_cluster_action_with_ansible_plugin(
     cluster_with_two_hosts: Tuple[Cluster, List[Host]],
     action_with_ansible_plugin: str,
 ):
-    """Test that host is unlocked after cluster action in ansible plugin"""
+    """
+    Test host should be unlocked after Cluster action with ansible plugin (simple job or multi-job)
+    """
     cluster, _ = cluster_with_two_hosts
     _test_object_action_with_ansible_plugin(
         cluster_with_two_hosts, action_name=action_with_ansible_plugin, obj_for_action=cluster
@@ -495,6 +569,9 @@ def _test_expand_object_action(
     obj_for_action: Union[Cluster, Service, Component],
     action_name: str,
 ):
+    """
+    Common method for expand object tests
+    """
     cluster, hosts = cluster_with_two_hosts
     host1, host2 = hosts
     first_service = cluster.service(name="first_service")
@@ -526,6 +603,9 @@ def _test_shrink_object_action(
     obj_for_action: Union[Cluster, Service, Component],
     action_name: str,
 ):
+    """
+    Common method for shrink object tests
+    """
     cluster, hosts = cluster_with_two_hosts
     host1, host2 = hosts
     first_service_component, second_service_component = _cluster_with_components(cluster, hosts)
@@ -553,6 +633,9 @@ def _test_object_action_with_ansible_plugin(
     obj_for_action: Union[Cluster, Service, Component],
     action_name: str,
 ):
+    """
+    Common method for object action with ansible plugin
+    """
     cluster, hosts = cluster_with_two_hosts
     host1, host2 = hosts
     _cluster_with_components(cluster, hosts)
@@ -580,12 +663,14 @@ def _cluster_with_components(cluster: Cluster, hosts: List[Host]):
     return first_service_component, second_service_component
 
 
-def _lock_obj(obj: AnyADCMObject, lock_action: str = "lock") -> Task:
+def _lock_obj(
+    obj: Union[Cluster, Service, Component, Provider, Host], lock_action: str = "lock", duration: int = 5
+) -> Task:
     """
     Run action lock on object
     """
-    with allure.step(f"Lock {obj.__class__.__name__} with action '{lock_action}'"):
-        return obj.action(name=lock_action).run()
+    with allure.step(f"Lock {obj.__class__.__name__} with {duration} sec duration"):
+        return obj.action(name=lock_action).run(config={"duration": duration})
 
 
 def is_locked(obj: Union[Cluster, Service, Component, Provider, Host]):
diff --git a/tests/functional/test_objects_issues.py b/tests/functional/test_objects_issues.py
index ba5adf94aa..a306e92e5c 100644
--- a/tests/functional/test_objects_issues.py
+++ b/tests/functional/test_objects_issues.py
@@ -11,17 +11,36 @@
 # limitations under the License.
 
 """Tests for object issues"""
+from typing import Tuple
 
 import allure
 import coreapi
 import pytest
 
 from adcm_client.base import ActionHasIssues
-from adcm_client.objects import ADCMClient
+from adcm_client.objects import ADCMClient, Provider, Host, Service
 from adcm_pytest_plugin import utils
+from adcm_pytest_plugin.utils import catch_failed
+from coreapi.exceptions import ErrorMessage
+
 from tests.library.errorcodes import UPGRADE_ERROR
 
 
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture()
+def provider_bundle(sdk_client_fs):
+    """Get provider without concerns bundle"""
+    return sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, 'provider_wo_concern'))
+
+
+@pytest.fixture()
+def provider(provider_bundle):
+    """Get provider without concerns"""
+    return provider_bundle.provider_create(name='Test Provider')
+
+
 def test_action_should_not_be_run_while_cluster_has_an_issue(sdk_client_fs: ADCMClient):
     """Test action should not be run while cluster has an issue"""
     bundle_path = utils.get_data_dir(__file__, "cluster")
@@ -86,9 +105,7 @@ def test_when_hostprovider_has_issue_than_upgrade_locked(sdk_client_fs: ADCMClie
 
 
 @allure.link("https://jira.arenadata.io/browse/ADCM-487")
-def test_when_component_has_no_constraint_then_cluster_doesnt_have_issues(
-    sdk_client_fs: ADCMClient,
-):
+def test_when_component_has_no_constraint_then_cluster_doesnt_have_issues(sdk_client_fs: ADCMClient):
     """Test no cluster issues if no constraints on components"""
     with allure.step("Create cluster (component has no constraint)"):
         bundle_path = utils.get_data_dir(__file__, "cluster_component_hasnt_constraint")
@@ -100,3 +117,82 @@ def test_when_component_has_no_constraint_then_cluster_doesnt_have_issues(
     with allure.step("Check if state is always-locked"):
         cluster.reread()
         assert cluster.state == "always-locked"
+
+
+@allure.link('https://arenadata.atlassian.net/browse/ADCM-2810')
+def test_concerns_are_deleted_with_cluster_deletion(sdk_client_fs: ADCMClient, provider: Provider):
+    """Tests concerns are deleted from all related objects when the cluster is deleted"""
+    with allure.step('Upload bundles and create cluster'):
+        cluster_bundle = sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, 'cluster'))
+        cluster = cluster_bundle.cluster_create(name='Test Cluster')
+    _, host_1, host_2 = _add_services_and_map_hosts_to_it(cluster, provider)
+    with allure.step('Check there is a concern on one of the hosts'):
+        with catch_failed(ErrorMessage, 'No errors should be raised on concerns check'):
+            _check_object_has_concerns(host_1)
+            _check_object_has_no_concerns(host_2)
+    with allure.step('Delete cluster and expect issues to go away'):
+        cluster.delete()
+    with allure.step('Check concern is gone'):
+        with catch_failed(ErrorMessage, 'No errors should be raised on concerns check'):
+            _check_object_has_no_concerns(host_1)
+            _check_object_has_no_concerns(host_2)
+
+
+@pytest.mark.parametrize('bundle_name', ['host', 'provider'])
+def test_host_concerns_stays_after_cluster_deletion(bundle_name: str, sdk_client_fs: ADCMClient):
+    """Test that host/provider's concerns aren't deleted with cluster deletion"""
+    concerns_from_provider_objects = 1
+    concerns_from_cluster_objects = 3
+    with allure.step('Upload provider bundle'):
+        provider_bundle = sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, bundle_name))
+        provider = provider_bundle.provider_create(name='Test Provider')
+    with allure.step('Upload bundles and create cluster'):
+        cluster_bundle = sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, 'cluster'))
+        cluster = cluster_bundle.cluster_create(name='Test Cluster')
+    _, host_1, host_2 = _add_services_and_map_hosts_to_it(cluster, provider)
+    with allure.step('Check there are correct amount of concerns before cluster is deleted'):
+        _check_concerns_amount(host_1, concerns_from_provider_objects + concerns_from_cluster_objects)
+        _check_concerns_amount(host_2, concerns_from_provider_objects)
+    cluster.delete()
+    with allure.step('Check there are correct amount of concerns after cluster is deleted'):
+        _check_concerns_amount(host_1, concerns_from_provider_objects)
+        _check_concerns_amount(host_2, concerns_from_provider_objects)
+
+
+def test_only_service_concerns_are_deleted_after_it(sdk_client_fs: ADCMClient):
+    """Test amount of concerns before/after the service with concerns is deleted from the cluster"""
+    with allure.step('Upload bundles and create cluster'):
+        cluster_bundle = sdk_client_fs.upload_from_fs(utils.get_data_dir(__file__, 'cluster'))
+        cluster = cluster_bundle.cluster_create(name='Test Cluster')
+        service = cluster.service_add(name='service_1')
+    with allure.step('Check there are correct amount of concerns before service is deleted'):
+        _check_concerns_amount(cluster, 3)
+    service.delete()
+    with allure.step('Check there are correct amount of concerns after service is deleted'):
+        _check_concerns_amount(cluster, 1)
+
+
+def _check_object_has_concerns(adcm_object):
+    adcm_object.reread()
+    assert len(adcm_object.concerns()) > 0, f'Object {adcm_object} should has at least one concern'
+
+
+def _check_object_has_no_concerns(adcm_object):
+    adcm_object.reread()
+    assert len(adcm_object.concerns()) == 0, f'Object {adcm_object} should not has any concerns'
+
+
+def _check_concerns_amount(adcm_object, expected_amount):
+    adcm_object.reread()
+    assert (
+        actual_amount := len(adcm_object.concerns()) == expected_amount
+    ), f'Object {adcm_object} should be {expected_amount}, not {actual_amount}'
+
+
+@allure.step('Add service, map host to it and add another service')
+def _add_services_and_map_hosts_to_it(cluster, provider) -> Tuple[Service, Host, Host]:
+    service_1 = cluster.service_add(name='service_1')
+    host_1, host_2 = [cluster.host_add(provider.host_create(f'test-host-{i}')) for i in range(2)]
+    cluster.hostcomponent_set((host_1, service_1.component()))
+    cluster.service_add(name='service_2')
+    return service_1, host_1, host_2
diff --git a/tests/functional/test_objects_issues_data/cluster/config.yaml b/tests/functional/test_objects_issues_data/cluster/config.yaml
index f874901a6e..5beffd7b4e 100644
--- a/tests/functional/test_objects_issues_data/cluster/config.yaml
+++ b/tests/functional/test_objects_issues_data/cluster/config.yaml
@@ -21,7 +21,7 @@
       description: cluster bundle upgrade
       states: { available: any }
 
-  config:
+  config: &config
     required_param:
       type: integer
       required: true
@@ -35,3 +35,22 @@
         available: any
         on_success: installed
         on_fail: created
+
+
+- type: service
+  name: service_1
+  version: 3.4
+
+  config: *config
+
+  components: &components
+    test_component:
+      config: *config
+
+- type: service
+  name: service_2
+  version: 3.2
+
+  config: *config
+
+  components: *components
diff --git a/tests/functional/test_objects_issues_data/provider_wo_concern/config.yaml b/tests/functional/test_objects_issues_data/provider_wo_concern/config.yaml
new file mode 100644
index 0000000000..ee59506b56
--- /dev/null
+++ b/tests/functional/test_objects_issues_data/provider_wo_concern/config.yaml
@@ -0,0 +1,39 @@
+# 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.
+---
+- type: provider
+  name: sample hostprovider
+  version: 1.0
+
+  actions:
+    install:
+      type: job
+      script_type: ansible
+      script: install.yaml
+      states:
+        available: any
+        on_success: installed
+        on_fail: created
+
+- type: host
+  name: host sample
+  version: 1.0
+
+  actions:
+    install:
+      type: job
+      script_type: ansible
+      script: install.yaml
+      states:
+        available: any
+        on_success: installed
+        on_fail: created
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/functional/tools.py b/tests/functional/tools.py
index 62192cc872..ffedbe4291 100644
--- a/tests/functional/tools.py
+++ b/tests/functional/tools.py
@@ -12,12 +12,15 @@
 """
 Common functions and helpers for testing ADCM
 """
-from typing import List, Tuple, Callable, Union, Iterable
+import json
+from typing import List, Tuple, Callable, Union, Iterable, Dict, Collection
 
 import allure
 import pytest
 
 from _pytest.outcomes import Failed
+from adcm_pytest_plugin.utils import catch_failed
+from adcm_pytest_plugin.docker_utils import get_file_from_container, ADCM
 from coreapi.exceptions import ErrorMessage
 from adcm_client.base import ObjectNotFound, PagingEnds
 from adcm_client.objects import (
@@ -35,7 +38,6 @@
     Group,
     User,
 )
-from adcm_pytest_plugin.utils import catch_failed
 
 
 BEFORE_UPGRADE_DEFAULT_STATE = None
@@ -125,3 +127,30 @@ def create_config_group_and_add_host(
         for host in hosts:
             group.host_add(host)
         return group
+
+
+def get_inventory_file(adcm_fs: ADCM, task_id: int) -> dict:
+    """Get inventory.json file from ADCM as dict"""
+    file = get_file_from_container(adcm_fs, f'/adcm/data/run/{task_id}/', 'inventory.json')
+    content = file.read().decode('utf8')
+    return json.loads(content)
+
+
+# !===== HC ACL builder =====!
+
+
+def build_hc_for_hc_acl_action(
+    cluster: Cluster, add: Collection[Tuple[Component, Host]] = (), remove: Collection[Tuple[Component, Host]] = ()
+) -> List[Dict[str, int]]:
+    """
+    Build a `hc` argument for a `hc_acl` action run based on cluster's hostcomponent and add/remove "directives".
+    Result contains only unique entries (because of the HC nature).
+    """
+    hostcomponent = {(hc['service_id'], hc['component_id'], hc['host_id']) for hc in cluster.hostcomponent()}
+    to_remove = {(component.service_id, component.id, from_host.id) for component, from_host in remove}
+    hostcomponent.difference_update(to_remove)
+    to_add = {(component.service_id, component.id, to_host.id) for component, to_host in add}
+    return [
+        {'service_id': service_id, 'component_id': component_id, 'host_id': host_id}
+        for service_id, component_id, host_id in (hostcomponent | to_add)
+    ]
diff --git a/tests/library/assertions.py b/tests/library/assertions.py
index 89f10cd68d..a723aab625 100644
--- a/tests/library/assertions.py
+++ b/tests/library/assertions.py
@@ -11,10 +11,16 @@
 # limitations under the License.
 
 """Various "rich" checks for common assertions"""
+import json
 import pprint
-from typing import Callable, Union, Collection, TypeVar
+from typing import Callable, Union, Collection, TypeVar, Optional
 
 import allure
+import pytest
+from adcm_pytest_plugin.utils import catch_failed
+from coreapi.exceptions import ErrorMessage
+
+from tests.library.errorcodes import ADCMError
 
 T = TypeVar('T')  # pylint: disable=invalid-name
 
@@ -116,9 +122,47 @@ def dicts_are_equal(actual: dict, expected: dict, message: Union[str, Callable]
     if actual == expected:
         return
 
-    allure.attach(pprint.pformat(actual), name='Actual dictionary')
-    allure.attach(pprint.pformat(expected), name='Expected dictionary')
+    allure.attach(json.dumps(actual, indent=2), name='Actual dictionary', attachment_type=allure.attachment_type.JSON)
+    allure.attach(
+        json.dumps(expected, indent=2), name='Expected dictionary', attachment_type=allure.attachment_type.JSON
+    )
     message = message if not callable(message) else message(**kwargs)
     if not message:
         message = "Two dictionaries aren't equal as was expected.\nCheck step attachments for more details."
     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.
+
+    If `err_` is provided, raised exception will be checked against it by calling `.equal`
+    """
+    with allure.step(f'Execute "{operation_name}" and expect it to raise API error "{err_}"'):
+        with pytest.raises(ErrorMessage) as e:
+            operation(*args, **kwargs)
+        if err_:
+            err_.equal(e)
+
+
+def expect_no_api_error(operation_name: str, operation: Callable, *args, **kwargs):
+    """
+    Perform "operation" and expect it to pass without raising an API error
+    """
+    with allure.step(f'Execute "{operation_name}" and expect it to succeed without API errors'):
+        with catch_failed(ErrorMessage, f'Operation should be allowed: {operation_name}'):
+            return operation(*args, **kwargs)
diff --git a/tests/library/errorcodes.py b/tests/library/errorcodes.py
index 2e39e403c5..435e3bce91 100644
--- a/tests/library/errorcodes.py
+++ b/tests/library/errorcodes.py
@@ -244,3 +244,19 @@ def __str__(self):
     '409 Conflict',
     'BIND_ERROR',
 )
+
+MAINTENANCE_MODE_NOT_AVAILABLE = ADCMError(
+    '409 Conflict',
+    'MAINTENANCE_MODE_NOT_AVAILABLE',
+)
+
+ACTION_ERROR = ADCMError(
+    '409 Conflict',
+    'ACTION_ERROR',
+)
+
+
+INVALID_HC_HOST_IN_MM = ADCMError(
+    '409 Conflict',
+    'INVALID_HC_HOST_IN_MM',
+)
diff --git a/tests/library/utils.py b/tests/library/utils.py
index 5a0afc4875..9455350479 100644
--- a/tests/library/utils.py
+++ b/tests/library/utils.py
@@ -15,10 +15,10 @@
 import time
 import json
 import random
-from typing import Tuple
+from typing import Tuple, Iterable
 
 import requests
-
+from adcm_client.objects import Host
 from adcm_pytest_plugin.plugin import parametrized_by_adcm_version
 
 
@@ -189,3 +189,8 @@ def previous_adcm_version_tag() -> Tuple[str, str]:
 def lower_class_name(obj: object) -> str:
     """Return lower class name"""
     return obj.__class__.__name__.lower()
+
+
+def get_hosts_fqdn_representation(hosts: Iterable[Host]):
+    """Return string with host FQDNs separated by ','"""
+    return ", ".join(host.fqdn for host in hosts)
diff --git a/tests/ui_tests/app/helpers/configs_generator.py b/tests/ui_tests/app/helpers/configs_generator.py
index bdb66507a2..740ca92acf 100644
--- a/tests/ui_tests/app/helpers/configs_generator.py
+++ b/tests/ui_tests/app/helpers/configs_generator.py
@@ -15,6 +15,7 @@
 
 import os
 import tempfile
+from typing import Optional
 
 import allure
 import pytest
@@ -24,7 +25,7 @@
 pytestmark = [pytest.mark.full()]
 
 
-TYPES = ['string', 'password', 'integer', 'text', 'boolean', 'float', 'list', 'map', 'json', 'file', 'secrettext']
+TYPES = ['string', 'password', 'integer', 'text', 'boolean', 'float', 'list', 'map', 'json', 'file']
 
 CONFIG_FILE = 'config.yaml'
 DEFAULT_VALUE = {
@@ -55,6 +56,7 @@ def __repr__(self):
 @allure.step('Generate expected result for config')
 def generate_config_expected_result(config) -> dict:
     """Generate expected result for config"""
+
     expected_result = {
         'visible': not config['ui_options']['invisible'],
         'editable': not config['read_only'],
@@ -83,25 +85,43 @@ def generate_configs(
     default: bool = True,
     required: bool = True,
     read_only: bool = True,
-    config_group_customization: bool = True,
-    group_customization: bool = True,
+    config_group_customization: Optional[bool] = True,
+    group_customization: Optional[bool] = True,
 ) -> tuple:
     """Generate ADCM config dictionaries for fields"""
 
     with allure.step('Generate data set for configs without groups'):
-        data = {
-            'default': default,
-            "required": required,
-            "read_only": read_only,
-            "ui_options": {"invisible": invisible, 'advanced': advanced},
-            "group_customization": group_customization,
+        data = (
+            {
+                'default': default,
+                "required": required,
+                "read_only": read_only,
+                "ui_options": {"invisible": invisible, 'advanced': advanced},
+                "group_customization": group_customization,
+                "description": "test description",
+            }
+            if group_customization is not None
+            else {
+                'default': default,
+                "required": required,
+                "read_only": read_only,
+                "ui_options": {"invisible": invisible, 'advanced': advanced},
+            }
+        )
+    config_dict = (
+        {
+            "type": "cluster",
+            "version": "1",
+            "config": [],
+            "config_group_customization": config_group_customization,
         }
-    config_dict = {
-        "type": "cluster",
-        "version": "1",
-        "config": [],
-        "config_group_customization": config_group_customization,
-    }
+        if config_group_customization is not None
+        else {
+            "type": "cluster",
+            "version": "1",
+            "config": [],
+        }
+    )
     field_config = {'name': field_type, 'type': field_type, 'required': data['required']}
     if data['default']:
         field_config['default'] = DEFAULT_VALUE[field_type]
@@ -118,18 +138,19 @@ def generate_configs(
 
 
 def prepare_config(config):
+    """Create config file and return config, expected result and path to config file"""
+
     config_info = config[0][0]['config'][0]
-    templ = "type_{}_required_{}_ro_{}_content_{}_invisible_{}_advanced_{}"
-    config_folder_name = templ.format(
-        config_info['type'],
-        config_info['required'],
-        bool('read_only' in config_info.keys()),
-        bool('default' in config_info.keys()),
-        config_info['ui_options']['invisible'],
-        config_info['ui_options']['advanced'],
-    )
     temdir = tempfile.mkdtemp()
-    d_name = f"{temdir}/configs/fields/{config_info['type']}/{config_folder_name}"
+    d_name = (
+        f"{temdir}/configs/fields/{config_info['type']}/"
+        f"type_{config_info['type']}_"
+        f"required_{config_info['required']}_"
+        f"ro_{bool('read_only' in config_info.keys())}_"
+        f"content_{bool('default' in config_info.keys())}_"
+        f"invisible_{config_info['ui_options']['invisible']}_"
+        f"advanced_{config_info['ui_options']['advanced']}"
+    )
 
     os.makedirs(d_name)
     config[0][0]["name"] = random_string()
@@ -189,6 +210,9 @@ def generate_group_configs(
     read_only: bool = True,
     field_invisible: bool = True,
     field_advanced: bool = True,
+    config_group_customization: Optional[bool] = True,
+    group_customization: Optional[bool] = True,
+    field_customization: Optional[bool] = True,
 ) -> tuple:
     """Generate ADCM config dictionaries for groups"""
 
@@ -198,13 +222,18 @@ def generate_group_configs(
         "activatable": activatable,
         'active': active,
         "read_only": read_only,
+        "description": "test description",
         "ui_options": {"invisible": group_invisible, 'advanced': group_advanced},
         "field_ui_options": {
             "invisible": field_invisible,
             'advanced': field_advanced,
         },
     }
+    if group_customization is not None:
+        data["group_customization"] = group_customization
     config_dict = {"type": "cluster", "version": "1", "config": []}
+    if config_group_customization is not None:
+        config_dict["config_group_customization"] = config_group_customization
     cluster_config = {
         "name": "group",
         "type": "group",
@@ -213,7 +242,14 @@ def generate_group_configs(
             'advanced': data['ui_options']['advanced'],
         },
     }
-    sub_config = {'name': field_type, 'type': field_type, 'required': data['required']}
+    sub_config = {
+        'name': field_type,
+        'type': field_type,
+        'required': data['required'],
+        "description": "test description",
+    }
+    if field_customization is not None:
+        sub_config["group_customization"] = field_customization
     if data['default']:
         sub_config['default'] = DEFAULT_VALUE[field_type]
     if data['read_only']:
@@ -233,6 +269,8 @@ def generate_group_configs(
 
 
 def prepare_group_config(config):
+    """Create config file with group and return config, expected result and path to config file"""
+
     config_info = config[0][0]['config'][0]
     config_subs = config_info['subs'][0]
     if "activatable" in config_info.keys():
diff --git a/tests/ui_tests/app/page/admin/page.py b/tests/ui_tests/app/page/admin/page.py
index b5c58779c7..dfb5125a50 100644
--- a/tests/ui_tests/app/page/admin/page.py
+++ b/tests/ui_tests/app/page/admin/page.py
@@ -20,6 +20,7 @@
 
 import allure
 from adcm_pytest_plugin.utils import wait_until_step_succeeds
+from selenium.common.exceptions import StaleElementReferenceException
 from selenium.common.exceptions import TimeoutException
 from selenium.webdriver.remote.webelement import WebElement
 
@@ -404,7 +405,7 @@ def check_default_roles(self):
                 description='',
                 permissions='Create host, Upload bundle, Edit cluster configurations, Edit host configurations, '
                 'Add service, Remove service, Remove hosts, Map hosts, Unmap hosts, Edit host-components, '
-                'Upgrade cluster bundle, Remove bundle, Service Administrator',
+                'Upgrade cluster bundle, Remove bundle, Service Administrator, Manage Maintenance mode',
             ),
             AdminRoleInfo(
                 name='Provider Administrator',
@@ -567,10 +568,14 @@ def fill_select_in_policy_popup(self, items, available_items_locator):
         for item in items.split(", "):
             self.wait_element_visible(available_items_locator)
             for count, available_item in enumerate(self.find_elements(available_items_locator)):
-                if available_item.text == item:
-                    self.scroll_to(available_item)
-                    available_item.click()
-                    break
+                try:
+                    if available_item.text == item:
+                        self.scroll_to(available_item)
+                        available_item.click()
+                        break
+                except StaleElementReferenceException:
+                    if self.find_elements(available_items_locator)[count].text == item:
+                        self.find_elements(available_items_locator)[count].click()
             else:
                 raise AssertionError(f"There are no item {item} in select popup")
 
diff --git a/tests/ui_tests/app/page/cluster/locators.py b/tests/ui_tests/app/page/cluster/locators.py
index 5fc95309dd..49e0cf4d48 100644
--- a/tests/ui_tests/app/page/cluster/locators.py
+++ b/tests/ui_tests/app/page/cluster/locators.py
@@ -55,6 +55,7 @@ class ServiceTableRow:
         actions = Locator(By.CSS_SELECTOR, "app-actions-button button", "Service actions")
         service_import = Locator(By.CSS_SELECTOR, "mat-cell:nth-child(6) button", "Service import")
         config = Locator(By.CSS_SELECTOR, "mat-cell:nth-child(7) button", "Service config")
+        delete_btn = Locator(By.CSS_SELECTOR, "mat-cell:nth-child(8) button", "Row delete button")
 
 
 class ClusterHostLocators:
diff --git a/tests/ui_tests/app/page/cluster/page.py b/tests/ui_tests/app/page/cluster/page.py
index 68e59d5e94..a660bf71ec 100644
--- a/tests/ui_tests/app/page/cluster/page.py
+++ b/tests/ui_tests/app/page/cluster/page.py
@@ -14,7 +14,10 @@
 
 from contextlib import contextmanager
 from dataclasses import dataclass
-from typing import List
+from typing import (
+    List,
+    Optional,
+)
 
 import allure
 from adcm_pytest_plugin.utils import wait_until_step_succeeds
@@ -30,6 +33,7 @@
 from tests.ui_tests.app.page.common.configuration.locators import CommonConfigMenu
 from tests.ui_tests.app.page.common.configuration.page import CommonConfigMenuObj
 from tests.ui_tests.app.page.common.dialogs_locators import ActionDialog, DeleteDialog
+from tests.ui_tests.app.page.common.group_config.page import CommonGroupConfigMenu
 from tests.ui_tests.app.page.common.group_config_list.locators import GroupConfigListLocators
 from tests.ui_tests.app.page.common.group_config_list.page import GroupConfigList
 from tests.ui_tests.app.page.common.import_page.locators import ImportLocators
@@ -218,6 +222,13 @@ def click_action_btn_in_row(self, row: WebElement):
         """Click on Action button from the row"""
         self.find_child(row, ClusterServicesLocators.ServiceTableRow.actions).click()
 
+    def click_delete_btn_in_row(self, row: WebElement):
+        """Click on delete button from the row"""
+        self.find_child(row, ClusterServicesLocators.ServiceTableRow.delete_btn).click()
+        self.wait_element_visible(DeleteDialog.body)
+        self.find_and_click(DeleteDialog.yes)
+        self.wait_element_hide(DeleteDialog.body)
+
     def click_import_btn_in_row(self, row: WebElement):
         """Click on Import button from the row"""
         self.find_child(row, ClusterServicesLocators.ServiceTableRow.service_import).click()
@@ -437,6 +448,50 @@ def delete_host_by_row(self, row: WebElement):
     def check_cluster_hosts_toolbar(self, cluster_name: str, host_name: str):
         self.toolbar.check_toolbar_elements(["CLUSTERS", cluster_name, "HOSTS", host_name])
 
+    @allure.step('Click on maintenance mode button in row {row_num}')
+    def click_on_maintenance_mode_btn(self, row_num: int):
+        """Click maintenance mode in row"""
+
+        row = self.table.get_row(row_num)
+        self.find_child(row, ClusterHostLocators.HostTable.HostRow.maintenance_mode_btn).click()
+
+    @allure.step('Assert maintenance mode state in row {row_num}')
+    def assert_maintenance_mode_state(self, row_num: int, is_mm_state_on: Optional[bool] = True):
+        """
+        Assert maintenance mode state in row
+        :param row_num: number of the row with maintenance mode button
+        :param is_mm_state_on: state of maintenance mode button:
+            True for ON state
+            False for OFF state
+            None for not available state
+        """
+
+        def _check_mm_state(page: ClusterHostPage, row: WebElement):
+            button_state = page.find_child(
+                row, ClusterHostLocators.HostTable.HostRow.maintenance_mode_btn
+            ).get_attribute("class")
+            tooltips_info = [
+                t.get_property("innerHTML") for t in page.find_elements(ClusterHostLocators.HostTable.tooltip_text)
+            ]
+            if is_mm_state_on:
+                assert "mat-primary" in button_state, "Button should be gray"
+                assert (
+                    "Turn maintenance mode ON" in tooltips_info
+                ), "There should be tooltip that user could turn on maintenance mode"
+            elif is_mm_state_on is False:
+                assert "mat-on" in button_state, "Button should be red"
+                assert (
+                    "Turn maintenance mode OFF" in tooltips_info
+                ), "There should be tooltip that user could turn off maintenance mode"
+            else:
+                assert "mat-button-disabled" in button_state, "Button should be disabled"
+                assert (
+                    "Maintenance mode is not available" in tooltips_info
+                ), "There should be tooltip that maintenance mode is not available"
+
+        host_row = self.table.get_row(row_num)
+        wait_until_step_succeeds(_check_mm_state, timeout=4, period=0.5, page=self, row=host_row)
+
 
 class ClusterComponentsPage(ClusterPageMixin):
     """Cluster page components menu"""
@@ -559,6 +614,7 @@ class ClusterGroupConfigPageMixin(BasePageObject):
     header: PageHeader
     footer: PageFooter
     config: CommonConfigMenuObj
+    group_config: CommonGroupConfigMenu
     toolbar: CommonToolbar
     table: CommonTableObj
 
@@ -575,10 +631,47 @@ def __init__(self, driver, base_url, cluster_id: int, group_config_id: int):
         self.header = PageHeader(self.driver, self.base_url)
         self.footer = PageFooter(self.driver, self.base_url)
         self.config = CommonConfigMenuObj(self.driver, self.base_url)
+        self.group_config = CommonGroupConfigMenu(self.driver, self.base_url)
         self.cluster_id = cluster_id
+        self.group_config_id = group_config_id
         self.toolbar = CommonToolbar(self.driver, self.base_url)
         self.table = CommonTableObj(self.driver, self.base_url)
 
+    @allure.step("Assert that all main elements on the page are presented")
+    def check_all_elements(self):
+        """Assert all main elements presence"""
+        self.assert_displayed_elements(self.MAIN_ELEMENTS)
+
+    def open_hosts_tab(self):
+        """Open Hosts tab by menu click"""
+
+        self.find_and_click(ObjectPageMenuLocators.hosts_tab)
+        page = ClusterGroupConfigHosts(self.driver, self.base_url, self.cluster_id, self.group_config_id)
+        page.wait_page_is_opened()
+        return page
+
+    def open_config_tab(self):
+        """Open Hosts tab by menu click"""
+
+        self.find_and_click(ObjectPageMenuLocators.config_tab)
+        page = ClusterGroupConfigConfig(self.driver, self.base_url, self.cluster_id, self.group_config_id)
+        page.wait_page_is_opened()
+        return page
+
+    def check_cluster_group_conf_toolbar(self, cluster_name: str, group_name: str):
+        self.toolbar.check_toolbar_elements(["CLUSTERS", cluster_name, "GROUPCONFIGS", group_name])
+
+
+class ClusterGroupConfigHosts(ClusterGroupConfigPageMixin):
+    """Cluster page status menu"""
+
+    MENU_SUFFIX = 'host'
+    MAIN_ELEMENTS = [
+        ObjectPageLocators.title,
+        ObjectPageLocators.subtitle,
+        ObjectPageLocators.text,
+    ]
+
 
 class ClusterGroupConfigConfig(ClusterGroupConfigPageMixin):
     """Cluster page status menu"""
diff --git a/tests/ui_tests/app/page/common/configuration/locators.py b/tests/ui_tests/app/page/common/configuration/locators.py
index c9ac419f46..62c2dd4d00 100644
--- a/tests/ui_tests/app/page/common/configuration/locators.py
+++ b/tests/ui_tests/app/page/common/configuration/locators.py
@@ -74,7 +74,7 @@ class ConfigRow:
         history = Locator(By.CSS_SELECTOR, "mat-list-item span.accent", "Row history")
         reset_btn = Locator(By.CSS_SELECTOR, "button[mattooltip='Reset to default']", "Reset button")
 
-        checkbox = Locator(By.CSS_SELECTOR, "mat-checkbox", "Checkbox")
+        checkbox = Locator(By.CSS_SELECTOR, "app-fields-boolean mat-checkbox", "Checkbox")
 
         # complex parameters
         add_item_btn = Locator(
diff --git a/tests/ui_tests/app/page/common/configuration/page.py b/tests/ui_tests/app/page/common/configuration/page.py
index 9d7a8bdabe..5e601fd0aa 100644
--- a/tests/ui_tests/app/page/common/configuration/page.py
+++ b/tests/ui_tests/app/page/common/configuration/page.py
@@ -152,6 +152,22 @@ def get_input_value(self, row: WebElement, *, is_password: bool = False) -> str:
         locator = row_locators.value if not is_password else row_locators.password
         return self.find_child(row, locator).get_property("value")
 
+    def check_inputs_disabled(self, row: WebElement, is_password: bool = False):
+        """Check that inputs in row are disabled"""
+
+        row_locators = CommonConfigMenu.ConfigRow
+        locator = row_locators.value if not is_password else row_locators.password
+        for row_input in self.find_children(row, locator):
+            assert row_input.get_attribute("disabled") == 'true', "Input should be disabled"
+
+    def check_inputs_enabled(self, row: WebElement, is_password: bool = False):
+        """Check that inputs in row are enabled"""
+
+        row_locators = CommonConfigMenu.ConfigRow
+        locator = row_locators.value if not is_password else row_locators.password
+        for row_input in self.find_children(row, locator):
+            assert not row_input.get_attribute("disabled"), "Input should be enabled"
+
     @allure.step('Check bool field')
     def assert_checkbox_state(self, row: WebElement, expected_value: bool):
         current_bool_state = "checked" in self.find_child(row, CommonConfigMenu.ConfigRow.checkbox).get_attribute(
@@ -382,7 +398,7 @@ def is_save_btn_disabled(self):
     def check_save_btn_state_and_save_conf(self, expected_state: bool):
         assert (
             not (self.is_save_btn_disabled()) == expected_state
-        ), f'Save button should{" not " if expected_state is False else " "}be disabled'
+        ), f'Save button should{" not " if expected_state is True else " "}be disabled'
         if expected_state:
             self.save_config()
 
diff --git a/tests/ui_tests/app/page/common/group_config/locators.py b/tests/ui_tests/app/page/common/group_config/locators.py
new file mode 100644
index 0000000000..d5d726189f
--- /dev/null
+++ b/tests/ui_tests/app/page/common/group_config/locators.py
@@ -0,0 +1,24 @@
+# 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.
+
+"""Group Configuration list page locators"""
+
+from selenium.webdriver.common.by import By
+
+from tests.ui_tests.app.helpers.locator import Locator
+
+
+class GroupConfigLocators:
+    """Group Configuration locators"""
+
+    config_row = Locator(By.CSS_SELECTOR, "app-config-field-attribute-provider", "Configuration row")
+    customization_chbx = Locator(By.CSS_SELECTOR, ".group-checkbox mat-checkbox", "Checkbox customization")
diff --git a/tests/ui_tests/app/page/common/group_config/page.py b/tests/ui_tests/app/page/common/group_config/page.py
new file mode 100644
index 0000000000..dff155fa06
--- /dev/null
+++ b/tests/ui_tests/app/page/common/group_config/page.py
@@ -0,0 +1,71 @@
+# 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.
+
+"""Config page PageObjects classes"""
+
+from typing import (
+    List,
+)
+
+import allure
+from adcm_pytest_plugin.utils import wait_until_step_succeeds
+from selenium.common.exceptions import TimeoutException
+from selenium.webdriver.remote.webdriver import WebElement
+
+from tests.ui_tests.app.page.common.base_page import BasePageObject
+from tests.ui_tests.app.page.common.group_config.locators import GroupConfigLocators
+
+
+class CommonGroupConfigMenu(BasePageObject):
+    """Class for working with group configuration menu"""
+
+    def __init__(self, driver, base_url, config_class_locators=GroupConfigLocators):
+        super().__init__(driver, base_url)
+        self.locators = config_class_locators
+
+    def is_customization_chbx_disabled(self, row: WebElement) -> bool:
+        """Check if customization checkbox is disabled"""
+
+        return 'mat-checkbox-disabled' in str(
+            self.find_child(row, self.locators.customization_chbx).get_attribute("class")
+        )
+
+    @allure.step("Click on customization checkbox")
+    def click_on_customization_chbx(self, row: WebElement):
+        state_before = self.is_customization_chbx_checked(row)
+        self.find_child(row, self.locators.customization_chbx).click()
+
+        def wait_state_change():
+            return state_before != self.is_customization_chbx_checked(row)
+
+        wait_until_step_succeeds(wait_state_change, period=1, timeout=5)
+
+    def is_customization_chbx_checked(self, row: WebElement) -> bool:
+        """Check if customization checkbox is checked"""
+
+        return 'mat-checkbox-checked' in str(
+            self.find_child(row, self.locators.customization_chbx).get_attribute("class")
+        )
+
+    def get_all_group_config_rows(self, *, displayed_only: bool = True, timeout: int = 5) -> List[WebElement]:
+        """Return all config field rows"""
+
+        try:
+            if displayed_only:
+                return [r for r in self.find_elements(self.locators.config_row, timeout=timeout) if r.is_displayed()]
+            return self.find_elements(self.locators.config_row, timeout=timeout)
+        except TimeoutException:
+            return []
+
+    @allure.step('Check that there are no rows on group config page')
+    def check_no_rows(self):
+        assert len(self.get_all_group_config_rows(timeout=1)) == 0, "There should not be any rows"
diff --git a/tests/ui_tests/app/page/common/table/locator.py b/tests/ui_tests/app/page/common/table/locator.py
index e3ffda490f..4158744711 100644
--- a/tests/ui_tests/app/page/common/table/locator.py
+++ b/tests/ui_tests/app/page/common/table/locator.py
@@ -27,6 +27,7 @@ class CommonTable:
     header = Locator(By.CSS_SELECTOR, "mat-header-cell>div", "Table header")
     row = Locator(By.CSS_SELECTOR, "mat-row[adwphover]", "Table row")
     backdrop = Locator(By.CSS_SELECTOR, ".cdk-overlay-backdrop", "backdrop")
+    tooltip_text = Locator(By.CSS_SELECTOR, "#cdk-describedby-message-container div", "Tooltip text")
 
     class ActionPopup:
         """Common popup for action in tables."""
diff --git a/tests/ui_tests/app/page/common/table/page.py b/tests/ui_tests/app/page/common/table/page.py
index 2d2275311f..484ee9017e 100644
--- a/tests/ui_tests/app/page/common/table/page.py
+++ b/tests/ui_tests/app/page/common/table/page.py
@@ -42,10 +42,10 @@ def row_count(self) -> int:
         """Get amount of rows on page"""
         return len(self.get_all_rows())
 
-    def get_all_rows(self) -> list:
+    def get_all_rows(self, timeout=5) -> list:
         """Get all rows from the table"""
         try:
-            return self.find_elements(self.locators.visible_row, timeout=5)
+            return self.find_elements(self.locators.visible_row, timeout=timeout)
         except TimeoutException:
             return []
 
@@ -97,7 +97,7 @@ def click_page_by_number(self, number: int):
     def check_pagination(self, second_page_item_amount: int):
         """Check pagination"""
         params = {"fist_page_cluster_amount": 10}
-        self.wait_element_hide(CommonToolbarLocators.progress_bar, timeout=30)
+        self.wait_element_hide(CommonToolbarLocators.progress_bar, timeout=60)
         with self.wait_rows_change():
             self.click_page_by_number(2)
         assert self.row_count == second_page_item_amount, f"Second page should contains {second_page_item_amount} items"
diff --git a/tests/ui_tests/app/page/host_list/locators.py b/tests/ui_tests/app/page/host_list/locators.py
index 3ecabc9430..c87a0a4adc 100644
--- a/tests/ui_tests/app/page/host_list/locators.py
+++ b/tests/ui_tests/app/page/host_list/locators.py
@@ -47,6 +47,11 @@ class HostRow:
             status = Locator(By.CSS_SELECTOR, "app-status-column button", "Host status in row")
             actions = Locator(By.CSS_SELECTOR, "app-actions-button button", "Host actions in row")
             config = Locator(By.XPATH, ".//button[.//mat-icon[text()='settings']]", "Host config in row")
+            maintenance_mode_btn = Locator(
+                By.XPATH, ".//button[.//mat-icon[text()='medical_services']]", "Maintenance Mode button in row"
+            )
             delete_btn = Locator(By.XPATH, ".//button[.//mat-icon[text()='delete']]", "Host delete button in row")
             link_off_btn = Locator(By.XPATH, ".//button[.//mat-icon[text()='link_off']]", "Host link off button in row")
+            dropdown_menu = Locator(By.CSS_SELECTOR, "div[role='menu']", "Dropdown menu")
             action_option = TemplateLocator(By.XPATH, "//button/span[text()='{}']", "Action dropdown option")
+            action_option_all = Locator(By.CSS_SELECTOR, "button[adcm_test='action_btn']", "Action dropdown options")
diff --git a/tests/ui_tests/app/page/host_list/page.py b/tests/ui_tests/app/page/host_list/page.py
index 70236225d3..36c17e1f43 100644
--- a/tests/ui_tests/app/page/host_list/page.py
+++ b/tests/ui_tests/app/page/host_list/page.py
@@ -12,11 +12,14 @@
 
 """Host List page PageObjects classes"""
 
-from typing import Optional, ClassVar
 from dataclasses import dataclass
+from typing import Optional, ClassVar
 
 import allure
 from adcm_pytest_plugin.utils import wait_until_step_succeeds
+from selenium.common.exceptions import (
+    TimeoutException,
+)
 from selenium.webdriver.remote.webelement import WebElement
 from selenium.webdriver.support import expected_conditions as EC
 from selenium.webdriver.support.ui import WebDriverWait as WDW
@@ -131,6 +134,18 @@ def create_provider_and_host(
         # because we don't pass provider name
         return provider_name
 
+    def get_all_available_actions(self, host_row_num: int):
+        "Return list with actions"
+
+        host_row = HostListLocators.HostTable.HostRow
+        self.click_on_row_child(host_row_num, host_row.actions)
+        self.wait_element_visible(host_row.dropdown_menu)
+        try:
+            actions = [action.text for action in self.find_elements(host_row.action_option_all, timeout=2)]
+            return actions
+        except TimeoutException:
+            return []
+
     @allure.step('Run action "{action_display_name}" on host in row {host_row_num}')
     def run_action(self, host_row_num: int, action_display_name: str):
         """Run action from Host row"""
@@ -178,6 +193,50 @@ def _check_host_state(page: HostListPage, row: WebElement):
         host_row = self.table.get_row(row_num)
         wait_until_step_succeeds(_check_host_state, timeout=10, period=0.5, page=self, row=host_row)
 
+    @allure.step('Click on maintenance mode button in row {row_num}')
+    def click_on_maintenance_mode_btn(self, row_num: int):
+        """Click maintenance mode in row"""
+
+        row = self.table.get_row(row_num)
+        self.find_child(row, HostListLocators.HostTable.HostRow.maintenance_mode_btn).click()
+
+    @allure.step('Assert maintenance mode state in row {row_num}')
+    def assert_maintenance_mode_state(self, row_num: int, is_mm_state_on: Optional[bool] = True):
+        """
+        Assert maintenance mode state in row
+        :param row_num: number of the row with maintenance mode button
+        :param is_mm_state_on: state of maintenance mode button:
+            True for ON state
+            False for OFF state
+            None for not available state
+        """
+
+        def _check_mm_state(page: HostListPage, row: WebElement):
+            button_state = page.find_child(row, HostListLocators.HostTable.HostRow.maintenance_mode_btn).get_attribute(
+                "class"
+            )
+            tooltips_info = [
+                t.get_property("innerHTML") for t in page.find_elements(HostListLocators.HostTable.tooltip_text)
+            ]
+            if is_mm_state_on:
+                assert "mat-primary" in button_state, "Button should be gray"
+                assert (
+                    "Turn maintenance mode ON" in tooltips_info
+                ), "There should be tooltip that user could turn on maintenance mode"
+            elif is_mm_state_on is False:
+                assert "mat-on" in button_state, "Button should be red"
+                assert (
+                    "Turn maintenance mode OFF" in tooltips_info
+                ), "There should be tooltip that user could turn off maintenance mode"
+            else:
+                assert "mat-button-disabled" in button_state, "Button should be disabled"
+                assert (
+                    "Maintenance mode is not available" in tooltips_info
+                ), "There should be tooltip that maintenance mode is not available"
+
+        host_row = self.table.get_row(row_num)
+        wait_until_step_succeeds(_check_mm_state, timeout=4, period=0.5, page=self, row=host_row)
+
     @allure.step('Open host creation popup')
     def open_host_creation_popup(self):
         """Open host creation popup"""
diff --git a/tests/ui_tests/test_admin_page.py b/tests/ui_tests/test_admin_page.py
index 4fa98b2c85..494addede1 100644
--- a/tests/ui_tests/test_admin_page.py
+++ b/tests/ui_tests/test_admin_page.py
@@ -49,11 +49,13 @@
 from tests.ui_tests.app.page.cluster_list.page import ClusterListPage
 from tests.ui_tests.app.page.component.page import ComponentConfigPage
 from tests.ui_tests.app.page.host.page import HostConfigPage
+from tests.ui_tests.app.page.host_list.page import HostListPage
 from tests.ui_tests.app.page.job.page import JobPageStdout
 from tests.ui_tests.app.page.job_list.page import JobListPage
 from tests.ui_tests.app.page.login.page import LoginPage
 from tests.ui_tests.app.page.provider.page import ProviderConfigPage
 from tests.ui_tests.app.page.service.page import ServiceConfigPage
+from tests.ui_tests.conftest import login_over_api
 from tests.ui_tests.utils import expect_rows_amount_change
 
 BUNDLE = "cluster_with_services"
@@ -115,6 +117,17 @@ def create_cluster_with_component(
 # !===== Tests =====!
 
 
+CUSTOM_ROLE_NAME = 'Test_Role'
+CUSTOM_POLICY = AdminPolicyInfo(
+    name="Test policy name",
+    description="Test policy description",
+    role="ADCM User",
+    users="admin, status",
+    groups=None,
+    objects=None,
+)
+
+
 @pytest.mark.usefixtures("login_to_adcm_over_api")
 class TestAdminIntroPage:
     """Tests for the /admin/intro"""
@@ -453,21 +466,11 @@ def test_delete_group_from_groups_page(self, app_fs):
 class TestAdminPolicyPage:
     """Tests for the /admin/policies"""
 
-    custom_role_name = 'Test_Role'
-    custom_policy = AdminPolicyInfo(
-        name="Test policy name",
-        description="Test policy description",
-        role="ADCM User",
-        users="admin, status",
-        groups=None,
-        objects=None,
-    )
-
     @allure.step('Check custome policy')
     def check_custom_policy(self, policies_page, policy=None):
         """Check that there is only one created policy with expected params"""
 
-        policy = policy or self.custom_policy
+        policy = policy or CUSTOM_POLICY
         current_policies = policies_page.get_all_policies()
         assert len(current_policies) == 1, "There should be 1 policy on the page"
         assert current_policies == [policy], "Created policy should be on the page"
@@ -489,10 +492,10 @@ def test_create_policy_on_admin_groups_page(self, app_fs):
 
         policies_page = AdminPoliciesPage(app_fs.driver, app_fs.adcm.url).open()
         policies_page.create_policy(
-            policy_name=self.custom_policy.name,
-            description=self.custom_policy.description,
-            role=self.custom_policy.role,
-            users=self.custom_policy.users,
+            policy_name=CUSTOM_POLICY.name,
+            description=CUSTOM_POLICY.description,
+            role=CUSTOM_POLICY.role,
+            users=CUSTOM_POLICY.users,
         )
         self.check_custom_policy(policies_page)
 
@@ -505,10 +508,10 @@ def test_check_pagination_policy_list_page(self, app_fs):
         with allure.step("Create 11 policies"):
             for i in range(11):
                 policies_page.create_policy(
-                    policy_name=f"{self.custom_policy.name}_{i}",
-                    description=self.custom_policy.description,
-                    role=self.custom_policy.role,
-                    users=self.custom_policy.users,
+                    policy_name=f"{CUSTOM_POLICY.name}_{i}",
+                    description=CUSTOM_POLICY.description,
+                    role=CUSTOM_POLICY.role,
+                    users=CUSTOM_POLICY.users,
                 )
         policies_page.table.check_pagination(second_page_item_amount=1)
 
@@ -518,15 +521,14 @@ def test_delete_policy_from_policies_page(self, app_fs):
 
         policies_page = AdminPoliciesPage(app_fs.driver, app_fs.adcm.url).open()
         policies_page.create_policy(
-            policy_name=self.custom_policy.name,
-            description=self.custom_policy.description,
-            role=self.custom_policy.role,
-            users=self.custom_policy.users,
+            policy_name=CUSTOM_POLICY.name,
+            description=CUSTOM_POLICY.description,
+            role=CUSTOM_POLICY.role,
+            users=CUSTOM_POLICY.users,
         )
         policies_page.delete_all_policies()
 
     # pylint: disable=too-many-arguments
-    @pytest.mark.skip(reason="https://arenadata.atlassian.net/browse/ADCM-2761")
     @pytest.mark.usefixtures("login_to_adcm_over_api")
     @pytest.mark.parametrize(
         ("clusters", "services", "providers", "hosts", "parents", "role_name"),
@@ -563,13 +565,13 @@ def test_check_policy_popup_for_entities(
     ):
         """Test creating policy"""
 
-        custom_policy = deepcopy(self.custom_policy)
-        custom_policy.role = self.custom_role_name
+        custom_policy = deepcopy(CUSTOM_POLICY)
+        custom_policy.role = CUSTOM_ROLE_NAME
         custom_policy.objects = clusters or services or providers or hosts
         with allure.step("Create test role"):
             sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name=r).id} for r in role_name.split(", ")],
             )
         policies_page = AdminPoliciesPage(app_fs.driver, app_fs.adcm.url).open()
@@ -596,8 +598,8 @@ def test_policy_permission_to_view_access_cluster(
         cluster, *_ = create_cluster_with_component
         with allure.step("Create test role"):
             test_role = sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name="View cluster configurations").id}],
             )
         with allure.step("Create test policy"):
@@ -627,8 +629,8 @@ def test_policy_permission_to_view_access_service(
         cluster, service, *_ = create_cluster_with_component
         with allure.step("Create test role"):
             test_role = sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name="View service configurations").id}],
             )
         with allure.step("Create test policy"):
@@ -662,8 +664,8 @@ def test_policy_permission_to_view_access_component(
         cluster, service, host, _ = create_cluster_with_component
         with allure.step("Create test role"):
             test_role = sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name="View component configurations").id}],
             )
         with allure.step("Create test policy"):
@@ -699,8 +701,8 @@ def test_policy_permission_to_view_access_provider(self, sdk_client_fs, app_fs,
         provider = provider_bundle.provider_create('test_provider')
         with allure.step("Create test role"):
             test_role = sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name="View provider configurations").id}],
             )
         with allure.step("Create test policy"):
@@ -733,8 +735,8 @@ def test_policy_permission_to_view_access_host(self, sdk_client_fs, app_fs, anot
         host = provider.host_create('test-host')
         with allure.step("Create test role"):
             test_role = sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name="View host configurations").id}],
             )
         with allure.step("Create test policy"):
@@ -765,8 +767,8 @@ def test_policy_permission_to_run_cluster_action_and_view_task(
         cluster, *_ = create_cluster_with_component
         with allure.step("Create test role"):
             test_role = sdk_client_fs.role_create(
-                name=self.custom_role_name,
-                display_name=self.custom_role_name,
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
                 child=[{"id": sdk_client_fs.role(name="Cluster Action: some_action").id}],
             )
         with allure.step("Create test policy"):
@@ -787,8 +789,8 @@ def test_policy_permission_to_run_cluster_action_and_view_task(
             assert len(cluster_list_page.table.get_all_rows()) == 1, "There should be 1 row with cluster"
         with allure.step("Create second policy"):
             test_role_2 = sdk_client_fs.role_create(
-                name=f"{self.custom_role_name}_2",
-                display_name=f"{self.custom_role_name}_2",
+                name=f"{CUSTOM_ROLE_NAME}_2",
+                display_name=f"{CUSTOM_ROLE_NAME}_2",
                 child=[{"id": sdk_client_fs.role(name="View cluster configurations").id}],
             )
             sdk_client_fs.policy_create(
@@ -823,3 +825,28 @@ def test_policy_permission_to_run_cluster_action_and_view_task(
             JobPageStdout(app_fs.driver, app_fs.adcm.url, 1).wait_page_is_opened()
 
     # pylint: enable=too-many-locals
+
+    @pytest.mark.usefixtures("login_to_adcm_over_api")
+    def test_policy_with_maintenance_mode(self, sdk_client_fs, app_fs, another_user, create_cluster_with_component):
+        """Test create a group on /admin/policies"""
+
+        _, _, host, _ = create_cluster_with_component
+        with allure.step("Create test role"):
+            test_role = sdk_client_fs.role_create(
+                name=CUSTOM_ROLE_NAME,
+                display_name=CUSTOM_ROLE_NAME,
+                child=[{"id": sdk_client_fs.role(name="Manage Maintenance mode").id}],
+            )
+        with allure.step("Create test policy"):
+            sdk_client_fs.policy_create(
+                name="Test policy",
+                role=test_role,
+                user=[sdk_client_fs.user(username=another_user['username'])],
+                objects=[host],
+            )
+        login_over_api(app_fs, another_user)
+        with allure.step("Check that user can change maintenance mode state"):
+            host_list_page = HostListPage(app_fs.driver, app_fs.adcm.url).open()
+            host_list_page.assert_maintenance_mode_state(0)
+            host_list_page.click_on_maintenance_mode_btn(0)
+            host_list_page.assert_maintenance_mode_state(0, False)
diff --git a/tests/ui_tests/test_admin_page_data/cluster_with_services/config.yaml b/tests/ui_tests/test_admin_page_data/cluster_with_services/config.yaml
index fed55ae66f..2dd48e54e4 100644
--- a/tests/ui_tests/test_admin_page_data/cluster_with_services/config.yaml
+++ b/tests/ui_tests/test_admin_page_data/cluster_with_services/config.yaml
@@ -14,6 +14,7 @@
   name: test_cluster
   version: 1.5
   edition: community
+  allow_maintenance_mode: true
   config: &config
     str_param:
       type: string
diff --git a/tests/ui_tests/test_cluster_list_page.py b/tests/ui_tests/test_cluster_list_page.py
index b79b3bcc1f..104e5100b3 100644
--- a/tests/ui_tests/test_cluster_list_page.py
+++ b/tests/ui_tests/test_cluster_list_page.py
@@ -51,6 +51,7 @@
     ComponentsHostRowInfo,
     ClusterStatusPage,
     ClusterGroupConfigConfig,
+    ClusterGroupConfigHosts,
 )
 from tests.ui_tests.app.page.cluster_list.page import ClusterListPage
 from tests.ui_tests.app.page.common.configuration.page import CONFIG_ITEMS
@@ -521,14 +522,26 @@ def test_check_service_list_page_open_service_config(self, app_fs, create_commun
         service_conf_page.wait_page_is_opened()
         service_conf_page.check_service_toolbar(CLUSTER_NAME, SERVICE_NAME)
 
+    @pytest.mark.skip("https://arenadata.atlassian.net/browse/ADCM-2790")
     def test_check_pagination_on_service_list_page(self, sdk_client_fs: ADCMClient, app_fs):
         """Test pagination on cluster/{}/service page"""
         bundle = cluster_bundle(sdk_client_fs, BUNDLE_WITH_SERVICES)
         cluster = bundle.cluster_create(name=CLUSTER_NAME)
         cluster_service_page = ClusterServicesPage(app_fs.driver, app_fs.adcm.url, cluster.id).open()
         cluster_service_page.add_service_by_name(service_name="All")
+        cluster_service_page.wait_page_is_opened()
         cluster_service_page.table.check_pagination(second_page_item_amount=2)
 
+    def test_delete_service_on_service_list_page(self, app_fs, create_community_cluster_with_service):
+        """Test delete service from cluster/{}/service page"""
+
+        cluster, service = create_community_cluster_with_service
+        cluster_service_page = ClusterServicesPage(app_fs.driver, app_fs.adcm.url, cluster.id).open()
+        row = cluster_service_page.table.get_all_rows()[0]
+        cluster_service_page.click_delete_btn_in_row(row)
+        with allure.step("Check that after deleting service row there are no rows"):
+            assert len(cluster_service_page.table.get_all_rows(timeout=2)) == 0, "There should not be any rows"
+
 
 class TestClusterHostPage:
     """Tests for the /cluster/{}/host page"""
@@ -684,6 +697,18 @@ def test_check_pagination_on_cluster_host_page(self, app_fs, upload_and_create_p
         cluster_host_page.table.check_pagination(1)
         cluster_host_page.check_cluster_toolbar(CLUSTER_NAME)
 
+    @pytest.mark.smoke()
+    def test_maintenance_mode_from_cluster_host_page(self, app_fs, create_community_cluster_with_host):
+        """Test turn on and off maintenance mode on cluster/{}/host page"""
+
+        cluster, _ = create_community_cluster_with_host
+        cluster_host_page = ClusterHostPage(app_fs.driver, app_fs.adcm.url, cluster.id).open()
+
+        with allure.step("Check that user can change maintenance mode state"):
+            cluster_host_page.assert_maintenance_mode_state(0)
+            cluster_host_page.click_on_maintenance_mode_btn(0)
+            cluster_host_page.assert_maintenance_mode_state(0, False)
+
 
 class TestClusterComponentsPage:
     """Tests for the /cluster/{}/component page"""
@@ -730,13 +755,34 @@ def test_check_cluster_components_page_create_host(self, app_fs, bundle_archive,
 
     @pytest.mark.smoke()
     @pytest.mark.include_firefox()
-    def test_check_cluster_components_page_create_components(
-        self, app_fs, create_community_cluster_with_host_and_service
-    ):
-        """Test distribution of components on hosts"""
+    @pytest.mark.parametrize(
+        "bundle_name",
+        [
+            "cluster_with_not_required_component",
+            "cluster_with_required_component",
+            "cluster_with_required_component_short",
+        ],
+    )
+    def test_check_cluster_components_page_create_components(self, app_fs, sdk_client_fs, create_host, bundle_name):
+        """Test adding components on hosts"""
+
         params = {"message": "Successfully saved."}
-        cluster, *_ = create_community_cluster_with_host_and_service
+        is_not_required = "_not_required" in bundle_name
+
+        with allure.step("Add cluster with service and host"):
+            bundle = cluster_bundle(sdk_client_fs, bundle_name)
+            cluster = bundle.cluster_create(name=CLUSTER_NAME)
+            cluster.service_add(name=SERVICE_NAME)
+            cluster.host_add(create_host)
+
         cluster_components_page = ClusterComponentsPage(app_fs.driver, app_fs.adcm.url, cluster.id).open()
+
+        with allure.step("Check that required component has * in name"):
+            component_name = cluster_components_page.get_components_rows()[0].text
+            assert (
+                "*" not in component_name if is_not_required else f"* {COMPONENT_NAME}" in component_name
+            ), f'There are {"" if is_not_required else "no"} * in component name "{component_name}"'
+
         host_row = cluster_components_page.find_host_row_by_name(HOST_NAME)
         component_row = cluster_components_page.find_component_row_by_name(COMPONENT_NAME)
         cluster_components_page.click_host(host_row)
@@ -749,7 +795,9 @@ def test_check_cluster_components_page_create_components(
             host_row = cluster_components_page.get_host_rows()[0]
             check_components_host_info(cluster_components_page.get_row_info(host_row), HOST_NAME, "1")
             component_row = cluster_components_page.get_components_rows()[0]
-            check_components_host_info(cluster_components_page.get_row_info(component_row), COMPONENT_NAME, "1")
+            check_components_host_info(
+                cluster_components_page.get_row_info(component_row), COMPONENT_NAME, "1" if is_not_required else "1 / 1"
+            )
 
     def test_check_cluster_components_page_restore_components(
         self, app_fs, create_community_cluster_with_host_and_service
@@ -1355,7 +1403,7 @@ def check_expectations():
 class TestClusterGroupConfigPage:
     """Tests for the cluster/{}/group_config page"""
 
-    def test_open_by_tab_group_config_cluster_page(self, app_fs, create_community_cluster):
+    def test_open_by_tab_group_config_list_cluster_page(self, app_fs, create_community_cluster):
         """Test open cluster/{}/group_config from left menu"""
 
         cluster_main_page = ClusterMainPage(app_fs.driver, app_fs.adcm.url, create_community_cluster.id).open()
@@ -1363,6 +1411,30 @@ def test_open_by_tab_group_config_cluster_page(self, app_fs, create_community_cl
         cluster_groupconf_page.check_all_elements()
         cluster_groupconf_page.check_cluster_toolbar(CLUSTER_NAME)
 
+    def test_open_by_tab_group_config_host_cluster_page(self, app_fs, create_community_cluster):
+        """Test open cluster/{}/group_config from left menu"""
+
+        params = {'group_name': 'Test group'}
+        cluster_group_config = create_community_cluster.group_config_create(name=params['group_name'])
+        cluster_config_page = ClusterGroupConfigConfig(
+            app_fs.driver, app_fs.adcm.url, create_community_cluster.id, cluster_group_config.id
+        ).open()
+        cluster_groupconf_page = cluster_config_page.open_hosts_tab()
+        cluster_groupconf_page.check_all_elements()
+        cluster_groupconf_page.check_cluster_group_conf_toolbar(CLUSTER_NAME, params['group_name'])
+
+    def test_open_by_tab_group_config_cluster_page(self, app_fs, create_community_cluster):
+        """Test open cluster/{}/group_config from left menu"""
+
+        params = {'group_name': 'Test group'}
+        cluster_group_config = create_community_cluster.group_config_create(name=params['group_name'])
+        cluster_host_config_page = ClusterGroupConfigHosts(
+            app_fs.driver, app_fs.adcm.url, create_community_cluster.id, cluster_group_config.id
+        ).open()
+        cluster_groupconf_page = cluster_host_config_page.open_config_tab()
+        cluster_groupconf_page.check_all_elements()
+        cluster_groupconf_page.check_cluster_group_conf_toolbar(CLUSTER_NAME, params['group_name'])
+
     def test_create_group_config_cluster(self, app_fs, create_community_cluster):
         """Test create group config on cluster/{}/group_config"""
 
@@ -1393,7 +1465,7 @@ def test_check_pagination_on_group_config_component_page(self, app_fs, create_co
         group_conf_page.table.check_pagination(second_page_item_amount=1)
 
     # pylint: disable=too-many-locals
-    @pytest.mark.skip("https://arenadata.atlassian.net/browse/ADCM-2741")
+
     @pytest.mark.full()
     @pytest.mark.parametrize("field_type", TYPES)
     @pytest.mark.parametrize("is_advanced", [True, False], ids=("field_advanced", "field_non-advanced"))
@@ -1402,11 +1474,13 @@ def test_check_pagination_on_group_config_component_page(self, app_fs, create_co
     @pytest.mark.parametrize("is_read_only", [True, False], ids=("read_only", "not_read_only"))
     @pytest.mark.parametrize(
         "config_group_customization",
-        [True, False],
-        ids=("config_group_customization_true", "config_group_customization_false"),
+        [True, False, None],
+        ids=("config_group_customization_true", "config_group_customization_false", "no_config_group_customization"),
     )
     @pytest.mark.parametrize(
-        "group_customization", [True, False], ids=("group_customization_true", "group_customization_false")
+        "group_customization",
+        [True, False, None],
+        ids=("group_customization_true", "group_customization_false", "no_group_customization"),
     )
     @pytest.mark.usefixtures("login_to_adcm_over_api")
     def test_group_config_fields_invisible_false(
@@ -1442,15 +1516,30 @@ def test_group_config_fields_invisible_false(
 
         def check_expectations():
             with allure.step('Check that field visible'):
-                for config_item in cluster_config_page.config.get_all_config_rows():
+
+                for config_item in cluster_config_page.group_config.get_all_group_config_rows():
                     assert config_item.is_displayed(), f"Config field {field_type} should be visible"
                     if is_default:
                         check_default_field_values_in_configs(cluster_config_page, config_item, field_type, config)
+                        if not config_group_customization:
+                            cluster_config_page.config.check_inputs_disabled(
+                                config_item, is_password=bool(field_type == "password")
+                            )
                     if is_read_only:
                         assert cluster_config_page.config.is_element_read_only(
                             config_item
                         ), f"Config field {field_type} should be read only"
-            if expected['alerts'] and not is_read_only:
+                    if not config_group_customization:
+                        assert cluster_config_page.group_config.is_customization_chbx_disabled(
+                            config_item
+                        ), f"Checkbox for field {field_type} should be disabled"
+                    if config_group_customization and not is_read_only:
+                        if not cluster_config_page.group_config.is_customization_chbx_checked(config_item):
+                            cluster_config_page.group_config.click_on_customization_chbx(config_item)
+                        assert cluster_config_page.group_config.is_customization_chbx_checked(
+                            config_item
+                        ), f"Config field {field_type} should be checked"
+            if expected['alerts'] and (not is_read_only) and config_group_customization:
                 cluster_config_page.config.check_invalid_value_message(field_type)
 
         # skip next check until https://arenadata.atlassian.net/browse/ADCM-2769
@@ -1464,7 +1553,137 @@ def check_expectations():
         # cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
         check_expectations()
 
-    # pylint: enable=too-many-locals
+    @pytest.mark.skip("https://arenadata.atlassian.net/browse/ADCM-2802")
+    @pytest.mark.full()
+    @pytest.mark.parametrize("field_type", TYPES)
+    @pytest.mark.parametrize("activatable", [True, False], ids=("activatable", "non-activatable"))
+    @pytest.mark.parametrize("group_advanced", [True, False], ids=("group_advanced", "group_non-advanced"))
+    @pytest.mark.parametrize("is_read_only", [True, False], ids=("read_only", "not_read_only"))
+    @pytest.mark.parametrize(
+        "field_advanced",
+        [
+            pytest.param(True, id="field_advanced", marks=pytest.mark.regression),
+            pytest.param(False, id="field_non_advanced"),
+        ],
+    )
+    @pytest.mark.parametrize(
+        "config_group_customization",
+        [True, False, None],
+        ids=("config_group_customization_true", "config_group_customization_false", "no_config_group_customization"),
+    )
+    @pytest.mark.parametrize(
+        "group_customization",
+        [True, False, None],
+        ids=("group_customization_true", "group_customization_false", "no_group_customization"),
+    )
+    @pytest.mark.parametrize(
+        "field_customization",
+        [True, False, None],
+        ids=("field_customization_true", "field_customization_false", "no_field_customization"),
+    )
+    @pytest.mark.usefixtures("login_to_adcm_over_api")  # pylint: disable-next=too-many-locals
+    def test_group_configs_fields_in_group_invisible_false(
+        self,
+        sdk_client_fs: ADCMClient,
+        app_fs,
+        field_type,
+        activatable,
+        group_advanced,
+        is_read_only,
+        field_advanced,
+        config_group_customization,
+        group_customization,
+        field_customization,
+    ):
+        """Test group configs with not-invisible fields"""
+        config, expected, path = prepare_group_config(
+            generate_group_configs(
+                field_type=field_type,
+                activatable=activatable,
+                active=True,
+                group_invisible=False,
+                group_advanced=group_advanced,
+                default=True,
+                required=False,
+                read_only=is_read_only,
+                field_invisible=False,
+                field_advanced=field_advanced,
+                config_group_customization=config_group_customization,
+                group_customization=group_customization,
+                field_customization=field_customization,
+            )
+        )
+        cluster, *_ = prepare_cluster_and_open_config_page(sdk_client_fs, path, app_fs)
+        cluster_group_config = cluster.group_config_create(name="Test group")
+        cluster_config_page = ClusterGroupConfigConfig(
+            app_fs.driver, app_fs.adcm.url, cluster.id, cluster_group_config.id
+        ).open()
+
+        def check_expectations():
+            with allure.step('Check that field visible'):
+                for config_item in cluster_config_page.group_config.get_all_group_config_rows():
+                    group_name = cluster_config_page.config.get_group_names()[0].text
+                    assert group_name == 'group', "Should be group 'group' visible"
+                    if not activatable:
+                        continue
+                    if not cluster_config_page.config.advanced:
+                        cluster_config_page.config.check_group_is_active(group_name, config['config'][0]['active'])
+                    if (cluster_config_page.config.advanced and field_advanced) or not field_advanced:
+                        cluster_config_page.config.expand_or_close_group(group_name, expand=True)
+                        assert len(cluster_config_page.config.get_all_config_rows()) >= 2, "Field should be visible"
+                        check_default_field_values_in_configs(cluster_config_page, config_item, field_type, config)
+                        if is_read_only:
+                            if config_item.tag_name == 'app-field':
+                                assert cluster_config_page.config.is_element_read_only(
+                                    config_item
+                                ), f"Config field {field_type} should be read only"
+                        else:
+                            if (
+                                ((config_group_customization is False) and (field_customization is False))
+                                or ((config_group_customization is False) and (field_customization is None))
+                                or ((config_group_customization is not False) and (field_customization is False))
+                            ):
+                                cluster_config_page.config.check_inputs_disabled(
+                                    config_item, is_password=bool(field_type == "password")
+                                )
+                                assert cluster_config_page.group_config.is_customization_chbx_disabled(
+                                    config_item
+                                ), f"Checkbox for field {field_type} should be disabled"
+                            else:
+                                cluster_config_page.config.check_inputs_enabled(
+                                    config_item, is_password=bool(field_type == "password")
+                                )
+                                assert not cluster_config_page.group_config.is_customization_chbx_disabled(
+                                    config_item
+                                ), f"Checkbox for field {field_type} should not be disabled"
+                                if not cluster_config_page.group_config.is_customization_chbx_checked(config_item):
+                                    cluster_config_page.group_config.click_on_customization_chbx(config_item)
+                                assert cluster_config_page.group_config.is_customization_chbx_checked(
+                                    config_item
+                                ), f"Config field {field_type} should be checked"
+                                if expected['alerts']:
+                                    if field_type == "map":
+                                        is_advanced = cluster_config_page.config.advanced
+                                        cluster_config_page.driver.refresh()
+                                        if is_advanced:
+                                            cluster_config_page.config.click_on_advanced()
+                                        cluster_config_page.config.expand_or_close_group(group_name, expand=True)
+                                    else:
+                                        cluster_config_page.config.click_on_advanced()
+                                        cluster_config_page.config.click_on_advanced()
+                                    cluster_config_page.config.check_invalid_value_message(field_type)
+                    else:
+                        assert len(cluster_config_page.config.get_all_config_rows()) == 1, "Field should not be visible"
+
+        # skip next check until https://arenadata.atlassian.net/browse/ADCM-2769
+        # cluster_config_page.config.check_save_btn_state_and_save_conf(expected['save'])
+        if group_advanced:
+            cluster_config_page.config.check_no_rows_or_groups_on_page()
+            cluster_config_page.group_config.check_no_rows()
+        else:
+            check_expectations()
+        cluster_config_page.config.click_on_advanced()
+        check_expectations()
 
 
 class TestClusterStatusPage:
diff --git a/tests/ui_tests/test_cluster_list_page_data/cluster_community/config.yaml b/tests/ui_tests/test_cluster_list_page_data/cluster_community/config.yaml
index f43dad5a77..762bdf5b40 100644
--- a/tests/ui_tests/test_cluster_list_page_data/cluster_community/config.yaml
+++ b/tests/ui_tests/test_cluster_list_page_data/cluster_community/config.yaml
@@ -14,11 +14,13 @@
   name: test_cluster
   version: 1.5
   edition: community
+  allow_maintenance_mode: true
   actions:
     long_action:
       type: job
       script: dummy_action.yaml
       script_type: ansible
+      allow_in_maintenance_mode: true
       states:
         available: any
       params:
@@ -27,6 +29,7 @@
       type: job
       script: dummy_action.yaml
       script_type: ansible
+      allow_in_maintenance_mode: true
       states:
         available:
           - created
diff --git a/tests/ui_tests/test_cluster_list_page_data/cluster_with_not_required_component/config.yaml b/tests/ui_tests/test_cluster_list_page_data/cluster_with_not_required_component/config.yaml
new file mode 100644
index 0000000000..c36c71daf3
--- /dev/null
+++ b/tests/ui_tests/test_cluster_list_page_data/cluster_with_not_required_component/config.yaml
@@ -0,0 +1,24 @@
+# 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.
+
+- type: cluster
+  name: test_cluster
+  version: 1.5
+  edition: community
+
+- type: service
+  name: test_service
+  description: Test service
+  version: '1.2'
+  components:
+    first:
+        constraint: [0, odd]
diff --git a/tests/ui_tests/test_cluster_list_page_data/cluster_with_required_component/config.yaml b/tests/ui_tests/test_cluster_list_page_data/cluster_with_required_component/config.yaml
new file mode 100644
index 0000000000..b1887a3798
--- /dev/null
+++ b/tests/ui_tests/test_cluster_list_page_data/cluster_with_required_component/config.yaml
@@ -0,0 +1,24 @@
+# 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.
+
+- type: cluster
+  name: test_cluster
+  version: 1.5
+  edition: community
+
+- type: service
+  name: test_service
+  description: Test service
+  version: '1.2'
+  components:
+    first:
+        constraint: [1, odd]
diff --git a/tests/ui_tests/test_cluster_list_page_data/cluster_with_required_component_short/config.yaml b/tests/ui_tests/test_cluster_list_page_data/cluster_with_required_component_short/config.yaml
new file mode 100644
index 0000000000..878248b459
--- /dev/null
+++ b/tests/ui_tests/test_cluster_list_page_data/cluster_with_required_component_short/config.yaml
@@ -0,0 +1,24 @@
+# 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.
+
+- type: cluster
+  name: test_cluster
+  version: 1.5
+  edition: community
+
+- type: service
+  name: test_service
+  description: Test service
+  version: '1.2'
+  components:
+    first:
+        constraint: [odd]
diff --git a/tests/ui_tests/test_host_page.py b/tests/ui_tests/test_host_page.py
index f2ee87fec9..26426f29d8 100644
--- a/tests/ui_tests/test_host_page.py
+++ b/tests/ui_tests/test_host_page.py
@@ -99,7 +99,7 @@ def _create_many_hosts(request, upload_and_create_provider):
 
 
 @pytest.fixture()
-def _create_bonded_host(
+def create_bonded_host(
     upload_and_create_cluster: Tuple[Bundle, Cluster],
     upload_and_create_provider: Tuple[Bundle, Provider],
 ):
@@ -108,6 +108,7 @@ def _create_bonded_host(
     host = provider.host_create(HOST_FQDN)
     cluster = upload_and_create_cluster[1]
     cluster.host_add(host)
+    return cluster, host
 
 
 @pytest.fixture()
@@ -286,8 +287,7 @@ def test_delete_host(self, page: HostListPage):
 
     @pytest.mark.smoke()
     @pytest.mark.include_firefox()
-    @pytest.mark.usefixtures("_create_bonded_host")
-    def test_delete_bonded_host(self, page: HostListPage):
+    def test_delete_bonded_host(self, page: HostListPage, create_bonded_host):
         """Host shouldn't be deleted"""
 
         page.check_element_should_be_visible(HostListLocators.HostTable.row)
@@ -315,6 +315,42 @@ def test_run_action_on_new_host(self, page: HostListPage):
         page.run_action(0, INIT_ACTION)
         page.assert_host_state(0, 'running')
 
+    @pytest.mark.smoke()
+    def test_maintenance_mode_on_host_page(self, page: HostListPage, create_bonded_host):
+        """Test maintenance mode on host page"""
+
+        cluster, host = create_bonded_host
+        page.driver.refresh()
+        with allure.step("Check that mm is not available after removing from cluster with ON state"):
+            page.assert_maintenance_mode_state(0)
+            cluster.host_delete(host)
+            page.driver.refresh()
+            page.assert_maintenance_mode_state(0, None)
+        with allure.step("Check that mm is not available after removing from cluster with OFF state"):
+            cluster.host_add(host)
+            page.driver.refresh()
+            page.click_on_maintenance_mode_btn(0)
+            cluster.host_delete(host)
+            page.driver.refresh()
+            page.assert_maintenance_mode_state(0, None)
+
+    @pytest.mark.smoke()
+    def test_action_with_maintenance_mode_on_host_page(self, page: HostListPage, create_bonded_host):
+        """Test maintenance mode on host page"""
+
+        with allure.step("Turn OFF maintenance mode"):
+            page.driver.refresh()
+            page.click_on_maintenance_mode_btn(0)
+        with allure.step("Check there are no actions"):
+            assert not page.get_all_available_actions(0), "Action list with MM OFF should be empty"
+            page.driver.refresh()
+        with allure.step("Turn ON maintenance mode"):
+            page.click_on_maintenance_mode_btn(0)
+        with allure.step("Check there are action"):
+            assert page.get_all_available_actions(0) == [
+                INIT_ACTION
+            ], f"Action list with MM ON should be with action {INIT_ACTION}"
+
 
 @pytest.mark.usefixtures('login_to_adcm_over_api')
 class TestHostMainPage:
diff --git a/tests/ui_tests/test_host_page_data/cluster/config.yaml b/tests/ui_tests/test_host_page_data/cluster/config.yaml
index 617a1baafc..51bcfaff8f 100644
--- a/tests/ui_tests/test_host_page_data/cluster/config.yaml
+++ b/tests/ui_tests/test_host_page_data/cluster/config.yaml
@@ -14,6 +14,7 @@
   name: test_cluster
   version: 1.5
   edition: community
+  allow_maintenance_mode: true
 
 - type: service
   name: test_service
diff --git a/tests/ui_tests/test_host_page_data/provider/config.yaml b/tests/ui_tests/test_host_page_data/provider/config.yaml
index 4802f360d0..5cf3959165 100644
--- a/tests/ui_tests/test_host_page_data/provider/config.yaml
+++ b/tests/ui_tests/test_host_page_data/provider/config.yaml
@@ -26,6 +26,7 @@
       script_type: ansible
       script: ./fake_init.yaml
       display_name: "Init"
+      allow_in_maintenance_mode: true
       states:
         available:
           - created
@@ -34,6 +35,7 @@
     reinit:
       <<: *job
       display_name: "Reinit"
+      allow_in_maintenance_mode: false
       states:
         available:
           - running
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,