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