Merge lp:~james-page/juju-deployer/fixup-to-for-strings into lp:~gandelman-a/juju-deployer/trunk
- fixup-to-for-strings
- Merge into trunk
Proposed by
James Page
Status: | Superseded |
---|---|
Proposed branch: | lp:~james-page/juju-deployer/fixup-to-for-strings |
Merge into: | lp:~gandelman-a/juju-deployer/trunk |
Diff against target: |
505 lines (+404/-9) 9 files modified
.bzrignore (+4/-0) deployer/env/base.py (+2/-2) deployer/env/gui.py (+57/-0) deployer/guiserver.py (+70/-0) deployer/service.py (+2/-1) deployer/tests/test_guienv.py (+51/-0) deployer/tests/test_guiserver.py (+136/-0) deployer/tests/test_utils.py (+57/-2) deployer/utils.py (+25/-4) |
To merge this branch: | bzr merge lp:~james-page/juju-deployer/fixup-to-for-strings |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Adam Gandelman | Pending | ||
Review via email: mp+188294@code.launchpad.net |
This proposal has been superseded by a proposal from 2013-09-30.
Commit message
Description of the change
Support use of string based machine identifiers in force-machine and
terminate-machine calls
To post a comment you must log in.
Unmerged revisions
- 118. By James Page
-
Fixup terminate-machine to deal with string based machine ids
- 117. By James Page
-
Support strings when specifing to/force-machine to enable support for lxc machines
- 116. By Kapil Thangavelu
-
merge frankban guiserver
- 115. By Kapil Thangavelu
-
merge gnuoy fix-force-machine
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2013-05-16 03:17:28 +0000 |
3 | +++ .bzrignore 2013-09-30 10:52:03 +0000 |
4 | @@ -1,7 +1,11 @@ |
5 | deployer.sublime-project |
6 | deployer.sublime-workspace |
7 | tmp |
8 | +jujuclient-0.0.9-py2.7.egg/ |
9 | juju_deployer.egg-info |
10 | .emacs.desktop |
11 | .emacs.desktop.lock |
12 | _build |
13 | +dist/ |
14 | +juju-deployer.sublime-workspace |
15 | +juju-deployer |
16 | |
17 | === modified file 'deployer/env/base.py' |
18 | --- deployer/env/base.py 2013-07-30 23:39:51 +0000 |
19 | +++ deployer/env/base.py 2013-09-30 10:52:03 +0000 |
20 | @@ -82,7 +82,7 @@ |
21 | repo = "." |
22 | params.extend(["--repository=%s" % repo]) |
23 | if force_machine is not None: |
24 | - params.extend["--force-machine=%d" % force_machine] |
25 | + params.extend(["--to=%s" % force_machine]) |
26 | |
27 | params.extend([charm_url, name]) |
28 | self._check_call( |
29 | @@ -96,7 +96,7 @@ |
30 | delete the machine (ie units have finished executing stop hooks and are |
31 | removed) |
32 | """ |
33 | - if int(mid) == 0: |
34 | + if isinstance(mid, int) and int(mid) == 0: |
35 | raise RuntimeError("Can't terminate machine 0") |
36 | params = self._named_env(["juju", "terminate-machine"]) |
37 | params.append(mid) |
38 | |
39 | === added file 'deployer/env/gui.py' |
40 | --- deployer/env/gui.py 1970-01-01 00:00:00 +0000 |
41 | +++ deployer/env/gui.py 2013-09-30 10:52:03 +0000 |
42 | @@ -0,0 +1,57 @@ |
43 | +"""GUI server environment implementation. |
44 | + |
45 | +The environment defined here is intended to be used by the Juju GUI server. |
46 | +See <https://code.launchpad.net/~juju-gui/charms/precise/juju-gui/trunk>. |
47 | +""" |
48 | + |
49 | +from .go import GoEnvironment |
50 | + |
51 | + |
52 | +class GUIEnvironment(GoEnvironment): |
53 | + """A Juju environment for the juju-deployer. |
54 | + |
55 | + Add support for deployments via the Juju API and for authenticating with |
56 | + the provided password. |
57 | + """ |
58 | + |
59 | + def __init__(self, endpoint, password): |
60 | + super(GUIEnvironment, self).__init__('gui', endpoint=endpoint) |
61 | + self._password = password |
62 | + |
63 | + def _get_token(self): |
64 | + """Return the stored password. |
65 | + |
66 | + This method is overridden so that the juju-deployer does not try to |
67 | + parse the environments.yaml file in order to retrieve the admin-secret. |
68 | + """ |
69 | + return self._password |
70 | + |
71 | + def connect(self): |
72 | + """Connect the API client to the Juju backend. |
73 | + |
74 | + This method is overridden so that a call to connect is a no-op if the |
75 | + client is already connected. |
76 | + """ |
77 | + if self.client is None: |
78 | + super(GUIEnvironment, self).connect() |
79 | + |
80 | + def close(self): |
81 | + """Close the API connection. |
82 | + |
83 | + Also set the client attribute to None after the disconnection. |
84 | + """ |
85 | + super(GUIEnvironment, self).close() |
86 | + self.client = None |
87 | + |
88 | + def deploy( |
89 | + self, name, charm_url, config=None, constraints=None, num_units=1, |
90 | + *args, **kwargs): |
91 | + """Deploy a service using the API. |
92 | + |
93 | + Using the API in place of the command line introduces some limitations: |
94 | + - it is not possible to use a local charm/repository; |
95 | + - it is not possible to deploy to a specific machine. |
96 | + """ |
97 | + self.client.deploy( |
98 | + name, charm_url, config=config, constraints=constraints, |
99 | + num_units=num_units) |
100 | |
101 | === added file 'deployer/guiserver.py' |
102 | --- deployer/guiserver.py 1970-01-01 00:00:00 +0000 |
103 | +++ deployer/guiserver.py 2013-09-30 10:52:03 +0000 |
104 | @@ -0,0 +1,70 @@ |
105 | +"""Juju GUI server bundles deployment support. |
106 | + |
107 | +The following functions are used by the Juju GUI server to validate and start |
108 | +bundle deployments. The validate and import_bundle operations represents the |
109 | +public API: they are directly called in the GUI server bundles support code, |
110 | +which also takes care of handling any exception they can raise. |
111 | +Those functions are blocking, and therefore the GUI server executes them in |
112 | +separate processes. |
113 | +See <https://code.launchpad.net/~juju-gui/charms/precise/juju-gui/trunk>. |
114 | +""" |
115 | + |
116 | +import os |
117 | + |
118 | +from deployer.action.importer import Importer |
119 | +from deployer.deployment import Deployment |
120 | +from deployer.env.gui import GUIEnvironment |
121 | +from deployer.utils import mkdir |
122 | + |
123 | + |
124 | +# This value is used by the juju-deployer Importer object to store charms. |
125 | +# This directory is usually created in the machine where the Juju GUI charm is |
126 | +# deployed the first time a bundle deployment is requested. |
127 | +JUJU_HOME = '/var/lib/juju-gui/juju-home' |
128 | + |
129 | + |
130 | +def _validate(env, bundle): |
131 | + """Bundle validation logic, used by both validate and import_bundle. |
132 | + |
133 | + This function receives a connected environment and the bundle as a YAML |
134 | + decoded object. |
135 | + """ |
136 | + # Retrieve the services deployed in the Juju environment. |
137 | + env_status = env.status() |
138 | + env_services = set(env_status['services'].keys()) |
139 | + # Retrieve the services in the bundle. |
140 | + bundle_services = set(bundle.get('services', {}).keys()) |
141 | + # Calculate overlapping services. |
142 | + overlapping = env_services.intersection(bundle_services) |
143 | + if overlapping: |
144 | + services = ', '.join(overlapping) |
145 | + error = 'service(s) already in the environment: {}'.format(services) |
146 | + raise ValueError(error) |
147 | + |
148 | + |
149 | +def validate(apiurl, password, bundle): |
150 | + """Validate a bundle.""" |
151 | + env = GUIEnvironment(apiurl, password) |
152 | + env.connect() |
153 | + try: |
154 | + _validate(env, bundle) |
155 | + finally: |
156 | + env.close() |
157 | + |
158 | + |
159 | +def import_bundle(apiurl, password, name, bundle, options): |
160 | + """Import a bundle.""" |
161 | + env = GUIEnvironment(apiurl, password) |
162 | + deployment = Deployment(name, bundle, []) |
163 | + importer = Importer(env, deployment, options) |
164 | + env.connect() |
165 | + # The Importer tries to retrieve the Juju home from the JUJU_HOME |
166 | + # environment variable: create a customized directory (if required) and |
167 | + # set up the environment context for the Importer. |
168 | + mkdir(JUJU_HOME) |
169 | + os.environ['JUJU_HOME'] = JUJU_HOME |
170 | + try: |
171 | + _validate(env, bundle) |
172 | + importer.run() |
173 | + finally: |
174 | + env.close() |
175 | |
176 | === modified file 'deployer/service.py' |
177 | --- deployer/service.py 2013-07-22 15:29:31 +0000 |
178 | +++ deployer/service.py 2013-09-30 10:52:03 +0000 |
179 | @@ -18,7 +18,8 @@ |
180 | |
181 | @property |
182 | def force_machine(self): |
183 | - return self.svc_data.get('force-machine') |
184 | + return self.svc_data.get('to') or self.svc_data.get( |
185 | + 'force-machine') |
186 | |
187 | @property |
188 | def expose(self): |
189 | |
190 | === added file 'deployer/tests/test_guienv.py' |
191 | --- deployer/tests/test_guienv.py 1970-01-01 00:00:00 +0000 |
192 | +++ deployer/tests/test_guienv.py 2013-09-30 10:52:03 +0000 |
193 | @@ -0,0 +1,51 @@ |
194 | +"""Tests for the GUIEnvironment.""" |
195 | + |
196 | +import unittest |
197 | + |
198 | +import mock |
199 | + |
200 | +from deployer.env.gui import GUIEnvironment |
201 | + |
202 | + |
203 | +@mock.patch('deployer.env.go.EnvironmentClient') |
204 | +class TestGUIEnvironment(unittest.TestCase): |
205 | + |
206 | + endpoint = 'wss://api.example.com:17070' |
207 | + password = 'Secret!' |
208 | + |
209 | + def setUp(self): |
210 | + self.env = GUIEnvironment(self.endpoint, self.password) |
211 | + |
212 | + def test_connect(self, mock_client): |
213 | + # The environment uses the provided endpoint and password to connect |
214 | + # to the Juju API server. |
215 | + self.env.connect() |
216 | + mock_client.assert_called_once_with(self.endpoint) |
217 | + mock_client().login.assert_called_once_with(self.password) |
218 | + |
219 | + def test_multiple_connections(self, mock_client): |
220 | + # The environment does not attempt a second connection if it is already |
221 | + # connected to the API backend. |
222 | + self.env.connect() |
223 | + self.env.connect() |
224 | + self.assertEqual(1, mock_client.call_count) |
225 | + |
226 | + def test_close(self, mock_client): |
227 | + # The client attribute is set to None when the connection is closed. |
228 | + self.env.connect() |
229 | + self.env.close() |
230 | + self.assertIsNone(self.env.client) |
231 | + |
232 | + def test_deploy(self, mock_client): |
233 | + # The environment uses the API to deploy charms. |
234 | + self.env.connect() |
235 | + config = {'foo': 'bar'} |
236 | + constraints = {'cpu': 4} |
237 | + # Deploy a service: the last two arguments (force_machine and repo) are |
238 | + # ignored. |
239 | + self.env.deploy( |
240 | + 'myservice', 'cs:precise/service-42', config=config, |
241 | + constraints=constraints, num_units=2, force_machine=1, repo='/tmp') |
242 | + mock_client().deploy.assert_called_once_with( |
243 | + 'myservice', 'cs:precise/service-42', config=config, |
244 | + constraints=constraints, num_units=2) |
245 | |
246 | === added file 'deployer/tests/test_guiserver.py' |
247 | --- deployer/tests/test_guiserver.py 1970-01-01 00:00:00 +0000 |
248 | +++ deployer/tests/test_guiserver.py 2013-09-30 10:52:03 +0000 |
249 | @@ -0,0 +1,136 @@ |
250 | +"""Tests for the GUI server bundles deployment support.""" |
251 | + |
252 | +from contextlib import contextmanager |
253 | +import os |
254 | +import shutil |
255 | +import tempfile |
256 | +import unittest |
257 | + |
258 | +import mock |
259 | + |
260 | +from deployer import guiserver |
261 | +from deployer.deployment import Deployment |
262 | + |
263 | + |
264 | +class DeployerFunctionsTestMixin(object): |
265 | + """Base set up for the functions that make use of the juju-deployer.""" |
266 | + |
267 | + apiurl = 'wss://api.example.com:17070' |
268 | + password = 'Secret!' |
269 | + name = 'mybundle' |
270 | + bundle = {'services': {'wordpress': {}, 'mysql': {}}} |
271 | + |
272 | + def check_environment_life(self, mock_environment): |
273 | + """Check the calls executed on the given mock environment. |
274 | + |
275 | + Ensure that, in order to retrieve the list of currently deployed |
276 | + services, the environment is instantiated, connected, env.status is |
277 | + called and then the connection is closed. |
278 | + """ |
279 | + mock_environment.assert_called_once_with(self.apiurl, self.password) |
280 | + mock_env_instance = mock_environment() |
281 | + mock_env_instance.connect.assert_called_once_with() |
282 | + mock_env_instance.status.assert_called_once_with() |
283 | + mock_env_instance.close.assert_called_once_with() |
284 | + |
285 | + @contextmanager |
286 | + def assert_overlapping_services(self, mock_environment): |
287 | + """Ensure a ValueError is raised in the context manager block. |
288 | + |
289 | + The given mock environment object is set up so that its status |
290 | + simulates an existing service. The name of this service overlaps with |
291 | + the name of one of the services in the bundle. |
292 | + """ |
293 | + mock_env_instance = mock_environment() |
294 | + mock_env_instance.status.return_value = {'services': {'mysql': {}}} |
295 | + # Ensure a ValueError is raised by the code in the context block. |
296 | + with self.assertRaises(ValueError) as context_manager: |
297 | + yield |
298 | + # The error reflects the overlapping service name. |
299 | + error = str(context_manager.exception) |
300 | + self.assertEqual('service(s) already in the environment: mysql', error) |
301 | + # Even if an error occurs, the environment connection is closed. |
302 | + mock_env_instance.close.assert_called_once_with() |
303 | + |
304 | + |
305 | +@mock.patch('deployer.guiserver.GUIEnvironment') |
306 | +class TestValidate(DeployerFunctionsTestMixin, unittest.TestCase): |
307 | + |
308 | + def test_validation(self, mock_environment): |
309 | + # The validation is correctly run. |
310 | + guiserver.validate(self.apiurl, self.password, self.bundle) |
311 | + # The environment is correctly instantiated and used. |
312 | + self.check_environment_life(mock_environment) |
313 | + |
314 | + def test_overlapping_services(self, mock_environment): |
315 | + # The validation fails if the bundle includes a service name already |
316 | + # present in the Juju environment. |
317 | + with self.assert_overlapping_services(mock_environment): |
318 | + guiserver.validate(self.apiurl, self.password, self.bundle) |
319 | + |
320 | + |
321 | +@mock.patch('deployer.guiserver.GUIEnvironment') |
322 | +class TestImportBundle(DeployerFunctionsTestMixin, unittest.TestCase): |
323 | + |
324 | + # The options attribute simulates the options passed to the Importer. |
325 | + options = 'mock options' |
326 | + |
327 | + @contextmanager |
328 | + def patch_juju_home(self): |
329 | + """Patch the value used by the bundle importer as Juju home.""" |
330 | + base_dir = tempfile.mkdtemp() |
331 | + self.addCleanup(shutil.rmtree, base_dir) |
332 | + juju_home = os.path.join(base_dir, 'juju-home') |
333 | + with mock.patch('deployer.guiserver.JUJU_HOME', juju_home): |
334 | + try: |
335 | + yield juju_home |
336 | + finally: |
337 | + del os.environ['JUJU_HOME'] |
338 | + |
339 | + def import_bundle(self): |
340 | + """Call the import_bundle function.""" |
341 | + guiserver.import_bundle( |
342 | + self.apiurl, self.password, self.name, self.bundle, self.options) |
343 | + |
344 | + @mock.patch('deployer.guiserver.Importer') |
345 | + def test_importing_bundle(self, mock_importer, mock_environment): |
346 | + # The juju-deployer importer is correctly set up and run. |
347 | + with self.patch_juju_home(): |
348 | + self.import_bundle() |
349 | + # The environment is correctly instantiated and used. |
350 | + self.check_environment_life(mock_environment) |
351 | + # The importer is correctly instantiated. |
352 | + self.assertEqual(1, mock_importer.call_count) |
353 | + importer_args = mock_importer.call_args[0] |
354 | + self.assertEqual(3, len(importer_args)) |
355 | + env, deployment, options = importer_args |
356 | + # The first argument passed to the importer is the environment. |
357 | + self.assertIs(mock_environment(), env) |
358 | + # The second argument is the deployment object. |
359 | + self.assertIsInstance(deployment, Deployment) |
360 | + self.assertEqual(self.name, deployment.name) |
361 | + self.assertEqual(self.bundle, deployment.data) |
362 | + # The third and last argument is the options object. |
363 | + self.assertIs(self.options, options) |
364 | + # The importer is started. |
365 | + mock_importer().run.assert_called_once_with() |
366 | + |
367 | + def test_overlapping_services(self, mock_environment): |
368 | + # The import fails if the bundle includes a service name already |
369 | + # present in the Juju environment. |
370 | + with self.assert_overlapping_services(mock_environment): |
371 | + with self.patch_juju_home(): |
372 | + self.import_bundle() |
373 | + |
374 | + @mock.patch('deployer.guiserver.Importer') |
375 | + def test_juju_home(self, mock_importer, mock_environment): |
376 | + # A customized Juju home is created and used during the import process. |
377 | + with self.patch_juju_home() as juju_home: |
378 | + assert not os.path.isdir(juju_home), 'directory should not exist' |
379 | + # Ensure JUJU_HOME is included in the context when the Importer |
380 | + # instance is run. |
381 | + run = lambda: self.assertEqual(juju_home, os.getenv('JUJU_HOME')) |
382 | + mock_importer().run = run |
383 | + self.import_bundle() |
384 | + # The JUJU_HOME directory has been created. |
385 | + self.assertTrue(os.path.isdir(juju_home)) |
386 | |
387 | === modified file 'deployer/tests/test_utils.py' |
388 | --- deployer/tests/test_utils.py 2013-07-30 23:39:51 +0000 |
389 | +++ deployer/tests/test_utils.py 2013-09-30 10:52:03 +0000 |
390 | @@ -1,7 +1,15 @@ |
391 | +import os |
392 | +from subprocess import CalledProcessError |
393 | + |
394 | from mock import patch, MagicMock |
395 | -from subprocess import CalledProcessError |
396 | + |
397 | from .base import Base |
398 | -from deployer.utils import dict_merge, _check_call, ErrorExit |
399 | +from deployer.utils import ( |
400 | + _check_call, |
401 | + dict_merge, |
402 | + ErrorExit, |
403 | + mkdir, |
404 | +) |
405 | |
406 | |
407 | class UtilTests(Base): |
408 | @@ -48,3 +56,50 @@ |
409 | self.assertEquals(output, 'good') |
410 | # 1 failure + 3 retries |
411 | self.assertEquals(len(check_output.call_args_list), 3) |
412 | + |
413 | + |
414 | +class TestMkdir(Base): |
415 | + |
416 | + def setUp(self): |
417 | + self.playground = self.mkdir() |
418 | + |
419 | + def test_create_dir(self): |
420 | + # A directory is correctly created. |
421 | + path = os.path.join(self.playground, 'foo') |
422 | + mkdir(path) |
423 | + self.assertTrue(os.path.isdir(path)) |
424 | + |
425 | + def test_intermediate_dirs(self): |
426 | + # All intermediate directories are created. |
427 | + path = os.path.join(self.playground, 'foo', 'bar', 'leaf') |
428 | + mkdir(path) |
429 | + self.assertTrue(os.path.isdir(path)) |
430 | + |
431 | + def test_expand_user(self): |
432 | + # The ~ construction is expanded. |
433 | + with patch('os.environ', {'HOME': self.playground}): |
434 | + mkdir('~/in/my/home') |
435 | + path = os.path.join(self.playground, 'in', 'my', 'home') |
436 | + self.assertTrue(os.path.isdir(path)) |
437 | + |
438 | + def test_existing_dir(self): |
439 | + # The function exits without errors if the target directory exists. |
440 | + path = os.path.join(self.playground, 'foo') |
441 | + os.mkdir(path) |
442 | + mkdir(path) |
443 | + |
444 | + def test_existing_file(self): |
445 | + # An OSError is raised if a file already exists in the target path. |
446 | + path = os.path.join(self.playground, 'foo') |
447 | + with open(path, 'w'): |
448 | + with self.assertRaises(OSError): |
449 | + mkdir(path) |
450 | + |
451 | + def test_failure(self): |
452 | + # Errors are correctly re-raised. |
453 | + path = os.path.join(self.playground, 'foo') |
454 | + os.chmod(self.playground, 0000) |
455 | + self.addCleanup(os.chmod, self.playground, 0700) |
456 | + with self.assertRaises(OSError): |
457 | + mkdir(os.path.join(path)) |
458 | + self.assertFalse(os.path.exists(path)) |
459 | |
460 | === modified file 'deployer/utils.py' |
461 | --- deployer/utils.py 2013-07-30 23:39:51 +0000 |
462 | +++ deployer/utils.py 2013-09-30 10:52:03 +0000 |
463 | @@ -1,13 +1,19 @@ |
464 | from copy import deepcopy |
465 | from contextlib import contextmanager |
466 | |
467 | +import errno |
468 | import logging |
469 | from logging.config import dictConfig as logConfig |
470 | + |
471 | import os |
472 | - |
473 | -from os.path import abspath, isabs |
474 | -from os.path import join as path_join |
475 | -from os.path import exists as path_exists |
476 | +from os.path import ( |
477 | + abspath, |
478 | + expanduser, |
479 | + isabs, |
480 | + isdir, |
481 | + join as path_join, |
482 | + exists as path_exists, |
483 | +) |
484 | |
485 | import stat |
486 | import subprocess |
487 | @@ -221,3 +227,18 @@ |
488 | return full_path |
489 | |
490 | return None |
491 | + |
492 | + |
493 | +def mkdir(path): |
494 | + """Create a leaf directory and all intermediate ones. |
495 | + |
496 | + Also expand ~ and ~user constructions. |
497 | + If path exists and it's a directory, return without errors. |
498 | + """ |
499 | + path = expanduser(path) |
500 | + try: |
501 | + os.makedirs(path) |
502 | + except OSError as err: |
503 | + # Re-raise the error if the target path exists but it is not a dir. |
504 | + if (err.errno != errno.EEXIST) or (not isdir(path)): |
505 | + raise |