]> git.proxmox.com Git - ceph.git/blob - ceph/src/python-common/ceph/deployment/service_spec.py
update ceph source to reef 18.1.2
[ceph.git] / ceph / src / python-common / ceph / deployment / service_spec.py
1 import fnmatch
2 import os
3 import re
4 import enum
5 from collections import OrderedDict
6 from contextlib import contextmanager
7 from functools import wraps
8 from ipaddress import ip_network, ip_address
9 from typing import Optional, Dict, Any, List, Union, Callable, Iterable, Type, TypeVar, cast, \
10 NamedTuple, Mapping, Iterator
11
12 import yaml
13
14 from ceph.deployment.hostspec import HostSpec, SpecValidationError, assert_valid_host
15 from ceph.deployment.utils import unwrap_ipv6, valid_addr
16 from ceph.utils import is_hex
17
18 ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec')
19 FuncT = TypeVar('FuncT', bound=Callable)
20
21
22 def handle_type_error(method: FuncT) -> FuncT:
23 @wraps(method)
24 def inner(cls: Any, *args: Any, **kwargs: Any) -> Any:
25 try:
26 return method(cls, *args, **kwargs)
27 except (TypeError, AttributeError) as e:
28 error_msg = '{}: {}'.format(cls.__name__, e)
29 raise SpecValidationError(error_msg)
30 return cast(FuncT, inner)
31
32
33 class HostPlacementSpec(NamedTuple):
34 hostname: str
35 network: str
36 name: str
37
38 def __str__(self) -> str:
39 res = ''
40 res += self.hostname
41 if self.network:
42 res += ':' + self.network
43 if self.name:
44 res += '=' + self.name
45 return res
46
47 @classmethod
48 @handle_type_error
49 def from_json(cls, data: Union[dict, str]) -> 'HostPlacementSpec':
50 if isinstance(data, str):
51 return cls.parse(data)
52 return cls(**data)
53
54 def to_json(self) -> str:
55 return str(self)
56
57 @classmethod
58 def parse(cls, host, require_network=True):
59 # type: (str, bool) -> HostPlacementSpec
60 """
61 Split host into host, network, and (optional) daemon name parts. The network
62 part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'.
63 e.g.,
64 "myhost"
65 "myhost=name"
66 "myhost:1.2.3.4"
67 "myhost:1.2.3.4=name"
68 "myhost:1.2.3.0/24"
69 "myhost:1.2.3.0/24=name"
70 "myhost:[v2:1.2.3.4:3000]=name"
71 "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name"
72 """
73 # Matches from start to : or = or until end of string
74 host_re = r'^(.*?)(:|=|$)'
75 # Matches from : to = or until end of string
76 ip_re = r':(.*?)(=|$)'
77 # Matches from = to end of string
78 name_re = r'=(.*?)$'
79
80 # assign defaults
81 host_spec = cls('', '', '')
82
83 match_host = re.search(host_re, host)
84 if match_host:
85 host_spec = host_spec._replace(hostname=match_host.group(1))
86
87 name_match = re.search(name_re, host)
88 if name_match:
89 host_spec = host_spec._replace(name=name_match.group(1))
90
91 ip_match = re.search(ip_re, host)
92 if ip_match:
93 host_spec = host_spec._replace(network=ip_match.group(1))
94
95 if not require_network:
96 return host_spec
97
98 networks = list() # type: List[str]
99 network = host_spec.network
100 # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478]
101 if ',' in network:
102 networks = [x for x in network.split(',')]
103 else:
104 if network != '':
105 networks.append(network)
106
107 for network in networks:
108 # only if we have versioned network configs
109 if network.startswith('v') or network.startswith('[v'):
110 # if this is ipv6 we can't just simply split on ':' so do
111 # a split once and rsplit once to leave us with just ipv6 addr
112 network = network.split(':', 1)[1]
113 network = network.rsplit(':', 1)[0]
114 try:
115 # if subnets are defined, also verify the validity
116 if '/' in network:
117 ip_network(network)
118 else:
119 ip_address(unwrap_ipv6(network))
120 except ValueError as e:
121 # logging?
122 raise e
123 host_spec.validate()
124 return host_spec
125
126 def validate(self) -> None:
127 assert_valid_host(self.hostname)
128
129
130 class PlacementSpec(object):
131 """
132 For APIs that need to specify a host subset
133 """
134
135 def __init__(self,
136 label=None, # type: Optional[str]
137 hosts=None, # type: Union[List[str],List[HostPlacementSpec], None]
138 count=None, # type: Optional[int]
139 count_per_host=None, # type: Optional[int]
140 host_pattern=None, # type: Optional[str]
141 ):
142 # type: (...) -> None
143 self.label = label
144 self.hosts = [] # type: List[HostPlacementSpec]
145
146 if hosts:
147 self.set_hosts(hosts)
148
149 self.count = count # type: Optional[int]
150 self.count_per_host = count_per_host # type: Optional[int]
151
152 #: fnmatch patterns to select hosts. Can also be a single host.
153 self.host_pattern = host_pattern # type: Optional[str]
154
155 self.validate()
156
157 def is_empty(self) -> bool:
158 return (
159 self.label is None
160 and not self.hosts
161 and not self.host_pattern
162 and self.count is None
163 and self.count_per_host is None
164 )
165
166 def __eq__(self, other: Any) -> bool:
167 if isinstance(other, PlacementSpec):
168 return self.label == other.label \
169 and self.hosts == other.hosts \
170 and self.count == other.count \
171 and self.host_pattern == other.host_pattern \
172 and self.count_per_host == other.count_per_host
173 return NotImplemented
174
175 def set_hosts(self, hosts: Union[List[str], List[HostPlacementSpec]]) -> None:
176 # To backpopulate the .hosts attribute when using labels or count
177 # in the orchestrator backend.
178 if all([isinstance(host, HostPlacementSpec) for host in hosts]):
179 self.hosts = hosts # type: ignore
180 else:
181 self.hosts = [HostPlacementSpec.parse(x, require_network=False) # type: ignore
182 for x in hosts if x]
183
184 # deprecated
185 def filter_matching_hosts(self, _get_hosts_func: Callable) -> List[str]:
186 return self.filter_matching_hostspecs(_get_hosts_func(as_hostspec=True))
187
188 def filter_matching_hostspecs(self, hostspecs: Iterable[HostSpec]) -> List[str]:
189 if self.hosts:
190 all_hosts = [hs.hostname for hs in hostspecs]
191 return [h.hostname for h in self.hosts if h.hostname in all_hosts]
192 if self.label:
193 return [hs.hostname for hs in hostspecs if self.label in hs.labels]
194 all_hosts = [hs.hostname for hs in hostspecs]
195 if self.host_pattern:
196 return fnmatch.filter(all_hosts, self.host_pattern)
197 return all_hosts
198
199 def get_target_count(self, hostspecs: Iterable[HostSpec]) -> int:
200 if self.count:
201 return self.count
202 return len(self.filter_matching_hostspecs(hostspecs)) * (self.count_per_host or 1)
203
204 def pretty_str(self) -> str:
205 """
206 >>> #doctest: +SKIP
207 ... ps = PlacementSpec(...) # For all placement specs:
208 ... PlacementSpec.from_string(ps.pretty_str()) == ps
209 """
210 kv = []
211 if self.hosts:
212 kv.append(';'.join([str(h) for h in self.hosts]))
213 if self.count:
214 kv.append('count:%d' % self.count)
215 if self.count_per_host:
216 kv.append('count-per-host:%d' % self.count_per_host)
217 if self.label:
218 kv.append('label:%s' % self.label)
219 if self.host_pattern:
220 kv.append(self.host_pattern)
221 return ';'.join(kv)
222
223 def __repr__(self) -> str:
224 kv = []
225 if self.count:
226 kv.append('count=%d' % self.count)
227 if self.count_per_host:
228 kv.append('count_per_host=%d' % self.count_per_host)
229 if self.label:
230 kv.append('label=%s' % repr(self.label))
231 if self.hosts:
232 kv.append('hosts={!r}'.format(self.hosts))
233 if self.host_pattern:
234 kv.append('host_pattern={!r}'.format(self.host_pattern))
235 return "PlacementSpec(%s)" % ', '.join(kv)
236
237 @classmethod
238 @handle_type_error
239 def from_json(cls, data: dict) -> 'PlacementSpec':
240 c = data.copy()
241 hosts = c.get('hosts', [])
242 if hosts:
243 c['hosts'] = []
244 for host in hosts:
245 c['hosts'].append(HostPlacementSpec.from_json(host))
246 _cls = cls(**c)
247 _cls.validate()
248 return _cls
249
250 def to_json(self) -> dict:
251 r: Dict[str, Any] = {}
252 if self.label:
253 r['label'] = self.label
254 if self.hosts:
255 r['hosts'] = [host.to_json() for host in self.hosts]
256 if self.count:
257 r['count'] = self.count
258 if self.count_per_host:
259 r['count_per_host'] = self.count_per_host
260 if self.host_pattern:
261 r['host_pattern'] = self.host_pattern
262 return r
263
264 def validate(self) -> None:
265 if self.hosts and self.label:
266 # TODO: a less generic Exception
267 raise SpecValidationError('Host and label are mutually exclusive')
268 if self.count is not None:
269 try:
270 intval = int(self.count)
271 except (ValueError, TypeError):
272 raise SpecValidationError("num/count must be a numeric value")
273 if self.count != intval:
274 raise SpecValidationError("num/count must be an integer value")
275 if self.count < 1:
276 raise SpecValidationError("num/count must be >= 1")
277 if self.count_per_host is not None:
278 try:
279 intval = int(self.count_per_host)
280 except (ValueError, TypeError):
281 raise SpecValidationError("count-per-host must be a numeric value")
282 if self.count_per_host != intval:
283 raise SpecValidationError("count-per-host must be an integer value")
284 if self.count_per_host < 1:
285 raise SpecValidationError("count-per-host must be >= 1")
286 if self.count_per_host is not None and not (
287 self.label
288 or self.hosts
289 or self.host_pattern
290 ):
291 raise SpecValidationError(
292 "count-per-host must be combined with label or hosts or host_pattern"
293 )
294 if self.count is not None and self.count_per_host is not None:
295 raise SpecValidationError("cannot combine count and count-per-host")
296 if (
297 self.count_per_host is not None
298 and self.hosts
299 and any([hs.network or hs.name for hs in self.hosts])
300 ):
301 raise SpecValidationError(
302 "count-per-host cannot be combined explicit placement with names or networks"
303 )
304 if self.host_pattern:
305 if not isinstance(self.host_pattern, str):
306 raise SpecValidationError('host_pattern must be of type string')
307 if self.hosts:
308 raise SpecValidationError('cannot combine host patterns and hosts')
309
310 for h in self.hosts:
311 h.validate()
312
313 @classmethod
314 def from_string(cls, arg):
315 # type: (Optional[str]) -> PlacementSpec
316 """
317 A single integer is parsed as a count:
318
319 >>> PlacementSpec.from_string('3')
320 PlacementSpec(count=3)
321
322 A list of names is parsed as host specifications:
323
324 >>> PlacementSpec.from_string('host1 host2')
325 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
326 tSpec(hostname='host2', network='', name='')])
327
328 You can also prefix the hosts with a count as follows:
329
330 >>> PlacementSpec.from_string('2 host1 host2')
331 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
332 tPlacementSpec(hostname='host2', network='', name='')])
333
334 You can specify labels using `label:<label>`
335
336 >>> PlacementSpec.from_string('label:mon')
337 PlacementSpec(label='mon')
338
339 Labels also support a count:
340
341 >>> PlacementSpec.from_string('3 label:mon')
342 PlacementSpec(count=3, label='mon')
343
344 fnmatch is also supported:
345
346 >>> PlacementSpec.from_string('data[1-3]')
347 PlacementSpec(host_pattern='data[1-3]')
348
349 >>> PlacementSpec.from_string(None)
350 PlacementSpec()
351 """
352 if arg is None or not arg:
353 strings = []
354 elif isinstance(arg, str):
355 if ' ' in arg:
356 strings = arg.split(' ')
357 elif ';' in arg:
358 strings = arg.split(';')
359 elif ',' in arg and '[' not in arg:
360 # FIXME: this isn't quite right. we want to avoid breaking
361 # a list of mons with addrvecs... so we're basically allowing
362 # , most of the time, except when addrvecs are used. maybe
363 # ok?
364 strings = arg.split(',')
365 else:
366 strings = [arg]
367 else:
368 raise SpecValidationError('invalid placement %s' % arg)
369
370 count = None
371 count_per_host = None
372 if strings:
373 try:
374 count = int(strings[0])
375 strings = strings[1:]
376 except ValueError:
377 pass
378 for s in strings:
379 if s.startswith('count:'):
380 try:
381 count = int(s[len('count:'):])
382 strings.remove(s)
383 break
384 except ValueError:
385 pass
386 for s in strings:
387 if s.startswith('count-per-host:'):
388 try:
389 count_per_host = int(s[len('count-per-host:'):])
390 strings.remove(s)
391 break
392 except ValueError:
393 pass
394
395 advanced_hostspecs = [h for h in strings if
396 (':' in h or '=' in h or not any(c in '[]?*:=' for c in h)) and
397 'label:' not in h]
398 for a_h in advanced_hostspecs:
399 strings.remove(a_h)
400
401 labels = [x for x in strings if 'label:' in x]
402 if len(labels) > 1:
403 raise SpecValidationError('more than one label provided: {}'.format(labels))
404 for l in labels:
405 strings.remove(l)
406 label = labels[0][6:] if labels else None
407
408 host_patterns = strings
409 if len(host_patterns) > 1:
410 raise SpecValidationError(
411 'more than one host pattern provided: {}'.format(host_patterns))
412
413 ps = PlacementSpec(count=count,
414 count_per_host=count_per_host,
415 hosts=advanced_hostspecs,
416 label=label,
417 host_pattern=host_patterns[0] if host_patterns else None)
418 return ps
419
420
421 _service_spec_from_json_validate = True
422
423
424 class CustomConfig:
425 """
426 Class to specify custom config files to be mounted in daemon's container
427 """
428
429 _fields = ['content', 'mount_path']
430
431 def __init__(self, content: str, mount_path: str) -> None:
432 self.content: str = content
433 self.mount_path: str = mount_path
434 self.validate()
435
436 def to_json(self) -> Dict[str, Any]:
437 return {
438 'content': self.content,
439 'mount_path': self.mount_path,
440 }
441
442 @classmethod
443 def from_json(cls, data: Dict[str, Any]) -> "CustomConfig":
444 for k in cls._fields:
445 if k not in data:
446 raise SpecValidationError(f'CustomConfig must have "{k}" field')
447 for k in data.keys():
448 if k not in cls._fields:
449 raise SpecValidationError(f'CustomConfig got unknown field "{k}"')
450 return cls(**data)
451
452 @property
453 def filename(self) -> str:
454 return os.path.basename(self.mount_path)
455
456 def __eq__(self, other: Any) -> bool:
457 if isinstance(other, CustomConfig):
458 return (
459 self.content == other.content
460 and self.mount_path == other.mount_path
461 )
462 return NotImplemented
463
464 def __repr__(self) -> str:
465 return f'CustomConfig({self.mount_path})'
466
467 def validate(self) -> None:
468 if not isinstance(self.content, str):
469 raise SpecValidationError(
470 f'CustomConfig content must be a string. Got {type(self.content)}')
471 if not isinstance(self.mount_path, str):
472 raise SpecValidationError(
473 f'CustomConfig content must be a string. Got {type(self.mount_path)}')
474
475
476 @contextmanager
477 def service_spec_allow_invalid_from_json() -> Iterator[None]:
478 """
479 I know this is evil, but unfortunately `ceph orch ls`
480 may return invalid OSD specs for OSDs not associated to
481 and specs. If you have a better idea, please!
482 """
483 global _service_spec_from_json_validate
484 _service_spec_from_json_validate = False
485 yield
486 _service_spec_from_json_validate = True
487
488
489 class ServiceSpec(object):
490 """
491 Details of service creation.
492
493 Request to the orchestrator for a cluster of daemons
494 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
495
496 This structure is supposed to be enough information to
497 start the services.
498 """
499 KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi loki promtail mds mgr mon nfs ' \
500 'node-exporter osd prometheus rbd-mirror rgw agent ceph-exporter ' \
501 'container ingress cephfs-mirror snmp-gateway jaeger-tracing ' \
502 'elasticsearch jaeger-agent jaeger-collector jaeger-query'.split()
503 REQUIRES_SERVICE_ID = 'iscsi mds nfs rgw container ingress '.split()
504 MANAGED_CONFIG_OPTIONS = [
505 'mds_join_fs',
506 ]
507
508 @classmethod
509 def _cls(cls: Type[ServiceSpecT], service_type: str) -> Type[ServiceSpecT]:
510 from ceph.deployment.drive_group import DriveGroupSpec
511
512 ret = {
513 'mon': MONSpec,
514 'rgw': RGWSpec,
515 'nfs': NFSServiceSpec,
516 'osd': DriveGroupSpec,
517 'mds': MDSSpec,
518 'iscsi': IscsiServiceSpec,
519 'alertmanager': AlertManagerSpec,
520 'ingress': IngressSpec,
521 'container': CustomContainerSpec,
522 'grafana': GrafanaSpec,
523 'node-exporter': MonitoringSpec,
524 'ceph-exporter': CephExporterSpec,
525 'prometheus': PrometheusSpec,
526 'loki': MonitoringSpec,
527 'promtail': MonitoringSpec,
528 'snmp-gateway': SNMPGatewaySpec,
529 'elasticsearch': TracingSpec,
530 'jaeger-agent': TracingSpec,
531 'jaeger-collector': TracingSpec,
532 'jaeger-query': TracingSpec,
533 'jaeger-tracing': TracingSpec,
534 }.get(service_type, cls)
535 if ret == ServiceSpec and not service_type:
536 raise SpecValidationError('Spec needs a "service_type" key.')
537 return ret
538
539 def __new__(cls: Type[ServiceSpecT], *args: Any, **kwargs: Any) -> ServiceSpecT:
540 """
541 Some Python foo to make sure, we don't have an object
542 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
543
544 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
545 True
546
547 """
548 if cls != ServiceSpec:
549 return object.__new__(cls)
550 service_type = kwargs.get('service_type', args[0] if args else None)
551 sub_cls: Any = cls._cls(service_type)
552 return object.__new__(sub_cls)
553
554 def __init__(self,
555 service_type: str,
556 service_id: Optional[str] = None,
557 placement: Optional[PlacementSpec] = None,
558 count: Optional[int] = None,
559 config: Optional[Dict[str, str]] = None,
560 unmanaged: bool = False,
561 preview_only: bool = False,
562 networks: Optional[List[str]] = None,
563 extra_container_args: Optional[List[str]] = None,
564 extra_entrypoint_args: Optional[List[str]] = None,
565 custom_configs: Optional[List[CustomConfig]] = None,
566 ):
567
568 #: See :ref:`orchestrator-cli-placement-spec`.
569 self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec
570
571 assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type
572 #: The type of the service. Needs to be either a Ceph
573 #: service (``mon``, ``crash``, ``mds``, ``mgr``, ``osd`` or
574 #: ``rbd-mirror``), a gateway (``nfs`` or ``rgw``), part of the
575 #: monitoring stack (``alertmanager``, ``grafana``, ``node-exporter`` or
576 #: ``prometheus``) or (``container``) for custom containers.
577 self.service_type = service_type
578
579 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
580 #: ``container``, ``ingress``
581 self.service_id = None
582
583 if self.service_type in self.REQUIRES_SERVICE_ID or self.service_type == 'osd':
584 self.service_id = service_id
585
586 #: If set to ``true``, the orchestrator will not deploy nor remove
587 #: any daemon associated with this service. Placement and all other properties
588 #: will be ignored. This is useful, if you do not want this service to be
589 #: managed temporarily. For cephadm, See :ref:`cephadm-spec-unmanaged`
590 self.unmanaged = unmanaged
591 self.preview_only = preview_only
592
593 #: A list of network identities instructing the daemons to only bind
594 #: on the particular networks in that list. In case the cluster is distributed
595 #: across multiple networks, you can add multiple networks. See
596 #: :ref:`cephadm-monitoring-networks-ports`,
597 #: :ref:`cephadm-rgw-networks` and :ref:`cephadm-mgr-networks`.
598 self.networks: List[str] = networks or []
599
600 self.config: Optional[Dict[str, str]] = None
601 if config:
602 self.config = {k.replace(' ', '_'): v for k, v in config.items()}
603
604 self.extra_container_args: Optional[List[str]] = extra_container_args
605 self.extra_entrypoint_args: Optional[List[str]] = extra_entrypoint_args
606 self.custom_configs: Optional[List[CustomConfig]] = custom_configs
607
608 @classmethod
609 @handle_type_error
610 def from_json(cls: Type[ServiceSpecT], json_spec: Dict) -> ServiceSpecT:
611 """
612 Initialize 'ServiceSpec' object data from a json structure
613
614 There are two valid styles for service specs:
615
616 the "old" style:
617
618 .. code:: yaml
619
620 service_type: nfs
621 service_id: foo
622 pool: mypool
623 namespace: myns
624
625 and the "new" style:
626
627 .. code:: yaml
628
629 service_type: nfs
630 service_id: foo
631 config:
632 some_option: the_value
633 networks: [10.10.0.0/16]
634 spec:
635 pool: mypool
636 namespace: myns
637
638 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
639 prefer the new style as it is more readable and provides a better
640 understanding of what fields are special for a give service type.
641
642 Note, we'll need to stay compatible with both versions for the
643 the next two major releases (octopus, pacific).
644
645 :param json_spec: A valid dict with ServiceSpec
646
647 :meta private:
648 """
649 if not isinstance(json_spec, dict):
650 raise SpecValidationError(
651 f'Service Spec is not an (JSON or YAML) object. got "{str(json_spec)}"')
652
653 json_spec = cls.normalize_json(json_spec)
654
655 c = json_spec.copy()
656
657 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
658 # Open question: Remove `service_id` form to_json?
659 if c.get('service_name', ''):
660 service_type_id = c['service_name'].split('.', 1)
661
662 if not c.get('service_type', ''):
663 c['service_type'] = service_type_id[0]
664 if not c.get('service_id', '') and len(service_type_id) > 1:
665 c['service_id'] = service_type_id[1]
666 del c['service_name']
667
668 service_type = c.get('service_type', '')
669 _cls = cls._cls(service_type)
670
671 if 'status' in c:
672 del c['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
673
674 return _cls._from_json_impl(c) # type: ignore
675
676 @staticmethod
677 def normalize_json(json_spec: dict) -> dict:
678 networks = json_spec.get('networks')
679 if networks is None:
680 return json_spec
681 if isinstance(networks, list):
682 return json_spec
683 if not isinstance(networks, str):
684 raise SpecValidationError(f'Networks ({networks}) must be a string or list of strings')
685 json_spec['networks'] = [networks]
686 return json_spec
687
688 @classmethod
689 def _from_json_impl(cls: Type[ServiceSpecT], json_spec: dict) -> ServiceSpecT:
690 args = {} # type: Dict[str, Any]
691 for k, v in json_spec.items():
692 if k == 'placement':
693 v = PlacementSpec.from_json(v)
694 if k == 'custom_configs':
695 v = [CustomConfig.from_json(c) for c in v]
696 if k == 'spec':
697 args.update(v)
698 continue
699 args.update({k: v})
700 _cls = cls(**args)
701 if _service_spec_from_json_validate:
702 _cls.validate()
703 return _cls
704
705 def service_name(self) -> str:
706 n = self.service_type
707 if self.service_id:
708 n += '.' + self.service_id
709 return n
710
711 def get_port_start(self) -> List[int]:
712 # If defined, we will allocate and number ports starting at this
713 # point.
714 return []
715
716 def get_virtual_ip(self) -> Optional[str]:
717 return None
718
719 def to_json(self):
720 # type: () -> OrderedDict[str, Any]
721 ret: OrderedDict[str, Any] = OrderedDict()
722 ret['service_type'] = self.service_type
723 if self.service_id:
724 ret['service_id'] = self.service_id
725 ret['service_name'] = self.service_name()
726 if self.placement.to_json():
727 ret['placement'] = self.placement.to_json()
728 if self.unmanaged:
729 ret['unmanaged'] = self.unmanaged
730 if self.networks:
731 ret['networks'] = self.networks
732 if self.extra_container_args:
733 ret['extra_container_args'] = self.extra_container_args
734 if self.extra_entrypoint_args:
735 ret['extra_entrypoint_args'] = self.extra_entrypoint_args
736 if self.custom_configs:
737 ret['custom_configs'] = [c.to_json() for c in self.custom_configs]
738
739 c = {}
740 for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]):
741 if key in ret:
742 continue
743 if hasattr(val, 'to_json'):
744 val = val.to_json()
745 if val:
746 c[key] = val
747 if c:
748 ret['spec'] = c
749 return ret
750
751 def validate(self) -> None:
752 if not self.service_type:
753 raise SpecValidationError('Cannot add Service: type required')
754
755 if self.service_type != 'osd':
756 if self.service_type in self.REQUIRES_SERVICE_ID and not self.service_id:
757 raise SpecValidationError('Cannot add Service: id required')
758 if self.service_type not in self.REQUIRES_SERVICE_ID and self.service_id:
759 raise SpecValidationError(
760 f'Service of type \'{self.service_type}\' should not contain a service id')
761
762 if self.service_id:
763 if not re.match('^[a-zA-Z0-9_.-]+$', str(self.service_id)):
764 raise SpecValidationError('Service id contains invalid characters, '
765 'only [a-zA-Z0-9_.-] allowed')
766
767 if self.placement is not None:
768 self.placement.validate()
769 if self.config:
770 for k, v in self.config.items():
771 if k in self.MANAGED_CONFIG_OPTIONS:
772 raise SpecValidationError(
773 f'Cannot set config option {k} in spec: it is managed by cephadm'
774 )
775 for network in self.networks or []:
776 try:
777 ip_network(network)
778 except ValueError as e:
779 raise SpecValidationError(
780 f'Cannot parse network {network}: {e}'
781 )
782
783 def __repr__(self) -> str:
784 y = yaml.dump(cast(dict, self), default_flow_style=False)
785 return f"{self.__class__.__name__}.from_json(yaml.safe_load('''{y}'''))"
786
787 def __eq__(self, other: Any) -> bool:
788 return (self.__class__ == other.__class__
789 and
790 self.__dict__ == other.__dict__)
791
792 def one_line_str(self) -> str:
793 return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name())
794
795 @staticmethod
796 def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec') -> Any:
797 return dumper.represent_dict(cast(Mapping, data.to_json().items()))
798
799
800 yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer)
801
802
803 class NFSServiceSpec(ServiceSpec):
804 def __init__(self,
805 service_type: str = 'nfs',
806 service_id: Optional[str] = None,
807 placement: Optional[PlacementSpec] = None,
808 unmanaged: bool = False,
809 preview_only: bool = False,
810 config: Optional[Dict[str, str]] = None,
811 networks: Optional[List[str]] = None,
812 port: Optional[int] = None,
813 virtual_ip: Optional[str] = None,
814 extra_container_args: Optional[List[str]] = None,
815 extra_entrypoint_args: Optional[List[str]] = None,
816 custom_configs: Optional[List[CustomConfig]] = None,
817 ):
818 assert service_type == 'nfs'
819 super(NFSServiceSpec, self).__init__(
820 'nfs', service_id=service_id,
821 placement=placement, unmanaged=unmanaged, preview_only=preview_only,
822 config=config, networks=networks, extra_container_args=extra_container_args,
823 extra_entrypoint_args=extra_entrypoint_args, custom_configs=custom_configs)
824
825 self.port = port
826 self.virtual_ip = virtual_ip
827
828 def get_port_start(self) -> List[int]:
829 if self.port:
830 return [self.port]
831 return []
832
833 def rados_config_name(self):
834 # type: () -> str
835 return 'conf-' + self.service_name()
836
837
838 yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer)
839
840
841 class RGWSpec(ServiceSpec):
842 """
843 Settings to configure a (multisite) Ceph RGW
844
845 .. code-block:: yaml
846
847 service_type: rgw
848 service_id: myrealm.myzone
849 spec:
850 rgw_realm: myrealm
851 rgw_zonegroup: myzonegroup
852 rgw_zone: myzone
853 ssl: true
854 rgw_frontend_port: 1234
855 rgw_frontend_type: beast
856 rgw_frontend_ssl_certificate: ...
857
858 See also: :ref:`orchestrator-cli-service-spec`
859 """
860
861 MANAGED_CONFIG_OPTIONS = ServiceSpec.MANAGED_CONFIG_OPTIONS + [
862 'rgw_zone',
863 'rgw_realm',
864 'rgw_zonegroup',
865 'rgw_frontends',
866 ]
867
868 def __init__(self,
869 service_type: str = 'rgw',
870 service_id: Optional[str] = None,
871 placement: Optional[PlacementSpec] = None,
872 rgw_realm: Optional[str] = None,
873 rgw_zonegroup: Optional[str] = None,
874 rgw_zone: Optional[str] = None,
875 rgw_frontend_port: Optional[int] = None,
876 rgw_frontend_ssl_certificate: Optional[List[str]] = None,
877 rgw_frontend_type: Optional[str] = None,
878 rgw_frontend_extra_args: Optional[List[str]] = None,
879 unmanaged: bool = False,
880 ssl: bool = False,
881 preview_only: bool = False,
882 config: Optional[Dict[str, str]] = None,
883 networks: Optional[List[str]] = None,
884 subcluster: Optional[str] = None, # legacy, only for from_json on upgrade
885 extra_container_args: Optional[List[str]] = None,
886 extra_entrypoint_args: Optional[List[str]] = None,
887 custom_configs: Optional[List[CustomConfig]] = None,
888 rgw_realm_token: Optional[str] = None,
889 update_endpoints: Optional[bool] = False,
890 zone_endpoints: Optional[str] = None # commad separated endpoints list
891 ):
892 assert service_type == 'rgw', service_type
893
894 # for backward compatibility with octopus spec files,
895 if not service_id and (rgw_realm and rgw_zone):
896 service_id = rgw_realm + '.' + rgw_zone
897
898 super(RGWSpec, self).__init__(
899 'rgw', service_id=service_id,
900 placement=placement, unmanaged=unmanaged,
901 preview_only=preview_only, config=config, networks=networks,
902 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
903 custom_configs=custom_configs)
904
905 #: The RGW realm associated with this service. Needs to be manually created
906 #: if the spec is being applied directly to cephdam. In case of rgw module
907 #: the realm is created automatically.
908 self.rgw_realm: Optional[str] = rgw_realm
909 #: The RGW zonegroup associated with this service. Needs to be manually created
910 #: if the spec is being applied directly to cephdam. In case of rgw module
911 #: the zonegroup is created automatically.
912 self.rgw_zonegroup: Optional[str] = rgw_zonegroup
913 #: The RGW zone associated with this service. Needs to be manually created
914 #: if the spec is being applied directly to cephdam. In case of rgw module
915 #: the zone is created automatically.
916 self.rgw_zone: Optional[str] = rgw_zone
917 #: Port of the RGW daemons
918 self.rgw_frontend_port: Optional[int] = rgw_frontend_port
919 #: List of SSL certificates
920 self.rgw_frontend_ssl_certificate: Optional[List[str]] = rgw_frontend_ssl_certificate
921 #: civetweb or beast (default: beast). See :ref:`rgw_frontends`
922 self.rgw_frontend_type: Optional[str] = rgw_frontend_type
923 #: List of extra arguments for rgw_frontend in the form opt=value. See :ref:`rgw_frontends`
924 self.rgw_frontend_extra_args: Optional[List[str]] = rgw_frontend_extra_args
925 #: enable SSL
926 self.ssl = ssl
927 self.rgw_realm_token = rgw_realm_token
928 self.update_endpoints = update_endpoints
929 self.zone_endpoints = zone_endpoints
930
931 def get_port_start(self) -> List[int]:
932 return [self.get_port()]
933
934 def get_port(self) -> int:
935 if self.rgw_frontend_port:
936 return self.rgw_frontend_port
937 if self.ssl:
938 return 443
939 else:
940 return 80
941
942 def validate(self) -> None:
943 super(RGWSpec, self).validate()
944
945 if self.rgw_realm and not self.rgw_zone:
946 raise SpecValidationError(
947 'Cannot add RGW: Realm specified but no zone specified')
948 if self.rgw_zone and not self.rgw_realm:
949 raise SpecValidationError('Cannot add RGW: Zone specified but no realm specified')
950
951 if self.rgw_frontend_type is not None:
952 if self.rgw_frontend_type not in ['beast', 'civetweb']:
953 raise SpecValidationError(
954 'Invalid rgw_frontend_type value. Valid values are: beast, civetweb.\n'
955 'Additional rgw type parameters can be passed using rgw_frontend_extra_args.'
956 )
957
958
959 yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer)
960
961
962 class IscsiServiceSpec(ServiceSpec):
963 def __init__(self,
964 service_type: str = 'iscsi',
965 service_id: Optional[str] = None,
966 pool: Optional[str] = None,
967 trusted_ip_list: Optional[str] = None,
968 api_port: Optional[int] = 5000,
969 api_user: Optional[str] = 'admin',
970 api_password: Optional[str] = 'admin',
971 api_secure: Optional[bool] = None,
972 ssl_cert: Optional[str] = None,
973 ssl_key: Optional[str] = None,
974 placement: Optional[PlacementSpec] = None,
975 unmanaged: bool = False,
976 preview_only: bool = False,
977 config: Optional[Dict[str, str]] = None,
978 networks: Optional[List[str]] = None,
979 extra_container_args: Optional[List[str]] = None,
980 extra_entrypoint_args: Optional[List[str]] = None,
981 custom_configs: Optional[List[CustomConfig]] = None,
982 ):
983 assert service_type == 'iscsi'
984 super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id,
985 placement=placement, unmanaged=unmanaged,
986 preview_only=preview_only,
987 config=config, networks=networks,
988 extra_container_args=extra_container_args,
989 extra_entrypoint_args=extra_entrypoint_args,
990 custom_configs=custom_configs)
991
992 #: RADOS pool where ceph-iscsi config data is stored.
993 self.pool = pool
994 #: list of trusted IP addresses
995 self.trusted_ip_list = trusted_ip_list
996 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
997 self.api_port = api_port
998 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
999 self.api_user = api_user
1000 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
1001 self.api_password = api_password
1002 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
1003 self.api_secure = api_secure
1004 #: SSL certificate
1005 self.ssl_cert = ssl_cert
1006 #: SSL private key
1007 self.ssl_key = ssl_key
1008
1009 if not self.api_secure and self.ssl_cert and self.ssl_key:
1010 self.api_secure = True
1011
1012 def get_port_start(self) -> List[int]:
1013 return [self.api_port or 5000]
1014
1015 def validate(self) -> None:
1016 super(IscsiServiceSpec, self).validate()
1017
1018 if not self.pool:
1019 raise SpecValidationError(
1020 'Cannot add ISCSI: No Pool specified')
1021
1022 # Do not need to check for api_user and api_password as they
1023 # now default to 'admin' when setting up the gateway url. Older
1024 # iSCSI specs from before this change should be fine as they will
1025 # have been required to have an api_user and api_password set and
1026 # will be unaffected by the new default value.
1027
1028
1029 yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer)
1030
1031
1032 class IngressSpec(ServiceSpec):
1033 def __init__(self,
1034 service_type: str = 'ingress',
1035 service_id: Optional[str] = None,
1036 config: Optional[Dict[str, str]] = None,
1037 networks: Optional[List[str]] = None,
1038 placement: Optional[PlacementSpec] = None,
1039 backend_service: Optional[str] = None,
1040 frontend_port: Optional[int] = None,
1041 ssl_cert: Optional[str] = None,
1042 ssl_key: Optional[str] = None,
1043 ssl_dh_param: Optional[str] = None,
1044 ssl_ciphers: Optional[List[str]] = None,
1045 ssl_options: Optional[List[str]] = None,
1046 monitor_port: Optional[int] = None,
1047 monitor_user: Optional[str] = None,
1048 monitor_password: Optional[str] = None,
1049 enable_stats: Optional[bool] = None,
1050 keepalived_password: Optional[str] = None,
1051 virtual_ip: Optional[str] = None,
1052 virtual_ips_list: Optional[List[str]] = None,
1053 virtual_interface_networks: Optional[List[str]] = [],
1054 unmanaged: bool = False,
1055 ssl: bool = False,
1056 keepalive_only: bool = False,
1057 extra_container_args: Optional[List[str]] = None,
1058 extra_entrypoint_args: Optional[List[str]] = None,
1059 custom_configs: Optional[List[CustomConfig]] = None,
1060 ):
1061 assert service_type == 'ingress'
1062
1063 super(IngressSpec, self).__init__(
1064 'ingress', service_id=service_id,
1065 placement=placement, config=config,
1066 networks=networks,
1067 extra_container_args=extra_container_args,
1068 extra_entrypoint_args=extra_entrypoint_args,
1069 custom_configs=custom_configs
1070 )
1071 self.backend_service = backend_service
1072 self.frontend_port = frontend_port
1073 self.ssl_cert = ssl_cert
1074 self.ssl_key = ssl_key
1075 self.ssl_dh_param = ssl_dh_param
1076 self.ssl_ciphers = ssl_ciphers
1077 self.ssl_options = ssl_options
1078 self.monitor_port = monitor_port
1079 self.monitor_user = monitor_user
1080 self.monitor_password = monitor_password
1081 self.keepalived_password = keepalived_password
1082 self.virtual_ip = virtual_ip
1083 self.virtual_ips_list = virtual_ips_list
1084 self.virtual_interface_networks = virtual_interface_networks or []
1085 self.unmanaged = unmanaged
1086 self.ssl = ssl
1087 self.keepalive_only = keepalive_only
1088
1089 def get_port_start(self) -> List[int]:
1090 ports = []
1091 if self.frontend_port is not None:
1092 ports.append(cast(int, self.frontend_port))
1093 if self.monitor_port is not None:
1094 ports.append(cast(int, self.monitor_port))
1095 return ports
1096
1097 def get_virtual_ip(self) -> Optional[str]:
1098 return self.virtual_ip
1099
1100 def validate(self) -> None:
1101 super(IngressSpec, self).validate()
1102
1103 if not self.backend_service:
1104 raise SpecValidationError(
1105 'Cannot add ingress: No backend_service specified')
1106 if not self.keepalive_only and not self.frontend_port:
1107 raise SpecValidationError(
1108 'Cannot add ingress: No frontend_port specified')
1109 if not self.monitor_port:
1110 raise SpecValidationError(
1111 'Cannot add ingress: No monitor_port specified')
1112 if not self.virtual_ip and not self.virtual_ips_list:
1113 raise SpecValidationError(
1114 'Cannot add ingress: No virtual_ip provided')
1115 if self.virtual_ip is not None and self.virtual_ips_list is not None:
1116 raise SpecValidationError(
1117 'Cannot add ingress: Single and multiple virtual IPs specified')
1118
1119
1120 yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer)
1121
1122
1123 class CustomContainerSpec(ServiceSpec):
1124 def __init__(self,
1125 service_type: str = 'container',
1126 service_id: Optional[str] = None,
1127 config: Optional[Dict[str, str]] = None,
1128 networks: Optional[List[str]] = None,
1129 placement: Optional[PlacementSpec] = None,
1130 unmanaged: bool = False,
1131 preview_only: bool = False,
1132 image: Optional[str] = None,
1133 entrypoint: Optional[str] = None,
1134 extra_entrypoint_args: Optional[List[str]] = None,
1135 uid: Optional[int] = None,
1136 gid: Optional[int] = None,
1137 volume_mounts: Optional[Dict[str, str]] = {},
1138 args: Optional[List[str]] = [], # args for the container runtime, not entrypoint
1139 envs: Optional[List[str]] = [],
1140 privileged: Optional[bool] = False,
1141 bind_mounts: Optional[List[List[str]]] = None,
1142 ports: Optional[List[int]] = [],
1143 dirs: Optional[List[str]] = [],
1144 files: Optional[Dict[str, Any]] = {},
1145 ):
1146 assert service_type == 'container'
1147 assert service_id is not None
1148 assert image is not None
1149
1150 super(CustomContainerSpec, self).__init__(
1151 service_type, service_id,
1152 placement=placement, unmanaged=unmanaged,
1153 preview_only=preview_only, config=config,
1154 networks=networks, extra_entrypoint_args=extra_entrypoint_args)
1155
1156 self.image = image
1157 self.entrypoint = entrypoint
1158 self.uid = uid
1159 self.gid = gid
1160 self.volume_mounts = volume_mounts
1161 self.args = args
1162 self.envs = envs
1163 self.privileged = privileged
1164 self.bind_mounts = bind_mounts
1165 self.ports = ports
1166 self.dirs = dirs
1167 self.files = files
1168
1169 def config_json(self) -> Dict[str, Any]:
1170 """
1171 Helper function to get the value of the `--config-json` cephadm
1172 command line option. It will contain all specification properties
1173 that haven't a `None` value. Such properties will get default
1174 values in cephadm.
1175 :return: Returns a dictionary containing all specification
1176 properties.
1177 """
1178 config_json = {}
1179 for prop in ['image', 'entrypoint', 'uid', 'gid', 'args',
1180 'envs', 'volume_mounts', 'privileged',
1181 'bind_mounts', 'ports', 'dirs', 'files']:
1182 value = getattr(self, prop)
1183 if value is not None:
1184 config_json[prop] = value
1185 return config_json
1186
1187
1188 yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer)
1189
1190
1191 class MonitoringSpec(ServiceSpec):
1192 def __init__(self,
1193 service_type: str,
1194 service_id: Optional[str] = None,
1195 config: Optional[Dict[str, str]] = None,
1196 networks: Optional[List[str]] = None,
1197 placement: Optional[PlacementSpec] = None,
1198 unmanaged: bool = False,
1199 preview_only: bool = False,
1200 port: Optional[int] = None,
1201 extra_container_args: Optional[List[str]] = None,
1202 extra_entrypoint_args: Optional[List[str]] = None,
1203 custom_configs: Optional[List[CustomConfig]] = None,
1204 ):
1205 assert service_type in ['grafana', 'node-exporter', 'prometheus', 'alertmanager',
1206 'loki', 'promtail']
1207
1208 super(MonitoringSpec, self).__init__(
1209 service_type, service_id,
1210 placement=placement, unmanaged=unmanaged,
1211 preview_only=preview_only, config=config,
1212 networks=networks, extra_container_args=extra_container_args,
1213 extra_entrypoint_args=extra_entrypoint_args,
1214 custom_configs=custom_configs)
1215
1216 self.service_type = service_type
1217 self.port = port
1218
1219 def get_port_start(self) -> List[int]:
1220 return [self.get_port()]
1221
1222 def get_port(self) -> int:
1223 if self.port:
1224 return self.port
1225 else:
1226 return {'prometheus': 9095,
1227 'node-exporter': 9100,
1228 'alertmanager': 9093,
1229 'grafana': 3000,
1230 'loki': 3100,
1231 'promtail': 9080}[self.service_type]
1232
1233
1234 yaml.add_representer(MonitoringSpec, ServiceSpec.yaml_representer)
1235
1236
1237 class AlertManagerSpec(MonitoringSpec):
1238 def __init__(self,
1239 service_type: str = 'alertmanager',
1240 service_id: Optional[str] = None,
1241 placement: Optional[PlacementSpec] = None,
1242 unmanaged: bool = False,
1243 preview_only: bool = False,
1244 user_data: Optional[Dict[str, Any]] = None,
1245 config: Optional[Dict[str, str]] = None,
1246 networks: Optional[List[str]] = None,
1247 port: Optional[int] = None,
1248 secure: bool = False,
1249 extra_container_args: Optional[List[str]] = None,
1250 extra_entrypoint_args: Optional[List[str]] = None,
1251 custom_configs: Optional[List[CustomConfig]] = None,
1252 ):
1253 assert service_type == 'alertmanager'
1254 super(AlertManagerSpec, self).__init__(
1255 'alertmanager', service_id=service_id,
1256 placement=placement, unmanaged=unmanaged,
1257 preview_only=preview_only, config=config, networks=networks, port=port,
1258 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
1259 custom_configs=custom_configs)
1260
1261 # Custom configuration.
1262 #
1263 # Example:
1264 # service_type: alertmanager
1265 # service_id: xyz
1266 # user_data:
1267 # default_webhook_urls:
1268 # - "https://foo"
1269 # - "https://bar"
1270 #
1271 # Documentation:
1272 # default_webhook_urls - A list of additional URL's that are
1273 # added to the default receivers'
1274 # <webhook_configs> configuration.
1275 self.user_data = user_data or {}
1276 self.secure = secure
1277
1278 def get_port_start(self) -> List[int]:
1279 return [self.get_port(), 9094]
1280
1281 def validate(self) -> None:
1282 super(AlertManagerSpec, self).validate()
1283
1284 if self.port == 9094:
1285 raise SpecValidationError(
1286 'Port 9094 is reserved for AlertManager cluster listen address')
1287
1288
1289 yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer)
1290
1291
1292 class GrafanaSpec(MonitoringSpec):
1293 def __init__(self,
1294 service_type: str = 'grafana',
1295 service_id: Optional[str] = None,
1296 placement: Optional[PlacementSpec] = None,
1297 unmanaged: bool = False,
1298 preview_only: bool = False,
1299 config: Optional[Dict[str, str]] = None,
1300 networks: Optional[List[str]] = None,
1301 port: Optional[int] = None,
1302 protocol: Optional[str] = 'https',
1303 initial_admin_password: Optional[str] = None,
1304 anonymous_access: Optional[bool] = True,
1305 extra_container_args: Optional[List[str]] = None,
1306 extra_entrypoint_args: Optional[List[str]] = None,
1307 custom_configs: Optional[List[CustomConfig]] = None,
1308 ):
1309 assert service_type == 'grafana'
1310 super(GrafanaSpec, self).__init__(
1311 'grafana', service_id=service_id,
1312 placement=placement, unmanaged=unmanaged,
1313 preview_only=preview_only, config=config, networks=networks, port=port,
1314 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
1315 custom_configs=custom_configs)
1316
1317 self.initial_admin_password = initial_admin_password
1318 self.anonymous_access = anonymous_access
1319 self.protocol = protocol
1320
1321 def validate(self) -> None:
1322 super(GrafanaSpec, self).validate()
1323 if self.protocol not in ['http', 'https']:
1324 err_msg = f"Invalid protocol '{self.protocol}'. Valid values are: 'http', 'https'."
1325 raise SpecValidationError(err_msg)
1326
1327 if not self.anonymous_access and not self.initial_admin_password:
1328 err_msg = ('Either initial_admin_password must be set or anonymous_access '
1329 'must be set to true. Otherwise the grafana dashboard will '
1330 'be inaccessible.')
1331 raise SpecValidationError(err_msg)
1332
1333
1334 yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer)
1335
1336
1337 class PrometheusSpec(MonitoringSpec):
1338 def __init__(self,
1339 service_type: str = 'prometheus',
1340 service_id: Optional[str] = None,
1341 placement: Optional[PlacementSpec] = None,
1342 unmanaged: bool = False,
1343 preview_only: bool = False,
1344 config: Optional[Dict[str, str]] = None,
1345 networks: Optional[List[str]] = None,
1346 port: Optional[int] = None,
1347 retention_time: Optional[str] = None,
1348 retention_size: Optional[str] = None,
1349 extra_container_args: Optional[List[str]] = None,
1350 extra_entrypoint_args: Optional[List[str]] = None,
1351 custom_configs: Optional[List[CustomConfig]] = None,
1352 ):
1353 assert service_type == 'prometheus'
1354 super(PrometheusSpec, self).__init__(
1355 'prometheus', service_id=service_id,
1356 placement=placement, unmanaged=unmanaged,
1357 preview_only=preview_only, config=config, networks=networks, port=port,
1358 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
1359 custom_configs=custom_configs)
1360
1361 self.retention_time = retention_time.strip() if retention_time else None
1362 self.retention_size = retention_size.strip() if retention_size else None
1363
1364 def validate(self) -> None:
1365 super(PrometheusSpec, self).validate()
1366
1367 if self.retention_time:
1368 valid_units = ['y', 'w', 'd', 'h', 'm', 's']
1369 m = re.search(rf"^(\d+)({'|'.join(valid_units)})$", self.retention_time)
1370 if not m:
1371 units = ', '.join(valid_units)
1372 raise SpecValidationError(f"Invalid retention time. Valid units are: {units}")
1373 if self.retention_size:
1374 valid_units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']
1375 m = re.search(rf"^(\d+)({'|'.join(valid_units)})$", self.retention_size)
1376 if not m:
1377 units = ', '.join(valid_units)
1378 raise SpecValidationError(f"Invalid retention size. Valid units are: {units}")
1379
1380
1381 yaml.add_representer(PrometheusSpec, ServiceSpec.yaml_representer)
1382
1383
1384 class SNMPGatewaySpec(ServiceSpec):
1385 class SNMPVersion(str, enum.Enum):
1386 V2c = 'V2c'
1387 V3 = 'V3'
1388
1389 def to_json(self) -> str:
1390 return self.value
1391
1392 class SNMPAuthType(str, enum.Enum):
1393 MD5 = 'MD5'
1394 SHA = 'SHA'
1395
1396 def to_json(self) -> str:
1397 return self.value
1398
1399 class SNMPPrivacyType(str, enum.Enum):
1400 DES = 'DES'
1401 AES = 'AES'
1402
1403 def to_json(self) -> str:
1404 return self.value
1405
1406 valid_destination_types = [
1407 'Name:Port',
1408 'IPv4:Port'
1409 ]
1410
1411 def __init__(self,
1412 service_type: str = 'snmp-gateway',
1413 snmp_version: Optional[SNMPVersion] = None,
1414 snmp_destination: str = '',
1415 credentials: Dict[str, str] = {},
1416 engine_id: Optional[str] = None,
1417 auth_protocol: Optional[SNMPAuthType] = None,
1418 privacy_protocol: Optional[SNMPPrivacyType] = None,
1419 placement: Optional[PlacementSpec] = None,
1420 unmanaged: bool = False,
1421 preview_only: bool = False,
1422 port: Optional[int] = None,
1423 extra_container_args: Optional[List[str]] = None,
1424 extra_entrypoint_args: Optional[List[str]] = None,
1425 custom_configs: Optional[List[CustomConfig]] = None,
1426 ):
1427 assert service_type == 'snmp-gateway'
1428
1429 super(SNMPGatewaySpec, self).__init__(
1430 service_type,
1431 placement=placement,
1432 unmanaged=unmanaged,
1433 preview_only=preview_only,
1434 extra_container_args=extra_container_args,
1435 extra_entrypoint_args=extra_entrypoint_args,
1436 custom_configs=custom_configs)
1437
1438 self.service_type = service_type
1439 self.snmp_version = snmp_version
1440 self.snmp_destination = snmp_destination
1441 self.port = port
1442 self.credentials = credentials
1443 self.engine_id = engine_id
1444 self.auth_protocol = auth_protocol
1445 self.privacy_protocol = privacy_protocol
1446
1447 @classmethod
1448 def _from_json_impl(cls, json_spec: dict) -> 'SNMPGatewaySpec':
1449
1450 cpy = json_spec.copy()
1451 types = [
1452 ('snmp_version', SNMPGatewaySpec.SNMPVersion),
1453 ('auth_protocol', SNMPGatewaySpec.SNMPAuthType),
1454 ('privacy_protocol', SNMPGatewaySpec.SNMPPrivacyType),
1455 ]
1456 for d in cpy, cpy.get('spec', {}):
1457 for key, enum_cls in types:
1458 try:
1459 if key in d:
1460 d[key] = enum_cls(d[key])
1461 except ValueError:
1462 raise SpecValidationError(f'{key} unsupported. Must be one of '
1463 f'{", ".join(enum_cls)}')
1464 return super(SNMPGatewaySpec, cls)._from_json_impl(cpy)
1465
1466 @property
1467 def ports(self) -> List[int]:
1468 return [self.port or 9464]
1469
1470 def get_port_start(self) -> List[int]:
1471 return self.ports
1472
1473 def validate(self) -> None:
1474 super(SNMPGatewaySpec, self).validate()
1475
1476 if not self.credentials:
1477 raise SpecValidationError(
1478 'Missing authentication information (credentials). '
1479 'SNMP V2c and V3 require credential information'
1480 )
1481 elif not self.snmp_version:
1482 raise SpecValidationError(
1483 'Missing SNMP version (snmp_version)'
1484 )
1485
1486 creds_requirement = {
1487 'V2c': ['snmp_community'],
1488 'V3': ['snmp_v3_auth_username', 'snmp_v3_auth_password']
1489 }
1490 if self.privacy_protocol:
1491 creds_requirement['V3'].append('snmp_v3_priv_password')
1492
1493 missing = [parm for parm in creds_requirement[self.snmp_version]
1494 if parm not in self.credentials]
1495 # check that credentials are correct for the version
1496 if missing:
1497 raise SpecValidationError(
1498 f'SNMP {self.snmp_version} credentials are incomplete. Missing {", ".join(missing)}'
1499 )
1500
1501 if self.engine_id:
1502 if 10 <= len(self.engine_id) <= 64 and \
1503 is_hex(self.engine_id) and \
1504 len(self.engine_id) % 2 == 0:
1505 pass
1506 else:
1507 raise SpecValidationError(
1508 'engine_id must be a string containing 10-64 hex characters. '
1509 'Its length must be divisible by 2'
1510 )
1511
1512 else:
1513 if self.snmp_version == 'V3':
1514 raise SpecValidationError(
1515 'Must provide an engine_id for SNMP V3 notifications'
1516 )
1517
1518 if not self.snmp_destination:
1519 raise SpecValidationError(
1520 'SNMP destination (snmp_destination) must be provided'
1521 )
1522 else:
1523 valid, description = valid_addr(self.snmp_destination)
1524 if not valid:
1525 raise SpecValidationError(
1526 f'SNMP destination (snmp_destination) is invalid: {description}'
1527 )
1528 if description not in self.valid_destination_types:
1529 raise SpecValidationError(
1530 f'SNMP destination (snmp_destination) type ({description}) is invalid. '
1531 f'Must be either: {", ".join(sorted(self.valid_destination_types))}'
1532 )
1533
1534
1535 yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer)
1536
1537
1538 class MDSSpec(ServiceSpec):
1539 def __init__(self,
1540 service_type: str = 'mds',
1541 service_id: Optional[str] = None,
1542 placement: Optional[PlacementSpec] = None,
1543 config: Optional[Dict[str, str]] = None,
1544 unmanaged: bool = False,
1545 preview_only: bool = False,
1546 extra_container_args: Optional[List[str]] = None,
1547 extra_entrypoint_args: Optional[List[str]] = None,
1548 custom_configs: Optional[List[CustomConfig]] = None,
1549 ):
1550 assert service_type == 'mds'
1551 super(MDSSpec, self).__init__('mds', service_id=service_id,
1552 placement=placement,
1553 config=config,
1554 unmanaged=unmanaged,
1555 preview_only=preview_only,
1556 extra_container_args=extra_container_args,
1557 extra_entrypoint_args=extra_entrypoint_args,
1558 custom_configs=custom_configs)
1559
1560 def validate(self) -> None:
1561 super(MDSSpec, self).validate()
1562
1563 if str(self.service_id)[0].isdigit():
1564 raise SpecValidationError('MDS service id cannot start with a numeric digit')
1565
1566
1567 yaml.add_representer(MDSSpec, ServiceSpec.yaml_representer)
1568
1569
1570 class MONSpec(ServiceSpec):
1571 def __init__(self,
1572 service_type: str,
1573 service_id: Optional[str] = None,
1574 placement: Optional[PlacementSpec] = None,
1575 count: Optional[int] = None,
1576 config: Optional[Dict[str, str]] = None,
1577 unmanaged: bool = False,
1578 preview_only: bool = False,
1579 networks: Optional[List[str]] = None,
1580 extra_container_args: Optional[List[str]] = None,
1581 custom_configs: Optional[List[CustomConfig]] = None,
1582 crush_locations: Optional[Dict[str, List[str]]] = None,
1583 ):
1584 assert service_type == 'mon'
1585 super(MONSpec, self).__init__('mon', service_id=service_id,
1586 placement=placement,
1587 count=count,
1588 config=config,
1589 unmanaged=unmanaged,
1590 preview_only=preview_only,
1591 networks=networks,
1592 extra_container_args=extra_container_args,
1593 custom_configs=custom_configs)
1594
1595 self.crush_locations = crush_locations
1596 self.validate()
1597
1598 def validate(self) -> None:
1599 if self.crush_locations:
1600 for host, crush_locs in self.crush_locations.items():
1601 try:
1602 assert_valid_host(host)
1603 except SpecValidationError as e:
1604 err_str = f'Invalid hostname found in spec crush locations: {e}'
1605 raise SpecValidationError(err_str)
1606 for cloc in crush_locs:
1607 if '=' not in cloc or len(cloc.split('=')) != 2:
1608 err_str = ('Crush locations must be of form <bucket>=<location>. '
1609 f'Found crush location: {cloc}')
1610 raise SpecValidationError(err_str)
1611
1612
1613 yaml.add_representer(MONSpec, ServiceSpec.yaml_representer)
1614
1615
1616 class TracingSpec(ServiceSpec):
1617 SERVICE_TYPES = ['elasticsearch', 'jaeger-collector', 'jaeger-query', 'jaeger-agent']
1618
1619 def __init__(self,
1620 service_type: str,
1621 es_nodes: Optional[str] = None,
1622 without_query: bool = False,
1623 service_id: Optional[str] = None,
1624 config: Optional[Dict[str, str]] = None,
1625 networks: Optional[List[str]] = None,
1626 placement: Optional[PlacementSpec] = None,
1627 unmanaged: bool = False,
1628 preview_only: bool = False
1629 ):
1630 assert service_type in TracingSpec.SERVICE_TYPES + ['jaeger-tracing']
1631
1632 super(TracingSpec, self).__init__(
1633 service_type, service_id,
1634 placement=placement, unmanaged=unmanaged,
1635 preview_only=preview_only, config=config,
1636 networks=networks)
1637 self.without_query = without_query
1638 self.es_nodes = es_nodes
1639
1640 def get_port_start(self) -> List[int]:
1641 return [self.get_port()]
1642
1643 def get_port(self) -> int:
1644 return {'elasticsearch': 9200,
1645 'jaeger-agent': 6799,
1646 'jaeger-collector': 14250,
1647 'jaeger-query': 16686}[self.service_type]
1648
1649 def get_tracing_specs(self) -> List[ServiceSpec]:
1650 assert self.service_type == 'jaeger-tracing'
1651 specs: List[ServiceSpec] = []
1652 daemons: Dict[str, Optional[PlacementSpec]] = {
1653 daemon: None for daemon in TracingSpec.SERVICE_TYPES}
1654
1655 if self.es_nodes:
1656 del daemons['elasticsearch']
1657 if self.without_query:
1658 del daemons['jaeger-query']
1659 if self.placement:
1660 daemons.update({'jaeger-collector': self.placement})
1661
1662 for daemon, daemon_placement in daemons.items():
1663 specs.append(TracingSpec(service_type=daemon,
1664 es_nodes=self.es_nodes,
1665 placement=daemon_placement,
1666 unmanaged=self.unmanaged,
1667 config=self.config,
1668 networks=self.networks,
1669 preview_only=self.preview_only
1670 ))
1671 return specs
1672
1673
1674 yaml.add_representer(TracingSpec, ServiceSpec.yaml_representer)
1675
1676
1677 class TunedProfileSpec():
1678 def __init__(self,
1679 profile_name: str,
1680 placement: Optional[PlacementSpec] = None,
1681 settings: Optional[Dict[str, str]] = None,
1682 ):
1683 self.profile_name = profile_name
1684 self.placement = placement or PlacementSpec(host_pattern='*')
1685 self.settings = settings or {}
1686 self._last_updated: str = ''
1687
1688 @classmethod
1689 def from_json(cls, spec: Dict[str, Any]) -> 'TunedProfileSpec':
1690 data = {}
1691 if 'profile_name' not in spec:
1692 raise SpecValidationError('Tuned profile spec must include "profile_name" field')
1693 data['profile_name'] = spec['profile_name']
1694 if not isinstance(data['profile_name'], str):
1695 raise SpecValidationError('"profile_name" field must be a string')
1696 if 'placement' in spec:
1697 data['placement'] = PlacementSpec.from_json(spec['placement'])
1698 if 'settings' in spec:
1699 data['settings'] = spec['settings']
1700 return cls(**data)
1701
1702 def to_json(self) -> Dict[str, Any]:
1703 res: Dict[str, Any] = {}
1704 res['profile_name'] = self.profile_name
1705 res['placement'] = self.placement.to_json()
1706 res['settings'] = self.settings
1707 return res
1708
1709 def __eq__(self, other: Any) -> bool:
1710 if isinstance(other, TunedProfileSpec):
1711 if (
1712 self.placement == other.placement
1713 and self.profile_name == other.profile_name
1714 and self.settings == other.settings
1715 ):
1716 return True
1717 return False
1718 return NotImplemented
1719
1720 def __repr__(self) -> str:
1721 return f'TunedProfile({self.profile_name})'
1722
1723 def copy(self) -> 'TunedProfileSpec':
1724 # for making deep copies so you can edit the settings in one without affecting the other
1725 # mostly for testing purposes
1726 return TunedProfileSpec(self.profile_name, self.placement, self.settings.copy())
1727
1728
1729 class CephExporterSpec(ServiceSpec):
1730 def __init__(self,
1731 service_type: str = 'ceph-exporter',
1732 sock_dir: Optional[str] = None,
1733 addrs: str = '',
1734 port: Optional[int] = None,
1735 prio_limit: Optional[int] = 5,
1736 stats_period: Optional[int] = 5,
1737 placement: Optional[PlacementSpec] = None,
1738 unmanaged: bool = False,
1739 preview_only: bool = False,
1740 extra_container_args: Optional[List[str]] = None,
1741 ):
1742 assert service_type == 'ceph-exporter'
1743
1744 super(CephExporterSpec, self).__init__(
1745 service_type,
1746 placement=placement,
1747 unmanaged=unmanaged,
1748 preview_only=preview_only,
1749 extra_container_args=extra_container_args)
1750
1751 self.service_type = service_type
1752 self.sock_dir = sock_dir
1753 self.addrs = addrs
1754 self.port = port
1755 self.prio_limit = prio_limit
1756 self.stats_period = stats_period
1757
1758 def validate(self) -> None:
1759 super(CephExporterSpec, self).validate()
1760
1761 if not isinstance(self.prio_limit, int):
1762 raise SpecValidationError(
1763 f'prio_limit must be an integer. Got {type(self.prio_limit)}')
1764 if not isinstance(self.stats_period, int):
1765 raise SpecValidationError(
1766 f'stats_period must be an integer. Got {type(self.stats_period)}')
1767
1768
1769 yaml.add_representer(CephExporterSpec, ServiceSpec.yaml_representer)