6285b63572
If the client side abnormally exits, its rootwrap daemon cannot receive a shutdown message and will be left forever. Let it timeout and exit to save such cases. Change-Id: I783717b5fa019371747b98bf92965b6e689603f6 Related-bug: #1658973 Related-bug: #1658977 Related-bug: #1663458
297 lines
10 KiB
Python
297 lines
10 KiB
Python
# Copyright (c) 2014 Mirantis Inc.
|
|
# All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import contextlib
|
|
import io
|
|
import logging
|
|
import os
|
|
import pwd
|
|
import shutil
|
|
import signal
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
try:
|
|
import eventlet
|
|
except ImportError:
|
|
eventlet = None
|
|
|
|
import fixtures
|
|
import mock
|
|
import six
|
|
import testtools
|
|
from testtools import content
|
|
|
|
from oslo_rootwrap import client
|
|
from oslo_rootwrap import cmd
|
|
from oslo_rootwrap import subprocess
|
|
from oslo_rootwrap.tests import run_daemon
|
|
|
|
|
|
class _FunctionalBase(object):
|
|
def setUp(self):
|
|
super(_FunctionalBase, self).setUp()
|
|
tmpdir = self.useFixture(fixtures.TempDir()).path
|
|
self.config_file = os.path.join(tmpdir, 'rootwrap.conf')
|
|
self.later_cmd = os.path.join(tmpdir, 'later_install_cmd')
|
|
filters_dir = os.path.join(tmpdir, 'filters.d')
|
|
filters_file = os.path.join(tmpdir, 'filters.d', 'test.filters')
|
|
os.mkdir(filters_dir)
|
|
with open(self.config_file, 'w') as f:
|
|
f.write("""[DEFAULT]
|
|
filters_path=%s
|
|
daemon_timeout=10
|
|
exec_dirs=/bin""" % (filters_dir,))
|
|
with open(filters_file, 'w') as f:
|
|
f.write("""[Filters]
|
|
echo: CommandFilter, /bin/echo, root
|
|
cat: CommandFilter, /bin/cat, root
|
|
sh: CommandFilter, /bin/sh, root
|
|
id: CommandFilter, /usr/bin/id, nobody
|
|
unknown_cmd: CommandFilter, /unknown/unknown_cmd, root
|
|
later_install_cmd: CommandFilter, %s, root
|
|
""" % self.later_cmd)
|
|
|
|
def _test_run_once(self, expect_byte=True):
|
|
code, out, err = self.execute(['echo', 'teststr'])
|
|
self.assertEqual(0, code)
|
|
if expect_byte:
|
|
expect_out = b'teststr\n'
|
|
expect_err = b''
|
|
else:
|
|
expect_out = 'teststr\n'
|
|
expect_err = ''
|
|
self.assertEqual(expect_out, out)
|
|
self.assertEqual(expect_err, err)
|
|
|
|
def _test_run_with_stdin(self, expect_byte=True):
|
|
code, out, err = self.execute(['cat'], stdin=b'teststr')
|
|
self.assertEqual(0, code)
|
|
if expect_byte:
|
|
expect_out = b'teststr'
|
|
expect_err = b''
|
|
else:
|
|
expect_out = 'teststr'
|
|
expect_err = ''
|
|
self.assertEqual(expect_out, out)
|
|
self.assertEqual(expect_err, err)
|
|
|
|
def test_run_command_not_found(self):
|
|
code, out, err = self.execute(['unknown_cmd'])
|
|
self.assertEqual(cmd.RC_NOEXECFOUND, code)
|
|
|
|
def test_run_unauthorized_command(self):
|
|
code, out, err = self.execute(['unauthorized_cmd'])
|
|
self.assertEqual(cmd.RC_UNAUTHORIZED, code)
|
|
|
|
def test_run_as(self):
|
|
if os.getuid() != 0:
|
|
self.skip('Test requires root (for setuid)')
|
|
|
|
# Should run as 'nobody'
|
|
code, out, err = self.execute(['id', '-u'])
|
|
self.assertEqual('%s\n' % pwd.getpwnam('nobody').pw_uid, out)
|
|
|
|
# Should run as 'root'
|
|
code, out, err = self.execute(['sh', '-c', 'id -u'])
|
|
self.assertEqual('0\n', out)
|
|
|
|
|
|
class RootwrapTest(_FunctionalBase, testtools.TestCase):
|
|
def setUp(self):
|
|
super(RootwrapTest, self).setUp()
|
|
self.cmd = [
|
|
sys.executable, '-c',
|
|
'from oslo_rootwrap import cmd; cmd.main()',
|
|
self.config_file]
|
|
|
|
def execute(self, cmd, stdin=None):
|
|
proc = subprocess.Popen(
|
|
self.cmd + cmd,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
out, err = proc.communicate(stdin)
|
|
self.addDetail('stdout',
|
|
content.text_content(out.decode('utf-8', 'replace')))
|
|
self.addDetail('stderr',
|
|
content.text_content(err.decode('utf-8', 'replace')))
|
|
return proc.returncode, out, err
|
|
|
|
def test_run_once(self):
|
|
self._test_run_once(expect_byte=True)
|
|
|
|
def test_run_with_stdin(self):
|
|
self._test_run_with_stdin(expect_byte=True)
|
|
|
|
|
|
class RootwrapDaemonTest(_FunctionalBase, testtools.TestCase):
|
|
def assert_unpatched(self):
|
|
# We need to verify that these tests are run without eventlet patching
|
|
if eventlet and eventlet.patcher.is_monkey_patched('socket'):
|
|
self.fail("Standard library should not be patched by eventlet"
|
|
" for this test")
|
|
|
|
def setUp(self):
|
|
self.assert_unpatched()
|
|
|
|
super(RootwrapDaemonTest, self).setUp()
|
|
|
|
# Collect daemon logs
|
|
daemon_log = io.BytesIO()
|
|
p = mock.patch('oslo_rootwrap.subprocess.Popen',
|
|
run_daemon.forwarding_popen(daemon_log))
|
|
p.start()
|
|
self.addCleanup(p.stop)
|
|
|
|
# Collect client logs
|
|
client_log = six.StringIO()
|
|
handler = logging.StreamHandler(client_log)
|
|
log_format = run_daemon.log_format.replace('+', ' ')
|
|
handler.setFormatter(logging.Formatter(log_format))
|
|
logger = logging.getLogger('oslo_rootwrap')
|
|
logger.addHandler(handler)
|
|
logger.setLevel(logging.DEBUG)
|
|
self.addCleanup(logger.removeHandler, handler)
|
|
|
|
# Add all logs as details
|
|
@self.addCleanup
|
|
def add_logs():
|
|
self.addDetail('daemon_log', content.Content(
|
|
content.UTF8_TEXT,
|
|
lambda: [daemon_log.getvalue()]))
|
|
self.addDetail('client_log', content.Content(
|
|
content.UTF8_TEXT,
|
|
lambda: [client_log.getvalue().encode('utf-8')]))
|
|
|
|
# Create client
|
|
self.client = client.Client([
|
|
sys.executable, run_daemon.__file__,
|
|
self.config_file])
|
|
|
|
# _finalize is set during Client.execute()
|
|
@self.addCleanup
|
|
def finalize_client():
|
|
if self.client._initialized:
|
|
self.client._finalize()
|
|
|
|
self.execute = self.client.execute
|
|
|
|
def test_run_once(self):
|
|
self._test_run_once(expect_byte=False)
|
|
|
|
def test_run_with_stdin(self):
|
|
self._test_run_with_stdin(expect_byte=False)
|
|
|
|
def test_run_with_later_install_cmd(self):
|
|
code, out, err = self.execute(['later_install_cmd'])
|
|
self.assertEqual(cmd.RC_NOEXECFOUND, code)
|
|
# Install cmd and try again
|
|
shutil.copy('/bin/echo', self.later_cmd)
|
|
code, out, err = self.execute(['later_install_cmd'])
|
|
# Expect successfully run the cmd
|
|
self.assertEqual(0, code)
|
|
|
|
def test_daemon_ressurection(self):
|
|
# Let the client start a daemon
|
|
self.execute(['cat'])
|
|
# Make daemon go away
|
|
os.kill(self.client._process.pid, signal.SIGTERM)
|
|
# Expect client to successfully restart daemon and run simple request
|
|
self.test_run_once()
|
|
|
|
def test_daemon_timeout(self):
|
|
# Let the client start a daemon
|
|
self.execute(['echo'])
|
|
# Make daemon timeout
|
|
with mock.patch.object(self.client, '_restart') as restart:
|
|
time.sleep(15)
|
|
self.execute(['echo'])
|
|
restart.assert_called_once()
|
|
|
|
def _exec_thread(self, fifo_path):
|
|
try:
|
|
# Run a shell script that signals calling process through FIFO and
|
|
# then hangs around for 1 sec
|
|
self._thread_res = self.execute([
|
|
'sh', '-c', 'echo > "%s"; sleep 1; echo OK' % fifo_path])
|
|
except Exception as e:
|
|
self._thread_res = e
|
|
|
|
def test_graceful_death(self):
|
|
# Create a fifo in a temporary dir
|
|
tmpdir = self.useFixture(fixtures.TempDir()).path
|
|
fifo_path = os.path.join(tmpdir, 'fifo')
|
|
os.mkfifo(fifo_path)
|
|
# Start daemon
|
|
self.execute(['cat'])
|
|
# Begin executing shell script
|
|
t = threading.Thread(target=self._exec_thread, args=(fifo_path,))
|
|
t.start()
|
|
# Wait for shell script to actually start
|
|
with open(fifo_path) as f:
|
|
f.readline()
|
|
# Gracefully kill daemon process
|
|
os.kill(self.client._process.pid, signal.SIGTERM)
|
|
# Expect daemon to wait for our request to finish
|
|
t.join()
|
|
if isinstance(self._thread_res, Exception):
|
|
raise self._thread_res # Python 3 will even provide nice traceback
|
|
code, out, err = self._thread_res
|
|
self.assertEqual(0, code)
|
|
self.assertEqual('OK\n', out)
|
|
self.assertEqual('', err)
|
|
|
|
@contextlib.contextmanager
|
|
def _test_daemon_cleanup(self):
|
|
# Start a daemon
|
|
self.execute(['cat'])
|
|
socket_path = self.client._manager._address
|
|
# Stop it one way or another
|
|
yield
|
|
process = self.client._process
|
|
stop = threading.Event()
|
|
|
|
# Start background thread that would kill process in 1 second if it
|
|
# doesn't die by then
|
|
def sleep_kill():
|
|
stop.wait(1)
|
|
if not stop.is_set():
|
|
os.kill(process.pid, signal.SIGKILL)
|
|
threading.Thread(target=sleep_kill).start()
|
|
# Wait for process to finish one way or another
|
|
self.client._process.wait()
|
|
# Notify background thread that process is dead (no need to kill it)
|
|
stop.set()
|
|
# Fail if the process got killed by the background thread
|
|
self.assertNotEqual(-signal.SIGKILL, process.returncode,
|
|
"Server haven't stopped in one second")
|
|
# Verify that socket is deleted
|
|
self.assertFalse(os.path.exists(socket_path),
|
|
"Server didn't remove its temporary directory")
|
|
|
|
def test_daemon_cleanup_client(self):
|
|
# Run _test_daemon_cleanup stopping daemon as Client instance would
|
|
# normally do
|
|
with self._test_daemon_cleanup():
|
|
self.client._finalize()
|
|
|
|
def test_daemon_cleanup_signal(self):
|
|
# Run _test_daemon_cleanup stopping daemon with SIGTERM signal
|
|
with self._test_daemon_cleanup():
|
|
os.kill(self.client._process.pid, signal.SIGTERM)
|