Merge lp:~frankban/juju-quickstart/idempotent-feature into lp:juju-quickstart

Proposed by Francesco Banconi
Status: Merged
Merged at revision: 15
Proposed branch: lp:~frankban/juju-quickstart/idempotent-feature
Merge into: lp:juju-quickstart
Diff against target: 1203 lines (+639/-182)
10 files modified
quickstart/__init__.py (+1/-1)
quickstart/app.py (+110/-32)
quickstart/juju.py (+28/-23)
quickstart/manage.py (+15/-6)
quickstart/tests/helpers.py (+30/-0)
quickstart/tests/test_app.py (+234/-62)
quickstart/tests/test_juju.py (+119/-55)
quickstart/tests/test_manage.py (+4/-3)
quickstart/tests/test_utils.py (+73/-0)
quickstart/utils.py (+25/-0)
To merge this branch: bzr merge lp:~frankban/juju-quickstart/idempotent-feature
Reviewer Review Type Date Requested Status
Juju GUI Hackers Pending
Review via email: mp+195592@code.launchpad.net

Description of the change

Make quickstart idempotent.

- do not bootstrap an environment if already bootstrapped;
- do not deploy the GUI if already there.

Sorry, the diff is long, but there are a lot of tests.

Tests: make check

QA:
- .venv/bin/python juju-quickstart -e ec2
  and ensure the GUI is correctly deployed;

- .venv/bin/python juju-quickstart -e ec2
  again, to check it recognizes that env is
  already bootstrapped and the
  GUI unit is already there;

In the following steps, sometimes the browser
can get confused about certs, wss conections
etc. If the GUI is not loading correctly,
try harder, use incognito mode, change the
browser.

- juju destroy-service -e ec2 juju-gui;
- .venv/bin/python juju-quickstart -e ec2
  again, to check the service and the unit
  are correctly re-deployed;

Incidentally the step above, in the case it
succeeds, also demonstrates that the GUI can
safely be redeployed in the same machine: I
wasn't sure about this and this means we are
cleaning up things correctly in our charm, yay!

- juju unexpose -e ec2 juju-gui;
- .venv/bin/python juju-quickstart -e ec2
  again, to check that the service is
  properly re-exposed;

- juju destroy-unit -e ec2 juju-gui/0;
- .venv/bin/python juju-quickstart -e ec2
  again, to check that the unit is
  re-added on the existing service
  (this time it should be named juju-gui/1);

- juju destroy-service -e ec2 juju-gui;
- juju deploy -e ec2 juju-gui (if juju exits with a
  "service already exists" error, retry after a while);
- .venv/bin/python juju-quickstart -e ec2 \
    bundle:~jorge/mediawiki-simple/4/mediawiki-simple;
The last command, executed right after juju-deploy should
also demonstrates that incidentally quickstart
can also be used to watch an already running
deployment, and that a bundle can still be deployed;

Final check:
- .venv/bin/python juju-quickstart -e ec2;
just to ensure quickstart is not surprised
that the unit is not in the bootstrap node
(i.e. you should see "juju-gui/0 is ready on machine 1").

Thanks a lot for testing all of this.
I added a card to automate the QA above with
a collection of functional tests.

Remember to destroy your ec2 environment.

https://codereview.appspot.com/28250044/

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

Reviewers: mp+195592_code.launchpad.net,

Message:
Please take a look.

Description:
Make quickstart idempotent.

- do not bootstrap an environment if already bootstrapped;
- do not deploy the GUI if already there.

Sorry, the diff is long, but there are a lot of tests.

Tests: make check

QA:
- .venv/bin/python juju-quickstart -e ec2
   and ensure the GUI is correctly deployed;

- .venv/bin/python juju-quickstart -e ec2
   again, to check it recognizes that env is
   already bootstrapped and the
   GUI unit is already there;

In the following steps, sometimes the browser
can get confused about certs, wss conections
etc. If the GUI is not loading correctly,
try harder, use incognito mode, change the
browser.

- juju destroy-service -e ec2 juju-gui;
- .venv/bin/python juju-quickstart -e ec2
   again, to check the service and the unit
   are correctly re-deployed;

Incidentally the step above, in the case it
succeeds, also demonstrates that the GUI can
safely be redeployed in the same machine: I
wasn't sure about this and this means we are
cleaning up things correctly in our charm, yay!

- juju unexpose -e ec2 juju-gui;
- .venv/bin/python juju-quickstart -e ec2
   again, to check that the service is
   properly re-exposed;

- juju destroy-unit -e ec2 juju-gui/0;
- .venv/bin/python juju-quickstart -e ec2
   again, to check that the unit is
   re-added on the existing service
   (this time it should be named juju-gui/1);

- juju destroy-service -e ec2 juju-gui;
- juju deploy -e ec2 juju-gui (if juju exits with a
   "service already exists" error, retry after a while);
- .venv/bin/python juju-quickstart -e ec2 \
     bundle:~jorge/mediawiki-simple/4/mediawiki-simple;
The last command, executed right after juju-deploy should
also demonstrates that incidentally quickstart
can also be used to watch an already running
deployment, and that a bundle can still be deployed;

Final check:
- .venv/bin/python juju-quickstart -e ec2;
just to ensure quickstart is not surprised
that the unit is not in the bootstrap node
(i.e. you should see "juju-gui/0 is ready on machine 1").

Thanks a lot for testing all of this.
I added a card to automate the QA above with
a collection of functional tests.

Remember to destroy your ec2 environment.

https://code.launchpad.net/~frankban/juju-quickstart/idempotent-feature/+merge/195592

(do not edit description out of merge proposal)

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

Affected files (+635, -182 lines):
   A [revision details]
   M quickstart/__init__.py
   M quickstart/app.py
   M quickstart/juju.py
   M quickstart/manage.py
   M quickstart/tests/helpers.py
   M quickstart/tests/test_app.py
   M quickstart/tests/test_juju.py
   M quickstart/tests/test_manage.py
   M quickstart/tests/test_utils.py
   M quickstart/utils.py

Revision history for this message
Gary Poster (gary) wrote :

LGTM with a few trivials and a small. QA good. Very nice
functionality. Thank you!

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode66
quickstart/app.py:66: # XXX frankban 2013-11-13: the check below is
weak. We are relying on
Could you file a Juju Core bug, add juju-quickstart as an affected
project, and add the bug number to this comment, please?

It looks like Wm prefers --format to get machine readable output, so
maybe that's a way forward?

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode77
quickstart/app.py:77: # rather thank comparing the expected error with
the obtained one, we
typo: ...rather than...

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode82
quickstart/app.py:82: already_bootstrapped = True
It took me a few seconds of thinking to realize why you didn't just
return True here. Low priority, but might be nice to say something
explanatory. Example:

# Juju is bootstapped, but we don't know if it is ready yet. Fall
# through to the next block for that check.

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode155
quickstart/app.py:155: Raise a ProgramExit if the API server returns an
error response.
After reading docstring, I am not clear on three things.

(1) why is the provider type important here?
(2) why is the already_bootstrapped value important here?
(3) what happens if the GUI charm exists with a different name? what
happens if the given name is not a GUI charm?

Maybe we don't need to answer all of these in the docstring, but seemed
like a hole as I read it.

The code below answers #1 and #2; I have a comment below about #3.

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode158
quickstart/app.py:158: if already_bootstrapped:
Ah! It is an optimization.

I thought about suggesting some different names for the argument,
focused on the local behavior of enabling checking for services and
units rather than the contextual idea that we are already bootstrapped.
E.g., s/already_bootstrapped/check_for_preexisting/ or something. From
the local function perspective, you could even argue that you should
only control disabling the behavior, since this is an
optimization--e.g., s/already_bootstrapped/not
prevent_preexisting_check/--but that's effectively a double negative.
In the end, I'm not suggesting this because I don't like any of the
other concrete options I considered. At best, this would have been
bikeshedding, anyway.

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode187
quickstart/app.py:187: print('charm URL: {}'.format(charm_url))
Maybe we should warn the user if the actual charm_url != the given
charm_url, and warn louder if the charm id doesn't match
/\/juju-gui-\d+$/ ?

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode199
quickstart/app.py:199: # bootstrap node if we are using the local
provider.
Ah-ha!

https://codereview.appspot.com/28250044/

Revision history for this message
Gary Poster (gary) wrote :

One QA note.

The end of your instructions said this:

--------------------------
Final check:
- .venv/bin/python juju-quickstart -e ec2;
just to ensure quickstart is not surprised
that the unit is not in the bootstrap node
(i.e. you should see "juju-gui/0 is ready on machine 1").
--------------------------

I saw it on machine 0 (ec2), which I expected given the immediately
previous instruction to destroy the service. If this is in fact a
surprise and broken...I reported it. :-)

https://codereview.appspot.com/28250044/

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

LGTM and qa-ok.

