Merge lp:~fwereade/pyjuju/add-upstart-class into lp:pyjuju

Proposed by William Reade
Status: Merged
Approved by: Kapil Thangavelu
Approved revision: 446
Merged at revision: 459
Proposed branch: lp:~fwereade/pyjuju/add-upstart-class
Merge into: lp:pyjuju
Prerequisite: lp:~fwereade/pyjuju/fix-charm-upgrade
Diff against target: 427 lines (+375/-1)
7 files modified
juju/errors.py (+3/-0)
juju/lib/tests/data/test_basic_install (+10/-0)
juju/lib/tests/data/test_less_basic_install (+11/-0)
juju/lib/tests/data/test_standard_install (+10/-0)
juju/lib/tests/test_upstart.py (+229/-0)
juju/lib/upstart.py (+105/-0)
juju/tests/test_errors.py (+7/-1)
To merge this branch: bzr merge lp:~fwereade/pyjuju/add-upstart-class
Reviewer Review Type Date Requested Status
Kapil Thangavelu (community) Approve
Jim Baker (community) Approve
Review via email: mp+85335@code.launchpad.net

Description of the change

This is just the class we will be using in lp:~fwereade/juju/upstartify-services, but extracted so as to make the diffs a little less daunting.

To post a comment you must log in.
440. By William Reade

merge parent

441. By William Reade

merge parent

442. By William Reade

merge parent

Revision history for this message
Jim Baker (jimbaker) wrote :

+1. Just minors here:

[1]

+description "blah"
+author "Juju Team <email address hidden>"
+
+start on runlevel [2345]
+stop on runlevel [!2345]
+respawn
+
+env BLAH="blah blah"
+
+exec /bin/imagination-failure --no-ideas

Having a generating family of dummy data (eg your Star Wars theme or really whatever)
can be helpful for seeing the expected patterns. "blah blah" is unfortunately
just too much noise when looking at these tests.

The other thing is that putting these files inline might work much better, to avoid one
more place to look to see what the expected output should be.

[2]

+class UpstartServiceTest(TestCase):
+
+ def setUp(self):
+ self.root = self.makeDir()
+ init_dir = os.path.join(self.root, "etc", "init")
+ os.makedirs(init_dir)
+ self.conf = os.path.join(init_dir, "some-name.conf")
+ self.patch(UpstartService, "root_path", self.root)
+ self.service = UpstartService("some-name")

It's unlikely this will be mixed in with other tests, but it's likely best to preserve
the existing cooperative super inheritance support, including returning a Deferred
(usually by inlineCallbacks).

[3]

+ def test_is_installed(self):

Doc strings for tests!

+ self.assertFalse(self.service.is_installed())
+ self.write_dummy_conf()
+ self.assertTrue(self.service.is_installed())
+

[4]

+ @inlineCallbacks
+ def test_is_running(self):

Docs/comments especially here. Best to be explicit about how the mocks are
setting things up, especially when there's a sequence of multiple calls as seen here.

review: Approve
443. By William Reade

merge parent

444. By William Reade

address review points

445. By William Reade

merge parent

446. By William Reade

merge parent

Revision history for this message
Kapil Thangavelu (hazmat) wrote :

lgtm +1, some minors

[0]

    # on class for ease of mocking

s/mocking/testing

[1]

+ self.conf = os.path.join(init_dir, "some-name.conf")

pep8 cheese, double space after =

[2]

+ def set_start_on_filesystem(self):
+ self._start_on_filesystem = True

this line is never tested, nor entirely clear on usage. There are several upstart gurus in the room if this needs clarification.

[3] more untested lines

+ self.root_path = root_path

its not clear why this mocked out as a class attribute, when the constructor takes the arg.

+ raise ServiceError("Cannot render .conf: no description set")
+ raise ServiceError("Cannot render .conf: no command set")

review: Approve
Revision history for this message
William Reade (fwereade) wrote :

[0,1]

Cool, thanks

[2,3]

...gaah, missing @inlineCallbacks~s led to some untestedness; root_path tested explicitly. After consultation with bcsaller (original author), it's fine to drop the "start on filesystem or" complication, so it's gone.

