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