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
=== modified file '.bzrignore'
--- .bzrignore 2013-05-16 03:17:28 +0000
+++ .bzrignore 2013-09-30 10:52:03 +0000
@@ -1,7 +1,11 @@
1deployer.sublime-project1deployer.sublime-project
2deployer.sublime-workspace2deployer.sublime-workspace
3tmp3tmp
4jujuclient-0.0.9-py2.7.egg/
4juju_deployer.egg-info5juju_deployer.egg-info
5.emacs.desktop6.emacs.desktop
6.emacs.desktop.lock7.emacs.desktop.lock
7_build8_build
9dist/
10juju-deployer.sublime-workspace
11juju-deployer
812
=== modified file 'deployer/env/base.py'
--- deployer/env/base.py 2013-07-30 23:39:51 +0000
+++ deployer/env/base.py 2013-09-30 10:52:03 +0000
@@ -82,7 +82,7 @@
82 repo = "."82 repo = "."
83 params.extend(["--repository=%s" % repo])83 params.extend(["--repository=%s" % repo])
84 if force_machine is not None:84 if force_machine is not None:
85 params.extend["--force-machine=%d" % force_machine]85 params.extend(["--to=%s" % force_machine])
8686
87 params.extend([charm_url, name])87 params.extend([charm_url, name])
88 self._check_call(88 self._check_call(
@@ -96,7 +96,7 @@
96 delete the machine (ie units have finished executing stop hooks and are96 delete the machine (ie units have finished executing stop hooks and are
97 removed)97 removed)
98 """98 """
99 if int(mid) == 0:99 if isinstance(mid, int) and int(mid) == 0:
100 raise RuntimeError("Can't terminate machine 0")100 raise RuntimeError("Can't terminate machine 0")
101 params = self._named_env(["juju", "terminate-machine"])101 params = self._named_env(["juju", "terminate-machine"])
102 params.append(mid)102 params.append(mid)
103103
=== added file 'deployer/env/gui.py'
--- deployer/env/gui.py 1970-01-01 00:00:00 +0000
+++ deployer/env/gui.py 2013-09-30 10:52:03 +0000
@@ -0,0 +1,57 @@
1"""GUI server environment implementation.
2
3The environment defined here is intended to be used by the Juju GUI server.
4See <https://code.launchpad.net/~juju-gui/charms/precise/juju-gui/trunk>.
5"""
6
7from .go import GoEnvironment
8
9
10class GUIEnvironment(GoEnvironment):
11 """A Juju environment for the juju-deployer.
12
13 Add support for deployments via the Juju API and for authenticating with
14 the provided password.
15 """
16
17 def __init__(self, endpoint, password):
18 super(GUIEnvironment, self).__init__('gui', endpoint=endpoint)
19 self._password = password
20
21 def _get_token(self):
22 """Return the stored password.
23
24 This method is overridden so that the juju-deployer does not try to
25 parse the environments.yaml file in order to retrieve the admin-secret.
26 """
27 return self._password
28
29 def connect(self):
30 """Connect the API client to the Juju backend.
31
32 This method is overridden so that a call to connect is a no-op if the
33 client is already connected.
34 """
35 if self.client is None:
36 super(GUIEnvironment, self).connect()
37
38 def close(self):
39 """Close the API connection.
40
41 Also set the client attribute to None after the disconnection.
42 """
43 super(GUIEnvironment, self).close()
44 self.client = None
45
46 def deploy(
47 self, name, charm_url, config=None, constraints=None, num_units=1,
48 *args, **kwargs):
49 """Deploy a service using the API.
50
51 Using the API in place of the command line introduces some limitations:
52 - it is not possible to use a local charm/repository;
53 - it is not possible to deploy to a specific machine.
54 """
55 self.client.deploy(
56 name, charm_url, config=config, constraints=constraints,
57 num_units=num_units)
058
=== added file 'deployer/guiserver.py'
--- deployer/guiserver.py 1970-01-01 00:00:00 +0000
+++ deployer/guiserver.py 2013-09-30 10:52:03 +0000
@@ -0,0 +1,70 @@
1"""Juju GUI server bundles deployment support.
2
3The following functions are used by the Juju GUI server to validate and start
4bundle deployments. The validate and import_bundle operations represents the
5public API: they are directly called in the GUI server bundles support code,
6which also takes care of handling any exception they can raise.
7Those functions are blocking, and therefore the GUI server executes them in
8separate processes.
9See <https://code.launchpad.net/~juju-gui/charms/precise/juju-gui/trunk>.
10"""
11
12import os
13
14from deployer.action.importer import Importer
15from deployer.deployment import Deployment
16from deployer.env.gui import GUIEnvironment
17from deployer.utils import mkdir
18
19
20# This value is used by the juju-deployer Importer object to store charms.
21# This directory is usually created in the machine where the Juju GUI charm is
22# deployed the first time a bundle deployment is requested.
23JUJU_HOME = '/var/lib/juju-gui/juju-home'
24
25
26def _validate(env, bundle):
27 """Bundle validation logic, used by both validate and import_bundle.
28
29 This function receives a connected environment and the bundle as a YAML
30 decoded object.
31 """
32 # Retrieve the services deployed in the Juju environment.
33 env_status = env.status()
34 env_services = set(env_status['services'].keys())
35 # Retrieve the services in the bundle.
36 bundle_services = set(bundle.get('services', {}).keys())
37 # Calculate overlapping services.
38 overlapping = env_services.intersection(bundle_services)
39 if overlapping:
40 services = ', '.join(overlapping)
41 error = 'service(s) already in the environment: {}'.format(services)
42 raise ValueError(error)
43
44
45def validate(apiurl, password, bundle):
46 """Validate a bundle."""
47 env = GUIEnvironment(apiurl, password)
48 env.connect()
49 try:
50 _validate(env, bundle)
51 finally:
52 env.close()
53
54
55def import_bundle(apiurl, password, name, bundle, options):
56 """Import a bundle."""
57 env = GUIEnvironment(apiurl, password)
58 deployment = Deployment(name, bundle, [])
59 importer = Importer(env, deployment, options)
60 env.connect()
61 # The Importer tries to retrieve the Juju home from the JUJU_HOME
62 # environment variable: create a customized directory (if required) and
63 # set up the environment context for the Importer.
64 mkdir(JUJU_HOME)
65 os.environ['JUJU_HOME'] = JUJU_HOME
66 try:
67 _validate(env, bundle)
68 importer.run()
69 finally:
70 env.close()
071
=== modified file 'deployer/service.py'
--- deployer/service.py 2013-07-22 15:29:31 +0000
+++ deployer/service.py 2013-09-30 10:52:03 +0000
@@ -18,7 +18,8 @@
1818
19 @property19 @property
20 def force_machine(self):20 def force_machine(self):
21 return self.svc_data.get('force-machine')21 return self.svc_data.get('to') or self.svc_data.get(
22 'force-machine')
2223
23 @property24 @property
24 def expose(self):25 def expose(self):
2526
=== added file 'deployer/tests/test_guienv.py'
--- deployer/tests/test_guienv.py 1970-01-01 00:00:00 +0000
+++ deployer/tests/test_guienv.py 2013-09-30 10:52:03 +0000
@@ -0,0 +1,51 @@
1"""Tests for the GUIEnvironment."""
2
3import unittest
4
5import mock
6
7from deployer.env.gui import GUIEnvironment
8
9
10@mock.patch('deployer.env.go.EnvironmentClient')
11class TestGUIEnvironment(unittest.TestCase):
12
13 endpoint = 'wss://api.example.com:17070'
14 password = 'Secret!'
15
16 def setUp(self):
17 self.env = GUIEnvironment(self.endpoint, self.password)
18
19 def test_connect(self, mock_client):
20 # The environment uses the provided endpoint and password to connect
21 # to the Juju API server.
22 self.env.connect()
23 mock_client.assert_called_once_with(self.endpoint)
24 mock_client().login.assert_called_once_with(self.password)
25
26 def test_multiple_connections(self, mock_client):
27 # The environment does not attempt a second connection if it is already
28 # connected to the API backend.
29 self.env.connect()
30 self.env.connect()
31 self.assertEqual(1, mock_client.call_count)
32
33 def test_close(self, mock_client):
34 # The client attribute is set to None when the connection is closed.
35 self.env.connect()
36 self.env.close()
37 self.assertIsNone(self.env.client)
38
39 def test_deploy(self, mock_client):
40 # The environment uses the API to deploy charms.
41 self.env.connect()
42 config = {'foo': 'bar'}
43 constraints = {'cpu': 4}
44 # Deploy a service: the last two arguments (force_machine and repo) are
45 # ignored.
46 self.env.deploy(
47 'myservice', 'cs:precise/service-42', config=config,
48 constraints=constraints, num_units=2, force_machine=1, repo='/tmp')
49 mock_client().deploy.assert_called_once_with(
50 'myservice', 'cs:precise/service-42', config=config,
51 constraints=constraints, num_units=2)
052
=== added file 'deployer/tests/test_guiserver.py'
--- deployer/tests/test_guiserver.py 1970-01-01 00:00:00 +0000
+++ deployer/tests/test_guiserver.py 2013-09-30 10:52:03 +0000
@@ -0,0 +1,136 @@
1"""Tests for the GUI server bundles deployment support."""
2
3from contextlib import contextmanager
4import os
5import shutil
6import tempfile
7import unittest
8
9import mock
10
11from deployer import guiserver
12from deployer.deployment import Deployment
13
14
15class DeployerFunctionsTestMixin(object):
16 """Base set up for the functions that make use of the juju-deployer."""
17
18 apiurl = 'wss://api.example.com:17070'
19 password = 'Secret!'
20 name = 'mybundle'
21 bundle = {'services': {'wordpress': {}, 'mysql': {}}}
22
23 def check_environment_life(self, mock_environment):
24 """Check the calls executed on the given mock environment.
25
26 Ensure that, in order to retrieve the list of currently deployed
27 services, the environment is instantiated, connected, env.status is
28 called and then the connection is closed.
29 """
30 mock_environment.assert_called_once_with(self.apiurl, self.password)
31 mock_env_instance = mock_environment()
32 mock_env_instance.connect.assert_called_once_with()
33 mock_env_instance.status.assert_called_once_with()
34 mock_env_instance.close.assert_called_once_with()
35
36 @contextmanager
37 def assert_overlapping_services(self, mock_environment):
38 """Ensure a ValueError is raised in the context manager block.
39
40 The given mock environment object is set up so that its status
41 simulates an existing service. The name of this service overlaps with
42 the name of one of the services in the bundle.
43 """
44 mock_env_instance = mock_environment()
45 mock_env_instance.status.return_value = {'services': {'mysql': {}}}
46 # Ensure a ValueError is raised by the code in the context block.
47 with self.assertRaises(ValueError) as context_manager:
48 yield
49 # The error reflects the overlapping service name.
50 error = str(context_manager.exception)
51 self.assertEqual('service(s) already in the environment: mysql', error)
52 # Even if an error occurs, the environment connection is closed.
53 mock_env_instance.close.assert_called_once_with()
54
55
56@mock.patch('deployer.guiserver.GUIEnvironment')
57class TestValidate(DeployerFunctionsTestMixin, unittest.TestCase):
58
59 def test_validation(self, mock_environment):
60 # The validation is correctly run.
61 guiserver.validate(self.apiurl, self.password, self.bundle)
62 # The environment is correctly instantiated and used.
63 self.check_environment_life(mock_environment)
64
65 def test_overlapping_services(self, mock_environment):
66 # The validation fails if the bundle includes a service name already
67 # present in the Juju environment.
68 with self.assert_overlapping_services(mock_environment):
69 guiserver.validate(self.apiurl, self.password, self.bundle)
70
71
72@mock.patch('deployer.guiserver.GUIEnvironment')
73class TestImportBundle(DeployerFunctionsTestMixin, unittest.TestCase):
74
75 # The options attribute simulates the options passed to the Importer.
76 options = 'mock options'
77
78 @contextmanager
79 def patch_juju_home(self):
80 """Patch the value used by the bundle importer as Juju home."""
81 base_dir = tempfile.mkdtemp()
82 self.addCleanup(shutil.rmtree, base_dir)
83 juju_home = os.path.join(base_dir, 'juju-home')
84 with mock.patch('deployer.guiserver.JUJU_HOME', juju_home):
85 try:
86 yield juju_home
87 finally:
88 del os.environ['JUJU_HOME']
89
90 def import_bundle(self):
91 """Call the import_bundle function."""
92 guiserver.import_bundle(
93 self.apiurl, self.password, self.name, self.bundle, self.options)
94
95 @mock.patch('deployer.guiserver.Importer')
96 def test_importing_bundle(self, mock_importer, mock_environment):
97 # The juju-deployer importer is correctly set up and run.
98 with self.patch_juju_home():
99 self.import_bundle()
100 # The environment is correctly instantiated and used.
101 self.check_environment_life(mock_environment)
102 # The importer is correctly instantiated.
103 self.assertEqual(1, mock_importer.call_count)
104 importer_args = mock_importer.call_args[0]
105 self.assertEqual(3, len(importer_args))
106 env, deployment, options = importer_args
107 # The first argument passed to the importer is the environment.
108 self.assertIs(mock_environment(), env)
109 # The second argument is the deployment object.
110 self.assertIsInstance(deployment, Deployment)
111 self.assertEqual(self.name, deployment.name)
112 self.assertEqual(self.bundle, deployment.data)
113 # The third and last argument is the options object.
114 self.assertIs(self.options, options)
115 # The importer is started.
116 mock_importer().run.assert_called_once_with()
117
118 def test_overlapping_services(self, mock_environment):
119 # The import fails if the bundle includes a service name already
120 # present in the Juju environment.
121 with self.assert_overlapping_services(mock_environment):
122 with self.patch_juju_home():
123 self.import_bundle()
124
125 @mock.patch('deployer.guiserver.Importer')
126 def test_juju_home(self, mock_importer, mock_environment):
127 # A customized Juju home is created and used during the import process.
128 with self.patch_juju_home() as juju_home:
129 assert not os.path.isdir(juju_home), 'directory should not exist'
130 # Ensure JUJU_HOME is included in the context when the Importer
131 # instance is run.
132 run = lambda: self.assertEqual(juju_home, os.getenv('JUJU_HOME'))
133 mock_importer().run = run
134 self.import_bundle()
135 # The JUJU_HOME directory has been created.
136 self.assertTrue(os.path.isdir(juju_home))
0137
=== modified file 'deployer/tests/test_utils.py'
--- deployer/tests/test_utils.py 2013-07-30 23:39:51 +0000
+++ deployer/tests/test_utils.py 2013-09-30 10:52:03 +0000
@@ -1,7 +1,15 @@
1import os
2from subprocess import CalledProcessError
3
1from mock import patch, MagicMock4from mock import patch, MagicMock
2from subprocess import CalledProcessError5
3from .base import Base6from .base import Base
4from deployer.utils import dict_merge, _check_call, ErrorExit7from deployer.utils import (
8 _check_call,
9 dict_merge,
10 ErrorExit,
11 mkdir,
12)
513
614
7class UtilTests(Base):15class UtilTests(Base):
@@ -48,3 +56,50 @@
48 self.assertEquals(output, 'good')56 self.assertEquals(output, 'good')
49 # 1 failure + 3 retries57 # 1 failure + 3 retries
50 self.assertEquals(len(check_output.call_args_list), 3)58 self.assertEquals(len(check_output.call_args_list), 3)
59
60
61class TestMkdir(Base):
62
63 def setUp(self):
64 self.playground = self.mkdir()
65
66 def test_create_dir(self):
67 # A directory is correctly created.
68 path = os.path.join(self.playground, 'foo')
69 mkdir(path)
70 self.assertTrue(os.path.isdir(path))
71
72 def test_intermediate_dirs(self):
73 # All intermediate directories are created.
74 path = os.path.join(self.playground, 'foo', 'bar', 'leaf')
75 mkdir(path)
76 self.assertTrue(os.path.isdir(path))
77
78 def test_expand_user(self):
79 # The ~ construction is expanded.
80 with patch('os.environ', {'HOME': self.playground}):
81 mkdir('~/in/my/home')
82 path = os.path.join(self.playground, 'in', 'my', 'home')
83 self.assertTrue(os.path.isdir(path))
84
85 def test_existing_dir(self):
86 # The function exits without errors if the target directory exists.
87 path = os.path.join(self.playground, 'foo')
88 os.mkdir(path)
89 mkdir(path)
90
91 def test_existing_file(self):
92 # An OSError is raised if a file already exists in the target path.
93 path = os.path.join(self.playground, 'foo')
94 with open(path, 'w'):
95 with self.assertRaises(OSError):
96 mkdir(path)
97
98 def test_failure(self):
99 # Errors are correctly re-raised.
100 path = os.path.join(self.playground, 'foo')
101 os.chmod(self.playground, 0000)
102 self.addCleanup(os.chmod, self.playground, 0700)
103 with self.assertRaises(OSError):
104 mkdir(os.path.join(path))
105 self.assertFalse(os.path.exists(path))
51106
=== modified file 'deployer/utils.py'
--- deployer/utils.py 2013-07-30 23:39:51 +0000
+++ deployer/utils.py 2013-09-30 10:52:03 +0000
@@ -1,13 +1,19 @@
1from copy import deepcopy1from copy import deepcopy
2from contextlib import contextmanager2from contextlib import contextmanager
33
4import errno
4import logging5import logging
5from logging.config import dictConfig as logConfig6from logging.config import dictConfig as logConfig
7
6import os8import os
79from os.path import (
8from os.path import abspath, isabs10 abspath,
9from os.path import join as path_join11 expanduser,
10from os.path import exists as path_exists12 isabs,
13 isdir,
14 join as path_join,
15 exists as path_exists,
16)
1117
12import stat18import stat
13import subprocess19import subprocess
@@ -221,3 +227,18 @@
221 return full_path227 return full_path
222228
223 return None229 return None
230
231
232def mkdir(path):
233 """Create a leaf directory and all intermediate ones.
234
235 Also expand ~ and ~user constructions.
236 If path exists and it's a directory, return without errors.
237 """
238 path = expanduser(path)
239 try:
240 os.makedirs(path)
241 except OSError as err:
242 # Re-raise the error if the target path exists but it is not a dir.
243 if (err.errno != errno.EEXIST) or (not isdir(path)):
244 raise

Subscribers

People subscribed via source and target branches