Merge lp:~frankban/juju-quickstart/old-style-bundles-regression into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 123
Proposed branch: lp:~frankban/juju-quickstart/old-style-bundles-regression
Merge into: lp:juju-quickstart
Diff against target: 1630 lines (+868/-269)
15 files modified
quickstart/__init__.py (+1/-1)
quickstart/app.py (+4/-2)
quickstart/charmstore.py (+161/-0)
quickstart/manage.py (+5/-5)
quickstart/models/bundles.py (+136/-59)
quickstart/models/references.py (+9/-48)
quickstart/netutils.py (+9/-18)
quickstart/settings.py (+2/-2)
quickstart/tests/helpers.py (+5/-4)
quickstart/tests/models/test_bundles.py (+166/-25)
quickstart/tests/models/test_references.py (+0/-37)
quickstart/tests/test_app.py (+31/-27)
quickstart/tests/test_charmstore.py (+313/-0)
quickstart/tests/test_netutils.py (+25/-40)
quickstart/utils.py (+1/-1)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/old-style-bundles-regression
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+252353@code.launchpad.net

Description of the change

Fix the old-style bundle regression.

This branch fixes
https://bugs.launchpad.net/juju-quickstart/+bug/1429129
In essence, legacy bundles are converted by the
ingestion process like the following:
- if a basked includes multiple bundles, the resulting
  v4 bundle name is {basket-name}-{bundle-name} for
  each {bundle-name};
- if a basket only includes one bundle, the v4 bundle
  is just {basket-name}.
Previously quickstart always assumed the former: this
branch adds a check for the latter before exiting with
an error.

This branch also introduces a charmstore module in
quickstart. All the interactions between quickstart
and the charm store are now collected in this module.

As part of this refactoring, quickstart is now able
to distinguish HTTP 404 errors and all the other
generic IOErrors that can be raised when connecting
to the network.

Also simplified the logging format and bootstrap logging
earlier in the application execution.

Tests: `make check`.

QA:
install bundles with quickstart:
`devenv/bin/juju-quickstart {bundle}`
Try the following bundles:
- devenv/bin/juju-quickstart mediawiki-single
- devenv/bin/juju-quickstart u/bigdata-dev/apache-analytics-sql
- devenv/bin/juju-quickstart bundle:mediawiki/scalable
- devenv/bin/juju-quickstart bundle:~landscape/landscape-dense-maas/landscape-dense-maas
- devenv/bin/juju-quickstart bundle:django/example-single

Those instead should return errors:
- devenv/bin/juju-quickstart mediawiki/trusty
- devenv/bin/juju-quickstart mediawiki-nosuch
- devenv/bin/juju-quickstart no-such
- devenv/bin/juju-quickstart bundle:no/such
- devenv/bin/juju-quickstart bundle:invalid
- devenv/bin/juju-quickstart bundle:~landscape/landscape-dense-maas/landscape

https://codereview.appspot.com/215070043/

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

Reviewers: mp+252353_code.launchpad.net,

Message:
Please take a look.

Description:
Fix the old-style bundle regression.

This branch fixes
https://bugs.launchpad.net/juju-quickstart/+bug/1429129
In essence, legacy bundles are converted by the
ingestion process like the following:
- if a basked includes multiple bundles, the resulting
   v4 bundle name is {basket-name}-{bundle-name} for
   each {bundle-name};
- if a basket only includes one bundle, the v4 bundle
   is just {basket-name}.
Previously quickstart always assumed the former: this
branch adds a check for the latter before exiting with
an error.

This branch also introduces a charmstore module in
quickstart. All the interactions between quickstart
and the charm store are now collected in this module.

As part of this refactoring, quickstart is now able
to distinguish HTTP 404 errors and all the other
generic IOErrors that can be raised when connecting
to the network.

Also simplified the logging format and bootstrap logging
earlier in the application execution.

Tests: `make check`.

QA:
install bundles with quickstart:
`devenv/bin/juju-quickstart {bundle}`
Try the following bundles:
- devenv/bin/juju-quickstart mediawiki-single
- devenv/bin/juju-quickstart u/bigdata-dev/apache-analytics-sql
- devenv/bin/juju-quickstart bundle:mediawiki/scalable
- devenv/bin/juju-quickstart
bundle:~landscape/landscape-dense-maas/landscape-dense-maas
- devenv/bin/juju-quickstart bundle:django/example-single

Those instead should return errors:
- devenv/bin/juju-quickstart mediawiki/trusty
- devenv/bin/juju-quickstart mediawiki-nosuch
- devenv/bin/juju-quickstart no-such
- devenv/bin/juju-quickstart bundle:no/such
- devenv/bin/juju-quickstart bundle:invalid
- devenv/bin/juju-quickstart
bundle:~landscape/landscape-dense-maas/landscape

https://code.launchpad.net/~frankban/juju-quickstart/old-style-bundles-regression/+merge/252353

(do not edit description out of merge proposal)

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

Affected files (+856, -266 lines):
   A [revision details]
   M quickstart/__init__.py
   M quickstart/app.py
   A quickstart/charmstore.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/helpers.py
   M quickstart/tests/models/test_bundles.py
   M quickstart/tests/models/test_references.py
   M quickstart/tests/test_app.py
   A quickstart/tests/test_charmstore.py
   M quickstart/tests/test_netutils.py

Revision history for this message
Jeff Pihach (hatch) wrote :
Revision history for this message
Jeff Pihach (hatch) wrote :

Thanks a lot for this fix. really cleans up a lot of the code!

https://codereview.appspot.com/215070043/

Revision history for this message
Richard Harding (rharding) wrote :
Download full text (4.3 KiB)

Thanks for this branch Francesco, I've mainly got one big question on
the legacy version of the call and some minor questions on just code
bits.

Will make sure to look asap in the morning. Let me know if anything here
is unclear.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py
File quickstart/charmstore.py (right):

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode30
quickstart/charmstore.py:30: class NotFoundError(Exception):
can this just use the netutils NotFoundError?

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode34
quickstart/charmstore.py:34: def get(path):
are these internal? Should they be _get prefixed or anything as internal
use methods?

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode45
quickstart/charmstore.py:45: url = settings.CHARMSTORE_API +
path.lstrip('/')
can you confirm that the settings will allow overriding the api endpoint
via env var? Is that documented, maybe in the hacking doc?

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode51
quickstart/charmstore.py:51: raise NotFoundError(msg)
Ok, so this is intentional to tell which kind of "NotFoundError" you've
got by if it's a netutils vs a charmstore.py one? Should we name them
different or just use the traceback?

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode64
quickstart/charmstore.py:64: For instance, to retrieve the hash of a
charm reference, use the following:
here you mention a charm reference but this is particular to bundles.
Should this be bundle reference?

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode78
quickstart/charmstore.py:78: def resolve(name, series=None):
what about specifying the version? The main concern is that if it
doesn't exist that the failure is consistent or clear to the user.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode116
quickstart/charmstore.py:116: def get_legacy_bundle_data(reference):
do we ever get the legacy bundle though now? I thought we only got the
updated bundle and then turned it into legacy format by nesting it
inside another level?

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode134
quickstart/charmstore.py:134: def _retrieve_bundle_data(reference,
path):
get_bundle_data vs _retrieve_bundle_data was a bit unclear to me. Maybe
we can get the word 'parse' into the function name to help clarify that
one fetches while the other actually parses?

https://codereview.appspot.com/215070043/diff/1/quickstart/models/bundles.py
File quickstart/models/bundles.py (right):

https://codereview.appspot.com/215070043/diff/1/quickstart/models/bundles.py#newcode228
quickstart/models/bundles.py:228: data =
charmstore.get_legacy_bundle_data(reference)
I was expecting this to use the same 'get_bundle_data but with the
different reference using only the basket vs getting the orig.yaml file
using the get_legacy_bundle_data. Can you clarify this for me please?

https://codereview.appspot.com/215070043/diff/1/quickstart/models/bundles.py#newcode400
quickstart/mo...

Read more...

Revision history for this message
Brad Crittenden (bac) wrote :

LGTM. Thanks for the branch. Did not QA.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py
File quickstart/charmstore.py (right):

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode68
quickstart/charmstore.py:68: Raise a NotFoundError the an entity with
the given reference cannot be
typo: the an entity

https://codereview.appspot.com/215070043/diff/1/quickstart/models/bundles.py
File quickstart/models/bundles.py (right):

https://codereview.appspot.com/215070043/diff/1/quickstart/models/bundles.py#newcode187
quickstart/models/bundles.py:187:
Thank you for the super-clear regex construction.

https://codereview.appspot.com/215070043/

130. By Francesco Banconi

Changes as per review.

Revision history for this message
Francesco Banconi (frankban) wrote :
Download full text (8.4 KiB)

Please take a look.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py
File quickstart/charmstore.py (right):

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode30
quickstart/charmstore.py:30: class NotFoundError(Exception):
On 2015/03/10 01:18:32, rharding wrote:
> can this just use the netutils NotFoundError?

It can. But I didn't implement it lije that on purpose.
My idea is that having the charmstore raise a charmstore.NotFoundError
is part of the contract. Instead, exposing its internal dependency on
netutils.urlread is not. This is why we basically mask the underlying
exception.
With this implementation, we discourage people to write something like:

try:
    charmstore.get(...)
except netutils.NotFoundError: # This currently does not work.
    ...

The code above would establish a connection between the charmstore and
the netutils code, which is only an implementation detail. If, for
instance in the future we decide to replace the simplistic code in
netutils with something like requests or similar, the current API
doesn't have to change, i.e. the charmstore would still raise a
charmstore.NotFoundError.
In essence, clients using the charmstore API doesn't have to know (or
import) netutils, and vice versa.

I considered the module namespace to be good enough to distinguish
between netutils.NotFoundError and charmstore.NotFoundError.

That is the reasoning behind this implementation, and I agree it can
seem a repetition, but it's separation of concerns in my idea. Of course
feel free to disagree with the above.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode34
quickstart/charmstore.py:34: def get(path):
On 2015/03/10 01:18:32, rharding wrote:
> are these internal? Should they be _get prefixed or anything as
internal use
> methods?

The idea is that this is public API. A client can use this function when
no higher level calls are available.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode45
quickstart/charmstore.py:45: url = settings.CHARMSTORE_API +
path.lstrip('/')
On 2015/03/10 01:18:32, rharding wrote:
> can you confirm that the settings will allow overriding the api
endpoint via env
> var? Is that documented, maybe in the hacking doc?

Overriding the charmstore API URL is not supported currently. Do we need
to support that? If so, I'll create a card.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode51
quickstart/charmstore.py:51: raise NotFoundError(msg)
On 2015/03/10 01:18:32, rharding wrote:
> Ok, so this is intentional to tell which kind of "NotFoundError"
you've got by
> if it's a netutils vs a charmstore.py one? Should we name them
different or just
> use the traceback?

Not sure if I understand the question about using the traceback. See
above for the goal of having two separate exception types.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode64
quickstart/charmstore.py:64: For instance, to retrieve the hash of a
charm reference, use the following:
On 2015/03/10 01:18:32, rharding wrote:
> here you mention a charm reference but this is particula...

Read more...

Revision history for this message
Richard Harding (rharding) wrote :
Download full text (8.8 KiB)

On 2015/03/10 10:20:07, frankban wrote:
> Please take a look.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py
> File quickstart/charmstore.py (right):

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode30
> quickstart/charmstore.py:30: class NotFoundError(Exception):
> On 2015/03/10 01:18:32, rharding wrote:
> > can this just use the netutils NotFoundError?

> It can. But I didn't implement it lije that on purpose.
> My idea is that having the charmstore raise a charmstore.NotFoundError
is part
> of the contract. Instead, exposing its internal dependency on
netutils.urlread
> is not. This is why we basically mask the underlying exception.
> With this implementation, we discourage people to write something
like:

