Merge lp:~frankban/juju-quickstart/enable-env-management into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 44
Proposed branch: lp:~frankban/juju-quickstart/enable-env-management
Merge into: lp:juju-quickstart
Diff against target: 1392 lines (+644/-378)
13 files modified
cli-app-demo.py (+0/-57)
quickstart/__init__.py (+1/-1)
quickstart/app.py (+0/-19)
quickstart/cli/views.py (+41/-1)
quickstart/manage.py (+118/-22)
quickstart/models/envs.py (+105/-131)
quickstart/tests/cli/test_views.py (+42/-0)
quickstart/tests/helpers.py (+4/-1)
quickstart/tests/models/test_envs.py (+44/-79)
quickstart/tests/test_app.py (+0/-34)
quickstart/tests/test_manage.py (+221/-33)
quickstart/tests/test_utils.py (+52/-0)
quickstart/utils.py (+16/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/enable-env-management
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+201163@code.launchpad.net

Description of the change

Integrate the env management functionality.

This branch enables the environment management
Urwid interactive session feature in quickstart,
but also includes some other improvements, described
below.

Exposed a way to let the user quickly
create and bootstrap a local env if no environments
are already configured. This is implemented
adding a closure (create_and_start_local_env) to the
env_index view: this could have been implemented
as a separate function as well (more easily testable,
less surprising), but I decided to stick with the
pattern used by the other views for now.

Changed the env_edit view so that the first created
environment is automatically set as default.

Renamed manage._validate_env to manage._setup_env:
the function now does a lot more than validation, e.g.
it lunches the interactive session, handles the
case when the Juju home is not yet configured, etc.
This function also sets up a save_callable to be used
by views. I could have used partial to create such a
function, but I decided to implement a HO
_create_save_callable that can be easily be extended
later in order to enable the backup functionality.

Bumped version up.

The diff is very long, my apologies, but you can safely
ignore deleted code:
- the demo application, no longer required;
- the ensure_environments function + its tests;
- envs.parse_env_file + its tests (
  replaced by envs.load + envs.validate).
Moreover, the env_type_db definition in
quickstart.models.envs is just a copy/paste replacement
of the original one: the only difference is that
an ordered dict is used in place of the usual dict.
This change has been introduced so that views
can list the supported environment types in the order
they are defined.

Tests: `make check`.

QA: if you have time, given the amount of code
enabled by this branch, and given the differences
in each one Juju configuration, I'd appreciate both
reviewers to QA the interactive session.
The instructions follow, thank you very much!

- Run `make`.

- Backup your Juju home:
  `mv ~/.juju ~/juju-home-backup`
  This way we can simulate Juju is not configured at all.

- Run `.venv/bin/python juju-quickstart`.
  Quickstart should welcome you and propose, among
  other things, to automatically create and bootstrap
  a local environment. Do that, provide your sudo
  password if requested, wait until the process completes
  and the GUI opens in your default browser as usual.
  This can take several minutes based on the sanity of your
  LXC configuration. Before opening the browser, you should
  see instructions on re-running quickstart to reopen
  and log in to the GUI later or restarting the interactive
  session. Also note that an admin password has been
  automatically generated and printed to stdout.

- Run `.venv/bin/python juju-quickstart`.
  This time no interactive session is started, the application
  recognizes the environment is already bootstrapped and
  quickly reopens the browser and logs in to the GUI.

- Run `cat ~/.juju/environments.yaml`.
  Check the generated environment file looks sane.

- Destroy the local environment:
  `sudo juju destroy-environment -e local -y`

- Run `.venv/bin/python juju-quickstart -i`.
  The interactive session should start and this time
  it should list your existing environment.
  Let's customize the local one: click on it, then
  on the "edit" button, and then use the form
  to change its name (e.g. replace "local" with "lxc").
  Hit page-down and click "save" an then "back" to
  return to the index view.
  Now let's create an ec2 environment by selecting
  "new ec2 environment". Call it "aws", auto-generate
  the admin-secret and the control-bucket, fill in your
  ec2 credentials and save the new environment.
  Also make it the default one, either by using the
  checkbox in the form or by clicking "set default"
  from the env details view. From the envs detail view,
  let's go ahead and bootstrap the "aws" env by clicking "use".
  As above, wait for the ec2 environment to be bootstrapped
  and the GUI opened. In the meanwhile, you can run
  `cat ~/.juju/environments.yaml` in another terminal to
  ensure the env file looks good. Note that at this time
  no backup files are created: this is the goal of my next card.

- Run `juju switch` to check that the default environment
  is now "aws".

- Check that command line options validation still work: all the
  following command should exit with pertinent errors:

  .venv/bin/python juju-quickstart --gui-charm-url invalid

  .venv/bin/python juju-quickstart --gui-charm-url http:~juju-gui/precise/juju-gui-80

  .venv/bin/python juju-quickstart --gui-charm-url cs:precise/juju-gui-1 bundle:~jorge/mediawiki-simple/4/mediawiki-simple

  .venv/bin/python juju-quickstart --gui-charm-url cs:saucy/juju-gui-80

  .venv/bin/python juju-quickstart -e no-such

  .venv/bin/python juju-quickstart /no-such-bundle

  .venv/bin/python juju-quickstart bundle:~jorge/mediawiki-simple/4/no-such

- Let's also try to deploy a bundle:
  run `juju unexpose juju-gui` and then
  `.venv/bin/python juju-quickstart bundle:~jorge/mediawiki-simple/4/mediawiki-simple`.
  The service should be re-exposed by quickstart and the bundle
  deployment should start as usual.

- Destroy the ec2 environment: `juju destroy-environment -e aws -y`.

- Restore your existing juju home:
  `rm -rf ~/.juju && mv ~/juju-home-backup .juju`.

- Create a backup copy of your environments file:
  `cp ~/.juju/environments.yaml ~/environments.yaml.bak`.

- Not it's time to be creative. Run quickstart in interactive
  mode (`.venv/bin/python juju-quickstart -i`): you should
  see your environments listed.
  Start changing/removing/creating/bootstrapping your
  environments. Remove required fields, exit without editing
  to check your environments file is not saved by quickstart
  if not necessary, try to create two envs with the same name.
  In a sentence: try hard to break the application in any
  way you can imagine.

- Once done, restore your original environments file:
  `mv ~/environments.yaml.bak ~/.juju/environments.yaml`.

- Remember to destroy all the environments you
  bootstrapped.

Done! Many thanks for going through all these QA steps.

https://codereview.appspot.com/50430043/

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

Reviewers: mp+201163_code.launchpad.net,

Message:
Please take a look.

Description:
Integrate the env management functionality.

This branch enables the environment management
Urwid interactive session feature in quickstart,
but also includes some other improvements, described
below.

Exposed a way to let the user quickly
create and bootstrap a local env if no environments
are already configured. This is implemented
adding a closure (create_and_start_local_env) to the
env_index view: this could have been implemented
as a separate function as well (more easily testable,
less surprising), but I decided to stick with the
pattern used by the other views for now.

Changed the env_edit view so that the first created
environment is automatically set as default.

Renamed manage._validate_env to manage._setup_env:
the function now does a lot more than validation, e.g.
it lunches the interactive session, handles the
case when the Juju home is not yet configured, etc.
This function also sets up a save_callable to be used
by views. I could have used partial to create such a
function, but I decided to implement a HO
_create_save_callable that can be easily be extended
later in order to enable the backup functionality.

Bumped version up.

The diff is very long, my apologies, but you can safely
ignore deleted code:
- the demo application, no longer required;
- the ensure_environments function + its tests;
- envs.parse_env_file + its tests (
   replaced by envs.load + envs.validate).
Moreover, the env_type_db definition in
quickstart.models.envs is just a copy/paste replacement
of the original one: the only difference is that
an ordered dict is used in place of the usual dict.
This change has been introduced so that views
can list the supported environment types in the order
they are defined.

Tests: `make check`.

QA: if you have time, given the amount of code
enabled by this branch, and given the differences
in each one Juju configuration, I'd appreciate both
reviewers to QA the interactive session.
The instructions follow, thank you very much!

- Run `make`.

- Backup your Juju home:
   `mv ~/.juju ~/juju-home-backup`
   This way we can simulate Juju is not configured at all.

- Run `.venv/bin/python juju-quickstart`.
   Quickstart should welcome you and propose, among
   other things, to automatically create and bootstrap
   a local environment. Do that, provide your sudo
   password if requested, wait until the process completes
   and the GUI opens in your default browser as usual.
   This can take several minutes based on the sanity of your
   LXC configuration. Before opening the browser, you should
   see instructions on re-running quickstart to reopen
   and log in to the GUI later or restarting the interactive
   session. Also note that an admin password has been
   automatically generated and printed to stdout.

- Run `.venv/bin/python juju-quickstart`.
   This time no interactive session is started, the application
   recognizes the environment is already bootstrapped and
   quickly reopens the browser and logs in to the GUI.

- Run `cat ~/.juju/environments.yaml`.
   Check the generated environment file looks sane.

- Destroy the local enviro...

Read more...

Revision history for this message
Benji York (benji) wrote :

LGTM with one tiny suggestion.

https://codereview.appspot.com/50430043/diff/1/quickstart/manage.py
File quickstart/manage.py (right):

https://codereview.appspot.com/50430043/diff/1/quickstart/manage.py#newcode211
quickstart/manage.py:211: options.interactive = interactive
This function has gotten a bit big. I wonder if a nice refactoring
would be one that made this function contain no conditionals but instead
called four or five subfunctions.

https://codereview.appspot.com/50430043/

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

code looks good, will start QA.

https://codereview.appspot.com/50430043/

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

QA ok, LGTM

It was cool to use it in interactive mode against my original
environments.yaml file. The only issue from a user POV was I was looking
for some command to save/write the changes made. When I ended up just
quitting I saw the output

changes to the environments file have been saved

but it'd be nice to have some sort of feedback or specific 'save'
command in the UI.

https://codereview.appspot.com/50430043/

57. By Francesco Banconi

Changes as per review.

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

*** Submitted:

Integrate the env management functionality.

This branch enables the environment management
Urwid interactive session feature in quickstart,
but also includes some other improvements, described
below.

Exposed a way to let the user quickly
create and bootstrap a local env if no environments
are already configured. This is implemented
adding a closure (create_and_start_local_env) to the
env_index view: this could have been implemented
as a separate function as well (more easily testable,
less surprising), but I decided to stick with the
pattern used by the other views for now.

Changed the env_edit view so that the first created
environment is automatically set as default.

Renamed manage._validate_env to manage._setup_env:
the function now does a lot more than validation, e.g.
it lunches the interactive session, handles the
case when the Juju home is not yet configured, etc.
This function also sets up a save_callable to be used
by views. I could have used partial to create such a
function, but I decided to implement a HO
_create_save_callable that can be easily be extended
later in order to enable the backup functionality.

Bumped version up.

The diff is very long, my apologies, but you can safely
ignore deleted code:
- the demo application, no longer required;
- the ensure_environments function + its tests;
- envs.parse_env_file + its tests (
   replaced by envs.load + envs.validate).
Moreover, the env_type_db definition in
quickstart.models.envs is just a copy/paste replacement
of the original one: the only difference is that
an ordered dict is used in place of the usual dict.
This change has been introduced so that views
can list the supported environment types in the order
they are defined.

Tests: `make check`.

QA: if you have time, given the amount of code
enabled by this branch, and given the differences
in each one Juju configuration, I'd appreciate both
reviewers to QA the interactive session.
The instructions follow, thank you very much!

- Run `make`.

- Backup your Juju home:
   `mv ~/.juju ~/juju-home-backup`
   This way we can simulate Juju is not configured at all.

- Run `.venv/bin/python juju-quickstart`.
   Quickstart should welcome you and propose, among
   other things, to automatically create and bootstrap
   a local environment. Do that, provide your sudo
   password if requested, wait until the process completes
   and the GUI opens in your default browser as usual.
   This can take several minutes based on the sanity of your
   LXC configuration. Before opening the browser, you should
   see instructions on re-running quickstart to reopen
   and log in to the GUI later or restarting the interactive
   session. Also note that an admin password has been
   automatically generated and printed to stdout.

- Run `.venv/bin/python juju-quickstart`.
   This time no interactive session is started, the application
   recognizes the environment is already bootstrapped and
   quickly reopens the browser and logs in to the GUI.

- Run `cat ~/.juju/environments.yaml`.
   Check the generated environment file looks sane.

- Destroy the local environment:
   `sudo juju destroy-environment -e local -y`

- Run `.venv/b...

Read more...

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

On 2014/01/10 18:05:45, benji1 wrote:
> LGTM with one tiny suggestion.

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

https://codereview.appspot.com/50430043/diff/1/quickstart/manage.py#newcode211
> quickstart/manage.py:211: options.interactive = interactive
> This function has gotten a bit big. I wonder if a nice refactoring
would be one
> that made this function contain no conditionals but instead called
four or five
> subfunctions.

Done. Thank you both!

https://codereview.appspot.com/50430043/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== removed file 'cli-app-demo.py'
2--- cli-app-demo.py 2013-12-19 10:04:19 +0000
3+++ cli-app-demo.py 1970-01-01 00:00:00 +0000
4@@ -1,57 +0,0 @@
5-#!.venv/bin/python
6-
7-# This file is part of the Juju Quickstart Plugin, which lets users set up a
8-# Juju environment in very few steps (https://launchpad.net/juju-quickstart).
9-# Copyright (C) 2013 Canonical Ltd.
10-#
11-# This program is free software: you can redistribute it and/or modify it under
12-# the terms of the GNU Affero General Public License version 3, as published by
13-# the Free Software Foundation.
14-#
15-# This program is distributed in the hope that it will be useful, but WITHOUT
16-# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY,
17-# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
18-# Affero General Public License for more details.
19-#
20-# You should have received a copy of the GNU Affero General Public License
21-# along with this program. If not, see <http://www.gnu.org/licenses/>.
22-
23-"""Juju Quickstart CLI application demo."""
24-
25-# XXX frankban (2013-12-16): this file is only for demonstration/QA purposes.
26-# Remove this file when the environment management integration is completed.
27-# Run "make" before running this file.
28-
29-from __future__ import (
30- print_function,
31- unicode_literals,
32-)
33-
34-import pprint
35-
36-from quickstart.cli import views
37-from quickstart.models import envs
38-from quickstart.tests import helpers
39-
40-
41-def main():
42- """Start the Quickstart environments list view."""
43- env_type_db = envs.get_env_type_db()
44- # This simulates an env database as returned by envs.load().
45- env_db = helpers.make_env_db(default='lxc')
46- save_callable = lambda db: None
47- # Show the view.
48- new_env_db, env_data = views.show(
49- views.env_index, env_type_db, env_db, save_callable)
50- if new_env_db != env_db:
51- print('saved a new env db')
52- pprint.pprint(new_env_db)
53- print('default: {}'.format(new_env_db.get('default')))
54- if env_data is None:
55- print('no environment selected')
56- else:
57- print('selected: {}'.format(env_data['name']))
58-
59-
60-if __name__ == '__main__':
61- main()
62
63=== modified file 'quickstart/__init__.py'
64--- quickstart/__init__.py 2013-12-06 10:07:40 +0000
65+++ quickstart/__init__.py 2014-01-13 15:31:13 +0000
66@@ -22,7 +22,7 @@
67 from __future__ import unicode_literals
68
69
70-VERSION = (0, 5, 0)
71+VERSION = (0, 6, 0)
72
73
74 def get_version():
75
76=== modified file 'quickstart/app.py'
77--- quickstart/app.py 2014-01-03 11:38:43 +0000
78+++ quickstart/app.py 2014-01-13 15:31:13 +0000
79@@ -208,25 +208,6 @@
80 print('a new ssh key was generated in {}'.format(key_file))
81
82
83-def ensure_environments():
84- """Ensure that the environments file exists.
85-
86- Return the env_name argument unchanged if the environment file exists;
87- however, since the other environment types require additional work to
88- set up, return 'local' if juju init is run.
89- """
90- print('environments file not found; running juju init')
91- retcode, _, error = utils.call('juju', 'init')
92- if retcode:
93- raise ProgramExit(error)
94- print('defaulting to local environment for a new environments file')
95- env_name = 'local'
96- retcode, _, error = utils.call('juju', 'switch', env_name)
97- if retcode:
98- raise ProgramExit(error)
99- return env_name
100-
101-
102 def bootstrap(env_name, is_local=False, debug=False):
103 """Bootstrap the Juju environment with the given name.
104
105
106=== modified file 'quickstart/cli/views.py'
107--- quickstart/cli/views.py 2014-01-07 15:53:57 +0000
108+++ quickstart/cli/views.py 2014-01-13 15:31:13 +0000
109@@ -156,13 +156,50 @@
110 envs.get_env_data(env_db, env_name)
111 for env_name in env_db['environments']
112 ], key=operator.itemgetter('name'))
113+
114+ def create_and_start_local_env():
115+ # Automatically create and use a local environment named "local".
116+ # This closure can only be called when there are no environments in the
117+ # database. For this reason, the new environment is set as default.
118+ # Exit the interactive session selecting the newly created environment.
119+ env_data = envs.create_local_env_data(
120+ env_type_db, 'local', is_default=True)
121+ # Add the new environment to the environments database.
122+ envs.set_env_data(env_db, None, env_data)
123+ save_callable(env_db)
124+ # Use the newly created environment.
125+ raise ui.AppExit((env_db, env_data))
126+
127 if environments:
128 title = 'Select an existing Juju environment or create a new one'
129+ widgets = []
130 else:
131 title = 'No Juju environments already set up: please create one'
132+ widgets = [
133+ urwid.Text([
134+ ('highlight', 'Welcome to Juju Quickstart!'),
135+ '\nYou can use this interactive session to manage your Juju '
136+ 'environments.\nInteractive mode has been automatically '
137+ 'started because no environments have been found. After '
138+ 'creating your first environment you can start Juju '
139+ 'Quickstart in interactive mode again by passing the -i flag, '
140+ 'e.g.:\n',
141+ ('highlight', '$ juju quickstart -i'),
142+ '\n\nAt the bottom of the page you can find links to manually '
143+ 'create new environments. If you instead prefer to quickly '
144+ 'start your Juju experience in a local environment (LXC), '
145+ 'just click the link below:'
146+ ]),
147+ ui.MenuButton(
148+ '\N{BULLET} automatically create and bootstrap a local '
149+ 'environment', ui.thunk(create_and_start_local_env)),
150+ ]
151 app.set_title(title)
152 # Start creating the page contents: a list of selectable environments.
153- widgets = []
154+ # Wouldn't it be nice if we were able to highlight in some way the
155+ # currently running environments? Unfortunately this requires calling
156+ # "juju status" for each environment in the list, which is expensive and
157+ # time consuming.
158 focus_position = None
159 errors_found = default_found = False
160 for position, env_data in enumerate(environments):
161@@ -376,6 +413,9 @@
162 # Without errors, normalize the new values, update the env_db and save
163 # the resulting environments database.
164 env_data = envs.normalize(env_metadata, new_env_data)
165+ # If this is the only environment in the db, set it as the default one.
166+ if not env_db['environments']:
167+ env_data['is-default'] = True
168 envs.set_env_data(env_db, initial_name, env_data)
169 save_callable(env_db)
170 verb = 'modified' if exists else 'created'
171
172=== modified file 'quickstart/manage.py'
173--- quickstart/manage.py 2014-01-03 11:38:43 +0000
174+++ quickstart/manage.py 2014-01-13 15:31:13 +0000
175@@ -34,6 +34,7 @@
176 settings,
177 utils,
178 )
179+from quickstart.cli import views
180 from quickstart.models import (
181 charms,
182 envs,
183@@ -134,35 +135,124 @@
184 'revision: {}'.format(charm))
185
186
187-def _validate_env(options, parser):
188- """Validate and process the provided environment related options.
189+def _retrieve_env_db(parser, env_file=None):
190+ """Retrieve the environment database (or create an in-memory empty one)."""
191+ if env_file is None:
192+ return envs.create_empty_env_db()
193+ try:
194+ return envs.load(env_file)
195+ except ValueError as err:
196+ return parser.error(bytes(err))
197+
198+
199+def _create_save_callable(parser, env_file):
200+ """Return a function that can be used to save an env_db to the env_file.
201+
202+ The returned function is used as save_callable by the environments
203+ management views.
204+
205+ The resulting function uses the given parser instance to exit the
206+ application with an error if an OSError exception is raised while saving
207+ the environments database.
208+ """
209+ def save_callable(env_db):
210+ try:
211+ envs.save(env_file, env_db)
212+ except OSError as err:
213+ return parser.error(bytes(err))
214+
215+ return save_callable
216+
217+
218+def _start_interactive_session(parser, env_type_db, env_db, env_file):
219+ """Start the Urwid interactive session.
220+
221+ Return the env_data corresponding to the user selected environment.
222+ Exit the application if the user exits the interactive session without
223+ selecting an environment to start.
224+ """
225+ save_callable = _create_save_callable(parser, env_file)
226+ new_env_db, env_data = views.show(
227+ views.env_index, env_type_db, env_db, save_callable)
228+ if new_env_db != env_db:
229+ print('changes to the environments file have been saved')
230+ if env_data is None:
231+ # The user exited the interactive session without selecting an
232+ # environment to start: this means this was just an environment
233+ # editing session and we can just quit now.
234+ return sys.exit('quitting')
235+ return env_data
236+
237+
238+def _retrieve_env_data(parser, env_type_db, env_db, env_name):
239+ """Retrieve and return the env_data corresponding to the given env_name.
240+
241+ Invoke a parser error if the environment does not exist or is not valid.
242+ """
243+ try:
244+ env_data = envs.get_env_data(env_db, env_name)
245+ except ValueError as err:
246+ # The specified environment does not exist.
247+ return parser.error(bytes(err))
248+ env_metadata = envs.get_env_metadata(env_type_db, env_data)
249+ errors = envs.validate(env_metadata, env_data)
250+ if errors:
251+ msg = 'cannot use the {} environment:\n{}'.format(
252+ env_name, '\n'.join(errors.values()))
253+ return parser.error(msg.encode('utf-8'))
254+ return env_data
255+
256+
257+def _setup_env(options, parser):
258+ """Set up, validate and process the provided environment related options.
259+
260+ Also start the environments management interactive session if required.
261
262 Exit with an error if options are not valid.
263 """
264- logging.debug('ensuring juju environments available')
265+ logging.debug('setting up juju environments')
266+ env_name = options.env_name
267 env_file = os.path.abspath(os.path.expanduser(options.env_file))
268+ interactive = options.interactive
269+ env_file_exists = os.path.exists(env_file)
270+ if not env_file_exists:
271+ # If the Juju home is not set up, force the interactive mode and ignore
272+ # the user provided env name.
273+ interactive = True
274+ env_name = None
275 # Validate the environment name.
276- env_name = options.env_name
277- if env_name is None:
278+ if env_name is None and not interactive:
279+ # The user forced non-interactive mode but a default env name cannot
280+ # be retrieved. In this case, just exit with an error.
281 return parser.error(
282 'unable to find an environment name to use\n'
283- 'It is possible to specify the environment name by either:\n'
284+ 'It is possible to specify the environment to use by either:\n'
285+ ' - selecting one from the quickstart interactive session,\n'
286+ ' i.e. juju quickstart -i;\n'
287 ' - passing the -e or --environment argument;\n'
288 ' - setting the JUJU_ENV environment variable;\n'
289 ' - using "juju switch" to select the default environment;\n'
290 ' - setting the default environment in {}.'.format(env_file)
291 )
292- # Validate the environment file.
293- try:
294- env_type, admin_secret, default_series = (
295- envs.parse_env_file(env_file, env_name))
296- except ValueError as err:
297- return parser.error(bytes(err))
298+ # Retrieve the environment database (or create an in-memory empty one).
299+ env_db = _retrieve_env_db(parser, env_file if env_file_exists else None)
300+ # Validate the environment.
301+ env_type_db = envs.get_env_type_db()
302+ if interactive:
303+ # Start the interactive session.
304+ env_data = _start_interactive_session(
305+ parser, env_type_db, env_db, env_file)
306+ else:
307+ # This is a non-interactive session and we need to validate the
308+ # selected environment before proceeding.
309+ env_data = _retrieve_env_data(parser, env_type_db, env_db, env_name)
310 # Update the options namespace with the new values.
311- options.admin_secret = admin_secret
312+ options.admin_secret = env_data['admin-secret']
313 options.env_file = env_file
314- options.env_type = env_type
315- options.default_series = default_series
316+ options.env_name = env_data['name']
317+ options.env_type = env_data['type']
318+ options.default_series = env_data.get('default-series')
319+ options.interactive = interactive
320
321
322 def _configure_logging(level):
323@@ -204,6 +294,7 @@
324 - env_file: the absolute path of the Juju environments.yaml file;
325 - env_name: the name of the Juju environment to use;
326 - env_type: the provider type of the selected Juju environment;
327+ - interactive: whether to start the interactive session;
328 - open_browser: whether the GUI browser must be opened.
329
330 The following attributes will also be included in the namespace if a bundle
331@@ -219,11 +310,6 @@
332 default_env_name = envs.get_default_env_name()
333 # Define the help message for the --environment option.
334 env_help = 'The name of the Juju environment to use'
335- # XXX 2013-12-02 makyo:
336- # ensure_environments will be removed with more robust environment
337- # management code in the next few weeks.
338- if default_env_name is None:
339- default_env_name = app.ensure_environments()
340 if default_env_name is not None:
341 env_help = '{} (%(default)s)'.format(env_help)
342 # Create and set up the arguments parser.
343@@ -245,6 +331,9 @@
344 'required if the bundle YAML/JSON only contains one bundle. This '
345 'option is ignored if the bundle file is not specified')
346 parser.add_argument(
347+ '-i', '--interactive', action='store_true', dest='interactive',
348+ help='Start the environments management interactive session')
349+ parser.add_argument(
350 '--environments-file', dest='env_file',
351 default=os.path.join(settings.JUJU_HOME, 'environments.yaml'),
352 help='The path to the Juju environments YAML file (%(default)s)')
353@@ -274,7 +363,7 @@
354 # Convert the provided string arguments to unicode.
355 _convert_options_to_unicode(options)
356 # Validate and process the provided arguments.
357- _validate_env(options, parser)
358+ _setup_env(options, parser)
359 if options.bundle is not None:
360 _validate_bundle(options, parser)
361 if options.charm_url is not None:
362@@ -336,4 +425,11 @@
363 url += '/?authtoken={}'.format(token)
364 webbrowser.open(url)
365 gui_env.close()
366- print('done!')
367+ print(
368+ 'done!\n\n'
369+ 'Run "juju quickstart -e {}" again if you want\n'
370+ 'to reopen and log in to the GUI browser later.\n'
371+ 'Run "juju quickstart -i" if you want to manage\n'
372+ 'or bootstrap your Juju environments using the\n'
373+ 'interactive session.'.format(options.env_name)
374+ )
375
376=== modified file 'quickstart/models/envs.py'
377--- quickstart/models/envs.py 2014-01-07 15:41:55 +0000
378+++ quickstart/models/envs.py 2014-01-13 15:31:13 +0000
379@@ -74,9 +74,6 @@
380 The set_env_data function, as seen above, needs to be passed the original name
381 of the environment being modified. If None is passed, that means we are adding
382 a new environment.
383-
384-XXX frankban 13-12-08: remove the parse_env_file function: this can be done
385- when activating models code in manage.py.
386 """
387
388 from __future__ import unicode_literals
389@@ -129,6 +126,11 @@
390 return match.groups()[0]
391
392
393+def create_empty_env_db():
394+ """Create and return an empty environments database."""
395+ return {'environments': {}}
396+
397+
398 def load(env_file):
399 """Load and parse the provided Juju environments.yaml file.
400
401@@ -164,6 +166,8 @@
402 except Exception as err:
403 msg = b'unable to parse environments file {}: {}'
404 raise ValueError(msg.format(env_file.encode('utf-8'), err))
405+ if contents is None:
406+ return create_empty_env_db()
407 # Retrieve the environment list.
408 try:
409 env_contents = contents.get('environments', {}).items()
410@@ -181,7 +185,7 @@
411 default = contents.get('default')
412 if default in environments:
413 env_db['default'] = default
414- else:
415+ elif default is not None:
416 logging.warn('excluding invalid default {}'.format(default))
417 return env_db
418
419@@ -198,6 +202,7 @@
420 # and destination files are on different file systems, use for the
421 # temporary file the same directory where the env_file is stored.
422 dirname = os.path.dirname(env_file)
423+ utils.mkdir(dirname)
424 banner = utils.get_quickstart_banner()
425 try:
426 temp_file = tempfile.NamedTemporaryFile(
427@@ -245,7 +250,8 @@
428 try:
429 info = env_db['environments'][env_name]
430 except KeyError:
431- raise ValueError(b'environment {!r} not found'.format(env_name))
432+ msg = 'environment {} not found'.format(env_name)
433+ raise ValueError(msg.encode('utf-8'))
434 # Why not just use env_data.copy()? Because this way internal mutable data
435 # structures are preserved, even if they are unlikely to be found.
436 env_data = copy.deepcopy(info)
437@@ -296,6 +302,25 @@
438 del env_db['default']
439
440
441+def create_local_env_data(env_type_db, name, is_default=False):
442+ """Create and return an local (LXC) env_data.
443+
444+ Local environments' fields (except for name and type) are assumed to be
445+ either optional or suitable for automatic generation of their values. For
446+ this reason local environments can be safely created given just their name.
447+ """
448+ env_data = {'type': 'local', 'name': name, 'is-default': is_default}
449+ env_metadata = get_env_metadata(env_type_db, env_data)
450+ # Retrieve a list of missing required fields.
451+ missing_fields = [
452+ field for field in env_metadata['fields']
453+ if field.required and field.name not in env_data
454+ ]
455+ # Assume all missing fields can be automatically generated.
456+ env_data.update((field.name, field.generate()) for field in missing_fields)
457+ return env_data
458+
459+
460 def remove_env(env_db, env_name):
461 """Remove the environment named env_name from the environments database.
462
463@@ -347,80 +372,9 @@
464 )
465 # Define the env_type_db dictionary: this is done inside this function in
466 # order to avoid instantiating fields at import time.
467- env_type_db = {
468- 'ec2': {
469- 'description': (
470- 'The ec2 provider enable you to run Juju on the EC2 cloud. '
471- 'This process requires you to have an Amazon Web Services '
472- '(AWS) account. If you have not signed up for one yet, it '
473- 'can obtained at http://aws.amazon.com. '
474- 'See https://juju.ubuntu.com/docs/config-aws.html for more '
475- 'details on the ec2 provider configuration.'
476- ),
477- 'fields': (
478- provider_field,
479- name_field,
480- admin_secret_field,
481- default_series_field,
482- fields.PasswordField(
483- 'access-key', label='access key', required=True,
484- help='The access key to use to authenticate to AWS. '
485- 'You can retrieve these values easily from your AWS '
486- 'Management Console (http://console.aws.amazon.com). '
487- 'Click on your name in the top-right and then the '
488- '"Security Credentials" link from the drop down '
489- 'menu. Under the "Access Keys" heading click the '
490- '"Create New Root Key" button. You will be prompted '
491- 'to "Download Key File" which by default is named '
492- 'rootkey.csv. Open this file to get the access-key '
493- 'and secret-key for the environments.yaml '
494- 'configuration file.'),
495- fields.PasswordField(
496- 'secret-key', label='secret key', required=True,
497- help='The secret key to use to authenticate to AWS. '
498- 'See the access key help above.'),
499- fields.AutoGeneratedStringField(
500- 'control-bucket', label='control bucket', required=True,
501- help='the globally unique S3 bucket name'),
502- fields.ChoiceField(
503- 'region', choices=ec2_regions, default='us-east-1',
504- label='region', required=False,
505- help='the ec2 region to use'),
506- is_default_field,
507- ),
508- },
509- 'local': {
510- 'description': (
511- 'The LXC local provider enables you to run Juju on a single '
512- 'system like your local computer or a single server. '
513- 'See https://juju.ubuntu.com/docs/config-LXC.html for more '
514- 'details on the local provider configuration.'
515- ),
516- 'fields': (
517- provider_field,
518- name_field,
519- admin_secret_field,
520- default_series_field,
521- fields.StringField(
522- 'root-dir', label='root dir', required=False,
523- default='~/.juju/local',
524- help='the directory that is used for the storage files'),
525- fields.IntField(
526- 'storage-port', min_value=1, max_value=65535,
527- label='storage port', required=False, default=8040,
528- help='override if you have multiple local providers, '
529- 'or if the default port is used by another program'),
530- fields.IntField(
531- 'shared-storage-port', min_value=1, max_value=65535,
532- label='shared storage port', required=False, default=8041,
533- help='override if you have multiple local providers, '
534- 'or if the default port is used by another program'),
535- fields.StringField(
536- 'network-bridge', label='network bridge', required=False,
537- default='lxcbr0', help='the LXC bridge interface to use'),
538- is_default_field,
539- ),
540- },
541+ # This is an ordered dict so that views can expose options to create new
542+ # environments in the order we like.
543+ env_type_db = collections.OrderedDict({
544 '__fallback__': {
545 'description': (
546 'This provider type is not supported by quickstart. '
547@@ -434,7 +388,78 @@
548 default_series_field,
549 is_default_field,
550 ),
551- }
552+ },
553+ })
554+ env_type_db['ec2'] = {
555+ 'description': (
556+ 'The ec2 provider enable you to run Juju on the EC2 cloud. '
557+ 'This process requires you to have an Amazon Web Services (AWS) '
558+ 'account. If you have not signed up for one yet, it can obtained '
559+ 'at http://aws.amazon.com. '
560+ 'See https://juju.ubuntu.com/docs/config-aws.html for more '
561+ 'details on the ec2 provider configuration.'
562+ ),
563+ 'fields': (
564+ provider_field,
565+ name_field,
566+ admin_secret_field,
567+ default_series_field,
568+ fields.PasswordField(
569+ 'access-key', label='access key', required=True,
570+ help='The access key to use to authenticate to AWS. '
571+ 'You can retrieve these values easily from your AWS '
572+ 'Management Console (http://console.aws.amazon.com). '
573+ 'Click on your name in the top-right and then the '
574+ '"Security Credentials" link from the drop down menu. '
575+ 'Under the "Access Keys" heading click the '
576+ '"Create New Root Key" button. You will be prompted to '
577+ '"Download Key File" which by default is named '
578+ 'rootkey.csv. Open this file to get the access-key and '
579+ 'secret-key for the environments.yaml config file.'),
580+ fields.PasswordField(
581+ 'secret-key', label='secret key', required=True,
582+ help='The secret key to use to authenticate to AWS. '
583+ 'See the access key help above.'),
584+ fields.AutoGeneratedStringField(
585+ 'control-bucket', label='control bucket', required=True,
586+ help='the globally unique S3 bucket name'),
587+ fields.ChoiceField(
588+ 'region', choices=ec2_regions, default='us-east-1',
589+ label='region', required=False, help='the ec2 region to use'),
590+ is_default_field,
591+ ),
592+ }
593+ env_type_db['local'] = {
594+ 'description': (
595+ 'The LXC local provider enables you to run Juju on a single '
596+ 'system like your local computer or a single server. '
597+ 'See https://juju.ubuntu.com/docs/config-LXC.html for more '
598+ 'details on the local provider configuration.'
599+ ),
600+ 'fields': (
601+ provider_field,
602+ name_field,
603+ admin_secret_field,
604+ default_series_field,
605+ fields.StringField(
606+ 'root-dir', label='root dir', required=False,
607+ default='~/.juju/local',
608+ help='the directory that is used for the storage files'),
609+ fields.IntField(
610+ 'storage-port', min_value=1, max_value=65535,
611+ label='storage port', required=False, default=8040,
612+ help='override if you have multiple local providers, '
613+ 'or if the default port is used by another program'),
614+ fields.IntField(
615+ 'shared-storage-port', min_value=1, max_value=65535,
616+ label='shared storage port', required=False, default=8041,
617+ help='override if you have multiple local providers, '
618+ 'or if the default port is used by another program'),
619+ fields.StringField(
620+ 'network-bridge', label='network bridge', required=False,
621+ default='lxcbr0', help='the LXC bridge interface to use'),
622+ is_default_field,
623+ ),
624 }
625 return env_type_db
626
627@@ -534,54 +559,3 @@
628 if info_parts:
629 info = ' ({})'.format(', '.join(info_parts))
630 return env_data['name'] + info
631-
632-
633-def parse_env_file(env_file, env_name):
634- """Parse the provided Juju environments.yaml file.
635-
636- Return a tuple containing:
637- - provider type
638- - admin secret
639- - default series (None if not set)
640-
641- Raise a ValueError if:
642- - the environment file is not found;
643- - the environment file contents are not parsable by YAML;
644- - the YAML contents are not properly structured;
645- - the environment named envname is not found;
646- - the environment does not include the "type" field;
647- - the environment does not include the "admin_secret" field.
648- """
649- # Load the Juju environments file.
650- try:
651- environments_file = open(env_file.encode('utf-8'))
652- except IOError as err:
653- msg = b'unable to open environments file: {}'.format(err)
654- raise ValueError(msg)
655- # Parse the Juju environments file.
656- try:
657- environments = serializers.yaml_load(environments_file)
658- except Exception as err:
659- msg = b'unable to parse environments file {}: {}'
660- raise ValueError(msg.format(env_file.encode('utf-8'), err))
661- # Retrieve the information about the current environment.
662- try:
663- environment = environments.get('environments', {}).get(env_name)
664- except AttributeError as err:
665- msg = 'invalid YAML contents in {}: {}'.format(env_file, environments)
666- raise ValueError(msg.encode('utf-8'))
667- if environment is None:
668- msg = 'environment {} not found in {}'.format(env_name, env_file)
669- raise ValueError(msg.encode('utf-8'))
670- # Retrieve the provider type and the admin secret.
671- env_type = environment.get('type')
672- if env_type is None:
673- msg = '{} provider type not found in {}'.format(env_name, env_file)
674- raise ValueError(msg.encode('utf-8'))
675- admin_secret = environment.get('admin-secret')
676- if admin_secret is None:
677- msg = '{} admin secret not found in {}'.format(env_name, env_file)
678- raise ValueError(msg.encode('utf-8'))
679- # The default-series is not required to be set so None is ok.
680- default_series = environment.get('default-series')
681- return env_type, admin_secret, default_series
682
683=== modified file 'quickstart/tests/cli/test_views.py'
684--- quickstart/tests/cli/test_views.py 2014-01-07 15:53:57 +0000
685+++ quickstart/tests/cli/test_views.py 2014-01-13 15:31:13 +0000
686@@ -216,6 +216,28 @@
687 # loop cycle.
688 mock_env_edit.reset_mock()
689
690+ def test_create_and_bootstrap_local_environment_clicked(self):
691+ # When there are no environments in the env_db the view exposes an
692+ # option to automatically create and bootstrap a new local environment.
693+ # If that option is clicked, the view quits the application returning
694+ # the newly created env_data.
695+ env_db = envs.create_empty_env_db()
696+ views.env_index(self.app, self.env_type_db, env_db, self.save_callable)
697+ buttons = self.get_widgets_in_contents(
698+ filter_function=self.is_a(ui.MenuButton))
699+ # The "create and bootstrap" button is the first one in the contents.
700+ create_button = buttons[0]
701+ self.assertEqual(
702+ '\N{BULLET} automatically create and bootstrap a local '
703+ 'environment', cli_helpers.get_button_caption(create_button))
704+ # An AppExit is raised clicking the button.
705+ with self.assertRaises(ui.AppExit) as context_manager:
706+ cli_helpers.emit(create_button)
707+ new_env_db, env_data = context_manager.exception.return_value
708+ # The environments database is no longer empty.
709+ self.assertIn('local', new_env_db['environments'])
710+ self.assertEqual(envs.get_env_data(new_env_db, 'local'), env_data)
711+
712 def test_selected_environment(self):
713 # The default environment is already selected in the list.
714 env_db = helpers.make_env_db(default='lxc')
715@@ -604,6 +626,26 @@
716 self.app, self.env_type_db, self.env_db, self.save_callable,
717 new_env_data)
718
719+ @mock.patch('quickstart.cli.views.env_detail')
720+ def test_save_empty_db(self, mock_env_detail):
721+ # If the env_db is empty, the new environment is set as default.
722+ self.env_db = envs.create_empty_env_db()
723+ changes = {
724+ 'name': 'lxc',
725+ 'admin-secret': 'Secret!',
726+ 'is-default': False,
727+ }
728+ with self.patch_create_form(changes=changes):
729+ self.call_view(env_type='local')
730+ save_button = self.get_control_buttons()[0]
731+ cli_helpers.emit(save_button)
732+ expected_new_env_data = changes.copy()
733+ expected_new_env_data.update({'type': 'local', 'is-default': True})
734+ envs.set_env_data(self.env_db, None, expected_new_env_data)
735+ mock_env_detail.assert_called_once_with(
736+ self.app, self.env_type_db, self.env_db, self.save_callable,
737+ expected_new_env_data)
738+
739 def test_save_invalid_form_data(self):
740 # Errors are displayed if the user tries to save invalid data.
741 changes = {'admin-secret': ''}
742
743=== modified file 'quickstart/tests/helpers.py'
744--- quickstart/tests/helpers.py 2013-12-19 08:52:34 +0000
745+++ quickstart/tests/helpers.py 2014-01-13 15:31:13 +0000
746@@ -109,7 +109,10 @@
747 'aws': {
748 'admin-secret': 'Secret!',
749 'type': 'ec2',
750- 'default-series': 'edgy',
751+ 'default-series': 'saucy',
752+ 'access-key': 'AccessKey',
753+ 'secret-key': 'SeceretKey',
754+ 'control-bucket': 'ControlBucket',
755 },
756 },
757 })
758
759=== modified file 'quickstart/tests/models/test_envs.py'
760--- quickstart/tests/models/test_envs.py 2013-12-20 14:47:35 +0000
761+++ quickstart/tests/models/test_envs.py 2014-01-13 15:31:13 +0000
762@@ -83,6 +83,14 @@
763 mock_call.assert_called_once_with('juju', 'switch')
764
765
766+class TestCreateEmptyEnvDb(unittest.TestCase):
767+
768+ def test_resulting_env_db(self):
769+ # The function surprisingly returns an empty environments database.
770+ env_db = envs.create_empty_env_db()
771+ self.assertEqual({'environments': {}}, env_db)
772+
773+
774 class TestLoad(
775 helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin,
776 unittest.TestCase):
777@@ -104,6 +112,12 @@
778 expected = 'unable to parse environments file {}'.format(env_file)
779 self.assertIn(expected, bytes(context_manager.exception))
780
781+ def test_empty_file(self):
782+ # An empty environments database is returned if the file is empty.
783+ env_file = self.make_env_file('')
784+ env_db = envs.load(env_file)
785+ self.assertEqual({'environments': {}}, env_db)
786+
787 def test_invalid_yaml_contents(self):
788 # A ValueError is raised if the environments file is not well formed.
789 env_file = self.make_env_file('a-string')
790@@ -258,7 +272,7 @@
791 def test_env_not_found(self):
792 # A ValueError is raised if an environment with the given name is not
793 # found in the environments dictionary.
794- with self.assert_value_error("environment u'no-such' not found"):
795+ with self.assert_value_error("environment no-such not found"):
796 envs.get_env_data(self.env_db, 'no-such')
797
798 def test_resulting_env_data(self):
799@@ -481,6 +495,35 @@
800 self.assertNotIn('default', self.env_db)
801
802
803+class TestCreateLocalEnvData(unittest.TestCase):
804+
805+ def setUp(self):
806+ # Store the env_type_db.
807+ self.env_type_db = envs.get_env_type_db()
808+
809+ def test_not_default(self):
810+ # The resulting env_data is correctly structured for non default envs.
811+ env_data = envs.create_local_env_data(
812+ self.env_type_db, 'my-lxc', is_default=False)
813+ # The function is not pure: auto-generated values change each time the
814+ # function is called. For local environments, the only auto-generated
815+ # value should be the admin-secret.
816+ admin_secret = env_data.pop('admin-secret', '')
817+ self.assertNotEqual(0, len(admin_secret))
818+ expected = {'type': 'local', 'name': 'my-lxc', 'is-default': False}
819+ self.assertEqual(expected, env_data)
820+
821+ def test_default(self):
822+ # The resulting env_data is correctly structured for default envs.
823+ env_data = envs.create_local_env_data(
824+ self.env_type_db, 'my-default', is_default=True)
825+ # See the comment about auto-generated fields in the test method above.
826+ admin_secret = env_data.pop('admin-secret', '')
827+ self.assertNotEqual(0, len(admin_secret))
828+ expected = {'type': 'local', 'name': 'my-default', 'is-default': True}
829+ self.assertEqual(expected, env_data)
830+
831+
832 class TestRemoveEnv(
833 EnvDataTestsMixin, helpers.ValueErrorTestsMixin, unittest.TestCase):
834
835@@ -913,81 +956,3 @@
836 env_data = {'name': 'lxc', 'type': None, 'is-default': True}
837 description = envs.get_env_short_description(env_data)
838 self.assertEqual('lxc (default)', description)
839-
840-
841-class TestParseEnvFile(
842- helpers.EnvFileTestsMixin, helpers.ValueErrorTestsMixin,
843- unittest.TestCase):
844-
845- def test_no_file(self):
846- # A ValueError is raised if the environments file is not found.
847- expected = (
848- 'unable to open environments file: '
849- "[Errno 2] No such file or directory: '/no/such/file.yaml'"
850- )
851- with self.assert_value_error(expected):
852- envs.parse_env_file('/no/such/file.yaml', 'ec2')
853-
854- def test_invalid_yaml(self):
855- # A ValueError is raised if the environments file is not a valid YAML.
856- env_file = self.make_env_file(':')
857- with self.assertRaises(ValueError) as context_manager:
858- envs.parse_env_file(env_file, 'ec2')
859- expected = 'unable to parse environments file {}'.format(env_file)
860- self.assertIn(expected, bytes(context_manager.exception))
861-
862- def test_invalid_yaml_contents(self):
863- # A ValueError is raised if the environments file is not well formed.
864- env_file = self.make_env_file('a-string')
865- expected = 'invalid YAML contents in {}: a-string'.format(env_file)
866- with self.assert_value_error(expected):
867- envs.parse_env_file(env_file, 'ec2')
868-
869- def test_no_env(self):
870- # A ValueError is raised if the environment is not found in the YAML.
871- contents = yaml.safe_dump({'environments': {'local': {}}})
872- env_file = self.make_env_file(contents)
873- expected = 'environment ec2 not found in {}'.format(env_file)
874- with self.assert_value_error(expected):
875- envs.parse_env_file(env_file, 'ec2')
876-
877- def test_no_provider_type(self):
878- # A ValueError is raised if the provider type is not included in the
879- # environment info.
880- contents = yaml.safe_dump({'environments': {'aws': {}}})
881- env_file = self.make_env_file(contents)
882- expected = 'aws provider type not found in {}'.format(env_file)
883- with self.assert_value_error(expected):
884- envs.parse_env_file(env_file, 'aws')
885-
886- def test_no_admin_secret(self):
887- # A ValueError is raised if the admin secret is not included in the
888- # environment info.
889- contents = yaml.safe_dump({'environments': {'aws': {'type': 'ec2'}}})
890- env_file = self.make_env_file(contents)
891- expected = 'aws admin secret not found in {}'.format(env_file)
892- with self.assert_value_error(expected):
893- envs.parse_env_file(env_file, 'aws')
894-
895- def test_success_no_default_series(self):
896- # The environment provider type and admin secret are returned.
897- # Default series is None.
898- contents = yaml.safe_dump({
899- 'environments': {'aws': {'admin-secret': 'Secret!', 'type': 'ec2'}}
900- })
901- env_file = self.make_env_file(contents)
902- env_type, admin_secret, default_series = (
903- envs.parse_env_file(env_file, 'aws'))
904- self.assertEqual('ec2', env_type)
905- self.assertEqual('Secret!', admin_secret)
906- self.assertIsNone(default_series)
907-
908- def test_success_with_default_series(self):
909- # The environment provider type and admin secret are returned.
910- # Default series is also returned.
911- env_file = self.make_env_file()
912- env_type, admin_secret, default_series = (
913- envs.parse_env_file(env_file, 'aws'))
914- self.assertEqual('ec2', env_type)
915- self.assertEqual('Secret!', admin_secret)
916- self.assertEqual('edgy', default_series)
917
918=== modified file 'quickstart/tests/test_app.py'
919--- quickstart/tests/test_app.py 2013-12-19 03:29:39 +0000
920+++ quickstart/tests/test_app.py 2014-01-13 15:31:13 +0000
921@@ -377,40 +377,6 @@
922
923
924 @helpers.mock_print
925-class TestEnsureEnvironments(
926- helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):
927-
928- def test_inits(self, mock_print):
929- side_effects = (
930- (0, '', ''),
931- (0, '', ''),
932- )
933- with self.patch_multiple_calls(side_effects):
934- self.assertEqual(app.ensure_environments(), 'local')
935- mock_print.assert_has_calls([
936- mock.call('environments file not found; running juju init'),
937- mock.call('defaulting to local environment for a new '
938- 'environments file')
939- ])
940-
941- def test_failure(self, mock_print):
942- side_effects = (
943- (1, '', 'Error!'), # Fail on init.
944- (0, '', ''),
945- )
946- with self.assert_program_exit('Error!'):
947- with self.patch_multiple_calls(side_effects):
948- app.ensure_environments()
949- side_effects = (
950- (0, '', ''),
951- (1, '', 'Error!'), # Fail on switch.
952- )
953- with self.assert_program_exit('Error!'):
954- with self.patch_multiple_calls(side_effects):
955- app.ensure_environments()
956-
957-
958-@helpers.mock_print
959 class TestBootstrap(
960 helpers.CallTestsMixin, ProgramExitTestsMixin, unittest.TestCase):
961
962
963=== modified file 'quickstart/tests/test_manage.py'
964--- quickstart/tests/test_manage.py 2013-12-06 18:16:02 +0000
965+++ quickstart/tests/test_manage.py 2014-01-13 15:31:13 +0000
966@@ -19,6 +19,7 @@
967 from __future__ import unicode_literals
968
969 import argparse
970+from contextlib import contextmanager
971 import logging
972 import os
973 import shutil
974@@ -34,6 +35,8 @@
975 manage,
976 settings,
977 )
978+from quickstart.cli import views
979+from quickstart.models import envs
980 from quickstart.tests import helpers
981
982
983@@ -244,24 +247,182 @@
984 self.assertFalse(self.parser.error.called, charm_url)
985
986
987-class TestValidateEnv(helpers.EnvFileTestsMixin, unittest.TestCase):
988-
989- def setUp(self):
990- self.parser = mock.Mock()
991-
992- def make_options(self, env_file, env_name=None):
993+class TestRetrieveEnvDb(helpers.EnvFileTestsMixin, unittest.TestCase):
994+
995+ def setUp(self):
996+ self.parser = mock.Mock()
997+
998+ def test_existing_env_file(self):
999+ # The env_db is correctly retrieved from an existing environments file.
1000+ env_file = self.make_env_file()
1001+ env_db = manage._retrieve_env_db(self.parser, env_file=env_file)
1002+ self.assertEqual(yaml.safe_load(self.valid_contents), env_db)
1003+
1004+ def test_error_parsing_env_file(self):
1005+ # A parser error is invoked if an error occurs parsing the env file.
1006+ env_file = self.make_env_file('so-bad')
1007+ manage._retrieve_env_db(self.parser, env_file=env_file)
1008+ self.parser.error.assert_called_once_with(
1009+ 'invalid YAML contents in {}: so-bad'.format(env_file))
1010+
1011+ def test_missing_env_file(self):
1012+ # An empty env_db is returned if the environments file does not exist.
1013+ env_db = manage._retrieve_env_db(self.parser, env_file=None)
1014+ self.assertEqual(envs.create_empty_env_db(), env_db)
1015+
1016+
1017+@mock.patch('quickstart.manage.envs.save')
1018+class TestCreateSaveCallable(unittest.TestCase):
1019+
1020+ def setUp(self):
1021+ self.parser = mock.Mock()
1022+ self.env_file = '/tmp/envfile.yaml'
1023+ self.env_db = helpers.make_env_db()
1024+ self.save_callable = manage._create_save_callable(
1025+ self.parser, self.env_file)
1026+
1027+ def test_saved(self, mock_save):
1028+ # The returned function correctly saves the new environments database.
1029+ self.save_callable(self.env_db)
1030+ mock_save.assert_called_once_with(self.env_file, self.env_db)
1031+ self.assertFalse(self.parser.error.called)
1032+
1033+ def test_error(self, mock_save):
1034+ # The returned function uses the parser to exit the program if an error
1035+ # occurs while saving the new environments database.
1036+ mock_save.side_effect = OSError(b'bad wolf')
1037+ self.save_callable(self.env_db)
1038+ mock_save.assert_called_once_with(self.env_file, self.env_db)
1039+ self.parser.error.assert_called_once_with('bad wolf')
1040+
1041+
1042+class TestStartInteractiveSession(
1043+ helpers.EnvFileTestsMixin, unittest.TestCase):
1044+
1045+ def setUp(self):
1046+ # Set up a parser, the environments metadata, an environments file and
1047+ # a testing env_db.
1048+ self.parser = mock.Mock()
1049+ self.env_type_db = envs.get_env_type_db()
1050+ self.env_file = self.make_env_file()
1051+ self.env_db = envs.load(self.env_file)
1052+
1053+ @contextmanager
1054+ def patch_interactive_mode(self, env_db, return_value):
1055+ """Patch the quickstart.cli.views.show function.
1056+
1057+ Ensure the interactive mode is started by the code in the context block
1058+ passing the given env_db. Make the view return the given return_value.
1059+ """
1060+ create_save_callable_path = 'quickstart.manage._create_save_callable'
1061+ mock_show = mock.Mock(return_value=return_value)
1062+ with mock.patch(create_save_callable_path) as mock_save_callable:
1063+ with mock.patch('quickstart.manage.views.show', mock_show):
1064+ yield
1065+ mock_save_callable.assert_called_once_with(self.parser, self.env_file)
1066+ mock_show.assert_called_once_with(
1067+ views.env_index, self.env_type_db, env_db,
1068+ mock_save_callable())
1069+
1070+ def test_resulting_env_data(self):
1071+ # The interactive session can be used to select an environment, in
1072+ # which case the function returns the corresponding env_data.
1073+ env_data = envs.get_env_data(self.env_db, 'aws')
1074+ with self.patch_interactive_mode(self.env_db, [self.env_db, env_data]):
1075+ obtained_env_data = manage._start_interactive_session(
1076+ self.parser, self.env_type_db, self.env_db, self.env_file)
1077+ self.assertEqual(env_data, obtained_env_data)
1078+
1079+ @helpers.mock_print
1080+ def test_modified_environments(self, mock_print):
1081+ # The function informs the user that environments have been modified
1082+ # during the interactive session.
1083+ env_data = envs.get_env_data(self.env_db, 'aws')
1084+ new_env_db = helpers.make_env_db()
1085+ with self.patch_interactive_mode(self.env_db, [new_env_db, env_data]):
1086+ manage._start_interactive_session(
1087+ self.parser, self.env_type_db, self.env_db, self.env_file)
1088+ mock_print.assert_called_once_with(
1089+ 'changes to the environments file have been saved')
1090+
1091+ @mock.patch('sys.exit')
1092+ def test_interactive_mode_quit(self, mock_exit):
1093+ # If the user explicitly quits the interactive mode, the program exits
1094+ # without proceeding with the environment bootstrapping.
1095+ with self.patch_interactive_mode(self.env_db, [self.env_db, None]):
1096+ manage._start_interactive_session(
1097+ self.parser, self.env_type_db, self.env_db, self.env_file)
1098+ mock_exit.assert_called_once_with('quitting')
1099+
1100+
1101+class TestRetrieveEnvData(unittest.TestCase):
1102+
1103+ def setUp(self):
1104+ # Set up a parser, the environments metadata and a testing env_db.
1105+ self.parser = mock.Mock()
1106+ self.env_type_db = envs.get_env_type_db()
1107+ self.env_db = helpers.make_env_db()
1108+
1109+ def test_resulting_env_data(self):
1110+ # The env_data is correctly validated and returned.
1111+ expected_env_data = envs.get_env_data(self.env_db, 'lxc')
1112+ env_data = manage._retrieve_env_data(
1113+ self.parser, self.env_type_db, self.env_db, 'lxc')
1114+ self.assertEqual(expected_env_data, env_data)
1115+
1116+ def test_error_environment_not_found(self):
1117+ # A parser error is invoked if the provided environment is not included
1118+ # in the environments database.
1119+ manage._retrieve_env_data(
1120+ self.parser, self.env_type_db, self.env_db, 'no-such')
1121+ self.parser.error.assert_called_once_with(
1122+ 'environment no-such not found')
1123+
1124+ def test_error_environment_not_valid(self):
1125+ # A parser error is invoked if the selected environment is not valid.
1126+ manage._retrieve_env_data(
1127+ self.parser, self.env_type_db, self.env_db, 'local-with-errors')
1128+ self.parser.error.assert_called_once_with(
1129+ 'cannot use the local-with-errors environment:\n'
1130+ 'the storage port field requires an integer value\n'
1131+ 'a value is required for the admin secret field')
1132+
1133+
1134+class TestSetupEnv(helpers.EnvFileTestsMixin, unittest.TestCase):
1135+
1136+ def setUp(self):
1137+ self.parser = mock.Mock()
1138+
1139+ def make_options(self, env_file, env_name=None, interactive=False):
1140 """Return a mock options object which includes the passed arguments."""
1141- return mock.Mock(env_file=env_file, env_name=env_name)
1142+ return mock.Mock(
1143+ env_file=env_file,
1144+ env_name=env_name,
1145+ interactive=interactive,
1146+ )
1147+
1148+ def patch_interactive_mode(self, return_value):
1149+ """Patch the quickstart.manage._start_interactive_session function.
1150+
1151+ Make the mocked function return the given return_value.
1152+ """
1153+ mock_start_interactive_session = mock.Mock(return_value=return_value)
1154+ return mock.patch(
1155+ 'quickstart.manage._start_interactive_session',
1156+ mock_start_interactive_session)
1157
1158 def test_resulting_options(self):
1159 # The options object is correctly set up.
1160 env_file = self.make_env_file()
1161- options = self.make_options(env_file, env_name='aws')
1162- manage._validate_env(options, self.parser)
1163+ options = self.make_options(
1164+ env_file, env_name='aws', interactive=False)
1165+ manage._setup_env(options, self.parser)
1166 self.assertEqual('Secret!', options.admin_secret)
1167 self.assertEqual(env_file, options.env_file)
1168 self.assertEqual('aws', options.env_name)
1169 self.assertEqual('ec2', options.env_type)
1170+ self.assertEqual('saucy', options.default_series)
1171+ self.assertFalse(options.interactive)
1172
1173 def test_expand_user(self):
1174 # The ~ construct is correctly expanded in the validation process.
1175@@ -274,40 +435,74 @@
1176 path = '~/{}'.format(filename)
1177 options = self.make_options(env_file=path, env_name='aws')
1178 with mock.patch('os.environ', {'HOME': base_path}):
1179- manage._validate_env(options, self.parser)
1180+ manage._setup_env(options, self.parser)
1181 self.assertEqual(env_file, options.env_file)
1182
1183 def test_no_env_name(self):
1184- # A parser error is invoked if the environment name is missing.
1185- options = self.make_options(self.make_env_file())
1186- manage._validate_env(options, self.parser)
1187+ # A parser error is invoked if the environment name is missing and
1188+ # interactive mode is disabled.
1189+ options = self.make_options(self.make_env_file(), interactive=False)
1190+ manage._setup_env(options, self.parser)
1191 self.assertTrue(self.parser.error.called)
1192 message = self.parser.error.call_args[0][0]
1193 self.assertIn('unable to find an environment name to use', message)
1194
1195- def test_error_parsing_env_file(self):
1196- # A parser error is invoked if an error occurs parsing the env file.
1197- env_file = self.make_env_file()
1198- options = self.make_options(env_file, env_name='no-such')
1199- manage._validate_env(options, self.parser)
1200- expected = 'environment no-such not found in {}'.format(env_file)
1201- self.parser.error.assert_called_once_with(expected)
1202-
1203 def test_local_provider(self):
1204- # The parser exits with an error if the provided environment name
1205- # refers to a local provider type.
1206+ # Local environments are correctly handled.
1207 contents = yaml.safe_dump({
1208 'environments': {
1209 'lxc': {'admin-secret': 'Secret!', 'type': 'local'},
1210 },
1211 })
1212 env_file = self.make_env_file(contents)
1213- options = self.make_options(env_file, env_name='lxc')
1214- manage._validate_env(options, self.parser)
1215+ options = self.make_options(
1216+ env_file, env_name='lxc', interactive=False)
1217+ manage._setup_env(options, self.parser)
1218 self.assertEqual('Secret!', options.admin_secret)
1219 self.assertEqual(env_file, options.env_file)
1220 self.assertEqual('lxc', options.env_name)
1221 self.assertEqual('local', options.env_type)
1222+ self.assertIsNone(options.default_series)
1223+ self.assertFalse(options.interactive)
1224+
1225+ def test_interactive_mode(self):
1226+ # The interactive mode is started properly if the corresponding option
1227+ # flag is set.
1228+ env_file = self.make_env_file()
1229+ options = self.make_options(env_file, interactive=True)
1230+ # Simulate the user did not make any changes to the env_db from the
1231+ # interactive session.
1232+ env_db = yaml.load(self.valid_contents)
1233+ # Simulate the aws environment has been selected and started from the
1234+ # interactive session.
1235+ env_data = envs.get_env_data(env_db, 'aws')
1236+ get_env_type_db_path = 'quickstart.models.envs.get_env_type_db'
1237+ with mock.patch(get_env_type_db_path) as mock_get_env_type_db:
1238+ with self.patch_interactive_mode(env_data) as mock_interactive:
1239+ manage._setup_env(options, self.parser)
1240+ mock_interactive.assert_called_once_with(
1241+ self.parser, mock_get_env_type_db(), env_db, env_file)
1242+ # The options is updated with data from the selected environment.
1243+ self.assertEqual('Secret!', options.admin_secret)
1244+ self.assertEqual(env_file, options.env_file)
1245+ self.assertEqual('aws', options.env_name)
1246+ self.assertEqual('ec2', options.env_type)
1247+ self.assertEqual('saucy', options.default_series)
1248+ self.assertTrue(options.interactive)
1249+
1250+ @helpers.mock_print
1251+ def test_missing_env_file(self, mock_print):
1252+ # If the environments file does not exist, an empty env_db is created
1253+ # in memory and interactive mode is forced.
1254+ new_env_db = helpers.make_env_db()
1255+ env_data = envs.get_env_data(new_env_db, 'lxc')
1256+ options = self.make_options('__no_such_env_file__', interactive=False)
1257+ # In this case, we expect the interactive mode to be started and the
1258+ # env_db passed to the view to be an empty one.
1259+ with self.patch_interactive_mode(env_data) as mock_interactive:
1260+ manage._setup_env(options, self.parser)
1261+ self.assertTrue(mock_interactive.called)
1262+ self.assertTrue(options.interactive)
1263
1264
1265 class TestConvertOptionsToUnicode(unittest.TestCase):
1266@@ -334,7 +529,7 @@
1267 self.assertIsNone(options.opt2)
1268
1269
1270-@mock.patch('quickstart.manage._validate_env', mock.Mock())
1271+@mock.patch('quickstart.manage._setup_env', mock.Mock())
1272 class TestSetup(unittest.TestCase):
1273
1274 def patch_get_default_env_name(self, env_name=None):
1275@@ -388,13 +583,6 @@
1276 output = stdout_write.call_args[0][0]
1277 self.assertIn('The name of the Juju environment to use (hp)\n', output)
1278
1279- def test_init_environment(self):
1280- # The program calls ensure_environments if env_name is None.
1281- with mock.patch('quickstart.app.ensure_environments',
1282- return_value='local') as ensure_environments:
1283- self.call_setup([], env_name=None, exit_called=False)
1284- self.assertTrue(ensure_environments.called)
1285-
1286 def test_description(self):
1287 # The program description is properly printed out as required by juju.
1288 with helpers.mock_print as mock_print:
1289
1290=== modified file 'quickstart/tests/test_utils.py'
1291--- quickstart/tests/test_utils.py 2013-12-19 08:47:57 +0000
1292+++ quickstart/tests/test_utils.py 2014-01-13 15:31:13 +0000
1293@@ -21,7 +21,10 @@
1294 import datetime
1295 import httplib
1296 import json
1297+import os
1298+import shutil
1299 import socket
1300+import tempfile
1301 import unittest
1302 import urllib2
1303
1304@@ -378,6 +381,55 @@
1305 mock_call.assert_called_once_with('lsb_release', '-cs')
1306
1307
1308+class TestMkdir(unittest.TestCase):
1309+
1310+ def setUp(self):
1311+ # Set up a playground directory.
1312+ self.playground = tempfile.mkdtemp()
1313+ self.addCleanup(shutil.rmtree, self.playground)
1314+
1315+ def test_create_dir(self):
1316+ # A directory is correctly created.
1317+ path = os.path.join(self.playground, 'foo')
1318+ utils.mkdir(path)
1319+ self.assertTrue(os.path.isdir(path))
1320+
1321+ def test_intermediate_dirs(self):
1322+ # All intermediate directories are created.
1323+ path = os.path.join(self.playground, 'foo', 'bar', 'leaf')
1324+ utils.mkdir(path)
1325+ self.assertTrue(os.path.isdir(path))
1326+
1327+ def test_expand_user(self):
1328+ # The ~ construction is expanded.
1329+ with mock.patch('os.environ', {'HOME': self.playground}):
1330+ utils.mkdir('~/in/my/home')
1331+ path = os.path.join(self.playground, 'in', 'my', 'home')
1332+ self.assertTrue(os.path.isdir(path))
1333+
1334+ def test_existing_dir(self):
1335+ # The function exits without errors if the target directory exists.
1336+ path = os.path.join(self.playground, 'foo')
1337+ os.mkdir(path)
1338+ utils.mkdir(path)
1339+
1340+ def test_existing_file(self):
1341+ # An OSError is raised if a file already exists in the target path.
1342+ path = os.path.join(self.playground, 'foo')
1343+ with open(path, 'w'):
1344+ with self.assertRaises(OSError):
1345+ utils.mkdir(path)
1346+
1347+ def test_failure(self):
1348+ # Errors are correctly re-raised.
1349+ path = os.path.join(self.playground, 'foo')
1350+ os.chmod(self.playground, 0000)
1351+ self.addCleanup(os.chmod, self.playground, 0700)
1352+ with self.assertRaises(OSError):
1353+ utils.mkdir(os.path.join(path))
1354+ self.assertFalse(os.path.exists(path))
1355+
1356+
1357 class TestParseBundle(
1358 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
1359 unittest.TestCase):
1360
1361=== modified file 'quickstart/utils.py'
1362--- quickstart/utils.py 2013-12-19 08:47:57 +0000
1363+++ quickstart/utils.py 2014-01-13 15:31:13 +0000
1364@@ -23,6 +23,7 @@
1365
1366 import collections
1367 import datetime
1368+import errno
1369 import httplib
1370 import json
1371 import logging
1372@@ -204,6 +205,21 @@
1373 return output.strip()
1374
1375
1376+def mkdir(path):
1377+ """Create a leaf directory and all intermediate ones.
1378+
1379+ Also expand ~ and ~user constructions.
1380+ If path exists and it's a directory, return without errors.
1381+ """
1382+ path = os.path.expanduser(path)
1383+ try:
1384+ os.makedirs(path)
1385+ except OSError as err:
1386+ # Re-raise the error if the target path exists but it is not a dir.
1387+ if (err.errno != errno.EEXIST) or (not os.path.isdir(path)):
1388+ raise
1389+
1390+
1391 def parse_bundle(bundle_yaml, bundle_name=None):
1392 """Parse the provided bundle YAML encoded contents.
1393

Subscribers

People subscribed via source and target branches