Merge lp:~frankban/charms/precise/juju-gui/cancel-deployment into lp:~juju-gui/charms/precise/juju-gui/trunk
- Precise Pangolin (12.04)
- cancel-deployment
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 108 |
Proposed branch: | lp:~frankban/charms/precise/juju-gui/cancel-deployment |
Merge into: | lp:~juju-gui/charms/precise/juju-gui/trunk |
Diff against target: |
542 lines (+245/-33) 9 files modified
revision (+1/-1) server/guiserver/__init__.py (+1/-1) server/guiserver/bundles/__init__.py (+46/-13) server/guiserver/bundles/base.py (+39/-5) server/guiserver/bundles/utils.py (+7/-0) server/guiserver/bundles/views.py (+22/-0) server/guiserver/tests/bundles/test_base.py (+63/-1) server/guiserver/tests/bundles/test_utils.py (+29/-12) server/guiserver/tests/bundles/test_views.py (+37/-0) |
To merge this branch: | bzr merge lp:~frankban/charms/precise/juju-gui/cancel-deployment |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
charmers | Pending | ||
Review via email: mp+185448@code.launchpad.net |
Commit message
Description of the change
GUI server: cancel deployment feature.
Added an API call for cancelling a pending
deployment.
Also updated the bundles module documentation.
For this first implementation, I discussed
with Gary a workaround to avoid scheduled
deployments to be included in the ProcessPool
executor's call queue: a time.sleep call is
added to the queue right after a new deployment.
This way, as described in the code, it is still
possible to cancel a scheduled deployment job
even if it is the first in the queue.
Tests:
run `make unittest` from the root of this branch.
QA:
- bootstrap a juju-core environment;
- deploy the GUI from this branch (`make deploy`);
- switch to the builtin server:
`juju set juju-gui builtin-
- ensure the GUI is working well by visiting
https:/
To test the deployer support and the
"cancel deployment" feature, use the script in
http://
- download and save the Python script;
- run it passing the GUI node address as first argument:
`python start-deployer-
The script does the following:
1) it logs in to the juju-core API server;
2) it starts/schedules three deployments;
3) it send two invalid Cancel requests;
4) it cancels the second deployment;
5) it shows the deployments status before and
after the deployment deletion.
- if everything is ok, you should see an output like
this: http://
- you should also be able to check the deployment
progress from the GUI;
- when the two deployments are completed (it may take
some minutes) you should see wordpress, mysql and
mediawiki correctly deployed and displayed in the
topology view;
- visiting https:/
should see something like this:
{
"uptime": 970,
"deployer": [
{"Status": "completed", "DeploymentId": 0, "Time": 1379062144},
{"Status": "cancelled", "DeploymentId": 1, "Time": 1379061766},
{"Status": "completed", "DeploymentId": 2, "Time": 1379062156}
],
"apiversion": "go",
"sandbox": false,
"version": "0.2.0",
"debug": false,
"apiurl": "wss://
}
That's all, thanks a lot for QAing this branch!
Francesco Banconi (frankban) wrote : | # |
Francesco Banconi (frankban) wrote : | # |
I forgot to mention that you need to change the PASSWORD
at line #17 of the script, setting your own admin secret,
before running the script itself.
Richard Harding (rharding) wrote : | # |
code ok, will qa next. Just have the one objection to camel cased http
params.
https:/
File server/
https:/
server/
request.
caps in the query string? why not match the var name used deployment_id?
Francesco Banconi (frankban) wrote : | # |
On 2013/09/13 13:16:21, rharding wrote:
> server/
> request.
> caps in the query string? why not match the var name used
deployment_id?
params is not really a querystring, it's the content of the Params field
included in the WebSocket message. Caps are used for consistency with
all
the other juju-core API calls.
- 116. By Francesco Banconi
-
Debugging a unit test.
Richard Harding (rharding) wrote : | # |
On 2013/09/13 13:24:32, frankban wrote:
> On 2013/09/13 13:16:21, rharding wrote:
> > server/
> > request.
> > caps in the query string? why not match the var name used
deployment_id?
> params is not really a querystring, it's the content of the Params
field
> included in the WebSocket message. Caps are used for consistency with
all
> the other juju-core API calls.
Ah, ok. In pyramid/charmworld request.params is a map into
request.GET/POST.
Richard Harding (rharding) wrote : | # |
LGTM
Results of the script, first run:
http://
I did run it a second time and got an error:
http://
I'm not sure if this is a bug here or as a follow up to not crash when
the service is already there.
- 117. By Francesco Banconi
-
Debugging again.
Francesco Banconi (frankban) wrote : | # |
On 2013/09/13 13:45:00, rharding wrote:
> LGTM
> Results of the script, first run:
> http://
Great, thank you for QAing this branch!
> I did run it a second time and got an error:
> http://
> I'm not sure if this is a bug here or as a follow up to not crash when
the
> service is already there.
Running the script a second time you also tested the validation
step failing when there is a service name clash in the
environment, and that's the correct behavior.
The subsequent KeyError is ok since the script expects
the request to be successful.
Thanks!
- 118. By Francesco Banconi
-
Final debugging.
Brad Crittenden (bac) wrote : | # |
LGTM
https:/
File server/
https:/
server/
if the job is the next to be started.
It's not clear to my why you want to be able to cancel this first job.
Only giving a window of 1 second seems arbitrary. Perhaps the use case
will become clearer as I read more code.
Francesco Banconi (frankban) wrote : | # |
*** Submitted:
GUI server: cancel deployment feature.
Added an API call for cancelling a pending
deployment.
Also updated the bundles module documentation.
For this first implementation, I discussed
with Gary a workaround to avoid scheduled
deployments to be included in the ProcessPool
executor's call queue: a time.sleep call is
added to the queue right after a new deployment.
This way, as described in the code, it is still
possible to cancel a scheduled deployment job
even if it is the first in the queue.
Tests:
run `make unittest` from the root of this branch.
QA:
- bootstrap a juju-core environment;
- deploy the GUI from this branch (`make deploy`);
- switch to the builtin server:
`juju set juju-gui builtin-
- ensure the GUI is working well by visiting
https:/
To test the deployer support and the
"cancel deployment" feature, use the script in
http://
- download and save the Python script;
- run it passing the GUI node address as first argument:
`python start-deployer-
The script does the following:
1) it logs in to the juju-core API server;
2) it starts/schedules three deployments;
3) it send two invalid Cancel requests;
4) it cancels the second deployment;
5) it shows the deployments status before and
after the deployment deletion.
- if everything is ok, you should see an output like
this: http://
- you should also be able to check the deployment
progress from the GUI;
- when the two deployments are completed (it may take
some minutes) you should see wordpress, mysql and
mediawiki correctly deployed and displayed in the
topology view;
- visiting https:/
should see something like this:
{
"uptime": 970,
"deployer": [
{"Status": "completed", "DeploymentId": 0, "Time": 1379062144},
{"Status": "cancelled", "DeploymentId": 1, "Time": 1379061766},
{"Status": "completed", "DeploymentId": 2, "Time": 1379062156}
],
"apiversion": "go",
"sandbox": false,
"version": "0.2.0",
"debug": false,
"apiurl": "wss://
}
That's all, thanks a lot for QAing this branch!
R=rharding, bac
CC=
https:/
https:/
File server/
https:/
server/
if the job is the next to be started.
On 2013/09/13 14:33:19, bac wrote:
> It's not clear to my why you want to be able to cancel this first job.
Only
> giving a window of 1 second seems arbitrary. Perhaps the use case
will become
> clearer as I read more code.
It is arbitrary indeed. This is just a way to fill the call queue so
that, when a job starts (and it is removed from the queue) he sleep is
put in the queue and the next deployment can still be cancelled. This is
just a workaround, and will be changed in the future, likely
implementing our own as...
Preview Diff
1 | === modified file 'revision' | |||
2 | --- revision 2013-09-12 19:58:07 +0000 | |||
3 | +++ revision 2013-09-13 14:09:53 +0000 | |||
4 | @@ -1,1 +1,1 @@ | |||
6 | 1 | 84 | 1 | 85 |
7 | 2 | 2 | ||
8 | === modified file 'server/guiserver/__init__.py' | |||
9 | --- server/guiserver/__init__.py 2013-08-23 14:44:00 +0000 | |||
10 | +++ server/guiserver/__init__.py 2013-09-13 14:09:53 +0000 | |||
11 | @@ -30,7 +30,7 @@ | |||
12 | 30 | the HTTPS connection, allowing changes in the Juju environment to be propagated | 30 | the HTTPS connection, allowing changes in the Juju environment to be propagated |
13 | 31 | and shown immediately by the browser. """ | 31 | and shown immediately by the browser. """ |
14 | 32 | 32 | ||
16 | 33 | VERSION = (0, 1, 0) | 33 | VERSION = (0, 2, 0) |
17 | 34 | 34 | ||
18 | 35 | 35 | ||
19 | 36 | def get_version(): | 36 | def get_version(): |
20 | 37 | 37 | ||
21 | === modified file 'server/guiserver/bundles/__init__.py' | |||
22 | --- server/guiserver/bundles/__init__.py 2013-09-04 15:21:54 +0000 | |||
23 | +++ server/guiserver/bundles/__init__.py 2013-09-13 14:09:53 +0000 | |||
24 | @@ -16,9 +16,6 @@ | |||
25 | 16 | 16 | ||
26 | 17 | """Juju GUI server bundles support. | 17 | """Juju GUI server bundles support. |
27 | 18 | 18 | ||
28 | 19 | XXX frankban: note that the following is a work in progress. Some of the | ||
29 | 20 | objects described below are not yet implemented. | ||
30 | 21 | |||
31 | 22 | This package includes the objects and functions required to support deploying | 19 | This package includes the objects and functions required to support deploying |
32 | 23 | bundles in juju-core. The base pieces of the infrastructure are placed in the | 20 | bundles in juju-core. The base pieces of the infrastructure are placed in the |
33 | 24 | base module: | 21 | base module: |
34 | @@ -195,7 +192,9 @@ | |||
35 | 195 | bundle at the time. A Queue value of zero means the deployment will be started | 192 | bundle at the time. A Queue value of zero means the deployment will be started |
36 | 196 | as soon as possible. | 193 | as soon as possible. |
37 | 197 | 194 | ||
39 | 198 | The Status can be one of the following: 'scheduled', 'started' and 'completed'. | 195 | The Status can be one of the following: 'scheduled', 'started', 'completed' and |
40 | 196 | 'cancelled. See the next section for an explanation of how to cancel a pending | ||
41 | 197 | (scheduled) deployment. | ||
42 | 199 | 198 | ||
43 | 200 | The Time field indicates the number of seconds since the epoch at the time of | 199 | The Time field indicates the number of seconds since the epoch at the time of |
44 | 201 | the change. | 200 | the change. |
45 | @@ -222,6 +221,40 @@ | |||
46 | 222 | XXX frankban: a timeout to delete completed deployments history will be | 221 | XXX frankban: a timeout to delete completed deployments history will be |
47 | 223 | eventually implemented. | 222 | eventually implemented. |
48 | 224 | 223 | ||
49 | 224 | Cancelling a deployment. | ||
50 | 225 | ------------------------ | ||
51 | 226 | |||
52 | 227 | It is possible to cancel the execution of scheduled deployments by sending a | ||
53 | 228 | Cancel request, e.g.: | ||
54 | 229 | |||
55 | 230 | { | ||
56 | 231 | 'RequestId': 5, | ||
57 | 232 | 'Type': 'Deployer', | ||
58 | 233 | 'Request': 'Cancel', | ||
59 | 234 | 'Params': {'DeploymentId': 42}, | ||
60 | 235 | } | ||
61 | 236 | |||
62 | 237 | Note that it is allowed to cancel a deployment only if it is not yet started, | ||
63 | 238 | i.e. if it is in a 'scheduled' state. | ||
64 | 239 | |||
65 | 240 | If any error occurs, the response is like this: | ||
66 | 241 | |||
67 | 242 | { | ||
68 | 243 | 'RequestId': 5, | ||
69 | 244 | 'Response': {}, | ||
70 | 245 | 'Error': 'some error: error details', | ||
71 | 246 | } | ||
72 | 247 | |||
73 | 248 | Usually an error response is returned when either an invalid deployment id was | ||
74 | 249 | provided or the request attempted to cancel an already started deployment. | ||
75 | 250 | |||
76 | 251 | If the deployment is successfully cancelled, the response is the following: | ||
77 | 252 | |||
78 | 253 | { | ||
79 | 254 | 'RequestId': 5, | ||
80 | 255 | 'Response': {}, | ||
81 | 256 | } | ||
82 | 257 | |||
83 | 225 | Deployments status. | 258 | Deployments status. |
84 | 226 | ------------------- | 259 | ------------------- |
85 | 227 | 260 | ||
86 | @@ -229,7 +262,7 @@ | |||
87 | 229 | the client can send the following request: | 262 | the client can send the following request: |
88 | 230 | 263 | ||
89 | 231 | { | 264 | { |
91 | 232 | 'RequestId': 5, | 265 | 'RequestId': 6, |
92 | 233 | 'Type': 'Deployer', | 266 | 'Type': 'Deployer', |
93 | 234 | 'Request': 'Status', | 267 | 'Request': 'Status', |
94 | 235 | } | 268 | } |
95 | @@ -238,29 +271,29 @@ | |||
96 | 238 | the second one is a successful response: | 271 | the second one is a successful response: |
97 | 239 | 272 | ||
98 | 240 | { | 273 | { |
100 | 241 | 'RequestId': 5, | 274 | 'RequestId': 6, |
101 | 242 | 'Response': {}, | 275 | 'Response': {}, |
102 | 243 | 'Error': 'some error: error details', | 276 | 'Error': 'some error: error details', |
103 | 244 | } | 277 | } |
104 | 245 | 278 | ||
105 | 246 | { | 279 | { |
107 | 247 | 'RequestId': 5, | 280 | 'RequestId': 6, |
108 | 248 | 'Response': { | 281 | 'Response': { |
109 | 249 | 'LastChanges': [ | 282 | 'LastChanges': [ |
111 | 250 | {'DeploymentId': 42, 'Status': 'completed', 'Time': 1377080001, | 283 | {'DeploymentId': 1, 'Status': 'completed', 'Time': 1377080001, |
112 | 251 | 'Error': 'error'}, | 284 | 'Error': 'error'}, |
116 | 252 | {'DeploymentId': 43, 'Status': 'completed', | 285 | {'DeploymentId': 2, 'Status': 'completed', 'Time': 1377080002}, |
117 | 253 | 'Time': 1377080002}, | 286 | {'DeploymentId': 3, 'Status': 'started', 'Time': 1377080003, |
115 | 254 | {'DeploymentId': 44, 'Status': 'started', 'Time': 1377080003, | ||
118 | 255 | 'Queue': 0}, | 287 | 'Queue': 0}, |
120 | 256 | {'DeploymentId': 45, 'Status': 'scheduled', 'Time': 1377080004, | 288 | {'DeploymentId': 4, 'Status': 'cancelled', 'Time': 1377080004}, |
121 | 289 | {'DeploymentId': 5, 'Status': 'scheduled', 'Time': 1377080005, | ||
122 | 257 | 'Queue': 1}, | 290 | 'Queue': 1}, |
123 | 258 | ], | 291 | ], |
124 | 259 | }, | 292 | }, |
125 | 260 | } | 293 | } |
126 | 261 | 294 | ||
127 | 262 | In the second response above, the Error field in the first attempted deployment | 295 | In the second response above, the Error field in the first attempted deployment |
129 | 263 | (42) contains details about an error that occurred while deploying a bundle. | 296 | (1) contains details about an error that occurred while deploying a bundle. |
130 | 264 | This means that bundle deployment has been completed but an error occurred | 297 | This means that bundle deployment has been completed but an error occurred |
131 | 265 | during the process. | 298 | during the process. |
132 | 266 | """ | 299 | """ |
133 | 267 | 300 | ||
134 | === modified file 'server/guiserver/bundles/base.py' | |||
135 | --- server/guiserver/bundles/base.py 2013-08-26 07:56:52 +0000 | |||
136 | +++ server/guiserver/bundles/base.py 2013-09-13 14:09:53 +0000 | |||
137 | @@ -23,7 +23,12 @@ | |||
138 | 23 | a detailed explanation of how these objects are used. | 23 | a detailed explanation of how these objects are used. |
139 | 24 | """ | 24 | """ |
140 | 25 | 25 | ||
142 | 26 | from concurrent.futures import ProcessPoolExecutor | 26 | import time |
143 | 27 | |||
144 | 28 | from concurrent.futures import ( | ||
145 | 29 | process, | ||
146 | 30 | ProcessPoolExecutor, | ||
147 | 31 | ) | ||
148 | 27 | from tornado import gen | 32 | from tornado import gen |
149 | 28 | from tornado.ioloop import IOLoop | 33 | from tornado.ioloop import IOLoop |
150 | 29 | from tornado.util import ObjectDict | 34 | from tornado.util import ObjectDict |
151 | @@ -37,6 +42,10 @@ | |||
152 | 37 | from guiserver.watchers import WatcherError | 42 | from guiserver.watchers import WatcherError |
153 | 38 | 43 | ||
154 | 39 | 44 | ||
155 | 45 | # Controls how many more calls than processes will be queued in the call queue. | ||
156 | 46 | # Set to zero to make Future.cancel() succeed more frequently (Futures in the | ||
157 | 47 | # call queue cannot be cancelled). | ||
158 | 48 | process.EXTRA_QUEUED_CALLS = 0 | ||
159 | 40 | # Juju API versions supported by the GUI server Deployer. | 49 | # Juju API versions supported by the GUI server Deployer. |
160 | 41 | # Tests use the first API version in this list. | 50 | # Tests use the first API version in this list. |
161 | 42 | SUPPORTED_API_VERSIONS = ['go'] | 51 | SUPPORTED_API_VERSIONS = ['go'] |
162 | @@ -78,6 +87,8 @@ | |||
163 | 78 | # Queue stores the deployment identifiers corresponding to the | 87 | # Queue stores the deployment identifiers corresponding to the |
164 | 79 | # currently started/queued jobs. | 88 | # currently started/queued jobs. |
165 | 80 | self._queue = [] | 89 | self._queue = [] |
166 | 90 | # The futures attribute maps deployment identifiers to Futures. | ||
167 | 91 | self._futures = {} | ||
168 | 81 | 92 | ||
169 | 82 | @gen.coroutine | 93 | @gen.coroutine |
170 | 83 | def validate(self, user, name, bundle): | 94 | def validate(self, user, name, bundle): |
171 | @@ -132,9 +143,14 @@ | |||
172 | 132 | future = self._run_executor.submit( | 143 | future = self._run_executor.submit( |
173 | 133 | blocking.import_bundle, self._apiurl, user.password, name, bundle) | 144 | blocking.import_bundle, self._apiurl, user.password, name, bundle) |
174 | 134 | add_future(self._io_loop, future, self._import_callback, deployment_id) | 145 | add_future(self._io_loop, future, self._import_callback, deployment_id) |
175 | 146 | self._futures[deployment_id] = future | ||
176 | 135 | # If a customized callback is provided, schedule it as well. | 147 | # If a customized callback is provided, schedule it as well. |
177 | 136 | if test_callback is not None: | 148 | if test_callback is not None: |
178 | 137 | add_future(self._io_loop, future, test_callback) | 149 | add_future(self._io_loop, future, test_callback) |
179 | 150 | # Submit a sleeping job in order to avoid the next deployment job to be | ||
180 | 151 | # immediately put in the executor's call queue. This allows for | ||
181 | 152 | # cancelling scheduled jobs, even if the job is the next to be started. | ||
182 | 153 | self._run_executor.submit(time.sleep, 1) | ||
183 | 138 | return deployment_id | 154 | return deployment_id |
184 | 139 | 155 | ||
185 | 140 | def _import_callback(self, deployment_id, future): | 156 | def _import_callback(self, deployment_id, future): |
186 | @@ -144,12 +160,17 @@ | |||
187 | 144 | deployment_id identifying one specific deployment job, and the fired | 160 | deployment_id identifying one specific deployment job, and the fired |
188 | 145 | future returned by the executor. | 161 | future returned by the executor. |
189 | 146 | """ | 162 | """ |
194 | 147 | exception = future.exception() | 163 | if future.cancelled(): |
195 | 148 | error = None if exception is None else str(exception) | 164 | # Notify a deployment has been cancelled. |
196 | 149 | # Notify a deployment completed. | 165 | self._observer.notify_cancelled(deployment_id) |
197 | 150 | self._observer.notify_completed(deployment_id, error=error) | 166 | else: |
198 | 167 | exception = future.exception() | ||
199 | 168 | error = None if exception is None else str(exception) | ||
200 | 169 | # Notify a deployment completed. | ||
201 | 170 | self._observer.notify_completed(deployment_id, error=error) | ||
202 | 151 | # Remove the completed deployment job from the queue. | 171 | # Remove the completed deployment job from the queue. |
203 | 152 | self._queue.remove(deployment_id) | 172 | self._queue.remove(deployment_id) |
204 | 173 | del self._futures[deployment_id] | ||
205 | 153 | # Notify the new position of all remaining deployments in the queue. | 174 | # Notify the new position of all remaining deployments in the queue. |
206 | 154 | for position, deploy_id in enumerate(self._queue): | 175 | for position, deploy_id in enumerate(self._queue): |
207 | 155 | self._observer.notify_position(deploy_id, position) | 176 | self._observer.notify_position(deploy_id, position) |
208 | @@ -183,6 +204,18 @@ | |||
209 | 183 | except WatcherError: | 204 | except WatcherError: |
210 | 184 | return | 205 | return |
211 | 185 | 206 | ||
212 | 207 | def cancel(self, deployment_id): | ||
213 | 208 | """Attempt to cancel the deployment identified by deployment_id. | ||
214 | 209 | |||
215 | 210 | Return None if the deployment has been correctly cancelled. | ||
216 | 211 | Return an error string otherwise. | ||
217 | 212 | """ | ||
218 | 213 | future = self._futures.get(deployment_id) | ||
219 | 214 | if future is None: | ||
220 | 215 | return 'deployment not found or already completed' | ||
221 | 216 | if not future.cancel(): | ||
222 | 217 | return 'unable to cancel the deployment' | ||
223 | 218 | |||
224 | 186 | def status(self): | 219 | def status(self): |
225 | 187 | """Return a list containing the last known change for each deployment. | 220 | """Return a list containing the last known change for each deployment. |
226 | 188 | """ | 221 | """ |
227 | @@ -221,6 +254,7 @@ | |||
228 | 221 | 'Import': views.import_bundle, | 254 | 'Import': views.import_bundle, |
229 | 222 | 'Watch': views.watch, | 255 | 'Watch': views.watch, |
230 | 223 | 'Next': views.next, | 256 | 'Next': views.next, |
231 | 257 | 'Cancel': views.cancel, | ||
232 | 224 | 'Status': views.status, | 258 | 'Status': views.status, |
233 | 225 | } | 259 | } |
234 | 226 | 260 | ||
235 | 227 | 261 | ||
236 | === modified file 'server/guiserver/bundles/utils.py' | |||
237 | --- server/guiserver/bundles/utils.py 2013-08-21 16:12:45 +0000 | |||
238 | +++ server/guiserver/bundles/utils.py 2013-09-13 14:09:53 +0000 | |||
239 | @@ -29,6 +29,7 @@ | |||
240 | 29 | # Change statuses. | 29 | # Change statuses. |
241 | 30 | SCHEDULED = 'scheduled' | 30 | SCHEDULED = 'scheduled' |
242 | 31 | STARTED = 'started' | 31 | STARTED = 'started' |
243 | 32 | CANCELLED = 'cancelled' | ||
244 | 32 | COMPLETED = 'completed' | 33 | COMPLETED = 'completed' |
245 | 33 | 34 | ||
246 | 34 | 35 | ||
247 | @@ -99,6 +100,12 @@ | |||
248 | 99 | change = create_change(deployment_id, status, queue=position) | 100 | change = create_change(deployment_id, status, queue=position) |
249 | 100 | watcher.put(change) | 101 | watcher.put(change) |
250 | 101 | 102 | ||
251 | 103 | def notify_cancelled(self, deployment_id): | ||
252 | 104 | """Add a change to the deployment watcher notifying it is cancelled.""" | ||
253 | 105 | watcher = self.deployments[deployment_id] | ||
254 | 106 | change = create_change(deployment_id, CANCELLED) | ||
255 | 107 | watcher.close(change) | ||
256 | 108 | |||
257 | 102 | def notify_completed(self, deployment_id, error=None): | 109 | def notify_completed(self, deployment_id, error=None): |
258 | 103 | """Add a change to the deployment watcher notifying it is completed.""" | 110 | """Add a change to the deployment watcher notifying it is completed.""" |
259 | 104 | watcher = self.deployments[deployment_id] | 111 | watcher = self.deployments[deployment_id] |
260 | 105 | 112 | ||
261 | === modified file 'server/guiserver/bundles/views.py' | |||
262 | --- server/guiserver/bundles/views.py 2013-09-04 15:22:42 +0000 | |||
263 | +++ server/guiserver/bundles/views.py 2013-09-13 14:09:53 +0000 | |||
264 | @@ -169,6 +169,28 @@ | |||
265 | 169 | 169 | ||
266 | 170 | @gen.coroutine | 170 | @gen.coroutine |
267 | 171 | @require_authenticated_user | 171 | @require_authenticated_user |
268 | 172 | def cancel(request, deployer): | ||
269 | 173 | """Cancel the given pending deployment. | ||
270 | 174 | |||
271 | 175 | The deployment is identified in the request by the DeploymentId parameter. | ||
272 | 176 | If the request is not valid or the deployment cannot be cancelled (e.g. | ||
273 | 177 | because it is already started) an error response is returned. | ||
274 | 178 | |||
275 | 179 | Request: 'Cancel'. | ||
276 | 180 | Parameters example: {'DeploymentId': 42}. | ||
277 | 181 | """ | ||
278 | 182 | deployment_id = request.params.get('DeploymentId') | ||
279 | 183 | if deployment_id is None: | ||
280 | 184 | raise response(error='invalid request: invalid data parameters') | ||
281 | 185 | # Use the Deployer instance to cancel the deployment. | ||
282 | 186 | err = deployer.cancel(deployment_id) | ||
283 | 187 | if err is not None: | ||
284 | 188 | raise response(error='invalid request: {}'.format(err)) | ||
285 | 189 | raise response() | ||
286 | 190 | |||
287 | 191 | |||
288 | 192 | @gen.coroutine | ||
289 | 193 | @require_authenticated_user | ||
290 | 172 | def status(request, deployer): | 194 | def status(request, deployer): |
291 | 173 | """Return the current status of all the bundle deployments. | 195 | """Return the current status of all the bundle deployments. |
292 | 174 | 196 | ||
293 | 175 | 197 | ||
294 | === modified file 'server/guiserver/tests/bundles/test_base.py' | |||
295 | --- server/guiserver/tests/bundles/test_base.py 2013-08-26 07:56:52 +0000 | |||
296 | +++ server/guiserver/tests/bundles/test_base.py 2013-09-13 14:09:53 +0000 | |||
297 | @@ -195,13 +195,75 @@ | |||
298 | 195 | # Wait for the deployment to be completed. | 195 | # Wait for the deployment to be completed. |
299 | 196 | self.wait() | 196 | self.wait() |
300 | 197 | 197 | ||
301 | 198 | @gen_test | ||
302 | 199 | def test_invalid_watcher(self): | 198 | def test_invalid_watcher(self): |
303 | 200 | # None is returned if the watcher id is not valid. | 199 | # None is returned if the watcher id is not valid. |
304 | 201 | deployer = self.make_deployer() | 200 | deployer = self.make_deployer() |
305 | 202 | changes = deployer.next(42) | 201 | changes = deployer.next(42) |
306 | 203 | self.assertIsNone(changes) | 202 | self.assertIsNone(changes) |
307 | 204 | 203 | ||
308 | 204 | @gen_test | ||
309 | 205 | def test_cancel(self): | ||
310 | 206 | # It is possible to cancel the execution of a pending deployment. | ||
311 | 207 | deployer = self.make_deployer() | ||
312 | 208 | with self.patch_import_bundle(): | ||
313 | 209 | # The test callback is passed to the first deployment because we | ||
314 | 210 | # expect the second one to be immediately cancelled. | ||
315 | 211 | deployer.import_bundle( | ||
316 | 212 | self.user, 'bundle', self.bundle, test_callback=self.stop) | ||
317 | 213 | deployment_id = deployer.import_bundle( | ||
318 | 214 | self.user, 'bundle', self.bundle) | ||
319 | 215 | watcher_id = deployer.watch(deployment_id) | ||
320 | 216 | self.assertIsNone(deployer.cancel(deployment_id)) | ||
321 | 217 | # We expect two changes: the second one should notify the deployment | ||
322 | 218 | # has been cancelled. | ||
323 | 219 | yield deployer.next(watcher_id) | ||
324 | 220 | changes = yield deployer.next(watcher_id) | ||
325 | 221 | self.assert_change(changes, deployment_id, utils.CANCELLED) | ||
326 | 222 | # Wait for the deployment to be completed. | ||
327 | 223 | self.wait() | ||
328 | 224 | |||
329 | 225 | def test_cancel_unknown_deployment(self): | ||
330 | 226 | # An error is returned when trying to cancel an invalid deployment. | ||
331 | 227 | deployer = self.make_deployer() | ||
332 | 228 | error = deployer.cancel(42) | ||
333 | 229 | self.assertEqual('deployment not found or already completed', error) | ||
334 | 230 | |||
335 | 231 | @gen_test | ||
336 | 232 | def test_cancel_completed_deployment(self): | ||
337 | 233 | # An error is returned when trying to cancel a completed deployment. | ||
338 | 234 | deployer = self.make_deployer() | ||
339 | 235 | with self.patch_import_bundle(): | ||
340 | 236 | deployment_id = deployer.import_bundle( | ||
341 | 237 | self.user, 'bundle', self.bundle, test_callback=self.stop) | ||
342 | 238 | watcher_id = deployer.watch(deployment_id) | ||
343 | 239 | # Assume the deployment is completed after two changes. | ||
344 | 240 | yield deployer.next(watcher_id) | ||
345 | 241 | yield deployer.next(watcher_id) | ||
346 | 242 | error = deployer.cancel(deployment_id) | ||
347 | 243 | self.assertEqual('deployment not found or already completed', error) | ||
348 | 244 | # Wait for the deployment to be completed. | ||
349 | 245 | self.wait() | ||
350 | 246 | |||
351 | 247 | @gen_test | ||
352 | 248 | def test_cancel_started_deployment(self): | ||
353 | 249 | # An error is returned when trying to cancel a deployment already | ||
354 | 250 | # started. | ||
355 | 251 | deployer = self.make_deployer() | ||
356 | 252 | with self.patch_import_bundle() as mock_import_bundle: | ||
357 | 253 | deployment_id = deployer.import_bundle( | ||
358 | 254 | self.user, 'bundle', self.bundle, test_callback=self.stop) | ||
359 | 255 | watcher_id = deployer.watch(deployment_id) | ||
360 | 256 | # Wait until the deployment is started. | ||
361 | 257 | yield deployer.next(watcher_id) | ||
362 | 258 | while True: | ||
363 | 259 | if mock_import_bundle.call_count: | ||
364 | 260 | break | ||
365 | 261 | error = deployer.cancel(deployment_id) | ||
366 | 262 | self.assertEqual('unable to cancel the deployment', error) | ||
367 | 263 | # Wait for the deployment to be completed. | ||
368 | 264 | yield deployer.next(watcher_id) | ||
369 | 265 | self.wait() | ||
370 | 266 | |||
371 | 205 | def test_initial_status(self): | 267 | def test_initial_status(self): |
372 | 206 | # The initial deployer status is an empty list. | 268 | # The initial deployer status is an empty list. |
373 | 207 | deployer = self.make_deployer() | 269 | deployer = self.make_deployer() |
374 | 208 | 270 | ||
375 | === modified file 'server/guiserver/tests/bundles/test_utils.py' | |||
376 | --- server/guiserver/tests/bundles/test_utils.py 2013-08-21 16:12:45 +0000 | |||
377 | +++ server/guiserver/tests/bundles/test_utils.py 2013-09-13 14:09:53 +0000 | |||
378 | @@ -32,12 +32,15 @@ | |||
379 | 32 | from guiserver.tests import helpers | 32 | from guiserver.tests import helpers |
380 | 33 | 33 | ||
381 | 34 | 34 | ||
383 | 35 | @mock.patch('time.time', mock.Mock(return_value=1234)) | 35 | mock_time = mock.patch('time.time', mock.Mock(return_value=12345)) |
384 | 36 | |||
385 | 37 | |||
386 | 38 | @mock_time | ||
387 | 36 | class TestCreateChange(unittest.TestCase): | 39 | class TestCreateChange(unittest.TestCase): |
388 | 37 | 40 | ||
389 | 38 | def test_status(self): | 41 | def test_status(self): |
390 | 39 | # The change includes the deployment status. | 42 | # The change includes the deployment status. |
392 | 40 | expected = {'DeploymentId': 0, 'Status': utils.STARTED, 'Time': 1234} | 43 | expected = {'DeploymentId': 0, 'Status': utils.STARTED, 'Time': 12345} |
393 | 41 | obtained = utils.create_change(0, utils.STARTED) | 44 | obtained = utils.create_change(0, utils.STARTED) |
394 | 42 | self.assertEqual(expected, obtained) | 45 | self.assertEqual(expected, obtained) |
395 | 43 | 46 | ||
396 | @@ -46,7 +49,7 @@ | |||
397 | 46 | expected = { | 49 | expected = { |
398 | 47 | 'DeploymentId': 1, | 50 | 'DeploymentId': 1, |
399 | 48 | 'Status': utils.SCHEDULED, | 51 | 'Status': utils.SCHEDULED, |
401 | 49 | 'Time': 1234, | 52 | 'Time': 12345, |
402 | 50 | 'Queue': 42, | 53 | 'Queue': 42, |
403 | 51 | } | 54 | } |
404 | 52 | obtained = utils.create_change(1, utils.SCHEDULED, queue=42) | 55 | obtained = utils.create_change(1, utils.SCHEDULED, queue=42) |
405 | @@ -57,7 +60,7 @@ | |||
406 | 57 | expected = { | 60 | expected = { |
407 | 58 | 'DeploymentId': 2, | 61 | 'DeploymentId': 2, |
408 | 59 | 'Status': utils.COMPLETED, | 62 | 'Status': utils.COMPLETED, |
410 | 60 | 'Time': 1234, | 63 | 'Time': 12345, |
411 | 61 | 'Error': 'an error', | 64 | 'Error': 'an error', |
412 | 62 | } | 65 | } |
413 | 63 | obtained = utils.create_change(2, utils.COMPLETED, error='an error') | 66 | obtained = utils.create_change(2, utils.COMPLETED, error='an error') |
414 | @@ -68,7 +71,7 @@ | |||
415 | 68 | expected = { | 71 | expected = { |
416 | 69 | 'DeploymentId': 3, | 72 | 'DeploymentId': 3, |
417 | 70 | 'Status': utils.COMPLETED, | 73 | 'Status': utils.COMPLETED, |
419 | 71 | 'Time': 1234, | 74 | 'Time': 12345, |
420 | 72 | 'Queue': 47, | 75 | 'Queue': 47, |
421 | 73 | 'Error': 'an error', | 76 | 'Error': 'an error', |
422 | 74 | } | 77 | } |
423 | @@ -139,7 +142,7 @@ | |||
424 | 139 | self.assert_watcher(watcher1, deployment1) | 142 | self.assert_watcher(watcher1, deployment1) |
425 | 140 | self.assert_watcher(watcher2, deployment2) | 143 | self.assert_watcher(watcher2, deployment2) |
426 | 141 | 144 | ||
428 | 142 | @mock.patch('time.time', mock.Mock(return_value=1234)) | 145 | @mock_time |
429 | 143 | def test_notify_scheduled(self): | 146 | def test_notify_scheduled(self): |
430 | 144 | # It is possible to notify a new queue position for a deployment. | 147 | # It is possible to notify a new queue position for a deployment. |
431 | 145 | deployment_id = self.observer.add_deployment() | 148 | deployment_id = self.observer.add_deployment() |
432 | @@ -148,13 +151,13 @@ | |||
433 | 148 | expected = { | 151 | expected = { |
434 | 149 | 'DeploymentId': deployment_id, | 152 | 'DeploymentId': deployment_id, |
435 | 150 | 'Status': utils.SCHEDULED, | 153 | 'Status': utils.SCHEDULED, |
437 | 151 | 'Time': 1234, | 154 | 'Time': 12345, |
438 | 152 | 'Queue': 3, | 155 | 'Queue': 3, |
439 | 153 | } | 156 | } |
440 | 154 | self.assertEqual(expected, watcher.getlast()) | 157 | self.assertEqual(expected, watcher.getlast()) |
441 | 155 | self.assertFalse(watcher.closed) | 158 | self.assertFalse(watcher.closed) |
442 | 156 | 159 | ||
444 | 157 | @mock.patch('time.time', mock.Mock(return_value=12345)) | 160 | @mock_time |
445 | 158 | def test_notify_started(self): | 161 | def test_notify_started(self): |
446 | 159 | # It is possible to notify that a deployment is (about to be) started. | 162 | # It is possible to notify that a deployment is (about to be) started. |
447 | 160 | deployment_id = self.observer.add_deployment() | 163 | deployment_id = self.observer.add_deployment() |
448 | @@ -169,7 +172,21 @@ | |||
449 | 169 | self.assertEqual(expected, watcher.getlast()) | 172 | self.assertEqual(expected, watcher.getlast()) |
450 | 170 | self.assertFalse(watcher.closed) | 173 | self.assertFalse(watcher.closed) |
451 | 171 | 174 | ||
453 | 172 | @mock.patch('time.time', mock.Mock(return_value=123456)) | 175 | @mock_time |
454 | 176 | def test_notify_cancelled(self): | ||
455 | 177 | # It is possible to notify that a deployment has been cancelled. | ||
456 | 178 | deployment_id = self.observer.add_deployment() | ||
457 | 179 | watcher = self.observer.deployments[deployment_id] | ||
458 | 180 | self.observer.notify_cancelled(deployment_id) | ||
459 | 181 | expected = { | ||
460 | 182 | 'DeploymentId': deployment_id, | ||
461 | 183 | 'Status': utils.CANCELLED, | ||
462 | 184 | 'Time': 12345, | ||
463 | 185 | } | ||
464 | 186 | self.assertEqual(expected, watcher.getlast()) | ||
465 | 187 | self.assertTrue(watcher.closed) | ||
466 | 188 | |||
467 | 189 | @mock_time | ||
468 | 173 | def test_notify_completed(self): | 190 | def test_notify_completed(self): |
469 | 174 | # It is possible to notify that a deployment is completed. | 191 | # It is possible to notify that a deployment is completed. |
470 | 175 | deployment_id = self.observer.add_deployment() | 192 | deployment_id = self.observer.add_deployment() |
471 | @@ -178,12 +195,12 @@ | |||
472 | 178 | expected = { | 195 | expected = { |
473 | 179 | 'DeploymentId': deployment_id, | 196 | 'DeploymentId': deployment_id, |
474 | 180 | 'Status': utils.COMPLETED, | 197 | 'Status': utils.COMPLETED, |
476 | 181 | 'Time': 123456, | 198 | 'Time': 12345, |
477 | 182 | } | 199 | } |
478 | 183 | self.assertEqual(expected, watcher.getlast()) | 200 | self.assertEqual(expected, watcher.getlast()) |
479 | 184 | self.assertTrue(watcher.closed) | 201 | self.assertTrue(watcher.closed) |
480 | 185 | 202 | ||
482 | 186 | @mock.patch('time.time', mock.Mock(return_value=1234567)) | 203 | @mock_time |
483 | 187 | def test_notify_error(self): | 204 | def test_notify_error(self): |
484 | 188 | # It is possible to notify that an error occurred during a deployment. | 205 | # It is possible to notify that an error occurred during a deployment. |
485 | 189 | deployment_id = self.observer.add_deployment() | 206 | deployment_id = self.observer.add_deployment() |
486 | @@ -192,7 +209,7 @@ | |||
487 | 192 | expected = { | 209 | expected = { |
488 | 193 | 'DeploymentId': deployment_id, | 210 | 'DeploymentId': deployment_id, |
489 | 194 | 'Status': utils.COMPLETED, | 211 | 'Status': utils.COMPLETED, |
491 | 195 | 'Time': 1234567, | 212 | 'Time': 12345, |
492 | 196 | 'Error': 'bad wolf', | 213 | 'Error': 'bad wolf', |
493 | 197 | } | 214 | } |
494 | 198 | self.assertEqual(expected, watcher.getlast()) | 215 | self.assertEqual(expected, watcher.getlast()) |
495 | 199 | 216 | ||
496 | === modified file 'server/guiserver/tests/bundles/test_views.py' | |||
497 | --- server/guiserver/tests/bundles/test_views.py 2013-09-04 10:59:44 +0000 | |||
498 | +++ server/guiserver/tests/bundles/test_views.py 2013-09-13 14:09:53 +0000 | |||
499 | @@ -267,6 +267,43 @@ | |||
500 | 267 | self.deployer.next.assert_called_once_with(42) | 267 | self.deployer.next.assert_called_once_with(42) |
501 | 268 | 268 | ||
502 | 269 | 269 | ||
503 | 270 | class TestCancel( | ||
504 | 271 | ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, | ||
505 | 272 | AsyncTestCase): | ||
506 | 273 | |||
507 | 274 | def get_view(self): | ||
508 | 275 | return views.cancel | ||
509 | 276 | |||
510 | 277 | @gen_test | ||
511 | 278 | def test_invalid_deployment(self): | ||
512 | 279 | # An error response is returned if the deployment identifier is not | ||
513 | 280 | # valid. | ||
514 | 281 | request = self.make_view_request(params={'DeploymentId': 42}) | ||
515 | 282 | # Set up the Deployer mock. | ||
516 | 283 | self.deployer.cancel.return_value = 'bad wolf' | ||
517 | 284 | # Execute the view. | ||
518 | 285 | response = yield self.view(request, self.deployer) | ||
519 | 286 | expected_response = { | ||
520 | 287 | 'Response': {}, | ||
521 | 288 | 'Error': 'invalid request: bad wolf', | ||
522 | 289 | } | ||
523 | 290 | self.assertEqual(expected_response, response) | ||
524 | 291 | # Ensure the Deployer methods have been correctly called. | ||
525 | 292 | self.deployer.cancel.assert_called_once_with(42) | ||
526 | 293 | |||
527 | 294 | @gen_test | ||
528 | 295 | def test_success(self): | ||
529 | 296 | # An empty response is returned if everything is ok. | ||
530 | 297 | request = self.make_view_request(params={'DeploymentId': 42}) | ||
531 | 298 | # Set up the Deployer mock. | ||
532 | 299 | self.deployer.cancel.return_value = None | ||
533 | 300 | # Execute the view. | ||
534 | 301 | response = yield self.view(request, self.deployer) | ||
535 | 302 | self.assertEqual({'Response': {}}, response) | ||
536 | 303 | # Ensure the Deployer methods have been correctly called. | ||
537 | 304 | self.deployer.cancel.assert_called_once_with(42) | ||
538 | 305 | |||
539 | 306 | |||
540 | 270 | class TestStatus( | 307 | class TestStatus( |
541 | 271 | ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, | 308 | ViewsTestMixin, helpers.BundlesTestMixin, LogTrapTestCase, |
542 | 272 | AsyncTestCase): | 309 | AsyncTestCase): |
Reviewers: mp+185448_ code.launchpad. net,
Message:
Please take a look.
Description:
GUI server: cancel deployment feature.
Added an API call for cancelling a pending
deployment.
Also updated the bundles module documentation.
For this first implementation, I discussed
with Gary a workaround to avoid scheduled
deployments to be included in the ProcessPool
executor's call queue: a time.sleep call is
added to the queue right after a new deployment.
This way, as described in the code, it is still
possible to cancel a scheduled deployment job
even if it is the first in the queue.
Tests:
run `make unittest` from the root of this branch.
QA: server= true`; /GUI-ADDRESS .
- bootstrap a juju-core environment;
- deploy the GUI from this branch (`make deploy`);
- switch to the builtin server:
`juju set juju-gui builtin-
- ensure the GUI is working well by visiting
https:/
To test the deployer support and the pastebin. ubuntu. com/6100815/ e.g.:
"cancel deployment" feature, use the script in
http://
- download and save the Python script; cancel. py GUI-ADDRESS`.
- run it passing the GUI node address as first argument:
`python start-deployer-
The script does the following:
1) it logs in to the juju-core API server;
2) it starts/schedules three deployments;
3) it send two invalid Cancel requests;
4) it cancels the second deployment;
5) it shows the deployments status before and
after the deployment deletion.
- if everything is ok, you should see an output like pastebin. ubuntu. com/6100861/ ; /GUI-ADDRESS/ gui-server- info you ec2-50- 17-116- 51.compute- 1.amazonaws. com:17070"
this: http://
- you should also be able to check the deployment
progress from the GUI;
- when the two deployments are completed (it may take
some minutes) you should see wordpress, mysql and
mediawiki correctly deployed and displayed in the
topology view;
- visiting https:/
should see something like this:
{
"uptime": 970,
"deployer": [
{"Status": "completed", "DeploymentId": 0, "Time": 1379062144},
{"Status": "cancelled", "DeploymentId": 1, "Time": 1379061766},
{"Status": "completed", "DeploymentId": 2, "Time": 1379062156}
],
"apiversion": "go",
"sandbox": false,
"version": "0.2.0",
"debug": false,
"apiurl": "wss://
}
That's all, thanks a lot for QAing this branch!
https:/ /code.launchpad .net/~frankban/ charms/ precise/ juju-gui/ cancel- deployment/ +merge/ 185448
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/13549046/
Affected files (+243, -32 lines): guiserver/ __init_ _.py guiserver/ bundles/ __init_ _.py guiserver/ bundles/ base.py guiserver/ bundles/ utils.py guiserver/ bundles/ views.py guiserver/ tests/bundles/ test_base. py guiserver/ tests/bundles/ test_utils. py guiserver/ tests/bundles/ test_views. py
A [revision details]
M revision
M server/
M server/
M server/
M server/
M server/
M server/
M server/
M server/