]> git.proxmox.com Git - ceph.git/blame - 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
CommitLineData
9f95a23c 1import fnmatch
2a845540 2import os
9f95a23c 3import re
20effc67 4import enum
f67539c2 5from collections import OrderedDict
20effc67 6from contextlib import contextmanager
1911f103 7from functools import wraps
f67539c2
TL
8from ipaddress import ip_network, ip_address
9from typing import Optional, Dict, Any, List, Union, Callable, Iterable, Type, TypeVar, cast, \
20effc67 10 NamedTuple, Mapping, Iterator
9f95a23c 11
f6b5b4d7
TL
12import yaml
13
20effc67
TL
14from ceph.deployment.hostspec import HostSpec, SpecValidationError, assert_valid_host
15from ceph.deployment.utils import unwrap_ipv6, valid_addr
16from ceph.utils import is_hex
9f95a23c 17
f67539c2
TL
18ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec')
19FuncT = TypeVar('FuncT', bound=Callable)
9f95a23c 20
9f95a23c 21
f67539c2 22def handle_type_error(method: FuncT) -> FuncT:
1911f103 23 @wraps(method)
f67539c2 24 def inner(cls: Any, *args: Any, **kwargs: Any) -> Any:
1911f103
TL
25 try:
26 return method(cls, *args, **kwargs)
27 except (TypeError, AttributeError) as e:
28 error_msg = '{}: {}'.format(cls.__name__, e)
f67539c2
TL
29 raise SpecValidationError(error_msg)
30 return cast(FuncT, inner)
31
1911f103 32
f67539c2
TL
33class HostPlacementSpec(NamedTuple):
34 hostname: str
35 network: str
36 name: str
1911f103 37
f67539c2 38 def __str__(self) -> str:
9f95a23c
TL
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
1911f103 48 @handle_type_error
f67539c2 49 def from_json(cls, data: Union[dict, str]) -> 'HostPlacementSpec':
f91f0fd5
TL
50 if isinstance(data, str):
51 return cls.parse(data)
9f95a23c
TL
52 return cls(**data)
53
f91f0fd5
TL
54 def to_json(self) -> str:
55 return str(self)
9f95a23c
TL
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
9f95a23c
TL
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:
1911f103
TL
104 if network != '':
105 networks.append(network)
106
9f95a23c
TL
107 for network in networks:
108 # only if we have versioned network configs
109 if network.startswith('v') or network.startswith('[v'):
f91f0fd5
TL
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]
9f95a23c
TL
114 try:
115 # if subnets are defined, also verify the validity
116 if '/' in network:
f67539c2 117 ip_network(network)
9f95a23c 118 else:
f91f0fd5 119 ip_address(unwrap_ipv6(network))
9f95a23c
TL
120 except ValueError as e:
121 # logging?
122 raise e
123 host_spec.validate()
124 return host_spec
125
f67539c2 126 def validate(self) -> None:
9f95a23c
TL
127 assert_valid_host(self.hostname)
128
129
130class 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]
f67539c2 137 hosts=None, # type: Union[List[str],List[HostPlacementSpec], None]
9f95a23c 138 count=None, # type: Optional[int]
f67539c2
TL
139 count_per_host=None, # type: Optional[int]
140 host_pattern=None, # type: Optional[str]
9f95a23c
TL
141 ):
142 # type: (...) -> None
143 self.label = label
144 self.hosts = [] # type: List[HostPlacementSpec]
145
146 if hosts:
f6b5b4d7 147 self.set_hosts(hosts)
9f95a23c
TL
148
149 self.count = count # type: Optional[int]
f67539c2 150 self.count_per_host = count_per_host # type: Optional[int]
9f95a23c
TL
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
f67539c2
TL
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 )
9f95a23c 165
f67539c2 166 def __eq__(self, other: Any) -> bool:
f6b5b4d7
TL
167 if isinstance(other, PlacementSpec):
168 return self.label == other.label \
169 and self.hosts == other.hosts \
170 and self.count == other.count \
f67539c2
TL
171 and self.host_pattern == other.host_pattern \
172 and self.count_per_host == other.count_per_host
f6b5b4d7
TL
173 return NotImplemented
174
f67539c2 175 def set_hosts(self, hosts: Union[List[str], List[HostPlacementSpec]]) -> None:
9f95a23c
TL
176 # To backpopulate the .hosts attribute when using labels or count
177 # in the orchestrator backend.
f6b5b4d7
TL
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]
9f95a23c 183
f91f0fd5 184 # deprecated
e306af50 185 def filter_matching_hosts(self, _get_hosts_func: Callable) -> List[str]:
f6b5b4d7
TL
186 return self.filter_matching_hostspecs(_get_hosts_func(as_hostspec=True))
187
f91f0fd5 188 def filter_matching_hostspecs(self, hostspecs: Iterable[HostSpec]) -> List[str]:
e306af50 189 if self.hosts:
f6b5b4d7 190 all_hosts = [hs.hostname for hs in hostspecs]
e306af50 191 return [h.hostname for h in self.hosts if h.hostname in all_hosts]
f67539c2 192 if self.label:
f6b5b4d7 193 return [hs.hostname for hs in hostspecs if self.label in hs.labels]
f67539c2
TL
194 all_hosts = [hs.hostname for hs in hostspecs]
195 if self.host_pattern:
f6b5b4d7 196 return fnmatch.filter(all_hosts, self.host_pattern)
f67539c2 197 return all_hosts
e306af50 198
f67539c2 199 def get_target_count(self, hostspecs: Iterable[HostSpec]) -> int:
e306af50
TL
200 if self.count:
201 return self.count
f67539c2 202 return len(self.filter_matching_hostspecs(hostspecs)) * (self.count_per_host or 1)
9f95a23c 203
f67539c2 204 def pretty_str(self) -> str:
f91f0fd5
TL
205 """
206 >>> #doctest: +SKIP
207 ... ps = PlacementSpec(...) # For all placement specs:
208 ... PlacementSpec.from_string(ps.pretty_str()) == ps
209 """
9f95a23c 210 kv = []
f91f0fd5
TL
211 if self.hosts:
212 kv.append(';'.join([str(h) for h in self.hosts]))
9f95a23c
TL
213 if self.count:
214 kv.append('count:%d' % self.count)
f67539c2
TL
215 if self.count_per_host:
216 kv.append('count-per-host:%d' % self.count_per_host)
9f95a23c
TL
217 if self.label:
218 kv.append('label:%s' % self.label)
9f95a23c
TL
219 if self.host_pattern:
220 kv.append(self.host_pattern)
f91f0fd5 221 return ';'.join(kv)
9f95a23c 222
f67539c2 223 def __repr__(self) -> str:
9f95a23c
TL
224 kv = []
225 if self.count:
226 kv.append('count=%d' % self.count)
f67539c2
TL
227 if self.count_per_host:
228 kv.append('count_per_host=%d' % self.count_per_host)
9f95a23c
TL
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
1911f103 238 @handle_type_error
f67539c2 239 def from_json(cls, data: dict) -> 'PlacementSpec':
1911f103
TL
240 c = data.copy()
241 hosts = c.get('hosts', [])
9f95a23c 242 if hosts:
1911f103
TL
243 c['hosts'] = []
244 for host in hosts:
f91f0fd5 245 c['hosts'].append(HostPlacementSpec.from_json(host))
1911f103 246 _cls = cls(**c)
9f95a23c
TL
247 _cls.validate()
248 return _cls
249
f67539c2
TL
250 def to_json(self) -> dict:
251 r: Dict[str, Any] = {}
9f95a23c
TL
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
f67539c2
TL
258 if self.count_per_host:
259 r['count_per_host'] = self.count_per_host
9f95a23c
TL
260 if self.host_pattern:
261 r['host_pattern'] = self.host_pattern
262 return r
263
f67539c2 264 def validate(self) -> None:
9f95a23c
TL
265 if self.hosts and self.label:
266 # TODO: a less generic Exception
f67539c2 267 raise SpecValidationError('Host and label are mutually exclusive')
20effc67
TL
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")
f67539c2
TL
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 )
20effc67
TL
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
9f95a23c
TL
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:
1d09f67e 318
9f95a23c
TL
319 >>> PlacementSpec.from_string('3')
320 PlacementSpec(count=3)
321
322 A list of names is parsed as host specifications:
1d09f67e 323
9f95a23c
TL
324 >>> PlacementSpec.from_string('host1 host2')
325 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
326tSpec(hostname='host2', network='', name='')])
327
328 You can also prefix the hosts with a count as follows:
1d09f67e 329
9f95a23c
TL
330 >>> PlacementSpec.from_string('2 host1 host2')
331 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
332tPlacementSpec(hostname='host2', network='', name='')])
333
1d09f67e
TL
334 You can specify labels using `label:<label>`
335
9f95a23c
TL
336 >>> PlacementSpec.from_string('label:mon')
337 PlacementSpec(label='mon')
338
1d09f67e
TL
339 Labels also support a count:
340
9f95a23c
TL
341 >>> PlacementSpec.from_string('3 label:mon')
342 PlacementSpec(count=3, label='mon')
343
344 fnmatch is also supported:
1d09f67e 345
9f95a23c
TL
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:
f67539c2 368 raise SpecValidationError('invalid placement %s' % arg)
9f95a23c
TL
369
370 count = None
f67539c2 371 count_per_host = None
9f95a23c
TL
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:
f67539c2
TL
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:'):])
9f95a23c
TL
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:
f67539c2 403 raise SpecValidationError('more than one label provided: {}'.format(labels))
9f95a23c
TL
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:
f67539c2 410 raise SpecValidationError(
9f95a23c
TL
411 'more than one host pattern provided: {}'.format(host_patterns))
412
413 ps = PlacementSpec(count=count,
f67539c2 414 count_per_host=count_per_host,
9f95a23c
TL
415 hosts=advanced_hostspecs,
416 label=label,
417 host_pattern=host_patterns[0] if host_patterns else None)
418 return ps
419
420
20effc67
TL
421_service_spec_from_json_validate = True
422
423
2a845540
TL
424class 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
20effc67
TL
476@contextmanager
477def 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
9f95a23c
TL
489class 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.
9f95a23c 498 """
33c7a0ef 499 KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi loki promtail mds mgr mon nfs ' \
1e59de90
TL
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()
20effc67 503 REQUIRES_SERVICE_ID = 'iscsi mds nfs rgw container ingress '.split()
f67539c2
TL
504 MANAGED_CONFIG_OPTIONS = [
505 'mds_join_fs',
506 ]
9f95a23c 507
1911f103 508 @classmethod
f67539c2 509 def _cls(cls: Type[ServiceSpecT], service_type: str) -> Type[ServiceSpecT]:
1911f103
TL
510 from ceph.deployment.drive_group import DriveGroupSpec
511
512 ret = {
1e59de90 513 'mon': MONSpec,
1911f103
TL
514 'rgw': RGWSpec,
515 'nfs': NFSServiceSpec,
516 'osd': DriveGroupSpec,
33c7a0ef 517 'mds': MDSSpec,
1911f103 518 'iscsi': IscsiServiceSpec,
f91f0fd5 519 'alertmanager': AlertManagerSpec,
f67539c2 520 'ingress': IngressSpec,
f91f0fd5 521 'container': CustomContainerSpec,
20effc67 522 'grafana': GrafanaSpec,
b3b6e05e 523 'node-exporter': MonitoringSpec,
39ae355f
TL
524 'ceph-exporter': CephExporterSpec,
525 'prometheus': PrometheusSpec,
33c7a0ef
TL
526 'loki': MonitoringSpec,
527 'promtail': MonitoringSpec,
20effc67 528 'snmp-gateway': SNMPGatewaySpec,
1e59de90
TL
529 'elasticsearch': TracingSpec,
530 'jaeger-agent': TracingSpec,
531 'jaeger-collector': TracingSpec,
532 'jaeger-query': TracingSpec,
533 'jaeger-tracing': TracingSpec,
1911f103
TL
534 }.get(service_type, cls)
535 if ret == ServiceSpec and not service_type:
f67539c2 536 raise SpecValidationError('Spec needs a "service_type" key.')
1911f103
TL
537 return ret
538
f67539c2 539 def __new__(cls: Type[ServiceSpecT], *args: Any, **kwargs: Any) -> ServiceSpecT:
1911f103
TL
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)
f67539c2 551 sub_cls: Any = cls._cls(service_type)
1911f103
TL
552 return object.__new__(sub_cls)
553
9f95a23c 554 def __init__(self,
e306af50
TL
555 service_type: str,
556 service_id: Optional[str] = None,
557 placement: Optional[PlacementSpec] = None,
558 count: Optional[int] = None,
f67539c2 559 config: Optional[Dict[str, str]] = None,
e306af50 560 unmanaged: bool = False,
f6b5b4d7 561 preview_only: bool = False,
f67539c2 562 networks: Optional[List[str]] = None,
20effc67 563 extra_container_args: Optional[List[str]] = None,
39ae355f 564 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 565 custom_configs: Optional[List[CustomConfig]] = None,
9f95a23c 566 ):
a4b75251
TL
567
568 #: See :ref:`orchestrator-cli-placement-spec`.
9f95a23c
TL
569 self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec
570
571 assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type
a4b75251
TL
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.
9f95a23c 577 self.service_type = service_type
a4b75251
TL
578
579 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
580 #: ``container``, ``ingress``
f6b5b4d7 581 self.service_id = None
a4b75251 582
20effc67 583 if self.service_type in self.REQUIRES_SERVICE_ID or self.service_type == 'osd':
f6b5b4d7 584 self.service_id = service_id
a4b75251
TL
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`
9f95a23c 590 self.unmanaged = unmanaged
f6b5b4d7 591 self.preview_only = preview_only
a4b75251
TL
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`.
f67539c2
TL
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()}
9f95a23c 603
20effc67 604 self.extra_container_args: Optional[List[str]] = extra_container_args
39ae355f 605 self.extra_entrypoint_args: Optional[List[str]] = extra_entrypoint_args
2a845540 606 self.custom_configs: Optional[List[CustomConfig]] = custom_configs
20effc67 607
9f95a23c 608 @classmethod
1911f103 609 @handle_type_error
f67539c2 610 def from_json(cls: Type[ServiceSpecT], json_spec: Dict) -> ServiceSpecT:
9f95a23c
TL
611 """
612 Initialize 'ServiceSpec' object data from a json structure
f6b5b4d7
TL
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
f67539c2
TL
631 config:
632 some_option: the_value
633 networks: [10.10.0.0/16]
f6b5b4d7
TL
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
1e59de90 643 the next two major releases (octopus, pacific).
f6b5b4d7 644
9f95a23c 645 :param json_spec: A valid dict with ServiceSpec
a4b75251
TL
646
647 :meta private:
9f95a23c 648 """
f67539c2
TL
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
1911f103 655 c = json_spec.copy()
9f95a23c 656
1911f103
TL
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()`
9f95a23c 673
1911f103 674 return _cls._from_json_impl(c) # type: ignore
9f95a23c 675
f67539c2
TL
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
9f95a23c 688 @classmethod
f67539c2
TL
689 def _from_json_impl(cls: Type[ServiceSpecT], json_spec: dict) -> ServiceSpecT:
690 args = {} # type: Dict[str, Any]
9f95a23c
TL
691 for k, v in json_spec.items():
692 if k == 'placement':
693 v = PlacementSpec.from_json(v)
2a845540
TL
694 if k == 'custom_configs':
695 v = [CustomConfig.from_json(c) for c in v]
9f95a23c
TL
696 if k == 'spec':
697 args.update(v)
698 continue
699 args.update({k: v})
1911f103 700 _cls = cls(**args)
20effc67
TL
701 if _service_spec_from_json_validate:
702 _cls.validate()
1911f103 703 return _cls
9f95a23c 704
f67539c2 705 def service_name(self) -> str:
9f95a23c
TL
706 n = self.service_type
707 if self.service_id:
708 n += '.' + self.service_id
709 return n
710
f67539c2
TL
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
9f95a23c 719 def to_json(self):
f6b5b4d7
TL
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()
20effc67
TL
726 if self.placement.to_json():
727 ret['placement'] = self.placement.to_json()
f6b5b4d7
TL
728 if self.unmanaged:
729 ret['unmanaged'] = self.unmanaged
f67539c2
TL
730 if self.networks:
731 ret['networks'] = self.networks
20effc67
TL
732 if self.extra_container_args:
733 ret['extra_container_args'] = self.extra_container_args
39ae355f
TL
734 if self.extra_entrypoint_args:
735 ret['extra_entrypoint_args'] = self.extra_entrypoint_args
2a845540
TL
736 if self.custom_configs:
737 ret['custom_configs'] = [c.to_json() for c in self.custom_configs]
f6b5b4d7 738
9f95a23c 739 c = {}
f6b5b4d7
TL
740 for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]):
741 if key in ret:
742 continue
9f95a23c
TL
743 if hasattr(val, 'to_json'):
744 val = val.to_json()
745 if val:
746 c[key] = val
f6b5b4d7
TL
747 if c:
748 ret['spec'] = c
749 return ret
9f95a23c 750
f67539c2 751 def validate(self) -> None:
9f95a23c 752 if not self.service_type:
f67539c2 753 raise SpecValidationError('Cannot add Service: type required')
9f95a23c 754
20effc67
TL
755 if self.service_type != 'osd':
756 if self.service_type in self.REQUIRES_SERVICE_ID and not self.service_id:
f67539c2 757 raise SpecValidationError('Cannot add Service: id required')
20effc67
TL
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:
33c7a0ef 763 if not re.match('^[a-zA-Z0-9_.-]+$', str(self.service_id)):
f67539c2
TL
764 raise SpecValidationError('Service id contains invalid characters, '
765 'only [a-zA-Z0-9_.-] allowed')
f6b5b4d7 766
9f95a23c
TL
767 if self.placement is not None:
768 self.placement.validate()
f67539c2
TL
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 )
9f95a23c 782
f67539c2 783 def __repr__(self) -> str:
20effc67
TL
784 y = yaml.dump(cast(dict, self), default_flow_style=False)
785 return f"{self.__class__.__name__}.from_json(yaml.safe_load('''{y}'''))"
9f95a23c 786
f67539c2 787 def __eq__(self, other: Any) -> bool:
f6b5b4d7
TL
788 return (self.__class__ == other.__class__
789 and
790 self.__dict__ == other.__dict__)
791
f67539c2 792 def one_line_str(self) -> str:
9f95a23c
TL
793 return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name())
794
f6b5b4d7 795 @staticmethod
f67539c2 796 def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec') -> Any:
522d829b 797 return dumper.represent_dict(cast(Mapping, data.to_json().items()))
f6b5b4d7 798
9f95a23c 799
f6b5b4d7 800yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer)
9f95a23c
TL
801
802
803class NFSServiceSpec(ServiceSpec):
e306af50
TL
804 def __init__(self,
805 service_type: str = 'nfs',
806 service_id: Optional[str] = None,
e306af50
TL
807 placement: Optional[PlacementSpec] = None,
808 unmanaged: bool = False,
f67539c2
TL
809 preview_only: bool = False,
810 config: Optional[Dict[str, str]] = None,
811 networks: Optional[List[str]] = None,
b3b6e05e 812 port: Optional[int] = None,
1e59de90 813 virtual_ip: Optional[str] = None,
33c7a0ef 814 extra_container_args: Optional[List[str]] = None,
39ae355f 815 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 816 custom_configs: Optional[List[CustomConfig]] = None,
e306af50 817 ):
9f95a23c
TL
818 assert service_type == 'nfs'
819 super(NFSServiceSpec, self).__init__(
820 'nfs', service_id=service_id,
f67539c2 821 placement=placement, unmanaged=unmanaged, preview_only=preview_only,
2a845540 822 config=config, networks=networks, extra_container_args=extra_container_args,
39ae355f 823 extra_entrypoint_args=extra_entrypoint_args, custom_configs=custom_configs)
9f95a23c 824
b3b6e05e 825 self.port = port
1e59de90 826 self.virtual_ip = virtual_ip
b3b6e05e
TL
827
828 def get_port_start(self) -> List[int]:
829 if self.port:
830 return [self.port]
831 return []
9f95a23c 832
1911f103
TL
833 def rados_config_name(self):
834 # type: () -> str
835 return 'conf-' + self.service_name()
836
9f95a23c 837
f6b5b4d7
TL
838yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer)
839
840
9f95a23c
TL
841class RGWSpec(ServiceSpec):
842 """
843 Settings to configure a (multisite) Ceph RGW
844
a4b75251
TL
845 .. code-block:: yaml
846
847 service_type: rgw
848 service_id: myrealm.myzone
849 spec:
850 rgw_realm: myrealm
1e59de90 851 rgw_zonegroup: myzonegroup
a4b75251
TL
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`
9f95a23c 859 """
a4b75251 860
f67539c2
TL
861 MANAGED_CONFIG_OPTIONS = ServiceSpec.MANAGED_CONFIG_OPTIONS + [
862 'rgw_zone',
863 'rgw_realm',
1e59de90 864 'rgw_zonegroup',
f67539c2
TL
865 'rgw_frontends',
866 ]
867
9f95a23c 868 def __init__(self,
e306af50
TL
869 service_type: str = 'rgw',
870 service_id: Optional[str] = None,
871 placement: Optional[PlacementSpec] = None,
872 rgw_realm: Optional[str] = None,
1e59de90 873 rgw_zonegroup: Optional[str] = None,
e306af50 874 rgw_zone: Optional[str] = None,
e306af50
TL
875 rgw_frontend_port: Optional[int] = None,
876 rgw_frontend_ssl_certificate: Optional[List[str]] = None,
f67539c2 877 rgw_frontend_type: Optional[str] = None,
1e59de90 878 rgw_frontend_extra_args: Optional[List[str]] = None,
e306af50
TL
879 unmanaged: bool = False,
880 ssl: bool = False,
f6b5b4d7 881 preview_only: bool = False,
f67539c2
TL
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
33c7a0ef 885 extra_container_args: Optional[List[str]] = None,
39ae355f 886 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 887 custom_configs: Optional[List[CustomConfig]] = None,
1e59de90
TL
888 rgw_realm_token: Optional[str] = None,
889 update_endpoints: Optional[bool] = False,
890 zone_endpoints: Optional[str] = None # commad separated endpoints list
9f95a23c 891 ):
1911f103 892 assert service_type == 'rgw', service_type
f67539c2
TL
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
9f95a23c
TL
898 super(RGWSpec, self).__init__(
899 'rgw', service_id=service_id,
f6b5b4d7 900 placement=placement, unmanaged=unmanaged,
33c7a0ef 901 preview_only=preview_only, config=config, networks=networks,
39ae355f
TL
902 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
903 custom_configs=custom_configs)
9f95a23c 904
a4b75251 905 #: The RGW realm associated with this service. Needs to be manually created
1e59de90
TL
906 #: if the spec is being applied directly to cephdam. In case of rgw module
907 #: the realm is created automatically.
a4b75251 908 self.rgw_realm: Optional[str] = rgw_realm
1e59de90
TL
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
a4b75251 913 #: The RGW zone associated with this service. Needs to be manually created
1e59de90
TL
914 #: if the spec is being applied directly to cephdam. In case of rgw module
915 #: the zone is created automatically.
a4b75251
TL
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
1e59de90
TL
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
a4b75251 925 #: enable SSL
9f95a23c 926 self.ssl = ssl
1e59de90
TL
927 self.rgw_realm_token = rgw_realm_token
928 self.update_endpoints = update_endpoints
929 self.zone_endpoints = zone_endpoints
9f95a23c 930
f67539c2
TL
931 def get_port_start(self) -> List[int]:
932 return [self.get_port()]
933
934 def get_port(self) -> int:
9f95a23c
TL
935 if self.rgw_frontend_port:
936 return self.rgw_frontend_port
937 if self.ssl:
938 return 443
939 else:
940 return 80
1911f103 941
f67539c2 942 def validate(self) -> None:
f6b5b4d7
TL
943 super(RGWSpec, self).validate()
944
f67539c2
TL
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:
1e59de90
TL
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 )
f6b5b4d7
TL
957
958
959yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer)
960
1911f103
TL
961
962class IscsiServiceSpec(ServiceSpec):
e306af50
TL
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,
39ae355f
TL
968 api_port: Optional[int] = 5000,
969 api_user: Optional[str] = 'admin',
970 api_password: Optional[str] = 'admin',
e306af50
TL
971 api_secure: Optional[bool] = None,
972 ssl_cert: Optional[str] = None,
973 ssl_key: Optional[str] = None,
974 placement: Optional[PlacementSpec] = None,
f6b5b4d7 975 unmanaged: bool = False,
f67539c2
TL
976 preview_only: bool = False,
977 config: Optional[Dict[str, str]] = None,
978 networks: Optional[List[str]] = None,
33c7a0ef 979 extra_container_args: Optional[List[str]] = None,
39ae355f 980 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 981 custom_configs: Optional[List[CustomConfig]] = None,
e306af50 982 ):
1911f103
TL
983 assert service_type == 'iscsi'
984 super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id,
f6b5b4d7 985 placement=placement, unmanaged=unmanaged,
f67539c2 986 preview_only=preview_only,
33c7a0ef 987 config=config, networks=networks,
2a845540 988 extra_container_args=extra_container_args,
39ae355f 989 extra_entrypoint_args=extra_entrypoint_args,
2a845540 990 custom_configs=custom_configs)
1911f103
TL
991
992 #: RADOS pool where ceph-iscsi config data is stored.
993 self.pool = pool
a4b75251 994 #: list of trusted IP addresses
1911f103 995 self.trusted_ip_list = trusted_ip_list
a4b75251 996 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
1911f103 997 self.api_port = api_port
a4b75251 998 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
1911f103 999 self.api_user = api_user
a4b75251 1000 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
1911f103 1001 self.api_password = api_password
a4b75251 1002 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
1911f103 1003 self.api_secure = api_secure
a4b75251 1004 #: SSL certificate
1911f103 1005 self.ssl_cert = ssl_cert
a4b75251 1006 #: SSL private key
1911f103
TL
1007 self.ssl_key = ssl_key
1008
e306af50
TL
1009 if not self.api_secure and self.ssl_cert and self.ssl_key:
1010 self.api_secure = True
1011
39ae355f
TL
1012 def get_port_start(self) -> List[int]:
1013 return [self.api_port or 5000]
1014
f67539c2 1015 def validate(self) -> None:
e306af50 1016 super(IscsiServiceSpec, self).validate()
1911f103
TL
1017
1018 if not self.pool:
f67539c2 1019 raise SpecValidationError(
1911f103 1020 'Cannot add ISCSI: No Pool specified')
adb31ebb
TL
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.
f6b5b4d7
TL
1027
1028
1029yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer)
1030
1031
f67539c2
TL
1032class 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,
b3b6e05e 1042 ssl_key: Optional[str] = None,
f67539c2
TL
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,
2a845540 1052 virtual_ips_list: Optional[List[str]] = None,
f67539c2 1053 virtual_interface_networks: Optional[List[str]] = [],
b3b6e05e 1054 unmanaged: bool = False,
33c7a0ef 1055 ssl: bool = False,
1e59de90 1056 keepalive_only: bool = False,
33c7a0ef 1057 extra_container_args: Optional[List[str]] = None,
39ae355f 1058 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 1059 custom_configs: Optional[List[CustomConfig]] = None,
f67539c2
TL
1060 ):
1061 assert service_type == 'ingress'
2a845540 1062
f67539c2
TL
1063 super(IngressSpec, self).__init__(
1064 'ingress', service_id=service_id,
1065 placement=placement, config=config,
33c7a0ef 1066 networks=networks,
2a845540 1067 extra_container_args=extra_container_args,
39ae355f 1068 extra_entrypoint_args=extra_entrypoint_args,
2a845540 1069 custom_configs=custom_configs
f67539c2
TL
1070 )
1071 self.backend_service = backend_service
1072 self.frontend_port = frontend_port
1073 self.ssl_cert = ssl_cert
b3b6e05e 1074 self.ssl_key = ssl_key
f67539c2
TL
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
2a845540 1083 self.virtual_ips_list = virtual_ips_list
f67539c2 1084 self.virtual_interface_networks = virtual_interface_networks or []
b3b6e05e
TL
1085 self.unmanaged = unmanaged
1086 self.ssl = ssl
1e59de90 1087 self.keepalive_only = keepalive_only
f67539c2
TL
1088
1089 def get_port_start(self) -> List[int]:
1e59de90
TL
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
f67539c2
TL
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')
1e59de90 1106 if not self.keepalive_only and not self.frontend_port:
f67539c2
TL
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')
2a845540 1112 if not self.virtual_ip and not self.virtual_ips_list:
f67539c2
TL
1113 raise SpecValidationError(
1114 'Cannot add ingress: No virtual_ip provided')
2a845540
TL
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')
f67539c2
TL
1118
1119
b3b6e05e
TL
1120yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer)
1121
1122
f91f0fd5
TL
1123class CustomContainerSpec(ServiceSpec):
1124 def __init__(self,
1125 service_type: str = 'container',
f67539c2
TL
1126 service_id: Optional[str] = None,
1127 config: Optional[Dict[str, str]] = None,
1128 networks: Optional[List[str]] = None,
f91f0fd5
TL
1129 placement: Optional[PlacementSpec] = None,
1130 unmanaged: bool = False,
1131 preview_only: bool = False,
f67539c2 1132 image: Optional[str] = None,
f91f0fd5 1133 entrypoint: Optional[str] = None,
39ae355f 1134 extra_entrypoint_args: Optional[List[str]] = None,
f91f0fd5
TL
1135 uid: Optional[int] = None,
1136 gid: Optional[int] = None,
1137 volume_mounts: Optional[Dict[str, str]] = {},
39ae355f 1138 args: Optional[List[str]] = [], # args for the container runtime, not entrypoint
f91f0fd5
TL
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,
f67539c2 1153 preview_only=preview_only, config=config,
39ae355f 1154 networks=networks, extra_entrypoint_args=extra_entrypoint_args)
f91f0fd5
TL
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
1188yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer)
b3b6e05e
TL
1189
1190
1191class 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,
33c7a0ef 1201 extra_container_args: Optional[List[str]] = None,
39ae355f 1202 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 1203 custom_configs: Optional[List[CustomConfig]] = None,
b3b6e05e 1204 ):
33c7a0ef
TL
1205 assert service_type in ['grafana', 'node-exporter', 'prometheus', 'alertmanager',
1206 'loki', 'promtail']
b3b6e05e
TL
1207
1208 super(MonitoringSpec, self).__init__(
1209 service_type, service_id,
1210 placement=placement, unmanaged=unmanaged,
1211 preview_only=preview_only, config=config,
2a845540 1212 networks=networks, extra_container_args=extra_container_args,
39ae355f 1213 extra_entrypoint_args=extra_entrypoint_args,
2a845540 1214 custom_configs=custom_configs)
b3b6e05e
TL
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,
20effc67 1228 'alertmanager': 9093,
33c7a0ef
TL
1229 'grafana': 3000,
1230 'loki': 3100,
1231 'promtail': 9080}[self.service_type]
20effc67
TL
1232
1233
1234yaml.add_representer(MonitoringSpec, ServiceSpec.yaml_representer)
1235
1236
1237class 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,
33c7a0ef
TL
1248 secure: bool = False,
1249 extra_container_args: Optional[List[str]] = None,
39ae355f 1250 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 1251 custom_configs: Optional[List[CustomConfig]] = None,
20effc67
TL
1252 ):
1253 assert service_type == 'alertmanager'
1254 super(AlertManagerSpec, self).__init__(
1255 'alertmanager', service_id=service_id,
1256 placement=placement, unmanaged=unmanaged,
33c7a0ef 1257 preview_only=preview_only, config=config, networks=networks, port=port,
39ae355f
TL
1258 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
1259 custom_configs=custom_configs)
20effc67
TL
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 {}
33c7a0ef 1276 self.secure = secure
20effc67
TL
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
1289yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer)
1290
1291
1292class 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,
1e59de90 1302 protocol: Optional[str] = 'https',
33c7a0ef 1303 initial_admin_password: Optional[str] = None,
1e59de90 1304 anonymous_access: Optional[bool] = True,
33c7a0ef 1305 extra_container_args: Optional[List[str]] = None,
39ae355f 1306 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 1307 custom_configs: Optional[List[CustomConfig]] = None,
20effc67
TL
1308 ):
1309 assert service_type == 'grafana'
1310 super(GrafanaSpec, self).__init__(
1311 'grafana', service_id=service_id,
1312 placement=placement, unmanaged=unmanaged,
33c7a0ef 1313 preview_only=preview_only, config=config, networks=networks, port=port,
39ae355f
TL
1314 extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args,
1315 custom_configs=custom_configs)
20effc67
TL
1316
1317 self.initial_admin_password = initial_admin_password
1e59de90
TL
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)
20effc67
TL
1332
1333
1334yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer)
1335
1336
39ae355f
TL
1337class 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
1e59de90
TL
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
39ae355f
TL
1380
1381yaml.add_representer(PrometheusSpec, ServiceSpec.yaml_representer)
1382
1383
20effc67
TL
1384class 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,
33c7a0ef 1423 extra_container_args: Optional[List[str]] = None,
39ae355f 1424 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 1425 custom_configs: Optional[List[CustomConfig]] = None,
20effc67
TL
1426 ):
1427 assert service_type == 'snmp-gateway'
1428
1429 super(SNMPGatewaySpec, self).__init__(
1430 service_type,
1431 placement=placement,
1432 unmanaged=unmanaged,
33c7a0ef 1433 preview_only=preview_only,
2a845540 1434 extra_container_args=extra_container_args,
39ae355f 1435 extra_entrypoint_args=extra_entrypoint_args,
2a845540 1436 custom_configs=custom_configs)
20effc67
TL
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
1535yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer)
33c7a0ef
TL
1536
1537
1538class 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,
39ae355f 1547 extra_entrypoint_args: Optional[List[str]] = None,
2a845540 1548 custom_configs: Optional[List[CustomConfig]] = None,
33c7a0ef
TL
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,
2a845540 1556 extra_container_args=extra_container_args,
39ae355f 1557 extra_entrypoint_args=extra_entrypoint_args,
2a845540 1558 custom_configs=custom_configs)
33c7a0ef
TL
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
1567yaml.add_representer(MDSSpec, ServiceSpec.yaml_representer)
2a845540
TL
1568
1569
1e59de90
TL
1570class 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
1613yaml.add_representer(MONSpec, ServiceSpec.yaml_representer)
1614
1615
1616class 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
1674yaml.add_representer(TracingSpec, ServiceSpec.yaml_representer)
1675
1676
2a845540
TL
1677class 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())
39ae355f
TL
1727
1728
1729class 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
1769yaml.add_representer(CephExporterSpec, ServiceSpec.yaml_representer)