1
"""Juju GUI charm utilities."""
20
'find_missing_packages',
23
'get_npm_cache_archive_url',
24
'get_release_file_url',
25
'get_staging_dependencies',
26
'get_zookeeper_address',
33
'save_or_create_certificates',
39
'write_apache_config',
42
from contextlib import contextmanager
48
from subprocess import CalledProcessError
50
from urlparse import urlparse
55
from launchpadlib.launchpad import Launchpad
56
from shelltoolbox import (
61
install_extra_repositories,
67
from charmhelpers.core.host import (
70
from charmhelpers.core.hookenv import (
77
AGENT = 'juju-api-agent'
79
IMPROV = 'juju-api-improv'
85
CURRENT_DIR = os.getcwd()
86
JUJU_DIR = os.path.join(CURRENT_DIR, 'juju')
87
JUJU_GUI_DIR = os.path.join(CURRENT_DIR, 'juju-gui')
88
JUJU_GUI_SITE = '/etc/apache2/sites-available/juju-gui'
89
JUJU_GUI_PORTS = '/etc/apache2/ports.conf'
90
JUJU_PEM = 'juju.includes-private-key.pem'
91
BUILD_REPOSITORIES = ('ppa:chris-lea/node.js-legacy',)
92
DEB_BUILD_DEPENDENCIES = (
93
'bzr', 'imagemagick', 'make', 'nodejs', 'npm',
95
DEB_STAGE_DEPENDENCIES = (
100
# Store the configuration from on invocation to the next.
101
config_json = Serializer('/tmp/config.json')
102
# Bazaar checkout command.
103
bzr_checkout = command('bzr', 'co', '--lightweight')
104
# Whether or not the charm is deployed using juju-core.
105
# If juju-core has been used to deploy the charm, an agent.conf file must
106
# be present in the charm parent directory.
107
legacy_juju = lambda: not os.path.exists(
108
os.path.join(CURRENT_DIR, '..', 'agent.conf'))
111
def _get_build_dependencies():
112
"""Install deb dependencies for building."""
113
log('Installing build dependencies.')
114
cmd_log(install_extra_repositories(*BUILD_REPOSITORIES))
115
cmd_log(apt_get_install(*DEB_BUILD_DEPENDENCIES))
118
def get_api_address(unit_dir):
119
"""Return the Juju API address stored in the uniter agent.conf file."""
120
import yaml # python-yaml is only installed if juju-core is used.
121
# XXX 2013-03-27 frankban bug=1161443:
122
# currently the uniter agent.conf file does not include the API
123
# address. For now retrieve it from the machine agent file.
124
base_dir = os.path.abspath(os.path.join(unit_dir, '..'))
125
for dirname in os.listdir(base_dir):
126
if dirname.startswith('machine-'):
127
agent_conf = os.path.join(base_dir, dirname, 'agent.conf')
130
raise IOError('Juju agent configuration file not found.')
131
contents = yaml.load(open(agent_conf))
132
return contents['apiinfo']['addrs'][0]
135
def get_staging_dependencies():
136
"""Install deb dependencies for the stage (improv) environment."""
137
log('Installing stage dependencies.')
138
cmd_log(apt_get_install(*DEB_STAGE_DEPENDENCIES))
141
def first_path_in_dir(directory):
142
"""Return the full path of the first file/dir in *directory*."""
143
return os.path.join(directory, os.listdir(directory)[0])
146
def _get_by_attr(collection, attr, value):
147
"""Return the first item in collection having attr == value.
149
Return None if the item is not found.
151
for item in collection:
152
if getattr(item, attr) == value:
156
def get_release_file_url(project, series_name, release_version):
157
"""Return the URL of the release file hosted in Launchpad.
159
The returned URL points to a release file for the given project, series
160
name and release version.
161
The argument *project* is a project object as returned by launchpadlib.
162
The arguments *series_name* and *release_version* are strings. If
163
*release_version* is None, the URL of the latest release will be returned.
165
series = _get_by_attr(project.series, 'name', series_name)
167
raise ValueError('%r: series not found' % series_name)
168
# Releases are returned by Launchpad in reverse date order.
169
releases = list(series.releases)
171
raise ValueError('%r: series does not contain releases' % series_name)
172
if release_version is not None:
173
release = _get_by_attr(releases, 'version', release_version)
175
raise ValueError('%r: release not found' % release_version)
177
for release in releases:
178
for file_ in release.files:
179
if str(file_).endswith('.tgz'):
180
return file_.file_link
181
raise ValueError('%r: file not found' % release_version)
184
def get_zookeeper_address(agent_file_path):
185
"""Retrieve the Zookeeper address contained in the given *agent_file_path*.
187
The *agent_file_path* is a path to a file containing a line similar to the
190
env JUJU_ZOOKEEPER="address"
192
line = search_file('JUJU_ZOOKEEPER', agent_file_path).strip()
193
return line.split('=')[1].strip('"')
198
"""Log when a hook starts and stops its execution.
200
Also log to stdout possible CalledProcessError exceptions raised executing
203
script = script_name()
204
log(">>> Entering {}".format(script))
207
except CalledProcessError as err:
208
log('Exception caught:')
212
log("<<< Exiting {}".format(script))
215
def parse_source(source):
216
"""Parse the ``juju-gui-source`` option.
218
Return a tuple of two elements representing info on how to deploy Juju GUI.
220
- ('stable', None): latest stable release;
221
- ('stable', '0.1.0'): stable release v0.1.0;
222
- ('trunk', None): latest trunk release;
223
- ('trunk', '0.1.0+build.1'): trunk release v0.1.0 bzr revision 1;
224
- ('branch', 'lp:juju-gui'): release is made from a branch;
225
- ('url', 'http://example.com/gui'): release from a downloaded file.
227
if source.startswith('url:'):
229
# Support file paths, including relative paths.
230
if urlparse(source).scheme == '':
231
if not source.startswith('/'):
232
source = os.path.join(os.path.abspath(CURRENT_DIR), source)
233
source = "file://%s" % source
235
if source in ('stable', 'trunk'):
237
if source.startswith('lp:') or source.startswith('http://'):
238
return 'branch', source
239
if 'build' in source:
240
return 'trunk', source
241
return 'stable', source
244
def render_to_file(template_name, context, destination):
245
"""Render the given *template_name* into *destination* using *context*.
247
The tempita template language is used to render contents
248
(see http://pythonpaste.org/tempita/).
249
The argument *template_name* is the name or path of the template file:
250
it may be either a path relative to ``../config`` or an absolute path.
251
The argument *destination* is a file path.
252
The argument *context* is a dict-like object.
254
template_path = os.path.abspath(template_name)
255
template = tempita.Template.from_filename(template_path)
256
with open(destination, 'w') as stream:
257
stream.write(template.substitute(context))
265
if results_log is not None:
269
filename=cfg['command-log-file'],
271
format="%(asctime)s: %(name)s@%(levelname)s %(message)s")
272
results_log = logging.getLogger('juju-gui')
275
def cmd_log(results):
279
if results_log is None:
281
# Since 'results' may be multi-line output, start it on a separate line
282
# from the logger timestamp, etc.
283
results_log.info('\n' + results)
286
def start_improv(staging_env, ssl_cert_path,
287
config_path='/etc/init/juju-api-improv.conf'):
288
"""Start a simulated juju environment using ``improv.py``."""
289
log('Setting up staging start up script.')
291
'juju_dir': JUJU_DIR,
292
'keys': ssl_cert_path,
294
'staging_env': staging_env,
296
render_to_file('config/juju-api-improv.conf.template', context, config_path)
297
log('Starting the staging backend.')
299
service_start(IMPROV)
303
ssl_cert_path, config_path='/etc/init/juju-api-agent.conf',
305
"""Start the Juju agent and connect to the current environment."""
306
# Retrieve the Zookeeper address from the start up script.
307
unit_dir = os.path.realpath(os.path.join(CURRENT_DIR, '..'))
308
agent_file = '/etc/init/juju-{0}.conf'.format(os.path.basename(unit_dir))
309
zookeeper = get_zookeeper_address(agent_file)
310
log('Setting up API agent start up script.')
312
'juju_dir': JUJU_DIR,
313
'keys': ssl_cert_path,
315
'zookeeper': zookeeper,
316
'read_only': read_only
318
render_to_file('config/juju-api-agent.conf.template', context, config_path)
319
log('Starting API agent.')
325
console_enabled, login_help, readonly, in_staging, ssl_cert_path,
326
charmworld_url, serve_tests, haproxy_path='/etc/haproxy/haproxy.cfg',
327
config_js_path=None, secure=True, sandbox=False):
328
"""Set up and start the Juju GUI server."""
330
run('chown', '-R', 'ubuntu:', JUJU_GUI_DIR)
331
# XXX 2013-02-05 frankban bug=1116320:
332
# External insecure resources are still loaded when testing in the
333
# debug environment. For now, switch to the production environment if
334
# the charm is configured to serve tests.
335
if in_staging and not serve_tests:
336
build_dirname = 'build-debug'
338
build_dirname = 'build-prod'
339
build_dir = os.path.join(JUJU_GUI_DIR, build_dirname)
340
log('Generating the Juju GUI configuration file.')
341
is_legacy_juju = legacy_juju()
342
user, password = None, None
343
if (is_legacy_juju and in_staging) or sandbox:
344
user, password = 'admin', 'admin'
346
user, password = None, None
348
api_backend = 'python' if is_legacy_juju else 'go'
352
log('Running in insecure mode! Port 80 will serve unencrypted.')
356
'raw_protocol': protocol,
357
'address': unit_get('public-address'),
358
'console_enabled': json.dumps(console_enabled),
359
'login_help': json.dumps(login_help),
360
'password': json.dumps(password),
361
'api_backend': json.dumps(api_backend),
362
'readonly': json.dumps(readonly),
363
'user': json.dumps(user),
364
'protocol': json.dumps(protocol),
365
'sandbox': json.dumps(sandbox),
366
'charmworld_url': json.dumps(charmworld_url),
368
if config_js_path is None:
369
config_js_path = os.path.join(
370
build_dir, 'juju-ui', 'assets', 'config.js')
371
render_to_file('config/config.js.template', context, config_js_path)
373
write_apache_config(build_dir, serve_tests)
375
log('Generating haproxy configuration file.')
377
# The PyJuju API agent is listening on localhost.
378
api_address = '127.0.0.1:{0}'.format(API_PORT)
380
# Retrieve the juju-core API server address.
381
api_address = get_api_address(os.path.join(CURRENT_DIR, '..'))
383
'api_address': api_address,
385
'legacy_juju': is_legacy_juju,
386
'ssl_cert_path': ssl_cert_path,
387
# In PyJuju environments, use the same certificate for both HTTPS and
388
# WebSocket connections. In juju-core the system already has the proper
389
# certificate installed.
391
'web_port': WEB_PORT,
394
render_to_file('config/haproxy.cfg.template', context, haproxy_path)
395
log('Starting Juju GUI.')
398
def write_apache_config(build_dir, serve_tests=False):
399
log('Generating the apache site configuration file.')
402
'serve_tests': serve_tests,
403
'server_root': build_dir,
404
'tests_root': os.path.join(JUJU_GUI_DIR, 'test', ''),
406
render_to_file('config/apache-ports.template', context, JUJU_GUI_PORTS)
407
render_to_file('config/apache-site.template', context, JUJU_GUI_SITE)
410
def get_npm_cache_archive_url(Launchpad=Launchpad):
411
"""Figure out the URL of the most recent NPM cache archive on Launchpad."""
412
launchpad = Launchpad.login_anonymously('Juju GUI charm', 'production')
413
project = launchpad.projects['juju-gui']
414
# Find the URL of the most recently created NPM cache archive.
415
npm_cache_url = get_release_file_url(project, 'npm-cache', None)
419
def prime_npm_cache(npm_cache_url):
420
"""Download NPM cache archive and prime the NPM cache with it."""
421
# Download the cache archive and then uncompress it into the NPM cache.
422
npm_cache_archive = os.path.join(CURRENT_DIR, 'npm-cache.tgz')
423
cmd_log(run('curl', '-L', '-o', npm_cache_archive, npm_cache_url))
424
npm_cache_dir = os.path.expanduser('~/.npm')
425
# The NPM cache directory probably does not exist, so make it if not.
427
os.mkdir(npm_cache_dir)
429
# If the directory already exists then ignore the error.
430
if e.errno != errno.EEXIST: # File exists.
432
uncompress = command('tar', '-x', '-z', '-C', npm_cache_dir, '-f')
433
cmd_log(uncompress(npm_cache_archive))
436
def fetch_gui(juju_gui_source, logpath):
437
"""Retrieve the Juju GUI release/branch."""
438
# Retrieve a Juju GUI release.
439
origin, version_or_branch = parse_source(juju_gui_source)
440
if origin == 'branch':
441
# Make sure we have the dependencies necessary for us to actually make
443
_get_build_dependencies()
444
# Create a release starting from a branch.
445
juju_gui_source_dir = os.path.join(CURRENT_DIR, 'juju-gui-source')
446
log('Retrieving Juju GUI source checkout from %s.' % version_or_branch)
447
cmd_log(run('rm', '-rf', juju_gui_source_dir))
448
cmd_log(bzr_checkout(version_or_branch, juju_gui_source_dir))
449
log('Preparing a Juju GUI release.')
450
logdir = os.path.dirname(logpath)
451
fd, name = tempfile.mkstemp(prefix='make-distfile-', dir=logdir)
452
log('Output from "make distfile" sent to %s' % name)
453
with environ(NO_BZR='1'):
454
run('make', '-C', juju_gui_source_dir, 'distfile',
455
stdout=fd, stderr=fd)
456
release_tarball = first_path_in_dir(
457
os.path.join(juju_gui_source_dir, 'releases'))
459
log('Retrieving Juju GUI release.')
461
file_url = version_or_branch
463
# Retrieve a release from Launchpad.
464
launchpad = Launchpad.login_anonymously(
465
'Juju GUI charm', 'production')
466
project = launchpad.projects['juju-gui']
467
file_url = get_release_file_url(project, origin, version_or_branch)
468
log('Downloading release file from %s.' % file_url)
469
release_tarball = os.path.join(CURRENT_DIR, 'release.tgz')
470
cmd_log(run('curl', '-L', '-o', release_tarball, file_url))
471
return release_tarball
474
def fetch_api(juju_api_branch):
475
"""Retrieve the Juju branch."""
476
# Retrieve Juju API source checkout.
477
log('Retrieving Juju API source checkout.')
478
cmd_log(run('rm', '-rf', JUJU_DIR))
479
cmd_log(bzr_checkout(juju_api_branch, JUJU_DIR))
482
def setup_gui(release_tarball):
483
"""Set up Juju GUI."""
484
# Uncompress the release tarball.
485
log('Installing Juju GUI.')
486
release_dir = os.path.join(CURRENT_DIR, 'release')
487
cmd_log(run('rm', '-rf', release_dir))
488
os.mkdir(release_dir)
489
uncompress = command('tar', '-x', '-z', '-C', release_dir, '-f')
490
cmd_log(uncompress(release_tarball))
491
# Link the Juju GUI dir to the contents of the release tarball.
492
cmd_log(run('ln', '-sf', first_path_in_dir(release_dir), JUJU_GUI_DIR))
497
log('Setting up apache.')
498
if not os.path.exists(JUJU_GUI_SITE):
499
cmd_log(run('touch', JUJU_GUI_SITE))
500
cmd_log(run('chown', 'ubuntu:', JUJU_GUI_SITE))
502
run('ln', '-s', JUJU_GUI_SITE,
503
'/etc/apache2/sites-enabled/juju-gui'))
505
if not os.path.exists(JUJU_GUI_PORTS):
506
cmd_log(run('touch', JUJU_GUI_PORTS))
507
cmd_log(run('chown', 'ubuntu:', JUJU_GUI_PORTS))
510
run('a2dissite', 'default')
511
run('a2ensite', 'juju-gui')
514
def save_or_create_certificates(
515
ssl_cert_path, ssl_cert_contents, ssl_key_contents):
516
"""Generate the SSL certificates.
518
If both *ssl_cert_contents* and *ssl_key_contents* are provided, use them
519
as certificates; otherwise, generate them.
521
Also create a pem file, suitable for use in the haproxy configuration,
522
concatenating the key and the certificate files.
524
crt_path = os.path.join(ssl_cert_path, 'juju.crt')
525
key_path = os.path.join(ssl_cert_path, 'juju.key')
526
if not os.path.exists(ssl_cert_path):
527
os.makedirs(ssl_cert_path)
528
if ssl_cert_contents and ssl_key_contents:
529
# Save the provided certificates.
530
with open(crt_path, 'w') as cert_file:
531
cert_file.write(ssl_cert_contents)
532
with open(key_path, 'w') as key_file:
533
key_file.write(ssl_key_contents)
535
# Generate certificates.
536
# See http://superuser.com/questions/226192/openssl-without-prompt
538
'openssl', 'req', '-new', '-newkey', 'rsa:4096',
539
'-days', '365', '-nodes', '-x509', '-subj',
540
# These are arbitrary test values for the certificate.
541
'/C=GB/ST=Juju/L=GUI/O=Ubuntu/CN=juju.ubuntu.com',
542
'-keyout', key_path, '-out', crt_path))
543
# Generate the pem file.
544
pem_path = os.path.join(ssl_cert_path, JUJU_PEM)
545
if os.path.exists(pem_path):
547
with open(pem_path, 'w') as pem_file:
548
shutil.copyfileobj(open(key_path), pem_file)
549
shutil.copyfileobj(open(crt_path), pem_file)
552
def find_missing_packages(*packages):
553
"""Given a list of packages, return the packages which are not installed.
557
for pkg_name in packages:
559
pkg = cache[pkg_name]
561
missing.add(pkg_name)
565
missing.add(pkg_name)
569
## Backend support decorators
572
"""Helper method to compose a set of mixin objects into a callable.
574
Each method is called in the context of its mixin instance, and its
575
argument is the Backend instance.
577
# Chain method calls through all implementing mixins.
579
for mixin in self.mixins:
580
a_callable = getattr(type(mixin), name, None)
582
a_callable(mixin, self)
584
method.__name__ = name
589
"""Helper to merge a property from a set of strategy objects
592
# Return merged property from every providing mixin as a set.
596
for mixin in self.mixins:
597
segment = getattr(type(mixin), name, None)
598
if segment and isinstance(segment, (list, tuple, set)):
599
result |= set(segment)