]> git.proxmox.com Git - ceph.git/blob - ceph/src/python-common/ceph/deployment/service_spec.py
be3f9deebeeee8332fa013ce5af5b4c598e69836
[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
318 >>> PlacementSpec.from_string('3')
319 PlacementSpec(count=3)
320
321 A list of names is parsed as host specifications:
322
323 >>> PlacementSpec.from_string('host1 host2')
324 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
325 tSpec(hostname='host2', network='', name='')])
326
327 You can also prefix the hosts with a count as follows:
328
329 >>> PlacementSpec.from_string('2 host1 host2')
330 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
331 tPlacementSpec(hostname='host2', network='', name='')])
332
333 You can specify labels using `label:<label>`
334
335 >>> PlacementSpec.from_string('label:mon')
336 PlacementSpec(label='mon')
337
338 Labels also support a count:
339
340 >>> PlacementSpec.from_string('3 label:mon')
341 PlacementSpec(count=3, label='mon')
342
343 fnmatch is also supported:
344
345 >>> PlacementSpec.from_string('data[1-3]')
346 PlacementSpec(host_pattern='data[1-3]')
347
348 >>> PlacementSpec.from_string(None)
349 PlacementSpec()
350 """
351 if arg is None or not arg:
352 strings = []
353 elif isinstance(arg, str):
354 if ' ' in arg:
355 strings = arg.split(' ')
356 elif ';' in arg:
357 strings = arg.split(';')
358 elif ',' in arg and '[' not in arg:
359 # FIXME: this isn't quite right. we want to avoid breaking
360 # a list of mons with addrvecs... so we're basically allowing
361 # , most of the time, except when addrvecs are used. maybe
362 # ok?
363 strings = arg.split(',')
364 else:
365 strings = [arg]
366 else:
367 raise SpecValidationError('invalid placement %s' % arg)
368
369 count = None
370 count_per_host = None
371 if strings:
372 try:
373 count = int(strings[0])
374 strings = strings[1:]
375 except ValueError:
376 pass
377 for s in strings:
378 if s.startswith('count:'):
379 try:
380 count = int(s[len('count:'):])
381 strings.remove(s)
382 break
383 except ValueError:
384 pass
385 for s in strings:
386 if s.startswith('count-per-host:'):
387 try:
388 count_per_host = int(s[len('count-per-host:'):])
389 strings.remove(s)
390 break
391 except ValueError:
392 pass
393
394 advanced_hostspecs = [h for h in strings if
395 (':' in h or '=' in h or not any(c in '[]?*:=' for c in h)) and
396 'label:' not in h]
397 for a_h in advanced_hostspecs:
398 strings.remove(a_h)
399
400 labels = [x for x in strings if 'label:' in x]
401 if len(labels) > 1:
402 raise SpecValidationError('more than one label provided: {}'.format(labels))
403 for l in labels:
404 strings.remove(l)
405 label = labels[0][6:] if labels else None
406
407 host_patterns = strings
408 if len(host_patterns) > 1:
409 raise SpecValidationError(
410 'more than one host pattern provided: {}'.format(host_patterns))
411
412 ps = PlacementSpec(count=count,
413 count_per_host=count_per_host,
414 hosts=advanced_hostspecs,
415 label=label,
416 host_pattern=host_patterns[0] if host_patterns else None)
417 return ps
418
419
420 _service_spec_from_json_validate = True
421
422
423 @contextmanager
424 def service_spec_allow_invalid_from_json() -> Iterator[None]:
425 """
426 I know this is evil, but unfortunately `ceph orch ls`
427 may return invalid OSD specs for OSDs not associated to
428 and specs. If you have a better idea, please!
429 """
430 global _service_spec_from_json_validate
431 _service_spec_from_json_validate = False
432 yield
433 _service_spec_from_json_validate = True
434
435
436 class ServiceSpec(object):
437 """
438 Details of service creation.
439
440 Request to the orchestrator for a cluster of daemons
441 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
442
443 This structure is supposed to be enough information to
444 start the services.
445 """
446 KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi mds mgr mon nfs ' \
447 'node-exporter osd prometheus rbd-mirror rgw agent ' \
448 'container ingress cephfs-mirror snmp-gateway'.split()
449 REQUIRES_SERVICE_ID = 'iscsi mds nfs rgw container ingress '.split()
450 MANAGED_CONFIG_OPTIONS = [
451 'mds_join_fs',
452 ]
453
454 @classmethod
455 def _cls(cls: Type[ServiceSpecT], service_type: str) -> Type[ServiceSpecT]:
456 from ceph.deployment.drive_group import DriveGroupSpec
457
458 ret = {
459 'rgw': RGWSpec,
460 'nfs': NFSServiceSpec,
461 'osd': DriveGroupSpec,
462 'iscsi': IscsiServiceSpec,
463 'alertmanager': AlertManagerSpec,
464 'ingress': IngressSpec,
465 'container': CustomContainerSpec,
466 'grafana': GrafanaSpec,
467 'node-exporter': MonitoringSpec,
468 'prometheus': MonitoringSpec,
469 'snmp-gateway': SNMPGatewaySpec,
470 }.get(service_type, cls)
471 if ret == ServiceSpec and not service_type:
472 raise SpecValidationError('Spec needs a "service_type" key.')
473 return ret
474
475 def __new__(cls: Type[ServiceSpecT], *args: Any, **kwargs: Any) -> ServiceSpecT:
476 """
477 Some Python foo to make sure, we don't have an object
478 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
479
480 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
481 True
482
483 """
484 if cls != ServiceSpec:
485 return object.__new__(cls)
486 service_type = kwargs.get('service_type', args[0] if args else None)
487 sub_cls: Any = cls._cls(service_type)
488 return object.__new__(sub_cls)
489
490 def __init__(self,
491 service_type: str,
492 service_id: Optional[str] = None,
493 placement: Optional[PlacementSpec] = None,
494 count: Optional[int] = None,
495 config: Optional[Dict[str, str]] = None,
496 unmanaged: bool = False,
497 preview_only: bool = False,
498 networks: Optional[List[str]] = None,
499 extra_container_args: Optional[List[str]] = None,
500 ):
501
502 #: See :ref:`orchestrator-cli-placement-spec`.
503 self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec
504
505 assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type
506 #: The type of the service. Needs to be either a Ceph
507 #: service (``mon``, ``crash``, ``mds``, ``mgr``, ``osd`` or
508 #: ``rbd-mirror``), a gateway (``nfs`` or ``rgw``), part of the
509 #: monitoring stack (``alertmanager``, ``grafana``, ``node-exporter`` or
510 #: ``prometheus``) or (``container``) for custom containers.
511 self.service_type = service_type
512
513 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
514 #: ``container``, ``ingress``
515 self.service_id = None
516
517 if self.service_type in self.REQUIRES_SERVICE_ID or self.service_type == 'osd':
518 self.service_id = service_id
519
520 #: If set to ``true``, the orchestrator will not deploy nor remove
521 #: any daemon associated with this service. Placement and all other properties
522 #: will be ignored. This is useful, if you do not want this service to be
523 #: managed temporarily. For cephadm, See :ref:`cephadm-spec-unmanaged`
524 self.unmanaged = unmanaged
525 self.preview_only = preview_only
526
527 #: A list of network identities instructing the daemons to only bind
528 #: on the particular networks in that list. In case the cluster is distributed
529 #: across multiple networks, you can add multiple networks. See
530 #: :ref:`cephadm-monitoring-networks-ports`,
531 #: :ref:`cephadm-rgw-networks` and :ref:`cephadm-mgr-networks`.
532 self.networks: List[str] = networks or []
533
534 self.config: Optional[Dict[str, str]] = None
535 if config:
536 self.config = {k.replace(' ', '_'): v for k, v in config.items()}
537
538 self.extra_container_args: Optional[List[str]] = extra_container_args
539
540 @classmethod
541 @handle_type_error
542 def from_json(cls: Type[ServiceSpecT], json_spec: Dict) -> ServiceSpecT:
543 """
544 Initialize 'ServiceSpec' object data from a json structure
545
546 There are two valid styles for service specs:
547
548 the "old" style:
549
550 .. code:: yaml
551
552 service_type: nfs
553 service_id: foo
554 pool: mypool
555 namespace: myns
556
557 and the "new" style:
558
559 .. code:: yaml
560
561 service_type: nfs
562 service_id: foo
563 config:
564 some_option: the_value
565 networks: [10.10.0.0/16]
566 spec:
567 pool: mypool
568 namespace: myns
569
570 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
571 prefer the new style as it is more readable and provides a better
572 understanding of what fields are special for a give service type.
573
574 Note, we'll need to stay compatible with both versions for the
575 the next two major releases (octoups, pacific).
576
577 :param json_spec: A valid dict with ServiceSpec
578
579 :meta private:
580 """
581 if not isinstance(json_spec, dict):
582 raise SpecValidationError(
583 f'Service Spec is not an (JSON or YAML) object. got "{str(json_spec)}"')
584
585 json_spec = cls.normalize_json(json_spec)
586
587 c = json_spec.copy()
588
589 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
590 # Open question: Remove `service_id` form to_json?
591 if c.get('service_name', ''):
592 service_type_id = c['service_name'].split('.', 1)
593
594 if not c.get('service_type', ''):
595 c['service_type'] = service_type_id[0]
596 if not c.get('service_id', '') and len(service_type_id) > 1:
597 c['service_id'] = service_type_id[1]
598 del c['service_name']
599
600 service_type = c.get('service_type', '')
601 _cls = cls._cls(service_type)
602
603 if 'status' in c:
604 del c['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
605
606 return _cls._from_json_impl(c) # type: ignore
607
608 @staticmethod
609 def normalize_json(json_spec: dict) -> dict:
610 networks = json_spec.get('networks')
611 if networks is None:
612 return json_spec
613 if isinstance(networks, list):
614 return json_spec
615 if not isinstance(networks, str):
616 raise SpecValidationError(f'Networks ({networks}) must be a string or list of strings')
617 json_spec['networks'] = [networks]
618 return json_spec
619
620 @classmethod
621 def _from_json_impl(cls: Type[ServiceSpecT], json_spec: dict) -> ServiceSpecT:
622 args = {} # type: Dict[str, Any]
623 for k, v in json_spec.items():
624 if k == 'placement':
625 v = PlacementSpec.from_json(v)
626 if k == 'spec':
627 args.update(v)
628 continue
629 args.update({k: v})
630 _cls = cls(**args)
631 if _service_spec_from_json_validate:
632 _cls.validate()
633 return _cls
634
635 def service_name(self) -> str:
636 n = self.service_type
637 if self.service_id:
638 n += '.' + self.service_id
639 return n
640
641 def get_port_start(self) -> List[int]:
642 # If defined, we will allocate and number ports starting at this
643 # point.
644 return []
645
646 def get_virtual_ip(self) -> Optional[str]:
647 return None
648
649 def to_json(self):
650 # type: () -> OrderedDict[str, Any]
651 ret: OrderedDict[str, Any] = OrderedDict()
652 ret['service_type'] = self.service_type
653 if self.service_id:
654 ret['service_id'] = self.service_id
655 ret['service_name'] = self.service_name()
656 if self.placement.to_json():
657 ret['placement'] = self.placement.to_json()
658 if self.unmanaged:
659 ret['unmanaged'] = self.unmanaged
660 if self.networks:
661 ret['networks'] = self.networks
662 if self.extra_container_args:
663 ret['extra_container_args'] = self.extra_container_args
664
665 c = {}
666 for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]):
667 if key in ret:
668 continue
669 if hasattr(val, 'to_json'):
670 val = val.to_json()
671 if val:
672 c[key] = val
673 if c:
674 ret['spec'] = c
675 return ret
676
677 def validate(self) -> None:
678 if not self.service_type:
679 raise SpecValidationError('Cannot add Service: type required')
680
681 if self.service_type != 'osd':
682 if self.service_type in self.REQUIRES_SERVICE_ID and not self.service_id:
683 raise SpecValidationError('Cannot add Service: id required')
684 if self.service_type not in self.REQUIRES_SERVICE_ID and self.service_id:
685 raise SpecValidationError(
686 f'Service of type \'{self.service_type}\' should not contain a service id')
687
688 if self.service_id:
689 if not re.match('^[a-zA-Z0-9_.-]+$', self.service_id):
690 raise SpecValidationError('Service id contains invalid characters, '
691 'only [a-zA-Z0-9_.-] allowed')
692
693 if self.placement is not None:
694 self.placement.validate()
695 if self.config:
696 for k, v in self.config.items():
697 if k in self.MANAGED_CONFIG_OPTIONS:
698 raise SpecValidationError(
699 f'Cannot set config option {k} in spec: it is managed by cephadm'
700 )
701 for network in self.networks or []:
702 try:
703 ip_network(network)
704 except ValueError as e:
705 raise SpecValidationError(
706 f'Cannot parse network {network}: {e}'
707 )
708
709 def __repr__(self) -> str:
710 y = yaml.dump(cast(dict, self), default_flow_style=False)
711 return f"{self.__class__.__name__}.from_json(yaml.safe_load('''{y}'''))"
712
713 def __eq__(self, other: Any) -> bool:
714 return (self.__class__ == other.__class__
715 and
716 self.__dict__ == other.__dict__)
717
718 def one_line_str(self) -> str:
719 return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name())
720
721 @staticmethod
722 def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec') -> Any:
723 return dumper.represent_dict(cast(Mapping, data.to_json().items()))
724
725
726 yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer)
727
728
729 class NFSServiceSpec(ServiceSpec):
730 def __init__(self,
731 service_type: str = 'nfs',
732 service_id: Optional[str] = None,
733 placement: Optional[PlacementSpec] = None,
734 unmanaged: bool = False,
735 preview_only: bool = False,
736 config: Optional[Dict[str, str]] = None,
737 networks: Optional[List[str]] = None,
738 port: Optional[int] = None,
739 ):
740 assert service_type == 'nfs'
741 super(NFSServiceSpec, self).__init__(
742 'nfs', service_id=service_id,
743 placement=placement, unmanaged=unmanaged, preview_only=preview_only,
744 config=config, networks=networks)
745
746 self.port = port
747
748 def get_port_start(self) -> List[int]:
749 if self.port:
750 return [self.port]
751 return []
752
753 def rados_config_name(self):
754 # type: () -> str
755 return 'conf-' + self.service_name()
756
757
758 yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer)
759
760
761 class RGWSpec(ServiceSpec):
762 """
763 Settings to configure a (multisite) Ceph RGW
764
765 .. code-block:: yaml
766
767 service_type: rgw
768 service_id: myrealm.myzone
769 spec:
770 rgw_realm: myrealm
771 rgw_zone: myzone
772 ssl: true
773 rgw_frontend_port: 1234
774 rgw_frontend_type: beast
775 rgw_frontend_ssl_certificate: ...
776
777 See also: :ref:`orchestrator-cli-service-spec`
778 """
779
780 MANAGED_CONFIG_OPTIONS = ServiceSpec.MANAGED_CONFIG_OPTIONS + [
781 'rgw_zone',
782 'rgw_realm',
783 'rgw_frontends',
784 ]
785
786 def __init__(self,
787 service_type: str = 'rgw',
788 service_id: Optional[str] = None,
789 placement: Optional[PlacementSpec] = None,
790 rgw_realm: Optional[str] = None,
791 rgw_zone: Optional[str] = None,
792 rgw_frontend_port: Optional[int] = None,
793 rgw_frontend_ssl_certificate: Optional[List[str]] = None,
794 rgw_frontend_type: Optional[str] = None,
795 unmanaged: bool = False,
796 ssl: bool = False,
797 preview_only: bool = False,
798 config: Optional[Dict[str, str]] = None,
799 networks: Optional[List[str]] = None,
800 subcluster: Optional[str] = None, # legacy, only for from_json on upgrade
801 ):
802 assert service_type == 'rgw', service_type
803
804 # for backward compatibility with octopus spec files,
805 if not service_id and (rgw_realm and rgw_zone):
806 service_id = rgw_realm + '.' + rgw_zone
807
808 super(RGWSpec, self).__init__(
809 'rgw', service_id=service_id,
810 placement=placement, unmanaged=unmanaged,
811 preview_only=preview_only, config=config, networks=networks)
812
813 #: The RGW realm associated with this service. Needs to be manually created
814 self.rgw_realm: Optional[str] = rgw_realm
815 #: The RGW zone associated with this service. Needs to be manually created
816 self.rgw_zone: Optional[str] = rgw_zone
817 #: Port of the RGW daemons
818 self.rgw_frontend_port: Optional[int] = rgw_frontend_port
819 #: List of SSL certificates
820 self.rgw_frontend_ssl_certificate: Optional[List[str]] = rgw_frontend_ssl_certificate
821 #: civetweb or beast (default: beast). See :ref:`rgw_frontends`
822 self.rgw_frontend_type: Optional[str] = rgw_frontend_type
823 #: enable SSL
824 self.ssl = ssl
825
826 def get_port_start(self) -> List[int]:
827 return [self.get_port()]
828
829 def get_port(self) -> int:
830 if self.rgw_frontend_port:
831 return self.rgw_frontend_port
832 if self.ssl:
833 return 443
834 else:
835 return 80
836
837 def validate(self) -> None:
838 super(RGWSpec, self).validate()
839
840 if self.rgw_realm and not self.rgw_zone:
841 raise SpecValidationError(
842 'Cannot add RGW: Realm specified but no zone specified')
843 if self.rgw_zone and not self.rgw_realm:
844 raise SpecValidationError(
845 'Cannot add RGW: Zone specified but no realm specified')
846
847
848 yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer)
849
850
851 class IscsiServiceSpec(ServiceSpec):
852 def __init__(self,
853 service_type: str = 'iscsi',
854 service_id: Optional[str] = None,
855 pool: Optional[str] = None,
856 trusted_ip_list: Optional[str] = None,
857 api_port: Optional[int] = None,
858 api_user: Optional[str] = None,
859 api_password: Optional[str] = None,
860 api_secure: Optional[bool] = None,
861 ssl_cert: Optional[str] = None,
862 ssl_key: Optional[str] = None,
863 placement: Optional[PlacementSpec] = None,
864 unmanaged: bool = False,
865 preview_only: bool = False,
866 config: Optional[Dict[str, str]] = None,
867 networks: Optional[List[str]] = None,
868 ):
869 assert service_type == 'iscsi'
870 super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id,
871 placement=placement, unmanaged=unmanaged,
872 preview_only=preview_only,
873 config=config, networks=networks)
874
875 #: RADOS pool where ceph-iscsi config data is stored.
876 self.pool = pool
877 #: list of trusted IP addresses
878 self.trusted_ip_list = trusted_ip_list
879 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
880 self.api_port = api_port
881 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
882 self.api_user = api_user
883 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
884 self.api_password = api_password
885 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
886 self.api_secure = api_secure
887 #: SSL certificate
888 self.ssl_cert = ssl_cert
889 #: SSL private key
890 self.ssl_key = ssl_key
891
892 if not self.api_secure and self.ssl_cert and self.ssl_key:
893 self.api_secure = True
894
895 def validate(self) -> None:
896 super(IscsiServiceSpec, self).validate()
897
898 if not self.pool:
899 raise SpecValidationError(
900 'Cannot add ISCSI: No Pool specified')
901
902 # Do not need to check for api_user and api_password as they
903 # now default to 'admin' when setting up the gateway url. Older
904 # iSCSI specs from before this change should be fine as they will
905 # have been required to have an api_user and api_password set and
906 # will be unaffected by the new default value.
907
908
909 yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer)
910
911
912 class IngressSpec(ServiceSpec):
913 def __init__(self,
914 service_type: str = 'ingress',
915 service_id: Optional[str] = None,
916 config: Optional[Dict[str, str]] = None,
917 networks: Optional[List[str]] = None,
918 placement: Optional[PlacementSpec] = None,
919 backend_service: Optional[str] = None,
920 frontend_port: Optional[int] = None,
921 ssl_cert: Optional[str] = None,
922 ssl_key: Optional[str] = None,
923 ssl_dh_param: Optional[str] = None,
924 ssl_ciphers: Optional[List[str]] = None,
925 ssl_options: Optional[List[str]] = None,
926 monitor_port: Optional[int] = None,
927 monitor_user: Optional[str] = None,
928 monitor_password: Optional[str] = None,
929 enable_stats: Optional[bool] = None,
930 keepalived_password: Optional[str] = None,
931 virtual_ip: Optional[str] = None,
932 virtual_interface_networks: Optional[List[str]] = [],
933 unmanaged: bool = False,
934 ssl: bool = False
935 ):
936 assert service_type == 'ingress'
937 super(IngressSpec, self).__init__(
938 'ingress', service_id=service_id,
939 placement=placement, config=config,
940 networks=networks
941 )
942 self.backend_service = backend_service
943 self.frontend_port = frontend_port
944 self.ssl_cert = ssl_cert
945 self.ssl_key = ssl_key
946 self.ssl_dh_param = ssl_dh_param
947 self.ssl_ciphers = ssl_ciphers
948 self.ssl_options = ssl_options
949 self.monitor_port = monitor_port
950 self.monitor_user = monitor_user
951 self.monitor_password = monitor_password
952 self.keepalived_password = keepalived_password
953 self.virtual_ip = virtual_ip
954 self.virtual_interface_networks = virtual_interface_networks or []
955 self.unmanaged = unmanaged
956 self.ssl = ssl
957
958 def get_port_start(self) -> List[int]:
959 return [cast(int, self.frontend_port),
960 cast(int, self.monitor_port)]
961
962 def get_virtual_ip(self) -> Optional[str]:
963 return self.virtual_ip
964
965 def validate(self) -> None:
966 super(IngressSpec, self).validate()
967
968 if not self.backend_service:
969 raise SpecValidationError(
970 'Cannot add ingress: No backend_service specified')
971 if not self.frontend_port:
972 raise SpecValidationError(
973 'Cannot add ingress: No frontend_port specified')
974 if not self.monitor_port:
975 raise SpecValidationError(
976 'Cannot add ingress: No monitor_port specified')
977 if not self.virtual_ip:
978 raise SpecValidationError(
979 'Cannot add ingress: No virtual_ip provided')
980
981
982 yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer)
983
984
985 class CustomContainerSpec(ServiceSpec):
986 def __init__(self,
987 service_type: str = 'container',
988 service_id: Optional[str] = None,
989 config: Optional[Dict[str, str]] = None,
990 networks: Optional[List[str]] = None,
991 placement: Optional[PlacementSpec] = None,
992 unmanaged: bool = False,
993 preview_only: bool = False,
994 image: Optional[str] = None,
995 entrypoint: Optional[str] = None,
996 uid: Optional[int] = None,
997 gid: Optional[int] = None,
998 volume_mounts: Optional[Dict[str, str]] = {},
999 args: Optional[List[str]] = [],
1000 envs: Optional[List[str]] = [],
1001 privileged: Optional[bool] = False,
1002 bind_mounts: Optional[List[List[str]]] = None,
1003 ports: Optional[List[int]] = [],
1004 dirs: Optional[List[str]] = [],
1005 files: Optional[Dict[str, Any]] = {},
1006 ):
1007 assert service_type == 'container'
1008 assert service_id is not None
1009 assert image is not None
1010
1011 super(CustomContainerSpec, self).__init__(
1012 service_type, service_id,
1013 placement=placement, unmanaged=unmanaged,
1014 preview_only=preview_only, config=config,
1015 networks=networks)
1016
1017 self.image = image
1018 self.entrypoint = entrypoint
1019 self.uid = uid
1020 self.gid = gid
1021 self.volume_mounts = volume_mounts
1022 self.args = args
1023 self.envs = envs
1024 self.privileged = privileged
1025 self.bind_mounts = bind_mounts
1026 self.ports = ports
1027 self.dirs = dirs
1028 self.files = files
1029
1030 def config_json(self) -> Dict[str, Any]:
1031 """
1032 Helper function to get the value of the `--config-json` cephadm
1033 command line option. It will contain all specification properties
1034 that haven't a `None` value. Such properties will get default
1035 values in cephadm.
1036 :return: Returns a dictionary containing all specification
1037 properties.
1038 """
1039 config_json = {}
1040 for prop in ['image', 'entrypoint', 'uid', 'gid', 'args',
1041 'envs', 'volume_mounts', 'privileged',
1042 'bind_mounts', 'ports', 'dirs', 'files']:
1043 value = getattr(self, prop)
1044 if value is not None:
1045 config_json[prop] = value
1046 return config_json
1047
1048
1049 yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer)
1050
1051
1052 class MonitoringSpec(ServiceSpec):
1053 def __init__(self,
1054 service_type: str,
1055 service_id: Optional[str] = None,
1056 config: Optional[Dict[str, str]] = None,
1057 networks: Optional[List[str]] = None,
1058 placement: Optional[PlacementSpec] = None,
1059 unmanaged: bool = False,
1060 preview_only: bool = False,
1061 port: Optional[int] = None,
1062 ):
1063 assert service_type in ['grafana', 'node-exporter', 'prometheus', 'alertmanager']
1064
1065 super(MonitoringSpec, self).__init__(
1066 service_type, service_id,
1067 placement=placement, unmanaged=unmanaged,
1068 preview_only=preview_only, config=config,
1069 networks=networks)
1070
1071 self.service_type = service_type
1072 self.port = port
1073
1074 def get_port_start(self) -> List[int]:
1075 return [self.get_port()]
1076
1077 def get_port(self) -> int:
1078 if self.port:
1079 return self.port
1080 else:
1081 return {'prometheus': 9095,
1082 'node-exporter': 9100,
1083 'alertmanager': 9093,
1084 'grafana': 3000}[self.service_type]
1085
1086
1087 yaml.add_representer(MonitoringSpec, ServiceSpec.yaml_representer)
1088
1089
1090 class AlertManagerSpec(MonitoringSpec):
1091 def __init__(self,
1092 service_type: str = 'alertmanager',
1093 service_id: Optional[str] = None,
1094 placement: Optional[PlacementSpec] = None,
1095 unmanaged: bool = False,
1096 preview_only: bool = False,
1097 user_data: Optional[Dict[str, Any]] = None,
1098 config: Optional[Dict[str, str]] = None,
1099 networks: Optional[List[str]] = None,
1100 port: Optional[int] = None,
1101 ):
1102 assert service_type == 'alertmanager'
1103 super(AlertManagerSpec, self).__init__(
1104 'alertmanager', service_id=service_id,
1105 placement=placement, unmanaged=unmanaged,
1106 preview_only=preview_only, config=config, networks=networks, port=port)
1107
1108 # Custom configuration.
1109 #
1110 # Example:
1111 # service_type: alertmanager
1112 # service_id: xyz
1113 # user_data:
1114 # default_webhook_urls:
1115 # - "https://foo"
1116 # - "https://bar"
1117 #
1118 # Documentation:
1119 # default_webhook_urls - A list of additional URL's that are
1120 # added to the default receivers'
1121 # <webhook_configs> configuration.
1122 self.user_data = user_data or {}
1123
1124 def get_port_start(self) -> List[int]:
1125 return [self.get_port(), 9094]
1126
1127 def validate(self) -> None:
1128 super(AlertManagerSpec, self).validate()
1129
1130 if self.port == 9094:
1131 raise SpecValidationError(
1132 'Port 9094 is reserved for AlertManager cluster listen address')
1133
1134
1135 yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer)
1136
1137
1138 class GrafanaSpec(MonitoringSpec):
1139 def __init__(self,
1140 service_type: str = 'grafana',
1141 service_id: Optional[str] = None,
1142 placement: Optional[PlacementSpec] = None,
1143 unmanaged: bool = False,
1144 preview_only: bool = False,
1145 config: Optional[Dict[str, str]] = None,
1146 networks: Optional[List[str]] = None,
1147 port: Optional[int] = None,
1148 initial_admin_password: Optional[str] = None
1149 ):
1150 assert service_type == 'grafana'
1151 super(GrafanaSpec, self).__init__(
1152 'grafana', service_id=service_id,
1153 placement=placement, unmanaged=unmanaged,
1154 preview_only=preview_only, config=config, networks=networks, port=port)
1155
1156 self.initial_admin_password = initial_admin_password
1157
1158
1159 yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer)
1160
1161
1162 class SNMPGatewaySpec(ServiceSpec):
1163 class SNMPVersion(str, enum.Enum):
1164 V2c = 'V2c'
1165 V3 = 'V3'
1166
1167 def to_json(self) -> str:
1168 return self.value
1169
1170 class SNMPAuthType(str, enum.Enum):
1171 MD5 = 'MD5'
1172 SHA = 'SHA'
1173
1174 def to_json(self) -> str:
1175 return self.value
1176
1177 class SNMPPrivacyType(str, enum.Enum):
1178 DES = 'DES'
1179 AES = 'AES'
1180
1181 def to_json(self) -> str:
1182 return self.value
1183
1184 valid_destination_types = [
1185 'Name:Port',
1186 'IPv4:Port'
1187 ]
1188
1189 def __init__(self,
1190 service_type: str = 'snmp-gateway',
1191 snmp_version: Optional[SNMPVersion] = None,
1192 snmp_destination: str = '',
1193 credentials: Dict[str, str] = {},
1194 engine_id: Optional[str] = None,
1195 auth_protocol: Optional[SNMPAuthType] = None,
1196 privacy_protocol: Optional[SNMPPrivacyType] = None,
1197 placement: Optional[PlacementSpec] = None,
1198 unmanaged: bool = False,
1199 preview_only: bool = False,
1200 port: Optional[int] = None,
1201 ):
1202 assert service_type == 'snmp-gateway'
1203
1204 super(SNMPGatewaySpec, self).__init__(
1205 service_type,
1206 placement=placement,
1207 unmanaged=unmanaged,
1208 preview_only=preview_only)
1209
1210 self.service_type = service_type
1211 self.snmp_version = snmp_version
1212 self.snmp_destination = snmp_destination
1213 self.port = port
1214 self.credentials = credentials
1215 self.engine_id = engine_id
1216 self.auth_protocol = auth_protocol
1217 self.privacy_protocol = privacy_protocol
1218
1219 @classmethod
1220 def _from_json_impl(cls, json_spec: dict) -> 'SNMPGatewaySpec':
1221
1222 cpy = json_spec.copy()
1223 types = [
1224 ('snmp_version', SNMPGatewaySpec.SNMPVersion),
1225 ('auth_protocol', SNMPGatewaySpec.SNMPAuthType),
1226 ('privacy_protocol', SNMPGatewaySpec.SNMPPrivacyType),
1227 ]
1228 for d in cpy, cpy.get('spec', {}):
1229 for key, enum_cls in types:
1230 try:
1231 if key in d:
1232 d[key] = enum_cls(d[key])
1233 except ValueError:
1234 raise SpecValidationError(f'{key} unsupported. Must be one of '
1235 f'{", ".join(enum_cls)}')
1236 return super(SNMPGatewaySpec, cls)._from_json_impl(cpy)
1237
1238 @property
1239 def ports(self) -> List[int]:
1240 return [self.port or 9464]
1241
1242 def get_port_start(self) -> List[int]:
1243 return self.ports
1244
1245 def validate(self) -> None:
1246 super(SNMPGatewaySpec, self).validate()
1247
1248 if not self.credentials:
1249 raise SpecValidationError(
1250 'Missing authentication information (credentials). '
1251 'SNMP V2c and V3 require credential information'
1252 )
1253 elif not self.snmp_version:
1254 raise SpecValidationError(
1255 'Missing SNMP version (snmp_version)'
1256 )
1257
1258 creds_requirement = {
1259 'V2c': ['snmp_community'],
1260 'V3': ['snmp_v3_auth_username', 'snmp_v3_auth_password']
1261 }
1262 if self.privacy_protocol:
1263 creds_requirement['V3'].append('snmp_v3_priv_password')
1264
1265 missing = [parm for parm in creds_requirement[self.snmp_version]
1266 if parm not in self.credentials]
1267 # check that credentials are correct for the version
1268 if missing:
1269 raise SpecValidationError(
1270 f'SNMP {self.snmp_version} credentials are incomplete. Missing {", ".join(missing)}'
1271 )
1272
1273 if self.engine_id:
1274 if 10 <= len(self.engine_id) <= 64 and \
1275 is_hex(self.engine_id) and \
1276 len(self.engine_id) % 2 == 0:
1277 pass
1278 else:
1279 raise SpecValidationError(
1280 'engine_id must be a string containing 10-64 hex characters. '
1281 'Its length must be divisible by 2'
1282 )
1283
1284 else:
1285 if self.snmp_version == 'V3':
1286 raise SpecValidationError(
1287 'Must provide an engine_id for SNMP V3 notifications'
1288 )
1289
1290 if not self.snmp_destination:
1291 raise SpecValidationError(
1292 'SNMP destination (snmp_destination) must be provided'
1293 )
1294 else:
1295 valid, description = valid_addr(self.snmp_destination)
1296 if not valid:
1297 raise SpecValidationError(
1298 f'SNMP destination (snmp_destination) is invalid: {description}'
1299 )
1300 if description not in self.valid_destination_types:
1301 raise SpecValidationError(
1302 f'SNMP destination (snmp_destination) type ({description}) is invalid. '
1303 f'Must be either: {", ".join(sorted(self.valid_destination_types))}'
1304 )
1305
1306
1307 yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer)