5 from collections
import OrderedDict
6 from contextlib
import contextmanager
7 from functools
import wraps
8 from ipaddress
import ip_network
, ip_address
9 from typing
import Optional
, Dict
, Any
, List
, Union
, Callable
, Iterable
, Type
, TypeVar
, cast
, \
10 NamedTuple
, Mapping
, Iterator
14 from ceph
.deployment
.hostspec
import HostSpec
, SpecValidationError
, assert_valid_host
15 from ceph
.deployment
.utils
import unwrap_ipv6
, valid_addr
16 from ceph
.utils
import is_hex
18 ServiceSpecT
= TypeVar('ServiceSpecT', bound
='ServiceSpec')
19 FuncT
= TypeVar('FuncT', bound
=Callable
)
22 def handle_type_error(method
: FuncT
) -> FuncT
:
24 def inner(cls
: Any
, *args
: Any
, **kwargs
: Any
) -> Any
:
26 return method(cls
, *args
, **kwargs
)
27 except (TypeError, AttributeError) as e
:
28 error_msg
= '{}: {}'.format(cls
.__name
__, e
)
29 raise SpecValidationError(error_msg
)
30 return cast(FuncT
, inner
)
33 class HostPlacementSpec(NamedTuple
):
38 def __str__(self
) -> str:
42 res
+= ':' + self
.network
44 res
+= '=' + self
.name
49 def from_json(cls
, data
: Union
[dict, str]) -> 'HostPlacementSpec':
50 if isinstance(data
, str):
51 return cls
.parse(data
)
54 def to_json(self
) -> str:
58 def parse(cls
, host
, require_network
=True):
59 # type: (str, bool) -> HostPlacementSpec
61 Split host into host, network, and (optional) daemon name parts. The network
62 part can be an IP, CIDR, or ceph addrvec like '[v2:1.2.3.4:3300,v1:1.2.3.4:6789]'.
69 "myhost:1.2.3.0/24=name"
70 "myhost:[v2:1.2.3.4:3000]=name"
71 "myhost:[v2:1.2.3.4:3000,v1:1.2.3.4:6789]=name"
73 # Matches from start to : or = or until end of string
74 host_re
= r
'^(.*?)(:|=|$)'
75 # Matches from : to = or until end of string
76 ip_re
= r
':(.*?)(=|$)'
77 # Matches from = to end of string
81 host_spec
= cls('', '', '')
83 match_host
= re
.search(host_re
, host
)
85 host_spec
= host_spec
._replace
(hostname
=match_host
.group(1))
87 name_match
= re
.search(name_re
, host
)
89 host_spec
= host_spec
._replace
(name
=name_match
.group(1))
91 ip_match
= re
.search(ip_re
, host
)
93 host_spec
= host_spec
._replace
(network
=ip_match
.group(1))
95 if not require_network
:
98 networks
= list() # type: List[str]
99 network
= host_spec
.network
100 # in case we have [v2:1.2.3.4:3000,v1:1.2.3.4:6478]
102 networks
= [x
for x
in network
.split(',')]
105 networks
.append(network
)
107 for network
in networks
:
108 # only if we have versioned network configs
109 if network
.startswith('v') or network
.startswith('[v'):
110 # if this is ipv6 we can't just simply split on ':' so do
111 # a split once and rsplit once to leave us with just ipv6 addr
112 network
= network
.split(':', 1)[1]
113 network
= network
.rsplit(':', 1)[0]
115 # if subnets are defined, also verify the validity
119 ip_address(unwrap_ipv6(network
))
120 except ValueError as e
:
126 def validate(self
) -> None:
127 assert_valid_host(self
.hostname
)
130 class PlacementSpec(object):
132 For APIs that need to specify a host subset
136 label
=None, # type: Optional[str]
137 hosts
=None, # type: Union[List[str],List[HostPlacementSpec], None]
138 count
=None, # type: Optional[int]
139 count_per_host
=None, # type: Optional[int]
140 host_pattern
=None, # type: Optional[str]
142 # type: (...) -> None
144 self
.hosts
= [] # type: List[HostPlacementSpec]
147 self
.set_hosts(hosts
)
149 self
.count
= count
# type: Optional[int]
150 self
.count_per_host
= count_per_host
# type: Optional[int]
152 #: fnmatch patterns to select hosts. Can also be a single host.
153 self
.host_pattern
= host_pattern
# type: Optional[str]
157 def is_empty(self
) -> bool:
161 and not self
.host_pattern
162 and self
.count
is None
163 and self
.count_per_host
is None
166 def __eq__(self
, other
: Any
) -> bool:
167 if isinstance(other
, PlacementSpec
):
168 return self
.label
== other
.label \
169 and self
.hosts
== other
.hosts \
170 and self
.count
== other
.count \
171 and self
.host_pattern
== other
.host_pattern \
172 and self
.count_per_host
== other
.count_per_host
173 return NotImplemented
175 def set_hosts(self
, hosts
: Union
[List
[str], List
[HostPlacementSpec
]]) -> None:
176 # To backpopulate the .hosts attribute when using labels or count
177 # in the orchestrator backend.
178 if all([isinstance(host
, HostPlacementSpec
) for host
in hosts
]):
179 self
.hosts
= hosts
# type: ignore
181 self
.hosts
= [HostPlacementSpec
.parse(x
, require_network
=False) # type: ignore
185 def filter_matching_hosts(self
, _get_hosts_func
: Callable
) -> List
[str]:
186 return self
.filter_matching_hostspecs(_get_hosts_func(as_hostspec
=True))
188 def filter_matching_hostspecs(self
, hostspecs
: Iterable
[HostSpec
]) -> List
[str]:
190 all_hosts
= [hs
.hostname
for hs
in hostspecs
]
191 return [h
.hostname
for h
in self
.hosts
if h
.hostname
in all_hosts
]
193 return [hs
.hostname
for hs
in hostspecs
if self
.label
in hs
.labels
]
194 all_hosts
= [hs
.hostname
for hs
in hostspecs
]
195 if self
.host_pattern
:
196 return fnmatch
.filter(all_hosts
, self
.host_pattern
)
199 def get_target_count(self
, hostspecs
: Iterable
[HostSpec
]) -> int:
202 return len(self
.filter_matching_hostspecs(hostspecs
)) * (self
.count_per_host
or 1)
204 def pretty_str(self
) -> str:
207 ... ps = PlacementSpec(...) # For all placement specs:
208 ... PlacementSpec.from_string(ps.pretty_str()) == ps
212 kv
.append(';'.join([str(h
) for h
in self
.hosts
]))
214 kv
.append('count:%d' % self
.count
)
215 if self
.count_per_host
:
216 kv
.append('count-per-host:%d' % self
.count_per_host
)
218 kv
.append('label:%s' % self
.label
)
219 if self
.host_pattern
:
220 kv
.append(self
.host_pattern
)
223 def __repr__(self
) -> str:
226 kv
.append('count=%d' % self
.count
)
227 if self
.count_per_host
:
228 kv
.append('count_per_host=%d' % self
.count_per_host
)
230 kv
.append('label=%s' % repr(self
.label
))
232 kv
.append('hosts={!r}'.format(self
.hosts
))
233 if self
.host_pattern
:
234 kv
.append('host_pattern={!r}'.format(self
.host_pattern
))
235 return "PlacementSpec(%s)" % ', '.join(kv
)
239 def from_json(cls
, data
: dict) -> 'PlacementSpec':
241 hosts
= c
.get('hosts', [])
245 c
['hosts'].append(HostPlacementSpec
.from_json(host
))
250 def to_json(self
) -> dict:
251 r
: Dict
[str, Any
] = {}
253 r
['label'] = self
.label
255 r
['hosts'] = [host
.to_json() for host
in self
.hosts
]
257 r
['count'] = self
.count
258 if self
.count_per_host
:
259 r
['count_per_host'] = self
.count_per_host
260 if self
.host_pattern
:
261 r
['host_pattern'] = self
.host_pattern
264 def validate(self
) -> None:
265 if self
.hosts
and self
.label
:
266 # TODO: a less generic Exception
267 raise SpecValidationError('Host and label are mutually exclusive')
268 if self
.count
is not None:
270 intval
= int(self
.count
)
271 except (ValueError, TypeError):
272 raise SpecValidationError("num/count must be a numeric value")
273 if self
.count
!= intval
:
274 raise SpecValidationError("num/count must be an integer value")
276 raise SpecValidationError("num/count must be >= 1")
277 if self
.count_per_host
is not None:
279 intval
= int(self
.count_per_host
)
280 except (ValueError, TypeError):
281 raise SpecValidationError("count-per-host must be a numeric value")
282 if self
.count_per_host
!= intval
:
283 raise SpecValidationError("count-per-host must be an integer value")
284 if self
.count_per_host
< 1:
285 raise SpecValidationError("count-per-host must be >= 1")
286 if self
.count_per_host
is not None and not (
291 raise SpecValidationError(
292 "count-per-host must be combined with label or hosts or host_pattern"
294 if self
.count
is not None and self
.count_per_host
is not None:
295 raise SpecValidationError("cannot combine count and count-per-host")
297 self
.count_per_host
is not None
299 and any([hs
.network
or hs
.name
for hs
in self
.hosts
])
301 raise SpecValidationError(
302 "count-per-host cannot be combined explicit placement with names or networks"
304 if self
.host_pattern
:
305 if not isinstance(self
.host_pattern
, str):
306 raise SpecValidationError('host_pattern must be of type string')
308 raise SpecValidationError('cannot combine host patterns and hosts')
314 def from_string(cls
, arg
):
315 # type: (Optional[str]) -> PlacementSpec
317 A single integer is parsed as a count:
319 >>> PlacementSpec.from_string('3')
320 PlacementSpec(count=3)
322 A list of names is parsed as host specifications:
324 >>> PlacementSpec.from_string('host1 host2')
325 PlacementSpec(hosts=[HostPlacementSpec(hostname='host1', network='', name=''), HostPlacemen\
326 tSpec(hostname='host2', network='', name='')])
328 You can also prefix the hosts with a count as follows:
330 >>> PlacementSpec.from_string('2 host1 host2')
331 PlacementSpec(count=2, hosts=[HostPlacementSpec(hostname='host1', network='', name=''), Hos\
332 tPlacementSpec(hostname='host2', network='', name='')])
334 You can specify labels using `label:<label>`
336 >>> PlacementSpec.from_string('label:mon')
337 PlacementSpec(label='mon')
339 Labels also support a count:
341 >>> PlacementSpec.from_string('3 label:mon')
342 PlacementSpec(count=3, label='mon')
344 fnmatch is also supported:
346 >>> PlacementSpec.from_string('data[1-3]')
347 PlacementSpec(host_pattern='data[1-3]')
349 >>> PlacementSpec.from_string(None)
352 if arg
is None or not arg
:
354 elif isinstance(arg
, str):
356 strings
= arg
.split(' ')
358 strings
= arg
.split(';')
359 elif ',' in arg
and '[' not in arg
:
360 # FIXME: this isn't quite right. we want to avoid breaking
361 # a list of mons with addrvecs... so we're basically allowing
362 # , most of the time, except when addrvecs are used. maybe
364 strings
= arg
.split(',')
368 raise SpecValidationError('invalid placement %s' % arg
)
371 count_per_host
= None
374 count
= int(strings
[0])
375 strings
= strings
[1:]
379 if s
.startswith('count:'):
381 count
= int(s
[len('count:'):])
387 if s
.startswith('count-per-host:'):
389 count_per_host
= int(s
[len('count-per-host:'):])
395 advanced_hostspecs
= [h
for h
in strings
if
396 (':' in h
or '=' in h
or not any(c
in '[]?*:=' for c
in h
)) and
398 for a_h
in advanced_hostspecs
:
401 labels
= [x
for x
in strings
if 'label:' in x
]
403 raise SpecValidationError('more than one label provided: {}'.format(labels
))
406 label
= labels
[0][6:] if labels
else None
408 host_patterns
= strings
409 if len(host_patterns
) > 1:
410 raise SpecValidationError(
411 'more than one host pattern provided: {}'.format(host_patterns
))
413 ps
= PlacementSpec(count
=count
,
414 count_per_host
=count_per_host
,
415 hosts
=advanced_hostspecs
,
417 host_pattern
=host_patterns
[0] if host_patterns
else None)
421 _service_spec_from_json_validate
= True
426 Class to specify custom config files to be mounted in daemon's container
429 _fields
= ['content', 'mount_path']
431 def __init__(self
, content
: str, mount_path
: str) -> None:
432 self
.content
: str = content
433 self
.mount_path
: str = mount_path
436 def to_json(self
) -> Dict
[str, Any
]:
438 'content': self
.content
,
439 'mount_path': self
.mount_path
,
443 def from_json(cls
, data
: Dict
[str, Any
]) -> "CustomConfig":
444 for k
in cls
._fields
:
446 raise SpecValidationError(f
'CustomConfig must have "{k}" field')
447 for k
in data
.keys():
448 if k
not in cls
._fields
:
449 raise SpecValidationError(f
'CustomConfig got unknown field "{k}"')
453 def filename(self
) -> str:
454 return os
.path
.basename(self
.mount_path
)
456 def __eq__(self
, other
: Any
) -> bool:
457 if isinstance(other
, CustomConfig
):
459 self
.content
== other
.content
460 and self
.mount_path
== other
.mount_path
462 return NotImplemented
464 def __repr__(self
) -> str:
465 return f
'CustomConfig({self.mount_path})'
467 def validate(self
) -> None:
468 if not isinstance(self
.content
, str):
469 raise SpecValidationError(
470 f
'CustomConfig content must be a string. Got {type(self.content)}')
471 if not isinstance(self
.mount_path
, str):
472 raise SpecValidationError(
473 f
'CustomConfig content must be a string. Got {type(self.mount_path)}')
477 def service_spec_allow_invalid_from_json() -> Iterator
[None]:
479 I know this is evil, but unfortunately `ceph orch ls`
480 may return invalid OSD specs for OSDs not associated to
481 and specs. If you have a better idea, please!
483 global _service_spec_from_json_validate
484 _service_spec_from_json_validate
= False
486 _service_spec_from_json_validate
= True
489 class ServiceSpec(object):
491 Details of service creation.
493 Request to the orchestrator for a cluster of daemons
494 such as MDS, RGW, iscsi gateway, MONs, MGRs, Prometheus
496 This structure is supposed to be enough information to
499 KNOWN_SERVICE_TYPES
= 'alertmanager crash grafana iscsi loki promtail mds mgr mon nfs ' \
500 'node-exporter osd prometheus rbd-mirror rgw agent ' \
501 'container ingress cephfs-mirror snmp-gateway'.split()
502 REQUIRES_SERVICE_ID
= 'iscsi mds nfs rgw container ingress '.split()
503 MANAGED_CONFIG_OPTIONS
= [
508 def _cls(cls
: Type
[ServiceSpecT
], service_type
: str) -> Type
[ServiceSpecT
]:
509 from ceph
.deployment
.drive_group
import DriveGroupSpec
513 'nfs': NFSServiceSpec
,
514 'osd': DriveGroupSpec
,
516 'iscsi': IscsiServiceSpec
,
517 'alertmanager': AlertManagerSpec
,
518 'ingress': IngressSpec
,
519 'container': CustomContainerSpec
,
520 'grafana': GrafanaSpec
,
521 'node-exporter': MonitoringSpec
,
522 'prometheus': MonitoringSpec
,
523 'loki': MonitoringSpec
,
524 'promtail': MonitoringSpec
,
525 'snmp-gateway': SNMPGatewaySpec
,
526 }.get(service_type
, cls
)
527 if ret
== ServiceSpec
and not service_type
:
528 raise SpecValidationError('Spec needs a "service_type" key.')
531 def __new__(cls
: Type
[ServiceSpecT
], *args
: Any
, **kwargs
: Any
) -> ServiceSpecT
:
533 Some Python foo to make sure, we don't have an object
534 like `ServiceSpec('rgw')` of type `ServiceSpec`. Now we have:
536 >>> type(ServiceSpec('rgw')) == type(RGWSpec('rgw'))
540 if cls
!= ServiceSpec
:
541 return object.__new
__(cls
)
542 service_type
= kwargs
.get('service_type', args
[0] if args
else None)
543 sub_cls
: Any
= cls
._cls
(service_type
)
544 return object.__new
__(sub_cls
)
548 service_id
: Optional
[str] = None,
549 placement
: Optional
[PlacementSpec
] = None,
550 count
: Optional
[int] = None,
551 config
: Optional
[Dict
[str, str]] = None,
552 unmanaged
: bool = False,
553 preview_only
: bool = False,
554 networks
: Optional
[List
[str]] = None,
555 extra_container_args
: Optional
[List
[str]] = None,
556 custom_configs
: Optional
[List
[CustomConfig
]] = None,
559 #: See :ref:`orchestrator-cli-placement-spec`.
560 self
.placement
= PlacementSpec() if placement
is None else placement
# type: PlacementSpec
562 assert service_type
in ServiceSpec
.KNOWN_SERVICE_TYPES
, service_type
563 #: The type of the service. Needs to be either a Ceph
564 #: service (``mon``, ``crash``, ``mds``, ``mgr``, ``osd`` or
565 #: ``rbd-mirror``), a gateway (``nfs`` or ``rgw``), part of the
566 #: monitoring stack (``alertmanager``, ``grafana``, ``node-exporter`` or
567 #: ``prometheus``) or (``container``) for custom containers.
568 self
.service_type
= service_type
570 #: The name of the service. Required for ``iscsi``, ``mds``, ``nfs``, ``osd``, ``rgw``,
571 #: ``container``, ``ingress``
572 self
.service_id
= None
574 if self
.service_type
in self
.REQUIRES_SERVICE_ID
or self
.service_type
== 'osd':
575 self
.service_id
= service_id
577 #: If set to ``true``, the orchestrator will not deploy nor remove
578 #: any daemon associated with this service. Placement and all other properties
579 #: will be ignored. This is useful, if you do not want this service to be
580 #: managed temporarily. For cephadm, See :ref:`cephadm-spec-unmanaged`
581 self
.unmanaged
= unmanaged
582 self
.preview_only
= preview_only
584 #: A list of network identities instructing the daemons to only bind
585 #: on the particular networks in that list. In case the cluster is distributed
586 #: across multiple networks, you can add multiple networks. See
587 #: :ref:`cephadm-monitoring-networks-ports`,
588 #: :ref:`cephadm-rgw-networks` and :ref:`cephadm-mgr-networks`.
589 self
.networks
: List
[str] = networks
or []
591 self
.config
: Optional
[Dict
[str, str]] = None
593 self
.config
= {k
.replace(' ', '_'): v
for k
, v
in config
.items()}
595 self
.extra_container_args
: Optional
[List
[str]] = extra_container_args
596 self
.custom_configs
: Optional
[List
[CustomConfig
]] = custom_configs
600 def from_json(cls
: Type
[ServiceSpecT
], json_spec
: Dict
) -> ServiceSpecT
:
602 Initialize 'ServiceSpec' object data from a json structure
604 There are two valid styles for service specs:
622 some_option: the_value
623 networks: [10.10.0.0/16]
628 In https://tracker.ceph.com/issues/45321 we decided that we'd like to
629 prefer the new style as it is more readable and provides a better
630 understanding of what fields are special for a give service type.
632 Note, we'll need to stay compatible with both versions for the
633 the next two major releases (octoups, pacific).
635 :param json_spec: A valid dict with ServiceSpec
639 if not isinstance(json_spec
, dict):
640 raise SpecValidationError(
641 f
'Service Spec is not an (JSON or YAML) object. got "{str(json_spec)}"')
643 json_spec
= cls
.normalize_json(json_spec
)
647 # kludge to make `from_json` compatible to `Orchestrator.describe_service`
648 # Open question: Remove `service_id` form to_json?
649 if c
.get('service_name', ''):
650 service_type_id
= c
['service_name'].split('.', 1)
652 if not c
.get('service_type', ''):
653 c
['service_type'] = service_type_id
[0]
654 if not c
.get('service_id', '') and len(service_type_id
) > 1:
655 c
['service_id'] = service_type_id
[1]
656 del c
['service_name']
658 service_type
= c
.get('service_type', '')
659 _cls
= cls
._cls
(service_type
)
662 del c
['status'] # kludge to make us compatible to `ServiceDescription.to_json()`
664 return _cls
._from
_json
_impl
(c
) # type: ignore
667 def normalize_json(json_spec
: dict) -> dict:
668 networks
= json_spec
.get('networks')
671 if isinstance(networks
, list):
673 if not isinstance(networks
, str):
674 raise SpecValidationError(f
'Networks ({networks}) must be a string or list of strings')
675 json_spec
['networks'] = [networks
]
679 def _from_json_impl(cls
: Type
[ServiceSpecT
], json_spec
: dict) -> ServiceSpecT
:
680 args
= {} # type: Dict[str, Any]
681 for k
, v
in json_spec
.items():
683 v
= PlacementSpec
.from_json(v
)
684 if k
== 'custom_configs':
685 v
= [CustomConfig
.from_json(c
) for c
in v
]
691 if _service_spec_from_json_validate
:
695 def service_name(self
) -> str:
696 n
= self
.service_type
698 n
+= '.' + self
.service_id
701 def get_port_start(self
) -> List
[int]:
702 # If defined, we will allocate and number ports starting at this
706 def get_virtual_ip(self
) -> Optional
[str]:
710 # type: () -> OrderedDict[str, Any]
711 ret
: OrderedDict
[str, Any
] = OrderedDict()
712 ret
['service_type'] = self
.service_type
714 ret
['service_id'] = self
.service_id
715 ret
['service_name'] = self
.service_name()
716 if self
.placement
.to_json():
717 ret
['placement'] = self
.placement
.to_json()
719 ret
['unmanaged'] = self
.unmanaged
721 ret
['networks'] = self
.networks
722 if self
.extra_container_args
:
723 ret
['extra_container_args'] = self
.extra_container_args
724 if self
.custom_configs
:
725 ret
['custom_configs'] = [c
.to_json() for c
in self
.custom_configs
]
728 for key
, val
in sorted(self
.__dict
__.items(), key
=lambda tpl
: tpl
[0]):
731 if hasattr(val
, 'to_json'):
739 def validate(self
) -> None:
740 if not self
.service_type
:
741 raise SpecValidationError('Cannot add Service: type required')
743 if self
.service_type
!= 'osd':
744 if self
.service_type
in self
.REQUIRES_SERVICE_ID
and not self
.service_id
:
745 raise SpecValidationError('Cannot add Service: id required')
746 if self
.service_type
not in self
.REQUIRES_SERVICE_ID
and self
.service_id
:
747 raise SpecValidationError(
748 f
'Service of type \'{self.service_type}\' should not contain a service id')
751 if not re
.match('^[a-zA-Z0-9_.-]+$', str(self
.service_id
)):
752 raise SpecValidationError('Service id contains invalid characters, '
753 'only [a-zA-Z0-9_.-] allowed')
755 if self
.placement
is not None:
756 self
.placement
.validate()
758 for k
, v
in self
.config
.items():
759 if k
in self
.MANAGED_CONFIG_OPTIONS
:
760 raise SpecValidationError(
761 f
'Cannot set config option {k} in spec: it is managed by cephadm'
763 for network
in self
.networks
or []:
766 except ValueError as e
:
767 raise SpecValidationError(
768 f
'Cannot parse network {network}: {e}'
771 def __repr__(self
) -> str:
772 y
= yaml
.dump(cast(dict, self
), default_flow_style
=False)
773 return f
"{self.__class__.__name__}.from_json(yaml.safe_load('''{y}'''))"
775 def __eq__(self
, other
: Any
) -> bool:
776 return (self
.__class
__ == other
.__class
__
778 self
.__dict
__ == other
.__dict
__)
780 def one_line_str(self
) -> str:
781 return '<{} for service_name={}>'.format(self
.__class
__.__name
__, self
.service_name())
784 def yaml_representer(dumper
: 'yaml.SafeDumper', data
: 'ServiceSpec') -> Any
:
785 return dumper
.represent_dict(cast(Mapping
, data
.to_json().items()))
788 yaml
.add_representer(ServiceSpec
, ServiceSpec
.yaml_representer
)
791 class NFSServiceSpec(ServiceSpec
):
793 service_type
: str = 'nfs',
794 service_id
: Optional
[str] = None,
795 placement
: Optional
[PlacementSpec
] = None,
796 unmanaged
: bool = False,
797 preview_only
: bool = False,
798 config
: Optional
[Dict
[str, str]] = None,
799 networks
: Optional
[List
[str]] = None,
800 port
: Optional
[int] = None,
801 extra_container_args
: Optional
[List
[str]] = None,
802 custom_configs
: Optional
[List
[CustomConfig
]] = None,
804 assert service_type
== 'nfs'
805 super(NFSServiceSpec
, self
).__init
__(
806 'nfs', service_id
=service_id
,
807 placement
=placement
, unmanaged
=unmanaged
, preview_only
=preview_only
,
808 config
=config
, networks
=networks
, extra_container_args
=extra_container_args
,
809 custom_configs
=custom_configs
)
813 def get_port_start(self
) -> List
[int]:
818 def rados_config_name(self
):
820 return 'conf-' + self
.service_name()
823 yaml
.add_representer(NFSServiceSpec
, ServiceSpec
.yaml_representer
)
826 class RGWSpec(ServiceSpec
):
828 Settings to configure a (multisite) Ceph RGW
833 service_id: myrealm.myzone
838 rgw_frontend_port: 1234
839 rgw_frontend_type: beast
840 rgw_frontend_ssl_certificate: ...
842 See also: :ref:`orchestrator-cli-service-spec`
845 MANAGED_CONFIG_OPTIONS
= ServiceSpec
.MANAGED_CONFIG_OPTIONS
+ [
852 service_type
: str = 'rgw',
853 service_id
: Optional
[str] = None,
854 placement
: Optional
[PlacementSpec
] = None,
855 rgw_realm
: Optional
[str] = None,
856 rgw_zone
: Optional
[str] = None,
857 rgw_frontend_port
: Optional
[int] = None,
858 rgw_frontend_ssl_certificate
: Optional
[List
[str]] = None,
859 rgw_frontend_type
: Optional
[str] = None,
860 unmanaged
: bool = False,
862 preview_only
: bool = False,
863 config
: Optional
[Dict
[str, str]] = None,
864 networks
: Optional
[List
[str]] = None,
865 subcluster
: Optional
[str] = None, # legacy, only for from_json on upgrade
866 extra_container_args
: Optional
[List
[str]] = None,
867 custom_configs
: Optional
[List
[CustomConfig
]] = None,
869 assert service_type
== 'rgw', service_type
871 # for backward compatibility with octopus spec files,
872 if not service_id
and (rgw_realm
and rgw_zone
):
873 service_id
= rgw_realm
+ '.' + rgw_zone
875 super(RGWSpec
, self
).__init
__(
876 'rgw', service_id
=service_id
,
877 placement
=placement
, unmanaged
=unmanaged
,
878 preview_only
=preview_only
, config
=config
, networks
=networks
,
879 extra_container_args
=extra_container_args
, custom_configs
=custom_configs
)
881 #: The RGW realm associated with this service. Needs to be manually created
882 self
.rgw_realm
: Optional
[str] = rgw_realm
883 #: The RGW zone associated with this service. Needs to be manually created
884 self
.rgw_zone
: Optional
[str] = rgw_zone
885 #: Port of the RGW daemons
886 self
.rgw_frontend_port
: Optional
[int] = rgw_frontend_port
887 #: List of SSL certificates
888 self
.rgw_frontend_ssl_certificate
: Optional
[List
[str]] = rgw_frontend_ssl_certificate
889 #: civetweb or beast (default: beast). See :ref:`rgw_frontends`
890 self
.rgw_frontend_type
: Optional
[str] = rgw_frontend_type
894 def get_port_start(self
) -> List
[int]:
895 return [self
.get_port()]
897 def get_port(self
) -> int:
898 if self
.rgw_frontend_port
:
899 return self
.rgw_frontend_port
905 def validate(self
) -> None:
906 super(RGWSpec
, self
).validate()
908 if self
.rgw_realm
and not self
.rgw_zone
:
909 raise SpecValidationError(
910 'Cannot add RGW: Realm specified but no zone specified')
911 if self
.rgw_zone
and not self
.rgw_realm
:
912 raise SpecValidationError(
913 'Cannot add RGW: Zone specified but no realm specified')
916 yaml
.add_representer(RGWSpec
, ServiceSpec
.yaml_representer
)
919 class IscsiServiceSpec(ServiceSpec
):
921 service_type
: str = 'iscsi',
922 service_id
: Optional
[str] = None,
923 pool
: Optional
[str] = None,
924 trusted_ip_list
: Optional
[str] = None,
925 api_port
: Optional
[int] = None,
926 api_user
: Optional
[str] = None,
927 api_password
: Optional
[str] = None,
928 api_secure
: Optional
[bool] = None,
929 ssl_cert
: Optional
[str] = None,
930 ssl_key
: Optional
[str] = None,
931 placement
: Optional
[PlacementSpec
] = None,
932 unmanaged
: bool = False,
933 preview_only
: bool = False,
934 config
: Optional
[Dict
[str, str]] = None,
935 networks
: Optional
[List
[str]] = None,
936 extra_container_args
: Optional
[List
[str]] = None,
937 custom_configs
: Optional
[List
[CustomConfig
]] = None,
939 assert service_type
== 'iscsi'
940 super(IscsiServiceSpec
, self
).__init
__('iscsi', service_id
=service_id
,
941 placement
=placement
, unmanaged
=unmanaged
,
942 preview_only
=preview_only
,
943 config
=config
, networks
=networks
,
944 extra_container_args
=extra_container_args
,
945 custom_configs
=custom_configs
)
947 #: RADOS pool where ceph-iscsi config data is stored.
949 #: list of trusted IP addresses
950 self
.trusted_ip_list
= trusted_ip_list
951 #: ``api_port`` as defined in the ``iscsi-gateway.cfg``
952 self
.api_port
= api_port
953 #: ``api_user`` as defined in the ``iscsi-gateway.cfg``
954 self
.api_user
= api_user
955 #: ``api_password`` as defined in the ``iscsi-gateway.cfg``
956 self
.api_password
= api_password
957 #: ``api_secure`` as defined in the ``iscsi-gateway.cfg``
958 self
.api_secure
= api_secure
960 self
.ssl_cert
= ssl_cert
962 self
.ssl_key
= ssl_key
964 if not self
.api_secure
and self
.ssl_cert
and self
.ssl_key
:
965 self
.api_secure
= True
967 def validate(self
) -> None:
968 super(IscsiServiceSpec
, self
).validate()
971 raise SpecValidationError(
972 'Cannot add ISCSI: No Pool specified')
974 # Do not need to check for api_user and api_password as they
975 # now default to 'admin' when setting up the gateway url. Older
976 # iSCSI specs from before this change should be fine as they will
977 # have been required to have an api_user and api_password set and
978 # will be unaffected by the new default value.
981 yaml
.add_representer(IscsiServiceSpec
, ServiceSpec
.yaml_representer
)
984 class IngressSpec(ServiceSpec
):
986 service_type
: str = 'ingress',
987 service_id
: Optional
[str] = None,
988 config
: Optional
[Dict
[str, str]] = None,
989 networks
: Optional
[List
[str]] = None,
990 placement
: Optional
[PlacementSpec
] = None,
991 backend_service
: Optional
[str] = None,
992 frontend_port
: Optional
[int] = None,
993 ssl_cert
: Optional
[str] = None,
994 ssl_key
: Optional
[str] = None,
995 ssl_dh_param
: Optional
[str] = None,
996 ssl_ciphers
: Optional
[List
[str]] = None,
997 ssl_options
: Optional
[List
[str]] = None,
998 monitor_port
: Optional
[int] = None,
999 monitor_user
: Optional
[str] = None,
1000 monitor_password
: Optional
[str] = None,
1001 enable_stats
: Optional
[bool] = None,
1002 keepalived_password
: Optional
[str] = None,
1003 virtual_ip
: Optional
[str] = None,
1004 virtual_ips_list
: Optional
[List
[str]] = None,
1005 virtual_interface_networks
: Optional
[List
[str]] = [],
1006 unmanaged
: bool = False,
1008 extra_container_args
: Optional
[List
[str]] = None,
1009 custom_configs
: Optional
[List
[CustomConfig
]] = None,
1011 assert service_type
== 'ingress'
1013 super(IngressSpec
, self
).__init
__(
1014 'ingress', service_id
=service_id
,
1015 placement
=placement
, config
=config
,
1017 extra_container_args
=extra_container_args
,
1018 custom_configs
=custom_configs
1020 self
.backend_service
= backend_service
1021 self
.frontend_port
= frontend_port
1022 self
.ssl_cert
= ssl_cert
1023 self
.ssl_key
= ssl_key
1024 self
.ssl_dh_param
= ssl_dh_param
1025 self
.ssl_ciphers
= ssl_ciphers
1026 self
.ssl_options
= ssl_options
1027 self
.monitor_port
= monitor_port
1028 self
.monitor_user
= monitor_user
1029 self
.monitor_password
= monitor_password
1030 self
.keepalived_password
= keepalived_password
1031 self
.virtual_ip
= virtual_ip
1032 self
.virtual_ips_list
= virtual_ips_list
1033 self
.virtual_interface_networks
= virtual_interface_networks
or []
1034 self
.unmanaged
= unmanaged
1037 def get_port_start(self
) -> List
[int]:
1038 return [cast(int, self
.frontend_port
),
1039 cast(int, self
.monitor_port
)]
1041 def get_virtual_ip(self
) -> Optional
[str]:
1042 return self
.virtual_ip
1044 def validate(self
) -> None:
1045 super(IngressSpec
, self
).validate()
1047 if not self
.backend_service
:
1048 raise SpecValidationError(
1049 'Cannot add ingress: No backend_service specified')
1050 if not self
.frontend_port
:
1051 raise SpecValidationError(
1052 'Cannot add ingress: No frontend_port specified')
1053 if not self
.monitor_port
:
1054 raise SpecValidationError(
1055 'Cannot add ingress: No monitor_port specified')
1056 if not self
.virtual_ip
and not self
.virtual_ips_list
:
1057 raise SpecValidationError(
1058 'Cannot add ingress: No virtual_ip provided')
1059 if self
.virtual_ip
is not None and self
.virtual_ips_list
is not None:
1060 raise SpecValidationError(
1061 'Cannot add ingress: Single and multiple virtual IPs specified')
1064 yaml
.add_representer(IngressSpec
, ServiceSpec
.yaml_representer
)
1067 class CustomContainerSpec(ServiceSpec
):
1069 service_type
: str = 'container',
1070 service_id
: Optional
[str] = None,
1071 config
: Optional
[Dict
[str, str]] = None,
1072 networks
: Optional
[List
[str]] = None,
1073 placement
: Optional
[PlacementSpec
] = None,
1074 unmanaged
: bool = False,
1075 preview_only
: bool = False,
1076 image
: Optional
[str] = None,
1077 entrypoint
: Optional
[str] = None,
1078 uid
: Optional
[int] = None,
1079 gid
: Optional
[int] = None,
1080 volume_mounts
: Optional
[Dict
[str, str]] = {},
1081 args
: Optional
[List
[str]] = [],
1082 envs
: Optional
[List
[str]] = [],
1083 privileged
: Optional
[bool] = False,
1084 bind_mounts
: Optional
[List
[List
[str]]] = None,
1085 ports
: Optional
[List
[int]] = [],
1086 dirs
: Optional
[List
[str]] = [],
1087 files
: Optional
[Dict
[str, Any
]] = {},
1089 assert service_type
== 'container'
1090 assert service_id
is not None
1091 assert image
is not None
1093 super(CustomContainerSpec
, self
).__init
__(
1094 service_type
, service_id
,
1095 placement
=placement
, unmanaged
=unmanaged
,
1096 preview_only
=preview_only
, config
=config
,
1100 self
.entrypoint
= entrypoint
1103 self
.volume_mounts
= volume_mounts
1106 self
.privileged
= privileged
1107 self
.bind_mounts
= bind_mounts
1112 def config_json(self
) -> Dict
[str, Any
]:
1114 Helper function to get the value of the `--config-json` cephadm
1115 command line option. It will contain all specification properties
1116 that haven't a `None` value. Such properties will get default
1118 :return: Returns a dictionary containing all specification
1122 for prop
in ['image', 'entrypoint', 'uid', 'gid', 'args',
1123 'envs', 'volume_mounts', 'privileged',
1124 'bind_mounts', 'ports', 'dirs', 'files']:
1125 value
= getattr(self
, prop
)
1126 if value
is not None:
1127 config_json
[prop
] = value
1131 yaml
.add_representer(CustomContainerSpec
, ServiceSpec
.yaml_representer
)
1134 class MonitoringSpec(ServiceSpec
):
1137 service_id
: Optional
[str] = None,
1138 config
: Optional
[Dict
[str, str]] = None,
1139 networks
: Optional
[List
[str]] = None,
1140 placement
: Optional
[PlacementSpec
] = None,
1141 unmanaged
: bool = False,
1142 preview_only
: bool = False,
1143 port
: Optional
[int] = None,
1144 extra_container_args
: Optional
[List
[str]] = None,
1145 custom_configs
: Optional
[List
[CustomConfig
]] = None,
1147 assert service_type
in ['grafana', 'node-exporter', 'prometheus', 'alertmanager',
1150 super(MonitoringSpec
, self
).__init
__(
1151 service_type
, service_id
,
1152 placement
=placement
, unmanaged
=unmanaged
,
1153 preview_only
=preview_only
, config
=config
,
1154 networks
=networks
, extra_container_args
=extra_container_args
,
1155 custom_configs
=custom_configs
)
1157 self
.service_type
= service_type
1160 def get_port_start(self
) -> List
[int]:
1161 return [self
.get_port()]
1163 def get_port(self
) -> int:
1167 return {'prometheus': 9095,
1168 'node-exporter': 9100,
1169 'alertmanager': 9093,
1172 'promtail': 9080}[self
.service_type
]
1175 yaml
.add_representer(MonitoringSpec
, ServiceSpec
.yaml_representer
)
1178 class AlertManagerSpec(MonitoringSpec
):
1180 service_type
: str = 'alertmanager',
1181 service_id
: Optional
[str] = None,
1182 placement
: Optional
[PlacementSpec
] = None,
1183 unmanaged
: bool = False,
1184 preview_only
: bool = False,
1185 user_data
: Optional
[Dict
[str, Any
]] = None,
1186 config
: Optional
[Dict
[str, str]] = None,
1187 networks
: Optional
[List
[str]] = None,
1188 port
: Optional
[int] = None,
1189 secure
: bool = False,
1190 extra_container_args
: Optional
[List
[str]] = None,
1191 custom_configs
: Optional
[List
[CustomConfig
]] = None,
1193 assert service_type
== 'alertmanager'
1194 super(AlertManagerSpec
, self
).__init
__(
1195 'alertmanager', service_id
=service_id
,
1196 placement
=placement
, unmanaged
=unmanaged
,
1197 preview_only
=preview_only
, config
=config
, networks
=networks
, port
=port
,
1198 extra_container_args
=extra_container_args
, custom_configs
=custom_configs
)
1200 # Custom configuration.
1203 # service_type: alertmanager
1206 # default_webhook_urls:
1211 # default_webhook_urls - A list of additional URL's that are
1212 # added to the default receivers'
1213 # <webhook_configs> configuration.
1214 self
.user_data
= user_data
or {}
1215 self
.secure
= secure
1217 def get_port_start(self
) -> List
[int]:
1218 return [self
.get_port(), 9094]
1220 def validate(self
) -> None:
1221 super(AlertManagerSpec
, self
).validate()
1223 if self
.port
== 9094:
1224 raise SpecValidationError(
1225 'Port 9094 is reserved for AlertManager cluster listen address')
1228 yaml
.add_representer(AlertManagerSpec
, ServiceSpec
.yaml_representer
)
1231 class GrafanaSpec(MonitoringSpec
):
1233 service_type
: str = 'grafana',
1234 service_id
: Optional
[str] = None,
1235 placement
: Optional
[PlacementSpec
] = None,
1236 unmanaged
: bool = False,
1237 preview_only
: bool = False,
1238 config
: Optional
[Dict
[str, str]] = None,
1239 networks
: Optional
[List
[str]] = None,
1240 port
: Optional
[int] = None,
1241 initial_admin_password
: Optional
[str] = None,
1242 extra_container_args
: Optional
[List
[str]] = None,
1243 custom_configs
: Optional
[List
[CustomConfig
]] = None,
1245 assert service_type
== 'grafana'
1246 super(GrafanaSpec
, self
).__init
__(
1247 'grafana', service_id
=service_id
,
1248 placement
=placement
, unmanaged
=unmanaged
,
1249 preview_only
=preview_only
, config
=config
, networks
=networks
, port
=port
,
1250 extra_container_args
=extra_container_args
, custom_configs
=custom_configs
)
1252 self
.initial_admin_password
= initial_admin_password
1255 yaml
.add_representer(GrafanaSpec
, ServiceSpec
.yaml_representer
)
1258 class SNMPGatewaySpec(ServiceSpec
):
1259 class SNMPVersion(str, enum
.Enum
):
1263 def to_json(self
) -> str:
1266 class SNMPAuthType(str, enum
.Enum
):
1270 def to_json(self
) -> str:
1273 class SNMPPrivacyType(str, enum
.Enum
):
1277 def to_json(self
) -> str:
1280 valid_destination_types
= [
1286 service_type
: str = 'snmp-gateway',
1287 snmp_version
: Optional
[SNMPVersion
] = None,
1288 snmp_destination
: str = '',
1289 credentials
: Dict
[str, str] = {},
1290 engine_id
: Optional
[str] = None,
1291 auth_protocol
: Optional
[SNMPAuthType
] = None,
1292 privacy_protocol
: Optional
[SNMPPrivacyType
] = None,
1293 placement
: Optional
[PlacementSpec
] = None,
1294 unmanaged
: bool = False,
1295 preview_only
: bool = False,
1296 port
: Optional
[int] = None,
1297 extra_container_args
: Optional
[List
[str]] = None,
1298 custom_configs
: Optional
[List
[CustomConfig
]] = None,
1300 assert service_type
== 'snmp-gateway'
1302 super(SNMPGatewaySpec
, self
).__init
__(
1304 placement
=placement
,
1305 unmanaged
=unmanaged
,
1306 preview_only
=preview_only
,
1307 extra_container_args
=extra_container_args
,
1308 custom_configs
=custom_configs
)
1310 self
.service_type
= service_type
1311 self
.snmp_version
= snmp_version
1312 self
.snmp_destination
= snmp_destination
1314 self
.credentials
= credentials
1315 self
.engine_id
= engine_id
1316 self
.auth_protocol
= auth_protocol
1317 self
.privacy_protocol
= privacy_protocol
1320 def _from_json_impl(cls
, json_spec
: dict) -> 'SNMPGatewaySpec':
1322 cpy
= json_spec
.copy()
1324 ('snmp_version', SNMPGatewaySpec
.SNMPVersion
),
1325 ('auth_protocol', SNMPGatewaySpec
.SNMPAuthType
),
1326 ('privacy_protocol', SNMPGatewaySpec
.SNMPPrivacyType
),
1328 for d
in cpy
, cpy
.get('spec', {}):
1329 for key
, enum_cls
in types
:
1332 d
[key
] = enum_cls(d
[key
])
1334 raise SpecValidationError(f
'{key} unsupported. Must be one of '
1335 f
'{", ".join(enum_cls)}')
1336 return super(SNMPGatewaySpec
, cls
)._from
_json
_impl
(cpy
)
1339 def ports(self
) -> List
[int]:
1340 return [self
.port
or 9464]
1342 def get_port_start(self
) -> List
[int]:
1345 def validate(self
) -> None:
1346 super(SNMPGatewaySpec
, self
).validate()
1348 if not self
.credentials
:
1349 raise SpecValidationError(
1350 'Missing authentication information (credentials). '
1351 'SNMP V2c and V3 require credential information'
1353 elif not self
.snmp_version
:
1354 raise SpecValidationError(
1355 'Missing SNMP version (snmp_version)'
1358 creds_requirement
= {
1359 'V2c': ['snmp_community'],
1360 'V3': ['snmp_v3_auth_username', 'snmp_v3_auth_password']
1362 if self
.privacy_protocol
:
1363 creds_requirement
['V3'].append('snmp_v3_priv_password')
1365 missing
= [parm
for parm
in creds_requirement
[self
.snmp_version
]
1366 if parm
not in self
.credentials
]
1367 # check that credentials are correct for the version
1369 raise SpecValidationError(
1370 f
'SNMP {self.snmp_version} credentials are incomplete. Missing {", ".join(missing)}'
1374 if 10 <= len(self
.engine_id
) <= 64 and \
1375 is_hex(self
.engine_id
) and \
1376 len(self
.engine_id
) % 2 == 0:
1379 raise SpecValidationError(
1380 'engine_id must be a string containing 10-64 hex characters. '
1381 'Its length must be divisible by 2'
1385 if self
.snmp_version
== 'V3':
1386 raise SpecValidationError(
1387 'Must provide an engine_id for SNMP V3 notifications'
1390 if not self
.snmp_destination
:
1391 raise SpecValidationError(
1392 'SNMP destination (snmp_destination) must be provided'
1395 valid
, description
= valid_addr(self
.snmp_destination
)
1397 raise SpecValidationError(
1398 f
'SNMP destination (snmp_destination) is invalid: {description}'
1400 if description
not in self
.valid_destination_types
:
1401 raise SpecValidationError(
1402 f
'SNMP destination (snmp_destination) type ({description}) is invalid. '
1403 f
'Must be either: {", ".join(sorted(self.valid_destination_types))}'
1407 yaml
.add_representer(SNMPGatewaySpec
, ServiceSpec
.yaml_representer
)
1410 class MDSSpec(ServiceSpec
):
1412 service_type
: str = 'mds',
1413 service_id
: Optional
[str] = None,
1414 placement
: Optional
[PlacementSpec
] = None,
1415 config
: Optional
[Dict
[str, str]] = None,
1416 unmanaged
: bool = False,
1417 preview_only
: bool = False,
1418 extra_container_args
: Optional
[List
[str]] = None,
1419 custom_configs
: Optional
[List
[CustomConfig
]] = None,
1421 assert service_type
== 'mds'
1422 super(MDSSpec
, self
).__init
__('mds', service_id
=service_id
,
1423 placement
=placement
,
1425 unmanaged
=unmanaged
,
1426 preview_only
=preview_only
,
1427 extra_container_args
=extra_container_args
,
1428 custom_configs
=custom_configs
)
1430 def validate(self
) -> None:
1431 super(MDSSpec
, self
).validate()
1433 if str(self
.service_id
)[0].isdigit():
1434 raise SpecValidationError('MDS service id cannot start with a numeric digit')
1437 yaml
.add_representer(MDSSpec
, ServiceSpec
.yaml_representer
)
1440 class TunedProfileSpec():
1443 placement
: Optional
[PlacementSpec
] = None,
1444 settings
: Optional
[Dict
[str, str]] = None,
1446 self
.profile_name
= profile_name
1447 self
.placement
= placement
or PlacementSpec(host_pattern
='*')
1448 self
.settings
= settings
or {}
1449 self
._last
_updated
: str = ''
1452 def from_json(cls
, spec
: Dict
[str, Any
]) -> 'TunedProfileSpec':
1454 if 'profile_name' not in spec
:
1455 raise SpecValidationError('Tuned profile spec must include "profile_name" field')
1456 data
['profile_name'] = spec
['profile_name']
1457 if not isinstance(data
['profile_name'], str):
1458 raise SpecValidationError('"profile_name" field must be a string')
1459 if 'placement' in spec
:
1460 data
['placement'] = PlacementSpec
.from_json(spec
['placement'])
1461 if 'settings' in spec
:
1462 data
['settings'] = spec
['settings']
1465 def to_json(self
) -> Dict
[str, Any
]:
1466 res
: Dict
[str, Any
] = {}
1467 res
['profile_name'] = self
.profile_name
1468 res
['placement'] = self
.placement
.to_json()
1469 res
['settings'] = self
.settings
1472 def __eq__(self
, other
: Any
) -> bool:
1473 if isinstance(other
, TunedProfileSpec
):
1475 self
.placement
== other
.placement
1476 and self
.profile_name
== other
.profile_name
1477 and self
.settings
== other
.settings
1481 return NotImplemented
1483 def __repr__(self
) -> str:
1484 return f
'TunedProfile({self.profile_name})'
1486 def copy(self
) -> 'TunedProfileSpec':
1487 # for making deep copies so you can edit the settings in one without affecting the other
1488 # mostly for testing purposes
1489 return TunedProfileSpec(self
.profile_name
, self
.placement
, self
.settings
.copy())