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
1=== modified file 'hooks/backend.py'
2--- hooks/backend.py 2013-04-30 13:53:20 +0000
3+++ hooks/backend.py 2013-04-30 14:57:26 +0000
4@@ -29,17 +29,19 @@
5 IMPROV,
6 JUJU_DIR,
7 chain,
8- check_packages,
9+ find_missing_packages,
10 cmd_log,
11 fetch_api,
12 fetch_gui,
13 get_config,
14+ get_npm_cache_archive_url,
15 legacy_juju,
16 merge,
17 overrideable,
18+ prime_npm_cache,
19 save_or_create_certificates,
20+ setup_apache,
21 setup_gui,
22- setup_apache,
23 start_agent,
24 start_gui,
25 start_improv,
26@@ -53,20 +55,40 @@
27
28
29 class InstallMixin(object):
30+ """Provide for the GUI and its dependencies to be installed."""
31+
32 def install(self, backend):
33+ """Install the GUI and dependencies."""
34 config = backend.config
35- missing = backend.check_packages(*backend.debs)
36+ # If the given installable thing ("backend") requires one or more debs
37+ # that are not yet installed, install them.
38+ missing = backend.find_missing_packages(*backend.debs)
39 if missing:
40 cmd_log(backend.install_extra_repositories(*backend.repositories))
41 cmd_log(apt_get_install(*backend.debs))
42
43+ # If we are not using a pre-built release of the GUI (i.e., we are
44+ # using a branch) then we need to build a release archive to use.
45 if backend.different('juju-gui-source'):
46- release_tarball = fetch_gui(
47- config['juju-gui-source'], config['command-log-file'])
48- setup_gui(release_tarball)
49+ # Inject NPM packages into the cache for faster building.
50+ self._prime_npm_cache()
51+ # Build a release from the branch.
52+ self._build_and_install_from_branch()
53+
54+ def _prime_npm_cache(self):
55+ # This is a separate method so it can be easily overridden for testing.
56+ prime_npm_cache(get_npm_cache_archive_url())
57+
58+ def _build_and_install_from_branch(self):
59+ # This is a separate method so it can be easily overridden for testing.
60+ release_tarball = fetch_gui(
61+ config['juju-gui-source'], config['command-log-file'])
62+ setup_gui(release_tarball)
63
64
65 class UpstartMixin(object):
66+ """Manage (install, start, stop, etc.) some service via Upstart."""
67+
68 upstart_scripts = ('haproxy.conf', )
69 debs = ('curl', 'openssl', 'haproxy', 'apache2')
70
71@@ -152,16 +174,14 @@
72
73
74 class Backend(object):
75- """Compose methods and policy needed to interact
76- with a Juju backend. Given a config dict (which typically
77- comes from the JSON de-serialization of config.json in JujuGUI).
78+ """Compose methods and policy needed to interact with a Juju backend.
79+
80+ "config" is a config dict (which typically comes from the JSON
81+ de-serialization of config.json in JujuGUI).
82 """
83
84 def __init__(self, config=None, prev_config=None, **overrides):
85- """
86- Backends function through composition. __init__ becomes the
87- factory method to generate a selection of strategy classes
88- to use together to implement the backend proper.
89+ """Generate a selection of strategy classes that implement the backend.
90 """
91 # Ingest the config and build out the ordered list of
92 # backend elements to include
93@@ -171,7 +191,7 @@
94 self.prev_config = prev_config
95 self.overrides = overrides
96
97- # We always use upstart.
98+ # We always install the GUI.
99 backends = [InstallMixin, ]
100
101 api = "python" if legacy_juju() else "go"
102@@ -194,7 +214,8 @@
103 "Unable to use sandbox with {} backend".format(api))
104 backends.append(GoBackend)
105
106- # All backends can manage the gui.
107+ # All backends need to install, start, and stop the services that
108+ # provide the GUI.
109 backends.append(GuiMixin)
110 backends.append(UpstartMixin)
111
112@@ -213,8 +234,8 @@
113 raise
114
115 @overrideable
116- def check_packages(self, *packages):
117- return check_packages(*packages)
118+ def find_missing_packages(self, *packages):
119+ return find_missing_packages(*packages)
120
121 @overrideable
122 def service_control(self, service, action):
123
124=== modified file 'hooks/utils.py'
125--- hooks/utils.py 2013-04-30 13:53:20 +0000
126+++ hooks/utils.py 2013-04-30 14:57:26 +0000
127@@ -404,6 +404,32 @@
128 render_to_file('apache-site.template', context, JUJU_GUI_SITE)
129
130
131+def get_npm_cache_archive_url(Launchpad=Launchpad):
132+ """Figure out the URL of the most recent NPM cache archive on Launchpad."""
133+ launchpad = Launchpad.login_anonymously('Juju GUI charm', 'production')
134+ project = launchpad.projects['juju-gui']
135+ # Find the URL of the most recently created NPM cache archive.
136+ npm_cache_url = get_release_file_url(project, 'npm-cache', None)
137+ return npm_cache_url
138+
139+
140+def prime_npm_cache(npm_cache_url):
141+ """Download NPM cache archive and prime the NPM cache with it."""
142+ # Download the cache archive and then uncompress it into the NPM cache.
143+ npm_cache_archive = os.path.join(CURRENT_DIR, 'npm-cache.tgz')
144+ cmd_log(run('curl', '-L', '-o', npm_cache_archive, npm_cache_url))
145+ npm_cache_dir = os.path.expanduser('~/.npm')
146+ # The NPM cache directory probably does not exist, so make it if not.
147+ try:
148+ os.mkdir(npm_cache_dir)
149+ except OSError, e:
150+ # If the directory already exists then ignore the error.
151+ if e.errno != errno.EEXIST: # File exists.
152+ raise
153+ uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
154+ cmd_log(uncompress(npm_cache_archive))
155+
156+
157 def fetch_gui(juju_gui_source, logpath):
158 """Retrieve the Juju GUI release/branch."""
159 # Retrieve a Juju GUI release.
160@@ -520,7 +546,7 @@
161 shutil.copyfileobj(open(crt_path), pem_file)
162
163
164-def check_packages(*packages):
165+def find_missing_packages(*packages):
166 """Given a list of packages, return the packages which are not installed.
167 """
168 cache = apt.Cache()
169
170=== modified file 'revision'
171--- revision 2013-04-30 13:53:20 +0000
172+++ revision 2013-04-30 14:57:26 +0000
173@@ -1,1 +1,5 @@
174+<<<<<<< TREE
175 41
176+=======
177+44
178+>>>>>>> MERGE-SOURCE
179
180=== modified symlink 'tests/backend.py'
181=== target was u'../hooks/backend.py'
182--- tests/backend.py 1970-01-01 00:00:00 +0000
183+++ tests/backend.py 2013-04-30 14:57:26 +0000
184@@ -0,0 +1,291 @@
185+"""
186+A composition system for creating backend object.
187+
188+Backends implement start(), stop() and install() methods. A backend is composed
189+of many mixins and each mixin will implement any/all of those methods and all
190+will be called. Backends additionally provide for collecting property values
191+from each mixin into a single final property on the backend. There is also a
192+feature for determining if configuration values have changed between old and
193+new configurations so we can selectively take action.
194+"""
195+
196+from charmhelpers import (
197+ RESTART,
198+ STOP,
199+ log,
200+ open_port,
201+ service_control,
202+)
203+from shelltoolbox import (
204+ apt_get_install,
205+ command,
206+ install_extra_repositories,
207+ su,
208+)
209+from utils import (
210+ AGENT,
211+ APACHE,
212+ HAPROXY,
213+ IMPROV,
214+ JUJU_DIR,
215+ chain,
216+ find_missing_packages,
217+ cmd_log,
218+ fetch_api,
219+ fetch_gui,
220+ get_config,
221+ get_npm_cache_archive_url,
222+ legacy_juju,
223+ merge,
224+ overrideable,
225+ prime_npm_cache,
226+ save_or_create_certificates,
227+ setup_apache,
228+ setup_gui,
229+ start_agent,
230+ start_gui,
231+ start_improv,
232+)
233+
234+import os
235+import shutil
236+
237+
238+apt_get = command('apt-get')
239+
240+
241+class InstallMixin(object):
242+ """Provide for the GUI and its dependencies to be installed."""
243+
244+ def install(self, backend):
245+ """Install the GUI and dependencies."""
246+ config = backend.config
247+ # If the given installable thing ("backend") requires one or more debs
248+ # that are not yet installed, install them.
249+ missing = backend.find_missing_packages(*backend.debs)
250+ if missing:
251+ cmd_log(backend.install_extra_repositories(*backend.repositories))
252+ cmd_log(apt_get_install(*backend.debs))
253+
254+ # If we are not using a pre-built release of the GUI (i.e., we are
255+ # using a branch) then we need to build a release archive to use.
256+ if backend.different('juju-gui-source'):
257+ # Inject NPM packages into the cache for faster building.
258+ self._prime_npm_cache()
259+ # Build a release from the branch.
260+ self._build_and_install_from_branch()
261+
262+ def _prime_npm_cache(self):
263+ # This is a separate method so it can be easily overridden for testing.
264+ prime_npm_cache(get_npm_cache_archive_url())
265+
266+ def _build_and_install_from_branch(self):
267+ # This is a separate method so it can be easily overridden for testing.
268+ release_tarball = fetch_gui(
269+ config['juju-gui-source'], config['command-log-file'])
270+ setup_gui(release_tarball)
271+
272+
273+class UpstartMixin(object):
274+ """Manage (install, start, stop, etc.) some service via Upstart."""
275+
276+ upstart_scripts = ('haproxy.conf', )
277+ debs = ('curl', 'openssl', 'haproxy', 'apache2')
278+
279+ def install(self, backend):
280+ """Set up haproxy and nginx upstart configuration files."""
281+ setup_apache()
282+ backend.log('Setting up haproxy and nginx start up scripts.')
283+ config = backend.config
284+ if backend.different(
285+ 'ssl-cert-path', 'ssl-cert-contents', 'ssl-key-contents'):
286+ save_or_create_certificates(
287+ config['ssl-cert-path'], config.get('ssl-cert-contents'),
288+ config.get('ssl-key-contents'))
289+
290+ source_dir = os.path.join(os.path.dirname(__file__), '..', 'config')
291+ for config_file in backend.upstart_scripts:
292+ shutil.copy(os.path.join(source_dir, config_file), '/etc/init/')
293+
294+ def start(self, backend):
295+ with su('root'):
296+ backend.service_control(APACHE, RESTART)
297+ backend.service_control(HAPROXY, RESTART)
298+
299+ def stop(self, backend):
300+ with su('root'):
301+ backend.service_control(HAPROXY, STOP)
302+ backend.service_control(APACHE, STOP)
303+
304+
305+class GuiMixin(object):
306+ gui_properties = set([
307+ 'juju-gui-console-enabled', 'login-help', 'read-only',
308+ 'serve-tests', 'secure'])
309+
310+ repositories = ('ppa:juju-gui/ppa',)
311+
312+ def start(self, config):
313+ start_gui(
314+ config['juju-gui-console-enabled'], config['login-help'],
315+ config['read-only'], config['staging'], config['ssl-cert-path'],
316+ config['charmworld-url'], config['serve-tests'],
317+ secure=config['secure'], sandbox=config['sandbox'])
318+ open_port(80)
319+ open_port(443)
320+
321+
322+class SandboxBackend(object):
323+ pass
324+
325+
326+class PythonBackend(object):
327+
328+ def install(self, config):
329+ if (not os.path.exists(JUJU_DIR) or
330+ config.different('staging', 'juju-api-branch')):
331+ fetch_api(config['juju-api-branch'])
332+
333+ def start(self, backend):
334+ backend.start_agent(backend['ssl-cert-path'])
335+
336+ def stop(self, backend):
337+ backend.service_control(AGENT, STOP)
338+
339+
340+class ImprovBackend(object):
341+ debs = ('zookeeper', )
342+
343+ def install(self, config):
344+ if (not os.path.exists(JUJU_DIR) or
345+ config.different('staging', 'juju-api-branch')):
346+ fetch_api(config['juju-api-branch'])
347+
348+ def start(self, backend):
349+ backend.start_improv(
350+ backend['staging-environment'], backend['ssl-cert-path'])
351+
352+ def stop(self, backend):
353+ backend.service_control(IMPROV, STOP)
354+
355+
356+class GoBackend(object):
357+ debs = ('python-yaml', )
358+
359+
360+class Backend(object):
361+ """Compose methods and policy needed to interact with a Juju backend.
362+
363+ "config" is a config dict (which typically comes from the JSON
364+ de-serialization of config.json in JujuGUI).
365+ """
366+
367+ def __init__(self, config=None, prev_config=None, **overrides):
368+ """Generate a selection of strategy classes that implement the backend.
369+ """
370+ # Ingest the config and build out the ordered list of
371+ # backend elements to include
372+ if config is None:
373+ config = get_config()
374+ self.config = config
375+ self.prev_config = prev_config
376+ self.overrides = overrides
377+
378+ # We always install the GUI.
379+ backends = [InstallMixin, ]
380+
381+ api = "python" if legacy_juju() else "go"
382+ sandbox = config.get('sandbox', False)
383+ staging = config.get('staging', False)
384+
385+ if api == 'python':
386+ if staging:
387+ backends.append(ImprovBackend)
388+ elif sandbox:
389+ backends.append(SandboxBackend)
390+ else:
391+ backends.append(PythonBackend)
392+ else:
393+ if staging:
394+ raise ValueError(
395+ "Unable to use staging with {} backend".format(api))
396+ if sandbox:
397+ raise ValueError(
398+ "Unable to use sandbox with {} backend".format(api))
399+ backends.append(GoBackend)
400+
401+ # All backends need to install, start, and stop the services that
402+ # provide the GUI.
403+ backends.append(GuiMixin)
404+ backends.append(UpstartMixin)
405+
406+ # record our choice mapping classes to instances
407+ for i, b in enumerate(backends):
408+ if callable(b):
409+ backends[i] = b()
410+ self.backends = backends
411+
412+ def __getitem__(self, key):
413+ try:
414+ return self.config[key]
415+ except KeyError:
416+ print("Unable to extract config key '%s' from %s" %
417+ (key, self.config))
418+ raise
419+
420+ @overrideable
421+ def find_missing_packages(self, *packages):
422+ return find_missing_packages(*packages)
423+
424+ @overrideable
425+ def service_control(self, service, action):
426+ service_control(service, action)
427+
428+ @overrideable
429+ def start_agent(self, cert_path):
430+ start_agent(cert_path)
431+
432+ @overrideable
433+ def start_improv(self, stage_env, cert_path):
434+ start_improv(stage_env, cert_path)
435+
436+ @overrideable
437+ def log(self, msg, *args):
438+ log(msg, *args)
439+
440+ @overrideable
441+ def install_extra_repositories(self, *packages):
442+ if self.config.get('allow-additional-deb-repositories', True):
443+ install_extra_repositories(*packages)
444+ else:
445+ apt_get('update')
446+
447+ def different(self, *keys):
448+ """Return a boolean indicating if the current config
449+ value differs from the config value passed in prev_config
450+ with respect to any of the passed in string keys.
451+ """
452+ if self.prev_config is None:
453+ return True
454+
455+ for key in keys:
456+ current = self.config.get(key)
457+ prev = self.prev_config.get(key)
458+ r = current != prev
459+ if r:
460+ return True
461+ return False
462+
463+ ## Composed Methods
464+ install = chain('install')
465+ start = chain('start')
466+ stop = chain('stop')
467+
468+ ## Merged Properties
469+ dependencies = merge('dependencies')
470+ build_dependencies = merge('build_dependencies')
471+ staging_dependencies = merge('staging_dependencies')
472+
473+ repositories = merge('repositories')
474+ debs = merge('debs')
475+ upstart_scripts = merge('upstart_scripts')
476
477=== modified file 'tests/test_utils.py'
478--- tests/test_utils.py 2013-04-28 00:59:29 +0000
479+++ tests/test_utils.py 2013-04-30 14:57:26 +0000
480@@ -12,25 +12,27 @@
481 import charmhelpers
482 import yaml
483
484+from backend import InstallMixin
485 from utils import (
486+ API_PORT,
487+ JUJU_GUI_DIR,
488+ JUJU_PEM,
489+ WEB_PORT,
490 _get_by_attr,
491- API_PORT,
492 cmd_log,
493 first_path_in_dir,
494 get_api_address,
495 get_release_file_url,
496 get_zookeeper_address,
497- JUJU_GUI_DIR,
498- JUJU_PEM,
499 legacy_juju,
500 log_hook,
501 parse_source,
502+ get_npm_cache_archive_url,
503 render_to_file,
504 save_or_create_certificates,
505 start_agent,
506 start_gui,
507 start_improv,
508- WEB_PORT,
509 )
510 # Import the whole utils package for monkey patching.
511 import utils
512@@ -603,5 +605,86 @@
513 self.assertNotIn('redirect scheme https', haproxy_conf)
514
515
516+class TestNpmCache(unittest.TestCase):
517+ """To speed building from a branch we prepopulate the NPM cache."""
518+
519+ def test_retrieving_cache_url(self):
520+ # The URL for the latest cache file can be retrieved from Launchpad.
521+ class FauxLaunchpadFactory(object):
522+ @staticmethod
523+ def login_anonymously(agent, site):
524+ # We download the cache from the production site.
525+ self.assertEqual(site, 'production')
526+ return FauxLaunchpad
527+
528+ class CacheFile(object):
529+ file_link = 'http://launchpad.example/path/to/cache/file'
530+
531+ def __str__(self):
532+ return 'cache-file-123.tgz'
533+
534+ class NpmRelease(object):
535+ files = [CacheFile()]
536+
537+ class NpmSeries(object):
538+ name = 'npm-cache'
539+ releases = [NpmRelease]
540+
541+ class FauxProject(object):
542+ series = [NpmSeries]
543+
544+ class FauxLaunchpad(object):
545+ projects = {'juju-gui': FauxProject()}
546+
547+ url = get_npm_cache_archive_url(Launchpad=FauxLaunchpadFactory())
548+ self.assertEqual(url, 'http://launchpad.example/path/to/cache/file')
549+
550+
551+ def test_InstallMixin_primes_npm_cache(self):
552+ # The InstallMixin.install() method primes the NPM cache before
553+ # building (and installing) the GUI from a branch.
554+ assertFalse = self.assertFalse
555+ assertTrue = self.assertTrue
556+
557+ class TestableInstallMixin(InstallMixin):
558+ """An InstallMixin that records actions instead of taking them."""
559+ cache_primed = False
560+ branch_installed = False
561+
562+ def _prime_npm_cache(self):
563+ # The cache is primed before the branch is installed.
564+ assertFalse(self.branch_installed)
565+ self.cache_primed = True
566+
567+ def _build_and_install_from_branch(self):
568+ # The cache is primed before the branch is installed.
569+ assertTrue(self.cache_primed)
570+ self.branch_installed = True
571+
572+ class FauxBackend(object):
573+ """A test backend."""
574+ config = None
575+ debs = ('DEBS',)
576+
577+ @classmethod
578+ def find_missing_packages(cls, *debs):
579+ self.assertEqual(cls.debs, debs)
580+ return False
581+
582+ @classmethod
583+ def different(cls, key):
584+ self.assertEqual(key, 'juju-gui-source')
585+ return True
586+
587+ mixin = TestableInstallMixin()
588+ # Prior to "install" the NPM cache has not been primed.
589+ self.assertFalse(mixin.cache_primed)
590+ self.assertFalse(mixin.branch_installed)
591+ mixin.install(FauxBackend())
592+ # After "install" the NPM cache has been primed.
593+ self.assertTrue(mixin.cache_primed)
594+ self.assertTrue(mixin.branch_installed)
595+
596+
597 if __name__ == '__main__':
598 unittest.main(verbosity=2)
599
600=== modified symlink 'tests/utils.py'
601=== target was u'../hooks/utils.py'
602--- tests/utils.py 1970-01-01 00:00:00 +0000
603+++ tests/utils.py 2013-04-30 14:57:26 +0000
604@@ -0,0 +1,629 @@
605+"""Juju GUI charm utilities."""
606+
607+__all__ = [
608+ 'AGENT',
609+ 'APACHE',
610+ 'API_PORT',
611+ 'CURRENT_DIR',
612+ 'HAPROXY',
613+ 'IMPROV',
614+ 'JUJU_DIR',
615+ 'JUJU_GUI_DIR',
616+ 'JUJU_GUI_SITE',
617+ 'JUJU_PEM',
618+ 'StopChain',
619+ 'WEB_PORT',
620+ 'bzr_checkout',
621+ 'chain',
622+ 'cmd_log',
623+ 'fetch_api',
624+ 'fetch_gui',
625+ 'first_path_in_dir',
626+ 'get_api_address',
627+ 'get_release_file_url',
628+ 'get_staging_dependencies',
629+ 'get_zookeeper_address',
630+ 'legacy_juju',
631+ 'log_hook',
632+ 'merge',
633+ 'overrideable',
634+ 'parse_source',
635+ 'render_to_file',
636+ 'save_or_create_certificates',
637+ 'setup_apache',
638+ 'setup_gui',
639+ 'start_agent',
640+ 'start_gui',
641+ 'start_improv',
642+ 'write_apache_config',
643+]
644+
645+from contextlib import contextmanager
646+import json
647+import os
648+import logging
649+import shutil
650+from subprocess import CalledProcessError
651+import tempfile
652+from urlparse import urlparse
653+
654+from launchpadlib.launchpad import Launchpad
655+from shelltoolbox import (
656+ Serializer,
657+ apt_get_install,
658+ command,
659+ environ,
660+ install_extra_repositories,
661+ run,
662+ script_name,
663+ search_file,
664+ su,
665+)
666+from charmhelpers import (
667+ START,
668+ get_config,
669+ log,
670+ service_control,
671+ unit_get,
672+)
673+
674+import apt
675+import tempita
676+
677+
678+AGENT = 'juju-api-agent'
679+APACHE = 'apache2'
680+IMPROV = 'juju-api-improv'
681+HAPROXY = 'haproxy'
682+
683+API_PORT = 8080
684+WEB_PORT = 8000
685+
686+CURRENT_DIR = os.getcwd()
687+JUJU_DIR = os.path.join(CURRENT_DIR, 'juju')
688+JUJU_GUI_DIR = os.path.join(CURRENT_DIR, 'juju-gui')
689+JUJU_GUI_SITE = '/etc/apache2/sites-available/juju-gui'
690+JUJU_GUI_PORTS = '/etc/apache2/ports.conf'
691+JUJU_PEM = 'juju.includes-private-key.pem'
692+BUILD_REPOSITORIES = ('ppa:chris-lea/node.js-legacy',)
693+DEB_BUILD_DEPENDENCIES = (
694+ 'bzr', 'imagemagick', 'make', 'nodejs', 'npm',
695+)
696+DEB_STAGE_DEPENDENCIES = (
697+ 'zookeeper',
698+)
699+
700+
701+# Store the configuration from on invocation to the next.
702+config_json = Serializer('/tmp/config.json')
703+# Bazaar checkout command.
704+bzr_checkout = command('bzr', 'co', '--lightweight')
705+# Whether or not the charm is deployed using juju-core.
706+# If juju-core has been used to deploy the charm, an agent.conf file must
707+# be present in the charm parent directory.
708+legacy_juju = lambda: not os.path.exists(
709+ os.path.join(CURRENT_DIR, '..', 'agent.conf'))
710+
711+
712+def _get_build_dependencies():
713+ """Install deb dependencies for building."""
714+ log('Installing build dependencies.')
715+ cmd_log(install_extra_repositories(*BUILD_REPOSITORIES))
716+ cmd_log(apt_get_install(*DEB_BUILD_DEPENDENCIES))
717+
718+
719+def get_api_address(unit_dir):
720+ """Return the Juju API address stored in the uniter agent.conf file."""
721+ import yaml # python-yaml is only installed if juju-core is used.
722+ # XXX 2013-03-27 frankban bug=1161443:
723+ # currently the uniter agent.conf file does not include the API
724+ # address. For now retrieve it from the machine agent file.
725+ base_dir = os.path.abspath(os.path.join(unit_dir, '..'))
726+ for dirname in os.listdir(base_dir):
727+ if dirname.startswith('machine-'):
728+ agent_conf = os.path.join(base_dir, dirname, 'agent.conf')
729+ break
730+ else:
731+ raise IOError('Juju agent configuration file not found.')
732+ contents = yaml.load(open(agent_conf))
733+ return contents['apiinfo']['addrs'][0]
734+
735+
736+def get_staging_dependencies():
737+ """Install deb dependencies for the stage (improv) environment."""
738+ log('Installing stage dependencies.')
739+ cmd_log(apt_get_install(*DEB_STAGE_DEPENDENCIES))
740+
741+
742+def first_path_in_dir(directory):
743+ """Return the full path of the first file/dir in *directory*."""
744+ return os.path.join(directory, os.listdir(directory)[0])
745+
746+
747+def _get_by_attr(collection, attr, value):
748+ """Return the first item in collection having attr == value.
749+
750+ Return None if the item is not found.
751+ """
752+ for item in collection:
753+ if getattr(item, attr) == value:
754+ return item
755+
756+
757+def get_release_file_url(project, series_name, release_version):
758+ """Return the URL of the release file hosted in Launchpad.
759+
760+ The returned URL points to a release file for the given project, series
761+ name and release version.
762+ The argument *project* is a project object as returned by launchpadlib.
763+ The arguments *series_name* and *release_version* are strings. If
764+ *release_version* is None, the URL of the latest release will be returned.
765+ """
766+ series = _get_by_attr(project.series, 'name', series_name)
767+ if series is None:
768+ raise ValueError('%r: series not found' % series_name)
769+ # Releases are returned by Launchpad in reverse date order.
770+ releases = list(series.releases)
771+ if not releases:
772+ raise ValueError('%r: series does not contain releases' % series_name)
773+ if release_version is not None:
774+ release = _get_by_attr(releases, 'version', release_version)
775+ if release is None:
776+ raise ValueError('%r: release not found' % release_version)
777+ releases = [release]
778+ for release in releases:
779+ for file_ in release.files:
780+ if str(file_).endswith('.tgz'):
781+ return file_.file_link
782+ raise ValueError('%r: file not found' % release_version)
783+
784+
785+def get_zookeeper_address(agent_file_path):
786+ """Retrieve the Zookeeper address contained in the given *agent_file_path*.
787+
788+ The *agent_file_path* is a path to a file containing a line similar to the
789+ following::
790+
791+ env JUJU_ZOOKEEPER="address"
792+ """
793+ line = search_file('JUJU_ZOOKEEPER', agent_file_path).strip()
794+ return line.split('=')[1].strip('"')
795+
796+
797+@contextmanager
798+def log_hook():
799+ """Log when a hook starts and stops its execution.
800+
801+ Also log to stdout possible CalledProcessError exceptions raised executing
802+ the hook.
803+ """
804+ script = script_name()
805+ log(">>> Entering {}".format(script))
806+ try:
807+ yield
808+ except CalledProcessError as err:
809+ log('Exception caught:')
810+ log(err.output)
811+ raise
812+ finally:
813+ log("<<< Exiting {}".format(script))
814+
815+
816+def parse_source(source):
817+ """Parse the ``juju-gui-source`` option.
818+
819+ Return a tuple of two elements representing info on how to deploy Juju GUI.
820+ Examples:
821+ - ('stable', None): latest stable release;
822+ - ('stable', '0.1.0'): stable release v0.1.0;
823+ - ('trunk', None): latest trunk release;
824+ - ('trunk', '0.1.0+build.1'): trunk release v0.1.0 bzr revision 1;
825+ - ('branch', 'lp:juju-gui'): release is made from a branch;
826+ - ('url', 'http://example.com/gui'): release from a downloaded file.
827+ """
828+ if source.startswith('url:'):
829+ source = source[4:]
830+ # Support file paths, including relative paths.
831+ if urlparse(source).scheme == '':
832+ if not source.startswith('/'):
833+ source = os.path.join(os.path.abspath(CURRENT_DIR), source)
834+ source = "file://%s" % source
835+ return 'url', source
836+ if source in ('stable', 'trunk'):
837+ return source, None
838+ if source.startswith('lp:') or source.startswith('http://'):
839+ return 'branch', source
840+ if 'build' in source:
841+ return 'trunk', source
842+ return 'stable', source
843+
844+
845+def render_to_file(template_name, context, destination):
846+ """Render the given *template_name* into *destination* using *context*.
847+
848+ The tempita template language is used to render contents
849+ (see http://pythonpaste.org/tempita/).
850+ The argument *template_name* is the name or path of the template file:
851+ it may be either a path relative to ``../config`` or an absolute path.
852+ The argument *destination* is a file path.
853+ The argument *context* is a dict-like object.
854+ """
855+ template_path = os.path.join(
856+ os.path.dirname(__file__), '..', 'config', template_name)
857+ template = tempita.Template.from_filename(template_path)
858+ with open(destination, 'w') as stream:
859+ stream.write(template.substitute(context))
860+
861+
862+results_log = None
863+
864+
865+def _setupLogging():
866+ global results_log
867+ if results_log is not None:
868+ return
869+ config = get_config()
870+ logging.basicConfig(
871+ filename=config['command-log-file'],
872+ level=logging.INFO,
873+ format="%(asctime)s: %(name)s@%(levelname)s %(message)s")
874+ results_log = logging.getLogger('juju-gui')
875+
876+
877+def cmd_log(results):
878+ global results_log
879+ if not results:
880+ return
881+ if results_log is None:
882+ _setupLogging()
883+ # Since 'results' may be multi-line output, start it on a separate line
884+ # from the logger timestamp, etc.
885+ results_log.info('\n' + results)
886+
887+
888+def start_improv(staging_env, ssl_cert_path,
889+ config_path='/etc/init/juju-api-improv.conf'):
890+ """Start a simulated juju environment using ``improv.py``."""
891+ log('Setting up staging start up script.')
892+ context = {
893+ 'juju_dir': JUJU_DIR,
894+ 'keys': ssl_cert_path,
895+ 'port': API_PORT,
896+ 'staging_env': staging_env,
897+ }
898+ render_to_file('juju-api-improv.conf.template', context, config_path)
899+ log('Starting the staging backend.')
900+ with su('root'):
901+ service_control(IMPROV, START)
902+
903+
904+def start_agent(ssl_cert_path, config_path='/etc/init/juju-api-agent.conf'):
905+ """Start the Juju agent and connect to the current environment."""
906+ # Retrieve the Zookeeper address from the start up script.
907+ unit_dir = os.path.realpath(os.path.join(CURRENT_DIR, '..'))
908+ agent_file = '/etc/init/juju-{0}.conf'.format(os.path.basename(unit_dir))
909+ zookeeper = get_zookeeper_address(agent_file)
910+ log('Setting up API agent start up script.')
911+ context = {
912+ 'juju_dir': JUJU_DIR,
913+ 'keys': ssl_cert_path,
914+ 'port': API_PORT,
915+ 'zookeeper': zookeeper,
916+ }
917+ render_to_file('juju-api-agent.conf.template', context, config_path)
918+ log('Starting API agent.')
919+ with su('root'):
920+ service_control(AGENT, START)
921+
922+
923+def start_gui(
924+ console_enabled, login_help, readonly, in_staging, ssl_cert_path,
925+ charmworld_url, serve_tests, haproxy_path='/etc/haproxy/haproxy.cfg',
926+ config_js_path=None, secure=True, sandbox=False):
927+ """Set up and start the Juju GUI server."""
928+ with su('root'):
929+ run('chown', '-R', 'ubuntu:', JUJU_GUI_DIR)
930+ # XXX 2013-02-05 frankban bug=1116320:
931+ # External insecure resources are still loaded when testing in the
932+ # debug environment. For now, switch to the production environment if
933+ # the charm is configured to serve tests.
934+ if in_staging and not serve_tests:
935+ build_dirname = 'build-debug'
936+ else:
937+ build_dirname = 'build-prod'
938+ build_dir = os.path.join(JUJU_GUI_DIR, build_dirname)
939+ log('Generating the Juju GUI configuration file.')
940+ is_legacy_juju = legacy_juju()
941+ user, password = None, None
942+ if is_legacy_juju and in_staging:
943+ user, password = 'admin', 'admin'
944+ else:
945+ user, password = None, None
946+
947+ api_backend = 'python' if is_legacy_juju else 'go'
948+ if secure:
949+ protocol = 'wss'
950+ else:
951+ log('Running in insecure mode! Port 80 will serve unencrypted.')
952+ protocol = 'ws'
953+
954+ context = {
955+ 'raw_protocol': protocol,
956+ 'address': unit_get('public-address'),
957+ 'console_enabled': json.dumps(console_enabled),
958+ 'login_help': json.dumps(login_help),
959+ 'password': json.dumps(password),
960+ 'api_backend': json.dumps(api_backend),
961+ 'readonly': json.dumps(readonly),
962+ 'user': json.dumps(user),
963+ 'protocol': json.dumps(protocol),
964+ 'sandbox': json.dumps(sandbox),
965+ 'charmworld_url': json.dumps(charmworld_url),
966+ }
967+ if config_js_path is None:
968+ config_js_path = os.path.join(
969+ build_dir, 'juju-ui', 'assets', 'config.js')
970+ render_to_file('config.js.template', context, config_js_path)
971+
972+ write_apache_config(build_dir, serve_tests)
973+
974+ log('Generating haproxy configuration file.')
975+ if is_legacy_juju:
976+ # The PyJuju API agent is listening on localhost.
977+ api_address = '127.0.0.1:{0}'.format(API_PORT)
978+ else:
979+ # Retrieve the juju-core API server address.
980+ api_address = get_api_address(os.path.join(CURRENT_DIR, '..'))
981+ context = {
982+ 'api_address': api_address,
983+ 'api_pem': JUJU_PEM,
984+ 'legacy_juju': is_legacy_juju,
985+ 'ssl_cert_path': ssl_cert_path,
986+ # In PyJuju environments, use the same certificate for both HTTPS and
987+ # WebSocket connections. In juju-core the system already has the proper
988+ # certificate installed.
989+ 'web_pem': JUJU_PEM,
990+ 'web_port': WEB_PORT,
991+ 'secure': secure
992+ }
993+ render_to_file('haproxy.cfg.template', context, haproxy_path)
994+ log('Starting Juju GUI.')
995+
996+def write_apache_config(build_dir, serve_tests=False):
997+ log('Generating the apache site configuration file.')
998+ context = {
999+ 'port': WEB_PORT,
1000+ 'serve_tests': serve_tests,
1001+ 'server_root': build_dir,
1002+ 'tests_root': os.path.join(JUJU_GUI_DIR, 'test', ''),
1003+ }
1004+ render_to_file('apache-ports.template', context, JUJU_GUI_PORTS)
1005+ render_to_file('apache-site.template', context, JUJU_GUI_SITE)
1006+
1007+
1008+def get_npm_cache_archive_url(Launchpad=Launchpad):
1009+ """Figure out the URL of the most recent NPM cache archive on Launchpad."""
1010+ launchpad = Launchpad.login_anonymously('Juju GUI charm', 'production')
1011+ project = launchpad.projects['juju-gui']
1012+ # Find the URL of the most recently created NPM cache archive.
1013+ npm_cache_url = get_release_file_url(project, 'npm-cache', None)
1014+ return npm_cache_url
1015+
1016+
1017+def prime_npm_cache(npm_cache_url):
1018+ """Download NPM cache archive and prime the NPM cache with it."""
1019+ # Download the cache archive and then uncompress it into the NPM cache.
1020+ npm_cache_archive = os.path.join(CURRENT_DIR, 'npm-cache.tgz')
1021+ cmd_log(run('curl', '-L', '-o', npm_cache_archive, npm_cache_url))
1022+ npm_cache_dir = os.path.expanduser('~/.npm')
1023+ # The NPM cache directory probably does not exist, so make it if not.
1024+ try:
1025+ os.mkdir(npm_cache_dir)
1026+ except OSError, e:
1027+ # If the directory already exists then ignore the error.
1028+ if e.errno != 17: # File exists.
1029+ raise
1030+ uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
1031+ cmd_log(uncompress(npm_cache_archive))
1032+
1033+
1034+def fetch_gui(juju_gui_source, logpath):
1035+ """Retrieve the Juju GUI release/branch."""
1036+ # Retrieve a Juju GUI release.
1037+ origin, version_or_branch = parse_source(juju_gui_source)
1038+ if origin == 'branch':
1039+ # Make sure we have the dependencies necessary for us to actually make
1040+ # a build.
1041+ _get_build_dependencies()
1042+ # Create a release starting from a branch.
1043+ juju_gui_source_dir = os.path.join(CURRENT_DIR, 'juju-gui-source')
1044+ log('Retrieving Juju GUI source checkout from %s.' % version_or_branch)
1045+ cmd_log(run('rm', '-rf', juju_gui_source_dir))
1046+ cmd_log(bzr_checkout(version_or_branch, juju_gui_source_dir))
1047+ log('Preparing a Juju GUI release.')
1048+ logdir = os.path.dirname(logpath)
1049+ fd, name = tempfile.mkstemp(prefix='make-distfile-', dir=logdir)
1050+ log('Output from "make distfile" sent to %s' % name)
1051+ with environ(NO_BZR='1'):
1052+ run('make', '-C', juju_gui_source_dir, 'distfile',
1053+ stdout=fd, stderr=fd)
1054+ release_tarball = first_path_in_dir(
1055+ os.path.join(juju_gui_source_dir, 'releases'))
1056+ else:
1057+ log('Retrieving Juju GUI release.')
1058+ if origin == 'url':
1059+ file_url = version_or_branch
1060+ else:
1061+ # Retrieve a release from Launchpad.
1062+ launchpad = Launchpad.login_anonymously(
1063+ 'Juju GUI charm', 'production')
1064+ project = launchpad.projects['juju-gui']
1065+ file_url = get_release_file_url(project, origin, version_or_branch)
1066+ log('Downloading release file from %s.' % file_url)
1067+ release_tarball = os.path.join(CURRENT_DIR, 'release.tgz')
1068+ cmd_log(run('curl', '-L', '-o', release_tarball, file_url))
1069+ return release_tarball
1070+
1071+
1072+def fetch_api(juju_api_branch):
1073+ """Retrieve the Juju branch."""
1074+ # Retrieve Juju API source checkout.
1075+ log('Retrieving Juju API source checkout.')
1076+ cmd_log(run('rm', '-rf', JUJU_DIR))
1077+ cmd_log(bzr_checkout(juju_api_branch, JUJU_DIR))
1078+
1079+
1080+def setup_gui(release_tarball):
1081+ """Set up Juju GUI."""
1082+ # Uncompress the release tarball.
1083+ log('Installing Juju GUI.')
1084+ release_dir = os.path.join(CURRENT_DIR, 'release')
1085+ cmd_log(run('rm', '-rf', release_dir))
1086+ os.mkdir(release_dir)
1087+ uncompress = command('tar', '-x', '-z', '-C', release_dir, '-f')
1088+ cmd_log(uncompress(release_tarball))
1089+ # Link the Juju GUI dir to the contents of the release tarball.
1090+ cmd_log(run('ln', '-sf', first_path_in_dir(release_dir), JUJU_GUI_DIR))
1091+
1092+
1093+def setup_apache():
1094+ """Set up apache."""
1095+ log('Setting up apache.')
1096+ if not os.path.exists(JUJU_GUI_SITE):
1097+ cmd_log(run('touch', JUJU_GUI_SITE))
1098+ cmd_log(run('chown', 'ubuntu:', JUJU_GUI_SITE))
1099+ cmd_log(
1100+ run('ln', '-s', JUJU_GUI_SITE,
1101+ '/etc/apache2/sites-enabled/juju-gui'))
1102+
1103+ if not os.path.exists(JUJU_GUI_PORTS):
1104+ cmd_log(run('touch', JUJU_GUI_PORTS))
1105+ cmd_log(run('chown', 'ubuntu:', JUJU_GUI_PORTS))
1106+
1107+ with su('root'):
1108+ run('a2dissite', 'default')
1109+ run('a2ensite', 'juju-gui')
1110+
1111+
1112+def save_or_create_certificates(
1113+ ssl_cert_path, ssl_cert_contents, ssl_key_contents):
1114+ """Generate the SSL certificates.
1115+
1116+ If both *ssl_cert_contents* and *ssl_key_contents* are provided, use them
1117+ as certificates; otherwise, generate them.
1118+
1119+ Also create a pem file, suitable for use in the haproxy configuration,
1120+ concatenating the key and the certificate files.
1121+ """
1122+ crt_path = os.path.join(ssl_cert_path, 'juju.crt')
1123+ key_path = os.path.join(ssl_cert_path, 'juju.key')
1124+ if not os.path.exists(ssl_cert_path):
1125+ os.makedirs(ssl_cert_path)
1126+ if ssl_cert_contents and ssl_key_contents:
1127+ # Save the provided certificates.
1128+ with open(crt_path, 'w') as cert_file:
1129+ cert_file.write(ssl_cert_contents)
1130+ with open(key_path, 'w') as key_file:
1131+ key_file.write(ssl_key_contents)
1132+ else:
1133+ # Generate certificates.
1134+ # See http://superuser.com/questions/226192/openssl-without-prompt
1135+ cmd_log(run(
1136+ 'openssl', 'req', '-new', '-newkey', 'rsa:4096',
1137+ '-days', '365', '-nodes', '-x509', '-subj',
1138+ # These are arbitrary test values for the certificate.
1139+ '/C=GB/ST=Juju/L=GUI/O=Ubuntu/CN=juju.ubuntu.com',
1140+ '-keyout', key_path, '-out', crt_path))
1141+ # Generate the pem file.
1142+ pem_path = os.path.join(ssl_cert_path, JUJU_PEM)
1143+ if os.path.exists(pem_path):
1144+ os.remove(pem_path)
1145+ with open(pem_path, 'w') as pem_file:
1146+ shutil.copyfileobj(open(key_path), pem_file)
1147+ shutil.copyfileobj(open(crt_path), pem_file)
1148+
1149+
1150+def find_missing_packages(*packages):
1151+ """Given a list of packages, return the packages which are not installed.
1152+ """
1153+ cache = apt.Cache()
1154+ missing = set()
1155+ for pkg_name in packages:
1156+ try:
1157+ pkg = cache[pkg_name]
1158+ except KeyError:
1159+ missing.add(pkg_name)
1160+ continue
1161+ if pkg.is_installed:
1162+ continue
1163+ missing.add(pkg_name)
1164+ return missing
1165+
1166+
1167+## Backend support decorators
1168+class StopChain(Exception):
1169+ """Stop Processing a chain command without raising
1170+ another error.
1171+ """
1172+
1173+
1174+def chain(name, reverse=False):
1175+ """Helper method to compose a set of strategy objects into
1176+ a callable.
1177+
1178+ Each method is called in the context of its strategy
1179+ instance (normal OOP) and its argument is the Backend
1180+ instance.
1181+ """
1182+ # chain method calls through all implementing mixins
1183+ def method(self):
1184+ workingset = self.backends
1185+ if reverse:
1186+ workingset = reversed(workingset)
1187+ for backend in workingset:
1188+ call = backend.__class__.__dict__.get(name)
1189+ if call:
1190+ try:
1191+ call(backend, self)
1192+ except StopChain:
1193+ break
1194+
1195+ method.__name__ = name
1196+ return method
1197+
1198+
1199+def overrideable(f):
1200+ """Helper to support very limited overrides for use in testing.
1201+
1202+ def foo():
1203+ return True
1204+ b = Backend(foo=foo)
1205+ assert b.foo() is True
1206+ """
1207+ name = f.__name__
1208+
1209+ def overridden(self, *args, **kwargs):
1210+ if name in self.overrides:
1211+ return self.overrides[name](*args, **kwargs)
1212+ else:
1213+ return f(self, *args, **kwargs)
1214+
1215+ overridden.__name__ = name
1216+ return overridden
1217+
1218+
1219+def merge(name):
1220+ """Helper to merge a property from a set of strategy objects
1221+ into a unified set.
1222+ """
1223+ # return merged property from every providing backend as a set
1224+ @property
1225+ def method(self):
1226+ result = set()
1227+ for backend in self.backends:
1228+ segment = backend.__class__.__dict__.get(name)
1229+ if segment and isinstance(segment, (list, tuple, set)):
1230+ result |= set(segment)
1231+
1232+ return result
1233+ return method

Subscribers

People subscribed via source and target branches

to all changes: