]>
Commit | Line | Data |
---|---|---|
9f95a23c | 1 | import fnmatch |
2a845540 | 2 | import os |
9f95a23c | 3 | import re |
20effc67 | 4 | import enum |
f67539c2 | 5 | from collections import OrderedDict |
20effc67 | 6 | from contextlib import contextmanager |
1911f103 | 7 | from functools import wraps |
f67539c2 TL |
8 | from ipaddress import ip_network, ip_address |
9 | from typing import Optional, Dict, Any, List, Union, Callable, Iterable, Type, TypeVar, cast, \ | |
20effc67 | 10 | NamedTuple, Mapping, Iterator |
9f95a23c | 11 | |
f6b5b4d7 TL |
12 | import yaml |
13 | ||
20effc67 TL |
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 | |
9f95a23c | 17 | |
f67539c2 TL |
18 | ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec') |
19 | FuncT = TypeVar('FuncT', bound=Callable) | |
9f95a23c | 20 | |
9f95a23c | 21 | |
f67539c2 | 22 | def 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 |
33 | class 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 | ||
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] | |
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\ | |
326 | tSpec(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\ | |
332 | tPlacementSpec(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 |
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 | ||
20effc67 TL |
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 | ||
9f95a23c TL |
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. | |
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 | 800 | yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer) |
9f95a23c TL |
801 | |
802 | ||
803 | class 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 |
838 | yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer) |
839 | ||
840 | ||
9f95a23c TL |
841 | class 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 | ||
959 | yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer) | |
960 | ||
1911f103 TL |
961 | |
962 | class 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 | ||
1029 | yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer) | |
1030 | ||
1031 | ||
f67539c2 TL |
1032 | class IngressSpec(ServiceSpec): |
1033 | def __init__(self, | |
1034 | service_type: str = 'ingress', | |
1035 | service_id: Optional[str] = None, | |
1036 | config: Optional[Dict[str, str]] = None, | |
1037 | networks: Optional[List[str]] = None, | |
1038 | placement: Optional[PlacementSpec] = None, | |
1039 | backend_service: Optional[str] = None, | |
1040 | frontend_port: Optional[int] = None, | |
1041 | ssl_cert: Optional[str] = None, | |
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 |
1120 | yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer) |
1121 | ||
1122 | ||
f91f0fd5 TL |
1123 | class 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 | ||
1188 | yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer) | |
b3b6e05e TL |
1189 | |
1190 | ||
1191 | class MonitoringSpec(ServiceSpec): | |
1192 | def __init__(self, | |
1193 | service_type: str, | |
1194 | service_id: Optional[str] = None, | |
1195 | config: Optional[Dict[str, str]] = None, | |
1196 | networks: Optional[List[str]] = None, | |
1197 | placement: Optional[PlacementSpec] = None, | |
1198 | unmanaged: bool = False, | |
1199 | preview_only: bool = False, | |
1200 | port: Optional[int] = None, | |
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 | ||
1234 | yaml.add_representer(MonitoringSpec, ServiceSpec.yaml_representer) | |
1235 | ||
1236 | ||
1237 | class AlertManagerSpec(MonitoringSpec): | |
1238 | def __init__(self, | |
1239 | service_type: str = 'alertmanager', | |
1240 | service_id: Optional[str] = None, | |
1241 | placement: Optional[PlacementSpec] = None, | |
1242 | unmanaged: bool = False, | |
1243 | preview_only: bool = False, | |
1244 | user_data: Optional[Dict[str, Any]] = None, | |
1245 | config: Optional[Dict[str, str]] = None, | |
1246 | networks: Optional[List[str]] = None, | |
1247 | port: Optional[int] = None, | |
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 | ||
1289 | yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer) | |
1290 | ||
1291 | ||
1292 | class GrafanaSpec(MonitoringSpec): | |
1293 | def __init__(self, | |
1294 | service_type: str = 'grafana', | |
1295 | service_id: Optional[str] = None, | |
1296 | placement: Optional[PlacementSpec] = None, | |
1297 | unmanaged: bool = False, | |
1298 | preview_only: bool = False, | |
1299 | config: Optional[Dict[str, str]] = None, | |
1300 | networks: Optional[List[str]] = None, | |
1301 | port: Optional[int] = None, | |
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 | ||
1334 | yaml.add_representer(GrafanaSpec, ServiceSpec.yaml_representer) | |
1335 | ||
1336 | ||
39ae355f TL |
1337 | class PrometheusSpec(MonitoringSpec): |
1338 | def __init__(self, | |
1339 | service_type: str = 'prometheus', | |
1340 | service_id: Optional[str] = None, | |
1341 | placement: Optional[PlacementSpec] = None, | |
1342 | unmanaged: bool = False, | |
1343 | preview_only: bool = False, | |
1344 | config: Optional[Dict[str, str]] = None, | |
1345 | networks: Optional[List[str]] = None, | |
1346 | port: Optional[int] = None, | |
1347 | retention_time: Optional[str] = None, | |
1348 | retention_size: Optional[str] = None, | |
1349 | extra_container_args: Optional[List[str]] = None, | |
1350 | extra_entrypoint_args: Optional[List[str]] = None, | |
1351 | custom_configs: Optional[List[CustomConfig]] = None, | |
1352 | ): | |
1353 | assert service_type == 'prometheus' | |
1354 | super(PrometheusSpec, self).__init__( | |
1355 | 'prometheus', service_id=service_id, | |
1356 | placement=placement, unmanaged=unmanaged, | |
1357 | preview_only=preview_only, config=config, networks=networks, port=port, | |
1358 | extra_container_args=extra_container_args, extra_entrypoint_args=extra_entrypoint_args, | |
1359 | custom_configs=custom_configs) | |
1360 | ||
1361 | self.retention_time = retention_time.strip() if retention_time else None | |
1362 | self.retention_size = retention_size.strip() if retention_size else None | |
1363 | ||
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 | |
1381 | yaml.add_representer(PrometheusSpec, ServiceSpec.yaml_representer) | |
1382 | ||
1383 | ||
20effc67 TL |
1384 | class SNMPGatewaySpec(ServiceSpec): |
1385 | class SNMPVersion(str, enum.Enum): | |
1386 | V2c = 'V2c' | |
1387 | V3 = 'V3' | |
1388 | ||
1389 | def to_json(self) -> str: | |
1390 | return self.value | |
1391 | ||
1392 | class SNMPAuthType(str, enum.Enum): | |
1393 | MD5 = 'MD5' | |
1394 | SHA = 'SHA' | |
1395 | ||
1396 | def to_json(self) -> str: | |
1397 | return self.value | |
1398 | ||
1399 | class SNMPPrivacyType(str, enum.Enum): | |
1400 | DES = 'DES' | |
1401 | AES = 'AES' | |
1402 | ||
1403 | def to_json(self) -> str: | |
1404 | return self.value | |
1405 | ||
1406 | valid_destination_types = [ | |
1407 | 'Name:Port', | |
1408 | 'IPv4:Port' | |
1409 | ] | |
1410 | ||
1411 | def __init__(self, | |
1412 | service_type: str = 'snmp-gateway', | |
1413 | snmp_version: Optional[SNMPVersion] = None, | |
1414 | snmp_destination: str = '', | |
1415 | credentials: Dict[str, str] = {}, | |
1416 | engine_id: Optional[str] = None, | |
1417 | auth_protocol: Optional[SNMPAuthType] = None, | |
1418 | privacy_protocol: Optional[SNMPPrivacyType] = None, | |
1419 | placement: Optional[PlacementSpec] = None, | |
1420 | unmanaged: bool = False, | |
1421 | preview_only: bool = False, | |
1422 | port: Optional[int] = None, | |
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 | ||
1535 | yaml.add_representer(SNMPGatewaySpec, ServiceSpec.yaml_representer) | |
33c7a0ef TL |
1536 | |
1537 | ||
1538 | class MDSSpec(ServiceSpec): | |
1539 | def __init__(self, | |
1540 | service_type: str = 'mds', | |
1541 | service_id: Optional[str] = None, | |
1542 | placement: Optional[PlacementSpec] = None, | |
1543 | config: Optional[Dict[str, str]] = None, | |
1544 | unmanaged: bool = False, | |
1545 | preview_only: bool = False, | |
1546 | extra_container_args: Optional[List[str]] = None, | |
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 | ||
1567 | yaml.add_representer(MDSSpec, ServiceSpec.yaml_representer) | |
2a845540 TL |
1568 | |
1569 | ||
1e59de90 TL |
1570 | class MONSpec(ServiceSpec): |
1571 | def __init__(self, | |
1572 | service_type: str, | |
1573 | service_id: Optional[str] = None, | |
1574 | placement: Optional[PlacementSpec] = None, | |
1575 | count: Optional[int] = None, | |
1576 | config: Optional[Dict[str, str]] = None, | |
1577 | unmanaged: bool = False, | |
1578 | preview_only: bool = False, | |
1579 | networks: Optional[List[str]] = None, | |
1580 | extra_container_args: Optional[List[str]] = None, | |
1581 | custom_configs: Optional[List[CustomConfig]] = None, | |
1582 | crush_locations: Optional[Dict[str, List[str]]] = None, | |
1583 | ): | |
1584 | assert service_type == 'mon' | |
1585 | super(MONSpec, self).__init__('mon', service_id=service_id, | |
1586 | placement=placement, | |
1587 | count=count, | |
1588 | config=config, | |
1589 | unmanaged=unmanaged, | |
1590 | preview_only=preview_only, | |
1591 | networks=networks, | |
1592 | extra_container_args=extra_container_args, | |
1593 | custom_configs=custom_configs) | |
1594 | ||
1595 | self.crush_locations = crush_locations | |
1596 | self.validate() | |
1597 | ||
1598 | def validate(self) -> None: | |
1599 | if self.crush_locations: | |
1600 | for host, crush_locs in self.crush_locations.items(): | |
1601 | try: | |
1602 | assert_valid_host(host) | |
1603 | except SpecValidationError as e: | |
1604 | err_str = f'Invalid hostname found in spec crush locations: {e}' | |
1605 | raise SpecValidationError(err_str) | |
1606 | for cloc in crush_locs: | |
1607 | if '=' not in cloc or len(cloc.split('=')) != 2: | |
1608 | err_str = ('Crush locations must be of form <bucket>=<location>. ' | |
1609 | f'Found crush location: {cloc}') | |
1610 | raise SpecValidationError(err_str) | |
1611 | ||
1612 | ||
1613 | yaml.add_representer(MONSpec, ServiceSpec.yaml_representer) | |
1614 | ||
1615 | ||
1616 | class TracingSpec(ServiceSpec): | |
1617 | SERVICE_TYPES = ['elasticsearch', 'jaeger-collector', 'jaeger-query', 'jaeger-agent'] | |
1618 | ||
1619 | def __init__(self, | |
1620 | service_type: str, | |
1621 | es_nodes: Optional[str] = None, | |
1622 | without_query: bool = False, | |
1623 | service_id: Optional[str] = None, | |
1624 | config: Optional[Dict[str, str]] = None, | |
1625 | networks: Optional[List[str]] = None, | |
1626 | placement: Optional[PlacementSpec] = None, | |
1627 | unmanaged: bool = False, | |
1628 | preview_only: bool = False | |
1629 | ): | |
1630 | assert service_type in TracingSpec.SERVICE_TYPES + ['jaeger-tracing'] | |
1631 | ||
1632 | super(TracingSpec, self).__init__( | |
1633 | service_type, service_id, | |
1634 | placement=placement, unmanaged=unmanaged, | |
1635 | preview_only=preview_only, config=config, | |
1636 | networks=networks) | |
1637 | self.without_query = without_query | |
1638 | self.es_nodes = es_nodes | |
1639 | ||
1640 | def get_port_start(self) -> List[int]: | |
1641 | return [self.get_port()] | |
1642 | ||
1643 | def get_port(self) -> int: | |
1644 | return {'elasticsearch': 9200, | |
1645 | 'jaeger-agent': 6799, | |
1646 | 'jaeger-collector': 14250, | |
1647 | 'jaeger-query': 16686}[self.service_type] | |
1648 | ||
1649 | def get_tracing_specs(self) -> List[ServiceSpec]: | |
1650 | assert self.service_type == 'jaeger-tracing' | |
1651 | specs: List[ServiceSpec] = [] | |
1652 | daemons: Dict[str, Optional[PlacementSpec]] = { | |
1653 | daemon: None for daemon in TracingSpec.SERVICE_TYPES} | |
1654 | ||
1655 | if self.es_nodes: | |
1656 | del daemons['elasticsearch'] | |
1657 | if self.without_query: | |
1658 | del daemons['jaeger-query'] | |
1659 | if self.placement: | |
1660 | daemons.update({'jaeger-collector': self.placement}) | |
1661 | ||
1662 | for daemon, daemon_placement in daemons.items(): | |
1663 | specs.append(TracingSpec(service_type=daemon, | |
1664 | es_nodes=self.es_nodes, | |
1665 | placement=daemon_placement, | |
1666 | unmanaged=self.unmanaged, | |
1667 | config=self.config, | |
1668 | networks=self.networks, | |
1669 | preview_only=self.preview_only | |
1670 | )) | |
1671 | return specs | |
1672 | ||
1673 | ||
1674 | yaml.add_representer(TracingSpec, ServiceSpec.yaml_representer) | |
1675 | ||
1676 | ||
2a845540 TL |
1677 | class TunedProfileSpec(): |
1678 | def __init__(self, | |
1679 | profile_name: str, | |
1680 | placement: Optional[PlacementSpec] = None, | |
1681 | settings: Optional[Dict[str, str]] = None, | |
1682 | ): | |
1683 | self.profile_name = profile_name | |
1684 | self.placement = placement or PlacementSpec(host_pattern='*') | |
1685 | self.settings = settings or {} | |
1686 | self._last_updated: str = '' | |
1687 | ||
1688 | @classmethod | |
1689 | def from_json(cls, spec: Dict[str, Any]) -> 'TunedProfileSpec': | |
1690 | data = {} | |
1691 | if 'profile_name' not in spec: | |
1692 | raise SpecValidationError('Tuned profile spec must include "profile_name" field') | |
1693 | data['profile_name'] = spec['profile_name'] | |
1694 | if not isinstance(data['profile_name'], str): | |
1695 | raise SpecValidationError('"profile_name" field must be a string') | |
1696 | if 'placement' in spec: | |
1697 | data['placement'] = PlacementSpec.from_json(spec['placement']) | |
1698 | if 'settings' in spec: | |
1699 | data['settings'] = spec['settings'] | |
1700 | return cls(**data) | |
1701 | ||
1702 | def to_json(self) -> Dict[str, Any]: | |
1703 | res: Dict[str, Any] = {} | |
1704 | res['profile_name'] = self.profile_name | |
1705 | res['placement'] = self.placement.to_json() | |
1706 | res['settings'] = self.settings | |
1707 | return res | |
1708 | ||
1709 | def __eq__(self, other: Any) -> bool: | |
1710 | if isinstance(other, TunedProfileSpec): | |
1711 | if ( | |
1712 | self.placement == other.placement | |
1713 | and self.profile_name == other.profile_name | |
1714 | and self.settings == other.settings | |
1715 | ): | |
1716 | return True | |
1717 | return False | |
1718 | return NotImplemented | |
1719 | ||
1720 | def __repr__(self) -> str: | |
1721 | return f'TunedProfile({self.profile_name})' | |
1722 | ||
1723 | def copy(self) -> 'TunedProfileSpec': | |
1724 | # for making deep copies so you can edit the settings in one without affecting the other | |
1725 | # mostly for testing purposes | |
1726 | return TunedProfileSpec(self.profile_name, self.placement, self.settings.copy()) | |
39ae355f TL |
1727 | |
1728 | ||
1729 | class CephExporterSpec(ServiceSpec): | |
1730 | def __init__(self, | |
1731 | service_type: str = 'ceph-exporter', | |
1732 | sock_dir: Optional[str] = None, | |
1733 | addrs: str = '', | |
1734 | port: Optional[int] = None, | |
1735 | prio_limit: Optional[int] = 5, | |
1736 | stats_period: Optional[int] = 5, | |
1737 | placement: Optional[PlacementSpec] = None, | |
1738 | unmanaged: bool = False, | |
1739 | preview_only: bool = False, | |
1740 | extra_container_args: Optional[List[str]] = None, | |
1741 | ): | |
1742 | assert service_type == 'ceph-exporter' | |
1743 | ||
1744 | super(CephExporterSpec, self).__init__( | |
1745 | service_type, | |
1746 | placement=placement, | |
1747 | unmanaged=unmanaged, | |
1748 | preview_only=preview_only, | |
1749 | extra_container_args=extra_container_args) | |
1750 | ||
1751 | self.service_type = service_type | |
1752 | self.sock_dir = sock_dir | |
1753 | self.addrs = addrs | |
1754 | self.port = port | |
1755 | self.prio_limit = prio_limit | |
1756 | self.stats_period = stats_period | |
1757 | ||
1758 | def validate(self) -> None: | |
1759 | super(CephExporterSpec, self).validate() | |
1760 | ||
1761 | if not isinstance(self.prio_limit, int): | |
1762 | raise SpecValidationError( | |
1763 | f'prio_limit must be an integer. Got {type(self.prio_limit)}') | |
1764 | if not isinstance(self.stats_period, int): | |
1765 | raise SpecValidationError( | |
1766 | f'stats_period must be an integer. Got {type(self.stats_period)}') | |
1767 | ||
1768 | ||
1769 | yaml.add_representer(CephExporterSpec, ServiceSpec.yaml_representer) |