]>
Commit | Line | Data |
---|---|---|
f6b5b4d7 | 1 | import errno |
9f95a23c TL |
2 | import fnmatch |
3 | import re | |
f6b5b4d7 | 4 | from collections import namedtuple, OrderedDict |
1911f103 | 5 | from functools import wraps |
f91f0fd5 | 6 | from typing import Optional, Dict, Any, List, Union, Callable, Iterable |
9f95a23c TL |
7 | |
8 | import six | |
f6b5b4d7 TL |
9 | import yaml |
10 | ||
11 | from ceph.deployment.hostspec import HostSpec | |
f91f0fd5 | 12 | from ceph.deployment.utils import unwrap_ipv6 |
9f95a23c TL |
13 | |
14 | ||
15 | class ServiceSpecValidationError(Exception): | |
16 | """ | |
17 | Defining an exception here is a bit problematic, cause you cannot properly catch it, | |
18 | if it was raised in a different mgr module. | |
19 | """ | |
f6b5b4d7 TL |
20 | def __init__(self, |
21 | msg: str, | |
22 | errno: int = -errno.EINVAL): | |
9f95a23c | 23 | super(ServiceSpecValidationError, self).__init__(msg) |
f6b5b4d7 | 24 | self.errno = errno |
9f95a23c TL |
25 | |
26 | ||
27 | def assert_valid_host(name): | |
28 | p = re.compile('^[a-zA-Z0-9-]+$') | |
29 | try: | |
30 | assert len(name) <= 250, 'name is too long (max 250 chars)' | |
31 | for part in name.split('.'): | |
32 | assert len(part) > 0, '.-delimited name component must not be empty' | |
33 | assert len(part) <= 63, '.-delimited name component must not be more than 63 chars' | |
34 | assert p.match(part), 'name component must include only a-z, 0-9, and -' | |
35 | except AssertionError as e: | |
36 | raise ServiceSpecValidationError(e) | |
37 | ||
38 | ||
1911f103 TL |
39 | def handle_type_error(method): |
40 | @wraps(method) | |
41 | def inner(cls, *args, **kwargs): | |
42 | try: | |
43 | return method(cls, *args, **kwargs) | |
44 | except (TypeError, AttributeError) as e: | |
45 | error_msg = '{}: {}'.format(cls.__name__, e) | |
f6b5b4d7 | 46 | raise ServiceSpecValidationError(error_msg) |
1911f103 TL |
47 | return inner |
48 | ||
49 | ||
9f95a23c TL |
50 | class HostPlacementSpec(namedtuple('HostPlacementSpec', ['hostname', 'network', 'name'])): |
51 | def __str__(self): | |
52 | res = '' | |
53 | res += self.hostname | |
54 | if self.network: | |
55 | res += ':' + self.network | |
56 | if self.name: | |
57 | res += '=' + self.name | |
58 | return res | |
59 | ||
60 | @classmethod | |
1911f103 | 61 | @handle_type_error |
9f95a23c | 62 | def from_json(cls, data): |
f91f0fd5 TL |
63 | if isinstance(data, str): |
64 | return cls.parse(data) | |
9f95a23c TL |
65 | return cls(**data) |
66 | ||
f91f0fd5 TL |
67 | def to_json(self) -> str: |
68 | return str(self) | |
9f95a23c TL |
69 | |
70 | @classmethod | |
71 | def parse(cls, host, require_network=True): | |
72 | # type: (str, bool) -> HostPlacementSpec | |
73 | """ | |
74 | Split host into host, network, and (optional) daemon name parts. The network | |
75 | part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'. | |
76 | e.g., | |
77 | "myhost" | |
78 | "myhost=name" | |
79 | "myhost:1.2.3.4" | |
80 | "myhost:1.2.3.4=name" | |
81 | "myhost:1.2.3.0/24" | |
82 | "myhost:1.2.3.0/24=name" | |
83 | "myhost:[v2:1.2.3.4:3000]=name" | |
84 | "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name" | |
85 | """ | |
86 | # Matches from start to : or = or until end of string | |
87 | host_re = r'^(.*?)(:|=|$)' | |
88 | # Matches from : to = or until end of string | |
89 | ip_re = r':(.*?)(=|$)' | |
90 | # Matches from = to end of string | |
91 | name_re = r'=(.*?)$' | |
92 | ||
93 | # assign defaults | |
94 | host_spec = cls('', '', '') | |
95 | ||
96 | match_host = re.search(host_re, host) | |
97 | if match_host: | |
98 | host_spec = host_spec._replace(hostname=match_host.group(1)) | |
99 | ||
100 | name_match = re.search(name_re, host) | |
101 | if name_match: | |
102 | host_spec = host_spec._replace(name=name_match.group(1)) | |
103 | ||
104 | ip_match = re.search(ip_re, host) | |
105 | if ip_match: | |
106 | host_spec = host_spec._replace(network=ip_match.group(1)) | |
107 | ||
108 | if not require_network: | |
109 | return host_spec | |
110 | ||
111 | from ipaddress import ip_network, ip_address | |
112 | networks = list() # type: List[str] | |
113 | network = host_spec.network | |
114 | # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478] | |
115 | if ',' in network: | |
116 | networks = [x for x in network.split(',')] | |
117 | else: | |
1911f103 TL |
118 | if network != '': |
119 | networks.append(network) | |
120 | ||
9f95a23c TL |
121 | for network in networks: |
122 | # only if we have versioned network configs | |
123 | if network.startswith('v') or network.startswith('[v'): | |
f91f0fd5 TL |
124 | # if this is ipv6 we can't just simply split on ':' so do |
125 | # a split once and rsplit once to leave us with just ipv6 addr | |
126 | network = network.split(':', 1)[1] | |
127 | network = network.rsplit(':', 1)[0] | |
9f95a23c TL |
128 | try: |
129 | # if subnets are defined, also verify the validity | |
130 | if '/' in network: | |
131 | ip_network(six.text_type(network)) | |
132 | else: | |
f91f0fd5 | 133 | ip_address(unwrap_ipv6(network)) |
9f95a23c TL |
134 | except ValueError as e: |
135 | # logging? | |
136 | raise e | |
137 | host_spec.validate() | |
138 | return host_spec | |
139 | ||
140 | def validate(self): | |
141 | assert_valid_host(self.hostname) | |
142 | ||
143 | ||
144 | class PlacementSpec(object): | |
145 | """ | |
146 | For APIs that need to specify a host subset | |
147 | """ | |
148 | ||
149 | def __init__(self, | |
150 | label=None, # type: Optional[str] | |
151 | hosts=None, # type: Union[List[str],List[HostPlacementSpec]] | |
152 | count=None, # type: Optional[int] | |
153 | host_pattern=None # type: Optional[str] | |
154 | ): | |
155 | # type: (...) -> None | |
156 | self.label = label | |
157 | self.hosts = [] # type: List[HostPlacementSpec] | |
158 | ||
159 | if hosts: | |
f6b5b4d7 | 160 | self.set_hosts(hosts) |
9f95a23c TL |
161 | |
162 | self.count = count # type: Optional[int] | |
163 | ||
164 | #: fnmatch patterns to select hosts. Can also be a single host. | |
165 | self.host_pattern = host_pattern # type: Optional[str] | |
166 | ||
167 | self.validate() | |
168 | ||
169 | def is_empty(self): | |
170 | return self.label is None and \ | |
171 | not self.hosts and \ | |
172 | not self.host_pattern and \ | |
173 | self.count is None | |
174 | ||
f6b5b4d7 TL |
175 | def __eq__(self, other): |
176 | if isinstance(other, PlacementSpec): | |
177 | return self.label == other.label \ | |
178 | and self.hosts == other.hosts \ | |
179 | and self.count == other.count \ | |
180 | and self.host_pattern == other.host_pattern | |
181 | return NotImplemented | |
182 | ||
9f95a23c TL |
183 | def set_hosts(self, hosts): |
184 | # To backpopulate the .hosts attribute when using labels or count | |
185 | # in the orchestrator backend. | |
f6b5b4d7 TL |
186 | if all([isinstance(host, HostPlacementSpec) for host in hosts]): |
187 | self.hosts = hosts # type: ignore | |
188 | else: | |
189 | self.hosts = [HostPlacementSpec.parse(x, require_network=False) # type: ignore | |
190 | for x in hosts if x] | |
9f95a23c | 191 | |
f91f0fd5 | 192 | # deprecated |
e306af50 | 193 | def filter_matching_hosts(self, _get_hosts_func: Callable) -> List[str]: |
f6b5b4d7 TL |
194 | return self.filter_matching_hostspecs(_get_hosts_func(as_hostspec=True)) |
195 | ||
f91f0fd5 | 196 | def filter_matching_hostspecs(self, hostspecs: Iterable[HostSpec]) -> List[str]: |
e306af50 | 197 | if self.hosts: |
f6b5b4d7 | 198 | all_hosts = [hs.hostname for hs in hostspecs] |
e306af50 TL |
199 | return [h.hostname for h in self.hosts if h.hostname in all_hosts] |
200 | elif self.label: | |
f6b5b4d7 | 201 | return [hs.hostname for hs in hostspecs if self.label in hs.labels] |
e306af50 | 202 | elif self.host_pattern: |
f6b5b4d7 TL |
203 | all_hosts = [hs.hostname for hs in hostspecs] |
204 | return fnmatch.filter(all_hosts, self.host_pattern) | |
e306af50 TL |
205 | else: |
206 | # This should be caught by the validation but needs to be here for | |
207 | # get_host_selection_size | |
9f95a23c | 208 | return [] |
e306af50 | 209 | |
f91f0fd5 | 210 | def get_host_selection_size(self, hostspecs: Iterable[HostSpec]): |
e306af50 TL |
211 | if self.count: |
212 | return self.count | |
f6b5b4d7 | 213 | return len(self.filter_matching_hostspecs(hostspecs)) |
9f95a23c TL |
214 | |
215 | def pretty_str(self): | |
f91f0fd5 TL |
216 | """ |
217 | >>> #doctest: +SKIP | |
218 | ... ps = PlacementSpec(...) # For all placement specs: | |
219 | ... PlacementSpec.from_string(ps.pretty_str()) == ps | |
220 | """ | |
9f95a23c | 221 | kv = [] |
f91f0fd5 TL |
222 | if self.hosts: |
223 | kv.append(';'.join([str(h) for h in self.hosts])) | |
9f95a23c TL |
224 | if self.count: |
225 | kv.append('count:%d' % self.count) | |
226 | if self.label: | |
227 | kv.append('label:%s' % self.label) | |
9f95a23c TL |
228 | if self.host_pattern: |
229 | kv.append(self.host_pattern) | |
f91f0fd5 | 230 | return ';'.join(kv) |
9f95a23c TL |
231 | |
232 | def __repr__(self): | |
233 | kv = [] | |
234 | if self.count: | |
235 | kv.append('count=%d' % self.count) | |
236 | if self.label: | |
237 | kv.append('label=%s' % repr(self.label)) | |
238 | if self.hosts: | |
239 | kv.append('hosts={!r}'.format(self.hosts)) | |
240 | if self.host_pattern: | |
241 | kv.append('host_pattern={!r}'.format(self.host_pattern)) | |
242 | return "PlacementSpec(%s)" % ', '.join(kv) | |
243 | ||
244 | @classmethod | |
1911f103 | 245 | @handle_type_error |
9f95a23c | 246 | def from_json(cls, data): |
1911f103 TL |
247 | c = data.copy() |
248 | hosts = c.get('hosts', []) | |
9f95a23c | 249 | if hosts: |
1911f103 TL |
250 | c['hosts'] = [] |
251 | for host in hosts: | |
f91f0fd5 | 252 | c['hosts'].append(HostPlacementSpec.from_json(host)) |
1911f103 | 253 | _cls = cls(**c) |
9f95a23c TL |
254 | _cls.validate() |
255 | return _cls | |
256 | ||
257 | def to_json(self): | |
258 | r = {} | |
259 | if self.label: | |
260 | r['label'] = self.label | |
261 | if self.hosts: | |
262 | r['hosts'] = [host.to_json() for host in self.hosts] | |
263 | if self.count: | |
264 | r['count'] = self.count | |
265 | if self.host_pattern: | |
266 | r['host_pattern'] = self.host_pattern | |
267 | return r | |
268 | ||
269 | def validate(self): | |
270 | if self.hosts and self.label: | |
271 | # TODO: a less generic Exception | |
272 | raise ServiceSpecValidationError('Host and label are mutually exclusive') | |
273 | if self.count is not None and self.count <= 0: | |
274 | raise ServiceSpecValidationError("num/count must be > 1") | |
275 | if self.host_pattern and self.hosts: | |
276 | raise ServiceSpecValidationError('cannot combine host patterns and hosts') | |
277 | for h in self.hosts: | |
278 | h.validate() | |
279 | ||
280 | @classmethod | |
281 | def from_string(cls, arg): | |
282 | # type: (Optional[str]) -> PlacementSpec | |
283 | """ | |
284 | A single integer is parsed as a count: | |
285 | >>> PlacementSpec.from_string('3') | |
286 | PlacementSpec(count=3) | |
287 | ||
288 | A list of names is parsed as host specifications: | |
289 | >>> PlacementSpec.from_string('host1 host2') | |
290 | PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\ | |
291 | tSpec(hostname='host2', network='', name='')]) | |
292 | ||
293 | You can also prefix the hosts with a count as follows: | |
294 | >>> PlacementSpec.from_string('2 host1 host2') | |
295 | PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\ | |
296 | tPlacementSpec(hostname='host2', network='', name='')]) | |
297 | ||
298 | You can spefify labels using `label:<label>` | |
299 | >>> PlacementSpec.from_string('label:mon') | |
300 | PlacementSpec(label='mon') | |
301 | ||
302 | Labels als support a count: | |
303 | >>> PlacementSpec.from_string('3 label:mon') | |
304 | PlacementSpec(count=3, label='mon') | |
305 | ||
306 | fnmatch is also supported: | |
307 | >>> PlacementSpec.from_string('data[1-3]') | |
308 | PlacementSpec(host_pattern='data[1-3]') | |
309 | ||
310 | >>> PlacementSpec.from_string(None) | |
311 | PlacementSpec() | |
312 | """ | |
313 | if arg is None or not arg: | |
314 | strings = [] | |
315 | elif isinstance(arg, str): | |
316 | if ' ' in arg: | |
317 | strings = arg.split(' ') | |
318 | elif ';' in arg: | |
319 | strings = arg.split(';') | |
320 | elif ',' in arg and '[' not in arg: | |
321 | # FIXME: this isn't quite right. we want to avoid breaking | |
322 | # a list of mons with addrvecs... so we're basically allowing | |
323 | # , most of the time, except when addrvecs are used. maybe | |
324 | # ok? | |
325 | strings = arg.split(',') | |
326 | else: | |
327 | strings = [arg] | |
328 | else: | |
329 | raise ServiceSpecValidationError('invalid placement %s' % arg) | |
330 | ||
331 | count = None | |
332 | if strings: | |
333 | try: | |
334 | count = int(strings[0]) | |
335 | strings = strings[1:] | |
336 | except ValueError: | |
337 | pass | |
338 | for s in strings: | |
339 | if s.startswith('count:'): | |
340 | try: | |
341 | count = int(s[6:]) | |
342 | strings.remove(s) | |
343 | break | |
344 | except ValueError: | |
345 | pass | |
346 | ||
347 | advanced_hostspecs = [h for h in strings if | |
348 | (':' in h or '=' in h or not any(c in '[]?*:=' for c in h)) and | |
349 | 'label:' not in h] | |
350 | for a_h in advanced_hostspecs: | |
351 | strings.remove(a_h) | |
352 | ||
353 | labels = [x for x in strings if 'label:' in x] | |
354 | if len(labels) > 1: | |
355 | raise ServiceSpecValidationError('more than one label provided: {}'.format(labels)) | |
356 | for l in labels: | |
357 | strings.remove(l) | |
358 | label = labels[0][6:] if labels else None | |
359 | ||
360 | host_patterns = strings | |
361 | if len(host_patterns) > 1: | |
362 | raise ServiceSpecValidationError( | |
363 | 'more than one host pattern provided: {}'.format(host_patterns)) | |
364 | ||
365 | ps = PlacementSpec(count=count, | |
366 | hosts=advanced_hostspecs, | |
367 | label=label, | |
368 | host_pattern=host_patterns[0] if host_patterns else None) | |
369 | return ps | |
370 | ||
371 | ||
372 | class ServiceSpec(object): | |
373 | """ | |
374 | Details of service creation. | |
375 | ||
376 | Request to the orchestrator for a cluster of daemons | |
377 | such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus | |
378 | ||
379 | This structure is supposed to be enough information to | |
380 | start the services. | |
381 | ||
382 | """ | |
1911f103 | 383 | KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi mds mgr mon nfs ' \ |
f91f0fd5 TL |
384 | 'node-exporter osd prometheus rbd-mirror rgw ' \ |
385 | 'container'.split() | |
386 | REQUIRES_SERVICE_ID = 'iscsi mds nfs osd rgw container'.split() | |
9f95a23c | 387 | |
1911f103 TL |
388 | @classmethod |
389 | def _cls(cls, service_type): | |
390 | from ceph.deployment.drive_group import DriveGroupSpec | |
391 | ||
392 | ret = { | |
393 | 'rgw': RGWSpec, | |
394 | 'nfs': NFSServiceSpec, | |
395 | 'osd': DriveGroupSpec, | |
396 | 'iscsi': IscsiServiceSpec, | |
f91f0fd5 TL |
397 | 'alertmanager': AlertManagerSpec, |
398 | 'container': CustomContainerSpec, | |
1911f103 TL |
399 | }.get(service_type, cls) |
400 | if ret == ServiceSpec and not service_type: | |
401 | raise ServiceSpecValidationError('Spec needs a "service_type" key.') | |
402 | return ret | |
403 | ||
404 | def __new__(cls, *args, **kwargs): | |
405 | """ | |
406 | Some Python foo to make sure, we don't have an object | |
407 | like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have: | |
408 | ||
409 | >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw')) | |
410 | True | |
411 | ||
412 | """ | |
413 | if cls != ServiceSpec: | |
414 | return object.__new__(cls) | |
415 | service_type = kwargs.get('service_type', args[0] if args else None) | |
416 | sub_cls = cls._cls(service_type) | |
417 | return object.__new__(sub_cls) | |
418 | ||
9f95a23c | 419 | def __init__(self, |
e306af50 TL |
420 | service_type: str, |
421 | service_id: Optional[str] = None, | |
422 | placement: Optional[PlacementSpec] = None, | |
423 | count: Optional[int] = None, | |
424 | unmanaged: bool = False, | |
f6b5b4d7 | 425 | preview_only: bool = False, |
9f95a23c TL |
426 | ): |
427 | self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec | |
428 | ||
429 | assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type | |
430 | self.service_type = service_type | |
f6b5b4d7 TL |
431 | self.service_id = None |
432 | if self.service_type in self.REQUIRES_SERVICE_ID: | |
433 | self.service_id = service_id | |
9f95a23c | 434 | self.unmanaged = unmanaged |
f6b5b4d7 | 435 | self.preview_only = preview_only |
9f95a23c TL |
436 | |
437 | @classmethod | |
1911f103 | 438 | @handle_type_error |
9f95a23c TL |
439 | def from_json(cls, json_spec): |
440 | # type: (dict) -> Any | |
441 | # Python 3: | |
442 | # >>> ServiceSpecs = TypeVar('Base', bound=ServiceSpec) | |
443 | # then, the real type is: (dict) -> ServiceSpecs | |
444 | """ | |
445 | Initialize 'ServiceSpec' object data from a json structure | |
f6b5b4d7 TL |
446 | |
447 | There are two valid styles for service specs: | |
448 | ||
449 | the "old" style: | |
450 | ||
451 | .. code:: yaml | |
452 | ||
453 | service_type: nfs | |
454 | service_id: foo | |
455 | pool: mypool | |
456 | namespace: myns | |
457 | ||
458 | and the "new" style: | |
459 | ||
460 | .. code:: yaml | |
461 | ||
462 | service_type: nfs | |
463 | service_id: foo | |
464 | spec: | |
465 | pool: mypool | |
466 | namespace: myns | |
467 | ||
468 | In https://tracker.ceph.com/issues/45321 we decided that we'd like to | |
469 | prefer the new style as it is more readable and provides a better | |
470 | understanding of what fields are special for a give service type. | |
471 | ||
472 | Note, we'll need to stay compatible with both versions for the | |
473 | the next two major releases (octoups, pacific). | |
474 | ||
9f95a23c TL |
475 | :param json_spec: A valid dict with ServiceSpec |
476 | """ | |
9f95a23c | 477 | |
1911f103 | 478 | c = json_spec.copy() |
9f95a23c | 479 | |
1911f103 TL |
480 | # kludge to make `from_json` compatible to `Orchestrator.describe_service` |
481 | # Open question: Remove `service_id` form to_json? | |
482 | if c.get('service_name', ''): | |
483 | service_type_id = c['service_name'].split('.', 1) | |
484 | ||
485 | if not c.get('service_type', ''): | |
486 | c['service_type'] = service_type_id[0] | |
487 | if not c.get('service_id', '') and len(service_type_id) > 1: | |
488 | c['service_id'] = service_type_id[1] | |
489 | del c['service_name'] | |
490 | ||
491 | service_type = c.get('service_type', '') | |
492 | _cls = cls._cls(service_type) | |
493 | ||
494 | if 'status' in c: | |
495 | del c['status'] # kludge to make us compatible to `ServiceDescription.to_json()` | |
9f95a23c | 496 | |
1911f103 | 497 | return _cls._from_json_impl(c) # type: ignore |
9f95a23c TL |
498 | |
499 | @classmethod | |
500 | def _from_json_impl(cls, json_spec): | |
501 | args = {} # type: Dict[str, Dict[Any, Any]] | |
502 | for k, v in json_spec.items(): | |
503 | if k == 'placement': | |
504 | v = PlacementSpec.from_json(v) | |
505 | if k == 'spec': | |
506 | args.update(v) | |
507 | continue | |
508 | args.update({k: v}) | |
1911f103 TL |
509 | _cls = cls(**args) |
510 | _cls.validate() | |
511 | return _cls | |
9f95a23c TL |
512 | |
513 | def service_name(self): | |
514 | n = self.service_type | |
515 | if self.service_id: | |
516 | n += '.' + self.service_id | |
517 | return n | |
518 | ||
519 | def to_json(self): | |
f6b5b4d7 TL |
520 | # type: () -> OrderedDict[str, Any] |
521 | ret: OrderedDict[str, Any] = OrderedDict() | |
522 | ret['service_type'] = self.service_type | |
523 | if self.service_id: | |
524 | ret['service_id'] = self.service_id | |
525 | ret['service_name'] = self.service_name() | |
526 | ret['placement'] = self.placement.to_json() | |
527 | if self.unmanaged: | |
528 | ret['unmanaged'] = self.unmanaged | |
529 | ||
9f95a23c | 530 | c = {} |
f6b5b4d7 TL |
531 | for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]): |
532 | if key in ret: | |
533 | continue | |
9f95a23c TL |
534 | if hasattr(val, 'to_json'): |
535 | val = val.to_json() | |
536 | if val: | |
537 | c[key] = val | |
f6b5b4d7 TL |
538 | if c: |
539 | ret['spec'] = c | |
540 | return ret | |
9f95a23c TL |
541 | |
542 | def validate(self): | |
543 | if not self.service_type: | |
544 | raise ServiceSpecValidationError('Cannot add Service: type required') | |
545 | ||
f6b5b4d7 TL |
546 | if self.service_type in self.REQUIRES_SERVICE_ID: |
547 | if not self.service_id: | |
548 | raise ServiceSpecValidationError('Cannot add Service: id required') | |
549 | elif self.service_id: | |
550 | raise ServiceSpecValidationError( | |
551 | f'Service of type \'{self.service_type}\' should not contain a service id') | |
552 | ||
9f95a23c TL |
553 | if self.placement is not None: |
554 | self.placement.validate() | |
555 | ||
556 | def __repr__(self): | |
557 | return "{}({!r})".format(self.__class__.__name__, self.__dict__) | |
558 | ||
f6b5b4d7 TL |
559 | def __eq__(self, other): |
560 | return (self.__class__ == other.__class__ | |
561 | and | |
562 | self.__dict__ == other.__dict__) | |
563 | ||
9f95a23c TL |
564 | def one_line_str(self): |
565 | return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name()) | |
566 | ||
f6b5b4d7 TL |
567 | @staticmethod |
568 | def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec'): | |
569 | return dumper.represent_dict(data.to_json().items()) | |
570 | ||
9f95a23c | 571 | |
f6b5b4d7 | 572 | yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer) |
9f95a23c TL |
573 | |
574 | ||
575 | class NFSServiceSpec(ServiceSpec): | |
e306af50 TL |
576 | def __init__(self, |
577 | service_type: str = 'nfs', | |
578 | service_id: Optional[str] = None, | |
579 | pool: Optional[str] = None, | |
580 | namespace: Optional[str] = None, | |
581 | placement: Optional[PlacementSpec] = None, | |
582 | unmanaged: bool = False, | |
f6b5b4d7 | 583 | preview_only: bool = False |
e306af50 | 584 | ): |
9f95a23c TL |
585 | assert service_type == 'nfs' |
586 | super(NFSServiceSpec, self).__init__( | |
587 | 'nfs', service_id=service_id, | |
f6b5b4d7 | 588 | placement=placement, unmanaged=unmanaged, preview_only=preview_only) |
9f95a23c TL |
589 | |
590 | #: RADOS pool where NFS client recovery data is stored. | |
591 | self.pool = pool | |
592 | ||
593 | #: RADOS namespace where NFS client recovery data is stored in the pool. | |
594 | self.namespace = namespace | |
595 | ||
f6b5b4d7 TL |
596 | def validate(self): |
597 | super(NFSServiceSpec, self).validate() | |
9f95a23c TL |
598 | |
599 | if not self.pool: | |
f6b5b4d7 TL |
600 | raise ServiceSpecValidationError( |
601 | 'Cannot add NFS: No Pool specified') | |
9f95a23c | 602 | |
1911f103 TL |
603 | def rados_config_name(self): |
604 | # type: () -> str | |
605 | return 'conf-' + self.service_name() | |
606 | ||
607 | def rados_config_location(self): | |
608 | # type: () -> str | |
f6b5b4d7 TL |
609 | url = '' |
610 | if self.pool: | |
611 | url += 'rados://' + self.pool + '/' | |
612 | if self.namespace: | |
613 | url += self.namespace + '/' | |
614 | url += self.rados_config_name() | |
1911f103 TL |
615 | return url |
616 | ||
9f95a23c | 617 | |
f6b5b4d7 TL |
618 | yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer) |
619 | ||
620 | ||
9f95a23c TL |
621 | class RGWSpec(ServiceSpec): |
622 | """ | |
623 | Settings to configure a (multisite) Ceph RGW | |
624 | ||
625 | """ | |
626 | def __init__(self, | |
e306af50 TL |
627 | service_type: str = 'rgw', |
628 | service_id: Optional[str] = None, | |
629 | placement: Optional[PlacementSpec] = None, | |
630 | rgw_realm: Optional[str] = None, | |
631 | rgw_zone: Optional[str] = None, | |
632 | subcluster: Optional[str] = None, | |
633 | rgw_frontend_port: Optional[int] = None, | |
634 | rgw_frontend_ssl_certificate: Optional[List[str]] = None, | |
635 | rgw_frontend_ssl_key: Optional[List[str]] = None, | |
636 | unmanaged: bool = False, | |
637 | ssl: bool = False, | |
f6b5b4d7 | 638 | preview_only: bool = False, |
9f95a23c | 639 | ): |
1911f103 | 640 | assert service_type == 'rgw', service_type |
9f95a23c TL |
641 | if service_id: |
642 | a = service_id.split('.', 2) | |
643 | rgw_realm = a[0] | |
f6b5b4d7 TL |
644 | if len(a) > 1: |
645 | rgw_zone = a[1] | |
9f95a23c TL |
646 | if len(a) > 2: |
647 | subcluster = a[2] | |
648 | else: | |
649 | if subcluster: | |
650 | service_id = '%s.%s.%s' % (rgw_realm, rgw_zone, subcluster) | |
651 | else: | |
652 | service_id = '%s.%s' % (rgw_realm, rgw_zone) | |
653 | super(RGWSpec, self).__init__( | |
654 | 'rgw', service_id=service_id, | |
f6b5b4d7 TL |
655 | placement=placement, unmanaged=unmanaged, |
656 | preview_only=preview_only) | |
9f95a23c TL |
657 | |
658 | self.rgw_realm = rgw_realm | |
659 | self.rgw_zone = rgw_zone | |
660 | self.subcluster = subcluster | |
661 | self.rgw_frontend_port = rgw_frontend_port | |
1911f103 TL |
662 | self.rgw_frontend_ssl_certificate = rgw_frontend_ssl_certificate |
663 | self.rgw_frontend_ssl_key = rgw_frontend_ssl_key | |
9f95a23c TL |
664 | self.ssl = ssl |
665 | ||
666 | def get_port(self): | |
667 | if self.rgw_frontend_port: | |
668 | return self.rgw_frontend_port | |
669 | if self.ssl: | |
670 | return 443 | |
671 | else: | |
672 | return 80 | |
1911f103 TL |
673 | |
674 | def rgw_frontends_config_value(self): | |
675 | ports = [] | |
676 | if self.ssl: | |
677 | ports.append(f"ssl_port={self.get_port()}") | |
678 | ports.append(f"ssl_certificate=config://rgw/cert/{self.rgw_realm}/{self.rgw_zone}.crt") | |
679 | ports.append(f"ssl_key=config://rgw/cert/{self.rgw_realm}/{self.rgw_zone}.key") | |
680 | else: | |
681 | ports.append(f"port={self.get_port()}") | |
682 | return f'beast {" ".join(ports)}' | |
683 | ||
f6b5b4d7 TL |
684 | def validate(self): |
685 | super(RGWSpec, self).validate() | |
686 | ||
687 | if not self.rgw_realm: | |
688 | raise ServiceSpecValidationError( | |
689 | 'Cannot add RGW: No realm specified') | |
690 | if not self.rgw_zone: | |
691 | raise ServiceSpecValidationError( | |
692 | 'Cannot add RGW: No zone specified') | |
693 | ||
694 | ||
695 | yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer) | |
696 | ||
1911f103 TL |
697 | |
698 | class IscsiServiceSpec(ServiceSpec): | |
e306af50 TL |
699 | def __init__(self, |
700 | service_type: str = 'iscsi', | |
701 | service_id: Optional[str] = None, | |
702 | pool: Optional[str] = None, | |
703 | trusted_ip_list: Optional[str] = None, | |
704 | api_port: Optional[int] = None, | |
705 | api_user: Optional[str] = None, | |
706 | api_password: Optional[str] = None, | |
707 | api_secure: Optional[bool] = None, | |
708 | ssl_cert: Optional[str] = None, | |
709 | ssl_key: Optional[str] = None, | |
710 | placement: Optional[PlacementSpec] = None, | |
f6b5b4d7 TL |
711 | unmanaged: bool = False, |
712 | preview_only: bool = False | |
e306af50 | 713 | ): |
1911f103 TL |
714 | assert service_type == 'iscsi' |
715 | super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id, | |
f6b5b4d7 TL |
716 | placement=placement, unmanaged=unmanaged, |
717 | preview_only=preview_only) | |
1911f103 TL |
718 | |
719 | #: RADOS pool where ceph-iscsi config data is stored. | |
720 | self.pool = pool | |
721 | self.trusted_ip_list = trusted_ip_list | |
1911f103 TL |
722 | self.api_port = api_port |
723 | self.api_user = api_user | |
724 | self.api_password = api_password | |
725 | self.api_secure = api_secure | |
726 | self.ssl_cert = ssl_cert | |
727 | self.ssl_key = ssl_key | |
728 | ||
e306af50 TL |
729 | if not self.api_secure and self.ssl_cert and self.ssl_key: |
730 | self.api_secure = True | |
731 | ||
732 | def validate(self): | |
733 | super(IscsiServiceSpec, self).validate() | |
1911f103 TL |
734 | |
735 | if not self.pool: | |
736 | raise ServiceSpecValidationError( | |
737 | 'Cannot add ISCSI: No Pool specified') | |
e306af50 TL |
738 | if not self.api_user: |
739 | raise ServiceSpecValidationError( | |
740 | 'Cannot add ISCSI: No Api user specified') | |
741 | if not self.api_password: | |
742 | raise ServiceSpecValidationError( | |
743 | 'Cannot add ISCSI: No Api password specified') | |
f6b5b4d7 TL |
744 | |
745 | ||
746 | yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer) | |
747 | ||
748 | ||
749 | class AlertManagerSpec(ServiceSpec): | |
750 | def __init__(self, | |
751 | service_type: str = 'alertmanager', | |
752 | service_id: Optional[str] = None, | |
753 | placement: Optional[PlacementSpec] = None, | |
754 | unmanaged: bool = False, | |
755 | preview_only: bool = False, | |
756 | user_data: Optional[Dict[str, Any]] = None, | |
757 | ): | |
758 | assert service_type == 'alertmanager' | |
759 | super(AlertManagerSpec, self).__init__( | |
760 | 'alertmanager', service_id=service_id, | |
761 | placement=placement, unmanaged=unmanaged, | |
762 | preview_only=preview_only) | |
763 | ||
764 | # Custom configuration. | |
765 | # | |
766 | # Example: | |
767 | # service_type: alertmanager | |
768 | # service_id: xyz | |
769 | # user_data: | |
770 | # default_webhook_urls: | |
771 | # - "https://foo" | |
772 | # - "https://bar" | |
773 | # | |
774 | # Documentation: | |
775 | # default_webhook_urls - A list of additional URL's that are | |
776 | # added to the default receivers' | |
777 | # <webhook_configs> configuration. | |
778 | self.user_data = user_data or {} | |
779 | ||
780 | ||
781 | yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer) | |
f91f0fd5 TL |
782 | |
783 | ||
784 | class CustomContainerSpec(ServiceSpec): | |
785 | def __init__(self, | |
786 | service_type: str = 'container', | |
787 | service_id: str = None, | |
788 | placement: Optional[PlacementSpec] = None, | |
789 | unmanaged: bool = False, | |
790 | preview_only: bool = False, | |
791 | image: str = None, | |
792 | entrypoint: Optional[str] = None, | |
793 | uid: Optional[int] = None, | |
794 | gid: Optional[int] = None, | |
795 | volume_mounts: Optional[Dict[str, str]] = {}, | |
796 | args: Optional[List[str]] = [], | |
797 | envs: Optional[List[str]] = [], | |
798 | privileged: Optional[bool] = False, | |
799 | bind_mounts: Optional[List[List[str]]] = None, | |
800 | ports: Optional[List[int]] = [], | |
801 | dirs: Optional[List[str]] = [], | |
802 | files: Optional[Dict[str, Any]] = {}, | |
803 | ): | |
804 | assert service_type == 'container' | |
805 | assert service_id is not None | |
806 | assert image is not None | |
807 | ||
808 | super(CustomContainerSpec, self).__init__( | |
809 | service_type, service_id, | |
810 | placement=placement, unmanaged=unmanaged, | |
811 | preview_only=preview_only) | |
812 | ||
813 | self.image = image | |
814 | self.entrypoint = entrypoint | |
815 | self.uid = uid | |
816 | self.gid = gid | |
817 | self.volume_mounts = volume_mounts | |
818 | self.args = args | |
819 | self.envs = envs | |
820 | self.privileged = privileged | |
821 | self.bind_mounts = bind_mounts | |
822 | self.ports = ports | |
823 | self.dirs = dirs | |
824 | self.files = files | |
825 | ||
826 | def config_json(self) -> Dict[str, Any]: | |
827 | """ | |
828 | Helper function to get the value of the `--config-json` cephadm | |
829 | command line option. It will contain all specification properties | |
830 | that haven't a `None` value. Such properties will get default | |
831 | values in cephadm. | |
832 | :return: Returns a dictionary containing all specification | |
833 | properties. | |
834 | """ | |
835 | config_json = {} | |
836 | for prop in ['image', 'entrypoint', 'uid', 'gid', 'args', | |
837 | 'envs', 'volume_mounts', 'privileged', | |
838 | 'bind_mounts', 'ports', 'dirs', 'files']: | |
839 | value = getattr(self, prop) | |
840 | if value is not None: | |
841 | config_json[prop] = value | |
842 | return config_json | |
843 | ||
844 | ||
845 | yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer) |