Make start.sh the entrypoint (#2087)

This commit is contained in:
Simon Li
2024-01-22 04:47:48 +00:00
committed by GitHub
parent b71f4cb525
commit 6e437aa489
21 changed files with 86 additions and 58 deletions

View File

@@ -225,21 +225,17 @@ docker run -it --rm \
### `start.sh` ### `start.sh`
The `start-notebook.py` script inherits most of its option handling capability from a more generic `start.sh` script. Most of the configuration options in the `start-notebook.py` script are handled by an internal `start.sh` script that automatically runs before the command provided to the container
The `start.sh` script supports all the features described above but allows you to specify an arbitrary command to execute. (it's set as the container entrypoint).
This allows you to specify an arbitrary command that takes advantage of all these features.
For example, to run the text-based `ipython` console in a container, do the following: For example, to run the text-based `ipython` console in a container, do the following:
```bash ```bash
docker run -it --rm quay.io/jupyter/base-notebook start.sh ipython docker run -it --rm quay.io/jupyter/base-notebook ipython
``` ```
This script is handy when you derive a new Dockerfile from this image and install additional Jupyter applications with subcommands like `jupyter console`, `jupyter kernelgateway`, etc. This script is handy when you derive a new Dockerfile from this image and install additional Jupyter applications with subcommands like `jupyter console`, `jupyter kernelgateway`, etc.
### Others
You can bypass the provided scripts and specify an arbitrary start command.
If you do, keep in mind that features, supported by the `start.sh` script and its kin, will not function (e.g., `GRANT_SUDO`).
## Conda Environments ## Conda Environments
The default Python 3.x [Conda environment](https://conda.io/projects/conda/en/latest/user-guide/concepts/environments.html) resides in `/opt/conda`. The default Python 3.x [Conda environment](https://conda.io/projects/conda/en/latest/user-guide/concepts/environments.html) resides in `/opt/conda`.

View File

@@ -34,8 +34,7 @@ It contains:
- [mamba](https://github.com/mamba-org/mamba): "reimplementation of the conda package manager in C++". We use this package manager by default when installing packages. - [mamba](https://github.com/mamba-org/mamba): "reimplementation of the conda package manager in C++". We use this package manager by default when installing packages.
- Unprivileged user `jovyan` (`uid=1000`, configurable, [see options in the common features section](./common.md) of this documentation) in group `users` (`gid=100`) - Unprivileged user `jovyan` (`uid=1000`, configurable, [see options in the common features section](./common.md) of this documentation) in group `users` (`gid=100`)
with ownership over the `/home/jovyan` and `/opt/conda` paths with ownership over the `/home/jovyan` and `/opt/conda` paths
- `tini` as the container entry point - `tini` and a `start.sh` script as the container entry point - useful for running alternative commands in the container as applications are added (e.g. `ipython`, `jupyter kernelgateway`, `jupyter lab`)
- A `start.sh` script as the default command - useful for running alternative commands in the container as applications are added (e.g. `ipython`, `jupyter kernelgateway`, `jupyter lab`)
- A `run-hooks.sh` script, which can source/run files in a given directory - A `run-hooks.sh` script, which can source/run files in a given directory
- Options for a passwordless sudo - Options for a passwordless sudo
- Common system libraries like `bzip2`, `ca-certificates`, `locales` - Common system libraries like `bzip2`, `ca-certificates`, `locales`

View File

@@ -14,8 +14,8 @@ if "JUPYTERHUB_API_TOKEN" in os.environ:
os.execvp(command[0], command) os.execvp(command[0], command)
# Wrap everything in start.sh, no matter what # Entrypoint is start.sh
command = ["/usr/local/bin/start.sh"] command = []
# If we want to survive restarts, tell that to start.sh # If we want to survive restarts, tell that to start.sh
if os.environ.get("RESTARTABLE") == "yes": if os.environ.get("RESTARTABLE") == "yes":
@@ -40,4 +40,5 @@ if "NOTEBOOK_ARGS" in os.environ:
command += sys.argv[1:] command += sys.argv[1:]
# Execute the command! # Execute the command!
print("Executing: " + " ".join(command))
os.execvp(command[0], command) os.execvp(command[0], command)

View File

@@ -5,7 +5,8 @@ import os
import shlex import shlex
import sys import sys
command = ["/usr/local/bin/start.sh", "jupyterhub-singleuser"] # Entrypoint is start.sh
command = ["jupyterhub-singleuser"]
# set default ip to 0.0.0.0 # set default ip to 0.0.0.0
if "--ip=" not in os.environ.get("NOTEBOOK_ARGS", ""): if "--ip=" not in os.environ.get("NOTEBOOK_ARGS", ""):
@@ -20,4 +21,5 @@ if "NOTEBOOK_ARGS" in os.environ:
command += sys.argv[1:] command += sys.argv[1:]
# Execute the command! # Execute the command!
print("Executing: " + " ".join(command))
os.execvp(command[0], command) os.execvp(command[0], command)

View File

@@ -124,8 +124,7 @@ RUN set -x && \
fix-permissions "/home/${NB_USER}" fix-permissions "/home/${NB_USER}"
# Configure container startup # Configure container startup
ENTRYPOINT ["tini", "-g", "--"] ENTRYPOINT ["tini", "-g", "--", "start.sh"]
CMD ["start.sh"]
# Copy local files as late as possible to avoid cache busting # Copy local files as late as possible to avoid cache busting
COPY run-hooks.sh start.sh /usr/local/bin/ COPY run-hooks.sh start.sh /usr/local/bin/

View File

@@ -34,6 +34,17 @@ else
cmd=( "$@" ) cmd=( "$@" )
fi fi
# Backwards compatibility: `start.sh` is executed by default in ENTRYPOINT
# so it should no longer be specified in CMD
if [ "${_START_SH_EXECUTED}" = "1" ]; then
_log "WARNING: start.sh is the default ENTRYPOINT, do not include it in CMD"
_log "Executing the command:" "${cmd[@]}"
exec "${cmd[@]}"
else
export _START_SH_EXECUTED=1
fi
# NOTE: This hook will run as the user the container was started with! # NOTE: This hook will run as the user the container was started with!
# shellcheck disable=SC1091 # shellcheck disable=SC1091
source /usr/local/bin/run-hooks.sh /usr/local/bin/start-notebook.d source /usr/local/bin/run-hooks.sh /usr/local/bin/start-notebook.d

View File

@@ -11,10 +11,14 @@ def check_r_mimetypes(container: TrackedContainer) -> None:
"""Check if Rscript command can be executed""" """Check if Rscript command can be executed"""
LOGGER.info("Test that R command can be executed ...") LOGGER.info("Test that R command can be executed ...")
R_MIMETYPES_CHECK_CMD = 'if (length(getOption("jupyter.plot_mimetypes")) != 5) {stop("missing jupyter.plot_mimetypes")}' R_MIMETYPES_CHECK_CMD = 'if (length(getOption("jupyter.plot_mimetypes")) != 5) {stop("missing jupyter.plot_mimetypes")}'
command = ["Rscript", "-e", R_MIMETYPES_CHECK_CMD]
logs = container.run_and_wait( logs = container.run_and_wait(
timeout=10, timeout=10,
tty=True, tty=True,
command=["Rscript", "-e", R_MIMETYPES_CHECK_CMD], command=command,
) )
LOGGER.debug(f"{logs=}") LOGGER.debug(f"{logs=}")
assert len(logs) == 0, f"Command {R_MIMETYPES_CHECK_CMD=} failed" # If there is any output after this it means there was an error
assert logs.splitlines()[-1] == "Executing the command: " + " ".join(
command
), f"Command {R_MIMETYPES_CHECK_CMD=} failed"

View File

@@ -33,7 +33,7 @@ def test_nbconvert(container: TrackedContainer, test_file: str) -> None:
timeout=60, timeout=60,
volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["bash", "-c", command],
) )
expected_file = f"{output_dir}/{test_file}.md" expected_file = f"{output_dir}/{test_file}.md"

View File

@@ -35,7 +35,7 @@ def test_nb_user_change(container: TrackedContainer) -> None:
tty=True, tty=True,
user="root", user="root",
environment=[f"NB_USER={nb_user}", "CHOWN_HOME=yes"], environment=[f"NB_USER={nb_user}", "CHOWN_HOME=yes"],
command=["start.sh", "bash", "-c", "sleep infinity"], command=["bash", "-c", "sleep infinity"],
) )
# Give the chown time to complete. # Give the chown time to complete.

