Merge lp:~dooferlad/juju-ci-tools/juju-ci-tools-addressable-containers into lp:juju-ci-tools

Proposed by James Tunnicliffe on 2015-08-10
Status: Work in progress
Proposed branch: lp:~dooferlad/juju-ci-tools/juju-ci-tools-addressable-containers
Merge into: lp:juju-ci-tools
Diff against target: 934 lines (+878/-2)
3 files modified
assess_container_networking.py (+427/-0)
jujupy.py (+41/-2)
test_container_networking.py (+410/-0)
To merge this branch: bzr merge lp:~dooferlad/juju-ci-tools/juju-ci-tools-addressable-containers
Reviewer Review Type Date Requested Status
Martin Packman (community) 2015-08-10 Needs Fixing on 2015-08-11
Review via email: mp+267494@code.launchpad.net

Description of the Change

This adds tests to check container networking as set up by the Juju addressable containers feature.
It contains comprehensive unit tests and passes make lint.

An unusual but I hope useful feature that, if you like it, should end up in a library is the request_machines / clean_environment pair of functions. They allow you to use an existing environment and existing machines to run tests on. This is particularly useful during development of tests because bringing up machines and containers is time consuming and when some of your tests are "ping google.com" then you don't want to wait 10 minutes to see if your fix for a dumb mistake works. Obviously you don't want to use it in production, but it sure speeds up test development!

I realise this is about 1000 lines (about half are tests) and I am happy to go through them with the reviewer.

To post a comment you must log in.
Martin Packman (gz) wrote :

Thanks for the proposal!

I have a number of specific comments in line, but my general concern is this proposal adds a lot of new code just to this assess file where extending existing mechanisms would be preferable.

In particular, you could add methods in jujupy to Status for machine/container handling, and to EnvJujuClient for waiting. Using the existing bootstrap and log collection from deploy_stack would save a lot of code, and don't seem to need much modification for what you want to do here.

review: Needs Fixing
Aaron Bentley (abentley) wrote :

This is a follow-on to Martin's review.

Please don't apologize for the length of your diff. Instead, please split your work into separate chunks, as described in https://github.com/juju/juju/wiki/ci-tests#creating-a-new-ci-test

Please merge trunk and fix conflicts.

I haven't reviewed the unit tests, because I think the code being tested will change a lot.

See comments inline.

James Tunnicliffe (dooferlad) wrote :

Thanks for your comments. I clearly wasn't looking at the right tests for example code and should have asked to pair on this!

James Tunnicliffe (dooferlad) wrote :

I will be returning to this in a few days after we hit feature freeze - I haven't forgotten it!

Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 2015-08-17 05:19 AM, James Tunnicliffe wrote:
>> + while new_machine_count > 0: +
>> new_machine_count -= 1 + with
>> client.juju_async('add-machine', ''): + pass
>
> If memory serves it was because client.juju is blocking and I
> wanted to add multiple machines or containers in parallel. Will
> look for a nicer solution.

The point of the juju_async is that you do something in the 'with'
block while the process is executing. When you exit the block, we
call subprocess.Popen.wait(). So the effect of doing nothing in the
'with block' is the same as doing a blocking call.

Assuming 2 machines, here's what you've done:
1. For machine 1, client.juju_async('add-machine', '').__enter__() is
called. The process starts.
2. For machine 1, client.juju_async('add-machine', '').__exit__() is
called. This blocks until the process exits.
3. For machine 2, client.juju_async('add-machine', '').__enter__() is
called. The process starts.
4. For machine 2, client.juju_async('add-machine', '').__exit__() is
called. This blocks until the process exits.

What you want is something like:
1. For machine 1, client.juju_async('add-machine', '').__enter__() is
called. The process starts.
2. For machine 2, client.juju_async('add-machine', '').__enter__() is
called. The process starts.
3. For machine 1, client.juju_async('add-machine', '').__exit__() is
called. This blocks until the process exits.
4. For machine 2, client.juju_async('add-machine', '').__exit__() is
called. This blocks until the process exits.

The simplest way of achieving this that I can see is recursion:

def add_many_machines(machine_count):
    if machine_count == 0:
        return
    with client.juju_async('add-machine', ''):
        add_many_machines(machine_count - 1)

Of course, Python limits recursion. So another option would be
itertools.nested:

machine_contexts = [client.juju_async('add-machine', '')
                    for x in range(new_machine_count)]
with itertools.nested(*machine_contexts):
    pass

Unfortunately, itertools.nested is deprecated. There is syntax to
replace it, but AFAICT, it does not support variable-length lists.

Aaron
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1

iQEcBAEBAgAGBQJV0fx4AAoJEK84cMOcf+9hsqQH/2vK9Tt4Jusx9+ttQyKdzQBX
Bva8Y/8oe5y2uBTpQt9RGtSb+mAwJgZAVJ09uet7kXrXIolZ7pw1fzV/gWzQiK10
MoxhCz4hn083Wfy1c/PfHQyc6jVAbE0KRh76NqaqaIqj7MKqfLqs+NZ8grAH3iOS
OzChtvoJLO8b2WWzGT//ZKkjTaumPeFac4Um5cnBqqUK0mnqMMkPjZBDMZXR2Tq3
WsxXmCcqTnfwIP0HpyvgjPsj0HxeXptikc9PnMMZXoygzYl/m1whfva7mg/qY+yH
umKArOJVkQ6vLJ8vkPiIU2uYKv7SQuHf+Kjmu9HjTwaJKnZtIXVwvcLYFm3DU3Y=
=02h5
-----END PGP SIGNATURE-----

