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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Javier Collado (community) | Approve | ||
Review via email:
|
Commit message
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Max Brustkern (nuclearbob) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
>
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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.
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.
For the file being copied, making sure that `shutil.copyfile` has been
called would be just fine.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Andy Doan (doanac) wrote : | # |
I'm not that good with Mock, but I *think* this is what you were suggesting.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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
`TestMasterRu
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.
- 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.
- 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://
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Andy Doan (doanac) wrote : | # |
try it out now. I took your patch, but also added a new class as you suggested.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Javier Collado (javier.collado) wrote : | # |
Looks good now. Thanks for the update.
Preview Diff
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 |
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.