I'm with Gary on the is_bootstrapped being a bit confusing sometimes in
the inner workings.

I also get a bit confused with the 'charm_url' as I forget that in this
context that's ALWAYS the Gui charm. As features grow and things like
deploying bundles come into the picture, I wonder if a
s/charm_url/gui_charm_url might be in order.

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode74
quickstart/app.py:74: # jens files seems to be an internal juju-core
detail. Definitely we
jenv

https://codereview.appspot.com/28250044/

23. By Francesco Banconi

Changes as per review.

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

*** Submitted:

Make quickstart idempotent.

- do not bootstrap an environment if already bootstrapped;
- do not deploy the GUI if already there.

Sorry, the diff is long, but there are a lot of tests.

Tests: make check

QA:
- .venv/bin/python juju-quickstart -e ec2
   and ensure the GUI is correctly deployed;

- .venv/bin/python juju-quickstart -e ec2
   again, to check it recognizes that env is
   already bootstrapped and the
   GUI unit is already there;

In the following steps, sometimes the browser
can get confused about certs, wss conections
etc. If the GUI is not loading correctly,
try harder, use incognito mode, change the
browser.

- juju destroy-service -e ec2 juju-gui;
- .venv/bin/python juju-quickstart -e ec2
   again, to check the service and the unit
   are correctly re-deployed;

Incidentally the step above, in the case it
succeeds, also demonstrates that the GUI can
safely be redeployed in the same machine: I
wasn't sure about this and this means we are
cleaning up things correctly in our charm, yay!

- juju unexpose -e ec2 juju-gui;
- .venv/bin/python juju-quickstart -e ec2
   again, to check that the service is
   properly re-exposed;

- juju destroy-unit -e ec2 juju-gui/0;
- .venv/bin/python juju-quickstart -e ec2
   again, to check that the unit is
   re-added on the existing service
   (this time it should be named juju-gui/1);

- juju destroy-service -e ec2 juju-gui;
- juju deploy -e ec2 juju-gui (if juju exits with a
   "service already exists" error, retry after a while);
- .venv/bin/python juju-quickstart -e ec2 \
     bundle:~jorge/mediawiki-simple/4/mediawiki-simple;
The last command, executed right after juju-deploy should
also demonstrates that incidentally quickstart
can also be used to watch an already running
deployment, and that a bundle can still be deployed;

Final check:
- .venv/bin/python juju-quickstart -e ec2;
just to ensure quickstart is not surprised
that the unit is not in the bootstrap node
(i.e. you should see "juju-gui/0 is ready on machine 1").

Thanks a lot for testing all of this.
I added a card to automate the QA above with
a collection of functional tests.

Remember to destroy your ec2 environment.

R=gary.poster, rharding
CC=
https://codereview.appspot.com/28250044

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py
File quickstart/app.py (right):

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode66
quickstart/app.py:66: # XXX frankban 2013-11-13: the check below is
weak. We are relying on
On 2013/11/18 15:02:19, gary.poster wrote:
> Could you file a Juju Core bug, add juju-quickstart as an affected
project, and
> add the bug number to this comment, please?

> It looks like Wm prefers --format to get machine readable output, so
maybe
> that's a way forward?

Done. Filed bug 1252322.

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode74
quickstart/app.py:74: # jens files seems to be an internal juju-core
detail. Definitely we
On 2013/11/18 15:42:56, rharding wrote:
> jenv

Done.

https://codereview.appspot.com/28250044/diff/1/quickstart/app.py#newcode77
quickstart/app.py:77: # rather thank comparing the expected error with
the obtai...

Read more...

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

Thank you both for the great reviews!

