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