]> git.proxmox.com Git - ceph.git/blame - ceph/src/pybind/ceph_daemon.py
buildsys: change download over to reef release
[ceph.git] / ceph / src / pybind / ceph_daemon.py
CommitLineData
7c673cae
FG
1# -*- mode:python -*-
2# vim: ts=4 sw=4 smarttab expandtab
3
4"""
5Copyright (C) 2015 Red Hat
6
7This is free software; you can redistribute it and/or
8modify it under the terms of the GNU General Public
9License version 2, as published by the Free Software
10Foundation. See file COPYING.
11"""
12
13import sys
14import json
15import socket
16import struct
17import time
20effc67 18from collections import OrderedDict
7c673cae
FG
19from fcntl import ioctl
20from fnmatch import fnmatch
21from prettytable import PrettyTable, HEADER
20effc67 22from signal import signal, Signals, SIGWINCH
7c673cae 23from termios import TIOCGWINSZ
20effc67
TL
24from types import FrameType
25from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple
7c673cae
FG
26
27from ceph_argparse import parse_json_funcsigs, validate_command
28
29COUNTER = 0x8
30LONG_RUNNING_AVG = 0x4
31READ_CHUNK_SIZE = 4096
32
33
f67539c2 34def admin_socket(asok_path: str,
20effc67 35 cmd: List[str],
f67539c2 36 format: Optional[str] = '') -> bytes:
7c673cae
FG
37 """
38 Send a daemon (--admin-daemon) command 'cmd'. asok_path is the
39 path to the admin socket; cmd is a list of strings; format may be
40 set to one of the formatted forms to get output in that form
41 (daemon commands don't support 'plain' output).
42 """
43
20effc67 44 def do_sockio(path: str, cmd_bytes: bytes) -> bytes:
7c673cae
FG
45 """ helper: do all the actual low-level stream I/O """
46 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
47 sock.connect(path)
48 try:
49 sock.sendall(cmd_bytes + b'\0')
50 len_str = sock.recv(4)
51 if len(len_str) < 4:
52 raise RuntimeError("no data returned from admin socket")
53 l, = struct.unpack(">I", len_str)
54 sock_ret = b''
55
56 got = 0
57 while got < l:
58 # recv() receives signed int, i.e max 2GB
59 # workaround by capping READ_CHUNK_SIZE per call.
60 want = min(l - got, READ_CHUNK_SIZE)
61 bit = sock.recv(want)
62 sock_ret += bit
63 got += len(bit)
64
65 except Exception as sock_e:
66 raise RuntimeError('exception: ' + str(sock_e))
67 return sock_ret
68
69 try:
70 cmd_json = do_sockio(asok_path,
71 b'{"prefix": "get_command_descriptions"}')
72 except Exception as e:
73 raise RuntimeError('exception getting command descriptions: ' + str(e))
74
7c673cae
FG
75 sigdict = parse_json_funcsigs(cmd_json.decode('utf-8'), 'cli')
76 valid_dict = validate_command(sigdict, cmd)
77 if not valid_dict:
78 raise RuntimeError('invalid command')
79
80 if format:
81 valid_dict['format'] = format
82
83 try:
84 ret = do_sockio(asok_path, json.dumps(valid_dict).encode('utf-8'))
85 except Exception as e:
86 raise RuntimeError('exception: ' + str(e))
87
88 return ret
89
90
7c673cae 91class Termsize(object):
31f18b77 92 DEFAULT_SIZE = (25, 80)
20effc67
TL
93
94 def __init__(self) -> None:
31f18b77 95 self.rows, self.cols = self._gettermsize()
7c673cae
FG
96 self.changed = False
97
20effc67 98 def _gettermsize(self) -> Tuple[int, int]:
31f18b77
FG
99 try:
100 fd = sys.stdin.fileno()
101 sz = struct.pack('hhhh', 0, 0, 0, 0)
102 rows, cols = struct.unpack('hhhh', ioctl(fd, TIOCGWINSZ, sz))[:2]
103 return rows, cols
104 except IOError:
105 return self.DEFAULT_SIZE
106
20effc67 107 def update(self) -> None:
31f18b77
FG
108 rows, cols = self._gettermsize()
109 if not self.changed:
110 self.changed = (self.rows, self.cols) != (rows, cols)
7c673cae
FG
111 self.rows, self.cols = rows, cols
112
20effc67 113 def reset_changed(self) -> None:
7c673cae
FG
114 self.changed = False
115
20effc67 116 def __str__(self) -> str:
31f18b77
FG
117 return '%s(%dx%d, changed %s)' % (self.__class__,
118 self.rows, self.cols, self.changed)
7c673cae 119
20effc67
TL
120 def __repr__(self) -> str:
121 return '%s(%d,%d,%s)' % (self.__class__,
122 self.rows, self.cols, self.changed)
7c673cae
FG
123
124
125class DaemonWatcher(object):
126 """
127 Given a Ceph daemon's admin socket path, poll its performance counters
128 and output a series of output lines showing the momentary values of
129 counters of interest (those with the 'nick' property in Ceph's schema)
130 """
131 (
132 BLACK,
133 RED,
134 GREEN,
135 YELLOW,
136 BLUE,
137 MAGENTA,
138 CYAN,
139 GRAY
140 ) = range(8)
141
142 RESET_SEQ = "\033[0m"
143 COLOR_SEQ = "\033[1;%dm"
144 COLOR_DARK_SEQ = "\033[0;%dm"
145 BOLD_SEQ = "\033[1m"
146 UNDERLINE_SEQ = "\033[4m"
147
20effc67
TL
148 def __init__(self,
149 asok: str,
150 statpats: Optional[Sequence[str]] = None,
151 min_prio: int = 0) -> None:
7c673cae
FG
152 self.asok_path = asok
153 self._colored = False
154
20effc67 155 self._stats: Optional[Dict[str, dict]] = None
7c673cae
FG
156 self._schema = None
157 self._statpats = statpats
20effc67 158 self._stats_that_fit: Dict[str, dict] = OrderedDict()
7c673cae
FG
159 self._min_prio = min_prio
160 self.termsize = Termsize()
161
20effc67 162 def supports_color(self, ostr: TextIO) -> bool:
7c673cae
FG
163 """
164 Returns True if the running system's terminal supports color, and False
165 otherwise.
166 """
167 unsupported_platform = (sys.platform in ('win32', 'Pocket PC'))
168 # isatty is not always implemented, #6223.
169 is_a_tty = hasattr(ostr, 'isatty') and ostr.isatty()
170 if unsupported_platform or not is_a_tty:
171 return False
172 return True
173
20effc67
TL
174 def colorize(self,
175 msg: str,
176 color: int,
177 dark: bool = False) -> str:
7c673cae
FG
178 """
179 Decorate `msg` with escape sequences to give the requested color
180 """
181 return (self.COLOR_DARK_SEQ if dark else self.COLOR_SEQ) % (30 + color) \
182 + msg + self.RESET_SEQ
183
20effc67 184 def bold(self, msg: str) -> str:
7c673cae
FG
185 """
186 Decorate `msg` with escape sequences to make it appear bold
187 """
188 return self.BOLD_SEQ + msg + self.RESET_SEQ
189
20effc67 190 def format_dimless(self, n: int, width: int) -> str:
7c673cae
FG
191 """
192 Format a number without units, so as to fit into `width` characters, substituting
193 an appropriate unit suffix.
194 """
11fdf7f2 195 units = [' ', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']
7c673cae
FG
196 unit = 0
197 while len("%s" % (int(n) // (1000**unit))) > width - 1:
11fdf7f2 198 if unit >= len(units) - 1:
f67539c2 199 break
7c673cae
FG
200 unit += 1
201
202 if unit > 0:
203 truncated_float = ("%f" % (n / (1000.0 ** unit)))[0:width - 1]
204 if truncated_float[-1] == '.':
205 truncated_float = " " + truncated_float[0:-1]
206 else:
207 truncated_float = "%{wid}d".format(wid=width-1) % n
208 formatted = "%s%s" % (truncated_float, units[unit])
209
210 if self._colored:
211 if n == 0:
212 color = self.BLACK, False
213 else:
214 color = self.YELLOW, False
215 return self.bold(self.colorize(formatted[0:-1], color[0], color[1])) \
f67539c2 216 + self.bold(self.colorize(formatted[-1], self.YELLOW, False))
7c673cae
FG
217 else:
218 return formatted
219
20effc67 220 def col_width(self, nick: str) -> int:
7c673cae
FG
221 """
222 Given the short name `nick` for a column, how many characters
223 of width should the column be allocated? Does not include spacing
224 between columns.
225 """
226 return max(len(nick), 4)
227
20effc67 228 def get_stats_that_fit(self) -> Tuple[Dict[str, dict], bool]:
7c673cae
FG
229 '''
230 Get a possibly-truncated list of stats to display based on
231 current terminal width. Allow breaking mid-section.
232 '''
20effc67 233 current_fit: Dict[str, dict] = OrderedDict()
7c673cae
FG
234 if self.termsize.changed or not self._stats_that_fit:
235 width = 0
20effc67 236 assert self._stats is not None
7c673cae
FG
237 for section_name, names in self._stats.items():
238 for name, stat_data in names.items():
239 width += self.col_width(stat_data) + 1
240 if width > self.termsize.cols:
241 break
242 if section_name not in current_fit:
243 current_fit[section_name] = OrderedDict()
244 current_fit[section_name][name] = stat_data
245 if width > self.termsize.cols:
246 break
247
248 self.termsize.reset_changed()
20effc67 249 changed = bool(current_fit) and (current_fit != self._stats_that_fit)
7c673cae
FG
250 if changed:
251 self._stats_that_fit = current_fit
252 return self._stats_that_fit, changed
253
20effc67 254 def _print_headers(self, ostr: TextIO) -> None:
7c673cae
FG
255 """
256 Print a header row to `ostr`
257 """
258 header = ""
259 stats, _ = self.get_stats_that_fit()
260 for section_name, names in stats.items():
261 section_width = \
262 sum([self.col_width(x) + 1 for x in names.values()]) - 1
263 pad = max(section_width - len(section_name), 0)
264 pad_prefix = pad // 2
265 header += (pad_prefix * '-')
266 header += (section_name[0:section_width])
267 header += ((pad - pad_prefix) * '-')
268 header += ' '
269 header += "\n"
270 ostr.write(self.colorize(header, self.BLUE, True))
271
272 sub_header = ""
273 for section_name, names in stats.items():
274 for stat_name, stat_nick in names.items():
275 sub_header += self.UNDERLINE_SEQ \
276 + self.colorize(
277 stat_nick.ljust(self.col_width(stat_nick)),
278 self.BLUE) \
279 + ' '
280 sub_header = sub_header[0:-1] + self.colorize('|', self.BLUE)
281 sub_header += "\n"
282 ostr.write(sub_header)
283
20effc67
TL
284 def _print_vals(self,
285 ostr: TextIO,
286 dump: Dict[str, Any],
287 last_dump: Dict[str, Any]) -> None:
7c673cae
FG
288 """
289 Print a single row of values to `ostr`, based on deltas between `dump` and
290 `last_dump`.
291 """
292 val_row = ""
293 fit, changed = self.get_stats_that_fit()
294 if changed:
295 self._print_headers(ostr)
296 for section_name, names in fit.items():
297 for stat_name, stat_nick in names.items():
20effc67 298 assert self._schema is not None
7c673cae
FG
299 stat_type = self._schema[section_name][stat_name]['type']
300 if bool(stat_type & COUNTER):
301 n = max(dump[section_name][stat_name] -
302 last_dump[section_name][stat_name], 0)
303 elif bool(stat_type & LONG_RUNNING_AVG):
304 entries = dump[section_name][stat_name]['avgcount'] - \
305 last_dump[section_name][stat_name]['avgcount']
306 if entries:
307 n = (dump[section_name][stat_name]['sum'] -
308 last_dump[section_name][stat_name]['sum']) \
309 / float(entries)
310 n *= 1000.0 # Present in milliseconds
311 else:
312 n = 0
313 else:
314 n = dump[section_name][stat_name]
315
20effc67
TL
316 val_row += self.format_dimless(int(n),
317 self.col_width(stat_nick))
7c673cae
FG
318 val_row += " "
319 val_row = val_row[0:-1]
320 val_row += self.colorize("|", self.BLUE)
321 val_row = val_row[0:-len(self.colorize("|", self.BLUE))]
322 ostr.write("{0}\n".format(val_row))
323
20effc67 324 def _should_include(self, sect: str, name: str, prio: int) -> bool:
7c673cae
FG
325 '''
326 boolean: should we output this stat?
327
328 1) If self._statpats exists and the name filename-glob-matches
329 anything in the list, and prio is high enough, or
330 2) If self._statpats doesn't exist and prio is high enough
331
332 then yes.
333 '''
334 if self._statpats:
335 sectname = '.'.join((sect, name))
336 if not any([
337 p for p in self._statpats
338 if fnmatch(name, p) or fnmatch(sectname, p)
339 ]):
340 return False
341
342 if self._min_prio is not None and prio is not None:
343 return (prio >= self._min_prio)
344
345 return True
346
20effc67 347 def _load_schema(self) -> None:
7c673cae
FG
348 """
349 Populate our instance-local copy of the daemon's performance counter
350 schema, and work out which stats we will display.
351 """
352 self._schema = json.loads(
353 admin_socket(self.asok_path, ["perf", "schema"]).decode('utf-8'),
354 object_pairs_hook=OrderedDict)
355
356 # Build list of which stats we will display
357 self._stats = OrderedDict()
20effc67 358 assert self._schema is not None
7c673cae
FG
359 for section_name, section_stats in self._schema.items():
360 for name, schema_data in section_stats.items():
361 prio = schema_data.get('priority', 0)
362 if self._should_include(section_name, name, prio):
363 if section_name not in self._stats:
364 self._stats[section_name] = OrderedDict()
365 self._stats[section_name][name] = schema_data['nick']
366 if not len(self._stats):
367 raise RuntimeError("no stats selected by filters")
368
20effc67
TL
369 def _handle_sigwinch(self,
370 signo: Signals,
371 frame: FrameType) -> None:
7c673cae
FG
372 self.termsize.update()
373
20effc67
TL
374 def run(self,
375 interval: int,
376 count: Optional[int] = None,
377 ostr: TextIO = sys.stdout) -> None:
7c673cae
FG
378 """
379 Print output at regular intervals until interrupted.
380
381 :param ostr: Stream to which to send output
382 """
383
384 self._load_schema()
385 self._colored = self.supports_color(ostr)
386
387 self._print_headers(ostr)
388
389 last_dump = json.loads(admin_socket(self.asok_path, ["perf", "dump"]).decode('utf-8'))
390 rows_since_header = 0
391
392 try:
393 signal(SIGWINCH, self._handle_sigwinch)
394 while True:
395 dump = json.loads(admin_socket(self.asok_path, ["perf", "dump"]).decode('utf-8'))
396 if rows_since_header >= self.termsize.rows - 2:
397 self._print_headers(ostr)
398 rows_since_header = 0
399 self._print_vals(ostr, dump, last_dump)
400 if count is not None:
401 count -= 1
402 if count <= 0:
403 break
404 rows_since_header += 1
405 last_dump = dump
406
407 # time.sleep() is interrupted by SIGWINCH; avoid that
408 end = time.time() + interval
409 while time.time() < end:
410 time.sleep(end - time.time())
411
412 except KeyboardInterrupt:
413 return
414
20effc67 415 def list(self, ostr: TextIO = sys.stdout) -> None:
7c673cae
FG
416 """
417 Show all selected stats with section, full name, nick, and prio
418 """
419 table = PrettyTable(('section', 'name', 'nick', 'prio'))
420 table.align['section'] = 'l'
421 table.align['name'] = 'l'
422 table.align['nick'] = 'l'
423 table.align['prio'] = 'r'
424 self._load_schema()
20effc67
TL
425 assert self._stats is not None
426 assert self._schema is not None
7c673cae
FG
427 for section_name, section_stats in self._stats.items():
428 for name, nick in section_stats.items():
429 prio = self._schema[section_name][name].get('priority') or 0
430 table.add_row((section_name, name, nick, prio))
431 ostr.write(table.get_string(hrules=HEADER) + '\n')