Merge lp:~abentley/juju-ci-tools/industrial-test into lp:juju-ci-tools

Proposed by Aaron Bentley
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
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.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you Aaron. I have questions about a try-finally block.

review: Needs Information (code)
Revision history for this message
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_attempts: + result =
>> attempt.do_stage(self.old_client, self.new_client) + yield result
>> + if False in result: + try: +
>> self.old_client.destroy_environment() + finally:
>
> 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.destroy_environment also raises, it will swallow the
old_client.destroy_environment exception.

While the exception for old_client can be suppressed, the juju error
messages for each "juju destroy-environment" invocation will not be
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_both_even_with_exception(self): +
>> old_client = FakeEnvJujuClient('old') + new_client =
>> FakeEnvJujuClient('new') + industrial =
>> IndustrialTest(old_client, new_client, + [FakeAttempt(False,
>> False), FakeAttempt(True, True)]) + attempt =
>> industrial.run_stages() + with patch.object(old_client,
>> 'destroy_environment', + side_effect=Exception) as oc_mock: +
>> with patch.object(new_client, 'destroy_environment', +
>> side_effect=Exception) as nc_mock: + with
>> self.assertRaises(Exception): + list(attempt)
>
> 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

iQEcBAEBAgAGBQJUQSdHAAoJEK84cMOcf+9hyKAH/2ESEERPpZQeR42SzJUXVWUY
sZw7nDB4Eoe94QG2UgC8YyoZKLpCwy/Ga2IOFKXptlwQdZXaKjztL2Jc36JsTXk1
Bk4PW4QWr/BHxRx1pNroQw/+YBXasTijQPhJO5eE5MgGqtkt5Rg+mIYDhM40d98t
wu4qrNjg7mR42Nno9j8SC7JakXTJzytQCKHG98349Sq/M1Cer0ixuAZM8LV2qY2d
7ZpjnnF4IumJ5EO3ygjwZB3sAO4SV2WU7YslBDPX7qZr3aNuqdYoN4uhvaXmwuyB
/uKjNN+u/lXyHq2Ousow9SEXVe7A2qzHHVl9KRvFHpRwI8U8Np0rhjkqDlbjPb0=
=+BZl
-----END PGP SIGNATURE-----

Revision history for this message
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.

review: Needs Information (code)
Revision history for this message
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-environment' command cannot be constructed due to bogus values
- 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.destroy_environment() *also* raises a rare and weird error.

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.

Revision history for this message
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.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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'))

Subscribers

People subscribed via source and target branches