7
from twisted.internet.defer import gatherResults
8
from twisted.internet.error import ProcessDone
9
from twisted.python.failure import Failure
11
from landscape.manager.scriptexecution import (
12
ScriptExecution, ProcessTimeLimitReachedError, PROCESS_FAILED_RESULT,
14
from landscape.manager.manager import SUCCEEDED, FAILED
15
from landscape.tests.helpers import (
16
LandscapeTest, LandscapeIsolatedTest, ManagerHelper,
17
StubProcessFactory, DummyProcess)
18
from landscape.tests.mocker import ANY, ARGS
21
class RunScriptTests(LandscapeTest):
23
helpers = [ManagerHelper]
26
super(RunScriptTests, self).setUp()
27
self.plugin = ScriptExecution()
28
self.manager.add(self.plugin)
30
def test_basic_run(self):
32
The plugin returns a Deferred resulting in the output of basic
35
result = self.plugin.run_script("/bin/sh", "echo hi")
36
result.addCallback(self.assertEquals, "hi\n")
39
def test_other_interpreter(self):
40
"""Non-shell interpreters can be specified."""
41
result = self.plugin.run_script("/usr/bin/python", "print 'hi'")
42
result.addCallback(self.assertEquals, "hi\n")
45
def test_concurrent(self):
46
"""Scripts run with the ScriptExecution plugin are run concurrently."""
47
fifo = self.make_path()
49
# If the first process is blocking on a fifo, and the second process
50
# wants to write to the fifo, the only way this will complete is if
51
# run_script is truly async
52
d1 = self.plugin.run_script("/bin/sh", "cat " + fifo)
53
d2 = self.plugin.run_script("/bin/sh", "echo hi > " + fifo)
54
d1.addCallback(self.assertEquals, "hi\n")
55
d2.addCallback(self.assertEquals, "")
56
return gatherResults([d1, d2])
58
def test_accented_run_in_code(self):
60
Scripts can contain accented data both in the code and in the
63
accented_content = u"\N{LATIN SMALL LETTER E WITH ACUTE}"
64
result = self.plugin.run_script(
65
u"/bin/sh", u"echo %s" % (accented_content,))
67
self.assertEquals, "%s\n" % (accented_content.encode("utf-8"),))
70
def test_accented_run_in_interpreter(self):
72
Scripts can also contain accents in the interpreter.
74
accented_content = u"\N{LATIN SMALL LETTER E WITH ACUTE}"
75
result = self.plugin.run_script(
76
u"/bin/echo %s" % (accented_content,), u"")
79
"%s " % (accented_content.encode("utf-8"),) in result)
80
result.addCallback(check)
83
def test_set_umask_appropriately(self):
85
We should be setting the umask to 0022 before executing a script, and
86
restoring it to the previous value when finishing.
88
mock_umask = self.mocker.replace("os.umask")
90
self.mocker.result(0077)
93
result = self.plugin.run_script("/bin/sh", "umask")
94
result.addCallback(self.assertEquals, "0022\n")
97
def test_restore_umask_in_event_of_error(self):
99
We set the umask before executing the script, in the event that there's
100
an error setting up the script, we want to restore the umask.
102
mock_umask = self.mocker.replace("os.umask")
104
self.mocker.result(0077)
105
mock_mkdtemp = self.mocker.replace("tempfile.mkdtemp", passthrough=False)
107
self.mocker.throw(OSError("Fail!"))
110
self.assertRaises(OSError, self.plugin.run_script, "/bin/sh", "umask",
111
attachments={u"file1": "some data"})
113
def test_run_with_attachments(self):
114
result = self.plugin.run_script(
116
u"ls $LANDSCAPE_ATTACHMENTS && cat $LANDSCAPE_ATTACHMENTS/file1",
117
attachments={u"file1": "some data"})
119
self.assertEquals(result, "file1\nsome data")
120
result.addCallback(check)
123
def test_self_remove_script(self):
125
If a script removes itself, it doesn't create an error when the script
126
execution plugin tries to remove the script file.
128
result = self.plugin.run_script("/bin/sh", "echo hi && rm $0")
129
result.addCallback(self.assertEquals, "hi\n")
132
def test_self_remove_attachments(self):
134
If a script removes its attachments, it doesn't create an error when
135
the script execution plugin tries to remove the attachments directory.
137
result = self.plugin.run_script(
139
u"ls $LANDSCAPE_ATTACHMENTS && rm -r $LANDSCAPE_ATTACHMENTS",
140
attachments={u"file1": "some data"})
142
self.assertEquals(result, "file1\n")
143
result.addCallback(check)
146
def _run_script(self, username, uid, gid, path):
147
# ignore the call to chown!
148
mock_chown = self.mocker.replace("os.chown", passthrough=False)
151
factory = StubProcessFactory()
152
self.plugin.process_factory = factory
156
result = self.plugin.run_script("/bin/sh", "echo hi", user=username)
158
self.assertEquals(len(factory.spawns), 1)
159
spawn = factory.spawns[0]
160
self.assertEquals(spawn[4], path)
161
self.assertEquals(spawn[5], uid)
162
self.assertEquals(spawn[6], gid)
163
result.addCallback(self.assertEquals, "foobar")
166
protocol.childDataReceived(1, "foobar")
168
protocol.childConnectionLost(fd)
169
protocol.processEnded(Failure(ProcessDone(0)))
174
Running a script as a particular user calls
175
C{IReactorProcess.spawnProcess} with an appropriate C{uid} argument,
176
with the user's primary group as the C{gid} argument and with the user
177
home as C{path} argument.
180
info = pwd.getpwuid(uid)
181
username = info.pw_name
185
return self._run_script(username, uid, gid, path)
187
def test_user_no_home(self):
189
When the user specified to C{run_script} doesn't have a home, the
190
script executes in '/'.
192
mock_getpwnam = self.mocker.replace("pwd.getpwnam", passthrough=False)
196
pw_dir = self.make_path()
198
self.expect(mock_getpwnam("user")).result(pwnam)
200
return self._run_script("user", 1234, 5678, "/")
202
def test_user_with_attachments(self):
204
info = pwd.getpwuid(uid)
205
username = info.pw_name
209
mock_chown = self.mocker.replace("os.chown", passthrough=False)
210
mock_chown(ANY, uid, gid)
213
factory = StubProcessFactory()
214
self.plugin.process_factory = factory
218
result = self.plugin.run_script("/bin/sh", "echo hi", user=username,
219
attachments={u"file 1": "some data"})
221
self.assertEquals(len(factory.spawns), 1)
222
spawn = factory.spawns[0]
223
self.assertEquals(spawn[3].keys(), ["LANDSCAPE_ATTACHMENTS"])
224
attachment_dir = spawn[3]["LANDSCAPE_ATTACHMENTS"]
225
self.assertEquals(stat.S_IMODE(os.stat(attachment_dir).st_mode), 0700)
226
filename = os.path.join(attachment_dir, "file 1")
227
self.assertEquals(stat.S_IMODE(os.stat(filename).st_mode), 0600)
230
protocol.childDataReceived(1, "foobar")
232
protocol.childConnectionLost(fd)
233
protocol.processEnded(Failure(ProcessDone(0)))
235
self.assertEquals(data, "foobar")
236
self.assertFalse(os.path.exists(attachment_dir))
237
return result.addCallback(check)
239
def test_limit_size(self):
240
"""Data returned from the command is limited."""
241
factory = StubProcessFactory()
242
self.plugin.process_factory = factory
243
self.plugin.size_limit = 100
244
result = self.plugin.run_script("/bin/sh", "")
245
result.addCallback(self.assertEquals, "x"*100)
247
protocol = factory.spawns[0][0]
248
protocol.childDataReceived(1, "x"*200)
250
protocol.childConnectionLost(fd)
251
protocol.processEnded(Failure(ProcessDone(0)))
255
def test_limit_time(self):
257
The process only lasts for a certain number of seconds.
259
result = self.plugin.run_script("/bin/sh", "cat", time_limit=500)
260
self.manager.reactor.advance(501)
261
self.assertFailure(result, ProcessTimeLimitReachedError)
264
def test_limit_time_accumulates_data(self):
266
Data from processes that time out should still be accumulated and
267
available from the exception object that is raised.
269
factory = StubProcessFactory()
270
self.plugin.process_factory = factory
271
result = self.plugin.run_script("/bin/sh", "", time_limit=500)
272
protocol = factory.spawns[0][0]
273
protocol.makeConnection(DummyProcess())
274
protocol.childDataReceived(1, "hi\n")
275
self.manager.reactor.advance(501)
276
protocol.processEnded(Failure(ProcessDone(0)))
278
self.assertTrue(f.check(ProcessTimeLimitReachedError))
279
self.assertEquals(f.value.data, "hi\n")
280
result.addErrback(got_error)
283
def test_time_limit_canceled_after_success(self):
285
The timeout call is cancelled after the script terminates.
287
factory = StubProcessFactory()
288
self.plugin.process_factory = factory
289
result = self.plugin.run_script("/bin/sh", "", time_limit=500)
290
protocol = factory.spawns[0][0]
291
transport = DummyProcess()
292
protocol.makeConnection(transport)
293
protocol.childDataReceived(1, "hi\n")
294
protocol.processEnded(Failure(ProcessDone(0)))
295
self.manager.reactor.advance(501)
296
self.assertEquals(transport.signals, [])
298
def test_cancel_doesnt_blow_after_success(self):
300
When the process ends successfully and is immediately followed by the
301
timeout, the output should still be in the failure and nothing bad will
303
[regression test: killing of the already-dead process would blow up.]
305
factory = StubProcessFactory()
306
self.plugin.process_factory = factory
307
result = self.plugin.run_script("/bin/sh", "", time_limit=500)
308
protocol = factory.spawns[0][0]
309
protocol.makeConnection(DummyProcess())
310
protocol.childDataReceived(1, "hi")
311
protocol.processEnded(Failure(ProcessDone(0)))
312
self.manager.reactor.advance(501)
315
self.assertTrue(f.check(ProcessTimeLimitReachedError))
316
self.assertEquals(f.value.data, "hi\n")
317
result.addErrback(got_error)
320
def test_script_is_owned_by_user(self):
322
This is a very white-box test. When a script is generated, it must be
323
created such that data NEVER gets into it before the file has the
324
correct permissions. Therefore os.chmod and os.chown must be called
325
before data is written.
330
mock_chown = self.mocker.replace("os.chown", passthrough=False)
331
mock_chmod = self.mocker.replace("os.chmod", passthrough=False)
332
mock_mkstemp = self.mocker.replace("tempfile.mkstemp",
334
mock_fdopen = self.mocker.replace("os.fdopen", passthrough=False)
335
process_factory = self.mocker.mock()
336
self.plugin.process_factory = process_factory
340
self.expect(mock_mkstemp()).result((99, "tempo!"))
342
script_file = mock_fdopen(99, "w")
343
mock_chmod("tempo!", 0700)
344
mock_chown("tempo!", uid, gid)
345
# The contents are written *after* the permissions have been set up!
346
script_file.write("#!/bin/sh\ncode")
349
process_factory.spawnProcess(
350
ANY, ANY, uid=uid, gid=gid, path=ANY, env={})
354
# We don't really care about the deferred that's returned, as long as
355
# those things happened in the correct order.
356
self.plugin.run_script("/bin/sh", "code",
357
user=pwd.getpwuid(uid)[0])
359
def test_script_removed(self):
361
The script is removed after it is finished.
363
mock_mkstemp = self.mocker.replace("tempfile.mkstemp",
365
fd, filename = tempfile.mkstemp()
366
self.expect(mock_mkstemp()).result((fd, filename))
368
d = self.plugin.run_script("/bin/sh", "true")
369
d.addCallback(lambda ign: self.assertFalse(os.path.exists(filename)))
372
def test_unknown_interpreter(self):
374
If the script is run with an unknown interpreter, it raises a
375
meaningful error instead of crashing in execvpe.
377
d = self.plugin.run_script("/bin/cantpossiblyexist", "stuff")
379
self.fail("Should not be there")
381
failure.trap(ProcessFailedError)
384
"Unknown interpreter: '/bin/cantpossiblyexist'")
385
return d.addCallback(cb).addErrback(eb)
388
class ScriptExecutionMessageTests(LandscapeIsolatedTest):
389
helpers = [ManagerHelper]
392
super(ScriptExecutionMessageTests, self).setUp()
393
self.broker_service.message_store.set_accepted_types(
394
["operation-result"])
395
self.manager.config.script_users = "ALL"
397
def _verify_script(self, executable, interp, code):
399
Given spawnProcess arguments, check to make sure that the temporary
400
script has the correct content.
402
data = open(executable, "r").read()
403
self.assertEquals(data, "#!%s\n%s" % (interp, code))
406
def _send_script(self, interpreter, code, operation_id=123,
407
user=pwd.getpwuid(os.getuid())[0],
409
return self.manager.dispatch_message(
410
{"type": "execute-script",
411
"interpreter": interpreter,
413
"operation-id": operation_id,
415
"time-limit": time_limit,
418
def test_success(self):
420
When a C{execute-script} message is received from the server, the
421
specified script will be run and an operation-result will be sent back
424
# Let's use a stub process factory, because otherwise we don't have
425
# access to the deferred.
426
factory = StubProcessFactory()
428
# ignore the call to chown!
429
mock_chown = self.mocker.replace("os.chown", passthrough=False)
432
self.manager.add(ScriptExecution(process_factory=factory))
435
result = self._send_script(sys.executable, "print 'hi'")
437
self._verify_script(factory.spawns[0][1], sys.executable, "print 'hi'")
439
self.broker_service.message_store.get_pending_messages(), [])
441
# Now let's simulate the completion of the process
442
factory.spawns[0][0].childDataReceived(1, "hi!\n")
443
factory.spawns[0][0].processEnded(Failure(ProcessDone(0)))
447
self.broker_service.message_store.get_pending_messages(),
448
[{"type": "operation-result",
451
"result-text": u"hi!\n"}])
452
result.addCallback(got_result)
456
"""A user can be specified in the message."""
459
username = pwd.getpwuid(uid)[0]
461
# ignore the call to chown!
462
mock_chown = self.mocker.replace("os.chown", passthrough=False)
465
def spawn_called(protocol, filename, uid, gid, path, env):
466
protocol.childDataReceived(1, "hi!\n")
467
protocol.processEnded(Failure(ProcessDone(0)))
468
self._verify_script(filename, sys.executable, "print 'hi'")
470
process_factory = self.mocker.mock()
471
process_factory.spawnProcess(
472
ANY, ANY, uid=uid, gid=gid, path=ANY, env={})
473
self.mocker.call(spawn_called)
477
self.manager.add(ScriptExecution(process_factory=process_factory))
479
result = self._send_script(sys.executable, "print 'hi'", user=username)
482
def test_timeout(self):
484
If a L{ProcessTimeLimitReachedError} is fired back, the
485
operation-result should have a failed status.
487
factory = StubProcessFactory()
488
self.manager.add(ScriptExecution(process_factory=factory))
490
# ignore the call to chown!
491
mock_chown = self.mocker.replace("os.chown", passthrough=False)
495
result = self._send_script(sys.executable, "bar", time_limit=30)
496
self._verify_script(factory.spawns[0][1], sys.executable, "bar")
498
protocol = factory.spawns[0][0]
499
protocol.makeConnection(DummyProcess())
500
protocol.childDataReceived(2, "ONOEZ")
501
self.manager.reactor.advance(31)
502
protocol.processEnded(Failure(ProcessDone(0)))
506
self.broker_service.message_store.get_pending_messages(),
507
[{"type": "operation-result",
510
"result-text": u"ONOEZ",
511
"result-code": 102}])
512
result.addCallback(got_result)
515
def test_configured_users(self):
517
Messages which try to run a script as a user that is not allowed should
520
self.manager.add(ScriptExecution())
521
self.manager.config.script_users = "landscape, nobody"
522
result = self._send_script(sys.executable, "bar", user="whatever")
525
self.broker_service.message_store.get_pending_messages(),
526
[{"type": "operation-result",
529
"result-text": u"Scripts cannot be run as user whatever."}])
530
result.addCallback(got_result)
533
def test_urgent_response(self):
534
"""Responses to script execution messages are urgent."""
537
username = pwd.getpwuid(uid)[0]
539
# ignore the call to chown!
540
mock_chown = self.mocker.replace("os.chown", passthrough=False)
543
def spawn_called(protocol, filename, uid, gid, path, env):
544
protocol.childDataReceived(1, "hi!\n")
545
protocol.processEnded(Failure(ProcessDone(0)))
546
self._verify_script(filename, sys.executable, "print 'hi'")
548
process_factory = self.mocker.mock()
549
process_factory.spawnProcess(
550
ANY, ANY, uid=uid, gid=gid, path=ANY, env={})
551
self.mocker.call(spawn_called)
555
self.manager.add(ScriptExecution(process_factory=process_factory))
558
self.assertTrue(self.broker_service.exchanger.is_urgent())
560
self.broker_service.message_store.get_pending_messages(),
561
[{"type": "operation-result",
563
"result-text": u"hi!\n",
564
"status": SUCCEEDED}])
566
result = self._send_script(sys.executable, "print 'hi'")
567
result.addCallback(got_result)
570
def test_parse_error_causes_operation_failure(self):
572
If there is an error parsing the message, an operation-result will be
573
sent (assuming operation-id *is* successfully parsed).
575
self.log_helper.ignore_errors(KeyError)
576
self.manager.add(ScriptExecution())
578
self.manager.dispatch_message(
579
{"type": "execute-script", "operation-id": 444})
582
self.broker_service.message_store.get_pending_messages(),
583
[{"type": "operation-result",
585
"result-text": u"KeyError: 'username'",
588
self.assertTrue("KeyError: 'username'" in self.logfile.getvalue())
590
def test_non_zero_exit_fails_operation(self):
592
If a script exits with a nen-zero exit code, the operation associated
593
with it should fail, but the data collected should still be sent.
595
# Mock a bunch of crap so that we can run a real process
596
self.mocker.replace("os.chown", passthrough=False)(ARGS)
597
self.mocker.replace("os.setuid", passthrough=False)(ARGS)
598
self.mocker.count(0, None)
599
self.mocker.replace("os.setgid", passthrough=False)(ARGS)
600
self.mocker.count(0, None)
603
self.manager.add(ScriptExecution())
604
result = self._send_script("/bin/sh", "echo hi; exit 1")
606
def got_result(ignored):
608
self.broker_service.message_store.get_pending_messages(),
609
[{"type": "operation-result",
611
"result-text": "hi\n",
612
"result-code": PROCESS_FAILED_RESULT,
614
return result.addCallback(got_result)
616
def test_unknown_error(self):
618
When a completely unknown error comes back from the process protocol,
619
the operation fails and the formatted failure is included in the
622
factory = StubProcessFactory()
624
# ignore the call to chown!
625
mock_chown = self.mocker.replace("os.chown", passthrough=False)
628
self.manager.add(ScriptExecution(process_factory=factory))
631
result = self._send_script(sys.executable, "print 'hi'")
633
self._verify_script(factory.spawns[0][1], sys.executable, "print 'hi'")
635
self.broker_service.message_store.get_pending_messages(), [])
637
failure = Failure(RuntimeError("Oh noes!"))
638
factory.spawns[0][0].result_deferred.errback(failure)
642
self.broker_service.message_store.get_pending_messages(),
643
[{"type": "operation-result",
646
"result-text": str(failure)}])
647
result.addCallback(got_result)