]> git.proxmox.com Git - ceph.git/blob - ceph/src/pybind/mgr/dashboard/controllers/host.py
6703a546295042525086c8ddfff7764b3e19a4a8
[ceph.git] / ceph / src / pybind / mgr / dashboard / controllers / host.py
1 # -*- coding: utf-8 -*-
2
3 import copy
4 import os
5 import time
6 from typing import Dict, List, Optional
7
8 import cherrypy
9 from mgr_util import merge_dicts
10 from orchestrator import HostSpec
11
12 from .. import mgr
13 from ..exceptions import DashboardException
14 from ..security import Scope
15 from ..services.ceph_service import CephService
16 from ..services.exception import handle_orchestrator_error
17 from ..services.orchestrator import OrchClient, OrchFeature
18 from ..tools import TaskManager, merge_list_of_dicts_by_key, str_to_bool
19 from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, \
20 ReadPermission, RESTController, Task, UIRouter, UpdatePermission, \
21 allow_empty_body
22 from ._version import APIVersion
23 from .orchestrator import raise_if_no_orchestrator
24
25 LIST_HOST_SCHEMA = {
26 "hostname": (str, "Hostname"),
27 "services": ([{
28 "type": (str, "type of service"),
29 "id": (str, "Service Id"),
30 }], "Services related to the host"),
31 "ceph_version": (str, "Ceph version"),
32 "addr": (str, "Host address"),
33 "labels": ([str], "Labels related to the host"),
34 "service_type": (str, ""),
35 "sources": ({
36 "ceph": (bool, ""),
37 "orchestrator": (bool, "")
38 }, "Host Sources"),
39 "status": (str, "")
40 }
41
42 INVENTORY_SCHEMA = {
43 "name": (str, "Hostname"),
44 "addr": (str, "Host address"),
45 "devices": ([{
46 "rejected_reasons": ([str], ""),
47 "available": (bool, "If the device can be provisioned to an OSD"),
48 "path": (str, "Device path"),
49 "sys_api": ({
50 "removable": (str, ""),
51 "ro": (str, ""),
52 "vendor": (str, ""),
53 "model": (str, ""),
54 "rev": (str, ""),
55 "sas_address": (str, ""),
56 "sas_device_handle": (str, ""),
57 "support_discard": (str, ""),
58 "rotational": (str, ""),
59 "nr_requests": (str, ""),
60 "scheduler_mode": (str, ""),
61 "partitions": ({
62 "partition_name": ({
63 "start": (str, ""),
64 "sectors": (str, ""),
65 "sectorsize": (int, ""),
66 "size": (int, ""),
67 "human_readable_size": (str, ""),
68 "holders": ([str], "")
69 }, "")
70 }, ""),
71 "sectors": (int, ""),
72 "sectorsize": (str, ""),
73 "size": (int, ""),
74 "human_readable_size": (str, ""),
75 "path": (str, ""),
76 "locked": (int, "")
77 }, ""),
78 "lvs": ([{
79 "name": (str, ""),
80 "osd_id": (str, ""),
81 "cluster_name": (str, ""),
82 "type": (str, ""),
83 "osd_fsid": (str, ""),
84 "cluster_fsid": (str, ""),
85 "osdspec_affinity": (str, ""),
86 "block_uuid": (str, ""),
87 }], ""),
88 "human_readable_type": (str, "Device type. ssd or hdd"),
89 "device_id": (str, "Device's udev ID"),
90 "lsm_data": ({
91 "serialNum": (str, ""),
92 "transport": (str, ""),
93 "mediaType": (str, ""),
94 "rpm": (str, ""),
95 "linkSpeed": (str, ""),
96 "health": (str, ""),
97 "ledSupport": ({
98 "IDENTsupport": (str, ""),
99 "IDENTstatus": (str, ""),
100 "FAILsupport": (str, ""),
101 "FAILstatus": (str, ""),
102 }, ""),
103 "errors": ([str], "")
104 }, ""),
105 "osd_ids": ([int], "Device OSD IDs")
106 }], "Host devices"),
107 "labels": ([str], "Host labels")
108 }
109
110
111 def host_task(name, metadata, wait_for=10.0):
112 return Task("host/{}".format(name), metadata, wait_for)
113
114
115 def merge_hosts_by_hostname(ceph_hosts, orch_hosts):
116 # type: (List[dict], List[HostSpec]) -> List[dict]
117 """
118 Merge Ceph hosts with orchestrator hosts by hostnames.
119
120 :param ceph_hosts: hosts returned from mgr
121 :type ceph_hosts: list of dict
122 :param orch_hosts: hosts returned from ochestrator
123 :type orch_hosts: list of HostSpec
124 :return list of dict
125 """
126 hosts = copy.deepcopy(ceph_hosts)
127 orch_hosts_map = {host.hostname: host.to_json() for host in orch_hosts}
128
129 # Sort labels.
130 for hostname in orch_hosts_map:
131 orch_hosts_map[hostname]['labels'].sort()
132
133 # Hosts in both Ceph and Orchestrator.
134 for host in hosts:
135 hostname = host['hostname']
136 if hostname in orch_hosts_map:
137 host.update(orch_hosts_map[hostname])
138 host['sources']['orchestrator'] = True
139 orch_hosts_map.pop(hostname)
140
141 # Hosts only in Orchestrator.
142 orch_hosts_only = [
143 merge_dicts(
144 {
145 'ceph_version': '',
146 'services': [],
147 'sources': {
148 'ceph': False,
149 'orchestrator': True
150 }
151 }, orch_hosts_map[hostname]) for hostname in orch_hosts_map
152 ]
153 hosts.extend(orch_hosts_only)
154 return hosts
155
156
157 def get_hosts(sources=None):
158 """
159 Get hosts from various sources.
160 """
161 from_ceph = True
162 from_orchestrator = True
163 if sources:
164 _sources = sources.split(',')
165 from_ceph = 'ceph' in _sources
166 from_orchestrator = 'orchestrator' in _sources
167
168 ceph_hosts = []
169 if from_ceph:
170 ceph_hosts = [
171 merge_dicts(
172 server, {
173 'addr': '',
174 'labels': [],
175 'sources': {
176 'ceph': True,
177 'orchestrator': False
178 },
179 'status': ''
180 }) for server in mgr.list_servers()
181 ]
182 if from_orchestrator:
183 orch = OrchClient.instance()
184 if orch.available():
185 return merge_hosts_by_hostname(ceph_hosts, orch.hosts.list())
186 return ceph_hosts
187
188
189 def get_host(hostname: str) -> Dict:
190 """
191 Get a specific host from Ceph or Orchestrator (if available).
192 :param hostname: The name of the host to fetch.
193 :raises: cherrypy.HTTPError: If host not found.
194 """
195 for host in get_hosts():
196 if host['hostname'] == hostname:
197 return host
198 raise cherrypy.HTTPError(404)
199
200
201 def get_device_osd_map():
202 """Get mappings from inventory devices to OSD IDs.
203
204 :return: Returns a dictionary containing mappings. Note one device might
205 shared between multiple OSDs.
206 e.g. {
207 'node1': {
208 'nvme0n1': [0, 1],
209 'vdc': [0],
210 'vdb': [1]
211 },
212 'node2': {
213 'vdc': [2]
214 }
215 }
216 :rtype: dict
217 """
218 result: dict = {}
219 for osd_id, osd_metadata in mgr.get('osd_metadata').items():
220 hostname = osd_metadata.get('hostname')
221 devices = osd_metadata.get('devices')
222 if not hostname or not devices:
223 continue
224 if hostname not in result:
225 result[hostname] = {}
226 # for OSD contains multiple devices, devices is in `sda,sdb`
227 for device in devices.split(','):
228 if device not in result[hostname]:
229 result[hostname][device] = [int(osd_id)]
230 else:
231 result[hostname][device].append(int(osd_id))
232 return result
233
234
235 def get_inventories(hosts: Optional[List[str]] = None,
236 refresh: Optional[bool] = None) -> List[dict]:
237 """Get inventories from the Orchestrator and link devices with OSD IDs.
238
239 :param hosts: Hostnames to query.
240 :param refresh: Ask the Orchestrator to refresh the inventories. Note the this is an
241 asynchronous operation, the updated version of inventories need to
242 be re-qeuried later.
243 :return: Returns list of inventory.
244 :rtype: list
245 """
246 do_refresh = False
247 if refresh is not None:
248 do_refresh = str_to_bool(refresh)
249 orch = OrchClient.instance()
250 inventory_hosts = [host.to_json()
251 for host in orch.inventory.list(hosts=hosts, refresh=do_refresh)]
252 device_osd_map = get_device_osd_map()
253 for inventory_host in inventory_hosts:
254 host_osds = device_osd_map.get(inventory_host['name'])
255 for device in inventory_host['devices']:
256 if host_osds: # pragma: no cover
257 dev_name = os.path.basename(device['path'])
258 device['osd_ids'] = sorted(host_osds.get(dev_name, []))
259 else:
260 device['osd_ids'] = []
261 return inventory_hosts
262
263
264 @allow_empty_body
265 def add_host(hostname: str, addr: Optional[str] = None,
266 labels: Optional[List[str]] = None,
267 status: Optional[str] = None):
268 orch_client = OrchClient.instance()
269 host = Host()
270 host.check_orchestrator_host_op(orch_client, hostname)
271 orch_client.hosts.add(hostname, addr, labels)
272 if status == 'maintenance':
273 orch_client.hosts.enter_maintenance(hostname)
274
275
276 @APIRouter('/host', Scope.HOSTS)
277 @APIDoc("Get Host Details", "Host")
278 class Host(RESTController):
279 @EndpointDoc("List Host Specifications",
280 parameters={
281 'sources': (str, 'Host Sources'),
282 'facts': (bool, 'Host Facts')
283 },
284 responses={200: LIST_HOST_SCHEMA})
285 @RESTController.MethodMap(version=APIVersion(1, 1))
286 def list(self, sources=None, facts=False):
287 hosts = get_hosts(sources)
288 orch = OrchClient.instance()
289 if str_to_bool(facts):
290 if orch.available():
291 if not orch.get_missing_features(['get_facts']):
292 hosts_facts = orch.hosts.get_facts()
293 return merge_list_of_dicts_by_key(hosts, hosts_facts, 'hostname')
294
295 raise DashboardException(
296 code='invalid_orchestrator_backend', # pragma: no cover
297 msg="Please enable the cephadm orchestrator backend "
298 "(try `ceph orch set backend cephadm`)",
299 component='orchestrator',
300 http_status_code=400)
301
302 raise DashboardException(code='orchestrator_status_unavailable', # pragma: no cover
303 msg="Please configure and enable the orchestrator if you "
304 "really want to gather facts from hosts",
305 component='orchestrator',
306 http_status_code=400)
307 return hosts
308
309 @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_ADD])
310 @handle_orchestrator_error('host')
311 @host_task('add', {'hostname': '{hostname}'})
312 @EndpointDoc('',
313 parameters={
314 'hostname': (str, 'Hostname'),
315 'addr': (str, 'Network Address'),
316 'labels': ([str], 'Host Labels'),
317 'status': (str, 'Host Status')
318 },
319 responses={200: None, 204: None})
320 @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
321 def create(self, hostname: str,
322 addr: Optional[str] = None,
323 labels: Optional[List[str]] = None,
324 status: Optional[str] = None): # pragma: no cover - requires realtime env
325 add_host(hostname, addr, labels, status)
326
327 @raise_if_no_orchestrator([OrchFeature.HOST_LIST, OrchFeature.HOST_REMOVE])
328 @handle_orchestrator_error('host')
329 @host_task('remove', {'hostname': '{hostname}'})
330 @allow_empty_body
331 def delete(self, hostname): # pragma: no cover - requires realtime env
332 orch_client = OrchClient.instance()
333 self.check_orchestrator_host_op(orch_client, hostname, False)
334 orch_client.hosts.remove(hostname)
335
336 def check_orchestrator_host_op(self, orch_client, hostname, add=True): # pragma:no cover
337 """Check if we can adding or removing a host with orchestrator
338
339 :param orch_client: Orchestrator client
340 :param add: True for adding host operation, False for removing host
341 :raise DashboardException
342 """
343 host = orch_client.hosts.get(hostname)
344 if add and host:
345 raise DashboardException(
346 code='orchestrator_add_existed_host',
347 msg='{} is already in orchestrator'.format(hostname),
348 component='orchestrator')
349 if not add and not host:
350 raise DashboardException(
351 code='orchestrator_remove_nonexistent_host',
352 msg='Remove a non-existent host {} from orchestrator'.format(hostname),
353 component='orchestrator')
354
355 @RESTController.Resource('GET')
356 def devices(self, hostname):
357 # (str) -> List
358 return CephService.get_devices_by_host(hostname)
359
360 @RESTController.Resource('GET')
361 def smart(self, hostname):
362 # type: (str) -> dict
363 return CephService.get_smart_data_by_host(hostname)
364
365 @RESTController.Resource('GET')
366 @raise_if_no_orchestrator([OrchFeature.DEVICE_LIST])
367 @handle_orchestrator_error('host')
368 @EndpointDoc('Get inventory of a host',
369 parameters={
370 'hostname': (str, 'Hostname'),
371 'refresh': (str, 'Trigger asynchronous refresh'),
372 },
373 responses={200: INVENTORY_SCHEMA})
374 def inventory(self, hostname, refresh=None):
375 inventory = get_inventories([hostname], refresh)
376 if inventory:
377 return inventory[0]
378 return {}
379
380 @RESTController.Resource('POST')
381 @UpdatePermission
382 @raise_if_no_orchestrator([OrchFeature.DEVICE_BLINK_LIGHT])
383 @handle_orchestrator_error('host')
384 @host_task('identify_device', ['{hostname}', '{device}'], wait_for=2.0)
385 def identify_device(self, hostname, device, duration):
386 # type: (str, str, int) -> None
387 """
388 Identify a device by switching on the device light for N seconds.
389 :param hostname: The hostname of the device to process.
390 :param device: The device identifier to process, e.g. ``/dev/dm-0`` or
391 ``ABC1234DEF567-1R1234_ABC8DE0Q``.
392 :param duration: The duration in seconds how long the LED should flash.
393 """
394 orch = OrchClient.instance()
395 TaskManager.current_task().set_progress(0)
396 orch.blink_device_light(hostname, device, 'ident', True)
397 for i in range(int(duration)):
398 percentage = int(round(i / float(duration) * 100))
399 TaskManager.current_task().set_progress(percentage)
400 time.sleep(1)
401 orch.blink_device_light(hostname, device, 'ident', False)
402 TaskManager.current_task().set_progress(100)
403
404 @RESTController.Resource('GET')
405 @raise_if_no_orchestrator([OrchFeature.DAEMON_LIST])
406 def daemons(self, hostname: str) -> List[dict]:
407 orch = OrchClient.instance()
408 daemons = orch.services.list_daemons(hostname=hostname)
409 return [d.to_dict() for d in daemons]
410
411 @handle_orchestrator_error('host')
412 def get(self, hostname: str) -> Dict:
413 """
414 Get the specified host.
415 :raises: cherrypy.HTTPError: If host not found.
416 """
417 return get_host(hostname)
418
419 @raise_if_no_orchestrator([OrchFeature.HOST_LABEL_ADD,
420 OrchFeature.HOST_LABEL_REMOVE,
421 OrchFeature.HOST_MAINTENANCE_ENTER,
422 OrchFeature.HOST_MAINTENANCE_EXIT,
423 OrchFeature.HOST_DRAIN])
424 @handle_orchestrator_error('host')
425 @EndpointDoc('',
426 parameters={
427 'hostname': (str, 'Hostname'),
428 'update_labels': (bool, 'Update Labels'),
429 'labels': ([str], 'Host Labels'),
430 'maintenance': (bool, 'Enter/Exit Maintenance'),
431 'force': (bool, 'Force Enter Maintenance'),
432 'drain': (bool, 'Drain Host')
433 },
434 responses={200: None, 204: None})
435 @RESTController.MethodMap(version=APIVersion.EXPERIMENTAL)
436 def set(self, hostname: str, update_labels: bool = False,
437 labels: List[str] = None, maintenance: bool = False,
438 force: bool = False, drain: bool = False):
439 """
440 Update the specified host.
441 Note, this is only supported when Ceph Orchestrator is enabled.
442 :param hostname: The name of the host to be processed.
443 :param update_labels: To update the labels.
444 :param labels: List of labels.
445 :param maintenance: Enter/Exit maintenance mode.
446 :param force: Force enter maintenance mode.
447 :param drain: Drain host
448 """
449 orch = OrchClient.instance()
450 host = get_host(hostname)
451
452 if maintenance:
453 status = host['status']
454 if status != 'maintenance':
455 orch.hosts.enter_maintenance(hostname, force)
456
457 if status == 'maintenance':
458 orch.hosts.exit_maintenance(hostname)
459
460 if drain:
461 orch.hosts.drain(hostname)
462
463 if update_labels:
464 # only allow List[str] type for labels
465 if not isinstance(labels, list):
466 raise DashboardException(
467 msg='Expected list of labels. Please check API documentation.',
468 http_status_code=400,
469 component='orchestrator')
470 current_labels = set(host['labels'])
471 # Remove labels.
472 remove_labels = list(current_labels.difference(set(labels)))
473 for label in remove_labels:
474 orch.hosts.remove_label(hostname, label)
475 # Add labels.
476 add_labels = list(set(labels).difference(current_labels))
477 for label in add_labels:
478 orch.hosts.add_label(hostname, label)
479
480
481 @UIRouter('/host', Scope.HOSTS)
482 class HostUi(BaseController):
483 @Endpoint('GET')
484 @ReadPermission
485 @handle_orchestrator_error('host')
486 def labels(self) -> List[str]:
487 """
488 Get all host labels.
489 Note, host labels are only supported when Ceph Orchestrator is enabled.
490 If Ceph Orchestrator is not enabled, an empty list is returned.
491 :return: A list of all host labels.
492 """
493 labels = []
494 orch = OrchClient.instance()
495 if orch.available():
496 for host in orch.hosts.list():
497 labels.extend(host.labels)
498 labels.sort()
499 return list(set(labels)) # Filter duplicate labels.
500
501 @Endpoint('GET')
502 @ReadPermission
503 @raise_if_no_orchestrator([OrchFeature.DEVICE_LIST])
504 @handle_orchestrator_error('host')
505 def inventory(self, refresh=None):
506 return get_inventories(None, refresh)