https://codereview.appspot.com/28250044/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'quickstart/__init__.py'
--- quickstart/__init__.py 2013-11-06 10:17:08 +0000
+++ quickstart/__init__.py 2013-11-18 16:54:22 +0000
@@ -19,7 +19,7 @@
19that it can be managed using a Web interface (the Juju GUI).19that it can be managed using a Web interface (the Juju GUI).
20"""20"""
2121
22VERSION = (0, 3, 0)22VERSION = (0, 4, 0)
2323
2424
25def get_version():25def get_version():
2626
=== modified file 'quickstart/app.py'
--- quickstart/app.py 2013-11-13 12:23:36 +0000
+++ quickstart/app.py 2013-11-18 16:54:22 +0000
@@ -47,17 +47,41 @@
47def bootstrap(env_name, debug=False):47def bootstrap(env_name, debug=False):
48 """Bootstrap the Juju environment with the given name.48 """Bootstrap the Juju environment with the given name.
4949
50 If debug is True, bootstrap the environment passing the --debug flag.50 Do not bootstrap the environment if already bootstrapped.
5151
52 Return when the bootstrap node is ready.52 Return True without errors if the environment is already bootstrapped.
53 Return False otherwise. Only return when the bootstrap node is ready.
54
55 If debug is True and the environment not bootstrapped, execute the
56 bootstrap command passing the --debug flag.
57
53 Raise a ProgramExit if any error occurs in the bootstrap process.58 Raise a ProgramExit if any error occurs in the bootstrap process.
54 """59 """
60 already_bootstrapped = False
55 cmd = ['juju', 'bootstrap', '-e', env_name]61 cmd = ['juju', 'bootstrap', '-e', env_name]
56 if debug:62 if debug:
57 cmd.append('--debug')63 cmd.append('--debug')
58 retcode, _, error = utils.call(*cmd)64 retcode, _, error = utils.call(*cmd)
59 if retcode:65 if retcode:
60 raise ProgramExit(error)66 # XXX frankban 2013-11-13 bug 1252322: the check below is weak. We are
67 # relying on an error message in order to decide if the environment is
68 # already bootstrapped. Other possibilities include checking if the
69 # jenv file is present (in ~/.juju/environments/) and, if so, check the
70 # juju status. Unfortunately this is also prone to errors, because a
71 # jenv file can be there but the environment not really bootstrapped or
72 # functional (e.g. sync-tools was used, or a previous bootstrap failed,
73 # or the user terminated machines from the ec2 panel, etc.). Moreover
74 # jenv files seems to be an internal juju-core detail. Definitely we
75 # need to find a better way, but for now the "asking forgiveness"
76 # approach feels like the best compromise we have. Also note that,
77 # rather than comparing the expected error with the obtained one, we
78 # search in the error in order to support bootstrap --debug.
79 if 'environment is already bootstrapped' not in error:
80 # We exit if the error is not "already bootstrapped".
81 raise ProgramExit(error)
82 # Juju is bootstrapped, but we don't know if it is ready yet. Fall
83 # through to the next block for that check.
84 already_bootstrapped = True
61 # Call "juju status" multiple times until the bootstrap node is ready.85 # Call "juju status" multiple times until the bootstrap node is ready.
62 for _ in range(5):86 for _ in range(5):
63 retcode, output, error = utils.call(87 retcode, output, error = utils.call(
@@ -70,7 +94,7 @@
70 except ValueError:94 except ValueError:
71 continue95 continue
72 if agent_state == 'started':96 if agent_state == 'started':
73 return97 return already_bootstrapped
74 details = ''.join(filter(None, [output, error])).strip()98 details = ''.join(filter(None, [output, error])).strip()
75 raise ProgramExit('the state server is not ready:\n{}'.format(details))99 raise ProgramExit('the state server is not ready:\n{}'.format(details))
76100
@@ -112,34 +136,88 @@
112 return env136 return env
113137
114138
115def deploy_gui(env, service_name, charm_url=None):139def deploy_gui(
140 env, service_name, machine, charm_url=None, check_preexisting=False):
116 """Deploy and expose the given service, reusing the bootstrap node.141 """Deploy and expose the given service, reusing the bootstrap node.
117142
118 Receive an authenticated Juju Environment instance, the name of the service143 Only deploy the service if not already present in the environment.
119 and the optional Juju GUI charm URL, e.g. cs:~juju-gui/precise/juju-gui-42.144 Do not add a unit to the service if the unit is already there.
120 If the charm URL is not provided, the function tries to retrieve it from145
121 charmworld. In this case a default charm URL is used if charmworld is not146 Receive an authenticated Juju Environment instance, the name of the
122 available.147 service, the machine where to deploy to (or None for a new machine),
123148 the optional Juju GUI charm URL (e.g. cs:~juju-gui/precise/juju-gui-42),
149 and a flag (check_preexisting) that can be set to True in order to make
150 the function check for a pre-existing service and/or unit.
151
152 If the charm URL is not provided, and the service is not already deployed,
153 the function tries to retrieve it from charmworld. In this case a default
154 charm URL is used if charmworld is not available.
155
156 Return the name of the first running unit belonging to the given service.
124 Raise a ProgramExit if the API server returns an error response.157 Raise a ProgramExit if the API server returns an error response.
125 """158 """
126 if charm_url is None:159 service_data, unit_data = None, None
127 try:160 if check_preexisting:
128 charm_url = utils.get_charm_url()161 # The service and/or the unit can be already in the environment.
129 except (IOError, ValueError) as err:162 try:
130 msg = 'unable to retrieve the Juju GUI charm URL from the API: {}'163 status = env.get_status()
131 logging.warn(msg.format(err))164 except jujuclient.EnvError as err:
132 charm_url = settings.DEFAULT_CHARM_URL165 raise ProgramExit('bad API response: {}'.format(err.message))
133 print('charm URL: {}'.format(charm_url))166 service_data, unit_data = utils.get_service_info(status, service_name)
134 try:167 if service_data is None:
135 env.deploy(service_name, charm_url, to=0)168 # The service does not exist in the environment.
136 env.expose(service_name)169 print('requesting {} deployment'.format(service_name))
137 except jujuclient.EnvError as err:170 if charm_url is None:
138 raise ProgramExit('bad API server response: {}'.format(err.message))171 try:
139172 charm_url = utils.get_charm_url()
140173 except (IOError, ValueError) as err:
141def watch(env, service_name):174 msg = 'unable to retrieve the {} charm URL from the API: {}'
142 """Start watching the first unit belonging to the given service.175 logging.warn(msg.format(service_name, err))
176 charm_url = settings.DEFAULT_CHARM_URL
177 print('charm URL: {}'.format(charm_url))
178 # Deploy the service without units.
179 try:
180 env.deploy(service_name, charm_url, num_units=0)
181 except jujuclient.EnvError as err:
182 raise ProgramExit('bad API response: {}'.format(err.message))
183 print('{} deployment request accepted'.format(service_name))
184 service_exposed = False
185 else:
186 # We already have the service in the environment.
187 print('service {} already deployed'.format(service_name))
188 charm_url = service_data['CharmURL']
189 print('charm URL: {}'.format(charm_url))
190 # XXX frankban: warn the user if the actual charm_url != the given
191 # charm_url, and warn louder if the charm URL does not match
192 # /\/juju-gui-\d+$/.
193 service_exposed = service_data.get('Exposed', False)
194 # At this point the service is surely deployed in the environment: expose
195 # it if necessary and add a unit if it is missing.
196 if not service_exposed:
197 print('exposing service {}'.format(service_name))
198 try:
199 env.expose(service_name)
200 except jujuclient.EnvError as err:
201 raise ProgramExit('bad API response: {}'.format(err.message))
202 if unit_data is None:
203 # Add a unit to the service.
204 print('requesting new unit deployment')
205 try:
206 response = env.add_unit(service_name, machine_spec=machine)
207 except jujuclient.EnvError as err:
208 raise ProgramExit('bad API response: {}'.format(err.message))
209 unit_name = response['Units'][0]
210 print('{} deployment request accepted'.format(unit_name))
211 else:
212 # A service unit is already present in the environment. Go ahead
213 # and try to reuse that unit.
214 unit_name = unit_data['Name']
215 print('reusing unit {}'.format(unit_name))
216 return unit_name
217
218
219def watch(env, unit_name):
220 """Start watching the given unit.
143221
144 Output a human readable messages from each time a relevant change is found.222 Output a human readable messages from each time a relevant change is found.
145223
@@ -153,7 +231,6 @@
153 Raise a ProgramExit if the API server returns an error response, or if231 Raise a ProgramExit if the API server returns an error response, or if
154 the service unit is removed or in error.232 the service unit is removed or in error.
155 """233 """
156 unit_name = '{}/0'.format(service_name)
157 processor = functools.partial(utils.unit_changes, unit_name)234 processor = functools.partial(utils.unit_changes, unit_name)
158 watcher = env.watch_changes(processor)235 watcher = env.watch_changes(processor)
159 address = current_status = ''236 address = current_status = ''
@@ -176,7 +253,7 @@
176 if not address:253 if not address:
177 address = data['PublicAddress']254 address = data['PublicAddress']
178 if address:255 if address:
179 print('installing {} on {}'.format(unit_name, address))256 print('setting up {} on {}'.format(unit_name, address))
180 # Notify status changes.257 # Notify status changes.
181 if status != current_status:258 if status != current_status:
182 if status == 'pending':259 if status == 'pending':
@@ -185,7 +262,8 @@
185 print('{} is installed'.format(unit_name))262 print('{} is installed'.format(unit_name))
186 elif address and status == 'started':263 elif address and status == 'started':
187 # We can exit this loop.264 # We can exit this loop.
188 print('{} is ready'.format(unit_name))265 print('{} is ready on machine {}'.format(
266 unit_name, data['MachineId']))
189 return address267 return address
190 current_status = status268 current_status = status
191269
192270
=== modified file 'quickstart/juju.py'
--- quickstart/juju.py 2013-10-30 11:44:35 +0000
+++ quickstart/juju.py 2013-11-18 16:54:22 +0000
@@ -41,35 +41,25 @@
41 deployments to specific machines.41 deployments to specific machines.
42 """42 """
4343
44 def deploy(44 def add_unit(self, service_name, machine_spec=None):
45 self, service_name, charm_url, num_units=1, config=None,45 """Add a unit to the given service and machine.
46 constraints=None, to=None):
47 """Deploy a charm. Local charms are not supported.
4846
49 This method is overridden to add the ability to deploy to a specific47 This method is present in newer versions of python-jujuclient and can
50 machine (i.e. support the ToMachineSpec API parameter).48 be safely deleted when upgrading to the new releases.
51 """49 """
52 service_config = {}50 # XXX frankban 2013-11-15: remove this method when upgrading to
53 if config is not None:51 # python-jujuclient >= 0.15.
54 service_config = self._prepare_strparams(config)
55 service_constraints = {}
56 if constraints is not None:
57 service_constraints = self._prepare_constraints(constraints)
58 params = {52 params = {
59 'ServiceName': service_name,53 'ServiceName': service_name,
60 'CharmURL': charm_url,54 'NumUnits': 1,
61 'NumUnits': num_units,
62 'Config': service_config,
63 'Constraints': service_constraints,
64 }55 }
65 if to is not None:56 if machine_spec is not None:
66 params['ToMachineSpec'] = str(to)57 params['ToMachineSpec'] = machine_spec
67 request = {58 return self._rpc({
68 'Type': 'Client',59 'Type': 'Client',
69 'Request': 'ServiceDeploy',60 'Request': 'AddServiceUnits',
70 'Params': params,61 'Params': params,
71 }62 })
72 return self._rpc(request)
7363
74 def deploy_bundle(self, yaml, name=None):64 def deploy_bundle(self, yaml, name=None):
75 """Deploy a bundle."""65 """Deploy a bundle."""
@@ -83,13 +73,28 @@
83 }73 }
84 return self._rpc(request)74 return self._rpc(request)
8575
76 def get_status(self):
77 """Return the current status of the environment.
78
79 The status is represented by a single mega-watcher changeset.
80 Each change in the changeset is a tuple (entity, action, data) where:
81 - entity is a string representing the changed content type
82 (e.g. "service" or "unit");
83 - action is a string representing the event which generated the
84 change (i.e. "change" or "remove");
85 - data is a dict containing information about the releated entity.
86 """
87 with self.get_watch() as watcher:
88 changeset = watcher.next()
89 return changeset
90
86 def watch_changes(self, processor):91 def watch_changes(self, processor):
87 """Start watching the changes occurring in the Juju environment.92 """Start watching the changes occurring in the Juju environment.
8893
89 For each changeset, call the given processor callable, and yield94 For each changeset, call the given processor callable, and yield
90 the values returned by the processor.95 the values returned by the processor.
91 """96 """
92 with jujuclient.Watcher(self.conn) as watcher:97 with self.get_watch() as watcher:
93 # The watcher closes when the context manager exit hook is called.98 # The watcher closes when the context manager exit hook is called.
94 for changeset in watcher:99 for changeset in watcher:
95 changes = processor(changeset)100 changes = processor(changeset)
96101
=== modified file 'quickstart/manage.py'
--- quickstart/manage.py 2013-11-13 12:23:36 +0000
+++ quickstart/manage.py 2013-11-18 16:54:22 +0000
@@ -187,7 +187,8 @@
187 help='The Juju GUI charm URL to deploy in the environment. If not '187 help='The Juju GUI charm URL to deploy in the environment. If not '
188 'provided, the last release of the GUI will be deployed. The '188 'provided, the last release of the GUI will be deployed. The '
189 'charm URL must include the charm version, e.g. '189 'charm URL must include the charm version, e.g. '
190 'cs:~juju-gui/precise/juju-gui-116')190 'cs:~juju-gui/precise/juju-gui-116. This option is ignored if '
191 'Juju GUI is already present in the environment')
191 parser.add_argument(192 parser.add_argument(
192 '--no-browser', action='store_false', dest='open_browser',193 '--no-browser', action='store_false', dest='open_browser',
193 help='Avoid opening the browser to the GUI at the end of the process')194 help='Avoid opening the browser to the GUI at the end of the process')
@@ -218,15 +219,23 @@
218 print('juju quickstart v{}'.format(version))219 print('juju quickstart v{}'.format(version))
219 print('bootstrapping the {} environment (type: {})'.format(220 print('bootstrapping the {} environment (type: {})'.format(
220 options.env_name, options.env_type))221 options.env_name, options.env_type))
221 app.bootstrap(options.env_name, debug=options.debug)222 already_bootstrapped = app.bootstrap(options.env_name, debug=options.debug)
223 if already_bootstrapped:
224 print('reusing the already bootstrapped {} environment'.format(
225 options.env_name))
226
222 print('retrieving the Juju API address')227 print('retrieving the Juju API address')
223 api_url = app.get_api_url(options.env_name)228 api_url = app.get_api_url(options.env_name)
224 print('connecting to {}'.format(api_url))229 print('connecting to {}'.format(api_url))
225 env = app.connect(api_url, options.admin_secret)230 env = app.connect(api_url, options.admin_secret)
226 print('requesting Juju GUI deployment')231
227 app.deploy_gui(env, settings.JUJU_GUI_NAME, charm_url=options.charm_url)232 # It is not possible to deploy on the bootstrap node if we are using the
228 print('Juju GUI deployment request accepted')233 # local provider.
229 address = app.watch(env, settings.JUJU_GUI_NAME)234 machine = None if options.env_type == 'local' else '0'
235 unit_name = app.deploy_gui(
236 env, settings.JUJU_GUI_NAME, machine, charm_url=options.charm_url,
237 check_preexisting=already_bootstrapped)
238 address = app.watch(env, unit_name)
230 url = 'https://{}'.format(address)239 url = 'https://{}'.format(address)
231 print('url: {}\npassword: {}'.format(url, options.admin_secret))240 print('url: {}\npassword: {}'.format(url, options.admin_secret))
232241
233242
=== modified file 'quickstart/tests/helpers.py'
--- quickstart/tests/helpers.py 2013-11-06 14:55:30 +0000
+++ quickstart/tests/helpers.py 2013-11-18 16:54:22 +0000
@@ -150,3 +150,33 @@
150 with self.assertRaises(ValueError) as context_manager:150 with self.assertRaises(ValueError) as context_manager:
151 yield151 yield
152 self.assertEqual(error, str(context_manager.exception))152 self.assertEqual(error, str(context_manager.exception))
153
154
155class WatcherDataTestsMixin(object):
156 """Shared methods for testing Juju mega-watcher data."""
157
158 def _make_change(self, entity, action, default_data, data):
159 if data is not None:
160 default_data.update(data)
161 return entity, action, default_data
162
163 def make_service_change(self, action='change', data=None):
164 """Create and return a change on a service.
165
166 The passed data can be used to override default values.
167 """
168 default_data = {
169 'CharmURL': 'cs:precise/juju-gui-47',
170 'Exposed': True,
171 'Life': 'alive',
172 'Name': 'my-gui',
173 }
174 return self._make_change('service', action, default_data, data)
175
176 def make_unit_change(self, action='change', data=None):
177 """Create and return a change on a unit.
178
179 The passed data can be used to override default values.
180 """
181 default_data = {'Name': 'my-gui/47', 'Service': 'my-gui'}
182 return self._make_change('unit', action, default_data, data)
153183
=== modified file 'quickstart/tests/test_app.py'
--- quickstart/tests/test_app.py 2013-11-13 12:23:36 +0000
+++ quickstart/tests/test_app.py 2013-11-18 16:54:22 +0000
@@ -99,7 +99,8 @@
99 def test_success(self):99 def test_success(self):
100 # The environment is successfully bootstrapped.100 # The environment is successfully bootstrapped.
101 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:101 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:
102 app.bootstrap(self.env_name)102 already_bootstrapped = app.bootstrap(self.env_name)
103 self.assertFalse(already_bootstrapped)
103 mock_call.assert_has_calls([104 mock_call.assert_has_calls([
104 mock.call('juju', 'bootstrap', '-e', self.env_name),105 mock.call('juju', 'bootstrap', '-e', self.env_name),
105 ] + self.make_status_calls(1))106 ] + self.make_status_calls(1))
@@ -107,11 +108,26 @@
107 def test_success_debug(self):108 def test_success_debug(self):
108 # The environment is successfully bootstrapped in debug mode.109 # The environment is successfully bootstrapped in debug mode.
109 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:110 with self.patch_multiple_calls(self.make_side_effects()) as mock_call:
110 app.bootstrap(self.env_name, debug=True)111 already_bootstrapped = app.bootstrap(self.env_name, debug=True)
112 self.assertFalse(already_bootstrapped)
111 mock_call.assert_has_calls([113 mock_call.assert_has_calls([
112 mock.call('juju', 'bootstrap', '-e', self.env_name, '--debug'),114 mock.call('juju', 'bootstrap', '-e', self.env_name, '--debug'),
113 ] + self.make_status_calls(1))115 ] + self.make_status_calls(1))
114116
117 def test_already_bootstrapped(self):
118 # The function succeeds and returns True if the environment is already
119 # bootstrapped.
120 side_effects = [
121 (1, '', '***environment is already bootstrapped**'),
122 (0, self.make_status_output('started'), ''),
123 ]
124 with self.patch_multiple_calls(side_effects) as mock_call:
125 already_bootstrapped = app.bootstrap(self.env_name)
126 self.assertTrue(already_bootstrapped)
127 mock_call.assert_has_calls([
128 mock.call('juju', 'bootstrap', '-e', self.env_name),
129 ] + self.make_status_calls(1))
130
115 def test_bootstrap_failure(self):131 def test_bootstrap_failure(self):
116 # A ProgramExit is raised if an error occurs while bootstrapping.132 # A ProgramExit is raised if an error occurs while bootstrapping.
117 with self.patch_call(1, error='bad wolf') as mock_call:133 with self.patch_call(1, error='bad wolf') as mock_call:
@@ -251,10 +267,32 @@
251267
252268
253@mock_print269@mock_print
254class TestDeployGui(ProgramExitTestsMixin, unittest.TestCase):270class TestDeployGui(
271 ProgramExitTestsMixin, helpers.WatcherDataTestsMixin,
272 unittest.TestCase):
255273
256 charm_url = 'cs:precise/juju-gui-42'274 charm_url = 'cs:precise/juju-gui-42'
257275
276 def make_env(self, unit_name=None, service_data=None, unit_data=None):
277 """Create and return a mock environment object.
278
279 Set up the object so that a call to add_unit returns the given
280 unit_name, and a call to status returns a status object containing the
281 service and unit described by the given service_data and unit_data.
282 """
283 env = mock.Mock()
284 # Set up the add_unit return value.
285 if unit_name is not None:
286 env.add_unit.return_value = {'Units': [unit_name]}
287 #Set up the get_status return value.
288 status = []
289 if service_data is not None:
290 status.append(self.make_service_change(data=service_data))
291 if unit_data is not None:
292 status.append(self.make_unit_change(data=unit_data))
293 env.get_status.return_value = status
294 return env
295
258 def patch_get_charm_url(self, side_effect=None):296 def patch_get_charm_url(self, side_effect=None):
259 """Patch the get_charm_url helper function."""297 """Patch the get_charm_url helper function."""
260 if side_effect is None:298 if side_effect is None:
@@ -265,65 +303,198 @@
265 def test_deployment(self, mock_print):303 def test_deployment(self, mock_print):
266 # The function correctly deploys and exposes the service, retrieving304 # The function correctly deploys and exposes the service, retrieving
267 # the charm URL from the charmworld API.305 # the charm URL from the charmworld API.
268 env = mock.Mock()306 env = self.make_env(unit_name='my-gui/42')
269 with self.patch_get_charm_url():307 with self.patch_get_charm_url():
270 app.deploy_gui(env, 'my-gui')308 unit_name = app.deploy_gui(env, 'my-gui', '0')
271 env.assert_has_calls([309 self.assertEqual('my-gui/42', unit_name)
272 mock.call.deploy('my-gui', self.charm_url, to=0),310 env.assert_has_calls([
273 mock.call.expose('my-gui')311 mock.call.deploy('my-gui', self.charm_url, num_units=0),
274 ])312 mock.call.expose('my-gui'),
275 mock_print.assert_called_once_with(313 mock.call.add_unit('my-gui', machine_spec='0'),
276 'charm URL: {}'.format(self.charm_url))314 ])
277315 # There is no need to call status if the environment was just created.
278 def test_deployment_default_charm_url(self, mock_print):316 self.assertFalse(env.get_status.called)
317 mock_print.assert_has_calls([
318 mock.call('requesting my-gui deployment'),
319 mock.call('charm URL: {}'.format(self.charm_url)),
320 mock.call('my-gui deployment request accepted'),
321 mock.call('exposing service my-gui'),
322 mock.call('requesting new unit deployment'),
323 mock.call('my-gui/42 deployment request accepted'),
324 ])
325
326 def test_existing_environment_without_entities(self, mock_print):
327 # The deployment is processed in an already bootstrapped environment
328 # with no relevant entities in it.
329 env = self.make_env(unit_name='my-gui/42')
330 with self.patch_get_charm_url():
331 unit_name = app.deploy_gui(
332 env, 'my-gui', '0', check_preexisting=True)
333 self.assertEqual('my-gui/42', unit_name)
334 env.assert_has_calls([
335 mock.call.get_status(),
336 mock.call.deploy('my-gui', self.charm_url, num_units=0),
337 mock.call.expose('my-gui'),
338 mock.call.add_unit('my-gui', machine_spec='0'),
339 ])
340
341 def test_default_charm_url(self, mock_print):
279 # The function correctly deploys and exposes the service, even if it is342 # The function correctly deploys and exposes the service, even if it is
280 # not able to retrieve the charm URL from the charmworld API.343 # not able to retrieve the charm URL from the charmworld API.
281 env = mock.Mock()344 env = self.make_env(unit_name='my-gui/42')
282 log = 'unable to retrieve the Juju GUI charm URL from the API: boo!'345 log = 'unable to retrieve the my-gui charm URL from the API: boo!'
283 with self.patch_get_charm_url(side_effect=IOError('boo!')):346 with self.patch_get_charm_url(side_effect=IOError('boo!')):
284 # A warning is logged which notifies we are using the default URL.347 # A warning is logged which notifies we are using the default URL.
285 with helpers.assert_logs([log], level='warn'):348 with helpers.assert_logs([log], level='warn'):
286 app.deploy_gui(env, 'my-gui')349 app.deploy_gui(env, 'my-gui', '0')
287 env.assert_has_calls([350 env.assert_has_calls([
288 mock.call.deploy('my-gui', settings.DEFAULT_CHARM_URL, to=0),351 mock.call.deploy(
289 mock.call.expose('my-gui')352 'my-gui', settings.DEFAULT_CHARM_URL, num_units=0),
290 ])353 mock.call.expose('my-gui'),
291 mock_print.assert_called_once_with(354 mock.call.add_unit('my-gui', machine_spec='0'),
292 'charm URL: {}'.format(settings.DEFAULT_CHARM_URL))355 ])
356 mock_print.assert_has_calls([
357 mock.call('requesting my-gui deployment'),
358 mock.call('charm URL: {}'.format(settings.DEFAULT_CHARM_URL)),
359 ])
293360
294 def test_deployment_provided_charm_url(self, mock_print):361 def test_provided_charm_url(self, mock_print):
295 # The function correctly deploys and exposes the service using a user362 # The function correctly deploys and exposes the service using a user
296 # provided Juju GUI charm URL.363 # provided Juju GUI charm URL.
297 env = mock.Mock()364 env = self.make_env(unit_name='my-gui/42')
298 charm_url = 'cs:~juju-gui/precise/juju-gui-116'365 charm_url = 'cs:~juju-gui/precise/juju-gui-116'
299 app.deploy_gui(env, 'my-gui', charm_url=charm_url)366 app.deploy_gui(env, 'my-gui', '0', charm_url=charm_url)
300 env.assert_has_calls([367 env.assert_has_calls([
301 mock.call.deploy('my-gui', charm_url, to=0),368 mock.call.deploy('my-gui', charm_url, num_units=0),
302 mock.call.expose('my-gui')369 mock.call.expose('my-gui'),
303 ])370 mock.call.add_unit('my-gui', machine_spec='0'),
304 mock_print.assert_called_once_with('charm URL: {}'.format(charm_url))371 ])
305372 mock_print.assert_has_calls([
306 def test_api_error(self, mock_print):373 mock.call('requesting my-gui deployment'),
307 # A ProgramExit is raised if an error occurs in one of the API calls.374 mock.call('charm URL: {}'.format(charm_url)),
308 env = mock.Mock()375 ])
309 env.deploy.side_effect = self.make_env_error('service already exists')376
310 expected = 'bad API server response: service already exists'377 def test_existing_service(self, mock_print):
311 with self.patch_get_charm_url():378 # The deployment is executed reusing an already deployed service.
312 with self.assert_program_exit(expected):379 env = self.make_env(unit_name='my-gui/42', service_data={})
313 app.deploy_gui(env, 'another-gui')380 unit_name = app.deploy_gui(
381 env, 'my-gui', '0', check_preexisting=True)
382 self.assertEqual('my-gui/42', unit_name)
383 env.assert_has_calls([
384 mock.call.get_status(),
385 mock.call.add_unit('my-gui', machine_spec='0'),
386 ])
387 # The service is not re-deployed.
388 self.assertFalse(env.deploy.called)
389 # The service is not re-exposed.
390 self.assertFalse(env.expose.called)
391 mock_print.assert_has_calls([
392 mock.call('service my-gui already deployed'),
393 mock.call('charm URL: cs:precise/juju-gui-47'),
394 mock.call('requesting new unit deployment'),
395 mock.call('my-gui/42 deployment request accepted'),
396 ])
397
398 def test_existing_service_unexposed(self, mock_print):
399 # The existing service is exposed if required.
400 service_data = {'Exposed': False}
401 env = self.make_env(unit_name='my-gui/42', service_data=service_data)
402 unit_name = app.deploy_gui(
403 env, 'my-gui', '1', check_preexisting=True)
404 self.assertEqual('my-gui/42', unit_name)
405 env.assert_has_calls([
406 mock.call.get_status(),
407 mock.call.expose('my-gui'),
408 mock.call.add_unit('my-gui', machine_spec='1'),
409 ])
410 # The service is not re-deployed.
411 self.assertFalse(env.deploy.called)
412 mock_print.assert_has_calls([
413 mock.call('service my-gui already deployed'),
414 mock.call('charm URL: cs:precise/juju-gui-47'),
415 mock.call('exposing service my-gui'),
416 mock.call('requesting new unit deployment'),
417 mock.call('my-gui/42 deployment request accepted'),
418 ])
419
420 def test_existing_service_and_unit(self, mock_print):
421 # A unit is reused if a suitable one is already present.
422 env = self.make_env(service_data={}, unit_data={})
423 unit_name = app.deploy_gui(
424 env, 'my-gui', '0', check_preexisting=True)
425 self.assertEqual('my-gui/47', unit_name)
426 env.get_status.assert_called_once_with()
427 # The service is not re-deployed.
428 self.assertFalse(env.deploy.called)
429 # The service is not re-exposed.
430 self.assertFalse(env.expose.called)
431 # The unit is not re-added.
432 self.assertFalse(env.add_unit.called)
433 mock_print.assert_has_calls([
434 mock.call('service my-gui already deployed'),
435 mock.call('charm URL: cs:precise/juju-gui-47'),
436 mock.call('reusing unit my-gui/47'),
437 ])
438
439 def test_new_machine(self, mock_print):
440 # The unit is correctly deployed in a new machine.
441 env = self.make_env(unit_name='my-gui/42')
442 with self.patch_get_charm_url():
443 unit_name = app.deploy_gui(env, 'my-gui', None)
444 self.assertEqual('my-gui/42', unit_name)
445 env.assert_has_calls([
446 mock.call.deploy('my-gui', self.charm_url, num_units=0),
447 mock.call.expose('my-gui'),
448 mock.call.add_unit('my-gui', machine_spec=None),
449 ])
450
451 def test_status_error(self, mock_print):
452 # A ProgramExit is raised if an error occurs in the status API call.
453 env = self.make_env()
454 env.get_status.side_effect = self.make_env_error('bad wolf')
455 with self.assert_program_exit('bad API response: bad wolf'):
456 app.deploy_gui(
457 env, 'another-gui', '0', check_preexisting=True)
458 env.get_status.assert_called_once_with()
459
460 def test_deploy_error(self, mock_print):
461 # A ProgramExit is raised if an error occurs in the deploy API call.
462 env = self.make_env()
463 env.deploy.side_effect = self.make_env_error('bad wolf')
464 with self.patch_get_charm_url():
465 with self.assert_program_exit('bad API response: bad wolf'):
466 app.deploy_gui(env, 'another-gui', '0')
314 env.deploy.assert_called_once_with(467 env.deploy.assert_called_once_with(
315 'another-gui', 'cs:precise/juju-gui-42', to=0)468 'another-gui', self.charm_url, num_units=0)
469
470 def test_expose_error(self, mock_print):
471 # A ProgramExit is raised if an error occurs in the expose API call.
472 env = self.make_env()
473 env.expose.side_effect = self.make_env_error('bad wolf')
474 with self.patch_get_charm_url():
475 with self.assert_program_exit('bad API response: bad wolf'):
476 app.deploy_gui(env, 'another-gui', '0')
477 env.expose.assert_called_once_with('another-gui')
478
479 def test_add_unit_error(self, mock_print):
480 # A ProgramExit is raised if an error occurs in the add_unit API call.
481 env = self.make_env()
482 env.add_unit.side_effect = self.make_env_error('bad wolf')
483 with self.patch_get_charm_url():
484 with self.assert_program_exit('bad API response: bad wolf'):
485 app.deploy_gui(env, 'another-gui', '0')
486 env.add_unit.assert_called_once_with('another-gui', machine_spec='0')
316487
317 def test_other_errors(self, mock_print):488 def test_other_errors(self, mock_print):
318 # Any other errors occurred during the process are not trapped.489 # Any other errors occurred during the process are not trapped.
319 error = ValueError('explode!')490 error = ValueError('explode!')
320 env = mock.Mock()491 env = self.make_env(unit_name='my-gui/42')
321 env.expose.side_effect = error492 env.expose.side_effect = error
322 with self.patch_get_charm_url():493 with self.patch_get_charm_url():
323 with self.assertRaises(ValueError) as context_manager:494 with self.assertRaises(ValueError) as context_manager:
324 app.deploy_gui(env, 'juju-gui')495 app.deploy_gui(env, 'juju-gui', '0')
325 env.deploy.assert_called_once_with(496 env.deploy.assert_called_once_with(
326 'juju-gui', 'cs:precise/juju-gui-42', to=0)497 'juju-gui', 'cs:precise/juju-gui-42', num_units=0)
327 env.expose.assert_called_once_with('juju-gui')498 env.expose.assert_called_once_with('juju-gui')
328 self.assertIs(error, context_manager.exception)499 self.assertIs(error, context_manager.exception)
329500
@@ -334,28 +505,29 @@
334 address = 'unit.example.com'505 address = 'unit.example.com'
335 change_pending = ('unit', 'change', {506 change_pending = ('unit', 'change', {
336 'Name': 'django/42',507 'Name': 'django/42',
337 'Status': 'pending',
338 'PublicAddress': '',508 'PublicAddress': '',
509 'Status': 'pending',
339 })510 })
340 change_reachable = ('unit', 'change', {511 change_reachable = ('unit', 'change', {
341 'Name': 'django/42',512 'Name': 'django/42',
513 'PublicAddress': address,
342 'Status': 'pending',514 'Status': 'pending',
343 'PublicAddress': address,
344 })515 })
345 change_installed = ('unit', 'change', {516 change_installed = ('unit', 'change', {
346 'Name': 'django/42',517 'Name': 'django/42',
518 'PublicAddress': address,
347 'Status': 'installed',519 'Status': 'installed',
348 'PublicAddress': address,
349 })520 })
350 change_started = ('unit', 'change', {521 change_started = ('unit', 'change', {
522 'MachineId': '0',
351 'Name': 'django/42',523 'Name': 'django/42',
524 'PublicAddress': address,
352 'Status': 'started',525 'Status': 'started',
353 'PublicAddress': address,
354 })526 })
355 pending_call = mock.call('django/0 deployment is pending')527 pending_call = mock.call('django/42 deployment is pending')
356 reachable_call = mock.call('installing django/0 on {}'.format(address))528 reachable_call = mock.call('setting up django/42 on {}'.format(address))
357 installed_call = mock.call('django/0 is installed')529 installed_call = mock.call('django/42 is installed')
358 started_call = mock.call('django/0 is ready')530 started_call = mock.call('django/42 is ready on machine 0')
359531
360 def make_env(self, changes):532 def make_env(self, changes):
361 """Create and return a patched Environment instance.533 """Create and return a patched Environment instance.
@@ -375,7 +547,7 @@
375 self.change_installed,547 self.change_installed,
376 self.change_started,548 self.change_started,
377 ])549 ])
378 address = app.watch(env, 'django')550 address = app.watch(env, 'django/42')
379 self.assertEqual(self.address, address)551 self.assertEqual(self.address, address)
380 mock_print.assert_has_calls([552 mock_print.assert_has_calls([
381 self.pending_call,553 self.pending_call,
@@ -392,7 +564,7 @@
392 self.change_installed,564 self.change_installed,
393 self.change_started,565 self.change_started,
394 ])566 ])
395 address = app.watch(env, 'django')567 address = app.watch(env, 'django/42')
396 self.assertEqual(self.address, address)568 self.assertEqual(self.address, address)
397 mock_print.assert_has_calls([569 mock_print.assert_has_calls([
398 self.reachable_call,570 self.reachable_call,
@@ -404,7 +576,7 @@
404 def test_missing_changes(self, mock_print):576 def test_missing_changes(self, mock_print):
405 # Only the started change is strictly required.577 # Only the started change is strictly required.
406 env = self.make_env([self.change_started])578 env = self.make_env([self.change_started])
407 address = app.watch(env, 'django')579 address = app.watch(env, 'django/42')
408 self.assertEqual(self.address, address)580 self.assertEqual(self.address, address)
409 mock_print.assert_has_calls([581 mock_print.assert_has_calls([
410 self.reachable_call,582 self.reachable_call,
@@ -432,7 +604,7 @@
432 }),604 }),
433 self.change_started,605 self.change_started,
434 ])606 ])
435 address = app.watch(env, 'django')607 address = app.watch(env, 'django/42')
436 self.assertEqual(self.address, address)608 self.assertEqual(self.address, address)
437 mock_print.assert_has_calls([609 mock_print.assert_has_calls([
438 self.pending_call,610 self.pending_call,
@@ -449,7 +621,7 @@
449 ])621 ])
450 expected = 'bad API server response: next returned an error'622 expected = 'bad API server response: next returned an error'
451 with self.assert_program_exit(expected):623 with self.assert_program_exit(expected):
452 app.watch(env, 'django')624 app.watch(env, 'django/42')
453 mock_print.assert_has_calls([self.pending_call])625 mock_print.assert_has_calls([self.pending_call])
454626
455 def test_other_errors(self, mock_print):627 def test_other_errors(self, mock_print):
@@ -457,7 +629,7 @@
457 error = ValueError('explode!')629 error = ValueError('explode!')
458 env = self.make_env([self.change_installed, error])630 env = self.make_env([self.change_installed, error])
459 with self.assertRaises(ValueError) as context_manager:631 with self.assertRaises(ValueError) as context_manager:
460 app.watch(env, 'django')632 app.watch(env, 'django/42')
461 mock_print.assert_has_calls([self.reachable_call, self.installed_call])633 mock_print.assert_has_calls([self.reachable_call, self.installed_call])
462 self.assertIs(error, context_manager.exception)634 self.assertIs(error, context_manager.exception)
463635
@@ -472,8 +644,8 @@
472 }),644 }),
473 self.change_reachable,645 self.change_reachable,
474 ])646 ])
475 with self.assert_program_exit('django/0 unexpectedly removed'):647 with self.assert_program_exit('django/42 unexpectedly removed'):
476 app.watch(env, 'django')648 app.watch(env, 'django/42')
477 mock_print.assert_has_calls([self.pending_call])649 mock_print.assert_has_calls([self.pending_call])
478650
479 def test_status_error(self, mock_print):651 def test_status_error(self, mock_print):
@@ -483,14 +655,14 @@
483 ('unit', 'change', {655 ('unit', 'change', {
484 'Name': 'django/42',656 'Name': 'django/42',
485 'Status': 'error',657 'Status': 'error',
486 'StatusInfo': 'install hook failure',658 'StatusInfo': 'install failure',
487 'PublicAddress': '',659 'PublicAddress': '',
488 }),660 }),
489 self.change_reachable,661 self.change_reachable,
490 ])662 ])
491 expected = 'django/0 is in an error state: error: install hook failure'663 expected = 'django/42 is in an error state: error: install failure'
492 with self.assert_program_exit(expected):664 with self.assert_program_exit(expected):
493 app.watch(env, 'django')665 app.watch(env, 'django/42')
494 mock_print.assert_has_calls([self.pending_call])666 mock_print.assert_has_calls([self.pending_call])
495667
496668
497669
=== modified file 'quickstart/tests/test_juju.py'
--- quickstart/tests/test_juju.py 2013-10-30 10:16:36 +0000
+++ quickstart/tests/test_juju.py 2013-11-18 16:54:22 +0000
@@ -25,6 +25,9 @@
25from quickstart.tests import helpers25from quickstart.tests import helpers
2626
2727
28patch_rpc = mock.patch('quickstart.juju.Environment._rpc')
29
30
28class TestConnect(unittest.TestCase):31class TestConnect(unittest.TestCase):
2932
30 api_url = 'wss://api.example.com:17070'33 api_url = 'wss://api.example.com:17070'
@@ -45,6 +48,9 @@
4548
4649
47class TestEnvironment(unittest.TestCase):50class TestEnvironment(unittest.TestCase):
51 # Note that in some of the tests below, rather than exercising quickstart
52 # code, we are actually testing the external jujuclient methods. This is so
53 # by design, and will help us when upgrading the python-jujuclient library.
4854
49 api_url = 'wss://api.example.com:17070'55 api_url = 'wss://api.example.com:17070'
50 charm_url = 'cs:precise/juju-gui-77'56 charm_url = 'cs:precise/juju-gui-77'
@@ -59,8 +65,24 @@
59 # Keep track of watcher changes in the changesets list.65 # Keep track of watcher changes in the changesets list.
60 self.changesets = []66 self.changesets = []
6167
68 def make_add_unit_request(self, **kwargs):
69 """Create and return an "add unit" request.
70
71 Use kwargs to add or override request parameters.
72 """
73 params = {
74 'ServiceName': self.service_name,
75 'NumUnits': 1,
76 }
77 params.update(kwargs)
78 return {
79 'Type': 'Client',
80 'Request': 'AddServiceUnits',
81 'Params': params,
82 }
83
62 def make_deploy_request(self, **kwargs):84 def make_deploy_request(self, **kwargs):
63 """Create and return a deploy request.85 """Create and return a "deploy" request.
6486
65 Use kwargs to add or override request parameters.87 Use kwargs to add or override request parameters.
66 """88 """
@@ -78,17 +100,43 @@
78 'Params': params,100 'Params': params,
79 }101 }
80102
103 def patch_get_watch(self, return_value):
104 """Patch the Environment.get_watch method.
105
106 When the resulting mock is used as a context manager, the given return
107 value is returned.
108 """
109 get_watch_path = 'quickstart.juju.jujuclient.Environment.get_watch'
110 mock_get_watch = mock.MagicMock()
111 mock_get_watch().__enter__.return_value = iter(return_value)
112 mock_get_watch.reset_mock()
113 return mock.patch(get_watch_path, mock_get_watch)
114
81 def processor(self, changeset):115 def processor(self, changeset):
82 self.changesets.append(changeset)116 self.changesets.append(changeset)
83 return changeset117 return changeset
84118
85 @mock.patch('quickstart.juju.Environment._rpc')119 @patch_rpc
120 def test_add_unit(self, mock_rpc):
121 # The AddServiceUnits API call is properly generated.
122 self.env.add_unit(self.service_name)
123 mock_rpc.assert_called_once_with(self.make_add_unit_request())
124
125 @patch_rpc
126 def test_add_unit_to_machine(self, mock_rpc):
127 # The AddServiceUnits API call is properly generated when deploying a
128 # unit in a specific machine.
129 self.env.add_unit(self.service_name, machine_spec='0')
130 expected = self.make_add_unit_request(ToMachineSpec='0')
131 mock_rpc.assert_called_once_with(expected)
132
133 @patch_rpc
86 def test_deploy(self, mock_rpc):134 def test_deploy(self, mock_rpc):
87 # The deploy API call is properly generated.135 # The deploy API call is properly generated.
88 self.env.deploy(self.service_name, self.charm_url)136 self.env.deploy(self.service_name, self.charm_url)
89 mock_rpc.assert_called_once_with(self.make_deploy_request())137 mock_rpc.assert_called_once_with(self.make_deploy_request())
90138
91 @mock.patch('quickstart.juju.Environment._rpc')139 @patch_rpc
92 def test_deploy_config(self, mock_rpc):140 def test_deploy_config(self, mock_rpc):
93 # The deploy API call is properly generated when passing settings.141 # The deploy API call is properly generated when passing settings.
94 self.env.deploy(142 self.env.deploy(
@@ -98,7 +146,7 @@
98 Config={'key1': 'value1', 'key2': '42'})146 Config={'key1': 'value1', 'key2': '42'})
99 mock_rpc.assert_called_once_with(expected)147 mock_rpc.assert_called_once_with(expected)
100148
101 @mock.patch('quickstart.juju.Environment._rpc')149 @patch_rpc
102 def test_deploy_constraints(self, mock_rpc):150 def test_deploy_constraints(self, mock_rpc):
103 # The deploy API call is properly generated when passing constraints.151 # The deploy API call is properly generated when passing constraints.
104 constraints = {'cpu-cores': 8, 'mem': 16}152 constraints = {'cpu-cores': 8, 'mem': 16}
@@ -107,15 +155,14 @@
107 expected = self.make_deploy_request(Constraints=constraints)155 expected = self.make_deploy_request(Constraints=constraints)
108 mock_rpc.assert_called_once_with(expected)156 mock_rpc.assert_called_once_with(expected)
109157
110 @mock.patch('quickstart.juju.Environment._rpc')158 @patch_rpc
111 def test_deploy_to(self, mock_rpc):159 def test_deploy_no_units(self, mock_rpc):
112 # The deploy API call is properly generated when passing a machine160 # The deploy API call is properly generated when passing zero units.
113 # specification.161 self.env.deploy(self.service_name, self.charm_url, num_units=0)
114 self.env.deploy(self.service_name, self.charm_url, to=0)162 expected = self.make_deploy_request(NumUnits=0)
115 expected = self.make_deploy_request(ToMachineSpec='0')
116 mock_rpc.assert_called_once_with(expected)163 mock_rpc.assert_called_once_with(expected)
117164
118 @mock.patch('quickstart.juju.Environment._rpc')165 @patch_rpc
119 def test_deploy_bundle(self, mock_rpc):166 def test_deploy_bundle(self, mock_rpc):
120 # The deploy bundle call is properly generated.167 # The deploy bundle call is properly generated.
121 self.env.deploy_bundle('name: contents')168 self.env.deploy_bundle('name: contents')
@@ -126,7 +173,7 @@
126 }173 }
127 mock_rpc.assert_called_once_with(expected)174 mock_rpc.assert_called_once_with(expected)
128175
129 @mock.patch('quickstart.juju.Environment._rpc')176 @patch_rpc
130 def test_deploy_bundle_with_name(self, mock_rpc):177 def test_deploy_bundle_with_name(self, mock_rpc):
131 # The deploy bundle call is properly generated when passing a name.178 # The deploy bundle call is properly generated when passing a name.
132 self.env.deploy_bundle('name: contents', name='name')179 self.env.deploy_bundle('name: contents', name='name')
@@ -137,58 +184,75 @@
137 }184 }
138 mock_rpc.assert_called_once_with(expected)185 mock_rpc.assert_called_once_with(expected)
139186
140 @mock.patch('quickstart.juju.jujuclient.Watcher._rpc')187 @patch_rpc
141 def test_watch_changes(self, mock_rpc):188 def test_expose(self, mock_rpc):
189 # The expose API call is properly generated.
190 self.env.expose(self.service_name)
191 expected = {
192 'Type': 'Client',
193 'Request': 'ServiceExpose',
194 'Params': {'ServiceName': self.service_name},
195 }
196 mock_rpc.assert_called_once_with(expected)
197
198 def test_get_status(self):
199 # The current status of the Juju environment is properly returned.
200 changeset1 = ['change1', 'change2']
201 changeset2 = ['change3']
202 with self.patch_get_watch([changeset1, changeset2]) as mock_get_watch:
203 status = self.env.get_status()
204 # The get_status call only waits for the first changeset.
205 self.assertEqual(changeset1, status)
206 # The watcher is correctly closed.
207 self.assertEqual(1, mock_get_watch().__exit__.call_count)
208
209 def test_watch_changes(self):
142 # It is possible to watch for changes using a processor callable.210 # It is possible to watch for changes using a processor callable.
143 changeset1 = ['change1', 'change2']211 changeset1 = ['change1', 'change2']
144 changeset2 = ['change3']212 changeset2 = ['change3']
145 mock_rpc.side_effect = [213 with self.patch_get_watch([changeset1, changeset2]) as mock_get_watch:
146 # Define the response to the watcher's start call.214 watcher = self.env.watch_changes(self.processor)
147 {'Response': {}, 'AllWatcherId': 42},215 # The first set of changes is correctly returned.
148 # Define two responses to the two subsequent next calls.216 changeset = watcher.next()
149 {'Response': {}, 'Deltas': changeset1},217 self.assertEqual(changeset1, changeset)
150 {'Response': {}, 'Deltas': changeset2},218 # The second set of changes is correctly returned.
151 # Define the response to the watcher's stop call.219 changeset = watcher.next()
152 {'Response': {}},220 self.assertEqual(changeset2, changeset)
153 ]
154 watcher = self.env.watch_changes(self.processor)
155 # The first set of changes is correctly returned.
156 changeset = watcher.next()
157 self.assertEqual(changeset1, changeset)
158 # The second set of changes is correctly returned.
159 changeset = watcher.next()
160 self.assertEqual(changeset2, changeset)
161 # All the changes have been processed.221 # All the changes have been processed.
162 self.assertEqual([changeset1, changeset2], self.changesets)222 self.assertEqual([changeset1, changeset2], self.changesets)
163 # Ensure the API has been used properly.223 # Ensure the API has been used properly.
164 mock_rpc.assert_has_calls([224 mock_get_watch().__enter__.assert_called_once_with()
165 mock.call({'Type': 'Client', 'Request': 'WatchAll', 'Params': {}}),225
166 mock.call({'Type': 'AllWatcher', 'Request': 'Next', 'Id': 42}),226 def test_watch_changes_map(self):
167 mock.call({'Type': 'AllWatcher', 'Request': 'Next', 'Id': 42}),227 # The processor callable can be used to modify changes.
168 ])228 changeset1 = ['change1', 'change2']
169229 changeset2 = ['change3']
170 @mock.patch('quickstart.juju.jujuclient.Watcher._rpc')230 with self.patch_get_watch([changeset1, changeset2]):
171 def test_watch_closed(self, mock_rpc):231 watcher = self.env.watch_changes(len)
232 changesets = list(watcher)
233 self.assertEqual([len(changeset1), len(changeset2)], changesets)
234
235 def test_watch_changes_filter(self):
236 # The processor callable can be used to filter changes.
237 changeset1 = ['change1', 'change2']
238 changeset2 = ['change3']
239 processor = lambda changes: None if len(changes) == 1 else changes
240 with self.patch_get_watch([changeset1, changeset2]):
241 watcher = self.env.watch_changes(processor)
242 changesets = list(watcher)
243 self.assertEqual([changeset1], changesets)
244
245 def test_watch_closed(self):
172 # A stop API call on the AllWatcher is performed when the watcher is246 # A stop API call on the AllWatcher is performed when the watcher is
173 # garbage collected.247 # garbage collected.
174 mock_rpc.side_effect = [248 changeset = ['change1', 'change2']
175 # Define the response to the watcher's start call.249 with self.patch_get_watch([changeset]) as mock_get_watch:
176 {'Response': {}, 'AllWatcherId': 42},250 watcher = self.env.watch_changes(self.processor)
177 # Define a response to a next call.251 # The first set of changes is correctly returned.
178 {'Response': {}, 'Deltas': ['change1', 'change2']},252 watcher.next()
179 # Define the response to the watcher's stop call.253 del watcher
180 {'Response': {}},
181 ]
182 watcher = self.env.watch_changes(self.processor)
183 # The first set of changes is correctly returned.
184 watcher.next()
185 del watcher
186 # Ensure the API has been used properly.254 # Ensure the API has been used properly.
187 mock_rpc.assert_has_calls([255 self.assertEqual(1, mock_get_watch().__exit__.call_count)
188 mock.call({'Type': 'Client', 'Request': 'WatchAll', 'Params': {}}),
189 mock.call({'Type': 'AllWatcher', 'Request': 'Next', 'Id': 42}),
190 mock.call({'Type': 'AllWatcher', 'Request': 'Stop', 'Id': 42}),
191 ])
192256
193257
194class TestWebSocketConnection(unittest.TestCase):258class TestWebSocketConnection(unittest.TestCase):
195259
=== modified file 'quickstart/tests/test_manage.py'
--- quickstart/tests/test_manage.py 2013-11-13 12:23:36 +0000
+++ quickstart/tests/test_manage.py 2013-11-18 16:54:22 +0000
@@ -356,10 +356,11 @@
356 mock_app.connect.assert_called_once_with(356 mock_app.connect.assert_called_once_with(
357 mock_app.get_api_url(), options.admin_secret)357 mock_app.get_api_url(), options.admin_secret)
358 mock_app.deploy_gui.assert_called_once_with(358 mock_app.deploy_gui.assert_called_once_with(
359 mock_app.connect(), settings.JUJU_GUI_NAME,359 mock_app.connect(), settings.JUJU_GUI_NAME, '0',
360 charm_url=options.charm_url)360 charm_url=options.charm_url,
361 check_preexisting=mock_app.bootstrap())
361 mock_app.watch.assert_called_once_with(362 mock_app.watch.assert_called_once_with(
362 mock_app.connect(), settings.JUJU_GUI_NAME)363 mock_app.connect(), mock_app.deploy_gui())
363 mock_open.assert_called_once_with(364 mock_open.assert_called_once_with(
364 'https://{}'.format(mock_app.watch()))365 'https://{}'.format(mock_app.watch()))
365 self.assertFalse(mock_app.deploy_bundle.called)366 self.assertFalse(mock_app.deploy_bundle.called)
366367
=== modified file 'quickstart/tests/test_utils.py'
--- quickstart/tests/test_utils.py 2013-11-13 12:23:36 +0000
+++ quickstart/tests/test_utils.py 2013-11-18 16:54:22 +0000
@@ -178,6 +178,79 @@
178 mock_call.assert_called_once_with('juju', 'switch')178 mock_call.assert_called_once_with('juju', 'switch')
179179
180180
181class TestGetServiceInfo(helpers.WatcherDataTestsMixin, unittest.TestCase):
182
183 def test_service_and_unit(self):
184 # The data about the given service and unit is correctly returned.
185 service_change = self.make_service_change()
186 unit_change = self.make_unit_change()
187 status = [service_change, unit_change]
188 expected = (service_change[2], unit_change[2])
189 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
190
191 def test_service_only(self):
192 # The data about the given service without units is correctly returned.
193 service_change = self.make_service_change()
194 status = [service_change]
195 expected = (service_change[2], None)
196 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
197
198 def test_service_removed(self):
199 # A tuple (None, None) is returned if the service is being removed.
200 status = [
201 self.make_service_change(action='remove'),
202 self.make_unit_change(),
203 ]
204 expected = (None, None)
205 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
206
207 def test_another_service(self):
208 # A tuple (None, None) is returned if the service is not found.
209 status = [
210 self.make_service_change(data={'Name': 'another-service'}),
211 self.make_unit_change(),
212 ]
213 expected = (None, None)
214 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
215
216 def test_service_not_alive(self):
217 # A tuple (None, None) is returned if the service is not alive.
218 status = [
219 self.make_service_change(data={'Life': 'dying'}),
220 self.make_unit_change(),
221 ]
222 expected = (None, None)
223 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
224
225 def test_unit_removed(self):
226 # The unit data is not returned if the unit is being removed.
227 service_change = self.make_service_change()
228 status = [service_change, self.make_unit_change(action='remove')]
229 expected = (service_change[2], None)
230 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
231
232 def test_another_unit(self):
233 # The unit data is not returned if the unit belongs to another service.
234 service_change = self.make_service_change()
235 status = [
236 service_change,
237 self.make_unit_change(data={'Service': 'another-service'}),
238 ]
239 expected = (service_change[2], None)
240 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
241
242 def test_no_services(self):
243 # A tuple (None, None) is returned no services are found.
244 status = [self.make_unit_change()]
245 expected = (None, None)
246 self.assertEqual(expected, utils.get_service_info(status, 'my-gui'))
247
248 def test_no_entities(self):
249 # A tuple (None, None) is returned no entities are found.
250 expected = (None, None)
251 self.assertEqual(expected, utils.get_service_info([], 'my-gui'))
252
253
181class TestParseBundle(254class TestParseBundle(
182 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,255 helpers.BundleFileTestsMixin, helpers.ValueErrorTestsMixin,
183 unittest.TestCase):256 unittest.TestCase):
184257
=== modified file 'quickstart/utils.py'
--- quickstart/utils.py 2013-11-13 12:23:36 +0000
+++ quickstart/utils.py 2013-11-18 16:54:22 +0000
@@ -111,6 +111,31 @@
111 return match.groups()[0]111 return match.groups()[0]
112112
113113
114def get_service_info(status, service_name):
115 """Retrieve information on the given service and on its first alive unit.
116
117 Return a tuple containing two values: (service data, unit data).
118 Each value can be:
119 - a dictionary of data about the given entity (service or unit) as
120 returned by the Juju watcher;
121 - None, if the entity is not present in the Juju environment.
122 If the service data is None, the unit data is always None.
123 """
124 services = [
125 data for entity, action, data in status if
126 (entity == 'service') and (action != 'remove') and
127 (data['Name'] == service_name) and (data['Life'] == 'alive')
128 ]
129 if not services:
130 return None, None
131 units = [
132 data for entity, action, data in status if
133 entity == 'unit' and action != 'remove' and
134 data['Service'] == service_name
135 ]
136 return services[0], units[0] if units else None
137
138
114def parse_bundle(bundle_yaml, bundle_name=None):139def parse_bundle(bundle_yaml, bundle_name=None):
115 """Parse the provided bundle YAML encoded contents.140 """Parse the provided bundle YAML encoded contents.
116141

Subscribers

People subscribed via source and target branches