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