Merge pull request #984 from minrk/spawner-shell

allow customization of spawn command
This commit is contained in:
Carol Willing
2017-02-17 10:43:31 -08:00
committed by GitHub
5 changed files with 124 additions and 33 deletions

View File

@@ -2,6 +2,7 @@
mock mock
codecov codecov
pytest-cov pytest-cov
pytest-tornado
pytest>=2.8 pytest>=2.8
notebook notebook
requests-mock requests-mock

View File

@@ -258,7 +258,6 @@ class Spawner(LoggingConfigurable):
@validate('notebook_dir', 'default_url') @validate('notebook_dir', 'default_url')
def _deprecate_percent_u(self, proposal): def _deprecate_percent_u(self, proposal):
print(proposal)
v = proposal['value'] v = proposal['value']
if '%U' in v: if '%U' in v:
self.log.warning("%%U for username in %s is deprecated in JupyterHub 0.7, use {username}", self.log.warning("%%U for username in %s is deprecated in JupyterHub 0.7, use {username}",
@@ -708,6 +707,37 @@ class LocalProcessSpawner(Spawner):
""" """
).tag(config=True) ).tag(config=True)
popen_kwargs = Dict(
help="""Extra keyword arguments to pass to Popen
when spawning single-user servers.
For example::
popen_kwargs = dict(shell=True)
"""
).tag(config=True)
shell_cmd = Command(minlen=0,
help="""Specify a shell command to launch.
The single-user command will be appended to this list,
so it sould end with `-c` (for bash) or equivalent.
For example::
c.LocalProcessSpawner.shell_cmd = ['bash', '-l', '-c']
to launch with a bash login shell, which would set up the user's own complete environment.
.. warning::
Using shell_cmd gives users control over PATH, etc.,
which could change what the jupyterhub-singleuser launch command does.
Only use this for trusted users.
"""
)
proc = Instance(Popen, proc = Instance(Popen,
allow_none=True, allow_none=True,
help=""" help="""
@@ -782,12 +812,22 @@ class LocalProcessSpawner(Spawner):
cmd.extend(self.cmd) cmd.extend(self.cmd)
cmd.extend(self.get_args()) cmd.extend(self.get_args())
if self.shell_cmd:
# using shell_cmd (e.g. bash -c),
# add our cmd list as the last (single) argument:
cmd = self.shell_cmd + [' '.join(pipes.quote(s) for s in cmd)]
self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd)) self.log.info("Spawning %s", ' '.join(pipes.quote(s) for s in cmd))
try:
self.proc = Popen(cmd, env=env, popen_kwargs = dict(
preexec_fn=self.make_preexec_fn(self.user.name), preexec_fn=self.make_preexec_fn(self.user.name),
start_new_session=True, # don't forward signals start_new_session=True, # don't forward signals
) )
popen_kwargs.update(self.popen_kwargs)
# don't let user config override env
popen_kwargs['env'] = env
try:
self.proc = Popen(cmd, **popen_kwargs)
except PermissionError: except PermissionError:
# use which to get abspath # use which to get abspath
script = shutil.which(cmd[0]) or cmd[0] script = shutil.which(cmd[0]) or cmd[0]

View File

@@ -27,7 +27,7 @@ def db():
"""Get a db session""" """Get a db session"""
global _db global _db
if _db is None: if _db is None:
_db = orm.new_session_factory('sqlite:///:memory:', echo=True)() _db = orm.new_session_factory('sqlite:///:memory:')()
user = orm.User( user = orm.User(
name=getuser(), name=getuser(),
) )

View File

@@ -9,7 +9,7 @@ import json
import sys import sys
from tornado import web, httpserver, ioloop from tornado import web, httpserver, ioloop
from .mockservice import EnvHandler
class EchoHandler(web.RequestHandler): class EchoHandler(web.RequestHandler):
def get(self): def get(self):
@@ -23,6 +23,7 @@ def main(args):
app = web.Application([ app = web.Application([
(r'.*/args', ArgsHandler), (r'.*/args', ArgsHandler),
(r'.*/env', EnvHandler),
(r'.*', EchoHandler), (r'.*', EchoHandler),
]) ])

View File