447. By William Reade

address review points

448. By William Reade

merge parent

449. By William Reade

merge parent

450. By William Reade

merge parent

451. By William Reade

merge parent

452. By William Reade

merge parent

453. By William Reade

merge parent

454. By William Reade

merge parent

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'juju/errors.py'
2--- juju/errors.py 2012-02-21 10:36:56 +0000
3+++ juju/errors.py 2012-02-21 10:36:57 +0000
4@@ -174,3 +174,6 @@
5 self.user_policy,
6 self.provider_type,
7 ", ".join(self.provider_policies)))
8+
9+class ServiceError(JujuError):
10+ """Some problem with an upstart service"""
11
12=== added directory 'juju/lib/tests/data'
13=== added file 'juju/lib/tests/data/test_basic_install'
14--- juju/lib/tests/data/test_basic_install 1970-01-01 00:00:00 +0000
15+++ juju/lib/tests/data/test_basic_install 2012-02-21 10:36:57 +0000
16@@ -0,0 +1,10 @@
17+description "uninteresting service"
18+author "Juju Team <juju@lists.ubuntu.com>"
19+
20+start on runlevel [2345]
21+stop on runlevel [!2345]
22+respawn
23+
24+
25+
26+exec /bin/false
27
28=== added file 'juju/lib/tests/data/test_less_basic_install'
29--- juju/lib/tests/data/test_less_basic_install 1970-01-01 00:00:00 +0000
30+++ juju/lib/tests/data/test_less_basic_install 2012-02-21 10:36:57 +0000
31@@ -0,0 +1,11 @@
32+description "pew pew pew blam"
33+author "Juju Team <juju@lists.ubuntu.com>"
34+
35+start on runlevel [2345]
36+stop on runlevel [!2345]
37+respawn
38+
39+env FOO="bar baz qux"
40+env PEW="pew"
41+
42+exec /bin/deathstar --ignore-ewoks endor
43
44=== added file 'juju/lib/tests/data/test_standard_install'
45--- juju/lib/tests/data/test_standard_install 1970-01-01 00:00:00 +0000
46+++ juju/lib/tests/data/test_standard_install 2012-02-21 10:36:57 +0000
47@@ -0,0 +1,10 @@
48+description "a wretched hive of scum and villainy"
49+author "Juju Team <juju@lists.ubuntu.com>"
50+
51+start on runlevel [2345]
52+stop on runlevel [!2345]
53+respawn
54+
55+env LIGHTSABER="civilised weapon"
56+
57+exec /bin/imagination-failure --no-ideas
58
59=== added file 'juju/lib/tests/test_upstart.py'
60--- juju/lib/tests/test_upstart.py 1970-01-01 00:00:00 +0000
61+++ juju/lib/tests/test_upstart.py 2012-02-21 10:36:57 +0000
62@@ -0,0 +1,229 @@
63+import os
64+
65+from twisted.internet.defer import inlineCallbacks, succeed
66+
67+from juju.errors import ServiceError
68+from juju.lib.mocker import ANY
69+from juju.lib.testing import TestCase
70+from juju.lib.upstart import UpstartService
71+
72+
73+DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data")
74+
75+
76+class UpstartServiceTest(TestCase):
77+
78+ @inlineCallbacks
79+ def setUp(self):
80+ yield super(UpstartServiceTest, self).setUp()
81+ self.root = self.makeDir()
82+ init_dir = os.path.join(self.root, "etc", "init")
83+ os.makedirs(init_dir)
84+ self.conf = os.path.join(init_dir, "some-name.conf")
85+ self.patch(UpstartService, "root_path", self.root)
86+ self.service = UpstartService("some-name")
87+
88+ def setup_service(self):
89+ self.service.set_description("a wretched hive of scum and villainy")
90+ self.service.set_command("/bin/imagination-failure --no-ideas")
91+ self.service.set_environ({"LIGHTSABER": "civilised weapon"})
92+
93+ def setup_mock(self):
94+ self.check_call = self.mocker.replace("subprocess.check_call")
95+ self.getProcessOutput = self.mocker.replace(
96+ "twisted.internet.utils.getProcessOutput")
97+
98+ def mock_status(self, result):
99+ self.getProcessOutput("/sbin/status", ["some-name"])
100+ self.mocker.result(result)
101+
102+ def mock_call(self, args):
103+ self.check_call(args)
104+ self.mocker.result(0)
105+
106+ def mock_start(self):
107+ self.mock_call(("/sbin/start", "some-name"))
108+
109+ def mock_stop(self):
110+ self.mock_call(("/sbin/stop", "some-name"))
111+
112+ def write_dummy_conf(self):
113+ with open(self.conf, "w") as f:
114+ f.write("dummy")
115+
116+ def assert_dummy_conf(self):
117+ with open(self.conf) as f:
118+ self.assertEquals(f.read(), "dummy")
119+
120+ def assert_no_conf(self):
121+ self.assertFalse(os.path.exists(self.conf))
122+
123+ def assert_conf(self, name="test_standard_install"):
124+ with open(os.path.join(DATA_DIR, name)) as expected:
125+ with open(self.conf) as actual:
126+ self.assertEquals(actual.read(), expected.read())
127+
128+ def test_is_installed(self):
129+ """Check is_installed depends on conf file existence"""
130+ self.assertFalse(self.service.is_installed())
131+ self.write_dummy_conf()
132+ self.assertTrue(self.service.is_installed())
133+
134+ def test_root_path(self):
135+ """
136+ Check is_installed still works when root_path specified explicitly
137+ """
138+ self.patch(UpstartService, "root_path", "/BAD/PATH")
139+ self.service = UpstartService("some-name", root_path=self.root)
140+ self.setup_service()
141+
142+ self.assertFalse(self.service.is_installed())
143+ self.write_dummy_conf()
144+ self.assertTrue(self.service.is_installed())
145+
146+ @inlineCallbacks
147+ def test_is_running(self):
148+ """
149+ Check is_running interprets status output (when service is installed)
150+ """
151+ self.setup_mock()
152+ self.mock_status(succeed("blah stop/waiting blah"))
153+ self.mock_status(succeed("blah blob/gibbering blah"))
154+ self.mock_status(succeed("blah start/running blah"))
155+ self.mocker.replay()
156+
157+ # Won't hit status; conf is not installed
158+ self.assertFalse((yield self.service.is_running()))
159+ self.write_dummy_conf()
160+
161+ # These 3 calls correspond to the 3 mock_status calls above
162+ self.assertFalse((yield self.service.is_running()))
163+ self.assertFalse((yield self.service.is_running()))
164+ self.assertTrue((yield self.service.is_running()))
165+
166+ @inlineCallbacks
167+ def test_basic_install(self):
168+ """Check a simple UpstartService writes expected conf file"""
169+ e = yield self.assertFailure(self.service.install(), ServiceError)
170+ self.assertEquals(str(e), "Cannot render .conf: no description set")
171+ self.service.set_description("uninteresting service")
172+ e = yield self.assertFailure(self.service.install(), ServiceError)
173+ self.assertEquals(str(e), "Cannot render .conf: no command set")
174+ self.service.set_command("/bin/false")
175+ yield self.service.install()
176+
177+ self.assert_conf("test_basic_install")
178+
179+ @inlineCallbacks
180+ def test_less_basic_install(self):
181+ """Check conf for a different UpstartService (which sets an env var)"""
182+ self.service.set_description("pew pew pew blam")
183+ self.service.set_command("/bin/deathstar --ignore-ewoks endor")
184+ self.service.set_environ({"FOO": "bar baz qux", "PEW": "pew"})
185+ yield self.service.install()
186+
187+ self.assert_conf("test_less_basic_install")
188+
189+ def test_install_via_script(self):
190+ """Check that the output-as-script form does the right thing"""
191+ self.setup_service()
192+ install, start = self.service.get_cloud_init_commands()
193+
194+ os.system(install)
195+ self.assert_conf()
196+ self.assertEquals(start, "/sbin/start some-name")
197+
198+ @inlineCallbacks
199+ def test_start_not_installed(self):
200+ """Check that .start() also installs if necessary"""
201+ self.setup_mock()
202+ self.mock_status(succeed("blah stop/waiting blah"))
203+ self.mock_start()
204+ self.mocker.replay()
205+
206+ self.setup_service()
207+ yield self.service.start()
208+ self.assert_conf()
209+
210+ @inlineCallbacks
211+ def test_start_not_started(self):
212+ """Check that .start() starts if stopped"""
213+ self.write_dummy_conf()
214+ self.setup_mock()
215+ self.mock_status(succeed("blah stop/waiting blah"))
216+ self.mock_start()
217+ self.mocker.replay()
218+
219+ self.setup_service()
220+ yield self.service.start()
221+ self.assert_dummy_conf()
222+
223+ @inlineCallbacks
224+ def test_start_started(self):
225+ """Check that .start() does nothing if already running"""
226+ self.write_dummy_conf()
227+ self.setup_mock()
228+ self.mock_status(succeed("blah start/running blah"))
229+ self.mocker.replay()
230+
231+ self.setup_service()
232+ yield self.service.start()
233+ self.assert_dummy_conf()
234+
235+ @inlineCallbacks
236+ def test_destroy_not_installed(self):
237+ """Check .destroy() does nothing if not installed"""
238+ yield self.service.destroy()
239+ self.assert_no_conf()
240+
241+ @inlineCallbacks
242+ def test_destroy_not_started(self):
243+ """Check .destroy just deletes conf if not running"""
244+ self.write_dummy_conf()
245+ self.setup_mock()
246+ self.mock_status(succeed("blah stop/waiting blah"))
247+ self.mocker.replay()
248+
249+ yield self.service.destroy()
250+ self.assert_no_conf()
251+
252+ @inlineCallbacks
253+ def test_destroy_started(self):
254+ """Check .destroy() stops running service and deletes conf file"""
255+ self.write_dummy_conf()
256+ self.setup_mock()
257+ self.mock_status(succeed("blah start/running blah"))
258+ self.mock_stop()
259+ self.mocker.replay()
260+
261+ yield self.service.destroy()
262+ self.assert_no_conf()
263+
264+ @inlineCallbacks
265+ def test_use_sudo(self):
266+ """Check that expected commands are generated when use_sudo is set"""
267+ self.setup_mock()
268+ self.service = UpstartService("some-name", use_sudo=True)
269+ self.setup_service()
270+
271+ def verify_cp(args):
272+ sudo, cp, src, dst = args
273+ self.assertEquals(sudo, "sudo")
274+ self.assertEquals(cp, "cp")
275+ with open(os.path.join(DATA_DIR, "test_standard_install")) as exp:
276+ with open(src) as actual:
277+ self.assertEquals(actual.read(), exp.read())
278+ self.assertEquals(dst, self.conf)
279+ self.write_dummy_conf()
280+ self.check_call(ANY)
281+ self.mocker.call(verify_cp)
282+ self.mock_call(("sudo", "chmod", "644", self.conf))
283+ self.mock_status(succeed("blah stop/waiting blah"))
284+ self.mock_call(("sudo", "/sbin/start", "some-name"))
285+ self.mock_status(succeed("blah start/running blah"))
286+ self.mock_call(("sudo", "/sbin/stop", "some-name"))
287+ self.mock_call(("sudo", "rm", self.conf))
288+
289+ self.mocker.replay()
290+ yield self.service.start()
291+ yield self.service.destroy()
292
293=== added file 'juju/lib/upstart.py'
294--- juju/lib/upstart.py 1970-01-01 00:00:00 +0000
295+++ juju/lib/upstart.py 2012-02-21 10:36:57 +0000
296@@ -0,0 +1,105 @@
297+import os
298+import subprocess
299+from tempfile import NamedTemporaryFile
300+
301+from twisted.internet.defer import inlineCallbacks, returnValue
302+from twisted.internet.threads import deferToThread
303+from twisted.internet.utils import getProcessOutput
304+
305+from juju.errors import ServiceError
306+
307+
308+_UPSTART_TEMPLATE = """\
309+description "%s"
310+author "Juju Team <juju@lists.ubuntu.com>"
311+
312+start on runlevel [2345]
313+stop on runlevel [!2345]
314+respawn
315+
316+%s
317+
318+exec %s
319+"""
320+
321+
322+class UpstartService(object):
323+
324+ # on class for ease of testing
325+ root_path = "/"
326+
327+ def __init__(self, name, root_path=None, use_sudo=False):
328+ self._name = name
329+ self._use_sudo = use_sudo
330+ if root_path is not None:
331+ self.root_path = root_path
332+ self._description = None
333+ self._environ = {}
334+ self._command = None
335+
336+ def set_description(self, description):
337+ self._description = description
338+
339+ def set_environ(self, environ):
340+ self._environ = environ
341+
342+ def set_command(self, command):
343+ self._command = command
344+
345+ def _render(self):
346+ if self._description is None:
347+ raise ServiceError("Cannot render .conf: no description set")
348+ if self._command is None:
349+ raise ServiceError("Cannot render .conf: no command set")
350+ return _UPSTART_TEMPLATE % (
351+ self._description,
352+ "\n".join('env %s="%s"' % kv
353+ for kv in sorted(self._environ.items())),
354+ self._command)
355+
356+ @property
357+ def _conf_path(self):
358+ return os.path.join(
359+ self.root_path, "etc", "init", "%s.conf" % self._name)
360+
361+ def _call(self, *args):
362+ if self._use_sudo:
363+ args = ("sudo",) + args
364+ return deferToThread(subprocess.check_call, args)
365+
366+ def get_cloud_init_commands(self):
367+ return ["cat >> %s <<EOF\n%sEOF\n" % (self._conf_path, self._render()),
368+ "/sbin/start %s" % self._name]
369+
370+ @inlineCallbacks
371+ def install(self):
372+ with NamedTemporaryFile() as f:
373+ f.write(self._render())
374+ f.flush()
375+ yield self._call("cp", f.name, self._conf_path)
376+ yield self._call("chmod", "644", self._conf_path)
377+
378+ @inlineCallbacks
379+ def start(self):
380+ if not self.is_installed():
381+ yield self.install()
382+ if (yield self.is_running()):
383+ return
384+ yield self._call("/sbin/start", self._name)
385+
386+ @inlineCallbacks
387+ def destroy(self):
388+ if (yield self.is_running()):
389+ yield self._call("/sbin/stop", self._name)
390+ if self.is_installed():
391+ yield self._call("rm", self._conf_path)
392+
393+ @inlineCallbacks
394+ def is_running(self):
395+ if not self.is_installed():
396+ returnValue(False)
397+ status = yield getProcessOutput("/sbin/status", [self._name])
398+ returnValue("start/running" in status)
399+
400+ def is_installed(self):
401+ return os.path.exists(self._conf_path)
402
403=== modified file 'juju/tests/test_errors.py'
404--- juju/tests/test_errors.py 2012-02-21 10:36:56 +0000
405+++ juju/tests/test_errors.py 2012-02-21 10:36:57 +0000
406@@ -3,7 +3,8 @@
407 CharmInvocationError, CharmUpgradeError, NoConnection, InvalidHost,
408 InvalidUser, ProviderError, CloudInitError, ProviderInteractionError,
409 CannotTerminateMachine, MachinesNotFound, EnvironmentPending,
410- EnvironmentNotFound, IncompatibleVersion, InvalidPlacementPolicy)
411+ EnvironmentNotFound, IncompatibleVersion, InvalidPlacementPolicy,
412+ ServiceError)
413
414 from juju.lib.testing import TestCase
415
416@@ -111,6 +112,11 @@
417 ("Unsupported placement policy: 'x' for provider: 'foobar', "
418 "supported policies a, b, c"))
419
420+ def test_ServiceError(self):
421+ error = ServiceError("blah")
422+ self.assertEquals(str(error), "blah")
423+ self.assertIsJujuError(error)
424+
425 def test_CharmError(self):
426 error = CharmError("/foo/bar", "blah blah")
427 self.assertIsJujuError(error)

Subscribers

People subscribed via source and target branches

to status/vote changes: