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
1=== modified file 'HACKING.rst'
2--- HACKING.rst 2015-02-05 13:35:53 +0000
3+++ HACKING.rst 2015-02-27 18:40:58 +0000
4@@ -219,13 +219,13 @@
5 juju-quickstart -e local -n single $HOME/bundles/mediawiki
6 juju destroy-environment local -y
7
8-* Verify an environment that has already been bootstrapped is recogized and
9+* Verify an environment that has already been bootstrapped is recognized and
10 the GUI is deployed. This test also shows that a remote bundle is properly
11 deployed
12 ::
13
14 juju bootstrap -e local
15- juju quickstart -e local bundle:mediawiki/single
16+ juju quickstart -e local mediawiki-single
17 juju destroy-environment local -y
18
19 * Prove that an environments.yaml file can be created and used::
20
21=== modified file 'quickstart/__init__.py'
22--- quickstart/__init__.py 2015-01-12 14:30:44 +0000
23+++ quickstart/__init__.py 2015-02-27 18:40:58 +0000
24@@ -45,7 +45,7 @@
25 Once Juju has been installed, the command can also be run as a juju plugin,
26 without the hyphen ("juju quickstart").
27 """
28-VERSION = (1, 6, 0)
29+VERSION = (2, 0, 0)
30
31
32 def get_version():
33
34=== modified file 'quickstart/app.py'
35--- quickstart/app.py 2015-02-09 18:00:33 +0000
36+++ quickstart/app.py 2015-02-27 18:40:58 +0000
37@@ -408,8 +408,9 @@
38 default charm URL is used if the charm store service is not available.
39
40 Return a tuple including the following values:
41- - charm: the charm that will be used to deploy the service, as an
42- instance of "quickstart.models.charms.Charm";
43+ - charm_ref: the entity reference of the charm that will be used to
44+ deploy the service, as an instance of
45+ "quickstart.models.references.Reference";
46 - machine: the machine where to deploy to (e.g. "0") or None if a new
47 machine must be created;
48 - service_data: the service info as returned by the mega-watcher for
49@@ -449,7 +450,7 @@
50 # A deployed service already exists in the environment: ignore the
51 # provided charm URL and just use the already deployed charm.
52 charm_url = service_data['CharmURL']
53- charm = jujutools.parse_gui_charm_url(charm_url)
54+ charm_ref = jujutools.parse_gui_charm_url(charm_url)
55 # Deploy on the bootstrap node if the following conditions are satisfied:
56 # - we are not using the local provider (which uses localhost);
57 # - we are not using the azure provider (in which availability sets prevent
58@@ -457,10 +458,10 @@
59 # - the requested charm and the bootstrap node have the same series.
60 if (
61 (env_type not in ('local', 'azure')) and
62- (charm.series == bootstrap_node_series)
63+ (charm_ref.series == bootstrap_node_series)
64 ):
65 machine = '0'
66- return charm, machine, service_data, unit_data
67+ return charm_ref, machine, service_data, unit_data
68
69
70 def deploy_gui(env, service_name, charm_url, machine, service_data, unit_data):
71@@ -587,15 +588,23 @@
72 return address
73
74
75-def deploy_bundle(env, bundle_yaml, bundle_name, bundle_id):
76- """Deploy a bundle.
77+def deploy_bundle(env, bundle):
78+ """Deploy the given bundle connecting to the given environment.
79
80- Receive the environment connection to use for deploying the bundle, the
81- bundle YAML encoded contents, the bundle name to be imported and its id.
82+ Receive the environment connection to use for deploying the bundle and the
83+ bundle object as an instance of "quickstart.models.bundles.Bundle".
84
85 Raise a ProgramExit if the API server returns an error response.
86 """
87+ # XXX frankban 2015-02-26: use new bundle format if the GUI server is
88+ # capable of handling bundle deployments with the API version 4.
89+ yaml = bundle.serialize_legacy()
90+ version = 3
91+ # XXX frankban 2015-02-26: find and implement a better way to increase the
92+ # bundle deployments count.
93+ ref = bundle.reference
94+ bundle_id = None if ref is None else ref.charmworld_id
95 try:
96- env.deploy_bundle(bundle_yaml, name=bundle_name, bundle_id=bundle_id)
97+ env.deploy_bundle(yaml, version, bundle_id=bundle_id)
98 except jujuclient.EnvError as err:
99 raise ProgramExit('bad API server response: {}'.format(err.message))
100
101=== modified file 'quickstart/juju.py'
102--- quickstart/juju.py 2015-01-30 17:38:36 +0000
103+++ quickstart/juju.py 2015-02-27 18:40:58 +0000
104@@ -61,11 +61,11 @@
105 return self.login(
106 password, user='{}-{}'.format(JUJU_USER_TAG, username))
107
108- def deploy_bundle(self, yaml, name=None, bundle_id=None):
109+ def deploy_bundle(self, yaml, version, bundle_id=None):
110 """Deploy a bundle."""
111 params = {'YAML': yaml}
112- if name is not None:
113- params['Name'] = name
114+ if version > 3:
115+ params['Version'] = version
116 if bundle_id is not None:
117 params['BundleID'] = bundle_id
118 request = {
119
120=== modified file 'quickstart/jujutools.py'
121--- quickstart/jujutools.py 2015-02-09 18:00:33 +0000
122+++ quickstart/jujutools.py 2015-02-27 18:40:58 +0000
123@@ -27,10 +27,11 @@
124 serializers,
125 settings,
126 )
127-from quickstart.models import charms
128-
129-
130-def get_api_url(api_address, juju_version, env_uuid, prefix='', charm=None):
131+from quickstart.models import references
132+
133+
134+def get_api_url(
135+ api_address, juju_version, env_uuid, prefix='', charm_ref=None):
136 """Return the Juju WebSocket API endpoint.
137
138 Receives the Juju API server address, the Juju version and the unique
139@@ -38,9 +39,9 @@
140
141 Optionally receive a prefix to be used in the path.
142
143- Optionally also receive the Juju GUI charm object as an instance of
144- "quickstart.models.charms.Charm". If provided, the function checks that
145- the specified Juju GUI charm supports the new Juju API endpoint.
146+ Optionally also receive the Juju GUI charm reference as an instance of
147+ "quickstart.models.references.Reference". If provided, the function checks
148+ that the corresponding Juju GUI charm supports the new Juju API endpoint.
149 If not supported, the old endpoint is returned.
150
151 The environment UUID can be None, in which case the old-style API URL
152@@ -53,19 +54,19 @@
153 if (env_uuid is None) or (juju_version < (1, 22, 0)):
154 return base_url
155 complete_url = '{}/environment/{}/api'.format(base_url, env_uuid)
156- if charm is None:
157+ if charm_ref is None:
158 return complete_url
159 # If a customized Juju GUI charm is in use, there is no way to check if the
160 # GUI server is recent enough to support the new Juju API endpoints.
161 # In these cases, assume the customized charm is recent enough.
162 if (
163- charm.name != settings.JUJU_GUI_CHARM_NAME or
164- charm.user or
165- charm.is_local()
166+ charm_ref.name != settings.JUJU_GUI_CHARM_NAME or
167+ charm_ref.user or
168+ charm_ref.is_local()
169 ):
170 return complete_url
171 # This is the promulgated Juju GUI charm. Check if it supports new APIs.
172- revision, series = charm.revision, charm.series
173+ revision, series = charm_ref.revision, charm_ref.series
174 if revision < settings.MINIMUM_REVISIONS_FOR_NEW_API_ENDPOINT[series]:
175 return base_url
176 return complete_url
177@@ -99,29 +100,29 @@
178 def parse_gui_charm_url(charm_url):
179 """Parse the given charm URL.
180
181- Check if the charm looks like a Juju GUI charm.
182+ Check if the charm URL seems to refer to a Juju GUI charm.
183 Print (to stdout or to logs) info and warnings about the charm URL.
184
185- Return the parsed charm object as an instance of
186- "quickstart.models.charms.Charm".
187+ Return the parsed charm reference object as an instance of
188+ "quickstart.models.references.Reference".
189 """
190 print('charm URL: {}'.format(charm_url))
191- charm = charms.Charm.from_url(charm_url)
192+ ref = references.Reference.from_fully_qualified_url(charm_url)
193 charm_name = settings.JUJU_GUI_CHARM_NAME
194- if charm.name != charm_name:
195+ if ref.name != charm_name:
196 # This does not seem to be a Juju GUI charm.
197 logging.warn(
198 'unexpected URL for the {} charm: '
199 'the service may not work as expected'.format(charm_name))
200- return charm
201- if charm.user or charm.is_local():
202+ return ref
203+ if ref.user or ref.is_local():
204 # This is not the official Juju GUI charm.
205 logging.warn('using a customized {} charm'.format(charm_name))
206- elif charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]:
207+ elif ref.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[ref.series]:
208 # This is the official Juju GUI charm, but it is outdated.
209 logging.warn(
210 'charm is outdated and may not support bundle deployments')
211- return charm
212+ return ref
213
214
215 def parse_status_output(output, keys=None):
216
217=== modified file 'quickstart/manage.py'
218--- quickstart/manage.py 2015-02-09 18:00:33 +0000
219+++ quickstart/manage.py 2015-02-27 18:40:58 +0000
220@@ -22,7 +22,6 @@
221 )
222
223 import argparse
224-import codecs
225 import logging
226 import os
227 import shutil
228@@ -33,7 +32,6 @@
229 from quickstart import (
230 app,
231 jujutools,
232- netutils,
233 packaging,
234 platform_support,
235 settings,
236@@ -45,9 +43,9 @@
237 )
238 from quickstart.models import (
239 bundles,
240- charms,
241 envs,
242 jenv,
243+ references,
244 )
245
246
247@@ -97,50 +95,17 @@
248 """Validate and process the bundle options.
249
250 Populate the options namespace with the following names:
251- - bundle_name: the name of the bundle;
252- - bundle_services: a list of service names included in the bundle;
253- - bundle_yaml: the YAML encoded contents of the bundle.
254- - bundle_id: the bundle_id in Charmworld. None if not a 'bundle:' URL.
255- Exit with an error if the bundle options are not valid.
256+ - bundle: the bundle object as an instance of
257+ "quickstart.models.bundles.Bundle";
258+
259+ Exit with an error if the bundle options are not valid, or if the bundle
260+ content cannot be retrieved.
261 """
262- bundle = options.bundle
263- bundle_id = None
264- jujucharms_prefix = settings.JUJUCHARMS_BUNDLE_URL
265- if bundle.startswith('bundle:') or bundle.startswith(jujucharms_prefix):
266- # Convert "bundle:" or jujucharms.com URLs into Charmworld HTTPS ones.
267- try:
268- bundle, bundle_id = bundles.convert_bundle_url(bundle)
269- except ValueError as err:
270- return parser.error('unable to open the bundle: {}'.format(err))
271- # The next if block below will then load the bundle contents from the
272- # remote location.
273- if bundle.startswith('http://') or bundle.startswith('https://'):
274- # Load the bundle from a remote URL.
275- try:
276- bundle_yaml = netutils.urlread(bundle)
277- except IOError as err:
278- return parser.error('unable to open bundle URL: {}'.format(err))
279- else:
280- # Load the bundle from a file.
281- bundle_file = os.path.abspath(os.path.expanduser(bundle))
282- if os.path.isdir(bundle_file):
283- bundle_file = os.path.join(bundle_file, 'bundles.yaml')
284- try:
285- bundle_yaml = codecs.open(
286- bundle_file.encode('utf-8'), encoding='utf-8').read()
287- except IOError as err:
288- return parser.error('unable to open bundle file: {}'.format(err))
289- # Validate the bundle.
290 try:
291- bundle_name, bundle_services = bundles.parse_bundle(
292- bundle_yaml, options.bundle_name)
293- except ValueError as err:
294- return parser.error(bytes(err))
295- # Update the options namespace with the new values.
296- options.bundle_name = bundle_name
297- options.bundle_services = bundle_services
298- options.bundle_yaml = bundle_yaml
299- options.bundle_id = bundle_id
300+ options.bundle = bundles.from_source(
301+ options.bundle_source, options.bundle_name)
302+ except (IOError, ValueError) as err:
303+ return parser.error(b'unable to open the bundle: {}'.format(err))
304
305
306 def _validate_charm_url(options, parser):
307@@ -156,25 +121,24 @@
308 Leave the options namespace untouched.
309 """
310 try:
311- charm = charms.Charm.from_url(options.charm_url)
312+ ref = references.Reference.from_fully_qualified_url(options.charm_url)
313 except ValueError as err:
314 return parser.error(bytes(err))
315- if charm.is_local():
316- return parser.error(b'local charms are not allowed: {}'.format(charm))
317- if charm.series not in settings.JUJU_GUI_SUPPORTED_SERIES:
318- return parser.error(
319- 'unsupported charm series: {}'.format(charm.series))
320+ if ref.is_local():
321+ return parser.error(b'local charms are not allowed: {}'.format(ref))
322+ if ref.series not in settings.JUJU_GUI_SUPPORTED_SERIES:
323+ return parser.error('unsupported charm series: {}'.format(ref.series))
324 if (
325 # The user requested a bundle deployment.
326- options.bundle and
327+ options.bundle_source is not None and
328 # This is the official Juju GUI charm.
329- charm.name == settings.JUJU_GUI_CHARM_NAME and not charm.user and
330+ ref.name == settings.JUJU_GUI_CHARM_NAME and not ref.user and
331 # The charm at this revision does not support bundle deployments.
332- charm.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[charm.series]
333+ ref.revision < settings.MINIMUM_REVISIONS_FOR_BUNDLES[ref.series]
334 ):
335 return parser.error(
336 'bundle deployments not supported by the requested charm '
337- 'revision: {}'.format(charm))
338+ 'revision: {}'.format(ref))
339
340
341 def _retrieve_env_db(parser, env_file=None):
342@@ -363,7 +327,10 @@
343 """Set up the application options and logger.
344
345 Return the options as a namespace containing the following attributes:
346- - bundle: the optional bundle (path or URL) to be deployed;
347+ - bundle_name: the optional name of the bundle in the case the legacy
348+ bundle format is being used, or None if the name is not specified;
349+ - bundle_source: the optional bundle identifier to be deployed, or None
350+ if a bundle deployment is not requested;
351 - charm_url: the Juju GUI charm URL or None if not specified;
352 - constraints: the environment constrains or None if not set;
353 - debug: whether debug mode is activated;
354@@ -380,11 +347,8 @@
355
356 The following attributes will also be included in the namespace if a bundle
357 deployment is requested:
358- - bundle_name: the name of the bundle to be deployed;
359- - bundle_services: a list of service names included in the bundle;
360- - bundle_yaml: the YAML encoded contents of the bundle.
361- - bundle_id: the Charmworld identifier for the bundle if a
362- 'bundle:' URL is provided.
363+ - bundle: the bundle instance to be deployed, as an instance of
364+ "quickstart.models.bundles.Bundle".
365
366 Exit with an error if the provided arguments are not valid.
367 """
368@@ -406,26 +370,33 @@
369 # Note: since we use the RawTextHelpFormatter, when adding/changing options
370 # make sure the help text is nicely displayed on small 80 columns terms.
371 parser.add_argument(
372- 'bundle', default=None, nargs='?',
373+ 'bundle_source', default=None, nargs='?', metavar='BUNDLE',
374 help='The optional bundle to be deployed. The bundle can be:\n'
375- '1) a fully qualified bundle URL, starting with "bundle:"\n'
376- ' e.g. "bundle:mediawiki/single".\n'
377+ '1) a bundle path as shown in jujucharms.com, e.g.\n'
378+ ' "mediawiki-single" or "django".\n'
379+ ' Non promulgated bundles can be requested providing\n'
380+ ' the user, e.g. "u/bigdata-dev/apache-analytics-sql".\n'
381+ ' A specific bundle revision can also be requested,\n'
382+ ' e.g. "mediawiki-scalable/7".\n'
383+ ' If not specified, the most recent revision is used;\n'
384+ '2) a jujucharms.com full URL of the bundle detail page,\n'
385+ ' with or without the revision. e.g.\n'
386+ ' "{jujucharms}mongodb-cluster/4" or\n'
387+ ' "{jujucharms}openstack";\n'
388+ '3) a URL ("http:" or "https:") to a YAML/JSON, e.g.\n'
389+ ' "https://raw.github.com/user/my/master/bundle.yaml";\n'
390+ '4) a local path to a YAML/JSON file with ".yaml" or\n'
391+ ' ".json" extension, e.g. "~/bundles/django.yaml";\n'
392+ '5) a legacy fully qualified bundle URL, starting with\n'
393+ ' "bundle:", e.g. "bundle:mediawiki/single".\n'
394 ' Non promulgated bundles can be requested providing\n'
395 ' the user, e.g. "bundle:~user/mediawiki/single".\n'
396 ' A specific bundle revision can also be requested,\n'
397 ' e.g. "bundle:~myuser/mediawiki/42/single".\n'
398- ' If not specified, the last bundle revision is used;\n'
399- '2) a jujucharms bundle URL, starting with\n'
400- ' "{jujucharm}", e.g.\n'
401- ' "{jujucharm}~user/wiki/1/simple/".\n'
402- ' As seen above, jujucharms bundle URLs can also be\n'
403- ' shortened, e.g.\n'
404- ' "{jujucharm}mediawiki/scalable/";\n'
405- '3) a URL ("http:" or "https:") to a YAML/JSON, e.g.\n'
406- ' "https://raw.github.com/user/my/master/bundles.yaml";\n'
407- '4) a local path to a YAML/JSON file;\n'
408- '5) a path to a directory containing a "bundles.yaml"\n'
409- ' file'.format(jujucharm=settings.JUJUCHARMS_BUNDLE_URL))
410+ ' If not specified, the last bundle revision is used.\n'
411+ ' Note that this form is DEPRECATED, and a deprecation\n'
412+ ' warning is printed suggesting the new value to use\n'
413+ ''.format(jujucharms=settings.JUJUCHARMS_URL))
414 parser.add_argument(
415 '-e', '--environment', default=default_env_name, dest='env_name',
416 help=env_help)
417@@ -497,7 +468,7 @@
418 _convert_options_to_unicode(options)
419 # Validate and process the provided arguments.
420 _setup_env(options, parser)
421- if options.bundle is not None:
422+ if options.bundle_source is not None:
423 _validate_bundle(options, parser)
424 if options.charm_url is not None:
425 _validate_charm_url(options, parser)
426@@ -509,9 +480,9 @@
427 def run(options):
428 """Run the application."""
429 print('juju quickstart v{}'.format(version))
430- if options.bundle is not None:
431- print('contents loaded for bundle {} (services: {})'.format(
432- options.bundle_name, len(options.bundle_services)))
433+ if options.bundle_source is not None:
434+ print('contents loaded for {} (services: {})'.format(
435+ options.bundle, len(options.bundle.services())))
436
437 juju_command, custom_juju = platform_support.get_juju_command(
438 options.platform)
439@@ -578,11 +549,11 @@
440 print('environment type: {}'.format(env_type))
441
442 # Inspect the environment and deploy the charm if required.
443- charm, machine, service_data, unit_data = app.check_environment(
444+ charm_ref, machine, service_data, unit_data = app.check_environment(
445 env, settings.JUJU_GUI_SERVICE_NAME, options.charm_url,
446 env_type, bootstrap_node_series, already_bootstrapped)
447 unit_name = app.deploy_gui(
448- env, settings.JUJU_GUI_SERVICE_NAME, charm.url(), machine,
449+ env, settings.JUJU_GUI_SERVICE_NAME, charm_ref.id(), machine,
450 service_data, unit_data)
451
452 # Observe the deployment progress.
453@@ -597,20 +568,22 @@
454 # Connect to the GUI server WebSocket API.
455 print('connecting to the Juju GUI server')
456 gui_api_url = jujutools.get_api_url(
457- address + ':443', juju_version, env_uuid, prefix='ws', charm=charm)
458+ address + ':443', juju_version, env_uuid,
459+ prefix='ws', charm_ref=charm_ref)
460 gui_env = app.connect(gui_api_url, username, password)
461
462 # Handle bundle deployment.
463- if options.bundle is not None:
464- services = ', '.join(options.bundle_services)
465- print('requesting a deployment of the {} bundle with the following '
466- 'services:\n {}'.format(options.bundle_name, services))
467+ if options.bundle_source is not None:
468+ services = ', '.join(options.bundle.services())
469+ print('requesting a deployment of {} with the following services:\n'
470+ ' {}'.format(options.bundle, services))
471+ if options.bundle.reference is not None:
472+ print('more details about this bundle can be found at\n'
473+ ' {}'.format(options.bundle.reference.jujucharms_url()))
474 # We need to connect to an API WebSocket server supporting bundle
475 # deployments. The GUI builtin server, listening on the Juju GUI
476 # address, exposes an API suitable for deploying bundles.
477- app.deploy_bundle(
478- gui_env, options.bundle_yaml, options.bundle_name,
479- options.bundle_id)
480+ app.deploy_bundle(gui_env, options.bundle)
481 print('bundle deployment request accepted\n'
482 'use the GUI to check the bundle deployment progress')
483
484
485=== modified file 'quickstart/models/bundles.py'
486--- quickstart/models/bundles.py 2015-02-09 12:58:04 +0000
487+++ quickstart/models/bundles.py 2015-02-27 18:40:58 +0000
488@@ -14,105 +14,317 @@
489 # You should have received a copy of the GNU Affero General Public License
490 # along with this program. If not, see <http://www.gnu.org/licenses/>.
491
492-"""Juju Quickstart bundles management."""
493+"""Juju Quickstart bundles management.
494+
495+This module defines objects and functions that help working with bundles.
496+Bundles are described by a YAML content defining a collection of services in a
497+Juju topology, along with their options, relations and unit placement.
498+
499+Published bundles are identified by a charm store id and by the corresponding
500+URL in jujucharms.com, just like regular charms. The reference object in
501+"quickstart.models.references.Reference" can be used to identify a bundle.
502+
503+In this module, the Bundle class represents a bundle that may or may not have
504+a specific reference id. For instance, a reference is not set on a bundle if
505+its contents are retrieved from an arbitrary local or remote location.
506+
507+Juju Quickstart usually instantiates bundles using the "from_source" helper
508+below, which retrieves the bundle content from all the supported sources,
509+validates it and then creates a "Bundle" instance with the validated content
510+and the bundle reference if avaliable.
511+
512+Use "parse_yaml" to parse and validate a YAML encoded string as a bundle
513+content. If the YAML decoded object is already available, the same validation
514+can be achieved using the "validate" function directly.
515+"""
516
517 from __future__ import unicode_literals
518
519+import codecs
520 import collections
521-import re
522+import logging
523+import os
524
525 from quickstart import (
526+ netutils,
527 serializers,
528 settings,
529 )
530-
531-
532-# Compile the regular expression used to parse bundle URLs.
533-_bundle_expression = re.compile(r"""
534- # Bundle schema or bundle URL namespace on jujucharms.com.
535- ^(?:bundle:|{})
536- (?:~([-\w]+)/)? # Optional user name.
537- ([-\w]+)/ # Basket name.
538- (?:(\d+)/)? # Optional bundle revision number.
539- ([-\w]+) # Bundle name.
540- /?$ # Optional trailing slash.
541-""".format(settings.JUJUCHARMS_BUNDLE_URL), re.VERBOSE)
542-
543-
544-def convert_bundle_url(bundle_url):
545- """Return the equivalent YAML HTTPS location for the given bundle URL.
546-
547- Raise a ValueError if the given URL is not a valid bundle URL.
548- """
549- match = _bundle_expression.match(bundle_url)
550- if match is None:
551- msg = 'invalid bundle URL: {}'.format(bundle_url)
552- raise ValueError(msg.encode('utf-8'))
553- user, basket, revision, name = match.groups()
554- user_part = '~charmers/' if user is None else '~{}/'.format(user)
555- revision_part = '' if revision is None else '{}/'.format(revision)
556- bundle_id = '{}{}/{}{}'.format(user_part, basket, revision_part, name)
557- return ('https://manage.jujucharms.com/bundle/{}/json'.format(bundle_id),
558- bundle_id)
559-
560-
561-def parse_bundle(bundle_yaml, bundle_name=None):
562- """Parse the provided bundle YAML encoded contents.
563-
564- Since a valid JSON is a subset of YAML this function can be used also to
565- parse JSON encoded contents.
566-
567- Return a tuple containing the bundle name and the list of services included
568- in the bundle.
569-
570- Raise a ValueError if:
571- - the bundle YAML contents are not parsable by YAML;
572- - the YAML contents are not properly structured;
573- - the bundle name is specified but not included in the bundle file;
574- - the bundle name is not specified and the bundle file includes more than
575- one bundle;
576- - the bundle does not include services.
577- """
578- # Parse the bundle file.
579- try:
580- bundles = serializers.yaml_load(bundle_yaml)
581+from quickstart.models import references
582+
583+
584+class Bundle(object):
585+ """Store information about a charm store bundle entity"""
586+
587+ def __init__(self, data, reference=None):
588+ """Initialize the bundle.
589+
590+ The data argument is the bundle YAML decoded content.
591+ An optional entity reference can be provided as an instance of
592+ "quickstart.models.references.Reference".
593+ """
594+ self.data = data
595+ self.reference = reference
596+
597+ def __str__(self):
598+ """Return the byte string representation of this bundle."""
599+ return self.__unicode__().encode('utf-8')
600+
601+ def __unicode__(self):
602+ """Return the unicode string representation of this bundle."""
603+ parts = ['bundle']
604+ if self.reference is not None:
605+ parts.append(self.reference.jujucharms_id())
606+ return ' '.join(parts)
607+
608+ def __repr__(self):
609+ return b'<Bundle: {}>'.format(bytes(self))
610+
611+ def serialize(self):
612+ """Serialize the bundle data as a YAML encoded string."""
613+ return serializers.yaml_dump(self.data)
614+
615+ def serialize_legacy(self):
616+ """Serialize the bundle data as a YAML encoded string.
617+
618+ The resulting string uses the legacy API version 3 format.
619+ """
620+ return serializers.yaml_dump({'bundle': self.data})
621+
622+ def services(self):
623+ """Return a list of service names included in the bundle.
624+
625+ Service names are returned in alphabetical order.
626+ """
627+ return sorted(self.data['services'].keys())
628+
629+
630+def from_source(source, name=None):
631+ """Return a bundle YAML encoded string and id from the given source.
632+
633+ The source argument is a string, and can be provided as:
634+
635+ - a bundle path as shown in jujucharms.com, e.g. "mediawiki-single" or
636+ "u/bigdata-dev/apache-analytics-sql";
637+
638+ - a bundle path as shown in jujucharms.com including the bundle
639+ revision, e.g. "mediawiki-single/7" or "u/frankban/django/42";
640+
641+ - the two forms above with leading or trailing slashes, e.g.
642+ "/mediawiki-scalable" or "/u/frankban/django/42";
643+
644+ - a full jujucharms.com URL, e.g. "https://jujucharms.com/django/" or
645+ "https://jujucharms.com/u/bigdata-dev/apache-analytics-sql";
646+
647+ - a full jujucharms.com URL including the bundle revision, e.g.
648+ "https://jujucharms.com/django/2/";
649+
650+ - a URL ("http:" or "https:") to a YAML/JSON, e.g.
651+ "https://raw.github.com/user/my/master/bundles.yaml";
652+
653+ - a local path to a YAML/JSON file, ending with ".yaml" or ".json",
654+ e.g. "mybundle.yaml" or "~/bundles/django.json";
655+
656+ - an old style bundle fully qualified URL, e.g.
657+ "bundle:~myuser/mediawiki/42/single";
658+
659+ - and old style bundle URL without user and/or revision, e.g.
660+ "bundle:mediawiki/single" or "bundle:~user/mediawiki/single".
661+
662+ Return a Bundle instance whose bundle reference attribute is None if this
663+ information cannot be inferred from the given source.
664+
665+ Raise a ValueError if the given source is not valid.
666+ Raise an IOError if the YAML content cannot be retrieved from the given
667+ local or remote source.
668+ """
669+ if source.startswith('bundle:'):
670+ # The source refers to an old style bundle URL.
671+ reference = references.Reference.from_charmworld_url(source)
672+ logging.warn(
673+ 'this bundle URL is deprecated: please use the new format: '
674+ '{}'.format(reference.jujucharms_id()))
675+ return _bundle_from_reference(reference)
676+
677+ has_extension = source.endswith('.yaml') or source.endswith('.json')
678+ is_remote = source.startswith('http://') or source.startswith('https://')
679+ if has_extension and not is_remote:
680+ # The source refers to a local file.
681+ data = _parse_and_flatten_yaml(_retrieve_from_file(source), name)
682+ return Bundle(data)
683+
684+ try:
685+ reference = references.Reference.from_jujucharms_url(source)
686+ except ValueError:
687+ if is_remote:
688+ # The source is an arbitrary URL to a YAML/JSON content.
689+ data = _parse_and_flatten_yaml(_retrieve_from_url(source), name)
690+ return Bundle(data)
691+ # No other options are available.
692+ raise
693+
694+ if not reference.is_bundle():
695+ raise ValueError(
696+ b'expected a bundle, provided charm {}'.format(reference))
697+
698+ # The source refers to a bundle URL in jujucharms.com.
699+ return _bundle_from_reference(reference)
700+
701+
702+def _bundle_from_reference(reference):
703+ """Retrieve bundle YAML contents from its reference in the charm store.
704+
705+ The path of an entity in the charm store is the fully qualified URL without
706+ the schema. The schema is implicitly set to "cs" (charm store entity), e.g.
707+ "vivid/django" or "~who/trusty/mediawiki-42".
708+
709+ Return a Bundle instance which includes the retrieved data and the given
710+ reference.
711+ Raise a IOError if a problem is encountered while fetching the YAML
712+ content from the charm store.
713+ Raise a ValueError if the bundle content is not valid.
714+ """
715+ url = settings.CHARMSTORE_API + reference.path() + '/archive/bundle.yaml'
716+ content = _retrieve_from_url(url)
717+ data = parse_yaml(content)
718+ return Bundle(data, reference=reference)
719+
720+
721+def _retrieve_from_url(url):
722+ """Retrieve bundle YAML content from the given URL.
723+
724+ Return the bundle content as a YAML encoded string.
725+ Raise a IOError if a problem is encountered while opening the URL.
726+ """
727+ try:
728+ return netutils.urlread(url)
729+ except IOError as err:
730+ msg = b'cannot retrieve bundle from remote URL {}: {}'.format(
731+ url.encode('utf-8'), err)
732+ raise IOError(msg)
733+
734+
735+def _retrieve_from_file(path):
736+ """Retrieve bundle YAML content from the given local file path.
737+
738+ Return the bundle content as a YAML encoded string.
739+ Raise a IOError if a problem is encountered while opening the file.
740+ """
741+ path = os.path.abspath(os.path.expanduser(path))
742+ try:
743+ return codecs.open(path.encode('utf-8'), encoding='utf-8').read()
744+ except IOError as err:
745+ raise IOError(
746+ b'cannot retrieve bundle from local file: {}'.format(err))
747+
748+
749+def parse_yaml(content):
750+ """Parse and validate the given bundle content as a YAML encoded string.
751+
752+ Note that the bundle validation performed by Juju Quickstart is weak by
753+ design: it just checks that the content looks like a bundle YAML. Contents
754+ provided by the charm store are already known as valid. For other sources,
755+ a more cogent validation is done down in the stack, when the content is
756+ sent to the GUI server and then to the Juju deployer.
757+
758+ Return the resulting YAML decoded dictionary.
759+ Raise a ValueError if:
760+ - the bundle YAML contents are not parsable by YAML;
761+ - the YAML contents are not properly structured;
762+ - the bundle does not include services.
763+ """
764+ data = _open_yaml(content)
765+ # Validate the bundle data.
766+ validate(data)
767+ return data
768+
769+
770+def _parse_and_flatten_yaml(content, name):
771+ """Parse and validate the given bundle content.
772+
773+ The content is provided as a YAML encoded string and can be either a new
774+ style flat bundle or a legacy bundle format.
775+ In both cases, the returned YAML decoded data represents a new style
776+ bundle (API version 4).
777+
778+ Raise a ValueError if:
779+ - the bundle YAML contents are not parsable by YAML;
780+ - the YAML contents are not properly structured;
781+ - the bundle name is specified but not included in the bundle file;
782+ - the bundle name is not specified and the bundle file includes more
783+ than one bundle;
784+ - the bundle does not include services.
785+ """
786+ data = _open_yaml(content)
787+ services = data.get('services')
788+ # The internal structure of a bundle in the API version 4 does not include
789+ # a wrapping namespace with the bundle name. That's why the check below,
790+ # despite its ugliness, is quite effective.
791+ if services and 'services' not in services:
792+ # This is an API version 4 bundle.
793+ validate(data)
794+ return data
795+ num_bundles = len(data)
796+ if not num_bundles:
797+ raise ValueError(b'no bundles found in the provided list of bundles')
798+ names = ', '.join(sorted(data.keys()))
799+ if name is None:
800+ if num_bundles > 1:
801+ msg = 'multiple bundles found ({}) but no bundle name specified'
802+ raise ValueError(msg.format(names).encode('utf-8'))
803+ data = data.values()[0]
804+ else:
805+ data = data.get(name)
806+ if data is None:
807+ msg = 'bundle {} not found in the provided list of bundles ({})'
808+ raise ValueError(msg.format(name, names).encode('utf-8'))
809+ validate(data)
810+ return data
811+
812+
813+def _open_yaml(content):
814+ """Deserialize the given content, that must be a YAML encoded dictionary.
815+
816+ Raise a ValueError if the content is not valid.
817+ """
818+ try:
819+ data = serializers.yaml_load(content)
820 except Exception as err:
821- msg = b'unable to parse the bundle: {}'.format(err)
822+ msg = b'unable to parse the bundle content: {}'.format(err)
823 raise ValueError(msg)
824- # Ensure the bundle file is well formed and contains at least one bundle.
825- if not isinstance(bundles, collections.Mapping):
826- msg = 'invalid YAML contents: {}'.format(bundle_yaml)
827+ # Ensure the bundle content is well formed.
828+ if not isinstance(data, collections.Mapping):
829+ msg = 'invalid YAML content: {}'.format(data)
830 raise ValueError(msg.encode('utf-8'))
831+ return data
832+
833+
834+def validate(data):
835+ """Validate the given YAML decoded bundle data.
836+
837+ Note that the bundle validation performed by Juju Quickstart is weak by
838+ design: it just checks that the content looks like a bundle YAML. Contents
839+ provided by the charm store are already known as valid. For other sources,
840+ a more cogent validation is done down in the stack, when the content is
841+ sent to the GUI server and then to the Juju deployer.
842+
843+ Raise a ValueError if:
844+ - the YAML contents are not properly structured;
845+ - the bundle does not include services.
846+ """
847+ # Retrieve the bundle services.
848 try:
849- name_services_map = dict(
850- (key, value['services'].keys())
851- for key, value in bundles.items()
852- )
853+ services = data['services'].keys()
854 except (AttributeError, KeyError, TypeError):
855- msg = 'invalid YAML contents: {}'.format(bundle_yaml)
856- raise ValueError(msg.encode('utf-8'))
857- if not name_services_map:
858- raise ValueError(b'no bundles found')
859- # Retrieve the bundle name and services.
860- if bundle_name is None:
861- if len(name_services_map) > 1:
862- msg = 'multiple bundles found ({}) but no bundle name specified'
863- bundle_names = ', '.join(sorted(name_services_map.keys()))
864- raise ValueError(msg.format(bundle_names).encode('utf-8'))
865- bundle_name, bundle_services = name_services_map.items()[0]
866- else:
867- bundle_services = name_services_map.get(bundle_name)
868- if bundle_services is None:
869- msg = 'bundle {} not found in the provided list of bundles ({})'
870- bundle_names = ', '.join(sorted(name_services_map.keys()))
871- raise ValueError(
872- msg.format(bundle_name, bundle_names).encode('utf-8'))
873- if not bundle_services:
874- msg = 'bundle {} does not include any services'.format(bundle_name)
875- raise ValueError(msg.encode('utf-8'))
876- if settings.JUJU_GUI_SERVICE_NAME in bundle_services:
877- msg = ('bundle {} contains an instance of juju-gui. quickstart will '
878- 'install the latest version of the Juju GUI automatically, '
879- 'please remove juju-gui from the bundle.'.format(bundle_name))
880- raise ValueError(msg.encode('utf-8'))
881- return bundle_name, bundle_services
882+ content = serializers.yaml_dump(data).strip()
883+ msg = 'unable to retrieve bundle services: {}'.format(content)
884+ raise ValueError(msg.encode('utf-8'))
885+ # Ensure at least one service is defined in the bundle.
886+ if not services:
887+ raise ValueError(b'no services found in the bundle')
888+ # Check that the Juju GUI charm is not included as a service.
889+ if settings.JUJU_GUI_SERVICE_NAME in services:
890+ raise ValueError(
891+ b'the provided bundle contains an instance of juju-gui. Juju '
892+ b'Quickstart will install the latest version of the Juju GUI '
893+ b'automatically; please remove juju-gui from the bundle')
894
895=== renamed file 'quickstart/models/charms.py' => 'quickstart/models/references.py'
896--- quickstart/models/charms.py 2015-02-09 14:56:32 +0000
897+++ quickstart/models/references.py 2015-02-27 18:40:58 +0000
898@@ -14,37 +14,203 @@
899 # You should have received a copy of the GNU Affero General Public License
900 # along with this program. If not, see <http://www.gnu.org/licenses/>.
901
902-"""Juju Quickstart charms management."""
903+"""Juju Quickstart charm and bundle references management."""
904
905 from __future__ import unicode_literals
906
907 import re
908
909+from quickstart import settings
910+
911
912 # The following regular expressions are the same used in juju-core: see
913 # http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go.
914-valid_user = re.compile(r'^[a-z0-9][a-zA-Z0-9+.-]+$').match
915-valid_series = re.compile(r'^[a-z]+([a-z-]+[a-z])?$').match
916-valid_name = re.compile(r'^[a-z][a-z0-9]*(-[a-z0-9]*[a-z][a-z0-9]*)*$').match
917-
918-
919-def parse_url(url):
920- """Parse the given charm URL.
921-
922- Return a tuple containing the charm URL fragments: schema, user, series,
923- name and revision. Each fragment is a string except revision (int).
924+_USER_PATTERN = r'[a-z0-9][a-zA-Z0-9+.-]+'
925+_SERIES_PATTERN = r'[a-z]+(?:[a-z-]+[a-z])?'
926+_NAME_PATTERN = r'[a-z][a-z0-9]*(?:-[a-z0-9]*[a-z][a-z0-9]*)*'
927+
928+# Define the callables used to check if entity reference components are valid.
929+_valid_user = re.compile(r'^{}$'.format(_USER_PATTERN)).match
930+_valid_series = re.compile(r'^{}$'.format(_SERIES_PATTERN)).match
931+_valid_name = re.compile(r'^{}$'.format(_NAME_PATTERN)).match
932+
933+# Compile the regular expression used to parse charmworld bundle URLs.
934+_charmworld_url_expression = re.compile(r"""
935+ ^ # Beginning of the line.
936+ (?:bundle:) # Bundle schema.
937+ (?:~({user_pattern})/)? # Optional user name.
938+ ({name_pattern})/ # Basket name.
939+ (?:(\d+)/)? # Optional bundle revision number.
940+ ({name_pattern}) # Bundle name.
941+ /? # Optional trailing slash.
942+ $ # End of the line.
943+""".format(
944+ name_pattern=_NAME_PATTERN,
945+ user_pattern=_USER_PATTERN,
946+), re.VERBOSE)
947+# Compile the regular expression used to parse new jujucharms entity URLs.
948+_jujucharms_url_expression = re.compile(r"""
949+ ^ # Beginning of the line.
950+ (?:
951+ (?:{jujucharms})? # Optional jujucharms.com URL.
952+ |
953+ /? # Optional leading slash.
954+ )?
955+ (?:u/({user_pattern})/)? # Optional user name.
956+ ({name_pattern}) # Bundle name.
957+ (?:/({series_pattern}))? # Optional series.
958+ (?:/(\d+))? # Optional bundle revision number.
959+ /? # Optional trailing slash.
960+ $ # End of the line.
961+""".format(
962+ jujucharms=settings.JUJUCHARMS_URL,
963+ name_pattern=_NAME_PATTERN,
964+ series_pattern=_SERIES_PATTERN,
965+ user_pattern=_USER_PATTERN,
966+), re.VERBOSE)
967+
968+
969+class Reference(object):
970+ """Represent a charm or bundle URL reference."""
971+
972+ def __init__(self, schema, user, series, name, revision):
973+ """Initialize the reference. Receives the URL fragments."""
974+ self.schema = schema
975+ self.user = user
976+ self.series = series
977+ self.name = name
978+ if revision is not None:
979+ revision = int(revision)
980+ self.revision = revision
981+ # XXX frankban 2015-02-26: remove the following attribute when
982+ # switching to the new bundle format, and when we have a better way
983+ # to increase bundle deployments count.
984+ self.charmworld_id = None
985+
986+ @classmethod
987+ def from_fully_qualified_url(cls, url):
988+ """Given an entity URL as a string, create and return a Reference.
989+
990+ Fully qualified URLs represent the regular entity reference
991+ representation in Juju, e.g.: "cs:`~who/vivid/django-42" or
992+ "local:bundle/wordpress-0".
993+
994+ Raise a ValueError if the provided value is not a valid and fully
995+ qualified URL, also including the schema and the revision.
996+ """
997+ return cls(*_parse_fully_qualified_url(url))
998+
999+ @classmethod
1000+ def from_charmworld_url(cls, url):
1001+ """Create and return a Reference from the given charmworld URL.
1002+
1003+ These kind of "bundle:basket/name" URLs were used before the release
1004+ of the new charm store (API version 4). Possible examples are
1005+ "bundle:mediawiki/single" or "bundle:~who/wordpress/42/scalable".
1006+ Note that charmworld URLs always represent a bundle.
1007+
1008+ Raise a ValueError if the provided URL is not valid.
1009+ """
1010+ match = _charmworld_url_expression.match(url)
1011+ if match is None:
1012+ msg = 'invalid bundle URL: {}'.format(url)
1013+ raise ValueError(msg.encode('utf-8'))
1014+ user, basket, revision, name = match.groups()
1015+ name = '{}-{}'.format(basket, name)
1016+ self = cls('cs', user, 'bundle', name, revision)
1017+ # XXX frankban 2015-02-26: remove this when switching to the new bundle
1018+ # format. Note that this is monkey patched on purpose: we don't want
1019+ # the legacy bundle id to be part of this class contract, and we don't
1020+ # want to keep track of obsolete concepts such as "basket" here.
1021+ self.charmworld_id = url[len('bundle:'):]
1022+ return self
1023+
1024+ @classmethod
1025+ def from_jujucharms_url(cls, url):
1026+ """Create and return a Reference from the given jujucharms.com URL.
1027+
1028+ These are the preferred way to refer to a charm or bundle in Juju
1029+ Quickstart. They basically look like the URL paths in jujucharms.com,
1030+ e.g. "u/who/django", "mediawiki/42" or just "mediawiki". The full HTTP
1031+ URL can be also provided, for instance "https://jujucharms.com/django".
1032+
1033+ Raise a ValueError if the provided URL is not valid.
1034+ """
1035+ match = _jujucharms_url_expression.match(url)
1036+ if match is None:
1037+ msg = 'invalid bundle URL: {}'.format(url)
1038+ raise ValueError(msg.encode('utf-8'))
1039+ user, name, series, revision = match.groups()
1040+ return cls('cs', user, series or 'bundle', name, revision)
1041+
1042+ def __str__(self):
1043+ """The string representation of a reference is its URL string."""
1044+ return self.__unicode__().encode('utf-8')
1045+
1046+ def __unicode__(self):
1047+ """The unicode representation of a reference is its URL string."""
1048+ return self.id()
1049+
1050+ def __repr__(self):
1051+ return b'<Reference: {}>'.format(bytes(self))
1052+
1053+ def __eq__(self, other):
1054+ """Two refs are equal if they have the same string representation."""
1055+ return isinstance(other, self.__class__) and self.id() == other.id()
1056+
1057+ def path(self):
1058+ """Return the reference as a string without the schema."""
1059+ user_part = '~{}/'.format(self.user) if self.user else ''
1060+ revision_part = ''
1061+ if self.revision is not None:
1062+ revision_part = '-{}'.format(self.revision)
1063+ return '{}{}/{}{}'.format(
1064+ user_part, self.series, self.name, revision_part)
1065+
1066+ def id(self):
1067+ """Return the reference URL as a string."""
1068+ return '{}:{}'.format(self.schema, self.path())
1069+
1070+ def jujucharms_id(self):
1071+ """Return the identifier of this reference in jujucharms.com."""
1072+ user_part = 'u/{}/'.format(self.user) if self.user else ''
1073+ series_part = '' if self.is_bundle() else '/{}'.format(self.series)
1074+ revision_part = ''
1075+ if self.revision is not None:
1076+ revision_part = '/{}'.format(self.revision)
1077+ return '{}{}{}{}'.format(
1078+ user_part, self.name, series_part, revision_part)
1079+
1080+ def jujucharms_url(self):
1081+ """Return the URL where this entity lives in jujucharms.com."""
1082+ return settings.JUJUCHARMS_URL + self.jujucharms_id()
1083+
1084+ def is_bundle(self):
1085+ """Report whether this reference refers to a bundle entity."""
1086+ return self.series == 'bundle'
1087+
1088+ def is_local(self):
1089+ """Return True if this refers to a local entity, False otherwise."""
1090+ return self.schema == 'local'
1091+
1092+
1093+def _parse_fully_qualified_url(url):
1094+ """Parse the given charm or bundle URL, provided as a string.
1095+
1096+ Return a tuple containing the entity reference fragments: schema, user,
1097+ series, name and revision. Each fragment is a string except revision (int).
1098
1099 Raise a ValueError with a descriptive message if the given URL is not a
1100- valid charm URL.
1101+ valid and fully qualified entity URL.
1102 """
1103 # Retrieve the schema.
1104 try:
1105 schema, remaining = url.split(':', 1)
1106 except ValueError:
1107- msg = 'charm URL has no schema: {}'.format(url)
1108+ msg = 'URL has no schema: {}'.format(url)
1109 raise ValueError(msg.encode('utf-8'))
1110 if schema not in ('cs', 'local'):
1111- msg = 'charm URL has invalid schema: {}'.format(schema)
1112+ msg = 'URL has invalid schema: {}'.format(schema)
1113 raise ValueError(msg.encode('utf-8'))
1114 # Retrieve the optional user, the series, name and revision.
1115 parts = remaining.split('/')
1116@@ -52,82 +218,37 @@
1117 if parts_length == 3:
1118 user, series, name_revision = parts
1119 if not user.startswith('~'):
1120- msg = 'charm URL has invalid user name form: {}'.format(user)
1121+ msg = 'URL has invalid user name form: {}'.format(user)
1122 raise ValueError(msg.encode('utf-8'))
1123 user = user[1:]
1124- if not valid_user(user):
1125- msg = 'charm URL has invalid user name: {}'.format(user)
1126+ if not _valid_user(user):
1127+ msg = 'URL has invalid user name: {}'.format(user)
1128 raise ValueError(msg.encode('utf-8'))
1129 if schema == 'local':
1130- msg = 'local charm URL with user name: {}'.format(url)
1131+ msg = 'local entity URL with user name: {}'.format(url)
1132 raise ValueError(msg.encode('utf-8'))
1133 elif parts_length == 2:
1134 user = ''
1135 series, name_revision = parts
1136 else:
1137- msg = 'charm URL has invalid form: {}'.format(url)
1138+ msg = 'URL has invalid form: {}'.format(url)
1139 raise ValueError(msg.encode('utf-8'))
1140 # Validate the series.
1141- if not valid_series(series):
1142- msg = 'charm URL has invalid series: {}'.format(series)
1143+ if not _valid_series(series):
1144+ msg = 'URL has invalid series: {}'.format(series)
1145 raise ValueError(msg.encode('utf-8'))
1146 # Validate name and revision.
1147 try:
1148 name, revision = name_revision.rsplit('-', 1)
1149 except ValueError:
1150- msg = 'charm URL has no revision: {}'.format(url)
1151+ msg = 'URL has no revision: {}'.format(url)
1152 raise ValueError(msg.encode('utf-8'))
1153- if not valid_name(name):
1154- msg = 'charm URL has invalid name: {}'.format(name)
1155+ if not _valid_name(name):
1156+ msg = 'URL has invalid name: {}'.format(name)
1157 raise ValueError(msg.encode('utf-8'))
1158 try:
1159 revision = int(revision)
1160 except ValueError:
1161- msg = 'charm URL has invalid revision: {}'.format(revision)
1162+ msg = 'URL has invalid revision: {}'.format(revision)
1163 raise ValueError(msg.encode('utf-8'))
1164 return schema, user, series, name, revision
1165-
1166-
1167-class Charm(object):
1168- """Represent the charm information stored in the charm URL."""
1169-
1170- def __init__(self, schema, user, series, name, revision):
1171- """Initialize the charm. Receives the URL fragments."""
1172- self.schema = schema
1173- self.user = user
1174- self.series = series
1175- self.name = name
1176- self.revision = int(revision)
1177-
1178- @classmethod
1179- def from_url(cls, url):
1180- """Given a charm URL, create and return a Charm instance.
1181-
1182- Raise a ValueError if the charm URL is not valid.
1183- """
1184- return cls(*parse_url(url))
1185-
1186- def __str__(self):
1187- """The string representation of a charm is its URL."""
1188- return self.__unicode__().encode('utf-8')
1189-
1190- def __unicode__(self):
1191- """The unicode representation of a charm is its URL."""
1192- return self.url()
1193-
1194- def __repr__(self):
1195- return b'<Charm: {}>'.format(bytes(self))
1196-
1197- def __eq__(self, other):
1198- """Two charms are equal if they have the same URL."""
1199- return isinstance(other, self.__class__) and self.url() == other.url()
1200-
1201- def url(self):
1202- """Return the charm URL."""
1203- user_part = '~{}/'.format(self.user) if self.user else ''
1204- return '{}:{}{}/{}-{}'.format(
1205- self.schema, user_part, self.series, self.name, self.revision)
1206-
1207- def is_local(self):
1208- """Return True if this is a local charm, False otherwise."""
1209- return self.schema == 'local'
1210
1211=== modified file 'quickstart/netutils.py'
1212--- quickstart/netutils.py 2015-01-12 15:00:52 +0000
1213+++ quickstart/netutils.py 2015-02-27 18:40:58 +0000
1214@@ -69,8 +69,8 @@
1215 Raise an IOError if any problems occur connecting to the API endpoint.
1216 Raise a ValueError if the API returns invalid data.
1217 """
1218- url = settings.CHARMSTORE_API.format(
1219- series=series, charm=settings.JUJU_GUI_CHARM_NAME)
1220+ url = '{}{}/{}/meta/id'.format(
1221+ settings.CHARMSTORE_API, series, settings.JUJU_GUI_CHARM_NAME)
1222 data = json.loads(urlread(url))
1223 charm_url = data.get('Id')
1224 if charm_url is None:
1225
1226=== modified file 'quickstart/settings.py'
1227--- quickstart/settings.py 2015-02-09 15:56:20 +0000
1228+++ quickstart/settings.py 2015-02-27 18:40:58 +0000
1229@@ -29,11 +29,8 @@
1230 UNKNOWN_PLATFORM = object()
1231 WINDOWS = object()
1232
1233-# The base charm store API URL containing information about charms.
1234-# This URL must be formatted with a series and a charm name.
1235-CHARMSTORE_API = (
1236- 'https://api.jujucharms.com'
1237- '/charmstore/v4/{series}/{charm}/meta/id')
1238+# The base charm store API URL containing information about charms and bundles.
1239+CHARMSTORE_API = 'https://api.jujucharms.com/charmstore/v4/'
1240
1241 # The default Juju GUI charm URLs for each supported series. Used when it is
1242 # not possible to retrieve the charm URL from the charm store API, e.g. due to
1243@@ -47,8 +44,8 @@
1244 # The quickstart app short description.
1245 DESCRIPTION = 'set up a Juju environment (including the GUI) in very few steps'
1246
1247-# The URL namespace for bundles in jujucharms.com.
1248-JUJUCHARMS_BUNDLE_URL = 'https://jujucharms.com/bundle/'
1249+# The URL of jujucharms.com, the home of Juju.
1250+JUJUCHARMS_URL = 'https://jujucharms.com/'
1251
1252 # The path to the Juju command, based on platform.
1253 JUJU_CMD_PATHS = {
1254
1255=== modified file 'quickstart/tests/functional/test_functional.py'
1256--- quickstart/tests/functional/test_functional.py 2015-02-05 12:19:48 +0000
1257+++ quickstart/tests/functional/test_functional.py 2015-02-27 18:40:58 +0000
1258@@ -167,7 +167,7 @@
1259 def test_bundle_deployment(self):
1260 # The application can be used to deploy bundles.
1261 retcode, output, error = run_quickstart(
1262- self.env_name, 'bundle:mediawiki/single')
1263+ self.env_name, 'mediawiki-single')
1264 self.assertEqual(0, retcode)
1265 self.assertIn('bundle deployment request accepted', output)
1266 self.assertEqual('', error)
1267
1268=== modified file 'quickstart/tests/helpers.py'
1269--- quickstart/tests/helpers.py 2015-02-09 11:22:44 +0000
1270+++ quickstart/tests/helpers.py 2015-02-27 18:40:58 +0000
1271@@ -47,15 +47,18 @@
1272 class BundleFileTestsMixin(object):
1273 """Shared methods for testing Juju bundle files."""
1274
1275- valid_bundle = yaml.safe_dump({
1276+ bundle_data = {'services': {'wordpress': {}, 'mysql': {}}}
1277+ bundle_content = yaml.safe_dump(bundle_data)
1278+ legacy_bundle_data = {
1279 'bundle1': {'services': {'wordpress': {}, 'mysql': {}}},
1280 'bundle2': {'services': {'django': {}, 'nodejs': {}}},
1281- })
1282+ }
1283+ legacy_bundle_content = yaml.safe_dump(legacy_bundle_data)
1284
1285 def _write_bundle_file(self, bundle_file, contents):
1286 """Parse and write contents into the given bundle file object."""
1287 if contents is None:
1288- contents = self.valid_bundle
1289+ contents = self.bundle_content
1290 elif isinstance(contents, dict):
1291 contents = yaml.safe_dump(contents)
1292 bundle_file.write(contents)
1293@@ -64,31 +67,15 @@
1294 """Create a Juju bundle file containing the given contents.
1295
1296 If contents is None, use the valid bundle contents defined in
1297- self.valid_bundle.
1298+ self.bundle_content.
1299 Return the bundle file path.
1300 """
1301- bundle_file = tempfile.NamedTemporaryFile(delete=False)
1302+ bundle_file = tempfile.NamedTemporaryFile(delete=False, suffix='.yaml')
1303 self.addCleanup(os.remove, bundle_file.name)
1304 self._write_bundle_file(bundle_file, contents)
1305 bundle_file.close()
1306 return bundle_file.name
1307
1308- def make_bundle_dir(self, contents=None):
1309- """Create a Juju bundle directory including a bundles.yaml file.
1310-
1311- The file will contain the given contents.
1312-
1313- If contents is None, use the valid bundle contents defined in
1314- self.valid_bundle.
1315- Return the bundle directory path.
1316- """
1317- bundle_dir = tempfile.mkdtemp()
1318- self.addCleanup(shutil.rmtree, bundle_dir)
1319- bundle_path = os.path.join(bundle_dir, 'bundles.yaml')
1320- with open(bundle_path, 'w') as bundle_file:
1321- self._write_bundle_file(bundle_file, contents)
1322- return bundle_dir
1323-
1324
1325 class CallTestsMixin(object):
1326 """Easily use the quickstart.utils.call function."""
1327
1328=== modified file 'quickstart/tests/models/test_bundles.py'
1329--- quickstart/tests/models/test_bundles.py 2015-02-09 12:58:04 +0000
1330+++ quickstart/tests/models/test_bundles.py 2015-02-27 18:40:58 +0000
1331@@ -24,231 +24,419 @@
1332 import yaml
1333
1334 from quickstart import settings
1335-from quickstart.models import bundles
1336+from quickstart.models import (
1337+ bundles,
1338+ references,
1339+)
1340 from quickstart.tests import helpers
1341
1342
1343-class TestConvertBundleUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):
1344-
1345- def test_full_bundle_url(self):
1346- # The HTTPS location to the YAML contents is correctly returned.
1347- bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki'
1348- url, bundle_id = bundles.convert_bundle_url(bundle_url)
1349- self.assertEqual(
1350- 'https://manage.jujucharms.com'
1351- '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
1352- self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
1353-
1354- def test_bundle_url_right_strip(self):
1355- # The trailing slash in the bundle URL is removed.
1356- bundle_url = 'bundle:~myuser/wiki-bundle/42/wiki/'
1357- url, bundle_id = bundles.convert_bundle_url(bundle_url)
1358- self.assertEqual(
1359- 'https://manage.jujucharms.com'
1360- '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
1361- self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
1362-
1363- def test_bundle_url_no_revision(self):
1364- # The bundle revision is optional.
1365- bundle_url = 'bundle:~myuser/wiki-bundle/wiki-simple'
1366- url, bundle_id = bundles.convert_bundle_url(bundle_url)
1367- self.assertEqual(
1368- 'https://manage.jujucharms.com'
1369- '/bundle/~myuser/wiki-bundle/wiki-simple/json', url)
1370- self.assertEqual('~myuser/wiki-bundle/wiki-simple', bundle_id)
1371-
1372- def test_bundle_url_no_user(self):
1373- # If the bundle user is not specified, the bundle is assumed to be
1374- # promulgated and owned by "charmers".
1375- bundle_url = 'bundle:wiki-bundle/1/wiki'
1376- url, bundle_id = bundles.convert_bundle_url(bundle_url)
1377- self.assertEqual(
1378- 'https://manage.jujucharms.com'
1379- '/bundle/~charmers/wiki-bundle/1/wiki/json', url)
1380- self.assertEqual('~charmers/wiki-bundle/1/wiki', bundle_id)
1381-
1382- def test_bundle_url_short_form(self):
1383- # A promulgated bundle URL can just include the basket and the name.
1384- bundle_url = 'bundle:wiki-bundle/wiki'
1385- url, bundle_id = bundles.convert_bundle_url(bundle_url)
1386- self.assertEqual(
1387- 'https://manage.jujucharms.com'
1388- '/bundle/~charmers/wiki-bundle/wiki/json', url)
1389- self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
1390-
1391- def test_full_jujucharms_url(self):
1392- # The HTTPS location to the YAML contents is correctly returned.
1393- url, bundle_id = bundles.convert_bundle_url(
1394- settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki-bundle/42/wiki')
1395- self.assertEqual(
1396- 'https://manage.jujucharms.com'
1397- '/bundle/~myuser/wiki-bundle/42/wiki/json', url)
1398- self.assertEqual('~myuser/wiki-bundle/42/wiki', bundle_id)
1399-
1400- def test_jujucharms_url_right_strip(self):
1401- # The trailing slash in the jujucharms URL is removed.
1402- url, bundle_id = bundles.convert_bundle_url(
1403- settings.JUJUCHARMS_BUNDLE_URL + '~charmers/mediawiki/6/scalable/')
1404- self.assertEqual(
1405- 'https://manage.jujucharms.com'
1406- '/bundle/~charmers/mediawiki/6/scalable/json', url)
1407- self.assertEqual('~charmers/mediawiki/6/scalable', bundle_id)
1408-
1409- def test_jujucharms_url_no_revision(self):
1410- # The bundle revision is optional.
1411- url, bundle_id = bundles.convert_bundle_url(
1412- settings.JUJUCHARMS_BUNDLE_URL + '~myuser/wiki/wiki-simple/')
1413- self.assertEqual(
1414- 'https://manage.jujucharms.com'
1415- '/bundle/~myuser/wiki/wiki-simple/json', url)
1416- self.assertEqual('~myuser/wiki/wiki-simple', bundle_id)
1417-
1418- def test_jujucharms_url_no_user(self):
1419- # If the bundle user is not specified, the bundle is assumed to be
1420- # promulgated and owned by "charmers".
1421- url, bundle_id = bundles.convert_bundle_url(
1422- settings.JUJUCHARMS_BUNDLE_URL + 'mediawiki/42/single/')
1423- self.assertEqual(
1424- 'https://manage.jujucharms.com'
1425- '/bundle/~charmers/mediawiki/42/single/json', url)
1426- self.assertEqual('~charmers/mediawiki/42/single', bundle_id)
1427-
1428- def test_jujucharms_url_short_form(self):
1429- # A jujucharms URL for a promulgated bundle can just include the basket
1430- # and the name.
1431- url, bundle_id = bundles.convert_bundle_url(
1432- settings.JUJUCHARMS_BUNDLE_URL + 'wiki-bundle/wiki/')
1433- self.assertEqual(
1434- 'https://manage.jujucharms.com'
1435- '/bundle/~charmers/wiki-bundle/wiki/json', url)
1436- self.assertEqual('~charmers/wiki-bundle/wiki', bundle_id)
1437-
1438- def test_error(self):
1439- # A ValueError is raised if the bundle/jujucharms URL is not valid.
1440- bad_urls = (
1441- 'bad', 'bundle:', 'bundle:~user', 'bundle:no-such',
1442- 'bundle:~user/name', 'bundle:~user/basket/revision/name',
1443- 'bundle:basket/name//', 'bundle:basket.name/bundle.name',
1444- settings.JUJUCHARMS_BUNDLE_URL,
1445- settings.JUJUCHARMS_BUNDLE_URL + 'bad',
1446- settings.JUJUCHARMS_BUNDLE_URL + '~user/no-such',
1447- settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/revision/name/',
1448- settings.JUJUCHARMS_BUNDLE_URL + '~user/basket/42/name/error',
1449- 'https://jujucharms.com/charms/mediawiki/simple/',
1450- )
1451- for url in bad_urls:
1452- with self.assert_value_error('invalid bundle URL: {}'.format(url)):
1453- bundles.convert_bundle_url(url)
1454-
1455-
1456-class TestParseBundle(
1457+class TestBundle(helpers.BundleFileTestsMixin, unittest.TestCase):
1458+
1459+ reference = references.Reference.from_jujucharms_url('django')
1460+
1461+ def setUp(self):
1462+ # Create a bundle instance.
1463+ self.bundle = bundles.Bundle(
1464+ self.bundle_data, reference=self.reference)
1465+
1466+ def test_attributes(self):
1467+ # The bundle data and the optional reference are stored as attributes.
1468+ self.assertEqual(self.bundle_data, self.bundle.data)
1469+ self.assertEqual(self.reference, self.bundle.reference)
1470+
1471+ def test_string(self):
1472+ # The bundle correctly represents itself as a string.
1473+ self.assertEqual('bundle django', str(self.bundle))
1474+ # Create a bundle without a reference.
1475+ bundle = bundles.Bundle(self.bundle_data)
1476+ self.assertEqual('bundle', str(bundle))
1477+
1478+ def test_repr(self):
1479+ # The bundle correctly represents itself as an object.
1480+ self.assertEqual('<Bundle: bundle django>', repr(self.bundle))
1481+ # Create a bundle without a reference.
1482+ bundle = bundles.Bundle(self.bundle_data)
1483+ self.assertEqual('<Bundle: bundle>', repr(bundle))
1484+
1485+ def test_serialization(self):
1486+ # The bundle data is correctly serialized into a YAML encoded string.
1487+ content = self.bundle.serialize()
1488+ self.assertEqual('services:\n mysql: {}\n wordpress: {}\n', content)
1489+
1490+ def test_legacy_serialization(self):
1491+ # The bundle data can be serialized for legacy API version 3.
1492+ content = self.bundle.serialize_legacy()
1493+ self.assertEqual(
1494+ 'bundle:\n services:\n mysql: {}\n wordpress: {}\n',
1495+ content)
1496+
1497+ def test_services(self):
1498+ # Bundle services can be easily retrieved.
1499+ self.assertEqual(['mysql', 'wordpress'], self.bundle.services())
1500+
1501+
1502+class TestFromSource(
1503+ helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin,
1504+ helpers.ValueErrorTestsMixin, unittest.TestCase):
1505+
1506+ def test_charmworld_bundle(self):
1507+ # A bundle instance is properly returned from a charmworld id source.
1508+ with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
1509+ bundle = bundles.from_source('bundle:mediawiki/single')
1510+ self.assertEqual(self.bundle_data, bundle.data)
1511+ self.assertEqual('cs:bundle/mediawiki-single', bundle.reference.id())
1512+ mock_urlread.assert_called_once_with(
1513+ settings.CHARMSTORE_API +
1514+ 'bundle/mediawiki-single/archive/bundle.yaml')
1515+
1516+ def test_charmworld_bundle_with_user_and_revision(self):
1517+ # A specific revision of a user owned bundle is properly returned from
1518+ # a charmworld id source.
1519+ with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
1520+ bundle = bundles.from_source('bundle:~who/mediawiki/42/single')
1521+ self.assertEqual(self.bundle_data, bundle.data)
1522+ self.assertEqual(
1523+ 'cs:~who/bundle/mediawiki-single-42', bundle.reference.id())
1524+ mock_urlread.assert_called_once_with(
1525+ settings.CHARMSTORE_API +
1526+ '~who/bundle/mediawiki-single-42/archive/bundle.yaml')
1527+
1528+ def test_charmworld_bundle_deprecation_warning(self):
1529+ # A deprecation warning is printed if the no longer supported
1530+ # charmworld bundle identifiers are used.
1531+ expected_logs = [
1532+ 'this bundle URL is deprecated: please use the new format: '
1533+ 'mediawiki-single']
1534+ with self.patch_urlread(contents=self.bundle_content):
1535+ with helpers.assert_logs(expected_logs, 'warn'):
1536+ bundles.from_source('bundle:mediawiki/single')
1537+
1538+ def test_charmworld_bundle_invalid_url(self):
1539+ # A ValueError is raised if the provided charmworld id is not valid.
1540+ with self.assert_value_error('invalid bundle URL: bundle:invalid'):
1541+ bundles.from_source('bundle:invalid')
1542+
1543+ def test_charmworld_bundle_invalid_content(self):
1544+ # A ValueError is raised if the content associated with the given
1545+ # charmworld URL are not valid.
1546+ with self.patch_urlread(error=True):
1547+ with self.assertRaises(IOError) as ctx:
1548+ bundles.from_source('bundle:mediawiki/single')
1549+ expected_error = (
1550+ 'cannot retrieve bundle from remote URL '
1551+ '{}bundle/mediawiki-single/archive/bundle.yaml: '
1552+ 'bad wolf'.format(settings.CHARMSTORE_API))
1553+ self.assertEqual(expected_error, bytes(ctx.exception))
1554+
1555+ def test_charmworld_bundle_connection_error(self):
1556+ # An IOError is raised if a connection problem is encountered while
1557+ # retrieving the charmworld bundle.
1558+ with self.patch_urlread(contents='exterminate!'):
1559+ with self.assert_value_error('invalid YAML content: exterminate!'):
1560+ bundles.from_source('bundle:mediawiki/single')
1561+
1562+ def test_jujucharms_bundle(self):
1563+ # A bundle instance is properly returned from a jujucharms.com id.
1564+ with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
1565+ bundle = bundles.from_source('django')
1566+ self.assertEqual(self.bundle_data, bundle.data)
1567+ self.assertEqual('cs:bundle/django', bundle.reference.id())
1568+ mock_urlread.assert_called_once_with(
1569+ settings.CHARMSTORE_API + 'bundle/django/archive/bundle.yaml')
1570+
1571+ def test_jujucharms_bundle_with_user_and_revision(self):
1572+ # A specific revision of a user owned bundle is properly returned from
1573+ # a jujucharms.com id source.
1574+ with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
1575+ bundle = bundles.from_source('u/who/mediawiki-single/42')
1576+ self.assertEqual(self.bundle_data, bundle.data)
1577+ self.assertEqual(
1578+ 'cs:~who/bundle/mediawiki-single-42', bundle.reference.id())
1579+ mock_urlread.assert_called_once_with(
1580+ settings.CHARMSTORE_API +
1581+ '~who/bundle/mediawiki-single-42/archive/bundle.yaml')
1582+
1583+ def test_jujucharms_bundle_charm_error(self):
1584+ # A ValueError is raised if the given jujucharms.com id refers to a
1585+ # charm and not to a bundle.
1586+ expected_error = 'expected a bundle, provided charm cs:trusty/django'
1587+ with self.assert_value_error(expected_error):
1588+ bundles.from_source('django/trusty')
1589+
1590+ def test_jujucharms_bundle_invalid_url(self):
1591+ # A ValueError is raised if the provided jujucharms.com identifier is
1592+ # not valid.
1593+ with self.assert_value_error('invalid bundle URL: u/no/such/bundle/!'):
1594+ bundles.from_source('u/no/such/bundle/!')
1595+
1596+ def test_jujucharms_bundle_invalid_content(self):
1597+ # A ValueError is raised if the content associated with the given
1598+ # jujucharms.com URL are not valid.
1599+ with self.patch_urlread(error=True):
1600+ with self.assertRaises(IOError) as ctx:
1601+ bundles.from_source('django/42')
1602+ expected_error = (
1603+ 'cannot retrieve bundle from remote URL '
1604+ '{}bundle/django-42/archive/bundle.yaml: '
1605+ 'bad wolf'.format(settings.CHARMSTORE_API))
1606+ self.assertEqual(expected_error, bytes(ctx.exception))
1607+
1608+ def test_jujucharms_bundle_connection_error(self):
1609+ # An IOError is raised if a connection problem is encountered while
1610+ # retrieving the jujucharms.com bundle.
1611+ with self.patch_urlread(contents='exterminate!'):
1612+ with self.assert_value_error('invalid YAML content: exterminate!'):
1613+ bundles.from_source('wordpress-scalable')
1614+
1615+ def test_local_file(self):
1616+ # A bundle instance can be created from a local file source.
1617+ # In this case, the resulting bundle does not have a reference.
1618+ path = self.make_bundle_file()
1619+ bundle = bundles.from_source(path)
1620+ self.assertEqual(self.bundle_data, bundle.data)
1621+ self.assertIsNone(bundle.reference)
1622+
1623+ def test_local_file_legacy_bundle(self):
1624+ # A bundle instance can be created from a local file source including
1625+ # a legacy version 3 bundle.
1626+ # In this case, the resulting bundle does not have a reference.
1627+ legacy_bundle_data = {
1628+ 'bundle': {'services': {'wordpress': {}, 'mysql': {}}},
1629+ }
1630+ path = self.make_bundle_file(legacy_bundle_data)
1631+ bundle = bundles.from_source(path)
1632+ self.assertEqual(legacy_bundle_data['bundle'], bundle.data)
1633+ self.assertIsNone(bundle.reference)
1634+
1635+ def test_local_file_not_found(self):
1636+ # An IOError is raised if a local file source cannot be found.
1637+ with self.assertRaises(IOError) as ctx:
1638+ bundles.from_source('/no/such/file.yaml')
1639+ expected_error = (
1640+ 'cannot retrieve bundle from local file: '
1641+ "[Errno 2] No such file or directory: '/no/such/file.yaml'")
1642+ self.assertEqual(expected_error, bytes(ctx.exception))
1643+
1644+ def test_local_file_legacy_bundle_no_bundles_error(self):
1645+ # A ValueError is raised if a local file contains a legacy version 3
1646+ # YAML content with no bundles defined.
1647+ path = self.make_bundle_file({})
1648+ expected_error = 'no bundles found in the provided list of bundles'
1649+ with self.assert_value_error(expected_error):
1650+ bundles.from_source(path)
1651+
1652+ def test_local_file_legacy_bundle_multiple_bundles_error(self):
1653+ # A ValueError is raised if a local file contains a legacy version 3
1654+ # YAML content with multiple bundles defined and the bundle name is
1655+ # not provided for disambiguation.
1656+ path = self.make_bundle_file(self.legacy_bundle_content)
1657+ expected_error = (
1658+ 'multiple bundles found (bundle1, bundle2) '
1659+ 'but no bundle name specified')
1660+ with self.assert_value_error(expected_error):
1661+ bundles.from_source(path)
1662+
1663+ def test_local_file_legacy_bundle_multiple_bundles_name_not_found(self):
1664+ # A ValueError is raised if a local file contains a legacy version 3
1665+ # YAML content with multiple bundles defined and the provided bundle
1666+ # name is not included in the list.
1667+ path = self.make_bundle_file(self.legacy_bundle_content)
1668+ expected_error = (
1669+ 'bundle mybundle not found in the provided list of bundles '
1670+ '(bundle1, bundle2)')
1671+ with self.assert_value_error(expected_error):
1672+ bundles.from_source(path, 'mybundle')
1673+
1674+ def test_local_file_legacy_bundle_invalid_bundle_content(self):
1675+ # A ValueError is raised if a local file contains an invalid legacy
1676+ # version 3 content.
1677+ path = self.make_bundle_file({'bundle': '42'})
1678+ expected_error = "unable to retrieve bundle services: '42'"
1679+ with self.assert_value_error(expected_error):
1680+ bundles.from_source(path, 'bundle')
1681+
1682+ def test_remote_url(self):
1683+ # A bundle instance can be created from an arbitrary remote URL
1684+ # pointing to a valid YAML/JSON content.
1685+ # In this case, the resulting bundle does not have a reference.
1686+ with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
1687+ bundle = bundles.from_source('https://1.2.3.4')
1688+ self.assertEqual(self.bundle_data, bundle.data)
1689+ self.assertIsNone(bundle.reference)
1690+ mock_urlread.assert_called_once_with('https://1.2.3.4')
1691+
1692+ def test_remote_url_legacy_bundle(self):
1693+ # A bundle instance can be created from an arbitrary remote URL
1694+ # pointing to a legacy version 3 content.
1695+ # In this case, the resulting bundle does not have a reference.
1696+ content = self.legacy_bundle_content
1697+ with self.patch_urlread(contents=content) as mock_urlread:
1698+ bundle = bundles.from_source('https://1.2.3.4:8000', 'bundle2')
1699+ self.assertEqual(self.legacy_bundle_data['bundle2'], bundle.data)
1700+ self.assertIsNone(bundle.reference)
1701+ mock_urlread.assert_called_once_with('https://1.2.3.4:8000')
1702+
1703+ def test_remote_url_not_reachable(self):
1704+ # An IOError is raised if a network problem is encountered while
1705+ # trying to reach the remote URL.
1706+ with self.patch_urlread(error=True):
1707+ with self.assertRaises(IOError) as ctx:
1708+ bundles.from_source('https://1.2.3.4')
1709+ expected_error = (
1710+ 'cannot retrieve bundle from remote URL https://1.2.3.4: bad wolf')
1711+ self.assertEqual(expected_error, bytes(ctx.exception))
1712+
1713+ def test_remote_url_legacy_bundle_no_bundles_error(self):
1714+ # A ValueError is raised if a remote URL contains a legacy version 3
1715+ # YAML content with no bundles defined.
1716+ expected_error = 'no bundles found in the provided list of bundles'
1717+ with self.patch_urlread(contents='{}'):
1718+ with self.assert_value_error(expected_error):
1719+ bundles.from_source('http://1.2.3.4')
1720+
1721+ def test_remote_url_legacy_bundle_multiple_bundles_error(self):
1722+ # A ValueError is raised if a remote URL contains a legacy version 3
1723+ # YAML content with multiple bundles defined and the bundle name is
1724+ # not provided for disambiguation.
1725+ expected_error = (
1726+ 'multiple bundles found (bundle1, bundle2) '
1727+ 'but no bundle name specified')
1728+ with self.patch_urlread(contents=self.legacy_bundle_content):
1729+ with self.assert_value_error(expected_error):
1730+ bundles.from_source('http://1.2.3.4')
1731+
1732+ def test_remote_url_legacy_bundle_multiple_bundles_name_not_found(self):
1733+ # A ValueError is raised if a remote URL contains a legacy version 3
1734+ # YAML content with multiple bundles defined and the provided bundle
1735+ # name is not included in the list.
1736+ expected_error = (
1737+ 'bundle no-such not found in the provided list of bundles '
1738+ '(bundle1, bundle2)')
1739+ with self.patch_urlread(contents=self.legacy_bundle_content):
1740+ with self.assert_value_error(expected_error):
1741+ bundles.from_source('http://1.2.3.4', 'no-such')
1742+
1743+ def test_remote_url_legacy_bundle_invalid_bundle_content(self):
1744+ # A ValueError is raised if a remote URL contains an invalid legacy
1745+ # version 3 content.
1746+ with self.patch_urlread(contents='bad wolf'):
1747+ with self.assert_value_error('invalid YAML content: bad wolf'):
1748+ bundles.from_source('http://1.2.3.4', 'bundle')
1749+
1750+
1751+class TestParseYAML(
1752 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
1753 unittest.TestCase):
1754
1755- def assert_bundle(
1756- self, expected_name, expected_services, contents,
1757- bundle_name=None):
1758- """Ensure parsing the given contents returns the expected values."""
1759- name, services = bundles.parse_bundle(
1760- contents, bundle_name=bundle_name)
1761- self.assertEqual(expected_name, name)
1762- self.assertEqual(set(expected_services), set(services))
1763-
1764 def test_invalid_yaml(self):
1765- # A ValueError is raised if the bundle contents are not a valid YAML.
1766- with self.assertRaises(ValueError) as context_manager:
1767- bundles.parse_bundle(':')
1768+ # A ValueError is raised if the bundle content is not a valid YAML.
1769+ with self.assertRaises(ValueError) as ctx:
1770+ bundles.parse_yaml(':')
1771 expected = 'unable to parse the bundle'
1772- self.assertIn(expected, bytes(context_manager.exception))
1773+ self.assertIn(expected, bytes(ctx.exception))
1774
1775 def test_yaml_invalid_type(self):
1776- # A ValueError is raised if the bundle contents are not well formed.
1777- with self.assert_value_error('invalid YAML contents: a-string'):
1778- bundles.parse_bundle('a-string')
1779+ # A ValueError is raised if the bundle content is not well formed.
1780+ with self.assert_value_error('invalid YAML content: a-string'):
1781+ bundles.parse_yaml('a-string')
1782
1783 def test_yaml_invalid_bundle_data(self):
1784- # A ValueError is raised if bundles are not well formed.
1785- contents = yaml.safe_dump({'mybundle': 'not valid'})
1786- expected = 'invalid YAML contents: {mybundle: not valid}\n'
1787- with self.assert_value_error(expected):
1788- bundles.parse_bundle(contents)
1789+ # A ValueError is raised if the bundle content is not well formed.
1790+ contents = yaml.safe_dump('not valid')
1791+ with self.assert_value_error('invalid YAML content: not valid'):
1792+ bundles.parse_yaml(contents)
1793
1794- def test_yaml_no_service(self):
1795- # A ValueError is raised if bundles do not include services.
1796- contents = yaml.safe_dump({'mybundle': {}})
1797- expected = 'invalid YAML contents: mybundle: {}\n'
1798- with self.assert_value_error(expected):
1799- bundles.parse_bundle(contents)
1800+ def test_yaml_no_services(self):
1801+ # A ValueError is raised if the bundle does not include services.
1802+ contents = yaml.safe_dump({})
1803+ with self.assert_value_error('unable to retrieve bundle services: {}'):
1804+ bundles.parse_yaml(contents)
1805
1806 def test_yaml_none_bundle_services(self):
1807 # A ValueError is raised if services are None.
1808- contents = yaml.safe_dump({'mybundle': {'services': None}})
1809- expected = 'invalid YAML contents: mybundle: {services: null}\n'
1810+ contents = yaml.safe_dump({'services': None})
1811+ expected = 'unable to retrieve bundle services: services: null'
1812 with self.assert_value_error(expected):
1813- bundles.parse_bundle(contents)
1814+ bundles.parse_yaml(contents)
1815
1816 def test_yaml_invalid_bundle_services_type(self):
1817 # A ValueError is raised if services have an invalid type.
1818- contents = yaml.safe_dump({'mybundle': {'services': 42}})
1819- expected = 'invalid YAML contents: mybundle: {services: 42}\n'
1820- with self.assert_value_error(expected):
1821- bundles.parse_bundle(contents)
1822-
1823- def test_yaml_no_bundles(self):
1824- # A ValueError is raised if the bundle contents are empty.
1825- with self.assert_value_error('no bundles found'):
1826- bundles.parse_bundle(yaml.safe_dump({}))
1827-
1828- def test_bundle_name_not_specified(self):
1829- # A ValueError is raised if the bundle name is not specified and the
1830- # contents contain more than one bundle.
1831- expected = ('multiple bundles found (bundle1, bundle2) '
1832- 'but no bundle name specified')
1833- with self.assert_value_error(expected):
1834- bundles.parse_bundle(self.valid_bundle)
1835-
1836- def test_bundle_name_not_found(self):
1837- # A ValueError is raised if the given bundle is not found in the file.
1838- expected = ('bundle no-such not found in the provided list of bundles '
1839- '(bundle1, bundle2)')
1840- with self.assert_value_error(expected):
1841- bundles.parse_bundle(self.valid_bundle, 'no-such')
1842+ contents = yaml.safe_dump({'services': 42})
1843+ expected = 'unable to retrieve bundle services: services: 42'
1844+ with self.assert_value_error(expected):
1845+ bundles.parse_yaml(contents)
1846
1847 def test_no_services(self):
1848 # A ValueError is raised if the specified bundle does not contain
1849 # services.
1850- contents = yaml.safe_dump({'mybundle': {'services': {}}})
1851- expected = 'bundle mybundle does not include any services'
1852- with self.assert_value_error(expected):
1853- bundles.parse_bundle(contents)
1854+ contents = yaml.safe_dump({'services': {}})
1855+ with self.assert_value_error('no services found in the bundle'):
1856+ bundles.parse_yaml(contents)
1857
1858 def test_yaml_gui_in_services(self):
1859 # A ValueError is raised if the bundle contains juju-gui.
1860 contents = yaml.safe_dump({
1861- 'mybundle': {'services': {settings.JUJU_GUI_SERVICE_NAME: {}}},
1862- })
1863- expected = 'bundle mybundle contains an instance of juju-gui. ' \
1864- 'quickstart will install the latest version of the Juju GUI ' \
1865- 'automatically, please remove juju-gui from the bundle.'
1866- with self.assert_value_error(expected):
1867- bundles.parse_bundle(contents)
1868-
1869- def test_success_no_name(self):
1870- # The function succeeds when an implicit bundle name is used.
1871- contents = yaml.safe_dump({
1872- 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
1873- })
1874- self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
1875-
1876- def test_success_multiple_bundles(self):
1877- # The function succeeds with multiple bundles.
1878- self.assert_bundle(
1879- 'bundle2', ['django', 'nodejs'], self.valid_bundle, 'bundle2')
1880+ 'services': {settings.JUJU_GUI_SERVICE_NAME: {}},
1881+ })
1882+ expected_error = (
1883+ 'the provided bundle contains an instance of juju-gui. Juju '
1884+ 'Quickstart will install the latest version of the Juju GUI '
1885+ 'automatically; please remove juju-gui from the bundle')
1886+ with self.assert_value_error(expected_error):
1887+ bundles.parse_yaml(contents)
1888+
1889+ def test_success(self):
1890+ # The function succeeds when a valid bundle content is provided.
1891+ data = bundles.parse_yaml(self.bundle_content)
1892+ self.assertEqual(self.bundle_data, data)
1893
1894 def test_success_json(self):
1895 # Since JSON is a subset of YAML, the function also support JSON
1896 # encoded bundles.
1897- contents = json.dumps({
1898- 'mybundle': {'services': {'wordpress': {}, 'mysql': {}}},
1899- })
1900- self.assert_bundle('mybundle', ['mysql', 'wordpress'], contents)
1901+ content = json.dumps(self.bundle_data)
1902+ data = bundles.parse_yaml(content)
1903+ self.assertEqual(self.bundle_data, data)
1904+
1905+
1906+class TestValidate(
1907+ helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
1908+ unittest.TestCase):
1909+
1910+ def test_yaml_no_services(self):
1911+ # A ValueError is raised if the bundle does not include services.
1912+ with self.assert_value_error('unable to retrieve bundle services: {}'):
1913+ bundles.validate({})
1914+
1915+ def test_yaml_none_bundle_services(self):
1916+ # A ValueError is raised if services are None.
1917+ expected = 'unable to retrieve bundle services: services: null'
1918+ with self.assert_value_error(expected):
1919+ bundles.validate({'services': None})
1920+
1921+ def test_yaml_invalid_bundle_services_type(self):
1922+ # A ValueError is raised if services have an invalid type.
1923+ expected = 'unable to retrieve bundle services: services: 42'
1924+ with self.assert_value_error(expected):
1925+ bundles.validate({'services': 42})
1926+
1927+ def test_no_services(self):
1928+ # A ValueError is raised if the specified bundle does not contain
1929+ # services.
1930+ with self.assert_value_error('no services found in the bundle'):
1931+ bundles.validate({'services': {}})
1932+
1933+ def test_yaml_gui_in_services(self):
1934+ # A ValueError is raised if the bundle contains juju-gui.
1935+ expected_error = (
1936+ 'the provided bundle contains an instance of juju-gui. Juju '
1937+ 'Quickstart will install the latest version of the Juju GUI '
1938+ 'automatically; please remove juju-gui from the bundle')
1939+ with self.assert_value_error(expected_error):
1940+ bundles.validate({
1941+ 'services': {settings.JUJU_GUI_SERVICE_NAME: {}},
1942+ })
1943+
1944+ def test_success(self):
1945+ # The function succeeds when a valid bundle content is provided.
1946+ bundles.validate(self.bundle_data)
1947
1948=== renamed file 'quickstart/tests/models/test_charms.py' => 'quickstart/tests/models/test_references.py'
1949--- quickstart/tests/models/test_charms.py 2015-02-09 18:00:33 +0000
1950+++ quickstart/tests/models/test_references.py 2015-02-27 18:40:58 +0000
1951@@ -14,222 +14,424 @@
1952 # You should have received a copy of the GNU Affero General Public License
1953 # along with this program. If not, see <http://www.gnu.org/licenses/>.
1954
1955-"""Tests for the Juju Quickstart charms management."""
1956+"""Tests for the Juju Quickstart charm and bundle references management."""
1957
1958 from __future__ import unicode_literals
1959
1960 import unittest
1961
1962-from quickstart.models import charms
1963+from quickstart import settings
1964+from quickstart.models import references
1965 from quickstart.tests import helpers
1966
1967
1968-class TestParseUrl(helpers.ValueErrorTestsMixin, unittest.TestCase):
1969+def make_reference(
1970+ schema='cs', user='myuser', series='precise', name='juju-gui',
1971+ revision=42):
1972+ """Create and return a Reference instance."""
1973+ return references.Reference(schema, user, series, name, revision)
1974+
1975+
1976+class TestReference(unittest.TestCase):
1977+
1978+ def test_attributes(self):
1979+ # All reference attributes are correctly stored.
1980+ ref = make_reference()
1981+ self.assertEqual('cs', ref.schema)
1982+ self.assertEqual('myuser', ref.user)
1983+ self.assertEqual('precise', ref.series)
1984+ self.assertEqual('juju-gui', ref.name)
1985+ self.assertEqual(42, ref.revision)
1986+
1987+ def test_revision_as_string(self):
1988+ # The reference revision is converted to an int.
1989+ ref = make_reference(revision='47')
1990+ self.assertEqual(47, ref.revision)
1991+
1992+ def test_string(self):
1993+ # The string representation of a reference is its URL.
1994+ tests = (
1995+ (make_reference(),
1996+ 'cs:~myuser/precise/juju-gui-42'),
1997+ (make_reference(schema='local'),
1998+ 'local:~myuser/precise/juju-gui-42'),
1999+ (make_reference(user=''),
2000+ 'cs:precise/juju-gui-42'),
2001+ (make_reference(user='dalek', revision=None, series='bundle'),
2002+ 'cs:~dalek/bundle/juju-gui'),
2003+ (make_reference(name='django', series='vivid', revision=0),
2004+ 'cs:~myuser/vivid/django-0'),
2005+ (make_reference(user='', revision=None),
2006+ 'cs:precise/juju-gui'),
2007+ )
2008+ for ref, expected_value in tests:
2009+ self.assertEqual(expected_value, bytes(ref))
2010+
2011+ def test_repr(self):
2012+ # A reference is correctly represented.
2013+ tests = (
2014+ (make_reference(),
2015+ '<Reference: cs:~myuser/precise/juju-gui-42>'),
2016+ (make_reference(schema='local'),
2017+ '<Reference: local:~myuser/precise/juju-gui-42>'),
2018+ (make_reference(user=''),
2019+ '<Reference: cs:precise/juju-gui-42>'),
2020+ (make_reference(user='dalek', revision=None, series='bundle'),
2021+ '<Reference: cs:~dalek/bundle/juju-gui>'),
2022+ (make_reference(name='django', series='vivid', revision=0),
2023+ '<Reference: cs:~myuser/vivid/django-0>'),
2024+ (make_reference(user='', revision=None),
2025+ '<Reference: cs:precise/juju-gui>'),
2026+ )
2027+ for ref, expected_value in tests:
2028+ self.assertEqual(expected_value, repr(ref))
2029+
2030+ def test_path(self):
2031+ # The reference path is properly returned as a URL string without the
2032+ # schema.
2033+ tests = (
2034+ (make_reference(),
2035+ '~myuser/precise/juju-gui-42'),
2036+ (make_reference(schema='local'),
2037+ '~myuser/precise/juju-gui-42'),
2038+ (make_reference(user=''),
2039+ 'precise/juju-gui-42'),
2040+ (make_reference(user='dalek', revision=None, series='bundle'),
2041+ '~dalek/bundle/juju-gui'),
2042+ (make_reference(name='django', series='vivid', revision=0),
2043+ '~myuser/vivid/django-0'),
2044+ (make_reference(user='', revision=None),
2045+ 'precise/juju-gui'),
2046+ )
2047+ for ref, expected_value in tests:
2048+ self.assertEqual(expected_value, ref.path())
2049+
2050+ def test_id(self):
2051+ # The reference id is correctly returned.
2052+ tests = (
2053+ (make_reference(),
2054+ 'cs:~myuser/precise/juju-gui-42'),
2055+ (make_reference(schema='local'),
2056+ 'local:~myuser/precise/juju-gui-42'),
2057+ (make_reference(user=''),
2058+ 'cs:precise/juju-gui-42'),
2059+ (make_reference(user='dalek', revision=None, series='bundle'),
2060+ 'cs:~dalek/bundle/juju-gui'),
2061+ (make_reference(name='django', series='vivid', revision=0),
2062+ 'cs:~myuser/vivid/django-0'),
2063+ (make_reference(user='', revision=None),
2064+ 'cs:precise/juju-gui'),
2065+ )
2066+ for ref, expected_value in tests:
2067+ self.assertEqual(expected_value, ref.id())
2068+
2069+ def test_jujucharms_id(self):
2070+ # It is possible to return the reference identifier in jujucharms.com.
2071+ tests = (
2072+ (make_reference(),
2073+ 'u/myuser/juju-gui/precise/42'),
2074+ (make_reference(schema='local'),
2075+ 'u/myuser/juju-gui/precise/42'),
2076+ (make_reference(user=''),
2077+ 'juju-gui/precise/42'),
2078+ (make_reference(user='dalek', revision=None, series='bundle'),
2079+ 'u/dalek/juju-gui'),
2080+ (make_reference(name='django', series='vivid', revision=0),
2081+ 'u/myuser/django/vivid/0'),
2082+ (make_reference(user='', revision=None),
2083+ 'juju-gui/precise'),
2084+ (make_reference(user='', series='bundle', revision=None),
2085+ 'juju-gui'),
2086+ )
2087+ for ref, expected_value in tests:
2088+ self.assertEqual(expected_value, ref.jujucharms_id())
2089+
2090+ def test_jujucharms_url(self):
2091+ # The corresponding charm or bundle page in jujucharms.com is correctly
2092+ # returned.
2093+ tests = (
2094+ (make_reference(),
2095+ 'u/myuser/juju-gui/precise/42'),
2096+ (make_reference(schema='local'),
2097+ 'u/myuser/juju-gui/precise/42'),
2098+ (make_reference(user=''),
2099+ 'juju-gui/precise/42'),
2100+ (make_reference(user='dalek', revision=None, series='bundle'),
2101+ 'u/dalek/juju-gui'),
2102+ (make_reference(name='django', series='vivid', revision=0),
2103+ 'u/myuser/django/vivid/0'),
2104+ (make_reference(user='', revision=None),
2105+ 'juju-gui/precise'),
2106+ (make_reference(user='', series='bundle', revision=None),
2107+ 'juju-gui'),
2108+ )
2109+ for ref, expected_value in tests:
2110+ expected_url = settings.JUJUCHARMS_URL + expected_value
2111+ self.assertEqual(expected_url, ref.jujucharms_url())
2112+
2113+ def test_charm_entity(self):
2114+ # The is_bundle method returns False for charm references.
2115+ ref = make_reference(series='vivid')
2116+ self.assertFalse(ref.is_bundle())
2117+
2118+ def test_bundle_entity(self):
2119+ # The is_bundle method returns True for bundle references.
2120+ ref = make_reference(series='bundle')
2121+ self.assertTrue(ref.is_bundle())
2122+
2123+ def test_charm_store_entity(self):
2124+ # The is_local method returns False for charm store references.
2125+ ref = make_reference(schema='cs')
2126+ self.assertFalse(ref.is_local())
2127+
2128+ def test_local_entity(self):
2129+ # The is_local method returns True for local references.
2130+ ref = make_reference(schema='local')
2131+ self.assertTrue(ref.is_local())
2132+
2133+ def test_equality(self):
2134+ # Two references are equal if they have the same URL.
2135+ self.assertEqual(make_reference(), make_reference())
2136+ self.assertEqual(make_reference(user=''), make_reference(user=''))
2137+ self.assertEqual(
2138+ make_reference(revision=None), make_reference(revision=None))
2139+
2140+ def test_equality_different_references(self):
2141+ # Two references with different attributes are not equal.
2142+ tests = (
2143+ (make_reference(schema='cs'),
2144+ make_reference(schema='local')),
2145+ (make_reference(user=''),
2146+ make_reference(user='who')),
2147+ (make_reference(series='trusty'),
2148+ make_reference(series='vivid')),
2149+ (make_reference(name='django'),
2150+ make_reference(name='rails')),
2151+ (make_reference(revision=0),
2152+ make_reference(revision=1)),
2153+ (make_reference(revision=None),
2154+ make_reference(revision=42)),
2155+ )
2156+ for ref1, ref2 in tests:
2157+ self.assertNotEqual(ref1, ref2)
2158+
2159+ def test_equality_different_types(self):
2160+ # A reference never equals a non-reference object.
2161+ self.assertNotEqual(make_reference(), 42)
2162+ self.assertNotEqual(make_reference(), True)
2163+ self.assertNotEqual(make_reference(), 'oranges')
2164+
2165+ def test_charmworld_id(self):
2166+ # By default, the reference id in charmworld is set to None.
2167+ # XXX frankban 2015-02-26: remove this test once we get rid of the
2168+ # charmworld id concept.
2169+ ref = make_reference()
2170+ self.assertIsNone(ref.charmworld_id)
2171+
2172+
2173+class TestReferenceFromFullyQualifiedUrl(
2174+ helpers.ValueErrorTestsMixin, unittest.TestCase):
2175
2176 def test_no_schema_error(self):
2177 # A ValueError is raised if the URL schema is missing.
2178- expected = 'charm URL has no schema: precise/juju-gui'
2179- with self.assert_value_error(expected):
2180- charms.parse_url('precise/juju-gui')
2181+ expected_error = 'URL has no schema: precise/juju-gui'
2182+ with self.assert_value_error(expected_error):
2183+ references.Reference.from_fully_qualified_url('precise/juju-gui')
2184
2185 def test_invalid_schema_error(self):
2186 # A ValueError is raised if the URL schema is not valid.
2187- expected = 'charm URL has invalid schema: http'
2188- with self.assert_value_error(expected):
2189- charms.parse_url('http:precise/juju-gui')
2190+ expected_error = 'URL has invalid schema: http'
2191+ with self.assert_value_error(expected_error):
2192+ references.Reference.from_fully_qualified_url(
2193+ 'http:precise/juju-gui')
2194
2195 def test_invalid_user_form_error(self):
2196 # A ValueError is raised if the user form is not valid.
2197- expected = 'charm URL has invalid user name form: jean-luc'
2198- with self.assert_value_error(expected):
2199- charms.parse_url('cs:jean-luc/precise/juju-gui')
2200+ expected_error = 'URL has invalid user name form: jean-luc'
2201+ with self.assert_value_error(expected_error):
2202+ references.Reference.from_fully_qualified_url(
2203+ 'cs:jean-luc/precise/juju-gui')
2204
2205 def test_invalid_user_name_error(self):
2206 # A ValueError is raised if the user name is not valid.
2207- expected = 'charm URL has invalid user name: jean:luc'
2208- with self.assert_value_error(expected):
2209- charms.parse_url('cs:~jean:luc/precise/juju-gui')
2210+ expected_error = 'URL has invalid user name: jean:luc'
2211+ with self.assert_value_error(expected_error):
2212+ references.Reference.from_fully_qualified_url(
2213+ 'cs:~jean:luc/precise/juju-gui')
2214
2215 def test_local_user_name_error(self):
2216- # A ValueError is raised if a user is specified on a local charm.
2217- expected = (
2218- 'local charm URL with user name: '
2219+ # A ValueError is raised if a user is specified on a local entity.
2220+ expected_error = (
2221+ 'local entity URL with user name: '
2222 'local:~jean-luc/precise/juju-gui')
2223- with self.assert_value_error(expected):
2224- charms.parse_url('local:~jean-luc/precise/juju-gui')
2225+ with self.assert_value_error(expected_error):
2226+ references.Reference.from_fully_qualified_url(
2227+ 'local:~jean-luc/precise/juju-gui')
2228
2229 def test_invalid_form_error(self):
2230 # A ValueError is raised if the URL is not valid.
2231- expected = 'charm URL has invalid form: cs:~user/series/name/what-?'
2232- with self.assert_value_error(expected):
2233- charms.parse_url('cs:~user/series/name/what-?')
2234+ expected_error = 'URL has invalid form: cs:~user/series/name/what-?'
2235+ with self.assert_value_error(expected_error):
2236+ references.Reference.from_fully_qualified_url(
2237+ 'cs:~user/series/name/what-?')
2238
2239 def test_invalid_series_error(self):
2240 # A ValueError is raised if the series is not valid.
2241- expected = 'charm URL has invalid series: boo!'
2242- with self.assert_value_error(expected):
2243- charms.parse_url('cs:boo!/juju-gui-42')
2244+ expected_error = 'URL has invalid series: boo!'
2245+ with self.assert_value_error(expected_error):
2246+ references.Reference.from_fully_qualified_url(
2247+ 'cs:boo!/juju-gui-42')
2248
2249 def test_no_revision_error(self):
2250- # A ValueError is raised if the charm revision is missing.
2251- expected = 'charm URL has no revision: cs:series/name'
2252- with self.assert_value_error(expected):
2253- charms.parse_url('cs:series/name')
2254+ # A ValueError is raised if the entity revision is missing.
2255+ expected_error = 'URL has no revision: cs:series/name'
2256+ with self.assert_value_error(expected_error):
2257+ references.Reference.from_fully_qualified_url('cs:series/name')
2258
2259 def test_invalid_revision_error(self):
2260- # A ValueError is raised if the charm revision is not valid.
2261- expected = 'charm URL has invalid revision: revision'
2262- with self.assert_value_error(expected):
2263- charms.parse_url('cs:series/name-revision')
2264+ # A ValueError is raised if the charm or bundle revision is not valid.
2265+ expected_error = 'URL has invalid revision: revision'
2266+ with self.assert_value_error(expected_error):
2267+ references.Reference.from_fully_qualified_url(
2268+ 'cs:series/name-revision')
2269
2270 def test_invalid_name_error(self):
2271- # A ValueError is raised if the charm name is not valid.
2272- expected = 'charm URL has invalid name: not:valid'
2273- with self.assert_value_error(expected):
2274- charms.parse_url('cs:precise/not:valid-42')
2275-
2276- def test_success_with_user(self):
2277- # A charm URL including the user is correctly parsed.
2278- schema, user, series, name, revision = charms.parse_url(
2279- 'cs:~who/precise/juju-gui-42')
2280- self.assertEqual('cs', schema)
2281- self.assertEqual('who', user)
2282- self.assertEqual('precise', series)
2283- self.assertEqual('juju-gui', name)
2284- self.assertEqual(42, revision)
2285-
2286- def test_success_without_user(self):
2287- # A charm URL not including the user is correctly parsed.
2288- schema, user, series, name, revision = charms.parse_url(
2289- 'cs:trusty/django-1')
2290- self.assertEqual('cs', schema)
2291- self.assertEqual('', user)
2292- self.assertEqual('trusty', series)
2293- self.assertEqual('django', name)
2294- self.assertEqual(1, revision)
2295-
2296- def test_success_local_charm(self):
2297- # A local charm URL is correctly parsed.
2298- schema, user, series, name, revision = charms.parse_url(
2299- 'local:saucy/wordpress-100')
2300- self.assertEqual('local', schema)
2301- self.assertEqual('', user)
2302- self.assertEqual('saucy', series)
2303- self.assertEqual('wordpress', name)
2304- self.assertEqual(100, revision)
2305-
2306-
2307-class TestCharm(helpers.ValueErrorTestsMixin, unittest.TestCase):
2308-
2309- def make_charm(
2310- self, schema='cs', user='myuser', series='precise',
2311- name='juju-gui', revision=42):
2312- """Create and return a Charm instance."""
2313- return charms.Charm(schema, user, series, name, revision)
2314-
2315- def test_attributes(self):
2316- # All charm attributes are correctly stored.
2317- charm = self.make_charm()
2318- self.assertEqual('cs', charm.schema)
2319- self.assertEqual('myuser', charm.user)
2320- self.assertEqual('precise', charm.series)
2321- self.assertEqual('juju-gui', charm.name)
2322- self.assertEqual(42, charm.revision)
2323-
2324- def test_revision_as_string(self):
2325- # Revision is converted to an int.
2326- charm = self.make_charm(revision='47')
2327- self.assertEqual(47, charm.revision)
2328-
2329- def test_from_url(self):
2330- # A Charm can be instantiated from a charm URL.
2331- charm = charms.Charm.from_url('cs:~who/trusty/django-1')
2332- self.assertEqual('cs', charm.schema)
2333- self.assertEqual('who', charm.user)
2334- self.assertEqual('trusty', charm.series)
2335- self.assertEqual('django', charm.name)
2336- self.assertEqual(1, charm.revision)
2337-
2338- def test_from_url_without_user(self):
2339- # Official charm store URLs are properly handled.
2340- charm = charms.Charm.from_url('cs:saucy/django-123')
2341- self.assertEqual('cs', charm.schema)
2342- self.assertEqual('', charm.user)
2343- self.assertEqual('saucy', charm.series)
2344- self.assertEqual('django', charm.name)
2345- self.assertEqual(123, charm.revision)
2346-
2347- def test_from_url_local(self):
2348- # Local charms URLs are properly handled.
2349- charm = charms.Charm.from_url('local:precise/my-local-charm-42')
2350- self.assertEqual('local', charm.schema)
2351- self.assertEqual('', charm.user)
2352- self.assertEqual('precise', charm.series)
2353- self.assertEqual('my-local-charm', charm.name)
2354- self.assertEqual(42, charm.revision)
2355-
2356- def test_from_url_error(self):
2357- # A ValueError is raised by the from_url class method if the provided
2358- # URL is not a valid charm URL.
2359- expected = 'charm URL has invalid form: cs:not-a-charm-url'
2360- with self.assert_value_error(expected):
2361- charms.Charm.from_url('cs:not-a-charm-url')
2362-
2363- def test_string(self):
2364- # The string representation of a charm instance is its URL.
2365- charm = self.make_charm()
2366- self.assertEqual('cs:~myuser/precise/juju-gui-42', bytes(charm))
2367-
2368- def test_repr(self):
2369- # A charm instance is correctly represented.
2370- charm = self.make_charm()
2371- self.assertEqual(
2372- '<Charm: cs:~myuser/precise/juju-gui-42>', repr(charm))
2373-
2374- def test_charm_store_url(self):
2375- # A charm store URL is correctly returned.
2376- charm = self.make_charm(schema='cs')
2377- self.assertEqual('cs:~myuser/precise/juju-gui-42', charm.url())
2378-
2379- def test_local_url(self):
2380- # A local charm URL is correctly returned.
2381- charm = self.make_charm(schema='local', user='')
2382- self.assertEqual('local:precise/juju-gui-42', charm.url())
2383-
2384- def test_charm_store_charm(self):
2385- # The is_local method returns False for charm store charms.
2386- charm = self.make_charm(schema='cs')
2387- self.assertFalse(charm.is_local())
2388-
2389- def test_local_charm(self):
2390- # The is_local method returns True for local charms.
2391- charm = self.make_charm(schema='local')
2392- self.assertTrue(charm.is_local())
2393-
2394- def test_equality(self):
2395- # Two charms are equal if they have the same URL.
2396- self.assertEqual(self.make_charm(), self.make_charm())
2397-
2398- def test_equality_different_name(self):
2399- # Two charms with different names are not equal.
2400- self.assertNotEqual(
2401- self.make_charm(name='django'),
2402- self.make_charm(name='rails'))
2403-
2404- def test_equality_different_revision(self):
2405- # Two charms with different revisions are not equal.
2406- self.assertNotEqual(
2407- self.make_charm(revision=0),
2408- self.make_charm(revision=1))
2409-
2410- def test_equality_different_user(self):
2411- # Two charms with different users are not equal.
2412- self.assertNotEqual(
2413- self.make_charm(user=''),
2414- self.make_charm(user='who'))
2415-
2416- def test_equality_different_types(self):
2417- # A charm never equals a non-charm object.
2418- self.assertNotEqual(self.make_charm(), 42)
2419- self.assertNotEqual(self.make_charm(), True)
2420- self.assertNotEqual(self.make_charm(), 'oranges')
2421+ # A ValueError is raised if the entity name is not valid.
2422+ expected_error = 'URL has invalid name: not:valid'
2423+ with self.assert_value_error(expected_error):
2424+ references.Reference.from_fully_qualified_url(
2425+ 'cs:precise/not:valid-42')
2426+
2427+ def test_success(self):
2428+ # References are correctly instantiated by parsing the fully qualified
2429+ # URL.
2430+ tests = (
2431+ ('cs:~myuser/precise/juju-gui-42',
2432+ make_reference()),
2433+ ('cs:trusty/juju-gui-42',
2434+ make_reference(user='', series='trusty')),
2435+ ('local:precise/juju-gui-42',
2436+ make_reference(schema='local', user='')),
2437+ )
2438+ for url, expected_ref in tests:
2439+ ref = references.Reference.from_fully_qualified_url(url)
2440+ self.assertEqual(expected_ref, ref)
2441+
2442+
2443+class TestReferenceFromCharmworldUrl(
2444+ helpers.ValueErrorTestsMixin, unittest.TestCase):
2445+
2446+ def test_invalid_form(self):
2447+ # A ValueError is raised if the URL is not valid.
2448+ expected_error = 'invalid bundle URL: bad wolf'
2449+ with self.assert_value_error(expected_error):
2450+ references.Reference.from_charmworld_url('bad wolf')
2451+
2452+ def test_success(self):
2453+ # A reference is correctly created from a charmworld identifier.
2454+ tests = (
2455+ ('bundle:~myuser/wordpress/42/single',
2456+ make_reference(series='bundle', name='wordpress-single')),
2457+ ('bundle:~myuser/wordpress/single',
2458+ make_reference(series='bundle', name='wordpress-single',
2459+ revision=None)),
2460+ ('bundle:wordpress/42/single',
2461+ make_reference(user='', series='bundle',
2462+ name='wordpress-single')),
2463+ ('bundle:wordpress/single',
2464+ make_reference(user='', series='bundle', name='wordpress-single',
2465+ revision=None)),
2466+ )
2467+ for url, expected_ref in tests:
2468+ ref = references.Reference.from_charmworld_url(url)
2469+ self.assertEqual(expected_ref, ref)
2470+
2471+ def test_charmworld_id(self):
2472+ # The charmworld id is properly set when parsing charmworld URLs.
2473+ # XXX frankban 2015-02-26: remove this test once we get rid of the
2474+ # charmworld id concept.
2475+ ref = references.Reference.from_charmworld_url(
2476+ 'bundle:wordpress/single')
2477+ self.assertEqual('wordpress/single', ref.charmworld_id)
2478+
2479+
2480+class TestReferenceFromJujucharmsUrl(
2481+ helpers.ValueErrorTestsMixin, unittest.TestCase):
2482+
2483+ def test_invalid_form(self):
2484+ # A ValueError is raised if the URL is not valid.
2485+ expected_error = 'invalid bundle URL: bad wolf'
2486+ with self.assert_value_error(expected_error):
2487+ references.Reference.from_jujucharms_url('bad wolf')
2488+
2489+ def test_success(self):
2490+ # A reference is correctly created from a jujucharms.com identifier or
2491+ # complete URL.
2492+ tests = (
2493+ # Check with both user and revision.
2494+ ('u/myuser/mediawiki/42',
2495+ make_reference(series='bundle', name='mediawiki')),
2496+ ('/u/myuser/mediawiki/42',
2497+ make_reference(series='bundle', name='mediawiki')),
2498+ ('u/myuser/django-scalable/42/',
2499+ make_reference(series='bundle', name='django-scalable')),
2500+ ('{}u/myuser/mediawiki/42'.format(settings.JUJUCHARMS_URL),
2501+ make_reference(series='bundle', name='mediawiki')),
2502+ ('{}u/myuser/mediawiki/42/'.format(settings.JUJUCHARMS_URL),
2503+ make_reference(series='bundle', name='mediawiki')),
2504+
2505+ # Check without revision.
2506+ ('u/myuser/mediawiki',
2507+ make_reference(series='bundle', name='mediawiki', revision=None)),
2508+ ('/u/myuser/wordpress',
2509+ make_reference(series='bundle', name='wordpress', revision=None)),
2510+ ('u/myuser/mediawiki/',
2511+ make_reference(series='bundle', name='mediawiki', revision=None)),
2512+ ('{}u/myuser/django'.format(settings.JUJUCHARMS_URL),
2513+ make_reference(series='bundle', name='django', revision=None)),
2514+ ('{}u/myuser/mediawiki/'.format(settings.JUJUCHARMS_URL),
2515+ make_reference(series='bundle', name='mediawiki', revision=None)),
2516+
2517+ # Check without the user.
2518+ ('rails-single/42',
2519+ make_reference(user='', series='bundle', name='rails-single')),
2520+ ('/mediawiki/42',
2521+ make_reference(user='', series='bundle', name='mediawiki')),
2522+ ('rails-scalable/42/',
2523+ make_reference(user='', series='bundle', name='rails-scalable')),
2524+ ('{}mediawiki/42'.format(settings.JUJUCHARMS_URL),
2525+ make_reference(user='', series='bundle', name='mediawiki')),
2526+ ('{}django/42/'.format(settings.JUJUCHARMS_URL),
2527+ make_reference(user='', series='bundle', name='django')),
2528+
2529+ # Check without user and revision.
2530+ ('mediawiki',
2531+ make_reference(user='', series='bundle', name='mediawiki',
2532+ revision=None)),
2533+ ('/wordpress',
2534+ make_reference(user='', series='bundle', name='wordpress',
2535+ revision=None)),
2536+ ('mediawiki/',
2537+ make_reference(user='', series='bundle', name='mediawiki',
2538+ revision=None)),
2539+ ('{}django'.format(settings.JUJUCHARMS_URL),
2540+ make_reference(user='', series='bundle', name='django',
2541+ revision=None)),
2542+ ('{}mediawiki/'.format(settings.JUJUCHARMS_URL),
2543+ make_reference(user='', series='bundle', name='mediawiki',
2544+ revision=None)),
2545+
2546+ # Check charm entities.
2547+ ('mediawiki/trusty/0',
2548+ make_reference(user='', series='trusty', name='mediawiki',
2549+ revision=0)),
2550+ ('/wordpress/precise',
2551+ make_reference(user='', series='precise', name='wordpress',
2552+ revision=None)),
2553+ ('u/who/rails/vivid',
2554+ make_reference(user='who', series='vivid', name='rails',
2555+ revision=None)),
2556+ )
2557+ for url, expected_ref in tests:
2558+ ref = references.Reference.from_jujucharms_url(url)
2559+ self.assertEqual(expected_ref, ref)
2560
2561=== modified file 'quickstart/tests/test_app.py'
2562--- quickstart/tests/test_app.py 2015-02-09 14:56:32 +0000
2563+++ quickstart/tests/test_app.py 2015-02-27 18:40:58 +0000
2564@@ -32,7 +32,10 @@
2565 platform_support,
2566 settings,
2567 )
2568-from quickstart.models import charms
2569+from quickstart.models import (
2570+ bundles,
2571+ references,
2572+)
2573 from quickstart.tests import helpers
2574
2575
2576@@ -934,10 +937,11 @@
2577 return mock.patch(
2578 'quickstart.netutils.get_charm_url', mock_get_charm_url)
2579
2580- def assert_charm_equal(self, expected_url, charm):
2581- """Ensure the given charm has the expected URL."""
2582- expected_charm = charms.Charm.from_url(expected_url)
2583- self.assertEqual(expected_charm, charm)
2584+ def assert_reference_equal(self, expected_url, ref):
2585+ """Ensure the given reference points to the expected URL."""
2586+ expected_ref = references.Reference.from_fully_qualified_url(
2587+ expected_url)
2588+ self.assertEqual(expected_ref, ref)
2589
2590 def test_environment_just_bootstrapped(self, mock_print):
2591 # The function correctly retrieves the charm URL and machine, and
2592@@ -952,14 +956,14 @@
2593 check_preexisting = False
2594 with self.patch_get_charm_url(
2595 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:
2596- charm, machine, service_data, unit_data = app.check_environment(
2597+ ref, machine, service_data, unit_data = app.check_environment(
2598 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2599 check_preexisting)
2600 # There is no need to call status if the environment was just created.
2601 self.assertFalse(env.get_status.called)
2602 # The charm URL has been retrieved from the charm store API based on
2603 # the current bootstrap node series.
2604- self.assert_charm_equal('cs:trusty/juju-gui-42', charm)
2605+ self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
2606 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)
2607 # Since the bootstrap node series is supported by the GUI charm, the
2608 # GUI unit can be deployed to machine 0.
2609@@ -987,14 +991,14 @@
2610 check_preexisting = True
2611 with self.patch_get_charm_url(
2612 return_value='cs:precise/juju-gui-42') as mock_get_charm_url:
2613- charm, machine, service_data, unit_data = app.check_environment(
2614+ ref, machine, service_data, unit_data = app.check_environment(
2615 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2616 check_preexisting)
2617 # The environment status has been retrieved.
2618 env.get_status.assert_called_once_with()
2619 # The charm URL has been retrieved from the charm store API based on
2620 # the current bootstrap node series.
2621- self.assert_charm_equal('cs:precise/juju-gui-42', charm)
2622+ self.assert_reference_equal('cs:precise/juju-gui-42', ref)
2623 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)
2624 # Since the bootstrap node series is supported by the GUI charm, the
2625 # GUI unit can be deployed to machine 0.
2626@@ -1019,13 +1023,13 @@
2627 bootstrap_node_series = 'precise'
2628 check_preexisting = True
2629 with self.patch_get_charm_url() as mock_get_charm_url:
2630- charm, machine, service_data, unit_data = app.check_environment(
2631+ ref, machine, service_data, unit_data = app.check_environment(
2632 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2633 check_preexisting)
2634 # The environment status has been retrieved.
2635 env.get_status.assert_called_once_with()
2636 # The charm URL has been retrieved from the environment.
2637- self.assert_charm_equal('cs:precise/juju-gui-47', charm)
2638+ self.assert_reference_equal('cs:precise/juju-gui-47', ref)
2639 self.assertFalse(mock_get_charm_url.called)
2640 # Since the bootstrap node series is supported by the GUI charm, the
2641 # GUI unit can be safely deployed to machine 0.
2642@@ -1044,12 +1048,12 @@
2643 check_preexisting = False
2644 with self.patch_get_charm_url(
2645 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:
2646- charm, machine, service_data, unit_data = app.check_environment(
2647+ ref, machine, service_data, unit_data = app.check_environment(
2648 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2649 check_preexisting)
2650 # The charm URL has been retrieved from the charm store API using the
2651 # most recent supported series.
2652- self.assert_charm_equal('cs:trusty/juju-gui-42', charm)
2653+ self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
2654 mock_get_charm_url.assert_called_once_with('trusty')
2655 # The Juju GUI unit cannot be deployed to saucy machine 0.
2656 self.assertIsNone(machine)
2657@@ -1069,11 +1073,11 @@
2658 bootstrap_node_series = 'trusty'
2659 check_preexisting = False
2660 with self.patch_get_charm_url(return_value='cs:trusty/juju-gui-42'):
2661- charm, machine, service_data, unit_data = app.check_environment(
2662+ ref, machine, service_data, unit_data = app.check_environment(
2663 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2664 check_preexisting)
2665 # The charm URL has been correctly retrieved from the charm store API.
2666- self.assert_charm_equal('cs:trusty/juju-gui-42', charm)
2667+ self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
2668 # The Juju GUI unit cannot be deployed to localhost.
2669 self.assertIsNone(machine)
2670
2671@@ -1100,11 +1104,12 @@
2672 bootstrap_node_series = 'precise'
2673 check_preexisting = False
2674 with self.patch_get_charm_url(side_effect=IOError('boo!')):
2675- charm, machine, service_data, unit_data = app.check_environment(
2676+ ref, machine, service_data, unit_data = app.check_environment(
2677 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2678 check_preexisting)
2679 # The default charm URL for the given series is returned.
2680- self.assert_charm_equal(settings.DEFAULT_CHARM_URLS['precise'], charm)
2681+ self.assert_reference_equal(
2682+ settings.DEFAULT_CHARM_URLS['precise'], ref)
2683 self.assertEqual('0', machine)
2684
2685 def test_most_recent_default_charm_url(self, mock_print):
2686@@ -1117,12 +1122,12 @@
2687 bootstrap_node_series = 'saucy'
2688 check_preexisting = False
2689 with self.patch_get_charm_url(side_effect=IOError('boo!')):
2690- charm, machine, service_data, unit_data = app.check_environment(
2691+ ref, machine, service_data, unit_data = app.check_environment(
2692 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2693 check_preexisting)
2694 # The default charm URL for the given series is returned.
2695 series = settings.JUJU_GUI_SUPPORTED_SERIES[-1]
2696- self.assert_charm_equal(settings.DEFAULT_CHARM_URLS[series], charm)
2697+ self.assert_reference_equal(settings.DEFAULT_CHARM_URLS[series], ref)
2698 self.assertIsNone(machine)
2699
2700 def test_charm_url_provided(self, mock_print):
2701@@ -1134,14 +1139,14 @@
2702 bootstrap_node_series = 'trusty'
2703 check_preexisting = False
2704 with self.patch_get_charm_url() as mock_get_charm_url:
2705- charm, machine, service_data, unit_data = app.check_environment(
2706+ ref, machine, service_data, unit_data = app.check_environment(
2707 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2708 check_preexisting)
2709 # There is no need to call the charmword API if the charm URL is
2710 # provided by the user.
2711 self.assertFalse(mock_get_charm_url.called)
2712 # The provided charm URL has been correctly returned.
2713- self.assert_charm_equal(charm_url, charm)
2714+ self.assert_reference_equal(charm_url, ref)
2715 # Since the provided charm series is trusty, the charm itself can be
2716 # safely deployed to machine 0.
2717 self.assertEqual('0', machine)
2718@@ -1161,14 +1166,14 @@
2719 bootstrap_node_series = 'precise'
2720 check_preexisting = False
2721 with self.patch_get_charm_url() as mock_get_charm_url:
2722- charm, machine, service_data, unit_data = app.check_environment(
2723+ ref, machine, service_data, unit_data = app.check_environment(
2724 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
2725 check_preexisting)
2726 # There is no need to call the charmword API if the charm URL is
2727 # provided by the user.
2728 self.assertFalse(mock_get_charm_url.called)
2729 # The provided charm URL has been correctly returned.
2730- self.assert_charm_equal(charm_url, charm)
2731+ self.assert_reference_equal(charm_url, ref)
2732 # Since the provided charm series is not precise, the charm must be
2733 # deployed to a new machine.
2734 self.assertIsNone(machine)
2735@@ -1642,26 +1647,39 @@
2736
2737 class TestDeployBundle(ProgramExitTestsMixin, unittest.TestCase):
2738
2739- name = 'mybundle'
2740- yaml = 'mybundle: contents'
2741- bundle_id = '~fake/basket/bundle'
2742+ bundle_data = {'services': {}}
2743+ bundle = bundles.Bundle(bundle_data)
2744
2745 def test_bundle_deployment(self):
2746 # A bundle is successfully deployed.
2747 env = mock.Mock()
2748- app.deploy_bundle(env, self.yaml, self.name, self.bundle_id)
2749+ app.deploy_bundle(env, self.bundle)
2750+ # For the time being, the bundle version 3 is deployed by default.
2751+ expected_yaml = yaml.safe_dump({'bundle': self.bundle_data})
2752 env.deploy_bundle.assert_called_once_with(
2753- self.yaml, name=self.name, bundle_id=self.bundle_id)
2754+ expected_yaml, 3, bundle_id=None)
2755 self.assertFalse(env.close.called)
2756
2757+ def test_bundle_deployment_with_id(self):
2758+ # If the bundle reference includes the charmworld id, it is passed when
2759+ # calling the GUI server API.
2760+ # XXX frankban 2015-02-26: remove this test once we get rid of the
2761+ # charmworld id concept.
2762+ env = mock.Mock()
2763+ ref = references.Reference.from_charmworld_url('bundle:django/single')
2764+ bundle = bundles.Bundle(self.bundle_data, reference=ref)
2765+ app.deploy_bundle(env, bundle)
2766+ env.deploy_bundle.assert_called_once_with(
2767+ self.bundle.serialize_legacy(), 3, bundle_id='django/single')
2768+
2769 def test_api_error(self):
2770 # A ProgramExit is raised if an error occurs in one of the API calls.
2771 env = mock.Mock()
2772 env.deploy_bundle.side_effect = self.make_env_error(
2773 'bundle deployment failure')
2774- expected = 'bad API server response: bundle deployment failure'
2775- with self.assert_program_exit(expected):
2776- app.deploy_bundle(env, self.yaml, self.name, self.bundle_id)
2777+ expected_error = 'bad API server response: bundle deployment failure'
2778+ with self.assert_program_exit(expected_error):
2779+ app.deploy_bundle(env, self.bundle)
2780
2781 def test_other_errors(self):
2782 # Any other errors occurred during the process are not trapped.
2783@@ -1669,5 +1687,5 @@
2784 error = ValueError('explode!')
2785 env.deploy_bundle.side_effect = error
2786 with self.assertRaises(ValueError) as context_manager:
2787- app.deploy_bundle(env, self.yaml, self.name, None)
2788+ app.deploy_bundle(env, self.bundle)
2789 self.assertIs(error, context_manager.exception)
2790
2791=== modified file 'quickstart/tests/test_juju.py'
2792--- quickstart/tests/test_juju.py 2015-01-30 17:47:10 +0000
2793+++ quickstart/tests/test_juju.py 2015-02-27 18:40:58 +0000
2794@@ -172,9 +172,9 @@
2795 mock_rpc.assert_called_once_with(expected)
2796
2797 @patch_rpc
2798- def test_deploy_bundle(self, mock_rpc):
2799- # The deploy bundle call is properly generated.
2800- self.env.deploy_bundle('name: contents')
2801+ def test_deploy_bundle_v3(self, mock_rpc):
2802+ # The deploy bundle call is properly generated (API v3).
2803+ self.env.deploy_bundle('name: contents', 3)
2804 expected = {
2805 'Type': 'Deployer',
2806 'Request': 'Import',
2807@@ -183,13 +183,13 @@
2808 mock_rpc.assert_called_once_with(expected)
2809
2810 @patch_rpc
2811- def test_deploy_bundle_with_name(self, mock_rpc):
2812- # The deploy bundle call is properly generated when passing a name.
2813- self.env.deploy_bundle('name: contents', name='name')
2814+ def test_deploy_bundle_v4(self, mock_rpc):
2815+ # The deploy bundle call is properly generated (API v4).
2816+ self.env.deploy_bundle('name: contents', 4)
2817 expected = {
2818 'Type': 'Deployer',
2819 'Request': 'Import',
2820- 'Params': {'Name': 'name', 'YAML': 'name: contents'},
2821+ 'Params': {'YAML': 'name: contents', 'Version': 4},
2822 }
2823 mock_rpc.assert_called_once_with(expected)
2824
2825@@ -197,13 +197,16 @@
2826 def test_deploy_bundle_with_bundle_id(self, mock_rpc):
2827 # The deploy bundle call is properly generated when passing a
2828 # bundle_id.
2829- self.env.deploy_bundle('name: contents', name='name',
2830- bundle_id='~celso/basquet/wiki')
2831+ self.env.deploy_bundle(
2832+ 'name: contents', 4, bundle_id='~celso/basquet/wiki')
2833 expected = {
2834 'Type': 'Deployer',
2835 'Request': 'Import',
2836- 'Params': {'Name': 'name', 'YAML': 'name: contents',
2837- 'BundleID': '~celso/basquet/wiki'},
2838+ 'Params': {
2839+ 'YAML': 'name: contents',
2840+ 'Version': 4,
2841+ 'BundleID': '~celso/basquet/wiki',
2842+ },
2843 }
2844 mock_rpc.assert_called_once_with(expected)
2845
2846
2847=== modified file 'quickstart/tests/test_jujutools.py'
2848--- quickstart/tests/test_jujutools.py 2015-02-09 15:56:20 +0000
2849+++ quickstart/tests/test_jujutools.py 2015-02-27 18:40:58 +0000
2850@@ -24,7 +24,7 @@
2851 import yaml
2852
2853 from quickstart import jujutools
2854-from quickstart.models import charms
2855+from quickstart.models import references
2856 from quickstart.tests import helpers
2857
2858
2859@@ -69,80 +69,90 @@
2860 def test_new_charm_old_juju(self):
2861 # The old Juju API endpoints are used if and old version of Juju is in
2862 # use, even if the Juju GUI charm is recent.
2863- charm = charms.Charm.from_url('cs:trusty/juju-gui-42')
2864+ ref = references.Reference.from_fully_qualified_url(
2865+ 'cs:trusty/juju-gui-42')
2866 url = jujutools.get_api_url(
2867- '1.2.3.4:5678', (1, 21, 7), 'env-uuid', charm=charm)
2868+ '1.2.3.4:5678', (1, 21, 7), 'env-uuid', charm_ref=ref)
2869 self.assertEqual('wss://1.2.3.4:5678', url)
2870
2871 def test_customized_charm_unexpected_name(self):
2872 # If a customized Juju GUI charm is used, then we assume it supports
2873 # the new Juju Login API endpoint (unexpected charm name).
2874- charm = charms.Charm.from_url('cs:trusty/the-amazing-gui-0')
2875+ ref = references.Reference.from_fully_qualified_url(
2876+ 'cs:trusty/the-amazing-gui-0')
2877 url = jujutools.get_api_url(
2878- 'example.com:17070', (1, 22, 2), 'uuid', charm=charm)
2879+ 'example.com:17070', (1, 22, 2), 'uuid', charm_ref=ref)
2880 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)
2881
2882 def test_customized_charm_unexpected_user(self):
2883 # If a customized Juju GUI charm is used, then we assume it supports
2884 # the new Juju Login API endpoint (unexpected charm user).
2885- charm = charms.Charm.from_url('cs:~who/trusty/juju-gui-0')
2886+ ref = references.Reference.from_fully_qualified_url(
2887+ 'cs:~who/trusty/juju-gui-0')
2888 url = jujutools.get_api_url(
2889- 'example.com:17070', (1, 22, 2), 'uuid', charm=charm)
2890+ 'example.com:17070', (1, 22, 2), 'uuid', charm_ref=ref)
2891 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)
2892
2893 def test_customized_charm_unexpected_schema(self):
2894 # If a customized Juju GUI charm is used, then we assume it supports
2895 # the new Juju Login API endpoint (local charm).
2896- charm = charms.Charm.from_url('local:precise/juju-gui-0')
2897+ ref = references.Reference.from_fully_qualified_url(
2898+ 'local:precise/juju-gui-0')
2899 url = jujutools.get_api_url(
2900- 'example.com:17070', (1, 22, 2), 'uuid', prefix='/', charm=charm)
2901+ 'example.com:17070', (1, 22, 2), 'uuid', prefix='/', charm_ref=ref)
2902 self.assertEqual('wss://example.com:17070/environment/uuid/api', url)
2903
2904 def test_customized_charm_unexpected_series(self):
2905 # If a customized Juju GUI charm is used, then we assume it supports
2906 # the new Juju Login API endpoint (unsupported charm series).
2907- charm = charms.Charm.from_url('cs:vivid/juju-gui-0')
2908+ ref = references.Reference.from_fully_qualified_url(
2909+ 'cs:vivid/juju-gui-0')
2910 url = jujutools.get_api_url(
2911- 'example.com:22', (1, 22, 2), 'uuid', prefix='ws', charm=charm)
2912+ 'example.com:22', (1, 22, 2), 'uuid', prefix='ws', charm_ref=ref)
2913 self.assertEqual('wss://example.com:22/ws/environment/uuid/api', url)
2914
2915 def test_recent_precise_charm(self):
2916 # The new API endpoints are used if a recent precise charm is in use.
2917- charm = charms.Charm.from_url('cs:precise/juju-gui-107')
2918+ ref = references.Reference.from_fully_qualified_url(
2919+ 'cs:precise/juju-gui-107')
2920 url = jujutools.get_api_url(
2921- '1.2.3.4:4747', (1, 42, 0), 'env-id', charm=charm)
2922+ '1.2.3.4:4747', (1, 42, 0), 'env-id', charm_ref=ref)
2923 self.assertEqual('wss://1.2.3.4:4747/environment/env-id/api', url)
2924
2925 def test_recent_trusty_charm(self):
2926 # The new API endpoints are used if a recent trusty charm is in use.
2927- charm = charms.Charm.from_url('cs:trusty/juju-gui-19')
2928+ ref = references.Reference.from_fully_qualified_url(
2929+ 'cs:trusty/juju-gui-19')
2930 url = jujutools.get_api_url(
2931- '1.2.3.4:4747', (1, 42, 0), 'env-id', charm=charm)
2932+ '1.2.3.4:4747', (1, 42, 0), 'env-id', charm_ref=ref)
2933 self.assertEqual('wss://1.2.3.4:4747/environment/env-id/api', url)
2934
2935 def test_old_precise_charm(self):
2936 # The old API endpoint is returned if the precise Juju GUI charm in use
2937 # is outdated.
2938- charm = charms.Charm.from_url('cs:precise/juju-gui-106')
2939+ ref = references.Reference.from_fully_qualified_url(
2940+ 'cs:precise/juju-gui-106')
2941 url = jujutools.get_api_url(
2942- '1.2.3.4:4747', (1, 42, 0), 'env-uuid', charm=charm)
2943+ '1.2.3.4:4747', (1, 42, 0), 'env-uuid', charm_ref=ref)
2944 self.assertEqual('wss://1.2.3.4:4747', url)
2945
2946 def test_old_trusty_charm(self):
2947 # The old API endpoint is returned if the trusty Juju GUI charm in use
2948 # is outdated.
2949- charm = charms.Charm.from_url('cs:trusty/juju-gui-18')
2950+ ref = references.Reference.from_fully_qualified_url(
2951+ 'cs:trusty/juju-gui-18')
2952 url = jujutools.get_api_url(
2953- '1.2.3.4:4747', (1, 42, 0), 'env-uuid', prefix='ws', charm=charm)
2954+ '1.2.3.4:4747', (1, 42, 0), 'env-uuid', prefix='ws', charm_ref=ref)
2955 self.assertEqual('wss://1.2.3.4:4747/ws', url)
2956
2957 def test_recent_charm_and_prefix(self):
2958 # The new API endpoint is returned if a recent charm and a prefix are
2959 # both provided. This test exercises the real case in which the GUI
2960 # server API endpoint is returned.
2961- charm = charms.Charm.from_url('cs:trusty/juju-gui-42')
2962+ ref = references.Reference.from_fully_qualified_url(
2963+ 'cs:trusty/juju-gui-42')
2964 url = jujutools.get_api_url(
2965- '1.2.3.4:17070', (1, 22, 0), 'env-id', prefix='ws', charm=charm)
2966+ '1.2.3.4:17070', (1, 22, 0), 'env-id', prefix='ws', charm_ref=ref)
2967 self.assertEqual('wss://1.2.3.4:17070/ws/environment/env-id/api', url)
2968
2969
2970@@ -231,25 +241,25 @@
2971 class TestParseGuiCharmUrl(unittest.TestCase):
2972
2973 def test_charm_instance_returned(self):
2974- # A charm instance is correctly returned.
2975- charm = jujutools.parse_gui_charm_url('cs:trusty/juju-gui-42')
2976- self.assertIsInstance(charm, charms.Charm)
2977- self.assertEqual('cs:trusty/juju-gui-42', charm.url())
2978+ # A charm reference instance is correctly returned.
2979+ ref = jujutools.parse_gui_charm_url('cs:trusty/juju-gui-42')
2980+ self.assertIsInstance(ref, references.Reference)
2981+ self.assertEqual('cs:trusty/juju-gui-42', ref.id())
2982
2983 def test_customized(self):
2984- # A customized charm URL is properly logged.
2985+ # A customized charm reference is properly logged.
2986 expected = 'using a customized juju-gui charm'
2987 with helpers.assert_logs([expected], level='warn'):
2988 jujutools.parse_gui_charm_url('cs:~juju-gui/precise/juju-gui-28')
2989
2990 def test_outdated(self):
2991- # An outdated charm URL is properly logged.
2992+ # An outdated charm reference is properly logged.
2993 expected = 'charm is outdated and may not support bundle deployments'
2994 with helpers.assert_logs([expected], level='warn'):
2995 jujutools.parse_gui_charm_url('cs:precise/juju-gui-1')
2996
2997 def test_unexpected(self):
2998- # An unexpected charm URL is properly logged.
2999+ # An unexpected charm reference is properly logged.
3000 expected = (
3001 'unexpected URL for the juju-gui charm: the service may not work '
3002 'as expected')
3003
3004=== modified file 'quickstart/tests/test_manage.py'
3005--- quickstart/tests/test_manage.py 2015-02-09 17:22:04 +0000
3006+++ quickstart/tests/test_manage.py 2015-02-27 18:40:58 +0000
3007@@ -24,7 +24,6 @@
3008 import os
3009 import shutil
3010 import StringIO as io
3011-import tempfile
3012 import unittest
3013
3014 import mock
3015@@ -40,9 +39,10 @@
3016 views,
3017 )
3018 from quickstart.models import (
3019- charms,
3020+ bundles,
3021 envs,
3022 jenv,
3023+ references,
3024 )
3025 from quickstart.tests import helpers
3026
3027@@ -98,156 +98,6 @@
3028 self.assertEqual(argparse.SUPPRESS, ppa_help)
3029
3030
3031-class TestValidateBundle(
3032- helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin,
3033- unittest.TestCase):
3034-
3035- def setUp(self):
3036- self.parser = mock.Mock()
3037-
3038- def make_options(self, bundle, bundle_name=None):
3039- """Return a mock options object which includes the passed arguments."""
3040- return mock.Mock(bundle=bundle, bundle_name=bundle_name)
3041-
3042- def test_resulting_options_from_file(self):
3043- # The options object is correctly set up when a bundle file is passed.
3044- bundle_file = self.make_bundle_file()
3045- options = self.make_options(bundle_file, bundle_name='bundle1')
3046- manage._validate_bundle(options, self.parser)
3047- self.assertEqual('bundle1', options.bundle_name)
3048- self.assertEqual(
3049- ['mysql', 'wordpress'], sorted(options.bundle_services))
3050- self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
3051-
3052- def test_resulting_options_from_url(self):
3053- # The options object is correctly set up when a bundle HTTP(S) URL is
3054- # passed.
3055- bundle_file = self.make_bundle_file()
3056- url = 'http://example.com/bundle.yaml'
3057- options = self.make_options(url, bundle_name='bundle1')
3058- with self.patch_urlread(contents=self.valid_bundle) as mock_urlread:
3059- manage._validate_bundle(options, self.parser)
3060- mock_urlread.assert_called_once_with(url)
3061- self.assertEqual('bundle1', options.bundle_name)
3062- self.assertEqual(
3063- ['mysql', 'wordpress'], sorted(options.bundle_services))
3064- self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
3065-
3066- def test_resulting_options_from_bundle_url(self):
3067- # The options object is correctly set up when a "bundle:" URL is
3068- # passed.
3069- bundle_file = self.make_bundle_file()
3070- url = 'bundle:~who/my/bundle'
3071- options = self.make_options(url, bundle_name='bundle1')
3072- with self.patch_urlread(contents=self.valid_bundle) as mock_urlread:
3073- manage._validate_bundle(options, self.parser)
3074- mock_urlread.assert_called_once_with(
3075- 'https://manage.jujucharms.com/bundle/~who/my/bundle/json')
3076- self.assertEqual('bundle1', options.bundle_name)
3077- self.assertEqual(
3078- ['mysql', 'wordpress'], sorted(options.bundle_services))
3079- self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
3080-
3081- def test_resulting_options_from_jujucharms_url(self):
3082- # The options object is correctly set up when a jujucharms bundle URL
3083- # is passed.
3084- bundle_file = self.make_bundle_file()
3085- url = settings.JUJUCHARMS_BUNDLE_URL + 'my/bundle/'
3086- options = self.make_options(url, bundle_name='bundle1')
3087- with self.patch_urlread(contents=self.valid_bundle) as mock_urlread:
3088- manage._validate_bundle(options, self.parser)
3089- mock_urlread.assert_called_once_with(
3090- 'https://manage.jujucharms.com/bundle/~charmers/my/bundle/json')
3091- self.assertEqual('bundle1', options.bundle_name)
3092- self.assertEqual(
3093- ['mysql', 'wordpress'], sorted(options.bundle_services))
3094- self.assertEqual(open(bundle_file).read(), options.bundle_yaml)
3095-
3096- def test_resulting_options_from_dir(self):
3097- # The options object is correctly set up when a bundle dir is passed.
3098- bundle_dir = self.make_bundle_dir()
3099- options = self.make_options(bundle_dir, bundle_name='bundle1')
3100- manage._validate_bundle(options, self.parser)
3101- self.assertEqual('bundle1', options.bundle_name)
3102- self.assertEqual(
3103- ['mysql', 'wordpress'], sorted(options.bundle_services))
3104- expected = open(os.path.join(bundle_dir, 'bundles.yaml')).read()
3105- self.assertEqual(expected, options.bundle_yaml)
3106-
3107- def test_expand_user(self):
3108- # The ~ construct is correctly expanded in the validation process.
3109- bundle_file = self.make_bundle_file()
3110- # Split the full path of the bundle file, e.g. from a full
3111- # "/tmp/bundle.file" path retrieve the base path "/tmp" and the file
3112- # name "bundle.file". By doing that we can simulate that the user's
3113- # home is "/tmp" and that the bundle file is "~/bundle.file".
3114- base_path, filename = os.path.split(bundle_file)
3115- path = '~/{}'.format(filename)
3116- options = self.make_options(bundle=path, bundle_name='bundle2')
3117- with mock.patch('os.environ', {'HOME': base_path}):
3118- manage._validate_bundle(options, self.parser)
3119- self.assertEqual(self.valid_bundle, options.bundle_yaml)
3120-
3121- def test_bundle_file_not_found(self):
3122- # A parser error is invoked if the bundle file is not found.
3123- options = self.make_options('/no/such/file.yaml')
3124- manage._validate_bundle(options, self.parser)
3125- expected = (
3126- 'unable to open bundle file: '
3127- "[Errno 2] No such file or directory: '/no/such/file.yaml'"
3128- )
3129- self.parser.error.assert_called_once_with(expected)
3130-
3131- def test_bundle_dir_not_valid(self):
3132- # A parser error is invoked if the bundle dir does not contain the
3133- # bundles.yaml file.
3134- bundle_dir = tempfile.mkdtemp()
3135- self.addCleanup(shutil.rmtree, bundle_dir)
3136- options = self.make_options(bundle_dir)
3137- manage._validate_bundle(options, self.parser)
3138- expected = (
3139- 'unable to open bundle file: '
3140- "[Errno 2] No such file or directory: '{}/bundles.yaml'"
3141- ).format(bundle_dir)
3142- self.parser.error.assert_called_once_with(expected)
3143-
3144- def test_url_error(self):
3145- # A parser error is invoked if the bundle cannot be fetched from the
3146- # provided URL.
3147- url = 'http://example.com/bundle.yaml'
3148- options = self.make_options(url)
3149- with self.patch_urlread(error=True) as mock_urlread:
3150- manage._validate_bundle(options, self.parser)
3151- mock_urlread.assert_called_once_with(url)
3152- self.parser.error.assert_called_once_with(
3153- 'unable to open bundle URL: bad wolf')
3154-
3155- def test_bundle_url_error(self):
3156- # A parser error is invoked if an invalid "bundle:" URL is provided.
3157- url = 'bundle:'
3158- options = self.make_options(url)
3159- manage._validate_bundle(options, self.parser)
3160- self.parser.error.assert_called_once_with(
3161- 'unable to open the bundle: invalid bundle URL: bundle:')
3162-
3163- def test_jujucharms_url_error(self):
3164- # A parser error is invoked if an invalid jujucharms URL is provided.
3165- url = settings.JUJUCHARMS_BUNDLE_URL + 'no-such'
3166- options = self.make_options(url)
3167- manage._validate_bundle(options, self.parser)
3168- self.parser.error.assert_called_once_with(
3169- 'unable to open the bundle: invalid bundle URL: {}'.format(url))
3170-
3171- def test_error_parsing_bundle_contents(self):
3172- # A parser error is invoked if an error occurs parsing the bundle YAML.
3173- bundle_file = self.make_bundle_file()
3174- options = self.make_options(bundle_file, bundle_name='no-such')
3175- manage._validate_bundle(options, self.parser)
3176- expected = ('bundle no-such not found in the provided list of bundles '
3177- '(bundle1, bundle2)')
3178- self.parser.error.assert_called_once_with(expected)
3179-
3180-
3181 class TestValidateCharmUrl(unittest.TestCase):
3182
3183 def setUp(self):
3184@@ -255,16 +105,16 @@
3185
3186 def make_options(self, charm_url, has_bundle=False):
3187 """Return a mock options object which includes the passed arguments."""
3188- options = mock.Mock(charm_url=charm_url, bundle=None)
3189+ options = mock.Mock(charm_url=charm_url, bundle_source=None)
3190 if has_bundle:
3191- options.bundle = 'bundle:~who/django/42/django'
3192+ options.bundle_source = 'u/who/django/42'
3193 return options
3194
3195 def test_invalid_url_error(self):
3196 # A parser error is invoked if the charm URL is not valid.
3197 options = self.make_options('cs:invalid')
3198 manage._validate_charm_url(options, self.parser)
3199- expected = 'charm URL has invalid form: cs:invalid'
3200+ expected = 'URL has invalid form: cs:invalid'
3201 self.parser.error.assert_called_once_with(expected)
3202
3203 def test_local_charm_error(self):
3204@@ -738,14 +588,21 @@
3205 expected = 'juju-quickstart {}\n'.format(quickstart.get_version())
3206 self.assertEqual(expected, mock_stderr.getvalue())
3207
3208- @mock.patch('quickstart.manage._validate_bundle')
3209- def test_bundle(self, mock_validate_bundle):
3210+ @mock.patch('quickstart.models.bundles.from_source')
3211+ def test_bundle(self, mock_from_source):
3212 # The bundle validation process is started if a bundle is provided.
3213- self.call_setup(['/path/to/bundle.file'], exit_called=False)
3214- self.assertTrue(mock_validate_bundle.called)
3215- options, parser = mock_validate_bundle.call_args_list[0][0]
3216- self.assertIsInstance(options, argparse.Namespace)
3217- self.assertIsInstance(parser, argparse.ArgumentParser)
3218+ self.call_setup(['/path/to/bundle.yaml'], exit_called=False)
3219+ mock_from_source.assert_called_once_with('/path/to/bundle.yaml', None)
3220+
3221+ def test_bundle_error(self):
3222+ # The bundle validation process fails if an invalid bundle source is
3223+ # provided.
3224+ with mock.patch('sys.stderr', new_callable=io.StringIO) as mock_stderr:
3225+ self.call_setup(['invalid/bundle!'], exit_called=False)
3226+ expected_error = (
3227+ 'error: unable to open the bundle: invalid bundle URL: '
3228+ 'invalid/bundle!')
3229+ self.assertIn(expected_error, mock_stderr.getvalue())
3230
3231 @mock.patch('quickstart.manage._validate_charm_url')
3232 def test_charm_url(self, mock_validate_charm_url):
3233@@ -778,15 +635,14 @@
3234 @mock.patch('webbrowser.open')
3235 @mock.patch('quickstart.manage.app')
3236 @mock.patch('__builtin__.print', mock.Mock())
3237-class TestRun(unittest.TestCase):
3238+class TestRun(helpers.BundleFileTestsMixin, unittest.TestCase):
3239
3240 juju_command = '/sbin/juju'
3241
3242 def make_options(self, **kwargs):
3243 """Set up the options to be passed to the run function."""
3244 options = {
3245- 'bundle': None,
3246- 'bundle_id': None,
3247+ 'bundle_source': None,
3248 'charm_url': None,
3249 'debug': False,
3250 'env_name': 'aws',
3251@@ -830,7 +686,8 @@
3252 'connect': env,
3253 # The environment is then checked.
3254 'check_environment': (
3255- charms.Charm.from_url('cs:trusty/juju-gui-42'),
3256+ references.Reference.from_fully_qualified_url(
3257+ 'cs:trusty/juju-gui-42'),
3258 '0',
3259 {'Name': 'juju-gui'},
3260 {'Name': 'juju-gui/0'}
3261@@ -921,7 +778,8 @@
3262 # Even if the Juju version is new, the old GUI server login API is used
3263 # if the charm in the environment is not recent enough.
3264 self.configure_app(mock_app, check_environment=(
3265- charms.Charm.from_url('cs:trusty/juju-gui-0'),
3266+ references.Reference.from_fully_qualified_url(
3267+ 'cs:trusty/juju-gui-0'),
3268 '0',
3269 {'Name': 'juju-gui'},
3270 {'Name': 'juju-gui/0'}
3271@@ -990,15 +848,15 @@
3272 def test_bundle(self, mock_app, mock_open):
3273 # A bundle is correctly deployed by the application.
3274 env = self.configure_app(mock_app, create_auth_token=None)
3275+ bundle_source = 'mediawiki-single'
3276+ reference = references.Reference.from_jujucharms_url(bundle_source)
3277+ bundle = bundles.Bundle(self.bundle_data, reference=reference)
3278 # Run the application.
3279- options = self.make_options(
3280- bundle='/my/bundle/file.yaml', bundle_yaml='mybundle: contents',
3281- bundle_name='mybundle', bundle_services=['service1', 'service2'])
3282+ options = self.make_options(bundle_source=bundle_source, bundle=bundle)
3283 with self.patch_get_juju_command():
3284 manage.run(options)
3285 # Ensure the bundle is correctly deployed.
3286- mock_app.deploy_bundle.assert_called_once_with(
3287- env, 'mybundle: contents', 'mybundle', None)
3288+ mock_app.deploy_bundle.assert_called_once_with(env, bundle)
3289
3290 def test_local_provider(self, mock_app, mock_open):
3291 # The application correctly handles working with local providers with
3292
3293=== modified file 'tox.ini'
3294--- tox.ini 2015-02-09 10:38:25 +0000
3295+++ tox.ini 2015-02-27 18:40:58 +0000
3296@@ -71,7 +71,7 @@
3297 # Dependencies present in ppa:juju/stable.
3298 # See https://launchpad.net/~juju/+archive/ubuntu/stable.
3299 websocket-client==0.18.0
3300- jujuclient==0.18.4
3301+ jujuclient==0.50.1
3302 urwid==1.2.1
3303 # The distribution PyYAML requirement is used in this case.
3304

Subscribers

People subscribed via source and target branches