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
=== added file 'industrial_test.py'
--- industrial_test.py 1970-01-01 00:00:00 +0000
+++ industrial_test.py 2014-10-16 20:18:49 +0000
@@ -0,0 +1,128 @@
1#!/usr/bin/env python
2__metaclass__ = type
3
4from argparse import ArgumentParser
5import logging
6
7from jujuconfig import get_juju_home
8from jujupy import (
9 EnvJujuClient,
10 SimpleEnvironment,
11 temp_bootstrap_env,
12 )
13
14
15def uniquify_local(env):
16 if not env.local:
17 return
18 port_defaults = {
19 'api-port': 17070,
20 'state-port': 37017,
21 'storage-port': 8040,
22 'syslog-port': 6514,
23 }
24 for key, default in port_defaults.items():
25 env.config[key] = env.config.get(key, default) + 1
26
27
28class IndustrialTest:
29
30 @classmethod
31 def from_args(cls, env, new_juju_path, stage_attempts):
32 old_env = SimpleEnvironment.from_config(env)
33 old_env.environment = env + '-old'
34 old_client = EnvJujuClient.by_version(old_env)
35 new_env = SimpleEnvironment.from_config(env)
36 new_env.environment = env + '-new'
37 uniquify_local(new_env)
38 new_client = EnvJujuClient.by_version(new_env, new_juju_path)
39 return cls(old_client, new_client, stage_attempts)
40
41 def __init__(self, old_client, new_client, stage_attempts):
42 self.old_client = old_client
43 self.new_client = new_client
44 self.stage_attempts = stage_attempts
45
46 def run_attempt(self):
47 self.old_client.destroy_environment()
48 self.new_client.destroy_environment()
49 return self.run_stages()
50
51 def run_stages(self):
52 for attempt in self.stage_attempts:
53 result = attempt.do_stage(self.old_client, self.new_client)
54 yield result
55 if False in result:
56 try:
57 self.old_client.destroy_environment()
58 finally:
59 self.new_client.destroy_environment()
60 break
61
62
63class StageAttempt:
64
65 def __init__(self):
66 self.failure_clients = set()
67
68 def do_stage(self, old, new):
69 self.do_operation(old)
70 self.do_operation(new)
71 old_result = self.get_result(old)
72 new_result = self.get_result(new)
73 return old_result, new_result
74
75 def do_operation(self, client, output=None):
76 try:
77 self._operation(client)
78 except Exception as e:
79 logging.exception(e)
80 self.failure_clients.add(client)
81
82 def get_result(self, client):
83 if client in self.failure_clients:
84 return False
85 try:
86 return self._result(client)
87 except Exception:
88 return False
89
90
91class BootstrapAttempt(StageAttempt):
92
93 def _operation(self, client):
94 with temp_bootstrap_env(get_juju_home(), client):
95 client.bootstrap()
96
97 def _result(self, client):
98 client.wait_for_started()
99 return True
100
101
102class DestroyEnvironmentAttempt(StageAttempt):
103
104 def _operation(self, client):
105 client.juju('destroy-environment', ('-y', client.env.environment),
106 include_e=False)
107
108 def _result(self, client):
109 return True
110
111
112def parse_args(args=None):
113 parser = ArgumentParser()
114 parser.add_argument('env')
115 parser.add_argument('new_juju_path')
116 return parser.parse_args(args)
117
118
119def main():
120 args = parse_args()
121 stages = [BootstrapAttempt(), DestroyEnvironmentAttempt()]
122 industrial = IndustrialTest.from_args(args.env, args.new_juju_path, stages)
123 results = list(industrial.run_attempt())
124 print results
125
126
127if __name__ == '__main__':
128 main()
0129
=== modified file 'jujupy.py'
--- jujupy.py 2014-09-29 18:00:46 +0000
+++ jujupy.py 2014-10-16 20:18:49 +0000
@@ -424,6 +424,24 @@
424 self.hpcloud = False424 self.hpcloud = False
425 self.maas = False425 self.maas = False
426426
427 def __eq__(self, other):
428 if type(self) != type(other):
429 return False
430 if self.environment != other.environment:
431 return False
432 if self.config != other.config:
433 return False
434 if self.local != other.local:
435 return False
436 if self.hpcloud != other.hpcloud:
437 return False
438 if self.maas != other.maas:
439 return False
440 return True
441
442 def __ne__(self, other):
443 return not self == other
444
427 @classmethod445 @classmethod
428 def from_config(cls, name):446 def from_config(cls, name):
429 return cls(name, get_selected_environment(name)[0])447 return cls(name, get_selected_environment(name)[0])
430448
=== added file 'test_industrial_test.py'
--- test_industrial_test.py 1970-01-01 00:00:00 +0000
+++ test_industrial_test.py 2014-10-16 20:18:49 +0000
@@ -0,0 +1,265 @@
1__metaclass__ = type
2
3from contextlib import contextmanager
4import os.path
5from StringIO import StringIO
6from unittest import TestCase
7
8from mock import patch
9import yaml
10
11from industrial_test import (
12 BootstrapAttempt,
13 DestroyEnvironmentAttempt,
14 IndustrialTest,
15 parse_args,
16 StageAttempt,
17 )
18from jujupy import (
19 EnvJujuClient,
20 SimpleEnvironment,
21 )
22
23
24@contextmanager
25def parse_error(test_case):
26 stderr = StringIO()
27 with test_case.assertRaises(SystemExit):
28 with patch('sys.stderr', stderr):
29 yield stderr
30
31
32class TestParseArgs(TestCase):
33
34 def test_parse_args(self):
35 with parse_error(self) as stderr:
36 args = parse_args([])
37 self.assertRegexpMatches(
38 stderr.getvalue(), '.*error: too few arguments.*')
39 with parse_error(self) as stderr:
40 args = parse_args(['env'])
41 self.assertRegexpMatches(
42 stderr.getvalue(), '.*error: too few arguments.*')
43 args = parse_args(['rai', 'new-juju'])
44 self.assertEqual(args.env, 'rai')
45 self.assertEqual(args.new_juju_path, 'new-juju')
46
47
48class FakeAttempt:
49
50 def __init__(self, *result):
51 self.result = result
52
53 def do_stage(self, old_client, new_client):
54 return self.result
55
56
57class TestIndustrialTest(TestCase):
58
59 def test_init(self):
60 old_client = object()
61 new_client = object()
62 attempt_list = []
63 industrial = IndustrialTest(old_client, new_client, attempt_list)
64 self.assertIs(old_client, industrial.old_client)
65 self.assertIs(new_client, industrial.new_client)
66 self.assertIs(attempt_list, industrial.stage_attempts)
67
68 def test_from_args(self):
69 side_effect = lambda x, y=None: (x, y)
70 with patch('jujupy.EnvJujuClient.by_version', side_effect=side_effect):
71 with patch('jujupy.SimpleEnvironment.from_config',
72 side_effect=lambda x: SimpleEnvironment(x, {})):
73 industrial = IndustrialTest.from_args(
74 'foo', 'new-juju-path', [])
75 self.assertIsInstance(industrial, IndustrialTest)
76 self.assertEqual(industrial.old_client,
77 (SimpleEnvironment('foo-old', {}), None))
78 self.assertEqual(industrial.new_client,
79 (SimpleEnvironment('foo-new', {}), 'new-juju-path'))
80 self.assertNotEqual(industrial.old_client[0].environment,
81 industrial.new_client[0].environment)
82
83 def test_run_stages(self):
84 old_client = FakeEnvJujuClient('old')
85 new_client = FakeEnvJujuClient('new')
86 industrial = IndustrialTest(old_client, new_client,
87 [FakeAttempt(True, True), FakeAttempt(True, True)])
88 with patch('subprocess.call') as cc_mock:
89 result = industrial.run_stages()
90 self.assertItemsEqual(result, [(True, True), (True, True)])
91 self.assertEqual(len(cc_mock.mock_calls), 0)
92
93 def test_run_stages_old_fail(self):
94 old_client = FakeEnvJujuClient('old')
95 new_client = FakeEnvJujuClient('new')
96 industrial = IndustrialTest(old_client, new_client,
97 [FakeAttempt(False, True), FakeAttempt(True, True)])
98 with patch('subprocess.call') as cc_mock:
99 result = industrial.run_stages()
100 self.assertItemsEqual(result, [(False, True)])
101 assert_juju_call(self, cc_mock, old_client,
102 ('juju', '--show-log', 'destroy-environment',
103 'old', '--force', '-y'), 0)
104 assert_juju_call(self, cc_mock, new_client,
105 ('juju', '--show-log', 'destroy-environment',
106 'new', '--force', '-y'), 1)
107
108 def test_run_stages_new_fail(self):
109 old_client = FakeEnvJujuClient('old')
110 new_client = FakeEnvJujuClient('new')
111 industrial = IndustrialTest(old_client, new_client,
112 [FakeAttempt(True, False), FakeAttempt(True, True)])
113 with patch('subprocess.call') as cc_mock:
114 result = industrial.run_stages()
115 self.assertItemsEqual(result, [(True, False)])
116 assert_juju_call(self, cc_mock, old_client,
117 ('juju', '--show-log', 'destroy-environment',
118 'old', '--force', '-y'), 0)
119 assert_juju_call(self, cc_mock, new_client,
120 ('juju', '--show-log', 'destroy-environment',
121 'new', '--force', '-y'), 1)
122
123 def test_run_stages_both_fail(self):
124 old_client = FakeEnvJujuClient('old')
125 new_client = FakeEnvJujuClient('new')
126 industrial = IndustrialTest(old_client, new_client,
127 [FakeAttempt(False, False), FakeAttempt(True, True)])
128 with patch('subprocess.call') as cc_mock:
129 result = industrial.run_stages()
130 self.assertItemsEqual(result, [(False, False)])
131 assert_juju_call(self, cc_mock, old_client,
132 ('juju', '--show-log', 'destroy-environment',
133 'old', '--force', '-y'), 0)
134 assert_juju_call(self, cc_mock, new_client,
135 ('juju', '--show-log', 'destroy-environment',
136 'new', '--force', '-y'), 1)
137
138 def test_destroy_both_even_with_exception(self):
139 old_client = FakeEnvJujuClient('old')
140 new_client = FakeEnvJujuClient('new')
141 industrial = IndustrialTest(old_client, new_client,
142 [FakeAttempt(False, False), FakeAttempt(True, True)])
143 attempt = industrial.run_stages()
144 with patch.object(old_client, 'destroy_environment',
145 side_effect=Exception) as oc_mock:
146 with patch.object(new_client, 'destroy_environment',
147 side_effect=Exception) as nc_mock:
148 with self.assertRaises(Exception):
149 list(attempt)
150 oc_mock.assert_called_once_with()
151 nc_mock.assert_called_once_with()
152
153
154class TestStageAttempt(TestCase):
155
156 def test_do_stage(self):
157
158 class StubSA(StageAttempt):
159
160 def __init__(self):
161 super(StageAttempt, self).__init__()
162 self.did_op = []
163
164 def do_operation(self, client):
165 self.did_op.append(client)
166
167 def get_result(self, client):
168 return self.did_op.index(client)
169
170
171 attempt = StubSA()
172 old = object()
173 new = object()
174 result = attempt.do_stage(old, new)
175 self.assertEqual([old, new], attempt.did_op)
176 self.assertEqual(result, (0, 1))
177
178
179class FakeEnvJujuClient(EnvJujuClient):
180
181 def __init__(self, name='steve'):
182 super(FakeEnvJujuClient, self).__init__(
183 SimpleEnvironment(name, {'type': 'fake'}), '1.2', '/jbin/juju')
184
185 def wait_for_started(self):
186 with patch('sys.stdout'):
187 super(FakeEnvJujuClient, self).wait_for_started(0.01)
188
189 def juju(self, *args, **kwargs):
190 # Suppress stdout for juju commands.
191 with patch('sys.stdout'):
192 return super(FakeEnvJujuClient, self).juju(*args, **kwargs)
193
194
195
196def assert_juju_call(test_case, mock_method, client, expected_args,
197 call_index=None):
198 if call_index is None:
199 test_case.assertEqual(len(mock_method.mock_calls), 1)
200 call_index = 0
201 empty, args, kwargs = mock_method.mock_calls[call_index]
202 test_case.assertEqual(args, (expected_args,))
203 test_case.assertEqual(kwargs.keys(), ['env'])
204 bin_dir = os.path.dirname(client.full_path)
205 test_case.assertRegexpMatches(kwargs['env']['PATH'],
206 r'^{}\:'.format(bin_dir))
207
208
209class TestBootstrapAttempt(TestCase):
210
211 def test_do_operation(self):
212 client = FakeEnvJujuClient()
213 bootstrap = BootstrapAttempt()
214 with patch('subprocess.check_call') as mock_cc:
215 bootstrap.do_operation(client)
216 assert_juju_call(self, mock_cc, client, (
217 'juju', '--show-log', 'bootstrap', '-e', 'steve',
218 '--constraints', 'mem=2G'))
219
220 def test_do_operation_exception(self):
221 client = FakeEnvJujuClient()
222 bootstrap = BootstrapAttempt()
223 with patch('subprocess.check_call', side_effect=Exception
224 ) as mock_cc:
225 bootstrap.do_operation(client)
226 assert_juju_call(self, mock_cc, client, (
227 'juju', '--show-log', 'bootstrap', '-e', 'steve',
228 '--constraints', 'mem=2G'))
229 output = yaml.safe_dump({
230 'machines': {'0': {'agent-state': 'started'}},
231 'services': {},
232 })
233 with patch('subprocess.check_output', return_value=output):
234 self.assertFalse(bootstrap.get_result(client))
235
236 def test_get_result_true(self):
237 bootstrap = BootstrapAttempt()
238 client = FakeEnvJujuClient()
239 output = yaml.safe_dump({
240 'machines': {'0': {'agent-state': 'started'}},
241 'services': {},
242 })
243 with patch('subprocess.check_output', return_value=output):
244 self.assertTrue(bootstrap.get_result(client))
245
246 def test_get_result_false(self):
247 bootstrap = BootstrapAttempt()
248 client = FakeEnvJujuClient()
249 output = yaml.safe_dump({
250 'machines': {'0': {'agent-state': 'pending'}},
251 'services': {},
252 })
253 with patch('subprocess.check_output', return_value=output):
254 self.assertFalse(bootstrap.get_result(client))
255
256
257class TestDestroyEnvironmentAttempt(TestCase):
258
259 def test_do_operation(self):
260 client = FakeEnvJujuClient()
261 bootstrap = DestroyEnvironmentAttempt()
262 with patch('subprocess.check_call') as mock_cc:
263 bootstrap.do_operation(client)
264 assert_juju_call(self, mock_cc, client, (
265 'juju', '--show-log', 'destroy-environment', '-y', 'steve'))

Subscribers

People subscribed via source and target branches