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
=== modified file 'juju/errors.py'
--- juju/errors.py 2012-02-21 10:36:56 +0000
+++ juju/errors.py 2012-02-21 10:36:57 +0000
@@ -174,3 +174,6 @@
174 self.user_policy,174 self.user_policy,
175 self.provider_type,175 self.provider_type,
176 ", ".join(self.provider_policies)))176 ", ".join(self.provider_policies)))
177
178class ServiceError(JujuError):
179 """Some problem with an upstart service"""
177180
=== added directory 'juju/lib/tests/data'
=== added file 'juju/lib/tests/data/test_basic_install'
--- juju/lib/tests/data/test_basic_install 1970-01-01 00:00:00 +0000
+++ juju/lib/tests/data/test_basic_install 2012-02-21 10:36:57 +0000
@@ -0,0 +1,10 @@
1description "uninteresting service"
2author "Juju Team <juju@lists.ubuntu.com>"
3
4start on runlevel [2345]
5stop on runlevel [!2345]
6respawn
7
8
9
10exec /bin/false
011
=== added file 'juju/lib/tests/data/test_less_basic_install'
--- juju/lib/tests/data/test_less_basic_install 1970-01-01 00:00:00 +0000
+++ juju/lib/tests/data/test_less_basic_install 2012-02-21 10:36:57 +0000
@@ -0,0 +1,11 @@
1description "pew pew pew blam"
2author "Juju Team <juju@lists.ubuntu.com>"
3
4start on runlevel [2345]
5stop on runlevel [!2345]
6respawn
7
8env FOO="bar baz qux"
9env PEW="pew"
10
11exec /bin/deathstar --ignore-ewoks endor
012
=== added file 'juju/lib/tests/data/test_standard_install'
--- juju/lib/tests/data/test_standard_install 1970-01-01 00:00:00 +0000
+++ juju/lib/tests/data/test_standard_install 2012-02-21 10:36:57 +0000
@@ -0,0 +1,10 @@
1description "a wretched hive of scum and villainy"
2author "Juju Team <juju@lists.ubuntu.com>"
3
4start on runlevel [2345]
5stop on runlevel [!2345]
6respawn
7
8env LIGHTSABER="civilised weapon"
9
10exec /bin/imagination-failure --no-ideas
011
=== added file 'juju/lib/tests/test_upstart.py'
--- juju/lib/tests/test_upstart.py 1970-01-01 00:00:00 +0000
+++ juju/lib/tests/test_upstart.py 2012-02-21 10:36:57 +0000
@@ -0,0 +1,229 @@
1import os
2
3from twisted.internet.defer import inlineCallbacks, succeed
4
5from juju.errors import ServiceError
6from juju.lib.mocker import ANY
7from juju.lib.testing import TestCase
8from juju.lib.upstart import UpstartService
9
10
11DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data")
12
13
14class UpstartServiceTest(TestCase):
15
16 @inlineCallbacks
17 def setUp(self):
18 yield super(UpstartServiceTest, self).setUp()
19 self.root = self.makeDir()
20 init_dir = os.path.join(self.root, "etc", "init")
21 os.makedirs(init_dir)
22 self.conf = os.path.join(init_dir, "some-name.conf")
23 self.patch(UpstartService, "root_path", self.root)
24 self.service = UpstartService("some-name")
25
26 def setup_service(self):
27 self.service.set_description("a wretched hive of scum and villainy")
28 self.service.set_command("/bin/imagination-failure --no-ideas")
29 self.service.set_environ({"LIGHTSABER": "civilised weapon"})
30
31 def setup_mock(self):
32 self.check_call = self.mocker.replace("subprocess.check_call")
33 self.getProcessOutput = self.mocker.replace(
34 "twisted.internet.utils.getProcessOutput")
35
36 def mock_status(self, result):
37 self.getProcessOutput("/sbin/status", ["some-name"])
38 self.mocker.result(result)
39
40 def mock_call(self, args):
41 self.check_call(args)
42 self.mocker.result(0)
43
44 def mock_start(self):
45 self.mock_call(("/sbin/start", "some-name"))
46
47 def mock_stop(self):
48 self.mock_call(("/sbin/stop", "some-name"))
49
50 def write_dummy_conf(self):
51 with open(self.conf, "w") as f:
52 f.write("dummy")
53
54 def assert_dummy_conf(self):
55 with open(self.conf) as f:
56 self.assertEquals(f.read(), "dummy")
57
58 def assert_no_conf(self):
59 self.assertFalse(os.path.exists(self.conf))
60
61 def assert_conf(self, name="test_standard_install"):
62 with open(os.path.join(DATA_DIR, name)) as expected:
63 with open(self.conf) as actual:
64 self.assertEquals(actual.read(), expected.read())
65
66 def test_is_installed(self):
67 """Check is_installed depends on conf file existence"""
68 self.assertFalse(self.service.is_installed())
69 self.write_dummy_conf()
70 self.assertTrue(self.service.is_installed())
71
72 def test_root_path(self):
73 """
74 Check is_installed still works when root_path specified explicitly
75 """
76 self.patch(UpstartService, "root_path", "/BAD/PATH")
77 self.service = UpstartService("some-name", root_path=self.root)
78 self.setup_service()
79
80 self.assertFalse(self.service.is_installed())
81 self.write_dummy_conf()
82 self.assertTrue(self.service.is_installed())
83
84 @inlineCallbacks
85 def test_is_running(self):
86 """
87 Check is_running interprets status output (when service is installed)
88 """
89 self.setup_mock()
90 self.mock_status(succeed("blah stop/waiting blah"))
91 self.mock_status(succeed("blah blob/gibbering blah"))
92 self.mock_status(succeed("blah start/running blah"))
93 self.mocker.replay()
94
95 # Won't hit status; conf is not installed
96 self.assertFalse((yield self.service.is_running()))
97 self.write_dummy_conf()
98
99 # These 3 calls correspond to the 3 mock_status calls above
100 self.assertFalse((yield self.service.is_running()))
101 self.assertFalse((yield self.service.is_running()))
102 self.assertTrue((yield self.service.is_running()))
103
104 @inlineCallbacks
105 def test_basic_install(self):
106 """Check a simple UpstartService writes expected conf file"""
107 e = yield self.assertFailure(self.service.install(), ServiceError)
108 self.assertEquals(str(e), "Cannot render .conf: no description set")
109 self.service.set_description("uninteresting service")
110 e = yield self.assertFailure(self.service.install(), ServiceError)
111 self.assertEquals(str(e), "Cannot render .conf: no command set")
112 self.service.set_command("/bin/false")
113 yield self.service.install()
114
115 self.assert_conf("test_basic_install")
116
117 @inlineCallbacks
118 def test_less_basic_install(self):
119 """Check conf for a different UpstartService (which sets an env var)"""
120 self.service.set_description("pew pew pew blam")
121 self.service.set_command("/bin/deathstar --ignore-ewoks endor")
122 self.service.set_environ({"FOO": "bar baz qux", "PEW": "pew"})
123 yield self.service.install()
124
125 self.assert_conf("test_less_basic_install")
126
127 def test_install_via_script(self):
128 """Check that the output-as-script form does the right thing"""
129 self.setup_service()
130 install, start = self.service.get_cloud_init_commands()
131
132 os.system(install)
133 self.assert_conf()
134 self.assertEquals(start, "/sbin/start some-name")
135
136 @inlineCallbacks
137 def test_start_not_installed(self):
138 """Check that .start() also installs if necessary"""
139 self.setup_mock()
140 self.mock_status(succeed("blah stop/waiting blah"))
141 self.mock_start()
142 self.mocker.replay()
143
144 self.setup_service()
145 yield self.service.start()
146 self.assert_conf()
147
148 @inlineCallbacks
149 def test_start_not_started(self):
150 """Check that .start() starts if stopped"""
151 self.write_dummy_conf()
152 self.setup_mock()
153 self.mock_status(succeed("blah stop/waiting blah"))
154 self.mock_start()
155 self.mocker.replay()
156
157 self.setup_service()
158 yield self.service.start()
159 self.assert_dummy_conf()
160
161 @inlineCallbacks
162 def test_start_started(self):
163 """Check that .start() does nothing if already running"""
164 self.write_dummy_conf()
165 self.setup_mock()
166 self.mock_status(succeed("blah start/running blah"))
167 self.mocker.replay()
168
169 self.setup_service()
170 yield self.service.start()
171 self.assert_dummy_conf()
172
173 @inlineCallbacks
174 def test_destroy_not_installed(self):
175 """Check .destroy() does nothing if not installed"""
176 yield self.service.destroy()
177 self.assert_no_conf()
178
179 @inlineCallbacks
180 def test_destroy_not_started(self):
181 """Check .destroy just deletes conf if not running"""
182 self.write_dummy_conf()
183 self.setup_mock()
184 self.mock_status(succeed("blah stop/waiting blah"))
185 self.mocker.replay()
186
187 yield self.service.destroy()
188 self.assert_no_conf()
189
190 @inlineCallbacks
191 def test_destroy_started(self):
192 """Check .destroy() stops running service and deletes conf file"""
193 self.write_dummy_conf()
194 self.setup_mock()
195 self.mock_status(succeed("blah start/running blah"))
196 self.mock_stop()
197 self.mocker.replay()
198
199 yield self.service.destroy()
200 self.assert_no_conf()
201
202 @inlineCallbacks
203 def test_use_sudo(self):
204 """Check that expected commands are generated when use_sudo is set"""
205 self.setup_mock()
206 self.service = UpstartService("some-name", use_sudo=True)
207 self.setup_service()
208
209 def verify_cp(args):
210 sudo, cp, src, dst = args
211 self.assertEquals(sudo, "sudo")
212 self.assertEquals(cp, "cp")
213 with open(os.path.join(DATA_DIR, "test_standard_install")) as exp:
214 with open(src) as actual:
215 self.assertEquals(actual.read(), exp.read())
216 self.assertEquals(dst, self.conf)
217 self.write_dummy_conf()
218 self.check_call(ANY)
219 self.mocker.call(verify_cp)
220 self.mock_call(("sudo", "chmod", "644", self.conf))
221 self.mock_status(succeed("blah stop/waiting blah"))
222 self.mock_call(("sudo", "/sbin/start", "some-name"))
223 self.mock_status(succeed("blah start/running blah"))
224 self.mock_call(("sudo", "/sbin/stop", "some-name"))
225 self.mock_call(("sudo", "rm", self.conf))
226
227 self.mocker.replay()
228 yield self.service.start()
229 yield self.service.destroy()
0230
=== added file 'juju/lib/upstart.py'
--- juju/lib/upstart.py 1970-01-01 00:00:00 +0000
+++ juju/lib/upstart.py 2012-02-21 10:36:57 +0000
@@ -0,0 +1,105 @@
1import os
2import subprocess
3from tempfile import NamedTemporaryFile
4
5from twisted.internet.defer import inlineCallbacks, returnValue
6from twisted.internet.threads import deferToThread
7from twisted.internet.utils import getProcessOutput
8
9from juju.errors import ServiceError
10
11
12_UPSTART_TEMPLATE = """\
13description "%s"
14author "Juju Team <juju@lists.ubuntu.com>"
15
16start on runlevel [2345]
17stop on runlevel [!2345]
18respawn
19
20%s
21
22exec %s
23"""
24
25
26class UpstartService(object):
27
28 # on class for ease of testing
29 root_path = "/"
30
31 def __init__(self, name, root_path=None, use_sudo=False):
32 self._name = name
33 self._use_sudo = use_sudo
34 if root_path is not None:
35 self.root_path = root_path
36 self._description = None
37 self._environ = {}
38 self._command = None
39
40 def set_description(self, description):
41 self._description = description
42
43 def set_environ(self, environ):
44 self._environ = environ
45
46 def set_command(self, command):
47 self._command = command
48
49 def _render(self):
50 if self._description is None:
51 raise ServiceError("Cannot render .conf: no description set")
52 if self._command is None:
53 raise ServiceError("Cannot render .conf: no command set")
54 return _UPSTART_TEMPLATE % (
55 self._description,
56 "\n".join('env %s="%s"' % kv
57 for kv in sorted(self._environ.items())),
58 self._command)
59
60 @property
61 def _conf_path(self):
62 return os.path.join(
63 self.root_path, "etc", "init", "%s.conf" % self._name)
64
65 def _call(self, *args):
66 if self._use_sudo:
67 args = ("sudo",) + args
68 return deferToThread(subprocess.check_call, args)
69
70 def get_cloud_init_commands(self):
71 return ["cat >> %s <<EOF\n%sEOF\n" % (self._conf_path, self._render()),
72 "/sbin/start %s" % self._name]
73
74 @inlineCallbacks
75 def install(self):
76 with NamedTemporaryFile() as f:
77 f.write(self._render())
78 f.flush()
79 yield self._call("cp", f.name, self._conf_path)
80 yield self._call("chmod", "644", self._conf_path)
81
82 @inlineCallbacks
83 def start(self):
84 if not self.is_installed():
85 yield self.install()
86 if (yield self.is_running()):
87 return
88 yield self._call("/sbin/start", self._name)
89
90 @inlineCallbacks
91 def destroy(self):
92 if (yield self.is_running()):
93 yield self._call("/sbin/stop", self._name)
94 if self.is_installed():
95 yield self._call("rm", self._conf_path)
96
97 @inlineCallbacks
98 def is_running(self):
99 if not self.is_installed():
100 returnValue(False)
101 status = yield getProcessOutput("/sbin/status", [self._name])
102 returnValue("start/running" in status)
103
104 def is_installed(self):
105 return os.path.exists(self._conf_path)
0106
=== modified file 'juju/tests/test_errors.py'
--- juju/tests/test_errors.py 2012-02-21 10:36:56 +0000
+++ juju/tests/test_errors.py 2012-02-21 10:36:57 +0000
@@ -3,7 +3,8 @@
3 CharmInvocationError, CharmUpgradeError, NoConnection, InvalidHost,3 CharmInvocationError, CharmUpgradeError, NoConnection, InvalidHost,
4 InvalidUser, ProviderError, CloudInitError, ProviderInteractionError,4 InvalidUser, ProviderError, CloudInitError, ProviderInteractionError,
5 CannotTerminateMachine, MachinesNotFound, EnvironmentPending,5 CannotTerminateMachine, MachinesNotFound, EnvironmentPending,
6 EnvironmentNotFound, IncompatibleVersion, InvalidPlacementPolicy)6 EnvironmentNotFound, IncompatibleVersion, InvalidPlacementPolicy,
7 ServiceError)
78
8from juju.lib.testing import TestCase9from juju.lib.testing import TestCase
910
@@ -111,6 +112,11 @@
111 ("Unsupported placement policy: 'x' for provider: 'foobar', "112 ("Unsupported placement policy: 'x' for provider: 'foobar', "
112 "supported policies a, b, c"))113 "supported policies a, b, c"))
113114
115 def test_ServiceError(self):
116 error = ServiceError("blah")
117 self.assertEquals(str(error), "blah")
118 self.assertIsJujuError(error)
119
114 def test_CharmError(self):120 def test_CharmError(self):
115 error = CharmError("/foo/bar", "blah blah")121 error = CharmError("/foo/bar", "blah blah")
116 self.assertIsJujuError(error)122 self.assertIsJujuError(error)

Subscribers

People subscribed via source and target branches

to status/vote changes: