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

Subscribers

People subscribed via source and target branches