Merge lp:~abentley/juju-ci-tools/industrial-test into lp:juju-ci-tools
- industrial-test
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 702 |
Proposed branch: | lp:~abentley/juju-ci-tools/industrial-test |
Merge into: | lp:juju-ci-tools |
Diff against target: |
431 lines (+411/-0) 3 files modified
industrial_test.py (+128/-0) jujupy.py (+18/-0) test_industrial_test.py (+265/-0) |
To merge this branch: | bzr merge lp:~abentley/juju-ci-tools/industrial-test |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Curtis Hovey (community) | code | Approve | |
Review via email: mp+238621@code.launchpad.net |
Commit message
Initial version of industrial testing.
Description of the change
This branch provides a minimal implementation of IndustrialTest.
The industrial_test.py script runs a bootstrap and destroy-environment using both the system juju and a specified juju. It accepts an existing environment name and appends -old and -new to it.
IndustrialTest provides run_attempt which returns an iterator of (boolean, boolean) to indicate whether a stage was run successfully by each client. Iteration stops when either client fails, and the environment is cleaned up. The last stage is assumed to be DestroyEnvironment, so if all stages succeed, no futher cleanup is performed.
Success and failure are implementation defined, but StageAttempt provides helpers. If a StageAttempt subclass provides _operation and _result, StageAttemp will treat exceptions from _operation as a failure of that client, and exceptions from _result will be translated into failure as well.
BootstrapAttempt uses the relatively-new temp_bootstrap_env contextmanager to bootstrap into its environment.
DestroyEnvironment is only considered to fail if the destroy-environment command has a non-zero exit status.
For local testing, uniquify_local ensures that the ports do not conflict between the two environments.
Future updates will flesh out the result statistic handling, support multiple runs, and add new kinds of stages.
Aaron Bentley (abentley) wrote : | # |
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1
On 14-10-17 09:47 AM, Curtis Hovey wrote:
>> + def run_stages(self): + for attempt in
>> self.stage_
>> attempt.
>> + if False in result: + try: +
>> self.old_
>
> Sorry. I don't understand this. If one stage fails, we destroy
> both?, not the one we know that failed?
Yes. We want a side-by-side comparison of old_client vs new_client,
to minimize the chance that substrate-side errors are counted as
problems with one client, but not the other.
If we are doing a side-by-side comparison, a failure in either (or
both) clients means we must stop both clients, because it ceases to be
a side-by-side comparison if one client cannot continue.
I haven't implemented the part that re-runs an industrial test until
every stage has been attempted sufficient times, but when I do, a
failure in either client will cause us to re-run the industrial test,
in order to test the stages following the stage that failed. So even
if we weren't doing side-by-side comparisons, there is no advantage in
continuing to run one client after the other fails.
The exception to the rule that failures require a re-run is, of
course, if the failure occurs in the final stage.
> Doesn't this code swallow an error if the old_client errors?
It's a finally block, so it doesn't swallow exceptions by default.
But if new_client.
old_client.
While the exception for old_client can be suppressed, the juju error
messages for each "juju destroy-
suppressed, and the exception from destroy_environment is not very
interesting (it just tells you which non-zero exit status was used).
>> + def test_destroy_
>> old_client = FakeEnvJujuClie
>> FakeEnvJujuClie
>> IndustrialTest(
>> False), FakeAttempt(True, True)]) + attempt =
>> industrial.
>> 'destroy_
>> with patch.object(
>> side_effect=
>> self.assertRais
>
> Sorry, I don't know which exception is raised? Do we expect both?
We expect the new_client exception. I don't know of a way of raising
two exceptions simultaneously, so I don't know how we could get both.
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1
iQEcBAEBAgAGBQJ
sZw7nDB4Eoe94QG
Bk4PW4QWr/
wu4qrNjg7mR42Nn
7ZpjnnF4IumJ5EO
/uKjNN+
=+BZl
-----END PGP SIGNATURE-----
Curtis Hovey (sinzui) wrote : | # |
Is this summarise the try-finally issue correct? As we know know there is a chance that the old-client error can be swallowed by a new client error. When investigate an exception destroying the new-client we need to be mindful of an exception from old-client is missing. Maybe we can address this issue in the future by adding an additional logging step after each destroy to indicate that the old-client did destroy itself.
Aaron Bentley (abentley) wrote : | # |
Your summary is correct, but I misled you about the nature of exceptions destroy_environment is likely to raise.
I had forgotten that destroy_environment does not raise an exception when juju exits with failure. (It uses "call" rather "check_call", because juju returns a non-zero exit status if you try to destroy an environment that does not exist.)
So the sorts of exceptions you're likely to suppress are:
- string formatting issues when the 'destroy-
- out-of-memory running fork()
- no-such-file running exec()
These are pretty rare and weird errors, and you'll only suppress them if new_client.
However, it's not hard to turn the try/finally into a try/except/finally where the except block logs the exception. Actually, it can be a try/except then, because after you've logged the exception, you shouldn't re-raise it.
Curtis Hovey (sinzui) wrote : | # |
Thank you. This was very informative. Let's merge this code. If we have issues about destroy-env in the future, we can solve this issue.
Preview Diff
1 | === added file 'industrial_test.py' |
2 | --- industrial_test.py 1970-01-01 00:00:00 +0000 |
3 | +++ industrial_test.py 2014-10-16 20:18:49 +0000 |
4 | @@ -0,0 +1,128 @@ |
5 | +#!/usr/bin/env python |
6 | +__metaclass__ = type |
7 | + |
8 | +from argparse import ArgumentParser |
9 | +import logging |
10 | + |
11 | +from jujuconfig import get_juju_home |
12 | +from jujupy import ( |
13 | + EnvJujuClient, |
14 | + SimpleEnvironment, |
15 | + temp_bootstrap_env, |
16 | + ) |
17 | + |
18 | + |
19 | +def uniquify_local(env): |
20 | + if not env.local: |
21 | + return |
22 | + port_defaults = { |
23 | + 'api-port': 17070, |
24 | + 'state-port': 37017, |
25 | + 'storage-port': 8040, |
26 | + 'syslog-port': 6514, |
27 | + } |
28 | + for key, default in port_defaults.items(): |
29 | + env.config[key] = env.config.get(key, default) + 1 |
30 | + |
31 | + |
32 | +class IndustrialTest: |
33 | + |
34 | + @classmethod |
35 | + def from_args(cls, env, new_juju_path, stage_attempts): |
36 | + old_env = SimpleEnvironment.from_config(env) |
37 | + old_env.environment = env + '-old' |
38 | + old_client = EnvJujuClient.by_version(old_env) |
39 | + new_env = SimpleEnvironment.from_config(env) |
40 | + new_env.environment = env + '-new' |
41 | + uniquify_local(new_env) |
42 | + new_client = EnvJujuClient.by_version(new_env, new_juju_path) |
43 | + return cls(old_client, new_client, stage_attempts) |
44 | + |
45 | + def __init__(self, old_client, new_client, stage_attempts): |
46 | + self.old_client = old_client |
47 | + self.new_client = new_client |
48 | + self.stage_attempts = stage_attempts |
49 | + |
50 | + def run_attempt(self): |
51 | + self.old_client.destroy_environment() |
52 | + self.new_client.destroy_environment() |
53 | + return self.run_stages() |
54 | + |
55 | + def run_stages(self): |
56 | + for attempt in self.stage_attempts: |
57 | + result = attempt.do_stage(self.old_client, self.new_client) |
58 | + yield result |
59 | + if False in result: |
60 | + try: |
61 | + self.old_client.destroy_environment() |
62 | + finally: |
63 | + self.new_client.destroy_environment() |
64 | + break |
65 | + |
66 | + |
67 | +class StageAttempt: |
68 | + |
69 | + def __init__(self): |
70 | + self.failure_clients = set() |
71 | + |
72 | + def do_stage(self, old, new): |
73 | + self.do_operation(old) |
74 | + self.do_operation(new) |
75 | + old_result = self.get_result(old) |
76 | + new_result = self.get_result(new) |
77 | + return old_result, new_result |
78 | + |
79 | + def do_operation(self, client, output=None): |
80 | + try: |
81 | + self._operation(client) |
82 | + except Exception as e: |
83 | + logging.exception(e) |
84 | + self.failure_clients.add(client) |
85 | + |
86 | + def get_result(self, client): |
87 | + if client in self.failure_clients: |
88 | + return False |
89 | + try: |
90 | + return self._result(client) |
91 | + except Exception: |
92 | + return False |
93 | + |
94 | + |
95 | +class BootstrapAttempt(StageAttempt): |
96 | + |
97 | + def _operation(self, client): |
98 | + with temp_bootstrap_env(get_juju_home(), client): |
99 | + client.bootstrap() |
100 | + |
101 | + def _result(self, client): |
102 | + client.wait_for_started() |
103 | + return True |
104 | + |
105 | + |
106 | +class DestroyEnvironmentAttempt(StageAttempt): |
107 | + |
108 | + def _operation(self, client): |
109 | + client.juju('destroy-environment', ('-y', client.env.environment), |
110 | + include_e=False) |
111 | + |
112 | + def _result(self, client): |
113 | + return True |
114 | + |
115 | + |
116 | +def parse_args(args=None): |
117 | + parser = ArgumentParser() |
118 | + parser.add_argument('env') |
119 | + parser.add_argument('new_juju_path') |
120 | + return parser.parse_args(args) |
121 | + |
122 | + |
123 | +def main(): |
124 | + args = parse_args() |
125 | + stages = [BootstrapAttempt(), DestroyEnvironmentAttempt()] |
126 | + industrial = IndustrialTest.from_args(args.env, args.new_juju_path, stages) |
127 | + results = list(industrial.run_attempt()) |
128 | + print results |
129 | + |
130 | + |
131 | +if __name__ == '__main__': |
132 | + main() |
133 | |
134 | === modified file 'jujupy.py' |
135 | --- jujupy.py 2014-09-29 18:00:46 +0000 |
136 | +++ jujupy.py 2014-10-16 20:18:49 +0000 |
137 | @@ -424,6 +424,24 @@ |
138 | self.hpcloud = False |
139 | self.maas = False |
140 | |
141 | + def __eq__(self, other): |
142 | + if type(self) != type(other): |
143 | + return False |
144 | + if self.environment != other.environment: |
145 | + return False |
146 | + if self.config != other.config: |
147 | + return False |
148 | + if self.local != other.local: |
149 | + return False |
150 | + if self.hpcloud != other.hpcloud: |
151 | + return False |
152 | + if self.maas != other.maas: |
153 | + return False |
154 | + return True |
155 | + |
156 | + def __ne__(self, other): |
157 | + return not self == other |
158 | + |
159 | @classmethod |
160 | def from_config(cls, name): |
161 | return cls(name, get_selected_environment(name)[0]) |
162 | |
163 | === added file 'test_industrial_test.py' |
164 | --- test_industrial_test.py 1970-01-01 00:00:00 +0000 |
165 | +++ test_industrial_test.py 2014-10-16 20:18:49 +0000 |
166 | @@ -0,0 +1,265 @@ |
167 | +__metaclass__ = type |
168 | + |
169 | +from contextlib import contextmanager |
170 | +import os.path |
171 | +from StringIO import StringIO |
172 | +from unittest import TestCase |
173 | + |
174 | +from mock import patch |
175 | +import yaml |
176 | + |
177 | +from industrial_test import ( |
178 | + BootstrapAttempt, |
179 | + DestroyEnvironmentAttempt, |
180 | + IndustrialTest, |
181 | + parse_args, |
182 | + StageAttempt, |
183 | + ) |
184 | +from jujupy import ( |
185 | + EnvJujuClient, |
186 | + SimpleEnvironment, |
187 | + ) |
188 | + |
189 | + |
190 | +@contextmanager |
191 | +def parse_error(test_case): |
192 | + stderr = StringIO() |
193 | + with test_case.assertRaises(SystemExit): |
194 | + with patch('sys.stderr', stderr): |
195 | + yield stderr |
196 | + |
197 | + |
198 | +class TestParseArgs(TestCase): |
199 | + |
200 | + def test_parse_args(self): |
201 | + with parse_error(self) as stderr: |
202 | + args = parse_args([]) |
203 | + self.assertRegexpMatches( |
204 | + stderr.getvalue(), '.*error: too few arguments.*') |
205 | + with parse_error(self) as stderr: |
206 | + args = parse_args(['env']) |
207 | + self.assertRegexpMatches( |
208 | + stderr.getvalue(), '.*error: too few arguments.*') |
209 | + args = parse_args(['rai', 'new-juju']) |
210 | + self.assertEqual(args.env, 'rai') |
211 | + self.assertEqual(args.new_juju_path, 'new-juju') |
212 | + |
213 | + |
214 | +class FakeAttempt: |
215 | + |
216 | + def __init__(self, *result): |
217 | + self.result = result |
218 | + |
219 | + def do_stage(self, old_client, new_client): |
220 | + return self.result |
221 | + |
222 | + |
223 | +class TestIndustrialTest(TestCase): |
224 | + |
225 | + def test_init(self): |
226 | + old_client = object() |
227 | + new_client = object() |
228 | + attempt_list = [] |
229 | + industrial = IndustrialTest(old_client, new_client, attempt_list) |
230 | + self.assertIs(old_client, industrial.old_client) |
231 | + self.assertIs(new_client, industrial.new_client) |
232 | + self.assertIs(attempt_list, industrial.stage_attempts) |
233 | + |
234 | + def test_from_args(self): |
235 | + side_effect = lambda x, y=None: (x, y) |
236 | + with patch('jujupy.EnvJujuClient.by_version', side_effect=side_effect): |
237 | + with patch('jujupy.SimpleEnvironment.from_config', |
238 | + side_effect=lambda x: SimpleEnvironment(x, {})): |
239 | + industrial = IndustrialTest.from_args( |
240 | + 'foo', 'new-juju-path', []) |
241 | + self.assertIsInstance(industrial, IndustrialTest) |
242 | + self.assertEqual(industrial.old_client, |
243 | + (SimpleEnvironment('foo-old', {}), None)) |
244 | + self.assertEqual(industrial.new_client, |
245 | + (SimpleEnvironment('foo-new', {}), 'new-juju-path')) |
246 | + self.assertNotEqual(industrial.old_client[0].environment, |
247 | + industrial.new_client[0].environment) |
248 | + |
249 | + def test_run_stages(self): |
250 | + old_client = FakeEnvJujuClient('old') |
251 | + new_client = FakeEnvJujuClient('new') |
252 | + industrial = IndustrialTest(old_client, new_client, |
253 | + [FakeAttempt(True, True), FakeAttempt(True, True)]) |
254 | + with patch('subprocess.call') as cc_mock: |
255 | + result = industrial.run_stages() |
256 | + self.assertItemsEqual(result, [(True, True), (True, True)]) |
257 | + self.assertEqual(len(cc_mock.mock_calls), 0) |
258 | + |
259 | + def test_run_stages_old_fail(self): |
260 | + old_client = FakeEnvJujuClient('old') |
261 | + new_client = FakeEnvJujuClient('new') |
262 | + industrial = IndustrialTest(old_client, new_client, |
263 | + [FakeAttempt(False, True), FakeAttempt(True, True)]) |
264 | + with patch('subprocess.call') as cc_mock: |
265 | + result = industrial.run_stages() |
266 | + self.assertItemsEqual(result, [(False, True)]) |
267 | + assert_juju_call(self, cc_mock, old_client, |
268 | + ('juju', '--show-log', 'destroy-environment', |
269 | + 'old', '--force', '-y'), 0) |
270 | + assert_juju_call(self, cc_mock, new_client, |
271 | + ('juju', '--show-log', 'destroy-environment', |
272 | + 'new', '--force', '-y'), 1) |
273 | + |
274 | + def test_run_stages_new_fail(self): |
275 | + old_client = FakeEnvJujuClient('old') |
276 | + new_client = FakeEnvJujuClient('new') |
277 | + industrial = IndustrialTest(old_client, new_client, |
278 | + [FakeAttempt(True, False), FakeAttempt(True, True)]) |
279 | + with patch('subprocess.call') as cc_mock: |
280 | + result = industrial.run_stages() |
281 | + self.assertItemsEqual(result, [(True, False)]) |
282 | + assert_juju_call(self, cc_mock, old_client, |
283 | + ('juju', '--show-log', 'destroy-environment', |
284 | + 'old', '--force', '-y'), 0) |
285 | + assert_juju_call(self, cc_mock, new_client, |
286 | + ('juju', '--show-log', 'destroy-environment', |
287 | + 'new', '--force', '-y'), 1) |
288 | + |
289 | + def test_run_stages_both_fail(self): |
290 | + old_client = FakeEnvJujuClient('old') |
291 | + new_client = FakeEnvJujuClient('new') |
292 | + industrial = IndustrialTest(old_client, new_client, |
293 | + [FakeAttempt(False, False), FakeAttempt(True, True)]) |
294 | + with patch('subprocess.call') as cc_mock: |
295 | + result = industrial.run_stages() |
296 | + self.assertItemsEqual(result, [(False, False)]) |
297 | + assert_juju_call(self, cc_mock, old_client, |
298 | + ('juju', '--show-log', 'destroy-environment', |
299 | + 'old', '--force', '-y'), 0) |
300 | + assert_juju_call(self, cc_mock, new_client, |
301 | + ('juju', '--show-log', 'destroy-environment', |
302 | + 'new', '--force', '-y'), 1) |
303 | + |
304 | + def test_destroy_both_even_with_exception(self): |
305 | + old_client = FakeEnvJujuClient('old') |
306 | + new_client = FakeEnvJujuClient('new') |
307 | + industrial = IndustrialTest(old_client, new_client, |
308 | + [FakeAttempt(False, False), FakeAttempt(True, True)]) |
309 | + attempt = industrial.run_stages() |
310 | + with patch.object(old_client, 'destroy_environment', |
311 | + side_effect=Exception) as oc_mock: |
312 | + with patch.object(new_client, 'destroy_environment', |
313 | + side_effect=Exception) as nc_mock: |
314 | + with self.assertRaises(Exception): |
315 | + list(attempt) |
316 | + oc_mock.assert_called_once_with() |
317 | + nc_mock.assert_called_once_with() |
318 | + |
319 | + |
320 | +class TestStageAttempt(TestCase): |
321 | + |
322 | + def test_do_stage(self): |
323 | + |
324 | + class StubSA(StageAttempt): |
325 | + |
326 | + def __init__(self): |
327 | + super(StageAttempt, self).__init__() |
328 | + self.did_op = [] |
329 | + |
330 | + def do_operation(self, client): |
331 | + self.did_op.append(client) |
332 | + |
333 | + def get_result(self, client): |
334 | + return self.did_op.index(client) |
335 | + |
336 | + |
337 | + attempt = StubSA() |
338 | + old = object() |
339 | + new = object() |
340 | + result = attempt.do_stage(old, new) |
341 | + self.assertEqual([old, new], attempt.did_op) |
342 | + self.assertEqual(result, (0, 1)) |
343 | + |
344 | + |
345 | +class FakeEnvJujuClient(EnvJujuClient): |
346 | + |
347 | + def __init__(self, name='steve'): |
348 | + super(FakeEnvJujuClient, self).__init__( |
349 | + SimpleEnvironment(name, {'type': 'fake'}), '1.2', '/jbin/juju') |
350 | + |
351 | + def wait_for_started(self): |
352 | + with patch('sys.stdout'): |
353 | + super(FakeEnvJujuClient, self).wait_for_started(0.01) |
354 | + |
355 | + def juju(self, *args, **kwargs): |
356 | + # Suppress stdout for juju commands. |
357 | + with patch('sys.stdout'): |
358 | + return super(FakeEnvJujuClient, self).juju(*args, **kwargs) |
359 | + |
360 | + |
361 | + |
362 | +def assert_juju_call(test_case, mock_method, client, expected_args, |
363 | + call_index=None): |
364 | + if call_index is None: |
365 | + test_case.assertEqual(len(mock_method.mock_calls), 1) |
366 | + call_index = 0 |
367 | + empty, args, kwargs = mock_method.mock_calls[call_index] |
368 | + test_case.assertEqual(args, (expected_args,)) |
369 | + test_case.assertEqual(kwargs.keys(), ['env']) |
370 | + bin_dir = os.path.dirname(client.full_path) |
371 | + test_case.assertRegexpMatches(kwargs['env']['PATH'], |
372 | + r'^{}\:'.format(bin_dir)) |
373 | + |
374 | + |
375 | +class TestBootstrapAttempt(TestCase): |
376 | + |
377 | + def test_do_operation(self): |
378 | + client = FakeEnvJujuClient() |
379 | + bootstrap = BootstrapAttempt() |
380 | + with patch('subprocess.check_call') as mock_cc: |
381 | + bootstrap.do_operation(client) |
382 | + assert_juju_call(self, mock_cc, client, ( |
383 | + 'juju', '--show-log', 'bootstrap', '-e', 'steve', |
384 | + '--constraints', 'mem=2G')) |
385 | + |
386 | + def test_do_operation_exception(self): |
387 | + client = FakeEnvJujuClient() |
388 | + bootstrap = BootstrapAttempt() |
389 | + with patch('subprocess.check_call', side_effect=Exception |
390 | + ) as mock_cc: |
391 | + bootstrap.do_operation(client) |
392 | + assert_juju_call(self, mock_cc, client, ( |
393 | + 'juju', '--show-log', 'bootstrap', '-e', 'steve', |
394 | + '--constraints', 'mem=2G')) |
395 | + output = yaml.safe_dump({ |
396 | + 'machines': {'0': {'agent-state': 'started'}}, |
397 | + 'services': {}, |
398 | + }) |
399 | + with patch('subprocess.check_output', return_value=output): |
400 | + self.assertFalse(bootstrap.get_result(client)) |
401 | + |
402 | + def test_get_result_true(self): |
403 | + bootstrap = BootstrapAttempt() |
404 | + client = FakeEnvJujuClient() |
405 | + output = yaml.safe_dump({ |
406 | + 'machines': {'0': {'agent-state': 'started'}}, |
407 | + 'services': {}, |
408 | + }) |
409 | + with patch('subprocess.check_output', return_value=output): |
410 | + self.assertTrue(bootstrap.get_result(client)) |
411 | + |
412 | + def test_get_result_false(self): |
413 | + bootstrap = BootstrapAttempt() |
414 | + client = FakeEnvJujuClient() |
415 | + output = yaml.safe_dump({ |
416 | + 'machines': {'0': {'agent-state': 'pending'}}, |
417 | + 'services': {}, |
418 | + }) |
419 | + with patch('subprocess.check_output', return_value=output): |
420 | + self.assertFalse(bootstrap.get_result(client)) |
421 | + |
422 | + |
423 | +class TestDestroyEnvironmentAttempt(TestCase): |
424 | + |
425 | + def test_do_operation(self): |
426 | + client = FakeEnvJujuClient() |
427 | + bootstrap = DestroyEnvironmentAttempt() |
428 | + with patch('subprocess.check_call') as mock_cc: |
429 | + bootstrap.do_operation(client) |
430 | + assert_juju_call(self, mock_cc, client, ( |
431 | + 'juju', '--show-log', 'destroy-environment', '-y', 'steve')) |
Thank you Aaron. I have questions about a try-finally block.