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
=== modified file 'config.yaml'
--- config.yaml 2013-09-30 20:16:09 +0000
+++ config.yaml 2013-10-08 07:46:30 +0000
@@ -183,7 +183,7 @@
183 This is a temporary option: the built-in server will be183 This is a temporary option: the built-in server will be
184 the only server in the future.184 the only server in the future.
185 type: boolean185 type: boolean
186 default: false186 default: true
187 builtin-server-logging:187 builtin-server-logging:
188 description: |188 description: |
189 Set the GUI server log level. Possible values are debug, info, warning189 Set the GUI server log level. Possible values are debug, info, warning
190190
=== modified file 'hooks/backend.py'
--- hooks/backend.py 2013-10-01 17:54:23 +0000
+++ hooks/backend.py 2013-10-08 07:46:30 +0000
@@ -42,8 +42,10 @@
42import os42import os
43import shutil43import shutil
4444
45import charmhelpers45from charmhelpers import (
46import shelltoolbox46 log,
47 open_port,
48)
4749
48import utils50import utils
4951
@@ -52,6 +54,7 @@
52 """Handle the overall set up and clean up processes."""54 """Handle the overall set up and clean up processes."""
5355
54 def install(self, backend):56 def install(self, backend):
57 log('Setting up base dir: {}.'.format(utils.BASE_DIR))
55 try:58 try:
56 os.makedirs(utils.BASE_DIR)59 os.makedirs(utils.BASE_DIR)
57 except OSError as err:60 except OSError as err:
@@ -60,6 +63,7 @@
60 raise63 raise
6164
62 def destroy(self, backend):65 def destroy(self, backend):
66 log('Cleaning up base dir: {}.'.format(utils.BASE_DIR))
63 shutil.rmtree(utils.BASE_DIR)67 shutil.rmtree(utils.BASE_DIR)
6468
6569
@@ -109,25 +113,27 @@
109class GuiMixin(object):113class GuiMixin(object):
110 """Install and start the GUI and its dependencies."""114 """Install and start the GUI and its dependencies."""
111115
116 # The curl package is used to download release tarballs from Launchpad.
112 debs = ('curl',)117 debs = ('curl',)
113118
114 def install(self, backend):119 def install(self, backend):
115 """Install the GUI and dependencies."""120 """Install the GUI and dependencies."""
116 # If the given installable thing ("backend") requires one or more debs
117 # that are not yet installed, install them.
118 missing = utils.find_missing_packages(*backend.debs)
119 if missing:
120 utils.cmd_log(
121 shelltoolbox.apt_get_install(*backend.debs))
122 # If the source setting has changed since the last time this was run,121 # If the source setting has changed since the last time this was run,
123 # get the code, from either a static release or a branch as specified122 # get the code, from either a static release or a branch as specified
124 # by the souce setting, and install it.123 # by the souce setting, and install it.
125 if backend.different('juju-gui-source'):124 if backend.different('juju-gui-source'):
126 # Get a tarball somehow.125 # Get a tarball somehow.
127 logpath = backend.config['command-log-file']
128 origin, version_or_branch = utils.parse_source(126 origin, version_or_branch = utils.parse_source(
129 backend.config['juju-gui-source'])127 backend.config['juju-gui-source'])
130 if origin == 'branch':128 if origin == 'branch':
129 logpath = backend.config['command-log-file']
130 # Make sure we have the required build dependencies.
131 # Note that we also need to add the juju-gui repository
132 # containing our version of nodejs.
133 log('Installing build dependencies.')
134 utils.install_missing_packages(
135 utils.DEB_BUILD_DEPENDENCIES,
136 repository=backend.config['repository-location'])
131 branch_url, revision = version_or_branch137 branch_url, revision = version_or_branch
132 release_tarball_path = utils.fetch_gui_from_branch(138 release_tarball_path = utils.fetch_gui_from_branch(
133 branch_url, revision, logpath)139 branch_url, revision, logpath)
@@ -138,7 +144,7 @@
138 utils.setup_gui(release_tarball_path)144 utils.setup_gui(release_tarball_path)
139145
140 def start(self, backend):146 def start(self, backend):
141 charmhelpers.log('Starting Juju GUI.')147 log('Starting Juju GUI.')
142 config = backend.config148 config = backend.config
143 build_dir = utils.compute_build_dir(149 build_dir = utils.compute_build_dir(
144 config['juju-gui-debug'], config['serve-tests'])150 config['juju-gui-debug'], config['serve-tests'])
@@ -151,8 +157,8 @@
151 show_get_juju_button=config['show-get-juju-button'],157 show_get_juju_button=config['show-get-juju-button'],
152 password=config.get('password'))158 password=config.get('password'))
153 # Expose the service.159 # Expose the service.
154 charmhelpers.open_port(80)160 open_port(80)
155 charmhelpers.open_port(443)161 open_port(443)
156162
157163
158class ServerInstallMixinBase(object):164class ServerInstallMixinBase(object):
@@ -175,6 +181,8 @@
175 """Manage haproxy and Apache via Upstart."""181 """Manage haproxy and Apache via Upstart."""
176182
177 debs = ('apache2', 'haproxy', 'openssl')183 debs = ('apache2', 'haproxy', 'openssl')
184 # We need to add the juju-gui PPA containing our customized haproxy.
185 ppa_required = True
178186
179 def install(self, backend):187 def install(self, backend):
180 self._setup_certificates(backend)188 self._setup_certificates(backend)
@@ -215,32 +223,15 @@
215 utils.stop_builtin_server()223 utils.stop_builtin_server()
216224
217225
218def chain_methods(name, reverse=False):226def call_methods(objects, name, *args):
219 """Helper to compose a set of mixin objects into a callable.227 """For each given object, call, if present, the method named name.
220228
221 Each method is called in the context of its mixin instance, and its229 Pass the given args.
222 argument is the Backend instance.
223 """230 """
224 # Chain method calls through all implementing mixins.231 for obj in objects:
225 def method(self):232 method = getattr(obj, name, None)
226 mixins = reversed(self.mixins) if reverse else self.mixins233 if method is not None:
227 for mixin in mixins:234 method(*args)
228 a_callable = getattr(type(mixin), name, None)
229 if a_callable is not None:
230 a_callable(mixin, self)
231 method.__name__ = name
232 return method
233
234
235def merge_properties(name):
236 """Helper to merge one property from mixin objects into a unified set."""
237 @property
238 def method(self):
239 result = set()
240 for mixin in self.mixins:
241 result |= set(getattr(type(mixin), name, frozenset()))
242 return result
243 return method
244235
245236
246class Backend(object):237class Backend(object):
@@ -296,11 +287,39 @@
296 current, previous = self.config.get, self.prev_config.get287 current, previous = self.config.get, self.prev_config.get
297 return any(current(key) != previous(key) for key in keys)288 return any(current(key) != previous(key) for key in keys)
298289
299 # Composed methods.290 def get_dependencies(self):
300 install = chain_methods('install')291 """Return a tuple (debs, repository) representing dependencies."""
301 start = chain_methods('start')292 debs = set()
302 stop = chain_methods('stop', reverse=True)293 needs_ppa = False
303 destroy = chain_methods('destroy', reverse=True)294 # Collect the required dependencies and check if adding the juju-gui
304295 # PPA is required.
305 # Merged properties.296 for mixin in self.mixins:
306 debs = merge_properties('debs')297 debs.update(getattr(mixin, 'debs', ()))
298 if getattr(mixin, 'ppa_required', False):
299 needs_ppa = True
300 return debs, self.config['repository-location'] if needs_ppa else None
301
302 def install(self):
303 """Execute the installation steps."""
304 debs, repository = self.get_dependencies()
305 log('Installing dependencies.')
306 utils.install_missing_packages(debs, repository=repository)
307 call_methods(self.mixins, 'install', self)
308
309 def start(self):
310 """Execute the charm's "start" steps."""
311 call_methods(self.mixins, 'start', self)
312
313 def stop(self):
314 """Execute the charm's "stop" steps.
315
316 Iterate through the mixins in reverse order.
317 """
318 call_methods(reversed(self.mixins), 'stop', self)
319
320 def destroy(self):
321 """Execute the charm removal steps.
322
323 Iterate through the mixins in reverse order.
324 """
325 call_methods(reversed(self.mixins), 'destroy', self)
307326
=== removed file 'hooks/bootstrap_utils.py'
--- hooks/bootstrap_utils.py 2013-06-11 14:04:04 +0000
+++ hooks/bootstrap_utils.py 1970-01-01 00:00:00 +0000
@@ -1,77 +0,0 @@
1# This file is part of the Juju GUI, which lets users view and manage Juju
2# environments within a graphical interface (https://launchpad.net/juju-gui).
3# Copyright (C) 2012-2013 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""
18These are actually maintained in python-shelltoolbox. Precise does not have
19that package, so we need to bootstrap the process by copying the functions
20we need here.
21"""
22
23import subprocess
24
25
26try:
27 import shelltoolbox
28except ImportError:
29
30 def run(*args, **kwargs):
31 """Run the command with the given arguments.
32
33 The first argument is the path to the command to run.
34 Subsequent arguments are command-line arguments to be passed.
35
36 This function accepts all optional keyword arguments accepted by
37 `subprocess.Popen`.
38 """
39 args = [i for i in args if i is not None]
40 pipe = subprocess.PIPE
41 process = subprocess.Popen(
42 args, stdout=kwargs.pop('stdout', pipe),
43 stderr=kwargs.pop('stderr', pipe),
44 close_fds=kwargs.pop('close_fds', True), **kwargs)
45 stdout, stderr = process.communicate()
46 if process.returncode:
47 exception = subprocess.CalledProcessError(
48 process.returncode, repr(args))
49 # The output argument of `CalledProcessError` was introduced in
50 # Python 2.7. Monkey patch the output here to avoid TypeErrors
51 # in older versions of Python, still preserving the output in
52 # Python 2.7.
53 exception.output = ''.join(filter(None, [stdout, stderr]))
54 raise exception
55 return stdout
56
57 def install_extra_repositories(*repositories):
58 """Install all of the extra repositories and update apt.
59
60 Given repositories can contain a "{distribution}" placeholder,
61 that will be replaced by current distribution codename.
62
63 :raises: subprocess.CalledProcessError
64 """
65 distribution = run('lsb_release', '-cs').strip()
66 # Starting from Oneiric, `apt-add-repository` is interactive by
67 # default, and requires a "-y" flag to be set.
68 assume_yes = None if distribution == 'lucid' else '-y'
69 for repo in repositories:
70 repository = repo.format(distribution=distribution)
71 run('apt-add-repository', assume_yes, repository)
72 run('apt-get', 'clean')
73 run('apt-get', 'update')
74
75else:
76 install_extra_repositories = shelltoolbox.install_extra_repositories
77 run = shelltoolbox.run
780
=== modified file 'hooks/install'
--- hooks/install 2013-10-01 20:53:38 +0000
+++ hooks/install 2013-10-08 07:46:30 +0000
@@ -18,31 +18,27 @@
18# along with this program. If not, see <http://www.gnu.org/licenses/>.18# along with this program. If not, see <http://www.gnu.org/licenses/>.
1919
20import errno20import errno
21import json
22import os21import os
2322
24# We need to install the Juju PPA, which we will do with a couple of functions23from charmhelpers import (
25# that are actually maintained in python-shelltoolbox.24 get_config,
26import bootstrap_utils25 log,
2726)
2827from shelltoolbox import (
29def get_config():28 apt_get_install,
30 output = bootstrap_utils.run('config-get', '--format', 'json')29 run,
31 return json.loads(output)30)
3231
33config = get_config()
34bootstrap_utils.install_extra_repositories(config['repository-location'])
3532
36# Python dependencies must be installed here so that the charm can import and33# Python dependencies must be installed here so that the charm can import and
37# use required libraries.34# use required libraries.
38PYTHON_DEPENDENCIES = (35PYTHON_DEPENDENCIES = ('python-apt', 'python-launchpadlib', 'python-tempita')
39 'python-apt', 'python-launchpadlib', 'python-shelltoolbox',36
40 'python-tempita',37log('Installing base Python dependnecies: {}.'.format(
41)38 ', '.join(PYTHON_DEPENDENCIES)))
42bootstrap_utils.run(*(('apt-get', 'install', '-y') + PYTHON_DEPENDENCIES))39apt_get_install(*PYTHON_DEPENDENCIES)
4340
4441
45from charmhelpers import log
46from utils import (42from utils import (
47 config_json,43 config_json,
48 log_hook,44 log_hook,
@@ -60,7 +56,7 @@
60 for module in dirnames:56 for module in dirnames:
61 filename = os.path.join('exec.d', module, 'charm-pre-install')57 filename = os.path.join('exec.d', module, 'charm-pre-install')
62 try:58 try:
63 bootstrap_utils.run(filename)59 run(filename)
64 except OSError, e:60 except OSError, e:
65 # If the exec.d file does not exist or is not runnable or61 # If the exec.d file does not exist or is not runnable or
66 # is not a directory, assume we can recover. Log the problem62 # is not a directory, assume we can recover. Log the problem
6763
=== added file 'hooks/shelltoolbox.py'
--- hooks/shelltoolbox.py 1970-01-01 00:00:00 +0000
+++ hooks/shelltoolbox.py 2013-10-08 07:46:30 +0000
@@ -0,0 +1,669 @@
1# Copyright 2012 Canonical Ltd.
2
3# This file is taken from the python-shelltoolbox package.
4#
5# IMPORTANT: Do not modify this file to add or change functionality. If you
6# really feel the need to do so, first convert our code to the shelltoolbox
7# library, and modify it instead (or modify the helpers or utils module here,
8# as appropriate).
9#
10# python-shell-toolbox is free software: you can redistribute it and/or modify
11# it under the terms of the GNU General Public License as published by the
12# Free Software Foundation, version 3 of the License.
13#
14# python-shell-toolbox is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
16# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with python-shell-toolbox. If not, see <http://www.gnu.org/licenses/>.
21
22"""Helper functions for accessing shell commands in Python."""
23
24__metaclass__ = type
25__all__ = [
26 'apt_get_install',
27 'bzr_whois',
28 'cd',
29 'command',
30 'DictDiffer',
31 'environ',
32 'file_append',
33 'file_prepend',
34 'generate_ssh_keys',
35 'get_su_command',
36 'get_user_home',
37 'get_user_ids',
38 'install_extra_repositories',
39 'join_command',
40 'mkdirs',
41 'run',
42 'Serializer',
43 'script_name',
44 'search_file',
45 'ssh',
46 'su',
47 'user_exists',
48 'wait_for_page_contents',
49 ]
50
51from collections import namedtuple
52from contextlib import contextmanager
53from email.Utils import parseaddr
54import errno
55import json
56import operator
57import os
58import pipes
59import pwd
60import re
61import subprocess
62import sys
63from textwrap import dedent
64import time
65import urllib2
66
67
68Env = namedtuple('Env', 'uid gid home')
69
70
71def apt_get_install(*args, **kwargs):
72 """Install given packages using apt.
73
74 It is possible to pass environment variables to be set during install
75 using keyword arguments.
76
77 :raises: subprocess.CalledProcessError
78 """
79 caller = kwargs.pop('caller', run)
80 debian_frontend = kwargs.pop('DEBIAN_FRONTEND', 'noninteractive')
81 with environ(DEBIAN_FRONTEND=debian_frontend, **kwargs):
82 cmd = ('apt-get', '-y', 'install') + args
83 return caller(*cmd)
84
85
86def bzr_whois(user):
87 """Return full name and email of bzr `user`.
88
89 Return None if the given `user` does not have a bzr user id.
90 """
91 with su(user):
92 try:
93 whoami = run('bzr', 'whoami')
94 except (subprocess.CalledProcessError, OSError):
95 return None
96 return parseaddr(whoami)
97
98
99@contextmanager
100def cd(directory):
101 """A context manager to temporarily change current working dir, e.g.::
102
103 >>> import os
104 >>> os.chdir('/tmp')
105 >>> with cd('/bin'): print os.getcwd()
106 /bin
107 >>> print os.getcwd()
108 /tmp
109 """
110 cwd = os.getcwd()
111 os.chdir(directory)
112 try:
113 yield
114 finally:
115 os.chdir(cwd)
116
117
118def command(*base_args):
119 """Return a callable that will run the given command with any arguments.
120
121 The first argument is the path to the command to run, subsequent arguments
122 are command-line arguments to "bake into" the returned callable.
123
124 The callable runs the given executable and also takes arguments that will
125 be appeneded to the "baked in" arguments.
126
127 For example, this code will list a file named "foo" (if it exists):
128
129 ls_foo = command('/bin/ls', 'foo')
130 ls_foo()
131
132 While this invocation will list "foo" and "bar" (assuming they exist):
133
134 ls_foo('bar')
135 """
136 def callable_command(*args):
137 all_args = base_args + args
138 return run(*all_args)
139
140 return callable_command
141
142
143@contextmanager
144def environ(**kwargs):
145 """A context manager to temporarily change environment variables.
146
147 If an existing environment variable is changed, it is restored during
148 context cleanup::
149
150 >>> import os
151 >>> os.environ['MY_VARIABLE'] = 'foo'
152 >>> with environ(MY_VARIABLE='bar'): print os.getenv('MY_VARIABLE')
153 bar
154 >>> print os.getenv('MY_VARIABLE')
155 foo
156 >>> del os.environ['MY_VARIABLE']
157
158 If we are adding environment variables, they are removed during context
159 cleanup::
160
161 >>> import os
162 >>> with environ(MY_VAR1='foo', MY_VAR2='bar'):
163 ... print os.getenv('MY_VAR1'), os.getenv('MY_VAR2')
164 foo bar
165 >>> os.getenv('MY_VAR1') == os.getenv('MY_VAR2') == None
166 True
167 """
168 backup = {}
169 for key, value in kwargs.items():
170 backup[key] = os.getenv(key)
171 os.environ[key] = value
172 try:
173 yield
174 finally:
175 for key, value in backup.items():
176 if value is None:
177 del os.environ[key]
178 else:
179 os.environ[key] = value
180
181
182def file_append(filename, line):
183 r"""Append given `line`, if not present, at the end of `filename`.
184
185 Usage example::
186
187 >>> import tempfile
188 >>> f = tempfile.NamedTemporaryFile('w', delete=False)
189 >>> f.write('line1\n')
190 >>> f.close()
191 >>> file_append(f.name, 'new line\n')
192 >>> open(f.name).read()
193 'line1\nnew line\n'
194
195 Nothing happens if the file already contains the given `line`::
196
197 >>> file_append(f.name, 'new line\n')
198 >>> open(f.name).read()
199 'line1\nnew line\n'
200
201 A new line is automatically added before the given `line` if it is not
202 present at the end of current file content::
203
204 >>> import tempfile
205 >>> f = tempfile.NamedTemporaryFile('w', delete=False)
206 >>> f.write('line1')
207 >>> f.close()
208 >>> file_append(f.name, 'new line\n')
209 >>> open(f.name).read()
210 'line1\nnew line\n'
211
212 The file is created if it does not exist::
213
214 >>> import tempfile
215 >>> filename = tempfile.mktemp()
216 >>> file_append(filename, 'line1\n')
217 >>> open(filename).read()
218 'line1\n'
219 """
220 if not line.endswith('\n'):
221 line += '\n'
222 with open(filename, 'a+') as f:
223 lines = f.readlines()
224 if line not in lines:
225 if not lines or lines[-1].endswith('\n'):
226 f.write(line)
227 else:
228 f.write('\n' + line)
229
230
231def file_prepend(filename, line):
232 r"""Insert given `line`, if not present, at the beginning of `filename`.
233
234 Usage example::
235
236 >>> import tempfile
237 >>> f = tempfile.NamedTemporaryFile('w', delete=False)
238 >>> f.write('line1\n')
239 >>> f.close()
240 >>> file_prepend(f.name, 'line0\n')
241 >>> open(f.name).read()
242 'line0\nline1\n'
243
244 If the file starts with the given `line`, nothing happens::
245
246 >>> file_prepend(f.name, 'line0\n')
247 >>> open(f.name).read()
248 'line0\nline1\n'
249
250 If the file contains the given `line`, but not at the beginning,
251 the line is moved on top::
252
253 >>> file_prepend(f.name, 'line1\n')
254 >>> open(f.name).read()
255 'line1\nline0\n'
256 """
257 if not line.endswith('\n'):
258 line += '\n'
259 with open(filename, 'r+') as f:
260 lines = f.readlines()
261 if lines[0] != line:
262 try:
263 lines.remove(line)
264 except ValueError:
265 pass
266 lines.insert(0, line)
267 f.seek(0)
268 f.writelines(lines)
269
270
271def generate_ssh_keys(path, passphrase=''):
272 """Generate ssh key pair, saving them inside the given `directory`.
273
274 >>> generate_ssh_keys('/tmp/id_rsa')
275 0
276 >>> open('/tmp/id_rsa').readlines()[0].strip()
277 '-----BEGIN RSA PRIVATE KEY-----'
278 >>> open('/tmp/id_rsa.pub').read().startswith('ssh-rsa')
279 True
280 >>> os.remove('/tmp/id_rsa')
281 >>> os.remove('/tmp/id_rsa.pub')
282
283 If either of the key files already exist, generate_ssh_keys() will
284 raise an Exception.
285
286 Note that ssh-keygen will prompt if the keyfiles already exist, but
287 when we're using it non-interactively it's better to pre-empt that
288 behaviour.
289
290 >>> with open('/tmp/id_rsa', 'w') as key_file:
291 ... key_file.write("Don't overwrite me, bro!")
292 >>> generate_ssh_keys('/tmp/id_rsa') # doctest: +ELLIPSIS
293 Traceback (most recent call last):
294 Exception: File /tmp/id_rsa already exists...
295 >>> os.remove('/tmp/id_rsa')
296
297 >>> with open('/tmp/id_rsa.pub', 'w') as key_file:
298 ... key_file.write("Don't overwrite me, bro!")
299 >>> generate_ssh_keys('/tmp/id_rsa') # doctest: +ELLIPSIS
300 Traceback (most recent call last):
301 Exception: File /tmp/id_rsa.pub already exists...
302 >>> os.remove('/tmp/id_rsa.pub')
303 """
304 if os.path.exists(path):
305 raise Exception("File {} already exists.".format(path))
306 if os.path.exists(path + '.pub'):
307 raise Exception("File {}.pub already exists.".format(path))
308 return subprocess.call([
309 'ssh-keygen', '-q', '-t', 'rsa', '-N', passphrase, '-f', path])
310
311
312def get_su_command(user, args):
313 """Return a command line as a sequence, prepending "su" if necessary.
314
315 This can be used together with `run` when the `su` context manager is not
316 enough (e.g. an external program uses uid rather than euid).
317
318 run(*get_su_command(user, ['bzr', 'whoami']))
319
320 If the su is requested as current user, the arguments are returned as
321 given::
322
323 >>> import getpass
324 >>> current_user = getpass.getuser()
325
326 >>> get_su_command(current_user, ('ls', '-l'))
327 ('ls', '-l')
328
329 Otherwise, "su" is prepended::
330
331 >>> get_su_command('nobody', ('ls', '-l', 'my file'))
332 ('su', 'nobody', '-c', "ls -l 'my file'")
333 """
334 if get_user_ids(user)[0] != os.getuid():
335 args = [i for i in args if i is not None]
336 return ('su', user, '-c', join_command(args))
337 return args
338
339
340def get_user_home(user):
341 """Return the home directory of the given `user`.
342
343 >>> get_user_home('root')
344 '/root'
345
346 If the user does not exist, return a default /home/[username] home::
347
348 >>> get_user_home('_this_user_does_not_exist_')
349 '/home/_this_user_does_not_exist_'
350 """
351 try:
352 return pwd.getpwnam(user).pw_dir
353 except KeyError:
354 return os.path.join(os.path.sep, 'home', user)
355
356
357def get_user_ids(user):
358 """Return the uid and gid of given `user`, e.g.::
359
360 >>> get_user_ids('root')
361 (0, 0)
362 """
363 userdata = pwd.getpwnam(user)
364 return userdata.pw_uid, userdata.pw_gid
365
366
367def install_extra_repositories(*repositories):
368 """Install all of the extra repositories and update apt.
369
370 Given repositories can contain a "{distribution}" placeholder, that will
371 be replaced by current distribution codename.
372
373 :raises: subprocess.CalledProcessError
374 """
375 distribution = run('lsb_release', '-cs').strip()
376 # Starting from Oneiric, `apt-add-repository` is interactive by
377 # default, and requires a "-y" flag to be set.
378 assume_yes = None if distribution == 'lucid' else '-y'
379 for repo in repositories:
380 repository = repo.format(distribution=distribution)
381 run('apt-add-repository', assume_yes, repository)
382 run('apt-get', 'clean')
383 run('apt-get', 'update')
384
385
386def join_command(args):
387 """Return a valid Unix command line from `args`.
388
389 >>> join_command(['ls', '-l'])
390 'ls -l'
391
392 Arguments containing spaces and empty args are correctly quoted::
393
394 >>> join_command(['command', 'arg1', 'arg containing spaces', ''])
395 "command arg1 'arg containing spaces' ''"
396 """
397 return ' '.join(pipes.quote(arg) for arg in args)
398
399
400def mkdirs(*args):
401 """Create leaf directories (given as `args`) and all intermediate ones.
402
403 >>> import tempfile
404 >>> base_dir = tempfile.mktemp(suffix='/')
405 >>> dir1 = tempfile.mktemp(prefix=base_dir)
406 >>> dir2 = tempfile.mktemp(prefix=base_dir)
407 >>> mkdirs(dir1, dir2)
408 >>> os.path.isdir(dir1)
409 True
410 >>> os.path.isdir(dir2)
411 True
412
413 If the leaf directory already exists the function returns without errors::
414
415 >>> mkdirs(dir1)
416
417 An `OSError` is raised if the leaf path exists and it is a file::
418
419 >>> f = tempfile.NamedTemporaryFile(
420 ... 'w', delete=False, prefix=base_dir)
421 >>> f.close()
422 >>> mkdirs(f.name) # doctest: +ELLIPSIS
423 Traceback (most recent call last):
424 OSError: ...
425 """
426 for directory in args:
427 try:
428 os.makedirs(directory)
429 except OSError as err:
430 if err.errno != errno.EEXIST or os.path.isfile(directory):
431 raise
432
433
434def run(*args, **kwargs):
435 """Run the command with the given arguments.
436
437 The first argument is the path to the command to run.
438 Subsequent arguments are command-line arguments to be passed.
439
440 This function accepts all optional keyword arguments accepted by
441 `subprocess.Popen`.
442 """
443 args = [i for i in args if i is not None]
444 pipe = subprocess.PIPE
445 process = subprocess.Popen(
446 args, stdout=kwargs.pop('stdout', pipe),
447 stderr=kwargs.pop('stderr', pipe),
448 close_fds=kwargs.pop('close_fds', True), **kwargs)
449 stdout, stderr = process.communicate()
450 if process.returncode:
451 exception = subprocess.CalledProcessError(
452 process.returncode, repr(args))
453 # The output argument of `CalledProcessError` was introduced in Python
454 # 2.7. Monkey patch the output here to avoid TypeErrors in older
455 # versions of Python, still preserving the output in Python 2.7.
456 exception.output = ''.join(filter(None, [stdout, stderr]))
457 raise exception
458 return stdout
459
460
461def script_name():
462 """Return the name of this script."""
463 return os.path.basename(sys.argv[0])
464
465
466def search_file(regexp, filename):
467 """Return the first line in `filename` that matches `regexp`."""
468 with open(filename) as f:
469 for line in f:
470 if re.search(regexp, line):
471 return line
472
473
474def ssh(location, user=None, key=None, caller=subprocess.call):
475 """Return a callable that can be used to run ssh shell commands.
476
477 The ssh `location` and, optionally, `user` must be given.
478 If the user is None then the current user is used for the connection.
479
480 The callable internally uses the given `caller`::
481
482 >>> def caller(cmd):
483 ... print tuple(cmd)
484 >>> sshcall = ssh('example.com', 'myuser', caller=caller)
485 >>> root_sshcall = ssh('example.com', caller=caller)
486 >>> sshcall('ls -l') # doctest: +ELLIPSIS
487 ('ssh', '-t', ..., 'myuser@example.com', '--', 'ls -l')
488 >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
489 ('ssh', '-t', ..., 'example.com', '--', 'ls -l')
490
491 The ssh key path can be optionally provided::
492
493 >>> root_sshcall = ssh('example.com', key='/tmp/foo', caller=caller)
494 >>> root_sshcall('ls -l') # doctest: +ELLIPSIS
495 ('ssh', '-t', ..., '-i', '/tmp/foo', 'example.com', '--', 'ls -l')
496
497 If the ssh command exits with an error code,
498 a `subprocess.CalledProcessError` is raised::
499
500 >>> ssh('loc', caller=lambda cmd: 1)('ls -l') # doctest: +ELLIPSIS
501 Traceback (most recent call last):
502 CalledProcessError: ...
503
504 If ignore_errors is set to True when executing the command, no error
505 will be raised, even if the command itself returns an error code.
506
507 >>> sshcall = ssh('loc', caller=lambda cmd: 1)
508 >>> sshcall('ls -l', ignore_errors=True)
509 """
510 sshcmd = [
511 'ssh',
512 '-t',
513 '-t', # Yes, this second -t is deliberate. See `man ssh`.
514 '-o', 'StrictHostKeyChecking=no',
515 '-o', 'UserKnownHostsFile=/dev/null',
516 ]
517 if key is not None:
518 sshcmd.extend(['-i', key])
519 if user is not None:
520 location = '{}@{}'.format(user, location)
521 sshcmd.extend([location, '--'])
522
523 def _sshcall(cmd, ignore_errors=False):
524 command = sshcmd + [cmd]
525 retcode = caller(command)
526 if retcode and not ignore_errors:
527 raise subprocess.CalledProcessError(retcode, ' '.join(command))
528
529 return _sshcall
530
531
532@contextmanager
533def su(user):
534 """A context manager to temporarily run the script as a different user."""
535 uid, gid = get_user_ids(user)
536 os.setegid(gid)
537 os.seteuid(uid)
538 home = get_user_home(user)
539 with environ(HOME=home):
540 try:
541 yield Env(uid, gid, home)
542 finally:
543 os.setegid(os.getgid())
544 os.seteuid(os.getuid())
545
546
547def user_exists(username):
548 """Return True if given `username` exists, e.g.::
549
550 >>> user_exists('root')
551 True
552 >>> user_exists('_this_user_does_not_exist_')
553 False
554 """
555 try:
556 pwd.getpwnam(username)
557 except KeyError:
558 return False
559 return True
560
561
562def wait_for_page_contents(url, contents, timeout=120, validate=None):
563 if validate is None:
564 validate = operator.contains
565 start_time = time.time()
566 while True:
567 try:
568 stream = urllib2.urlopen(url)
569 except (urllib2.HTTPError, urllib2.URLError):
570 pass
571 else:
572 page = stream.read()
573 if validate(page, contents):
574 return page
575 if time.time() - start_time >= timeout:
576 raise RuntimeError('timeout waiting for contents of ' + url)
577 time.sleep(0.1)
578
579
580class DictDiffer:
581 """
582 Calculate the difference between two dictionaries as:
583 (1) items added
584 (2) items removed
585 (3) keys same in both but changed values
586 (4) keys same in both and unchanged values
587 """
588
589 # Based on answer by hughdbrown at:
590 # http://stackoverflow.com/questions/1165352
591
592 def __init__(self, current_dict, past_dict):
593 self.current_dict = current_dict
594 self.past_dict = past_dict
595 self.set_current = set(current_dict)
596 self.set_past = set(past_dict)
597 self.intersect = self.set_current.intersection(self.set_past)
598
599 @property
600 def added(self):
601 return self.set_current - self.intersect
602
603 @property
604 def removed(self):
605 return self.set_past - self.intersect
606
607 @property
608 def changed(self):
609 return set(key for key in self.intersect
610 if self.past_dict[key] != self.current_dict[key])
611
612 @property
613 def unchanged(self):
614 return set(key for key in self.intersect
615 if self.past_dict[key] == self.current_dict[key])
616
617 @property
618 def modified(self):
619 return self.current_dict != self.past_dict
620
621 @property
622 def added_or_changed(self):
623 return self.added.union(self.changed)
624
625 def _changes(self, keys):
626 new = {}
627 old = {}
628 for k in keys:
629 new[k] = self.current_dict.get(k)
630 old[k] = self.past_dict.get(k)
631 return "%s -> %s" % (old, new)
632
633 def __str__(self):
634 if self.modified:
635 s = dedent("""\
636 added: %s
637 removed: %s
638 changed: %s
639 unchanged: %s""") % (
640 self._changes(self.added),
641 self._changes(self.removed),
642 self._changes(self.changed),
643 list(self.unchanged))
644 else:
645 s = "no changes"
646 return s
647
648
649class Serializer:
650 """Handle JSON (de)serialization."""
651
652 def __init__(self, path, default=None, serialize=None, deserialize=None):
653 self.path = path
654 self.default = default or {}
655 self.serialize = serialize or json.dump
656 self.deserialize = deserialize or json.load
657
658 def exists(self):
659 return os.path.exists(self.path)
660
661 def get(self):
662 if self.exists():
663 with open(self.path) as f:
664 return self.deserialize(f)
665 return self.default
666
667 def set(self, data):
668 with open(self.path, 'w') as f:
669 self.serialize(data, f)
0670
=== modified file 'hooks/utils.py'
--- hooks/utils.py 2013-10-04 12:53:53 +0000
+++ hooks/utils.py 2013-10-08 07:46:30 +0000
@@ -36,6 +36,7 @@
36 'get_npm_cache_archive_url',36 'get_npm_cache_archive_url',
37 'get_release_file_url',37 'get_release_file_url',
38 'get_zookeeper_address',38 'get_zookeeper_address',
39 'install_missing_packages',
39 'legacy_juju',40 'legacy_juju',
40 'log_hook',41 'log_hook',
41 'parse_source',42 'parse_source',
@@ -78,6 +79,7 @@
78 apt_get_install,79 apt_get_install,
79 command,80 command,
80 environ,81 environ,
82 install_extra_repositories,
81 run,83 run,
82 script_name,84 script_name,
83 search_file,85 search_file,
@@ -155,12 +157,6 @@
155results_log = None157results_log = None
156158
157159
158def _get_build_dependencies():
159 """Install deb dependencies for building."""
160 log('Installing build dependencies.')
161 cmd_log(apt_get_install(*DEB_BUILD_DEPENDENCIES))
162
163
164def get_api_address(unit_dir=None):160def get_api_address(unit_dir=None):
165 """Return the Juju API address.161 """Return the Juju API address.
166162
@@ -623,8 +619,6 @@
623619
624def fetch_gui_from_branch(branch_url, revision, logpath):620def fetch_gui_from_branch(branch_url, revision, logpath):
625 """Retrieve the Juju GUI from a branch and build a release archive."""621 """Retrieve the Juju GUI from a branch and build a release archive."""
626 # Make sure we have the needed dependencies.
627 _get_build_dependencies()
628 # Inject NPM packages into the cache for faster building.622 # Inject NPM packages into the cache for faster building.
629 prime_npm_cache(get_npm_cache_archive_url())623 prime_npm_cache(get_npm_cache_archive_url())
630 # Create a release starting from a branch.624 # Create a release starting from a branch.
@@ -738,3 +732,20 @@
738 continue732 continue
739 missing.add(pkg_name)733 missing.add(pkg_name)
740 return missing734 return missing
735
736
737def install_missing_packages(packages, repository=None):
738 """Install the required debian packages if they are missing.
739
740 If repository is not None, add the given apt repository before installing
741 the dependencies.
742 """
743 missing = find_missing_packages(*packages)
744 if missing:
745 if repository is not None:
746 log('Adding the apt repository {}.'.format(repository))
747 install_extra_repositories(repository)
748 log('Installing deb packages: {}.'.format(', '.join(missing)))
749 cmd_log(apt_get_install(*missing))
750 else:
751 log('No missing deb packages.')
741752
=== modified file 'revision'
--- revision 2013-10-04 13:40:26 +0000
+++ revision 2013-10-08 07:46:30 +0000
@@ -1,1 +1,1 @@
188189
22
=== modified file 'tests/20-functional.test'
--- tests/20-functional.test 2013-09-25 16:31:24 +0000
+++ tests/20-functional.test 2013-10-08 07:46:30 +0000
@@ -207,8 +207,11 @@
207207
208 def test_cache_headers(self):208 def test_cache_headers(self):
209 # Make sure the correct cache headers are sent.209 # Make sure the correct cache headers are sent.
210 unit_info = self.juju_deploy(210 options = {
211 self.charm, options={'juju-gui-source': JUJU_GUI_TEST_BRANCH})211 'builtin-server': False,
212 'juju-gui-source': JUJU_GUI_TEST_BRANCH,
213 }
214 unit_info = self.juju_deploy(self.charm, options=options)
212 hostname = unit_info['public-address']215 hostname = unit_info['public-address']
213 conn = httplib.HTTPSConnection(hostname)216 conn = httplib.HTTPSConnection(hostname)
214 conn.request('HEAD', '/')217 conn.request('HEAD', '/')
215218
=== modified file 'tests/requirements.pip'
--- tests/requirements.pip 2013-10-04 13:08:59 +0000
+++ tests/requirements.pip 2013-10-08 07:46:30 +0000
@@ -37,10 +37,8 @@
37tornado==3.1.137tornado==3.1.1
3838
39# Charm hooks.39# Charm hooks.
40-e bzr+http://launchpad.net/charm-tools#egg=charm-tools
41launchpadlib==1.10.240launchpadlib==1.10.2
42python-apt==0.8.541python-apt==0.8.5
43-e bzr+http://launchpad.net/python-shelltoolbox#egg=python-shelltoolbox
44Tempita==0.5.142Tempita==0.5.1
4543
46# Charm tests + GUI server tests.44# Charm tests + GUI server tests.
4745
=== added symlink 'tests/shelltoolbox.py'
=== target is u'../hooks/shelltoolbox.py'
=== modified file 'tests/test_backends.py'
--- tests/test_backends.py 2013-09-03 08:24:30 +0000
+++ tests/test_backends.py 2013-10-08 07:46:30 +0000
@@ -17,258 +17,492 @@
17"""Backend tests."""17"""Backend tests."""
1818
1919
20from collections import defaultdict20from contextlib import (
21from contextlib import contextmanager21 contextmanager,
22 nested,
23)
22import os24import os
23import shutil25import shutil
24import tempfile26import tempfile
25import unittest27import unittest
2628
27import charmhelpers
28import mock29import mock
29import shelltoolbox
3030
31import backend31import backend
32import utils32import utils
3333
3434
35def get_mixin_names(test_backend):35EXPECTED_PYTHON_LEGACY_DEBS = ('apache2', 'curl', 'haproxy', 'openssl')
36 return tuple(b.__class__.__name__ for b in test_backend.mixins)36EXPECTED_GO_LEGACY_DEBS = (
3737 'apache2', 'curl', 'haproxy', 'openssl', 'python-yaml')
3838EXPECTED_PYTHON_BUILTIN_DEBS = (
39class GotEmAllDict(defaultdict):39 'curl', 'openssl', 'python-bzrlib', 'python-pip')
40 """A dictionary that returns the same default value for all given keys."""40EXPECTED_GO_BUILTIN_DEBS = (
4141 'curl', 'openssl', 'python-bzrlib', 'python-pip', 'python-yaml')
42 def get(self, key, default=None):42
43 return self.default_factory()43simulate_pyjuju = mock.patch('utils.legacy_juju', mock.Mock(return_value=True))
44simulate_juju_core = mock.patch(
45 'utils.legacy_juju', mock.Mock(return_value=False))
4446
4547
46class TestBackendProperties(unittest.TestCase):48class TestBackendProperties(unittest.TestCase):
47 """Ensure the correct mixins and property values are collected."""49 """Ensure the correct mixins and property values are collected."""
4850
49 simulate_pyjuju = mock.patch(51 def assert_mixins(self, expected, backend):
50 'utils.legacy_juju', mock.Mock(return_value=True))52 """Ensure the given backend includes the expected mixins."""
51 simulate_juju_core = mock.patch(53 obtained = tuple(mixin.__class__.__name__ for mixin in backend.mixins)
52 'utils.legacy_juju', mock.Mock(return_value=False))54 self.assertEqual(tuple(expected), obtained)
55
56 def assert_dependencies(self, expected_debs, expected_repository, backend):
57 """Ensure the given backend includes the expected dependencies."""
58 obtained_debs, obtained_repository = backend.get_dependencies()
59 self.assertEqual(set(expected_debs), obtained_debs)
60 self.assertEqual(expected_repository, obtained_repository)
5361
54 def check_sandbox_mode(self):62 def check_sandbox_mode(self):
55 """The backend includes the correct mixins when sandbox mode is active.63 """The backend includes the correct mixins when sandbox mode is active.
56 """64 """
57 test_backend = backend.Backend(config={65 expected_mixins = (
58 'sandbox': True, 'staging': False, 'builtin-server': False})66 'SetUpMixin', 'SandboxMixin', 'GuiMixin', 'HaproxyApacheMixin')
59 mixin_names = get_mixin_names(test_backend)67 config = {
60 self.assertEqual(68 'builtin-server': False,
61 ('SetUpMixin', 'SandboxMixin', 'GuiMixin', 'HaproxyApacheMixin'),69 'repository-location': 'ppa:my/location',
62 mixin_names)70 'sandbox': True,
63 self.assertEqual(71 'staging': False,
64 frozenset(('apache2', 'curl', 'haproxy', 'openssl')),72 }
65 test_backend.debs)73 test_backend = backend.Backend(config=config)
74 self.assert_mixins(expected_mixins, test_backend)
75 self.assert_dependencies(
76 EXPECTED_PYTHON_LEGACY_DEBS, 'ppa:my/location', test_backend)
6677
67 def test_python_staging_backend(self):78 def test_python_staging_backend(self):
68 expected_mixins = (79 expected_mixins = (
69 'SetUpMixin', 'ImprovMixin', 'GuiMixin', 'HaproxyApacheMixin')80 'SetUpMixin', 'ImprovMixin', 'GuiMixin', 'HaproxyApacheMixin')
70 with self.simulate_pyjuju:81 config = {
71 test_backend = backend.Backend(config={82 'builtin-server': False,
72 'sandbox': False, 'staging': True, 'builtin-server': False})83 'repository-location': 'ppa:my/location',
73 mixin_names = get_mixin_names(test_backend)84 'sandbox': False,
74 self.assertEqual(expected_mixins, mixin_names)85 'staging': True,
75 debs = ('apache2', 'curl', 'haproxy', 'openssl', 'zookeeper')86 }
76 self.assertEqual(frozenset(debs), test_backend.debs)87 with simulate_pyjuju:
88 test_backend = backend.Backend(config=config)
89 self.assert_mixins(expected_mixins, test_backend)
90 self.assert_dependencies(
91 EXPECTED_PYTHON_LEGACY_DEBS + ('zookeeper',),
92 'ppa:my/location', test_backend)
7793
78 def test_go_staging_backend(self):94 def test_go_staging_backend(self):
79 config = {'sandbox': False, 'staging': True, 'builtin-server': False}95 config = {'sandbox': False, 'staging': True, 'builtin-server': False}
80 with self.simulate_juju_core:96 with simulate_juju_core:
81 with self.assertRaises(ValueError) as context_manager:97 with self.assertRaises(ValueError) as context_manager:
82 backend.Backend(config=config)98 backend.Backend(config=config)
83 error = str(context_manager.exception)99 error = str(context_manager.exception)
84 self.assertEqual('Unable to use staging with go backend', error)100 self.assertEqual('Unable to use staging with go backend', error)
85101
86 def test_python_sandbox_backend(self):102 def test_python_sandbox_backend(self):
87 with self.simulate_pyjuju:103 with simulate_pyjuju:
88 self.check_sandbox_mode()104 self.check_sandbox_mode()
89105
90 def test_go_sandbox_backend(self):106 def test_go_sandbox_backend(self):
91 with self.simulate_juju_core:107 with simulate_juju_core:
92 self.check_sandbox_mode()108 self.check_sandbox_mode()
93109
94 def test_python_backend(self):110 def test_python_backend(self):
95 expected_mixins = (111 expected_mixins = (
96 'SetUpMixin', 'PythonMixin', 'GuiMixin', 'HaproxyApacheMixin')112 'SetUpMixin', 'PythonMixin', 'GuiMixin', 'HaproxyApacheMixin')
97 with self.simulate_pyjuju:113 config = {
98 test_backend = backend.Backend(config={114 'builtin-server': False,
99 'sandbox': False, 'staging': False, 'builtin-server': False})115 'repository-location': 'ppa:my/location',
100 mixin_names = get_mixin_names(test_backend)116 'sandbox': False,
101 self.assertEqual(expected_mixins, mixin_names)117 'staging': False,
102 self.assertEqual(118 }
103 frozenset(('apache2', 'curl', 'haproxy', 'openssl')),119 with simulate_pyjuju:
104 test_backend.debs)120 test_backend = backend.Backend(config=config)
121 self.assert_mixins(expected_mixins, test_backend)
122 self.assert_dependencies(
123 EXPECTED_PYTHON_LEGACY_DEBS, 'ppa:my/location', test_backend)
105124
106 def test_go_backend(self):125 def test_go_backend(self):
107 with self.simulate_juju_core:126 expected_mixins = (
108 test_backend = backend.Backend(config={127 'SetUpMixin', 'GoMixin', 'GuiMixin', 'HaproxyApacheMixin')
109 'sandbox': False, 'staging': False, 'builtin-server': False})128 config = {
110 mixin_names = get_mixin_names(test_backend)129 'builtin-server': False,
111 self.assertEqual(130 'repository-location': 'ppa:my/location',
112 ('SetUpMixin', 'GoMixin', 'GuiMixin', 'HaproxyApacheMixin'),131 'sandbox': False,
113 mixin_names)132 'staging': False,
114 self.assertEqual(133 }
115 frozenset(134 with simulate_juju_core:
116 ('apache2', 'curl', 'haproxy', 'openssl', 'python-yaml')),135 test_backend = backend.Backend(config=config)
117 test_backend.debs)136 self.assert_mixins(expected_mixins, test_backend)
137 self.assert_dependencies(
138 EXPECTED_GO_LEGACY_DEBS, 'ppa:my/location', test_backend)
118139
119 def test_builtin_server(self):140 def test_go_builtin_server(self):
141 config = {
142 'builtin-server': True,
143 'repository-location': 'ppa:my/location',
144 'sandbox': False,
145 'staging': False,
146 }
120 expected_mixins = (147 expected_mixins = (
121 'SetUpMixin', 'GoMixin', 'GuiMixin', 'BuiltinServerMixin')148 'SetUpMixin', 'GoMixin', 'GuiMixin', 'BuiltinServerMixin')
122 expected_debs = set([149 with simulate_juju_core:
123 'python-pip', 'python-yaml', 'curl', 'openssl', 'python-bzrlib'])150 test_backend = backend.Backend(config)
124 with self.simulate_juju_core:151 self.assert_mixins(expected_mixins, test_backend)
125 test_backend = backend.Backend(config={152 self.assert_dependencies(
126 'sandbox': False, 'staging': False, 'builtin-server': True})153 EXPECTED_GO_BUILTIN_DEBS, None, test_backend)
127 mixin_names = get_mixin_names(test_backend)154
128 self.assertEqual(expected_mixins, mixin_names)155 def test_python_builtin_server(self):
129 self.assertEqual(expected_debs, test_backend.debs)156 config = {
157 'builtin-server': True,
158 'repository-location': 'ppa:my/location',
159 'sandbox': False,
160 'staging': False,
161 }
162 expected_mixins = (
163 'SetUpMixin', 'PythonMixin', 'GuiMixin', 'BuiltinServerMixin')
164 with simulate_pyjuju:
165 test_backend = backend.Backend(config)
166 self.assert_mixins(expected_mixins, test_backend)
167 self.assert_dependencies(
168 EXPECTED_PYTHON_BUILTIN_DEBS, None, test_backend)
169
170 def test_sandbox_builtin_server(self):
171 config = {
172 'builtin-server': True,
173 'repository-location': 'ppa:my/location',
174 'sandbox': True,
175 'staging': False,
176 }
177 expected_mixins = (
178 'SetUpMixin', 'SandboxMixin', 'GuiMixin', 'BuiltinServerMixin')
179 with simulate_juju_core:
180 test_backend = backend.Backend(config)
181 self.assert_mixins(expected_mixins, test_backend)
182 self.assert_dependencies(
183 EXPECTED_PYTHON_BUILTIN_DEBS, None, test_backend)
130184
131185
132class TestBackendCommands(unittest.TestCase):186class TestBackendCommands(unittest.TestCase):
133187
134 def setUp(self):188 def setUp(self):
135 self.called = {}189 # Set up directories.
136 self.alwaysFalse = GotEmAllDict(lambda: False)
137 self.alwaysTrue = GotEmAllDict(lambda: True)
138
139 # Monkeypatch functions.
140 self.utils_mocks = {
141 'compute_build_dir': utils.compute_build_dir,
142 'fetch_api': utils.fetch_api,
143 'fetch_gui_from_branch': utils.fetch_gui_from_branch,
144 'fetch_gui_release': utils.fetch_gui_release,
145 'find_missing_packages': utils.find_missing_packages,
146 'get_api_address': utils.get_api_address,
147 'get_npm_cache_archive_url': utils.get_npm_cache_archive_url,
148 'install_builtin_server': utils.install_builtin_server,
149 'parse_source': utils.parse_source,
150 'prime_npm_cache': utils.prime_npm_cache,
151 'remove_apache_setup': utils.remove_apache_setup,
152 'remove_haproxy_setup': utils.remove_haproxy_setup,
153 'save_or_create_certificates': utils.save_or_create_certificates,
154 'setup_apache_config': utils.setup_apache_config,
155 'setup_gui': utils.setup_gui,
156 'setup_haproxy_config': utils.setup_haproxy_config,
157 'start_agent': utils.start_agent,
158 'start_improv': utils.start_improv,
159 'write_builtin_server_startup': utils.write_builtin_server_startup,
160 'write_gui_config': utils.write_gui_config,
161 }
162 self.charmhelpers_mocks = {
163 'log': charmhelpers.log,
164 'open_port': charmhelpers.open_port,
165 'service_control': charmhelpers.service_control,
166 }
167
168 def make_mock_function(name):
169 def mock_function(*args, **kwargs):
170 self.called[name] = True
171 return (None, None)
172 mock_function.__name__ = name
173 return mock_function
174
175 for name in self.utils_mocks.keys():
176 setattr(utils, name, make_mock_function(name))
177 for name in self.charmhelpers_mocks.keys():
178 setattr(charmhelpers, name, make_mock_function(name))
179
180 @contextmanager
181 def mock_su(user):
182 self.called['su'] = True
183 yield
184 self.orig_su = utils.su
185 utils.su = mock_su
186
187 def mock_apt_get_install(*debs):
188 self.called['apt_get_install'] = True
189 self.orig_apt_get_install = shelltoolbox.apt_get_install
190 shelltoolbox.apt_get_install = mock_apt_get_install
191
192 def mock_run(*debs):
193 self.called['run'] = True
194 self.orig_run = shelltoolbox.run
195 shelltoolbox.run = mock_run
196
197 # Monkeypatch directories.
198 self.playground = tempfile.mkdtemp()190 self.playground = tempfile.mkdtemp()
199 self.orig_juju_dir = utils.JUJU_AGENT_DIR191 self.addCleanup(shutil.rmtree, self.playground)
200 utils.JUJU_AGENT_DIR = tempfile.mkdtemp(dir=self.playground)192 self.base_dir = os.path.join(self.playground, 'juju-gui')
201 self.orig_base_dir = utils.BASE_DIR193 self.command_log_file = os.path.join(self.playground, 'logs')
202 utils.BASE_DIR = os.path.join(self.playground, 'juju-gui')194 self.juju_agent_dir = os.path.join(self.playground, 'juju-agent-dir')
203195 self.ssl_cert_path = os.path.join(self.playground, 'ssl-cert-path')
204 def tearDown(self):196 # Set up default values.
205 # Cleanup directories.197 self.juju_api_branch = 'lp:juju-api'
206 shutil.rmtree(self.playground)198 self.juju_gui_source = 'stable'
207 utils.JUJU_AGENT_DIR = self.orig_juju_dir199 self.repository_location = 'ppa:my/location'
208 utils.BASE_DIR = self.orig_base_dir200 self.parse_source_return_value = ('stable', None)
209 # Undo the monkeypatching.201
210 shelltoolbox.run = self.orig_run202 def make_config(self, options=None):
211 shelltoolbox.apt_get_install = self.orig_apt_get_install203 """Create and return a backend configuration dict."""
212 utils.su = self.orig_su204 config = {
213 for name, orig_fun in self.charmhelpers_mocks.items():205 'builtin-server': True,
214 setattr(charmhelpers, name, orig_fun)206 'builtin-server-logging': 'info',
215 for name, orig_fun in self.utils_mocks.items():207 'charmworld-url': 'http://charmworld.example.com',
216 setattr(utils, name, orig_fun)208 'command-log-file': self.command_log_file,
209 'default-viewmode': 'sidebar',
210 'ga-key': 'my-key',
211 'juju-api-branch': self.juju_api_branch,
212 'juju-gui-debug': False,
213 'juju-gui-console-enabled': False,
214 'juju-gui-source': self.juju_gui_source,
215 'login-help': 'login-help',
216 'read-only': False,
217 'repository-location': self.repository_location,
218 'sandbox': False,
219 'secure': True,
220 'serve-tests': False,
221 'show-get-juju-button': False,
222 'ssl-cert-path': self.ssl_cert_path,
223 'staging': False,
224 }
225 if options is not None:
226 config.update(options)
227 return config
228
229 @contextmanager
230 def mock_all(self):
231 """Mock all the extrenal functions used by the backend framework."""
232 mock_parse_source = mock.Mock(
233 return_value=self.parse_source_return_value)
234 mocks = {
235 'base_dir': mock.patch('backend.utils.BASE_DIR', self.base_dir),
236 'compute_build_dir': mock.patch('backend.utils.compute_build_dir'),
237 'fetch_api': mock.patch('backend.utils.fetch_api'),
238 'fetch_gui_from_branch': mock.patch(
239 'backend.utils.fetch_gui_from_branch'),
240 'fetch_gui_release': mock.patch('backend.utils.fetch_gui_release'),
241 'install_builtin_server': mock.patch(
242 'backend.utils.install_builtin_server'),
243 'install_missing_packages': mock.patch(
244 'backend.utils.install_missing_packages'),
245 'juju_agent_dir': mock.patch(
246 'backend.utils.JUJU_AGENT_DIR', self.juju_agent_dir),
247 'log': mock.patch('backend.log'),
248 'open_port': mock.patch('backend.open_port'),
249 'parse_source': mock.patch(
250 'backend.utils.parse_source', mock_parse_source),
251 'save_or_create_certificates': mock.patch(
252 'backend.utils.save_or_create_certificates'),
253 'setup_gui': mock.patch('backend.utils.setup_gui'),
254 'start_agent': mock.patch('backend.utils.start_agent'),
255 'start_builtin_server': mock.patch(
256 'backend.utils.start_builtin_server'),
257 'start_haproxy_apache': mock.patch(
258 'backend.utils.start_haproxy_apache'),
259 'stop_agent': mock.patch('backend.utils.stop_agent'),
260 'stop_builtin_server': mock.patch(
261 'backend.utils.stop_builtin_server'),
262 'stop_haproxy_apache': mock.patch(
263 'backend.utils.stop_haproxy_apache'),
264 'write_gui_config': mock.patch('backend.utils.write_gui_config'),
265 }
266 # Note: nested is deprecated for good reasons which do not apply here.
267 # Used here to easily nest a dynamically generated list of context
268 # managers.
269 with nested(*mocks.values()) as context_managers:
270 object_dict = dict(zip(mocks.keys(), context_managers))
271 yield type('Mocks', (object,), object_dict)
272
273 def assert_write_gui_config_called(self, mocks, config):
274 """Ensure the mocked write_gui_config has been properly called."""
275 mocks.write_gui_config.assert_called_once_with(
276 config['juju-gui-console-enabled'], config['login-help'],
277 config['read-only'], config['staging'], config['charmworld-url'],
278 mocks.compute_build_dir(), secure=config['secure'],
279 sandbox=config['sandbox'], ga_key=config['ga-key'],
280 default_viewmode=config['default-viewmode'],
281 show_get_juju_button=config['show-get-juju-button'], password=None)
217282
218 def test_base_dir_created(self):283 def test_base_dir_created(self):
219 test_backend = backend.Backend(config=self.alwaysFalse)284 # The base Juju GUI directory is correctly created.
220 test_backend.install()285 config = self.make_config()
221 self.assertTrue(os.path.isdir(utils.BASE_DIR))286 test_backend = backend.Backend(config=config)
287 with self.mock_all():
288 test_backend.install()
289 self.assertTrue(os.path.isdir(self.base_dir))
222290
223 def test_base_dir_removed(self):291 def test_base_dir_removed(self):
224 test_backend = backend.Backend(config=self.alwaysFalse)292 # The base Juju GUI directory is correctly removed.
225 test_backend.install()293 config = self.make_config()
226 test_backend.destroy()294 test_backend = backend.Backend(config=config)
295 with self.mock_all():
296 test_backend.install()
297 test_backend.destroy()
227 self.assertFalse(os.path.exists(utils.BASE_DIR), utils.BASE_DIR)298 self.assertFalse(os.path.exists(utils.BASE_DIR), utils.BASE_DIR)
228299
229 def test_install_python(self):300 def test_install_python_legacy_stable(self):
230 test_backend = backend.Backend(config=self.alwaysFalse)301 # Install a pyJuju backend with legacy server and stable release.
231 test_backend.install()302 config = self.make_config({'builtin-server': False})
232 for mocked in (303 with simulate_pyjuju:
233 'apt_get_install', 'fetch_api', 'find_missing_packages',304 test_backend = backend.Backend(config=config)
234 ):305 with self.mock_all() as mocks:
235 self.assertTrue(306 test_backend.install()
236 self.called.get(mocked), '{} was not called'.format(mocked))307 mocks.install_missing_packages.assert_called_once_with(
237308 set(EXPECTED_PYTHON_LEGACY_DEBS),
238 def test_install_improv_builtin(self):309 repository=self.repository_location)
239 test_backend = backend.Backend(config=self.alwaysTrue)310 mocks.fetch_api.assert_called_once_with(self.juju_api_branch)
240 test_backend.install()311 mocks.parse_source.assert_called_once_with(self.juju_gui_source)
241 for mocked in (312 mocks.fetch_gui_release.assert_called_once_with(
242 'apt_get_install', 'fetch_api', 'find_missing_packages',313 *self.parse_source_return_value)
243 'install_builtin_server',314 self.assertFalse(mocks.fetch_gui_from_branch.called)
244 ):315 mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
245 self.assertTrue(316 self.assertFalse(mocks.install_builtin_server.called)
246 self.called.get(mocked), '{} was not called'.format(mocked))317
247318 def test_install_go_legacy_stable(self):
248 def test_start_agent(self):319 # Install a juju-core backend with legacy server and stable release.
249 test_backend = backend.Backend(config=self.alwaysFalse)320 config = self.make_config({'builtin-server': False})
250 test_backend.start()321 with simulate_juju_core:
251 for mocked in (322 test_backend = backend.Backend(config=config)
252 'compute_build_dir', 'open_port', 'setup_apache_config',323 with self.mock_all() as mocks:
253 'setup_haproxy_config', 'start_agent', 'su', 'write_gui_config',324 test_backend.install()
254 ):325 mocks.install_missing_packages.assert_called_once_with(
255 self.assertTrue(326 set(EXPECTED_GO_LEGACY_DEBS), repository=self.repository_location)
256 self.called.get(mocked), '{} was not called'.format(mocked))327 self.assertFalse(mocks.fetch_api.called)
257328 mocks.parse_source.assert_called_once_with(self.juju_gui_source)
258 def test_start_improv_builtin(self):329 mocks.fetch_gui_release.assert_called_once_with(
259 test_backend = backend.Backend(config=self.alwaysTrue)330 *self.parse_source_return_value)
260 test_backend.start()331 self.assertFalse(mocks.fetch_gui_from_branch.called)
261 for mocked in (332 mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
262 'compute_build_dir', 'open_port', 'start_improv', 'su',333 self.assertFalse(mocks.install_builtin_server.called)
263 'write_builtin_server_startup', 'write_gui_config',334
264 ):335 def test_install_python_builtin_stable(self):
265 self.assertTrue(336 # Install a pyJuju backend with builtin server and stable release.
266 self.called.get(mocked), '{} was not called'.format(mocked))337 config = self.make_config({'builtin-server': True})
267338 with simulate_pyjuju:
268 def test_stop(self):339 test_backend = backend.Backend(config=config)
269 test_backend = backend.Backend(config=self.alwaysFalse)340 with self.mock_all() as mocks:
270 test_backend.stop()341 test_backend.install()
271 self.assertTrue(self.called.get('su'), 'su was not called')342 mocks.install_missing_packages.assert_called_once_with(
343 set(EXPECTED_PYTHON_BUILTIN_DEBS), repository=None)
344 mocks.fetch_api.assert_called_once_with(self.juju_api_branch)
345 mocks.parse_source.assert_called_once_with(self.juju_gui_source)
346 mocks.fetch_gui_release.assert_called_once_with(
347 *self.parse_source_return_value)
348 self.assertFalse(mocks.fetch_gui_from_branch.called)
349 mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
350 mocks.install_builtin_server.assert_called_once_with()
351
352 def test_install_go_builtin_stable(self):
353 # Install a juju-core backend with builtin server and stable release.
354 config = self.make_config({'builtin-server': True})
355 with simulate_juju_core:
356 test_backend = backend.Backend(config=config)
357 with self.mock_all() as mocks:
358 test_backend.install()
359 mocks.install_missing_packages.assert_called_once_with(
360 set(EXPECTED_GO_BUILTIN_DEBS), repository=None)
361 self.assertFalse(mocks.fetch_api.called)
362 mocks.parse_source.assert_called_once_with(self.juju_gui_source)
363 mocks.fetch_gui_release.assert_called_once_with(
364 *self.parse_source_return_value)
365 self.assertFalse(mocks.fetch_gui_from_branch.called)
366 mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_release())
367 mocks.install_builtin_server.assert_called_once_with()
368
369 def test_install_go_builtin_branch(self):
370 # Install a juju-core backend with builtin server and branch release.
371 self.parse_source_return_value = ('branch', ('lp:juju-gui', 42))
372 expected_calls = [
373 mock.call(set(EXPECTED_GO_BUILTIN_DEBS), repository=None),
374 mock.call(
375 utils.DEB_BUILD_DEPENDENCIES,
376 repository=self.repository_location,
377 ),
378 ]
379 config = self.make_config({'builtin-server': True})
380 with simulate_juju_core:
381 test_backend = backend.Backend(config=config)
382 with self.mock_all() as mocks:
383 test_backend.install()
384 mocks.install_missing_packages.assert_has_calls(expected_calls)
385 self.assertFalse(mocks.fetch_api.called)
386 mocks.parse_source.assert_called_once_with(self.juju_gui_source)
387 mocks.fetch_gui_from_branch.assert_called_once_with(
388 'lp:juju-gui', 42, self.command_log_file)
389 self.assertFalse(mocks.fetch_gui_release.called)
390 mocks.setup_gui.assert_called_once_with(mocks.fetch_gui_from_branch())
391 mocks.install_builtin_server.assert_called_once_with()
392
393 def test_start_python_legacy(self):
394 # Start a pyJuju backend with legacy server.
395 config = self.make_config({'builtin-server': False})
396 with simulate_pyjuju:
397 test_backend = backend.Backend(config=config)
398 with self.mock_all() as mocks:
399 test_backend.start()
400 mocks.start_agent.assert_called_once_with(self.ssl_cert_path)
401 mocks.compute_build_dir.assert_called_with(
402 config['juju-gui-debug'], config['serve-tests'])
403 self.assert_write_gui_config_called(mocks, config)
404 mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
405 mocks.start_haproxy_apache.assert_called_once_with(
406 mocks.compute_build_dir(), config['serve-tests'],
407 self.ssl_cert_path, config['secure'])
408 self.assertFalse(mocks.start_builtin_server.called)
409
410 def test_start_go_legacy(self):
411 # Start a juju-core backend with legacy server.
412 config = self.make_config({'builtin-server': False})
413 with simulate_juju_core:
414 test_backend = backend.Backend(config=config)
415 with self.mock_all() as mocks:
416 test_backend.start()
417 self.assertFalse(mocks.start_agent.called)
418 mocks.compute_build_dir.assert_called_with(
419 config['juju-gui-debug'], config['serve-tests'])
420 self.assert_write_gui_config_called(mocks, config)
421 mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
422 mocks.start_haproxy_apache.assert_called_once_with(
423 mocks.compute_build_dir(), config['serve-tests'],
424 self.ssl_cert_path, config['secure'])
425 self.assertFalse(mocks.start_builtin_server.called)
426
427 def test_start_python_builtin(self):
428 # Start a pyJuju backend with builtin server.
429 config = self.make_config({'builtin-server': True})
430 with simulate_pyjuju:
431 test_backend = backend.Backend(config=config)
432 with self.mock_all() as mocks:
433 test_backend.start()
434 mocks.start_agent.assert_called_once_with(self.ssl_cert_path)
435 mocks.compute_build_dir.assert_called_with(
436 config['juju-gui-debug'], config['serve-tests'])
437 self.assert_write_gui_config_called(mocks, config)
438 mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
439 mocks.start_builtin_server.assert_called_once_with(
440 mocks.compute_build_dir(), self.ssl_cert_path,
441 config['serve-tests'], config['sandbox'],
442 config['builtin-server-logging'], not config['secure'])
443 self.assertFalse(mocks.start_haproxy_apache.called)
444
445 def test_start_go_builtin(self):
446 # Start a juju-core backend with builtin server.
447 config = self.make_config({'builtin-server': True})
448 with simulate_juju_core:
449 test_backend = backend.Backend(config=config)
450 with self.mock_all() as mocks:
451 test_backend.start()
452 self.assertFalse(mocks.start_agent.called)
453 mocks.compute_build_dir.assert_called_with(
454 config['juju-gui-debug'], config['serve-tests'])
455 self.assert_write_gui_config_called(mocks, config)
456 mocks.open_port.assert_has_calls([mock.call(80), mock.call(443)])
457 mocks.start_builtin_server.assert_called_once_with(
458 mocks.compute_build_dir(), self.ssl_cert_path,
459 config['serve-tests'], config['sandbox'],
460 config['builtin-server-logging'], not config['secure'])
461 self.assertFalse(mocks.start_haproxy_apache.called)
462
463 def test_stop_python_legacy(self):
464 # Stop a pyJuju backend with legacy server.
465 config = self.make_config({'builtin-server': False})
466 with simulate_pyjuju:
467 test_backend = backend.Backend(config=config)
468 with self.mock_all() as mocks:
469 test_backend.stop()
470 mocks.stop_agent.assert_called_once_with()
471 mocks.stop_haproxy_apache.assert_called_once_with()
472 self.assertFalse(mocks.stop_builtin_server.called)
473
474 def test_stop_go_legacy(self):
475 # Stop a juju-core backend with legacy server.
476 config = self.make_config({'builtin-server': False})
477 with simulate_juju_core:
478 test_backend = backend.Backend(config=config)
479 with self.mock_all() as mocks:
480 test_backend.stop()
481 self.assertFalse(mocks.stop_agent.called)
482 mocks.stop_haproxy_apache.assert_called_once_with()
483 self.assertFalse(mocks.stop_builtin_server.called)
484
485 def test_stop_python_builtin(self):
486 # Stop a pyJuju backend with builtin server.
487 config = self.make_config({'builtin-server': True})
488 with simulate_pyjuju:
489 test_backend = backend.Backend(config=config)
490 with self.mock_all() as mocks:
491 test_backend.stop()
492 mocks.stop_agent.assert_called_once_with()
493 mocks.stop_builtin_server.assert_called_once_with()
494 self.assertFalse(mocks.stop_haproxy_apache.called)
495
496 def test_stop_go_builtin(self):
497 # Stop a juju-core backend with builtin server.
498 config = self.make_config({'builtin-server': True})
499 with simulate_juju_core:
500 test_backend = backend.Backend(config=config)
501 with self.mock_all() as mocks:
502 test_backend.stop()
503 self.assertFalse(mocks.stop_agent.called)
504 mocks.stop_builtin_server.assert_called_once_with()
505 self.assertFalse(mocks.stop_haproxy_apache.called)
272506
273507
274class TestBackendUtils(unittest.TestCase):508class TestBackendUtils(unittest.TestCase):
@@ -294,23 +528,28 @@
294 self.assertFalse(test_backend.different('staging'))528 self.assertFalse(test_backend.different('staging'))
295529
296530
297class TestChainMethods(unittest.TestCase):531class TestCallMethods(unittest.TestCase):
298532
299 def setUp(self):533 def setUp(self):
300 self.called = []534 self.called = []
301535 self.objects = [self.make_object('Obj1'), self.make_object('Obj2')]
302 def method(mixin, backend):536
303 self.called.append(mixin.__class__.__name__)537 def make_object(self, name, has_method=True):
304 mixin1 = type('Mixin1', (object,), {'method': method})()538 """Create and return an test object with the given name."""
305 mixin2 = type('Mixin2', (object,), {'method': method})()539 def method(obj, *args):
306 self.backend = type('Backend', (), {'mixins': (mixin1, mixin2)})()540 self.called.append([obj.__class__.__name__, args])
307541 object_dict = {'method': method} if has_method else {}
308 def test_chain(self):542 return type(name, (object,), object_dict)()
309 method = backend.chain_methods('method')543
310 method(self.backend)544 def test_call(self):
311 self.assertEqual(['Mixin1', 'Mixin2'], self.called)545 # The methods are correctly called.
312546 backend.call_methods(self.objects, 'method', 'arg1', 'arg2')
313 def test_reversed(self):547 expected = [['Obj1', ('arg1', 'arg2')], ['Obj2', ('arg1', 'arg2')]]
314 method = backend.chain_methods('method', reverse=True)548 self.assertEqual(expected, self.called)
315 method(self.backend)549
316 self.assertEqual(['Mixin2', 'Mixin1'], self.called)550 def test_no_method(self):
551 # An object without the method is ignored.
552 self.objects.append(self.make_object('Obj3', has_method=False))
553 backend.call_methods(self.objects, 'method')
554 expected = [['Obj1', ()], ['Obj2', ()]]
555 self.assertEqual(expected, self.called)
317556
=== modified file 'tests/test_utils.py'
--- tests/test_utils.py 2013-09-30 20:16:09 +0000
+++ tests/test_utils.py 2013-10-08 07:46:30 +0000
@@ -48,6 +48,7 @@
48 parse_source,48 parse_source,
49 get_npm_cache_archive_url,49 get_npm_cache_archive_url,
50 install_builtin_server,50 install_builtin_server,
51 install_missing_packages,
51 remove_apache_setup,52 remove_apache_setup,
52 remove_haproxy_setup,53 remove_haproxy_setup,
53 render_to_file,54 render_to_file,
@@ -868,6 +869,55 @@
868 self.assertEqual(0, mock_run.call_count)869 self.assertEqual(0, mock_run.call_count)
869870
870871
872@mock.patch('utils.find_missing_packages')
873@mock.patch('utils.install_extra_repositories')
874@mock.patch('utils.apt_get_install')
875@mock.patch('utils.log')
876@mock.patch('utils.cmd_log', mock.Mock())
877class TestInstallMissingPackages(unittest.TestCase):
878
879 packages = ('pkg1', 'pkg2', 'pkg3')
880 repository = 'ppa:my/repository'
881
882 def test_missing(
883 self, mock_log, mock_apt_get_install,
884 mock_install_extra_repositories, mock_find_missing_packages):
885 # The extra repository and packages are correctly installed.
886 repository = self.repository
887 mock_find_missing_packages.return_value = ['pkg1', 'pkg2']
888 install_missing_packages(self.packages, repository=repository)
889 mock_find_missing_packages.assert_called_once_with(*self.packages)
890 mock_install_extra_repositories.assert_called_once_with(repository)
891 mock_apt_get_install.assert_called_once_with('pkg1', 'pkg2')
892 mock_log.assert_has_calls([
893 mock.call('Adding the apt repository ppa:my/repository.'),
894 mock.call('Installing deb packages: pkg1, pkg2.')
895 ])
896
897 def test_missing_no_repository(
898 self, mock_log, mock_apt_get_install,
899 mock_install_extra_repositories, mock_find_missing_packages):
900 # No repositories are installed if not passed.
901 mock_find_missing_packages.return_value = ['pkg1', 'pkg2']
902 install_missing_packages(self.packages)
903 mock_find_missing_packages.assert_called_once_with(*self.packages)
904 self.assertFalse(mock_install_extra_repositories.called)
905 mock_apt_get_install.assert_called_once_with('pkg1', 'pkg2')
906 mock_log.assert_called_once_with(
907 'Installing deb packages: pkg1, pkg2.')
908
909 def test_no_missing(
910 self, mock_log, mock_apt_get_install,
911 mock_install_extra_repositories, mock_find_missing_packages):
912 # Nothing is installed if no missing packages are found.
913 mock_find_missing_packages.return_value = []
914 install_missing_packages(self.packages, repository=self.repository)
915 mock_find_missing_packages.assert_called_once_with(*self.packages)
916 self.assertFalse(mock_install_extra_repositories.called)
917 self.assertFalse(mock_apt_get_install.called)
918 mock_log.assert_called_once_with('No missing deb packages.')
919
920
871class TestNpmCache(unittest.TestCase):921class TestNpmCache(unittest.TestCase):
872 """To speed building from a branch we prepopulate the NPM cache."""922 """To speed building from a branch we prepopulate the NPM cache."""
873923

Subscribers

People subscribed via source and target branches