> try:
> charmstore.get(...)
> except netutils.NotFoundError: # This currently does not work.
> ...

> The code above would establish a connection between the charmstore and
the
> netutils code, which is only an implementation detail. If, for
instance in the
> future we decide to replace the simplistic code in netutils with
something like
> requests or similar, the current API doesn't have to change, i.e. the
charmstore
> would still raise a charmstore.NotFoundError.
> In essence, clients using the charmstore API doesn't have to know (or
import)
> netutils, and vice versa.

> I considered the module namespace to be good enough to distinguish
between
> netutils.NotFoundError and charmstore.NotFoundError.

> That is the reasoning behind this implementation, and I agree it can
seem a
> repetition, but it's separation of concerns in my idea. Of course feel
free to
> disagree with the above.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode34
> quickstart/charmstore.py:34: def get(path):
> On 2015/03/10 01:18:32, rharding wrote:
> > are these internal? Should they be _get prefixed or anything as
internal use
> > methods?

> The idea is that this is public API. A client can use this function
when no
> higher level calls are available.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode45
> quickstart/charmstore.py:45: url = settings.CHARMSTORE_API +
path.lstrip('/')
> On 2015/03/10 01:18:32, rharding wrote:
> > can you confirm that the settings will allow overriding the api
endpoint via
> env
> > var? Is that documented, maybe in the hacking doc?

> Overriding the charmstore API URL is not supported currently. Do we
need to
> support that? If so, I'll create a card.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode51
> quickstart/charmstore.py:51: raise NotFoundError(msg)
> On 2015/03/10 01:18:32, rharding wrote:
> > Ok, so this is intentional to tell which kind of "NotFoundError"
you've got by
> > if it's a netutils vs a charmstore.py one? Should we name them
different or
> just
> > use the traceback?

> Not sure if I understand the question about using the traceback. See
above for
> the goal of having two separate exception types.

https://codereview.appspot.com/215070043/diff/1/quickstart/charmstore.py#newcode64
> quickstart/charmstore.py:64: For instance, to retrieve the has...

Read more...

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

*** Submitted:

Fix the old-style bundle regression.

This branch fixes
https://bugs.launchpad.net/juju-quickstart/+bug/1429129
In essence, legacy bundles are converted by the
ingestion process like the following:
- if a basked includes multiple bundles, the resulting
   v4 bundle name is {basket-name}-{bundle-name} for
   each {bundle-name};
- if a basket only includes one bundle, the v4 bundle
   is just {basket-name}.
Previously quickstart always assumed the former: this
branch adds a check for the latter before exiting with
an error.

This branch also introduces a charmstore module in
quickstart. All the interactions between quickstart
and the charm store are now collected in this module.

As part of this refactoring, quickstart is now able
to distinguish HTTP 404 errors and all the other
generic IOErrors that can be raised when connecting
to the network.

Also simplified the logging format and bootstrap logging
earlier in the application execution.

Tests: `make check`.

QA:
install bundles with quickstart:
`devenv/bin/juju-quickstart {bundle}`
Try the following bundles:
- devenv/bin/juju-quickstart mediawiki-single
- devenv/bin/juju-quickstart u/bigdata-dev/apache-analytics-sql
- devenv/bin/juju-quickstart bundle:mediawiki/scalable
- devenv/bin/juju-quickstart
bundle:~landscape/landscape-dense-maas/landscape-dense-maas
- devenv/bin/juju-quickstart bundle:django/example-single

Those instead should return errors:
- devenv/bin/juju-quickstart mediawiki/trusty
- devenv/bin/juju-quickstart mediawiki-nosuch
- devenv/bin/juju-quickstart no-such
- devenv/bin/juju-quickstart bundle:no/such
- devenv/bin/juju-quickstart bundle:invalid
- devenv/bin/juju-quickstart
bundle:~landscape/landscape-dense-maas/landscape

R=jeff.pihach, rharding, bac
CC=
https://codereview.appspot.com/215070043

