Merge lp:~doanac/utah/run-cleanups into lp:utah

Proposed by Andy Doan
Status: Merged
Approved by: Javier Collado
Approved revision: 877
Merged at revision: 874
Proposed branch: lp:~doanac/utah/run-cleanups
Merge into: lp:utah
Diff against target: 370 lines (+201/-105)
2 files modified
tests/test_run.py (+87/-2)
utah/run.py (+114/-103)
To merge this branch: bzr merge lp:~doanac/utah/run-cleanups
Reviewer Review Type Date Requested Status
Javier Collado (community) Approve
Review via email: mp+159879@code.launchpad.net

Description of the change

this cleans up the _run function of run.py into something easier to manage.

I've done testing on acer-veriton-04 and vm's at home.

To post a comment you must log in.
Revision history for this message
Max Brustkern (nuclearbob) wrote :

I'm wondering if any of what's currently in the various scripts should be in run.py instead?

I say that because I just started working on a branch to eliminate all but one of those. Maybe we should try to land this first, and then move any extra functionality into run.py. I don't like that we're passing args in; I feel like there should be some more specific and defined thing that gets handed to run.py, and all the actual command line handling should be maybe in the script. Either that or basically everything should be in run.py, and the script should just be handling picking an inventory and getting a machine? Not sure of the best approach for that, and we can just leave it as is for now if nothing else is compelling.

Revision history for this message
Andy Doan (doanac) wrote :

On 04/19/2013 02:48 PM, Max Brustkern wrote:
> I'm wondering if any of what's currently in the various scripts
> should be in run.py instead?

probably

> I say that because I just started working on a branch to eliminate
> all but one of those. Maybe we should try to land this first, and
> then move any extra functionality into run.py.

+1

> I don't like that
> we're passing args in; I feel like there should be some more specific
> and defined thing that gets handed to run.py, and all the actual
> command line handling should be maybe in the script.

I agree. Although there are soooooo many options we might wind up
passing a new object that looks just like "args". I wasn't sure if that
type of cleanup made sense in the scope of these changes or not. What do
you think?

> Either that or
> basically everything should be in run.py, and the script should just
> be handling picking an inventory and getting a machine? Not sure of
> the best approach for that, and we can just leave it as is for now if
> nothing else is compelling.
>

Revision history for this message
Max Brustkern (nuclearbob) wrote :

I think this is a good first step, and actually, I can probably go ahead and collapse the scripts into one in parallel with this. After that, we'll having one script calling functions from one file, and it'll maybe be easier to sort out which should be where from a design perspective rather than the current method of convenience and accretion.

Revision history for this message
Javier Collado (javier.collado) wrote :

The code looks good. A few comments regarding the test cases:

- tests/test_run.py

  I think `MockMachine` isn't really needed. In particular, I think the
  `downloadfiles` method shouldn't copy any file. Please, see below for more
  about this.

  - _files_equal:
    As explained above, I think files shouldn't be copied. Anyway, it that part
    isn't removed, it probably makes sense to take a look at `filecmp.cmp` in
    the standard library.

  - test__write:
    What about checking just that `machine.downloadfiles` has been called?
    Otherwise, it looks like isn't just the `_write` function what is being
    tested.

  - test_copy_preseed:
    For the invalid logpath part, it would be nice to check if
    `logging.warning` has been called.

    For the file being copied, making sure that `shutil.copyfile` has been
    called would be just fine.

lp:~doanac/utah/run-cleanups updated
876. By Andy Doan

clean up test_run as per javier's suggestions

Revision history for this message
Andy Doan (doanac) wrote :

I'm not that good with Mock, but I *think* this is what you were suggesting.

Revision history for this message
Javier Collado (javier.collado) wrote :

The update looks good to me.

A few more comments:

- All the test cases in `test_run.py` are inside the
  `TestMasterRunlistArgument` class. I think that they should be splitted into
  different classes to make sure it's clear what's the purpose of each set of
  test cases.

- There's no need to call configure mock for just one attribute, it can set
  directly:

   machine.configure_mock(name='name') => machine.name = 'name'

- If you think it's valuable you can get the arguments that were used to call
  the mock objects to test more specific things like:
  - machine.donwload_files was called with the same path that was added to logs
  - logging.warning message matches the expected pattern (i.e. failed to copy
    preseed file)

  I've pasted a diff with some of those things here just in case:
  http://pastebin.ubuntu.com/5595348/

lp:~doanac/utah/run-cleanups updated
877. By Andy Doan

patch from javier

plus I broke up my new tests into its own class TestRun

Revision history for this message
Andy Doan (doanac) wrote :

try it out now. I took your patch, but also added a new class as you suggested.

