Merge lp:~frankban/juju-quickstart/jujucharms-bundles into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 122
Proposed branch: lp:~frankban/juju-quickstart/jujucharms-bundles
Merge into: lp:juju-quickstart
Diff against target: 3303 lines (+1519/-940)
19 files modified
HACKING.rst (+2/-2)
quickstart/__init__.py (+1/-1)
quickstart/app.py (+19/-10)
quickstart/juju.py (+3/-3)
quickstart/jujutools.py (+22/-21)
quickstart/manage.py (+63/-90)
quickstart/models/bundles.py (+300/-88)
quickstart/models/references.py (+191/-70)
quickstart/netutils.py (+2/-2)
quickstart/settings.py (+4/-7)
quickstart/tests/functional/test_functional.py (+1/-1)
quickstart/tests/helpers.py (+8/-21)
quickstart/tests/models/test_bundles.py (+384/-196)
quickstart/tests/models/test_references.py (+386/-184)
quickstart/tests/test_app.py (+50/-32)
quickstart/tests/test_juju.py (+14/-11)
quickstart/tests/test_jujutools.py (+38/-28)
quickstart/tests/test_manage.py (+30/-172)
tox.ini (+1/-1)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/jujucharms-bundles
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+251156@code.launchpad.net

Description of the change

Support retrieving bundles from charm store v4.

This branch implements the ability to deploy
bundles from the new charm store, retrieving
them with the v4 API.

Also introduce the new preferred bundle id
spelling, i.e. reflecting jujucharms.com paths,
like "mediawiki-single" or "u/who/bundle-name".

The old "bundle:basket/name" identifiers are
still supported but deprecated.
Deploying a bundle by specifying a directory
containing the YAML file is instead not
supported anymore.

Ok, after this brief summary let me take two
lines to really apologize for the huge diff.
While I was there, I refactored some historical
inconsistencies (e.g. models.Charm really being
just a charm or bundle reference), and I also
improved the bundle model API so that the work
is done in the model and not in manage as before.
There are a lot of tests too, and some documentation.
Nonetheless, let me say sorry again, this is
really too much stuff.

With this branch Juju Quickstart is quite ready for
the v4 world. The "deploy bundle" API call to the GUI
server still uses the legacy format, but the ugliness
of being backward compatible with namespaced bundles
is very restrained and implemented in private logic
in the bundles model module.

Tests: `make check`.

QA: run `devenv/bin/juju-quickstart` to deploy
new style and old style bundles, with both version
3 and 4 formats. Note that version 3 can only be
provided with arbitrary URLs or local files.

Thanks a lot!

https://codereview.appspot.com/207040043/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+251156_code.launchpad.net,

Message:
Please take a look.

Description:
Support retrieving bundles from charm store v4.

This branch implements the ability to deploy
bundles from the new charm store, retrieving
them with the v4 API.

Also introduce the new preferred bundle id
spelling, i.e. reflecting jujucharms.com paths,
like "mediawiki-single" or "u/who/bundle-name".

The old "bundle:basket/name" identifiers are
still supported but deprecated.
Deploying a bundle by specifying a directory
containing the YAML file is instead not
supported anymore.

Ok, after this brief summary let me take two
lines to really apologize for the huge diff.
While I was there, I refactored some historical
inconsistencies (e.g. models.Charm really being
just a charm or bundle reference), and I also
improved the bundle model API so that the work
is done in the model and not in manage as before.
There are a lot of tests too, and some documentation.
Nonetheless, let me say sorry again, this is
really too much stuff.

With this branch Juju Quickstart is quite ready for
the v4 world. The "deploy bundle" API call to the GUI
server still uses the legacy format, but the ugliness
of being backward compatible with namespaced bundles
is very restrained and implemented in private logic
in the bundles model module.

Tests: `make check`.

QA: run `devenv/bin/juju-quickstart` to deploy
new style and old style bundles, with both version
3 and 4 formats. Note that version 3 can only be
provided with arbitrary URLs or local files.

Thanks a lot!

https://code.launchpad.net/~frankban/juju-quickstart/jujucharms-bundles/+merge/251156

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/207040043/

Affected files (+1520, -939 lines):
   M HACKING.rst
   A [revision details]
   M quickstart/__init__.py
   M quickstart/app.py
   M quickstart/juju.py
   M quickstart/jujutools.py
   M quickstart/manage.py
   M quickstart/models/bundles.py
   M quickstart/models/references.py
   M quickstart/netutils.py
   M quickstart/settings.py
   M quickstart/tests/functional/test_functional.py
   M quickstart/tests/helpers.py
   M quickstart/tests/models/test_bundles.py
   M quickstart/tests/models/test_references.py
   M quickstart/tests/test_app.py
   M quickstart/tests/test_juju.py
   M quickstart/tests/test_jujutools.py
   M quickstart/tests/test_manage.py

Revision history for this message
Richard Harding (rharding) wrote :

Thanks for this branch. It looks really solid and loving the new
features.

I'm not a huge fan of the 'reference' but can't think of a great
replacement word so oh well.

I've got one question on the rpc call changes to the gui server.

Heads up that Kapil updated the python-jujuclient tonight and says
"pushed new py j client w/ iterator fix .. version incr math fixed as
well (0.50.1)"

So if you get time in the morning please see if you can do a round of QA
with that and we need to look at what it'll take to get that into the
stable PPA. I mentioned to him how we'd be able to help packaging and I
think the tox work done here in quickstart might be useful for
python-jujuclient and the deployer in the future.

https://codereview.appspot.com/207040043/diff/1/HACKING.rst
File HACKING.rst (right):

https://codereview.appspot.com/207040043/diff/1/HACKING.rst#newcode228
HACKING.rst:228: juju quickstart -e local mediawiki-single
<3 nice pretty command

https://codereview.appspot.com/207040043/diff/1/quickstart/__init__.py
File quickstart/__init__.py (right):

https://codereview.appspot.com/207040043/diff/1/quickstart/__init__.py#newcode48
quickstart/__init__.py:48: VERSION = (1, 7, 0)
what do you think of a 2.0? I guess it's not backward incompatible but
wondering if new charmstore/etc will be worthy of a big version update?

https://codereview.appspot.com/207040043/diff/1/quickstart/juju.py
File quickstart/juju.py (right):

https://codereview.appspot.com/207040043/diff/1/quickstart/juju.py#newcode68
quickstart/juju.py:68: params['Version'] = version
so this seems like a change to the call to the charm? Is Version going
to be supported in the charm then and name no longer required as a
param? This is the guiserver bits correct?

https://codereview.appspot.com/207040043/diff/1/quickstart/manage.py
File quickstart/manage.py (right):

https://codereview.appspot.com/207040043/diff/1/quickstart/manage.py#newcode399
quickstart/manage.py:399: ''.format(jujucharms=settings.JUJUCHARMS_URL))
<3 this looks great.

https://codereview.appspot.com/207040043/diff/1/quickstart/tests/test_juju.py
File quickstart/tests/test_juju.py (right):

https://codereview.appspot.com/207040043/diff/1/quickstart/tests/test_juju.py#newcode207
quickstart/tests/test_juju.py:207: 'Version': 4,
Yea, so this is where I'm curious on the changes on the gui charm end.

https://codereview.appspot.com/207040043/

135. By Francesco Banconi

Bump version up to 2.0.0

Revision history for this message
Francesco Banconi (frankban) wrote :

Please take a look.

https://codereview.appspot.com/207040043/diff/1/HACKING.rst
File HACKING.rst (right):

https://codereview.appspot.com/207040043/diff/1/HACKING.rst#newcode228
HACKING.rst:228: juju quickstart -e local mediawiki-single
On 2015/02/27 03:00:09, rharding wrote:
> <3 nice pretty command

Indeed!

https://codereview.appspot.com/207040043/diff/1/quickstart/__init__.py
File quickstart/__init__.py (right):

https://codereview.appspot.com/207040043/diff/1/quickstart/__init__.py#newcode48
quickstart/__init__.py:48: VERSION = (1, 7, 0)
On 2015/02/27 03:00:10, rharding wrote:
> what do you think of a 2.0? I guess it's not backward incompatible but
wondering
> if new charmstore/etc will be worthy of a big version update?

Sounds good, bumped version up to 2.0.

https://codereview.appspot.com/207040043/diff/1/quickstart/juju.py
File quickstart/juju.py (right):

https://codereview.appspot.com/207040043/diff/1/quickstart/juju.py#newcode68
quickstart/juju.py:68: params['Version'] = version
On 2015/02/27 03:00:10, rharding wrote:
> so this seems like a change to the call to the charm? Is Version going
to be
> supported in the charm then and name no longer required as a param?
This is the
> guiserver bits correct?

Yes this is a call to the GUI server.
Version is already supported in the charm, as implemented by Madison.
Name has always been optional, only required when sending a YAML with
more than one bundle, which is never the case in quickstart.

https://codereview.appspot.com/207040043/

Revision history for this message
Richard Harding (rharding) wrote :

LGTM with the feedback thanks Francesco!

https://codereview.appspot.com/207040043/

Revision history for this message
Madison Scott-Clary (makyo) wrote :

LGTM - thanks for this, I think it's a great cleanup and implementation
of the newer urls.

https://codereview.appspot.com/207040043/

136. By Francesco Banconi

Update jujuclient to version 0.50.1.

Revision history for this message
Francesco Banconi (frankban) wrote :
Revision history for this message
Francesco Banconi (frankban) wrote :

*** Submitted:

Support retrieving bundles from charm store v4.

This branch implements the ability to deploy
bundles from the new charm store, retrieving
them with the v4 API.

Also introduce the new preferred bundle id
spelling, i.e. reflecting jujucharms.com paths,
like "mediawiki-single" or "u/who/bundle-name".

The old "bundle:basket/name" identifiers are
still supported but deprecated.
Deploying a bundle by specifying a directory
containing the YAML file is instead not
supported anymore.

Ok, after this brief summary let me take two
lines to really apologize for the huge diff.
While I was there, I refactored some historical
inconsistencies (e.g. models.Charm really being
just a charm or bundle reference), and I also
improved the bundle model API so that the work
is done in the model and not in manage as before.
There are a lot of tests too, and some documentation.
Nonetheless, let me say sorry again, this is
really too much stuff.

With this branch Juju Quickstart is quite ready for
the v4 world. The "deploy bundle" API call to the GUI
server still uses the legacy format, but the ugliness
of being backward compatible with namespaced bundles
is very restrained and implemented in private logic
in the bundles model module.

Tests: `make check`.

QA: run `devenv/bin/juju-quickstart` to deploy
new style and old style bundles, with both version
3 and 4 formats. Note that version 3 can only be
provided with arbitrary URLs or local files.

Thanks a lot!

R=rharding, matthew.scott
CC=
https://codereview.appspot.com/207040043

https://codereview.appspot.com/207040043/

Revision history for this message
Francesco Banconi (frankban) wrote :

Thanks a lot for the reviews!

