4 from collections
import namedtuple
, OrderedDict
5 from functools
import wraps
6 from typing
import Optional
, Dict
, Any
, List
, Union
, Callable
, Iterator
11 from ceph
.deployment
.hostspec
import HostSpec
14 class ServiceSpecValidationError(Exception):
16 Defining an exception here is a bit problematic, cause you cannot properly catch it,
17 if it was raised in a different mgr module.
21 errno
: int = -errno
.EINVAL
):
22 super(ServiceSpecValidationError
, self
).__init
__(msg
)
26 def assert_valid_host(name
):
27 p
= re
.compile('^[a-zA-Z0-9-]+$')
29 assert len(name
) <= 250, 'name is too long (max 250 chars)'
30 for part
in name
.split('.'):
31 assert len(part
) > 0, '.-delimited name component must not be empty'
32 assert len(part
) <= 63, '.-delimited name component must not be more than 63 chars'
33 assert p
.match(part
), 'name component must include only a-z, 0-9, and -'
34 except AssertionError as e
:
35 raise ServiceSpecValidationError(e
)
38 def handle_type_error(method
):
40 def inner(cls
, *args
, **kwargs
):
42 return method(cls
, *args
, **kwargs
)
43 except (TypeError, AttributeError) as e
:
44 error_msg
= '{}: {}'.format(cls
.__name
__, e
)
45 raise ServiceSpecValidationError(error_msg
)
49 class HostPlacementSpec(namedtuple('HostPlacementSpec', ['hostname', 'network', 'name'])):
54 res
+= ':' + self
.network
56 res
+= '=' + self
.name
61 def from_json(cls
, data
):
66 'hostname': self
.hostname
,
67 'network': self
.network
,
72 def parse(cls
, host
, require_network
=True):
73 # type: (str, bool) -> HostPlacementSpec
75 Split host into host, network, and (optional) daemon name parts. The network
76 part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'.
83 "myhost:1.2.3.0/24=name"
84 "myhost:[v2:1.2.3.4:3000]=name"
85 "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name"
87 # Matches from start to : or = or until end of string
88 host_re
= r
'^(.*?)(:|=|$)'
89 # Matches from : to = or until end of string
90 ip_re
= r
':(.*?)(=|$)'
91 # Matches from = to end of string
95 host_spec
= cls('', '', '')
97 match_host
= re
.search(host_re
, host
)
99 host_spec
= host_spec
._replace
(hostname
=match_host
.group(1))
101 name_match
= re
.search(name_re
, host
)
103 host_spec
= host_spec
._replace
(name
=name_match
.group(1))
105 ip_match
= re
.search(ip_re
, host
)
107 host_spec
= host_spec
._replace
(network
=ip_match
.group(1))
109 if not require_network
:
112 from ipaddress
import ip_network
, ip_address
113 networks
= list() # type: List[str]
114 network
= host_spec
.network
115 # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478]
117 networks
= [x
for x
in network
.split(',')]
120 networks
.append(network
)
122 for network
in networks
:
123 # only if we have versioned network configs
124 if network
.startswith('v') or network
.startswith('[v'):
125 network
= network
.split(':')[1]
127 # if subnets are defined, also verify the validity
129 ip_network(six
.text_type(network
))
131 ip_address(six
.text_type(network
))
132 except ValueError as e
:
139 assert_valid_host(self
.hostname
)
142 class PlacementSpec(object):
144 For APIs that need to specify a host subset
148 label
=None, # type: Optional[str]
149 hosts
=None, # type: Union[List[str],List[HostPlacementSpec]]
150 count
=None, # type: Optional[int]
151 host_pattern
=None # type: Optional[str]
153 # type: (...) -> None
155 self
.hosts
= [] # type: List[HostPlacementSpec]
158 self
.set_hosts(hosts
)
160 self
.count
= count
# type: Optional[int]
162 #: fnmatch patterns to select hosts. Can also be a single host.
163 self
.host_pattern
= host_pattern
# type: Optional[str]
168 return self
.label
is None and \
170 not self
.host_pattern
and \
173 def __eq__(self
, other
):
174 if isinstance(other
, PlacementSpec
):
175 return self
.label
== other
.label \
176 and self
.hosts
== other
.hosts \
177 and self
.count
== other
.count \
178 and self
.host_pattern
== other
.host_pattern
179 return NotImplemented
181 def set_hosts(self
, hosts
):
182 # To backpopulate the .hosts attribute when using labels or count
183 # in the orchestrator backend.
184 if all([isinstance(host
, HostPlacementSpec
) for host
in hosts
]):
185 self
.hosts
= hosts
# type: ignore
187 self
.hosts
= [HostPlacementSpec
.parse(x
, require_network
=False) # type: ignore
190 def filter_matching_hosts(self
, _get_hosts_func
: Callable
) -> List
[str]:
191 return self
.filter_matching_hostspecs(_get_hosts_func(as_hostspec
=True))
193 def filter_matching_hostspecs(self
, hostspecs
: Iterator
[HostSpec
]) -> List
[str]:
195 all_hosts
= [hs
.hostname
for hs
in hostspecs
]
196 return [h
.hostname
for h
in self
.hosts
if h
.hostname
in all_hosts
]
198 return [hs
.hostname
for hs
in hostspecs
if self
.label
in hs
.labels
]
199 elif self
.host_pattern
:
200 all_hosts
= [hs
.hostname
for hs
in hostspecs
]
201 return fnmatch
.filter(all_hosts
, self
.host_pattern
)
203 # This should be caught by the validation but needs to be here for
204 # get_host_selection_size
207 def get_host_selection_size(self
, hostspecs
: Iterator
[HostSpec
]):
210 return len(self
.filter_matching_hostspecs(hostspecs
))
212 def pretty_str(self
):
215 kv
.append('count:%d' % self
.count
)
217 kv
.append('label:%s' % self
.label
)
219 kv
.append('%s' % ','.join([str(h
) for h
in self
.hosts
]))
220 if self
.host_pattern
:
221 kv
.append(self
.host_pattern
)
227 kv
.append('count=%d' % self
.count
)
229 kv
.append('label=%s' % repr(self
.label
))
231 kv
.append('hosts={!r}'.format(self
.hosts
))
232 if self
.host_pattern
:
233 kv
.append('host_pattern={!r}'.format(self
.host_pattern
))
234 return "PlacementSpec(%s)" % ', '.join(kv
)
238 def from_json(cls
, data
):
240 hosts
= c
.get('hosts', [])
244 c
['hosts'].append(HostPlacementSpec
.parse(host
) if
245 isinstance(host
, str) else
246 HostPlacementSpec
.from_json(host
))
254 r
['label'] = self
.label
256 r
['hosts'] = [host
.to_json() for host
in self
.hosts
]
258 r
['count'] = self
.count
259 if self
.host_pattern
:
260 r
['host_pattern'] = self
.host_pattern
264 if self
.hosts
and self
.label
:
265 # TODO: a less generic Exception
266 raise ServiceSpecValidationError('Host and label are mutually exclusive')
267 if self
.count
is not None and self
.count
<= 0:
268 raise ServiceSpecValidationError("num/count must be > 1")
269 if self
.host_pattern
and self
.hosts
:
270 raise ServiceSpecValidationError('cannot combine host patterns and hosts')
275 def from_string(cls
, arg
):
276 # type: (Optional[str]) -> PlacementSpec
278 A single integer is parsed as a count:
279 >>> PlacementSpec.from_string('3')
280 PlacementSpec(count=3)
282 A list of names is parsed as host specifications:
283 >>> PlacementSpec.from_string('host1 host2')
284 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
285 tSpec(hostname='host2', network='', name='')])
287 You can also prefix the hosts with a count as follows:
288 >>> PlacementSpec.from_string('2 host1 host2')
289 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
290 tPlacementSpec(hostname='host2', network='', name='')])
292 You can spefify labels using `label:<label>`
293 >>> PlacementSpec.from_string('label:mon')
294 PlacementSpec(label='mon')
296 Labels als support a count:
297 >>> PlacementSpec.from_string('3 label:mon')
298 PlacementSpec(count=3, label='mon')
300 fnmatch is also supported:
301 >>> PlacementSpec.from_string('data[1-3]')
302 PlacementSpec(host_pattern='data[1-3]')
304 >>> PlacementSpec.from_string(None)
307 if arg
is None or not arg
:
309 elif isinstance(arg
, str):
311 strings
= arg
.split(' ')
313 strings
= arg
.split(';')
314 elif ',' in arg
and '[' not in arg
:
315 # FIXME: this isn't quite right. we want to avoid breaking
316 # a list of mons with addrvecs... so we're basically allowing
317 # , most of the time, except when addrvecs are used. maybe
319 strings
= arg
.split(',')
323 raise ServiceSpecValidationError('invalid placement %s' % arg
)
328 count
= int(strings
[0])
329 strings
= strings
[1:]
333 if s
.startswith('count:'):
341 advanced_hostspecs
= [h
for h
in strings
if
342 (':' in h
or '=' in h
or not any(c
in '[]?*:=' for c
in h
)) and
344 for a_h
in advanced_hostspecs
:
347 labels
= [x
for x
in strings
if 'label:' in x
]
349 raise ServiceSpecValidationError('more than one label provided: {}'.format(labels
))
352 label
= labels
[0][6:] if labels
else None
354 host_patterns
= strings
355 if len(host_patterns
) > 1:
356 raise ServiceSpecValidationError(
357 'more than one host pattern provided: {}'.format(host_patterns
))
359 ps
= PlacementSpec(count
=count
,
360 hosts
=advanced_hostspecs
,
362 host_pattern
=host_patterns
[0] if host_patterns
else None)
366 class ServiceSpec(object):
368 Details of service creation.
370 Request to the orchestrator for a cluster of daemons
371 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
373 This structure is supposed to be enough information to
377 KNOWN_SERVICE_TYPES
= 'alertmanager crash grafana iscsi mds mgr mon nfs ' \
378 'node-exporter osd prometheus rbd-mirror rgw'.split()
379 REQUIRES_SERVICE_ID
= 'iscsi mds nfs osd rgw'.split()
382 def _cls(cls
, service_type
):
383 from ceph
.deployment
.drive_group
import DriveGroupSpec
387 'nfs': NFSServiceSpec
,
388 'osd': DriveGroupSpec
,
389 'iscsi': IscsiServiceSpec
,
390 'alertmanager': AlertManagerSpec
391 }.get(service_type
, cls
)
392 if ret
== ServiceSpec
and not service_type
:
393 raise ServiceSpecValidationError('Spec needs a "service_type" key.')
396 def __new__(cls
, *args
, **kwargs
):
398 Some Python foo to make sure, we don't have an object
399 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
401 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
405 if cls
!= ServiceSpec
:
406 return object.__new
__(cls
)
407 service_type
= kwargs
.get('service_type', args
[0] if args
else None)
408 sub_cls
= cls
._cls
(service_type
)
409 return object.__new
__(sub_cls
)
413 service_id
: Optional
[str] = None,
414 placement
: Optional
[PlacementSpec
] = None,
415 count
: Optional
[int] = None,
416 unmanaged
: bool = False,
417 preview_only
: bool = False,
419 self
.placement
= PlacementSpec() if placement
is None else placement
# type: PlacementSpec
421 assert service_type
in ServiceSpec
.KNOWN_SERVICE_TYPES
, service_type
422 self
.service_type
= service_type
423 self
.service_id
= None
424 if self
.service_type
in self
.REQUIRES_SERVICE_ID
:
425 self
.service_id
= service_id
426 self
.unmanaged
= unmanaged
427 self
.preview_only
= preview_only
431 def from_json(cls
, json_spec
):
432 # type: (dict) -> Any
434 # >>> ServiceSpecs = TypeVar('Base', bound=ServiceSpec)
435 # then, the real type is: (dict) -> ServiceSpecs
437 Initialize 'ServiceSpec' object data from a json structure
439 There are two valid styles for service specs:
460 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
461 prefer the new style as it is more readable and provides a better
462 understanding of what fields are special for a give service type.
464 Note, we'll need to stay compatible with both versions for the
465 the next two major releases (octoups, pacific).
467 :param json_spec: A valid dict with ServiceSpec
472 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
473 # Open question: Remove `service_id` form to_json?
474 if c
.get('service_name', ''):
475 service_type_id
= c
['service_name'].split('.', 1)
477 if not c
.get('service_type', ''):
478 c
['service_type'] = service_type_id
[0]
479 if not c
.get('service_id', '') and len(service_type_id
) > 1:
480 c
['service_id'] = service_type_id
[1]
481 del c
['service_name']
483 service_type
= c
.get('service_type', '')
484 _cls
= cls
._cls
(service_type
)
487 del c
['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
489 return _cls
._from
_json
_impl
(c
) # type: ignore
492 def _from_json_impl(cls
, json_spec
):
493 args
= {} # type: Dict[str, Dict[Any, Any]]
494 for k
, v
in json_spec
.items():
496 v
= PlacementSpec
.from_json(v
)
505 def service_name(self
):
506 n
= self
.service_type
508 n
+= '.' + self
.service_id
512 # type: () -> OrderedDict[str, Any]
513 ret
: OrderedDict
[str, Any
] = OrderedDict()
514 ret
['service_type'] = self
.service_type
516 ret
['service_id'] = self
.service_id
517 ret
['service_name'] = self
.service_name()
518 ret
['placement'] = self
.placement
.to_json()
520 ret
['unmanaged'] = self
.unmanaged
523 for key
, val
in sorted(self
.__dict
__.items(), key
=lambda tpl
: tpl
[0]):
526 if hasattr(val
, 'to_json'):
535 if not self
.service_type
:
536 raise ServiceSpecValidationError('Cannot add Service: type required')
538 if self
.service_type
in self
.REQUIRES_SERVICE_ID
:
539 if not self
.service_id
:
540 raise ServiceSpecValidationError('Cannot add Service: id required')
541 elif self
.service_id
:
542 raise ServiceSpecValidationError(
543 f
'Service of type \'{self.service_type}\' should not contain a service id')
545 if self
.placement
is not None:
546 self
.placement
.validate()
549 return "{}({!r})".format(self
.__class
__.__name
__, self
.__dict
__)
551 def __eq__(self
, other
):
552 return (self
.__class
__ == other
.__class
__
554 self
.__dict
__ == other
.__dict
__)
556 def one_line_str(self
):
557 return '<{} for service_name={}>'.format(self
.__class
__.__name
__, self
.service_name())
560 def yaml_representer(dumper
: 'yaml.SafeDumper', data
: 'ServiceSpec'):
561 return dumper
.represent_dict(data
.to_json().items())
564 yaml
.add_representer(ServiceSpec
, ServiceSpec
.yaml_representer
)
567 class NFSServiceSpec(ServiceSpec
):
569 service_type
: str = 'nfs',
570 service_id
: Optional
[str] = None,
571 pool
: Optional
[str] = None,
572 namespace
: Optional
[str] = None,
573 placement
: Optional
[PlacementSpec
] = None,
574 unmanaged
: bool = False,
575 preview_only
: bool = False
577 assert service_type
== 'nfs'
578 super(NFSServiceSpec
, self
).__init
__(
579 'nfs', service_id
=service_id
,
580 placement
=placement
, unmanaged
=unmanaged
, preview_only
=preview_only
)
582 #: RADOS pool where NFS client recovery data is stored.
585 #: RADOS namespace where NFS client recovery data is stored in the pool.
586 self
.namespace
= namespace
588 self
.preview_only
= preview_only
591 super(NFSServiceSpec
, self
).validate()
594 raise ServiceSpecValidationError(
595 'Cannot add NFS: No Pool specified')
597 def rados_config_name(self
):
599 return 'conf-' + self
.service_name()
601 def rados_config_location(self
):
605 url
+= 'rados://' + self
.pool
+ '/'
607 url
+= self
.namespace
+ '/'
608 url
+= self
.rados_config_name()
612 yaml
.add_representer(NFSServiceSpec
, ServiceSpec
.yaml_representer
)
615 class RGWSpec(ServiceSpec
):
617 Settings to configure a (multisite) Ceph RGW
621 service_type
: str = 'rgw',
622 service_id
: Optional
[str] = None,
623 placement
: Optional
[PlacementSpec
] = None,
624 rgw_realm
: Optional
[str] = None,
625 rgw_zone
: Optional
[str] = None,
626 subcluster
: Optional
[str] = None,
627 rgw_frontend_port
: Optional
[int] = None,
628 rgw_frontend_ssl_certificate
: Optional
[List
[str]] = None,
629 rgw_frontend_ssl_key
: Optional
[List
[str]] = None,
630 unmanaged
: bool = False,
632 preview_only
: bool = False,
634 assert service_type
== 'rgw', service_type
636 a
= service_id
.split('.', 2)
644 service_id
= '%s.%s.%s' % (rgw_realm
, rgw_zone
, subcluster
)
646 service_id
= '%s.%s' % (rgw_realm
, rgw_zone
)
647 super(RGWSpec
, self
).__init
__(
648 'rgw', service_id
=service_id
,
649 placement
=placement
, unmanaged
=unmanaged
,
650 preview_only
=preview_only
)
652 self
.rgw_realm
= rgw_realm
653 self
.rgw_zone
= rgw_zone
654 self
.subcluster
= subcluster
655 self
.rgw_frontend_port
= rgw_frontend_port
656 self
.rgw_frontend_ssl_certificate
= rgw_frontend_ssl_certificate
657 self
.rgw_frontend_ssl_key
= rgw_frontend_ssl_key
659 self
.preview_only
= preview_only
662 if self
.rgw_frontend_port
:
663 return self
.rgw_frontend_port
669 def rgw_frontends_config_value(self
):
672 ports
.append(f
"ssl_port={self.get_port()}")
673 ports
.append(f
"ssl_certificate=config://rgw/cert/{self.rgw_realm}/{self.rgw_zone}.crt")
674 ports
.append(f
"ssl_key=config://rgw/cert/{self.rgw_realm}/{self.rgw_zone}.key")
676 ports
.append(f
"port={self.get_port()}")
677 return f
'beast {" ".join(ports)}'
680 super(RGWSpec
, self
).validate()
682 if not self
.rgw_realm
:
683 raise ServiceSpecValidationError(
684 'Cannot add RGW: No realm specified')
685 if not self
.rgw_zone
:
686 raise ServiceSpecValidationError(
687 'Cannot add RGW: No zone specified')
690 yaml
.add_representer(RGWSpec
, ServiceSpec
.yaml_representer
)
693 class IscsiServiceSpec(ServiceSpec
):
695 service_type
: str = 'iscsi',
696 service_id
: Optional
[str] = None,
697 pool
: Optional
[str] = None,
698 trusted_ip_list
: Optional
[str] = None,
699 api_port
: Optional
[int] = None,
700 api_user
: Optional
[str] = None,
701 api_password
: Optional
[str] = None,
702 api_secure
: Optional
[bool] = None,
703 ssl_cert
: Optional
[str] = None,
704 ssl_key
: Optional
[str] = None,
705 placement
: Optional
[PlacementSpec
] = None,
706 unmanaged
: bool = False,
707 preview_only
: bool = False
709 assert service_type
== 'iscsi'
710 super(IscsiServiceSpec
, self
).__init
__('iscsi', service_id
=service_id
,
711 placement
=placement
, unmanaged
=unmanaged
,
712 preview_only
=preview_only
)
714 #: RADOS pool where ceph-iscsi config data is stored.
716 self
.trusted_ip_list
= trusted_ip_list
717 self
.api_port
= api_port
718 self
.api_user
= api_user
719 self
.api_password
= api_password
720 self
.api_secure
= api_secure
721 self
.ssl_cert
= ssl_cert
722 self
.ssl_key
= ssl_key
723 self
.preview_only
= preview_only
725 if not self
.api_secure
and self
.ssl_cert
and self
.ssl_key
:
726 self
.api_secure
= True
729 super(IscsiServiceSpec
, self
).validate()
732 raise ServiceSpecValidationError(
733 'Cannot add ISCSI: No Pool specified')
734 if not self
.api_user
:
735 raise ServiceSpecValidationError(
736 'Cannot add ISCSI: No Api user specified')
737 if not self
.api_password
:
738 raise ServiceSpecValidationError(
739 'Cannot add ISCSI: No Api password specified')
742 yaml
.add_representer(IscsiServiceSpec
, ServiceSpec
.yaml_representer
)
745 class AlertManagerSpec(ServiceSpec
):
747 service_type
: str = 'alertmanager',
748 service_id
: Optional
[str] = None,
749 placement
: Optional
[PlacementSpec
] = None,
750 unmanaged
: bool = False,
751 preview_only
: bool = False,
752 user_data
: Optional
[Dict
[str, Any
]] = None,
754 assert service_type
== 'alertmanager'
755 super(AlertManagerSpec
, self
).__init
__(
756 'alertmanager', service_id
=service_id
,
757 placement
=placement
, unmanaged
=unmanaged
,
758 preview_only
=preview_only
)
760 # Custom configuration.
763 # service_type: alertmanager
766 # default_webhook_urls:
771 # default_webhook_urls - A list of additional URL's that are
772 # added to the default receivers'
773 # <webhook_configs> configuration.
774 self
.user_data
= user_data
or {}
777 yaml
.add_representer(AlertManagerSpec
, ServiceSpec
.yaml_representer
)