86494e8ad190fc95a1ce1d96aa3b6f36b887d88f
[ta/cm-plugins.git] / validators / src / HostsValidation.py
1 #!/usr/bin/python
2 # Copyright 2019 Nokia
3 #
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at
7 #
8 #    http://www.apache.org/licenses/LICENSE-2.0
9 #
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15
16 import logging
17 import json
18 import re
19 from netaddr import IPRange
20 from netaddr import IPNetwork
21
22 from cmframework.apis import cmvalidator
23 from cmdatahandlers.api import validation
24 from cmdatahandlers.api import utils
25 from serviceprofiles import profiles as service_profiles
26
27
28 class ConfigurationDoesNotExist(Exception):
29     pass
30
31
32 class HostsValidation(cmvalidator.CMValidator):
33     domain = 'cloud.hosts'
34     management_profile = 'management'
35     controller_profile = 'controller'
36     caas_master_profile = 'caas_master'
37     caas_worker_profile = 'caas_worker'
38     base_profile = 'base'
39     storage_profile = 'storage'
40
41     storage_profile_attr = 'cloud.storage_profiles'
42     network_profile_attr = 'cloud.network_profiles'
43     performance_profile_attr = 'cloud.performance_profiles'
44     networking_attr = 'cloud.networking'
45     MIN_PASSWORD_LENGTH = 8
46
47     def get_subscription_info(self):
48         logging.debug('get_subscription info called')
49         hosts = r'cloud\.hosts'
50         net_profiles = r'cloud\.network_profiles'
51         storage_profiles = r'cloud\.storage_profiles'
52         perf_profiles = r'cloud\.performance_profiles'
53         net = r'cloud\.networking'
54         return '^%s|%s|%s|%s|%s$' % (hosts, net_profiles, storage_profiles, perf_profiles, net)
55
56     def validate_set(self, dict_key_value):
57         logging.debug('HostsValidation: validate_set called with %s', dict_key_value)
58
59         for key, value in dict_key_value.iteritems():
60             value_dict = {} if not value else json.loads(value)
61             if not value_dict:
62                 if key != self.storage_profile_attr:
63                     raise validation.ValidationError('No value for %s' % key)
64
65             if key == self.domain:
66                 if not isinstance(value_dict, dict):
67                     raise validation.ValidationError('%s value is not a dict' % self.domain)
68
69                 net_profile_dict = self.get_domain_dict(dict_key_value,
70                                                         self.network_profile_attr)
71                 storage_profile_dict = self.get_domain_dict(dict_key_value,
72                                                             self.storage_profile_attr)
73                 perf_profile_dict = self.get_domain_dict(dict_key_value,
74                                                          self.performance_profile_attr)
75                 networking_dict = self.get_domain_dict(dict_key_value,
76                                                        self.networking_attr)
77                 self.validate_hosts(value_dict,
78                                     net_profile_dict,
79                                     storage_profile_dict,
80                                     perf_profile_dict,
81                                     networking_dict)
82
83                 self.validate_scale_in(dict_key_value)
84
85             elif key == self.network_profile_attr:
86                 profile_list = [] if not value_dict else value_dict.keys()
87
88                 host_dict = self.get_domain_dict(dict_key_value, self.domain)
89                 perf_profile_config = self.get_domain_dict(dict_key_value,
90                                                            self.performance_profile_attr)
91                 storage_profile_config = self.get_domain_dict(dict_key_value,
92                                                               self.storage_profile_attr)
93                 net_profile_dict = self.get_domain_dict(dict_key_value,
94                                                         self.network_profile_attr)
95                 networking_dict = self.get_domain_dict(dict_key_value,
96                                                        self.networking_attr)
97
98                 self.validate_network_ranges(host_dict, net_profile_dict, networking_dict)
99
100                 for host_name, host_data in host_dict.iteritems():
101                     attr = 'network_profiles'
102                     profiles = host_data.get(attr)
103                     profile_name = profiles[0]
104                     self.validate_profile_list(profiles, profile_list, host_name, attr)
105
106                     performance_profiles = host_data.get('performance_profiles')
107
108                     if self.is_provider_type_ovs_dpdk(profile_name, value_dict):
109                         if self.base_profile not in host_data['service_profiles']:
110                             reason = 'Missing base service profile with ovs_dpdk'
111                             reason += ' type provider network'
112                             raise validation.ValidationError(reason)
113                         if not performance_profiles:
114                             reason = \
115                                 'Missing performance profiles with ovs_dpdk type provider network'
116                             raise validation.ValidationError(reason)
117                         self.validate_performance_profile(perf_profile_config,
118                                                           performance_profiles[0])
119
120                     if self.is_provider_type_sriov(profile_name, value_dict):
121                         if not self.is_sriov_allowed_for_host(host_data['service_profiles']):
122                             reason = 'Missing base or caas_* service profile'
123                             reason += ' with SR-IOV type provider network'
124                             raise validation.ValidationError(reason)
125
126                     subnet_name = 'infra_internal'
127                     if not self.network_is_mapped(value_dict.get(profile_name), subnet_name):
128                         raise validation.ValidationError('%s is not mapped for %s' % (subnet_name,
129                                                                                       host_name))
130                     if self.management_profile in host_data['service_profiles']:
131                         subnet_name = 'infra_external'
132                         if not self.network_is_mapped(value_dict.get(profile_name), subnet_name):
133                             raise validation.ValidationError('%s is not mapped for %s' %
134                                                              (subnet_name, host_name))
135                     else:
136                         subnet_name = 'infra_external'
137                         if self.network_is_mapped(value_dict.get(profile_name), subnet_name):
138                             raise validation.ValidationError('%s is mapped for %s' %
139                                                              (subnet_name, host_name))
140
141                     if self.storage_profile in host_data['service_profiles']:
142                         storage_profile_list = host_data.get('storage_profiles')
143                         subnet_name = 'infra_storage_cluster'
144                         if not self.network_is_mapped(value_dict.get(profile_name), subnet_name) \
145                                 and self.is_ceph_profile(storage_profile_config,
146                                                          storage_profile_list):
147                             raise validation.ValidationError('%s is not mapped for %s' %
148                                                              (subnet_name, host_name))
149
150             elif key == self.storage_profile_attr:
151                 profile_list = [] if not value_dict else value_dict.keys()
152
153                 host_dict = self.get_domain_dict(dict_key_value, self.domain)
154
155                 for host_name, host_data in host_dict.iteritems():
156                     attr = 'storage_profiles'
157                     profiles = host_data.get(attr)
158                     if profiles:
159                         self.validate_profile_list(profiles, profile_list, host_name, attr)
160
161             elif key == self.performance_profile_attr:
162                 profile_list = [] if not value_dict else value_dict.keys()
163
164                 host_dict = self.get_domain_dict(dict_key_value, self.domain)
165                 network_profile_config = self.get_domain_dict(dict_key_value,
166                                                               self.network_profile_attr)
167
168                 for host_name, host_data in host_dict.iteritems():
169                     attr = 'performance_profiles'
170                     profiles = host_data.get(attr)
171                     if profiles:
172                         self.validate_profile_list(profiles, profile_list, host_name, attr)
173                         self.validate_nonempty_performance_profile(value_dict, profiles[0],
174                                                                    host_name)
175
176                     network_profiles = host_data.get('network_profiles')
177                     if self.is_provider_type_ovs_dpdk(network_profiles[0], network_profile_config):
178                         if not profiles:
179                             reason = \
180                                 'Missing performance profiles with ovs_dpdk type provider network'
181                             raise validation.ValidationError(reason)
182                         self.validate_performance_profile(value_dict,
183                                                           profiles[0])
184             elif key == self.networking_attr:
185                 networking_dict = value_dict
186
187                 hosts_dict = self.get_domain_dict(dict_key_value, self.domain)
188                 profile_config = self.get_domain_dict(dict_key_value,
189                                                       self.network_profile_attr)
190
191                 self.validate_network_ranges(hosts_dict, profile_config, networking_dict)
192
193             else:
194                 raise validation.ValidationError('Unexpected configuration %s' % key)
195
196     def validate_delete(self, props):
197         logging.debug('validate_delete called with %s', props)
198         if self.domain in props:
199             raise validation.ValidationError('%s cannot be deleted' % self.domain)
200         else:
201             raise validation.ValidationError('References in %s, cannot be deleted' % self.domain)
202
203     def validate_hosts(self, hosts_config, nw_profile_config,
204                        storage_profile_config, perf_profile_config,
205                        networking_config):
206         net_profile_list = [] if not nw_profile_config \
207                               else nw_profile_config.keys()
208         storage_profile_list = [] if not storage_profile_config else storage_profile_config.keys()
209         performance_profile_list = [] if not perf_profile_config else perf_profile_config.keys()
210
211         service_profile_list = service_profiles.Profiles().get_service_profiles()
212
213         bases = []
214         storages = []
215         caas_masters = []
216         managements = []
217
218         for key, value in hosts_config.iteritems():
219             # Hostname
220             if not re.match(r'^[\da-z][\da-z-]*$', key) or len(key) > 63:
221                 raise validation.ValidationError('Invalid hostname %s' % key)
222
223             # Network domain
224             attr = 'network_domain'
225             network_domain = value.get(attr)
226             if not network_domain:
227                 reason = 'Missing %s for %s' % (attr, key)
228                 raise validation.ValidationError(reason)
229
230             # Network profiles
231             attr = 'network_profiles'
232             profiles = value.get(attr)
233             self.validate_profile_list(profiles, net_profile_list, key, attr)
234             if len(profiles) != 1:
235                 reason = 'More than one %s defined for %s' % (attr, key)
236                 raise validation.ValidationError(reason)
237
238             nw_profile_name = profiles[0]
239             subnet_name = 'infra_internal'
240             if not self.network_is_mapped(nw_profile_config.get(nw_profile_name), subnet_name):
241                 raise validation.ValidationError('%s is not mapped for %s' % (subnet_name, key))
242
243             # Performance profiles
244             attr = 'performance_profiles'
245             perf_profile = None
246             profiles = value.get(attr)
247             if profiles:
248                 self.validate_profile_list(profiles, performance_profile_list,
249                                            key, attr)
250                 if len(profiles) != 1:
251                     reason = 'More than one %s defined for %s' % (attr, key)
252                     raise validation.ValidationError(reason)
253                 perf_profile = profiles[0]
254                 self.validate_nonempty_performance_profile(perf_profile_config, perf_profile, key)
255
256             if self.is_provider_type_ovs_dpdk(nw_profile_name, nw_profile_config):
257                 if not profiles:
258                     reason = 'Missing performance profiles with ovs_dpdk type provider network'
259                     raise validation.ValidationError(reason)
260                 self.validate_performance_profile(perf_profile_config, perf_profile)
261
262             # Service profiles
263             attr = 'service_profiles'
264             profiles = value.get(attr)
265             self.validate_profile_list(profiles, service_profile_list, key, attr)
266             if self.is_provider_type_ovs_dpdk(nw_profile_name, nw_profile_config):
267                 if self.base_profile not in profiles:
268                     reason = 'Missing base service profile with ovs_dpdk type provider network'
269                     raise validation.ValidationError(reason)
270             if self.is_provider_type_sriov(nw_profile_name, nw_profile_config):
271                 if not self.is_sriov_allowed_for_host(profiles):
272                     reason = 'Missing base or caas_* service profile'
273                     reason += ' with SR-IOV type provider network'
274                     raise validation.ValidationError(reason)
275             if perf_profile:
276                 if not self.is_perf_allowed_for_host(profiles):
277                     reason = 'Missing base or caas_* service profile'
278                     reason += ' with performance profile host'
279                     raise validation.ValidationError(reason)
280             if self.management_profile in profiles:
281                 managements.append(key)
282                 subnet_name = 'infra_external'
283                 if not self.network_is_mapped(nw_profile_config.get(nw_profile_name), subnet_name):
284                     raise validation.ValidationError('%s is not mapped for %s' % (subnet_name, key))
285             else:
286                 subnet_name = 'infra_external'
287                 if self.network_is_mapped(nw_profile_config.get(nw_profile_name), subnet_name):
288                     raise validation.ValidationError('%s is mapped for %s' % (subnet_name, key))
289
290             if self.base_profile in profiles:
291                 bases.append(key)
292             if self.caas_master_profile in profiles:
293                 caas_masters.append(key)
294
295             if self.storage_profile in profiles:
296                 storages.append(key)
297                 st_profiles = value.get('storage_profiles')
298                 self.validate_profile_list(st_profiles, storage_profile_list,
299                                            key, 'storage_profiles')
300                 subnet_name = 'infra_storage_cluster'
301                 if not self.network_is_mapped(nw_profile_config.get(nw_profile_name), subnet_name) \
302                         and self.is_ceph_profile(storage_profile_config, st_profiles):
303                     raise validation.ValidationError('%s is not mapped for %s' % (subnet_name, key))
304
305             # HW management
306             self.validate_hwmgmt(value.get('hwmgmt'), key)
307
308             # MAC address
309             self.validate_mac_list(value.get('mgmt_mac'))
310
311             # Preallocated IP validation
312             self.validate_preallocated_ips(value, nw_profile_config, networking_config)
313
314         # Check duplicated Preallocated IPs
315         self.search_for_duplicate_ips(hosts_config)
316
317         # There should be least one management node
318         if not managements and not caas_masters:
319             reason = 'No management node defined'
320             raise validation.ValidationError(reason)
321
322         # Number of caas_masters 1 or 3
323         if caas_masters:
324             if len(caas_masters) != 1 and len(caas_masters) != 3:
325                 reason = 'Unexpected number of caas_master nodes %d' % len(caas_masters)
326                 raise validation.ValidationError(reason)
327
328         # Number of management nodes 1 or 3
329         if managements:
330             if len(managements) != 1 and len(managements) != 3:
331                 reason = 'Unexpected number of controller nodes %d' % len(managements)
332                 raise validation.ValidationError(reason)
333
334         # All managements must be in same network domain
335         management_network_domain = None
336         for management in managements:
337             if management_network_domain is None:
338                 management_network_domain = hosts_config[management].get('network_domain')
339             else:
340                 if not management_network_domain == hosts_config[management].get('network_domain'):
341                     reason = 'All management nodes must belong to the same networking domain'
342                     raise validation.ValidationError(reason)
343
344         if len(managements) == 3 and len(storages) < 2:
345             raise validation.ValidationError('There are not enough storage nodes')
346
347         self.validate_network_ranges(hosts_config, nw_profile_config, networking_config)
348
349     def validate_network_ranges(self, hosts_config, nw_profile_config, networking_config):
350         host_counts = {}  # (infra_network, network_domain) as a key, mapped host count as a value
351         for host_conf in hosts_config.itervalues():
352             if (isinstance(host_conf, dict) and
353                     host_conf.get('network_profiles') and
354                     isinstance(host_conf['network_profiles'], list) and
355                     host_conf['network_profiles']):
356                 domain = host_conf.get('network_domain')
357                 profile = nw_profile_config.get(host_conf['network_profiles'][0])
358                 if (isinstance(profile, dict) and
359                         profile.get('interface_net_mapping') and
360                         isinstance(profile['interface_net_mapping'], dict)):
361                     for infras in profile['interface_net_mapping'].itervalues():
362                         if isinstance(infras, list):
363                             for infra in infras:
364                                 key = (infra, domain)
365                                 if key in host_counts:
366                                     host_counts[key] += 1
367                                 else:
368                                     host_counts[key] = 1
369         for (infra, domain), count in host_counts.iteritems():
370             self.validate_infra_network_range(infra, domain, networking_config, count)
371
372     def validate_infra_network_range(self, infra, network_domain, networking_config, host_count):
373         infra_conf = networking_config.get(infra)
374         if not isinstance(infra_conf, dict):
375             return
376
377         domains_conf = infra_conf.get('network_domains')
378         if not isinstance(domains_conf, dict) or network_domain not in domains_conf:
379             reason = '%s does not contain %s network domain configuration' % \
380                 (infra, network_domain)
381             raise validation.ValidationError(reason)
382         cidr = domains_conf[network_domain].get('cidr')
383         start = domains_conf[network_domain].get('ip_range_start')
384         end = domains_conf[network_domain].get('ip_range_end')
385
386         if not start and cidr:
387             start = str(IPNetwork(cidr)[1])
388         if not end and cidr:
389             end = str(IPNetwork(cidr)[-2])
390         required = host_count if infra != 'infra_external' else host_count + 1
391         if len(IPRange(start, end)) < required:
392             reason = 'IP range %s - %s does not contain %d addresses' % (start, end, required)
393             raise validation.ValidationError(reason)
394
395     def validate_profile_list(self, profile_list, profile_defs, host, attribute):
396         if not profile_list:
397             raise validation.ValidationError('Missing %s for %s' % (attribute, host))
398         if not isinstance(profile_list, list):
399             raise validation.ValidationError('%s %s value must be a list' % (host, attribute))
400         for profile in profile_list:
401             if profile not in profile_defs:
402                 raise validation.ValidationError('Unknown %s %s for %s' %
403                                                  (attribute, profile, host))
404
405     def validate_hwmgmt(self, hwmgmt, host):
406         if not hwmgmt:
407             raise validation.ValidationError('Missing hwmgmt configuration for %s' % host)
408         if not hwmgmt.get('user'):
409             raise validation.ValidationError('Missing hwmgmt username for %s' % host)
410         if not hwmgmt.get('password'):
411             raise validation.ValidationError('Missing hwmgmt password for %s' % host)
412         validationutils = validation.ValidationUtils()
413         validationutils.validate_ip_address(hwmgmt.get('address'))
414
415     def validate_nonempty_performance_profile(self, config, profile_name, host_name):
416         profile = config.get(profile_name)
417         if not isinstance(profile, dict) or not profile:
418             reason = 'Empty performance profile %s defined for %s' % (profile_name, host_name)
419             raise validation.ValidationError(reason)
420
421     def validate_performance_profile(self, config, profile_name):
422         attributes = ['default_hugepagesz', 'hugepagesz', 'hugepages',
423                       'ovs_dpdk_cpus']
424         profile = config.get(profile_name)
425         if not profile:
426             profile = {}
427         for attr in attributes:
428             if not profile.get(attr):
429                 raise validation.ValidationError('Missing %s value for performance profile %s'
430                                                  % (attr, profile_name))
431
432     def validate_mac_list(self, mac_list):
433         if not mac_list:
434             return
435
436         if not isinstance(mac_list, list):
437             raise validation.ValidationError('mgmt_mac value must be a list')
438
439         for mac in mac_list:
440             pattern = '[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$'
441             if not mac or not re.match(pattern, mac.lower()):
442                 raise validation.ValidationError('Invalid mac address syntax %s' % mac)
443
444     def validate_preallocated_ips(self, host, nw_profile_config, networking_config):
445         if not self.host_has_preallocated_ip(host):
446             return
447         validationutils = validation.ValidationUtils()
448         for network_name, ip in host["pre_allocated_ips"].iteritems():
449             for net_profile_name in host["network_profiles"]:
450                 if not self.is_network_in_net_profile(
451                         network_name, nw_profile_config.get(net_profile_name)):
452                     raise validation.ValidationError(
453                         "Network %s is missing from network profile %s" %
454                         (network_name, net_profile_name))
455             network_domains = networking_config.get(network_name).get("network_domains")
456             host_network_domain = host["network_domain"]
457             subnet = network_domains.get(host_network_domain)["cidr"]
458             validationutils.validate_ip_address(ip)
459             utils.validate_ip_in_network(ip, subnet)
460
461     def host_has_preallocated_ip(self, host):
462         ips_field = "pre_allocated_ips"
463         if ips_field in host and host.get(ips_field, {}) and all(host[ips_field]):
464             return True
465         return False
466
467     def is_network_in_net_profile(self, network_name, network_profile):
468         for networks in network_profile["interface_net_mapping"].itervalues():
469             if network_name in networks:
470                 return True
471         return False
472
473     def search_for_duplicate_ips(self, hosts):
474         ips_field = "pre_allocated_ips"
475         hosts_with_preallocated_ip = {name: attributes
476                                       for name, attributes in hosts.iteritems()
477                                       if self.host_has_preallocated_ip(attributes)}
478         for host_name, host in hosts_with_preallocated_ip.iteritems():
479             other_hosts = {name: attributes
480                            for name, attributes in hosts_with_preallocated_ip.iteritems()
481                            if name != host_name}
482             for other_host_name, other_host in other_hosts.iteritems():
483                 if self.host_has_preallocated_ip(other_host):
484                     logging.debug(
485                         "Checking %s and %s for duplicated preallocated IPs",
486                         host_name, other_host_name)
487                     duplicated_ip = self.is_ip_duplicated(host[ips_field], other_host[ips_field])
488                     if duplicated_ip:
489                         raise validation.ValidationError(
490                             "%s and %s has duplicated IP address: %s" %
491                             (host_name, other_host_name, duplicated_ip))
492
493     def is_ip_duplicated(self, ips, other_host_ips):
494         logging.debug("Checking for IP duplication from %s to %s", ips, other_host_ips)
495         for network_name, ip in ips.iteritems():
496             if (network_name in other_host_ips and
497                     ip == other_host_ips[network_name]):
498                 return ip
499         return False
500
501     def get_attribute_value(self, config, name_list):
502         value = config
503         for name in name_list:
504             value = None if not isinstance(value, dict) else value.get(name)
505             if not value:
506                 break
507         return value
508
509     def get_domain_dict(self, config, domain_name):
510         client = self.get_plugin_client()
511         str_value = config.get(domain_name)
512         if not str_value:
513             str_value = client.get_property(domain_name)
514         dict_value = {} if not str_value else json.loads(str_value)
515         return dict_value
516
517     def is_provider_type_ovs_dpdk(self, profile_name, profile_config):
518         path = [profile_name, 'provider_network_interfaces']
519         provider_ifs = self.get_attribute_value(profile_config, path)
520         if provider_ifs:
521             for value in provider_ifs.values():
522                 if value.get('type') == 'ovs-dpdk':
523                     return True
524         return False
525
526     def is_provider_type_sriov(self, profile_name, profile_config):
527         path = [profile_name, 'sriov_provider_networks']
528         if self.get_attribute_value(profile_config, path):
529             return True
530         return False
531
532     def is_sriov_allowed_for_host(self, profiles):
533         return (self.base_profile in profiles or
534                 self.caas_worker_profile in profiles or
535                 self.caas_master_profile in profiles)
536
537     def is_perf_allowed_for_host(self, profiles):
538         return self.is_sriov_allowed_for_host(profiles)
539
540     def network_is_mapped(self, network_profile, name):
541         mapping = network_profile.get('interface_net_mapping')
542         if isinstance(mapping, dict):
543             for interface in mapping.values():
544                 if name in interface:
545                     return True
546         return False
547
548     def is_ceph_profile(self, storage_profiles, profile_list):
549         ceph = 'ceph'
550         for profile in profile_list:
551             backend = storage_profiles[profile].get('backend')
552             if backend == ceph:
553                 return True
554         return False
555
556     def _get_type_of_nodes(self, nodetype, config):
557         nodes = [k for k, v in config.iteritems() if nodetype in v['service_profiles']]
558         return nodes
559
560     def _get_storage_nodes(self, config):
561         return self._get_type_of_nodes(self.storage_profile, config)
562
563     def _get_changed_hosts_config(self, config, domain_name):
564         str_value = config.get(domain_name)
565         return {} if not str_value else json.loads(str_value)
566
567     def _get_running_hosts_config(self):
568         return self.get_domain_dict({}, self.domain)
569
570     def _get_number_of_changed_storage_hosts(self, changes):
571         conf = self._get_changed_hosts_config(changes, self.domain)
572         num = len(self._get_storage_nodes(conf))
573         logging.debug(
574             'HostsValidator: number of changed storage hosts: %s', str(num))
575         return num
576
577     def _get_number_of_old_storage_hosts(self):
578         conf = self._get_running_hosts_config()
579         if conf:
580             num = len(self._get_storage_nodes(conf))
581             logging.debug(
582                 'HostsValidator: number of existing storage hosts: %s', str(num))
583             return num
584         raise ConfigurationDoesNotExist(
585             "The running hosts configuration does not exist -> deployment ongoing.")
586
587     def _validate_only_one_storage_host_removed(self, changes):
588         num_existing_storage_hosts = self._get_number_of_old_storage_hosts()
589         if self._get_number_of_changed_storage_hosts(changes) < num_existing_storage_hosts-1:
590             raise validation.ValidationError(
591                 "It is allowed to scale-in only 1 storage node at a time.")
592
593     def validate_scale_in(self, changes):
594         try:
595             self._validate_only_one_storage_host_removed(changes)
596         except ConfigurationDoesNotExist as exc:
597             logging.debug(str(exc))
598             return