]> git.proxmox.com Git - ceph.git/blob - ceph/src/python-common/ceph/deployment/service_spec.py
import quincy beta 17.1.0
[ceph.git] / ceph / src / python-common / ceph / deployment / service_spec.py
1 import fnmatch
2 import re
3 import enum
4 from collections import OrderedDict
5 from contextlib import contextmanager
6 from functools import wraps
7 from ipaddress import ip_network, ip_address
8 from typing import Optional, Dict, Any, List, Union, Callable, Iterable, Type, TypeVar, cast, \
9 NamedTuple, Mapping, Iterator
10
11 import yaml
12
13 from ceph.deployment.hostspec import HostSpec, SpecValidationError, assert_valid_host
14 from ceph.deployment.utils import unwrap_ipv6, valid_addr
15 from ceph.utils import is_hex
16
17 ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec')
18 FuncT = TypeVar('FuncT', bound=Callable)
19
20
21 def handle_type_error(method: FuncT) -> FuncT:
22 @wraps(method)
23 def inner(cls: Any, *args: Any, **kwargs: Any) -> Any:
24 try:
25 return method(cls, *args, **kwargs)
26 except (TypeError, AttributeError) as e:
27 error_msg = '{}: {}'.format(cls.__name__, e)
28 raise SpecValidationError(error_msg)
29 return cast(FuncT, inner)
30
31
32 class HostPlacementSpec(NamedTuple):
33 hostname: str
34 network: str
35 name: str
36
37 def __str__(self) -> str:
38 res = ''
39 res += self.hostname
40 if self.network:
41 res += ':' + self.network
42 if self.name:
43 res += '=' + self.name
44 return res
45
46 @classmethod
47 @handle_type_error
48 def from_json(cls, data: Union[dict, str]) -> 'HostPlacementSpec':
49 if isinstance(data, str):
50 return cls.parse(data)
51 return cls(**data)
52
53 def to_json(self) -> str:
54 return str(self)
55
56 @classmethod
57 def parse(cls, host, require_network=True):
58 # type: (str, bool) -> HostPlacementSpec
59 """
60 Split host into host, network, and (optional) daemon name parts. The network
61 part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'.
62 e.g.,
63 "myhost"
64 "myhost=name"
65 "myhost:1.2.3.4"
66 "myhost:1.2.3.4=name"
67 "myhost:1.2.3.0/24"
68 "myhost:1.2.3.0/24=name"
69 "myhost:[v2:1.2.3.4:3000]=name"
70 "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name"
71 """
72 # Matches from start to : or = or until end of string
73 host_re = r'^(.*?)(:|=|$)'
74 # Matches from : to = or until end of string
75 ip_re = r':(.*?)(=|$)'
76 # Matches from = to end of string
77 name_re = r'=(.*?)$'
78
79 # assign defaults
80 host_spec = cls('', '', '')
81
82 match_host = re.search(host_re, host)
83 if match_host:
84 host_spec = host_spec._replace(hostname=match_host.group(1))
85
86 name_match = re.search(name_re, host)
87 if name_match:
88 host_spec = host_spec._replace(name=name_match.group(1))
89
90 ip_match = re.search(ip_re, host)
91 if ip_match:
92 host_spec = host_spec._replace(network=ip_match.group(1))
93
94 if not require_network:
95 return host_spec
96
97 networks = list() # type: List[str]
98 network = host_spec.network
99 # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478]
100 if ',' in network:
101 networks = [x for x in network.split(',')]
102 else:
103 if network != '':
104 networks.append(network)
105
106 for network in networks:
107 # only if we have versioned network configs
108 if network.startswith('v') or network.startswith('[v'):
109 # if this is ipv6 we can't just simply split on ':' so do
110 # a split once and rsplit once to leave us with just ipv6 addr
111 network = network.split(':', 1)[1]
112 network = network.rsplit(':', 1)[0]
113 try:
114 # if subnets are defined, also verify the validity
115 if '/' in network:
116 ip_network(network)
117 else:
118 ip_address(unwrap_ipv6(network))
119 except ValueError as e:
120 # logging?
121 raise e
122 host_spec.validate()
123 return host_spec
124
125 def validate(self) -> None:
126 assert_valid_host(self.hostname)
127
128
129 class PlacementSpec(object):
130 """
131 For APIs that need to specify a host subset
132 """
133
134 def __init__(self,
135 label=None, # type: Optional[str]
136 hosts=None, # type: Union[List[str],List[HostPlacementSpec], None]
137 count=None, # type: Optional[int]
138 count_per_host=None, # type: Optional[int]
139 host_pattern=None, # type: Optional[str]
140 ):
141 # type: (...) -> None
142 self.label = label
143 self.hosts = [] # type: List[HostPlacementSpec]
144
145 if hosts:
146 self.set_hosts(hosts)
147
148 self.count = count # type: Optional[int]
149 self.count_per_host = count_per_host # type: Optional[int]
150
151 #: fnmatch patterns to select hosts. Can also be a single host.
152 self.host_pattern = host_pattern # type: Optional[str]
153
154 self.validate()
155
156 def is_empty(self) -> bool:
157 return (
158 self.label is None
159 and not self.hosts
160 and not self.host_pattern
161 and self.count is None
162 and self.count_per_host is None
163 )
164
165 def __eq__(self, other: Any) -> bool:
166 if isinstance(other, PlacementSpec):
167 return self.label == other.label \
168 and self.hosts == other.hosts \
169 and self.count == other.count \
170 and self.host_pattern == other.host_pattern \
171 and self.count_per_host == other.count_per_host
172 return NotImplemented
173
174 def set_hosts(self, hosts: Union[List[str], List[HostPlacementSpec]]) -> None:
175 # To backpopulate the .hosts attribute when using labels or count
176 # in the orchestrator backend.
177 if all([isinstance(host, HostPlacementSpec) for host in hosts]):
178 self.hosts = hosts # type: ignore
179 else:
180 self.hosts = [HostPlacementSpec.parse(x, require_network=False) # type: ignore
181 for x in hosts if x]
182
183 # deprecated
184 def filter_matching_hosts(self, _get_hosts_func: Callable) -> List[str]:
185 return self.filter_matching_hostspecs(_get_hosts_func(as_hostspec=True))
186
187 def filter_matching_hostspecs(self, hostspecs: Iterable[HostSpec]) -> List[str]:
188 if self.hosts:
189 all_hosts = [hs.hostname for hs in hostspecs]
190 return [h.hostname for h in self.hosts if h.hostname in all_hosts]
191 if self.label:
192 return [hs.hostname for hs in hostspecs if self.label in hs.labels]
193 all_hosts = [hs.hostname for hs in hostspecs]
194 if self.host_pattern:
195 return fnmatch.filter(all_hosts, self.host_pattern)
196 return all_hosts
197
198 def get_target_count(self, hostspecs: Iterable[HostSpec]) -> int:
199 if self.count:
200 return self.count
201 return len(self.filter_matching_hostspecs(hostspecs)) * (self.count_per_host or 1)
202
203 def pretty_str(self) -> str:
204 """
205 >>> #doctest: +SKIP
206 ... ps = PlacementSpec(...) # For all placement specs:
207 ... PlacementSpec.from_string(ps.pretty_str()) == ps
208 """
209 kv = []
210 if self.hosts:
211 kv.append(';'.join([str(h) for h in self.hosts]))
212 if self.count:
213 kv.append('count:%d' % self.count)
214 if self.count_per_host:
215 kv.append('count-per-host:%d' % self.count_per_host)
216 if self.label:
217 kv.append('label:%s' % self.label)
218 if self.host_pattern:
219 kv.append(self.host_pattern)
220 return ';'.join(kv)
221
222 def __repr__(self) -> str:
223 kv = []
224 if self.count:
225 kv.append('count=%d' % self.count)
226 if self.count_per_host:
227 kv.append('count_per_host=%d' % self.count_per_host)
228 if self.label:
229 kv.append('label=%s' % repr(self.label))
230 if self.hosts:
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)
235
236 @classmethod
237 @handle_type_error
238 def from_json(cls, data: dict) -> 'PlacementSpec':
239 c = data.copy()
240 hosts = c.get('hosts', [])
241 if hosts:
242 c['hosts'] = []
243 for host in hosts:
244 c['hosts'].append(HostPlacementSpec.from_json(host))
245 _cls = cls(**c)
246 _cls.validate()
247 return _cls
248
249 def to_json(self) -> dict:
250 r: Dict[str, Any] = {}
251 if self.label:
252 r['label'] = self.label
253 if self.hosts:
254 r['hosts'] = [host.to_json() for host in self.hosts]
255 if self.count:
256 r['count'] = self.count
257 if self.count_per_host:
258 r['count_per_host'] = self.count_per_host
259 if self.host_pattern:
260 r['host_pattern'] = self.host_pattern
261 return r
262
263 def validate(self) -> None:
264 if self.hosts and self.label:
265 # TODO: a less generic Exception
266 raise SpecValidationError('Host and label are mutually exclusive')
267 if self.count is not None:
268 try:
269 intval = int(self.count)
270 except (ValueError, TypeError):
271 raise SpecValidationError("num/count must be a numeric value")
272 if self.count != intval:
273 raise SpecValidationError("num/count must be an integer value")
274 if self.count < 1:
275 raise SpecValidationError("num/count must be >= 1")
276 if self.count_per_host is not None:
277 try:
278 intval = int(self.count_per_host)
279 except (ValueError, TypeError):
280 raise SpecValidationError("count-per-host must be a numeric value")
281 if self.count_per_host != intval:
282 raise SpecValidationError("count-per-host must be an integer value")
283 if self.count_per_host < 1:
284 raise SpecValidationError("count-per-host must be >= 1")
285 if self.count_per_host is not None and not (
286 self.label
287 or self.hosts
288 or self.host_pattern
289 ):
290 raise SpecValidationError(
291 "count-per-host must be combined with label or hosts or host_pattern"
292 )
293 if self.count is not None and self.count_per_host is not None:
294 raise SpecValidationError("cannot combine count and count-per-host")
295 if (
296 self.count_per_host is not None
297 and self.hosts
298 and any([hs.network or hs.name for hs in self.hosts])
299 ):
300 raise SpecValidationError(
301 "count-per-host cannot be combined explicit placement with names or networks"
302 )
303 if self.host_pattern:
304 if not isinstance(self.host_pattern, str):
305 raise SpecValidationError('host_pattern must be of type string')
306 if self.hosts:
307 raise SpecValidationError('cannot combine host patterns and hosts')
308
309 for h in self.hosts:
310 h.validate()
311
312 @classmethod
313 def from_string(cls, arg):
314 # type: (Optional[str]) -> PlacementSpec
315 """
316 A single integer is parsed as a count:
317 >>> PlacementSpec.from_string('3')
318 PlacementSpec(count=3)
319
320 A list of names is parsed as host specifications:
321 >>> PlacementSpec.from_string('host1 host2')
322 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
323 tSpec(hostname='host2', network='', name='')])
324
325 You can also prefix the hosts with a count as follows:
326 >>> PlacementSpec.from_string('2 host1 host2')
327 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
328 tPlacementSpec(hostname='host2', network='', name='')])
329
330 You can spefify labels using `label:<label>`
331 >>> PlacementSpec.from_string('label:mon')
332 PlacementSpec(label='mon')
333
334 Labels als support a count:
335 >>> PlacementSpec.from_string('3 label:mon')
336 PlacementSpec(count=3, label='mon')
337
338 fnmatch is also supported:
339 >>> PlacementSpec.from_string('data[1-3]')
340 PlacementSpec(host_pattern='data[1-3]')
341
342 >>> PlacementSpec.from_string(None)
343 PlacementSpec()
344 """
345 if arg is None or not arg:
346 strings = []
347 elif isinstance(arg, str):
348 if ' ' in arg:
349 strings = arg.split(' ')
350 elif ';' in arg:
351 strings = arg.split(';')
352 elif ',' in arg and '[' not in arg:
353 # FIXME: this isn't quite right. we want to avoid breaking
354 # a list of mons with addrvecs... so we're basically allowing
355 # , most of the time, except when addrvecs are used. maybe
356 # ok?
357 strings = arg.split(',')
358 else:
359 strings = [arg]
360 else:
361 raise SpecValidationError('invalid placement %s' % arg)
362
363 count = None
364 count_per_host = None
365 if strings:
366 try:
367 count = int(strings[0])
368 strings = strings[1:]
369 except ValueError:
370 pass
371 for s in strings:
372 if s.startswith('count:'):
373 try:
374 count = int(s[len('count:'):])
375 strings.remove(s)
376 break
377 except ValueError:
378 pass
379 for s in strings:
380 if s.startswith('count-per-host:'):
381 try:
382 count_per_host = int(s[len('count-per-host:'):])
383 strings.remove(s)
384 break
385 except ValueError:
386 pass
387
388 advanced_hostspecs = [h for h in strings if
389 (':' in h or '=' in h or not any(c in '[]?*:=' for c in h)) and
390 'label:' not in h]
391 for a_h in advanced_hostspecs:
392 strings.remove(a_h)
393
394 labels = [x for x in strings if 'label:' in x]
395 if len(labels) > 1:
396 raise SpecValidationError('more than one label provided: {}'.format(labels))
397 for l in labels:
398 strings.remove(l)
399 label = labels[0][6:] if labels else None
400
401 host_patterns = strings
402 if len(host_patterns) > 1:
403 raise SpecValidationError(
404 'more than one host pattern provided: {}'.format(host_patterns))
405
406 ps = PlacementSpec(count=count,
407 count_per_host=count_per_host,
408 hosts=advanced_hostspecs,
409 label=label,
410 host_pattern=host_patterns[0] if host_patterns else None)
411 return ps
412
413
414 _service_spec_from_json_validate = True
415
416
417 @contextmanager
418 def service_spec_allow_invalid_from_json() -> Iterator[None]:
419 """
420 I know this is evil, but unfortunately `ceph orch ls`
421 may return invalid OSD specs for OSDs not associated to
422 and specs. If you have a better idea, please!
423 """
424 global _service_spec_from_json_validate
425 _service_spec_from_json_validate = False
426 yield
427 _service_spec_from_json_validate = True
428
429
430 class ServiceSpec(object):
431 """
432 Details of service creation.
433
434 Request to the orchestrator for a cluster of daemons
435 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
436
437 This structure is supposed to be enough information to
438 start the services.
439 """
440 KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi mds mgr mon nfs ' \
441 'node-exporter osd prometheus rbd-mirror rgw agent ' \
442 'container ingress cephfs-mirror snmp-gateway'.split()
443 REQUIRES_SERVICE_ID = 'iscsi mds nfs rgw container ingress '.split()
444 MANAGED_CONFIG_OPTIONS = [
445 'mds_join_fs',
446 ]
447
448 @classmethod
449 def _cls(cls: Type[ServiceSpecT], service_type: str) -> Type[ServiceSpecT]:
450 from ceph.deployment.drive_group import DriveGroupSpec
451
452 ret = {
453 'rgw': RGWSpec,
454 'nfs': NFSServiceSpec,
455 'osd': DriveGroupSpec,
456 'iscsi': IscsiServiceSpec,
457 'alertmanager': AlertManagerSpec,
458 'ingress': IngressSpec,
459 'container': CustomContainerSpec,
460 'grafana': GrafanaSpec,
461 'node-exporter': MonitoringSpec,
462 'prometheus': MonitoringSpec,
463 'snmp-gateway': SNMPGatewaySpec,
464 }.get(service_type, cls)
465 if ret == ServiceSpec and not service_type:
466 raise SpecValidationError('Spec needs a "service_type" key.')
467 return ret
468
469 def __new__(cls: Type[ServiceSpecT], *args: Any, **kwargs: Any) -> ServiceSpecT:
470 """
471 Some Python foo to make sure, we don't have an object
472 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
473
474 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
475 True
476
477 """
478 if cls != ServiceSpec:
479 return object.__new__(cls)
480 service_type = kwargs.get('service_type', args[0] if args else None)
481 sub_cls: Any = cls._cls(service_type)
482 return object.__new__(sub_cls)
483
484 def __init__(self,
485 service_type: str,
486 service_id: Optional[str] = None,
487 placement: Optional[PlacementSpec] = None,
488 count: Optional[int] = None,
489 config: Optional[Dict[str, str]] = None,
490 unmanaged: bool = False,
491 preview_only: bool = False,
492 networks: Optional[List[str]] = None,
493 extra_container_args: Optional[List[str]] = None,
494 ):
495
496 #: See :ref:`orchestrator-cli-placement-spec`.
497 self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec
498
499 assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type
500 #: The type of the service. Needs to be either a Ceph
501 #: service (``mon``, ``crash``, ``mds``, ``mgr``, ``osd`` or
502 #: ``rbd-mirror``), a gateway (``nfs`` or ``rgw``), part of the
503 #: monitoring stack (``alertmanager``, ``grafana``, ``node-exporter`` or
504 #: ``prometheus``) or (``container``) for custom containers.
505 self.service_type = service_type
506
507 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
508 #: ``container``, ``ingress``
509 self.service_id = None
510
511 if self.service_type in self.REQUIRES_SERVICE_ID or self.service_type == 'osd':
512 self.service_id = service_id
513
514 #: If set to ``true``, the orchestrator will not deploy nor remove
515 #: any daemon associated with this service. Placement and all other properties
516 #: will be ignored. This is useful, if you do not want this service to be
517 #: managed temporarily. For cephadm, See :ref:`cephadm-spec-unmanaged`
518 self.unmanaged = unmanaged
519 self.preview_only = preview_only
520
521 #: A list of network identities instructing the daemons to only bind
522 #: on the particular networks in that list. In case the cluster is distributed
523 #: across multiple networks, you can add multiple networks. See
524 #: :ref:`cephadm-monitoring-networks-ports`,
525 #: :ref:`cephadm-rgw-networks` and :ref:`cephadm-mgr-networks`.
526 self.networks: List[str] = networks or []
527
528 self.config: Optional[Dict[str, str]] = None
529 if config:
530 self.config = {k.replace(' ', '_'): v for k, v in config.items()}
531
532 self.extra_container_args: Optional[List[str]] = extra_container_args
533
534 @classmethod
535 @handle_type_error
536 def from_json(cls: Type[ServiceSpecT], json_spec: Dict) -> ServiceSpecT:
537 """
538 Initialize 'ServiceSpec' object data from a json structure
539
540 There are two valid styles for service specs:
541
542 the "old" style:
543
544 .. code:: yaml
545
546 service_type: nfs
547 service_id: foo
548 pool: mypool
549 namespace: myns
550
551 and the "new" style:
552
553 .. code:: yaml
554
555 service_type: nfs
556 service_id: foo
557 config:
558 some_option: the_value
559 networks: [10.10.0.0/16]
560 spec:
561 pool: mypool
562 namespace: myns
563
564 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
565 prefer the new style as it is more readable and provides a better
566 understanding of what fields are special for a give service type.
567
568 Note, we'll need to stay compatible with both versions for the
569 the next two major releases (octoups, pacific).
570
571 :param json_spec: A valid dict with ServiceSpec
572
573 :meta private:
574 """
575 if not isinstance(json_spec, dict):
576 raise SpecValidationError(
577 f'Service Spec is not an (JSON or YAML) object. got "{str(json_spec)}"')
578
579 json_spec = cls.normalize_json(json_spec)
580
581 c = json_spec.copy()
582
583 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
584 # Open question: Remove `service_id` form to_json?
585 if c.get('service_name', ''):
586 service_type_id = c['service_name'].split('.', 1)
587
588 if not c.get('service_type', ''):
589 c['service_type'] = service_type_id[0]
590 if not c.get('service_id', '') and len(service_type_id) > 1:
591 c['service_id'] = service_type_id[1]
592 del c['service_name']
593
594 service_type = c.get('service_type', '')
595 _cls = cls._cls(service_type)
596
597 if 'status' in c:
598 del c['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
599
600 return _cls._from_json_impl(c) # type: ignore
601
602 @staticmethod
603 def normalize_json(json_spec: dict) -> dict:
604 networks = json_spec.get('networks')
605 if networks is None:
606 return json_spec
607 if isinstance(networks, list):
608 return json_spec
609 if not isinstance(networks, str):
610 raise SpecValidationError(f'Networks ({networks}) must be a string or list of strings')
611 json_spec['networks'] = [networks]
612 return json_spec
613
614 @classmethod
615 def _from_json_impl(cls: Type[ServiceSpecT], json_spec: dict) -> ServiceSpecT:
616 args = {} # type: Dict[str, Any]
617 for k, v in json_spec.items():
618 if k == 'placement':
619 v = PlacementSpec.from_json(v)
620 if k == 'spec':
621 args.update(v)
622 continue
623 args.update({k: v})
624 _cls = cls(**args)
625 if _service_spec_from_json_validate:
626 _cls.validate()
627 return _cls
628
629 def service_name(self) -> str:
630 n = self.service_type
631 if self.service_id:
632 n += '.' + self.service_id
633 return n
634
635 def get_port_start(self) -> List[int]:
636 # If defined, we will allocate and number ports starting at this
637 # point.
638 return []
639
640 def get_virtual_ip(self) -> Optional[str]:
641 return None
642
643 def to_json(self):
644 # type: () -> OrderedDict[str, Any]
645 ret: OrderedDict[str, Any] = OrderedDict()
646 ret['service_type'] = self.service_type
647 if self.service_id:
648 ret['service_id'] = self.service_id
649 ret['service_name'] = self.service_name()
650 if self.placement.to_json():
651 ret['placement'] = self.placement.to_json()
652 if self.unmanaged:
653 ret['unmanaged'] = self.unmanaged
654 if self.networks:
655 ret['networks'] = self.networks
656 if self.extra_container_args:
657 ret['extra_container_args'] = self.extra_container_args
658
659 c = {}
660 for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]):
661 if key in ret:
662 continue
663 if hasattr(val, 'to_json'):
664 val = val.to_json()
665 if val:
666 c[key] = val
667 if c:
668 ret['spec'] = c
669 return ret
670
671 def validate(self) -> None:
672 if not self.service_type:
673 raise SpecValidationError('Cannot add Service: type required')
674
675 if self.service_type != 'osd':
676 if self.service_type in self.REQUIRES_SERVICE_ID and not self.service_id:
677 raise SpecValidationError('Cannot add Service: id required')
678 if self.service_type not in self.REQUIRES_SERVICE_ID and self.service_id:
679 raise SpecValidationError(
680 f'Service of type \'{self.service_type}\' should not contain a service id')
681
682 if self.service_id:
683 if not re.match('^[a-zA-Z0-9_.-]+$', self.service_id):
684 raise SpecValidationError('Service id contains invalid characters, '
685 'only [a-zA-Z0-9_.-] allowed')
686
687 if self.placement is not None:
688 self.placement.validate()
689 if self.config:
690 for k, v in self.config.items():
691 if k in self.MANAGED_CONFIG_OPTIONS:
692 raise SpecValidationError(
693 f'Cannot set config option {k} in spec: it is managed by cephadm'
694 )
695 for network in self.networks or []:
696 try:
697 ip_network(network)
698 except ValueError as e:
699 raise SpecValidationError(
700 f'Cannot parse network {network}: {e}'
701 )
702
703 def __repr__(self) -> str:
704 y = yaml.dump(cast(dict, self), default_flow_style=False)
705 return f"{self.__class__.__name__}.from_json(yaml.safe_load('''{y}'''))"
706
707 def __eq__(self, other: Any) -> bool:
708 return (self.__class__ == other.__class__
709 and
710 self.__dict__ == other.__dict__)
711
712 def one_line_str(self) -> str:
713 return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name())
714
715 @staticmethod
716 def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec') -> Any:
717 return dumper.represent_dict(cast(Mapping, data.to_json().items()))
718
719
720 yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer)
721
722
723 class NFSServiceSpec(ServiceSpec):
724 def __init__(self,
725 service_type: str = 'nfs',
726 service_id: Optional[str] = None,
727 placement: Optional[PlacementSpec] = None,
728 unmanaged: bool = False,
729 preview_only: bool = False,
730 config: Optional[Dict[str, str]] = None,
731 networks: Optional[List[str]] = None,
732 port: Optional[int] = None,
733 ):
734 assert service_type == 'nfs'
735 super(NFSServiceSpec, self).__init__(
736 'nfs', service_id=service_id,
737 placement=placement, unmanaged=unmanaged, preview_only=preview_only,
738 config=config, networks=networks)
739
740 self.port = port
741
742 def get_port_start(self) -> List[int]:
743 if self.port:
744 return [self.port]
745 return []
746
747 def rados_config_name(self):
748 # type: () -> str
749 return 'conf-' + self.service_name()
750
751
752 yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer)
753
754
755 class RGWSpec(ServiceSpec):
756 """
757 Settings to configure a (multisite) Ceph RGW
758
759 .. code-block:: yaml
760
761 service_type: rgw
762 service_id: myrealm.myzone
763 spec:
764 rgw_realm: myrealm
765 rgw_zone: myzone
766 ssl: true
767 rgw_frontend_port: 1234
768 rgw_frontend_type: beast
769 rgw_frontend_ssl_certificate: ...
770
771 See also: :ref:`orchestrator-cli-service-spec`
772 """
773
774 MANAGED_CONFIG_OPTIONS = ServiceSpec.MANAGED_CONFIG_OPTIONS + [
775 'rgw_zone',
776 'rgw_realm',
777 'rgw_frontends',
778 ]
779
780 def __init__(self,
781 service_type: str = 'rgw',
782 service_id: Optional[str] = None,
783 placement: Optional[PlacementSpec] = None,
784 rgw_realm: Optional[str] = None,
785 rgw_zone: Optional[str] = None,
786 rgw_frontend_port: Optional[int] = None,
787 rgw_frontend_ssl_certificate: Optional[List[str]] = None,
788 rgw_frontend_type: Optional[str] = None,
789 unmanaged: bool = False,
790 ssl: bool = False,
791 preview_only: bool = False,
792 config: Optional[Dict[str, str]] = None,
793 networks: Optional[List[str]] = None,
794 subcluster: Optional[str] = None, # legacy, only for from_json on upgrade
795 ):
796 assert service_type == 'rgw', service_type
797
798 # for backward compatibility with octopus spec files,
799 if not service_id and (rgw_realm and rgw_zone):
800 service_id = rgw_realm + '.' + rgw_zone
801
802 super(RGWSpec, self).__init__(
803 'rgw', service_id=service_id,
804 placement=placement, unmanaged=unmanaged,
805 preview_only=preview_only, config=config, networks=networks)
806
807 #: The RGW realm associated with this service. Needs to be manually created
808 self.rgw_realm: Optional[str] = rgw_realm
809 #: The RGW zone associated with this service. Needs to be manually created
810 self.rgw_zone: Optional[str] = rgw_zone
811 #: Port of the RGW daemons
812 self.rgw_frontend_port: Optional[int] = rgw_frontend_port
813 #: List of SSL certificates
814 self.rgw_frontend_ssl_certificate: Optional[List[str]] = rgw_frontend_ssl_certificate
815 #: civetweb or beast (default: beast). See :ref:`rgw_frontends`
816 self.rgw_frontend_type: Optional[str] = rgw_frontend_type
817 #: enable SSL
818 self.ssl = ssl
819
820 def get_port_start(self) -> List[int]:
821 return [self.get_port()]
822
823 def get_port(self) -> int:
824 if self.rgw_frontend_port:
825 return self.rgw_frontend_port
826 if self.ssl:
827 return 443
828 else:
829 return 80
830
831 def validate(self) -> None:
832 super(RGWSpec, self).validate()
833
834 if self.rgw_realm and not self.rgw_zone:
835 raise SpecValidationError(
836 'Cannot add RGW: Realm specified but no zone specified')
837 if self.rgw_zone and not self.rgw_realm:
838 raise SpecValidationError(
839 'Cannot add RGW: Zone specified but no realm specified')
840
841
842 yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer)
843
844
845 class IscsiServiceSpec(ServiceSpec):
846 def __init__(self,
847 service_type: str = 'iscsi',
848 service_id: Optional[str] = None,
849 pool: Optional[str] = None,
850 trusted_ip_list: Optional[str] = None,
851 api_port: Optional[int] = None,
852 api_user: Optional[str] = None,
853 api_password: Optional[str] = None,
854 api_secure: Optional[bool] = None,
855 ssl_cert: Optional[str] = None,
856 ssl_key: Optional[str] = None,
857 placement: Optional[PlacementSpec] = None,
858 unmanaged: bool = False,
859 preview_only: bool = False,
860 config: Optional[Dict[str, str]] = None,
861 networks: Optional[List[str]] = None,
862 ):
863 assert service_type == 'iscsi'
864 super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id,
865 placement=placement, unmanaged=unmanaged,
866 preview_only=preview_only,
867 config=config, networks=networks)
868
869 #: RADOS pool where ceph-iscsi config data is stored.
870 self.pool = pool
871 #: list of trusted IP addresses
872 self.trusted_ip_list = trusted_ip_list
873 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
874 self.api_port = api_port
875 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
876 self.api_user = api_user
877 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
878 self.api_password = api_password
879 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
880 self.api_secure = api_secure
881 #: SSL certificate
882 self.ssl_cert = ssl_cert
883 #: SSL private key
884 self.ssl_key = ssl_key
885
886 if not self.api_secure and self.ssl_cert and self.ssl_key:
887 self.api_secure = True
888
889 def validate(self) -> None:
890 super(IscsiServiceSpec, self).validate()
891
892 if not self.pool:
893 raise SpecValidationError(
894 'Cannot add ISCSI: No Pool specified')
895
896 # Do not need to check for api_user and api_password as they
897 # now default to 'admin' when setting up the gateway url. Older
898 # iSCSI specs from before this change should be fine as they will
899 # have been required to have an api_user and api_password set and
900 # will be unaffected by the new default value.
901
902
903 yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer)
904
905
906 class IngressSpec(ServiceSpec):
907 def __init__(self,
908 service_type: str = 'ingress',
909 service_id: Optional[str] = None,
910 config: Optional[Dict[str, str]] = None,
911 networks: Optional[List[str]] = None,
912 placement: Optional[PlacementSpec] = None,
913 backend_service: Optional[str] = None,
914 frontend_port: Optional[int] = None,
915 ssl_cert: Optional[str] = None,
916 ssl_key: Optional[str] = None,
917 ssl_dh_param: Optional[str] = None,
918 ssl_ciphers: Optional[List[str]] = None,
919 ssl_options: Optional[List[str]] = None,
920 monitor_port: Optional[int] = None,
921 monitor_user: Optional[str] = None,
922 monitor_password: Optional[str] = None,
923 enable_stats: Optional[bool] = None,
924 keepalived_password: Optional[str] = None,
925 virtual_ip: Optional[str] = None,
926 virtual_interface_networks: Optional[List[str]] = [],
927 unmanaged: bool = False,
928 ssl: bool = False
929 ):
930 assert service_type == 'ingress'
931 super(IngressSpec, self).__init__(
932 'ingress', service_id=service_id,
933 placement=placement, config=config,
934 networks=networks
935 )
936 self.backend_service = backend_service
937 self.frontend_port = frontend_port
938 self.ssl_cert = ssl_cert
939 self.ssl_key = ssl_key
940 self.ssl_dh_param = ssl_dh_param
941 self.ssl_ciphers = ssl_ciphers
942 self.ssl_options = ssl_options
943 self.monitor_port = monitor_port
944 self.monitor_user = monitor_user
945 self.monitor_password = monitor_password
946 self.keepalived_password = keepalived_password
947 self.virtual_ip = virtual_ip
948 self.virtual_interface_networks = virtual_interface_networks or []
949 self.unmanaged = unmanaged
950 self.ssl = ssl
951
952 def get_port_start(self) -> List[int]:
953 return [cast(int, self.frontend_port),
954 cast(int, self.monitor_port)]
955
956 def get_virtual_ip(self) -> Optional[str]:
957 return self.virtual_ip
958
959 def validate(self) -> None:
960 super(IngressSpec, self).validate()
961
962 if not self.backend_service:
963 raise SpecValidationError(
964 'Cannot add ingress: No backend_service specified')
965 if not self.frontend_port:
966 raise SpecValidationError(
967 'Cannot add ingress: No frontend_port specified')
968 if not self.monitor_port:
969 raise SpecValidationError(
970 'Cannot add ingress: No monitor_port specified')
971 if not self.virtual_ip:
972 raise SpecValidationError(
973 'Cannot add ingress: No virtual_ip provided')
974
975
976 yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer)
977
978
979 class CustomContainerSpec(ServiceSpec):
980 def __init__(self,
981 service_type: str = 'container',
982 service_id: Optional[str] = None,
983 config: Optional[Dict[str, str]] = None,
984 networks: Optional[List[str]] = None,
985 placement: Optional[PlacementSpec] = None,
986 unmanaged: bool = False,
987 preview_only: bool = False,
988 image: Optional[str] = None,
989 entrypoint: Optional[str] = None,
990 uid: Optional[int] = None,
991 gid: Optional[int] = None,
992 volume_mounts: Optional[Dict[str, str]] = {},
993 args: Optional[List[str]] = [],
994 envs: Optional[List[str]] = [],
995 privileged: Optional[bool] = False,
996 bind_mounts: Optional[List[List[str]]] = None,
997 ports: Optional[List[int]] = [],
998 dirs: Optional[List[str]] = [],
999 files: Optional[Dict[str, Any]] = {},
1000 ):
1001 assert service_type == 'container'
1002 assert service_id is not None
1003 assert image is not None
1004
1005 super(CustomContainerSpec, self).__init__(
1006 service_type, service_id,
1007 placement=placement, unmanaged=unmanaged,
1008 preview_only=preview_only, config=config,
1009 networks=networks)
1010
1011 self.image = image
1012 self.entrypoint = entrypoint
1013 self.uid = uid
1014 self.gid = gid
1015 self.volume_mounts = volume_mounts
1016 self.args = args
1017 self.envs = envs
1018 self.privileged = privileged
1019 self.bind_mounts = bind_mounts
1020 self.ports = ports
1021 self.dirs = dirs
1022 self.files = files
1023
1024 def config_json(self) -> Dict[str, Any]:
1025 """
1026 Helper function to get the value of the `--config-json` cephadm
1027 command line option. It will contain all specification properties
1028 that haven't a `None` value. Such properties will get default
1029 values in cephadm.
1030 :return: Returns a dictionary containing all specification
1031 properties.
1032 """
1033 config_json = {}
1034 for prop in ['image', 'entrypoint', 'uid', 'gid', 'args',
1035 'envs', 'volume_mounts', 'privileged',
1036 'bind_mounts', 'ports', 'dirs', 'files']:
1037 value = getattr(self, prop)
1038 if value is not None:
1039 config_json[prop] = value
1040 return config_json
1041
1042
1043 yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer)
1044
1045
1046 class MonitoringSpec(ServiceSpec):
1047 def __init__(self,
1048 service_type: str,
1049 service_id: Optional[str] = None,
1050 config: Optional[Dict[str, str]] = None,
1051 networks: Optional[List[str]] = None,
1052 placement: Optional[PlacementSpec] = None,
1053 unmanaged: bool = False,
1054 preview_only: bool = False,
1055 port: Optional[int] = None,
1056 ):
1057 assert service_type in ['grafana', 'node-exporter', 'prometheus', 'alertmanager']
1058
1059 super(MonitoringSpec, self).__init__(
1060 service_type, service_id,
1061 placement=placement, unmanaged=unmanaged,
1062 preview_only=preview_only, config=config,
1063 networks=networks)
1064
1065 self.service_type = service_type
1066 self.port = port
1067
1068 def get_port_start(self) -> List[int]:
1069 return [self.get_port()]
1070
1071 def get_port(self) -> int:
1072 if self.port:
1073 return self.port
1074 else:
1075 return {'prometheus': 9095,
1076 'node-exporter': 9100,
1077 'alertmanager': 9093,
1078 'grafana': 3000}[self.service_type]
1079
1080
1081 yaml.add_representer(MonitoringSpec, ServiceSpec.yaml_representer)
1082
1083
1084 class AlertManagerSpec(MonitoringSpec):
1085 def __init__(self,
1086 service_type: str = 'alertmanager',
1087 service_id: Optional[str] = None,
1088 placement: Optional[PlacementSpec] = None,
1089 unmanaged: bool = False,
1090 preview_only: bool = False,
1091 user_data: Optional[Dict[str, Any]] = None,
1092 config: Optional[Dict[str, str]] = None,
1093 networks: Optional[List[str]] = None,
1094 port: Optional[int] = None,
1095 ):
1096 assert service_type == 'alertmanager'
1097 super(AlertManagerSpec, self).__init__(
1098 'alertmanager', service_id=service_id,
1099 placement=placement, unmanaged=unmanaged,
1100 preview_only=preview_only, config=config, networks=networks, port=port)
1101
1102 # Custom configuration.
1103 #
1104 # Example:
1105 # service_type: alertmanager
1106 # service_id: xyz
1107 # user_data:
1108 # default_webhook_urls:
1109 # - "https://foo"
1110 # - "https://bar"
1111 #
1112 # Documentation:
1113 # default_webhook_urls - A list of additional URL's that are
1114 # added to the default receivers'
1115 # <webhook_configs> configuration.
1116 self.user_data = user_data or {}
1117
1118 def get_port_start(self) -> List[int]:
1119 return [self.get_port(), 9094]
1120
1121 def validate(self) -> None:
1122 super(AlertManagerSpec, self).validate()
1123
1124 if self.port == 9094:
1125 raise SpecValidationError(
1126 'Port 9094 is reserved for AlertManager cluster listen address')
1127
1128
1129 yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer)
1130
1131
1132 class GrafanaSpec(MonitoringSpec):
1133 def __init__(self,
1134 service_type: str = 'grafana',
1135 service_id: Optional[str] = None,
1136 placement: Optional[PlacementSpec] = None,
1137 unmanaged: bool = False,
1138 preview_only: bool = False,
1139 config: Optional[Dict[str, str]] = None,
1140 networks: Optional[List[str]] = None,
1141 port: Optional[int] = None,
1142 initial_admin_password: Optional[str] = None
1143 ):
1144 assert service_type == 'grafana'
1145 super(GrafanaSpec, self).__init__(
1146 'grafana', service_id=service_id,
1147 placement=placement, unmanaged=unmanaged,
1148 preview_only=preview_only, config=config, networks=networks, port=port)
1149
1150 self.initial_admin_password = initial_admin_password
1151
1152
1153 yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer)
1154
1155
1156 class SNMPGatewaySpec(ServiceSpec):
1157 class SNMPVersion(str, enum.Enum):
1158 V2c = 'V2c'
1159 V3 = 'V3'
1160
1161 def to_json(self) -> str:
1162 return self.value
1163
1164 class SNMPAuthType(str, enum.Enum):
1165 MD5 = 'MD5'
1166 SHA = 'SHA'
1167
1168 def to_json(self) -> str:
1169 return self.value
1170
1171 class SNMPPrivacyType(str, enum.Enum):
1172 DES = 'DES'
1173 AES = 'AES'
1174
1175 def to_json(self) -> str:
1176 return self.value
1177
1178 valid_destination_types = [
1179 'Name:Port',
1180 'IPv4:Port'
1181 ]
1182
1183 def __init__(self,
1184 service_type: str = 'snmp-gateway',
1185 snmp_version: Optional[SNMPVersion] = None,
1186 snmp_destination: str = '',
1187 credentials: Dict[str, str] = {},
1188 engine_id: Optional[str] = None,
1189 auth_protocol: Optional[SNMPAuthType] = None,
1190 privacy_protocol: Optional[SNMPPrivacyType] = None,
1191 placement: Optional[PlacementSpec] = None,
1192 unmanaged: bool = False,
1193 preview_only: bool = False,
1194 port: Optional[int] = None,
1195 ):
1196 assert service_type == 'snmp-gateway'
1197
1198 super(SNMPGatewaySpec, self).__init__(
1199 service_type,
1200 placement=placement,
1201 unmanaged=unmanaged,
1202 preview_only=preview_only)
1203
1204 self.service_type = service_type
1205 self.snmp_version = snmp_version
1206 self.snmp_destination = snmp_destination
1207 self.port = port
1208 self.credentials = credentials
1209 self.engine_id = engine_id
1210 self.auth_protocol = auth_protocol
1211 self.privacy_protocol = privacy_protocol
1212
1213 @classmethod
1214 def _from_json_impl(cls, json_spec: dict) -> 'SNMPGatewaySpec':
1215
1216 cpy = json_spec.copy()
1217 types = [
1218 ('snmp_version', SNMPGatewaySpec.SNMPVersion),
1219 ('auth_protocol', SNMPGatewaySpec.SNMPAuthType),
1220 ('privacy_protocol', SNMPGatewaySpec.SNMPPrivacyType),
1221 ]
1222 for d in cpy, cpy.get('spec', {}):
1223 for key, enum_cls in types:
1224 try:
1225 if key in d:
1226 d[key] = enum_cls(d[key])
1227 except ValueError:
1228 raise SpecValidationError(f'{key} unsupported. Must be one of '
1229 f'{", ".join(enum_cls)}')
1230 return super(SNMPGatewaySpec, cls)._from_json_impl(cpy)
1231
1232 @property
1233 def ports(self) -> List[int]:
1234 return [self.port or 9464]
1235
1236 def get_port_start(self) -> List[int]:
1237 return self.ports
1238
1239 def validate(self) -> None:
1240 super(SNMPGatewaySpec, self).validate()
1241
1242 if not self.credentials:
1243 raise SpecValidationError(
1244 'Missing authentication information (credentials). '
1245 'SNMP V2c and V3 require credential information'
1246 )
1247 elif not self.snmp_version:
1248 raise SpecValidationError(
1249 'Missing SNMP version (snmp_version)'
1250 )
1251
1252 creds_requirement = {
1253 'V2c': ['snmp_community'],
1254 'V3': ['snmp_v3_auth_username', 'snmp_v3_auth_password']
1255 }
1256 if self.privacy_protocol:
1257 creds_requirement['V3'].append('snmp_v3_priv_password')
1258
1259 missing = [parm for parm in creds_requirement[self.snmp_version]
1260 if parm not in self.credentials]
1261 # check that credentials are correct for the version
1262 if missing:
1263 raise SpecValidationError(
1264 f'SNMP {self.snmp_version} credentials are incomplete. Missing {", ".join(missing)}'
1265 )
1266
1267 if self.engine_id:
1268 if 10 <= len(self.engine_id) <= 64 and \
1269 is_hex(self.engine_id) and \
1270 len(self.engine_id) % 2 == 0:
1271 pass
1272 else:
1273 raise SpecValidationError(
1274 'engine_id must be a string containing 10-64 hex characters. '
1275 'Its length must be divisible by 2'
1276 )
1277
1278 else:
1279 if self.snmp_version == 'V3':
1280 raise SpecValidationError(
1281 'Must provide an engine_id for SNMP V3 notifications'
1282 )
1283
1284 if not self.snmp_destination:
1285 raise SpecValidationError(
1286 'SNMP destination (snmp_destination) must be provided'
1287 )
1288 else:
1289 valid, description = valid_addr(self.snmp_destination)
1290 if not valid:
1291 raise SpecValidationError(
1292 f'SNMP destination (snmp_destination) is invalid: {description}'
1293 )
1294 if description not in self.valid_destination_types:
1295 raise SpecValidationError(
1296 f'SNMP destination (snmp_destination) type ({description}) is invalid. '
1297 f'Must be either: {", ".join(sorted(self.valid_destination_types))}'
1298 )
1299
1300
1301 yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer)