Merge ~kissiel/checkbox-support:import-watchers into checkbox-support:master
- Git
- lp:~kissiel/checkbox-support
- import-watchers
- Merge into master
Proposed by
Maciej Kisielewski
Status: | Merged |
---|---|
Approved by: | Maciej Kisielewski |
Approved revision: | 3201949269283f3e0997c96f76865eec6b4d989b |
Merged at revision: | 5e6b4d980001d0ae51021c9e30696c4abf9e4a43 |
Proposed branch: | ~kissiel/checkbox-support:import-watchers |
Merge into: | checkbox-support:master |
Diff against target: |
532 lines (+510/-0) 3 files modified
checkbox_support/log_watcher.py (+276/-0) checkbox_support/scripts/run_watcher.py (+231/-0) setup.py (+3/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jonathan Cave (community) | Approve | ||
Review via email: mp+313231@code.launchpad.net |
Commit message
Description of the change
This MR imports run_watcher that is used in usb tests to checkbox_support.
And removes x flag from the scripts, as it now should be available 'globally' after installing, or including in a snap, checkbox-support.
The entry point is checkbox-
To post a comment you must log in.
Revision history for this message
Paul Larson (pwlars) wrote : | # |
You may want to take a look at https:/
Revision history for this message
Maciej Kisielewski (kissiel) wrote : | # |
w00t, let's land it
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/checkbox_support/log_watcher.py b/checkbox_support/log_watcher.py |
2 | new file mode 100644 |
3 | index 0000000..4ae2da5 |
4 | --- /dev/null |
5 | +++ b/checkbox_support/log_watcher.py |
6 | @@ -0,0 +1,276 @@ |
7 | +#!/usr/bin/env python3 |
8 | +""" |
9 | +Real-time log files watcher supporting log rotation. |
10 | + |
11 | +Works with Python >= 2.6 and >= 3.2, on both POSIX and Windows. |
12 | + |
13 | + |
14 | +License: MIT |
15 | + |
16 | +Original work Copyright (c) Giampaolo Rodola' <g.rodola [AT] gmail [DOT] com> |
17 | +Modified work Copyright (c) 2015-2016: Taihsiang Ho <tai271828@gmail.com> |
18 | + |
19 | +Permission is hereby granted, free of charge, to any person obtaining a copy of |
20 | +this software and associated documentation files (the "Software"), to deal in |
21 | +the Software without restriction, including without limitation the rights to |
22 | +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies |
23 | +of the Software, and to permit persons to whom the Software is furnished to do |
24 | +so, subject to the following conditions: |
25 | + |
26 | +The above copyright notice and this permission notice shall be included in all |
27 | +copies or substantial portions of the Software. |
28 | + |
29 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
30 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
31 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
32 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
33 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
34 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
35 | +SOFTWARE. |
36 | +""" |
37 | +import os |
38 | +import time |
39 | +import errno |
40 | +import stat |
41 | + |
42 | + |
43 | +class LogWatcher(object): |
44 | + |
45 | + """ |
46 | + Looks for changes in all files of a directory. |
47 | + |
48 | + This is useful for watching log file changes in real-time. |
49 | + It also supports files rotation. |
50 | + |
51 | + Example: |
52 | + |
53 | + >>> def callback(filename, lines): |
54 | + ... print(filename, lines) |
55 | + ... |
56 | + >>> lw = LogWatcher("/var/log/", callback) |
57 | + >>> lw.loop() |
58 | + """ |
59 | + |
60 | + def __init__(self, folder: str, callback: 'Callable[[str], List[str]]', |
61 | + extensions: "List[str]"=None, logfile: str=None, |
62 | + tail_lines: int=0, |
63 | + sizehint: int=1048576): |
64 | + """ |
65 | + Initialize a new log watcher. |
66 | + |
67 | + :param folder: str |
68 | + the folder to watch |
69 | + |
70 | + :param callback: callback |
71 | + a function which is called every time one of the file being |
72 | + watched is updated; |
73 | + this is called with "filename" and "lines" arguments. |
74 | + |
75 | + :param extensions: list |
76 | + only watch files with these extensions |
77 | + |
78 | + :param logfile: str |
79 | + only watch this file. if this var exists, |
80 | + it will override extention list above. |
81 | + |
82 | + :param tail_lines: int |
83 | + read last N lines from files being watched before starting |
84 | + |
85 | + :param sizehint: int |
86 | + passed to file.readlines(), represents an |
87 | + approximation of the maximum number of bytes to read from |
88 | + a file on every ieration (as opposed to load the entire |
89 | + file in memory until EOF is reached). Defaults to 1MB. |
90 | + """ |
91 | + self.folder = os.path.realpath(folder) |
92 | + self.extensions = extensions |
93 | + self.logfile = logfile |
94 | + self._files_map = {} |
95 | + self._callback = callback |
96 | + self._sizehint = sizehint |
97 | + assert os.path.isdir(self.folder), self.folder |
98 | + assert callable(callback), repr(callback) |
99 | + self.update_files() |
100 | + for id, file in self._files_map.items(): |
101 | + file.seek(os.path.getsize(file.name)) # EOF |
102 | + if tail_lines: |
103 | + try: |
104 | + lines = self.tail(file.name, tail_lines) |
105 | + except IOError as err: |
106 | + if err.errno != errno.ENOENT: |
107 | + raise |
108 | + else: |
109 | + if lines: |
110 | + self._callback(file.name, lines) |
111 | + |
112 | + def __enter__(self): |
113 | + return self |
114 | + |
115 | + def __exit__(self, *args): |
116 | + self.close() |
117 | + |
118 | + def __del__(self): |
119 | + self.close() |
120 | + |
121 | + def loop(self, interval=0.1, blocking=True): |
122 | + """Start a busy loop checking for file changes every *interval* |
123 | + seconds. If *blocking* is False make one loop then return. |
124 | + """ |
125 | + # May be overridden in order to use pyinotify lib and block |
126 | + # until the directory being watched is updated. |
127 | + # Note that directly calling readlines() as we do is faster |
128 | + # than first checking file's last modification times. |
129 | + while True: |
130 | + self.update_files() |
131 | + for fid, file in list(self._files_map.items()): |
132 | + self.readlines(file) |
133 | + if not blocking: |
134 | + return |
135 | + time.sleep(interval) |
136 | + |
137 | + def log(self, line): |
138 | + """Log when a file is un/watched""" |
139 | + print(line) |
140 | + |
141 | + def listdir(self): |
142 | + """List directory and filter files by extension. |
143 | + You may want to override this to add extra logic or globbing |
144 | + support. |
145 | + """ |
146 | + ls = os.listdir(self.folder) |
147 | + if self.extensions: |
148 | + ls = [x for x in ls if os.path.splitext(x)[1][1:] |
149 | + in self.extensions] |
150 | + if self.logfile in ls: |
151 | + ls = [self.logfile] |
152 | + |
153 | + return ls |
154 | + |
155 | + @classmethod |
156 | + def open(cls, file): |
157 | + """Wrapper around open(). |
158 | + By default files are opened in binary mode and readlines() |
159 | + will return bytes on both Python 2 and 3. |
160 | + This means callback() will deal with a list of bytes. |
161 | + Can be overridden in order to deal with unicode strings |
162 | + instead, like this: |
163 | + |
164 | + import codecs, locale |
165 | + return codecs.open(file, 'r', encoding=locale.getpreferredencoding(), |
166 | + errors='ignore') |
167 | + """ |
168 | + return open(file, 'rb') |
169 | + |
170 | + @classmethod |
171 | + def tail(cls, fname, window): |
172 | + """Read last N lines from file fname.""" |
173 | + if window <= 0: |
174 | + raise ValueError('invalid window value %r' % window) |
175 | + with cls.open(fname) as f: |
176 | + buffer_size = 1024 |
177 | + # True if open() was overridden and file was opened in text |
178 | + # mode. In that case readlines() will return unicode strings |
179 | + # instead of bytes. |
180 | + encoded = getattr(f, 'encoding', False) |
181 | + CR = '\n' if encoded else b'\n' |
182 | + data = '' if encoded else b'' |
183 | + f.seek(0, os.SEEK_END) |
184 | + fsize = f.tell() |
185 | + block = -1 |
186 | + exit = False |
187 | + while not exit: |
188 | + step = (block * buffer_size) |
189 | + if abs(step) >= fsize: |
190 | + f.seek(0) |
191 | + newdata = f.read(buffer_size - (abs(step) - fsize)) |
192 | + exit = True |
193 | + else: |
194 | + f.seek(step, os.SEEK_END) |
195 | + newdata = f.read(buffer_size) |
196 | + data = newdata + data |
197 | + if data.count(CR) >= window: |
198 | + break |
199 | + else: |
200 | + block -= 1 |
201 | + return data.splitlines()[-window:] |
202 | + |
203 | + def update_files(self): |
204 | + ls = [] |
205 | + for name in self.listdir(): |
206 | + absname = os.path.realpath(os.path.join(self.folder, name)) |
207 | + try: |
208 | + st = os.stat(absname) |
209 | + except EnvironmentError as err: |
210 | + if err.errno != errno.ENOENT: |
211 | + raise |
212 | + else: |
213 | + if not stat.S_ISREG(st.st_mode): |
214 | + continue |
215 | + fid = self.get_file_id(st) |
216 | + ls.append((fid, absname)) |
217 | + |
218 | + # check existent files |
219 | + for fid, file in list(self._files_map.items()): |
220 | + try: |
221 | + st = os.stat(file.name) |
222 | + except EnvironmentError as err: |
223 | + if err.errno == errno.ENOENT: |
224 | + self.unwatch(file, fid) |
225 | + else: |
226 | + raise |
227 | + else: |
228 | + if fid != self.get_file_id(st): |
229 | + # same name but different file (rotation); reload it. |
230 | + self.unwatch(file, fid) |
231 | + self.watch(file.name) |
232 | + |
233 | + # add new ones |
234 | + for fid, fname in ls: |
235 | + if fid not in self._files_map: |
236 | + self.watch(fname) |
237 | + |
238 | + def readlines(self, file): |
239 | + """ |
240 | + Read file lines. |
241 | + |
242 | + Since last access until EOF is reached and invoke callback. |
243 | + """ |
244 | + while True: |
245 | + lines = file.readlines(self._sizehint) |
246 | + if not lines: |
247 | + break |
248 | + self._callback(file.name, lines) |
249 | + |
250 | + def watch(self, fname): |
251 | + try: |
252 | + file = self.open(fname) |
253 | + fid = self.get_file_id(os.stat(fname)) |
254 | + except EnvironmentError as err: |
255 | + if err.errno != errno.ENOENT: |
256 | + raise |
257 | + else: |
258 | + self.log("watching logfile %s" % fname) |
259 | + self._files_map[fid] = file |
260 | + |
261 | + def unwatch(self, file, fid): |
262 | + # File no longer exists. If it has been renamed try to read it |
263 | + # for the last time in case we're dealing with a rotating log |
264 | + # file. |
265 | + self.log("un-watching logfile %s" % file.name) |
266 | + del self._files_map[fid] |
267 | + with file: |
268 | + lines = self.readlines(file) |
269 | + if lines: |
270 | + self._callback(file.name, lines) |
271 | + |
272 | + @staticmethod |
273 | + def get_file_id(st): |
274 | + if os.name == 'posix': |
275 | + return "%xg%x" % (st.st_dev, st.st_ino) |
276 | + else: |
277 | + return "%f" % st.st_ctime |
278 | + |
279 | + def close(self): |
280 | + for id, file in self._files_map.items(): |
281 | + file.close() |
282 | + self._files_map.clear() |
283 | diff --git a/checkbox_support/scripts/run_watcher.py b/checkbox_support/scripts/run_watcher.py |
284 | new file mode 100644 |
285 | index 0000000..50f2db1 |
286 | --- /dev/null |
287 | +++ b/checkbox_support/scripts/run_watcher.py |
288 | @@ -0,0 +1,231 @@ |
289 | +#!/usr/bin/env python3 |
290 | +# Copyright 2015-2016 Canonical Ltd. |
291 | +# All rights reserved. |
292 | +# |
293 | +# Written by: |
294 | +# Taihsiang Ho <taihsiang.ho@canonical.com> |
295 | +""" |
296 | +application to use LogWatcher. |
297 | + |
298 | +this script use LogWatcher to define the actual behavior to watch log files by |
299 | +a customized callback. |
300 | +""" |
301 | +import argparse |
302 | +import contextlib |
303 | +import sys |
304 | +import os |
305 | +import re |
306 | +import signal |
307 | +import logging |
308 | +from checkbox_support.log_watcher import LogWatcher |
309 | + |
310 | +global ARGS |
311 | +USB_INSERT_TIMEOUT = 30 # sec |
312 | + |
313 | +# {log_string_1:status_1, log_string_2:status_2 ...} |
314 | +FLAG_DETECTION = {"device": { |
315 | + "new high-speed USB device number": False, |
316 | + "new SuperSpeed USB device number": False |
317 | + }, |
318 | + "driver": { |
319 | + "using ehci_hcd": False, |
320 | + "using xhci_hcd": False |
321 | + }, |
322 | + "insertion": { |
323 | + "USB Mass Storage device detected": False |
324 | + }, |
325 | + "removal": { |
326 | + "USB disconnect, device number": False |
327 | + } |
328 | + } |
329 | + |
330 | + |
331 | +MOUNTED_PARTITION_CANDIDATES = None |
332 | +MOUNTED_PARTITION = None |
333 | + |
334 | +logging.basicConfig(level=logging.WARNING) |
335 | + |
336 | + |
337 | +###################################################### |
338 | +# run the log watcher |
339 | +###################################################### |
340 | + |
341 | + |
342 | +def callback(filename, lines): |
343 | + """ |
344 | + a callback function for LogWatcher. |
345 | + |
346 | + customized callback to define the actual behavior about how to watch and |
347 | + what to watch of the log files. |
348 | + |
349 | + :param filename: str, a text filename. usually be a log file. |
350 | + :param lines: list, contents the elements as string to tell what to watch. |
351 | + """ |
352 | + for line in lines: |
353 | + line_str = str(line) |
354 | + refresh_detection(line_str) |
355 | + get_partition_info(line_str) |
356 | + report_detection() |
357 | + |
358 | + |
359 | +def detect_str(line, str_2_detect): |
360 | + """detect the string in the line.""" |
361 | + if str_2_detect in line: |
362 | + return True |
363 | + return False |
364 | + |
365 | + |
366 | +def detect_partition(line): |
367 | + """ |
368 | + detect device and partition info from lines. |
369 | + |
370 | + :param line: |
371 | + str, line string from log file |
372 | + |
373 | + :return : |
374 | + a list denoting [device, partition1, partition2 ...] |
375 | + from syslog |
376 | + """ |
377 | + # looking for string like |
378 | + # sdb: sdb1 |
379 | + pattern = "sd.+sd.+" |
380 | + match = re.search(pattern, line) |
381 | + if match: |
382 | + # remove the trailing \n and quote |
383 | + match_string = match.group()[:-3] |
384 | + # will looks like |
385 | + # ['sdb', ' sdb1'] |
386 | + match_list = match_string.split(":") |
387 | + return match_list |
388 | + |
389 | + |
390 | +def get_partition_info(line_str): |
391 | + """get partition info.""" |
392 | + global MOUNTED_PARTITION_CANDIDATES, MOUNTED_PARTITION |
393 | + MOUNTED_PARTITION_CANDIDIATES = detect_partition(line_str) |
394 | + if (MOUNTED_PARTITION_CANDIDIATES and |
395 | + len(MOUNTED_PARTITION_CANDIDIATES) == 2): |
396 | + # hard code because I expect |
397 | + # FLAG_MOUNT_DEVICE_CANDIDIATES is something like ['sdb', ' sdb1'] |
398 | + # This should be smarter if the device has multiple partitions. |
399 | + MOUNTED_PARTITION = MOUNTED_PARTITION_CANDIDIATES[1].strip() |
400 | + |
401 | + |
402 | +def refresh_detection(line_str): |
403 | + """ |
404 | + refresh values of the dictionary FLAG_DETECTION. |
405 | + |
406 | + :param line_str: str of the scanned log lines. |
407 | + """ |
408 | + global FLAG_DETECTION |
409 | + for key in FLAG_DETECTION.keys(): |
410 | + for sub_key in FLAG_DETECTION[key].keys(): |
411 | + if detect_str(line_str, sub_key): |
412 | + FLAG_DETECTION[key][sub_key] = True |
413 | + |
414 | + |
415 | +def report_detection(): |
416 | + """report detection status.""" |
417 | + # insertion detection |
418 | + if ( |
419 | + ARGS.testcase == "insertion" and |
420 | + FLAG_DETECTION["insertion"]["USB Mass Storage device detected"] and |
421 | + MOUNTED_PARTITION |
422 | + ): |
423 | + device = "" |
424 | + driver = "" |
425 | + for key in FLAG_DETECTION["device"]: |
426 | + if FLAG_DETECTION["device"][key]: |
427 | + device = key |
428 | + for key in FLAG_DETECTION["driver"]: |
429 | + if FLAG_DETECTION["driver"][key]: |
430 | + driver = key |
431 | + logging.info("%s was inserted %s controller" % (device, driver)) |
432 | + logging.info("usable partition: %s" % MOUNTED_PARTITION) |
433 | + |
434 | + # judge the detection by the expection |
435 | + if ( |
436 | + ARGS.usb_type == 'usb2' and |
437 | + device == "new high-speed USB device number" |
438 | + ): |
439 | + print("USB2 insertion test passed.") |
440 | + write_usb_info() |
441 | + sys.exit() |
442 | + if ( |
443 | + ARGS.usb_type == 'usb3' and |
444 | + device == "new SuperSpeed USB device number" |
445 | + ): |
446 | + print("USB3 insertion test passed.") |
447 | + write_usb_info() |
448 | + sys.exit() |
449 | + |
450 | + # removal detection |
451 | + if ( |
452 | + ARGS.testcase == "removal" and |
453 | + FLAG_DETECTION["removal"]["USB disconnect, device number"] |
454 | + ): |
455 | + logging.info("An USB mass storage was removed.") |
456 | + remove_usb_info() |
457 | + sys.exit() |
458 | + |
459 | + |
460 | +def write_usb_info(): |
461 | + """ |
462 | + reserve detected usb storage info. |
463 | + |
464 | + write the info we got in this script to $PLAINBOX_SESSION_SHARE |
465 | + so the other jobs, e.g. read/write test, could know more information, |
466 | + for example the partition it want to try to mount. |
467 | + """ |
468 | + plainbox_session_share = os.environ.get('PLAINBOX_SESSION_SHARE') |
469 | + if not plainbox_session_share: |
470 | + logging.warning("no env var PLAINBOX_SESSION_SHARE") |
471 | + sys.exit(1) |
472 | + |
473 | + if MOUNTED_PARTITION: |
474 | + logging.info( |
475 | + "cache file usb_insert_info is at: %s" % plainbox_session_share) |
476 | + file_to_share = open( |
477 | + os.path.join(plainbox_session_share, "usb_insert_info"), "w") |
478 | + file_to_share.write(MOUNTED_PARTITION + "\n") |
479 | + file_to_share.close() |
480 | + |
481 | + |
482 | +def remove_usb_info(): |
483 | + """remove usb strage info from $PLAINBOX_SESSION_SHARE.""" |
484 | + plainbox_session_share = os.environ.get('PLAINBOX_SESSION_SHARE') |
485 | + if not plainbox_session_share: |
486 | + logging.warning("no env var PLAINBOX_SESSION_SHARE") |
487 | + sys.exit(1) |
488 | + file_to_share = os.path.join(plainbox_session_share, "usb_insert_info") |
489 | + with contextlib.suppress(FileNotFoundError): |
490 | + os.remove(file_to_share) |
491 | + |
492 | + |
493 | +def no_usb_timeout(signum, frame): |
494 | + """ |
495 | + define timeout feature. |
496 | + |
497 | + timeout and return failure if there is no usb insertion is detected |
498 | + after USB_INSERT_TIMEOUT secs |
499 | + """ |
500 | + logging.info("no USB storage insertion was detected from /var/log/syslog") |
501 | + sys.exit(1) |
502 | + |
503 | +def main(): |
504 | + # access the parser |
505 | + parser = argparse.ArgumentParser() |
506 | + parser.add_argument('testcase', |
507 | + choices=['insertion', 'removal'], |
508 | + help=("insertion or removal")) |
509 | + parser.add_argument('usb_type', |
510 | + choices=['usb2', 'usb3'], |
511 | + help=("usb2 or usb3")) |
512 | + global ARGS |
513 | + ARGS = parser.parse_args() |
514 | + |
515 | + # set up the log watcher |
516 | + watcher = LogWatcher("/var/log", callback, logfile="syslog") |
517 | + signal.signal(signal.SIGALRM, no_usb_timeout) |
518 | + signal.alarm(USB_INSERT_TIMEOUT) |
519 | + watcher.loop() |
520 | diff --git a/setup.py b/setup.py |
521 | index e835917..00f655e 100755 |
522 | --- a/setup.py |
523 | +++ b/setup.py |
524 | @@ -75,5 +75,8 @@ setup( |
525 | ("bto=checkbox_support.parsers.image_info:parse_bto_attachment" |
526 | "_output"), |
527 | ], |
528 | + 'console_scripts': [ |
529 | + "checkbox-support-run_watcher=checkbox_support.scripts.run_watcher:main", |
530 | + ], |
531 | }, |
532 | ) |
Yes please, stops us needing to import it in to each provider that wants to define its own usb tests.