@@ -6,11 +6,14 @@
import logging import logging
import os import os
import signal import signal
from subprocess import Popen
import sys import sys
import tempfile import tempfile
import time import time
from unittest import mock from unittest import mock
import pytest
import requests
from tornado import gen from tornado import gen
from .. import spawner as spawnermod from .. import spawner as spawnermod
@@ -50,9 +53,10 @@ def new_spawner(db, **kwargs):
return LocalProcessSpawner(db=db, **kwargs) return LocalProcessSpawner(db=db, **kwargs)
def test_spawner(db, io_loop): @pytest.mark.gen_test
def test_spawner(db, request):
spawner = new_spawner(db) spawner = new_spawner(db)
ip, port = io_loop.run_sync(spawner.start) ip, port = yield spawner.start()
assert ip == '127.0.0.1' assert ip == '127.0.0.1'
assert isinstance(port, int) assert isinstance(port, int)
assert port > 0 assert port > 0
@@ -60,44 +64,53 @@ def test_spawner(db, io_loop):
spawner.user.server.port = port spawner.user.server.port = port
db.commit() db.commit()
# wait for the process to get to the while True: loop # wait for the process to get to the while True: loop
time.sleep(1) time.sleep(1)
status = io_loop.run_sync(spawner.poll) status = yield spawner.poll()
assert status is None assert status is None
io_loop.run_sync(spawner.stop) yield spawner.stop()
status = io_loop.run_sync(spawner.poll) status = yield spawner.poll()
assert status == 1 assert status == 1
def test_single_user_spawner(db, io_loop):
@gen.coroutine
def wait_for_spawner(spawner, timeout=10):
"""Wait for an http server to show up
polling at shorter intervals for early termination
"""
deadline = time.monotonic() + timeout
def wait():
return spawner.user.server.wait_up(timeout=1, http=True)
while time.monotonic() < deadline:
status = yield spawner.poll()
assert status is None
try:
yield wait()
except TimeoutError:
continue
else:
break
yield wait()
@pytest.mark.gen_test(run_sync=False)
def test_single_user_spawner(db, request):
spawner = new_spawner(db, cmd=['jupyterhub-singleuser']) spawner = new_spawner(db, cmd=['jupyterhub-singleuser'])
spawner.api_token = 'secret' spawner.api_token = 'secret'
ip, port = io_loop.run_sync(spawner.start) ip, port = yield spawner.start()
assert ip == '127.0.0.1' assert ip == '127.0.0.1'
assert isinstance(port, int) assert isinstance(port, int)
assert port > 0 assert port > 0
spawner.user.server.ip = ip spawner.user.server.ip = ip
spawner.user.server.port = port spawner.user.server.port = port
db.commit() db.commit()
# wait for http server to come up, yield wait_for_spawner(spawner)
# checking for early termination every 1s status = yield spawner.poll()
def wait():
return spawner.user.server.wait_up(timeout=1, http=True)
for i in range(30):
status = io_loop.run_sync(spawner.poll)
assert status is None assert status is None
try: yield spawner.stop()
io_loop.run_sync(wait) status = yield spawner.poll()
except TimeoutError:
continue
else:
break
io_loop.run_sync(wait)
status = io_loop.run_sync(spawner.poll)
assert status == None
io_loop.run_sync(spawner.stop)
status = io_loop.run_sync(spawner.poll)
assert status == 0 assert status == 0
@@ -192,3 +205,39 @@ def test_string_formatting(db):
assert s.format_string(s.notebook_dir) == 'user/%s/' % name assert s.format_string(s.notebook_dir) == 'user/%s/' % name
assert s.format_string(s.default_url) == '/base/%s' % name assert s.format_string(s.default_url) == '/base/%s' % name
@pytest.mark.gen_test
def test_popen_kwargs(db):
mock_proc = mock.Mock(spec=Popen)
def mock_popen(*args, **kwargs):
mock_proc.args = args
mock_proc.kwargs = kwargs
mock_proc.pid = 5
return mock_proc
s = new_spawner(db, popen_kwargs={'shell': True}, cmd='jupyterhub-singleuser')
with mock.patch.object(spawnermod, 'Popen', mock_popen):
yield s.start()
assert mock_proc.kwargs['shell'] == True
assert mock_proc.args[0][:1] == (['jupyterhub-singleuser'])
@pytest.mark.gen_test
def test_shell_cmd(db, tmpdir, request):
f = tmpdir.join('bashrc')
f.write('export TESTVAR=foo\n')
s = new_spawner(db,
cmd=[sys.executable, '-m', 'jupyterhub.tests.mocksu'],
shell_cmd=['bash', '--rcfile', str(f), '-i', '-c'],
)
(ip, port) = yield s.start()
request.addfinalizer(s.stop)
s.user.server.ip = ip
s.user.server.port = port
db.commit()
yield wait_for_spawner(s)
r = requests.get('http://%s:%i/env' % (ip, port))
r.raise_for_status()
env = r.json()
assert env['TESTVAR'] == 'foo'