Merge lp:~benji/charms/precise/juju-gui/use-npm-cache into lp:~juju-gui/charms/precise/juju-gui/trunk

Proposed by Benji York
Status: Merged
Merged at revision: 51
Proposed branch: lp:~benji/charms/precise/juju-gui/use-npm-cache
Merge into: lp:~juju-gui/charms/precise/juju-gui/trunk
Diff against target: 1233 lines (+1076/-22) (has conflicts)
6 files modified
hooks/backend.py (+38/-17)
hooks/utils.py (+27/-1)
revision (+4/-0)
tests/backend.py (+291/-0)
tests/test_utils.py (+87/-4)
tests/utils.py (+629/-0)
Text conflict in revision
To merge this branch: bzr merge lp:~benji/charms/precise/juju-gui/use-npm-cache
Reviewer Review Type Date Requested Status
Gary Poster (community) Approve
charmers Pending
Review via email: mp+161477@code.launchpad.net

Description of the change

Use the npm cache to speed installs from a branch

Creating the cache is handled by the GUI Makefile

To post a comment you must log in.
Revision history for this message
Benji York (benji) wrote :

Please take a look.

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

Thanks for the docs and comments! They are great to have.

29 + # If the given installable thing ("backend") requires one or more debs
30 + # that are not yet installed, install them.
31 missing = backend.check_packages(*backend.debs)

Yeah, check_packages might be better named install_missing_packages or something like that.

39 + # Inject NPM packages into the cache for faster building.
40 + #prime_npm_cache(get_npm_cache_archive_url())

Uh? I hope this wasn't commented out for your tests. :-)

44 + # XXX Why do we only set up the GUI if the "juju-gui-source"
45 + # configuration is non-default?

It's not non-default: it is not-what-it-was-before--or at least that's the intent. I think that's the behavior too, or we'd be in trouble. :-) At the start, what-it-was-before is some empty value, I believe.

124 + if e.errno != 17: # File exists.

Maybe use errno.EEXIST instead?

126 + uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
127 + cmd_log(uncompress(npm_cache_archive))

That reads very nicely. Good idea.

164 + get_npm_cache_archive_url,

You look like you were on your way to a test? That would have been nice. :-) If not, remove the import. But adding an import of prime_npm_cache and testing them both would be nicer. :-)

Thanks!

Gary

review: Approve
Revision history for this message
Benji York (benji) wrote :

On Mon, Apr 29, 2013 at 3:31 PM, Gary Poster <email address hidden> wrote:
> Review: Approve
>
> Thanks for the docs and comments! They are great to have.
>
> 29 + # If the given installable thing ("backend") requires one or more debs
> 30 + # that are not yet installed, install them.
> 31 missing = backend.check_packages(*backend.debs)
>
> Yeah, check_packages might be better named install_missing_packages or
> something like that.

Yep (I was only commenting things at the time, but this looks like a
local-enough change that I'll go ahead and do it).

> 39 + # Inject NPM packages into the cache for faster building.
> 40 + #prime_npm_cache(get_npm_cache_archive_url())
>
> Uh? I hope this wasn't commented out for your tests. :-)

Heh. Yeah, I was doing some non-cache timings and forgot to put it
back. Fixed.

> 44 + # XXX Why do we only set up the GUI if the "juju-gui-source"
> 45 + # configuration is non-default?
>
> It's not non-default: it is not-what-it-was-before--or at least that's
> the intent. I think that's the behavior too, or we'd be in trouble.
> :-) At the start, what-it-was-before is some empty value, I believe.

Ah! I think you're right. Maybe I can come up with variable/method
names that communicate better; or a comment at least.

> 124 + if e.errno != 17: # File exists.
>
> Maybe use errno.EEXIST instead?

Good call. Will do.

> 126 + uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
> 127 + cmd_log(uncompress(npm_cache_archive))
>
> That reads very nicely. Good idea.

I thought so too. That's why I copied it from another part of the code. ;)

> 164 + get_npm_cache_archive_url,
>
> You look like you were on your way to a test? That would have been
> nice. :-)

Indeed. I thought I had added it. I'll do so.
--
Benji York

49. By Benji York

review fixes

Revision history for this message
Benji York (benji) wrote :

Please take a look.

Revision history for this message
Benji York (benji) wrote :

*** Submitted:

Use the npm cache to speed installs from a branch

