Merge lp:~frankban/charms/precise/juju-gui/support-firewall into lp:~juju-gui/charms/precise/juju-gui/trunk
- Precise Pangolin (12.04)
- support-firewall
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
charmers | Pending | ||
Review via email: mp+189645@code.launchpad.net |
Commit message
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-
- 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-
- 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!
Francesco Banconi (frankban) wrote : | # |
- 120. By Francesco Banconi
-
Fix functional test.
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:/
File hooks/backend.py (right):
https:/
hooks/backend.
Does this belong somewhere else now? Or can it be deleted?
https:/
hooks/backend.
reversed list of objects.
Delete this line? (functionality is gone)
https:/
hooks/backend.
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:/
hooks/backend.
Perhaps the following?
----
Execute the charm's "stop" steps.
Iterates through the mixins in reverse order.
https:/
hooks/backend.
WDYT?
---
Execute the charm removal steps.
Iterates through the mixins in reverse order.
https:/
File hooks/shelltool
https:/
hooks/shelltool
I included comments about the source in hooks/charmhelp
what I did there. If you like it, include it here; otherwise, feel free
to ignore it.
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.
- 121. By Francesco Banconi
-
Changes as per review.
Francesco Banconi (frankban) wrote : | # |
*** 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-
- 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-
- 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:/
https:/
File hooks/backend.py (right):
https:/
hooks/backend.
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:/
hooks/backend.
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:/
hooks/backend.
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:/
Francesco Banconi (frankban) wrote : | # |
Hi Gary and Rick, thanks for your reviews!
Preview Diff
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 |
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: shelltoolbox package;
- 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-
- a lot of code is tests, the rest of the code
should be quite easy to follow.
QA: server= false and watch the logs:
`make deploy` and watch the logs:
- no PPA should be installed by default;
- the deployment succeeds and the GUI works well;
switch to builtin-
- 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): _utils. py box.py nts.pip backends. py
A [revision details]
M config.yaml
M hooks/backend.py
D hooks/bootstrap
M hooks/install
A hooks/shelltool
M hooks/utils.py
M revision
M tests/requireme
M tests/test_
M tests/test_utils.py