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 -%} {% endif -%}
# Add entrypoint # Add entrypoint
ENV PYTHONUNBUFFERED=1
COPY /repo2docker-entrypoint /usr/local/bin/repo2docker-entrypoint COPY /repo2docker-entrypoint /usr/local/bin/repo2docker-entrypoint
ENTRYPOINT ["/usr/local/bin/repo2docker-entrypoint"] ENTRYPOINT ["/usr/local/bin/repo2docker-entrypoint"]

Wyświetl plik

@ -1,24 +1,150 @@
#!/bin/bash -l #!/usr/bin/env python3
# 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)
# Setup a file descriptor (FD) that is connected to a tee process which # goals:
# writes its input to $REPO_DIR/.jupyter-server-log.txt # - load environment variables from a login shell (bash -l)
# We later use this FD as a place to redirect the output of the actual # - preserve signal handling of subprocess (kill -TERM and friends)
# command to. We can't add `tee` to the command directly as that will prevent # - tee output to a log file
# 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)
if [[ ! -z "${R2D_ENTRYPOINT:-}" ]]; then import json
if [[ ! -x "$R2D_ENTRYPOINT" ]]; then import os
chmod u+x "$R2D_ENTRYPOINT" import signal
fi import subprocess
exec "$R2D_ENTRYPOINT" "$@" 2>&1 >&"$log_fd" import sys
else import time
exec "$@" 2>&1 >&"$log_fd"
fi
# 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 os
import subprocess import subprocess
import sys
import tempfile import tempfile
import time import time
from getpass import getuser from getpass import getuser
def test_env(): def test_env(capfd):
""" """
Validate that you can define environment variables Validate that you can define environment variables
@ -42,20 +43,19 @@ def test_env():
# value # value
"--env", "--env",
"SPAM_2=", "SPAM_2=",
# "--",
tmpdir, tmpdir,
"--",
"/bin/bash", "/bin/bash",
"-c", "-c",
# Docker exports all passed env variables, so we can # Docker exports all passed env variables, so we can
# just look at exported variables. # just look at exported variables.
"export; sleep 1", "export",
# "export; echo TIMDONE",
# "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 assert result.returncode == 0
# all docker output is returned by repo2docker on stderr # all docker output is returned by repo2docker on stderr
@ -63,11 +63,8 @@ def test_env():
# stdout should be empty # stdout should be empty
assert not result.stdout assert not result.stdout
print(result.stderr.split("\n"))
# assert False
# stderr should contain lines of output # 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 FOO="{}"'.format(ts) in declares
assert 'declare -x BAR="baz"' in declares assert 'declare -x BAR="baz"' in declares
assert 'declare -x SPAM="eggs"' in declares assert 'declare -x SPAM="eggs"' in declares