]>
Commit | Line | Data |
---|---|---|
7c673cae FG |
1 | # -*- mode:python -*- |
2 | # vim: ts=4 sw=4 smarttab expandtab | |
3 | ||
4 | """ | |
5 | Copyright (C) 2015 Red Hat | |
6 | ||
7 | This is free software; you can redistribute it and/or | |
8 | modify it under the terms of the GNU General Public | |
9 | License version 2, as published by the Free Software | |
10 | Foundation. See file COPYING. | |
11 | """ | |
12 | ||
13 | import sys | |
14 | import json | |
15 | import socket | |
16 | import struct | |
17 | import time | |
20effc67 | 18 | from collections import OrderedDict |
7c673cae FG |
19 | from fcntl import ioctl |
20 | from fnmatch import fnmatch | |
21 | from prettytable import PrettyTable, HEADER | |
20effc67 | 22 | from signal import signal, Signals, SIGWINCH |
7c673cae | 23 | from termios import TIOCGWINSZ |
20effc67 TL |
24 | from types import FrameType |
25 | from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple | |
7c673cae FG |
26 | |
27 | from ceph_argparse import parse_json_funcsigs, validate_command | |
28 | ||
29 | COUNTER = 0x8 | |
30 | LONG_RUNNING_AVG = 0x4 | |
31 | READ_CHUNK_SIZE = 4096 | |
32 | ||
33 | ||
f67539c2 | 34 | def 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 | 91 | class 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 | ||
125 | class 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') |