https://codereview.appspot.com/207040043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'HACKING.rst'
--- HACKING.rst 2015-02-05 13:35:53 +0000
+++ HACKING.rst 2015-02-27 18:40:58 +0000
@@ -219,13 +219,13 @@
219 juju-quickstart -e local -n single $HOME/bundles/mediawiki219 juju-quickstart -e local -n single $HOME/bundles/mediawiki
220 juju destroy-environment local -y220 juju destroy-environment local -y
221221
222* Verify an environment that has already been bootstrapped is recogized and222* Verify an environment that has already been bootstrapped is recognized and
223 the GUI is deployed. This test also shows that a remote bundle is properly223 the GUI is deployed. This test also shows that a remote bundle is properly
224 deployed224 deployed
225::225::
226226
227 juju bootstrap -e local227 juju bootstrap -e local
228 juju quickstart -e local bundle:mediawiki/single228 juju quickstart -e local mediawiki-single
229 juju destroy-environment local -y229 juju destroy-environment local -y
230230
231* Prove that an environments.yaml file can be created and used::231* Prove that an environments.yaml file can be created and used::
232232
=== modified file 'quickstart/__init__.py'
--- quickstart/__init__.py 2015-01-12 14:30:44 +0000
+++ quickstart/__init__.py 2015-02-27 18:40:58 +0000
@@ -45,7 +45,7 @@
45Once Juju has been installed, the command can also be run as a juju plugin,45Once Juju has been installed, the command can also be run as a juju plugin,
46without the hyphen ("juju quickstart").46without the hyphen ("juju quickstart").
47"""47"""
48VERSION = (1, 6, 0)48VERSION = (2, 0, 0)
4949
5050
51def get_version():51def get_version():
5252
=== modified file 'quickstart/app.py'
--- quickstart/app.py 2015-02-09 18:00:33 +0000
+++ quickstart/app.py 2015-02-27 18:40:58 +0000
@@ -408,8 +408,9 @@
408 default charm URL is used if the charm store service is not available.408 default charm URL is used if the charm store service is not available.
409409
410 Return a tuple including the following values:410 Return a tuple including the following values:
411 - charm: the charm that will be used to deploy the service, as an411 - charm_ref: the entity reference of the charm that will be used to
412 instance of "quickstart.models.charms.Charm";412 deploy the service, as an instance of
413 "quickstart.models.references.Reference";
413 - machine: the machine where to deploy to (e.g. "0") or None if a new414 - machine: the machine where to deploy to (e.g. "0") or None if a new
414 machine must be created;415 machine must be created;
415 - service_data: the service info as returned by the mega-watcher for416 - service_data: the service info as returned by the mega-watcher for
@@ -449,7 +450,7 @@
449 # A deployed service already exists in the environment: ignore the450 # A deployed service already exists in the environment: ignore the
450 # provided charm URL and just use the already deployed charm.451 # provided charm URL and just use the already deployed charm.
451 charm_url = service_data['CharmURL']452 charm_url = service_data['CharmURL']
452 charm = jujutools.parse_gui_charm_url(charm_url)453 charm_ref = jujutools.parse_gui_charm_url(charm_url)
453 # Deploy on the bootstrap node if the following conditions are satisfied:454 # Deploy on the bootstrap node if the following conditions are satisfied:
454 # - we are not using the local provider (which uses localhost);455 # - we are not using the local provider (which uses localhost);
455 # - we are not using the azure provider (in which availability sets prevent456 # - we are not using the azure provider (in which availability sets prevent
@@ -457,10 +458,10 @@
457 # - the requested charm and the bootstrap node have the same series.458 # - the requested charm and the bootstrap node have the same series.
458 if (459 if (
459 (env_type not in ('local', 'azure')) and460 (env_type not in ('local', 'azure')) and
460 (charm.series == bootstrap_node_series)461 (charm_ref.series == bootstrap_node_series)
461 ):462 ):
462 machine = '0'463 machine = '0'
463 return charm, machine, service_data, unit_data464 return charm_ref, machine, service_data, unit_data
464465
465466
466def deploy_gui(env, service_name, charm_url, machine, service_data, unit_data):467def deploy_gui(env, service_name, charm_url, machine, service_data, unit_data):
@@ -587,15 +588,23 @@
587 return address588 return address
588589
589590
590def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id):591def deploy_bundle(env, bundle):
591 """Deploy a bundle.592 """Deploy the given bundle connecting to the given environment.
592593
593 Receive the environment connection to use for deploying the bundle, the594 Receive the environment connection to use for deploying the bundle and the
594 bundle YAML encoded contents, the bundle name to be imported and its id.595 bundle object as an instance of "quickstart.models.bundles.Bundle".
595596
596 Raise a ProgramExit if the API server returns an error response.597 Raise a ProgramExit if the API server returns an error response.
597 """598 """
599 # XXX frankban 2015-02-26: use new bundle format if the GUI server is
600 # capable of handling bundle deployments with the API version 4.
601 yaml = bundle.serialize_legacy()
602 version = 3
603 # XXX frankban 2015-02-26: find and implement a better way to increase the
604 # bundle deployments count.
605 ref = bundle.reference
606 bundle_id = None if ref is None else ref.charmworld_id
598 try:607 try:
599 env.deploy_bundle(bundle_yaml, name=bundle_name, bundle_id=bundle_id)608 env.deploy_bundle(yaml, version, bundle_id=bundle_id)
600 except jujuclient.EnvError as err:609 except jujuclient.EnvError as err:
601 raise ProgramExit('bad API server response: {}'.format(err.message))610 raise ProgramExit('bad API server response: {}'.format(err.message))
602611
=== modified file 'quickstart/juju.py'
--- quickstart/juju.py 2015-01-30 17:38:36 +0000
+++ quickstart/juju.py 2015-02-27 18:40:58 +0000
@@ -61,11 +61,11 @@
61 return self.login(61 return self.login(
62 password, user='{}-{}'.format(JUJU_USER_TAG, username))62 password, user='{}-{}'.format(JUJU_USER_TAG, username))
6363
64 def deploy_bundle(self, yaml, name=None, bundle_id=None):64 def deploy_bundle(self, yaml, version, bundle_id=None):
65 """Deploy a bundle."""65 """Deploy a bundle."""
66 params = {'YAML': yaml}66 params = {'YAML': yaml}
67 if name is not None:67 if version > 3:
68 params['Name'] = name68 params['Version'] = version
69 if bundle_id is not None:69 if bundle_id is not None:
70 params['BundleID'] = bundle_id70 params['BundleID'] = bundle_id
71 request = {71 request = {
7272
=== modified file 'quickstart/jujutools.py'
--- quickstart/jujutools.py 2015-02-09 18:00:33 +0000
+++ quickstart/jujutools.py 2015-02-27 18:40:58 +0000
@@ -27,10 +27,11 @@
27 serializers,27 serializers,
28 settings,28 settings,
29)29)
30from quickstart.models import charms30from quickstart.models import references
3131
3232
33def get_api_url(api_address, juju_version, env_uuid, prefix='', charm=None):33def get_api_url(
34 api_address, juju_version, env_uuid, prefix='', charm_ref=None):
34 """Return the Juju WebSocket API endpoint.35 """Return the Juju WebSocket API endpoint.
3536
36 Receives the Juju API server address, the Juju version and the unique37 Receives the Juju API server address, the Juju version and the unique
@@ -38,9 +39,9 @@
3839
39 Optionally receive a prefix to be used in the path.40 Optionally receive a prefix to be used in the path.
4041
41 Optionally also receive the Juju GUI charm object as an instance of42 Optionally also receive the Juju GUI charm reference as an instance of
42 "quickstart.models.charms.Charm". If provided, the function checks that43 "quickstart.models.references.Reference". If provided, the function checks
43 the specified Juju GUI charm supports the new Juju API endpoint.44 that the corresponding Juju GUI charm supports the new Juju API endpoint.
44 If not supported, the old endpoint is returned.45 If not supported, the old endpoint is returned.
4546
46 The environment UUID can be None, in which case the old-style API URL47 The environment UUID can be None, in which case the old-style API URL
@@ -53,19 +54,19 @@
53 if (env_uuid is None) or (juju_version < (1, 22, 0)):54 if (env_uuid is None) or (juju_version < (1, 22, 0)):
54 return base_url55 return base_url
55 complete_url = '{}/environment/{}/api'.format(base_url, env_uuid)56 complete_url = '{}/environment/{}/api'.format(base_url, env_uuid)
56 if charm is None:57 if charm_ref is None:
57 return complete_url58 return complete_url
58 # If a customized Juju GUI charm is in use, there is no way to check if the59 # If a customized Juju GUI charm is in use, there is no way to check if the
59 # GUI server is recent enough to support the new Juju API endpoints.60 # GUI server is recent enough to support the new Juju API endpoints.
60 # In these cases, assume the customized charm is recent enough.61 # In these cases, assume the customized charm is recent enough.
61 if (62 if (
62 charm.name != settings.JUJU_GUI_CHARM_NAME or63 charm_ref.name != settings.JUJU_GUI_CHARM_NAME or
63 charm.user or64 charm_ref.user or
64 charm.is_local()65 charm_ref.is_local()
65 ):66 ):
66 return complete_url67 return complete_url
67 # This is the promulgated Juju GUI charm. Check if it supports new APIs.68 # This is the promulgated Juju GUI charm. Check if it supports new APIs.
68 revision, series = charm.revision, charm.series69 revision, series = charm_ref.revision, charm_ref.series
69 if revision < settings.MINIMUM_REVISIONS_FOR_NEW_API_ENDPOINT[series]:70 if revision < settings.MINIMUM_REVISIONS_FOR_NEW_API_ENDPOINT[series]:
70 return base_url71 return base_url
71 return complete_url72 return complete_url
@@ -99,29 +100,29 @@
99def parse_gui_charm_url(charm_url):100def parse_gui_charm_url(charm_url):
100 """Parse the given charm URL.101 """Parse the given charm URL.
101102
102 Check if the charm looks like a Juju GUI charm.103 Check if the charm URL seems to refer to a Juju GUI charm.
103 Print (to stdout or to logs) info and warnings about the charm URL.104 Print (to stdout or to logs) info and warnings about the charm URL.
104105
105 Return the parsed charm object as an instance of106 Return the parsed charm reference object as an instance of
106 "quickstart.models.charms.Charm".107 "quickstart.models.references.Reference".
107 """108 """
108 print('charm URL: {}'.format(charm_url))109 print('charm URL: {}'.format(charm_url))
109 charm = charms.Charm.from_url(charm_url)110 ref = references.Reference.from_fully_qualified_url(charm_url)
110 charm_name = settings.JUJU_GUI_CHARM_NAME111 charm_name = settings.JUJU_GUI_CHARM_NAME
111 if charm.name != charm_name:112 if ref.name != charm_name:
112 # This does not seem to be a Juju GUI charm.113 # This does not seem to be a Juju GUI charm.
113 logging.warn(114 logging.warn(
114 'unexpected URL for the {} charm: '115 'unexpected URL for the {} charm: '
115 'the service may not work as expected'.format(charm_name))116 'the service may not work as expected'.format(charm_name))
116 return charm117 return ref
117 if charm.user or charm.is_local():118 if ref.user or ref.is_local():
118 # This is not the official Juju GUI charm.119 # This is not the official Juju GUI charm.
119 logging.warn('using a customized {} charm'.format(charm_name))120 logging.warn('using a customized {} charm'.format(charm_name))
120 elif charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]:121 elif ref.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[ref.series]:
121 # This is the official Juju GUI charm, but it is outdated.122 # This is the official Juju GUI charm, but it is outdated.
122 logging.warn(123 logging.warn(
123 'charm is outdated and may not support bundle deployments')124 'charm is outdated and may not support bundle deployments')
124 return charm125 return ref
125126
126127
127def parse_status_output(output, keys=None):128def parse_status_output(output, keys=None):
128129
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2015-02-09 18:00:33 +0000
+++ quickstart/manage.py 2015-02-27 18:40:58 +0000
@@ -22,7 +22,6 @@
22)22)
2323
24import argparse24import argparse
25import codecs
26import logging25import logging
27import os26import os
28import shutil27import shutil
@@ -33,7 +32,6 @@
33from quickstart import (32from quickstart import (
34 app,33 app,
35 jujutools,34 jujutools,
36 netutils,
37 packaging,35 packaging,
38 platform_support,36 platform_support,
39 settings,37 settings,
@@ -45,9 +43,9 @@
45)43)
46from quickstart.models import (44from quickstart.models import (
47 bundles,45 bundles,
48 charms,
49 envs,46 envs,
50 jenv,47 jenv,
48 references,
51)49)
5250
5351
@@ -97,50 +95,17 @@
97 """Validate and process the bundle options.95 """Validate and process the bundle options.
9896
99 Populate the options namespace with the following names:97 Populate the options namespace with the following names:
100 - bundle_name: the name of the bundle;98 - bundle: the bundle object as an instance of
101 - bundle_services: a list of service names included in the bundle;99 "quickstart.models.bundles.Bundle";
102 - bundle_yaml: the YAML encoded contents of the bundle.100
103 - bundle_id: the bundle_id in Charmworld. None if not a 'bundle:' URL.101 Exit with an error if the bundle options are not valid, or if the bundle
104 Exit with an error if the bundle options are not valid.102 content cannot be retrieved.
105 """103 """
106 bundle = options.bundle
107 bundle_id = None
108 jujucharms_prefix = settings.JUJUCHARMS_BUNDLE_URL
109 if bundle.startswith('bundle:') or bundle.startswith(jujucharms_prefix):
110 # Convert "bundle:" or jujucharms.com URLs into Charmworld HTTPS ones.
111 try:
112 bundle, bundle_id = bundles.convert_bundle_url(bundle)
113 except ValueError as err:
114 return parser.error('unable to open the bundle: {}'.format(err))
115 # The next if block below will then load the bundle contents from the
116 # remote location.
117 if bundle.startswith('http://') or bundle.startswith('https://'):
118 # Load the bundle from a remote URL.
119 try:
120 bundle_yaml = netutils.urlread(bundle)
121 except IOError as err:
122 return parser.error('unable to open bundle URL: {}'.format(err))
123 else:
124 # Load the bundle from a file.
125 bundle_file = os.path.abspath(os.path.expanduser(bundle))
126 if os.path.isdir(bundle_file):
127 bundle_file = os.path.join(bundle_file, 'bundles.yaml')
128 try:
129 bundle_yaml = codecs.open(
130 bundle_file.encode('utf-8'), encoding='utf-8').read()
131 except IOError as err:
132 return parser.error('unable to open bundle file: {}'.format(err))
133 # Validate the bundle.
134 try:104 try:
135 bundle_name, bundle_services = bundles.parse_bundle(105 options.bundle = bundles.from_source(
136 bundle_yaml, options.bundle_name)106 options.bundle_source, options.bundle_name)
137 except ValueError as err:107 except (IOError, ValueError) as err:
138 return parser.error(bytes(err))108 return parser.error(b'unable to open the bundle: {}'.format(err))
139 # Update the options namespace with the new values.
140 options.bundle_name = bundle_name
141 options.bundle_services = bundle_services
142 options.bundle_yaml = bundle_yaml
143 options.bundle_id = bundle_id
144109
145110
146def _validate_charm_url(options, parser):111def _validate_charm_url(options, parser):
@@ -156,25 +121,24 @@
156 Leave the options namespace untouched.121 Leave the options namespace untouched.
157 """122 """
158 try:123 try:
159 charm = charms.Charm.from_url(options.charm_url)124 ref = references.Reference.from_fully_qualified_url(options.charm_url)
160 except ValueError as err:125 except ValueError as err:
161 return parser.error(bytes(err))126 return parser.error(bytes(err))
162 if charm.is_local():127 if ref.is_local():
163 return parser.error(b'local charms are not allowed: {}'.format(charm))128 return parser.error(b'local charms are not allowed: {}'.format(ref))
164 if charm.series not in settings.JUJU_GUI_SUPPORTED_SERIES:129 if ref.series not in settings.JUJU_GUI_SUPPORTED_SERIES:
165 return parser.error(130 return parser.error('unsupported charm series: {}'.format(ref.series))
166 'unsupported charm series: {}'.format(charm.series))
167 if (131 if (
168 # The user requested a bundle deployment.132 # The user requested a bundle deployment.
169 options.bundle and133 options.bundle_source is not None and
170 # This is the official Juju GUI charm.134 # This is the official Juju GUI charm.
171 charm.name == settings.JUJU_GUI_CHARM_NAME and not charm.user and135 ref.name == settings.JUJU_GUI_CHARM_NAME and not ref.user and
172 # The charm at this revision does not support bundle deployments.136 # The charm at this revision does not support bundle deployments.
173 charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]137 ref.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[ref.series]
174 ):138 ):
175 return parser.error(139 return parser.error(
176 'bundle deployments not supported by the requested charm '140 'bundle deployments not supported by the requested charm '
177 'revision: {}'.format(charm))141 'revision: {}'.format(ref))
178142
179143
180def _retrieve_env_db(parser, env_file=None):144def _retrieve_env_db(parser, env_file=None):
@@ -363,7 +327,10 @@
363 """Set up the application options and logger.327 """Set up the application options and logger.
364328
365 Return the options as a namespace containing the following attributes:329 Return the options as a namespace containing the following attributes:
366 - bundle: the optional bundle (path or URL) to be deployed;330 - bundle_name: the optional name of the bundle in the case the legacy
331 bundle format is being used, or None if the name is not specified;
332 - bundle_source: the optional bundle identifier to be deployed, or None
333 if a bundle deployment is not requested;
367 - charm_url: the Juju GUI charm URL or None if not specified;334 - charm_url: the Juju GUI charm URL or None if not specified;
368 - constraints: the environment constrains or None if not set;335 - constraints: the environment constrains or None if not set;
369 - debug: whether debug mode is activated;336 - debug: whether debug mode is activated;
@@ -380,11 +347,8 @@
380347
381 The following attributes will also be included in the namespace if a bundle348 The following attributes will also be included in the namespace if a bundle
382 deployment is requested:349 deployment is requested:
383 - bundle_name: the name of the bundle to be deployed;350 - bundle: the bundle instance to be deployed, as an instance of
384 - bundle_services: a list of service names included in the bundle;351 "quickstart.models.bundles.Bundle".
385 - bundle_yaml: the YAML encoded contents of the bundle.
386 - bundle_id: the Charmworld identifier for the bundle if a
387 'bundle:' URL is provided.
388352
389 Exit with an error if the provided arguments are not valid.353 Exit with an error if the provided arguments are not valid.
390 """354 """
@@ -406,26 +370,33 @@
406 # Note: since we use the RawTextHelpFormatter, when adding/changing options370 # Note: since we use the RawTextHelpFormatter, when adding/changing options
407 # make sure the help text is nicely displayed on small 80 columns terms.371 # make sure the help text is nicely displayed on small 80 columns terms.
408 parser.add_argument(372 parser.add_argument(
409 'bundle', default=None, nargs='?',373 'bundle_source', default=None, nargs='?', metavar='BUNDLE',
410 help='The optional bundle to be deployed. The bundle can be:\n'374 help='The optional bundle to be deployed. The bundle can be:\n'
411 '1) a fully qualified bundle URL, starting with "bundle:"\n'375 '1) a bundle path as shown in jujucharms.com, e.g.\n'
412 ' e.g. "bundle:mediawiki/single".\n'376 ' "mediawiki-single" or "django".\n'
377 ' Non promulgated bundles can be requested providing\n'
378 ' the user, e.g. "u/bigdata-dev/apache-analytics-sql".\n'
379 ' A specific bundle revision can also be requested,\n'
380 ' e.g. "mediawiki-scalable/7".\n'
381 ' If not specified, the most recent revision is used;\n'
382 '2) a jujucharms.com full URL of the bundle detail page,\n'
383 ' with or without the revision. e.g.\n'
384 ' "{jujucharms}mongodb-cluster/4" or\n'
385 ' "{jujucharms}openstack";\n'
386 '3) a URL ("http:" or "https:") to a YAML/JSON, e.g.\n'
387 ' "https://raw.github.com/user/my/master/bundle.yaml";\n'
388 '4) a local path to a YAML/JSON file with ".yaml" or\n'
389 ' ".json" extension, e.g. "~/bundles/django.yaml";\n'
390 '5) a legacy fully qualified bundle URL, starting with\n'
391 ' "bundle:", e.g. "bundle:mediawiki/single".\n'
413 ' Non promulgated bundles can be requested providing\n'392 ' Non promulgated bundles can be requested providing\n'
414 ' the user, e.g. "bundle:~user/mediawiki/single".\n'393 ' the user, e.g. "bundle:~user/mediawiki/single".\n'
415 ' A specific bundle revision can also be requested,\n'394 ' A specific bundle revision can also be requested,\n'
416 ' e.g. "bundle:~myuser/mediawiki/42/single".\n'395 ' e.g. "bundle:~myuser/mediawiki/42/single".\n'
417 ' If not specified, the last bundle revision is used;\n'396 ' If not specified, the last bundle revision is used.\n'
418 '2) a jujucharms bundle URL, starting with\n'397 ' Note that this form is DEPRECATED, and a deprecation\n'
419 ' "{jujucharm}", e.g.\n'398 ' warning is printed suggesting the new value to use\n'
420 ' "{jujucharm}~user/wiki/1/simple/".\n'399 ''.format(jujucharms=settings.JUJUCHARMS_URL))
421 ' As seen above, jujucharms bundle URLs can also be\n'
422 ' shortened, e.g.\n'
423 ' "{jujucharm}mediawiki/scalable/";\n'
424 '3) a URL ("http:" or "https:") to a YAML/JSON, e.g.\n'
425 ' "https://raw.github.com/user/my/master/bundles.yaml";\n'
426 '4) a local path to a YAML/JSON file;\n'
427 '5) a path to a directory containing a "bundles.yaml"\n'
428 ' file'.format(jujucharm=settings.JUJUCHARMS_BUNDLE_URL))
429 parser.add_argument(400 parser.add_argument(
430 '-e', '--environment', default=default_env_name, dest='env_name',401 '-e', '--environment', default=default_env_name, dest='env_name',
431 help=env_help)402 help=env_help)
@@ -497,7 +468,7 @@
497 _convert_options_to_unicode(options)468 _convert_options_to_unicode(options)
498 # Validate and process the provided arguments.469 # Validate and process the provided arguments.
499 _setup_env(options, parser)470 _setup_env(options, parser)
500 if options.bundle is not None:471 if options.bundle_source is not None:
501 _validate_bundle(options, parser)472 _validate_bundle(options, parser)
502 if options.charm_url is not None:473 if options.charm_url is not None:
503 _validate_charm_url(options, parser)474 _validate_charm_url(options, parser)
@@ -509,9 +480,9 @@
509def run(options):480def run(options):
510 """Run the application."""481 """Run the application."""
511 print('juju quickstart v{}'.format(version))482 print('juju quickstart v{}'.format(version))
512 if options.bundle is not None:483 if options.bundle_source is not None:
513 print('contents loaded for bundle {} (services: {})'.format(484 print('contents loaded for {} (services: {})'.format(
514 options.bundle_name, len(options.bundle_services)))485 options.bundle, len(options.bundle.services())))
515486
516 juju_command, custom_juju = platform_support.get_juju_command(487 juju_command, custom_juju = platform_support.get_juju_command(
517 options.platform)488 options.platform)
@@ -578,11 +549,11 @@
578 print('environment type: {}'.format(env_type))549 print('environment type: {}'.format(env_type))
579550
580 # Inspect the environment and deploy the charm if required.551 # Inspect the environment and deploy the charm if required.
581 charm, machine, service_data, unit_data = app.check_environment(552 charm_ref, machine, service_data, unit_data = app.check_environment(
582 env, settings.JUJU_GUI_SERVICE_NAME, options.charm_url,553 env, settings.JUJU_GUI_SERVICE_NAME, options.charm_url,
583 env_type, bootstrap_node_series, already_bootstrapped)554 env_type, bootstrap_node_series, already_bootstrapped)
584 unit_name = app.deploy_gui(555 unit_name = app.deploy_gui(
585 env, settings.JUJU_GUI_SERVICE_NAME, charm.url(), machine,556 env, settings.JUJU_GUI_SERVICE_NAME, charm_ref.id(), machine,
586 service_data, unit_data)557 service_data, unit_data)
587558
588 # Observe the deployment progress.559 # Observe the deployment progress.
@@ -597,20 +568,22 @@
597 # Connect to the GUI server WebSocket API.568 # Connect to the GUI server WebSocket API.
598 print('connecting to the Juju GUI server')569 print('connecting to the Juju GUI server')
599 gui_api_url = jujutools.get_api_url(570 gui_api_url = jujutools.get_api_url(
600 address + ':443', juju_version, env_uuid, prefix='ws', charm=charm)571 address + ':443', juju_version, env_uuid,
572 prefix='ws', charm_ref=charm_ref)
601 gui_env = app.connect(gui_api_url, username, password)573 gui_env = app.connect(gui_api_url, username, password)
602574
603 # Handle bundle deployment.575 # Handle bundle deployment.
604 if options.bundle is not None:576 if options.bundle_source is not None:
605 services = ', '.join(options.bundle_services)577 services = ', '.join(options.bundle.services())
606 print('requesting a deployment of the {} bundle with the following '578 print('requesting a deployment of {} with the following services:\n'
607 'services:\n {}'.format(options.bundle_name, services))579 ' {}'.format(options.bundle, services))
580 if options.bundle.reference is not None:
581 print('more details about this bundle can be found at\n'
582 ' {}'.format(options.bundle.reference.jujucharms_url()))
608 # We need to connect to an API WebSocket server supporting bundle583 # We need to connect to an API WebSocket server supporting bundle
609 # deployments. The GUI builtin server, listening on the Juju GUI584 # deployments. The GUI builtin server, listening on the Juju GUI
610 # address, exposes an API suitable for deploying bundles.585 # address, exposes an API suitable for deploying bundles.
611 app.deploy_bundle(586 app.deploy_bundle(gui_env, options.bundle)
612 gui_env, options.bundle_yaml, options.bundle_name,
613 options.bundle_id)
614 print('bundle deployment request accepted\n'587 print('bundle deployment request accepted\n'
615 'use the GUI to check the bundle deployment progress')588 'use the GUI to check the bundle deployment progress')
616589
617590
=== modified file 'quickstart/models/bundles.py'
--- quickstart/models/bundles.py 2015-02-09 12:58:04 +0000
+++ quickstart/models/bundles.py 2015-02-27 18:40:58 +0000
@@ -14,105 +14,317 @@
14# You should have received a copy of the GNU Affero General Public License14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.15# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
17"""Juju Quickstart bundles management."""17"""Juju Quickstart bundles management.
18
19This module defines objects and functions that help working with bundles.
20Bundles are described by a YAML content defining a collection of services in a
21Juju topology, along with their options, relations and unit placement.
22
23Published bundles are identified by a charm store id and by the corresponding
24URL in jujucharms.com, just like regular charms. The reference object in
25"quickstart.models.references.Reference" can be used to identify a bundle.
26
27In this module, the Bundle class represents a bundle that may or may not have
28a specific reference id. For instance, a reference is not set on a bundle if
29its contents are retrieved from an arbitrary local or remote location.
30
31Juju Quickstart usually instantiates bundles using the "from_source" helper
32below, which retrieves the bundle content from all the supported sources,
33validates it and then creates a "Bundle" instance with the validated content
34and the bundle reference if avaliable.
35
36Use "parse_yaml" to parse and validate a YAML encoded string as a bundle
37content. If the YAML decoded object is already available, the same validation
38can be achieved using the "validate" function directly.
39"""
1840
19from __future__ import unicode_literals41from __future__ import unicode_literals
2042
43import codecs
21import collections44import collections
22import re45import logging
46import os
2347
24from quickstart import (48from quickstart import (
49 netutils,
25 serializers,50 serializers,
26 settings,51 settings,
27)52)
2853from quickstart.models import references
2954
30# Compile the regular expression used to parse bundle URLs.55
31_bundle_expression = re.compile(r"""56class Bundle(object):
32 # Bundle schema or bundle URL namespace on jujucharms.com.57 """Store information about a charm store bundle entity"""
33 ^(?:bundle:|{})58
34 (?:~([-\w]+)/)? # Optional user name.59 def __init__(self, data, reference=None):
35 ([-\w]+)/ # Basket name.60 """Initialize the bundle.
36 (?:(\d+)/)? # Optional bundle revision number.61
37 ([-\w]+) # Bundle name.62 The data argument is the bundle YAML decoded content.
38 /?$ # Optional trailing slash.63 An optional entity reference can be provided as an instance of
39""".format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE)64 "quickstart.models.references.Reference".
4065 """
4166 self.data = data
42def convert_bundle_url(bundle_url):67 self.reference = reference
43 """Return the equivalent YAML HTTPS location for the given bundle URL.68
4469 def __str__(self):
45 Raise a ValueError if the given URL is not a valid bundle URL.70 """Return the byte string representation of this bundle."""
46 """71 return self.__unicode__().encode('utf-8')
47 match = _bundle_expression.match(bundle_url)72
48 if match is None:73 def __unicode__(self):
49 msg = 'invalid bundle URL: {}'.format(bundle_url)74 """Return the unicode string representation of this bundle."""
50 raise ValueError(msg.encode('utf-8'))75 parts = ['bundle']
51 user, basket, revision, name = match.groups()76 if self.reference is not None:
52 user_part = '~charmers/' if user is None else '~{}/'.format(user)77 parts.append(self.reference.jujucharms_id())
53 revision_part = '' if revision is None else '{}/'.format(revision)78 return ' '.join(parts)
54 bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name)79
55 return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id),80 def __repr__(self):
56 bundle_id)81 return b'<Bundle: {}>'.format(bytes(self))
5782
5883 def serialize(self):
59def parse_bundle(bundle_yaml, bundle_name=None):84 """Serialize the bundle data as a YAML encoded string."""
60 """Parse the provided bundle YAML encoded contents.85 return serializers.yaml_dump(self.data)
6186
62 Since a valid JSON is a subset of YAML this function can be used also to87 def serialize_legacy(self):
63 parse JSON encoded contents.88 """Serialize the bundle data as a YAML encoded string.
6489
65 Return a tuple containing the bundle name and the list of services included90 The resulting string uses the legacy API version 3 format.
66 in the bundle.91 """
6792 return serializers.yaml_dump({'bundle': self.data})
68 Raise a ValueError if:93
69 - the bundle YAML contents are not parsable by YAML;94 def services(self):
70 - the YAML contents are not properly structured;95 """Return a list of service names included in the bundle.
71 - the bundle name is specified but not included in the bundle file;96
72 - the bundle name is not specified and the bundle file includes more than97 Service names are returned in alphabetical order.
73 one bundle;98 """
74 - the bundle does not include services.99 return sorted(self.data['services'].keys())
75 """100
76 # Parse the bundle file.101
77 try:102def from_source(source, name=None):
78 bundles = serializers.yaml_load(bundle_yaml)103 """Return a bundle YAML encoded string and id from the given source.
104
105 The source argument is a string, and can be provided as:
106
107 - a bundle path as shown in jujucharms.com, e.g. "mediawiki-single" or
108 "u/bigdata-dev/apache-analytics-sql";
109
110 - a bundle path as shown in jujucharms.com including the bundle
111 revision, e.g. "mediawiki-single/7" or "u/frankban/django/42";
112
113 - the two forms above with leading or trailing slashes, e.g.
114 "/mediawiki-scalable" or "/u/frankban/django/42";
115
116 - a full jujucharms.com URL, e.g. "https://jujucharms.com/django/" or
117 "https://jujucharms.com/u/bigdata-dev/apache-analytics-sql";
118
119 - a full jujucharms.com URL including the bundle revision, e.g.
120 "https://jujucharms.com/django/2/";
121
122 - a URL ("http:" or "https:") to a YAML/JSON, e.g.
123 "https://raw.github.com/user/my/master/bundles.yaml";
124
125 - a local path to a YAML/JSON file, ending with ".yaml" or ".json",
126 e.g. "mybundle.yaml" or "~/bundles/django.json";
127
128 - an old style bundle fully qualified URL, e.g.
129 "bundle:~myuser/mediawiki/42/single";
130
131 - and old style bundle URL without user and/or revision, e.g.
132 "bundle:mediawiki/single" or "bundle:~user/mediawiki/single".
133
134 Return a Bundle instance whose bundle reference attribute is None if this
135 information cannot be inferred from the given source.
136
137 Raise a ValueError if the given source is not valid.
138 Raise an IOError if the YAML content cannot be retrieved from the given
139 local or remote source.
140 """
141 if source.startswith('bundle:'):
142 # The source refers to an old style bundle URL.
143 reference = references.Reference.from_charmworld_url(source)
144 logging.warn(
145 'this bundle URL is deprecated: please use the new format: '
146 '{}'.format(reference.jujucharms_id()))
147 return _bundle_from_reference(reference)
148
149 has_extension = source.endswith('.yaml') or source.endswith('.json')
150 is_remote = source.startswith('http://') or source.startswith('https://')
151 if has_extension and not is_remote:
152 # The source refers to a local file.
153 data = _parse_and_flatten_yaml(_retrieve_from_file(source), name)
154 return Bundle(data)
155
156 try:
157 reference = references.Reference.from_jujucharms_url(source)
158 except ValueError:
159 if is_remote:
160 # The source is an arbitrary URL to a YAML/JSON content.
161 data = _parse_and_flatten_yaml(_retrieve_from_url(source), name)
162 return Bundle(data)
163 # No other options are available.
164 raise
165
166 if not reference.is_bundle():
167 raise ValueError(
168 b'expected a bundle, provided charm {}'.format(reference))
169
170 # The source refers to a bundle URL in jujucharms.com.
171 return _bundle_from_reference(reference)
172
173
174def _bundle_from_reference(reference):
175 """Retrieve bundle YAML contents from its reference in the charm store.
176
177 The path of an entity in the charm store is the fully qualified URL without
178 the schema. The schema is implicitly set to "cs" (charm store entity), e.g.
179 "vivid/django" or "~who/trusty/mediawiki-42".
180
181 Return a Bundle instance which includes the retrieved data and the given
182 reference.
183 Raise a IOError if a problem is encountered while fetching the YAML
184 content from the charm store.
185 Raise a ValueError if the bundle content is not valid.
186 """
187 url = settings.CHARMSTORE_API + reference.path() + '/archive/bundle.yaml'
188 content = _retrieve_from_url(url)
189 data = parse_yaml(content)
190 return Bundle(data, reference=reference)
191
192
193def _retrieve_from_url(url):
194 """Retrieve bundle YAML content from the given URL.
195
196 Return the bundle content as a YAML encoded string.
197 Raise a IOError if a problem is encountered while opening the URL.
198 """
199 try:
200 return netutils.urlread(url)
201 except IOError as err:
202 msg = b'cannot retrieve bundle from remote URL {}: {}'.format(
203 url.encode('utf-8'), err)
204 raise IOError(msg)
205
206
207def _retrieve_from_file(path):
208 """Retrieve bundle YAML content from the given local file path.
209
210 Return the bundle content as a YAML encoded string.
211 Raise a IOError if a problem is encountered while opening the file.
212 """
213 path = os.path.abspath(os.path.expanduser(path))
214 try:
215 return codecs.open(path.encode('utf-8'), encoding='utf-8').read()
216 except IOError as err:
217 raise IOError(
218 b'cannot retrieve bundle from local file: {}'.format(err))
219
220
221def parse_yaml(content):
222 """Parse and validate the given bundle content as a YAML encoded string.
223
224 Note that the bundle validation performed by Juju Quickstart is weak by
225 design: it just checks that the content looks like a bundle YAML. Contents
226 provided by the charm store are already known as valid. For other sources,
227 a more cogent validation is done down in the stack, when the content is
228 sent to the GUI server and then to the Juju deployer.
229
230 Return the resulting YAML decoded dictionary.
231 Raise a ValueError if:
232 - the bundle YAML contents are not parsable by YAML;
233 - the YAML contents are not properly structured;
234 - the bundle does not include services.
235 """
236 data = _open_yaml(content)
237 # Validate the bundle data.
238 validate(data)
239 return data
240
241
242def _parse_and_flatten_yaml(content, name):
243 """Parse and validate the given bundle content.
244
245 The content is provided as a YAML encoded string and can be either a new
246 style flat bundle or a legacy bundle format.
247 In both cases, the returned YAML decoded data represents a new style
248 bundle (API version 4).
249
250 Raise a ValueError if:
251 - the bundle YAML contents are not parsable by YAML;
252 - the YAML contents are not properly structured;
253 - the bundle name is specified but not included in the bundle file;
254 - the bundle name is not specified and the bundle file includes more
255 than one bundle;
256 - the bundle does not include services.
257 """
258 data = _open_yaml(content)
259 services = data.get('services')
260 # The internal structure of a bundle in the API version 4 does not include
261 # a wrapping namespace with the bundle name. That's why the check below,
262 # despite its ugliness, is quite effective.
263 if services and 'services' not in services:
264 # This is an API version 4 bundle.
265 validate(data)
266 return data
267 num_bundles = len(data)
268 if not num_bundles:
269 raise ValueError(b'no bundles found in the provided list of bundles')
270 names = ', '.join(sorted(data.keys()))
271 if name is None:
272 if num_bundles > 1:
273 msg = 'multiple bundles found ({}) but no bundle name specified'
274 raise ValueError(msg.format(names).encode('utf-8'))
275 data = data.values()[0]
276 else:
277 data = data.get(name)
278 if data is None:
279 msg = 'bundle {} not found in the provided list of bundles ({})'
280 raise ValueError(msg.format(name, names).encode('utf-8'))
281 validate(data)
282 return data
283
284
285def _open_yaml(content):
286 """Deserialize the given content, that must be a YAML encoded dictionary.
287
288 Raise a ValueError if the content is not valid.
289 """
290 try:
291 data = serializers.yaml_load(content)
79 except Exception as err:292 except Exception as err:
80 msg = b'unable to parse the bundle: {}'.format(err)293 msg = b'unable to parse the bundle content: {}'.format(err)
81 raise ValueError(msg)294 raise ValueError(msg)
82 # Ensure the bundle file is well formed and contains at least one bundle.295 # Ensure the bundle content is well formed.
83 if not isinstance(bundles, collections.Mapping):296 if not isinstance(data, collections.Mapping):
84 msg = 'invalid YAML contents: {}'.format(bundle_yaml)297 msg = 'invalid YAML content: {}'.format(data)
85 raise ValueError(msg.encode('utf-8'))298 raise ValueError(msg.encode('utf-8'))
299 return data
300
301
302def validate(data):
303 """Validate the given YAML decoded bundle data.
304
305 Note that the bundle validation performed by Juju Quickstart is weak by
306 design: it just checks that the content looks like a bundle YAML. Contents
307 provided by the charm store are already known as valid. For other sources,
308 a more cogent validation is done down in the stack, when the content is
309 sent to the GUI server and then to the Juju deployer.
310
311 Raise a ValueError if:
312 - the YAML contents are not properly structured;
313 - the bundle does not include services.
314 """
315 # Retrieve the bundle services.
86 try:316 try:
87 name_services_map = dict(317 services = data['services'].keys()
88 (key, value['services'].keys())
89 for key, value in bundles.items()
90 )
91 except (AttributeError, KeyError, TypeError):318 except (AttributeError, KeyError, TypeError):
92 msg = 'invalid YAML contents: {}'.format(bundle_yaml)319 content = serializers.yaml_dump(data).strip()
93 raise ValueError(msg.encode('utf-8'))320 msg = 'unable to retrieve bundle services: {}'.format(content)
94 if not name_services_map:321 raise ValueError(msg.encode('utf-8'))
95 raise ValueError(b'no bundles found')322 # Ensure at least one service is defined in the bundle.
96 # Retrieve the bundle name and services.323 if not services:
97 if bundle_name is None:324 raise ValueError(b'no services found in the bundle')
98 if len(name_services_map) > 1:325 # Check that the Juju GUI charm is not included as a service.
99 msg = 'multiple bundles found ({}) but no bundle name specified'326 if settings.JUJU_GUI_SERVICE_NAME in services:
100 bundle_names = ', '.join(sorted(name_services_map.keys()))327 raise ValueError(
101 raise ValueError(msg.format(bundle_names).encode('utf-8'))328 b'the provided bundle contains an instance of juju-gui. Juju '
102 bundle_name, bundle_services = name_services_map.items()[0]329 b'Quickstart will install the latest version of the Juju GUI '
103 else:330 b'automatically; please remove juju-gui from the bundle')
104 bundle_services = name_services_map.get(bundle_name)
105 if bundle_services is None:
106 msg = 'bundle {} not found in the provided list of bundles ({})'
107 bundle_names = ', '.join(sorted(name_services_map.keys()))
108 raise ValueError(
109 msg.format(bundle_name, bundle_names).encode('utf-8'))
110 if not bundle_services:
111 msg = 'bundle {} does not include any services'.format(bundle_name)
112 raise ValueError(msg.encode('utf-8'))
113 if settings.JUJU_GUI_SERVICE_NAME in bundle_services:
114 msg = ('bundle {} contains an instance of juju-gui. quickstart will '
115 'install the latest version of the Juju GUI automatically, '
116 'please remove juju-gui from the bundle.'.format(bundle_name))
117 raise ValueError(msg.encode('utf-8'))
118 return bundle_name, bundle_services
119331
=== renamed file 'quickstart/models/charms.py' => 'quickstart/models/references.py'
--- quickstart/models/charms.py 2015-02-09 14:56:32 +0000
+++ quickstart/models/references.py 2015-02-27 18:40:58 +0000
@@ -14,37 +14,203 @@
14# You should have received a copy of the GNU Affero General Public License14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.15# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
17"""Juju Quickstart charms management."""17"""Juju Quickstart charm and bundle references management."""
1818
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import re21import re
2222
23from quickstart import settings
24
2325
24# The following regular expressions are the same used in juju-core: see26# The following regular expressions are the same used in juju-core: see
25# http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go.27# http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go.
26valid_user = re.compile(r'^[a-z0-9][a-zA-Z0-9+.-]+$').match28_USER_PATTERN = r'[a-z0-9][a-zA-Z0-9+.-]+'
27valid_series = re.compile(r'^[a-z]+([a-z-]+[a-z])?$').match29_SERIES_PATTERN = r'[a-z]+(?:[a-z-]+[a-z])?'
28valid_name = re.compile(r'^[a-z][a-z0-9]*(-[a-z0-9]*[a-z][a-z0-9]*)*$').match30_NAME_PATTERN = r'[a-z][a-z0-9]*(?:-[a-z0-9]*[a-z][a-z0-9]*)*'
2931
3032# Define the callables used to check if entity reference components are valid.
31def parse_url(url):33_valid_user = re.compile(r'^{}$'.format(_USER_PATTERN)).match
32 """Parse the given charm URL.34_valid_series = re.compile(r'^{}$'.format(_SERIES_PATTERN)).match
3335_valid_name = re.compile(r'^{}$'.format(_NAME_PATTERN)).match
34 Return a tuple containing the charm URL fragments: schema, user, series,36
35 name and revision. Each fragment is a string except revision (int).37# Compile the regular expression used to parse charmworld bundle URLs.
38_charmworld_url_expression = re.compile(r"""
39 ^ # Beginning of the line.
40 (?:bundle:) # Bundle schema.
41 (?:~({user_pattern})/)? # Optional user name.
42 ({name_pattern})/ # Basket name.
43 (?:(\d+)/)? # Optional bundle revision number.
44 ({name_pattern}) # Bundle name.
45 /? # Optional trailing slash.
46 $ # End of the line.
47""".format(
48 name_pattern=_NAME_PATTERN,
49 user_pattern=_USER_PATTERN,
50), re.VERBOSE)
51# Compile the regular expression used to parse new jujucharms entity URLs.
52_jujucharms_url_expression = re.compile(r"""
53 ^ # Beginning of the line.
54 (?:
55 (?:{jujucharms})? # Optional jujucharms.com URL.
56 |
57 /? # Optional leading slash.
58 )?
59 (?:u/({user_pattern})/)? # Optional user name.
60 ({name_pattern}) # Bundle name.
61 (?:/({series_pattern}))? # Optional series.
62 (?:/(\d+))? # Optional bundle revision number.
63 /? # Optional trailing slash.
64 $ # End of the line.
65""".format(
66 jujucharms=settings.JUJUCHARMS_URL,
67 name_pattern=_NAME_PATTERN,
68 series_pattern=_SERIES_PATTERN,
69 user_pattern=_USER_PATTERN,
70), re.VERBOSE)
71
72
73class Reference(object):
74 """Represent a charm or bundle URL reference."""
75
76 def __init__(self, schema, user, series, name, revision):
77 """Initialize the reference. Receives the URL fragments."""
78 self.schema = schema
79 self.user = user
80 self.series = series
81 self.name = name
82 if revision is not None:
83 revision = int(revision)
84 self.revision = revision
85 # XXX frankban 2015-02-26: remove the following attribute when
86 # switching to the new bundle format, and when we have a better way
87 # to increase bundle deployments count.
88 self.charmworld_id = None
89
90 @classmethod
91 def from_fully_qualified_url(cls, url):
92 """Given an entity URL as a string, create and return a Reference.
93
94 Fully qualified URLs represent the regular entity reference
95 representation in Juju, e.g.: "cs:`~who/vivid/django-42" or
96 "local:bundle/wordpress-0".
97
98 Raise a ValueError if the provided value is not a valid and fully
99 qualified URL, also including the schema and the revision.
100 """
101 return cls(*_parse_fully_qualified_url(url))
102
103 @classmethod
104 def from_charmworld_url(cls, url):
105 """Create and return a Reference from the given charmworld URL.
106
107 These kind of "bundle:basket/name" URLs were used before the release
108 of the new charm store (API version 4). Possible examples are
109 "bundle:mediawiki/single" or "bundle:~who/wordpress/42/scalable".
110 Note that charmworld URLs always represent a bundle.
111
112 Raise a ValueError if the provided URL is not valid.
113 """
114 match = _charmworld_url_expression.match(url)
115 if match is None:
116 msg = 'invalid bundle URL: {}'.format(url)
117 raise ValueError(msg.encode('utf-8'))
118 user, basket, revision, name = match.groups()
119 name = '{}-{}'.format(basket, name)
120 self = cls('cs', user, 'bundle', name, revision)
121 # XXX frankban 2015-02-26: remove this when switching to the new bundle
122 # format. Note that this is monkey patched on purpose: we don't want
123 # the legacy bundle id to be part of this class contract, and we don't
124 # want to keep track of obsolete concepts such as "basket" here.
125 self.charmworld_id = url[len('bundle:'):]
126 return self
127
128 @classmethod
129 def from_jujucharms_url(cls, url):
130 """Create and return a Reference from the given jujucharms.com URL.
131
132 These are the preferred way to refer to a charm or bundle in Juju
133 Quickstart. They basically look like the URL paths in jujucharms.com,
134 e.g. "u/who/django", "mediawiki/42" or just "mediawiki". The full HTTP
135 URL can be also provided, for instance "https://jujucharms.com/django".
136
137 Raise a ValueError if the provided URL is not valid.
138 """
139 match = _jujucharms_url_expression.match(url)
140 if match is None:
141 msg = 'invalid bundle URL: {}'.format(url)
142 raise ValueError(msg.encode('utf-8'))
143 user, name, series, revision = match.groups()
144 return cls('cs', user, series or 'bundle', name, revision)
145
146 def __str__(self):
147 """The string representation of a reference is its URL string."""
148 return self.__unicode__().encode('utf-8')
149
150 def __unicode__(self):
151 """The unicode representation of a reference is its URL string."""
152 return self.id()
153
154 def __repr__(self):
155 return b'<Reference: {}>'.format(bytes(self))
156
157 def __eq__(self, other):
158 """Two refs are equal if they have the same string representation."""
159 return isinstance(other, self.__class__) and self.id() == other.id()
160
161 def path(self):
162 """Return the reference as a string without the schema."""
163 user_part = '~{}/'.format(self.user) if self.user else ''
164 revision_part = ''
165 if self.revision is not None:
166 revision_part = '-{}'.format(self.revision)
167 return '{}{}/{}{}'.format(
168 user_part, self.series, self.name, revision_part)
169
170 def id(self):
171 """Return the reference URL as a string."""
172 return '{}:{}'.format(self.schema, self.path())
173
174 def jujucharms_id(self):
175 """Return the identifier of this reference in jujucharms.com."""
176 user_part = 'u/{}/'.format(self.user) if self.user else ''
177 series_part = '' if self.is_bundle() else '/{}'.format(self.series)
178 revision_part = ''
179 if self.revision is not None:
180 revision_part = '/{}'.format(self.revision)
181 return '{}{}{}{}'.format(
182 user_part, self.name, series_part, revision_part)
183
184 def jujucharms_url(self):
185 """Return the URL where this entity lives in jujucharms.com."""
186 return settings.JUJUCHARMS_URL + self.jujucharms_id()
187
188 def is_bundle(self):
189 """Report whether this reference refers to a bundle entity."""
190 return self.series == 'bundle'
191
192 def is_local(self):
193 """Return True if this refers to a local entity, False otherwise."""
194 return self.schema == 'local'
195
196
197def _parse_fully_qualified_url(url):
198 """Parse the given charm or bundle URL, provided as a string.
199
200 Return a tuple containing the entity reference fragments: schema, user,
201 series, name and revision. Each fragment is a string except revision (int).
36202
37 Raise a ValueError with a descriptive message if the given URL is not a203 Raise a ValueError with a descriptive message if the given URL is not a
38 valid charm URL.204 valid and fully qualified entity URL.
39 """205 """
40 # Retrieve the schema.206 # Retrieve the schema.
41 try:207 try:
42 schema, remaining = url.split(':', 1)208 schema, remaining = url.split(':', 1)
43 except ValueError:209 except ValueError:
44 msg = 'charm URL has no schema: {}'.format(url)210 msg = 'URL has no schema: {}'.format(url)
45 raise ValueError(msg.encode('utf-8'))211 raise ValueError(msg.encode('utf-8'))
46 if schema not in ('cs', 'local'):212 if schema not in ('cs', 'local'):
47 msg = 'charm URL has invalid schema: {}'.format(schema)213 msg = 'URL has invalid schema: {}'.format(schema)
48 raise ValueError(msg.encode('utf-8'))214 raise ValueError(msg.encode('utf-8'))
49 # Retrieve the optional user, the series, name and revision.215 # Retrieve the optional user, the series, name and revision.
50 parts = remaining.split('/')216 parts = remaining.split('/')
@@ -52,82 +218,37 @@
52 if parts_length == 3:218 if parts_length == 3:
53 user, series, name_revision = parts219 user, series, name_revision = parts
54 if not user.startswith('~'):220 if not user.startswith('~'):
55 msg = 'charm URL has invalid user name form: {}'.format(user)221 msg = 'URL has invalid user name form: {}'.format(user)
56 raise ValueError(msg.encode('utf-8'))222 raise ValueError(msg.encode('utf-8'))
57 user = user[1:]223 user = user[1:]
58 if not valid_user(user):224 if not _valid_user(user):
59 msg = 'charm URL has invalid user name: {}'.format(user)225 msg = 'URL has invalid user name: {}'.format(user)
60 raise ValueError(msg.encode('utf-8'))226 raise ValueError(msg.encode('utf-8'))
61 if schema == 'local':227 if schema == 'local':
62 msg = 'local charm URL with user name: {}'.format(url)228 msg = 'local entity URL with user name: {}'.format(url)
63 raise ValueError(msg.encode('utf-8'))229 raise ValueError(msg.encode('utf-8'))
64 elif parts_length == 2:230 elif parts_length == 2:
65 user = ''231 user = ''
66 series, name_revision = parts232 series, name_revision = parts
67 else:233 else:
68 msg = 'charm URL has invalid form: {}'.format(url)234 msg = 'URL has invalid form: {}'.format(url)
69 raise ValueError(msg.encode('utf-8'))235 raise ValueError(msg.encode('utf-8'))
70 # Validate the series.236 # Validate the series.
71 if not valid_series(series):237 if not _valid_series(series):
72 msg = 'charm URL has invalid series: {}'.format(series)238 msg = 'URL has invalid series: {}'.format(series)
73 raise ValueError(msg.encode('utf-8'))239 raise ValueError(msg.encode('utf-8'))
74 # Validate name and revision.240 # Validate name and revision.
75 try:241 try:
76 name, revision = name_revision.rsplit('-', 1)242 name, revision = name_revision.rsplit('-', 1)
77 except ValueError:243 except ValueError:
78 msg = 'charm URL has no revision: {}'.format(url)244 msg = 'URL has no revision: {}'.format(url)
79 raise ValueError(msg.encode('utf-8'))245 raise ValueError(msg.encode('utf-8'))
80 if not valid_name(name):246 if not _valid_name(name):
81 msg = 'charm URL has invalid name: {}'.format(name)247 msg = 'URL has invalid name: {}'.format(name)
82 raise ValueError(msg.encode('utf-8'))248 raise ValueError(msg.encode('utf-8'))
83 try:249 try:
84 revision = int(revision)250 revision = int(revision)
85 except ValueError:251 except ValueError:
86 msg = 'charm URL has invalid revision: {}'.format(revision)252 msg = 'URL has invalid revision: {}'.format(revision)
87 raise ValueError(msg.encode('utf-8'))253 raise ValueError(msg.encode('utf-8'))
88 return schema, user, series, name, revision254 return schema, user, series, name, revision
89
90
91class Charm(object):
92 """Represent the charm information stored in the charm URL."""
93
94 def __init__(self, schema, user, series, name, revision):
95 """Initialize the charm. Receives the URL fragments."""
96 self.schema = schema
97 self.user = user
98 self.series = series
99 self.name = name
100 self.revision = int(revision)
101
102 @classmethod
103 def from_url(cls, url):
104 """Given a charm URL, create and return a Charm instance.
105
106 Raise a ValueError if the charm URL is not valid.
107 """
108 return cls(*parse_url(url))
109
110 def __str__(self):
111 """The string representation of a charm is its URL."""
112 return self.__unicode__().encode('utf-8')
113
114 def __unicode__(self):
115 """The unicode representation of a charm is its URL."""
116 return self.url()
117
118 def __repr__(self):
119 return b'<Charm: {}>'.format(bytes(self))
120
121 def __eq__(self, other):
122 """Two charms are equal if they have the same URL."""
123 return isinstance(other, self.__class__) and self.url() == other.url()
124
125 def url(self):
126 """Return the charm URL."""
127 user_part = '~{}/'.format(self.user) if self.user else ''
128 return '{}:{}{}/{}-{}'.format(
129 self.schema, user_part, self.series, self.name, self.revision)
130
131 def is_local(self):
132 """Return True if this is a local charm, False otherwise."""
133 return self.schema == 'local'
134255
=== modified file 'quickstart/netutils.py'
--- quickstart/netutils.py 2015-01-12 15:00:52 +0000
+++ quickstart/netutils.py 2015-02-27 18:40:58 +0000
@@ -69,8 +69,8 @@
69 Raise an IOError if any problems occur connecting to the API endpoint.69 Raise an IOError if any problems occur connecting to the API endpoint.
70 Raise a ValueError if the API returns invalid data.70 Raise a ValueError if the API returns invalid data.
71 """71 """
72 url = settings.CHARMSTORE_API.format(72 url = '{}{}/{}/meta/id'.format(
73 series=series, charm=settings.JUJU_GUI_CHARM_NAME)73 settings.CHARMSTORE_API, series, settings.JUJU_GUI_CHARM_NAME)
74 data = json.loads(urlread(url))74 data = json.loads(urlread(url))
75 charm_url = data.get('Id')75 charm_url = data.get('Id')
76 if charm_url is None:76 if charm_url is None:
7777
=== modified file 'quickstart/settings.py'
--- quickstart/settings.py 2015-02-09 15:56:20 +0000
+++ quickstart/settings.py 2015-02-27 18:40:58 +0000
@@ -29,11 +29,8 @@
29UNKNOWN_PLATFORM = object()29UNKNOWN_PLATFORM = object()
30WINDOWS = object()30WINDOWS = object()
3131
32# The base charm store API URL containing information about charms.32# The base charm store API URL containing information about charms and bundles.
33# This URL must be formatted with a series and a charm name.33CHARMSTORE_API = 'https://api.jujucharms.com/charmstore/v4/'
34CHARMSTORE_API = (
35 'https://api.jujucharms.com'
36 '/charmstore/v4/{series}/{charm}/meta/id')
3734
38# The default Juju GUI charm URLs for each supported series. Used when it is35# The default Juju GUI charm URLs for each supported series. Used when it is
39# not possible to retrieve the charm URL from the charm store API, e.g. due to36# not possible to retrieve the charm URL from the charm store API, e.g. due to
@@ -47,8 +44,8 @@
47# The quickstart app short description.44# The quickstart app short description.
48DESCRIPTION = 'set up a Juju environment (including the GUI) in very few steps'45DESCRIPTION = 'set up a Juju environment (including the GUI) in very few steps'
4946
50# The URL namespace for bundles in jujucharms.com.47# The URL of jujucharms.com, the home of Juju.
51JUJUCHARMS_BUNDLE_URL = 'https://jujucharms.com/bundle/'48JUJUCHARMS_URL = 'https://jujucharms.com/'
5249
53# The path to the Juju command, based on platform.50# The path to the Juju command, based on platform.
54JUJU_CMD_PATHS = {51JUJU_CMD_PATHS = {
5552
=== modified file 'quickstart/tests/functional/test_functional.py'
--- quickstart/tests/functional/test_functional.py 2015-02-05 12:19:48 +0000
+++ quickstart/tests/functional/test_functional.py 2015-02-27 18:40:58 +0000
@@ -167,7 +167,7 @@
167 def test_bundle_deployment(self):167 def test_bundle_deployment(self):
168 # The application can be used to deploy bundles.168 # The application can be used to deploy bundles.
169 retcode, output, error = run_quickstart(169 retcode, output, error = run_quickstart(
170 self.env_name, 'bundle:mediawiki/single')170 self.env_name, 'mediawiki-single')
171 self.assertEqual(0, retcode)171 self.assertEqual(0, retcode)
172 self.assertIn('bundle deployment request accepted', output)172 self.assertIn('bundle deployment request accepted', output)
173 self.assertEqual('', error)173 self.assertEqual('', error)
174174
=== modified file 'quickstart/tests/helpers.py'
--- quickstart/tests/helpers.py 2015-02-09 11:22:44 +0000
+++ quickstart/tests/helpers.py 2015-02-27 18:40:58 +0000
@@ -47,15 +47,18 @@
47class BundleFileTestsMixin(object):47class BundleFileTestsMixin(object):
48 """Shared methods for testing Juju bundle files."""48 """Shared methods for testing Juju bundle files."""
4949
50 valid_bundle = yaml.safe_dump({50 bundle_data = {'services': {'wordpress': {}, 'mysql': {}}}
51 bundle_content = yaml.safe_dump(bundle_data)
52 legacy_bundle_data = {
51 'bundle1': {'services': {'wordpress': {}, 'mysql': {}}},53 'bundle1': {'services': {'wordpress': {}, 'mysql': {}}},
52 'bundle2': {'services': {'django': {}, 'nodejs': {}}},54 'bundle2': {'services': {'django': {}, 'nodejs': {}}},
53 })55 }
56 legacy_bundle_content = yaml.safe_dump(legacy_bundle_data)
5457
55 def _write_bundle_file(self, bundle_file, contents):58 def _write_bundle_file(self, bundle_file, contents):
56 """Parse and write contents into the given bundle file object."""59 """Parse and write contents into the given bundle file object."""
57 if contents is None:60 if contents is None:
58 contents = self.valid_bundle61 contents = self.bundle_content
59 elif isinstance(contents, dict):62 elif isinstance(contents, dict):
60 contents = yaml.safe_dump(contents)63 contents = yaml.safe_dump(contents)
61 bundle_file.write(contents)64 bundle_file.write(contents)
@@ -64,31 +67,15 @@
64 """Create a Juju bundle file containing the given contents.67 """Create a Juju bundle file containing the given contents.
6568
66 If contents is None, use the valid bundle contents defined in69 If contents is None, use the valid bundle contents defined in
67 self.valid_bundle.70 self.bundle_content.
68 Return the bundle file path.71 Return the bundle file path.
69 """72 """
70 bundle_file = tempfile.NamedTemporaryFile(delete=False)73 bundle_file = tempfile.NamedTemporaryFile(delete=False, suffix='.yaml')
71 self.addCleanup(os.remove, bundle_file.name)74 self.addCleanup(os.remove, bundle_file.name)
72 self._write_bundle_file(bundle_file, contents)75 self._write_bundle_file(bundle_file, contents)
73 bundle_file.close()76 bundle_file.close()
74 return bundle_file.name77 return bundle_file.name
7578
76 def make_bundle_dir(self, contents=None):
77 """Create a Juju bundle directory including a bundles.yaml file.
78
79 The file will contain the given contents.
80
81 If contents is None, use the valid bundle contents defined in
82 self.valid_bundle.
83 Return the bundle directory path.
84 """
85 bundle_dir = tempfile.mkdtemp()
86 self.addCleanup(shutil.rmtree, bundle_dir)
87 bundle_path = os.path.join(bundle_dir, 'bundles.yaml')
88 with open(bundle_path, 'w') as bundle_file:
89 self._write_bundle_file(bundle_file, contents)
90 return bundle_dir
91
9279
93class CallTestsMixin(object):80class CallTestsMixin(object):
94 """Easily use the quickstart.utils.call function."""81 """Easily use the quickstart.utils.call function."""
9582
=== modified file 'quickstart/tests/models/test_bundles.py'
--- quickstart/tests/models/test_bundles.py 2015-02-09 12:58:04 +0000
+++ quickstart/tests/models/test_bundles.py 2015-02-27 18:40:58 +0000
@@ -24,231 +24,419 @@
24import yaml24import yaml
2525
26from quickstart import settings26from quickstart import settings
27from quickstart.models import bundles27from quickstart.models import (
28 bundles,
29 references,
30)
28from quickstart.tests import helpers31from quickstart.tests import helpers
2932
3033
31class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):34class TestBundle(helpers.BundleFileTestsMixin, unittest.TestCase):
3235
33 def test_full_bundle_url(self):36 reference = references.Reference.from_jujucharms_url('django')
34 # The HTTPS location to the YAML contents is correctly returned.37
35 bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki'38 def setUp(self):
36 url, bundle_id = bundles.convert_bundle_url(bundle_url)39 # Create a bundle instance.
37 self.assertEqual(40 self.bundle = bundles.Bundle(
38 'https://manage.jujucharms.com'41 self.bundle_data, reference=self.reference)
39 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)42
40 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)43 def test_attributes(self):
4144 # The bundle data and the optional reference are stored as attributes.
42 def test_bundle_url_right_strip(self):45 self.assertEqual(self.bundle_data, self.bundle.data)
43 # The trailing slash in the bundle URL is removed.46 self.assertEqual(self.reference, self.bundle.reference)
44 bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/'47
45 url, bundle_id = bundles.convert_bundle_url(bundle_url)48 def test_string(self):
46 self.assertEqual(49 # The bundle correctly represents itself as a string.
47 'https://manage.jujucharms.com'50 self.assertEqual('bundle django', str(self.bundle))
48 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)51 # Create a bundle without a reference.
49 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)52 bundle = bundles.Bundle(self.bundle_data)
5053 self.assertEqual('bundle', str(bundle))
51 def test_bundle_url_no_revision(self):54
52 # The bundle revision is optional.55 def test_repr(self):
53 bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple'56 # The bundle correctly represents itself as an object.
54 url, bundle_id = bundles.convert_bundle_url(bundle_url)57 self.assertEqual('<Bundle: bundle django>', repr(self.bundle))
55 self.assertEqual(58 # Create a bundle without a reference.
56 'https://manage.jujucharms.com'59 bundle = bundles.Bundle(self.bundle_data)
57 '/bundle/~myuser/wiki-bundle/wiki-simple/json', url)60 self.assertEqual('<Bundle: bundle>', repr(bundle))
58 self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id)61
5962 def test_serialization(self):
60 def test_bundle_url_no_user(self):63 # The bundle data is correctly serialized into a YAML encoded string.
61 # If the bundle user is not specified, the bundle is assumed to be64 content = self.bundle.serialize()
62 # promulgated and owned by "charmers".65 self.assertEqual('services:\n mysql: {}\n wordpress: {}\n', content)
63 bundle_url = 'bundle:wiki-bundle/1/wiki'66
64 url, bundle_id = bundles.convert_bundle_url(bundle_url)67 def test_legacy_serialization(self):
65 self.assertEqual(68 # The bundle data can be serialized for legacy API version 3.
66 'https://manage.jujucharms.com'69 content = self.bundle.serialize_legacy()
67 '/bundle/~charmers/wiki-bundle/1/wiki/json', url)70 self.assertEqual(
68 self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id)71 'bundle:\n services:\n mysql: {}\n wordpress: {}\n',
6972 content)
70 def test_bundle_url_short_form(self):73
71 # A promulgated bundle URL can just include the basket and the name.74 def test_services(self):
72 bundle_url = 'bundle:wiki-bundle/wiki'75 # Bundle services can be easily retrieved.
73 url, bundle_id = bundles.convert_bundle_url(bundle_url)76 self.assertEqual(['mysql', 'wordpress'], self.bundle.services())
74 self.assertEqual(77
75 'https://manage.jujucharms.com'78
76 '/bundle/~charmers/wiki-bundle/wiki/json', url)79class TestFromSource(
77 self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)80 helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin,
7881 helpers.ValueErrorTestsMixin, unittest.TestCase):
79 def test_full_jujucharms_url(self):82
80 # The HTTPS location to the YAML contents is correctly returned.83 def test_charmworld_bundle(self):
81 url, bundle_id = bundles.convert_bundle_url(84 # A bundle instance is properly returned from a charmworld id source.
82 settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki')85 with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
83 self.assertEqual(86 bundle = bundles.from_source('bundle:mediawiki/single')
84 'https://manage.jujucharms.com'87 self.assertEqual(self.bundle_data, bundle.data)
85 '/bundle/~myuser/wiki-bundle/42/wiki/json', url)88 self.assertEqual('cs:bundle/mediawiki-single', bundle.reference.id())
86 self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)89 mock_urlread.assert_called_once_with(
8790 settings.CHARMSTORE_API +
88 def test_jujucharms_url_right_strip(self):91 'bundle/mediawiki-single/archive/bundle.yaml')
89 # The trailing slash in the jujucharms URL is removed.92
90 url, bundle_id = bundles.convert_bundle_url(93 def test_charmworld_bundle_with_user_and_revision(self):
91 settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/')94 # A specific revision of a user owned bundle is properly returned from
92 self.assertEqual(95 # a charmworld id source.
93 'https://manage.jujucharms.com'96 with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
94 '/bundle/~charmers/mediawiki/6/scalable/json', url)97 bundle = bundles.from_source('bundle:~who/mediawiki/42/single')
95 self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id)98 self.assertEqual(self.bundle_data, bundle.data)
9699 self.assertEqual(
97 def test_jujucharms_url_no_revision(self):100 'cs:~who/bundle/mediawiki-single-42', bundle.reference.id())
98 # The bundle revision is optional.101 mock_urlread.assert_called_once_with(
99 url, bundle_id = bundles.convert_bundle_url(102 settings.CHARMSTORE_API +
100 settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/')103 '~who/bundle/mediawiki-single-42/archive/bundle.yaml')
101 self.assertEqual(104
102 'https://manage.jujucharms.com'105 def test_charmworld_bundle_deprecation_warning(self):
103 '/bundle/~myuser/wiki/wiki-simple/json', url)106 # A deprecation warning is printed if the no longer supported
104 self.assertEqual('~myuser/wiki/wiki-simple', bundle_id)107 # charmworld bundle identifiers are used.
105108 expected_logs = [
106 def test_jujucharms_url_no_user(self):109 'this bundle URL is deprecated: please use the new format: '
107 # If the bundle user is not specified, the bundle is assumed to be110 'mediawiki-single']
108 # promulgated and owned by "charmers".111 with self.patch_urlread(contents=self.bundle_content):
109 url, bundle_id = bundles.convert_bundle_url(112 with helpers.assert_logs(expected_logs, 'warn'):
110 settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/')113 bundles.from_source('bundle:mediawiki/single')
111 self.assertEqual(114
112 'https://manage.jujucharms.com'115 def test_charmworld_bundle_invalid_url(self):
113 '/bundle/~charmers/mediawiki/42/single/json', url)116 # A ValueError is raised if the provided charmworld id is not valid.
114 self.assertEqual('~charmers/mediawiki/42/single', bundle_id)117 with self.assert_value_error('invalid bundle URL: bundle:invalid'):
115118 bundles.from_source('bundle:invalid')
116 def test_jujucharms_url_short_form(self):119
117 # A jujucharms URL for a promulgated bundle can just include the basket120 def test_charmworld_bundle_invalid_content(self):
118 # and the name.121 # A ValueError is raised if the content associated with the given
119 url, bundle_id = bundles.convert_bundle_url(122 # charmworld URL are not valid.
120 settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/')123 with self.patch_urlread(error=True):
121 self.assertEqual(124 with self.assertRaises(IOError) as ctx:
122 'https://manage.jujucharms.com'125 bundles.from_source('bundle:mediawiki/single')
123 '/bundle/~charmers/wiki-bundle/wiki/json', url)126 expected_error = (
124 self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)127 'cannot retrieve bundle from remote URL '
125128 '{}bundle/mediawiki-single/archive/bundle.yaml: '
126 def test_error(self):129 'bad wolf'.format(settings.CHARMSTORE_API))
127 # A ValueError is raised if the bundle/jujucharms URL is not valid.130 self.assertEqual(expected_error, bytes(ctx.exception))
128 bad_urls = (131
129 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such',132 def test_charmworld_bundle_connection_error(self):
130 'bundle:~user/name', 'bundle:~user/basket/revision/name',133 # An IOError is raised if a connection problem is encountered while
131 'bundle:basket/name//', 'bundle:basket.name/bundle.name',134 # retrieving the charmworld bundle.
132 settings.JUJUCHARMS_BUNDLE_URL,135 with self.patch_urlread(contents='exterminate!'):
133 settings.JUJUCHARMS_BUNDLE_URL + 'bad',136 with self.assert_value_error('invalid YAML content: exterminate!'):
134 settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such',137 bundles.from_source('bundle:mediawiki/single')
135 settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/',138
136 settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error',139 def test_jujucharms_bundle(self):
137 'https://jujucharms.com/charms/mediawiki/simple/',140 # A bundle instance is properly returned from a jujucharms.com id.
138 )141 with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
139 for url in bad_urls:142 bundle = bundles.from_source('django')
140 with self.assert_value_error('invalid bundle URL: {}'.format(url)):143 self.assertEqual(self.bundle_data, bundle.data)
141 bundles.convert_bundle_url(url)144 self.assertEqual('cs:bundle/django', bundle.reference.id())
142145 mock_urlread.assert_called_once_with(
143146 settings.CHARMSTORE_API + 'bundle/django/archive/bundle.yaml')
144class TestParseBundle(147
148 def test_jujucharms_bundle_with_user_and_revision(self):
149 # A specific revision of a user owned bundle is properly returned from
150 # a jujucharms.com id source.
151 with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
152 bundle = bundles.from_source('u/who/mediawiki-single/42')
153 self.assertEqual(self.bundle_data, bundle.data)
154 self.assertEqual(
155 'cs:~who/bundle/mediawiki-single-42', bundle.reference.id())
156 mock_urlread.assert_called_once_with(
157 settings.CHARMSTORE_API +
158 '~who/bundle/mediawiki-single-42/archive/bundle.yaml')
159
160 def test_jujucharms_bundle_charm_error(self):
161 # A ValueError is raised if the given jujucharms.com id refers to a
162 # charm and not to a bundle.
163 expected_error = 'expected a bundle, provided charm cs:trusty/django'
164 with self.assert_value_error(expected_error):
165 bundles.from_source('django/trusty')
166
167 def test_jujucharms_bundle_invalid_url(self):
168 # A ValueError is raised if the provided jujucharms.com identifier is
169 # not valid.
170 with self.assert_value_error('invalid bundle URL: u/no/such/bundle/!'):
171 bundles.from_source('u/no/such/bundle/!')
172
173 def test_jujucharms_bundle_invalid_content(self):
174 # A ValueError is raised if the content associated with the given
175 # jujucharms.com URL are not valid.
176 with self.patch_urlread(error=True):
177 with self.assertRaises(IOError) as ctx:
178 bundles.from_source('django/42')
179 expected_error = (
180 'cannot retrieve bundle from remote URL '
181 '{}bundle/django-42/archive/bundle.yaml: '
182 'bad wolf'.format(settings.CHARMSTORE_API))
183 self.assertEqual(expected_error, bytes(ctx.exception))
184
185 def test_jujucharms_bundle_connection_error(self):
186 # An IOError is raised if a connection problem is encountered while
187 # retrieving the jujucharms.com bundle.
188 with self.patch_urlread(contents='exterminate!'):
189 with self.assert_value_error('invalid YAML content: exterminate!'):
190 bundles.from_source('wordpress-scalable')
191
192 def test_local_file(self):
193 # A bundle instance can be created from a local file source.
194 # In this case, the resulting bundle does not have a reference.
195 path = self.make_bundle_file()
196 bundle = bundles.from_source(path)
197 self.assertEqual(self.bundle_data, bundle.data)
198 self.assertIsNone(bundle.reference)
199
200 def test_local_file_legacy_bundle(self):
201 # A bundle instance can be created from a local file source including
202 # a legacy version 3 bundle.
203 # In this case, the resulting bundle does not have a reference.
204 legacy_bundle_data = {
205 'bundle': {'services': {'wordpress': {}, 'mysql': {}}},
206 }
207 path = self.make_bundle_file(legacy_bundle_data)
208 bundle = bundles.from_source(path)
209 self.assertEqual(legacy_bundle_data['bundle'], bundle.data)
210 self.assertIsNone(bundle.reference)
211
212 def test_local_file_not_found(self):
213 # An IOError is raised if a local file source cannot be found.
214 with self.assertRaises(IOError) as ctx:
215 bundles.from_source('/no/such/file.yaml')
216 expected_error = (
217 'cannot retrieve bundle from local file: '
218 "[Errno 2] No such file or directory: '/no/such/file.yaml'")
219 self.assertEqual(expected_error, bytes(ctx.exception))
220
221 def test_local_file_legacy_bundle_no_bundles_error(self):
222 # A ValueError is raised if a local file contains a legacy version 3
223 # YAML content with no bundles defined.
224 path = self.make_bundle_file({})
225 expected_error = 'no bundles found in the provided list of bundles'
226 with self.assert_value_error(expected_error):
227 bundles.from_source(path)
228
229 def test_local_file_legacy_bundle_multiple_bundles_error(self):
230 # A ValueError is raised if a local file contains a legacy version 3
231 # YAML content with multiple bundles defined and the bundle name is
232 # not provided for disambiguation.
233 path = self.make_bundle_file(self.legacy_bundle_content)
234 expected_error = (
235 'multiple bundles found (bundle1, bundle2) '
236 'but no bundle name specified')
237 with self.assert_value_error(expected_error):
238 bundles.from_source(path)
239
240 def test_local_file_legacy_bundle_multiple_bundles_name_not_found(self):
241 # A ValueError is raised if a local file contains a legacy version 3
242 # YAML content with multiple bundles defined and the provided bundle
243 # name is not included in the list.
244 path = self.make_bundle_file(self.legacy_bundle_content)
245 expected_error = (
246 'bundle mybundle not found in the provided list of bundles '
247 '(bundle1, bundle2)')
248 with self.assert_value_error(expected_error):
249 bundles.from_source(path, 'mybundle')
250
251 def test_local_file_legacy_bundle_invalid_bundle_content(self):
252 # A ValueError is raised if a local file contains an invalid legacy
253 # version 3 content.
254 path = self.make_bundle_file({'bundle': '42'})
255 expected_error = "unable to retrieve bundle services: '42'"
256 with self.assert_value_error(expected_error):
257 bundles.from_source(path, 'bundle')
258
259 def test_remote_url(self):
260 # A bundle instance can be created from an arbitrary remote URL
261 # pointing to a valid YAML/JSON content.
262 # In this case, the resulting bundle does not have a reference.
263 with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
264 bundle = bundles.from_source('https://1.2.3.4')
265 self.assertEqual(self.bundle_data, bundle.data)
266 self.assertIsNone(bundle.reference)
267 mock_urlread.assert_called_once_with('https://1.2.3.4')
268
269 def test_remote_url_legacy_bundle(self):
270 # A bundle instance can be created from an arbitrary remote URL
271 # pointing to a legacy version 3 content.
272 # In this case, the resulting bundle does not have a reference.
273 content = self.legacy_bundle_content
274 with self.patch_urlread(contents=content) as mock_urlread:
275 bundle = bundles.from_source('https://1.2.3.4:8000', 'bundle2')
276 self.assertEqual(self.legacy_bundle_data['bundle2'], bundle.data)
277 self.assertIsNone(bundle.reference)
278 mock_urlread.assert_called_once_with('https://1.2.3.4:8000')
279
280 def test_remote_url_not_reachable(self):
281 # An IOError is raised if a network problem is encountered while
282 # trying to reach the remote URL.
283 with self.patch_urlread(error=True):
284 with self.assertRaises(IOError) as ctx:
285 bundles.from_source('https://1.2.3.4')
286 expected_error = (
287 'cannot retrieve bundle from remote URL https://1.2.3.4: bad wolf')
288 self.assertEqual(expected_error, bytes(ctx.exception))
289
290 def test_remote_url_legacy_bundle_no_bundles_error(self):
291 # A ValueError is raised if a remote URL contains a legacy version 3
292 # YAML content with no bundles defined.
293 expected_error = 'no bundles found in the provided list of bundles'
294 with self.patch_urlread(contents='{}'):
295 with self.assert_value_error(expected_error):
296 bundles.from_source('http://1.2.3.4')
297
298 def test_remote_url_legacy_bundle_multiple_bundles_error(self):
299 # A ValueError is raised if a remote URL contains a legacy version 3
300 # YAML content with multiple bundles defined and the bundle name is
301 # not provided for disambiguation.
302 expected_error = (
303 'multiple bundles found (bundle1, bundle2) '
304 'but no bundle name specified')
305 with self.patch_urlread(contents=self.legacy_bundle_content):
306 with self.assert_value_error(expected_error):
307 bundles.from_source('http://1.2.3.4')
308
309 def test_remote_url_legacy_bundle_multiple_bundles_name_not_found(self):
310 # A ValueError is raised if a remote URL contains a legacy version 3
311 # YAML content with multiple bundles defined and the provided bundle
312 # name is not included in the list.
313 expected_error = (
314 'bundle no-such not found in the provided list of bundles '
315 '(bundle1, bundle2)')
316 with self.patch_urlread(contents=self.legacy_bundle_content):
317 with self.assert_value_error(expected_error):
318 bundles.from_source('http://1.2.3.4', 'no-such')
319
320 def test_remote_url_legacy_bundle_invalid_bundle_content(self):
321 # A ValueError is raised if a remote URL contains an invalid legacy
322 # version 3 content.
323 with self.patch_urlread(contents='bad wolf'):
324 with self.assert_value_error('invalid YAML content: bad wolf'):
325 bundles.from_source('http://1.2.3.4', 'bundle')
326
327
328class TestParseYAML(
145 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,329 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
146 unittest.TestCase):330 unittest.TestCase):
147331
148 def assert_bundle(
149 self, expected_name, expected_services, contents,
150 bundle_name=None):
151 """Ensure parsing the given contents returns the expected values."""
152 name, services = bundles.parse_bundle(
153 contents, bundle_name=bundle_name)
154 self.assertEqual(expected_name, name)
155 self.assertEqual(set(expected_services), set(services))
156
157 def test_invalid_yaml(self):332 def test_invalid_yaml(self):
158 # A ValueError is raised if the bundle contents are not a valid YAML.333 # A ValueError is raised if the bundle content is not a valid YAML.
159 with self.assertRaises(ValueError) as context_manager:334 with self.assertRaises(ValueError) as ctx:
160 bundles.parse_bundle(':')335 bundles.parse_yaml(':')
161 expected = 'unable to parse the bundle'336 expected = 'unable to parse the bundle'
162 self.assertIn(expected, bytes(context_manager.exception))337 self.assertIn(expected, bytes(ctx.exception))
163338
164 def test_yaml_invalid_type(self):339 def test_yaml_invalid_type(self):
165 # A ValueError is raised if the bundle contents are not well formed.340 # A ValueError is raised if the bundle content is not well formed.
166 with self.assert_value_error('invalid YAML contents: a-string'):341 with self.assert_value_error('invalid YAML content: a-string'):
167 bundles.parse_bundle('a-string')342 bundles.parse_yaml('a-string')
168343
169 def test_yaml_invalid_bundle_data(self):344 def test_yaml_invalid_bundle_data(self):
170 # A ValueError is raised if bundles are not well formed.345 # A ValueError is raised if the bundle content is not well formed.
171 contents = yaml.safe_dump({'mybundle': 'not valid'})346 contents = yaml.safe_dump('not valid')
172 expected = 'invalid YAML contents: {mybundle: not valid}\n'347 with self.assert_value_error('invalid YAML content: not valid'):
173 with self.assert_value_error(expected):348 bundles.parse_yaml(contents)
174 bundles.parse_bundle(contents)
175349
176 def test_yaml_no_service(self):350 def test_yaml_no_services(self):
177 # A ValueError is raised if bundles do not include services.351 # A ValueError is raised if the bundle does not include services.
178 contents = yaml.safe_dump({'mybundle': {}})352 contents = yaml.safe_dump({})
179 expected = 'invalid YAML contents: mybundle: {}\n'353 with self.assert_value_error('unable to retrieve bundle services: {}'):
180 with self.assert_value_error(expected):354 bundles.parse_yaml(contents)
181 bundles.parse_bundle(contents)
182355
183 def test_yaml_none_bundle_services(self):356 def test_yaml_none_bundle_services(self):
184 # A ValueError is raised if services are None.357 # A ValueError is raised if services are None.
185 contents = yaml.safe_dump({'mybundle': {'services': None}})358 contents = yaml.safe_dump({'services': None})
186 expected = 'invalid YAML contents: mybundle: {services: null}\n'359 expected = 'unable to retrieve bundle services: services: null'
187 with self.assert_value_error(expected):360 with self.assert_value_error(expected):
188 bundles.parse_bundle(contents)361 bundles.parse_yaml(contents)
189362
190 def test_yaml_invalid_bundle_services_type(self):363 def test_yaml_invalid_bundle_services_type(self):
191 # A ValueError is raised if services have an invalid type.364 # A ValueError is raised if services have an invalid type.
192 contents = yaml.safe_dump({'mybundle': {'services': 42}})365 contents = yaml.safe_dump({'services': 42})
193 expected = 'invalid YAML contents: mybundle: {services: 42}\n'366 expected = 'unable to retrieve bundle services: services: 42'
194 with self.assert_value_error(expected):367 with self.assert_value_error(expected):
195 bundles.parse_bundle(contents)368 bundles.parse_yaml(contents)
196
197 def test_yaml_no_bundles(self):
198 # A ValueError is raised if the bundle contents are empty.
199 with self.assert_value_error('no bundles found'):
200 bundles.parse_bundle(yaml.safe_dump({}))
201
202 def test_bundle_name_not_specified(self):
203 # A ValueError is raised if the bundle name is not specified and the
204 # contents contain more than one bundle.
205 expected = ('multiple bundles found (bundle1, bundle2) '
206 'but no bundle name specified')
207 with self.assert_value_error(expected):
208 bundles.parse_bundle(self.valid_bundle)
209
210 def test_bundle_name_not_found(self):
211 # A ValueError is raised if the given bundle is not found in the file.
212 expected = ('bundle no-such not found in the provided list of bundles '
213 '(bundle1, bundle2)')
214 with self.assert_value_error(expected):
215 bundles.parse_bundle(self.valid_bundle, 'no-such')
216369
217 def test_no_services(self):370 def test_no_services(self):
218 # A ValueError is raised if the specified bundle does not contain371 # A ValueError is raised if the specified bundle does not contain
219 # services.372 # services.
220 contents = yaml.safe_dump({'mybundle': {'services': {}}})373 contents = yaml.safe_dump({'services': {}})
221 expected = 'bundle mybundle does not include any services'374 with self.assert_value_error('no services found in the bundle'):
222 with self.assert_value_error(expected):375 bundles.parse_yaml(contents)
223 bundles.parse_bundle(contents)
224376
225 def test_yaml_gui_in_services(self):377 def test_yaml_gui_in_services(self):
226 # A ValueError is raised if the bundle contains juju-gui.378 # A ValueError is raised if the bundle contains juju-gui.
227 contents = yaml.safe_dump({379 contents = yaml.safe_dump({
228 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}},380 'services': {settings.JUJU_GUI_SERVICE_NAME: {}},
229 })381 })
230 expected = 'bundle mybundle contains an instance of juju-gui. ' \382 expected_error = (
231 'quickstart will install the latest version of the Juju GUI ' \383 'the provided bundle contains an instance of juju-gui. Juju '
232 'automatically, please remove juju-gui from the bundle.'384 'Quickstart will install the latest version of the Juju GUI '
233 with self.assert_value_error(expected):385 'automatically; please remove juju-gui from the bundle')
234 bundles.parse_bundle(contents)386 with self.assert_value_error(expected_error):
235387 bundles.parse_yaml(contents)
236 def test_success_no_name(self):388
237 # The function succeeds when an implicit bundle name is used.389 def test_success(self):
238 contents = yaml.safe_dump({390 # The function succeeds when a valid bundle content is provided.
239 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},391 data = bundles.parse_yaml(self.bundle_content)
240 })392 self.assertEqual(self.bundle_data, data)
241 self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
242
243 def test_success_multiple_bundles(self):
244 # The function succeeds with multiple bundles.
245 self.assert_bundle(
246 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2')
247393
248 def test_success_json(self):394 def test_success_json(self):
249 # Since JSON is a subset of YAML, the function also support JSON395 # Since JSON is a subset of YAML, the function also support JSON
250 # encoded bundles.396 # encoded bundles.
251 contents = json.dumps({397 content = json.dumps(self.bundle_data)
252 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},398 data = bundles.parse_yaml(content)
253 })399 self.assertEqual(self.bundle_data, data)
254 self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)400
401
402class TestValidate(
403 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
404 unittest.TestCase):
405
406 def test_yaml_no_services(self):
407 # A ValueError is raised if the bundle does not include services.
408 with self.assert_value_error('unable to retrieve bundle services: {}'):
409 bundles.validate({})
410
411 def test_yaml_none_bundle_services(self):
412 # A ValueError is raised if services are None.
413 expected = 'unable to retrieve bundle services: services: null'
414 with self.assert_value_error(expected):
415 bundles.validate({'services': None})
416
417 def test_yaml_invalid_bundle_services_type(self):
418 # A ValueError is raised if services have an invalid type.
419 expected = 'unable to retrieve bundle services: services: 42'
420 with self.assert_value_error(expected):
421 bundles.validate({'services': 42})
422
423 def test_no_services(self):
424 # A ValueError is raised if the specified bundle does not contain
425 # services.
426 with self.assert_value_error('no services found in the bundle'):
427 bundles.validate({'services': {}})
428
429 def test_yaml_gui_in_services(self):
430 # A ValueError is raised if the bundle contains juju-gui.
431 expected_error = (
432 'the provided bundle contains an instance of juju-gui. Juju '
433 'Quickstart will install the latest version of the Juju GUI '
434 'automatically; please remove juju-gui from the bundle')
435 with self.assert_value_error(expected_error):
436 bundles.validate({
437 'services': {settings.JUJU_GUI_SERVICE_NAME: {}},
438 })
439
440 def test_success(self):
441 # The function succeeds when a valid bundle content is provided.
442 bundles.validate(self.bundle_data)
255443
=== renamed file 'quickstart/tests/models/test_charms.py' => 'quickstart/tests/models/test_references.py'
--- quickstart/tests/models/test_charms.py 2015-02-09 18:00:33 +0000
+++ quickstart/tests/models/test_references.py 2015-02-27 18:40:58 +0000
@@ -14,222 +14,424 @@
14# You should have received a copy of the GNU Affero General Public License14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.15# along with this program. If not, see <http://www.gnu.org/licenses/>.
1616
17"""Tests for the Juju Quickstart charms management."""17"""Tests for the Juju Quickstart charm and bundle references management."""
1818
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import unittest21import unittest
2222
23from quickstart.models import charms23from quickstart import settings
24from quickstart.models import references
24from quickstart.tests import helpers25from quickstart.tests import helpers
2526
2627
27class TestParseUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):28def make_reference(
29 schema='cs', user='myuser', series='precise', name='juju-gui',
30 revision=42):
31 """Create and return a Reference instance."""
32 return references.Reference(schema, user, series, name, revision)
33
34
35class TestReference(unittest.TestCase):
36
37 def test_attributes(self):
38 # All reference attributes are correctly stored.
39 ref = make_reference()
40 self.assertEqual('cs', ref.schema)
41 self.assertEqual('myuser', ref.user)
42 self.assertEqual('precise', ref.series)
43 self.assertEqual('juju-gui', ref.name)
44 self.assertEqual(42, ref.revision)
45
46 def test_revision_as_string(self):
47 # The reference revision is converted to an int.
48 ref = make_reference(revision='47')
49 self.assertEqual(47, ref.revision)
50
51 def test_string(self):
52 # The string representation of a reference is its URL.
53 tests = (
54 (make_reference(),
55 'cs:~myuser/precise/juju-gui-42'),
56 (make_reference(schema='local'),
57 'local:~myuser/precise/juju-gui-42'),
58 (make_reference(user=''),
59 'cs:precise/juju-gui-42'),
60 (make_reference(user='dalek', revision=None, series='bundle'),
61 'cs:~dalek/bundle/juju-gui'),
62 (make_reference(name='django', series='vivid', revision=0),
63 'cs:~myuser/vivid/django-0'),
64 (make_reference(user='', revision=None),
65 'cs:precise/juju-gui'),
66 )
67 for ref, expected_value in tests:
68 self.assertEqual(expected_value, bytes(ref))
69
70 def test_repr(self):
71 # A reference is correctly represented.
72 tests = (
73 (make_reference(),
74 '<Reference: cs:~myuser/precise/juju-gui-42>'),
75 (make_reference(schema='local'),
76 '<Reference: local:~myuser/precise/juju-gui-42>'),
77 (make_reference(user=''),
78 '<Reference: cs:precise/juju-gui-42>'),
79 (make_reference(user='dalek', revision=None, series='bundle'),
80 '<Reference: cs:~dalek/bundle/juju-gui>'),
81 (make_reference(name='django', series='vivid', revision=0),
82 '<Reference: cs:~myuser/vivid/django-0>'),
83 (make_reference(user='', revision=None),
84 '<Reference: cs:precise/juju-gui>'),
85 )
86 for ref, expected_value in tests:
87 self.assertEqual(expected_value, repr(ref))
88
89 def test_path(self):
90 # The reference path is properly returned as a URL string without the
91 # schema.
92 tests = (
93 (make_reference(),
94 '~myuser/precise/juju-gui-42'),
95 (make_reference(schema='local'),
96 '~myuser/precise/juju-gui-42'),
97 (make_reference(user=''),
98 'precise/juju-gui-42'),
99 (make_reference(user='dalek', revision=None, series='bundle'),
100 '~dalek/bundle/juju-gui'),
101 (make_reference(name='django', series='vivid', revision=0),
102 '~myuser/vivid/django-0'),
103 (make_reference(user='', revision=None),
104 'precise/juju-gui'),
105 )
106 for ref, expected_value in tests:
107 self.assertEqual(expected_value, ref.path())
108
109 def test_id(self):
110 # The reference id is correctly returned.
111 tests = (
112 (make_reference(),
113 'cs:~myuser/precise/juju-gui-42'),
114 (make_reference(schema='local'),
115 'local:~myuser/precise/juju-gui-42'),
116 (make_reference(user=''),
117 'cs:precise/juju-gui-42'),
118 (make_reference(user='dalek', revision=None, series='bundle'),
119 'cs:~dalek/bundle/juju-gui'),
120 (make_reference(name='django', series='vivid', revision=0),
121 'cs:~myuser/vivid/django-0'),
122 (make_reference(user='', revision=None),
123 'cs:precise/juju-gui'),
124 )
125 for ref, expected_value in tests:
126 self.assertEqual(expected_value, ref.id())
127
128 def test_jujucharms_id(self):
129 # It is possible to return the reference identifier in jujucharms.com.
130 tests = (
131 (make_reference(),
132 'u/myuser/juju-gui/precise/42'),
133 (make_reference(schema='local'),
134 'u/myuser/juju-gui/precise/42'),
135 (make_reference(user=''),
136 'juju-gui/precise/42'),
137 (make_reference(user='dalek', revision=None, series='bundle'),
138 'u/dalek/juju-gui'),
139 (make_reference(name='django', series='vivid', revision=0),
140 'u/myuser/django/vivid/0'),
141 (make_reference(user='', revision=None),
142 'juju-gui/precise'),
143 (make_reference(user='', series='bundle', revision=None),
144 'juju-gui'),
145 )
146 for ref, expected_value in tests:
147 self.assertEqual(expected_value, ref.jujucharms_id())
148
149 def test_jujucharms_url(self):
150 # The corresponding charm or bundle page in jujucharms.com is correctly
151 # returned.
152 tests = (
153 (make_reference(),
154 'u/myuser/juju-gui/precise/42'),
155 (make_reference(schema='local'),
156 'u/myuser/juju-gui/precise/42'),
157 (make_reference(user=''),
158 'juju-gui/precise/42'),
159 (make_reference(user='dalek', revision=None, series='bundle'),
160 'u/dalek/juju-gui'),
161 (make_reference(name='django', series='vivid', revision=0),
162 'u/myuser/django/vivid/0'),
163 (make_reference(user='', revision=None),
164 'juju-gui/precise'),
165 (make_reference(user='', series='bundle', revision=None),
166 'juju-gui'),
167 )
168 for ref, expected_value in tests:
169 expected_url = settings.JUJUCHARMS_URL + expected_value
170 self.assertEqual(expected_url, ref.jujucharms_url())
171
172 def test_charm_entity(self):
173 # The is_bundle method returns False for charm references.
174 ref = make_reference(series='vivid')
175 self.assertFalse(ref.is_bundle())
176
177 def test_bundle_entity(self):
178 # The is_bundle method returns True for bundle references.
179 ref = make_reference(series='bundle')
180 self.assertTrue(ref.is_bundle())
181
182 def test_charm_store_entity(self):
183 # The is_local method returns False for charm store references.
184 ref = make_reference(schema='cs')
185 self.assertFalse(ref.is_local())
186
187 def test_local_entity(self):
188 # The is_local method returns True for local references.
189 ref = make_reference(schema='local')
190 self.assertTrue(ref.is_local())
191
192 def test_equality(self):
193 # Two references are equal if they have the same URL.
194 self.assertEqual(make_reference(), make_reference())
195 self.assertEqual(make_reference(user=''), make_reference(user=''))
196 self.assertEqual(
197 make_reference(revision=None), make_reference(revision=None))
198
199 def test_equality_different_references(self):
200 # Two references with different attributes are not equal.
201 tests = (
202 (make_reference(schema='cs'),
203 make_reference(schema='local')),
204 (make_reference(user=''),
205 make_reference(user='who')),
206 (make_reference(series='trusty'),
207 make_reference(series='vivid')),
208 (make_reference(name='django'),
209 make_reference(name='rails')),
210 (make_reference(revision=0),
211 make_reference(revision=1)),
212 (make_reference(revision=None),
213 make_reference(revision=42)),
214 )
215 for ref1, ref2 in tests:
216 self.assertNotEqual(ref1, ref2)
217
218 def test_equality_different_types(self):
219 # A reference never equals a non-reference object.
220 self.assertNotEqual(make_reference(), 42)
221 self.assertNotEqual(make_reference(), True)
222 self.assertNotEqual(make_reference(), 'oranges')
223
224 def test_charmworld_id(self):
225 # By default, the reference id in charmworld is set to None.
226 # XXX frankban 2015-02-26: remove this test once we get rid of the
227 # charmworld id concept.
228 ref = make_reference()
229 self.assertIsNone(ref.charmworld_id)
230
231
232class TestReferenceFromFullyQualifiedUrl(
233 helpers.ValueErrorTestsMixin, unittest.TestCase):
28234
29 def test_no_schema_error(self):235 def test_no_schema_error(self):
30 # A ValueError is raised if the URL schema is missing.236 # A ValueError is raised if the URL schema is missing.
31 expected = 'charm URL has no schema: precise/juju-gui'237 expected_error = 'URL has no schema: precise/juju-gui'
32 with self.assert_value_error(expected):238 with self.assert_value_error(expected_error):
33 charms.parse_url('precise/juju-gui')239 references.Reference.from_fully_qualified_url('precise/juju-gui')
34240
35 def test_invalid_schema_error(self):241 def test_invalid_schema_error(self):
36 # A ValueError is raised if the URL schema is not valid.242 # A ValueError is raised if the URL schema is not valid.
37 expected = 'charm URL has invalid schema: http'243 expected_error = 'URL has invalid schema: http'
38 with self.assert_value_error(expected):244 with self.assert_value_error(expected_error):
39 charms.parse_url('http:precise/juju-gui')245 references.Reference.from_fully_qualified_url(
246 'http:precise/juju-gui')
40247
41 def test_invalid_user_form_error(self):248 def test_invalid_user_form_error(self):
42 # A ValueError is raised if the user form is not valid.249 # A ValueError is raised if the user form is not valid.
43 expected = 'charm URL has invalid user name form: jean-luc'250 expected_error = 'URL has invalid user name form: jean-luc'
44 with self.assert_value_error(expected):251 with self.assert_value_error(expected_error):
45 charms.parse_url('cs:jean-luc/precise/juju-gui')252 references.Reference.from_fully_qualified_url(
253 'cs:jean-luc/precise/juju-gui')
46254
47 def test_invalid_user_name_error(self):255 def test_invalid_user_name_error(self):
48 # A ValueError is raised if the user name is not valid.256 # A ValueError is raised if the user name is not valid.
49 expected = 'charm URL has invalid user name: jean:luc'257 expected_error = 'URL has invalid user name: jean:luc'
50 with self.assert_value_error(expected):258 with self.assert_value_error(expected_error):
51 charms.parse_url('cs:~jean:luc/precise/juju-gui')259 references.Reference.from_fully_qualified_url(
260 'cs:~jean:luc/precise/juju-gui')
52261
53 def test_local_user_name_error(self):262 def test_local_user_name_error(self):
54 # A ValueError is raised if a user is specified on a local charm.263 # A ValueError is raised if a user is specified on a local entity.
55 expected = (264 expected_error = (
56 'local charm URL with user name: '265 'local entity URL with user name: '
57 'local:~jean-luc/precise/juju-gui')266 'local:~jean-luc/precise/juju-gui')
58 with self.assert_value_error(expected):267 with self.assert_value_error(expected_error):
59 charms.parse_url('local:~jean-luc/precise/juju-gui')268 references.Reference.from_fully_qualified_url(
269 'local:~jean-luc/precise/juju-gui')
60270
61 def test_invalid_form_error(self):271 def test_invalid_form_error(self):
62 # A ValueError is raised if the URL is not valid.272 # A ValueError is raised if the URL is not valid.
63 expected = 'charm URL has invalid form: cs:~user/series/name/what-?'273 expected_error = 'URL has invalid form: cs:~user/series/name/what-?'
64 with self.assert_value_error(expected):274 with self.assert_value_error(expected_error):
65 charms.parse_url('cs:~user/series/name/what-?')275 references.Reference.from_fully_qualified_url(
276 'cs:~user/series/name/what-?')
66277
67 def test_invalid_series_error(self):278 def test_invalid_series_error(self):
68 # A ValueError is raised if the series is not valid.279 # A ValueError is raised if the series is not valid.
69 expected = 'charm URL has invalid series: boo!'280 expected_error = 'URL has invalid series: boo!'
70 with self.assert_value_error(expected):281 with self.assert_value_error(expected_error):
71 charms.parse_url('cs:boo!/juju-gui-42')282 references.Reference.from_fully_qualified_url(
283 'cs:boo!/juju-gui-42')
72284
73 def test_no_revision_error(self):285 def test_no_revision_error(self):
74 # A ValueError is raised if the charm revision is missing.286 # A ValueError is raised if the entity revision is missing.
75 expected = 'charm URL has no revision: cs:series/name'287 expected_error = 'URL has no revision: cs:series/name'
76 with self.assert_value_error(expected):288 with self.assert_value_error(expected_error):
77 charms.parse_url('cs:series/name')289 references.Reference.from_fully_qualified_url('cs:series/name')
78290
79 def test_invalid_revision_error(self):291 def test_invalid_revision_error(self):
80 # A ValueError is raised if the charm revision is not valid.292 # A ValueError is raised if the charm or bundle revision is not valid.
81 expected = 'charm URL has invalid revision: revision'293 expected_error = 'URL has invalid revision: revision'
82 with self.assert_value_error(expected):294 with self.assert_value_error(expected_error):
83 charms.parse_url('cs:series/name-revision')295 references.Reference.from_fully_qualified_url(
296 'cs:series/name-revision')
84297
85 def test_invalid_name_error(self):298 def test_invalid_name_error(self):
86 # A ValueError is raised if the charm name is not valid.299 # A ValueError is raised if the entity name is not valid.
87 expected = 'charm URL has invalid name: not:valid'300 expected_error = 'URL has invalid name: not:valid'
88 with self.assert_value_error(expected):301 with self.assert_value_error(expected_error):
89 charms.parse_url('cs:precise/not:valid-42')302 references.Reference.from_fully_qualified_url(
90303 'cs:precise/not:valid-42')
91 def test_success_with_user(self):304
92 # A charm URL including the user is correctly parsed.305 def test_success(self):
93 schema, user, series, name, revision = charms.parse_url(306 # References are correctly instantiated by parsing the fully qualified
94 'cs:~who/precise/juju-gui-42')307 # URL.
95 self.assertEqual('cs', schema)308 tests = (
96 self.assertEqual('who', user)309 ('cs:~myuser/precise/juju-gui-42',
97 self.assertEqual('precise', series)310 make_reference()),
98 self.assertEqual('juju-gui', name)311 ('cs:trusty/juju-gui-42',
99 self.assertEqual(42, revision)312 make_reference(user='', series='trusty')),
100313 ('local:precise/juju-gui-42',
101 def test_success_without_user(self):314 make_reference(schema='local', user='')),
102 # A charm URL not including the user is correctly parsed.315 )
103 schema, user, series, name, revision = charms.parse_url(316 for url, expected_ref in tests:
104 'cs:trusty/django-1')317 ref = references.Reference.from_fully_qualified_url(url)
105 self.assertEqual('cs', schema)318 self.assertEqual(expected_ref, ref)
106 self.assertEqual('', user)319
107 self.assertEqual('trusty', series)320
108 self.assertEqual('django', name)321class TestReferenceFromCharmworldUrl(
109 self.assertEqual(1, revision)322 helpers.ValueErrorTestsMixin, unittest.TestCase):
110323
111 def test_success_local_charm(self):324 def test_invalid_form(self):
112 # A local charm URL is correctly parsed.325 # A ValueError is raised if the URL is not valid.
113 schema, user, series, name, revision = charms.parse_url(326 expected_error = 'invalid bundle URL: bad wolf'
114 'local:saucy/wordpress-100')327 with self.assert_value_error(expected_error):
115 self.assertEqual('local', schema)328 references.Reference.from_charmworld_url('bad wolf')
116 self.assertEqual('', user)329
117 self.assertEqual('saucy', series)330 def test_success(self):
118 self.assertEqual('wordpress', name)331 # A reference is correctly created from a charmworld identifier.
119 self.assertEqual(100, revision)332 tests = (
120333 ('bundle:~myuser/wordpress/42/single',
121334 make_reference(series='bundle', name='wordpress-single')),
122class TestCharm(helpers.ValueErrorTestsMixin, unittest.TestCase):335 ('bundle:~myuser/wordpress/single',
123336 make_reference(series='bundle', name='wordpress-single',
124 def make_charm(337 revision=None)),
125 self, schema='cs', user='myuser', series='precise',338 ('bundle:wordpress/42/single',
126 name='juju-gui', revision=42):339 make_reference(user='', series='bundle',
127 """Create and return a Charm instance."""340 name='wordpress-single')),
128 return charms.Charm(schema, user, series, name, revision)341 ('bundle:wordpress/single',
129342 make_reference(user='', series='bundle', name='wordpress-single',
130 def test_attributes(self):343 revision=None)),
131 # All charm attributes are correctly stored.344 )
132 charm = self.make_charm()345 for url, expected_ref in tests:
133 self.assertEqual('cs', charm.schema)346 ref = references.Reference.from_charmworld_url(url)
134 self.assertEqual('myuser', charm.user)347 self.assertEqual(expected_ref, ref)
135 self.assertEqual('precise', charm.series)348
136 self.assertEqual('juju-gui', charm.name)349 def test_charmworld_id(self):
137 self.assertEqual(42, charm.revision)350 # The charmworld id is properly set when parsing charmworld URLs.
138351 # XXX frankban 2015-02-26: remove this test once we get rid of the
139 def test_revision_as_string(self):352 # charmworld id concept.
140 # Revision is converted to an int.353 ref = references.Reference.from_charmworld_url(
141 charm = self.make_charm(revision='47')354 'bundle:wordpress/single')
142 self.assertEqual(47, charm.revision)355 self.assertEqual('wordpress/single', ref.charmworld_id)
143356
144 def test_from_url(self):357
145 # A Charm can be instantiated from a charm URL.358class TestReferenceFromJujucharmsUrl(
146 charm = charms.Charm.from_url('cs:~who/trusty/django-1')359 helpers.ValueErrorTestsMixin, unittest.TestCase):
147 self.assertEqual('cs', charm.schema)360
148 self.assertEqual('who', charm.user)361 def test_invalid_form(self):
149 self.assertEqual('trusty', charm.series)362 # A ValueError is raised if the URL is not valid.
150 self.assertEqual('django', charm.name)363 expected_error = 'invalid bundle URL: bad wolf'
151 self.assertEqual(1, charm.revision)364 with self.assert_value_error(expected_error):
152365 references.Reference.from_jujucharms_url('bad wolf')
153 def test_from_url_without_user(self):366
154 # Official charm store URLs are properly handled.367 def test_success(self):
155 charm = charms.Charm.from_url('cs:saucy/django-123')368 # A reference is correctly created from a jujucharms.com identifier or
156 self.assertEqual('cs', charm.schema)369 # complete URL.
157 self.assertEqual('', charm.user)370 tests = (
158 self.assertEqual('saucy', charm.series)371 # Check with both user and revision.
159 self.assertEqual('django', charm.name)372 ('u/myuser/mediawiki/42',
160 self.assertEqual(123, charm.revision)373 make_reference(series='bundle', name='mediawiki')),
161374 ('/u/myuser/mediawiki/42',
162 def test_from_url_local(self):375 make_reference(series='bundle', name='mediawiki')),
163 # Local charms URLs are properly handled.376 ('u/myuser/django-scalable/42/',
164 charm = charms.Charm.from_url('local:precise/my-local-charm-42')377 make_reference(series='bundle', name='django-scalable')),
165 self.assertEqual('local', charm.schema)378 ('{}u/myuser/mediawiki/42'.format(settings.JUJUCHARMS_URL),
166 self.assertEqual('', charm.user)379 make_reference(series='bundle', name='mediawiki')),
167 self.assertEqual('precise', charm.series)380 ('{}u/myuser/mediawiki/42/'.format(settings.JUJUCHARMS_URL),
168 self.assertEqual('my-local-charm', charm.name)381 make_reference(series='bundle', name='mediawiki')),
169 self.assertEqual(42, charm.revision)382
170383 # Check without revision.
171 def test_from_url_error(self):384 ('u/myuser/mediawiki',
172 # A ValueError is raised by the from_url class method if the provided385 make_reference(series='bundle', name='mediawiki', revision=None)),
173 # URL is not a valid charm URL.386 ('/u/myuser/wordpress',
174 expected = 'charm URL has invalid form: cs:not-a-charm-url'387 make_reference(series='bundle', name='wordpress', revision=None)),
175 with self.assert_value_error(expected):388 ('u/myuser/mediawiki/',
176 charms.Charm.from_url('cs:not-a-charm-url')389 make_reference(series='bundle', name='mediawiki', revision=None)),
177390 ('{}u/myuser/django'.format(settings.JUJUCHARMS_URL),
178 def test_string(self):391 make_reference(series='bundle', name='django', revision=None)),
179 # The string representation of a charm instance is its URL.392 ('{}u/myuser/mediawiki/'.format(settings.JUJUCHARMS_URL),
180 charm = self.make_charm()393 make_reference(series='bundle', name='mediawiki', revision=None)),
181 self.assertEqual('cs:~myuser/precise/juju-gui-42', bytes(charm))394
182395 # Check without the user.
183 def test_repr(self):396 ('rails-single/42',
184 # A charm instance is correctly represented.397 make_reference(user='', series='bundle', name='rails-single')),
185 charm = self.make_charm()398 ('/mediawiki/42',
186 self.assertEqual(399 make_reference(user='', series='bundle', name='mediawiki')),
187 '<Charm: cs:~myuser/precise/juju-gui-42>', repr(charm))400 ('rails-scalable/42/',
188401 make_reference(user='', series='bundle', name='rails-scalable')),
189 def test_charm_store_url(self):402 ('{}mediawiki/42'.format(settings.JUJUCHARMS_URL),
190 # A charm store URL is correctly returned.403 make_reference(user='', series='bundle', name='mediawiki')),
191 charm = self.make_charm(schema='cs')404 ('{}django/42/'.format(settings.JUJUCHARMS_URL),
192 self.assertEqual('cs:~myuser/precise/juju-gui-42', charm.url())405 make_reference(user='', series='bundle', name='django')),
193406
194 def test_local_url(self):407 # Check without user and revision.
195 # A local charm URL is correctly returned.408 ('mediawiki',
196 charm = self.make_charm(schema='local', user='')409 make_reference(user='', series='bundle', name='mediawiki',
197 self.assertEqual('local:precise/juju-gui-42', charm.url())410 revision=None)),
198411 ('/wordpress',
199 def test_charm_store_charm(self):412 make_reference(user='', series='bundle', name='wordpress',
200 # The is_local method returns False for charm store charms.413 revision=None)),
201 charm = self.make_charm(schema='cs')414 ('mediawiki/',
202 self.assertFalse(charm.is_local())415 make_reference(user='', series='bundle', name='mediawiki',
203416 revision=None)),
204 def test_local_charm(self):417 ('{}django'.format(settings.JUJUCHARMS_URL),
205 # The is_local method returns True for local charms.418 make_reference(user='', series='bundle', name='django',
206 charm = self.make_charm(schema='local')419 revision=None)),
207 self.assertTrue(charm.is_local())420 ('{}mediawiki/'.format(settings.JUJUCHARMS_URL),
208421 make_reference(user='', series='bundle', name='mediawiki',
209 def test_equality(self):422 revision=None)),
210 # Two charms are equal if they have the same URL.423
211 self.assertEqual(self.make_charm(), self.make_charm())424 # Check charm entities.
212425 ('mediawiki/trusty/0',
213 def test_equality_different_name(self):426 make_reference(user='', series='trusty', name='mediawiki',
214 # Two charms with different names are not equal.427 revision=0)),
215 self.assertNotEqual(428 ('/wordpress/precise',
216 self.make_charm(name='django'),429 make_reference(user='', series='precise', name='wordpress',
217 self.make_charm(name='rails'))430 revision=None)),
218431 ('u/who/rails/vivid',
219 def test_equality_different_revision(self):432 make_reference(user='who', series='vivid', name='rails',
220 # Two charms with different revisions are not equal.433 revision=None)),
221 self.assertNotEqual(434 )
222 self.make_charm(revision=0),435 for url, expected_ref in tests:
223 self.make_charm(revision=1))436 ref = references.Reference.from_jujucharms_url(url)
224437 self.assertEqual(expected_ref, ref)
225 def test_equality_different_user(self):
226 # Two charms with different users are not equal.
227 self.assertNotEqual(
228 self.make_charm(user=''),
229 self.make_charm(user='who'))
230
231 def test_equality_different_types(self):
232 # A charm never equals a non-charm object.
233 self.assertNotEqual(self.make_charm(), 42)
234 self.assertNotEqual(self.make_charm(), True)
235 self.assertNotEqual(self.make_charm(), 'oranges')
236438
=== modified file 'quickstart/tests/test_app.py'
--- quickstart/tests/test_app.py 2015-02-09 14:56:32 +0000
+++ quickstart/tests/test_app.py 2015-02-27 18:40:58 +0000
@@ -32,7 +32,10 @@
32 platform_support,32 platform_support,
33 settings,33 settings,
34)34)
35from quickstart.models import charms35from quickstart.models import (
36 bundles,
37 references,
38)
36from quickstart.tests import helpers39from quickstart.tests import helpers
3740
3841
@@ -934,10 +937,11 @@
934 return mock.patch(937 return mock.patch(
935 'quickstart.netutils.get_charm_url', mock_get_charm_url)938 'quickstart.netutils.get_charm_url', mock_get_charm_url)
936939
937 def assert_charm_equal(self, expected_url, charm):940 def assert_reference_equal(self, expected_url, ref):
938 """Ensure the given charm has the expected URL."""941 """Ensure the given reference points to the expected URL."""
939 expected_charm = charms.Charm.from_url(expected_url)942 expected_ref = references.Reference.from_fully_qualified_url(
940 self.assertEqual(expected_charm, charm)943 expected_url)
944 self.assertEqual(expected_ref, ref)
941945
942 def test_environment_just_bootstrapped(self, mock_print):946 def test_environment_just_bootstrapped(self, mock_print):
943 # The function correctly retrieves the charm URL and machine, and947 # The function correctly retrieves the charm URL and machine, and
@@ -952,14 +956,14 @@
952 check_preexisting = False956 check_preexisting = False
953 with self.patch_get_charm_url(957 with self.patch_get_charm_url(
954 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:958 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:
955 charm, machine, service_data, unit_data = app.check_environment(959 ref, machine, service_data, unit_data = app.check_environment(
956 env, 'my-gui', charm_url, env_type, bootstrap_node_series,960 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
957 check_preexisting)961 check_preexisting)
958 # There is no need to call status if the environment was just created.962 # There is no need to call status if the environment was just created.
959 self.assertFalse(env.get_status.called)963 self.assertFalse(env.get_status.called)
960 # The charm URL has been retrieved from the charm store API based on964 # The charm URL has been retrieved from the charm store API based on
961 # the current bootstrap node series.965 # the current bootstrap node series.
962 self.assert_charm_equal('cs:trusty/juju-gui-42', charm)966 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
963 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)967 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)
964 # Since the bootstrap node series is supported by the GUI charm, the968 # Since the bootstrap node series is supported by the GUI charm, the
965 # GUI unit can be deployed to machine 0.969 # GUI unit can be deployed to machine 0.
@@ -987,14 +991,14 @@
987 check_preexisting = True991 check_preexisting = True
988 with self.patch_get_charm_url(992 with self.patch_get_charm_url(
989 return_value='cs:precise/juju-gui-42') as mock_get_charm_url:993 return_value='cs:precise/juju-gui-42') as mock_get_charm_url:
990 charm, machine, service_data, unit_data = app.check_environment(994 ref, machine, service_data, unit_data = app.check_environment(
991 env, 'my-gui', charm_url, env_type, bootstrap_node_series,995 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
992 check_preexisting)996 check_preexisting)
993 # The environment status has been retrieved.997 # The environment status has been retrieved.
994 env.get_status.assert_called_once_with()998 env.get_status.assert_called_once_with()
995 # The charm URL has been retrieved from the charm store API based on999 # The charm URL has been retrieved from the charm store API based on
996 # the current bootstrap node series.1000 # the current bootstrap node series.
997 self.assert_charm_equal('cs:precise/juju-gui-42', charm)1001 self.assert_reference_equal('cs:precise/juju-gui-42', ref)
998 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)1002 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)
999 # Since the bootstrap node series is supported by the GUI charm, the1003 # Since the bootstrap node series is supported by the GUI charm, the
1000 # GUI unit can be deployed to machine 0.1004 # GUI unit can be deployed to machine 0.
@@ -1019,13 +1023,13 @@
1019 bootstrap_node_series = 'precise'1023 bootstrap_node_series = 'precise'
1020 check_preexisting = True1024 check_preexisting = True
1021 with self.patch_get_charm_url() as mock_get_charm_url:1025 with self.patch_get_charm_url() as mock_get_charm_url:
1022 charm, machine, service_data, unit_data = app.check_environment(1026 ref, machine, service_data, unit_data = app.check_environment(
1023 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1027 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1024 check_preexisting)1028 check_preexisting)
1025 # The environment status has been retrieved.1029 # The environment status has been retrieved.
1026 env.get_status.assert_called_once_with()1030 env.get_status.assert_called_once_with()
1027 # The charm URL has been retrieved from the environment.1031 # The charm URL has been retrieved from the environment.
1028 self.assert_charm_equal('cs:precise/juju-gui-47', charm)1032 self.assert_reference_equal('cs:precise/juju-gui-47', ref)
1029 self.assertFalse(mock_get_charm_url.called)1033 self.assertFalse(mock_get_charm_url.called)
1030 # Since the bootstrap node series is supported by the GUI charm, the1034 # Since the bootstrap node series is supported by the GUI charm, the
1031 # GUI unit can be safely deployed to machine 0.1035 # GUI unit can be safely deployed to machine 0.
@@ -1044,12 +1048,12 @@
1044 check_preexisting = False1048 check_preexisting = False
1045 with self.patch_get_charm_url(1049 with self.patch_get_charm_url(
1046 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:1050 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:
1047 charm, machine, service_data, unit_data = app.check_environment(1051 ref, machine, service_data, unit_data = app.check_environment(
1048 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1052 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1049 check_preexisting)1053 check_preexisting)
1050 # The charm URL has been retrieved from the charm store API using the1054 # The charm URL has been retrieved from the charm store API using the
1051 # most recent supported series.1055 # most recent supported series.
1052 self.assert_charm_equal('cs:trusty/juju-gui-42', charm)1056 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
1053 mock_get_charm_url.assert_called_once_with('trusty')1057 mock_get_charm_url.assert_called_once_with('trusty')
1054 # The Juju GUI unit cannot be deployed to saucy machine 0.1058 # The Juju GUI unit cannot be deployed to saucy machine 0.
1055 self.assertIsNone(machine)1059 self.assertIsNone(machine)
@@ -1069,11 +1073,11 @@
1069 bootstrap_node_series = 'trusty'1073 bootstrap_node_series = 'trusty'
1070 check_preexisting = False1074 check_preexisting = False
1071 with self.patch_get_charm_url(return_value='cs:trusty/juju-gui-42'):1075 with self.patch_get_charm_url(return_value='cs:trusty/juju-gui-42'):
1072 charm, machine, service_data, unit_data = app.check_environment(1076 ref, machine, service_data, unit_data = app.check_environment(
1073 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1077 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1074 check_preexisting)1078 check_preexisting)
1075 # The charm URL has been correctly retrieved from the charm store API.1079 # The charm URL has been correctly retrieved from the charm store API.
1076 self.assert_charm_equal('cs:trusty/juju-gui-42', charm)1080 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
1077 # The Juju GUI unit cannot be deployed to localhost.1081 # The Juju GUI unit cannot be deployed to localhost.
1078 self.assertIsNone(machine)1082 self.assertIsNone(machine)
10791083
@@ -1100,11 +1104,12 @@
1100 bootstrap_node_series = 'precise'1104 bootstrap_node_series = 'precise'
1101 check_preexisting = False1105 check_preexisting = False
1102 with self.patch_get_charm_url(side_effect=IOError('boo!')):1106 with self.patch_get_charm_url(side_effect=IOError('boo!')):
1103 charm, machine, service_data, unit_data = app.check_environment(1107 ref, machine, service_data, unit_data = app.check_environment(
1104 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1108 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1105 check_preexisting)1109 check_preexisting)
1106 # The default charm URL for the given series is returned.1110 # The default charm URL for the given series is returned.
1107 self.assert_charm_equal(settings.DEFAULT_CHARM_URLS['precise'], charm)1111 self.assert_reference_equal(
1112 settings.DEFAULT_CHARM_URLS['precise'], ref)
1108 self.assertEqual('0', machine)1113 self.assertEqual('0', machine)
11091114
1110 def test_most_recent_default_charm_url(self, mock_print):1115 def test_most_recent_default_charm_url(self, mock_print):
@@ -1117,12 +1122,12 @@
1117 bootstrap_node_series = 'saucy'1122 bootstrap_node_series = 'saucy'
1118 check_preexisting = False1123 check_preexisting = False
1119 with self.patch_get_charm_url(side_effect=IOError('boo!')):1124 with self.patch_get_charm_url(side_effect=IOError('boo!')):
1120 charm, machine, service_data, unit_data = app.check_environment(1125 ref, machine, service_data, unit_data = app.check_environment(
1121 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1126 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1122 check_preexisting)1127 check_preexisting)
1123 # The default charm URL for the given series is returned.1128 # The default charm URL for the given series is returned.
1124 series = settings.JUJU_GUI_SUPPORTED_SERIES[-1]1129 series = settings.JUJU_GUI_SUPPORTED_SERIES[-1]
1125 self.assert_charm_equal(settings.DEFAULT_CHARM_URLS[series], charm)1130 self.assert_reference_equal(settings.DEFAULT_CHARM_URLS[series], ref)
1126 self.assertIsNone(machine)1131 self.assertIsNone(machine)
11271132
1128 def test_charm_url_provided(self, mock_print):1133 def test_charm_url_provided(self, mock_print):
@@ -1134,14 +1139,14 @@
1134 bootstrap_node_series = 'trusty'1139 bootstrap_node_series = 'trusty'
1135 check_preexisting = False1140 check_preexisting = False
1136 with self.patch_get_charm_url() as mock_get_charm_url:1141 with self.patch_get_charm_url() as mock_get_charm_url:
1137 charm, machine, service_data, unit_data = app.check_environment(1142 ref, machine, service_data, unit_data = app.check_environment(
1138 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1143 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1139 check_preexisting)1144 check_preexisting)
1140 # There is no need to call the charmword API if the charm URL is1145 # There is no need to call the charmword API if the charm URL is
1141 # provided by the user.1146 # provided by the user.
1142 self.assertFalse(mock_get_charm_url.called)1147 self.assertFalse(mock_get_charm_url.called)
1143 # The provided charm URL has been correctly returned.1148 # The provided charm URL has been correctly returned.
1144 self.assert_charm_equal(charm_url, charm)1149 self.assert_reference_equal(charm_url, ref)
1145 # Since the provided charm series is trusty, the charm itself can be1150 # Since the provided charm series is trusty, the charm itself can be
1146 # safely deployed to machine 0.1151 # safely deployed to machine 0.
1147 self.assertEqual('0', machine)1152 self.assertEqual('0', machine)
@@ -1161,14 +1166,14 @@
1161 bootstrap_node_series = 'precise'1166 bootstrap_node_series = 'precise'
1162 check_preexisting = False1167 check_preexisting = False
1163 with self.patch_get_charm_url() as mock_get_charm_url:1168 with self.patch_get_charm_url() as mock_get_charm_url:
1164 charm, machine, service_data, unit_data = app.check_environment(1169 ref, machine, service_data, unit_data = app.check_environment(
1165 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1170 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1166 check_preexisting)1171 check_preexisting)
1167 # There is no need to call the charmword API if the charm URL is1172 # There is no need to call the charmword API if the charm URL is
1168 # provided by the user.1173 # provided by the user.
1169 self.assertFalse(mock_get_charm_url.called)1174 self.assertFalse(mock_get_charm_url.called)
1170 # The provided charm URL has been correctly returned.1175 # The provided charm URL has been correctly returned.
1171 self.assert_charm_equal(charm_url, charm)1176 self.assert_reference_equal(charm_url, ref)
1172 # Since the provided charm series is not precise, the charm must be1177 # Since the provided charm series is not precise, the charm must be
1173 # deployed to a new machine.1178 # deployed to a new machine.
1174 self.assertIsNone(machine)1179 self.assertIsNone(machine)
@@ -1642,26 +1647,39 @@
16421647
1643class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase):1648class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase):
16441649
1645 name = 'mybundle'1650 bundle_data = {'services': {}}
1646 yaml = 'mybundle: contents'1651 bundle = bundles.Bundle(bundle_data)
1647 bundle_id = '~fake/basket/bundle'
16481652
1649 def test_bundle_deployment(self):1653 def test_bundle_deployment(self):
1650 # A bundle is successfully deployed.1654 # A bundle is successfully deployed.
1651 env = mock.Mock()1655 env = mock.Mock()
1652 app.deploy_bundle(env, self.yaml, self.name, self.bundle_id)1656 app.deploy_bundle(env, self.bundle)
1657 # For the time being, the bundle version 3 is deployed by default.
1658 expected_yaml = yaml.safe_dump({'bundle': self.bundle_data})
1653 env.deploy_bundle.assert_called_once_with(1659 env.deploy_bundle.assert_called_once_with(
1654 self.yaml, name=self.name, bundle_id=self.bundle_id)1660 expected_yaml, 3, bundle_id=None)
1655 self.assertFalse(env.close.called)1661 self.assertFalse(env.close.called)
16561662
1663 def test_bundle_deployment_with_id(self):
1664 # If the bundle reference includes the charmworld id, it is passed when
1665 # calling the GUI server API.
1666 # XXX frankban 2015-02-26: remove this test once we get rid of the
1667 # charmworld id concept.
1668 env = mock.Mock()
1669 ref = references.Reference.from_charmworld_url('bundle:django/single')
1670 bundle = bundles.Bundle(self.bundle_data, reference=ref)
1671 app.deploy_bundle(env, bundle)
1672 env.deploy_bundle.assert_called_once_with(
1673 self.bundle.serialize_legacy(), 3, bundle_id='django/single')
1674
1657 def test_api_error(self):1675 def test_api_error(self):
1658 # A ProgramExit is raised if an error occurs in one of the API calls.1676 # A ProgramExit is raised if an error occurs in one of the API calls.
1659 env = mock.Mock()1677 env = mock.Mock()
1660 env.deploy_bundle.side_effect = self.make_env_error(1678 env.deploy_bundle.side_effect = self.make_env_error(
1661 'bundle deployment failure')1679 'bundle deployment failure')
1662 expected = 'bad API server response: bundle deployment failure'1680 expected_error = 'bad API server response: bundle deployment failure'
1663 with self.assert_program_exit(expected):1681 with self.assert_program_exit(expected_error):
1664 app.deploy_bundle(env, self.yaml, self.name, self.bundle_id)1682 app.deploy_bundle(env, self.bundle)
16651683
1666 def test_other_errors(self):1684 def test_other_errors(self):
1667 # Any other errors occurred during the process are not trapped.1685 # Any other errors occurred during the process are not trapped.
@@ -1669,5 +1687,5 @@
1669 error = ValueError('explode!')1687 error = ValueError('explode!')
1670 env.deploy_bundle.side_effect = error1688 env.deploy_bundle.side_effect = error
1671 with self.assertRaises(ValueError) as context_manager:1689 with self.assertRaises(ValueError) as context_manager:
1672 app.deploy_bundle(env, self.yaml, self.name, None)1690 app.deploy_bundle(env, self.bundle)
1673 self.assertIs(error, context_manager.exception)1691 self.assertIs(error, context_manager.exception)
16741692
=== modified file 'quickstart/tests/test_juju.py'
--- quickstart/tests/test_juju.py 2015-01-30 17:47:10 +0000
+++ quickstart/tests/test_juju.py 2015-02-27 18:40:58 +0000
@@ -172,9 +172,9 @@
172 mock_rpc.assert_called_once_with(expected)172 mock_rpc.assert_called_once_with(expected)
173173
174 @patch_rpc174 @patch_rpc
175 def test_deploy_bundle(self, mock_rpc):175 def test_deploy_bundle_v3(self, mock_rpc):
176 # The deploy bundle call is properly generated.176 # The deploy bundle call is properly generated (API v3).
177 self.env.deploy_bundle('name: contents')177 self.env.deploy_bundle('name: contents', 3)
178 expected = {178 expected = {
179 'Type': 'Deployer',179 'Type': 'Deployer',
180 'Request': 'Import',180 'Request': 'Import',
@@ -183,13 +183,13 @@
183 mock_rpc.assert_called_once_with(expected)183 mock_rpc.assert_called_once_with(expected)
184184
185 @patch_rpc185 @patch_rpc
186 def test_deploy_bundle_with_name(self, mock_rpc):186 def test_deploy_bundle_v4(self, mock_rpc):
187 # The deploy bundle call is properly generated when passing a name.187 # The deploy bundle call is properly generated (API v4).
188 self.env.deploy_bundle('name: contents', name='name')188 self.env.deploy_bundle('name: contents', 4)
189 expected = {189 expected = {
190 'Type': 'Deployer',190 'Type': 'Deployer',
191 'Request': 'Import',191 'Request': 'Import',
192 'Params': {'Name': 'name', 'YAML': 'name: contents'},192 'Params': {'YAML': 'name: contents', 'Version': 4},
193 }193 }
194 mock_rpc.assert_called_once_with(expected)194 mock_rpc.assert_called_once_with(expected)
195195
@@ -197,13 +197,16 @@
197 def test_deploy_bundle_with_bundle_id(self, mock_rpc):197 def test_deploy_bundle_with_bundle_id(self, mock_rpc):
198 # The deploy bundle call is properly generated when passing a198 # The deploy bundle call is properly generated when passing a
199 # bundle_id.199 # bundle_id.
200 self.env.deploy_bundle('name: contents', name='name',200 self.env.deploy_bundle(
201 bundle_id='~celso/basquet/wiki')201 'name: contents', 4, bundle_id='~celso/basquet/wiki')
202 expected = {202 expected = {
203 'Type': 'Deployer',203 'Type': 'Deployer',
204 'Request': 'Import',204 'Request': 'Import',
205 'Params': {'Name': 'name', 'YAML': 'name: contents',205 'Params': {
206 'BundleID': '~celso/basquet/wiki'},206 'YAML': 'name: contents',
207 'Version': 4,
208 'BundleID': '~celso/basquet/wiki',
209 },
207 }210 }
208 mock_rpc.assert_called_once_with(expected)211 mock_rpc.assert_called_once_with(expected)
209212
210213
=== modified file 'quickstart/tests/test_jujutools.py'
--- quickstart/tests/test_jujutools.py 2015-02-09 15:56:20 +0000
+++ quickstart/tests/test_jujutools.py 2015-02-27 18:40:58 +0000
@@ -24,7 +24,7 @@
24import yaml24import yaml
2525
26from quickstart import jujutools26from quickstart import jujutools
27from quickstart.models import charms27from quickstart.models import references
28from quickstart.tests import helpers28from quickstart.tests import helpers
2929
3030
@@ -69,80 +69,90 @@
69 def test_new_charm_old_juju(self):69 def test_new_charm_old_juju(self):
70 # The old Juju API endpoints are used if and old version of Juju is in70 # The old Juju API endpoints are used if and old version of Juju is in
71 # use, even if the Juju GUI charm is recent.71 # use, even if the Juju GUI charm is recent.
72 charm = charms.Charm.from_url('cs:trusty/juju-gui-42')72 ref = references.Reference.from_fully_qualified_url(
73 'cs:trusty/juju-gui-42')
73 url = jujutools.get_api_url(74 url = jujutools.get_api_url(
74 '1.2.3.4:5678', (1, 21, 7), 'env-uuid', charm=charm)75 '1.2.3.4:5678', (1, 21, 7), 'env-uuid', charm_ref=ref)
75 self.assertEqual('wss://1.2.3.4:5678', url)76 self.assertEqual('wss://1.2.3.4:5678', url)
7677
77 def test_customized_charm_unexpected_name(self):78 def test_customized_charm_unexpected_name(self):
78 # If a customized Juju GUI charm is used, then we assume it supports79 # If a customized Juju GUI charm is used, then we assume it supports
79 # the new Juju Login API endpoint (unexpected charm name).80 # the new Juju Login API endpoint (unexpected charm name).
80 charm = charms.Charm.from_url('cs:trusty/the-amazing-gui-0')81 ref = references.Reference.from_fully_qualified_url(
82 'cs:trusty/the-amazing-gui-0')
81 url = jujutools.get_api_url(83 url = jujutools.get_api_url(
82 'example.com:17070', (1, 22, 2), 'uuid', charm=charm)84 'example.com:17070', (1, 22, 2), 'uuid', charm_ref=ref)
83 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)85 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)
8486
85 def test_customized_charm_unexpected_user(self):87 def test_customized_charm_unexpected_user(self):
86 # If a customized Juju GUI charm is used, then we assume it supports88 # If a customized Juju GUI charm is used, then we assume it supports
87 # the new Juju Login API endpoint (unexpected charm user).89 # the new Juju Login API endpoint (unexpected charm user).
88 charm = charms.Charm.from_url('cs:~who/trusty/juju-gui-0')90 ref = references.Reference.from_fully_qualified_url(
91 'cs:~who/trusty/juju-gui-0')
89 url = jujutools.get_api_url(92 url = jujutools.get_api_url(
90 'example.com:17070', (1, 22, 2), 'uuid', charm=charm)93 'example.com:17070', (1, 22, 2), 'uuid', charm_ref=ref)
91 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)94 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)
9295
93 def test_customized_charm_unexpected_schema(self):96 def test_customized_charm_unexpected_schema(self):
94 # If a customized Juju GUI charm is used, then we assume it supports97 # If a customized Juju GUI charm is used, then we assume it supports
95 # the new Juju Login API endpoint (local charm).98 # the new Juju Login API endpoint (local charm).
96 charm = charms.Charm.from_url('local:precise/juju-gui-0')99 ref = references.Reference.from_fully_qualified_url(
100 'local:precise/juju-gui-0')
97 url = jujutools.get_api_url(101 url = jujutools.get_api_url(
98 'example.com:17070', (1, 22, 2), 'uuid', prefix='/', charm=charm)102 'example.com:17070', (1, 22, 2), 'uuid', prefix='/', charm_ref=ref)
99 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)103 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)
100104
101 def test_customized_charm_unexpected_series(self):105 def test_customized_charm_unexpected_series(self):
102 # If a customized Juju GUI charm is used, then we assume it supports106 # If a customized Juju GUI charm is used, then we assume it supports
103 # the new Juju Login API endpoint (unsupported charm series).107 # the new Juju Login API endpoint (unsupported charm series).
104 charm = charms.Charm.from_url('cs:vivid/juju-gui-0')108 ref = references.Reference.from_fully_qualified_url(
109 'cs:vivid/juju-gui-0')
105 url = jujutools.get_api_url(110 url = jujutools.get_api_url(
106 'example.com:22', (1, 22, 2), 'uuid', prefix='ws', charm=charm)111 'example.com:22', (1, 22, 2), 'uuid', prefix='ws', charm_ref=ref)
107 self.assertEqual('wss://example.com:22/ws/environment/uuid/api', url)112 self.assertEqual('wss://example.com:22/ws/environment/uuid/api', url)
108113
109 def test_recent_precise_charm(self):114 def test_recent_precise_charm(self):
110 # The new API endpoints are used if a recent precise charm is in use.115 # The new API endpoints are used if a recent precise charm is in use.
111 charm = charms.Charm.from_url('cs:precise/juju-gui-107')116 ref = references.Reference.from_fully_qualified_url(
117 'cs:precise/juju-gui-107')
112 url = jujutools.get_api_url(118 url = jujutools.get_api_url(
113 '1.2.3.4:4747', (1, 42, 0), 'env-id', charm=charm)119 '1.2.3.4:4747', (1, 42, 0), 'env-id', charm_ref=ref)
114 self.assertEqual('wss://1.2.3.4:4747/environment/env-id/api', url)120 self.assertEqual('wss://1.2.3.4:4747/environment/env-id/api', url)
115121
116 def test_recent_trusty_charm(self):122 def test_recent_trusty_charm(self):
117 # The new API endpoints are used if a recent trusty charm is in use.123 # The new API endpoints are used if a recent trusty charm is in use.
118 charm = charms.Charm.from_url('cs:trusty/juju-gui-19')124 ref = references.Reference.from_fully_qualified_url(
125 'cs:trusty/juju-gui-19')
119 url = jujutools.get_api_url(126 url = jujutools.get_api_url(
120 '1.2.3.4:4747', (1, 42, 0), 'env-id', charm=charm)127 '1.2.3.4:4747', (1, 42, 0), 'env-id', charm_ref=ref)
121 self.assertEqual('wss://1.2.3.4:4747/environment/env-id/api', url)128 self.assertEqual('wss://1.2.3.4:4747/environment/env-id/api', url)
122129
123 def test_old_precise_charm(self):130 def test_old_precise_charm(self):
124 # The old API endpoint is returned if the precise Juju GUI charm in use131 # The old API endpoint is returned if the precise Juju GUI charm in use
125 # is outdated.132 # is outdated.
126 charm = charms.Charm.from_url('cs:precise/juju-gui-106')133 ref = references.Reference.from_fully_qualified_url(
134 'cs:precise/juju-gui-106')
127 url = jujutools.get_api_url(135 url = jujutools.get_api_url(
128 '1.2.3.4:4747', (1, 42, 0), 'env-uuid', charm=charm)136 '1.2.3.4:4747', (1, 42, 0), 'env-uuid', charm_ref=ref)
129 self.assertEqual('wss://1.2.3.4:4747', url)137 self.assertEqual('wss://1.2.3.4:4747', url)
130138
131 def test_old_trusty_charm(self):139 def test_old_trusty_charm(self):
132 # The old API endpoint is returned if the trusty Juju GUI charm in use140 # The old API endpoint is returned if the trusty Juju GUI charm in use
133 # is outdated.141 # is outdated.
134 charm = charms.Charm.from_url('cs:trusty/juju-gui-18')142 ref = references.Reference.from_fully_qualified_url(
143 'cs:trusty/juju-gui-18')
135 url = jujutools.get_api_url(144 url = jujutools.get_api_url(
136 '1.2.3.4:4747', (1, 42, 0), 'env-uuid', prefix='ws', charm=charm)145 '1.2.3.4:4747', (1, 42, 0), 'env-uuid', prefix='ws', charm_ref=ref)
137 self.assertEqual('wss://1.2.3.4:4747/ws', url)146 self.assertEqual('wss://1.2.3.4:4747/ws', url)
138147
139 def test_recent_charm_and_prefix(self):148 def test_recent_charm_and_prefix(self):
140 # The new API endpoint is returned if a recent charm and a prefix are149 # The new API endpoint is returned if a recent charm and a prefix are
141 # both provided. This test exercises the real case in which the GUI150 # both provided. This test exercises the real case in which the GUI
142 # server API endpoint is returned.151 # server API endpoint is returned.
143 charm = charms.Charm.from_url('cs:trusty/juju-gui-42')152 ref = references.Reference.from_fully_qualified_url(
153 'cs:trusty/juju-gui-42')
144 url = jujutools.get_api_url(154 url = jujutools.get_api_url(
145 '1.2.3.4:17070', (1, 22, 0), 'env-id', prefix='ws', charm=charm)155 '1.2.3.4:17070', (1, 22, 0), 'env-id', prefix='ws', charm_ref=ref)
146 self.assertEqual('wss://1.2.3.4:17070/ws/environment/env-id/api', url)156 self.assertEqual('wss://1.2.3.4:17070/ws/environment/env-id/api', url)
147157
148158
@@ -231,25 +241,25 @@
231class TestParseGuiCharmUrl(unittest.TestCase):241class TestParseGuiCharmUrl(unittest.TestCase):
232242
233 def test_charm_instance_returned(self):243 def test_charm_instance_returned(self):
234 # A charm instance is correctly returned.244 # A charm reference instance is correctly returned.
235 charm = jujutools.parse_gui_charm_url('cs:trusty/juju-gui-42')245 ref = jujutools.parse_gui_charm_url('cs:trusty/juju-gui-42')
236 self.assertIsInstance(charm, charms.Charm)246 self.assertIsInstance(ref, references.Reference)
237 self.assertEqual('cs:trusty/juju-gui-42', charm.url())247 self.assertEqual('cs:trusty/juju-gui-42', ref.id())
238248
239 def test_customized(self):249 def test_customized(self):
240 # A customized charm URL is properly logged.250 # A customized charm reference is properly logged.
241 expected = 'using a customized juju-gui charm'251 expected = 'using a customized juju-gui charm'
242 with helpers.assert_logs([expected], level='warn'):252 with helpers.assert_logs([expected], level='warn'):
243 jujutools.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')253 jujutools.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')
244254
245 def test_outdated(self):255 def test_outdated(self):
246 # An outdated charm URL is properly logged.256 # An outdated charm reference is properly logged.
247 expected = 'charm is outdated and may not support bundle deployments'257 expected = 'charm is outdated and may not support bundle deployments'
248 with helpers.assert_logs([expected], level='warn'):258 with helpers.assert_logs([expected], level='warn'):
249 jujutools.parse_gui_charm_url('cs:precise/juju-gui-1')259 jujutools.parse_gui_charm_url('cs:precise/juju-gui-1')
250260
251 def test_unexpected(self):261 def test_unexpected(self):
252 # An unexpected charm URL is properly logged.262 # An unexpected charm reference is properly logged.
253 expected = (263 expected = (
254 'unexpected URL for the juju-gui charm: the service may not work '264 'unexpected URL for the juju-gui charm: the service may not work '
255 'as expected')265 'as expected')
256266
=== modified file 'quickstart/tests/test_manage.py'
--- quickstart/tests/test_manage.py 2015-02-09 17:22:04 +0000
+++ quickstart/tests/test_manage.py 2015-02-27 18:40:58 +0000
@@ -24,7 +24,6 @@
24import os24import os
25import shutil25import shutil
26import StringIO as io26import StringIO as io
27import tempfile
28import unittest27import unittest
2928
30import mock29import mock
@@ -40,9 +39,10 @@
40 views,39 views,
41)40)
42from quickstart.models import (41from quickstart.models import (
43 charms,42 bundles,
44 envs,43 envs,
45 jenv,44 jenv,
45 references,
46)46)
47from quickstart.tests import helpers47from quickstart.tests import helpers
4848
@@ -98,156 +98,6 @@
98 self.assertEqual(argparse.SUPPRESS, ppa_help)98 self.assertEqual(argparse.SUPPRESS, ppa_help)
9999
100100
101class TestValidateBundle(
102 helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin,
103 unittest.TestCase):
104
105 def setUp(self):
106 self.parser = mock.Mock()
107
108 def make_options(self, bundle, bundle_name=None):
109 """Return a mock options object which includes the passed arguments."""
110 return mock.Mock(bundle=bundle, bundle_name=bundle_name)
111
112 def test_resulting_options_from_file(self):
113 # The options object is correctly set up when a bundle file is passed.
114 bundle_file = self.make_bundle_file()
115 options = self.make_options(bundle_file, bundle_name='bundle1')
116 manage._validate_bundle(options, self.parser)
117 self.assertEqual('bundle1', options.bundle_name)
118 self.assertEqual(
119 ['mysql', 'wordpress'], sorted(options.bundle_services))
120 self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
121
122 def test_resulting_options_from_url(self):
123 # The options object is correctly set up when a bundle HTTP(S) URL is
124 # passed.
125 bundle_file = self.make_bundle_file()
126 url = 'http://example.com/bundle.yaml'
127 options = self.make_options(url, bundle_name='bundle1')
128 with self.patch_urlread(contents=self.valid_bundle) as mock_urlread:
129 manage._validate_bundle(options, self.parser)
130 mock_urlread.assert_called_once_with(url)
131 self.assertEqual('bundle1', options.bundle_name)
132 self.assertEqual(
133 ['mysql', 'wordpress'], sorted(options.bundle_services))
134 self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
135
136 def test_resulting_options_from_bundle_url(self):
137 # The options object is correctly set up when a "bundle:" URL is
138 # passed.
139 bundle_file = self.make_bundle_file()
140 url = 'bundle:~who/my/bundle'
141 options = self.make_options(url, bundle_name='bundle1')
142 with self.patch_urlread(contents=self.valid_bundle) as mock_urlread:
143 manage._validate_bundle(options, self.parser)
144 mock_urlread.assert_called_once_with(
145 'https://manage.jujucharms.com/bundle/~who/my/bundle/json')
146 self.assertEqual('bundle1', options.bundle_name)
147 self.assertEqual(
148 ['mysql', 'wordpress'], sorted(options.bundle_services))
149 self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
150
151 def test_resulting_options_from_jujucharms_url(self):
152 # The options object is correctly set up when a jujucharms bundle URL
153 # is passed.
154 bundle_file = self.make_bundle_file()
155 url = settings.JUJUCHARMS_BUNDLE_URL + 'my/bundle/'
156 options = self.make_options(url, bundle_name='bundle1')
157 with self.patch_urlread(contents=self.valid_bundle) as mock_urlread:
158 manage._validate_bundle(options, self.parser)
159 mock_urlread.assert_called_once_with(
160 'https://manage.jujucharms.com/bundle/~charmers/my/bundle/json')
161 self.assertEqual('bundle1', options.bundle_name)
162 self.assertEqual(
163 ['mysql', 'wordpress'], sorted(options.bundle_services))
164 self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
165
166 def test_resulting_options_from_dir(self):
167 # The options object is correctly set up when a bundle dir is passed.
168 bundle_dir = self.make_bundle_dir()
169 options = self.make_options(bundle_dir, bundle_name='bundle1')
170 manage._validate_bundle(options, self.parser)
171 self.assertEqual('bundle1', options.bundle_name)
172 self.assertEqual(
173 ['mysql', 'wordpress'], sorted(options.bundle_services))
174 expected = open(os.path.join(bundle_dir, 'bundles.yaml')).read()
175 self.assertEqual(expected, options.bundle_yaml)
176
177 def test_expand_user(self):
178 # The ~ construct is correctly expanded in the validation process.
179 bundle_file = self.make_bundle_file()
180 # Split the full path of the bundle file, e.g. from a full
181 # "/tmp/bundle.file" path retrieve the base path "/tmp" and the file
182 # name "bundle.file". By doing that we can simulate that the user's
183 # home is "/tmp" and that the bundle file is "~/bundle.file".
184 base_path, filename = os.path.split(bundle_file)
185 path = '~/{}'.format(filename)
186 options = self.make_options(bundle=path, bundle_name='bundle2')
187 with mock.patch('os.environ', {'HOME': base_path}):
188 manage._validate_bundle(options, self.parser)
189 self.assertEqual(self.valid_bundle, options.bundle_yaml)
190
191 def test_bundle_file_not_found(self):
192 # A parser error is invoked if the bundle file is not found.
193 options = self.make_options('/no/such/file.yaml')
194 manage._validate_bundle(options, self.parser)
195 expected = (
196 'unable to open bundle file: '
197 "[Errno 2] No such file or directory: '/no/such/file.yaml'"
198 )
199 self.parser.error.assert_called_once_with(expected)
200
201 def test_bundle_dir_not_valid(self):
202 # A parser error is invoked if the bundle dir does not contain the
203 # bundles.yaml file.
204 bundle_dir = tempfile.mkdtemp()
205 self.addCleanup(shutil.rmtree, bundle_dir)
206 options = self.make_options(bundle_dir)
207 manage._validate_bundle(options, self.parser)
208 expected = (
209 'unable to open bundle file: '
210 "[Errno 2] No such file or directory: '{}/bundles.yaml'"
211 ).format(bundle_dir)
212 self.parser.error.assert_called_once_with(expected)
213
214 def test_url_error(self):
215 # A parser error is invoked if the bundle cannot be fetched from the
216 # provided URL.
217 url = 'http://example.com/bundle.yaml'
218 options = self.make_options(url)
219 with self.patch_urlread(error=True) as mock_urlread:
220 manage._validate_bundle(options, self.parser)
221 mock_urlread.assert_called_once_with(url)
222 self.parser.error.assert_called_once_with(
223 'unable to open bundle URL: bad wolf')
224
225 def test_bundle_url_error(self):
226 # A parser error is invoked if an invalid "bundle:" URL is provided.
227 url = 'bundle:'
228 options = self.make_options(url)
229 manage._validate_bundle(options, self.parser)
230 self.parser.error.assert_called_once_with(
231 'unable to open the bundle: invalid bundle URL: bundle:')
232
233 def test_jujucharms_url_error(self):
234 # A parser error is invoked if an invalid jujucharms URL is provided.
235 url = settings.JUJUCHARMS_BUNDLE_URL + 'no-such'
236 options = self.make_options(url)
237 manage._validate_bundle(options, self.parser)
238 self.parser.error.assert_called_once_with(
239 'unable to open the bundle: invalid bundle URL: {}'.format(url))
240
241 def test_error_parsing_bundle_contents(self):
242 # A parser error is invoked if an error occurs parsing the bundle YAML.
243 bundle_file = self.make_bundle_file()
244 options = self.make_options(bundle_file, bundle_name='no-such')
245 manage._validate_bundle(options, self.parser)
246 expected = ('bundle no-such not found in the provided list of bundles '
247 '(bundle1, bundle2)')
248 self.parser.error.assert_called_once_with(expected)
249
250
251class TestValidateCharmUrl(unittest.TestCase):101class TestValidateCharmUrl(unittest.TestCase):
252102
253 def setUp(self):103 def setUp(self):
@@ -255,16 +105,16 @@
255105
256 def make_options(self, charm_url, has_bundle=False):106 def make_options(self, charm_url, has_bundle=False):
257 """Return a mock options object which includes the passed arguments."""107 """Return a mock options object which includes the passed arguments."""
258 options = mock.Mock(charm_url=charm_url, bundle=None)108 options = mock.Mock(charm_url=charm_url, bundle_source=None)
259 if has_bundle:109 if has_bundle:
260 options.bundle = 'bundle:~who/django/42/django'110 options.bundle_source = 'u/who/django/42'
261 return options111 return options
262112
263 def test_invalid_url_error(self):113 def test_invalid_url_error(self):
264 # A parser error is invoked if the charm URL is not valid.114 # A parser error is invoked if the charm URL is not valid.
265 options = self.make_options('cs:invalid')115 options = self.make_options('cs:invalid')
266 manage._validate_charm_url(options, self.parser)116 manage._validate_charm_url(options, self.parser)
267 expected = 'charm URL has invalid form: cs:invalid'117 expected = 'URL has invalid form: cs:invalid'
268 self.parser.error.assert_called_once_with(expected)118 self.parser.error.assert_called_once_with(expected)
269119
270 def test_local_charm_error(self):120 def test_local_charm_error(self):
@@ -738,14 +588,21 @@
738 expected = 'juju-quickstart {}\n'.format(quickstart.get_version())588 expected = 'juju-quickstart {}\n'.format(quickstart.get_version())
739 self.assertEqual(expected, mock_stderr.getvalue())589 self.assertEqual(expected, mock_stderr.getvalue())
740590
741 @mock.patch('quickstart.manage._validate_bundle')591 @mock.patch('quickstart.models.bundles.from_source')
742 def test_bundle(self, mock_validate_bundle):592 def test_bundle(self, mock_from_source):
743 # The bundle validation process is started if a bundle is provided.593 # The bundle validation process is started if a bundle is provided.
744 self.call_setup(['/path/to/bundle.file'], exit_called=False)594 self.call_setup(['/path/to/bundle.yaml'], exit_called=False)
745 self.assertTrue(mock_validate_bundle.called)595 mock_from_source.assert_called_once_with('/path/to/bundle.yaml', None)
746 options, parser = mock_validate_bundle.call_args_list[0][0]596
747 self.assertIsInstance(options, argparse.Namespace)597 def test_bundle_error(self):
748 self.assertIsInstance(parser, argparse.ArgumentParser)598 # The bundle validation process fails if an invalid bundle source is
599 # provided.
600 with mock.patch('sys.stderr', new_callable=io.StringIO) as mock_stderr:
601 self.call_setup(['invalid/bundle!'], exit_called=False)
602 expected_error = (
603 'error: unable to open the bundle: invalid bundle URL: '
604 'invalid/bundle!')
605 self.assertIn(expected_error, mock_stderr.getvalue())
749606
750 @mock.patch('quickstart.manage._validate_charm_url')607 @mock.patch('quickstart.manage._validate_charm_url')
751 def test_charm_url(self, mock_validate_charm_url):608 def test_charm_url(self, mock_validate_charm_url):
@@ -778,15 +635,14 @@
778@mock.patch('webbrowser.open')635@mock.patch('webbrowser.open')
779@mock.patch('quickstart.manage.app')636@mock.patch('quickstart.manage.app')
780@mock.patch('__builtin__.print', mock.Mock())637@mock.patch('__builtin__.print', mock.Mock())
781class TestRun(unittest.TestCase):638class TestRun(helpers.BundleFileTestsMixin, unittest.TestCase):
782639
783 juju_command = '/sbin/juju'640 juju_command = '/sbin/juju'
784641
785 def make_options(self, **kwargs):642 def make_options(self, **kwargs):
786 """Set up the options to be passed to the run function."""643 """Set up the options to be passed to the run function."""
787 options = {644 options = {
788 'bundle': None,645 'bundle_source': None,
789 'bundle_id': None,
790 'charm_url': None,646 'charm_url': None,
791 'debug': False,647 'debug': False,
792 'env_name': 'aws',648 'env_name': 'aws',
@@ -830,7 +686,8 @@
830 'connect': env,686 'connect': env,
831 # The environment is then checked.687 # The environment is then checked.
832 'check_environment': (688 'check_environment': (
833 charms.Charm.from_url('cs:trusty/juju-gui-42'),689 references.Reference.from_fully_qualified_url(
690 'cs:trusty/juju-gui-42'),
834 '0',691 '0',
835 {'Name': 'juju-gui'},692 {'Name': 'juju-gui'},
836 {'Name': 'juju-gui/0'}693 {'Name': 'juju-gui/0'}
@@ -921,7 +778,8 @@
921 # Even if the Juju version is new, the old GUI server login API is used778 # Even if the Juju version is new, the old GUI server login API is used
922 # if the charm in the environment is not recent enough.779 # if the charm in the environment is not recent enough.
923 self.configure_app(mock_app, check_environment=(780 self.configure_app(mock_app, check_environment=(
924 charms.Charm.from_url('cs:trusty/juju-gui-0'),781 references.Reference.from_fully_qualified_url(
782 'cs:trusty/juju-gui-0'),
925 '0',783 '0',
926 {'Name': 'juju-gui'},784 {'Name': 'juju-gui'},
927 {'Name': 'juju-gui/0'}785 {'Name': 'juju-gui/0'}
@@ -990,15 +848,15 @@
990 def test_bundle(self, mock_app, mock_open):848 def test_bundle(self, mock_app, mock_open):
991 # A bundle is correctly deployed by the application.849 # A bundle is correctly deployed by the application.
992 env = self.configure_app(mock_app, create_auth_token=None)850 env = self.configure_app(mock_app, create_auth_token=None)
851 bundle_source = 'mediawiki-single'
852 reference = references.Reference.from_jujucharms_url(bundle_source)
853 bundle = bundles.Bundle(self.bundle_data, reference=reference)
993 # Run the application.854 # Run the application.
994 options = self.make_options(855 options = self.make_options(bundle_source=bundle_source, bundle=bundle)
995 bundle='/my/bundle/file.yaml', bundle_yaml='mybundle: contents',
996 bundle_name='mybundle', bundle_services=['service1', 'service2'])
997 with self.patch_get_juju_command():856 with self.patch_get_juju_command():
998 manage.run(options)857 manage.run(options)
999 # Ensure the bundle is correctly deployed.858 # Ensure the bundle is correctly deployed.
1000 mock_app.deploy_bundle.assert_called_once_with(859 mock_app.deploy_bundle.assert_called_once_with(env, bundle)
1001 env, 'mybundle: contents', 'mybundle', None)
1002860
1003 def test_local_provider(self, mock_app, mock_open):861 def test_local_provider(self, mock_app, mock_open):
1004 # The application correctly handles working with local providers with862 # The application correctly handles working with local providers with
1005863
=== modified file 'tox.ini'
--- tox.ini 2015-02-09 10:38:25 +0000
+++ tox.ini 2015-02-27 18:40:58 +0000
@@ -71,7 +71,7 @@
71 # Dependencies present in ppa:juju/stable.71 # Dependencies present in ppa:juju/stable.
72 # See https://launchpad.net/~juju/+archive/ubuntu/stable.72 # See https://launchpad.net/~juju/+archive/ubuntu/stable.
73 websocket-client==0.18.073 websocket-client==0.18.0
74 jujuclient==0.18.474 jujuclient==0.50.1
75 urwid==1.2.175 urwid==1.2.1
76 # The distribution PyYAML requirement is used in this case.76 # The distribution PyYAML requirement is used in this case.
7777

Subscribers

People subscribed via source and target branches