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