Skip to content

Commit ebf640c

Browse files
committed
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?)
1 parent e862630 commit ebf640c

File tree

3 files changed

+160
-36
lines changed

3 files changed

+160
-36
lines changed

repo2docker/buildpacks/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@
181181
{% endif -%}
182182
183183
# Add entrypoint
184+
ENV PYTHONUNBUFFERED=1
184185
COPY /repo2docker-entrypoint /usr/local/bin/repo2docker-entrypoint
185186
ENTRYPOINT ["/usr/local/bin/repo2docker-entrypoint"]
186187
Lines changed: 150 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,150 @@
1-
#!/bin/bash -l
2-
# lightest possible entrypoint that ensures that
3-
# we use a login shell to get a fully configured shell environment
4-
# (e.g. sourcing /etc/profile.d, ~/.bashrc, and friends)
5-
6-
# Setup a file descriptor (FD) that is connected to a tee process which
7-
# writes its input to $REPO_DIR/.jupyter-server-log.txt
8-
# We later use this FD as a place to redirect the output of the actual
9-
# command to. We can't add `tee` to the command directly as that will prevent
10-
# the container from exiting when `docker stop` is run.
11-
# See https://stackoverflow.com/a/55678435
12-
exec {log_fd}> >(exec tee $REPO_DIR/.jupyter-server-log.txt)
13-
14-
if [[ ! -z "${R2D_ENTRYPOINT:-}" ]]; then
15-
if [[ ! -x "$R2D_ENTRYPOINT" ]]; then
16-
chmod u+x "$R2D_ENTRYPOINT"
17-
fi
18-
exec "$R2D_ENTRYPOINT" "$@" 2>&1 >&"$log_fd"
19-
else
20-
exec "$@" 2>&1 >&"$log_fd"
21-
fi
22-
23-
# Close the logging output again
24-
exec {log_fd}>&-
1+
#!/usr/bin/env python3
2+
3+
# goals:
4+
# - load environment variables from a login shell (bash -l)
5+
# - preserve signal handling of subprocess (kill -TERM and friends)
6+
# - tee output to a log file
7+
8+
import json
9+
import os
10+
import signal
11+
import subprocess
12+
import sys
13+
import time
14+
15+
16+
def get_login_env():
17+
"""Instantiate a login shell to retrieve environment variables
18+
19+
Serialize with Python to ensure proper escapes
20+
"""
21+
p = subprocess.run(
22+
[
23+
"bash",
24+
"-l",
25+
"-c",
26+
"python3 -c 'import os, json; print(json.dumps(dict(os.environ)))'",
27+
],
28+
stdout=subprocess.PIPE,
29+
)
30+
if p.returncode:
31+
print("Error getting login env")
32+
return {}
33+
34+
last_line = p.stdout.splitlines()[-1]
35+
try:
36+
return json.loads(last_line)
37+
except Exception as e:
38+
print(f"Error getting login env: {e}", file=sys.stderr)
39+
return {}
40+
41+
42+
def monitor_parent(parent_pid, child_pgid):
43+
"""Monitor parent_pid and shutdown child_pgid if parent goes away first"""
44+
while True:
45+
try:
46+
os.kill(parent_pid, 0)
47+
except ProcessLookupError:
48+
# parent is gone, likely by SIGKILL
49+
# send SIGKILL to child process group
50+
try:
51+
os.killpg(child_pgid, signal.SIGKILL)
52+
except (ProcessLookupError, PermissionError):
53+
# ignore if the child is already gone
54+
pass
55+
return
56+
else:
57+
time.sleep(1)
58+
59+
60+
# signals to be forwarded to the child
61+
SIGNALS = [
62+
signal.SIGHUP,
63+
signal.SIGINT,
64+
# signal.SIGKILL,
65+
signal.SIGQUIT,
66+
signal.SIGTERM,
67+
signal.SIGUSR1,
68+
signal.SIGUSR2,
69+
signal.SIGWINCH,
70+
]
71+
72+
73+
def main():
74+
75+
# load login shell environment
76+
login_env = get_login_env()
77+
env = os.environ.copy()
78+
env.update(login_env)
79+
80+
# open log file to send output
81+
log_file = open(
82+
os.path.join(os.environ.get("REPO_DIR", "."), ".jupyter-server-log.txt"),
83+
"a",
84+
)
85+
86+
child = subprocess.Popen(
87+
sys.argv[1:],
88+
bufsize=1,
89+
env=env,
90+
start_new_session=True,
91+
stdout=subprocess.PIPE,
92+
stderr=subprocess.STDOUT,
93+
universal_newlines=True,
94+
)
95+
child_pgid = os.getpgid(child.pid)
96+
97+
# if parent is forcefully shutdown,
98+
# make sure child shuts down immediately as well
99+
parent_pid = os.getpid()
100+
101+
monitor_pid = os.fork()
102+
if monitor_pid == 0:
103+
# child process, sibling of 'real' command
104+
# avoid receiving signals sent to parent
105+
os.setpgrp()
106+
# terminate child if parent goes away,
107+
# e.g. in ungraceful KILL not relayed to children
108+
monitor_parent(parent_pid, child_pgid)
109+
return
110+
111+
# hook up ~all signals so that every signal the parent gets,
112+
# the children also get
113+
114+
def relay_signal(sig, frame):
115+
"""Relay a signal to children"""
116+
print(f"Forwarding signal {sig} to {child_pgid}")
117+
os.killpg(child_pgid, sig)
118+
119+
# question: maybe use all valid_signals() except a few, e.g. SIGCHLD?
120+
# rather than opt-in list
121+
for signum in SIGNALS:
122+
signal.signal(signum, relay_signal)
123+
124+
# tee output from child to both our stdout and the log file
125+
def tee(chunk):
126+
for f in [sys.stdout, log_file]:
127+
f.write(chunk)
128+
f.flush()
129+
130+
while child.poll() is None:
131+
tee(child.stdout.readline())
132+
133+
# flush the rest
134+
chunk = child.stdout.read()
135+
while chunk:
136+
tee(chunk)
137+
chunk = child.stdout.read()
138+
139+
# child exited, cleanup monitor
140+
try:
141+
os.kill(monitor_pid, signal.SIGKILL)
142+
except ProcessLookupError:
143+
pass
144+
145+
# preserve returncode
146+
sys.exit(child.returncode)
147+
148+
149+
if __name__ == "__main__":
150+
main()