Creating the cache is handled by the GUI Makefile

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'hooks/backend.py'
--- hooks/backend.py 2013-04-30 13:53:20 +0000
+++ hooks/backend.py 2013-04-30 14:57:26 +0000
@@ -29,17 +29,19 @@
29 IMPROV,29 IMPROV,
30 JUJU_DIR,30 JUJU_DIR,
31 chain,31 chain,
32 check_packages,32 find_missing_packages,
33 cmd_log,33 cmd_log,
34 fetch_api,34 fetch_api,
35 fetch_gui,35 fetch_gui,
36 get_config,36 get_config,
37 get_npm_cache_archive_url,
37 legacy_juju,38 legacy_juju,
38 merge,39 merge,
39 overrideable,40 overrideable,
41 prime_npm_cache,
40 save_or_create_certificates,42 save_or_create_certificates,
43 setup_apache,
41 setup_gui,44 setup_gui,
42 setup_apache,
43 start_agent,45 start_agent,
44 start_gui,46 start_gui,
45 start_improv,47 start_improv,
@@ -53,20 +55,40 @@
5355
5456
55class InstallMixin(object):57class InstallMixin(object):
58 """Provide for the GUI and its dependencies to be installed."""
59
56 def install(self, backend):60 def install(self, backend):
61 """Install the GUI and dependencies."""
57 config = backend.config62 config = backend.config
58 missing = backend.check_packages(*backend.debs)63 # If the given installable thing ("backend") requires one or more debs
64 # that are not yet installed, install them.
65 missing = backend.find_missing_packages(*backend.debs)
59 if missing:66 if missing:
60 cmd_log(backend.install_extra_repositories(*backend.repositories))67 cmd_log(backend.install_extra_repositories(*backend.repositories))
61 cmd_log(apt_get_install(*backend.debs))68 cmd_log(apt_get_install(*backend.debs))
6269
70 # If we are not using a pre-built release of the GUI (i.e., we are
71 # using a branch) then we need to build a release archive to use.
63 if backend.different('juju-gui-source'):72 if backend.different('juju-gui-source'):
64 release_tarball = fetch_gui(73 # Inject NPM packages into the cache for faster building.
65 config['juju-gui-source'], config['command-log-file'])74 self._prime_npm_cache()
66 setup_gui(release_tarball)75 # Build a release from the branch.
76 self._build_and_install_from_branch()
77
78 def _prime_npm_cache(self):
79 # This is a separate method so it can be easily overridden for testing.
80 prime_npm_cache(get_npm_cache_archive_url())
81
82 def _build_and_install_from_branch(self):
83 # This is a separate method so it can be easily overridden for testing.
84 release_tarball = fetch_gui(
85 config['juju-gui-source'], config['command-log-file'])
86 setup_gui(release_tarball)
6787
6888
69class UpstartMixin(object):89class UpstartMixin(object):
90 """Manage (install, start, stop, etc.) some service via Upstart."""
91
70 upstart_scripts = ('haproxy.conf', )92 upstart_scripts = ('haproxy.conf', )
71 debs = ('curl', 'openssl', 'haproxy', 'apache2')93 debs = ('curl', 'openssl', 'haproxy', 'apache2')
7294
@@ -152,16 +174,14 @@
152174
153175
154class Backend(object):176class Backend(object):
155 """Compose methods and policy needed to interact177 """Compose methods and policy needed to interact with a Juju backend.
156 with a Juju backend. Given a config dict (which typically178
157 comes from the JSON de-serialization of config.json in JujuGUI).179 "config" is a config dict (which typically comes from the JSON
180 de-serialization of config.json in JujuGUI).
158 """181 """
159182
160 def __init__(self, config=None, prev_config=None, **overrides):183 def __init__(self, config=None, prev_config=None, **overrides):
161 """184 """Generate a selection of strategy classes that implement the backend.
162 Backends function through composition. __init__ becomes the
163 factory method to generate a selection of strategy classes
164 to use together to implement the backend proper.
165 """185 """
166 # Ingest the config and build out the ordered list of186 # Ingest the config and build out the ordered list of
167 # backend elements to include187 # backend elements to include
@@ -171,7 +191,7 @@
171 self.prev_config = prev_config191 self.prev_config = prev_config
172 self.overrides = overrides192 self.overrides = overrides
173193
174 # We always use upstart.194 # We always install the GUI.
175 backends = [InstallMixin, ]195 backends = [InstallMixin, ]
176196
177 api = "python" if legacy_juju() else "go"197 api = "python" if legacy_juju() else "go"
@@ -194,7 +214,8 @@
194 "Unable to use sandbox with {} backend".format(api))214 "Unable to use sandbox with {} backend".format(api))
195 backends.append(GoBackend)215 backends.append(GoBackend)
196216
197 # All backends can manage the gui.217 # All backends need to install, start, and stop the services that
218 # provide the GUI.
198 backends.append(GuiMixin)219 backends.append(GuiMixin)
199 backends.append(UpstartMixin)220 backends.append(UpstartMixin)
200221
@@ -213,8 +234,8 @@
213 raise234 raise
214235
215 @overrideable236 @overrideable
216 def check_packages(self, *packages):237 def find_missing_packages(self, *packages):
217 return check_packages(*packages)238 return find_missing_packages(*packages)
218239
219 @overrideable240 @overrideable
220 def service_control(self, service, action):241 def service_control(self, service, action):
221242
=== modified file 'hooks/utils.py'
--- hooks/utils.py 2013-04-30 13:53:20 +0000
+++ hooks/utils.py 2013-04-30 14:57:26 +0000
@@ -404,6 +404,32 @@
404 render_to_file('apache-site.template', context, JUJU_GUI_SITE)404 render_to_file('apache-site.template', context, JUJU_GUI_SITE)
405405
406406
407def get_npm_cache_archive_url(Launchpad=Launchpad):
408 """Figure out the URL of the most recent NPM cache archive on Launchpad."""
409 launchpad = Launchpad.login_anonymously('Juju GUI charm', 'production')
410 project = launchpad.projects['juju-gui']
411 # Find the URL of the most recently created NPM cache archive.
412 npm_cache_url = get_release_file_url(project, 'npm-cache', None)
413 return npm_cache_url
414
415
416def prime_npm_cache(npm_cache_url):
417 """Download NPM cache archive and prime the NPM cache with it."""
418 # Download the cache archive and then uncompress it into the NPM cache.
419 npm_cache_archive = os.path.join(CURRENT_DIR, 'npm-cache.tgz')
420 cmd_log(run('curl', '-L', '-o', npm_cache_archive, npm_cache_url))
421 npm_cache_dir = os.path.expanduser('~/.npm')
422 # The NPM cache directory probably does not exist, so make it if not.
423 try:
424 os.mkdir(npm_cache_dir)
425 except OSError, e:
426 # If the directory already exists then ignore the error.
427 if e.errno != errno.EEXIST: # File exists.
428 raise
429 uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
430 cmd_log(uncompress(npm_cache_archive))
431
432
407def fetch_gui(juju_gui_source, logpath):433def fetch_gui(juju_gui_source, logpath):
408 """Retrieve the Juju GUI release/branch."""434 """Retrieve the Juju GUI release/branch."""
409 # Retrieve a Juju GUI release.435 # Retrieve a Juju GUI release.
@@ -520,7 +546,7 @@
520 shutil.copyfileobj(open(crt_path), pem_file)546 shutil.copyfileobj(open(crt_path), pem_file)
521547
522548
523def check_packages(*packages):549def find_missing_packages(*packages):
524 """Given a list of packages, return the packages which are not installed.550 """Given a list of packages, return the packages which are not installed.
525 """551 """
526 cache = apt.Cache()552 cache = apt.Cache()
527553
=== modified file 'revision'
--- revision 2013-04-30 13:53:20 +0000
+++ revision 2013-04-30 14:57:26 +0000
@@ -1,1 +1,5 @@
1<<<<<<< TREE
141241
3=======
444
5>>>>>>> MERGE-SOURCE
26
=== modified symlink 'tests/backend.py'
=== target was u'../hooks/backend.py'
--- tests/backend.py 1970-01-01 00:00:00 +0000
+++ tests/backend.py 2013-04-30 14:57:26 +0000
@@ -0,0 +1,291 @@
1"""
2A composition system for creating backend object.
3
4Backends implement start(), stop() and install() methods. A backend is composed
5of many mixins and each mixin will implement any/all of those methods and all
6will be called. Backends additionally provide for collecting property values
7from each mixin into a single final property on the backend. There is also a
8feature for determining if configuration values have changed between old and
9new configurations so we can selectively take action.
10"""
11
12from charmhelpers import (
13 RESTART,
14 STOP,
15 log,
16 open_port,
17 service_control,
18)
19from shelltoolbox import (
20 apt_get_install,
21 command,
22 install_extra_repositories,
23 su,
24)
25from utils import (
26 AGENT,
27 APACHE,
28 HAPROXY,
29 IMPROV,
30 JUJU_DIR,
31 chain,
32 find_missing_packages,
33 cmd_log,
34 fetch_api,
35 fetch_gui,
36 get_config,
37 get_npm_cache_archive_url,
38 legacy_juju,
39 merge,
40 overrideable,
41 prime_npm_cache,
42 save_or_create_certificates,
43 setup_apache,
44 setup_gui,
45 start_agent,
46 start_gui,
47 start_improv,
48)
49
50import os
51import shutil
52
53
54apt_get = command('apt-get')
55
56
57class InstallMixin(object):
58 """Provide for the GUI and its dependencies to be installed."""
59
60 def install(self, backend):
61 """Install the GUI and dependencies."""
62 config = backend.config
63 # If the given installable thing ("backend") requires one or more debs
64 # that are not yet installed, install them.
65 missing = backend.find_missing_packages(*backend.debs)
66 if missing:
67 cmd_log(backend.install_extra_repositories(*backend.repositories))
68 cmd_log(apt_get_install(*backend.debs))
69
70 # If we are not using a pre-built release of the GUI (i.e., we are
71 # using a branch) then we need to build a release archive to use.
72 if backend.different('juju-gui-source'):
73 # Inject NPM packages into the cache for faster building.
74 self._prime_npm_cache()
75 # Build a release from the branch.
76 self._build_and_install_from_branch()
77
78 def _prime_npm_cache(self):
79 # This is a separate method so it can be easily overridden for testing.
80 prime_npm_cache(get_npm_cache_archive_url())
81
82 def _build_and_install_from_branch(self):
83 # This is a separate method so it can be easily overridden for testing.
84 release_tarball = fetch_gui(
85 config['juju-gui-source'], config['command-log-file'])
86 setup_gui(release_tarball)
87
88
89class UpstartMixin(object):
90 """Manage (install, start, stop, etc.) some service via Upstart."""
91
92 upstart_scripts = ('haproxy.conf', )
93 debs = ('curl', 'openssl', 'haproxy', 'apache2')
94
95 def install(self, backend):
96 """Set up haproxy and nginx upstart configuration files."""
97 setup_apache()
98 backend.log('Setting up haproxy and nginx start up scripts.')
99 config = backend.config
100 if backend.different(
101 'ssl-cert-path', 'ssl-cert-contents', 'ssl-key-contents'):
102 save_or_create_certificates(
103 config['ssl-cert-path'], config.get('ssl-cert-contents'),
104 config.get('ssl-key-contents'))
105
106 source_dir = os.path.join(os.path.dirname(__file__), '..', 'config')
107 for config_file in backend.upstart_scripts:
108 shutil.copy(os.path.join(source_dir, config_file), '/etc/init/')
109
110 def start(self, backend):
111 with su('root'):
112 backend.service_control(APACHE, RESTART)
113 backend.service_control(HAPROXY, RESTART)
114
115 def stop(self, backend):
116 with su('root'):
117 backend.service_control(HAPROXY, STOP)
118 backend.service_control(APACHE, STOP)
119
120
121class GuiMixin(object):
122 gui_properties = set([
123 'juju-gui-console-enabled', 'login-help', 'read-only',
124 'serve-tests', 'secure'])
125
126 repositories = ('ppa:juju-gui/ppa',)
127
128 def start(self, config):
129 start_gui(
130 config['juju-gui-console-enabled'], config['login-help'],
131 config['read-only'], config['staging'], config['ssl-cert-path'],
132 config['charmworld-url'], config['serve-tests'],
133 secure=config['secure'], sandbox=config['sandbox'])
134 open_port(80)
135 open_port(443)
136
137
138class SandboxBackend(object):
139 pass
140
141
142class PythonBackend(object):
143
144 def install(self, config):
145 if (not os.path.exists(JUJU_DIR) or
146 config.different('staging', 'juju-api-branch')):
147 fetch_api(config['juju-api-branch'])
148
149 def start(self, backend):
150 backend.start_agent(backend['ssl-cert-path'])
151
152 def stop(self, backend):
153 backend.service_control(AGENT, STOP)
154
155
156class ImprovBackend(object):
157 debs = ('zookeeper', )
158
159 def install(self, config):
160 if (not os.path.exists(JUJU_DIR) or
161 config.different('staging', 'juju-api-branch')):
162 fetch_api(config['juju-api-branch'])
163
164 def start(self, backend):
165 backend.start_improv(
166 backend['staging-environment'], backend['ssl-cert-path'])
167
168 def stop(self, backend):
169 backend.service_control(IMPROV, STOP)
170
171
172class GoBackend(object):
173 debs = ('python-yaml', )
174
175
176class Backend(object):
177 """Compose methods and policy needed to interact with a Juju backend.
178
179 "config" is a config dict (which typically comes from the JSON
180 de-serialization of config.json in JujuGUI).
181 """
182
183 def __init__(self, config=None, prev_config=None, **overrides):
184 """Generate a selection of strategy classes that implement the backend.
185 """
186 # Ingest the config and build out the ordered list of
187 # backend elements to include
188 if config is None:
189 config = get_config()
190 self.config = config
191 self.prev_config = prev_config
192 self.overrides = overrides
193
194 # We always install the GUI.
195 backends = [InstallMixin, ]
196
197 api = "python" if legacy_juju() else "go"
198 sandbox = config.get('sandbox', False)
199 staging = config.get('staging', False)
200
201 if api == 'python':
202 if staging:
203 backends.append(ImprovBackend)
204 elif sandbox:
205 backends.append(SandboxBackend)
206 else:
207 backends.append(PythonBackend)
208 else:
209 if staging:
210 raise ValueError(
211 "Unable to use staging with {} backend".format(api))
212 if sandbox:
213 raise ValueError(
214 "Unable to use sandbox with {} backend".format(api))
215 backends.append(GoBackend)
216
217 # All backends need to install, start, and stop the services that
218 # provide the GUI.
219 backends.append(GuiMixin)
220 backends.append(UpstartMixin)
221
222 # record our choice mapping classes to instances
223 for i, b in enumerate(backends):
224 if callable(b):
225 backends[i] = b()
226 self.backends = backends
227
228 def __getitem__(self, key):
229 try:
230 return self.config[key]
231 except KeyError:
232 print("Unable to extract config key '%s' from %s" %
233 (key, self.config))
234 raise
235
236 @overrideable
237 def find_missing_packages(self, *packages):
238 return find_missing_packages(*packages)
239
240 @overrideable
241 def service_control(self, service, action):
242 service_control(service, action)
243
244 @overrideable
245 def start_agent(self, cert_path):
246 start_agent(cert_path)
247
248 @overrideable
249 def start_improv(self, stage_env, cert_path):
250 start_improv(stage_env, cert_path)
251
252 @overrideable
253 def log(self, msg, *args):
254 log(msg, *args)
255
256 @overrideable
257 def install_extra_repositories(self, *packages):
258 if self.config.get('allow-additional-deb-repositories', True):
259 install_extra_repositories(*packages)
260 else:
261 apt_get('update')
262
263 def different(self, *keys):
264 """Return a boolean indicating if the current config
265 value differs from the config value passed in prev_config
266 with respect to any of the passed in string keys.
267 """
268 if self.prev_config is None:
269 return True
270
271 for key in keys:
272 current = self.config.get(key)
273 prev = self.prev_config.get(key)
274 r = current != prev
275 if r:
276 return True
277 return False
278
279 ## Composed Methods
280 install = chain('install')
281 start = chain('start')
282 stop = chain('stop')
283
284 ## Merged Properties
285 dependencies = merge('dependencies')
286 build_dependencies = merge('build_dependencies')
287 staging_dependencies = merge('staging_dependencies')
288
289 repositories = merge('repositories')
290 debs = merge('debs')
291 upstart_scripts = merge('upstart_scripts')
0292
=== modified file 'tests/test_utils.py'
--- tests/test_utils.py 2013-04-28 00:59:29 +0000
+++ tests/test_utils.py 2013-04-30 14:57:26 +0000
@@ -12,25 +12,27 @@
12import charmhelpers12import charmhelpers
13import yaml13import yaml
1414
15from backend import InstallMixin
15from utils import (16from utils import (
17 API_PORT,
18 JUJU_GUI_DIR,
19 JUJU_PEM,
20 WEB_PORT,
16 _get_by_attr,21 _get_by_attr,
17 API_PORT,
18 cmd_log,22 cmd_log,
19 first_path_in_dir,23 first_path_in_dir,
20 get_api_address,24 get_api_address,
21 get_release_file_url,25 get_release_file_url,
22 get_zookeeper_address,26 get_zookeeper_address,
23 JUJU_GUI_DIR,
24 JUJU_PEM,
25 legacy_juju,27 legacy_juju,
26 log_hook,28 log_hook,
27 parse_source,29 parse_source,
30 get_npm_cache_archive_url,
28 render_to_file,31 render_to_file,
29 save_or_create_certificates,32 save_or_create_certificates,
30 start_agent,33 start_agent,
31 start_gui,34 start_gui,
32 start_improv,35 start_improv,
33 WEB_PORT,
34)36)
35# Import the whole utils package for monkey patching.37# Import the whole utils package for monkey patching.
36import utils38import utils
@@ -603,5 +605,86 @@
603 self.assertNotIn('redirect scheme https', haproxy_conf)605 self.assertNotIn('redirect scheme https', haproxy_conf)
604606
605607
608class TestNpmCache(unittest.TestCase):
609 """To speed building from a branch we prepopulate the NPM cache."""
610
611 def test_retrieving_cache_url(self):
612 # The URL for the latest cache file can be retrieved from Launchpad.
613 class FauxLaunchpadFactory(object):
614 @staticmethod
615 def login_anonymously(agent, site):
616 # We download the cache from the production site.
617 self.assertEqual(site, 'production')
618 return FauxLaunchpad
619
620 class CacheFile(object):
621 file_link = 'http://launchpad.example/path/to/cache/file'
622
623 def __str__(self):
624 return 'cache-file-123.tgz'
625
626 class NpmRelease(object):
627 files = [CacheFile()]
628
629 class NpmSeries(object):
630 name = 'npm-cache'
631 releases = [NpmRelease]
632
633 class FauxProject(object):
634 series = [NpmSeries]
635
636 class FauxLaunchpad(object):
637 projects = {'juju-gui': FauxProject()}
638
639 url = get_npm_cache_archive_url(Launchpad=FauxLaunchpadFactory())
640 self.assertEqual(url, 'http://launchpad.example/path/to/cache/file')
641
642
643 def test_InstallMixin_primes_npm_cache(self):
644 # The InstallMixin.install() method primes the NPM cache before
645 # building (and installing) the GUI from a branch.
646 assertFalse = self.assertFalse
647 assertTrue = self.assertTrue
648
649 class TestableInstallMixin(InstallMixin):
650 """An InstallMixin that records actions instead of taking them."""
651 cache_primed = False
652 branch_installed = False
653
654 def _prime_npm_cache(self):
655 # The cache is primed before the branch is installed.
656 assertFalse(self.branch_installed)
657 self.cache_primed = True
658
659 def _build_and_install_from_branch(self):
660 # The cache is primed before the branch is installed.
661 assertTrue(self.cache_primed)
662 self.branch_installed = True
663
664 class FauxBackend(object):
665 """A test backend."""
666 config = None
667 debs = ('DEBS',)
668
669 @classmethod
670 def find_missing_packages(cls, *debs):
671 self.assertEqual(cls.debs, debs)
672 return False
673
674 @classmethod
675 def different(cls, key):
676 self.assertEqual(key, 'juju-gui-source')
677 return True
678
679 mixin = TestableInstallMixin()
680 # Prior to "install" the NPM cache has not been primed.
681 self.assertFalse(mixin.cache_primed)
682 self.assertFalse(mixin.branch_installed)
683 mixin.install(FauxBackend())
684 # After "install" the NPM cache has been primed.
685 self.assertTrue(mixin.cache_primed)
686 self.assertTrue(mixin.branch_installed)
687
688
606if __name__ == '__main__':689if __name__ == '__main__':
607 unittest.main(verbosity=2)690 unittest.main(verbosity=2)
608691
=== modified symlink 'tests/utils.py'
=== target was u'../hooks/utils.py'
--- tests/utils.py 1970-01-01 00:00:00 +0000
+++ tests/utils.py 2013-04-30 14:57:26 +0000
@@ -0,0 +1,629 @@
1"""Juju GUI charm utilities."""
2
3__all__ = [
4 'AGENT',
5 'APACHE',
6 'API_PORT',
7 'CURRENT_DIR',
8 'HAPROXY',
9 'IMPROV',
10 'JUJU_DIR',
11 'JUJU_GUI_DIR',
12 'JUJU_GUI_SITE',
13 'JUJU_PEM',
14 'StopChain',
15 'WEB_PORT',
16 'bzr_checkout',
17 'chain',
18 'cmd_log',
19 'fetch_api',
20 'fetch_gui',
21 'first_path_in_dir',
22 'get_api_address',
23 'get_release_file_url',
24 'get_staging_dependencies',
25 'get_zookeeper_address',
26 'legacy_juju',
27 'log_hook',
28 'merge',
29 'overrideable',
30 'parse_source',
31 'render_to_file',
32 'save_or_create_certificates',
33 'setup_apache',
34 'setup_gui',
35 'start_agent',
36 'start_gui',
37 'start_improv',
38 'write_apache_config',
39]
40
41from contextlib import contextmanager
42import json
43import os
44import logging
45import shutil
46from subprocess import CalledProcessError
47import tempfile
48from urlparse import urlparse
49
50from launchpadlib.launchpad import Launchpad
51from shelltoolbox import (
52 Serializer,
53 apt_get_install,
54 command,
55 environ,
56 install_extra_repositories,
57 run,
58 script_name,
59 search_file,
60 su,
61)
62from charmhelpers import (
63 START,
64 get_config,
65 log,
66 service_control,
67 unit_get,
68)
69
70import apt
71import tempita
72
73
74AGENT = 'juju-api-agent'
75APACHE = 'apache2'
76IMPROV = 'juju-api-improv'
77HAPROXY = 'haproxy'
78
79API_PORT = 8080
80WEB_PORT = 8000
81
82CURRENT_DIR = os.getcwd()
83JUJU_DIR = os.path.join(CURRENT_DIR, 'juju')
84JUJU_GUI_DIR = os.path.join(CURRENT_DIR, 'juju-gui')
85JUJU_GUI_SITE = '/etc/apache2/sites-available/juju-gui'
86JUJU_GUI_PORTS = '/etc/apache2/ports.conf'
87JUJU_PEM = 'juju.includes-private-key.pem'
88BUILD_REPOSITORIES = ('ppa:chris-lea/node.js-legacy',)
89DEB_BUILD_DEPENDENCIES = (
90 'bzr', 'imagemagick', 'make', 'nodejs', 'npm',
91)
92DEB_STAGE_DEPENDENCIES = (
93 'zookeeper',
94)
95
96
97# Store the configuration from on invocation to the next.
98config_json = Serializer('/tmp/config.json')
99# Bazaar checkout command.
100bzr_checkout = command('bzr', 'co', '--lightweight')
101# Whether or not the charm is deployed using juju-core.
102# If juju-core has been used to deploy the charm, an agent.conf file must
103# be present in the charm parent directory.
104legacy_juju = lambda: not os.path.exists(
105 os.path.join(CURRENT_DIR, '..', 'agent.conf'))
106
107
108def _get_build_dependencies():
109 """Install deb dependencies for building."""
110 log('Installing build dependencies.')
111 cmd_log(install_extra_repositories(*BUILD_REPOSITORIES))
112 cmd_log(apt_get_install(*DEB_BUILD_DEPENDENCIES))
113
114
115def get_api_address(unit_dir):
116 """Return the Juju API address stored in the uniter agent.conf file."""
117 import yaml # python-yaml is only installed if juju-core is used.
118 # XXX 2013-03-27 frankban bug=1161443:
119 # currently the uniter agent.conf file does not include the API
120 # address. For now retrieve it from the machine agent file.
121 base_dir = os.path.abspath(os.path.join(unit_dir, '..'))
122 for dirname in os.listdir(base_dir):
123 if dirname.startswith('machine-'):
124 agent_conf = os.path.join(base_dir, dirname, 'agent.conf')
125 break
126 else:
127 raise IOError('Juju agent configuration file not found.')
128 contents = yaml.load(open(agent_conf))
129 return contents['apiinfo']['addrs'][0]
130
131
132def get_staging_dependencies():
133 """Install deb dependencies for the stage (improv) environment."""
134 log('Installing stage dependencies.')
135 cmd_log(apt_get_install(*DEB_STAGE_DEPENDENCIES))
136
137
138def first_path_in_dir(directory):
139 """Return the full path of the first file/dir in *directory*."""
140 return os.path.join(directory, os.listdir(directory)[0])
141
142
143def _get_by_attr(collection, attr, value):
144 """Return the first item in collection having attr == value.
145
146 Return None if the item is not found.
147 """
148 for item in collection:
149 if getattr(item, attr) == value:
150 return item
151
152
153def get_release_file_url(project, series_name, release_version):
154 """Return the URL of the release file hosted in Launchpad.
155
156 The returned URL points to a release file for the given project, series
157 name and release version.
158 The argument *project* is a project object as returned by launchpadlib.
159 The arguments *series_name* and *release_version* are strings. If
160 *release_version* is None, the URL of the latest release will be returned.
161 """
162 series = _get_by_attr(project.series, 'name', series_name)
163 if series is None:
164 raise ValueError('%r: series not found' % series_name)
165 # Releases are returned by Launchpad in reverse date order.
166 releases = list(series.releases)
167 if not releases:
168 raise ValueError('%r: series does not contain releases' % series_name)
169 if release_version is not None:
170 release = _get_by_attr(releases, 'version', release_version)
171 if release is None:
172 raise ValueError('%r: release not found' % release_version)
173 releases = [release]
174 for release in releases:
175 for file_ in release.files:
176 if str(file_).endswith('.tgz'):
177 return file_.file_link
178 raise ValueError('%r: file not found' % release_version)
179
180
181def get_zookeeper_address(agent_file_path):
182 """Retrieve the Zookeeper address contained in the given *agent_file_path*.
183
184 The *agent_file_path* is a path to a file containing a line similar to the
185 following::
186
187 env JUJU_ZOOKEEPER="address"
188 """
189 line = search_file('JUJU_ZOOKEEPER', agent_file_path).strip()
190 return line.split('=')[1].strip('"')
191
192
193@contextmanager
194def log_hook():
195 """Log when a hook starts and stops its execution.
196
197 Also log to stdout possible CalledProcessError exceptions raised executing
198 the hook.
199 """
200 script = script_name()
201 log(">>> Entering {}".format(script))
202 try:
203 yield
204 except CalledProcessError as err:
205 log('Exception caught:')
206 log(err.output)
207 raise
208 finally:
209 log("<<< Exiting {}".format(script))
210
211
212def parse_source(source):
213 """Parse the ``juju-gui-source`` option.
214
215 Return a tuple of two elements representing info on how to deploy Juju GUI.
216 Examples:
217 - ('stable', None): latest stable release;
218 - ('stable', '0.1.0'): stable release v0.1.0;
219 - ('trunk', None): latest trunk release;
220 - ('trunk', '0.1.0+build.1'): trunk release v0.1.0 bzr revision 1;
221 - ('branch', 'lp:juju-gui'): release is made from a branch;
222 - ('url', 'http://example.com/gui'): release from a downloaded file.
223 """
224 if source.startswith('url:'):
225 source = source[4:]
226 # Support file paths, including relative paths.
227 if urlparse(source).scheme == '':
228 if not source.startswith('/'):
229 source = os.path.join(os.path.abspath(CURRENT_DIR), source)
230 source = "file://%s" % source
231 return 'url', source
232 if source in ('stable', 'trunk'):
233 return source, None
234 if source.startswith('lp:') or source.startswith('http://'):
235 return 'branch', source
236 if 'build' in source:
237 return 'trunk', source
238 return 'stable', source
239
240
241def render_to_file(template_name, context, destination):
242 """Render the given *template_name* into *destination* using *context*.
243
244 The tempita template language is used to render contents
245 (see http://pythonpaste.org/tempita/).
246 The argument *template_name* is the name or path of the template file:
247 it may be either a path relative to ``../config`` or an absolute path.
248 The argument *destination* is a file path.
249 The argument *context* is a dict-like object.
250 """
251 template_path = os.path.join(
252 os.path.dirname(__file__), '..', 'config', template_name)
253 template = tempita.Template.from_filename(template_path)
254 with open(destination, 'w') as stream:
255 stream.write(template.substitute(context))
256
257
258results_log = None
259
260
261def _setupLogging():
262 global results_log
263 if results_log is not None:
264 return
265 config = get_config()
266 logging.basicConfig(
267 filename=config['command-log-file'],
268 level=logging.INFO,
269 format="%(asctime)s: %(name)s@%(levelname)s %(message)s")
270 results_log = logging.getLogger('juju-gui')
271
272
273def cmd_log(results):
274 global results_log
275 if not results:
276 return
277 if results_log is None:
278 _setupLogging()
279 # Since 'results' may be multi-line output, start it on a separate line
280 # from the logger timestamp, etc.
281 results_log.info('\n' + results)
282
283
284def start_improv(staging_env, ssl_cert_path,
285 config_path='/etc/init/juju-api-improv.conf'):
286 """Start a simulated juju environment using ``improv.py``."""
287 log('Setting up staging start up script.')
288 context = {
289 'juju_dir': JUJU_DIR,
290 'keys': ssl_cert_path,
291 'port': API_PORT,
292 'staging_env': staging_env,
293 }
294 render_to_file('juju-api-improv.conf.template', context, config_path)
295 log('Starting the staging backend.')
296 with su('root'):
297 service_control(IMPROV, START)
298
299
300def start_agent(ssl_cert_path, config_path='/etc/init/juju-api-agent.conf'):
301 """Start the Juju agent and connect to the current environment."""
302 # Retrieve the Zookeeper address from the start up script.
303 unit_dir = os.path.realpath(os.path.join(CURRENT_DIR, '..'))
304 agent_file = '/etc/init/juju-{0}.conf'.format(os.path.basename(unit_dir))
305 zookeeper = get_zookeeper_address(agent_file)
306 log('Setting up API agent start up script.')
307 context = {
308 'juju_dir': JUJU_DIR,
309 'keys': ssl_cert_path,
310 'port': API_PORT,
311 'zookeeper': zookeeper,
312 }
313 render_to_file('juju-api-agent.conf.template', context, config_path)
314 log('Starting API agent.')
315 with su('root'):
316 service_control(AGENT, START)
317
318
319def start_gui(
320 console_enabled, login_help, readonly, in_staging, ssl_cert_path,
321 charmworld_url, serve_tests, haproxy_path='/etc/haproxy/haproxy.cfg',
322 config_js_path=None, secure=True, sandbox=False):
323 """Set up and start the Juju GUI server."""
324 with su('root'):
325 run('chown', '-R', 'ubuntu:', JUJU_GUI_DIR)
326 # XXX 2013-02-05 frankban bug=1116320:
327 # External insecure resources are still loaded when testing in the
328 # debug environment. For now, switch to the production environment if
329 # the charm is configured to serve tests.
330 if in_staging and not serve_tests:
331 build_dirname = 'build-debug'
332 else:
333 build_dirname = 'build-prod'
334 build_dir = os.path.join(JUJU_GUI_DIR, build_dirname)
335 log('Generating the Juju GUI configuration file.')
336 is_legacy_juju = legacy_juju()
337 user, password = None, None
338 if is_legacy_juju and in_staging:
339 user, password = 'admin', 'admin'
340 else:
341 user, password = None, None
342
343 api_backend = 'python' if is_legacy_juju else 'go'
344 if secure:
345 protocol = 'wss'
346 else:
347 log('Running in insecure mode! Port 80 will serve unencrypted.')
348 protocol = 'ws'
349
350 context = {
351 'raw_protocol': protocol,
352 'address': unit_get('public-address'),
353 'console_enabled': json.dumps(console_enabled),
354 'login_help': json.dumps(login_help),
355 'password': json.dumps(password),
356 'api_backend': json.dumps(api_backend),
357 'readonly': json.dumps(readonly),
358 'user': json.dumps(user),
359 'protocol': json.dumps(protocol),
360 'sandbox': json.dumps(sandbox),
361 'charmworld_url': json.dumps(charmworld_url),
362 }
363 if config_js_path is None:
364 config_js_path = os.path.join(
365 build_dir, 'juju-ui', 'assets', 'config.js')
366 render_to_file('config.js.template', context, config_js_path)
367
368 write_apache_config(build_dir, serve_tests)
369
370 log('Generating haproxy configuration file.')
371 if is_legacy_juju:
372 # The PyJuju API agent is listening on localhost.
373 api_address = '127.0.0.1:{0}'.format(API_PORT)
374 else:
375 # Retrieve the juju-core API server address.
376 api_address = get_api_address(os.path.join(CURRENT_DIR, '..'))
377 context = {
378 'api_address': api_address,
379 'api_pem': JUJU_PEM,
380 'legacy_juju': is_legacy_juju,
381 'ssl_cert_path': ssl_cert_path,
382 # In PyJuju environments, use the same certificate for both HTTPS and
383 # WebSocket connections. In juju-core the system already has the proper
384 # certificate installed.
385 'web_pem': JUJU_PEM,
386 'web_port': WEB_PORT,
387 'secure': secure
388 }
389 render_to_file('haproxy.cfg.template', context, haproxy_path)
390 log('Starting Juju GUI.')
391
392def write_apache_config(build_dir, serve_tests=False):
393 log('Generating the apache site configuration file.')
394 context = {
395 'port': WEB_PORT,
396 'serve_tests': serve_tests,
397 'server_root': build_dir,
398 'tests_root': os.path.join(JUJU_GUI_DIR, 'test', ''),
399 }
400 render_to_file('apache-ports.template', context, JUJU_GUI_PORTS)
401 render_to_file('apache-site.template', context, JUJU_GUI_SITE)
402
403
404def get_npm_cache_archive_url(Launchpad=Launchpad):
405 """Figure out the URL of the most recent NPM cache archive on Launchpad."""
406 launchpad = Launchpad.login_anonymously('Juju GUI charm', 'production')
407 project = launchpad.projects['juju-gui']
408 # Find the URL of the most recently created NPM cache archive.
409 npm_cache_url = get_release_file_url(project, 'npm-cache', None)
410 return npm_cache_url
411
412
413def prime_npm_cache(npm_cache_url):
414 """Download NPM cache archive and prime the NPM cache with it."""
415 # Download the cache archive and then uncompress it into the NPM cache.
416 npm_cache_archive = os.path.join(CURRENT_DIR, 'npm-cache.tgz')
417 cmd_log(run('curl', '-L', '-o', npm_cache_archive, npm_cache_url))
418 npm_cache_dir = os.path.expanduser('~/.npm')
419 # The NPM cache directory probably does not exist, so make it if not.
420 try:
421 os.mkdir(npm_cache_dir)
422 except OSError, e:
423 # If the directory already exists then ignore the error.
424 if e.errno != 17: # File exists.
425 raise
426 uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
427 cmd_log(uncompress(npm_cache_archive))
428
429
430def fetch_gui(juju_gui_source, logpath):
431 """Retrieve the Juju GUI release/branch."""
432 # Retrieve a Juju GUI release.
433 origin, version_or_branch = parse_source(juju_gui_source)
434 if origin == 'branch':
435 # Make sure we have the dependencies necessary for us to actually make
436 # a build.
437 _get_build_dependencies()
438 # Create a release starting from a branch.
439 juju_gui_source_dir = os.path.join(CURRENT_DIR, 'juju-gui-source')
440 log('Retrieving Juju GUI source checkout from %s.' % version_or_branch)
441 cmd_log(run('rm', '-rf', juju_gui_source_dir))
442 cmd_log(bzr_checkout(version_or_branch, juju_gui_source_dir))
443 log('Preparing a Juju GUI release.')
444 logdir = os.path.dirname(logpath)
445 fd, name = tempfile.mkstemp(prefix='make-distfile-', dir=logdir)
446 log('Output from "make distfile" sent to %s' % name)
447 with environ(NO_BZR='1'):
448 run('make', '-C', juju_gui_source_dir, 'distfile',
449 stdout=fd, stderr=fd)
450 release_tarball = first_path_in_dir(
451 os.path.join(juju_gui_source_dir, 'releases'))
452 else:
453 log('Retrieving Juju GUI release.')
454 if origin == 'url':
455 file_url = version_or_branch
456 else:
457 # Retrieve a release from Launchpad.
458 launchpad = Launchpad.login_anonymously(
459 'Juju GUI charm', 'production')
460 project = launchpad.projects['juju-gui']
461 file_url = get_release_file_url(project, origin, version_or_branch)
462 log('Downloading release file from %s.' % file_url)
463 release_tarball = os.path.join(CURRENT_DIR, 'release.tgz')
464 cmd_log(run('curl', '-L', '-o', release_tarball, file_url))
465 return release_tarball
466
467
468def fetch_api(juju_api_branch):
469 """Retrieve the Juju branch."""
470 # Retrieve Juju API source checkout.
471 log('Retrieving Juju API source checkout.')
472 cmd_log(run('rm', '-rf', JUJU_DIR))
473 cmd_log(bzr_checkout(juju_api_branch, JUJU_DIR))
474
475
476def setup_gui(release_tarball):
477 """Set up Juju GUI."""
478 # Uncompress the release tarball.
479 log('Installing Juju GUI.')
480 release_dir = os.path.join(CURRENT_DIR, 'release')
481 cmd_log(run('rm', '-rf', release_dir))
482 os.mkdir(release_dir)
483 uncompress = command('tar', '-x', '-z', '-C', release_dir, '-f')
484 cmd_log(uncompress(release_tarball))
485 # Link the Juju GUI dir to the contents of the release tarball.
486 cmd_log(run('ln', '-sf', first_path_in_dir(release_dir), JUJU_GUI_DIR))
487
488
489def setup_apache():
490 """Set up apache."""
491 log('Setting up apache.')
492 if not os.path.exists(JUJU_GUI_SITE):
493 cmd_log(run('touch', JUJU_GUI_SITE))
494 cmd_log(run('chown', 'ubuntu:', JUJU_GUI_SITE))
495 cmd_log(
496 run('ln', '-s', JUJU_GUI_SITE,
497 '/etc/apache2/sites-enabled/juju-gui'))
498
499 if not os.path.exists(JUJU_GUI_PORTS):
500 cmd_log(run('touch', JUJU_GUI_PORTS))
501 cmd_log(run('chown', 'ubuntu:', JUJU_GUI_PORTS))
502
503 with su('root'):
504 run('a2dissite', 'default')
505 run('a2ensite', 'juju-gui')
506
507
508def save_or_create_certificates(
509 ssl_cert_path, ssl_cert_contents, ssl_key_contents):
510 """Generate the SSL certificates.
511
512 If both *ssl_cert_contents* and *ssl_key_contents* are provided, use them
513 as certificates; otherwise, generate them.
514
515 Also create a pem file, suitable for use in the haproxy configuration,
516 concatenating the key and the certificate files.
517 """
518 crt_path = os.path.join(ssl_cert_path, 'juju.crt')
519 key_path = os.path.join(ssl_cert_path, 'juju.key')
520 if not os.path.exists(ssl_cert_path):
521 os.makedirs(ssl_cert_path)
522 if ssl_cert_contents and ssl_key_contents:
523 # Save the provided certificates.
524 with open(crt_path, 'w') as cert_file:
525 cert_file.write(ssl_cert_contents)
526 with open(key_path, 'w') as key_file:
527 key_file.write(ssl_key_contents)
528 else:
529 # Generate certificates.
530 # See http://superuser.com/questions/226192/openssl-without-prompt
531 cmd_log(run(
532 'openssl', 'req', '-new', '-newkey', 'rsa:4096',
533 '-days', '365', '-nodes', '-x509', '-subj',
534 # These are arbitrary test values for the certificate.
535 '/C=GB/ST=Juju/L=GUI/O=Ubuntu/CN=juju.ubuntu.com',
536 '-keyout', key_path, '-out', crt_path))
537 # Generate the pem file.
538 pem_path = os.path.join(ssl_cert_path, JUJU_PEM)
539 if os.path.exists(pem_path):
540 os.remove(pem_path)
541 with open(pem_path, 'w') as pem_file:
542 shutil.copyfileobj(open(key_path), pem_file)
543 shutil.copyfileobj(open(crt_path), pem_file)
544
545
546def find_missing_packages(*packages):
547 """Given a list of packages, return the packages which are not installed.
548 """
549 cache = apt.Cache()
550 missing = set()
551 for pkg_name in packages:
552 try:
553 pkg = cache[pkg_name]
554 except KeyError:
555 missing.add(pkg_name)
556 continue
557 if pkg.is_installed:
558 continue
559 missing.add(pkg_name)
560 return missing
561
562
563## Backend support decorators
564class StopChain(Exception):
565 """Stop Processing a chain command without raising
566 another error.
567 """
568
569
570def chain(name, reverse=False):
571 """Helper method to compose a set of strategy objects into
572 a callable.
573
574 Each method is called in the context of its strategy
575 instance (normal OOP) and its argument is the Backend
576 instance.
577 """
578 # chain method calls through all implementing mixins
579 def method(self):
580 workingset = self.backends
581 if reverse:
582 workingset = reversed(workingset)
583 for backend in workingset:
584 call = backend.__class__.__dict__.get(name)
585 if call:
586 try:
587 call(backend, self)
588 except StopChain:
589 break
590
591 method.__name__ = name
592 return method
593
594
595def overrideable(f):
596 """Helper to support very limited overrides for use in testing.
597
598 def foo():
599 return True
600 b = Backend(foo=foo)
601 assert b.foo() is True
602 """
603 name = f.__name__
604
605 def overridden(self, *args, **kwargs):
606 if name in self.overrides:
607 return self.overrides[name](*args, **kwargs)
608 else:
609 return f(self, *args, **kwargs)
610
611 overridden.__name__ = name
612 return overridden
613
614
615def merge(name):
616 """Helper to merge a property from a set of strategy objects
617 into a unified set.
618 """
619 # return merged property from every providing backend as a set
620 @property
621 def method(self):
622 result = set()
623 for backend in self.backends:
624 segment = backend.__class__.__dict__.get(name)
625 if segment and isinstance(segment, (list, tuple, set)):
626 result |= set(segment)
627
628 return result
629 return method

Subscribers

People subscribed via source and target branches

to all changes: