implement entrypoint in Python

- make sure to set PYTHONUNBUFFERED
- implement tee with subprocess readlines
- forward all relevant signals from entrypoint to 'true' command
- add monitor process to ensure child shuts down if parent does (maybe not useful in docker?)
pull/1014/head
Min RK 2021-02-17 12:08:43 +01:00
rodzic e862630ee1
commit ebf640cb84
3 zmienionych plików z 157 dodań i 33 usunięć

Wyświetl plik

@ -181,6 +181,7 @@ ENV R2D_ENTRYPOINT "{{ start_script }}"
{% endif -%}
# Add entrypoint
ENV PYTHONUNBUFFERED=1
COPY /repo2docker-entrypoint /usr/local/bin/repo2docker-entrypoint
ENTRYPOINT ["/usr/local/bin/repo2docker-entrypoint"]

Wyświetl plik

@ -1,24 +1,150 @@
#!/bin/bash -l
# lightest possible entrypoint that ensures that
# we use a login shell to get a fully configured shell environment
# (e.g. sourcing /etc/profile.d, ~/.bashrc, and friends)
#!/usr/bin/env python3
# Setup a file descriptor (FD) that is connected to a tee process which
# writes its input to $REPO_DIR/.jupyter-server-log.txt
# We later use this FD as a place to redirect the output of the actual
# command to. We can't add `tee` to the command directly as that will prevent
# the container from exiting when `docker stop` is run.
# See https://stackoverflow.com/a/55678435
exec {log_fd}> >(exec tee $REPO_DIR/.jupyter-server-log.txt)
# goals:
# - load environment variables from a login shell (bash -l)
# - preserve signal handling of subprocess (kill -TERM and friends)
# - tee output to a log file
if [[ ! -z "${R2D_ENTRYPOINT:-}" ]]; then
if [[ ! -x "$R2D_ENTRYPOINT" ]]; then
chmod u+x "$R2D_ENTRYPOINT"
fi
exec "$R2D_ENTRYPOINT" "$@" 2>&1 >&"$log_fd"
else
exec "$@" 2>&1 >&"$log_fd"
fi
import json
import os
import signal
import subprocess
import sys
import time
# Close the logging output again
exec {log_fd}>&-
def get_login_env():
"""Instantiate a login shell to retrieve environment variables
Serialize with Python to ensure proper escapes
"""
p = subprocess.run(
[
"bash",
"-l",
"-c",
"python3 -c 'import os, json; print(json.dumps(dict(os.environ)))'",
],
stdout=subprocess.PIPE,
)
if p.returncode:
print("Error getting login env")
return {}
last_line = p.stdout.splitlines()[-1]
try:
return json.loads(last_line)
except Exception as e:
print(f"Error getting login env: {e}", file=sys.stderr)
return {}
def monitor_parent(parent_pid, child_pgid):
"""Monitor parent_pid and shutdown child_pgid if parent goes away first"""
while True:
try:
os.kill(parent_pid, 0)
except ProcessLookupError:
# parent is gone, likely by SIGKILL
# send SIGKILL to child process group
try:
os.killpg(child_pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError):
# ignore if the child is already gone
pass
return
else:
time.sleep(1)
# signals to be forwarded to the child
SIGNALS = [
signal.SIGHUP,
signal.SIGINT,
# signal.SIGKILL,
signal.SIGQUIT,
signal.SIGTERM,
signal.SIGUSR1,
signal.SIGUSR2,
signal.SIGWINCH,
]
def main():
# load login shell environment
login_env = get_login_env()
env = os.environ.copy()
env.update(login_env)
# open log file to send output
log_file = open(
os.path.join(os.environ.get("REPO_DIR", "."), ".jupyter-server-log.txt"),
"a",
)
child = subprocess.Popen(
sys.argv[1:],
bufsize=1,
env=env,
start_new_session=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
universal_newlines=True,
)
child_pgid = os.getpgid(child.pid)
# if parent is forcefully shutdown,
# make sure child shuts down immediately as well
parent_pid = os.getpid()
monitor_pid = os.fork()
if monitor_pid == 0:
# child process, sibling of 'real' command
# avoid receiving signals sent to parent
os.setpgrp()
# terminate child if parent goes away,
# e.g. in ungraceful KILL not relayed to children
monitor_parent(parent_pid, child_pgid)
return
# hook up ~all signals so that every signal the parent gets,
# the children also get
def relay_signal(sig, frame):
"""Relay a signal to children"""
print(f"Forwarding signal {sig} to {child_pgid}")
os.killpg(child_pgid, sig)
# question: maybe use all valid_signals() except a few, e.g. SIGCHLD?
# rather than opt-in list
for signum in SIGNALS:
signal.signal(signum, relay_signal)
# tee output from child to both our stdout and the log file
def tee(chunk):
for f in [sys.stdout, log_file]:
f.write(chunk)
f.flush()
while child.poll() is None:
tee(child.stdout.readline())
# flush the rest
chunk = child.stdout.read()
while chunk:
tee(chunk)
chunk = child.stdout.read()
# child exited, cleanup monitor
try:
os.kill(monitor_pid, signal.SIGKILL)
except ProcessLookupError:
pass
# preserve returncode
sys.exit(child.returncode)
if __name__ == "__main__":
main()

Wyświetl plik

@ -3,12 +3,13 @@ Test that environment variables may be defined
"""
import os
import subprocess
import sys
import tempfile
import time
from getpass import getuser
def test_env():
def test_env(capfd):
"""
Validate that you can define environment variables
@ -42,20 +43,19 @@ def test_env():
# value
"--env",
"SPAM_2=",
# "--",
tmpdir,
"--",
"/bin/bash",
"-c",
# Docker exports all passed env variables, so we can
# just look at exported variables.
"export; sleep 1",
# "export; echo TIMDONE",
# "export",
"export",
],
universal_newlines=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
captured = capfd.readouterr()
print(captured.out, end="")
print(captured.err, file=sys.stderr, end="")
assert result.returncode == 0
# all docker output is returned by repo2docker on stderr
@ -63,11 +63,8 @@ def test_env():
# stdout should be empty
assert not result.stdout
print(result.stderr.split("\n"))
# assert False
# stderr should contain lines of output
declares = [x for x in result.stderr.split("\n") if x.startswith("declare")]
declares = [x for x in captured.err.splitlines() if x.startswith("declare")]
assert 'declare -x FOO="{}"'.format(ts) in declares
assert 'declare -x BAR="baz"' in declares
assert 'declare -x SPAM="eggs"' in declares