https://codereview.appspot.com/215070043/

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'quickstart/__init__.py'
--- quickstart/__init__.py 2015-02-27 09:25:33 +0000
+++ quickstart/__init__.py 2015-03-10 10:19:09 +0000
@@ -45,7 +45,7 @@
45Once Juju has been installed, the command can also be run as a juju plugin,45Once Juju has been installed, the command can also be run as a juju plugin,
46without the hyphen ("juju quickstart").46without the hyphen ("juju quickstart").
47"""47"""
48VERSION = (2, 0, 0)48VERSION = (2, 0, 1)
4949
5050
51def get_version():51def get_version():
5252
=== modified file 'quickstart/app.py'
--- quickstart/app.py 2015-02-26 11:02:57 +0000
+++ quickstart/app.py 2015-03-10 10:19:09 +0000
@@ -29,6 +29,7 @@
29import jujuclient29import jujuclient
3030
31from quickstart import (31from quickstart import (
32 charmstore,
32 juju,33 juju,
33 jujutools,34 jujutools,
34 netutils,35 netutils,
@@ -440,8 +441,9 @@
440 series = settings.JUJU_GUI_SUPPORTED_SERIES[-1]441 series = settings.JUJU_GUI_SUPPORTED_SERIES[-1]
441 try:442 try:
442 # Try to get the charm URL from the charm store API.443 # Try to get the charm URL from the charm store API.
443 charm_url = netutils.get_charm_url(series)444 charm_url = charmstore.resolve(
444 except (IOError, ValueError) as err:445 settings.JUJU_GUI_CHARM_NAME, series=series)
446 except (charmstore.NotFoundError, IOError, ValueError) as err:
445 # Fall back to the default URL for the current series.447 # Fall back to the default URL for the current series.
446 msg = 'unable to retrieve the {} charm URL from the API: {}'448 msg = 'unable to retrieve the {} charm URL from the API: {}'
447 logging.warn(msg.format(service_name, err))449 logging.warn(msg.format(service_name, err))
448450
=== added file 'quickstart/charmstore.py'
--- quickstart/charmstore.py 1970-01-01 00:00:00 +0000
+++ quickstart/charmstore.py 2015-03-10 10:19:09 +0000
@@ -0,0 +1,161 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2015 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Juju Quickstart utilities for communicating with the Juju charm store."""
18
19from __future__ import unicode_literals
20
21import json
22
23from quickstart import (
24 netutils,
25 serializers,
26 settings,
27)
28
29
30class NotFoundError(Exception):
31 """Represent a not found HTTP error while communicating with the store."""
32
33
34def get(path):
35 """Send a GET request to the charm store at the given path.
36
37 The path must not include the charm store API version identifier. In
38 essence, the path must exclude the "/v4/" fragment.
39
40 Return the string content returned by the charm store.
41 Raise a NotFoundError if a 404 not found response is returned.
42 Raise an IOError if any other problems occur while communicating with the
43 charm store.
44 """
45 url = settings.CHARMSTORE_API + path.lstrip('/')
46 try:
47 return netutils.urlread(url)
48 except netutils.NotFoundError as err:
49 msg = b'charm store resource not found at {}: {}'.format(
50 url.encode('utf-8'), err)
51 raise NotFoundError(msg)
52 except IOError as err:
53 msg = b'cannot communicate with the charm store at {}: {}'.format(
54 url.encode('utf-8'), err)
55 raise IOError(msg)
56
57
58def get_reference(reference, path):
59 """Retrieve the charm store contents for the given reference and path.
60
61 The reference argument identifies a charm or bundle entity and must be an
62 instance of "quickstart.models.references.Reference".
63
64 For instance, to retrieve the hash of a charm reference, use the following:
65
66 hash = get_reference(reference, '/meta/hash')
67
68 Raise a NotFoundError if an entity with the given reference cannot be
69 found in the charm store.
70 Raise an IOError if any other problems occur while communicating with the
71 charm store.
72 """
73 if not path.startswith('/'):
74 path = '/' + path
75 return get(reference.path() + path)
76
77
78def resolve(name, series=None):
79 """Return the fully qualified id of the entity with the given name.
80
81 If the optional series is provided, resolve the entity of the given series.
82
83 Raise a NotFoundError if an entity with the given name and optional series
84 cannot be found in the charm store.
85 Raise an IOError if any other problems occur while communicating with the
86 charm store.
87 Raise a ValueError if the API returns invalid data.
88 """
89 series_part = '' if series is None else '{}/'.format(series)
90 content = get(series_part + name + '/meta/id')
91 data = json.loads(content)
92 entity_id = data.get('Id')
93 if entity_id is None:
94 msg = 'unable to resolve entity id {}{}'.format(series_part, name)
95 raise ValueError(msg.encode('utf-8'))
96 return entity_id
97
98
99def get_bundle_data(reference):
100 """Retrieve and return the bundle data for the given bundle reference.
101
102 The bundle data is returned as a YAML decoded value.
103 The reference argument identifies a bundle entity and must be an instance
104 of "quickstart.models.references.Reference".
105
106 Raise a ValueError if the returned content is not a valid YAML, or if the
107 given reference does not represent a bundle.
108 Raise a NotFoundError if a bundle with the given reference cannot be found
109 in the charm store.
110 Raise an IOError if any other problems occur while communicating with the
111 charm store.
112 """
113 return _retrieve_and_parse_bundle_data(reference, '/archive/bundle.yaml')
114
115
116def get_legacy_bundle_data(reference):
117 """Retrieve and return the bundle legacy data for the given reference.
118
119 The bundle data is returned as a YAML decoded value and represents the
120 legacy bundle with a top level bundle name node.
121 The reference argument identifies a bundle entity and must be an instance
122 of "quickstart.models.references.Reference".
123
124 Raise a ValueError if the returned content is not a valid YAML, or if the
125 given reference does not represent a bundle.
126 Raise a NotFoundError if a bundle with the given reference cannot be found
127 in the charm store.
128 Raise an IOError if any other problems occur while communicating with the
129 charm store.
130 """
131 return _retrieve_and_parse_bundle_data(
132 reference, '/archive/bundles.yaml.orig')
133
134
135def _retrieve_and_parse_bundle_data(reference, path):
136 """Retrieve and parse the bundle YAML for the given reference and path.
137
138 Raise a ValueError if the returned content is not a valid YAML, or if the
139 given reference does not represent a bundle.
140 Raise a NotFoundError if a bundle with the given reference cannot be found
141 in the charm store.
142 Raise an IOError if any other problems occur while communicating with the
143 charm store.
144 """
145 if not reference.is_bundle():
146 raise ValueError(
147 b'expected a bundle, provided charm {}'.format(reference))
148 content = get_reference(reference, path)
149 return load_bundle_yaml(content)
150
151
152def load_bundle_yaml(content):
153 """Deserialize the given YAML encoded bundle content.
154
155 Raise a ValueError if the content is not valid.
156 """
157 try:
158 return serializers.yaml_load(content)
159 except Exception as err:
160 msg = b'unable to parse the bundle content: {}'.format(err)
161 raise ValueError(msg)
0162
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2015-02-26 12:42:34 +0000
+++ quickstart/manage.py 2015-03-10 10:19:09 +0000
@@ -294,7 +294,7 @@
294 level=level,294 level=level,
295 format=(295 format=(
296 '%(asctime)s %(levelname)s '296 '%(asctime)s %(levelname)s '
297 '%(module)s@%(funcName)s:%(lineno)d '297 '%(module)s:%(lineno)d '
298 '%(message)s'298 '%(message)s'
299 ),299 ),
300 datefmt='%H:%M:%S',300 datefmt='%H:%M:%S',
@@ -459,9 +459,11 @@
459 # Parse the provided arguments.459 # Parse the provided arguments.
460 options = parser.parse_args()460 options = parser.parse_args()
461461
462 # Set up logging.
463 _configure_logging(logging.DEBUG if options.debug else logging.INFO)
464
465 # Validate and add in the platform for convenience.
462 _validate_platform(parser, platform)466 _validate_platform(parser, platform)
463
464 # Add in the platform for convenience.
465 options.platform = platform467 options.platform = platform
466468
467 # Convert the provided string arguments to unicode.469 # Convert the provided string arguments to unicode.
@@ -472,8 +474,6 @@
472 _validate_bundle(options, parser)474 _validate_bundle(options, parser)
473 if options.charm_url is not None:475 if options.charm_url is not None:
474 _validate_charm_url(options, parser)476 _validate_charm_url(options, parser)
475 # Set up logging.
476 _configure_logging(logging.DEBUG if options.debug else logging.INFO)
477 return options477 return options
478478
479479
480480
=== modified file 'quickstart/models/bundles.py'
--- quickstart/models/bundles.py 2015-02-26 19:46:48 +0000
+++ quickstart/models/bundles.py 2015-03-10 10:19:09 +0000
@@ -44,8 +44,10 @@
44import collections44import collections
45import logging45import logging
46import os46import os
47import re
4748
48from quickstart import (49from quickstart import (
50 charmstore,
49 netutils,51 netutils,
50 serializers,52 serializers,
51 settings,53 settings,
@@ -140,11 +142,7 @@
140 """142 """
141 if source.startswith('bundle:'):143 if source.startswith('bundle:'):
142 # The source refers to an old style bundle URL.144 # The source refers to an old style bundle URL.
143 reference = references.Reference.from_charmworld_url(source)145 return _bundle_from_charmworld_url(source)
144 logging.warn(
145 'this bundle URL is deprecated: please use the new format: '
146 '{}'.format(reference.jujucharms_id()))
147 return _bundle_from_reference(reference)
148146
149 has_extension = source.endswith('.yaml') or source.endswith('.json')147 has_extension = source.endswith('.yaml') or source.endswith('.json')
150 is_remote = source.startswith('http://') or source.startswith('https://')148 is_remote = source.startswith('http://') or source.startswith('https://')
@@ -163,30 +161,84 @@
163 # No other options are available.161 # No other options are available.
164 raise162 raise
165163
166 if not reference.is_bundle():
167 raise ValueError(
168 b'expected a bundle, provided charm {}'.format(reference))
169
170 # The source refers to a bundle URL in jujucharms.com.164 # The source refers to a bundle URL in jujucharms.com.
171 return _bundle_from_reference(reference)165 try:
172166 data = charmstore.get_bundle_data(reference)
173167 except charmstore.NotFoundError as err:
174def _bundle_from_reference(reference):168 raise IOError(bytes(err))
175 """Retrieve bundle YAML contents from its reference in the charm store.169 validate(data)
176170 return Bundle(data, reference=reference)
177 The path of an entity in the charm store is the fully qualified URL without171
178 the schema. The schema is implicitly set to "cs" (charm store entity), e.g.172
179 "vivid/django" or "~who/trusty/mediawiki-42".173# Compile the regular expression used to parse charmworld bundle URLs.
180174_charmworld_url_expression = re.compile(r"""
181 Return a Bundle instance which includes the retrieved data and the given175 ^ # Beginning of the line.
182 reference.176 (?:bundle:) # Bundle schema.
177 (?:~({user_pattern})/)? # Optional user name.
178 ({name_pattern})/ # Basket name.
179 (?:(\d+)/)? # Optional bundle revision number.
180 ({name_pattern}) # Bundle name.
181 /? # Optional trailing slash.
182 $ # End of the line.
183""".format(
184 name_pattern=references.NAME_PATTERN,
185 user_pattern=references.USER_PATTERN,
186), re.VERBOSE)
187
188
189def _bundle_from_charmworld_url(url):
190 """Retrieve bundle YAML contents from the given charmworld URL.
191
192 These kind of "bundle:basket/name" URLs were used before the release
193 of the new charm store (API version 4). Possible examples are
194 "bundle:mediawiki/single" or "bundle:~who/wordpress/42/scalable".
195 Note that charmworld URLs always represent a bundle.
196
197 Return a Bundle instance which includes the retrieved data and the bundle
198 reference corresponding to the given charmworld URL.
199 Raise a ValueError if the provided URL is not valid, or if the bundle
200 content is not valid.
183 Raise a IOError if a problem is encountered while fetching the YAML201 Raise a IOError if a problem is encountered while fetching the YAML
184 content from the charm store.202 content from the charm store.
185 Raise a ValueError if the bundle content is not valid.
186 """203 """
187 url = settings.CHARMSTORE_API + reference.path() + '/archive/bundle.yaml'204 match = _charmworld_url_expression.match(url)
188 content = _retrieve_from_url(url)205 if match is None:
189 data = parse_yaml(content)206 msg = 'invalid bundle URL: {}'.format(url)
207 raise ValueError(msg.encode('utf-8'))
208 user, basket, revision, name = match.groups()
209 # The legacy bundle ingestion works like the following:
210 # - if a basket includes multiple bundles, the resulting v4 bundle name is
211 # "{basket-name}-{bundle-name}". In this case, a separate bundle is
212 # created for each top level name found in the YAML;
213 # - if a basket only includes one bundle, the v4 bundle just uses the
214 # basket name, because no disambiguation is required.
215 # For this reason, we cannot infer the new bundle identifier from a
216 # charmworld URL: we instead need to try to fetch both references.
217 fullname = '{}-{}'.format(basket, name)
218 reference = references.Reference('cs', user, 'bundle', fullname, revision)
219 try:
220 data = charmstore.get_bundle_data(reference)
221 except charmstore.NotFoundError:
222 # Also try the case in which a single bundle was included in the
223 # legacy YAML. In this case, validating that the name was included
224 # in the original YAML is also required.
225 reference = references.Reference(
226 'cs', user, 'bundle', basket, revision)
227 try:
228 data = charmstore.get_legacy_bundle_data(reference)
229 except charmstore.NotFoundError as err:
230 raise IOError(bytes(err))
231 data = _flatten_data(data, name)
232
233 validate(data)
234 # XXX frankban 2015-02-26: remove this when switching to the new bundle
235 # format. Note that this is monkey patched on purpose: we don't want the
236 # legacy bundle id to be part of the Reference class contract, and we don't
237 # want to keep track of obsolete concepts such as "basket" there.
238 reference.charmworld_id = url[len('bundle:'):]
239 logging.warn(
240 'this bundle URL is deprecated: please use the new format: '
241 '{}'.format(reference.jujucharms_id()))
190 return Bundle(data, reference=reference)242 return Bundle(data, reference=reference)
191243
192244
@@ -198,7 +250,7 @@
198 """250 """
199 try:251 try:
200 return netutils.urlread(url)252 return netutils.urlread(url)
201 except IOError as err:253 except (netutils.NotFoundError, IOError) as err:
202 msg = b'cannot retrieve bundle from remote URL {}: {}'.format(254 msg = b'cannot retrieve bundle from remote URL {}: {}'.format(
203 url.encode('utf-8'), err)255 url.encode('utf-8'), err)
204 raise IOError(msg)256 raise IOError(msg)
@@ -233,12 +285,28 @@
233 - the YAML contents are not properly structured;285 - the YAML contents are not properly structured;
234 - the bundle does not include services.286 - the bundle does not include services.
235 """287 """
236 data = _open_yaml(content)288 data = charmstore.load_bundle_yaml(content)
237 # Validate the bundle data.289 # Validate the bundle data.
238 validate(data)290 validate(data)
239 return data291 return data
240292
241293
294def is_legacy_bundle(data):
295 """Report whether the given bundle data represents a legacy bundle.
296
297 Assume the given data is a dictionary like object.
298 """
299 services = data.get('services')
300 # The internal structure of a bundle in the API version 4 does not include
301 # a wrapping namespace with the bundle name. That's why the check below,
302 # despite its ugliness, is quite effective.
303 if (not services):
304 return True
305 if isinstance(services, collections.Mapping) and ('services' in services):
306 return True
307 return False
308
309
242def _parse_and_flatten_yaml(content, name):310def _parse_and_flatten_yaml(content, name):
243 """Parse and validate the given bundle content.311 """Parse and validate the given bundle content.
244312
@@ -255,15 +323,29 @@
255 than one bundle;323 than one bundle;
256 - the bundle does not include services.324 - the bundle does not include services.
257 """325 """
258 data = _open_yaml(content)326 data = charmstore.load_bundle_yaml(content)
259 services = data.get('services')327 _ensure_is_dict(data)
260 # The internal structure of a bundle in the API version 4 does not include328 if is_legacy_bundle(data):
261 # a wrapping namespace with the bundle name. That's why the check below,329 data = _flatten_data(data, name)
262 # despite its ugliness, is quite effective.330 validate(data)
263 if services and 'services' not in services:331 return data
264 # This is an API version 4 bundle.332
265 validate(data)333
266 return data334def _flatten_data(data, name):
335 """Retrieve the bundle content from data for a specific bundle name.
336
337 The returned YAML decoded data represents a new style bundle
338 (API version 4).
339
340 Raise a ValueError if:
341 - the YAML data is not properly structured;
342 - the YAML data does not include any bundles;
343 - the bundle name is specified but not included in the bundle file;
344 - the bundle name is not specified and the bundle file includes more
345 than one bundle;
346 - the bundle does not include services.
347 """
348 _ensure_is_dict(data)
267 num_bundles = len(data)349 num_bundles = len(data)
268 if not num_bundles:350 if not num_bundles:
269 raise ValueError(b'no bundles found in the provided list of bundles')351 raise ValueError(b'no bundles found in the provided list of bundles')
@@ -272,30 +354,14 @@
272 if num_bundles > 1:354 if num_bundles > 1:
273 msg = 'multiple bundles found ({}) but no bundle name specified'355 msg = 'multiple bundles found ({}) but no bundle name specified'
274 raise ValueError(msg.format(names).encode('utf-8'))356 raise ValueError(msg.format(names).encode('utf-8'))
275 data = data.values()[0]357 return data.values()[0]
276 else:358 data = data.get(name)
277 data = data.get(name)359 if data is None:
278 if data is None:360 if num_bundles == 1:
361 msg = 'bundle {} not found, did you mean {}?'
362 else:
279 msg = 'bundle {} not found in the provided list of bundles ({})'363 msg = 'bundle {} not found in the provided list of bundles ({})'
280 raise ValueError(msg.format(name, names).encode('utf-8'))364 raise ValueError(msg.format(name, names).encode('utf-8'))
281 validate(data)
282 return data
283
284
285def _open_yaml(content):
286 """Deserialize the given content, that must be a YAML encoded dictionary.
287
288 Raise a ValueError if the content is not valid.
289 """
290 try:
291 data = serializers.yaml_load(content)
292 except Exception as err:
293 msg = b'unable to parse the bundle content: {}'.format(err)
294 raise ValueError(msg)
295 # Ensure the bundle content is well formed.
296 if not isinstance(data, collections.Mapping):
297 msg = 'invalid YAML content: {}'.format(data)
298 raise ValueError(msg.encode('utf-8'))
299 return data365 return data
300366
301367
@@ -312,6 +378,7 @@
312 - the YAML contents are not properly structured;378 - the YAML contents are not properly structured;
313 - the bundle does not include services.379 - the bundle does not include services.
314 """380 """
381 _ensure_is_dict(data)
315 # Retrieve the bundle services.382 # Retrieve the bundle services.
316 try:383 try:
317 services = data['services'].keys()384 services = data['services'].keys()
@@ -328,3 +395,13 @@
328 b'the provided bundle contains an instance of juju-gui. Juju '395 b'the provided bundle contains an instance of juju-gui. Juju '
329 b'Quickstart will install the latest version of the Juju GUI '396 b'Quickstart will install the latest version of the Juju GUI '
330 b'automatically; please remove juju-gui from the bundle')397 b'automatically; please remove juju-gui from the bundle')
398
399
400def _ensure_is_dict(data):
401 """Ensure that the given bundle data is a dictionary like object.
402
403 Raise a ValueError otherwise.
404 """
405 if not isinstance(data, collections.Mapping):
406 msg = 'invalid YAML content: {}'.format(data)
407 raise ValueError(msg.encode('utf-8'))
331408
=== modified file 'quickstart/models/references.py'
--- quickstart/models/references.py 2015-02-27 09:25:33 +0000
+++ quickstart/models/references.py 2015-03-10 10:19:09 +0000
@@ -25,29 +25,15 @@
2525
26# The following regular expressions are the same used in juju-core: see26# The following regular expressions are the same used in juju-core: see
27# http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go.27# http://bazaar.launchpad.net/~go-bot/juju-core/trunk/view/head:/charm/url.go.
28_USER_PATTERN = r'[a-z0-9][a-zA-Z0-9+.-]+'28USER_PATTERN = r'[a-z0-9][a-zA-Z0-9+.-]+'
29_SERIES_PATTERN = r'[a-z]+(?:[a-z-]+[a-z])?'29SERIES_PATTERN = r'[a-z]+(?:[a-z-]+[a-z])?'
30_NAME_PATTERN = r'[a-z][a-z0-9]*(?:-[a-z0-9]*[a-z][a-z0-9]*)*'30NAME_PATTERN = r'[a-z][a-z0-9]*(?:-[a-z0-9]*[a-z][a-z0-9]*)*'
3131
32# Define the callables used to check if entity reference components are valid.32# Define the callables used to check if entity reference components are valid.
33_valid_user = re.compile(r'^{}$'.format(_USER_PATTERN)).match33_valid_user = re.compile(r'^{}$'.format(USER_PATTERN)).match
34_valid_series = re.compile(r'^{}$'.format(_SERIES_PATTERN)).match34_valid_series = re.compile(r'^{}$'.format(SERIES_PATTERN)).match
35_valid_name = re.compile(r'^{}$'.format(_NAME_PATTERN)).match35_valid_name = re.compile(r'^{}$'.format(NAME_PATTERN)).match
3636
37# Compile the regular expression used to parse charmworld bundle URLs.
38_charmworld_url_expression = re.compile(r"""
39 ^ # Beginning of the line.
40 (?:bundle:) # Bundle schema.
41 (?:~({user_pattern})/)? # Optional user name.
42 ({name_pattern})/ # Basket name.
43 (?:(\d+)/)? # Optional bundle revision number.
44 ({name_pattern}) # Bundle name.
45 /? # Optional trailing slash.
46 $ # End of the line.
47""".format(
48 name_pattern=_NAME_PATTERN,
49 user_pattern=_USER_PATTERN,
50), re.VERBOSE)
51# Compile the regular expression used to parse new jujucharms entity URLs.37# Compile the regular expression used to parse new jujucharms entity URLs.
52_jujucharms_url_expression = re.compile(r"""38_jujucharms_url_expression = re.compile(r"""
53 ^ # Beginning of the line.39 ^ # Beginning of the line.
@@ -64,9 +50,9 @@
64 $ # End of the line.50 $ # End of the line.
65""".format(51""".format(
66 jujucharms=settings.JUJUCHARMS_URL,52 jujucharms=settings.JUJUCHARMS_URL,
67 name_pattern=_NAME_PATTERN,53 name_pattern=NAME_PATTERN,
68 series_pattern=_SERIES_PATTERN,54 series_pattern=SERIES_PATTERN,
69 user_pattern=_USER_PATTERN,55 user_pattern=USER_PATTERN,
70), re.VERBOSE)56), re.VERBOSE)
7157
7258
@@ -101,31 +87,6 @@
101 return cls(*_parse_fully_qualified_url(url))87 return cls(*_parse_fully_qualified_url(url))
10288
103 @classmethod89 @classmethod
104 def from_charmworld_url(cls, url):
105 """Create and return a Reference from the given charmworld URL.
106
107 These kind of "bundle:basket/name" URLs were used before the release
108 of the new charm store (API version 4). Possible examples are
109 "bundle:mediawiki/single" or "bundle:~who/wordpress/42/scalable".
110 Note that charmworld URLs always represent a bundle.
111
112 Raise a ValueError if the provided URL is not valid.
113 """
114 match = _charmworld_url_expression.match(url)
115 if match is None:
116 msg = 'invalid bundle URL: {}'.format(url)
117 raise ValueError(msg.encode('utf-8'))
118 user, basket, revision, name = match.groups()
119 name = '{}-{}'.format(basket, name)
120 self = cls('cs', user, 'bundle', name, revision)
121 # XXX frankban 2015-02-26: remove this when switching to the new bundle
122 # format. Note that this is monkey patched on purpose: we don't want
123 # the legacy bundle id to be part of this class contract, and we don't
124 # want to keep track of obsolete concepts such as "basket" here.
125 self.charmworld_id = url[len('bundle:'):]
126 return self
127
128 @classmethod
129 def from_jujucharms_url(cls, url):90 def from_jujucharms_url(cls, url):
130 """Create and return a Reference from the given jujucharms.com URL.91 """Create and return a Reference from the given jujucharms.com URL.
13192
13293
=== modified file 'quickstart/netutils.py'
--- quickstart/netutils.py 2015-02-25 15:28:56 +0000
+++ quickstart/netutils.py 2015-03-10 10:19:09 +0000
@@ -18,14 +18,11 @@
1818
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import json
22import httplib21import httplib
23import logging22import logging
24import socket23import socket
25import urllib224import urllib2
2625
27from quickstart import settings
28
2926
30def check_resolvable(hostname):27def check_resolvable(hostname):
31 """Check that the hostname can be resolved to a numeric IP address.28 """Check that the hostname can be resolved to a numeric IP address.
@@ -63,31 +60,25 @@
63 return None60 return None
6461
6562
66def get_charm_url(series):63class NotFoundError(Exception):
67 """Return the charm URL of the latest Juju GUI charm revision.64 """Represent a 404 not found HTTP error."""
68
69 Raise an IOError if any problems occur connecting to the API endpoint.
70 Raise a ValueError if the API returns invalid data.
71 """
72 url = '{}{}/{}/meta/id'.format(
73 settings.CHARMSTORE_API, series, settings.JUJU_GUI_CHARM_NAME)
74 data = json.loads(urlread(url))
75 charm_url = data.get('Id')
76 if charm_url is None:
77 raise ValueError(b'unable to find the charm URL')
78 return charm_url
7965
8066
81def urlread(url):67def urlread(url):
82 """Open the given URL and return the page contents.68 """Open the given URL and return the page contents.
8369
84 Raise an IOError if any problems occur.70 Raise a NotFoundError if the request returns a 404 not found response.
71 Raise an IOError if any other problems occur.
85 """72 """
73 logging.debug('sending HTTP GET request to {}'.format(url))
86 try:74 try:
87 response = urllib2.urlopen(url)75 response = urllib2.urlopen(url)
76 except urllib2.HTTPError as err:
77 exception = NotFoundError if err.code == 404 else IOError
78 raise exception(bytes(err))
88 except urllib2.URLError as err:79 except urllib2.URLError as err:
89 raise IOError(err.reason)80 raise IOError(err.reason)
90 except (httplib.HTTPException, socket.error, urllib2.HTTPError) as err:81 except (httplib.HTTPException, socket.error) as err:
91 raise IOError(bytes(err))82 raise IOError(bytes(err))
92 contents = response.read()83 contents = response.read()
93 content_type = response.headers['content-type']84 content_type = response.headers['content-type']
9485
=== modified file 'quickstart/settings.py'
--- quickstart/settings.py 2015-02-25 19:26:02 +0000
+++ quickstart/settings.py 2015-03-10 10:19:09 +0000
@@ -37,8 +37,8 @@
37# temporary connection/charm store errors.37# temporary connection/charm store errors.
38# Keep this list sorted by release date (older first).38# Keep this list sorted by release date (older first).
39DEFAULT_CHARM_URLS = collections.OrderedDict((39DEFAULT_CHARM_URLS = collections.OrderedDict((
40 ('precise', 'cs:precise/juju-gui-106'),40 ('precise', 'cs:precise/juju-gui-108'),
41 ('trusty', 'cs:trusty/juju-gui-18'),41 ('trusty', 'cs:trusty/juju-gui-21'),
42))42))
4343
44# The quickstart app short description.44# The quickstart app short description.
4545
=== modified file 'quickstart/tests/helpers.py'
--- quickstart/tests/helpers.py 2015-02-26 19:46:48 +0000
+++ quickstart/tests/helpers.py 2015-03-10 10:19:09 +0000
@@ -299,17 +299,18 @@
299class UrlReadTestsMixin(object):299class UrlReadTestsMixin(object):
300 """Helpers to mock the quickstart.netutils.urlread helper function."""300 """Helpers to mock the quickstart.netutils.urlread helper function."""
301301
302 def patch_urlread(self, contents=None, error=False):302 def patch_urlread(self, contents=None, error=None):
303 """Patch the quickstart.netutils.urlread helper function.303 """Patch the quickstart.netutils.urlread helper function.
304304
305 If contents is not None, urlread() will return the provided contents.305 If contents is not None, urlread() will return the provided contents.
306 If error is set to True, an IOError will be simulated.306 Otherwise, if error is not None, the given exception or list of side
307 effects will be simulated.
307 """308 """
308 mock_urlread = mock.Mock()309 mock_urlread = mock.Mock()
309 if contents is not None:310 if contents is not None:
310 mock_urlread.return_value = contents311 mock_urlread.return_value = contents
311 if error:312 elif error is not None:
312 mock_urlread.side_effect = IOError('bad wolf')313 mock_urlread.side_effect = error
313 return mock.patch('quickstart.netutils.urlread', mock_urlread)314 return mock.patch('quickstart.netutils.urlread', mock_urlread)
314315
315316
316317
=== modified file 'quickstart/tests/models/test_bundles.py'
--- quickstart/tests/models/test_bundles.py 2015-02-26 19:46:48 +0000
+++ quickstart/tests/models/test_bundles.py 2015-03-10 10:19:09 +0000
@@ -21,9 +21,13 @@
21import json21import json
22import unittest22import unittest
2323
24import mock
24import yaml25import yaml
2526
26from quickstart import settings27from quickstart import (
28 netutils,
29 settings,
30)
27from quickstart.models import (31from quickstart.models import (
28 bundles,32 bundles,
29 references,33 references,
@@ -86,6 +90,10 @@
86 bundle = bundles.from_source('bundle:mediawiki/single')90 bundle = bundles.from_source('bundle:mediawiki/single')
87 self.assertEqual(self.bundle_data, bundle.data)91 self.assertEqual(self.bundle_data, bundle.data)
88 self.assertEqual('cs:bundle/mediawiki-single', bundle.reference.id())92 self.assertEqual('cs:bundle/mediawiki-single', bundle.reference.id())
93 # The charmworld id is properly set when passing charmworld URLs.
94 # XXX frankban 2015-03-09: remove this check once we get rid of the
95 # charmworld id concept.
96 self.assertEqual('mediawiki/single', bundle.reference.charmworld_id)
89 mock_urlread.assert_called_once_with(97 mock_urlread.assert_called_once_with(
90 settings.CHARMSTORE_API +98 settings.CHARMSTORE_API +
91 'bundle/mediawiki-single/archive/bundle.yaml')99 'bundle/mediawiki-single/archive/bundle.yaml')
@@ -98,10 +106,45 @@
98 self.assertEqual(self.bundle_data, bundle.data)106 self.assertEqual(self.bundle_data, bundle.data)
99 self.assertEqual(107 self.assertEqual(
100 'cs:~who/bundle/mediawiki-single-42', bundle.reference.id())108 'cs:~who/bundle/mediawiki-single-42', bundle.reference.id())
109 # The charmworld id is properly set when passing charmworld URLs.
110 # XXX frankban 2015-03-09: remove this check once we get rid of the
111 # charmworld id concept.
112 self.assertEqual(
113 '~who/mediawiki/42/single', bundle.reference.charmworld_id)
101 mock_urlread.assert_called_once_with(114 mock_urlread.assert_called_once_with(
102 settings.CHARMSTORE_API +115 settings.CHARMSTORE_API +
103 '~who/bundle/mediawiki-single-42/archive/bundle.yaml')116 '~who/bundle/mediawiki-single-42/archive/bundle.yaml')
104117
118 def test_charmworld_bundle_from_legacy(self):
119 # A bundle instance is properly returned from a charmworld id source
120 # by looking at the legacy YAML stored in the charm store.
121 side_effect = [
122 # A first call urlread returns a not found error.
123 netutils.NotFoundError('boo!'),
124 # A second call succeeds.
125 self.legacy_bundle_content,
126 ]
127 with self.patch_urlread(error=side_effect) as mock_urlread:
128 bundle = bundles.from_source('bundle:mediawiki/bundle1')
129 self.assertEqual(self.bundle_data, bundle.data)
130 self.assertEqual('cs:bundle/mediawiki', bundle.reference.id())
131 # The charmworld id is properly set when passing charmworld URLs.
132 # XXX frankban 2015-03-09: remove this check once we get rid of the
133 # charmworld id concept.
134 self.assertEqual('mediawiki/bundle1', bundle.reference.charmworld_id)
135 # The urlread function has been called two times: the first time
136 # including both the basket and the bundle name, the second time
137 # to retrieve the legacy bundle yaml, only including the basket.
138 self.assertEqual(mock_urlread.call_count, 2)
139 mock_urlread.assert_has_calls([
140 mock.call(
141 settings.CHARMSTORE_API +
142 'bundle/mediawiki-bundle1/archive/bundle.yaml'),
143 mock.call(
144 settings.CHARMSTORE_API +
145 'bundle/mediawiki/archive/bundles.yaml.orig'),
146 ])
147
105 def test_charmworld_bundle_deprecation_warning(self):148 def test_charmworld_bundle_deprecation_warning(self):
106 # A deprecation warning is printed if the no longer supported149 # A deprecation warning is printed if the no longer supported
107 # charmworld bundle identifiers are used.150 # charmworld bundle identifiers are used.
@@ -119,22 +162,73 @@
119162
120 def test_charmworld_bundle_invalid_content(self):163 def test_charmworld_bundle_invalid_content(self):
121 # A ValueError is raised if the content associated with the given164 # A ValueError is raised if the content associated with the given
122 # charmworld URL are not valid.165 # charmworld URL is not valid.
123 with self.patch_urlread(error=True):166 with self.patch_urlread(contents='exterminate!'):
124 with self.assertRaises(IOError) as ctx:167 with self.assert_value_error('invalid YAML content: exterminate!'):
125 bundles.from_source('bundle:mediawiki/single')168 bundles.from_source('bundle:mediawiki/single')
126 expected_error = (
127 'cannot retrieve bundle from remote URL '
128 '{}bundle/mediawiki-single/archive/bundle.yaml: '
129 'bad wolf'.format(settings.CHARMSTORE_API))
130 self.assertEqual(expected_error, bytes(ctx.exception))
131169
132 def test_charmworld_bundle_connection_error(self):170 def test_charmworld_bundle_connection_error(self):
133 # An IOError is raised if a connection problem is encountered while171 # An IOError is raised if a connection problem is encountered while
134 # retrieving the charmworld bundle.172 # retrieving the charmworld bundle.
135 with self.patch_urlread(contents='exterminate!'):173 with self.patch_urlread(error=IOError('bad wolf')):
136 with self.assert_value_error('invalid YAML content: exterminate!'):174 with self.assertRaises(IOError) as ctx:
137 bundles.from_source('bundle:mediawiki/single')175 bundles.from_source('bundle:mediawiki/single')
176 expected_error = (
177 'cannot communicate with the charm store at '
178 '{}bundle/mediawiki-single/archive/bundle.yaml: '
179 'bad wolf'.format(settings.CHARMSTORE_API))
180 self.assertEqual(expected_error, bytes(ctx.exception))
181
182 def test_charmworld_bundle_not_found_error(self):
183 # An IOError is raised if a charmworld bundle is not found.
184 error = netutils.NotFoundError('bad wolf')
185 with self.patch_urlread(error=error) as mock_urlread:
186 with self.assertRaises(IOError) as ctx:
187 bundles.from_source('bundle:mediawiki/single')
188 expected_error = (
189 'charm store resource not found at '
190 '{}bundle/mediawiki/archive/bundles.yaml.orig: '
191 'bad wolf'.format(settings.CHARMSTORE_API))
192 self.assertEqual(expected_error, bytes(ctx.exception))
193 # The urlread function has been called two times: the first time
194 # including both the basket and the bundle name, the second time
195 # to retrieve the legacy bundle yaml, only including the basket.
196 self.assertEqual(mock_urlread.call_count, 2)
197 mock_urlread.assert_has_calls([
198 mock.call(
199 settings.CHARMSTORE_API +
200 'bundle/mediawiki-single/archive/bundle.yaml'),
201 mock.call(
202 settings.CHARMSTORE_API +
203 'bundle/mediawiki/archive/bundles.yaml.orig'),
204 ])
205
206 def test_charmworld_bundle_from_legacy_invalid_name(self):
207 # A ValueError is raised if the given bundle name is not found in
208 # the legacy basket.
209 legacy_bundle_content = yaml.safe_dump({'scalable': self.bundle_data})
210 side_effect = [
211 # A first call urlread returns a not found error.
212 netutils.NotFoundError('boo!'),
213 # A second call succeeds.
214 legacy_bundle_content,
215 ]
216 expected_error = 'bundle single not found, did you mean scalable?'
217 with self.patch_urlread(error=side_effect) as mock_urlread:
218 with self.assert_value_error(expected_error):
219 bundles.from_source('bundle:django/single')
220 # The urlread function has been called two times: the first time
221 # including both the basket and the bundle name, the second time
222 # to retrieve the legacy bundle yaml, only including the basket.
223 self.assertEqual(mock_urlread.call_count, 2)
224 mock_urlread.assert_has_calls([
225 mock.call(
226 settings.CHARMSTORE_API +
227 'bundle/django-single/archive/bundle.yaml'),
228 mock.call(
229 settings.CHARMSTORE_API +
230 'bundle/django/archive/bundles.yaml.orig'),
231 ])
138232
139 def test_jujucharms_bundle(self):233 def test_jujucharms_bundle(self):
140 # A bundle instance is properly returned from a jujucharms.com id.234 # A bundle instance is properly returned from a jujucharms.com id.
@@ -173,21 +267,33 @@
173 def test_jujucharms_bundle_invalid_content(self):267 def test_jujucharms_bundle_invalid_content(self):
174 # A ValueError is raised if the content associated with the given268 # A ValueError is raised if the content associated with the given
175 # jujucharms.com URL are not valid.269 # jujucharms.com URL are not valid.
176 with self.patch_urlread(error=True):270 with self.patch_urlread(contents='exterminate!'):
177 with self.assertRaises(IOError) as ctx:271 with self.assert_value_error('invalid YAML content: exterminate!'):
178 bundles.from_source('django/42')272 bundles.from_source('wordpress-scalable')
179 expected_error = (
180 'cannot retrieve bundle from remote URL '
181 '{}bundle/django-42/archive/bundle.yaml: '
182 'bad wolf'.format(settings.CHARMSTORE_API))
183 self.assertEqual(expected_error, bytes(ctx.exception))
184273
185 def test_jujucharms_bundle_connection_error(self):274 def test_jujucharms_bundle_connection_error(self):
186 # An IOError is raised if a connection problem is encountered while275 # An IOError is raised if a connection problem is encountered while
187 # retrieving the jujucharms.com bundle.276 # retrieving the jujucharms.com bundle.
188 with self.patch_urlread(contents='exterminate!'):277 with self.patch_urlread(error=IOError('bad wolf')):
189 with self.assert_value_error('invalid YAML content: exterminate!'):278 with self.assertRaises(IOError) as ctx:
190 bundles.from_source('wordpress-scalable')279 bundles.from_source('django/42')
280 expected_error = (
281 'cannot communicate with the charm store at '
282 '{}bundle/django-42/archive/bundle.yaml: '
283 'bad wolf'.format(settings.CHARMSTORE_API))
284 self.assertEqual(expected_error, bytes(ctx.exception))
285
286 def test_jujucharms_bundle_not_found_error(self):
287 # An IOError is raised if a connection problem is encountered while
288 # retrieving the jujucharms.com bundle.
289 with self.patch_urlread(error=netutils.NotFoundError('bad wolf')):
290 with self.assertRaises(IOError) as ctx:
291 bundles.from_source('django/42')
292 expected_error = (
293 'charm store resource not found at '
294 '{}bundle/django-42/archive/bundle.yaml: '
295 'bad wolf'.format(settings.CHARMSTORE_API))
296 self.assertEqual(expected_error, bytes(ctx.exception))
191297
192 def test_local_file(self):298 def test_local_file(self):
193 # A bundle instance can be created from a local file source.299 # A bundle instance can be created from a local file source.
@@ -248,11 +354,20 @@
248 with self.assert_value_error(expected_error):354 with self.assert_value_error(expected_error):
249 bundles.from_source(path, 'mybundle')355 bundles.from_source(path, 'mybundle')
250356
357 def test_local_file_legacy_bundle_single_bundle_name_not_found(self):
358 # A ValueError is raised if a local file contains a legacy version 3
359 # YAML content with one bundle defined and the provided bundle name
360 # does not match.
361 path = self.make_bundle_file({'scalable': self.bundle_data})
362 expected_error = 'bundle mybundle not found, did you mean scalable?'
363 with self.assert_value_error(expected_error):
364 bundles.from_source(path, 'mybundle')
365
251 def test_local_file_legacy_bundle_invalid_bundle_content(self):366 def test_local_file_legacy_bundle_invalid_bundle_content(self):
252 # A ValueError is raised if a local file contains an invalid legacy367 # A ValueError is raised if a local file contains an invalid legacy
253 # version 3 content.368 # version 3 content.
254 path = self.make_bundle_file({'bundle': '42'})369 path = self.make_bundle_file({'bundle': '42'})
255 expected_error = "unable to retrieve bundle services: '42'"370 expected_error = "invalid YAML content: 42"
256 with self.assert_value_error(expected_error):371 with self.assert_value_error(expected_error):
257 bundles.from_source(path, 'bundle')372 bundles.from_source(path, 'bundle')
258373
@@ -280,7 +395,7 @@
280 def test_remote_url_not_reachable(self):395 def test_remote_url_not_reachable(self):
281 # An IOError is raised if a network problem is encountered while396 # An IOError is raised if a network problem is encountered while
282 # trying to reach the remote URL.397 # trying to reach the remote URL.
283 with self.patch_urlread(error=True):398 with self.patch_urlread(error=IOError('bad wolf')):
284 with self.assertRaises(IOError) as ctx:399 with self.assertRaises(IOError) as ctx:
285 bundles.from_source('https://1.2.3.4')400 bundles.from_source('https://1.2.3.4')
286 expected_error = (401 expected_error = (
@@ -317,6 +432,16 @@
317 with self.assert_value_error(expected_error):432 with self.assert_value_error(expected_error):
318 bundles.from_source('http://1.2.3.4', 'no-such')433 bundles.from_source('http://1.2.3.4', 'no-such')
319434
435 def test_remote_url_legacy_bundle_single_bundle_name_not_found(self):
436 # A ValueError is raised if a remote URL contains a legacy version 3
437 # YAML content with one bundle defined and the provided bundle name
438 # does not match.
439 legacy_bundle_content = yaml.safe_dump({'scalable': self.bundle_data})
440 expected_error = 'bundle no-such not found, did you mean scalable?'
441 with self.patch_urlread(contents=legacy_bundle_content):
442 with self.assert_value_error(expected_error):
443 bundles.from_source('http://1.2.3.4', 'no-such')
444
320 def test_remote_url_legacy_bundle_invalid_bundle_content(self):445 def test_remote_url_legacy_bundle_invalid_bundle_content(self):
321 # A ValueError is raised if a remote URL contains an invalid legacy446 # A ValueError is raised if a remote URL contains an invalid legacy
322 # version 3 content.447 # version 3 content.
@@ -399,6 +524,22 @@
399 self.assertEqual(self.bundle_data, data)524 self.assertEqual(self.bundle_data, data)
400525
401526
527class TestIsLegacyBundle(helpers.BundleFileTestsMixin, unittest.TestCase):
528
529 def test_v4_bundle(self):
530 # False is returned for new-style version 4 bundles.
531 self.assertFalse(bundles.is_legacy_bundle(self.bundle_data))
532
533 def test_legacy_bundle(self):
534 # True is returned for legacy bundles.
535 tests = (
536 self.legacy_bundle_data,
537 {'services': {'services': {}}},
538 )
539 for data in tests:
540 self.assertTrue(bundles.is_legacy_bundle(data))
541
542
402class TestValidate(543class TestValidate(
403 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,544 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
404 unittest.TestCase):545 unittest.TestCase):
405546
=== modified file 'quickstart/tests/models/test_references.py'
--- quickstart/tests/models/test_references.py 2015-02-26 19:46:48 +0000
+++ quickstart/tests/models/test_references.py 2015-03-10 10:19:09 +0000
@@ -318,43 +318,6 @@
318 self.assertEqual(expected_ref, ref)318 self.assertEqual(expected_ref, ref)
319319
320320
321class TestReferenceFromCharmworldUrl(
322 helpers.ValueErrorTestsMixin, unittest.TestCase):
323
324 def test_invalid_form(self):
325 # A ValueError is raised if the URL is not valid.
326 expected_error = 'invalid bundle URL: bad wolf'
327 with self.assert_value_error(expected_error):
328 references.Reference.from_charmworld_url('bad wolf')
329
330 def test_success(self):
331 # A reference is correctly created from a charmworld identifier.
332 tests = (
333 ('bundle:~myuser/wordpress/42/single',
334 make_reference(series='bundle', name='wordpress-single')),
335 ('bundle:~myuser/wordpress/single',
336 make_reference(series='bundle', name='wordpress-single',
337 revision=None)),
338 ('bundle:wordpress/42/single',
339 make_reference(user='', series='bundle',
340 name='wordpress-single')),
341 ('bundle:wordpress/single',
342 make_reference(user='', series='bundle', name='wordpress-single',
343 revision=None)),
344 )
345 for url, expected_ref in tests:
346 ref = references.Reference.from_charmworld_url(url)
347 self.assertEqual(expected_ref, ref)
348
349 def test_charmworld_id(self):
350 # The charmworld id is properly set when parsing charmworld URLs.
351 # XXX frankban 2015-02-26: remove this test once we get rid of the
352 # charmworld id concept.
353 ref = references.Reference.from_charmworld_url(
354 'bundle:wordpress/single')
355 self.assertEqual('wordpress/single', ref.charmworld_id)
356
357
358class TestReferenceFromJujucharmsUrl(321class TestReferenceFromJujucharmsUrl(
359 helpers.ValueErrorTestsMixin, unittest.TestCase):322 helpers.ValueErrorTestsMixin, unittest.TestCase):
360323
361324
=== modified file 'quickstart/tests/test_app.py'
--- quickstart/tests/test_app.py 2015-02-26 19:46:48 +0000
+++ quickstart/tests/test_app.py 2015-03-10 10:19:09 +0000
@@ -930,12 +930,11 @@
930 env.get_status.side_effect = side_effect930 env.get_status.side_effect = side_effect
931 return env931 return env
932932
933 def patch_get_charm_url(self, return_value=None, side_effect=None):933 def patch_resolve(self, return_value=None, side_effect=None):
934 """Patch the get_charm_url helper function."""934 """Patch the charmstore.resolve function."""
935 mock_get_charm_url = mock.Mock(935 mock_resolve = mock.Mock(
936 return_value=return_value, side_effect=side_effect)936 return_value=return_value, side_effect=side_effect)
937 return mock.patch(937 return mock.patch('quickstart.charmstore.resolve', mock_resolve)
938 'quickstart.netutils.get_charm_url', mock_get_charm_url)
939938
940 def assert_reference_equal(self, expected_url, ref):939 def assert_reference_equal(self, expected_url, ref):
941 """Ensure the given reference points to the expected URL."""940 """Ensure the given reference points to the expected URL."""
@@ -954,8 +953,8 @@
954 env_type = 'ec2'953 env_type = 'ec2'
955 bootstrap_node_series = 'trusty'954 bootstrap_node_series = 'trusty'
956 check_preexisting = False955 check_preexisting = False
957 with self.patch_get_charm_url(956 with self.patch_resolve(
958 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:957 return_value='cs:trusty/juju-gui-42') as mock_resolve:
959 ref, machine, service_data, unit_data = app.check_environment(958 ref, machine, service_data, unit_data = app.check_environment(
960 env, 'my-gui', charm_url, env_type, bootstrap_node_series,959 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
961 check_preexisting)960 check_preexisting)
@@ -964,7 +963,8 @@
964 # The charm URL has been retrieved from the charm store API based on963 # The charm URL has been retrieved from the charm store API based on
965 # the current bootstrap node series.964 # the current bootstrap node series.
966 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)965 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
967 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)966 mock_resolve.assert_called_once_with(
967 settings.JUJU_GUI_CHARM_NAME, series=bootstrap_node_series)
968 # Since the bootstrap node series is supported by the GUI charm, the968 # Since the bootstrap node series is supported by the GUI charm, the
969 # GUI unit can be deployed to machine 0.969 # GUI unit can be deployed to machine 0.
970 self.assertEqual('0', machine)970 self.assertEqual('0', machine)
@@ -989,8 +989,8 @@
989 env_type = 'ec2'989 env_type = 'ec2'
990 bootstrap_node_series = 'precise'990 bootstrap_node_series = 'precise'
991 check_preexisting = True991 check_preexisting = True
992 with self.patch_get_charm_url(992 with self.patch_resolve(
993 return_value='cs:precise/juju-gui-42') as mock_get_charm_url:993 return_value='cs:precise/juju-gui-42') as mock_resolve:
994 ref, machine, service_data, unit_data = app.check_environment(994 ref, machine, service_data, unit_data = app.check_environment(
995 env, 'my-gui', charm_url, env_type, bootstrap_node_series,995 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
996 check_preexisting)996 check_preexisting)
@@ -999,7 +999,8 @@
999 # The charm URL has been retrieved from the charm store API based on999 # The charm URL has been retrieved from the charm store API based on
1000 # the current bootstrap node series.1000 # the current bootstrap node series.
1001 self.assert_reference_equal('cs:precise/juju-gui-42', ref)1001 self.assert_reference_equal('cs:precise/juju-gui-42', ref)
1002 mock_get_charm_url.assert_called_once_with(bootstrap_node_series)1002 mock_resolve.assert_called_once_with(
1003 settings.JUJU_GUI_CHARM_NAME, series=bootstrap_node_series)
1003 # Since the bootstrap node series is supported by the GUI charm, the1004 # Since the bootstrap node series is supported by the GUI charm, the
1004 # GUI unit can be deployed to machine 0.1005 # GUI unit can be deployed to machine 0.
1005 self.assertEqual('0', machine)1006 self.assertEqual('0', machine)
@@ -1022,7 +1023,7 @@
1022 env_type = 'ec2'1023 env_type = 'ec2'
1023 bootstrap_node_series = 'precise'1024 bootstrap_node_series = 'precise'
1024 check_preexisting = True1025 check_preexisting = True
1025 with self.patch_get_charm_url() as mock_get_charm_url:1026 with self.patch_resolve() as mock_resolve:
1026 ref, machine, service_data, unit_data = app.check_environment(1027 ref, machine, service_data, unit_data = app.check_environment(
1027 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1028 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1028 check_preexisting)1029 check_preexisting)
@@ -1030,7 +1031,7 @@
1030 env.get_status.assert_called_once_with()1031 env.get_status.assert_called_once_with()
1031 # The charm URL has been retrieved from the environment.1032 # The charm URL has been retrieved from the environment.
1032 self.assert_reference_equal('cs:precise/juju-gui-47', ref)1033 self.assert_reference_equal('cs:precise/juju-gui-47', ref)
1033 self.assertFalse(mock_get_charm_url.called)1034 self.assertFalse(mock_resolve.called)
1034 # Since the bootstrap node series is supported by the GUI charm, the1035 # Since the bootstrap node series is supported by the GUI charm, the
1035 # GUI unit can be safely deployed to machine 0.1036 # GUI unit can be safely deployed to machine 0.
1036 self.assertEqual('0', machine)1037 self.assertEqual('0', machine)
@@ -1046,15 +1047,16 @@
1046 env_type = 'ec2'1047 env_type = 'ec2'
1047 bootstrap_node_series = 'saucy'1048 bootstrap_node_series = 'saucy'
1048 check_preexisting = False1049 check_preexisting = False
1049 with self.patch_get_charm_url(1050 with self.patch_resolve(
1050 return_value='cs:trusty/juju-gui-42') as mock_get_charm_url:1051 return_value='cs:trusty/juju-gui-42') as mock_resolve:
1051 ref, machine, service_data, unit_data = app.check_environment(1052 ref, machine, service_data, unit_data = app.check_environment(
1052 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1053 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1053 check_preexisting)1054 check_preexisting)
1054 # The charm URL has been retrieved from the charm store API using the1055 # The charm URL has been retrieved from the charm store API using the
1055 # most recent supported series.1056 # most recent supported series.
1056 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)1057 self.assert_reference_equal('cs:trusty/juju-gui-42', ref)
1057 mock_get_charm_url.assert_called_once_with('trusty')1058 mock_resolve.assert_called_once_with(
1059 settings.JUJU_GUI_CHARM_NAME, series='trusty')
1058 # The Juju GUI unit cannot be deployed to saucy machine 0.1060 # The Juju GUI unit cannot be deployed to saucy machine 0.
1059 self.assertIsNone(machine)1061 self.assertIsNone(machine)
1060 # Ensure the function output makes sense.1062 # Ensure the function output makes sense.
@@ -1072,7 +1074,7 @@
1072 env_type = 'local'1074 env_type = 'local'
1073 bootstrap_node_series = 'trusty'1075 bootstrap_node_series = 'trusty'
1074 check_preexisting = False1076 check_preexisting = False
1075 with self.patch_get_charm_url(return_value='cs:trusty/juju-gui-42'):1077 with self.patch_resolve(return_value='cs:trusty/juju-gui-42'):
1076 ref, machine, service_data, unit_data = app.check_environment(1078 ref, machine, service_data, unit_data = app.check_environment(
1077 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1079 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1078 check_preexisting)1080 check_preexisting)
@@ -1089,7 +1091,7 @@
1089 env_type = 'azure'1091 env_type = 'azure'
1090 bootstrap_node_series = 'trusty'1092 bootstrap_node_series = 'trusty'
1091 check_preexisting = False1093 check_preexisting = False
1092 with self.patch_get_charm_url(return_value='cs:trusty/juju-gui-42'):1094 with self.patch_resolve(return_value='cs:trusty/juju-gui-42'):
1093 _, machine, _, _ = app.check_environment(1095 _, machine, _, _ = app.check_environment(
1094 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1096 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1095 check_preexisting)1097 check_preexisting)
@@ -1103,7 +1105,7 @@
1103 env_type = 'ec2'1105 env_type = 'ec2'
1104 bootstrap_node_series = 'precise'1106 bootstrap_node_series = 'precise'
1105 check_preexisting = False1107 check_preexisting = False
1106 with self.patch_get_charm_url(side_effect=IOError('boo!')):1108 with self.patch_resolve(side_effect=IOError('boo!')):
1107 ref, machine, service_data, unit_data = app.check_environment(1109 ref, machine, service_data, unit_data = app.check_environment(
1108 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1110 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1109 check_preexisting)1111 check_preexisting)
@@ -1121,7 +1123,7 @@
1121 env_type = 'ec2'1123 env_type = 'ec2'
1122 bootstrap_node_series = 'saucy'1124 bootstrap_node_series = 'saucy'
1123 check_preexisting = False1125 check_preexisting = False
1124 with self.patch_get_charm_url(side_effect=IOError('boo!')):1126 with self.patch_resolve(side_effect=IOError('boo!')):
1125 ref, machine, service_data, unit_data = app.check_environment(1127 ref, machine, service_data, unit_data = app.check_environment(
1126 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1128 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1127 check_preexisting)1129 check_preexisting)
@@ -1138,13 +1140,13 @@
1138 env_type = 'ec2'1140 env_type = 'ec2'
1139 bootstrap_node_series = 'trusty'1141 bootstrap_node_series = 'trusty'
1140 check_preexisting = False1142 check_preexisting = False
1141 with self.patch_get_charm_url() as mock_get_charm_url:1143 with self.patch_resolve() as mock_resolve:
1142 ref, machine, service_data, unit_data = app.check_environment(1144 ref, machine, service_data, unit_data = app.check_environment(
1143 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1145 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1144 check_preexisting)1146 check_preexisting)
1145 # There is no need to call the charmword API if the charm URL is1147 # There is no need to call the charm store API if the charm URL is
1146 # provided by the user.1148 # provided by the user.
1147 self.assertFalse(mock_get_charm_url.called)1149 self.assertFalse(mock_resolve.called)
1148 # The provided charm URL has been correctly returned.1150 # The provided charm URL has been correctly returned.
1149 self.assert_reference_equal(charm_url, ref)1151 self.assert_reference_equal(charm_url, ref)
1150 # Since the provided charm series is trusty, the charm itself can be1152 # Since the provided charm series is trusty, the charm itself can be
@@ -1165,13 +1167,13 @@
1165 env_type = 'ec2'1167 env_type = 'ec2'
1166 bootstrap_node_series = 'precise'1168 bootstrap_node_series = 'precise'
1167 check_preexisting = False1169 check_preexisting = False
1168 with self.patch_get_charm_url() as mock_get_charm_url:1170 with self.patch_resolve() as mock_resolve:
1169 ref, machine, service_data, unit_data = app.check_environment(1171 ref, machine, service_data, unit_data = app.check_environment(
1170 env, 'my-gui', charm_url, env_type, bootstrap_node_series,1172 env, 'my-gui', charm_url, env_type, bootstrap_node_series,
1171 check_preexisting)1173 check_preexisting)
1172 # There is no need to call the charmword API if the charm URL is1174 # There is no need to call the charm store API if the charm URL is
1173 # provided by the user.1175 # provided by the user.
1174 self.assertFalse(mock_get_charm_url.called)1176 self.assertFalse(mock_resolve.called)
1175 # The provided charm URL has been correctly returned.1177 # The provided charm URL has been correctly returned.
1176 self.assert_reference_equal(charm_url, ref)1178 self.assert_reference_equal(charm_url, ref)
1177 # Since the provided charm series is not precise, the charm must be1179 # Since the provided charm series is not precise, the charm must be
@@ -1666,7 +1668,9 @@
1666 # XXX frankban 2015-02-26: remove this test once we get rid of the1668 # XXX frankban 2015-02-26: remove this test once we get rid of the
1667 # charmworld id concept.1669 # charmworld id concept.
1668 env = mock.Mock()1670 env = mock.Mock()
1669 ref = references.Reference.from_charmworld_url('bundle:django/single')1671 ref = references.Reference.from_fully_qualified_url(
1672 'cs:bundle/django-single-42')
1673 ref.charmworld_id = 'django/single'
1670 bundle = bundles.Bundle(self.bundle_data, reference=ref)1674 bundle = bundles.Bundle(self.bundle_data, reference=ref)
1671 app.deploy_bundle(env, bundle)1675 app.deploy_bundle(env, bundle)
1672 env.deploy_bundle.assert_called_once_with(1676 env.deploy_bundle.assert_called_once_with(
16731677
=== added file 'quickstart/tests/test_charmstore.py'
--- quickstart/tests/test_charmstore.py 1970-01-01 00:00:00 +0000
+++ quickstart/tests/test_charmstore.py 2015-03-10 10:19:09 +0000
@@ -0,0 +1,313 @@
1# This file is part of the Juju Quickstart Plugin, which lets users set up a
2# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
3# Copyright (C) 2015 Canonical Ltd.
4#
5# This program is free software: you can redistribute it and/or modify it under
6# the terms of the GNU Affero General Public License version 3, as published by
7# the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
11# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12# Affero General Public License for more details.
13#
14# You should have received a copy of the GNU Affero General Public License
15# along with this program. If not, see <http://www.gnu.org/licenses/>.
16
17"""Tests for the Juju Quickstart charm store communication utilities."""
18
19from __future__ import unicode_literals
20
21import unittest
22
23import json
24
25from quickstart import (
26 charmstore,
27 netutils,
28 settings,
29)
30from quickstart.models import references
31from quickstart.tests import helpers
32
33
34class TestNotFoundError(unittest.TestCase):
35
36 def test_error_message(self):
37 # The error message can be properly retrieved.
38 err = charmstore.NotFoundError(b'bad wolf')
39 self.assertEqual('bad wolf', bytes(err))
40
41
42class TestGet(helpers.UrlReadTestsMixin, unittest.TestCase):
43
44 def test_success(self):
45 # A GET request to the charm store is correctly performed.
46 with self.patch_urlread(contents='ok') as mock_urlread:
47 content = charmstore.get('my/path')
48 self.assertEqual('ok', content)
49 mock_urlread.assert_called_once_with(
50 settings.CHARMSTORE_API + 'my/path')
51
52 def test_success_leading_slash(self):
53 # The resulting URL is correctly formatted.
54 with self.patch_urlread() as mock_urlread:
55 charmstore.get('/path/')
56 mock_urlread.assert_called_once_with(settings.CHARMSTORE_API + 'path/')
57
58 def test_io_error(self):
59 # An IOError is raised if a problem is encountered while connecting to
60 # the charm store.
61 with self.patch_urlread(error=IOError('bad wolf')):
62 with self.assertRaises(IOError) as ctx:
63 charmstore.get('/')
64 expected_error = (
65 'cannot communicate with the charm store at '
66 '{}: bad wolf'.format(settings.CHARMSTORE_API))
67 self.assertEqual(expected_error, bytes(ctx.exception))
68
69 def test_not_found_error(self):
70 # A charmstore.NotFoundError is raised if the request returns a 404 not
71 # found response.
72 with self.patch_urlread(error=netutils.NotFoundError('bad wolf')):
73 with self.assertRaises(charmstore.NotFoundError) as ctx:
74 charmstore.get('/no/such')
75 expected_error = (
76 'charm store resource not found at '
77 '{}no/such: bad wolf'.format(settings.CHARMSTORE_API))
78 self.assertEqual(expected_error, bytes(ctx.exception))
79
80
81class TestGetReference(helpers.UrlReadTestsMixin, unittest.TestCase):
82
83 def test_success(self):
84 # A GET request to the charm store is correctly performed using the
85 # given charm or bundle reference.
86 ref = references.Reference.from_jujucharms_url('mediawiki-single')
87 with self.patch_urlread(contents='hash') as mock_urlread:
88 content = charmstore.get_reference(ref, '/meta/hash')
89 self.assertEqual('hash', content)
90 mock_urlread.assert_called_once_with(
91 settings.CHARMSTORE_API + 'bundle/mediawiki-single/meta/hash')
92
93 def test_success_without_leading_slash(self):
94 # The resulting URL is correctly formatted when the static path does
95 # not include a leading slash.
96 ref = references.Reference.from_jujucharms_url('django/trusty/42')
97 with self.patch_urlread() as mock_urlread:
98 charmstore.get_reference(ref, 'expand-id')
99 mock_urlread.assert_called_once_with(
100 settings.CHARMSTORE_API + 'trusty/django-42/expand-id')
101
102 def test_io_error(self):
103 # An IOError is raised if a problem is encountered while connecting to
104 # the charm store.
105 ref = references.Reference.from_jujucharms_url('django/trusty')
106 with self.patch_urlread(error=IOError('bad wolf')):
107 with self.assertRaises(IOError) as ctx:
108 charmstore.get_reference(ref, 'meta/id')
109 expected_error = (
110 'cannot communicate with the charm store at '
111 '{}trusty/django/meta/id: '
112 'bad wolf'.format(settings.CHARMSTORE_API))
113 self.assertEqual(expected_error, bytes(ctx.exception))
114
115 def test_not_found_error(self):
116 # A charmstore.NotFoundError is raised if the reference is not found
117 # in the charm store.
118 ref = references.Reference.from_jujucharms_url('django')
119 with self.patch_urlread(error=netutils.NotFoundError('bad wolf')):
120 with self.assertRaises(charmstore.NotFoundError) as ctx:
121 charmstore.get_reference(ref, '/no/such')
122 expected_error = (
123 'charm store resource not found at '
124 '{}bundle/django/no/such: '
125 'bad wolf'.format(settings.CHARMSTORE_API))
126 self.assertEqual(expected_error, bytes(ctx.exception))
127
128
129class TestResolve(helpers.UrlReadTestsMixin, unittest.TestCase):
130
131 contents = json.dumps({
132 'Id': 'cs:trusty/juju-gui-42',
133 'Series': 'trusty',
134 'Name': 'juju-gui',
135 'Revision': 42,
136 })
137
138 def test_resolved(self):
139 # The resolved entity id is correctly returned.
140 with self.patch_urlread(contents=self.contents) as mock_urlread:
141 entity_id = charmstore.resolve('juju-gui')
142 self.assertEqual('cs:trusty/juju-gui-42', entity_id)
143 mock_urlread.assert_called_once_with(
144 settings.CHARMSTORE_API + 'juju-gui/meta/id')
145
146 def test_resolved_with_series(self):
147 # The resolved entity id is correctly returned when the entity series
148 # is specified.
149 with self.patch_urlread(contents=self.contents) as mock_urlread:
150 entity_id = charmstore.resolve('django', series='vivid')
151 self.assertEqual('cs:trusty/juju-gui-42', entity_id)
152 mock_urlread.assert_called_once_with(
153 settings.CHARMSTORE_API + 'vivid/django/meta/id')
154
155 def test_io_error(self):
156 # IOErrors are properly propagated.
157 with self.patch_urlread(error=IOError('bad wolf')):
158 with self.assertRaises(IOError) as ctx:
159 charmstore.resolve('django')
160 expected_error = (
161 'cannot communicate with the charm store at '
162 '{}django/meta/id: bad wolf'.format(settings.CHARMSTORE_API))
163 self.assertEqual(expected_error, bytes(ctx.exception))
164
165 def test_not_found_error(self):
166 # Not found errors are properly propagated.
167 with self.patch_urlread(error=netutils.NotFoundError('bad wolf')):
168 with self.assertRaises(charmstore.NotFoundError) as ctx:
169 charmstore.resolve('django', series='trusty')
170 expected_error = (
171 'charm store resource not found at '
172 '{}trusty/django/meta/id: '
173 'bad wolf'.format(settings.CHARMSTORE_API))
174 self.assertEqual(expected_error, bytes(ctx.exception))
175
176 def test_value_error(self):
177 # A ValueError is raised if the API response is not valid.
178 contents = json.dumps({'invalid': {}})
179 with self.patch_urlread(contents=contents):
180 with self.assertRaises(ValueError) as ctx:
181 charmstore.resolve('juju-gui', series='trusty')
182 self.assertEqual(
183 'unable to resolve entity id trusty/juju-gui',
184 bytes(ctx.exception))
185
186
187class TestGetBundleData(
188 helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin,
189 unittest.TestCase):
190
191 def test_data_retrieved(self):
192 # The bundle data is correctly retrieved and parsed.
193 ref = references.Reference.from_jujucharms_url('django/42')
194 with self.patch_urlread(contents=self.bundle_content) as mock_urlread:
195 data = charmstore.get_bundle_data(ref)
196 self.assertEqual(self.bundle_data, data)
197 mock_urlread.assert_called_once_with(
198 settings.CHARMSTORE_API + 'bundle/django-42/archive/bundle.yaml')
199
200 def test_io_error(self):
201 # IOErrors are properly propagated.
202 ref = references.Reference.from_jujucharms_url('mediawiki-single')
203 with self.patch_urlread(error=IOError('bad wolf')):
204 with self.assertRaises(IOError) as ctx:
205 charmstore.get_bundle_data(ref)
206 expected_error = (
207 'cannot communicate with the charm store at '
208 '{}bundle/mediawiki-single/archive/bundle.yaml: '
209 'bad wolf'.format(settings.CHARMSTORE_API))
210 self.assertEqual(expected_error, bytes(ctx.exception))
211
212 def test_not_found_error(self):
213 # Not found errors are properly propagated.
214 ref = references.Reference.from_jujucharms_url('no-such')
215 with self.patch_urlread(error=netutils.NotFoundError('bad wolf')):
216 with self.assertRaises(charmstore.NotFoundError) as ctx:
217 charmstore.get_bundle_data(ref)
218 expected_error = (
219 'charm store resource not found at '
220 '{}bundle/no-such/archive/bundle.yaml: '
221 'bad wolf'.format(settings.CHARMSTORE_API))
222 self.assertEqual(expected_error, bytes(ctx.exception))
223
224 def test_value_error_invalid_data(self):
225 # A ValueError is raised if the API response is not valid.
226 ref = references.Reference.from_jujucharms_url('u/who/django')
227 with self.patch_urlread(contents='invalid: data:'):
228 with self.assertRaises(ValueError) as ctx:
229 charmstore.get_bundle_data(ref)
230 self.assertIn(
231 'unable to parse the bundle content', bytes(ctx.exception))
232
233 def test_value_error_not_a_bundle(self):
234 # A ValueError is raised if the API response is not valid.
235 ref = references.Reference.from_jujucharms_url('django/trusty/47')
236 with self.assertRaises(ValueError) as ctx:
237 charmstore.get_bundle_data(ref)
238 self.assertEqual(
239 'expected a bundle, provided charm cs:trusty/django-47',
240 bytes(ctx.exception))
241
242
243class TestGetLegacyBundleData(
244 helpers.BundleFileTestsMixin, helpers.UrlReadTestsMixin,
245 unittest.TestCase):
246
247 def test_data_retrieved(self):
248 # The legacy bundle data is correctly retrieved and parsed.
249 ref = references.Reference.from_jujucharms_url('u/who/django/42')
250 contents = self.legacy_bundle_content
251 with self.patch_urlread(contents=contents) as mock_urlread:
252 data = charmstore.get_legacy_bundle_data(ref)
253 self.assertEqual(self.legacy_bundle_data, data)
254 mock_urlread.assert_called_once_with(
255 settings.CHARMSTORE_API +
256 '~who/bundle/django-42/archive/bundles.yaml.orig')
257
258 def test_io_error(self):
259 # IOErrors are properly propagated.
260 ref = references.Reference.from_jujucharms_url('mediawiki-single')
261 with self.patch_urlread(error=IOError('bad wolf')):
262 with self.assertRaises(IOError) as ctx:
263 charmstore.get_legacy_bundle_data(ref)
264 expected_error = (
265 'cannot communicate with the charm store at '
266 '{}bundle/mediawiki-single/archive/bundles.yaml.orig: '
267 'bad wolf'.format(settings.CHARMSTORE_API))
268 self.assertEqual(expected_error, bytes(ctx.exception))
269
270 def test_not_found_error(self):
271 # Not found errors are properly propagated.
272 ref = references.Reference.from_jujucharms_url('no-such')
273 with self.patch_urlread(error=netutils.NotFoundError('bad wolf')):
274 with self.assertRaises(charmstore.NotFoundError) as ctx:
275 charmstore.get_legacy_bundle_data(ref)
276 expected_error = (
277 'charm store resource not found at '
278 '{}bundle/no-such/archive/bundles.yaml.orig: '
279 'bad wolf'.format(settings.CHARMSTORE_API))
280 self.assertEqual(expected_error, bytes(ctx.exception))
281
282 def test_value_error_invalid_data(self):
283 # A ValueError is raised if the API response is not valid.
284 ref = references.Reference.from_jujucharms_url('u/who/django')
285 with self.patch_urlread(contents='invalid: data:'):
286 with self.assertRaises(ValueError) as ctx:
287 charmstore.get_legacy_bundle_data(ref)
288 self.assertIn(
289 'unable to parse the bundle content', bytes(ctx.exception))
290
291 def test_value_error_not_a_bundle(self):
292 # A ValueError is raised if the API response is not valid.
293 ref = references.Reference.from_jujucharms_url('django/trusty/47')
294 with self.assertRaises(ValueError) as ctx:
295 charmstore.get_legacy_bundle_data(ref)
296 self.assertEqual(
297 'expected a bundle, provided charm cs:trusty/django-47',
298 bytes(ctx.exception))
299
300
301class TestLoadBundleYaml(helpers.BundleFileTestsMixin, unittest.TestCase):
302
303 def test_valid_bundle_content(self):
304 # The bundle content is correctly loaded.
305 data = charmstore.load_bundle_yaml(self.bundle_content)
306 self.assertEqual(self.bundle_data, data)
307
308 def test_invalid_bundle_content(self):
309 # A ValueError is raised if the bundle content is not valid.
310 with self.assertRaises(ValueError) as ctx:
311 charmstore.load_bundle_yaml('invalid: content:')
312 self.assertIn(
313 'unable to parse the bundle content', bytes(ctx.exception))
0314
=== modified file 'quickstart/tests/test_netutils.py'
--- quickstart/tests/test_netutils.py 2015-01-12 15:07:51 +0000
+++ quickstart/tests/test_netutils.py 2015-03-10 10:19:09 +0000
@@ -19,7 +19,6 @@
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import httplib21import httplib
22import json
23import socket22import socket
24import unittest23import unittest
25import urllib224import urllib2
@@ -103,42 +102,12 @@
103 netutils.check_listening('1.2.3.4:17070')102 netutils.check_listening('1.2.3.4:17070')
104103
105104
106class TestGetCharmUrl(helpers.UrlReadTestsMixin, unittest.TestCase):105class TestNotFoundError(unittest.TestCase):
107106
108 def test_charm_url(self):107 def test_error_message(self):
109 # The Juju GUI charm URL is correctly returned.108 # The error message can be properly retrieved.
110 contents = json.dumps({109 err = netutils.NotFoundError(b'bad wolf')
111 'Id': 'cs:trusty/juju-gui-42',110 self.assertEqual('bad wolf', bytes(err))
112 'Series': 'trusty',
113 'Name': 'juju-gui',
114 'Revision': 42,
115 })
116 with self.patch_urlread(contents=contents) as mock_urlread:
117 charm_url = netutils.get_charm_url('trusty')
118 self.assertEqual('cs:trusty/juju-gui-42', charm_url)
119 mock_urlread.assert_called_once_with(
120 'https://api.jujucharms.com/charmstore/v4/trusty/juju-gui/meta/id')
121
122 def test_io_error(self):
123 # IOErrors are properly propagated.
124 with self.patch_urlread(error=True) as mock_urlread:
125 with self.assertRaises(IOError) as context_manager:
126 netutils.get_charm_url('precise')
127 mock_urlread.assert_called_once_with(
128 'https://api.jujucharms.com/charmstore/v4/precise/juju-gui/meta/id'
129 )
130 self.assertEqual('bad wolf', bytes(context_manager.exception))
131
132 def test_value_error(self):
133 # A ValueError is raised if the API response is not valid.
134 contents = json.dumps({'invalid': {}})
135 with self.patch_urlread(contents=contents) as mock_urlread:
136 with self.assertRaises(ValueError) as context_manager:
137 netutils.get_charm_url('trusty')
138 mock_urlread.assert_called_once_with(
139 'https://api.jujucharms.com/charmstore/v4/trusty/juju-gui/meta/id')
140 self.assertEqual(
141 'unable to find the charm URL', bytes(context_manager.exception))
142111
143112
144class TestUrlread(unittest.TestCase):113class TestUrlread(unittest.TestCase):
@@ -191,7 +160,14 @@
191 self.assertIsInstance(contents, unicode)160 self.assertIsInstance(contents, unicode)
192 mock_urlopen.assert_called_once_with('http://example.com/path/')161 mock_urlopen.assert_called_once_with('http://example.com/path/')
193162
194 def test_errors(self):163 def test_logging(self):
164 # The request URL is properly logged.
165 expected_log = 'sending HTTP GET request to http://example.com/path/'
166 with helpers.assert_logs([expected_log], level='debug'):
167 with self.patch_urlopen():
168 netutils.urlread('http://example.com/path/')
169
170 def test_io_errors(self):
195 # An IOError is raised if an error occurs connecting to the API.171 # An IOError is raised if an error occurs connecting to the API.
196 errors = {172 errors = {
197 'httplib HTTPException': httplib.HTTPException,173 'httplib HTTPException': httplib.HTTPException,
@@ -201,7 +177,16 @@
201 for message, exception_class in errors.items():177 for message, exception_class in errors.items():
202 exception = exception_class(message)178 exception = exception_class(message)
203 with self.patch_urlopen(error=exception) as mock_urlopen:179 with self.patch_urlopen(error=exception) as mock_urlopen:
204 with self.assertRaises(IOError) as context_manager:180 with self.assertRaises(IOError) as ctx:
205 netutils.urlread('http://example.com/path/')181 netutils.urlread('http://example.com/path/')
206 mock_urlopen.assert_called_once_with('http://example.com/path/')182 mock_urlopen.assert_called_once_with('http://example.com/path/')
207 self.assertEqual(message, bytes(context_manager.exception))183 self.assertEqual(message, bytes(ctx.exception))
184
185 def test_not_found_error(self):
186 # A netutils.NotFoundError is raised if the request returns a 404 not
187 # found response.
188 exception = urllib2.HTTPError('url', 404, 'bad wolf', None, None)
189 with self.patch_urlopen(error=exception):
190 with self.assertRaises(netutils.NotFoundError) as ctx:
191 netutils.urlread('http://example.com/path/')
192 self.assertEqual('HTTP Error 404: bad wolf', bytes(ctx.exception))
208193
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2015-02-09 12:34:33 +0000
+++ quickstart/utils.py 2015-03-10 10:19:09 +0000
@@ -88,7 +88,7 @@
88 The banner is returned as a string, e.g.:88 The banner is returned as a string, e.g.:
8989
90 # This file has been generated by juju quickstart v0.42.090 # This file has been generated by juju quickstart v0.42.0
91 # in date 2013-12-31 23:59:00 UTC.91 # at 2013-12-31 23:59:00 UTC.
92 """92 """
93 now = datetime.datetime.utcnow()93 now = datetime.datetime.utcnow()
94 formatted_date = now.isoformat(sep=b' ').split('.')[0]94 formatted_date = now.isoformat(sep=b' ').split('.')[0]

Subscribers

People subscribed via source and target branches