]> git.proxmox.com Git - ceph.git/blob - ceph/src/python-common/ceph/deployment/service_spec.py
72f3efa2e9132446cb3bd8811312d0f4c2974852
[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 ' \
501 'container ingress cephfs-mirror snmp-gateway'.split()
502 REQUIRES_SERVICE_ID = 'iscsi mds nfs rgw container ingress '.split()
503 MANAGED_CONFIG_OPTIONS = [
504 'mds_join_fs',
505 ]
506
507 @classmethod
508 def _cls(cls: Type[ServiceSpecT], service_type: str) -> Type[ServiceSpecT]:
509 from ceph.deployment.drive_group import DriveGroupSpec
510
511 ret = {
512 'rgw': RGWSpec,
513 'nfs': NFSServiceSpec,
514 'osd': DriveGroupSpec,
515 'mds': MDSSpec,
516 'iscsi': IscsiServiceSpec,
517 'alertmanager': AlertManagerSpec,
518 'ingress': IngressSpec,
519 'container': CustomContainerSpec,
520 'grafana': GrafanaSpec,
521 'node-exporter': MonitoringSpec,
522 'prometheus': MonitoringSpec,
523 'loki': MonitoringSpec,
524 'promtail': MonitoringSpec,
525 'snmp-gateway': SNMPGatewaySpec,
526 }.get(service_type, cls)
527 if ret == ServiceSpec and not service_type:
528 raise SpecValidationError('Spec needs a "service_type" key.')
529 return ret
530
531 def __new__(cls: Type[ServiceSpecT], *args: Any, **kwargs: Any) -> ServiceSpecT:
532 """
533 Some Python foo to make sure, we don't have an object
534 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
535
536 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
537 True
538
539 """
540 if cls != ServiceSpec:
541 return object.__new__(cls)
542 service_type = kwargs.get('service_type', args[0] if args else None)
543 sub_cls: Any = cls._cls(service_type)
544 return object.__new__(sub_cls)
545
546 def __init__(self,
547 service_type: str,
548 service_id: Optional[str] = None,
549 placement: Optional[PlacementSpec] = None,
550 count: Optional[int] = None,
551 config: Optional[Dict[str, str]] = None,
552 unmanaged: bool = False,
553 preview_only: bool = False,
554 networks: Optional[List[str]] = None,
555 extra_container_args: Optional[List[str]] = None,
556 custom_configs: Optional[List[CustomConfig]] = None,
557 ):
558
559 #: See :ref:`orchestrator-cli-placement-spec`.
560 self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec
561
562 assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type
563 #: The type of the service. Needs to be either a Ceph
564 #: service (``mon``, ``crash``, ``mds``, ``mgr``, ``osd`` or
565 #: ``rbd-mirror``), a gateway (``nfs`` or ``rgw``), part of the
566 #: monitoring stack (``alertmanager``, ``grafana``, ``node-exporter`` or
567 #: ``prometheus``) or (``container``) for custom containers.
568 self.service_type = service_type
569
570 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
571 #: ``container``, ``ingress``
572 self.service_id = None
573
574 if self.service_type in self.REQUIRES_SERVICE_ID or self.service_type == 'osd':
575 self.service_id = service_id
576
577 #: If set to ``true``, the orchestrator will not deploy nor remove
578 #: any daemon associated with this service. Placement and all other properties
579 #: will be ignored. This is useful, if you do not want this service to be
580 #: managed temporarily. For cephadm, See :ref:`cephadm-spec-unmanaged`
581 self.unmanaged = unmanaged
582 self.preview_only = preview_only
583
584 #: A list of network identities instructing the daemons to only bind
585 #: on the particular networks in that list. In case the cluster is distributed
586 #: across multiple networks, you can add multiple networks. See
587 #: :ref:`cephadm-monitoring-networks-ports`,
588 #: :ref:`cephadm-rgw-networks` and :ref:`cephadm-mgr-networks`.
589 self.networks: List[str] = networks or []
590
591 self.config: Optional[Dict[str, str]] = None
592 if config:
593 self.config = {k.replace(' ', '_'): v for k, v in config.items()}
594
595 self.extra_container_args: Optional[List[str]] = extra_container_args
596 self.custom_configs: Optional[List[CustomConfig]] = custom_configs
597
598 @classmethod
599 @handle_type_error
600 def from_json(cls: Type[ServiceSpecT], json_spec: Dict) -> ServiceSpecT:
601 """
602 Initialize 'ServiceSpec' object data from a json structure
603
604 There are two valid styles for service specs:
605
606 the "old" style:
607
608 .. code:: yaml
609
610 service_type: nfs
611 service_id: foo
612 pool: mypool
613 namespace: myns
614
615 and the "new" style:
616
617 .. code:: yaml
618
619 service_type: nfs
620 service_id: foo
621 config:
622 some_option: the_value
623 networks: [10.10.0.0/16]
624 spec:
625 pool: mypool
626 namespace: myns
627
628 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
629 prefer the new style as it is more readable and provides a better
630 understanding of what fields are special for a give service type.
631
632 Note, we'll need to stay compatible with both versions for the
633 the next two major releases (octoups, pacific).
634
635 :param json_spec: A valid dict with ServiceSpec
636
637 :meta private:
638 """
639 if not isinstance(json_spec, dict):
640 raise SpecValidationError(
641 f'Service Spec is not an (JSON or YAML) object. got "{str(json_spec)}"')
642
643 json_spec = cls.normalize_json(json_spec)
644
645 c = json_spec.copy()
646
647 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
648 # Open question: Remove `service_id` form to_json?
649 if c.get('service_name', ''):
650 service_type_id = c['service_name'].split('.', 1)
651
652 if not c.get('service_type', ''):
653 c['service_type'] = service_type_id[0]
654 if not c.get('service_id', '') and len(service_type_id) > 1:
655 c['service_id'] = service_type_id[1]
656 del c['service_name']
657
658 service_type = c.get('service_type', '')
659 _cls = cls._cls(service_type)
660
661 if 'status' in c:
662 del c['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
663
664 return _cls._from_json_impl(c) # type: ignore
665
666 @staticmethod
667 def normalize_json(json_spec: dict) -> dict:
668 networks = json_spec.get('networks')
669 if networks is None:
670 return json_spec
671 if isinstance(networks, list):
672 return json_spec
673 if not isinstance(networks, str):
674 raise SpecValidationError(f'Networks ({networks}) must be a string or list of strings')
675 json_spec['networks'] = [networks]
676 return json_spec
677
678 @classmethod
679 def _from_json_impl(cls: Type[ServiceSpecT], json_spec: dict) -> ServiceSpecT:
680 args = {} # type: Dict[str, Any]
681 for k, v in json_spec.items():
682 if k == 'placement':
683 v = PlacementSpec.from_json(v)
684 if k == 'custom_configs':
685 v = [CustomConfig.from_json(c) for c in v]
686 if k == 'spec':
687 args.update(v)
688 continue
689 args.update({k: v})
690 _cls = cls(**args)
691 if _service_spec_from_json_validate:
692 _cls.validate()
693 return _cls
694
695 def service_name(self) -> str:
696 n = self.service_type
697 if self.service_id:
698 n += '.' + self.service_id
699 return n
700
701 def get_port_start(self) -> List[int]:
702 # If defined, we will allocate and number ports starting at this
703 # point.
704 return []
705
706 def get_virtual_ip(self) -> Optional[str]:
707 return None
708
709 def to_json(self):
710 # type: () -> OrderedDict[str, Any]
711 ret: OrderedDict[str, Any] = OrderedDict()
712 ret['service_type'] = self.service_type
713 if self.service_id:
714 ret['service_id'] = self.service_id
715 ret['service_name'] = self.service_name()
716 if self.placement.to_json():
717 ret['placement'] = self.placement.to_json()
718 if self.unmanaged:
719 ret['unmanaged'] = self.unmanaged
720 if self.networks:
721 ret['networks'] = self.networks
722 if self.extra_container_args:
723 ret['extra_container_args'] = self.extra_container_args
724 if self.custom_configs:
725 ret['custom_configs'] = [c.to_json() for c in self.custom_configs]
726
727 c = {}
728 for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]):
729 if key in ret:
730 continue
731 if hasattr(val, 'to_json'):
732 val = val.to_json()
733 if val:
734 c[key] = val
735 if c:
736 ret['spec'] = c
737 return ret
738
739 def validate(self) -> None:
740 if not self.service_type:
741 raise SpecValidationError('Cannot add Service: type required')
742
743 if self.service_type != 'osd':
744 if self.service_type in self.REQUIRES_SERVICE_ID and not self.service_id:
745 raise SpecValidationError('Cannot add Service: id required')
746 if self.service_type not in self.REQUIRES_SERVICE_ID and self.service_id:
747 raise SpecValidationError(
748 f'Service of type \'{self.service_type}\' should not contain a service id')
749
750 if self.service_id:
751 if not re.match('^[a-zA-Z0-9_.-]+$', str(self.service_id)):
752 raise SpecValidationError('Service id contains invalid characters, '
753 'only [a-zA-Z0-9_.-] allowed')
754
755 if self.placement is not None:
756 self.placement.validate()
757 if self.config:
758 for k, v in self.config.items():
759 if k in self.MANAGED_CONFIG_OPTIONS:
760 raise SpecValidationError(
761 f'Cannot set config option {k} in spec: it is managed by cephadm'
762 )
763 for network in self.networks or []:
764 try:
765 ip_network(network)
766 except ValueError as e:
767 raise SpecValidationError(
768 f'Cannot parse network {network}: {e}'
769 )
770
771 def __repr__(self) -> str:
772 y = yaml.dump(cast(dict, self), default_flow_style=False)
773 return f"{self.__class__.__name__}.from_json(yaml.safe_load('''{y}'''))"
774
775 def __eq__(self, other: Any) -> bool:
776 return (self.__class__ == other.__class__
777 and
778 self.__dict__ == other.__dict__)
779
780 def one_line_str(self) -> str:
781 return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name())
782
783 @staticmethod
784 def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec') -> Any:
785 return dumper.represent_dict(cast(Mapping, data.to_json().items()))
786
787
788 yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer)
789
790
791 class NFSServiceSpec(ServiceSpec):
792 def __init__(self,
793 service_type: str = 'nfs',
794 service_id: Optional[str] = None,
795 placement: Optional[PlacementSpec] = None,
796 unmanaged: bool = False,
797 preview_only: bool = False,
798 config: Optional[Dict[str, str]] = None,
799 networks: Optional[List[str]] = None,
800 port: Optional[int] = None,
801 extra_container_args: Optional[List[str]] = None,
802 custom_configs: Optional[List[CustomConfig]] = None,
803 ):
804 assert service_type == 'nfs'
805 super(NFSServiceSpec, self).__init__(
806 'nfs', service_id=service_id,
807 placement=placement, unmanaged=unmanaged, preview_only=preview_only,
808 config=config, networks=networks, extra_container_args=extra_container_args,
809 custom_configs=custom_configs)
810
811 self.port = port
812
813 def get_port_start(self) -> List[int]:
814 if self.port:
815 return [self.port]
816 return []
817
818 def rados_config_name(self):
819 # type: () -> str
820 return 'conf-' + self.service_name()
821
822
823 yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer)
824
825
826 class RGWSpec(ServiceSpec):
827 """
828 Settings to configure a (multisite) Ceph RGW
829
830 .. code-block:: yaml
831
832 service_type: rgw
833 service_id: myrealm.myzone
834 spec:
835 rgw_realm: myrealm
836 rgw_zone: myzone
837 ssl: true
838 rgw_frontend_port: 1234
839 rgw_frontend_type: beast
840 rgw_frontend_ssl_certificate: ...
841
842 See also: :ref:`orchestrator-cli-service-spec`
843 """
844
845 MANAGED_CONFIG_OPTIONS = ServiceSpec.MANAGED_CONFIG_OPTIONS + [
846 'rgw_zone',
847 'rgw_realm',
848 'rgw_frontends',
849 ]
850
851 def __init__(self,
852 service_type: str = 'rgw',
853 service_id: Optional[str] = None,
854 placement: Optional[PlacementSpec] = None,
855 rgw_realm: Optional[str] = None,
856 rgw_zone: Optional[str] = None,
857 rgw_frontend_port: Optional[int] = None,
858 rgw_frontend_ssl_certificate: Optional[List[str]] = None,
859 rgw_frontend_type: Optional[str] = None,
860 unmanaged: bool = False,
861 ssl: bool = False,
862 preview_only: bool = False,
863 config: Optional[Dict[str, str]] = None,
864 networks: Optional[List[str]] = None,
865 subcluster: Optional[str] = None, # legacy, only for from_json on upgrade
866 extra_container_args: Optional[List[str]] = None,
867 custom_configs: Optional[List[CustomConfig]] = None,
868 ):
869 assert service_type == 'rgw', service_type
870
871 # for backward compatibility with octopus spec files,
872 if not service_id and (rgw_realm and rgw_zone):
873 service_id = rgw_realm + '.' + rgw_zone
874
875 super(RGWSpec, self).__init__(
876 'rgw', service_id=service_id,
877 placement=placement, unmanaged=unmanaged,
878 preview_only=preview_only, config=config, networks=networks,
879 extra_container_args=extra_container_args, custom_configs=custom_configs)
880
881 #: The RGW realm associated with this service. Needs to be manually created
882 self.rgw_realm: Optional[str] = rgw_realm
883 #: The RGW zone associated with this service. Needs to be manually created
884 self.rgw_zone: Optional[str] = rgw_zone
885 #: Port of the RGW daemons
886 self.rgw_frontend_port: Optional[int] = rgw_frontend_port
887 #: List of SSL certificates
888 self.rgw_frontend_ssl_certificate: Optional[List[str]] = rgw_frontend_ssl_certificate
889 #: civetweb or beast (default: beast). See :ref:`rgw_frontends`
890 self.rgw_frontend_type: Optional[str] = rgw_frontend_type
891 #: enable SSL
892 self.ssl = ssl
893
894 def get_port_start(self) -> List[int]:
895 return [self.get_port()]
896
897 def get_port(self) -> int:
898 if self.rgw_frontend_port:
899 return self.rgw_frontend_port
900 if self.ssl:
901 return 443
902 else:
903 return 80
904
905 def validate(self) -> None:
906 super(RGWSpec, self).validate()
907
908 if self.rgw_realm and not self.rgw_zone:
909 raise SpecValidationError(
910 'Cannot add RGW: Realm specified but no zone specified')
911 if self.rgw_zone and not self.rgw_realm:
912 raise SpecValidationError(
913 'Cannot add RGW: Zone specified but no realm specified')
914
915
916 yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer)
917
918
919 class IscsiServiceSpec(ServiceSpec):
920 def __init__(self,
921 service_type: str = 'iscsi',
922 service_id: Optional[str] = None,
923 pool: Optional[str] = None,
924 trusted_ip_list: Optional[str] = None,
925 api_port: Optional[int] = None,
926 api_user: Optional[str] = None,
927 api_password: Optional[str] = None,
928 api_secure: Optional[bool] = None,
929 ssl_cert: Optional[str] = None,
930 ssl_key: Optional[str] = None,
931 placement: Optional[PlacementSpec] = None,
932 unmanaged: bool = False,
933 preview_only: bool = False,
934 config: Optional[Dict[str, str]] = None,
935 networks: Optional[List[str]] = None,
936 extra_container_args: Optional[List[str]] = None,
937 custom_configs: Optional[List[CustomConfig]] = None,
938 ):
939 assert service_type == 'iscsi'
940 super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id,
941 placement=placement, unmanaged=unmanaged,
942 preview_only=preview_only,
943 config=config, networks=networks,
944 extra_container_args=extra_container_args,
945 custom_configs=custom_configs)
946
947 #: RADOS pool where ceph-iscsi config data is stored.
948 self.pool = pool
949 #: list of trusted IP addresses
950 self.trusted_ip_list = trusted_ip_list
951 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
952 self.api_port = api_port
953 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
954 self.api_user = api_user
955 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
956 self.api_password = api_password
957 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
958 self.api_secure = api_secure
959 #: SSL certificate
960 self.ssl_cert = ssl_cert
961 #: SSL private key
962 self.ssl_key = ssl_key
963
964 if not self.api_secure and self.ssl_cert and self.ssl_key:
965 self.api_secure = True
966
967 def validate(self) -> None:
968 super(IscsiServiceSpec, self).validate()
969
970 if not self.pool:
971 raise SpecValidationError(
972 'Cannot add ISCSI: No Pool specified')
973
974 # Do not need to check for api_user and api_password as they
975 # now default to 'admin' when setting up the gateway url. Older
976 # iSCSI specs from before this change should be fine as they will
977 # have been required to have an api_user and api_password set and
978 # will be unaffected by the new default value.
979
980
981 yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer)
982
983
984 class IngressSpec(ServiceSpec):
985 def __init__(self,
986 service_type: str = 'ingress',
987 service_id: Optional[str] = None,
988 config: Optional[Dict[str, str]] = None,
989 networks: Optional[List[str]] = None,
990 placement: Optional[PlacementSpec] = None,
991 backend_service: Optional[str] = None,
992 frontend_port: Optional[int] = None,
993 ssl_cert: Optional[str] = None,
994 ssl_key: Optional[str] = None,
995 ssl_dh_param: Optional[str] = None,
996 ssl_ciphers: Optional[List[str]] = None,
997 ssl_options: Optional[List[str]] = None,
998 monitor_port: Optional[int] = None,
999 monitor_user: Optional[str] = None,
1000 monitor_password: Optional[str] = None,
1001 enable_stats: Optional[bool] = None,
1002 keepalived_password: Optional[str] = None,
1003 virtual_ip: Optional[str] = None,
1004 virtual_ips_list: Optional[List[str]] = None,
1005 virtual_interface_networks: Optional[List[str]] = [],
1006 unmanaged: bool = False,
1007 ssl: bool = False,
1008 extra_container_args: Optional[List[str]] = None,
1009 custom_configs: Optional[List[CustomConfig]] = None,
1010 ):
1011 assert service_type == 'ingress'
1012
1013 super(IngressSpec, self).__init__(
1014 'ingress', service_id=service_id,
1015 placement=placement, config=config,
1016 networks=networks,
1017 extra_container_args=extra_container_args,
1018 custom_configs=custom_configs
1019 )
1020 self.backend_service = backend_service
1021 self.frontend_port = frontend_port
1022 self.ssl_cert = ssl_cert
1023 self.ssl_key = ssl_key
1024 self.ssl_dh_param = ssl_dh_param
1025 self.ssl_ciphers = ssl_ciphers
1026 self.ssl_options = ssl_options
1027 self.monitor_port = monitor_port
1028 self.monitor_user = monitor_user
1029 self.monitor_password = monitor_password
1030 self.keepalived_password = keepalived_password
1031 self.virtual_ip = virtual_ip
1032 self.virtual_ips_list = virtual_ips_list
1033 self.virtual_interface_networks = virtual_interface_networks or []
1034 self.unmanaged = unmanaged
1035 self.ssl = ssl
1036
1037 def get_port_start(self) -> List[int]:
1038 return [cast(int, self.frontend_port),
1039 cast(int, self.monitor_port)]
1040
1041 def get_virtual_ip(self) -> Optional[str]:
1042 return self.virtual_ip
1043
1044 def validate(self) -> None:
1045 super(IngressSpec, self).validate()
1046
1047 if not self.backend_service:
1048 raise SpecValidationError(
1049 'Cannot add ingress: No backend_service specified')
1050 if not self.frontend_port:
1051 raise SpecValidationError(
1052 'Cannot add ingress: No frontend_port specified')
1053 if not self.monitor_port:
1054 raise SpecValidationError(
1055 'Cannot add ingress: No monitor_port specified')
1056 if not self.virtual_ip and not self.virtual_ips_list:
1057 raise SpecValidationError(
1058 'Cannot add ingress: No virtual_ip provided')
1059 if self.virtual_ip is not None and self.virtual_ips_list is not None:
1060 raise SpecValidationError(
1061 'Cannot add ingress: Single and multiple virtual IPs specified')
1062
1063
1064 yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer)
1065
1066
1067 class CustomContainerSpec(ServiceSpec):
1068 def __init__(self,
1069 service_type: str = 'container',
1070 service_id: Optional[str] = None,
1071 config: Optional[Dict[str, str]] = None,
1072 networks: Optional[List[str]] = None,
1073 placement: Optional[PlacementSpec] = None,
1074 unmanaged: bool = False,
1075 preview_only: bool = False,
1076 image: Optional[str] = None,
1077 entrypoint: Optional[str] = None,
1078 uid: Optional[int] = None,
1079 gid: Optional[int] = None,
1080 volume_mounts: Optional[Dict[str, str]] = {},
1081 args: Optional[List[str]] = [],
1082 envs: Optional[List[str]] = [],
1083 privileged: Optional[bool] = False,
1084 bind_mounts: Optional[List[List[str]]] = None,
1085 ports: Optional[List[int]] = [],
1086 dirs: Optional[List[str]] = [],
1087 files: Optional[Dict[str, Any]] = {},
1088 ):
1089 assert service_type == 'container'
1090 assert service_id is not None
1091 assert image is not None
1092
1093 super(CustomContainerSpec, self).__init__(
1094 service_type, service_id,
1095 placement=placement, unmanaged=unmanaged,
1096 preview_only=preview_only, config=config,
1097 networks=networks)
1098
1099 self.image = image
1100 self.entrypoint = entrypoint
1101 self.uid = uid
1102 self.gid = gid
1103 self.volume_mounts = volume_mounts
1104 self.args = args
1105 self.envs = envs
1106 self.privileged = privileged
1107 self.bind_mounts = bind_mounts
1108 self.ports = ports
1109 self.dirs = dirs
1110 self.files = files
1111
1112 def config_json(self) -> Dict[str, Any]:
1113 """
1114 Helper function to get the value of the `--config-json` cephadm
1115 command line option. It will contain all specification properties
1116 that haven't a `None` value. Such properties will get default
1117 values in cephadm.
1118 :return: Returns a dictionary containing all specification
1119 properties.
1120 """
1121 config_json = {}
1122 for prop in ['image', 'entrypoint', 'uid', 'gid', 'args',
1123 'envs', 'volume_mounts', 'privileged',
1124 'bind_mounts', 'ports', 'dirs', 'files']:
1125 value = getattr(self, prop)
1126 if value is not None:
1127 config_json[prop] = value
1128 return config_json
1129
1130
1131 yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer)
1132
1133
1134 class MonitoringSpec(ServiceSpec):
1135 def __init__(self,
1136 service_type: str,
1137 service_id: Optional[str] = None,
1138 config: Optional[Dict[str, str]] = None,
1139 networks: Optional[List[str]] = None,
1140 placement: Optional[PlacementSpec] = None,
1141 unmanaged: bool = False,
1142 preview_only: bool = False,
1143 port: Optional[int] = None,
1144 extra_container_args: Optional[List[str]] = None,
1145 custom_configs: Optional[List[CustomConfig]] = None,
1146 ):
1147 assert service_type in ['grafana', 'node-exporter', 'prometheus', 'alertmanager',
1148 'loki', 'promtail']
1149
1150 super(MonitoringSpec, self).__init__(
1151 service_type, service_id,
1152 placement=placement, unmanaged=unmanaged,
1153 preview_only=preview_only, config=config,
1154 networks=networks, extra_container_args=extra_container_args,
1155 custom_configs=custom_configs)
1156
1157 self.service_type = service_type
1158 self.port = port
1159
1160 def get_port_start(self) -> List[int]:
1161 return [self.get_port()]
1162
1163 def get_port(self) -> int:
1164 if self.port:
1165 return self.port
1166 else:
1167 return {'prometheus': 9095,
1168 'node-exporter': 9100,
1169 'alertmanager': 9093,
1170 'grafana': 3000,
1171 'loki': 3100,
1172 'promtail': 9080}[self.service_type]
1173
1174
1175 yaml.add_representer(MonitoringSpec, ServiceSpec.yaml_representer)
1176
1177
1178 class AlertManagerSpec(MonitoringSpec):
1179 def __init__(self,
1180 service_type: str = 'alertmanager',
1181 service_id: Optional[str] = None,
1182 placement: Optional[PlacementSpec] = None,
1183 unmanaged: bool = False,
1184 preview_only: bool = False,
1185 user_data: Optional[Dict[str, Any]] = None,
1186 config: Optional[Dict[str, str]] = None,
1187 networks: Optional[List[str]] = None,
1188 port: Optional[int] = None,
1189 secure: bool = False,
1190 extra_container_args: Optional[List[str]] = None,
1191 custom_configs: Optional[List[CustomConfig]] = None,
1192 ):
1193 assert service_type == 'alertmanager'
1194 super(AlertManagerSpec, self).__init__(
1195 'alertmanager', service_id=service_id,
1196 placement=placement, unmanaged=unmanaged,
1197 preview_only=preview_only, config=config, networks=networks, port=port,
1198 extra_container_args=extra_container_args, custom_configs=custom_configs)
1199
1200 # Custom configuration.
1201 #
1202 # Example:
1203 # service_type: alertmanager
1204 # service_id: xyz
1205 # user_data:
1206 # default_webhook_urls:
1207 # - "https://foo"
1208 # - "https://bar"
1209 #
1210 # Documentation:
1211 # default_webhook_urls - A list of additional URL's that are
1212 # added to the default receivers'
1213 # <webhook_configs> configuration.
1214 self.user_data = user_data or {}
1215 self.secure = secure
1216
1217 def get_port_start(self) -> List[int]:
1218 return [self.get_port(), 9094]
1219
1220 def validate(self) -> None:
1221 super(AlertManagerSpec, self).validate()
1222
1223 if self.port == 9094:
1224 raise SpecValidationError(
1225 'Port 9094 is reserved for AlertManager cluster listen address')
1226
1227
1228 yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer)
1229
1230
1231 class GrafanaSpec(MonitoringSpec):
1232 def __init__(self,
1233 service_type: str = 'grafana',
1234 service_id: Optional[str] = None,
1235 placement: Optional[PlacementSpec] = None,
1236 unmanaged: bool = False,
1237 preview_only: bool = False,
1238 config: Optional[Dict[str, str]] = None,
1239 networks: Optional[List[str]] = None,
1240 port: Optional[int] = None,
1241 initial_admin_password: Optional[str] = None,
1242 extra_container_args: Optional[List[str]] = None,
1243 custom_configs: Optional[List[CustomConfig]] = None,
1244 ):
1245 assert service_type == 'grafana'
1246 super(GrafanaSpec, self).__init__(
1247 'grafana', service_id=service_id,
1248 placement=placement, unmanaged=unmanaged,
1249 preview_only=preview_only, config=config, networks=networks, port=port,
1250 extra_container_args=extra_container_args, custom_configs=custom_configs)
1251
1252 self.initial_admin_password = initial_admin_password
1253
1254
1255 yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer)
1256
1257
1258 class SNMPGatewaySpec(ServiceSpec):
1259 class SNMPVersion(str, enum.Enum):
1260 V2c = 'V2c'
1261 V3 = 'V3'
1262
1263 def to_json(self) -> str:
1264 return self.value
1265
1266 class SNMPAuthType(str, enum.Enum):
1267 MD5 = 'MD5'
1268 SHA = 'SHA'
1269
1270 def to_json(self) -> str:
1271 return self.value
1272
1273 class SNMPPrivacyType(str, enum.Enum):
1274 DES = 'DES'
1275 AES = 'AES'
1276
1277 def to_json(self) -> str:
1278 return self.value
1279
1280 valid_destination_types = [
1281 'Name:Port',
1282 'IPv4:Port'
1283 ]
1284
1285 def __init__(self,
1286 service_type: str = 'snmp-gateway',
1287 snmp_version: Optional[SNMPVersion] = None,
1288 snmp_destination: str = '',
1289 credentials: Dict[str, str] = {},
1290 engine_id: Optional[str] = None,
1291 auth_protocol: Optional[SNMPAuthType] = None,
1292 privacy_protocol: Optional[SNMPPrivacyType] = None,
1293 placement: Optional[PlacementSpec] = None,
1294 unmanaged: bool = False,
1295 preview_only: bool = False,
1296 port: Optional[int] = None,
1297 extra_container_args: Optional[List[str]] = None,
1298 custom_configs: Optional[List[CustomConfig]] = None,
1299 ):
1300 assert service_type == 'snmp-gateway'
1301
1302 super(SNMPGatewaySpec, self).__init__(
1303 service_type,
1304 placement=placement,
1305 unmanaged=unmanaged,
1306 preview_only=preview_only,
1307 extra_container_args=extra_container_args,
1308 custom_configs=custom_configs)
1309
1310 self.service_type = service_type
1311 self.snmp_version = snmp_version
1312 self.snmp_destination = snmp_destination
1313 self.port = port
1314 self.credentials = credentials
1315 self.engine_id = engine_id
1316 self.auth_protocol = auth_protocol
1317 self.privacy_protocol = privacy_protocol
1318
1319 @classmethod
1320 def _from_json_impl(cls, json_spec: dict) -> 'SNMPGatewaySpec':
1321
1322 cpy = json_spec.copy()
1323 types = [
1324 ('snmp_version', SNMPGatewaySpec.SNMPVersion),
1325 ('auth_protocol', SNMPGatewaySpec.SNMPAuthType),
1326 ('privacy_protocol', SNMPGatewaySpec.SNMPPrivacyType),
1327 ]
1328 for d in cpy, cpy.get('spec', {}):
1329 for key, enum_cls in types:
1330 try:
1331 if key in d:
1332 d[key] = enum_cls(d[key])
1333 except ValueError:
1334 raise SpecValidationError(f'{key} unsupported. Must be one of '
1335 f'{", ".join(enum_cls)}')
1336 return super(SNMPGatewaySpec, cls)._from_json_impl(cpy)
1337
1338 @property
1339 def ports(self) -> List[int]:
1340 return [self.port or 9464]
1341
1342 def get_port_start(self) -> List[int]:
1343 return self.ports
1344
1345 def validate(self) -> None:
1346 super(SNMPGatewaySpec, self).validate()
1347
1348 if not self.credentials:
1349 raise SpecValidationError(
1350 'Missing authentication information (credentials). '
1351 'SNMP V2c and V3 require credential information'
1352 )
1353 elif not self.snmp_version:
1354 raise SpecValidationError(
1355 'Missing SNMP version (snmp_version)'
1356 )
1357
1358 creds_requirement = {
1359 'V2c': ['snmp_community'],
1360 'V3': ['snmp_v3_auth_username', 'snmp_v3_auth_password']
1361 }
1362 if self.privacy_protocol:
1363 creds_requirement['V3'].append('snmp_v3_priv_password')
1364
1365 missing = [parm for parm in creds_requirement[self.snmp_version]
1366 if parm not in self.credentials]
1367 # check that credentials are correct for the version
1368 if missing:
1369 raise SpecValidationError(
1370 f'SNMP {self.snmp_version} credentials are incomplete. Missing {", ".join(missing)}'
1371 )
1372
1373 if self.engine_id:
1374 if 10 <= len(self.engine_id) <= 64 and \
1375 is_hex(self.engine_id) and \
1376 len(self.engine_id) % 2 == 0:
1377 pass
1378 else:
1379 raise SpecValidationError(
1380 'engine_id must be a string containing 10-64 hex characters. '
1381 'Its length must be divisible by 2'
1382 )
1383
1384 else:
1385 if self.snmp_version == 'V3':
1386 raise SpecValidationError(
1387 'Must provide an engine_id for SNMP V3 notifications'
1388 )
1389
1390 if not self.snmp_destination:
1391 raise SpecValidationError(
1392 'SNMP destination (snmp_destination) must be provided'
1393 )
1394 else:
1395 valid, description = valid_addr(self.snmp_destination)
1396 if not valid:
1397 raise SpecValidationError(
1398 f'SNMP destination (snmp_destination) is invalid: {description}'
1399 )
1400 if description not in self.valid_destination_types:
1401 raise SpecValidationError(
1402 f'SNMP destination (snmp_destination) type ({description}) is invalid. '
1403 f'Must be either: {", ".join(sorted(self.valid_destination_types))}'
1404 )
1405
1406
1407 yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer)
1408
1409
1410 class MDSSpec(ServiceSpec):
1411 def __init__(self,
1412 service_type: str = 'mds',
1413 service_id: Optional[str] = None,
1414 placement: Optional[PlacementSpec] = None,
1415 config: Optional[Dict[str, str]] = None,
1416 unmanaged: bool = False,
1417 preview_only: bool = False,
1418 extra_container_args: Optional[List[str]] = None,
1419 custom_configs: Optional[List[CustomConfig]] = None,
1420 ):
1421 assert service_type == 'mds'
1422 super(MDSSpec, self).__init__('mds', service_id=service_id,
1423 placement=placement,
1424 config=config,
1425 unmanaged=unmanaged,
1426 preview_only=preview_only,
1427 extra_container_args=extra_container_args,
1428 custom_configs=custom_configs)
1429
1430 def validate(self) -> None:
1431 super(MDSSpec, self).validate()
1432
1433 if str(self.service_id)[0].isdigit():
1434 raise SpecValidationError('MDS service id cannot start with a numeric digit')
1435
1436
1437 yaml.add_representer(MDSSpec, ServiceSpec.yaml_representer)
1438
1439
1440 class TunedProfileSpec():
1441 def __init__(self,
1442 profile_name: str,
1443 placement: Optional[PlacementSpec] = None,
1444 settings: Optional[Dict[str, str]] = None,
1445 ):
1446 self.profile_name = profile_name
1447 self.placement = placement or PlacementSpec(host_pattern='*')
1448 self.settings = settings or {}
1449 self._last_updated: str = ''
1450
1451 @classmethod
1452 def from_json(cls, spec: Dict[str, Any]) -> 'TunedProfileSpec':
1453 data = {}
1454 if 'profile_name' not in spec:
1455 raise SpecValidationError('Tuned profile spec must include "profile_name" field')
1456 data['profile_name'] = spec['profile_name']
1457 if not isinstance(data['profile_name'], str):
1458 raise SpecValidationError('"profile_name" field must be a string')
1459 if 'placement' in spec:
1460 data['placement'] = PlacementSpec.from_json(spec['placement'])
1461 if 'settings' in spec:
1462 data['settings'] = spec['settings']
1463 return cls(**data)
1464
1465 def to_json(self) -> Dict[str, Any]:
1466 res: Dict[str, Any] = {}
1467 res['profile_name'] = self.profile_name
1468 res['placement'] = self.placement.to_json()
1469 res['settings'] = self.settings
1470 return res
1471
1472 def __eq__(self, other: Any) -> bool:
1473 if isinstance(other, TunedProfileSpec):
1474 if (
1475 self.placement == other.placement
1476 and self.profile_name == other.profile_name
1477 and self.settings == other.settings
1478 ):
1479 return True
1480 return False
1481 return NotImplemented
1482
1483 def __repr__(self) -> str:
1484 return f'TunedProfile({self.profile_name})'
1485
1486 def copy(self) -> 'TunedProfileSpec':
1487 # for making deep copies so you can edit the settings in one without affecting the other
1488 # mostly for testing purposes
1489 return TunedProfileSpec(self.profile_name, self.placement, self.settings.copy())