Revision history for this message
Javier Collado (javier.collado) wrote :

Looks good now. Thanks for the update.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'tests/test_run.py'
2--- tests/test_run.py 2013-04-16 15:22:20 +0000
3+++ tests/test_run.py 2013-04-23 17:20:34 +0000
4@@ -15,22 +15,45 @@
5
6 """Test command line argument functions."""
7
8+import shutil
9+import tempfile
10 import unittest
11+import urllib
12
13 from argparse import ArgumentTypeError
14 from jsonschema import ValidationError
15-from mock import patch
16+from mock import patch, Mock
17
18+from utah import config
19 from utah.client.exceptions import (
20 YAMLEmptyFile,
21 YAMLParsingError,
22 )
23-from utah.run import master_runlist_argument
24+from utah.exceptions import (
25+ UTAHException,
26+)
27+from utah.run import (
28+ _copy_preseed,
29+ _get_runlist,
30+ _write,
31+ master_runlist_argument,
32+)
33
34
35 class TestMasterRunlistArgument(unittest.TestCase):
36+
37 """Test master_runlist_argument function."""
38
39+ def setUp(self):
40+ """Peform temporary setup required by tests."""
41+ self.logpath = config.logpath
42+ config.logpath = tempfile.mkdtemp(prefix='utah_run')
43+
44+ def tearDown(self):
45+ """Undo temporary stuff from setUp."""
46+ shutil.rmtree(config.logpath)
47+ config.logpath = self.logpath
48+
49 @patch('utah.run.parse_yaml_file')
50 @patch('utah.run.url_argument')
51 def test_empty_file(self, url_argument, parse_yaml_file):
52@@ -68,3 +91,65 @@
53 url_argument.side_effect = [expected_filename]
54 filename = master_runlist_argument('url')
55 self.assertEqual(filename, expected_filename)
56+
57+
58+class TestRun(unittest.TestCase):
59+
60+ """Unit tests on utility functions in run.py."""
61+
62+ @patch('urllib.urlretrieve')
63+ def test_get_runlist(self, urlretrieve):
64+ """Ensure _get_runlist behaves properly on errors."""
65+
66+ def _error(url):
67+ # the "url" passed is the exception class we want to throw
68+ raise url('blah', None)
69+
70+ urlretrieve.side_effect = _error
71+ pattern = 'IOError when downloading'
72+ self.assertRaisesRegexp(UTAHException, pattern, _get_runlist, IOError)
73+
74+ pattern = 'Error when downloading'
75+ self.assertRaisesRegexp(
76+ UTAHException, pattern, _get_runlist, urllib.ContentTooShortError)
77+
78+ def test__write(self):
79+ """Ensure proper behavior of the _write function."""
80+ logs = []
81+ args = Mock()
82+ args.configure_mock(
83+ report_type='yaml', runlist='tmp.txt', dumplogs=True)
84+ machine = Mock()
85+ machine.name = 'name'
86+ report_path = '/path/to/report'
87+ _write(machine, args, logs, report_path)
88+ self.assertTrue(machine.downloadfiles.called)
89+ call_args, _call_kwargs = machine.downloadfiles.call_args
90+ self.assertListEqual([call_args[1]], logs)
91+ self.assertEqual(call_args[0], report_path)
92+
93+ @patch('logging.warning')
94+ def test_copy_preseed_bad(self, warning):
95+ """Ensure _copy_preseed works for bad data."""
96+ logs = []
97+ args = Mock()
98+ args.outputpreseed = True
99+
100+ machine = Mock()
101+ machine.configure_mock(name='name', finalpreseed='/bad file name')
102+ _copy_preseed(machine, machine, logs)
103+ self.assertEqual(0, len(logs))
104+ self.assertTrue(warning.called)
105+ call_args, _call_kwargs = warning.call_args
106+ self.assertRegexpMatches(call_args[0],
107+ r'Failed to copy preseed file: %s')
108+
109+ @patch('shutil.copyfile')
110+ def test_copy_preseed_good(self, copyfile):
111+ """Ensure _copy_preseed works for good data."""
112+ logs = []
113+ machine = Mock()
114+ machine.configure_mock(name='name', finalpreseed='/bin/true')
115+ _copy_preseed(machine, machine, logs)
116+ self.assertEqual(1, len(logs))
117+ self.assertTrue(copyfile.called)
118
119=== modified file 'utah/run.py'
120--- utah/run.py 2013-04-18 14:11:57 +0000
121+++ utah/run.py 2013-04-23 17:20:34 +0000
122@@ -238,6 +238,114 @@
123 return parser
124
125
126+def _get_runlist(url):
127+ try:
128+ return urllib.urlretrieve(url)[0]
129+ except urllib.ContentTooShortError as e:
130+ err = ('Error when downloading {} (probably interrupted): {}'
131+ .format(url, e))
132+ except IOError as e:
133+ err = 'IOError when downloading {}: {}'.format(url, e)
134+
135+ raise UTAHException(err, external=True)
136+
137+
138+def _run(machine, runlist, extraopts):
139+ """Execute a single runlist on the machine.
140+
141+ :returns: a tuple (exit_status, report_remote_path)
142+
143+ """
144+ try:
145+ runlist_local_path = _get_runlist(runlist)
146+ target_dir = '/tmp'
147+ machine.uploadfiles([runlist_local_path], target_dir)
148+
149+ runlist_file = os.path.basename(runlist_local_path)
150+ runlist_remote_path = os.path.join(target_dir, runlist_file)
151+ report_remote_path = '/var/lib/utah/utah.out'
152+
153+ options = '-r {} -o {}'.format(runlist_remote_path, report_remote_path)
154+ utah_command = 'utah {} {}'.format(extraopts, options)
155+
156+ exitstatus, _stdout, _stderr = machine.run(utah_command, root=True)
157+ # Check state file to make sure client finished
158+ # when some reboot commands where executed during the test run
159+ if exitstatus == ClientReturnCodes.REBOOT:
160+ logging.info('System under test is rebooting')
161+ exitstatus = timeout(config.runtimeout, retry,
162+ is_utah_done, machine, 180,
163+ logmethod=logging.info)
164+ # Make sure that it's possible to know when the server failed
165+ # and when the client failed just checking the return code
166+ if exitstatus != 0:
167+ exitstatus = ReturnCodes.client_error(exitstatus)
168+ except UTAHException as error:
169+ logging.error('Failed to run test: %s', str(error))
170+ exitstatus = ReturnCodes.UTAH_EXCEPTION_ERROR
171+
172+ return exitstatus, report_remote_path
173+
174+
175+def _write(machine, args, locallogs, report_remote_path):
176+ """Write runlist report.
177+
178+ Report will be written using appropriate filename based on the runlist.
179+
180+ :param machine: Path to the runlist file
181+ :param args: options from argparse
182+ :param locallogs: array of files resulting from this job
183+ :param report_remote_path:
184+ Path to the report file in the machine being tested
185+
186+ """
187+ timestamp = time.strftime('%Y-%m-%d_%H-%m-%S', time.gmtime())
188+
189+ listname = os.path.basename(args.runlist)
190+ log_filename = ('{machine}_{runlist}_{timestamp}.{suffix}'
191+ .format(machine=machine.name,
192+ runlist=listname,
193+ timestamp=timestamp,
194+ suffix=args.report_type))
195+ report_local_path = os.path.join(config.logpath, log_filename)
196+ report_local_path = os.path.normpath(report_local_path)
197+ try:
198+ machine.downloadfiles(report_remote_path, report_local_path)
199+ except UTAHException:
200+ logging.error('Test log not retrieved')
201+ else:
202+ locallogs.append(report_local_path)
203+ logging.info('Test log copied to %s', report_local_path)
204+ if args.dumplogs:
205+ try:
206+ with open(report_local_path, 'r') as f:
207+ print(f.read())
208+ except IOError as err:
209+ logging.warning('Test log could not be dumped: {}'
210+ .format(err))
211+
212+
213+def _copy_preseed(machine, args, locallogs):
214+ """Copy preseed to locallogs.
215+
216+ If we are provisioning a system, we can optionally copy its preseed along
217+ with other locallogs from the job. Systems already provisioned will not
218+
219+ """
220+ if (not isinstance(machine, ProvisionedMachine) and
221+ (args.outputpreseed or config.outputpreseed)):
222+ if args.outputpreseed:
223+ logging.debug('Capturing preseed due to command line option')
224+
225+ p = os.path.join(config.logpath, '{}-preseed.cfg'.format(machine.name))
226+ try:
227+ shutil.copyfile(machine.finalpreseed, p)
228+ except (IOError, shutil.Error) as err:
229+ logging.warning('Failed to copy preseed file: %s', err)
230+ else:
231+ locallogs.append(p)
232+
233+
234 def run_tests(args, machine):
235 """Run a runlist and retrieve results.
236
237@@ -247,104 +355,23 @@
238 """
239 install_sigterm_handler()
240
241+ args.report_type = 'yaml'
242 if args.json:
243- report_type = 'json'
244- else:
245- report_type = 'yaml'
246-
247- def _run(runlist):
248- """Run a single runlist."""
249- try:
250- try:
251- runlist_local_path = urllib.urlretrieve(runlist)[0]
252- except IOError as err:
253- raise UTAHException(
254- 'IOError when downloading {}: {}'
255- .format(runlist, err), external=True)
256- except urllib.ContentTooShortError as err:
257- raise UTAHException(
258- 'Error when downloading {} (probably interrupted): {}'
259- .format(runlist, err), external=True)
260- target_dir = os.path.normpath('/tmp')
261- machine.uploadfiles([runlist_local_path], target_dir)
262-
263- runlist_file = os.path.basename(runlist_local_path)
264- runlist_remote_path = os.path.join(target_dir, runlist_file)
265- report_remote_path = os.path.join('/', 'var', 'lib', 'utah',
266- 'utah.out')
267- options = '-r {} -o {}'.format(runlist_remote_path,
268- report_remote_path)
269- utah_command = 'utah {} {}'.format(extraopts, options)
270-
271- exitstatus, _stdout, _stderr = machine.run(utah_command, root=True)
272- # Check state file to make sure client finished
273- # when some reboot commands where executed during the test run
274- if exitstatus == ClientReturnCodes.REBOOT:
275- logging.info('System under test is rebooting')
276- exitstatus = timeout(config.runtimeout, retry,
277- is_utah_done, machine, 180,
278- logmethod=logging.info)
279- # Make sure that it's possible to know when the server failed
280- # and when the client failed just checking the return code
281- if exitstatus != 0:
282- exitstatus = ReturnCodes.client_error(exitstatus)
283- except UTAHException as error:
284- logging.error('Failed to run test: %s', str(error))
285- exitstatus = ReturnCodes.UTAH_EXCEPTION_ERROR
286- else:
287- _write(runlist, report_remote_path)
288-
289- return exitstatus
290-
291- def _write(runlist, report_remote_path):
292- """Write runlist report.
293-
294- Report will be written using appropriate filename based on the runlist.
295-
296- :param runlist: Path to the runlist file
297- :type runlist: str
298- :param report_remote_path:
299- Path to the report file in the machine being tested
300- :type report: str
301-
302- """
303- timestamp = time.strftime('%Y-%m-%d_%H-%m-%S', time.gmtime())
304-
305- listname = os.path.basename(runlist)
306- log_filename = ('{machine}_{runlist}_{timestamp}.{suffix}'
307- .format(machine=machine.name,
308- runlist=listname,
309- timestamp=timestamp,
310- suffix=report_type))
311- report_local_path = os.path.join(config.logpath, log_filename)
312- report_local_path = os.path.normpath(report_local_path)
313- try:
314- machine.downloadfiles(report_remote_path, report_local_path)
315- except UTAHException:
316- logging.error('Test log not retrieved')
317- else:
318- logging.info('Test log copied to %s', report_local_path)
319- locallogs.append(report_local_path)
320- if args.dumplogs:
321- try:
322- with open(report_local_path, 'r') as f:
323- print(f.read())
324- except IOError as err:
325- logging.warning('Test log could not be dumped: {}'
326- .format(err))
327+ args.report_type = 'json'
328
329 # Write the machine name to standard out for log gathering
330 print('Running on machine: {}'.format(machine.name))
331
332 extraopts = ('-f {} --install-type {}'
333- .format(report_type, machine.installtype))
334+ .format(args.report_type, machine.installtype))
335
336 locallogs = []
337 machine.installclient()
338
339 # Server will return success code only if the execution
340 # of every runlist was successful
341- exitstatus = _run(args.runlist)
342+ exitstatus, remote_path = _run(machine, args.runlist, extraopts)
343+ _write(machine, args, locallogs, remote_path)
344
345 if args.files is not None:
346 try:
347@@ -352,23 +379,7 @@
348 except UTAHException as err:
349 logging.warning('Failed to download files: %s', str(err))
350
351- # Provisioned systems have an image already installed
352- # and the preseed file is no longer available
353- if (not isinstance(machine, ProvisionedMachine) and
354- (args.outputpreseed or config.outputpreseed)):
355- if args.outputpreseed:
356- logging.debug('Capturing preseed due to command line option')
357- elif config.outputpreseed:
358- logging.debug('Capturing preseed due to config option')
359- preseedfile = os.path.join(config.logpath,
360- '{}-preseed.cfg'.format(machine.name))
361- try:
362- shutil.copyfile(machine.finalpreseed, preseedfile)
363- except (IOError, shutil.Error) as err:
364- logging.warning('Failed to copy preseed file: {}'
365- .format(err))
366- else:
367- locallogs.append(preseedfile)
368+ _copy_preseed(machine, args, locallogs)
369
370 return exitstatus, locallogs
371

Subscribers

People subscribed via source and target branches

to all changes: