Merge lp:~frankban/charms/precise/juju-gui/support-firewall into lp:~juju-gui/charms/precise/juju-gui/trunk

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 114
Proposed branch: lp:~frankban/charms/precise/juju-gui/support-firewall
Merge into: lp:~juju-gui/charms/precise/juju-gui/trunk
Diff against target: 1923 lines (+1275/-367)
11 files modified
config.yaml (+1/-1)
hooks/backend.py (+63/-44)
hooks/bootstrap_utils.py (+0/-77)
hooks/install (+17/-21)
hooks/shelltoolbox.py (+669/-0)
hooks/utils.py (+19/-8)
revision (+1/-1)
tests/20-functional.test (+5/-2)
tests/requirements.pip (+0/-2)
tests/test_backends.py (+450/-211)
tests/test_utils.py (+50/-0)
To merge this branch: bzr merge lp:~frankban/charms/precise/juju-gui/support-firewall
Reviewer Review Type Date Requested Status
charmers Pending
Review via email: mp+189645@code.launchpad.net

Description of the change

Avoid installing from PPA if not required.

No longer add the juju-gui PPA by default:
the external repository is added only if required,
i.e. if the legacy server is used or if a branch
is passed to juju-gui-source.

The only missing bit to make the charm work well
from behind a firewall AFAICT is avoiding the release
to be downloaded from Launchpad.

Also included the shelltoolbox file in the charm:
unfortunately the python-shelltoolbox package is
not available on precise. On the other hand, this
allows for getting rid of the bootstrap_utils.py
file, and the install hook now feels cleaner.

Refactoring + some magic removal on the backend
framework. Now it should be less surprising, and
also allows for more customizations, e.g. what
I did in the install method.

Also added missing tests for the backend framework:
those were required in order to increase our control
over what's really happening in the backend "hooks".

Switched to the builtin Tornado server by default.

This diff is very big, I am sorry, but:
- you can ignore the bootstrap_utils removal;
- you can ignore the shelltoolbox.py file: it is
  just a copy of the one present in the raring
  python-shelltoolbox package;
- a lot of code is tests, the rest of the code
  should be quite easy to follow.

QA:
    `make deploy` and watch the logs:
    - no PPA should be installed by default;
    - the deployment succeeds and the GUI works well;
    switch to builtin-server=false and watch the logs:
    - the PPA is installed (and then haproxy, apache...);
    - the config-change hook exits without errors and
      the GUI works well.

Tests:
    `make unittest`
    (I ran the functional tests myself).

Thank you!

https://codereview.appspot.com/14433049/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+189645_code.launchpad.net,

Message:
Please take a look.

Description:
Avoid installing from PPA if not required.

No longer add the juju-gui PPA by default:
the external repository is added only if required,
i.e. if the legacy server is used or if a branch
is passed to juju-gui-source.

The only missing bit to make the charm work well
from behind a firewall AFAICT is avoiding the release
to be downloaded from Launchpad.

Also included the shelltoolbox file in the charm:
unfortunately the python-shelltoolbox package is
not available on precise. On the other hand, this
allows for getting rid of the bootstrap_utils.py
file, and the install hook now feels cleaner.

Refactoring + some magic removal on the backend
framework. Now it should be less surprising, and
also allows for more customizations, e.g. what
I did in the install method.

Also added missing tests for the backend framework:
those were required in order to increase our control
over what's really happening in the backend "hooks".

Switched to the builtin Tornado server by default.

This diff is very big, I am sorry, but:
- you can ignore the bootstrap_utils removal;
- you can ignore the shelltoolbox.py file: it is
   just a copy of the one present in the raring
   python-shelltoolbox package;
- a lot of code is tests, the rest of the code
   should be quite easy to follow.

QA:
     `make deploy` and watch the logs:
     - no PPA should be installed by default;
     - the deployment succeeds and the GUI works well;
     switch to builtin-server=false and watch the logs:
     - the PPA is installed (and then haproxy, apache...);
     - the config-change hook exits without errors and
       the GUI works well.

Tests:
     `make unittest`
     (I ran the functional tests myself).

Thank you!

https://code.launchpad.net/~frankban/charms/precise/juju-gui/support-firewall/+merge/189645

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/14433049/

Affected files (+1261, -365 lines):
   A [revision details]
   M config.yaml
   M hooks/backend.py
   D hooks/bootstrap_utils.py
   M hooks/install
   A hooks/shelltoolbox.py
   M hooks/utils.py
   M revision
   M tests/requirements.pip
   M tests/test_backends.py
   M tests/test_utils.py

120. By Francesco Banconi

Fix functional test.

Revision history for this message
Gary Poster (gary) wrote :

Wow! Great tests, and very nice improvement. Code LGTM. I will check
with Rick as to which of us will QA.

Thank you,

Gary

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py
File hooks/backend.py (right):

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode116
hooks/backend.py:116: debs = ('curl',)
Does this belong somewhere else now? Or can it be deleted?

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode229
hooks/backend.py:229: If reverse is True, the method is called on the
reversed list of objects.
Delete this line? (functionality is gone)

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode310
hooks/backend.py:310: """Execute the charm starting steps."""
A subtle thing, but any one of these would read a bit better to me, as
ranked from my personal highest to lowest preference.

Execute the charm's "start" steps.
Execute the charm's starting steps.
Execute the charm-starting steps.

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode314
hooks/backend.py:314: """Execute the charm stopping steps."""
Perhaps the following?
----
Execute the charm's "stop" steps.

Iterates through the mixins in reverse order.

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode318
hooks/backend.py:318: """Execute the charm removal steps."""
WDYT?
---
Execute the charm removal steps.

Iterates through the mixins in reverse order.

https://codereview.appspot.com/14433049/diff/1/hooks/shelltoolbox.py
File hooks/shelltoolbox.py (right):

https://codereview.appspot.com/14433049/diff/1/hooks/shelltoolbox.py#newcode3
hooks/shelltoolbox.py:3: # This file is part of python-shell-toolbox.
I included comments about the source in hooks/charmhelpers.py. Check
what I did there. If you like it, include it here; otherwise, feel free
to ignore it.

https://codereview.appspot.com/14433049/

Revision history for this message
Richard Harding (rharding) wrote :

LGTM QA ok

For QA I did a deploy, ssh'd into the box to verify that the ppa,
apache/haproxy packages were not installed.

Checked the gui worked fine.

Then changed the config for the server. SSH'd back into the box to
verify that packages were then installed. Gui still worked fine.

https://codereview.appspot.com/14433049/

121. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :
Download full text (4.1 KiB)

*** Submitted:

Avoid installing from PPA if not required.

No longer add the juju-gui PPA by default:
the external repository is added only if required,
i.e. if the legacy server is used or if a branch
is passed to juju-gui-source.

The only missing bit to make the charm work well
from behind a firewall AFAICT is avoiding the release
to be downloaded from Launchpad.

Also included the shelltoolbox file in the charm:
unfortunately the python-shelltoolbox package is
not available on precise. On the other hand, this
allows for getting rid of the bootstrap_utils.py
file, and the install hook now feels cleaner.

Refactoring + some magic removal on the backend
framework. Now it should be less surprising, and
also allows for more customizations, e.g. what
I did in the install method.

Also added missing tests for the backend framework:
those were required in order to increase our control
over what's really happening in the backend "hooks".

Switched to the builtin Tornado server by default.

This diff is very big, I am sorry, but:
- you can ignore the bootstrap_utils removal;
- you can ignore the shelltoolbox.py file: it is
   just a copy of the one present in the raring
   python-shelltoolbox package;
- a lot of code is tests, the rest of the code
   should be quite easy to follow.

QA:
     `make deploy` and watch the logs:
     - no PPA should be installed by default;
     - the deployment succeeds and the GUI works well;
     switch to builtin-server=false and watch the logs:
     - the PPA is installed (and then haproxy, apache...);
     - the config-change hook exits without errors and
       the GUI works well.

Tests:
     `make unittest`
     (I ran the functional tests myself).

Thank you!

R=gary.poster, rharding
CC=
https://codereview.appspot.com/14433049

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py
File hooks/backend.py (right):

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode116
hooks/backend.py:116: debs = ('curl',)
On 2013/10/07 17:01:08, gary.poster wrote:
> Does this belong somewhere else now? Or can it be deleted?

Yeah, I was curious about that dependency too. I found that curl is used
by fetch_gui_release to download a release tarball from Launchpad. So,
formally it belongs here: added a comment describing what curl is used
for.

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode229
hooks/backend.py:229: If reverse is True, the method is called on the
reversed list of objects.
On 2013/10/07 17:01:08, gary.poster wrote:
> Delete this line? (functionality is gone)

Done thank you, and yes, my first implementation included that kwarg,
but then I realized it was unnecessary.

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newcode310
hooks/backend.py:310: """Execute the charm starting steps."""
On 2013/10/07 17:01:08, gary.poster wrote:
> A subtle thing, but any one of these would read a bit better to me, as
ranked
> from my personal highest to lowest preference.

> Execute the charm's "start" steps.
> Execute the charm's starting steps.
> Execute the charm-starting steps.

Done.

https://codereview.appspot.com/14433049/diff/1/hooks/backend.py#newco...

Read more...

Revision history for this message
Francesco Banconi (frankban) wrote :

Hi Gary and Rick, thanks for your reviews!