tests/unit/test_env.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
"""
44
import os
55
import subprocess
6+
import sys
67
import tempfile
78
import time
89
from getpass import getuser
910

1011

11-
def test_env():
12+
def test_env(capfd):
1213
"""
1314
Validate that you can define environment variables
1415
@@ -42,32 +43,28 @@ def test_env():
4243
# value
4344
"--env",
4445
"SPAM_2=",
45-
# "--",
4646
tmpdir,
47+
"--",
4748
"/bin/bash",
4849
"-c",
4950
# Docker exports all passed env variables, so we can
5051
# just look at exported variables.
51-
"export; sleep 1",
52-
# "export; echo TIMDONE",
53-
# "export",
52+
"export",
5453
],
55-
universal_newlines=True,
56-
stdout=subprocess.PIPE,
57-
stderr=subprocess.PIPE,
5854
)
55+
captured = capfd.readouterr()
56+
print(captured.out, end="")
57+
print(captured.err, file=sys.stderr, end="")
58+
5959
assert result.returncode == 0
6060

6161
# all docker output is returned by repo2docker on stderr
6262
# extract just the declare for better failure message formatting
6363
# stdout should be empty
6464
assert not result.stdout
6565

66-
print(result.stderr.split("\n"))
67-
# assert False
68-
6966
# stderr should contain lines of output
70-
declares = [x for x in result.stderr.split("\n") if x.startswith("declare")]
67+
declares = [x for x in captured.err.splitlines() if x.startswith("declare")]
7168
assert 'declare -x FOO="{}"'.format(ts) in declares
7269
assert 'declare -x BAR="baz"' in declares
7370
assert 'declare -x SPAM="eggs"' in declares

0 commit comments

Comments
 (0)