]> git.proxmox.com Git - ceph.git/blame - ceph/src/python-common/ceph/deployment/service_spec.py
import ceph 16.2.7
[ceph.git] / ceph / src / python-common / ceph / deployment / service_spec.py
CommitLineData
9f95a23c
TL
1import fnmatch
2import re
f67539c2 3from collections import OrderedDict
1911f103 4from functools import wraps
f67539c2
TL
5from ipaddress import ip_network, ip_address
6from typing import Optional, Dict, Any, List, Union, Callable, Iterable, Type, TypeVar, cast, \
522d829b 7 NamedTuple, Mapping
9f95a23c 8
f6b5b4d7
TL
9import yaml
10
f67539c2 11from ceph.deployment.hostspec import HostSpec, SpecValidationError
f91f0fd5 12from ceph.deployment.utils import unwrap_ipv6
9f95a23c 13
f67539c2
TL
14ServiceSpecT = TypeVar('ServiceSpecT', bound='ServiceSpec')
15FuncT = TypeVar('FuncT', bound=Callable)
9f95a23c 16
9f95a23c 17
f67539c2 18def assert_valid_host(name: str) -> None:
9f95a23c
TL
19 p = re.compile('^[a-zA-Z0-9-]+$')
20 try:
21 assert len(name) <= 250, 'name is too long (max 250 chars)'
22 for part in name.split('.'):
23 assert len(part) > 0, '.-delimited name component must not be empty'
24 assert len(part) <= 63, '.-delimited name component must not be more than 63 chars'
25 assert p.match(part), 'name component must include only a-z, 0-9, and -'
26 except AssertionError as e:
f67539c2 27 raise SpecValidationError(str(e))
9f95a23c
TL
28
29
f67539c2 30def handle_type_error(method: FuncT) -> FuncT:
1911f103 31 @wraps(method)
f67539c2 32 def inner(cls: Any, *args: Any, **kwargs: Any) -> Any:
1911f103
TL
33 try:
34 return method(cls, *args, **kwargs)
35 except (TypeError, AttributeError) as e:
36 error_msg = '{}: {}'.format(cls.__name__, e)
f67539c2
TL
37 raise SpecValidationError(error_msg)
38 return cast(FuncT, inner)
39
1911f103 40
f67539c2
TL
41class HostPlacementSpec(NamedTuple):
42 hostname: str
43 network: str
44 name: str
1911f103 45
f67539c2 46 def __str__(self) -> str:
9f95a23c
TL
47 res = ''
48 res += self.hostname
49 if self.network:
50 res += ':' + self.network
51 if self.name:
52 res += '=' + self.name
53 return res
54
55 @classmethod
1911f103 56 @handle_type_error
f67539c2 57 def from_json(cls, data: Union[dict, str]) -> 'HostPlacementSpec':
f91f0fd5
TL
58 if isinstance(data, str):
59 return cls.parse(data)
9f95a23c
TL
60 return cls(**data)
61
f91f0fd5
TL
62 def to_json(self) -> str:
63 return str(self)
9f95a23c
TL
64
65 @classmethod
66 def parse(cls, host, require_network=True):
67 # type: (str, bool) -> HostPlacementSpec
68 """
69 Split host into host, network, and (optional) daemon name parts. The network
70 part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'.
71 e.g.,
72 "myhost"
73 "myhost=name"
74 "myhost:1.2.3.4"
75 "myhost:1.2.3.4=name"
76 "myhost:1.2.3.0/24"
77 "myhost:1.2.3.0/24=name"
78 "myhost:[v2:1.2.3.4:3000]=name"
79 "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name"
80 """
81 # Matches from start to : or = or until end of string
82 host_re = r'^(.*?)(:|=|$)'
83 # Matches from : to = or until end of string
84 ip_re = r':(.*?)(=|$)'
85 # Matches from = to end of string
86 name_re = r'=(.*?)$'
87
88 # assign defaults
89 host_spec = cls('', '', '')
90
91 match_host = re.search(host_re, host)
92 if match_host:
93 host_spec = host_spec._replace(hostname=match_host.group(1))
94
95 name_match = re.search(name_re, host)
96 if name_match:
97 host_spec = host_spec._replace(name=name_match.group(1))
98
99 ip_match = re.search(ip_re, host)
100 if ip_match:
101 host_spec = host_spec._replace(network=ip_match.group(1))
102
103 if not require_network:
104 return host_spec
105
9f95a23c
TL
106 networks = list() # type: List[str]
107 network = host_spec.network
108 # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478]
109 if ',' in network:
110 networks = [x for x in network.split(',')]
111 else:
1911f103
TL
112 if network != '':
113 networks.append(network)
114
9f95a23c
TL
115 for network in networks:
116 # only if we have versioned network configs
117 if network.startswith('v') or network.startswith('[v'):
f91f0fd5
TL
118 # if this is ipv6 we can't just simply split on ':' so do
119 # a split once and rsplit once to leave us with just ipv6 addr
120 network = network.split(':', 1)[1]
121 network = network.rsplit(':', 1)[0]
9f95a23c
TL
122 try:
123 # if subnets are defined, also verify the validity
124 if '/' in network:
f67539c2 125 ip_network(network)
9f95a23c 126 else:
f91f0fd5 127 ip_address(unwrap_ipv6(network))
9f95a23c
TL
128 except ValueError as e:
129 # logging?
130 raise e
131 host_spec.validate()
132 return host_spec
133
f67539c2 134 def validate(self) -> None:
9f95a23c
TL
135 assert_valid_host(self.hostname)
136
137
138class PlacementSpec(object):
139 """
140 For APIs that need to specify a host subset
141 """
142
143 def __init__(self,
144 label=None, # type: Optional[str]
f67539c2 145 hosts=None, # type: Union[List[str],List[HostPlacementSpec], None]
9f95a23c 146 count=None, # type: Optional[int]
f67539c2
TL
147 count_per_host=None, # type: Optional[int]
148 host_pattern=None, # type: Optional[str]
9f95a23c
TL
149 ):
150 # type: (...) -> None
151 self.label = label
152 self.hosts = [] # type: List[HostPlacementSpec]
153
154 if hosts:
f6b5b4d7 155 self.set_hosts(hosts)
9f95a23c
TL
156
157 self.count = count # type: Optional[int]
f67539c2 158 self.count_per_host = count_per_host # type: Optional[int]
9f95a23c
TL
159
160 #: fnmatch patterns to select hosts. Can also be a single host.
161 self.host_pattern = host_pattern # type: Optional[str]
162
163 self.validate()
164
f67539c2
TL
165 def is_empty(self) -> bool:
166 return (
167 self.label is None
168 and not self.hosts
169 and not self.host_pattern
170 and self.count is None
171 and self.count_per_host is None
172 )
9f95a23c 173
f67539c2 174 def __eq__(self, other: Any) -> bool:
f6b5b4d7
TL
175 if isinstance(other, PlacementSpec):
176 return self.label == other.label \
177 and self.hosts == other.hosts \
178 and self.count == other.count \
f67539c2
TL
179 and self.host_pattern == other.host_pattern \
180 and self.count_per_host == other.count_per_host
f6b5b4d7
TL
181 return NotImplemented
182
f67539c2 183 def set_hosts(self, hosts: Union[List[str], List[HostPlacementSpec]]) -> None:
9f95a23c
TL
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 199 return [h.hostname for h in self.hosts if h.hostname in all_hosts]
f67539c2 200 if self.label:
f6b5b4d7 201 return [hs.hostname for hs in hostspecs if self.label in hs.labels]
f67539c2
TL
202 all_hosts = [hs.hostname for hs in hostspecs]
203 if self.host_pattern:
f6b5b4d7 204 return fnmatch.filter(all_hosts, self.host_pattern)
f67539c2 205 return all_hosts
e306af50 206
f67539c2 207 def get_target_count(self, hostspecs: Iterable[HostSpec]) -> int:
e306af50
TL
208 if self.count:
209 return self.count
f67539c2 210 return len(self.filter_matching_hostspecs(hostspecs)) * (self.count_per_host or 1)
9f95a23c 211
f67539c2 212 def pretty_str(self) -> str:
f91f0fd5
TL
213 """
214 >>> #doctest: +SKIP
215 ... ps = PlacementSpec(...) # For all placement specs:
216 ... PlacementSpec.from_string(ps.pretty_str()) == ps
217 """
9f95a23c 218 kv = []
f91f0fd5
TL
219 if self.hosts:
220 kv.append(';'.join([str(h) for h in self.hosts]))
9f95a23c
TL
221 if self.count:
222 kv.append('count:%d' % self.count)
f67539c2
TL
223 if self.count_per_host:
224 kv.append('count-per-host:%d' % self.count_per_host)
9f95a23c
TL
225 if self.label:
226 kv.append('label:%s' % self.label)
9f95a23c
TL
227 if self.host_pattern:
228 kv.append(self.host_pattern)
f91f0fd5 229 return ';'.join(kv)
9f95a23c 230
f67539c2 231 def __repr__(self) -> str:
9f95a23c
TL
232 kv = []
233 if self.count:
234 kv.append('count=%d' % self.count)
f67539c2
TL
235 if self.count_per_host:
236 kv.append('count_per_host=%d' % self.count_per_host)
9f95a23c
TL
237 if self.label:
238 kv.append('label=%s' % repr(self.label))
239 if self.hosts:
240 kv.append('hosts={!r}'.format(self.hosts))
241 if self.host_pattern:
242 kv.append('host_pattern={!r}'.format(self.host_pattern))
243 return "PlacementSpec(%s)" % ', '.join(kv)
244
245 @classmethod
1911f103 246 @handle_type_error
f67539c2 247 def from_json(cls, data: dict) -> 'PlacementSpec':
1911f103
TL
248 c = data.copy()
249 hosts = c.get('hosts', [])
9f95a23c 250 if hosts:
1911f103
TL
251 c['hosts'] = []
252 for host in hosts:
f91f0fd5 253 c['hosts'].append(HostPlacementSpec.from_json(host))
1911f103 254 _cls = cls(**c)
9f95a23c
TL
255 _cls.validate()
256 return _cls
257
f67539c2
TL
258 def to_json(self) -> dict:
259 r: Dict[str, Any] = {}
9f95a23c
TL
260 if self.label:
261 r['label'] = self.label
262 if self.hosts:
263 r['hosts'] = [host.to_json() for host in self.hosts]
264 if self.count:
265 r['count'] = self.count
f67539c2
TL
266 if self.count_per_host:
267 r['count_per_host'] = self.count_per_host
9f95a23c
TL
268 if self.host_pattern:
269 r['host_pattern'] = self.host_pattern
270 return r
271
f67539c2 272 def validate(self) -> None:
9f95a23c
TL
273 if self.hosts and self.label:
274 # TODO: a less generic Exception
f67539c2 275 raise SpecValidationError('Host and label are mutually exclusive')
9f95a23c 276 if self.count is not None and self.count <= 0:
f67539c2
TL
277 raise SpecValidationError("num/count must be > 1")
278 if self.count_per_host is not None and self.count_per_host < 1:
279 raise SpecValidationError("count-per-host must be >= 1")
280 if self.count_per_host is not None and not (
281 self.label
282 or self.hosts
283 or self.host_pattern
284 ):
285 raise SpecValidationError(
286 "count-per-host must be combined with label or hosts or host_pattern"
287 )
288 if self.count is not None and self.count_per_host is not None:
289 raise SpecValidationError("cannot combine count and count-per-host")
290 if (
291 self.count_per_host is not None
292 and self.hosts
293 and any([hs.network or hs.name for hs in self.hosts])
294 ):
295 raise SpecValidationError(
296 "count-per-host cannot be combined explicit placement with names or networks"
297 )
9f95a23c 298 if self.host_pattern and self.hosts:
f67539c2 299 raise SpecValidationError('cannot combine host patterns and hosts')
9f95a23c
TL
300 for h in self.hosts:
301 h.validate()
302
303 @classmethod
304 def from_string(cls, arg):
305 # type: (Optional[str]) -> PlacementSpec
306 """
307 A single integer is parsed as a count:
308 >>> PlacementSpec.from_string('3')
309 PlacementSpec(count=3)
310
311 A list of names is parsed as host specifications:
312 >>> PlacementSpec.from_string('host1 host2')
313 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
314tSpec(hostname='host2', network='', name='')])
315
316 You can also prefix the hosts with a count as follows:
317 >>> PlacementSpec.from_string('2 host1 host2')
318 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
319tPlacementSpec(hostname='host2', network='', name='')])
320
321 You can spefify labels using `label:<label>`
322 >>> PlacementSpec.from_string('label:mon')
323 PlacementSpec(label='mon')
324
325 Labels als support a count:
326 >>> PlacementSpec.from_string('3 label:mon')
327 PlacementSpec(count=3, label='mon')
328
329 fnmatch is also supported:
330 >>> PlacementSpec.from_string('data[1-3]')
331 PlacementSpec(host_pattern='data[1-3]')
332
333 >>> PlacementSpec.from_string(None)
334 PlacementSpec()
335 """
336 if arg is None or not arg:
337 strings = []
338 elif isinstance(arg, str):
339 if ' ' in arg:
340 strings = arg.split(' ')
341 elif ';' in arg:
342 strings = arg.split(';')
343 elif ',' in arg and '[' not in arg:
344 # FIXME: this isn't quite right. we want to avoid breaking
345 # a list of mons with addrvecs... so we're basically allowing
346 # , most of the time, except when addrvecs are used. maybe
347 # ok?
348 strings = arg.split(',')
349 else:
350 strings = [arg]
351 else:
f67539c2 352 raise SpecValidationError('invalid placement %s' % arg)
9f95a23c
TL
353
354 count = None
f67539c2 355 count_per_host = None
9f95a23c
TL
356 if strings:
357 try:
358 count = int(strings[0])
359 strings = strings[1:]
360 except ValueError:
361 pass
362 for s in strings:
363 if s.startswith('count:'):
364 try:
f67539c2
TL
365 count = int(s[len('count:'):])
366 strings.remove(s)
367 break
368 except ValueError:
369 pass
370 for s in strings:
371 if s.startswith('count-per-host:'):
372 try:
373 count_per_host = int(s[len('count-per-host:'):])
9f95a23c
TL
374 strings.remove(s)
375 break
376 except ValueError:
377 pass
378
379 advanced_hostspecs = [h for h in strings if
380 (':' in h or '=' in h or not any(c in '[]?*:=' for c in h)) and
381 'label:' not in h]
382 for a_h in advanced_hostspecs:
383 strings.remove(a_h)
384
385 labels = [x for x in strings if 'label:' in x]
386 if len(labels) > 1:
f67539c2 387 raise SpecValidationError('more than one label provided: {}'.format(labels))
9f95a23c
TL
388 for l in labels:
389 strings.remove(l)
390 label = labels[0][6:] if labels else None
391
392 host_patterns = strings
393 if len(host_patterns) > 1:
f67539c2 394 raise SpecValidationError(
9f95a23c
TL
395 'more than one host pattern provided: {}'.format(host_patterns))
396
397 ps = PlacementSpec(count=count,
f67539c2 398 count_per_host=count_per_host,
9f95a23c
TL
399 hosts=advanced_hostspecs,
400 label=label,
401 host_pattern=host_patterns[0] if host_patterns else None)
402 return ps
403
404
405class ServiceSpec(object):
406 """
407 Details of service creation.
408
409 Request to the orchestrator for a cluster of daemons
410 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
411
412 This structure is supposed to be enough information to
413 start the services.
9f95a23c 414 """
1911f103 415 KNOWN_SERVICE_TYPES = 'alertmanager crash grafana iscsi mds mgr mon nfs ' \
f91f0fd5 416 'node-exporter osd prometheus rbd-mirror rgw ' \
f67539c2
TL
417 'container cephadm-exporter ingress cephfs-mirror'.split()
418 REQUIRES_SERVICE_ID = 'iscsi mds nfs osd rgw container ingress '.split()
419 MANAGED_CONFIG_OPTIONS = [
420 'mds_join_fs',
421 ]
9f95a23c 422
1911f103 423 @classmethod
f67539c2 424 def _cls(cls: Type[ServiceSpecT], service_type: str) -> Type[ServiceSpecT]:
1911f103
TL
425 from ceph.deployment.drive_group import DriveGroupSpec
426
427 ret = {
428 'rgw': RGWSpec,
429 'nfs': NFSServiceSpec,
430 'osd': DriveGroupSpec,
431 'iscsi': IscsiServiceSpec,
f91f0fd5 432 'alertmanager': AlertManagerSpec,
f67539c2 433 'ingress': IngressSpec,
f91f0fd5 434 'container': CustomContainerSpec,
b3b6e05e
TL
435 'grafana': MonitoringSpec,
436 'node-exporter': MonitoringSpec,
437 'prometheus': MonitoringSpec,
1911f103
TL
438 }.get(service_type, cls)
439 if ret == ServiceSpec and not service_type:
f67539c2 440 raise SpecValidationError('Spec needs a "service_type" key.')
1911f103
TL
441 return ret
442
f67539c2 443 def __new__(cls: Type[ServiceSpecT], *args: Any, **kwargs: Any) -> ServiceSpecT:
1911f103
TL
444 """
445 Some Python foo to make sure, we don't have an object
446 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
447
448 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
449 True
450
451 """
452 if cls != ServiceSpec:
453 return object.__new__(cls)
454 service_type = kwargs.get('service_type', args[0] if args else None)
f67539c2 455 sub_cls: Any = cls._cls(service_type)
1911f103
TL
456 return object.__new__(sub_cls)
457
9f95a23c 458 def __init__(self,
e306af50
TL
459 service_type: str,
460 service_id: Optional[str] = None,
461 placement: Optional[PlacementSpec] = None,
462 count: Optional[int] = None,
f67539c2 463 config: Optional[Dict[str, str]] = None,
e306af50 464 unmanaged: bool = False,
f6b5b4d7 465 preview_only: bool = False,
f67539c2 466 networks: Optional[List[str]] = None,
9f95a23c 467 ):
a4b75251
TL
468
469 #: See :ref:`orchestrator-cli-placement-spec`.
9f95a23c
TL
470 self.placement = PlacementSpec() if placement is None else placement # type: PlacementSpec
471
472 assert service_type in ServiceSpec.KNOWN_SERVICE_TYPES, service_type
a4b75251
TL
473 #: The type of the service. Needs to be either a Ceph
474 #: service (``mon``, ``crash``, ``mds``, ``mgr``, ``osd`` or
475 #: ``rbd-mirror``), a gateway (``nfs`` or ``rgw``), part of the
476 #: monitoring stack (``alertmanager``, ``grafana``, ``node-exporter`` or
477 #: ``prometheus``) or (``container``) for custom containers.
9f95a23c 478 self.service_type = service_type
a4b75251
TL
479
480 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
481 #: ``container``, ``ingress``
f6b5b4d7 482 self.service_id = None
a4b75251 483
f6b5b4d7
TL
484 if self.service_type in self.REQUIRES_SERVICE_ID:
485 self.service_id = service_id
a4b75251
TL
486
487 #: If set to ``true``, the orchestrator will not deploy nor remove
488 #: any daemon associated with this service. Placement and all other properties
489 #: will be ignored. This is useful, if you do not want this service to be
490 #: managed temporarily. For cephadm, See :ref:`cephadm-spec-unmanaged`
9f95a23c 491 self.unmanaged = unmanaged
f6b5b4d7 492 self.preview_only = preview_only
a4b75251
TL
493
494 #: A list of network identities instructing the daemons to only bind
495 #: on the particular networks in that list. In case the cluster is distributed
496 #: across multiple networks, you can add multiple networks. See
497 #: :ref:`cephadm-monitoring-networks-ports`,
498 #: :ref:`cephadm-rgw-networks` and :ref:`cephadm-mgr-networks`.
f67539c2
TL
499 self.networks: List[str] = networks or []
500
501 self.config: Optional[Dict[str, str]] = None
502 if config:
503 self.config = {k.replace(' ', '_'): v for k, v in config.items()}
9f95a23c
TL
504
505 @classmethod
1911f103 506 @handle_type_error
f67539c2 507 def from_json(cls: Type[ServiceSpecT], json_spec: Dict) -> ServiceSpecT:
9f95a23c
TL
508 """
509 Initialize 'ServiceSpec' object data from a json structure
f6b5b4d7
TL
510
511 There are two valid styles for service specs:
512
513 the "old" style:
514
515 .. code:: yaml
516
517 service_type: nfs
518 service_id: foo
519 pool: mypool
520 namespace: myns
521
522 and the "new" style:
523
524 .. code:: yaml
525
526 service_type: nfs
527 service_id: foo
f67539c2
TL
528 config:
529 some_option: the_value
530 networks: [10.10.0.0/16]
f6b5b4d7
TL
531 spec:
532 pool: mypool
533 namespace: myns
534
535 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
536 prefer the new style as it is more readable and provides a better
537 understanding of what fields are special for a give service type.
538
539 Note, we'll need to stay compatible with both versions for the
540 the next two major releases (octoups, pacific).
541
9f95a23c 542 :param json_spec: A valid dict with ServiceSpec
a4b75251
TL
543
544 :meta private:
9f95a23c 545 """
9f95a23c 546
f67539c2
TL
547 if not isinstance(json_spec, dict):
548 raise SpecValidationError(
549 f'Service Spec is not an (JSON or YAML) object. got "{str(json_spec)}"')
550
551 json_spec = cls.normalize_json(json_spec)
552
1911f103 553 c = json_spec.copy()
9f95a23c 554
1911f103
TL
555 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
556 # Open question: Remove `service_id` form to_json?
557 if c.get('service_name', ''):
558 service_type_id = c['service_name'].split('.', 1)
559
560 if not c.get('service_type', ''):
561 c['service_type'] = service_type_id[0]
562 if not c.get('service_id', '') and len(service_type_id) > 1:
563 c['service_id'] = service_type_id[1]
564 del c['service_name']
565
566 service_type = c.get('service_type', '')
567 _cls = cls._cls(service_type)
568
569 if 'status' in c:
570 del c['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
9f95a23c 571
1911f103 572 return _cls._from_json_impl(c) # type: ignore
9f95a23c 573
f67539c2
TL
574 @staticmethod
575 def normalize_json(json_spec: dict) -> dict:
576 networks = json_spec.get('networks')
577 if networks is None:
578 return json_spec
579 if isinstance(networks, list):
580 return json_spec
581 if not isinstance(networks, str):
582 raise SpecValidationError(f'Networks ({networks}) must be a string or list of strings')
583 json_spec['networks'] = [networks]
584 return json_spec
585
9f95a23c 586 @classmethod
f67539c2
TL
587 def _from_json_impl(cls: Type[ServiceSpecT], json_spec: dict) -> ServiceSpecT:
588 args = {} # type: Dict[str, Any]
9f95a23c
TL
589 for k, v in json_spec.items():
590 if k == 'placement':
591 v = PlacementSpec.from_json(v)
592 if k == 'spec':
593 args.update(v)
594 continue
595 args.update({k: v})
1911f103
TL
596 _cls = cls(**args)
597 _cls.validate()
598 return _cls
9f95a23c 599
f67539c2 600 def service_name(self) -> str:
9f95a23c
TL
601 n = self.service_type
602 if self.service_id:
603 n += '.' + self.service_id
604 return n
605
f67539c2
TL
606 def get_port_start(self) -> List[int]:
607 # If defined, we will allocate and number ports starting at this
608 # point.
609 return []
610
611 def get_virtual_ip(self) -> Optional[str]:
612 return None
613
9f95a23c 614 def to_json(self):
f6b5b4d7
TL
615 # type: () -> OrderedDict[str, Any]
616 ret: OrderedDict[str, Any] = OrderedDict()
617 ret['service_type'] = self.service_type
618 if self.service_id:
619 ret['service_id'] = self.service_id
620 ret['service_name'] = self.service_name()
621 ret['placement'] = self.placement.to_json()
622 if self.unmanaged:
623 ret['unmanaged'] = self.unmanaged
f67539c2
TL
624 if self.networks:
625 ret['networks'] = self.networks
f6b5b4d7 626
9f95a23c 627 c = {}
f6b5b4d7
TL
628 for key, val in sorted(self.__dict__.items(), key=lambda tpl: tpl[0]):
629 if key in ret:
630 continue
9f95a23c
TL
631 if hasattr(val, 'to_json'):
632 val = val.to_json()
633 if val:
634 c[key] = val
f6b5b4d7
TL
635 if c:
636 ret['spec'] = c
637 return ret
9f95a23c 638
f67539c2 639 def validate(self) -> None:
9f95a23c 640 if not self.service_type:
f67539c2 641 raise SpecValidationError('Cannot add Service: type required')
9f95a23c 642
f6b5b4d7
TL
643 if self.service_type in self.REQUIRES_SERVICE_ID:
644 if not self.service_id:
f67539c2
TL
645 raise SpecValidationError('Cannot add Service: id required')
646 if not re.match('^[a-zA-Z0-9_.-]+$', self.service_id):
647 raise SpecValidationError('Service id contains invalid characters, '
648 'only [a-zA-Z0-9_.-] allowed')
f6b5b4d7 649 elif self.service_id:
f67539c2 650 raise SpecValidationError(
f6b5b4d7
TL
651 f'Service of type \'{self.service_type}\' should not contain a service id')
652
9f95a23c
TL
653 if self.placement is not None:
654 self.placement.validate()
f67539c2
TL
655 if self.config:
656 for k, v in self.config.items():
657 if k in self.MANAGED_CONFIG_OPTIONS:
658 raise SpecValidationError(
659 f'Cannot set config option {k} in spec: it is managed by cephadm'
660 )
661 for network in self.networks or []:
662 try:
663 ip_network(network)
664 except ValueError as e:
665 raise SpecValidationError(
666 f'Cannot parse network {network}: {e}'
667 )
9f95a23c 668
f67539c2 669 def __repr__(self) -> str:
9f95a23c
TL
670 return "{}({!r})".format(self.__class__.__name__, self.__dict__)
671
f67539c2 672 def __eq__(self, other: Any) -> bool:
f6b5b4d7
TL
673 return (self.__class__ == other.__class__
674 and
675 self.__dict__ == other.__dict__)
676
f67539c2 677 def one_line_str(self) -> str:
9f95a23c
TL
678 return '<{} for service_name={}>'.format(self.__class__.__name__, self.service_name())
679
f6b5b4d7 680 @staticmethod
f67539c2 681 def yaml_representer(dumper: 'yaml.SafeDumper', data: 'ServiceSpec') -> Any:
522d829b 682 return dumper.represent_dict(cast(Mapping, data.to_json().items()))
f6b5b4d7 683
9f95a23c 684
f6b5b4d7 685yaml.add_representer(ServiceSpec, ServiceSpec.yaml_representer)
9f95a23c
TL
686
687
688class NFSServiceSpec(ServiceSpec):
e306af50
TL
689 def __init__(self,
690 service_type: str = 'nfs',
691 service_id: Optional[str] = None,
e306af50
TL
692 placement: Optional[PlacementSpec] = None,
693 unmanaged: bool = False,
f67539c2
TL
694 preview_only: bool = False,
695 config: Optional[Dict[str, str]] = None,
696 networks: Optional[List[str]] = None,
b3b6e05e 697 port: Optional[int] = None,
e306af50 698 ):
9f95a23c
TL
699 assert service_type == 'nfs'
700 super(NFSServiceSpec, self).__init__(
701 'nfs', service_id=service_id,
f67539c2
TL
702 placement=placement, unmanaged=unmanaged, preview_only=preview_only,
703 config=config, networks=networks)
9f95a23c 704
b3b6e05e
TL
705 self.port = port
706
707 def get_port_start(self) -> List[int]:
708 if self.port:
709 return [self.port]
710 return []
9f95a23c 711
1911f103
TL
712 def rados_config_name(self):
713 # type: () -> str
714 return 'conf-' + self.service_name()
715
9f95a23c 716
f6b5b4d7
TL
717yaml.add_representer(NFSServiceSpec, ServiceSpec.yaml_representer)
718
719
9f95a23c
TL
720class RGWSpec(ServiceSpec):
721 """
722 Settings to configure a (multisite) Ceph RGW
723
a4b75251
TL
724 .. code-block:: yaml
725
726 service_type: rgw
727 service_id: myrealm.myzone
728 spec:
729 rgw_realm: myrealm
730 rgw_zone: myzone
731 ssl: true
732 rgw_frontend_port: 1234
733 rgw_frontend_type: beast
734 rgw_frontend_ssl_certificate: ...
735
736 See also: :ref:`orchestrator-cli-service-spec`
9f95a23c 737 """
a4b75251 738
f67539c2
TL
739 MANAGED_CONFIG_OPTIONS = ServiceSpec.MANAGED_CONFIG_OPTIONS + [
740 'rgw_zone',
741 'rgw_realm',
742 'rgw_frontends',
743 ]
744
9f95a23c 745 def __init__(self,
e306af50
TL
746 service_type: str = 'rgw',
747 service_id: Optional[str] = None,
748 placement: Optional[PlacementSpec] = None,
749 rgw_realm: Optional[str] = None,
750 rgw_zone: Optional[str] = None,
e306af50
TL
751 rgw_frontend_port: Optional[int] = None,
752 rgw_frontend_ssl_certificate: Optional[List[str]] = None,
f67539c2 753 rgw_frontend_type: Optional[str] = None,
e306af50
TL
754 unmanaged: bool = False,
755 ssl: bool = False,
f6b5b4d7 756 preview_only: bool = False,
f67539c2
TL
757 config: Optional[Dict[str, str]] = None,
758 networks: Optional[List[str]] = None,
759 subcluster: Optional[str] = None, # legacy, only for from_json on upgrade
9f95a23c 760 ):
1911f103 761 assert service_type == 'rgw', service_type
f67539c2
TL
762
763 # for backward compatibility with octopus spec files,
764 if not service_id and (rgw_realm and rgw_zone):
765 service_id = rgw_realm + '.' + rgw_zone
766
9f95a23c
TL
767 super(RGWSpec, self).__init__(
768 'rgw', service_id=service_id,
f6b5b4d7 769 placement=placement, unmanaged=unmanaged,
f67539c2 770 preview_only=preview_only, config=config, networks=networks)
9f95a23c 771
a4b75251
TL
772 #: The RGW realm associated with this service. Needs to be manually created
773 self.rgw_realm: Optional[str] = rgw_realm
774 #: The RGW zone associated with this service. Needs to be manually created
775 self.rgw_zone: Optional[str] = rgw_zone
776 #: Port of the RGW daemons
777 self.rgw_frontend_port: Optional[int] = rgw_frontend_port
778 #: List of SSL certificates
779 self.rgw_frontend_ssl_certificate: Optional[List[str]] = rgw_frontend_ssl_certificate
780 #: civetweb or beast (default: beast). See :ref:`rgw_frontends`
781 self.rgw_frontend_type: Optional[str] = rgw_frontend_type
782 #: enable SSL
9f95a23c
TL
783 self.ssl = ssl
784
f67539c2
TL
785 def get_port_start(self) -> List[int]:
786 return [self.get_port()]
787
788 def get_port(self) -> int:
9f95a23c
TL
789 if self.rgw_frontend_port:
790 return self.rgw_frontend_port
791 if self.ssl:
792 return 443
793 else:
794 return 80
1911f103 795
f67539c2 796 def validate(self) -> None:
f6b5b4d7
TL
797 super(RGWSpec, self).validate()
798
f67539c2
TL
799 if self.rgw_realm and not self.rgw_zone:
800 raise SpecValidationError(
801 'Cannot add RGW: Realm specified but no zone specified')
802 if self.rgw_zone and not self.rgw_realm:
803 raise SpecValidationError(
804 'Cannot add RGW: Zone specified but no realm specified')
f6b5b4d7
TL
805
806
807yaml.add_representer(RGWSpec, ServiceSpec.yaml_representer)
808
1911f103
TL
809
810class IscsiServiceSpec(ServiceSpec):
e306af50
TL
811 def __init__(self,
812 service_type: str = 'iscsi',
813 service_id: Optional[str] = None,
814 pool: Optional[str] = None,
815 trusted_ip_list: Optional[str] = None,
816 api_port: Optional[int] = None,
817 api_user: Optional[str] = None,
818 api_password: Optional[str] = None,
819 api_secure: Optional[bool] = None,
820 ssl_cert: Optional[str] = None,
821 ssl_key: Optional[str] = None,
822 placement: Optional[PlacementSpec] = None,
f6b5b4d7 823 unmanaged: bool = False,
f67539c2
TL
824 preview_only: bool = False,
825 config: Optional[Dict[str, str]] = None,
826 networks: Optional[List[str]] = None,
e306af50 827 ):
1911f103
TL
828 assert service_type == 'iscsi'
829 super(IscsiServiceSpec, self).__init__('iscsi', service_id=service_id,
f6b5b4d7 830 placement=placement, unmanaged=unmanaged,
f67539c2
TL
831 preview_only=preview_only,
832 config=config, networks=networks)
1911f103
TL
833
834 #: RADOS pool where ceph-iscsi config data is stored.
835 self.pool = pool
a4b75251 836 #: list of trusted IP addresses
1911f103 837 self.trusted_ip_list = trusted_ip_list
a4b75251 838 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
1911f103 839 self.api_port = api_port
a4b75251 840 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
1911f103 841 self.api_user = api_user
a4b75251 842 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
1911f103 843 self.api_password = api_password
a4b75251 844 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
1911f103 845 self.api_secure = api_secure
a4b75251 846 #: SSL certificate
1911f103 847 self.ssl_cert = ssl_cert
a4b75251 848 #: SSL private key
1911f103
TL
849 self.ssl_key = ssl_key
850
e306af50
TL
851 if not self.api_secure and self.ssl_cert and self.ssl_key:
852 self.api_secure = True
853
f67539c2 854 def validate(self) -> None:
e306af50 855 super(IscsiServiceSpec, self).validate()
1911f103
TL
856
857 if not self.pool:
f67539c2 858 raise SpecValidationError(
1911f103 859 'Cannot add ISCSI: No Pool specified')
adb31ebb
TL
860
861 # Do not need to check for api_user and api_password as they
862 # now default to 'admin' when setting up the gateway url. Older
863 # iSCSI specs from before this change should be fine as they will
864 # have been required to have an api_user and api_password set and
865 # will be unaffected by the new default value.
f6b5b4d7
TL
866
867
868yaml.add_representer(IscsiServiceSpec, ServiceSpec.yaml_representer)
869
870
871class AlertManagerSpec(ServiceSpec):
872 def __init__(self,
873 service_type: str = 'alertmanager',
874 service_id: Optional[str] = None,
875 placement: Optional[PlacementSpec] = None,
876 unmanaged: bool = False,
877 preview_only: bool = False,
878 user_data: Optional[Dict[str, Any]] = None,
f67539c2
TL
879 config: Optional[Dict[str, str]] = None,
880 networks: Optional[List[str]] = None,
b3b6e05e 881 port: Optional[int] = None,
f6b5b4d7
TL
882 ):
883 assert service_type == 'alertmanager'
884 super(AlertManagerSpec, self).__init__(
885 'alertmanager', service_id=service_id,
886 placement=placement, unmanaged=unmanaged,
f67539c2 887 preview_only=preview_only, config=config, networks=networks)
f6b5b4d7
TL
888
889 # Custom configuration.
890 #
891 # Example:
892 # service_type: alertmanager
893 # service_id: xyz
894 # user_data:
895 # default_webhook_urls:
896 # - "https://foo"
897 # - "https://bar"
898 #
899 # Documentation:
900 # default_webhook_urls - A list of additional URL's that are
901 # added to the default receivers'
902 # <webhook_configs> configuration.
903 self.user_data = user_data or {}
b3b6e05e
TL
904 self.port = port
905
906 def get_port_start(self) -> List[int]:
907 return [self.get_port(), 9094]
908
909 def get_port(self) -> int:
910 if self.port:
911 return self.port
912 else:
913 return 9093
914
915 def validate(self) -> None:
916 super(AlertManagerSpec, self).validate()
917
918 if self.port == 9094:
919 raise SpecValidationError(
920 'Port 9094 is reserved for AlertManager cluster listen address')
f6b5b4d7
TL
921
922
923yaml.add_representer(AlertManagerSpec, ServiceSpec.yaml_representer)
f91f0fd5
TL
924
925
f67539c2
TL
926class IngressSpec(ServiceSpec):
927 def __init__(self,
928 service_type: str = 'ingress',
929 service_id: Optional[str] = None,
930 config: Optional[Dict[str, str]] = None,
931 networks: Optional[List[str]] = None,
932 placement: Optional[PlacementSpec] = None,
933 backend_service: Optional[str] = None,
934 frontend_port: Optional[int] = None,
935 ssl_cert: Optional[str] = None,
b3b6e05e 936 ssl_key: Optional[str] = None,
f67539c2
TL
937 ssl_dh_param: Optional[str] = None,
938 ssl_ciphers: Optional[List[str]] = None,
939 ssl_options: Optional[List[str]] = None,
940 monitor_port: Optional[int] = None,
941 monitor_user: Optional[str] = None,
942 monitor_password: Optional[str] = None,
943 enable_stats: Optional[bool] = None,
944 keepalived_password: Optional[str] = None,
945 virtual_ip: Optional[str] = None,
946 virtual_interface_networks: Optional[List[str]] = [],
b3b6e05e
TL
947 unmanaged: bool = False,
948 ssl: bool = False
f67539c2
TL
949 ):
950 assert service_type == 'ingress'
951 super(IngressSpec, self).__init__(
952 'ingress', service_id=service_id,
953 placement=placement, config=config,
954 networks=networks
955 )
956 self.backend_service = backend_service
957 self.frontend_port = frontend_port
958 self.ssl_cert = ssl_cert
b3b6e05e 959 self.ssl_key = ssl_key
f67539c2
TL
960 self.ssl_dh_param = ssl_dh_param
961 self.ssl_ciphers = ssl_ciphers
962 self.ssl_options = ssl_options
963 self.monitor_port = monitor_port
964 self.monitor_user = monitor_user
965 self.monitor_password = monitor_password
966 self.keepalived_password = keepalived_password
967 self.virtual_ip = virtual_ip
968 self.virtual_interface_networks = virtual_interface_networks or []
b3b6e05e
TL
969 self.unmanaged = unmanaged
970 self.ssl = ssl
f67539c2
TL
971
972 def get_port_start(self) -> List[int]:
973 return [cast(int, self.frontend_port),
974 cast(int, self.monitor_port)]
975
976 def get_virtual_ip(self) -> Optional[str]:
977 return self.virtual_ip
978
979 def validate(self) -> None:
980 super(IngressSpec, self).validate()
981
982 if not self.backend_service:
983 raise SpecValidationError(
984 'Cannot add ingress: No backend_service specified')
985 if not self.frontend_port:
986 raise SpecValidationError(
987 'Cannot add ingress: No frontend_port specified')
988 if not self.monitor_port:
989 raise SpecValidationError(
990 'Cannot add ingress: No monitor_port specified')
991 if not self.virtual_ip:
992 raise SpecValidationError(
993 'Cannot add ingress: No virtual_ip provided')
994
995
b3b6e05e
TL
996yaml.add_representer(IngressSpec, ServiceSpec.yaml_representer)
997
998
f91f0fd5
TL
999class CustomContainerSpec(ServiceSpec):
1000 def __init__(self,
1001 service_type: str = 'container',
f67539c2
TL
1002 service_id: Optional[str] = None,
1003 config: Optional[Dict[str, str]] = None,
1004 networks: Optional[List[str]] = None,
f91f0fd5
TL
1005 placement: Optional[PlacementSpec] = None,
1006 unmanaged: bool = False,
1007 preview_only: bool = False,
f67539c2 1008 image: Optional[str] = None,
f91f0fd5
TL
1009 entrypoint: Optional[str] = None,
1010 uid: Optional[int] = None,
1011 gid: Optional[int] = None,
1012 volume_mounts: Optional[Dict[str, str]] = {},
1013 args: Optional[List[str]] = [],
1014 envs: Optional[List[str]] = [],
1015 privileged: Optional[bool] = False,
1016 bind_mounts: Optional[List[List[str]]] = None,
1017 ports: Optional[List[int]] = [],
1018 dirs: Optional[List[str]] = [],
1019 files: Optional[Dict[str, Any]] = {},
1020 ):
1021 assert service_type == 'container'
1022 assert service_id is not None
1023 assert image is not None
1024
1025 super(CustomContainerSpec, self).__init__(
1026 service_type, service_id,
1027 placement=placement, unmanaged=unmanaged,
f67539c2
TL
1028 preview_only=preview_only, config=config,
1029 networks=networks)
f91f0fd5
TL
1030
1031 self.image = image
1032 self.entrypoint = entrypoint
1033 self.uid = uid
1034 self.gid = gid
1035 self.volume_mounts = volume_mounts
1036 self.args = args
1037 self.envs = envs
1038 self.privileged = privileged
1039 self.bind_mounts = bind_mounts
1040 self.ports = ports
1041 self.dirs = dirs
1042 self.files = files
1043
1044 def config_json(self) -> Dict[str, Any]:
1045 """
1046 Helper function to get the value of the `--config-json` cephadm
1047 command line option. It will contain all specification properties
1048 that haven't a `None` value. Such properties will get default
1049 values in cephadm.
1050 :return: Returns a dictionary containing all specification
1051 properties.
1052 """
1053 config_json = {}
1054 for prop in ['image', 'entrypoint', 'uid', 'gid', 'args',
1055 'envs', 'volume_mounts', 'privileged',
1056 'bind_mounts', 'ports', 'dirs', 'files']:
1057 value = getattr(self, prop)
1058 if value is not None:
1059 config_json[prop] = value
1060 return config_json
1061
1062
1063yaml.add_representer(CustomContainerSpec, ServiceSpec.yaml_representer)
b3b6e05e
TL
1064
1065
1066class MonitoringSpec(ServiceSpec):
1067 def __init__(self,
1068 service_type: str,
1069 service_id: Optional[str] = None,
1070 config: Optional[Dict[str, str]] = None,
1071 networks: Optional[List[str]] = None,
1072 placement: Optional[PlacementSpec] = None,
1073 unmanaged: bool = False,
1074 preview_only: bool = False,
1075 port: Optional[int] = None,
1076 ):
1077 assert service_type in ['grafana', 'node-exporter', 'prometheus']
1078
1079 super(MonitoringSpec, self).__init__(
1080 service_type, service_id,
1081 placement=placement, unmanaged=unmanaged,
1082 preview_only=preview_only, config=config,
1083 networks=networks)
1084
1085 self.service_type = service_type
1086 self.port = port
1087
1088 def get_port_start(self) -> List[int]:
1089 return [self.get_port()]
1090
1091 def get_port(self) -> int:
1092 if self.port:
1093 return self.port
1094 else:
1095 return {'prometheus': 9095,
1096 'node-exporter': 9100,
1097 'grafana': 3000}[self.service_type]