Aaron Bentley (abentley) wrote :

> Unfortunately, itertools.nested is deprecated. There is syntax to
> replace it, but AFAICT, it does not support variable-length lists.

According to StackOverflow, contextlib2.ExitStack is one way to do this. I would probably just use nested, though.

http://stackoverflow.com/questions/16083791/alternative-to-contextlib-nested-with-variable-number-of-context-managers
http://contextlib2.readthedocs.org/en/latest/

1003. By James Tunnicliffe on 2015-09-15

Initial post-review updates.

1004. By James Tunnicliffe on 2015-09-21

Refactored assess_networking.py and associated tests. Now far more modular and shorter.

1005. By James Tunnicliffe on 2015-09-21

changed 'lxc' and 'kvm' into constants.
removed extra_env from bootstrap.

Unmerged revisions

1005. By James Tunnicliffe on 2015-09-21

changed 'lxc' and 'kvm' into constants.
removed extra_env from bootstrap.

1004. By James Tunnicliffe on 2015-09-21

Refactored assess_networking.py and associated tests. Now far more modular and shorter.

1003. By James Tunnicliffe on 2015-09-15

Initial post-review updates.

1002. By James Tunnicliffe on 2015-08-10

Some pre-review cleanup

1001. By James Tunnicliffe on 2015-08-10

Finished testing

1000. By James Tunnicliffe on 2015-08-04

Added the first few tests

999. By James Tunnicliffe on 2015-06-30

Fetch logs and configs on failure

998. By James Tunnicliffe on 2015-06-29

