4 from collections
import namedtuple
, OrderedDict
5 from functools
import wraps
6 from typing
import Optional
, Dict
, Any
, List
, Union
, Callable
, Iterable
11 from ceph
.deployment
.hostspec
import HostSpec
12 from ceph
.deployment
.utils
import unwrap_ipv6
15 class ServiceSpecValidationError(Exception):
17 Defining an exception here is a bit problematic, cause you cannot properly catch it,
18 if it was raised in a different mgr module.
22 errno
: int = -errno
.EINVAL
):
23 super(ServiceSpecValidationError
, self
).__init
__(msg
)
27 def assert_valid_host(name
):
28 p
= re
.compile('^[a-zA-Z0-9-]+$')
30 assert len(name
) <= 250, 'name is too long (max 250 chars)'
31 for part
in name
.split('.'):
32 assert len(part
) > 0, '.-delimited name component must not be empty'
33 assert len(part
) <= 63, '.-delimited name component must not be more than 63 chars'
34 assert p
.match(part
), 'name component must include only a-z, 0-9, and -'
35 except AssertionError as e
:
36 raise ServiceSpecValidationError(e
)
39 def handle_type_error(method
):
41 def inner(cls
, *args
, **kwargs
):
43 return method(cls
, *args
, **kwargs
)
44 except (TypeError, AttributeError) as e
:
45 error_msg
= '{}: {}'.format(cls
.__name
__, e
)
46 raise ServiceSpecValidationError(error_msg
)
50 class HostPlacementSpec(namedtuple('HostPlacementSpec', ['hostname', 'network', 'name'])):
55 res
+= ':' + self
.network
57 res
+= '=' + self
.name
62 def from_json(cls
, data
):
63 if isinstance(data
, str):
64 return cls
.parse(data
)
67 def to_json(self
) -> str:
71 def parse(cls
, host
, require_network
=True):
72 # type: (str, bool) -> HostPlacementSpec
74 Split host into host, network, and (optional) daemon name parts. The network
75 part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'.
82 "myhost:1.2.3.0/24=name"
83 "myhost:[v2:1.2.3.4:3000]=name"
84 "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name"
86 # Matches from start to : or = or until end of string
87 host_re
= r
'^(.*?)(:|=|$)'
88 # Matches from : to = or until end of string
89 ip_re
= r
':(.*?)(=|$)'
90 # Matches from = to end of string
94 host_spec
= cls('', '', '')
96 match_host
= re
.search(host_re
, host
)
98 host_spec
= host_spec
._replace
(hostname
=match_host
.group(1))
100 name_match
= re
.search(name_re
, host
)
102 host_spec
= host_spec
._replace
(name
=name_match
.group(1))
104 ip_match
= re
.search(ip_re
, host
)
106 host_spec
= host_spec
._replace
(network
=ip_match
.group(1))
108 if not require_network
:
111 from ipaddress
import ip_network
, ip_address
112 networks
= list() # type: List[str]
113 network
= host_spec
.network
114 # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478]
116 networks
= [x
for x
in network
.split(',')]
119 networks
.append(network
)
121 for network
in networks
:
122 # only if we have versioned network configs
123 if network
.startswith('v') or network
.startswith('[v'):
124 # if this is ipv6 we can't just simply split on ':' so do
125 # a split once and rsplit once to leave us with just ipv6 addr
126 network
= network
.split(':', 1)[1]
127 network
= network
.rsplit(':', 1)[0]
129 # if subnets are defined, also verify the validity
131 ip_network(six
.text_type(network
))
133 ip_address(unwrap_ipv6(network
))
134 except ValueError as e
:
141 assert_valid_host(self
.hostname
)
144 class PlacementSpec(object):
146 For APIs that need to specify a host subset
150 label
=None, # type: Optional[str]
151 hosts
=None, # type: Union[List[str],List[HostPlacementSpec]]
152 count
=None, # type: Optional[int]
153 host_pattern
=None # type: Optional[str]
155 # type: (...) -> None
157 self
.hosts
= [] # type: List[HostPlacementSpec]
160 self
.set_hosts(hosts
)
162 self
.count
= count
# type: Optional[int]
164 #: fnmatch patterns to select hosts. Can also be a single host.
165 self
.host_pattern
= host_pattern
# type: Optional[str]
170 return self
.label
is None and \
172 not self
.host_pattern
and \
175 def __eq__(self
, other
):
176 if isinstance(other
, PlacementSpec
):
177 return self
.label
== other
.label \
178 and self
.hosts
== other
.hosts \
179 and self
.count
== other
.count \
180 and self
.host_pattern
== other
.host_pattern
181 return NotImplemented
183 def set_hosts(self
, hosts
):
184 # To backpopulate the .hosts attribute when using labels or count
185 # in the orchestrator backend.
186 if all([isinstance(host
, HostPlacementSpec
) for host
in hosts
]):
187 self
.hosts
= hosts
# type: ignore
189 self
.hosts
= [HostPlacementSpec
.parse(x
, require_network
=False) # type: ignore
193 def filter_matching_hosts(self
, _get_hosts_func
: Callable
) -> List
[str]:
194 return self
.filter_matching_hostspecs(_get_hosts_func(as_hostspec
=True))
196 def filter_matching_hostspecs(self
, hostspecs
: Iterable
[HostSpec
]) -> List
[str]:
198 all_hosts
= [hs
.hostname
for hs
in hostspecs
]
199 return [h
.hostname
for h
in self
.hosts
if h
.hostname
in all_hosts
]
201 return [hs
.hostname
for hs
in hostspecs
if self
.label
in hs
.labels
]
202 elif self
.host_pattern
:
203 all_hosts
= [hs
.hostname
for hs
in hostspecs
]
204 return fnmatch
.filter(all_hosts
, self
.host_pattern
)
206 # This should be caught by the validation but needs to be here for
207 # get_host_selection_size
210 def get_host_selection_size(self
, hostspecs
: Iterable
[HostSpec
]):
213 return len(self
.filter_matching_hostspecs(hostspecs
))
215 def pretty_str(self
):
218 ... ps = PlacementSpec(...) # For all placement specs:
219 ... PlacementSpec.from_string(ps.pretty_str()) == ps
223 kv
.append(';'.join([str(h
) for h
in self
.hosts
]))
225 kv
.append('count:%d' % self
.count
)
227 kv
.append('label:%s' % self
.label
)
228 if self
.host_pattern
:
229 kv
.append(self
.host_pattern
)
235 kv
.append('count=%d' % self
.count
)
237 kv
.append('label=%s' % repr(self
.label
))
239 kv
.append('hosts={!r}'.format(self
.hosts
))
240 if self
.host_pattern
:
241 kv
.append('host_pattern={!r}'.format(self
.host_pattern
))
242 return "PlacementSpec(%s)" % ', '.join(kv
)
246 def from_json(cls
, data
):
248 hosts
= c
.get('hosts', [])
252 c
['hosts'].append(HostPlacementSpec
.from_json(host
))
260 r
['label'] = self
.label
262 r
['hosts'] = [host
.to_json() for host
in self
.hosts
]
264 r
['count'] = self
.count
265 if self
.host_pattern
:
266 r
['host_pattern'] = self
.host_pattern
270 if self
.hosts
and self
.label
:
271 # TODO: a less generic Exception
272 raise ServiceSpecValidationError('Host and label are mutually exclusive')
273 if self
.count
is not None and self
.count
<= 0:
274 raise ServiceSpecValidationError("num/count must be > 1")
275 if self
.host_pattern
and self
.hosts
:
276 raise ServiceSpecValidationError('cannot combine host patterns and hosts')
281 def from_string(cls
, arg
):
282 # type: (Optional[str]) -> PlacementSpec
284 A single integer is parsed as a count:
285 >>> PlacementSpec.from_string('3')
286 PlacementSpec(count=3)
288 A list of names is parsed as host specifications:
289 >>> PlacementSpec.from_string('host1 host2')
290 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
291 tSpec(hostname='host2', network='', name='')])
293 You can also prefix the hosts with a count as follows:
294 >>> PlacementSpec.from_string('2 host1 host2')
295 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
296 tPlacementSpec(hostname='host2', network='', name='')])
298 You can spefify labels using `label:<label>`
299 >>> PlacementSpec.from_string('label:mon')
300 PlacementSpec(label='mon')
302 Labels als support a count:
303 >>> PlacementSpec.from_string('3 label:mon')
304 PlacementSpec(count=3, label='mon')
306 fnmatch is also supported:
307 >>> PlacementSpec.from_string('data[1-3]')
308 PlacementSpec(host_pattern='data[1-3]')
310 >>> PlacementSpec.from_string(None)
313 if arg
is None or not arg
:
315 elif isinstance(arg
, str):
317 strings
= arg
.split(' ')
319 strings
= arg
.split(';')
320 elif ',' in arg
and '[' not in arg
:
321 # FIXME: this isn't quite right. we want to avoid breaking
322 # a list of mons with addrvecs... so we're basically allowing
323 # , most of the time, except when addrvecs are used. maybe
325 strings
= arg
.split(',')
329 raise ServiceSpecValidationError('invalid placement %s' % arg
)
334 count
= int(strings
[0])
335 strings
= strings
[1:]
339 if s
.startswith('count:'):
347 advanced_hostspecs
= [h
for h
in strings
if
348 (':' in h
or '=' in h
or not any(c
in '[]?*:=' for c
in h
)) and
350 for a_h
in advanced_hostspecs
:
353 labels
= [x
for x
in strings
if 'label:' in x
]
355 raise ServiceSpecValidationError('more than one label provided: {}'.format(labels
))
358 label
= labels
[0][6:] if labels
else None
360 host_patterns
= strings
361 if len(host_patterns
) > 1:
362 raise ServiceSpecValidationError(
363 'more than one host pattern provided: {}'.format(host_patterns
))
365 ps
= PlacementSpec(count
=count
,
366 hosts
=advanced_hostspecs
,
368 host_pattern
=host_patterns
[0] if host_patterns
else None)
372 class ServiceSpec(object):
374 Details of service creation.
376 Request to the orchestrator for a cluster of daemons
377 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
379 This structure is supposed to be enough information to
383 KNOWN_SERVICE_TYPES
= 'alertmanager crash grafana iscsi mds mgr mon nfs ' \
384 'node-exporter osd prometheus rbd-mirror rgw ' \
386 REQUIRES_SERVICE_ID
= 'iscsi mds nfs osd rgw container'.split()
389 def _cls(cls
, service_type
):
390 from ceph
.deployment
.drive_group
import DriveGroupSpec
394 'nfs': NFSServiceSpec
,
395 'osd': DriveGroupSpec
,
396 'iscsi': IscsiServiceSpec
,
397 'alertmanager': AlertManagerSpec
,
398 'container': CustomContainerSpec
,
399 }.get(service_type
, cls
)
400 if ret
== ServiceSpec
and not service_type
:
401 raise ServiceSpecValidationError('Spec needs a "service_type" key.')
404 def __new__(cls
, *args
, **kwargs
):
406 Some Python foo to make sure, we don't have an object
407 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
409 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
413 if cls
!= ServiceSpec
:
414 return object.__new
__(cls
)
415 service_type
= kwargs
.get('service_type', args
[0] if args
else None)
416 sub_cls
= cls
._cls
(service_type
)
417 return object.__new
__(sub_cls
)
421 service_id
: Optional
[str] = None,
422 placement
: Optional
[PlacementSpec
] = None,
423 count
: Optional
[int] = None,
424 unmanaged
: bool = False,
425 preview_only
: bool = False,
427 self
.placement
= PlacementSpec() if placement
is None else placement
# type: PlacementSpec
429 assert service_type
in ServiceSpec
.KNOWN_SERVICE_TYPES
, service_type
430 self
.service_type
= service_type
431 self
.service_id
= None
432 if self
.service_type
in self
.REQUIRES_SERVICE_ID
:
433 self
.service_id
= service_id
434 self
.unmanaged
= unmanaged
435 self
.preview_only
= preview_only
439 def from_json(cls
, json_spec
):
440 # type: (dict) -> Any
442 # >>> ServiceSpecs = TypeVar('Base', bound=ServiceSpec)
443 # then, the real type is: (dict) -> ServiceSpecs
445 Initialize 'ServiceSpec' object data from a json structure
447 There are two valid styles for service specs:
468 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
469 prefer the new style as it is more readable and provides a better
470 understanding of what fields are special for a give service type.
472 Note, we'll need to stay compatible with both versions for the
473 the next two major releases (octoups, pacific).
475 :param json_spec: A valid dict with ServiceSpec
480 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
481 # Open question: Remove `service_id` form to_json?
482 if c
.get('service_name', ''):
483 service_type_id
= c
['service_name'].split('.', 1)
485 if not c
.get('service_type', ''):
486 c
['service_type'] = service_type_id
[0]
487 if not c
.get('service_id', '') and len(service_type_id
) > 1:
488 c
['service_id'] = service_type_id
[1]
489 del c
['service_name']
491 service_type
= c
.get('service_type', '')
492 _cls
= cls
._cls
(service_type
)
495 del c
['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
497 return _cls
._from
_json
_impl
(c
) # type: ignore
500 def _from_json_impl(cls
, json_spec
):
501 args
= {} # type: Dict[str, Dict[Any, Any]]
502 for k
, v
in json_spec
.items():
504 v
= PlacementSpec
.from_json(v
)
513 def service_name(self
):
514 n
= self
.service_type
516 n
+= '.' + self
.service_id
520 # type: () -> OrderedDict[str, Any]
521 ret
: OrderedDict
[str, Any
] = OrderedDict()
522 ret
['service_type'] = self
.service_type
524 ret
['service_id'] = self
.service_id
525 ret
['service_name'] = self
.service_name()
526 ret
['placement'] = self
.placement
.to_json()
528 ret
['unmanaged'] = self
.unmanaged
531 for key
, val
in sorted(self
.__dict
__.items(), key
=lambda tpl
: tpl
[0]):
534 if hasattr(val
, 'to_json'):
543 if not self
.service_type
:
544 raise ServiceSpecValidationError('Cannot add Service: type required')
546 if self
.service_type
in self
.REQUIRES_SERVICE_ID
:
547 if not self
.service_id
:
548 raise ServiceSpecValidationError('Cannot add Service: id required')
549 elif self
.service_id
:
550 raise ServiceSpecValidationError(
551 f
'Service of type \'{self.service_type}\' should not contain a service id')
553 if self
.placement
is not None:
554 self
.placement
.validate()
557 return "{}({!r})".format(self
.__class
__.__name
__, self
.__dict
__)
559 def __eq__(self
, other
):
560 return (self
.__class
__ == other
.__class
__
562 self
.__dict
__ == other
.__dict
__)
564 def one_line_str(self
):
565 return '<{} for service_name={}>'.format(self
.__class
__.__name
__, self
.service_name())
568 def yaml_representer(dumper
: 'yaml.SafeDumper', data
: 'ServiceSpec'):
569 return dumper
.represent_dict(data
.to_json().items())
572 yaml
.add_representer(ServiceSpec
, ServiceSpec
.yaml_representer
)
575 class NFSServiceSpec(ServiceSpec
):
577 service_type
: str = 'nfs',
578 service_id
: Optional
[str] = None,
579 pool
: Optional
[str] = None,
580 namespace
: Optional
[str] = None,
581 placement
: Optional
[PlacementSpec
] = None,
582 unmanaged
: bool = False,
583 preview_only
: bool = False
585 assert service_type
== 'nfs'
586 super(NFSServiceSpec
, self
).__init
__(
587 'nfs', service_id
=service_id
,
588 placement
=placement
, unmanaged
=unmanaged
, preview_only
=preview_only
)
590 #: RADOS pool where NFS client recovery data is stored.
593 #: RADOS namespace where NFS client recovery data is stored in the pool.
594 self
.namespace
= namespace
597 super(NFSServiceSpec
, self
).validate()
600 raise ServiceSpecValidationError(
601 'Cannot add NFS: No Pool specified')
603 def rados_config_name(self
):
605 return 'conf-' + self
.service_name()
607 def rados_config_location(self
):
611 url
+= 'rados://' + self
.pool
+ '/'
613 url
+= self
.namespace
+ '/'
614 url
+= self
.rados_config_name()
618 yaml
.add_representer(NFSServiceSpec
, ServiceSpec
.yaml_representer
)
621 class RGWSpec(ServiceSpec
):
623 Settings to configure a (multisite) Ceph RGW
627 service_type
: str = 'rgw',
628 service_id
: Optional
[str] = None,
629 placement
: Optional
[PlacementSpec
] = None,
630 rgw_realm
: Optional
[str] = None,
631 rgw_zone
: Optional
[str] = None,
632 subcluster
: Optional
[str] = None,
633 rgw_frontend_port
: Optional
[int] = None,
634 rgw_frontend_ssl_certificate
: Optional
[List
[str]] = None,
635 rgw_frontend_ssl_key
: Optional
[List
[str]] = None,
636 unmanaged
: bool = False,
638 preview_only
: bool = False,
640 assert service_type
== 'rgw', service_type
642 a
= service_id
.split('.', 2)
650 service_id
= '%s.%s.%s' % (rgw_realm
, rgw_zone
, subcluster
)
652 service_id
= '%s.%s' % (rgw_realm
, rgw_zone
)
653 super(RGWSpec
, self
).__init
__(
654 'rgw', service_id
=service_id
,
655 placement
=placement
, unmanaged
=unmanaged
,
656 preview_only
=preview_only
)
658 self
.rgw_realm
= rgw_realm
659 self
.rgw_zone
= rgw_zone
660 self
.subcluster
= subcluster
661 self
.rgw_frontend_port
= rgw_frontend_port
662 self
.rgw_frontend_ssl_certificate
= rgw_frontend_ssl_certificate
663 self
.rgw_frontend_ssl_key
= rgw_frontend_ssl_key
667 if self
.rgw_frontend_port
:
668 return self
.rgw_frontend_port
674 def rgw_frontends_config_value(self
):
677 ports
.append(f
"ssl_port={self.get_port()}")
678 ports
.append(f
"ssl_certificate=config://rgw/cert/{self.rgw_realm}/{self.rgw_zone}.crt")
679 ports
.append(f
"ssl_key=config://rgw/cert/{self.rgw_realm}/{self.rgw_zone}.key")
681 ports
.append(f
"port={self.get_port()}")
682 return f
'beast {" ".join(ports)}'
685 super(RGWSpec
, self
).validate()
687 if not self
.rgw_realm
:
688 raise ServiceSpecValidationError(
689 'Cannot add RGW: No realm specified')
690 if not self
.rgw_zone
:
691 raise ServiceSpecValidationError(
692 'Cannot add RGW: No zone specified')
695 yaml
.add_representer(RGWSpec
, ServiceSpec
.yaml_representer
)
698 class IscsiServiceSpec(ServiceSpec
):
700 service_type
: str = 'iscsi',
701 service_id
: Optional
[str] = None,
702 pool
: Optional
[str] = None,
703 trusted_ip_list
: Optional
[str] = None,
704 api_port
: Optional
[int] = None,
705 api_user
: Optional
[str] = None,
706 api_password
: Optional
[str] = None,
707 api_secure
: Optional
[bool] = None,
708 ssl_cert
: Optional
[str] = None,
709 ssl_key
: Optional
[str] = None,
710 placement
: Optional
[PlacementSpec
] = None,
711 unmanaged
: bool = False,
712 preview_only
: bool = False
714 assert service_type
== 'iscsi'
715 super(IscsiServiceSpec
, self
).__init
__('iscsi', service_id
=service_id
,
716 placement
=placement
, unmanaged
=unmanaged
,
717 preview_only
=preview_only
)
719 #: RADOS pool where ceph-iscsi config data is stored.
721 self
.trusted_ip_list
= trusted_ip_list
722 self
.api_port
= api_port
723 self
.api_user
= api_user
724 self
.api_password
= api_password
725 self
.api_secure
= api_secure
726 self
.ssl_cert
= ssl_cert
727 self
.ssl_key
= ssl_key
729 if not self
.api_secure
and self
.ssl_cert
and self
.ssl_key
:
730 self
.api_secure
= True
733 super(IscsiServiceSpec
, self
).validate()
736 raise ServiceSpecValidationError(
737 'Cannot add ISCSI: No Pool specified')
739 # Do not need to check for api_user and api_password as they
740 # now default to 'admin' when setting up the gateway url. Older
741 # iSCSI specs from before this change should be fine as they will
742 # have been required to have an api_user and api_password set and
743 # will be unaffected by the new default value.
746 yaml
.add_representer(IscsiServiceSpec
, ServiceSpec
.yaml_representer
)
749 class AlertManagerSpec(ServiceSpec
):
751 service_type
: str = 'alertmanager',
752 service_id
: Optional
[str] = None,
753 placement
: Optional
[PlacementSpec
] = None,
754 unmanaged
: bool = False,
755 preview_only
: bool = False,
756 user_data
: Optional
[Dict
[str, Any
]] = None,
758 assert service_type
== 'alertmanager'
759 super(AlertManagerSpec
, self
).__init
__(
760 'alertmanager', service_id
=service_id
,
761 placement
=placement
, unmanaged
=unmanaged
,
762 preview_only
=preview_only
)
764 # Custom configuration.
767 # service_type: alertmanager
770 # default_webhook_urls:
775 # default_webhook_urls - A list of additional URL's that are
776 # added to the default receivers'
777 # <webhook_configs> configuration.
778 self
.user_data
= user_data
or {}
781 yaml
.add_representer(AlertManagerSpec
, ServiceSpec
.yaml_representer
)
784 class CustomContainerSpec(ServiceSpec
):
786 service_type
: str = 'container',
787 service_id
: str = None,
788 placement
: Optional
[PlacementSpec
] = None,
789 unmanaged
: bool = False,
790 preview_only
: bool = False,
792 entrypoint
: Optional
[str] = None,
793 uid
: Optional
[int] = None,
794 gid
: Optional
[int] = None,
795 volume_mounts
: Optional
[Dict
[str, str]] = {},
796 args
: Optional
[List
[str]] = [],
797 envs
: Optional
[List
[str]] = [],
798 privileged
: Optional
[bool] = False,
799 bind_mounts
: Optional
[List
[List
[str]]] = None,
800 ports
: Optional
[List
[int]] = [],
801 dirs
: Optional
[List
[str]] = [],
802 files
: Optional
[Dict
[str, Any
]] = {},
804 assert service_type
== 'container'
805 assert service_id
is not None
806 assert image
is not None
808 super(CustomContainerSpec
, self
).__init
__(
809 service_type
, service_id
,
810 placement
=placement
, unmanaged
=unmanaged
,
811 preview_only
=preview_only
)
814 self
.entrypoint
= entrypoint
817 self
.volume_mounts
= volume_mounts
820 self
.privileged
= privileged
821 self
.bind_mounts
= bind_mounts
826 def config_json(self
) -> Dict
[str, Any
]:
828 Helper function to get the value of the `--config-json` cephadm
829 command line option. It will contain all specification properties
830 that haven't a `None` value. Such properties will get default
832 :return: Returns a dictionary containing all specification
836 for prop
in ['image', 'entrypoint', 'uid', 'gid', 'args',
837 'envs', 'volume_mounts', 'privileged',
838 'bind_mounts', 'ports', 'dirs', 'files']:
839 value
= getattr(self
, prop
)
840 if value
is not None:
841 config_json
[prop
] = value
845 yaml
.add_representer(CustomContainerSpec
, ServiceSpec
.yaml_representer
)