https://codereview.appspot.com/14433049/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'config.yaml'
2--- config.yaml 2013-09-30 20:16:09 +0000
3+++ config.yaml 2013-10-08 07:46:30 +0000
4@@ -183,7 +183,7 @@
5 This is a temporary option: the built-in server will be
6 the only server in the future.
7 type: boolean
8- default: false
9+ default: true
10 builtin-server-logging:
11 description: |
12 Set the GUI server log level. Possible values are debug, info, warning
13
14=== modified file 'hooks/backend.py'
15--- hooks/backend.py 2013-10-01 17:54:23 +0000
16+++ hooks/backend.py 2013-10-08 07:46:30 +0000
17@@ -42,8 +42,10 @@
18 import os
19 import shutil
20
21-import charmhelpers
22-import shelltoolbox
23+from charmhelpers import (
24+ log,
25+ open_port,
26+)
27
28 import utils
29
30@@ -52,6 +54,7 @@
31 """Handle the overall set up and clean up processes."""
32
33 def install(self, backend):
34+ log('Setting up base dir: {}.'.format(utils.BASE_DIR))
35 try:
36 os.makedirs(utils.BASE_DIR)
37 except OSError as err:
38@@ -60,6 +63,7 @@
39 raise
40
41 def destroy(self, backend):
42+ log('Cleaning up base dir: {}.'.format(utils.BASE_DIR))
43 shutil.rmtree(utils.BASE_DIR)
44
45
46@@ -109,25 +113,27 @@
47 class GuiMixin(object):
48 """Install and start the GUI and its dependencies."""
49
50+ # The curl package is used to download release tarballs from Launchpad.
51 debs = ('curl',)
52
53 def install(self, backend):
54 """Install the GUI and dependencies."""
55- # If the given installable thing ("backend") requires one or more debs
56- # that are not yet installed, install them.
57- missing = utils.find_missing_packages(*backend.debs)
58- if missing:
59- utils.cmd_log(
60- shelltoolbox.apt_get_install(*backend.debs))
61 # If the source setting has changed since the last time this was run,
62 # get the code, from either a static release or a branch as specified
63 # by the souce setting, and install it.
64 if backend.different('juju-gui-source'):
65 # Get a tarball somehow.
66- logpath = backend.config['command-log-file']
67 origin, version_or_branch = utils.parse_source(
68 backend.config['juju-gui-source'])
69 if origin == 'branch':
70+ logpath = backend.config['command-log-file']
71+ # Make sure we have the required build dependencies.
72+ # Note that we also need to add the juju-gui repository
73+ # containing our version of nodejs.
74+ log('Installing build dependencies.')
75+ utils.install_missing_packages(
76+ utils.DEB_BUILD_DEPENDENCIES,
77+ repository=backend.config['repository-location'])
78 branch_url, revision = version_or_branch
79 release_tarball_path = utils.fetch_gui_from_branch(
80 branch_url, revision, logpath)
81@@ -138,7 +144,7 @@
82 utils.setup_gui(release_tarball_path)
83
84 def start(self, backend):
85- charmhelpers.log('Starting Juju GUI.')
86+ log('Starting Juju GUI.')
87 config = backend.config
88 build_dir = utils.compute_build_dir(
89 config['juju-gui-debug'], config['serve-tests'])
90@@ -151,8 +157,8 @@
91 show_get_juju_button=config['show-get-juju-button'],
92 password=config.get('password'))
93 # Expose the service.
94- charmhelpers.open_port(80)
95- charmhelpers.open_port(443)
96+ open_port(80)
97+ open_port(443)
98
99
100 class ServerInstallMixinBase(object):
101@@ -175,6 +181,8 @@
102 """Manage haproxy and Apache via Upstart."""
103
104 debs = ('apache2', 'haproxy', 'openssl')
105+ # We need to add the juju-gui PPA containing our customized haproxy.
106+ ppa_required = True
107
108 def install(self, backend):
109 self._setup_certificates(backend)
110@@ -215,32 +223,15 @@
111 utils.stop_builtin_server()
112
113
114-def chain_methods(name, reverse=False):
115- """Helper to compose a set of mixin objects into a callable.
116+def call_methods(objects, name, *args):
117+ """For each given object, call, if present, the method named name.
118
119- Each method is called in the context of its mixin instance, and its
120- argument is the Backend instance.
121+ Pass the given args.
122 """
123- # Chain method calls through all implementing mixins.
124- def method(self):
125- mixins = reversed(self.mixins) if reverse else self.mixins
126- for mixin in mixins:
127- a_callable = getattr(type(mixin), name, None)
128- if a_callable is not None:
129- a_callable(mixin, self)
130- method.__name__ = name
131- return method
132-
133-
134-def merge_properties(name):
135- """Helper to merge one property from mixin objects into a unified set."""
136- @property
137- def method(self):
138- result = set()
139- for mixin in self.mixins:
140- result |= set(getattr(type(mixin), name, frozenset()))
141- return result
142- return method
143+ for obj in objects:
144+ method = getattr(obj, name, None)
145+ if method is not None:
146+ method(*args)
147
148
149 class Backend(object):
150@@ -296,11 +287,39 @@
151 current, previous = self.config.get, self.prev_config.get
152 return any(current(key) != previous(key) for key in keys)
153
154- # Composed methods.
155- install = chain_methods('install')
156- start = chain_methods('start')
157- stop = chain_methods('stop', reverse=True)
158- destroy = chain_methods('destroy', reverse=True)
159-
160- # Merged properties.
161- debs = merge_properties('debs')
162+ def get_dependencies(self):
163+ """Return a tuple (debs, repository) representing dependencies."""
164+ debs = set()
165+ needs_ppa = False
166+ # Collect the required dependencies and check if adding the juju-gui
167+ # PPA is required.
168+ for mixin in self.mixins:
169+ debs.update(getattr(mixin, 'debs', ()))
170+ if getattr(mixin, 'ppa_required', False):
171+ needs_ppa = True
172+ return debs, self.config['repository-location'] if needs_ppa else None
173+
174+ def install(self):
175+ """Execute the installation steps."""
176+ debs, repository = self.get_dependencies()
177+ log('Installing dependencies.')
178+ utils.install_missing_packages(debs, repository=repository)
179+ call_methods(self.mixins, 'install', self)
180+
181+ def start(self):
182+ """Execute the charm's "start" steps."""
183+ call_methods(self.mixins, 'start', self)
184+
185+ def stop(self):
186+ """Execute the charm's "stop" steps.
187+
188+ Iterate through the mixins in reverse order.
189+ """
190+ call_methods(reversed(self.mixins), 'stop', self)
191+
192+ def destroy(self):
193+ """Execute the charm removal steps.
194+
195+ Iterate through the mixins in reverse order.
196+ """
197+ call_methods(reversed(self.mixins), 'destroy', self)
198
199=== removed file 'hooks/bootstrap_utils.py'
200--- hooks/bootstrap_utils.py 2013-06-11 14:04:04 +0000
201+++ hooks/bootstrap_utils.py 1970-01-01 00:00:00 +0000
202@@ -1,77 +0,0 @@
203-# This file is part of the Juju GUI, which lets users view and manage Juju
204-# environments within a graphical interface (https://launchpad.net/juju-gui).
205-# Copyright (C) 2012-2013 Canonical Ltd.
206-#
207-# This program is free software: you can redistribute it and/or modify it under
208-# the terms of the GNU Affero General Public License version 3, as published by
209-# the Free Software Foundation.
210-#
211-# This program is distributed in the hope that it will be useful, but WITHOUT
212-# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
213-# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
214-# Affero General Public License for more details.
215-#
216-# You should have received a copy of the GNU Affero General Public License
217-# along with this program. If not, see <http://www.gnu.org/licenses/>.
218-
219-"""
220-These are actually maintained in python-shelltoolbox. Precise does not have
221-that package, so we need to bootstrap the process by copying the functions
222-we need here.
223-"""
224-
225-import subprocess
226-
227-
228-try:
229- import shelltoolbox
230-except ImportError:
231-
232- def run(*args, **kwargs):
233- """Run the command with the given arguments.
234-
235- The first argument is the path to the command to run.
236- Subsequent arguments are command-line arguments to be passed.
237-
238- This function accepts all optional keyword arguments accepted by
239- `subprocess.Popen`.
240- """
241- args = [i for i in args if i is not None]
242- pipe = subprocess.PIPE
243- process = subprocess.Popen(
244- args, stdout=kwargs.pop('stdout', pipe),
245- stderr=kwargs.pop('stderr', pipe),
246- close_fds=kwargs.pop('close_fds', True), **kwargs)
247- stdout, stderr = process.communicate()
248- if process.returncode:
249- exception = subprocess.CalledProcessError(
250- process.returncode, repr(args))
251- # The output argument of `CalledProcessError` was introduced in
252- # Python 2.7. Monkey patch the output here to avoid TypeErrors
253- # in older versions of Python, still preserving the output in
254- # Python 2.7.
255- exception.output = ''.join(filter(None, [stdout, stderr]))
256- raise exception
257- return stdout
258-
259- def install_extra_repositories(*repositories):
260- """Install all of the extra repositories and update apt.
261-
262- Given repositories can contain a "{distribution}" placeholder,
263- that will be replaced by current distribution codename.
264-
265- :raises: subprocess.CalledProcessError
266- """
267- distribution = run('lsb_release', '-cs').strip()
268- # Starting from Oneiric, `apt-add-repository` is interactive by
269- # default, and requires a "-y" flag to be set.
270- assume_yes = None if distribution == 'lucid' else '-y'
271- for repo in repositories:
272- repository = repo.format(distribution=distribution)
273- run('apt-add-repository', assume_yes, repository)
274- run('apt-get', 'clean')
275- run('apt-get', 'update')
276-
277-else:
278- install_extra_repositories = shelltoolbox.install_extra_repositories
279- run = shelltoolbox.run
280
281=== modified file 'hooks/install'
282--- hooks/install 2013-10-01 20:53:38 +0000
283+++ hooks/install 2013-10-08 07:46:30 +0000
284@@ -18,31 +18,27 @@
285 # along with this program. If not, see <http://www.gnu.org/licenses/>.
286
287 import errno
288-import json
289 import os
290
291-# We need to install the Juju PPA, which we will do with a couple of functions
292-# that are actually maintained in python-shelltoolbox.
293-import bootstrap_utils
294-
295-
296-def get_config():
297- output = bootstrap_utils.run('config-get', '--format', 'json')
298- return json.loads(output)
299-
300-config = get_config()
301-bootstrap_utils.install_extra_repositories(config['repository-location'])
302+from charmhelpers import (
303+ get_config,
304+ log,
305+)
306+from shelltoolbox import (
307+ apt_get_install,
308+ run,
309+)
310+
311
312 # Python dependencies must be installed here so that the charm can import and
313 # use required libraries.
314-PYTHON_DEPENDENCIES = (
315- 'python-apt', 'python-launchpadlib', 'python-shelltoolbox',
316- 'python-tempita',
317-)
318-bootstrap_utils.run(*(('apt-get', 'install', '-y') + PYTHON_DEPENDENCIES))
319-
320-
321-from charmhelpers import log
322+PYTHON_DEPENDENCIES = ('python-apt', 'python-launchpadlib', 'python-tempita')
323+
324+log('Installing base Python dependnecies: {}.'.format(
325+ ', '.join(PYTHON_DEPENDENCIES)))
326+apt_get_install(*PYTHON_DEPENDENCIES)
327+
328+
329 from utils import (
330 config_json,
331 log_hook,
332@@ -60,7 +56,7 @@
333 for module in dirnames:
334 filename = os.path.join('exec.d', module, 'charm-pre-install')
335 try:
336- bootstrap_utils.run(filename)
337+ run(filename)
338 except OSError, e:
339 # If the exec.d file does not exist or is not runnable or
340 # is not a directory, assume we can recover. Log the problem
341
342=== added file 'hooks/shelltoolbox.py'
343--- hooks/shelltoolbox.py 1970-01-01 00:00:00 +0000
344+++ hooks/shelltoolbox.py 2013-10-08 07:46:30 +0000
345@@ -0,0 +1,669 @@
346+# Copyright 2012 Canonical Ltd.
347+
348+# This file is taken from the python-shelltoolbox package.
349+#
350+# IMPORTANT: Do not modify this file to add or change functionality. If you
351+# really feel the need to do so, first convert our code to the shelltoolbox
352+# library, and modify it instead (or modify the helpers or utils module here,
353+# as appropriate).
354+#
355+# python-shell-toolbox is free software: you can redistribute it and/or modify
356+# it under the terms of the GNU General Public License as published by the
357+# Free Software Foundation, version 3 of the License.
358+#
359+# python-shell-toolbox is distributed in the hope that it will be useful, but
360+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
361+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
362+# more details.
363+#
364+# You should have received a copy of the GNU General Public License
365+# along with python-shell-toolbox. If not, see <http://www.gnu.org/licenses/>.
366+
367+"""Helper functions for accessing shell commands in Python."""
368+
369+__metaclass__ = type
370+__all__ = [
371+ 'apt_get_install',
372+ 'bzr_whois',
373+ 'cd',
374+ 'command',
375+ 'DictDiffer',
376+ 'environ',
377+ 'file_append',
378+ 'file_prepend',
379+ 'generate_ssh_keys',
380+ 'get_su_command',
381+ 'get_user_home',
382+ 'get_user_ids',
383+ 'install_extra_repositories',
384+ 'join_command',
385+ 'mkdirs',
386+ 'run',
387+ 'Serializer',
388+ 'script_name',
389+ 'search_file',
390+ 'ssh',
391+ 'su',
392+ 'user_exists',
393+ 'wait_for_page_contents',
394+ ]
395+
396+from collections import namedtuple
397+from contextlib import contextmanager
398+from email.Utils import parseaddr
399+import errno
400+import json
401+import operator
402+import os
403+import pipes
404+import pwd
405+import re
406+import subprocess
407+import sys
408+from textwrap import dedent
409+import time
410+import urllib2
411+
412+
413+Env = namedtuple('Env', 'uid gid home')
414+
415+
416+def apt_get_install(*args, **kwargs):
417+ """Install given packages using apt.
418+
419+ It is possible to pass environment variables to be set during install
420+ using keyword arguments.
421+
422+ :raises: subprocess.CalledProcessError
423+ """
424+ caller = kwargs.pop('caller', run)
425+ debian_frontend = kwargs.pop('DEBIAN_FRONTEND', 'noninteractive')
426+ with environ(DEBIAN_FRONTEND=debian_frontend, **kwargs):
427+ cmd = ('apt-get', '-y', 'install') + args
428+ return caller(*cmd)
429+
430+
431+def bzr_whois(user):
432+ """Return full name and email of bzr `user`.
433+
434+ Return None if the given `user` does not have a bzr user id.
435+ """
436+ with su(user):
437+ try:
438+ whoami = run('bzr', 'whoami')
439+ except (subprocess.CalledProcessError, OSError):
440+ return None
441+ return parseaddr(whoami)
442+
443+
444+@contextmanager
445+def cd(directory):
446+ """A context manager to temporarily change current working dir, e.g.::
447+
448+ >>> import os
449+ >>> os.chdir('/tmp')
450+ >>> with cd('/bin'): print os.getcwd()
451+ /bin
452+ >>> print os.getcwd()
453+ /tmp
454+ """
455+ cwd = os.getcwd()
456+ os.chdir(directory)
457+ try:
458+ yield
459+ finally:
460+ os.chdir(cwd)
461+
462+
463+def command(*base_args):
464+ """Return a callable that will run the given command with any arguments.
465+
466+ The first argument is the path to the command to run, subsequent arguments
467+ are command-line arguments to "bake into" the returned callable.
468+
469+ The callable runs the given executable and also takes arguments that will
470+ be appeneded to the "baked in" arguments.
471+
472+ For example, this code will list a file named "foo" (if it exists):
473+
474+ ls_foo = command('/bin/ls', 'foo')
475+ ls_foo()
476+
477+ While this invocation will list "foo" and "bar" (assuming they exist):
478+
479+ ls_foo('bar')
480+ """
481+ def callable_command(*args):
482+ all_args = base_args + args
483+ return run(*all_args)
484+
485+ return callable_command
486+
487+
488+@contextmanager
489+def environ(**kwargs):
490+ """A context manager to temporarily change environment variables.
491+
492+ If an existing environment variable is changed, it is restored during
493+ context cleanup::
494+
495+ >>> import os
496+ >>> os.environ['MY_VARIABLE'] = 'foo'
497+ >>> with environ(MY_VARIABLE='bar'): print os.getenv('MY_VARIABLE')
498+ bar
499+ >>> print os.getenv('MY_VARIABLE')
500+ foo
501+ >>> del os.environ['MY_VARIABLE']
502+
503+ If we are adding environment variables, they are removed during context
504+ cleanup::
505+
506+ >>> import os
507+ >>> with environ(MY_VAR1='foo', MY_VAR2='bar'):
508+ ... print os.getenv('MY_VAR1'), os.getenv('MY_VAR2')
509+ foo bar
510+ >>> os.getenv('MY_VAR1') == os.getenv('MY_VAR2') == None
511+ True
512+ """
513+ backup = {}
514+ for key, value in kwargs.items():
515+ backup[key] = os.getenv(key)
516+ os.environ[key] = value
517+ try:
518+ yield
519+ finally:
520+ for key, value in backup.items():
521+ if value is None:
522+ del os.environ[key]
523+ else:
524+ os.environ[key] = value
525+
526+
527+def file_append(filename, line):
528+ r"""Append given `line`, if not present, at the end of `filename`.
529+
530+ Usage example::
531+
532+ >>> import tempfile
533+ >>> f = tempfile.NamedTemporaryFile('w', delete=False)
534+ >>> f.write('line1\n')
535+ >>> f.close()
536+ >>> file_append(f.name, 'new line\n')
537+ >>> open(f.name).read()
538+ 'line1\nnew line\n'
539+
540+ Nothing happens if the file already contains the given `line`::
541+
542+ >>> file_append(f.name, 'new line\n')
543+ >>> open(f.name).read()
544+ 'line1\nnew line\n'
545+
546+ A new line is automatically added before the given `line` if it is not
547+ present at the end of current file content::
548+
549+ >>> import tempfile
550+ >>> f = tempfile.NamedTemporaryFile('w', delete=False)
551+ >>> f.write('line1')
552+ >>> f.close()
553+ >>> file_append(f.name, 'new line\n')
554+ >>> open(f.name).read()
555+ 'line1\nnew line\n'
556+
557+ The file is created if it does not exist::
558+
559+ >>> import tempfile
560+ >>> filename = tempfile.mktemp()
561+ >>> file_append(filename, 'line1\n')
562+ >>> open(filename).read()
563+ 'line1\n'
564+ """
565+ if not line.endswith('\n'):
566+ line += '\n'
567+ with open(filename, 'a+') as f:
568+ lines = f.readlines()
569+ if line not in lines:
570+ if not lines or lines[-1].endswith('\n'):
571+ f.write(line)
572+ else:
573+ f.write('\n' + line)
574+
575+
576+def file_prepend(filename, line):
577+ r"""Insert given `line`, if not present, at the beginning of `filename`.
578+
579+ Usage example::
580+
581+ >>> import tempfile
582+ >>> f = tempfile.NamedTemporaryFile('w', delete=False)
583+ >>> f.write('line1\n')
584+ >>> f.close()
585+ >>> file_prepend(f.name, 'line0\n')
586+ >>> open(f.name).read()
587+ 'line0\nline1\n'
588+
589+ If the file starts with the given `line`, nothing happens::
590+
591+ >>> file_prepend(f.name, 'line0\n')
592+ >>> open(f.name).read()
593+ 'line0\nline1\n'
594+
595+ If the file contains the given `line`, but not at the beginning,
596+ the line is moved on top::
597+
598+ >>> file_prepend(f.name, 'line1\n')
599+ >>> open(f.name).read()
600+ 'line1\nline0\n'
601+ """
602+ if not line.endswith('\n'):
603+ line += '\n'
604+ with open(filename, 'r+') as f:
605+ lines = f.readlines()
606+ if lines[0] != line:
607+ try:
608+ lines.remove(line)
609+ except ValueError:
610+ pass
611+ lines.insert(0, line)
612+ f.seek(0)
613+ f.writelines(lines)
614+
615+
616+def generate_ssh_keys(path, passphrase=''):
617+ """Generate ssh key pair, saving them inside the given `directory`.
618+
619+ >>> generate_ssh_keys('/tmp/id_rsa')
620+ 0
621+ >>> open('/tmp/id_rsa').readlines()[0].strip()
622+ '-----BEGIN RSA PRIVATE KEY-----'
623+ >>> open('/tmp/id_rsa.pub').read().startswith('ssh-rsa')
624+ True
625+ >>> os.remove('/tmp/id_rsa')
626+ >>> os.remove('/tmp/id_rsa.pub')
627+
628+ If either of the key files already exist, generate_ssh_keys() will
629+ raise an Exception.
630+
631+ Note that ssh-keygen will prompt if the keyfiles already exist, but
632+ when we're using it non-interactively it's better to pre-empt that
633+ behaviour.
634+
635+ >>> with open('/tmp/id_rsa', 'w') as key_file:
636+ ... key_file.write("Don't overwrite me, bro!")
637+ >>> generate_ssh_keys('/tmp/id_rsa') # doctest: +ELLIPSIS
638+ Traceback (most recent call last):
639+ Exception: File /tmp/id_rsa already exists...
640+ >>> os.remove('/tmp/id_rsa')
641+
642+ >>> with open('/tmp/id_rsa.pub', 'w') as key_file:
643+ ... key_file.write("Don't overwrite me, bro!")
644+ >>> generate_ssh_keys('/tmp/id_rsa') # doctest: +ELLIPSIS
645+ Traceback (most recent call last):
646+ Exception: File /tmp/id_rsa.pub already exists...
647+ >>> os.remove('/tmp/id_rsa.pub')
648+ """
649+ if os.path.exists(path):
650+ raise Exception("File {} already exists.".format(path))
651+ if os.path.exists(path + '.pub'):
652+ raise Exception("File {}.pub already exists.".format(path))
653+ return subprocess.call([
654+ 'ssh-keygen', '-q', '-t', 'rsa', '-N', passphrase, '-f', path])
655+
656+
657+def get_su_command(user, args):
658+ """Return a command line as a sequence, prepending "su" if necessary.
659+
660+ This can be used together with `run` when the `su` context manager is not
661+ enough (e.g. an external program uses uid rather than euid).
662+
663+ run(*get_su_command(user, ['bzr', 'whoami']))
664+
665+ If the su is requested as current user, the arguments are returned as
666+ given::
667+
668+ >>> import getpass
669+ >>> current_user = getpass.getuser()
670+
671+ >>> get_su_command(current_user, ('ls', '-l'))
672+ ('ls', '-l')
673+
674+ Otherwise, "su" is prepended::
675+
676+ >>> get_su_command('nobody', ('ls', '-l', 'my file'))
677+ ('su', 'nobody', '-c', "ls -l 'my file'")
678+ """
679+ if get_user_ids(user)[0] != os.getuid():
680+ args = [i for i in args if i is not None]
681+ return ('su', user, '-c', join_command(args))
682+ return args
683+
684+
685+def get_user_home(user):
686+ """Return the home directory of the given `user`.
687+
688+ >>> get_user_home('root')
689+ '/root'
690+
691+ If the user does not exist, return a default /home/[username] home::
692+
693+ >>> get_user_home('_this_user_does_not_exist_')
694+ '/home/_this_user_does_not_exist_'
695+ """
696+ try:
697+ return pwd.getpwnam(user).pw_dir
698+ except KeyError:
699+ return os.path.join(os.path.sep, 'home', user)
700+
701+
702+def get_user_ids(user):
703+ """Return the uid and gid of given `user`, e.g.::
704+
705+ >>> get_user_ids('root')
706+ (0, 0)
707+ """
708+ userdata = pwd.getpwnam(user)
709+ return userdata.pw_uid, userdata.pw_gid
710+
711+
712+def install_extra_repositories(*repositories):
713+ """Install all of the extra repositories and update apt.
714+
715+ Given repositories can contain a "{distribution}" placeholder, that will
716+ be replaced by current distribution codename.
717+
718+ :raises: subprocess.CalledProcessError
719+ """
720+ distribution = run('lsb_release', '-cs').strip()
721+ # Starting from Oneiric, `apt-add-repository` is interactive by
722+ # default, and requires a "-y" flag to be set.
723+ assume_yes = None if distribution == 'lucid' else '-y'
724+ for repo in repositories:
725+ repository = repo.format(distribution=distribution)
726+ run('apt-add-repository', assume_yes, repository)
727+ run('apt-get', 'clean')
728+ run('apt-get', 'update')
729+
730+
731+def join_command(args):
732+ """Return a valid Unix command line from `args`.
733+
734+ >>> join_command(['ls', '-l'])
735+ 'ls -l'
736+
737+ Arguments containing spaces and empty args are correctly quoted::
738+
739+ >>> join_command(['command', 'arg1', 'arg containing spaces', ''])
740+ "command arg1 'arg containing spaces' ''"
741+ """
742+ return ' '.join(pipes.quote(arg) for arg in args)
743+
744+
745+def mkdirs(*args):
746+ """Create leaf directories (given as `args`) and all intermediate ones.
747+
748+ >>> import tempfile
749+ >>> base_dir = tempfile.mktemp(suffix='/')
750+ >>> dir1 = tempfile.mktemp(prefix=base_dir)
751+ >>> dir2 = tempfile.mktemp(prefix=base_dir)
752+ >>> mkdirs(dir1, dir2)
753+ >>> os.path.isdir(dir1)
754+ True
755+ >>> os.path.isdir(dir2)
756+ True
757+
758+ If the leaf directory already exists the function returns without errors::
759+
760+ >>> mkdirs(dir1)
761+
762+ An `OSError` is raised if the leaf path exists and it is a file::
763+
764+ >>> f = tempfile.NamedTemporaryFile(
765+ ... 'w', delete=False, prefix=base_dir)
766+ >>> f.close()
767+ >>> mkdirs(f.name) # doctest: +ELLIPSIS
768+ Traceback (most recent call last):
769+ OSError: ...
770+ """
771+ for directory in args:
772+ try:
773+ os.makedirs(directory)
774+ except OSError as err:
775+ if err.errno != errno.EEXIST or os.path.isfile(directory):
776+ raise
777+
778+
779+def run(*args, **kwargs):
780+ """Run the command with the given arguments.
781+
782+ The first argument is the path to the command to run.
783+ Subsequent arguments are command-line arguments to be passed.
784+
785+ This function accepts all optional keyword arguments accepted by
786+ `subprocess.Popen`.
787+ """
788+ args = [i for i in args if i is not None]
789+ pipe = subprocess.PIPE
790+ process = subprocess.Popen(
791+ args, stdout=kwargs.pop('stdout', pipe),
792+ stderr=kwargs.pop('stderr', pipe),
793+ close_fds=kwargs.pop('close_fds', True), **kwargs)
794+ stdout, stderr = process.communicate()
795+ if process.returncode:
796+ exception = subprocess.CalledProcessError(
797+ process.returncode, repr(args))
798+ # The output argument of `CalledProcessError` was introduced in Python
799+ # 2.7. Monkey patch the output here to avoid TypeErrors in older
800+ # versions of Python, still preserving the output in Python 2.7.
801+ exception.output = ''.join(filter(None, [stdout, stderr]))
802+ raise exception
803+ return stdout
804+
805+
806+def script_name():
807+ """Return the name of this script."""
808+ return os.path.basename(sys.argv[0])
809+
810+
811+def search_file(regexp, filename):
812+ """Return the first line in `filename` that matches `regexp`."""
813+ with open(filename) as f:
814+ for line in f:
815+ if re.search(regexp, line):
816+ return line
817+
818+
819+def ssh(location, user=None, key=None, caller=subprocess.call):
820+ """Return a callable that can be used to run ssh shell commands.
821+
822+ The ssh `location` and, optionally, `user` must be given.
823+ If the user is None then the current user is used for the connection.
824+
825+ The callable internally uses the given `caller`::
826+
827+ >>> def caller(cmd):
828+ ... print tuple(cmd)
829+ >>> sshcall = ssh('example.com', 'myuser', caller=caller)
830+ >>> root_sshcall = ssh('example.com', caller=caller)
831+ >>> sshcall('ls -l') # doctest: +ELLIPSIS
832+ ('ssh', '-t', ..., 'myuser@example.com', '--', 'ls -l')
833+ >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
834+ ('ssh', '-t', ..., 'example.com', '--', 'ls -l')
835+
836+ The ssh key path can be optionally provided::
837+
838+ >>> root_sshcall = ssh('example.com', key='/tmp/foo', caller=caller)
839+ >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
840+ ('ssh', '-t', ..., '-i', '/tmp/foo', 'example.com', '--', 'ls -l')
841+
842+ If the ssh command exits with an error code,
843+ a `subprocess.CalledProcessError` is raised::
844+
845+ >>> ssh('loc', caller=lambda cmd: 1)('ls -l') # doctest: +ELLIPSIS
846+ Traceback (most recent call last):
847+ CalledProcessError: ...
848+
849+ If ignore_errors is set to True when executing the command, no error
850+ will be raised, even if the command itself returns an error code.
851+
852+ >>> sshcall = ssh('loc', caller=lambda cmd: 1)
853+ >>> sshcall('ls -l', ignore_errors=True)
854+ """
855+ sshcmd = [
856+ 'ssh',
857+ '-t',
858+ '-t', # Yes, this second -t is deliberate. See `man ssh`.
859+ '-o', 'StrictHostKeyChecking=no',
860+ '-o', 'UserKnownHostsFile=/dev/null',
861+ ]
862+ if key is not None:
863+ sshcmd.extend(['-i', key])
864+ if user is not None:
865+ location = '{}@{}'.format(user, location)
866+ sshcmd.extend([location, '--'])
867+
868+ def _sshcall(cmd, ignore_errors=False):
869+ command = sshcmd + [cmd]
870+ retcode = caller(command)
871+ if retcode and not ignore_errors:
872+ raise subprocess.CalledProcessError(retcode, ' '.join(command))
873+
874+ return _sshcall
875+
876+
877+@contextmanager
878+def su(user):
879+ """A context manager to temporarily run the script as a different user."""
880+ uid, gid = get_user_ids(user)
881+ os.setegid(gid)
882+ os.seteuid(uid)
883+ home = get_user_home(user)
884+ with environ(HOME=home):
885+ try:
886+ yield Env(uid, gid, home)
887+ finally:
888+ os.setegid(os.getgid())
889+ os.seteuid(os.getuid())
890+
891+
892+def user_exists(username):
893+ """Return True if given `username` exists, e.g.::
894+
895+ >>> user_exists('root')
896+ True
897+ >>> user_exists('_this_user_does_not_exist_')
898+ False
899+ """
900+ try:
901+ pwd.getpwnam(username)
902+ except KeyError:
903+ return False
904+ return True
905+
906+
907+def wait_for_page_contents(url, contents, timeout=120, validate=None):
908+ if validate is None:
909+ validate = operator.contains
910+ start_time = time.time()
911+ while True:
912+ try:
913+ stream = urllib2.urlopen(url)
914+ except (urllib2.HTTPError, urllib2.URLError):
915+ pass
916+ else:
917+ page = stream.read()
918+ if validate(page, contents):
919+ return page
920+ if time.time() - start_time >= timeout:
921+ raise RuntimeError('timeout waiting for contents of ' + url)
922+ time.sleep(0.1)
923+
924+
925+class DictDiffer:
926+ """
927+ Calculate the difference between two dictionaries as:
928+ (1) items added
929+ (2) items removed
930+ (3) keys same in both but changed values
931+ (4) keys same in both and unchanged values
932+ """
933+
934+ # Based on answer by hughdbrown at:
935+ # http://stackoverflow.com/questions/1165352
936+
937+ def __init__(self, current_dict, past_dict):
938+ self.current_dict = current_dict
939+ self.past_dict = past_dict
940+ self.set_current = set(current_dict)
941+ self.set_past = set(past_dict)
942+ self.intersect = self.set_current.intersection(self.set_past)
943+
944+ @property
945+ def added(self):
946+ return self.set_current - self.intersect
947+
948+ @property
949+ def removed(self):
950+ return self.set_past - self.intersect
951+
952+ @property
953+ def changed(self):
954+ return set(key for key in self.intersect
955+ if self.past_dict[key] != self.current_dict[key])
956+
957+ @property
958+ def unchanged(self):
959+ return set(key for key in self.intersect
960+ if self.past_dict[key] == self.current_dict[key])
961+
962+ @property
963+ def modified(self):
964+ return self.current_dict != self.past_dict
965+
966+ @property
967+ def added_or_changed(self):
968+ return self.added.union(self.changed)
969+
970+ def _changes(self, keys):
971+ new = {}
972+ old = {}
973+ for k in keys:
974+ new[k] = self.current_dict.get(k)
975+ old[k] = self.past_dict.get(k)
976+ return "%s -> %s" % (old, new)
977+
978+ def __str__(self):
979+ if self.modified:
980+ s = dedent("""\
981+ added: %s
982+ removed: %s
983+ changed: %s
984+ unchanged: %s""") % (
985+ self._changes(self.added),
986+ self._changes(self.removed),
987+ self._changes(self.changed),
988+ list(self.unchanged))
989+ else:
990+ s = "no changes"
991+ return s
992+
993+
994+class Serializer:
995+ """Handle JSON (de)serialization."""
996+
997+ def __init__(self, path, default=None, serialize=None, deserialize=None):
998+ self.path = path
999+ self.default = default or {}
1000+ self.serialize = serialize or json.dump
1001+ self.deserialize = deserialize or json.load
1002+
1003+ def exists(self):
1004+ return os.path.exists(self.path)
1005+
1006+ def get(self):
1007+ if self.exists():
1008+ with open(self.path) as f:
1009+ return self.deserialize(f)
1010+ return self.default
1011+
1012+ def set(self, data):
1013+ with open(self.path, 'w') as f:
1014+ self.serialize(data, f)
1015
1016=== modified file 'hooks/utils.py'
1017--- hooks/utils.py 2013-10-04 12:53:53 +0000
1018+++ hooks/utils.py 2013-10-08 07:46:30 +0000
1019@@ -36,6 +36,7 @@
1020 'get_npm_cache_archive_url',
1021 'get_release_file_url',
1022 'get_zookeeper_address',
1023+ 'install_missing_packages',
1024 'legacy_juju',
1025 'log_hook',
1026 'parse_source',
1027@@ -78,6 +79,7 @@
1028 apt_get_install,
1029 command,
1030 environ,
1031+ install_extra_repositories,
1032 run,
1033 script_name,
1034 search_file,
1035@@ -155,12 +157,6 @@
1036 results_log = None
1037
1038
1039-def _get_build_dependencies():
1040- """Install deb dependencies for building."""
1041- log('Installing build dependencies.')
1042- cmd_log(apt_get_install(*DEB_BUILD_DEPENDENCIES))
1043-
1044-
1045 def get_api_address(unit_dir=None):
1046 """Return the Juju API address.
1047
1048@@ -623,8 +619,6 @@
1049
1050 def fetch_gui_from_branch(branch_url, revision, logpath):
1051 """Retrieve the Juju GUI from a branch and build a release archive."""
1052- # Make sure we have the needed dependencies.
1053- _get_build_dependencies()
1054 # Inject NPM packages into the cache for faster building.
1055 prime_npm_cache(get_npm_cache_archive_url())
1056 # Create a release starting from a branch.
1057@@ -738,3 +732,20 @@
1058 continue
1059 missing.add(pkg_name)
1060 return missing
1061+
1062+
1063+def install_missing_packages(packages, repository=None):
1064+ """Install the required debian packages if they are missing.
1065+
1066+ If repository is not None, add the given apt repository before installing
1067+ the dependencies.
1068+ """
1069+ missing = find_missing_packages(*packages)
1070+ if missing:
1071+ if repository is not None:
1072+ log('Adding the apt repository {}.'.format(repository))
1073+ install_extra_repositories(repository)
1074+ log('Installing deb packages: {}.'.format(', '.join(missing)))
1075+ cmd_log(apt_get_install(*missing))
1076+ else:
1077+ log('No missing deb packages.')
1078
1079=== modified file 'revision'
1080--- revision 2013-10-04 13:40:26 +0000
1081+++ revision 2013-10-08 07:46:30 +0000
1082@@ -1,1 +1,1 @@
1083-88
1084+89
1085
1086=== modified file 'tests/20-functional.test'
1087--- tests/20-functional.test 2013-09-25 16:31:24 +0000
1088+++ tests/20-functional.test 2013-10-08 07:46:30 +0000
1089@@ -207,8 +207,11 @@
1090
1091 def test_cache_headers(self):
1092 # Make sure the correct cache headers are sent.
1093- unit_info = self.juju_deploy(
1094- self.charm, options={'juju-gui-source': JUJU_GUI_TEST_BRANCH})
1095+ options = {
1096+ 'builtin-server': False,
1097+ 'juju-gui-source': JUJU_GUI_TEST_BRANCH,
1098+ }
1099+ unit_info = self.juju_deploy(self.charm, options=options)
1100 hostname = unit_info['public-address']
1101 conn = httplib.HTTPSConnection(hostname)
1102 conn.request('HEAD', '/')
1103
1104=== modified file 'tests/requirements.pip'
1105--- tests/requirements.pip 2013-10-04 13:08:59 +0000
1106+++ tests/requirements.pip 2013-10-08 07:46:30 +0000
1107@@ -37,10 +37,8 @@
1108 tornado==3.1.1
1109
1110 # Charm hooks.
1111--e bzr+http://launchpad.net/charm-tools#egg=charm-tools
1112 launchpadlib==1.10.2
1113 python-apt==0.8.5
1114--e bzr+http://launchpad.net/python-shelltoolbox#egg=python-shelltoolbox
1115 Tempita==0.5.1
1116
1117 # Charm tests + GUI server tests.
1118
1119=== added symlink 'tests/shelltoolbox.py'
1120=== target is u'../hooks/shelltoolbox.py'
1121=== modified file 'tests/test_backends.py'
1122--- tests/test_backends.py 2013-09-03 08:24:30 +0000
1123+++ tests/test_backends.py 2013-10-08 07:46:30 +0000
1124@@ -17,258 +17,492 @@
1125 """Backend tests."""
1126
1127
1128-from collections import defaultdict
1129-from contextlib import contextmanager
1130+from contextlib import (
1131+ contextmanager,
1132+ nested,
1133+)
1134 import os
1135 import shutil
1136 import tempfile
1137 import unittest
1138
1139-import charmhelpers
1140 import mock
1141-import shelltoolbox
1142
1143 import backend
1144 import utils
1145
1146
1147-def get_mixin_names(test_backend):
1148- return tuple(b.__class__.__name__ for b in test_backend.mixins)
1149-
1150-
1151-class GotEmAllDict(defaultdict):
1152- """A dictionary that returns the same default value for all given keys."""
1153-
1154- def get(self, key, default=None):
1155- return self.default_factory()
1156+EXPECTED_PYTHON_LEGACY_DEBS = ('apache2', 'curl', 'haproxy', 'openssl')
1157+EXPECTED_GO_LEGACY_DEBS = (
1158+ 'apache2', 'curl', 'haproxy', 'openssl', 'python-yaml')
1159+EXPECTED_PYTHON_BUILTIN_DEBS = (
1160+ 'curl', 'openssl', 'python-bzrlib', 'python-pip')
1161+EXPECTED_GO_BUILTIN_DEBS = (
1162+ 'curl', 'openssl', 'python-bzrlib', 'python-pip', 'python-yaml')
1163+
1164+simulate_pyjuju = mock.patch('utils.legacy_juju', mock.Mock(return_value=True))
1165+simulate_juju_core = mock.patch(
1166+ 'utils.legacy_juju', mock.Mock(return_value=False))
1167
1168
1169 class TestBackendProperties(unittest.TestCase):
1170 """Ensure the correct mixins and property values are collected."""
1171
1172- simulate_pyjuju = mock.patch(
1173- 'utils.legacy_juju', mock.Mock(return_value=True))
1174- simulate_juju_core = mock.patch(
1175- 'utils.legacy_juju', mock.Mock(return_value=False))
1176+ def assert_mixins(self, expected, backend):
1177+ """Ensure the given backend includes the expected mixins."""
1178+ obtained = tuple(mixin.__class__.__name__ for mixin in backend.mixins)
1179+ self.assertEqual(tuple(expected), obtained)
1180+
1181+ def assert_dependencies(self, expected_debs, expected_repository, backend):
1182+ """Ensure the given backend includes the expected dependencies."""
1183+ obtained_debs, obtained_repository = backend.get_dependencies()
1184+ self.assertEqual(set(expected_debs), obtained_debs)
1185+ self.assertEqual(expected_repository, obtained_repository)
1186
1187 def check_sandbox_mode(self):
1188 """The backend includes the correct mixins when sandbox mode is active.
1189 """
1190- test_backend = backend.Backend(config={
1191- 'sandbox': True, 'staging': False, 'builtin-server': False})
1192- mixin_names = get_mixin_names(test_backend)
1193- self.assertEqual(
1194- ('SetUpMixin', 'SandboxMixin', 'GuiMixin', 'HaproxyApacheMixin'),
1195- mixin_names)
1196- self.assertEqual(
1197- frozenset(('apache2', 'curl', 'haproxy', 'openssl')),
1198- test_backend.debs)
1199+ expected_mixins = (
1200+ 'SetUpMixin', 'SandboxMixin', 'GuiMixin', 'HaproxyApacheMixin')
1201+ config = {
1202+ 'builtin-server': False,
1203+ 'repository-location': 'ppa:my/location',
1204+ 'sandbox': True,
1205+ 'staging': False,
1206+ }
1207+ test_backend = backend.Backend(config=config)
1208+ self.assert_mixins(expected_mixins, test_backend)
1209+ self.assert_dependencies(
1210+ EXPECTED_PYTHON_LEGACY_DEBS, 'ppa:my/location', test_backend)
1211
1212 def test_python_staging_backend(self):
1213 expected_mixins = (
1214 'SetUpMixin', 'ImprovMixin', 'GuiMixin', 'HaproxyApacheMixin')
1215- with self.simulate_pyjuju:
1216- test_backend = backend.Backend(config={
1217- 'sandbox': False, 'staging': True, 'builtin-server': False})
1218- mixin_names = get_mixin_names(test_backend)
1219- self.assertEqual(expected_mixins, mixin_names)
1220- debs = ('apache2', 'curl', 'haproxy', 'openssl', 'zookeeper')
1221- self.assertEqual(frozenset(debs), test_backend.debs)
1222+ config = {
1223+ 'builtin-server': False,
1224+ 'repository-location': 'ppa:my/location',
1225+ 'sandbox': False,
1226+ 'staging': True,
1227+ }
1228+ with simulate_pyjuju:
1229+ test_backend = backend.Backend(config=config)
1230+ self.assert_mixins(expected_mixins, test_backend)
1231+ self.assert_dependencies(
1232+ EXPECTED_PYTHON_LEGACY_DEBS + ('zookeeper',),
1233+ 'ppa:my/location', test_backend)
1234
1235 def test_go_staging_backend(self):
1236 config = {'sandbox': False, 'staging': True, 'builtin-server': False}
1237- with self.simulate_juju_core:
1238+ with simulate_juju_core:
1239 with self.assertRaises(ValueError) as context_manager:
1240 backend.Backend(config=config)
1241 error = str(context_manager.exception)
1242 self.assertEqual('Unable to use staging with go backend', error)
1243
1244 def test_python_sandbox_backend(self):
1245- with self.simulate_pyjuju:
1246+ with simulate_pyjuju:
1247 self.check_sandbox_mode()
1248
1249 def test_go_sandbox_backend(self):
1250- with self.simulate_juju_core:
1251+ with simulate_juju_core:
1252 self.check_sandbox_mode()
1253
1254 def test_python_backend(self):
1255 expected_mixins = (
1256 'SetUpMixin', 'PythonMixin', 'GuiMixin', 'HaproxyApacheMixin')
1257- with self.simulate_pyjuju:
1258- test_backend = backend.Backend(config={
1259- 'sandbox': False, 'staging': False, 'builtin-server': False})
1260- mixin_names = get_mixin_names(test_backend)
1261- self.assertEqual(expected_mixins, mixin_names)
1262- self.assertEqual(
1263- frozenset(('apache2', 'curl', 'haproxy', 'openssl')),
1264- test_backend.debs)
1265+ config = {
1266+ 'builtin-server': False,
1267+ 'repository-location': 'ppa:my/location',
1268+ 'sandbox': False,
1269+ 'staging': False,
1270+ }
1271+ with simulate_pyjuju:
1272+ test_backend = backend.Backend(config=config)
1273+ self.assert_mixins(expected_mixins, test_backend)
1274+ self.assert_dependencies(
1275+ EXPECTED_PYTHON_LEGACY_DEBS, 'ppa:my/location', test_backend)
1276
1277 def test_go_backend(self):
1278- with self.simulate_juju_core:
1279- test_backend = backend.Backend(config={
1280- 'sandbox': False, 'staging': False, 'builtin-server': False})
1281- mixin_names = get_mixin_names(test_backend)
1282- self.assertEqual(
1283- ('SetUpMixin', 'GoMixin', 'GuiMixin', 'HaproxyApacheMixin'),
1284- mixin_names)
1285- self.assertEqual(
1286- frozenset(
1287- ('apache2', 'curl', 'haproxy', 'openssl', 'python-yaml')),
1288- test_backend.debs)
1289+ expected_mixins = (
1290+ 'SetUpMixin', 'GoMixin', 'GuiMixin', 'HaproxyApacheMixin')
1291+ config = {
1292+ 'builtin-server': False,
1293+ 'repository-location': 'ppa:my/location',
1294+ 'sandbox': False,
1295+ 'staging': False,
1296+ }
1297+ with simulate_juju_core:
1298+ test_backend = backend.Backend(config=config)
1299+ self.assert_mixins(expected_mixins, test_backend)
1300+ self.assert_dependencies(
1301+ EXPECTED_GO_LEGACY_DEBS, 'ppa:my/location', test_backend)
1302
1303- def test_builtin_server(self):
1304+ def test_go_builtin_server(self):
1305+ config = {
1306+ 'builtin-server': True,
1307+ 'repository-location': 'ppa:my/location',
1308+ 'sandbox': False,
1309+ 'staging': False,
1310+ }
1311 expected_mixins = (
1312 'SetUpMixin', 'GoMixin', 'GuiMixin', 'BuiltinServerMixin')
1313- expected_debs = set([
1314- 'python-pip', 'python-yaml', 'curl', 'openssl', 'python-bzrlib'])
1315- with self.simulate_juju_core:
1316- test_backend = backend.Backend(config={
1317- 'sandbox': False, 'staging': False, 'builtin-server': True})
1318- mixin_names = get_mixin_names(test_backend)
1319- self.assertEqual(expected_mixins, mixin_names)
1320- self.assertEqual(expected_debs, test_backend.debs)
1321+ with simulate_juju_core:
1322+ test_backend = backend.Backend(config)
1323+ self.assert_mixins(expected_mixins, test_backend)
1324+ self.assert_dependencies(
1325+ EXPECTED_GO_BUILTIN_DEBS, None, test_backend)
1326+
1327+ def test_python_builtin_server(self):
1328+ config = {
1329+ 'builtin-server': True,
1330+ 'repository-location': 'ppa:my/location',
1331+ 'sandbox': False,
1332+ 'staging': False,
1333+ }
1334+ expected_mixins = (
1335+ 'SetUpMixin', 'PythonMixin', 'GuiMixin', 'BuiltinServerMixin')
1336+ with simulate_pyjuju:
1337+ test_backend = backend.Backend(config)
1338+ self.assert_mixins(expected_mixins, test_backend)
1339+ self.assert_dependencies(
1340+ EXPECTED_PYTHON_BUILTIN_DEBS, None, test_backend)
1341+
1342+ def test_sandbox_builtin_server(self):
1343+ config = {
1344+ 'builtin-server': True,
1345+ 'repository-location': 'ppa:my/location',
1346+ 'sandbox': True,
1347+ 'staging': False,
1348+ }
1349+ expected_mixins = (
1350+ 'SetUpMixin', 'SandboxMixin', 'GuiMixin', 'BuiltinServerMixin')
1351+ with simulate_juju_core:
1352+ test_backend = backend.Backend(config)
1353+ self.assert_mixins(expected_mixins, test_backend)
1354+ self.assert_dependencies(
1355+ EXPECTED_PYTHON_BUILTIN_DEBS, None, test_backend)
1356
1357
1358 class TestBackendCommands(unittest.TestCase):
1359
1360 def setUp(self):
1361- self.called = {}
1362- self.alwaysFalse = GotEmAllDict(lambda: False)
1363- self.alwaysTrue = GotEmAllDict(lambda: True)
1364-
1365- # Monkeypatch functions.
1366- self.utils_mocks = {
1367- 'compute_build_dir': utils.compute_build_dir,
1368- 'fetch_api': utils.fetch_api,
1369- 'fetch_gui_from_branch': utils.fetch_gui_from_branch,
1370- 'fetch_gui_release': utils.fetch_gui_release,
1371- 'find_missing_packages': utils.find_missing_packages,
1372- 'get_api_address': utils.get_api_address,
1373- 'get_npm_cache_archive_url': utils.get_npm_cache_archive_url,
1374- 'install_builtin_server': utils.install_builtin_server,
1375- 'parse_source': utils.parse_source,
1376- 'prime_npm_cache': utils.prime_npm_cache,
1377- 'remove_apache_setup': utils.remove_apache_setup,
1378- 'remove_haproxy_setup': utils.remove_haproxy_setup,
1379- 'save_or_create_certificates': utils.save_or_create_certificates,
1380- 'setup_apache_config': utils.setup_apache_config,
1381- 'setup_gui': utils.setup_gui,
1382- 'setup_haproxy_config': utils.setup_haproxy_config,
1383- 'start_agent': utils.start_agent,
1384- 'start_improv': utils.start_improv,
1385- 'write_builtin_server_startup': utils.write_builtin_server_startup,
1386- 'write_gui_config': utils.write_gui_config,
1387- }
1388- self.charmhelpers_mocks = {
1389- 'log': charmhelpers.log,
1390- 'open_port': charmhelpers.open_port,
1391- 'service_control': charmhelpers.service_control,
1392- }
1393-
1394- def make_mock_function(name):
1395- def mock_function(*args, **kwargs):
1396- self.called[name] = True
1397- return (None, None)
1398- mock_function.__name__ = name
1399- return mock_function
1400-
1401- for name in self.utils_mocks.keys():
1402- setattr(utils, name, make_mock_function(name))
1403- for name in self.charmhelpers_mocks.keys():
1404- setattr(charmhelpers, name, make_mock_function(name))
1405-
1406- @contextmanager
1407- def mock_su(user):
1408- self.called['su'] = True
1409- yield
1410- self.orig_su = utils.su
1411- utils.su = mock_su
1412-
1413- def mock_apt_get_install(*debs):
1414- self.called['apt_get_install'] = True
1415- self.orig_apt_get_install = shelltoolbox.apt_get_install
1416- shelltoolbox.apt_get_install = mock_apt_get_install
1417-
1418- def mock_run(*debs):
1419- self.called['run'] = True
1420- self.orig_run = shelltoolbox.run
1421- shelltoolbox.run = mock_run
1422-
1423- # Monkeypatch directories.
1424+ # Set up directories.
1425 self.playground = tempfile.mkdtemp()
1426- self.orig_juju_dir = utils.JUJU_AGENT_DIR
1427- utils.JUJU_AGENT_DIR = tempfile.mkdtemp(dir=self.playground)
1428- self.orig_base_dir = utils.BASE_DIR
1429- utils.BASE_DIR = os.path.join(self.playground, 'juju-gui')
1430-
1431- def tearDown(self):
1432- # Cleanup directories.
1433- shutil.rmtree(self.playground)
1434- utils.JUJU_AGENT_DIR = self.orig_juju_dir
1435- utils.BASE_DIR = self.orig_base_dir
1436- # Undo the monkeypatching.
1437- shelltoolbox.run = self.orig_run
1438- shelltoolbox.apt_get_install = self.orig_apt_get_install
1439- utils.su = self.orig_su
1440- for name, orig_fun in self.charmhelpers_mocks.items():
1441- setattr(charmhelpers, name, orig_fun)
1442- for name, orig_fun in self.utils_mocks.items():
1443- setattr(utils, name, orig_fun)
1444+ self.addCleanup(shutil.rmtree, self.playground)
1445+ self.base_dir = os.path.join(self.playground, 'juju-gui')
1446+ self.command_log_file = os.path.join(self.playground, 'logs')
1447+ self.juju_agent_dir = os.path.join(self.playground, 'juju-agent-dir')
1448+ self.ssl_cert_path = os.path.join(self.playground, 'ssl-cert-path')
1449+ # Set up default values.
1450+ self.juju_api_branch = 'lp:juju-api'
1451+ self.juju_gui_source = 'stable'
1452+ self.repository_location = 'ppa:my/location'
1453+ self.parse_source_return_value = ('stable', None)
1454+
1455+ def make_config(self, options=None):
1456+ """Create and return a backend configuration dict."""
1457+ config = {
1458+ 'builtin-server': True,
1459+ 'builtin-server-logging': 'info',
1460+ 'charmworld-url': 'http://charmworld.example.com',
1461+ 'command-log-file': self.command_log_file,
1462+ 'default-viewmode': 'sidebar',
1463+ 'ga-key': 'my-key',
1464+ 'juju-api-branch': self.juju_api_branch,
1465+ 'juju-gui-debug': False,
1466+ 'juju-gui-console-enabled': False,
1467+ 'juju-gui-source': self.juju_gui_source,
1468+ 'login-help': 'login-help',
1469+ 'read-only': False,
1470+ 'repository-location': self.repository_location,
1471+ 'sandbox': False,
1472+ 'secure': True,
1473+ 'serve-tests': False,
1474+ 'show-get-juju-button': False,
1475+ 'ssl-cert-path': self.ssl_cert_path,
1476+ 'staging': False,
1477+ }
1478+ if options is not None:
1479+ config.update(options)
1480+ return config
1481+
1482+ @contextmanager
1483+ def mock_all(self):
1484+ """Mock all the extrenal functions used by the backend framework."""
1485+ mock_parse_source = mock.Mock(
1486+ return_value=self.parse_source_return_value)
1487+ mocks = {
1488+ 'base_dir': mock.patch('backend.utils.BASE_DIR', self.base_dir),
1489+ 'compute_build_dir': mock.patch('backend.utils.compute_build_dir'),
1490+ 'fetch_api': mock.patch('backend.utils.fetch_api'),
1491+ 'fetch_gui_from_branch': mock.patch(
1492+ 'backend.utils.fetch_gui_from_branch'),
1493+ 'fetch_gui_release': mock.patch('backend.utils.fetch_gui_release'),
1494+ 'install_builtin_server': mock.patch(
1495+ 'backend.utils.install_builtin_server'),
1496+ 'install_missing_packages': mock.patch(
1497+ 'backend.utils.install_missing_packages'),
1498+ 'juju_agent_dir': mock.patch(
1499+ 'backend.utils.JUJU_AGENT_DIR', self.juju_agent_dir),
1500+ 'log': mock.patch('backend.log'),
1501+ 'open_port': mock.patch('backend.open_port'),
1502+ 'parse_source': mock.patch(
1503+ 'backend.utils.parse_source', mock_parse_source),
1504+ 'save_or_create_certificates': mock.patch(
1505+ 'backend.utils.save_or_create_certificates'),
1506+ 'setup_gui': mock.patch('backend.utils.setup_gui'),
1507+ 'start_agent': mock.patch('backend.utils.start_agent'),
1508+ 'start_builtin_server': mock.patch(
1509+ 'backend.utils.start_builtin_server'),
1510+ 'start_haproxy_apache': mock.patch(
1511+ 'backend.utils.start_haproxy_apache'),
1512+ 'stop_agent': mock.patch('backend.utils.stop_agent'),
1513+ 'stop_builtin_server': mock.patch(
1514+ 'backend.utils.stop_builtin_server'),
1515+ 'stop_haproxy_apache': mock.patch(
1516+ 'backend.utils.stop_haproxy_apache'),
1517+ 'write_gui_config': mock.patch('backend.utils.write_gui_config'),
1518+ }
1519+ # Note: nested is deprecated for good reasons which do not apply here.
1520+ # Used here to easily nest a dynamically generated list of context
1521+ # managers.
1522+ with nested(*mocks.values()) as context_managers:
1523+ object_dict = dict(zip(mocks.keys(), context_managers))
1524+ yield type('Mocks', (object,), object_dict)
1525+
1526+ def assert_write_gui_config_called(self, mocks, config):
1527+ """Ensure the mocked write_gui_config has been properly called."""
1528+ mocks.write_gui_config.assert_called_once_with(
1529+ config['juju-gui-console-enabled'], config['login-help'],
1530+ config['read-only'], config['staging'], config['charmworld-url'],
1531+ mocks.compute_build_dir(), secure=config['secure'],
1532+ sandbox=config['sandbox'], ga_key=config['ga-key'],
1533+ default_viewmode=config['default-viewmode'],
1534+ show_get_juju_button=config['show-get-juju-button'], password=None)
1535
1536 def test_base_dir_created(self):
1537- test_backend = backend.Backend(config=self.alwaysFalse)
1538- test_backend.install()
1539- self.assertTrue(os.path.isdir(utils.BASE_DIR))
1540+ # The base Juju GUI directory is correctly created.
1541+ config = self.make_config()
1542+ test_backend = backend.Backend(config=config)
1543+ with self.mock_all():
1544+ test_backend.install()
1545+ self.assertTrue(os.path.isdir(self.base_dir))
1546
1547 def test_base_dir_removed(self):
1548- test_backend = backend.Backend(config=self.alwaysFalse)
1549- test_backend.install()
1550- test_backend.destroy()
1551+ # The base Juju GUI directory is correctly removed.
1552+ config = self.make_config()
1553+ test_backend = backend.Backend(config=config)
1554+ with self.mock_all():
1555+ test_backend.install()
1556+ test_backend.destroy()
1557 self.assertFalse(os.path.exists(utils.BASE_DIR), utils.BASE_DIR)
1558
1559- def test_install_python(self):
1560- test_backend = backend.Backend(config=self.alwaysFalse)
1561- test_backend.install()
1562- for mocked in (
1563- 'apt_get_install', 'fetch_api', 'find_missing_packages',
1564- ):
1565- self.assertTrue(
1566- self.called.get(mocked), '{} was not called'.format(mocked))
1567-
1568- def test_install_improv_builtin(self):
1569- test_backend = backend.Backend(config=self.alwaysTrue)
1570- test_backend.install()
1571- for mocked in (
1572- 'apt_get_install', 'fetch_api', 'find_missing_packages',
1573- 'install_builtin_server',
1574- ):
1575- self.assertTrue(
1576- self.called.get(mocked), '{} was not called'.format(mocked))
1577-
1578- def test_start_agent(self):
1579- test_backend = backend.Backend(config=self.alwaysFalse)
1580- test_backend.start()
1581- for mocked in (
1582- 'compute_build_dir', 'open_port', 'setup_apache_config',
1583- 'setup_haproxy_config', 'start_agent', 'su', 'write_gui_config',
1584- ):
1585- self.assertTrue(
1586- self.called.get(mocked), '{} was not called'.format(mocked))
1587-
1588- def test_start_improv_builtin(self):
1589- test_backend = backend.Backend(config=self.alwaysTrue)
1590- test_backend.start()
1591- for mocked in (
1592- 'compute_build_dir', 'open_port', 'start_improv', 'su',
1593- 'write_builtin_server_startup', 'write_gui_config',
1594- ):
1595- self.assertTrue(
1596- self.called.get(mocked), '{} was not called'.format(mocked))
1597-
1598- def test_stop(self):
1599- test_backend = backend.Backend(config=self.alwaysFalse)
1600- test_backend.stop()
1601- self.assertTrue(self.called.get('su'), 'su was not called')
1602+ def test_install_python_legacy_stable(self):
1603+ # Install a pyJuju backend with legacy server and stable release.
1604+ config = self.make_config({'builtin-server': False})
1605+ with simulate_pyjuju:
1606+ test_backend = backend.Backend(config=config)
1607+ with self.mock_all() as mocks:
1608+ test_backend.install()
1609+ mocks.install_missing_packages.assert_called_once_with(
1610+ set(EXPECTED_PYTHON_LEGACY_DEBS),
1611+ repository=self.repository_location)
1612+ mocks.fetch_api.assert_called_once_with(self.juju_api_branch)
1613+ mocks.parse_source.assert_called_once_with(self.juju_gui_source)
1614+ mocks.fetch_gui_release.assert_called_once_with(
1615+ *self.parse_source_return_value)
1616+ self.assertFalse(mocks.fetch_gui_from_branch.called)
1617+ mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
1618+ self.assertFalse(mocks.install_builtin_server.called)
1619+
1620+ def test_install_go_legacy_stable(self):
1621+ # Install a juju-core backend with legacy server and stable release.
1622+ config = self.make_config({'builtin-server': False})
1623+ with simulate_juju_core:
1624+ test_backend = backend.Backend(config=config)
1625+ with self.mock_all() as mocks:
1626+ test_backend.install()
1627+ mocks.install_missing_packages.assert_called_once_with(
1628+ set(EXPECTED_GO_LEGACY_DEBS), repository=self.repository_location)
1629+ self.assertFalse(mocks.fetch_api.called)
1630+ mocks.parse_source.assert_called_once_with(self.juju_gui_source)
1631+ mocks.fetch_gui_release.assert_called_once_with(
1632+ *self.parse_source_return_value)
1633+ self.assertFalse(mocks.fetch_gui_from_branch.called)
1634+ mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
1635+ self.assertFalse(mocks.install_builtin_server.called)
1636+
1637+ def test_install_python_builtin_stable(self):
1638+ # Install a pyJuju backend with builtin server and stable release.
1639+ config = self.make_config({'builtin-server': True})
1640+ with simulate_pyjuju:
1641+ test_backend = backend.Backend(config=config)
1642+ with self.mock_all() as mocks:
1643+ test_backend.install()
1644+ mocks.install_missing_packages.assert_called_once_with(
1645+ set(EXPECTED_PYTHON_BUILTIN_DEBS), repository=None)
1646+ mocks.fetch_api.assert_called_once_with(self.juju_api_branch)
1647+ mocks.parse_source.assert_called_once_with(self.juju_gui_source)
1648+ mocks.fetch_gui_release.assert_called_once_with(
1649+ *self.parse_source_return_value)
1650+ self.assertFalse(mocks.fetch_gui_from_branch.called)
1651+ mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
1652+ mocks.install_builtin_server.assert_called_once_with()
1653+
1654+ def test_install_go_builtin_stable(self):
1655+ # Install a juju-core backend with builtin server and stable release.
1656+ config = self.make_config({'builtin-server': True})
1657+ with simulate_juju_core:
1658+ test_backend = backend.Backend(config=config)
1659+ with self.mock_all() as mocks:
1660+ test_backend.install()
1661+ mocks.install_missing_packages.assert_called_once_with(
1662+ set(EXPECTED_GO_BUILTIN_DEBS), repository=None)
1663+ self.assertFalse(mocks.fetch_api.called)
1664+ mocks.parse_source.assert_called_once_with(self.juju_gui_source)
1665+ mocks.fetch_gui_release.assert_called_once_with(
1666+ *self.parse_source_return_value)
1667+ self.assertFalse(mocks.fetch_gui_from_branch.called)
1668+ mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
1669+ mocks.install_builtin_server.assert_called_once_with()
1670+
1671+ def test_install_go_builtin_branch(self):
1672+ # Install a juju-core backend with builtin server and branch release.
1673+ self.parse_source_return_value = ('branch', ('lp:juju-gui', 42))
1674+ expected_calls = [
1675+ mock.call(set(EXPECTED_GO_BUILTIN_DEBS), repository=None),
1676+ mock.call(
1677+ utils.DEB_BUILD_DEPENDENCIES,
1678+ repository=self.repository_location,
1679+ ),
1680+ ]
1681+ config = self.make_config({'builtin-server': True})
1682+ with simulate_juju_core:
1683+ test_backend = backend.Backend(config=config)
1684+ with self.mock_all() as mocks:
1685+ test_backend.install()
1686+ mocks.install_missing_packages.assert_has_calls(expected_calls)
1687+ self.assertFalse(mocks.fetch_api.called)
1688+ mocks.parse_source.assert_called_once_with(self.juju_gui_source)
1689+ mocks.fetch_gui_from_branch.assert_called_once_with(
1690+ 'lp:juju-gui', 42, self.command_log_file)
1691+ self.assertFalse(mocks.fetch_gui_release.called)
1692+ mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_from_branch())
1693+ mocks.install_builtin_server.assert_called_once_with()
1694+
1695+ def test_start_python_legacy(self):
1696+ # Start a pyJuju backend with legacy server.
1697+ config = self.make_config({'builtin-server': False})
1698+ with simulate_pyjuju:
1699+ test_backend = backend.Backend(config=config)
1700+ with self.mock_all() as mocks:
1701+ test_backend.start()
1702+ mocks.start_agent.assert_called_once_with(self.ssl_cert_path)
1703+ mocks.compute_build_dir.assert_called_with(
1704+ config['juju-gui-debug'], config['serve-tests'])
1705+ self.assert_write_gui_config_called(mocks, config)
1706+ mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
1707+ mocks.start_haproxy_apache.assert_called_once_with(
1708+ mocks.compute_build_dir(), config['serve-tests'],
1709+ self.ssl_cert_path, config['secure'])
1710+ self.assertFalse(mocks.start_builtin_server.called)
1711+
1712+ def test_start_go_legacy(self):
1713+ # Start a juju-core backend with legacy server.
1714+ config = self.make_config({'builtin-server': False})
1715+ with simulate_juju_core:
1716+ test_backend = backend.Backend(config=config)
1717+ with self.mock_all() as mocks:
1718+ test_backend.start()
1719+ self.assertFalse(mocks.start_agent.called)
1720+ mocks.compute_build_dir.assert_called_with(
1721+ config['juju-gui-debug'], config['serve-tests'])
1722+ self.assert_write_gui_config_called(mocks, config)
1723+ mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
1724+ mocks.start_haproxy_apache.assert_called_once_with(
1725+ mocks.compute_build_dir(), config['serve-tests'],
1726+ self.ssl_cert_path, config['secure'])
1727+ self.assertFalse(mocks.start_builtin_server.called)
1728+
1729+ def test_start_python_builtin(self):
1730+ # Start a pyJuju backend with builtin server.
1731+ config = self.make_config({'builtin-server': True})
1732+ with simulate_pyjuju:
1733+ test_backend = backend.Backend(config=config)
1734+ with self.mock_all() as mocks:
1735+ test_backend.start()
1736+ mocks.start_agent.assert_called_once_with(self.ssl_cert_path)
1737+ mocks.compute_build_dir.assert_called_with(
1738+ config['juju-gui-debug'], config['serve-tests'])
1739+ self.assert_write_gui_config_called(mocks, config)
1740+ mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
1741+ mocks.start_builtin_server.assert_called_once_with(
1742+ mocks.compute_build_dir(), self.ssl_cert_path,
1743+ config['serve-tests'], config['sandbox'],
1744+ config['builtin-server-logging'], not config['secure'])
1745+ self.assertFalse(mocks.start_haproxy_apache.called)
1746+
1747+ def test_start_go_builtin(self):
1748+ # Start a juju-core backend with builtin server.
1749+ config = self.make_config({'builtin-server': True})
1750+ with simulate_juju_core:
1751+ test_backend = backend.Backend(config=config)
1752+ with self.mock_all() as mocks:
1753+ test_backend.start()
1754+ self.assertFalse(mocks.start_agent.called)
1755+ mocks.compute_build_dir.assert_called_with(
1756+ config['juju-gui-debug'], config['serve-tests'])
1757+ self.assert_write_gui_config_called(mocks, config)
1758+ mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
1759+ mocks.start_builtin_server.assert_called_once_with(
1760+ mocks.compute_build_dir(), self.ssl_cert_path,
1761+ config['serve-tests'], config['sandbox'],
1762+ config['builtin-server-logging'], not config['secure'])
1763+ self.assertFalse(mocks.start_haproxy_apache.called)
1764+
1765+ def test_stop_python_legacy(self):
1766+ # Stop a pyJuju backend with legacy server.
1767+ config = self.make_config({'builtin-server': False})
1768+ with simulate_pyjuju:
1769+ test_backend = backend.Backend(config=config)
1770+ with self.mock_all() as mocks:
1771+ test_backend.stop()
1772+ mocks.stop_agent.assert_called_once_with()
1773+ mocks.stop_haproxy_apache.assert_called_once_with()
1774+ self.assertFalse(mocks.stop_builtin_server.called)
1775+
1776+ def test_stop_go_legacy(self):
1777+ # Stop a juju-core backend with legacy server.
1778+ config = self.make_config({'builtin-server': False})
1779+ with simulate_juju_core:
1780+ test_backend = backend.Backend(config=config)
1781+ with self.mock_all() as mocks:
1782+ test_backend.stop()
1783+ self.assertFalse(mocks.stop_agent.called)
1784+ mocks.stop_haproxy_apache.assert_called_once_with()
1785+ self.assertFalse(mocks.stop_builtin_server.called)
1786+
1787+ def test_stop_python_builtin(self):
1788+ # Stop a pyJuju backend with builtin server.
1789+ config = self.make_config({'builtin-server': True})
1790+ with simulate_pyjuju:
1791+ test_backend = backend.Backend(config=config)
1792+ with self.mock_all() as mocks:
1793+ test_backend.stop()
1794+ mocks.stop_agent.assert_called_once_with()
1795+ mocks.stop_builtin_server.assert_called_once_with()
1796+ self.assertFalse(mocks.stop_haproxy_apache.called)
1797+
1798+ def test_stop_go_builtin(self):
1799+ # Stop a juju-core backend with builtin server.
1800+ config = self.make_config({'builtin-server': True})
1801+ with simulate_juju_core:
1802+ test_backend = backend.Backend(config=config)
1803+ with self.mock_all() as mocks:
1804+ test_backend.stop()
1805+ self.assertFalse(mocks.stop_agent.called)
1806+ mocks.stop_builtin_server.assert_called_once_with()
1807+ self.assertFalse(mocks.stop_haproxy_apache.called)
1808
1809
1810 class TestBackendUtils(unittest.TestCase):
1811@@ -294,23 +528,28 @@
1812 self.assertFalse(test_backend.different('staging'))
1813
1814
1815-class TestChainMethods(unittest.TestCase):
1816+class TestCallMethods(unittest.TestCase):
1817
1818 def setUp(self):
1819 self.called = []
1820-
1821- def method(mixin, backend):
1822- self.called.append(mixin.__class__.__name__)
1823- mixin1 = type('Mixin1', (object,), {'method': method})()
1824- mixin2 = type('Mixin2', (object,), {'method': method})()
1825- self.backend = type('Backend', (), {'mixins': (mixin1, mixin2)})()
1826-
1827- def test_chain(self):
1828- method = backend.chain_methods('method')
1829- method(self.backend)
1830- self.assertEqual(['Mixin1', 'Mixin2'], self.called)
1831-
1832- def test_reversed(self):
1833- method = backend.chain_methods('method', reverse=True)
1834- method(self.backend)
1835- self.assertEqual(['Mixin2', 'Mixin1'], self.called)
1836+ self.objects = [self.make_object('Obj1'), self.make_object('Obj2')]
1837+
1838+ def make_object(self, name, has_method=True):
1839+ """Create and return an test object with the given name."""
1840+ def method(obj, *args):
1841+ self.called.append([obj.__class__.__name__, args])
1842+ object_dict = {'method': method} if has_method else {}
1843+ return type(name, (object,), object_dict)()
1844+
1845+ def test_call(self):
1846+ # The methods are correctly called.
1847+ backend.call_methods(self.objects, 'method', 'arg1', 'arg2')
1848+ expected = [['Obj1', ('arg1', 'arg2')], ['Obj2', ('arg1', 'arg2')]]
1849+ self.assertEqual(expected, self.called)
1850+
1851+ def test_no_method(self):
1852+ # An object without the method is ignored.
1853+ self.objects.append(self.make_object('Obj3', has_method=False))
1854+ backend.call_methods(self.objects, 'method')
1855+ expected = [['Obj1', ()], ['Obj2', ()]]
1856+ self.assertEqual(expected, self.called)
1857
1858=== modified file 'tests/test_utils.py'
1859--- tests/test_utils.py 2013-09-30 20:16:09 +0000
1860+++ tests/test_utils.py 2013-10-08 07:46:30 +0000
1861@@ -48,6 +48,7 @@
1862 parse_source,
1863 get_npm_cache_archive_url,
1864 install_builtin_server,
1865+ install_missing_packages,
1866 remove_apache_setup,
1867 remove_haproxy_setup,
1868 render_to_file,
1869@@ -868,6 +869,55 @@
1870 self.assertEqual(0, mock_run.call_count)
1871
1872
1873+@mock.patch('utils.find_missing_packages')
1874+@mock.patch('utils.install_extra_repositories')
1875+@mock.patch('utils.apt_get_install')
1876+@mock.patch('utils.log')
1877+@mock.patch('utils.cmd_log', mock.Mock())
1878+class TestInstallMissingPackages(unittest.TestCase):
1879+
1880+ packages = ('pkg1', 'pkg2', 'pkg3')
1881+ repository = 'ppa:my/repository'
1882+
1883+ def test_missing(
1884+ self, mock_log, mock_apt_get_install,
1885+ mock_install_extra_repositories, mock_find_missing_packages):
1886+ # The extra repository and packages are correctly installed.
1887+ repository = self.repository
1888+ mock_find_missing_packages.return_value = ['pkg1', 'pkg2']
1889+ install_missing_packages(self.packages, repository=repository)
1890+ mock_find_missing_packages.assert_called_once_with(*self.packages)
1891+ mock_install_extra_repositories.assert_called_once_with(repository)
1892+ mock_apt_get_install.assert_called_once_with('pkg1', 'pkg2')
1893+ mock_log.assert_has_calls([
1894+ mock.call('Adding the apt repository ppa:my/repository.'),
1895+ mock.call('Installing deb packages: pkg1, pkg2.')
1896+ ])
1897+
1898+ def test_missing_no_repository(
1899+ self, mock_log, mock_apt_get_install,
1900+ mock_install_extra_repositories, mock_find_missing_packages):
1901+ # No repositories are installed if not passed.
1902+ mock_find_missing_packages.return_value = ['pkg1', 'pkg2']
1903+ install_missing_packages(self.packages)
1904+ mock_find_missing_packages.assert_called_once_with(*self.packages)
1905+ self.assertFalse(mock_install_extra_repositories.called)
1906+ mock_apt_get_install.assert_called_once_with('pkg1', 'pkg2')
1907+ mock_log.assert_called_once_with(
1908+ 'Installing deb packages: pkg1, pkg2.')
1909+
1910+ def test_no_missing(
1911+ self, mock_log, mock_apt_get_install,
1912+ mock_install_extra_repositories, mock_find_missing_packages):
1913+ # Nothing is installed if no missing packages are found.
1914+ mock_find_missing_packages.return_value = []
1915+ install_missing_packages(self.packages, repository=self.repository)
1916+ mock_find_missing_packages.assert_called_once_with(*self.packages)
1917+ self.assertFalse(mock_install_extra_repositories.called)
1918+ self.assertFalse(mock_apt_get_install.called)
1919+ mock_log.assert_called_once_with('No missing deb packages.')
1920+
1921+
1922 class TestNpmCache(unittest.TestCase):
1923 """To speed building from a branch we prepopulate the NPM cache."""
1924

Subscribers

People subscribed via source and target branches