WIP container and VM networking tests

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'assess_container_networking.py'
2--- assess_container_networking.py 1970-01-01 00:00:00 +0000
3+++ assess_container_networking.py 2015-09-21 15:30:36 +0000
4@@ -0,0 +1,427 @@
5+#!/usr/bin/env python
6+from __future__ import print_function
7+
8+__metaclass__ = type
9+
10+import logging
11+import re
12+import tempfile
13+import os
14+import socket
15+from textwrap import dedent
16+
17+from jujuconfig import (
18+ get_juju_home,
19+)
20+from jujupy import (
21+ make_client,
22+ parse_new_state_server_from_error,
23+ temp_bootstrap_env,
24+ SimpleEnvironment,
25+ EnvJujuClient,
26+)
27+from utility import (
28+ print_now,
29+ add_basic_testing_arguments,
30+)
31+from deploy_stack import (
32+ update_env,
33+ dump_env_logs,
34+)
35+
36+from argparse import ArgumentParser
37+
38+
39+KVM_MACHINE = 'kvm'
40+LXC_MACHINE = 'lxc'
41+
42+
43+def parse_args(argv=None):
44+ """Parse all arguments."""
45+
46+ description = dedent("""\
47+ Test container address allocation.
48+ For LXC and KVM, create machines of each type and test the network
49+ between LXC <--> LXC, KVM <--> KVM and LXC <--> KVM. Also test machine
50+ to outside world, DNS and that these tests still pass after a reboot. In
51+ case of failure pull logs and configuration files from the machine that
52+ we detected a problem on for later analysis.
53+ """)
54+ parser = add_basic_testing_arguments(ArgumentParser(
55+ description=description
56+ ))
57+ parser.add_argument(
58+ '--machine-type',
59+ help='Which virtual machine/container type to test. Defaults to all.',
60+ choices=[KVM_MACHINE, LXC_MACHINE])
61+ parser.add_argument(
62+ '--clean-environment', action='store_true', help=dedent("""\
63+ Attempts to re-use an existing environment rather than destroying it
64+ and creating a new one.
65+
66+ On launch, if an environment exists, clean out services and machines
67+ from it rather than destroying it. If an environment doesn't exist,
68+ create one and use it.
69+
70+ At termination, clean out services and machines from the environment
71+ rather than destroying it."""))
72+ return parser.parse_args(argv)
73+
74+
75+def ssh(client, machine, cmd):
76+ """Convenience function: run a juju ssh command and get back the output
77+ :param client: A Juju client
78+ :param machine: ID of the machine on which to run a command
79+ :param cmd: the command to run
80+ :return: text output of the command
81+ """
82+ return client.get_juju_output('ssh', machine, cmd)
83+
84+
85+def clean_environment(client, services_only=False):
86+ """Remove all the services and, optionally, machines from an environment.
87+
88+ Use as an alternative to destroying an environment and creating a new one
89+ to save a bit of time.
90+
91+ :param client: a Juju client
92+ """
93+ status = client.get_status()
94+ for service in status.status['services']:
95+ client.juju('remove-service', service)
96+
97+ if not services_only:
98+ # First remove all containers; we can't remove a machine that is
99+ # hosting containers.
100+ for m, _ in status.iter_machines(containers=True, machines=False):
101+ client.juju('remove-machine', m)
102+
103+ client.wait_for('containers', 'none')
104+
105+ for m, _ in status.iter_machines(containers=False, machines=True):
106+ if m != '0':
107+ client.juju('remove-machine', m)
108+
109+ client.wait_for('machines-not-0', 'none')
110+
111+ client.wait_for_started()
112+
113+
114+class MachineGetter:
115+ def __init__(self, client):
116+ """Get or allocate machines in this Juju environment
117+ :param client: Juju client
118+ """
119+ self.client = client
120+ self.count = None
121+ self.requested_machines = []
122+ self.allocated_machines = []
123+
124+ def get(self, container_type=None, machine=None, container=None, count=1):
125+ """Get one or more machines or containers that match the given spec.
126+ :param container_type: machine type ('machine', KVM_MACHINE,
127+ LXC_MACHINE)
128+ :param machine: obtain a specific machine if available
129+ :param container: obtain a specific container on the given machine if
130+ available
131+ :param count: number of machines to allocate/create
132+ :return: list of machine IDs
133+ """
134+
135+ # Allow user to send integers for ease of calling
136+ if machine is not None:
137+ machine = str(machine)
138+ if container is not None:
139+ container = str(container)
140+
141+ self.count = count
142+ self.requested_machines = []
143+ self.allocated_machines = []
144+
145+ status = self.client.get_status().status
146+
147+ for s in status['services'].values():
148+ for u in s['units'].values():
149+ self.allocated_machines.append(u['machine'])
150+
151+ if container_type in [KVM_MACHINE, LXC_MACHINE]:
152+ self._get_container(status, machine, container, container_type)
153+ elif container_type is None:
154+ self._get_machine(status, machine)
155+ else:
156+ raise ValueError("Unrecognised container type %r" % container_type)
157+
158+ return self.requested_machines
159+
160+ def _add_new_machines(self, machine_spec):
161+ req_machines = [machine_spec for _ in range(self._new_machine_count())]
162+ self._add_machines_worker(req_machines)
163+ self.client.wait_for_started(60 * 10)
164+
165+ def _add_machines_worker(self, machines):
166+ if len(machines) > 0:
167+ with self.client.juju_async('add-machine', machines[0]):
168+ if len(machines) > 1:
169+ self._add_machines_worker(machines[1:])
170+
171+ def _new_machine_count(self):
172+ return self.count - len(self.requested_machines)
173+
174+ def _get_machine(self, status, machine):
175+ # If we have the requested machine, return it if it exists
176+ if machine:
177+ if machine in status['machines']:
178+ self.requested_machines.append(machine)
179+ return
180+
181+ self._allocate_free_machines()
182+ self._add_new_machines('')
183+ self._allocate_free_machines()
184+
185+ def _get_container(self, status, machine, container, container_type):
186+ if machine is not None and machine in status['machines']:
187+ hosts = [machine]
188+
189+ if container is not None:
190+ name = '/'.join([machine, container_type, container])
191+ m = status['machines'][machine]
192+ if 'containers' in m and name in m['containers']:
193+ self.requested_machines.append(name)
194+ return
195+ else:
196+ hosts = sorted(status['machines'].keys())
197+
198+ self._allocate_free_containers(hosts, container_type)
199+ req = container_type + ':' + (machine or '0')
200+ self._add_new_machines(req)
201+ self._allocate_free_containers(hosts, container_type)
202+
203+ def _allocate_free_containers(self, hosts, container_type):
204+ status = self.client.get_status().status
205+ for m in hosts:
206+ if 'containers' in status['machines'][m]:
207+ for c in status['machines'][m]['containers']:
208+ self._try_allocation(c, container_type)
209+
210+ def _allocate_free_machines(self):
211+ status = self.client.get_status().status
212+ for m in status['machines']:
213+ self._try_allocation(m)
214+
215+ def _try_allocation(self, machine, container_type=None):
216+ if (machine not in self.requested_machines and
217+ machine not in self.allocated_machines):
218+ if container_type is not None:
219+ if container_type != machine.split('/')[1]:
220+ return
221+
222+ self.requested_machines.append(machine)
223+ if self._new_machine_count() == 0:
224+ return
225+
226+
227+def find_network(client, machine, addr):
228+ """Find a connected subnet containing the given address.
229+
230+ When using this to find the subnet of a container, don't use the container
231+ as the machine to run the ip route show command on ("machine"), use a real
232+ box because lxc will just send everything to its host machine, so it is on
233+ a subnet containing itself. Not much use.
234+ :param client: A Juju client
235+ :param machine: ID of the machine on which to run a command
236+ :param addr: find the connected subnet containing this address
237+ :return: CIDR containing the address if found, else, None
238+ """
239+ ip_cmd = ' '.join(['ip', 'route', 'show', 'to', 'match', addr])
240+ routes = ssh(client, machine, ip_cmd)
241+
242+ for route in re.findall(r'^(\S+).*[\d\.]+/\d+', routes, re.MULTILINE):
243+ if route != 'default':
244+ return route
245+
246+ raise ValueError("Unable to find route to %r" % addr)
247+
248+
249+def assess_network_traffic(client, targets):
250+ """Test that all containers in target can talk to target[0]
251+ :param client: Juju client
252+ :param targets: machine IDs of machines to test
253+ :return: None;
254+ """
255+ status = client.wait_for_started().status
256+ source = targets[0]
257+ dests = targets[1:]
258+
259+ with tempfile.NamedTemporaryFile(delete=False) as f:
260+ f.write('tmux new-session -d -s test "nc -l 6778 > nc_listen.out"')
261+ client.juju('scp', (f.name, source + ':/home/ubuntu/listen.sh'))
262+ os.remove(f.name)
263+
264+ # Containers are named 'x/type/y' where x is the host of the container. We
265+ host = source.split('/')[0]
266+ address = status['machines'][host]['containers'][source]['dns-name']
267+
268+ for dest in dests:
269+ ssh(client, source, 'rm nc_listen.out; bash ./listen.sh')
270+ ssh(client, dest, 'echo "hello" | nc ' + address + ' 6778')
271+ result = ssh(client, source, 'more nc_listen.out')
272+ if result.rstrip() != 'hello':
273+ raise ValueError("Wrong or missing message: %r" % result.rstrip())
274+
275+
276+def assess_address_range(client, targets):
277+ """Test that two containers are in the same subnet as their host
278+ :param client: Juju client
279+ :param targets: machine IDs of machines to test
280+ :return: None; will assert failures
281+ """
282+ status = client.wait_for_started().status
283+
284+ host = targets[0].split('/')[0]
285+ host_address = socket.gethostbyname(status['machines'][host]['dns-name'])
286+ host_subnet = find_network(client, host, host_address)
287+
288+ for target in targets:
289+ vm_host = target.split('/')[0]
290+ addr = status['machines'][vm_host]['containers'][target]['dns-name']
291+ subnet = find_network(client, host, addr)
292+ assert host_subnet == subnet, \
293+ '{} ({}) not on the same subnet as {} ({})'.format(
294+ target, subnet, host, host_subnet)
295+
296+
297+def assess_internet_connection(client, targets):
298+ """Test that targets can ping Google's DNS server, google.com
299+ :param client: Juju client
300+ :param targets: machine IDs of machines to test
301+ :return: None; will assert failures
302+ """
303+
304+ for target in targets:
305+ routes = ssh(client, target, 'ip route show')
306+
307+ d = re.search(r'^default\s+via\s+([\d\.]+)\s+', routes, re.MULTILINE)
308+ if d:
309+ rc = client.juju('ssh', (target, 'ping -c1 -q ' + d.group(1)),
310+ check=False)
311+ if rc != 0:
312+ raise ValueError('%s unable to ping default route' % target)
313+ else:
314+ raise ValueError("Default route not found")
315+
316+
317+def _assessment_iteration(client, containers):
318+ """Run the network tests on this collection of machines and containers
319+ :param client: Juju client
320+ :param hosts: list of hosts of containers
321+ :param containers: list of containers to run tests between
322+ :return: None
323+ """
324+ assess_internet_connection(client, containers)
325+ assess_address_range(client, containers)
326+ assess_network_traffic(client, containers)
327+
328+
329+def _assess_container_networking(client, args):
330+ """Run _assessment_iteration on all useful combinations of containers
331+ :param client: Juju client
332+ :param args: Parsed command line arguments
333+ :return: None
334+ """
335+ # Only test the containers we were asked to test
336+ if args.machine_type:
337+ types = [args.machine_type]
338+ else:
339+ types = [KVM_MACHINE, LXC_MACHINE]
340+
341+ mg = MachineGetter(client)
342+ hosts = mg.get(count=2)
343+
344+ for container_type in types:
345+ # Test with two containers on the same host
346+ containers = mg.get(container_type, count=2)
347+ _assessment_iteration(client, containers)
348+
349+ # Now test with two containers on two different hosts
350+ containers = [mg.get(container_type, machine=hosts[0])[0],
351+ mg.get(container_type, machine=hosts[1])[0]]
352+ _assessment_iteration(client, containers)
353+
354+ if args.machine_type is None:
355+ # Test with an LXC and a KVM on the same machine
356+ containers = [mg.get(LXC_MACHINE, machine=hosts[0])[0],
357+ mg.get(KVM_MACHINE, machine=hosts[0])[0]]
358+ _assessment_iteration(client, containers)
359+
360+ # Test with an LXC and a KVM on different machines
361+ containers = [mg.get(LXC_MACHINE, machine=hosts[0])[0],
362+ mg.get(KVM_MACHINE, machine=hosts[1])[0]]
363+ _assessment_iteration(client, containers)
364+
365+
366+def assess_container_networking(client, args):
367+ """Runs _assess_address_allocation, reboots hosts, repeat.
368+ :param client: Juju client
369+ :param args: Parsed command line arguments
370+ :return: None
371+ """
372+ _assess_container_networking(client, args)
373+ mg = MachineGetter(client)
374+ hosts = mg.get(count=2)
375+ for host in hosts:
376+ ssh(client, host, 'sudo reboot')
377+ client.wait_for_started()
378+ _assess_container_networking(client, args)
379+
380+
381+def main():
382+ args = parse_args()
383+ client = EnvJujuClient.by_version(
384+ SimpleEnvironment.from_config(args.env),
385+ os.path.join(args.juju_bin, 'juju'), args.debug)
386+ client.enable_container_address_allocation()
387+ update_env(client.env, args.temp_env_name)
388+ juju_home = get_juju_home()
389+ bootstrap_host = None
390+ try:
391+ if args.clean_environment:
392+ try:
393+ clean_environment(client, services_only=True)
394+ except Exception as e:
395+ logging.exception(e)
396+ with temp_bootstrap_env(juju_home, client):
397+ client.bootstrap(args.upload_tools)
398+ else:
399+ client.destroy_environment()
400+ client = make_client(
401+ args.juju_bin, args.debug, args.env, args.temp_env_name)
402+ with temp_bootstrap_env(juju_home, client):
403+ client.bootstrap(args.upload_tools)
404+
405+ logging.info('Waiting for the bootstrap machine agent to start.')
406+ status = client.wait_for_started()
407+ mid, data = list(status.iter_machines())[0]
408+ bootstrap_host = data['dns-name']
409+
410+ assess_container_networking(client, args)
411+
412+ except Exception as e:
413+ logging.exception(e)
414+ try:
415+ if bootstrap_host is None:
416+ parse_new_state_server_from_error(e)
417+ else:
418+ dump_env_logs(client, bootstrap_host, args.logs)
419+ except Exception as e:
420+ print_now('exception while dumping logs:\n')
421+ logging.exception(e)
422+ exit(1)
423+ finally:
424+ if args.clean_environment:
425+ clean_environment(client, services_only=True)
426+ else:
427+ client.destroy_environment()
428+
429+
430+if __name__ == '__main__':
431+ main()
432
433=== modified file 'jujupy.py'
434--- jujupy.py 2015-09-15 21:51:42 +0000
435+++ jujupy.py 2015-09-21 15:30:36 +0000
436@@ -173,6 +173,10 @@
437 prefix = get_timeout_prefix(timeout, self._timeout_path)
438 logging = '--debug' if self.debug else '--show-log'
439
440+ # If args is a string, make it a tuple. This makes writing commands
441+ # with one argument a bit nicer.
442+ if not isinstance(args, tuple) and isinstance(args, basestring):
443+ args = (args,)
444 # we split the command here so that the caller can control where the -e
445 # <env> flag goes. Everything in the command string is put before the
446 # -e flag.
447@@ -518,6 +522,34 @@
448 else:
449 raise Exception('Timed out waiting for services to start.')
450
451+ def wait_for(self, thing, search_type, timeout=300):
452+ for status in self.status_until(timeout):
453+ hit = False
454+ miss = False
455+
456+ for machine, details in status.status['machines'].iteritems():
457+ if thing == 'containers':
458+ if 'containers' in details:
459+ hit = True
460+ else:
461+ miss = True
462+
463+ if thing == 'not-machine-0':
464+ if machine != '0':
465+ hit = True
466+ else:
467+ miss = True
468+
469+ if search_type == 'none':
470+ if not hit:
471+ return
472+ elif search_type == 'some':
473+ if hit:
474+ return
475+ elif search_type == 'all':
476+ if not miss:
477+ return
478+
479 def get_matching_agent_version(self, no_build=False):
480 # strip the series and srch from the built version.
481 version_parts = self.version.split('-')
482@@ -629,6 +661,7 @@
483 def __init__(self, *args, **kwargs):
484 super(EnvJujuClient26, self).__init__(*args, **kwargs)
485 self._use_jes = False
486+ self._use_container_address_allocation = False
487
488 def enable_jes(self):
489 if self._use_jes:
490@@ -640,11 +673,16 @@
491 self._use_jes = False
492 raise JESNotSupported()
493
494+ def enable_container_address_allocation(self):
495+ self._use_container_address_allocation = True
496+
497 def _get_feature_flags(self):
498 if self.env.config.get('type') == 'cloudsigma':
499 yield 'cloudsigma'
500 if self._use_jes is True:
501 yield 'jes'
502+ if self._use_container_address_allocation:
503+ yield 'address-allocation'
504
505 def _shell_environ(self):
506 """Generate a suitable shell environment.
507@@ -845,9 +883,10 @@
508 status_yaml = yaml_loads(text)
509 return cls(status_yaml, text)
510
511- def iter_machines(self, containers=False):
512+ def iter_machines(self, containers=False, machines=True):
513 for machine_name, machine in sorted(self.status['machines'].items()):
514- yield machine_name, machine
515+ if machines:
516+ yield machine_name, machine
517 if containers:
518 for contained, unit in machine.get('containers', {}).items():
519 yield contained, unit
520
521=== added file 'test_container_networking.py'
522--- test_container_networking.py 1970-01-01 00:00:00 +0000
523+++ test_container_networking.py 2015-09-21 15:30:36 +0000
524@@ -0,0 +1,410 @@
525+from mock import (
526+ patch,
527+ Mock,
528+)
529+from unittest import TestCase
530+from jujupy import (
531+ EnvJujuClient,
532+ SimpleEnvironment,
533+ Status,
534+)
535+
536+import sys
537+from StringIO import StringIO
538+import assess_container_networking as jcnet
539+from copy import deepcopy
540+from contextlib import contextmanager
541+
542+
543+class JujuMock(object):
544+ """A mock of the parts of the Juju command that the tests hit."""
545+
546+ def __init__(self):
547+ self._call_n = 0
548+ self._status = {'services': {},
549+ 'machines': {'0': {}}}
550+ self.commands = []
551+ self.next_machine = 1
552+ self._ssh_output = []
553+
554+ def add_machine(self, name):
555+ if name == '':
556+ name = str(self.next_machine)
557+ self.next_machine += 1
558+
559+ bits = name.split(':')
560+ if len(bits) > 1:
561+ # is a container
562+ machine = bits[1]
563+ container_type = bits[0]
564+ if machine not in self._status['machines']:
565+ self._status['machines'][machine] = {}
566+ if 'containers' not in self._status['machines'][machine]:
567+ self._status['machines'][machine]['containers'] = {}
568+
569+ n = 0
570+ c_name = machine + '/' + container_type + '/' + str(n)
571+ while c_name in self._status['machines'][machine]['containers']:
572+ n += 1
573+ c_name = machine + '/' + container_type + '/' + str(n)
574+ self._status['machines'][machine]['containers'][c_name] = {}
575+ else:
576+ # normal machine
577+ self._status['machines'][name] = {}
578+
579+ def add_service(self, name, machine=0, instance_number=1):
580+ # We just add a hunk of data captured from a real Juju run and don't
581+ # worry about how it doesn't match reality. It is enough to exercise
582+ # the code under test.
583+ new_service = {
584+ 'units': {
585+ name + '/' + str(instance_number): {
586+ 'machine': str(machine),
587+ 'public-address': 'noxious-disgust.maas',
588+ 'workload-status': {
589+ 'current': 'unknown',
590+ 'since': '06 Aug 2015 11:39:29+01:00'},
591+ 'agent-status': {
592+ 'current': 'idle',
593+ 'since': '06 Aug 2015 11:39:33+01:00',
594+ 'version': '1.25-alpha1.1'},
595+ 'agent-state': 'started',
596+ 'agent-version': '1.25-alpha1.1'}
597+ },
598+ 'service-status': {
599+ 'current': 'unknown',
600+ 'since': '06 Aug 2015 11:39:29+01:00'
601+ },
602+ 'charm': 'cs:trusty/{}-26'.format(name),
603+ 'relations': {'cluster': [name]},
604+ 'exposed': False}
605+ self._status['services'][name] = deepcopy(new_service)
606+
607+ def juju(self, cmd, *args, **kwargs):
608+ if len(args) == 1:
609+ args = args[0]
610+ self.commands.append((cmd, args))
611+ if cmd == 'remove-service' and args in self._status['services']:
612+ del self._status['services'][args]
613+
614+ elif cmd == 'remove-machine':
615+ if args in self._status['machines']:
616+ del self._status['machines'][args]
617+ else:
618+ machine = args.split('/')[0]
619+ del self._status['machines'][machine]['containers'][args]
620+
621+ if len(self._status['machines'][machine]['containers']) == 0:
622+ del self._status['machines'][machine]['containers']
623+
624+ elif cmd == 'add-machine':
625+ self.add_machine(args)
626+
627+ elif cmd == 'ssh':
628+ if len(self._ssh_output) == 0:
629+ return ""
630+
631+ try:
632+ return self._ssh_output[self._call_number()]
633+ except IndexError:
634+ # If we ran out of values, just return the last one
635+ return self._ssh_output[-1]
636+
637+ @contextmanager
638+ def juju_async(self, cmd, args):
639+ self.juju(cmd, args)
640+ yield
641+ pass
642+
643+ def _call_number(self):
644+ call_number = self._call_n
645+ self._call_n += 1
646+ return call_number
647+
648+ def get_status(self, machine_id=None):
649+ return Status(deepcopy(self._status), "")
650+
651+ def set_status(self, status):
652+ self._status = deepcopy(status)
653+
654+ def set_ssh_output(self, ssh_output):
655+ self._ssh_output = deepcopy(ssh_output)
656+
657+ def reset_calls(self):
658+ self._call_n = 0
659+
660+
661+class TestContainerNetworking(TestCase):
662+ def setUp(self):
663+ self.client = EnvJujuClient(
664+ SimpleEnvironment('foo', {'type': 'local'}), '1.234-76', None)
665+
666+ nil_func = lambda *args, **kw: None
667+ self.juju_mock = JujuMock()
668+ self.ssh_mock = Mock()
669+
670+ patches = [
671+ patch.object(self.client, 'juju', self.juju_mock.juju),
672+ patch.object(self.client, 'get_status', self.juju_mock.get_status),
673+ patch.object(self.client, 'juju_async', self.juju_mock.juju_async),
674+ patch.object(self.client, 'wait_for', nil_func),
675+ patch.object(self.client, 'wait_for_started',
676+ self.juju_mock.get_status),
677+ patch.object(self.client, 'get_juju_output', self.juju_mock.juju),
678+ ]
679+
680+ for patcher in patches:
681+ patcher.start()
682+ self.addCleanup(patcher.stop)
683+
684+ def assert_ssh(self, args, machine, cmd):
685+ self.assertEqual(args, [('ssh', machine, cmd), ])
686+
687+ def test_parse_args(self):
688+ # Test a simple command line that should work
689+ cmdline = ['env', 'juju_bin', 'logs', 'ten']
690+ args = jcnet.parse_args(cmdline)
691+ self.assertEqual(args.machine_type, None)
692+ self.assertEqual(args.juju_bin, 'juju_bin')
693+ self.assertEqual(args.env, 'env')
694+ self.assertEqual(args.logs, 'logs')
695+ self.assertEqual(args.temp_env_name, 'ten')
696+ self.assertEqual(args.debug, False)
697+ self.assertEqual(args.upload_tools, False)
698+ self.assertEqual(args.clean_environment, False)
699+
700+ # check the optional arguments
701+ opts = ['--machine-type', jcnet.KVM_MACHINE, '--debug',
702+ '--upload-tools', '--clean-environment']
703+ args = jcnet.parse_args(cmdline + opts)
704+ self.assertEqual(args.machine_type, jcnet.KVM_MACHINE)
705+ self.assertEqual(args.debug, True)
706+ self.assertEqual(args.upload_tools, True)
707+ self.assertEqual(args.clean_environment, True)
708+
709+ # Now check that we can only set machine_type to kvm or lxc
710+ opts = ['--machine-type', jcnet.LXC_MACHINE]
711+ args = jcnet.parse_args(cmdline + opts)
712+ self.assertEqual(args.machine_type, jcnet.LXC_MACHINE)
713+
714+ # Set up an error (bob is invalid)
715+ opts = ['--machine-type', 'bob']
716+
717+ # Kill stderr so the test doesn't print anything (calling the tests
718+ # with buffer=True would also do this, but I am trying not to be
719+ # invasive)
720+ sys.stderr = StringIO()
721+ self.assertRaises(SystemExit, jcnet.parse_args, cmdline + opts)
722+
723+ # Restore stderr
724+ sys.stderr = sys.__stderr__
725+
726+ def test_ssh(self):
727+ machine, addr = '0', 'foobar'
728+ with patch.object(self.client, 'get_juju_output',
729+ autospec=True) as ssh_mock:
730+ jcnet.ssh(self.client, machine, addr)
731+ self.assertEqual(1, ssh_mock.call_count)
732+ self.assert_ssh(ssh_mock.call_args, machine, addr)
733+
734+ def test_find_network(self):
735+ machine, addr = '0', '1.1.1.1'
736+ self.assertRaises(ValueError,
737+ jcnet.find_network, self.client, machine, addr)
738+
739+ self.juju_mock.set_ssh_output([
740+ 'default via 192.168.0.1 dev eth3\n'
741+ '1.1.1.0/24 dev eth3 proto kernel scope link src 1.1.1.22',
742+ ])
743+ self.juju_mock.commands = []
744+ jcnet.find_network(self.client, machine, addr)
745+ self.assertItemsEqual(self.juju_mock.commands,
746+ [('ssh', (
747+ machine, 'ip route show to match ' + addr))])
748+
749+ def test_clean_environment(self):
750+ self.juju_mock.add_machine('1')
751+ self.juju_mock.add_machine('2')
752+ self.juju_mock.add_service('name')
753+
754+ jcnet.clean_environment(self.client)
755+ self.assertEqual(3, len(self.juju_mock.commands))
756+ self.assertItemsEqual(self.juju_mock.commands, [
757+ ('remove-service', 'name'),
758+ ('remove-machine', '1'),
759+ ('remove-machine', '2'),
760+ ])
761+
762+ def test_clean_environment_with_containers(self):
763+ self.juju_mock.add_machine('1')
764+ self.juju_mock.add_machine('2')
765+ self.juju_mock.add_machine('lxc:0')
766+ self.juju_mock.add_machine('kvm:0')
767+
768+ jcnet.clean_environment(self.client)
769+ self.assertEqual(4, len(self.juju_mock.commands))
770+ self.assertItemsEqual(self.juju_mock.commands, [
771+ ('remove-machine', '0/lxc/0'),
772+ ('remove-machine', '0/kvm/0'),
773+ ('remove-machine', '1'),
774+ ('remove-machine', '2')
775+ ])
776+
777+ def test_clean_environment_just_services(self):
778+ self.juju_mock.add_machine('1')
779+ self.juju_mock.add_machine('2')
780+ self.juju_mock.add_machine('lxc:0')
781+ self.juju_mock.add_machine('kvm:0')
782+ self.juju_mock.add_service('name')
783+
784+ jcnet.clean_environment(self.client, True)
785+ self.assertEqual(1, len(self.juju_mock.commands))
786+ self.assertEqual(self.juju_mock.commands, [
787+ ('remove-service', 'name'),
788+ ])
789+
790+ def test_request_machines(self):
791+ mg = jcnet.MachineGetter(self.client)
792+ # Requesting a machine with none have been allocated will always
793+ # return machine 0, which always exists if we have an environment.
794+ self.assertEqual(mg.get(), ['0'])
795+
796+ # Since we haven't done anything with machine 0, we will still get
797+ # it back
798+ self.assertEqual(mg.get(), ['0'])
799+
800+ # Adding a service will remove the machine it is on from the
801+ # available pool. Requesting a machine will allocate a new one.
802+ self.juju_mock.add_service('name', 0)
803+ self.assertEqual(mg.get(), ['1'])
804+
805+ # Request a specific machine without services allocated to it.
806+ self.assertEqual(mg.get(machine=1), ['1'])
807+
808+ # Request a specific machine with services allocated to it
809+ self.assertEqual(mg.get(machine=0), ['0'])
810+
811+ # Requesting a specific machine that doesn't exist returns nothing
812+ self.assertEqual(mg.get(machine=2), [])
813+
814+ # Request more machines than are allocated - a new one appears
815+ self.assertEqual(mg.get(count=2), ['1', '2'])
816+
817+ # Still there!
818+ self.assertEqual(mg.get(count=2), ['1', '2'])
819+
820+ # Now containers (kvm). Ask for a kvm
821+ self.assertEqual(mg.get(jcnet.KVM_MACHINE), ['0/kvm/0'])
822+
823+ # Ask for a kvm on machine 1
824+ self.assertEqual(mg.get(jcnet.KVM_MACHINE, '1'), ['1/kvm/0'])
825+
826+ # Ask for two kvms on machine 1
827+ machines = mg.get(jcnet.KVM_MACHINE, '1', count=2)
828+ self.assertEqual(machines, ['1/kvm/0', '1/kvm/1'])
829+
830+ # Ask for a specific kvm
831+ self.assertEqual(mg.get(jcnet.KVM_MACHINE, '1', '1'), ['1/kvm/1'])
832+
833+ # Ask for a specific kvm that doesn't exist
834+ self.assertEqual(mg.get(jcnet.KVM_MACHINE, '1', '2'), [])
835+
836+ # Now containers (lxc). Ask for an lxc
837+ self.assertEqual(mg.get(jcnet.LXC_MACHINE), ['0/lxc/0'])
838+
839+ # Ask for a lxc on machine 1
840+ self.assertEqual(mg.get(jcnet.LXC_MACHINE, '1'), ['1/lxc/0'])
841+
842+ # Ask for two lxcs on machine 1
843+ machines = mg.get(jcnet.LXC_MACHINE, '1', count=2)
844+ self.assertEqual(machines, ['1/lxc/0', '1/lxc/1'])
845+
846+ # Ask for a specific lxc
847+ self.assertEqual(mg.get(jcnet.LXC_MACHINE, '1', '1'), ['1/lxc/1'])
848+
849+ # Ask for a specific lxc that doesn't exist
850+ self.assertEqual(mg.get(jcnet.LXC_MACHINE, '1', '2'), [])
851+
852+ def test_test_network_traffic(self):
853+ targets = ['0/lxc/0', '0/lxc/1']
854+ self.juju_mock.set_status({'machines': {'0': {
855+ 'containers': {targets[0]: {'dns-name': '0-dns-name'}}}}})
856+
857+ self.juju_mock.set_ssh_output(['', '', 'hello'])
858+ jcnet.assess_network_traffic(self.client, targets)
859+
860+ self.juju_mock.reset_calls()
861+ self.juju_mock.set_ssh_output(['', '', 'fail'])
862+ self.assertRaises(ValueError,
863+ jcnet.assess_network_traffic, self.client, targets)
864+
865+ def test_test_address_range(self):
866+ rtn = Mock(**{'r.side_effect': [
867+ '192.168.0.2', '192.168.0.3', '192.168.0.4', '192.168.0.5']})
868+ with patch('assess_container_networking.socket.gethostbyname',
869+ rtn.r):
870+ targets = ['0/lxc/0', '0/lxc/1']
871+ self.juju_mock.set_status({'machines': {'0': {
872+ 'containers': {
873+ targets[0]: {'dns-name': 'lxc0-dns-name'},
874+ targets[1]: {'dns-name': 'lxc1-dns-name'},
875+ },
876+ 'dns-name': '0-dns-name',
877+ }}})
878+ self.juju_mock.set_ssh_output(['192.168.0.0/24'])
879+
880+ jcnet.assess_address_range(self.client, targets)
881+
882+ def test_test_address_range_fail(self):
883+ rtn = Mock(**{'r.side_effect': [
884+ '192.168.0.2', '192.168.0.3', '192.168.0.4', '192.168.0.5']})
885+ with patch('assess_container_networking.socket.gethostbyname',
886+ rtn.r):
887+ targets = ['0/lxc/0', '0/lxc/1']
888+ self.juju_mock.set_status({'machines': {'0': {
889+ 'containers': {
890+ targets[0]: {'dns-name': 'lxc0-dns-name'},
891+ targets[1]: {'dns-name': 'lxc1-dns-name'},
892+ },
893+ 'dns-name': '0-dns-name',
894+ }}})
895+ self.juju_mock.set_ssh_output([
896+ '192.168.0.0/24',
897+ '192.168.1.0/24',
898+ '192.168.2.0/24',
899+ '192.168.3.0/24',
900+ ])
901+
902+ self.assertRaises(AssertionError,
903+ jcnet.assess_address_range, self.client, targets)
904+
905+ def test_test_internet_connection(self):
906+ targets = ['0/lxc/0', '0/lxc/1']
907+ self.juju_mock.set_status({'machines': {'0': {
908+ 'containers': {
909+ targets[0]: {'dns-name': 'lxc0-dns-name'},
910+ targets[1]: {'dns-name': 'lxc1-dns-name'},
911+ },
912+ 'dns-name': '0-dns-name',
913+ }}})
914+
915+ # Can ping default route
916+ self.juju_mock.set_ssh_output([
917+ 'default via 192.168.0.1 dev eth3', 0,
918+ 'default via 192.168.0.1 dev eth3', 0])
919+ jcnet.assess_internet_connection(self.client, targets)
920+
921+ # Can't ping default route
922+ self.juju_mock.set_ssh_output([
923+ 'default via 192.168.0.1 dev eth3', 1])
924+ self.juju_mock.reset_calls()
925+ self.assertRaisesRegexp(
926+ ValueError, "0/lxc/0 unable to ping default route",
927+ jcnet.assess_internet_connection, self.client, targets)
928+
929+ # Can't find default route
930+ self.juju_mock.set_ssh_output(['', 1])
931+ self.juju_mock.reset_calls()
932+ self.assertRaisesRegexp(
933+ ValueError, "Default route not found",
934+ jcnet.assess_internet_connection, self.client, targets)

Subscribers

People subscribed via source and target branches