Merge lp:~frankban/juju-quickstart/more-cloud-providers into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 46
Proposed branch: lp:~frankban/juju-quickstart/more-cloud-providers
Merge into: lp:juju-quickstart
Diff against target: 730 lines (+354/-76)
10 files modified
quickstart/__init__.py (+1/-1)
quickstart/cli/forms.py (+30/-14)
quickstart/cli/views.py (+11/-5)
quickstart/manage.py (+5/-2)
quickstart/models/envs.py (+127/-3)
quickstart/models/fields.py (+9/-0)
quickstart/tests/cli/test_forms.py (+94/-40)
quickstart/tests/cli/test_views.py (+4/-3)
quickstart/tests/models/test_envs.py (+61/-8)
quickstart/tests/models/test_fields.py (+12/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/more-cloud-providers
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+201616@code.launchpad.net

Description of the change

Add support for OpenStack and Azure.

Tests: `make check`

QA: `.venv/bin/python juju-quickstart -i`
Ensure you can successfully create an openstack/HP cloud
and an azure environment.
If you already subscribed to any of those, please
check everything works ok. If not, no problem,
I already bootstrapped HP Cloud and azure.
Since this is the 1.0 version, please ensure
the environment management works well, and in general
quickstart bootstraps the environments and
deploys the GUI as expected.
Thank you!

https://codereview.appspot.com/52080044/

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

Reviewers: mp+201616_code.launchpad.net,

Message:
Please take a look.

Description:
Add support for OpenStack and Azure.

Tests: `make check`

QA: `.venv/bin/python juju-quickstart -i`
Ensure you can successfully create an openstack/HP cloud
and an azure environment.
If you already subscribed to any of those, please
check everything works ok. If not, no problem,
I already bootstrapped HP Cloud and azure.
Since this is the 1.0 version, please ensure
the environment management works well, and in general
quickstart bootstraps the environments and
deploys the GUI as expected.
Thank you!

https://code.launchpad.net/~frankban/juju-quickstart/more-cloud-providers/+merge/201616

(do not edit description out of merge proposal)

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

Affected files (+288, -55 lines):
   A [revision details]
   M quickstart/__init__.py
   M quickstart/cli/forms.py
   M quickstart/models/envs.py
   M quickstart/models/fields.py
   M quickstart/tests/cli/test_forms.py
   M quickstart/tests/models/test_envs.py
   M quickstart/tests/models/test_fields.py

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

Code looks good, a couple of comments.

Starting QA.

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

https://codereview.appspot.com/52080044/diff/1/quickstart/__init__.py#newcode25
quickstart/__init__.py:25: VERSION = (1, 0, 0)
wahoo!

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

https://codereview.appspot.com/52080044/diff/1/quickstart/models/envs.py#newcode529
quickstart/models/envs.py:529: fields.ChoiceField(
should this be a suggestion field to help users move along faster? Just
suggest a sensible default like US East which I think even things like
the AMZ web ui does? It's a suggestion field in the open stack example
above.

https://codereview.appspot.com/52080044/diff/1/quickstart/tests/cli/test_forms.py
File quickstart/tests/cli/test_forms.py (right):

https://codereview.appspot.com/52080044/diff/1/quickstart/tests/cli/test_forms.py#newcode174
quickstart/tests/cli/test_forms.py:174: class
TestCreateStringWidget(ChoicesTestsMixin, unittest.TestCase):
I think that the suggestion field is its own new class and should have
its own tests vs getting shoed into the String widget tests. This seems
like a path down like the one in the charm where the one suite of tests
contained others that weren't directly related.

https://codereview.appspot.com/52080044/

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

In creating an Azure environment there's a space issue before 'saucy' in
the list of default series.

The dual nature of this is kind of strange. There's a lot of text/links
there. I'd probably leave off the 'left_empty' since that's just
defaulting to precise. Why not just prefill that in and let users change
it?

Do you think we can skip the "Click the choices to auto-fill..." on each
field? Are the underline links a common enough/discoverable? If you
don't have/use a mouse does it help/matter?

The list of providers to create config for says 'openstack' but
everything inside of that references both openstack and HP. Should that
original selection be "openstack (HP Cloud)"?

I don't have openstack creds but looks sane.

I created 3 environments and removed two of them. One was the default.
I'd expect the last one left to be made the default now.

On the main interactive landing page it says "Use the links below to
create new environments." I don't really think of those as links. The
underlined bits in the choice fields are more "link like". I'd suggest
rephrasing this as just "Create new environments:"

Better yet, I'd try to match this up a bit. The main header can just be
"Manage Juju environments" and add a header to the top section as
"Manage existing environments:" and then the lower one could be the
"Create a new environment". I think it would flow and read a bit nicer.

When you select an existing environment the header is "name (type:
local, default). However that same information is listed below. Can it
be simpler and just be "name"?

I created a local environment, set it up, and got the gui loaded up on
it. I then changed it to be the default and selected 'use' think it
would open my existing session. Instead I got a request for my sudo
password, it went through the 'installing ppa...', asking for my ssh
passphrase, and only after that did it open. Can this shortcut to just
open it up and detect/know the other steps aren't necessary?

I removed my last environment and got the 'please create one' which is
nice. The bullet/selection for 'automatically create and bootstrap a
local' does not have a line of space between it and the above content
like the other ones do. I'd propose moving that down one newline.

One other note, removing the environment did not ask or do anything
about destroying it. Now that I've removed my config there's not a good
way for me to shut it down? Should we ask to destroy-environment when
removing a config?

Overall this is really great and nice to use. I'm with-holding the L G T
M because I think the destroy question and the re-opening without asking
might require enough code changes (if there's agreement to make those
changes) to want to re-review.

https://codereview.appspot.com/52080044/

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

I agree that some of the points Rick brings up - notably destroying an
environment - are worth considering, though I think they may be worth
their own branches. However, if that's the case, then I think we should
withhold the move to 1.0.0 until the destroy confirmation is made, since
that's potentially big. That means, either include that here or push to
another branch with 1.0.0; otherwise, LGTM! Thanks for the work!

https://codereview.appspot.com/52080044/

50. By Francesco Banconi

Checkpoint.

51. By Francesco Banconi

UI changes as per review.

52. By Francesco Banconi

All changes as per review.

53. By Francesco Banconi

Checkpoint.

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

Please take a look.

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

https://codereview.appspot.com/52080044/diff/1/quickstart/models/envs.py#newcode529
quickstart/models/envs.py:529: fields.ChoiceField(
On 2014/01/15 19:29:04, rharding wrote:
> should this be a suggestion field to help users move along faster?
Just suggest
> a sensible default like US East which I think even things like the AMZ
web ui
> does? It's a suggestion field in the open stack example above.

Suggestion fields are used to suggest a value while leaving the field
open to arbitrary values. The choice field here means validation is
involved. AFAICT Azure does not use US East as default.

https://codereview.appspot.com/52080044/diff/1/quickstart/tests/cli/test_forms.py
File quickstart/tests/cli/test_forms.py (right):

https://codereview.appspot.com/52080044/diff/1/quickstart/tests/cli/test_forms.py#newcode174
quickstart/tests/cli/test_forms.py:174: class
TestCreateStringWidget(ChoicesTestsMixin, unittest.TestCase):
On 2014/01/15 19:29:04, rharding wrote:
> I think that the suggestion field is its own new class and should have
its own
> tests vs getting shoed into the String widget tests. This seems like a
path down
> like the one in the charm where the one suite of tests contained
others that
> weren't directly related.

I am not sure I understood this comment. The suggestion field itself
already has its own tests in test_fields.py. Here we are just testing
that suggestions are included in the string widget if required.

https://codereview.appspot.com/52080044/

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

This is really good stuff Rick and Matthew.
Thank you both for your great reviews!
Please see the inline comments below, and,
if you can, please QA the new changes.

On 2014/01/15 20:51:45, rharding wrote:
> In creating an Azure environment there's a space issue before 'saucy'
in the
> list of default series.

Unfortunately this is due to the fact we are
using a Grid widget to display choices.
This is done so that line wrapping works as
expected on small terminals. This can be better
seen, e.g., in the azure location choices.
There is no easy way to fix this in Urwid.
I agree it's a bit ugly but I also think
we can live with that for 1.0. What do you think?

> The dual nature of this is kind of strange. There's a lot of
text/links there.
> I'd probably leave off the 'left_empty' since that's just defaulting
to precise.
> Why not just prefill that in and let users change it?

For optional fields, rather than pre-filling
values, I'd prefer to leave the field empty
(and consequently expose an option to empty
the field). Defaults can change over time,
and pre-filling with a value is semantically
different than not writing the field at all
in the envs.yaml file. The default series is
a good example of this. Leaving the field
empty means that juju-core, in a few months, will
bootstrap trusty machines. Explicitly selecting
precise avoids this behavior, and we want to
support both needs.

> Do you think we can skip the "Click the choices to auto-fill..." on
each field?
> Are the underline links a common enough/discoverable? If you don't
have/use a
> mouse does it help/matter?

This makes the UI more explicit and has been
requested in previous reviews.

> The list of providers to create config for says 'openstack' but
everything
> inside of that references both openstack and HP. Should that original
selection
> be "openstack (HP Cloud)"?

This is a very good point Rick.
Fixed, now the labels for new environments
are more human readable.

> I don't have openstack creds but looks sane.

Cool.

> I created 3 environments and removed two of them. One was the default.
I'd
> expect the last one left to be made the default now.

Good idea, done.

> On the main interactive landing page it says "Use the links below to
create new
> environments." I don't really think of those as links. The underlined
bits in
> the choice fields are more "link like". I'd suggest rephrasing this as
just
> "Create new environments:"

Done.

> Better yet, I'd try to match this up a bit. The main header can just
be "Manage
> Juju environments" and add a header to the top section as "Manage
existing
> environments:" and then the lower one could be the "Create a new
environment". I
> think it would flow and read a bit nicer.

Done.

> When you select an existing environment the header is "name (type:
local,
> default). However that same information is listed below. Can it be
simpler and
> just be "name"?

I find that summary useful, especially when the
environment is the default one: most of the times
you get all the info you require without having
to read the table below. I'll change it if you are
not convinced.

> I created a local environment, set it up, and got the gui loaded up o...

Read more...

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

Thanks for the out of band email and updates.

LGTM with the notes that this is 0.9 and 1.0 will have the checking for
catching Juju errors in bringing up the machine as a follow up card.

https://codereview.appspot.com/52080044/

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

*** Submitted:

Add support for OpenStack and Azure.

Tests: `make check`

QA: `.venv/bin/python juju-quickstart -i`
Ensure you can successfully create an openstack/HP cloud
and an azure environment.
If you already subscribed to any of those, please
check everything works ok. If not, no problem,
I already bootstrapped HP Cloud and azure.
Since this is the 1.0 version, please ensure
the environment management works well, and in general
quickstart bootstraps the environments and
deploys the GUI as expected.
Thank you!

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

https://codereview.appspot.com/52080044/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'quickstart/__init__.py'
--- quickstart/__init__.py 2014-01-09 11:04:03 +0000
+++ quickstart/__init__.py 2014-01-16 12:48:07 +0000
@@ -22,7 +22,7 @@
22from __future__ import unicode_literals22from __future__ import unicode_literals
2323
2424
25VERSION = (0, 6, 0)25VERSION = (1, 0, 0)
2626
2727
28def get_version():28def get_version():
2929
=== modified file 'quickstart/cli/forms.py'
--- quickstart/cli/forms.py 2014-01-08 12:00:44 +0000
+++ quickstart/cli/forms.py 2014-01-16 12:48:07 +0000
@@ -56,23 +56,34 @@
56 ])56 ])
5757
5858
59def _create_choices_widget(choices, required, edit_widget):59def _create_buttons_grid_widget(choices, edit_widget):
60 """Create and return a choices widget.60 """Create and return a grid of button widgets.
6161
62 The widget displays the given choices for a specific form field. Clicking a62 Buttons are associated with the given choices, and when clicked, udpate
63 choice updates the corresponding edit widget.63 the given edit_widget's text.
64 """64 """
65 widgets = [urwid.Text('possible values are:')]
66 callback = edit_widget.set_edit_text65 callback = edit_widget.set_edit_text
67 buttons = [66 buttons = [
68 ui.MenuButton(('field button', choice), ui.thunk(callback, choice))67 ui.MenuButton(('field button', choice), ui.thunk(callback, choice))
69 for choice in choices68 for choice in choices
70 ]69 ]
71 cell_width = max(button.base_widget.pack()[0] for button in buttons)70 cell_width = max(button.base_widget.pack()[0] for button in buttons)
72 widgets.append(urwid.GridFlow(buttons, cell_width, 1, 0, 'left'))71 return urwid.GridFlow(buttons, cell_width, 1, 0, 'left')
72
73
74def _create_choices_widget(choices, required, edit_widget):
75 """Create and return a choices widget.
76
77 The widget displays the given choices for a specific form field. Clicking a
78 choice updates the corresponding edit widget.
79 """
80 widgets = [urwid.Text('possible values are:')]
81 buttons_grid = _create_buttons_grid_widget(choices, edit_widget)
82 widgets.append(buttons_grid)
73 if not required:83 if not required:
74 button = ui.MenuButton(84 button = ui.MenuButton(
75 ('field button', 'left empty'), ui.thunk(callback, ''))85 ('field button', 'left empty'),
86 ui.thunk(edit_widget.set_edit_text, ''))
76 widgets.append(urwid.Columns([87 widgets.append(urwid.Columns([
77 ('pack', urwid.Text('but this field can also be ')), button,88 ('pack', urwid.Text('but this field can also be ')), button,
78 ]))89 ]))
@@ -118,18 +129,23 @@
118 # Display the field help message below the edit widget.129 # Display the field help message below the edit widget.
119 widgets.append(urwid.Text(field.help))130 widgets.append(urwid.Text(field.help))
120 if not field.readonly:131 if not field.readonly:
132 # Can we display suggestions for this field?
133 suggestions = getattr(field, 'suggestions', ())
134 if suggestions:
135 widgets.append(
136 _create_buttons_grid_widget(suggestions, edit_widget))
121 # Can the value be automatically generated?137 # Can the value be automatically generated?
122 generate_callable = getattr(field, 'generate', None)138 generate_callable = getattr(field, 'generate', None)
123 if generate_callable is not None:139 if generate_callable is not None:
124 widgets.append(140 widgets.append(
125 _create_generate_widget(generate_callable, edit_widget))141 _create_generate_widget(generate_callable, edit_widget))
126 # If the value must be in a range of choices, display the possible choices142 # If the value must be in a range of choices, display the possible
127 # as part of the help message.143 # choices as part of the help message.
128 choices = getattr(field, 'choices', None)144 choices = getattr(field, 'choices', None)
129 if choices is not None:145 if choices is not None:
130 choices_widget = _create_choices_widget(146 choices_widget = _create_choices_widget(
131 tuple(choices), field.required, edit_widget)147 tuple(choices), field.required, edit_widget)
132 widgets.append(choices_widget)148 widgets.append(choices_widget)
133 if field.default is not None:149 if field.default is not None:
134 widgets.append(150 widgets.append(
135 urwid.Text('default if not set: {}'.format(field.default)))151 urwid.Text('default if not set: {}'.format(field.default)))
136152
=== modified file 'quickstart/cli/views.py'
--- quickstart/cli/views.py 2014-01-09 18:10:40 +0000
+++ quickstart/cli/views.py 2014-01-16 12:48:07 +0000
@@ -172,7 +172,10 @@
172172
173 if environments:173 if environments:
174 title = 'Select an existing Juju environment or create a new one'174 title = 'Select an existing Juju environment or create a new one'
175 widgets = []175 widgets = [
176 urwid.Text(('highlight', 'Manage existing environments:')),
177 urwid.Divider(),
178 ]
176 else:179 else:
177 title = 'No Juju environments already set up: please create one'180 title = 'No Juju environments already set up: please create one'
178 widgets = [181 widgets = [
@@ -190,6 +193,7 @@
190 'start your Juju experience in a local environment (LXC), '193 'start your Juju experience in a local environment (LXC), '
191 'just click the link below:'194 'just click the link below:'
192 ]),195 ]),
196 urwid.Divider(),
193 ui.MenuButton(197 ui.MenuButton(
194 '\N{BULLET} automatically create and bootstrap a local '198 '\N{BULLET} automatically create and bootstrap a local '
195 'environment', ui.thunk(create_and_start_local_env)),199 'environment', ui.thunk(create_and_start_local_env)),
@@ -202,12 +206,14 @@
202 # time consuming.206 # time consuming.
203 focus_position = None207 focus_position = None
204 errors_found = default_found = False208 errors_found = default_found = False
209 existing_widgets_num = len(widgets)
205 for position, env_data in enumerate(environments):210 for position, env_data in enumerate(environments):
206 bullet = '\N{BULLET}'211 bullet = '\N{BULLET}'
207 # Is this environment the default one?212 # Is this environment the default one?
208 if env_data['is-default']:213 if env_data['is-default']:
209 default_found = True214 default_found = True
210 focus_position = position215 # The first two positions are the section header and the divider.
216 focus_position = position + existing_widgets_num
211 bullet = '\N{CHECK MARK}'217 bullet = '\N{CHECK MARK}'
212 # Is this environment valid?218 # Is this environment valid?
213 env_metadata = envs.get_env_metadata(env_type_db, env_data)219 env_metadata = envs.get_env_metadata(env_type_db, env_data)
@@ -223,7 +229,7 @@
223 widgets.extend([229 widgets.extend([
224 urwid.Divider(),230 urwid.Divider(),
225 urwid.Text((231 urwid.Text((
226 'highlight', 'Use the links below to create new environments')),232 'highlight', 'Create a new environment:')),
227 urwid.Divider(),233 urwid.Divider(),
228 ])234 ])
229 # The Juju GUI can be safely installed in the bootstrap node only if its235 # The Juju GUI can be safely installed in the bootstrap node only if its
@@ -231,11 +237,11 @@
231 preferred_series = settings.JUJU_GUI_PREFERRED_SERIES237 preferred_series = settings.JUJU_GUI_PREFERRED_SERIES
232 widgets.extend([238 widgets.extend([
233 ui.MenuButton(239 ui.MenuButton(
234 ['\N{BULLET} new ', ('highlight', env_type), ' environment'],240 ['\N{BULLET} new ', ('highlight', label), ' environment'],
235 ui.thunk(edit_view, {241 ui.thunk(edit_view, {
236 'type': env_type, 'default-series': preferred_series})242 'type': env_type, 'default-series': preferred_series})
237 )243 )
238 for env_type in envs.get_supported_env_types(env_type_db)244 for env_type, label in envs.get_supported_env_types(env_type_db)
239 ])245 ])
240 # Set up the application status messages.246 # Set up the application status messages.
241 status = [' \N{UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW} navigate ']247 status = [' \N{UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW} navigate ']
242248
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2014-01-13 15:46:27 +0000
+++ quickstart/manage.py 2014-01-16 12:48:07 +0000
@@ -430,9 +430,12 @@
430 gui_env.close()430 gui_env.close()
431 print(431 print(
432 'done!\n\n'432 'done!\n\n'
433 'Run "juju quickstart -e {}" again if you want\n'433 'Run "juju quickstart -e {env_name}" again if you want\n'
434 'to reopen and log in to the GUI browser later.\n'434 'to reopen and log in to the GUI browser later.\n'
435 'Run "juju quickstart -i" if you want to manage\n'435 'Run "juju quickstart -i" if you want to manage\n'
436 'or bootstrap your Juju environments using the\n'436 'or bootstrap your Juju environments using the\n'
437 'interactive session.'.format(options.env_name)437 'interactive session.\n'
438 'Run "juju destroy-environment -e {env_name} [-y]"\n'
439 'to destroy the environment you just bootstrapped.'.format(
440 env_name=options.env_name)
438 )441 )
439442
=== modified file 'quickstart/models/envs.py'
--- quickstart/models/envs.py 2014-01-10 15:44:36 +0000
+++ quickstart/models/envs.py 2014-01-16 12:48:07 +0000
@@ -341,13 +341,17 @@
341 Raise a ValueError if the environment is not present in env_db.341 Raise a ValueError if the environment is not present in env_db.
342 Without errors, the env_db is modified in place and None is returned.342 Without errors, the env_db is modified in place and None is returned.
343 """343 """
344 environments = env_db['environments']
344 try:345 try:
345 del env_db['environments'][env_name]346 del environments[env_name]
346 except KeyError:347 except KeyError:
347 raise ValueError(348 raise ValueError(
348 b'the environment named {!r} does not exist'.format(env_name))349 b'the environment named {!r} does not exist'.format(env_name))
349 if env_db.get('default') == env_name:350 if env_db.get('default') == env_name:
350 del env_db['default']351 del env_db['default']
352 # If only one environment remains, set it as default.
353 if len(environments) == 1:
354 env_db['default'] = environments.keys()[0]
351355
352356
353def get_env_type_db():357def get_env_type_db():
@@ -384,6 +388,11 @@
384 'eu-west-1', 'sa-east-1',388 'eu-west-1', 'sa-east-1',
385 'us-east-1', 'us-west-1', 'us-west-2',389 'us-east-1', 'us-west-1', 'us-west-2',
386 )390 )
391 hp_regions = (
392 'az-1.region-a.geo-1', 'az-2.region-a.geo-1', 'az-3.region-a.geo-1')
393 azure_locations = (
394 'East US', 'West US', 'West Europe', 'North Europe',
395 'Southeast Asia', 'East Asia')
387 # Define the env_type_db dictionary: this is done inside this function in396 # Define the env_type_db dictionary: this is done inside this function in
388 # order to avoid instantiating fields at import time.397 # order to avoid instantiating fields at import time.
389 # This is an ordered dict so that views can expose options to create new398 # This is an ordered dict so that views can expose options to create new
@@ -405,6 +414,7 @@
405 },414 },
406 })415 })
407 env_type_db['ec2'] = {416 env_type_db['ec2'] = {
417 'label': 'Amazon EC2',
408 'description': (418 'description': (
409 'The ec2 provider enable you to run Juju on the EC2 cloud. '419 'The ec2 provider enable you to run Juju on the EC2 cloud. '
410 'This process requires you to have an Amazon Web Services (AWS) '420 'This process requires you to have an Amazon Web Services (AWS) '
@@ -443,7 +453,114 @@
443 is_default_field,453 is_default_field,
444 ),454 ),
445 }455 }
456 env_type_db['openstack'] = {
457 'label': 'OpenStack (or HP Public Cloud)',
458 'description': (
459 'The openstack provider enable you to run Juju on OpenStack '
460 'private and public clouds. Use this also if you want to '
461 'set up Juju on HP Public Cloud. See '
462 'https://juju.ubuntu.com/docs/config-openstack.html and '
463 'https://juju.ubuntu.com/docs/config-hpcloud.html for more '
464 'details on the openstack provider configuration.'
465 ),
466 'fields': (
467 provider_field,
468 name_field,
469 admin_secret_field,
470 default_series_field,
471 fields.BoolField(
472 'use-floating-ip', label='use floating IP', allow_mixed=False,
473 required=True,
474 help='Specifies whether the use of a floating IP address is '
475 'required to give the nodes a public IP address. '
476 'Some installations assign public IP addresses by '
477 'default without requiring a floating IP address.'),
478 fields.AutoGeneratedStringField(
479 'control-bucket', label='control bucket', required=True,
480 help='the globally unique swift bucket name'),
481 fields.SuggestionsStringField(
482 'auth-url', label='authentication URL', required=True,
483 help='The Keystone URL to use in the authentication process. '
484 'For HP Public Cloud, use the value suggested below:',
485 suggestions=['https://region-a.geo-1.identity.hpcloudsvc.com'
486 ':35357/v2.0/']),
487 fields.StringField(
488 'tenant-name', label='tenant name', required=True,
489 help='The OpenStack tenant name. For HP Public Cloud, this is '
490 'listed as the project name on the '
491 'https://account.hpcloud.com/projects page.'),
492 fields.SuggestionsStringField(
493 'region', label='region', required=True,
494 help='The OpenStack region to use. '
495 'For HP Public Cloud, use one of the following:',
496 suggestions=hp_regions),
497 fields.ChoiceField(
498 'auth-mode', label='authentication mode', required=False,
499 default='userpass', choices=('userpass', 'keypair'),
500 help='The way Juju authenticates to OpenStack. The userpass '
501 'authentication requires you to fill in your user name '
502 'and password. The keypair mode requires access key and '
503 'secret key to be properly set up. For HP Public Cloud '
504 'these information can be retrieved on the '
505 'https://account.hpcloud.com/account/api_keys page.'),
506 fields.StringField(
507 'username', label='user name', required=False,
508 help='the user name to use for the userpass authentication'),
509 fields.PasswordField(
510 'password', label='password', required=False,
511 help='the user name to use for the userpass authentication'),
512 fields.StringField(
513 'access-key', label='access key', required=False,
514 help='the access key to use for the keypair authentication'),
515 fields.PasswordField(
516 'secret-key', label='secret key', required=False,
517 help='the secret key to use for the keypair authentication'),
518 is_default_field,
519 ),
520 }
521 env_type_db['azure'] = {
522 'label': 'Windows Azure',
523 'description': (
524 'The azure provider enable you to run Juju on Windows Azure. '
525 'It requires you to have an Windows Azure account. If you have '
526 'not signed up for one yet, it can obtained at '
527 'http://www.windowsazure.com/. See '
528 'https://juju.ubuntu.com/docs/config-azure.html for more '
529 'details on the azure provider configuration.'
530 ),
531 'fields': (
532 provider_field,
533 name_field,
534 admin_secret_field,
535 default_series_field,
536 fields.ChoiceField(
537 'location', choices=azure_locations, label='location',
538 required=True, help='the region to use'),
539 fields.StringField(
540 'management-subscription-id', required=True,
541 label='management subscription ID',
542 help='this information can be retrieved from '
543 'https://manage.windowsazure.com (Settings)'),
544 fields.StringField(
545 'management-certificate-path', required=True,
546 label='management certificate path',
547 help='the path to the pem file associated to the certificate '
548 'uploaded in the Azure management console: '
549 'https://manage.windowsazure.com '
550 '(Settings -> Management Certificates)'),
551 fields.StringField(
552 'storage-account-name', required=True,
553 label='storage account name',
554 help='the name you used when creating a storage account in '
555 'the Azure management console: '
556 'https://manage.windowsazure.com (Storage). '
557 'You must create the storage account in the same '
558 'region/location specified by the location key value.'),
559 is_default_field,
560 ),
561 }
446 env_type_db['local'] = {562 env_type_db['local'] = {
563 'label': 'local (LXC)',
447 'description': (564 'description': (
448 'The LXC local provider enables you to run Juju on a single '565 'The LXC local provider enables you to run Juju on a single '
449 'system like your local computer or a single server. '566 'system like your local computer or a single server. '
@@ -479,8 +596,15 @@
479596
480597
481def get_supported_env_types(env_type_db):598def get_supported_env_types(env_type_db):
482 """Return a list of supported provider type names."""599 """Return a list of supported (provider type, label) tuples.
483 return [env_type for env_type in env_type_db if env_type != '__fallback__']600
601 Each tuple represents an environment type supported by Quickstart.
602 """
603 return [
604 (env_type, metadata['label'])
605 for env_type, metadata in env_type_db.items()
606 if env_type != '__fallback__'
607 ]
484608
485609
486def get_env_metadata(env_type_db, env_data):610def get_env_metadata(env_type_db, env_data):
487611
=== modified file 'quickstart/models/fields.py'
--- quickstart/models/fields.py 2014-01-07 15:41:55 +0000
+++ quickstart/models/fields.py 2014-01-16 12:48:07 +0000
@@ -293,6 +293,15 @@
293 )293 )
294294
295295
296class SuggestionsStringField(StringField):
297 """A string field storing possible value suggestions."""
298
299 def __init__(self, name, suggestions=(), **kwargs):
300 """Initialize the choices field with the given choices."""
301 super(SuggestionsStringField, self).__init__(name, **kwargs)
302 self.suggestions = tuple(suggestions)
303
304
296class AutoGeneratedStringField(StringField):305class AutoGeneratedStringField(StringField):
297 """Can automatically generate string values if they are not provided.306 """Can automatically generate string values if they are not provided.
298307
299308
=== modified file 'quickstart/tests/cli/test_forms.py'
--- quickstart/tests/cli/test_forms.py 2014-01-08 12:00:44 +0000
+++ quickstart/tests/cli/test_forms.py 2014-01-16 12:48:07 +0000
@@ -18,6 +18,7 @@
1818
19from __future__ import unicode_literals19from __future__ import unicode_literals
2020
21import collections
21import unittest22import unittest
2223
23import mock24import mock
@@ -85,6 +86,38 @@
85 self.assertEqual(expected_choices, obtained_choices)86 self.assertEqual(expected_choices, obtained_choices)
8687
8788
89class TestCreateButtonsGridWidget(unittest.TestCase):
90
91 def setUp(self):
92 # Set up a mock edit widget and choices.
93 self.mock_edit_widget = mock.Mock()
94 self.choices = ['Kirk', 'Picard', 'Sisko']
95 self.widget = forms._create_buttons_grid_widget(
96 self.choices, self.mock_edit_widget)
97
98 def get_buttons(self):
99 """Return a list of buttons included in self.widget."""
100 return [button for button, _ in self.widget.contents]
101
102 def test_widget_structure(self):
103 # The resulting widget is a urwid.GridFlow including all the expected
104 # button widgets.
105 self.assertIsInstance(self.widget, urwid.GridFlow)
106 buttons = self.get_buttons()
107 self.assertEqual(len(self.choices), len(buttons))
108 self.assertEqual(
109 self.choices, map(cli_helpers.get_button_caption, buttons))
110
111 def test_button_click(self):
112 # The given edit widget is updated when buttons are clicked.
113 for button in self.get_buttons():
114 choice = cli_helpers.get_button_caption(button)
115 # Click the button.
116 cli_helpers.emit(button)
117 # Ensure the edit widget has been updated accordingly.
118 self.mock_edit_widget.set_edit_text.assert_called_with(choice)
119
120
88class TestCreateChoicesWidget(ChoicesTestsMixin, unittest.TestCase):121class TestCreateChoicesWidget(ChoicesTestsMixin, unittest.TestCase):
89122
90 def setUp(self):123 def setUp(self):
@@ -141,42 +174,49 @@
141class TestCreateStringWidget(ChoicesTestsMixin, unittest.TestCase):174class TestCreateStringWidget(ChoicesTestsMixin, unittest.TestCase):
142175
143 def inspect_widget(self, widget, field):176 def inspect_widget(self, widget, field):
144 """Return a list of sub-widgets composing the given string widget.177 """Return a dict of sub-widgets composing the given string widget.
145178
146 The sub-widgets are:179 The dictionary includes the following keys:
147 - the wrapper edit widget;180
148 - the base edit widget;181 - wrapper: the wrapper edit widget;
149 - the caption text widget;182 - edit: the base edit widget;
150 - the help widget (or None if not present);183 - caption: the caption text widget;
151 - the error text widget (or None if not present);184 - help: the help widget (or None if not present);
152 - the generate text widget (or None if not present);185 - error: the error text widget (or None if not present);
153 - the choices text widget (or None if not present);186 - suggestions: the suggested values grid (or None if not present);
154 - the default text widget (or None if not present).187 - generate: the generate text widget (or None if not present);
188 - choices: the choices text widget (or None if not present);
189 - default: the default text widget (or None if not present).
155 """190 """
156 help = error = generate = choices = default = None191 widgets = collections.defaultdict(lambda: None)
157 # Retrieve the Pile contents ignoring the last Divider widget.192 # Retrieve the Pile contents ignoring the last Divider widget.
158 contents = list(widget.contents)[:-1]193 contents = list(widget.contents)[:-1]
159 first_widget = contents.pop(0)[0]194 first_widget = contents.pop(0)[0]
160 if isinstance(first_widget, urwid.Text):195 if isinstance(first_widget, urwid.Text):
161 # This is the error message.196 # This is the error message.
162 error = first_widget197 widgets.update({
163 wrapper = contents.pop(0)[0]198 'error': first_widget,
199 'wrapper': contents.pop(0)[0]
200 })
164 else:201 else:
165 # The widget has no errors.202 # The widget has no errors.
166 wrapper = first_widget203 widgets['wrapper'] = first_widget
167 caption_attrs, edit_attrs = wrapper.base_widget.contents204 caption_attrs, edit_attrs = widgets['wrapper'].base_widget.contents
205 widgets.update({
206 'edit': edit_attrs[0],
207 'caption': caption_attrs[0],
208 })
168 if field.help:209 if field.help:
169 help = contents.pop(0)[0]210 widgets['help'] = contents.pop(0)[0]
211 if hasattr(field, 'suggestions'):
212 widgets['suggestions'] = contents.pop(0)[0]
170 if hasattr(field, 'generate'):213 if hasattr(field, 'generate'):
171 generate = contents.pop(0)[0]214 widgets['generate'] = contents.pop(0)[0]
172 if hasattr(field, 'choices'):215 if hasattr(field, 'choices'):
173 choices = contents.pop(0)[0]216 widgets['choices'] = contents.pop(0)[0]
174 if field.default is not None:217 if field.default is not None:
175 default = contents.pop(0)[0]218 widgets['default'] = contents.pop(0)[0]
176 return (219 return widgets
177 wrapper, edit_attrs[0], caption_attrs[0],
178 help, error, generate, choices, default,
179 )
180220
181 def test_widget_structure(self):221 def test_widget_structure(self):
182 # The widget includes all the information about a field.222 # The widget includes all the information about a field.
@@ -184,28 +224,29 @@
184 'first-name', label='first name', help='your first name',224 'first-name', label='first name', help='your first name',
185 default='Jean')225 default='Jean')
186 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')226 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
187 wrapper, edit, caption, help, error, generate, choices, default = (227 widgets = self.inspect_widget(widget, field)
188 self.inspect_widget(widget, field))
189 # Since the field is not read-only, the widget is properly enabled.228 # Since the field is not read-only, the widget is properly enabled.
190 self.assertNotIsInstance(wrapper, urwid.WidgetDisable)229 self.assertNotIsInstance(widgets['wrapper'], urwid.WidgetDisable)
191 # The edit widget is set to the given value.230 # The edit widget is set to the given value.
192 self.assertEqual('Luc', edit.get_edit_text())231 self.assertEqual('Luc', widgets['edit'].get_edit_text())
193 # The caption and help are properly set.232 # The caption and help are properly set.
194 self.assertEqual('\N{BULLET} first name: ', caption.text)233 self.assertEqual('\N{BULLET} first name: ', widgets['caption'].text)
195 self.assertEqual('your first name', help.text)234 self.assertEqual('your first name', widgets['help'].text)
196 # The error is displayed.235 # The error is displayed.
197 self.assertEqual('invalid name', error.text)236 self.assertEqual('invalid name', widgets['error'].text)
198 # The field is not able to generate a value, and there are no choices.237 # The field is not able to generate a value, and there are no choices
199 self.assertIsNone(generate)238 # or suggestions.
200 self.assertIsNone(choices)239 self.assertIsNone(widgets['generate'])
240 self.assertIsNone(widgets['suggestions'])
241 self.assertIsNone(widgets['choices'])
201 # The default value is properly displayed.242 # The default value is properly displayed.
202 self.assertEqual('default if not set: Jean', default.text)243 self.assertEqual('default if not set: Jean', widgets['default'].text)
203244
204 def test_value_getter(self):245 def test_value_getter(self):
205 # The returned value getter function returns the current widget value.246 # The returned value getter function returns the current widget value.
206 field = fields.StringField('first-name')247 field = fields.StringField('first-name')
207 widget, value_getter = forms.create_string_widget(field, 'Luc', None)248 widget, value_getter = forms.create_string_widget(field, 'Luc', None)
208 edit = self.inspect_widget(widget, field)[1]249 edit = self.inspect_widget(widget, field)['edit']
209 self.assertEqual('Luc', value_getter())250 self.assertEqual('Luc', value_getter())
210 # The value getter is lazy and always returns the current value.251 # The value getter is lazy and always returns the current value.
211 edit.set_edit_text('Jean-Luc')252 edit.set_edit_text('Jean-Luc')
@@ -227,15 +268,28 @@
227 # The widget is disabled if the field is read-only.268 # The widget is disabled if the field is read-only.
228 field = fields.StringField('first-name', readonly=True)269 field = fields.StringField('first-name', readonly=True)
229 widget, _ = forms.create_string_widget(field, 'Jean-Luc', None)270 widget, _ = forms.create_string_widget(field, 'Jean-Luc', None)
230 wrapper = self.inspect_widget(widget, field)[0]271 wrapper = self.inspect_widget(widget, field)['wrapper']
231 self.assertIsInstance(wrapper, urwid.WidgetDisable)272 self.assertIsInstance(wrapper, urwid.WidgetDisable)
232273
274 def test_suggestions(self):
275 # Suggested values, if present, are properly displayed below the edit
276 # widget.
277 field = fields.SuggestionsStringField(
278 'captain', suggestions=('Kirk', 'Picard'))
279 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
280 suggestions = self.inspect_widget(widget, field)['suggestions']
281 captions = [
282 cli_helpers.get_button_caption(button)
283 for button, attrs in suggestions.contents
284 ]
285 self.assertEqual(['Kirk', 'Picard'], captions)
286
233 def test_autogenerated_field(self):287 def test_autogenerated_field(self):
234 # The widgets allows for automatically generating field values.288 # The widgets allows for automatically generating field values.
235 field = fields.AutoGeneratedStringField('password')289 field = fields.AutoGeneratedStringField('password')
236 with mock.patch.object(field, 'generate', lambda: 'auto-generated!'):290 with mock.patch.object(field, 'generate', lambda: 'auto-generated!'):
237 widget, value_getter = forms.create_string_widget(field, '', None)291 widget, value_getter = forms.create_string_widget(field, '', None)
238 generate = self.inspect_widget(widget, field)[5]292 generate = self.inspect_widget(widget, field)['generate']
239 # The generate button is the first widget in the urwid.Columns.293 # The generate button is the first widget in the urwid.Columns.
240 generate_button = generate.contents[0][0]294 generate_button = generate.contents[0][0]
241 # Click the generate button, and ensure the value has been generated.295 # Click the generate button, and ensure the value has been generated.
@@ -246,7 +300,7 @@
246 # Possible choices are properly displayed below the edit widget.300 # Possible choices are properly displayed below the edit widget.
247 field = fields.ChoiceField('captain', choices=('Kirk', 'Picard'))301 field = fields.ChoiceField('captain', choices=('Kirk', 'Picard'))
248 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')302 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
249 choices = self.inspect_widget(widget, field)[6]303 choices = self.inspect_widget(widget, field)['choices']
250 self.assert_choices(['Kirk', 'Picard', 'left empty'], choices)304 self.assert_choices(['Kirk', 'Picard', 'left empty'], choices)
251305
252 def test_choices_required_field(self):306 def test_choices_required_field(self):
@@ -255,7 +309,7 @@
255 field = fields.ChoiceField(309 field = fields.ChoiceField(
256 'captain', required=True, choices=('Janeway', 'Sisko'))310 'captain', required=True, choices=('Janeway', 'Sisko'))
257 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')311 widget, _ = forms.create_string_widget(field, 'Luc', 'invalid name')
258 choices = self.inspect_widget(widget, field)[6]312 choices = self.inspect_widget(widget, field)['choices']
259 self.assert_choices(['Janeway', 'Sisko'], choices)313 self.assert_choices(['Janeway', 'Sisko'], choices)
260314
261315
262316
=== modified file 'quickstart/tests/cli/test_views.py'
--- quickstart/tests/cli/test_views.py 2014-01-09 16:18:18 +0000
+++ quickstart/tests/cli/test_views.py 2014-01-16 12:48:07 +0000
@@ -200,9 +200,10 @@
200 buttons = self.get_widgets_in_contents(200 buttons = self.get_widgets_in_contents(
201 filter_function=self.is_a(ui.MenuButton))201 filter_function=self.is_a(ui.MenuButton))
202 env_types = envs.get_supported_env_types(self.env_type_db)202 env_types = envs.get_supported_env_types(self.env_type_db)
203 for env_type, button in zip(env_types, buttons[-len(env_types):]):203 for type_tuple, button in zip(env_types, buttons[-len(env_types):]):
204 # The caption includes the environment type.204 env_type, label = type_tuple
205 expected_caption = 'new {} environment'.format(env_type)205 # The caption includes the environment type label.
206 expected_caption = 'new {} environment'.format(label)
206 caption = cli_helpers.get_button_caption(button)207 caption = cli_helpers.get_button_caption(button)
207 self.assertIn(expected_caption, caption)208 self.assertIn(expected_caption, caption)
208 # When the button is clicked, the edit view is called passing the209 # When the button is clicked, the edit view is called passing the
209210
=== modified file 'quickstart/tests/models/test_envs.py'
--- quickstart/tests/models/test_envs.py 2014-01-10 15:44:36 +0000
+++ quickstart/tests/models/test_envs.py 2014-01-16 12:48:07 +0000
@@ -584,14 +584,29 @@
584584
585 def test_default_environment_removal(self):585 def test_default_environment_removal(self):
586 # An environment is successfully removed even if it is the default one.586 # An environment is successfully removed even if it is the default one.
587 environments = self.env_db['environments']
588 environments['a-third-one'] = {'type': 'openstack'}
587 envs.remove_env(self.env_db, 'lxc')589 envs.remove_env(self.env_db, 'lxc')
588 environments = self.env_db['environments']
589 self.assertNotIn('lxc', environments)590 self.assertNotIn('lxc', environments)
590 # The other environments are not removed.591 # The other environments are not removed.
591 self.assertIn('aws', environments)592 self.assertIn('aws', environments)
592 # The environments database no longer includes a default environment.593 # Since there are two remaining environments, the environments database
594 # no longer includes a default environment.
593 self.assertNotIn('default', self.env_db)595 self.assertNotIn('default', self.env_db)
594596
597 def test_one_remaining_environment(self):
598 # If, after a removal, only one environment remains, it is
599 # automatically set as default.
600 envs.remove_env(self.env_db, 'lxc')
601 self.assertEqual(1, len(self.env_db['environments']))
602 self.assertEqual('aws', self.env_db['default'])
603
604 def test_remove_all_environments(self):
605 # Removing all the environments results in an empty environments db.
606 for env_name in list(self.env_db['environments'].keys()):
607 envs.remove_env(self.env_db, env_name)
608 self.assertEqual(envs.create_empty_env_db(), self.env_db)
609
595 def test_invalid_environment_name(self):610 def test_invalid_environment_name(self):
596 # A ValueError is raised if the environment is not present in env_db.611 # A ValueError is raised if the environment is not present in env_db.
597 expected = "the environment named u'no-such' does not exist"612 expected = "the environment named u'no-such' does not exist"
@@ -621,6 +636,11 @@
621 # provider type key.636 # provider type key.
622 self.assertNotEqual(0, len(self.env_type_db))637 self.assertNotEqual(0, len(self.env_type_db))
623 for provider_type, env_metadata in self.env_type_db.items():638 for provider_type, env_metadata in self.env_type_db.items():
639 # Check the label metadata (not present in the fallback provider).
640 if provider_type != '__fallback__':
641 self.assertIn('label', env_metadata, provider_type)
642 self.assertIsInstance(
643 env_metadata['label'], unicode, provider_type)
624 # Check the description metadata.644 # Check the description metadata.
625 self.assertIn('description', env_metadata, provider_type)645 self.assertIn('description', env_metadata, provider_type)
626 self.assertIsInstance(646 self.assertIsInstance(
@@ -665,18 +685,51 @@
665 self.assert_fields(expected, env_metadata)685 self.assert_fields(expected, env_metadata)
666 self.assert_required_fields(expected_required, env_metadata)686 self.assert_required_fields(expected_required, env_metadata)
667687
688 def test_openstack_environment(self):
689 # The openstack environment metadata includes the expected fields.
690 self.assertIn('openstack', self.env_type_db)
691 env_metadata = self.env_type_db['openstack']
692 expected = [
693 'type', 'name', 'admin-secret', 'default-series',
694 'use-floating-ip', 'control-bucket', 'auth-url', 'tenant-name',
695 'region', 'auth-mode', 'username', 'password', 'access-key',
696 'secret-key', 'is-default']
697 expected_required = [
698 'type', 'name', 'admin-secret', 'use-floating-ip',
699 'control-bucket', 'auth-url', 'tenant-name', 'region',
700 'is-default']
701 self.assert_fields(expected, env_metadata)
702 self.assert_required_fields(expected_required, env_metadata)
703
704 def test_azure_environment(self):
705 # The azure environment metadata includes the expected fields.
706 self.assertIn('azure', self.env_type_db)
707 env_metadata = self.env_type_db['azure']
708 expected = [
709 'type', 'name', 'admin-secret', 'default-series', 'location',
710 'management-subscription-id', 'management-certificate-path',
711 'storage-account-name', 'is-default']
712 expected_required = [
713 'type', 'name', 'admin-secret', 'location',
714 'management-subscription-id', 'management-certificate-path',
715 'storage-account-name', 'is-default']
716 self.assert_fields(expected, env_metadata)
717 self.assert_required_fields(expected_required, env_metadata)
718
668719
669class TestGetSupportedEnvTypes(unittest.TestCase):720class TestGetSupportedEnvTypes(unittest.TestCase):
670721
671 def test_env_types(self):722 def test_env_types(self):
672 # All the supported env_types but the fallback one are returned.723 # All the supported env_types but the fallback one are returned.
673 env_type_db = envs.get_env_type_db()724 env_type_db = envs.get_env_type_db()
674 expected = set(env_type_db)725 expected_env_types = [
675 expected.remove('__fallback__')726 ('ec2', 'Amazon EC2'),
676 supported_env_types = envs.get_supported_env_types(env_type_db)727 ('openstack', 'OpenStack (or HP Public Cloud)'),
677 obtained = set(supported_env_types)728 ('azure', 'Windows Azure'),
678 self.assertEqual(len(obtained), len(supported_env_types))729 ('local', 'local (LXC)'),
679 self.assertEqual(expected, obtained)730 ]
731 obtained_env_types = envs.get_supported_env_types(env_type_db)
732 self.assertEqual(expected_env_types, obtained_env_types)
680733
681734
682class TestGetEnvMetadata(unittest.TestCase):735class TestGetEnvMetadata(unittest.TestCase):
683736
=== modified file 'quickstart/tests/models/test_fields.py'
--- quickstart/tests/models/test_fields.py 2014-01-07 15:41:55 +0000
+++ quickstart/tests/models/test_fields.py 2014-01-16 12:48:07 +0000
@@ -457,6 +457,18 @@
457 field.validate(None)457 field.validate(None)
458458
459459
460class TestSuggestionsStringField(TestStringField):
461
462 field_class = fields.SuggestionsStringField
463
464 def test_suggestions(self):
465 # Suggested values are properly stored as a field attribute.
466 suggestions = ('these', 'are', 'the', 'voyages')
467 field = self.field_class(
468 'word', suggestions=suggestions, label='selected word')
469 self.assertEqual(suggestions, field.suggestions)
470
471
460class TestPasswordField(TestStringField):472class TestPasswordField(TestStringField):
461473
462 field_class = fields.PasswordField474 field_class = fields.PasswordField

Subscribers

People subscribed via source and target branches