View File

@@ -12,6 +12,6 @@ def test_pandoc(container: TrackedContainer) -> None:
logs = container.run_and_wait( logs = container.run_and_wait(
timeout=10, timeout=10,
tty=True, tty=True,
command=["start.sh", "bash", "-c", 'echo "**BOLD**" | pandoc'], command=["bash", "-c", 'echo "**BOLD**" | pandoc'],
) )
assert "<p><strong>BOLD</strong></p>" in logs assert "<p><strong>BOLD</strong></p>" in logs

View File

@@ -53,7 +53,7 @@ def test_start_notebook(
LOGGER.debug(logs) LOGGER.debug(logs)
# checking that the expected command is launched # checking that the expected command is launched
assert ( assert (
f"Executing the command: {expected_command}" in logs f"Executing: {expected_command}" in logs
), f"Not the expected command ({expected_command}) was launched" ), f"Not the expected command ({expected_command}) was launched"
# checking errors and warnings in logs # checking errors and warnings in logs
assert "ERROR" not in logs, "ERROR(s) found in logs" assert "ERROR" not in logs, "ERROR(s) found in logs"
@@ -76,10 +76,7 @@ def test_tini_entrypoint(
https://superuser.com/questions/632979/if-i-know-the-pid-number-of-a-process-how-can-i-get-its-name https://superuser.com/questions/632979/if-i-know-the-pid-number-of-a-process-how-can-i-get-its-name
""" """
LOGGER.info(f"Test that {command} is launched as PID {pid} ...") LOGGER.info(f"Test that {command} is launched as PID {pid} ...")
running_container = container.run_detached( running_container = container.run_detached(tty=True)
tty=True,
command=["start.sh"],
)
# Select the PID 1 and get the corresponding command # Select the PID 1 and get the corresponding command
cmd = running_container.exec_run(f"ps -p {pid} -o comm=") cmd = running_container.exec_run(f"ps -p {pid} -o comm=")
output = cmd.output.decode("utf-8").strip("\n") output = cmd.output.decode("utf-8").strip("\n")

View File

@@ -21,17 +21,20 @@ Use `package_helper.installed_packages()` instead of `package_helper.requested_p
Example: Example:
$ make test/base-notebook $ make test/docker-stacks-foundation
# [...] # [...]
# tests/base-notebook/test_packages.py::test_python_packages # tests/docker-stacks-foundation/test_packages.py::test_python_packages
# ---------------------------------------------------------------------------------------------- live log setup ---------------------------------------------------------------------------------------------- # -------------------------------- live log setup --------------------------------
# 2023-11-04 23:59:01 [ INFO] Starting container quay.io/jupyter/base-notebook ... (package_helper.py:55) # 2024-01-21 17:46:43 [ INFO] Starting container quay.io/jupyter/docker-stacks-foundation ... (package_helper.py:55)
# 2023-11-04 23:59:01 [ INFO] Running quay.io/jupyter/base-notebook with args {'detach': True, 'tty': True, 'command': ['start.sh', 'bash', '-c', 'sleep infinity']} ... (conftest.py:99) # 2024-01-21 17:46:43 [ INFO] Running quay.io/jupyter/docker-stacks-foundation with args {'detach': True, 'tty': True, 'command': ['bash', '-c', 'sleep infinity']} ... (conftest.py:99)
# 2023-11-04 23:59:01 [ INFO] Grabbing the list of manually requested packages ... (package_helper.py:83) # 2024-01-21 17:46:44 [ INFO] Grabbing the list of manually requested packages ... (package_helper.py:83)
# ---------------------------------------------------------------------------------------------- live log call ----------------------------------------------------------------------------------------------- # -------------------------------- live log call ---------------------------------
# 2023-11-04 23:59:02 [ INFO] Testing the import of packages ... (test_packages.py:152) # 2024-01-21 17:46:44 [ INFO] Testing the import of packages ... (test_packages.py:151)
# 2023-11-04 23:59:02 [ INFO] Trying to import mamba (test_packages.py:154) # 2024-01-21 17:46:44 [ INFO] Trying to import mamba (test_packages.py:153)
# 2024-01-21 17:46:44 [ INFO] Trying to import jupyter_core (test_packages.py:153)
PASSED [ 17%]
# ------------------------------ live log teardown -------------------------------
# [...] # [...]
""" """

View File

@@ -17,8 +17,8 @@ def test_python_version(container: TrackedContainer) -> None:
tty=True, tty=True,
command=["python", "--version"], command=["python", "--version"],
) )
assert logs.startswith("Python ") python = next(line for line in logs.splitlines() if line.startswith("Python "))
full_version = logs.split()[1] full_version = python.split()[1]
major_minor_version = full_version[: full_version.rfind(".")] major_minor_version = full_version[: full_version.rfind(".")]
assert major_minor_version == EXPECTED_PYTHON_VERSION assert major_minor_version == EXPECTED_PYTHON_VERSION
@@ -31,4 +31,4 @@ def test_python_pinned_version(container: TrackedContainer) -> None:
tty=True, tty=True,
command=["cat", "/opt/conda/conda-meta/pinned"], command=["cat", "/opt/conda/conda-meta/pinned"],
) )
assert logs.startswith(f"python {EXPECTED_PYTHON_VERSION}.*") assert f"python {EXPECTED_PYTHON_VERSION}.*" in logs

View File

@@ -34,5 +34,5 @@ def test_units(container: TrackedContainer) -> None:
timeout=30, timeout=30,
volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "python", f"{cont_data_dir}/{test_file_name}"], command=["python", f"{cont_data_dir}/{test_file_name}"],
) )

View File

@@ -18,7 +18,7 @@ def test_uid_change(container: TrackedContainer) -> None:
tty=True, tty=True,
user="root", user="root",
environment=["NB_UID=1010"], environment=["NB_UID=1010"],
command=["start.sh", "bash", "-c", "id && touch /opt/conda/test-file"], command=["bash", "-c", "id && touch /opt/conda/test-file"],
) )
assert "uid=1010(jovyan)" in logs assert "uid=1010(jovyan)" in logs
@@ -30,7 +30,7 @@ def test_gid_change(container: TrackedContainer) -> None:
tty=True, tty=True,
user="root", user="root",
environment=["NB_GID=110"], environment=["NB_GID=110"],
command=["start.sh", "id"], command=["id"],
) )
assert "gid=110(jovyan)" in logs assert "gid=110(jovyan)" in logs
assert "groups=110(jovyan),100(users)" in logs assert "groups=110(jovyan),100(users)" in logs
@@ -43,7 +43,7 @@ def test_nb_user_change(container: TrackedContainer) -> None:
tty=True, tty=True,
user="root", user="root",
environment=[f"NB_USER={nb_user}", "CHOWN_HOME=yes"], environment=[f"NB_USER={nb_user}", "CHOWN_HOME=yes"],
command=["start.sh", "bash", "-c", "sleep infinity"], command=["bash", "-c", "sleep infinity"],
) )
# Give the chown time to complete. # Give the chown time to complete.
@@ -99,7 +99,6 @@ def test_chown_extra(container: TrackedContainer) -> None:
"CHOWN_EXTRA_OPTS=-R", "CHOWN_EXTRA_OPTS=-R",
], ],
command=[ command=[
"start.sh",
"bash", "bash",
"-c", "-c",
"stat -c '%n:%u:%g' /home/jovyan/.bashrc /opt/conda/bin/jupyter", "stat -c '%n:%u:%g' /home/jovyan/.bashrc /opt/conda/bin/jupyter",
@@ -123,7 +122,7 @@ def test_chown_home(container: TrackedContainer) -> None:
"NB_UID=1010", "NB_UID=1010",
"NB_GID=101", "NB_GID=101",
], ],
command=["start.sh", "bash", "-c", "stat -c '%n:%u:%g' /home/kitten/.bashrc"], command=["bash", "-c", "stat -c '%n:%u:%g' /home/kitten/.bashrc"],
) )
assert "/home/kitten/.bashrc:1010:101" in logs assert "/home/kitten/.bashrc:1010:101" in logs
@@ -135,7 +134,7 @@ def test_sudo(container: TrackedContainer) -> None:
tty=True, tty=True,
user="root", user="root",
environment=["GRANT_SUDO=yes"], environment=["GRANT_SUDO=yes"],
command=["start.sh", "sudo", "id"], command=["sudo", "id"],
) )
assert "uid=0(root)" in logs assert "uid=0(root)" in logs
@@ -147,7 +146,7 @@ def test_sudo_path(container: TrackedContainer) -> None:
tty=True, tty=True,
user="root", user="root",
environment=["GRANT_SUDO=yes"], environment=["GRANT_SUDO=yes"],
command=["start.sh", "sudo", "which", "jupyter"], command=["sudo", "which", "jupyter"],
) )
assert logs.rstrip().endswith("/opt/conda/bin/jupyter") assert logs.rstrip().endswith("/opt/conda/bin/jupyter")
@@ -158,7 +157,7 @@ def test_sudo_path_without_grant(container: TrackedContainer) -> None:
timeout=10, timeout=10,
tty=True, tty=True,
user="root", user="root",
command=["start.sh", "which", "jupyter"], command=["which", "jupyter"],
) )
assert logs.rstrip().endswith("/opt/conda/bin/jupyter") assert logs.rstrip().endswith("/opt/conda/bin/jupyter")
@@ -173,7 +172,7 @@ def test_group_add(container: TrackedContainer) -> None:
no_warnings=False, no_warnings=False,
user="1010:1010", user="1010:1010",
group_add=["users"], # Ensures write access to /home/jovyan group_add=["users"], # Ensures write access to /home/jovyan
command=["start.sh", "id"], command=["id"],
) )
warnings = TrackedContainer.get_warnings(logs) warnings = TrackedContainer.get_warnings(logs)
assert len(warnings) == 1 assert len(warnings) == 1
@@ -191,7 +190,7 @@ def test_set_uid(container: TrackedContainer) -> None:
timeout=5, timeout=5,
no_warnings=False, no_warnings=False,
user="1010", user="1010",
command=["start.sh", "id"], command=["id"],
) )
assert "uid=1010(jovyan) gid=0(root)" in logs assert "uid=1010(jovyan) gid=0(root)" in logs
warnings = TrackedContainer.get_warnings(logs) warnings = TrackedContainer.get_warnings(logs)
@@ -207,7 +206,7 @@ def test_set_uid_and_nb_user(container: TrackedContainer) -> None:
user="1010", user="1010",
environment=["NB_USER=kitten"], environment=["NB_USER=kitten"],
group_add=["users"], # Ensures write access to /home/jovyan group_add=["users"], # Ensures write access to /home/jovyan
command=["start.sh", "id"], command=["id"],
) )
assert "uid=1010(kitten) gid=0(root)" in logs assert "uid=1010(kitten) gid=0(root)" in logs
warnings = TrackedContainer.get_warnings(logs) warnings = TrackedContainer.get_warnings(logs)
@@ -236,7 +235,7 @@ def test_container_not_delete_bind_mount(
"CHOWN_HOME=yes", "CHOWN_HOME=yes",
], ],
volumes={d: {"bind": "/home/jovyan/data", "mode": "rw"}}, volumes={d: {"bind": "/home/jovyan/data", "mode": "rw"}},
command=["start.sh", "ls"], command=["ls"],
) )
assert p.read_text() == "some-content" assert p.read_text() == "some-content"
assert len(list(tmp_path.iterdir())) == 1 assert len(list(tmp_path.iterdir())) == 1
@@ -259,7 +258,6 @@ def test_jupyter_env_vars_to_unset(
"SECRET_FRUIT=mango", "SECRET_FRUIT=mango",
], ],
command=[ command=[
"start.sh",
"bash", "bash",
"-c", "-c",
"echo I like ${FRUIT} and ${SECRET_FRUIT:-stuff}, and love ${SECRET_ANIMAL:-to keep secrets}!", "echo I like ${FRUIT} and ${SECRET_FRUIT:-stuff}, and love ${SECRET_ANIMAL:-to keep secrets}!",
@@ -284,7 +282,26 @@ def test_secure_path(container: TrackedContainer, tmp_path: pathlib.Path) -> Non
tty=True, tty=True,
user="root", user="root",
volumes={p: {"bind": "/usr/bin/python", "mode": "ro"}}, volumes={p: {"bind": "/usr/bin/python", "mode": "ro"}},
command=["start.sh", "python", "--version"], command=["python", "--version"],
) )
assert "Wrong python" not in logs assert "Wrong python" not in logs
assert "Python" in logs assert "Python" in logs
def test_startsh_multiple_exec(container: TrackedContainer) -> None:
"""If start.sh is executed multiple times check that configuration only occurs once."""
logs = container.run_and_wait(
timeout=10,
no_warnings=False,
tty=True,
user="root",
environment=["GRANT_SUDO=yes"],
command=["start.sh", "sudo", "id"],
)
assert "uid=0(root)" in logs
warnings = TrackedContainer.get_warnings(logs)
assert len(warnings) == 1
assert (
"WARNING: start.sh is the default ENTRYPOINT, do not include it in CMD"
in warnings[0]
)

View File

@@ -28,7 +28,7 @@ def test_nbconvert(
timeout=30, timeout=30,
volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["bash", "-c", command],
) )
expected_file = f"{output_dir}/{test_file}.{output_format}" expected_file = f"{output_dir}/{test_file}.{output_format}"
assert expected_file in logs, f"Expected file {expected_file} not generated" assert expected_file in logs, f"Expected file {expected_file} not generated"

View File

@@ -55,7 +55,7 @@ class CondaPackageHelper:
LOGGER.info(f"Starting container {container.image_name} ...") LOGGER.info(f"Starting container {container.image_name} ...")
return container.run_detached( return container.run_detached(
tty=True, tty=True,
command=["start.sh", "bash", "-c", "sleep infinity"], command=["bash", "-c", "sleep infinity"],
) )
@staticmethod @staticmethod

View File

@@ -18,5 +18,5 @@ def run_command(
return container.run_and_wait( return container.run_and_wait(
timeout=timeout, timeout=timeout,
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["bash", "-c", command],
) )

View File

@@ -16,7 +16,6 @@ def test_cython(container: TrackedContainer) -> None:
volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=[ command=[
"start.sh",
"bash", "bash",
"-c", "-c",
# We copy our data to a temporary folder to be able to modify the directory # We copy our data to a temporary folder to be able to modify the directory

View File

@@ -30,5 +30,5 @@ def test_check_extension(container: TrackedContainer, extension: str) -> None:
container.run_and_wait( container.run_and_wait(
timeout=10, timeout=10,
tty=True, tty=True,
command=["start.sh", "jupyter", "labextension", "check", extension], command=["jupyter", "labextension", "check", extension],
) )

View File

@@ -42,7 +42,7 @@ def test_matplotlib(
running_container = container.run_detached( running_container = container.run_detached(
volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}}, volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro"}},
tty=True, tty=True,
command=["start.sh", "bash", "-c", command], command=["bash", "-c", command],
) )
command = f"python {cont_data_dir}/{test_file}" command = f"python {cont_data_dir}/{test_file}"
cmd = running_container.exec_run(command) cmd = running_container.exec_run(command)