Merge ~kissiel/checkbox-support:import-watchers into checkbox-support: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)
Reviewer Review Type Date Requested Status
Jonathan Cave (community) Approve
Review via email: mp+313231@code.launchpad.net

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-support-run_watcher - I left the underscore, and I'm treating checkbox-support part as a prefix.

To post a comment you must log in.
Revision history for this message
Jonathan Cave (jocave) wrote :

Yes please, stops us needing to import it in to each provider that wants to define its own usb tests.

review: Approve
Revision history for this message
Paul Larson (pwlars) wrote :

You may want to take a look at https://pypi.python.org/pypi/inotify but don't use pyinotify

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
1diff --git a/checkbox_support/log_watcher.py b/checkbox_support/log_watcher.py
2new file mode 100644
3index 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()
283diff --git a/checkbox_support/scripts/run_watcher.py b/checkbox_support/scripts/run_watcher.py
284new file mode 100644
285index 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()
520diff --git a/setup.py b/setup.py
521index 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 )

Subscribers

People subscribed via source and target branches