Merge lp:~james-page/juju-deployer/fixup-to-for-strings into lp:~gandelman-a/juju-deployer/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
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.

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

Subscribers

People subscribed via source and target branches