diff --git a/docker/README.md b/docker/README.md index 2cc49088a..6e03a281a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -51,16 +51,15 @@ when calling `docker run`: deeplabcut-docker bash --gpus all -v /home/john:/home/john ``` -You can select which DeepLabCut version and CUDA version to use through the -`DLC_VERSION` and `CUDA_VERSION` environment variables. So to launch a container with -CUDA 12.1 and DLC 3.0.0, you can run: +Use `DLC_VERSION` and `CUDA_VERSION` to select the Hub tag (unset `DLC_VERSION` uses +`latest` / `latest-jupyter`): ```bash -DLC_VERSION=3.0.0 CUDA_VERSION=12.1 deeplabcut-docker bash --gpus all +DLC_VERSION=3.0.0rc14 CUDA_VERSION=12.4 deeplabcut-docker bash --gpus all ``` -*Note: Advanced users can also directly download and use the `deeplabcut-docker.sh` -script if this is preferred over a python helper script.* +To use a specific image instead of the default tags, pass `--image repo:tag`. +Make sure that the image supports jupyter notebooks when passing `notebook`. ### Jupyter Notebooks Running on Remote Servers @@ -129,11 +128,11 @@ ARG UNAME ARG GNAME # Create same user as on the host system -RUN mkdir -p /home +RUN mkdir -p /home/${UNAME} RUN mkdir -p /app RUN groupadd -g ${GID} ${GNAME} || groupmod -o -g ${GID} ${GNAME} -RUN useradd -d /home -s /bin/bash -u ${UID} -g ${GID} ${UNAME} -RUN chown -R ${UNAME}:${GNAME} /home +RUN useradd -d /home/${UNAME} -s /bin/bash -u ${UID} -g ${GID} ${UNAME} +RUN chown -R ${UNAME}:${GNAME} /home/${UNAME} RUN chown -R ${UNAME}:${GNAME} /app WORKDIR /app diff --git a/docker/package/MANIFEST.in b/docker/package/MANIFEST.in deleted file mode 100644 index 644ae8667..000000000 --- a/docker/package/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include pyproject.toml -include PYPI_README.md -include LICENSE -include deeplabcut_docker.sh diff --git a/docker/package/deeplabcut_docker.py b/docker/package/deeplabcut_docker.py index 296f4789d..ffdd94dbb 100644 --- a/docker/package/deeplabcut_docker.py +++ b/docker/package/deeplabcut_docker.py @@ -1,75 +1,177 @@ #!/usr/bin/env python3 -"""DeepLabCut2.0-2.2 Toolbox (deeplabcut.org) © A. - -& M. Mathis Labs https://github.com/DeepLabCut/DeepLabCut Please see AUTHORS for -contributors. -https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS -Licensed under GNU Lesser General Public License v3.0 -""" +"""Helper CLI to run DeepLabCut Docker images (LGPL-3.0).""" import argparse -import pty +import grp +import os +import platform +import pwd +import shlex +import subprocess import sys +from datetime import datetime, timezone + +__version__ = "0.0.12-alpha" + +_IMAGE = "deeplabcut/deeplabcut" +_DEFAULT_CUDA = "12.4" + + +def _docker() -> list[str]: + """Return the docker CLI argv prefix (from DOCKER env or `docker`).""" + return shlex.split(os.environ.get("DOCKER", "docker")) + + +def _log(msg: str) -> None: + """Log a timestamped message to stderr.""" + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z") + print(f"[{ts}]: {msg}", file=sys.stderr) + + +def _check_system() -> None: + """Verify docker group membership on Linux; warn on macOS.""" + if platform.system() == "Linux": + if os.environ.get("DOCKER", "docker").strip() == "sudo docker": + return + if os.geteuid() == 0: + return + try: + docker_gid = grp.getgrnam("docker").gr_gid + except KeyError: + return + if docker_gid not in os.getgroups(): + _log(f'The current user {os.getuid()} is not in the "docker" group.') + _log('Use DOCKER="sudo docker" (with care) or add your user to "docker".') + sys.exit(1) + elif platform.system() == "Darwin": + _log("macOS support is experimental; report issues at") + _log("https://github.com/DeepLabCut/DeepLabCut/issues") + -__version__ = "0.0.11-alpha" +def _remote_tag(mode: str) -> str: + """Get the DockerHub image tag from DLC_VERSION and CUDA_VERSION env vars.""" + cuda = os.environ.get("CUDA_VERSION", _DEFAULT_CUDA) + ver = os.environ.get("DLC_VERSION", "").strip() + if mode == "notebook": + if ver: + return f"{_IMAGE}:{ver}-jupyter-cuda{cuda}" + return f"{_IMAGE}:latest-jupyter" + if ver: + return f"{_IMAGE}:{ver}-core-cuda{cuda}" + return f"{_IMAGE}:latest" -_MOTD = r""" - .--, .--, - ( ( \.---./ ) ) - '.__/o o\__.' - `{= ^ =}´ - > u < - ____________________.""`-------`"".______________________ -\ ___ __ __ _____ __ / -/ / _ \ ___ ___ ___ / / ___ _ / / / ___/__ __ / /_ \ -\ / // // -_)/ -_)/ _ \ / /__/ _ `// _ \/ /__ / // // __/ / -//____/ \__/ \__// .__//____/\_,_//_.__/\___/ \_,_/ \__/ \ -\_________________________________________________________/ - ___)( )(___ `-.___. - (((__) (__))) ~` -Welcome to DeepLabCut docker! -""" +def _warn_if_not_jupyter_image(ref: str) -> None: + """Warn if the image does not appear to have a Jupyter entrypoint.""" + r = subprocess.run( + _docker() + + [ + "image", + "inspect", + ref, + "--format", + "{{json .Config.Entrypoint}} {{json .Config.Cmd}}", + ], + capture_output=True, + text=True, + ) + if r.returncode != 0: + sys.exit(f"Could not inspect image {ref!r} after pull.\n{r.stderr.strip()}") + blob = (r.stdout or "").lower() + if "jupyter" not in blob: + _log( + f"Warning: image {ref!r} does not appear to have a Jupyter entrypoint. " + "Proceeding anyway — if the server fails to start, ensure the image " + "exposes a Jupyter-compatible entrypoint on port 8888." + ) + + +def _build_user_image(remote: str, local: str) -> None: + """Build a small local image on top of remote with the current UID/GID user.""" + try: + uid, gid = os.getuid(), os.getgid() + except AttributeError: + sys.exit("deeplabcut-docker requires a POSIX system (Linux or macOS).") + user = pwd.getpwuid(uid).pw_name + group = grp.getgrgid(gid).gr_name + _log(f"Configuring a local image for user {user} ({uid}) in group {group} ({gid})") + dockerfile = ( + "\n".join( + ( + f"FROM {remote}", + f"RUN mkdir -p /home/{user} /app", + f"RUN groupadd -g {gid} {group} || groupmod -o -g {gid} {group}", + f"RUN useradd -d /home/{user} -s /bin/bash -u {uid} -g {gid} {user}", + f"RUN chown -R {user}:{group} /home/{user} /app", + f"USER {user}", + ) + ) + + "\n" + ) + subprocess.run( + _docker() + ["build", "-q", "-t", local, "-"], + input=dockerfile.encode(), + check=True, + ) + _log("Build succeeded") -def _parse_args(): +def _parse_args() -> tuple[argparse.Namespace, list[str]]: + """Parse CLI args and return (namespace, extra args for docker run).""" parser = argparse.ArgumentParser( - "deeplabcut-docker", + prog="deeplabcut-docker", description=( - "Utility tool for launching DeepLabCut docker containers. " - "Only a single argument is given to specify the container type. " - "By default, the current directory is mounted into the container " - "and used as the current working directory. You can additionally " - "specify any additional docker argument specified in " - "https://docs.docker.com/engine/reference/commandline/cli/." + "Launch DeepLabCut Docker containers. The current directory is mounted " + "at /app and used as the working directory. Additional arguments are " + "passed through to `docker run` (see " + "https://docs.docker.com/engine/reference/commandline/cli/)." ), ) parser.add_argument( "container", - type=str, - choices=["notebook", "bash"], + choices=("notebook", "bash"), help=( - "The container to launch. A list of all containers is available on " - "https://hub.docker.com/r/deeplabcut/deeplabcut/tags. By default, the " - "latest DLC version will be selected and automatically updated, if " - "possible. All containers are currently launched in interactive mode " - "by default, meaning you can use Ctrl+C in your terminal session to " - "terminate a command." + "notebook: Jupyter server; bash: interactive shell. " + "Image tags: https://hub.docker.com/r/deeplabcut/deeplabcut/tags — " + "use DLC_VERSION and CUDA_VERSION to select a versioned tag; unset " + "DLC_VERSION uses latest / latest-jupyter." + ), + ) + parser.add_argument( + "--image", + metavar="REF", + help=( + "Use this image (name:tag or digest) instead of the default from " + "DLC_VERSION / CUDA_VERSION. For notebook, the image is checked for " + "a Jupyter Notebook entrypoint after pull." ), ) return parser.parse_known_args() -def main(): - """Main entry point. +def main() -> None: + """Entry point: pull, user-layer build, and run the container.""" + _check_system() + args, docker_run_args = _parse_args() + mode = args.container + + remote = args.image or _remote_tag(mode) + local = f"deeplabcut-local-{mode}" + subprocess.run(_docker() + ["pull", remote], check=True) + if mode == "notebook" and args.image: + _warn_if_not_jupyter_image(remote) + _build_user_image(remote, local) - Parse arguments and launch container. - """ - launch_args, docker_arguments = _parse_args() - argv = ["deeplabcut_docker.sh", launch_args.container, *docker_arguments] - print(_MOTD, file=sys.stderr) - pty.spawn(argv) - print("Container stopped.", file=sys.stderr) + run = _docker() + ["run", "-it", "--rm", "-v", f"{os.getcwd()}:/app", "-w", "/app"] + if mode == "notebook": + port = os.environ.get("DLC_NOTEBOOK_PORT", "8888") + _log("Starting the notebook server.") + _log(f"Open your browser at http://127.0.0.1:{port}") + _log("If prompted for a token, enter 'deeplabcut' (default).") + _log("To use a custom token: add -e NOTEBOOK_TOKEN= to your arguments.") + run += ["-p", f"127.0.0.1:{port}:8888"] + run += docker_run_args + [local] + ([] if mode == "notebook" else ["bash"]) + sys.exit(subprocess.run(run).returncode) if __name__ == "__main__": diff --git a/docker/package/deeplabcut_docker.sh b/docker/package/deeplabcut_docker.sh deleted file mode 100755 index 05c929c45..000000000 --- a/docker/package/deeplabcut_docker.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/bin/bash -# -# Helper script for launching deeplabcut docker UI containers -# Usage: -# $ ./deeplabcut-docker.sh [notebook|bash] - -DOCKER=${DOCKER:-docker} -CUDA_VERSION=${CUDA_VERSION:-"12.4"} -CUDNN_VERSION=${CUDNN_VERSION:-"9"} -DLC_VERSION=${DLC_VERSION:-"3.0.0"} -DLC_NOTEBOOK_PORT=${DLC_NOTEBOOK_PORT:-8888} - -# Check if the current users has privileges to start -# a docker container. -check_system() { - if [[ $(uname -s) == Linux ]]; then - if [ $(groups | grep -c docker) -eq 0 ]; then - if [[ "$DOCKER" == "sudo docker" ]]; then - return 0 - fi - err "The current user $(id -u) is not " - err "part of the \"docker\" group. " - err "Please either: " - err " 1) Launch this script with the DOCKER environment " - err " variable set to DOCKER=\"sudo docker\" (use this " - err " with care)! " - err " 2) Add your user to the docker group. You might need " - err " to log in and out again to see the effect of the " - err " change. " - exit 1 - fi - elif [[ $(uname -s) == Darwin ]]; then - err "Please note that macOSX support is currently experimental" - err "If you encounter errors, please open an issue on" - err "https://github.com/DeepLabCut/DeepLabCut/issues" - err "Thanks for testing the package!" - fi -} - -get_mount_args() { - args=( - "-v $(pwd):/app -w /app" - ) - echo "${args[@]}" -} - -get_container_name() { - echo "deeplabcut/deeplabcut:${DLC_VERSION}-$1-cuda${CUDA_VERSION}-cudnn${CUDNN_VERSION}" -} - -get_local_container_name() { - echo "deeplabcut-${DLC_VERSION}-$1-cuda${CUDA_VERSION}-cudnn${CUDNN_VERSION}" -} - -### Start of helper functions ### - -# Print error messages to stderr -# Ref. https://google.github.io/styleguide/shellguide.html#stdout-vs-stderr -err() { - echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2 -} - -# Update the docker container -update() { - $DOCKER pull $(get_container_name $1) -} - -# Build the docker container -# Usage: build [core|jupyter] -build() { - tag=$1 - _build $(get_container_name $tag) $(get_local_container_name $tag) || exit 1 -} - -_build() { - remote_name=$1 - local_name=$2 - - uname=$(id -un) - uid=$(id -u) - gname=$(id -gn) - gid=$(id -g) - - err "Configuring a local container for user $uname ($uid) in group $gname ($gid)" - $DOCKER build -q -t "${local_name}" - <=77", "wheel" ] + +[project] +name = "deeplabcut-docker" +description = "A helper package to launch DeepLabCut docker images" +readme = { file = "PYPI_README.md", content-type = "text/markdown" } +license = "LGPL-3.0-or-later" +license-files = [ "LICENSE" ] +authors = [ + { name = "M-Lab of Adaptive Intelligence", email = "mackenzie@deeplabcut.org" }, + { name = "Mathis Group for Computational Neuroscience and AI", email = "alexander@deeplabcut.org" }, +] +requires-python = ">=3.10,<3.13" +classifiers = [ + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Utilities", +] +dynamic = [ "version" ] +urls."Bug Tracker" = "https://github.com/DeepLabCut/DeepLabCut/issues" +urls.Homepage = "https://github.com/DeepLabCut/DeepLabCut/tree/main/docker" +scripts.deeplabcut-docker = "deeplabcut_docker:main" + +[tool.setuptools] +py-modules = [ "deeplabcut_docker" ] +dynamic.version = { attr = "deeplabcut_docker.__version__" } diff --git a/docker/package/setup.cfg b/docker/package/setup.cfg deleted file mode 100644 index 00d64ed98..000000000 --- a/docker/package/setup.cfg +++ /dev/null @@ -1,41 +0,0 @@ -[metadata] -name = deeplabcut-docker -version = attr: deeplabcut_docker.__version__ -author = A. & M. Mathis Labs -author_email = alexander@deeplabcut.org -maintainer = Steffen Schneider -maintainer_email = stes@hey.com -description = A helper package to launch DeepLabCut docker images -url = https://github.com/DeepLabCut/DeepLabCut/tree/main/docker -project_urls = - Bug Tracker = https://github.com/DeepLabCut/DeepLabCut/issues -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3) - Operating System :: MacOS - Operating System :: POSIX :: Linux - Topic :: Utilities -license = LGPLv3 -long_description = file: PYPI_README.md -long_description_content_type = text/markdown -platform = any - -[options] -package_dir = - = . -py_modules = deeplabcut_docker -python_requires = >=3.10 -include_package_data = True - -[options.entry_points] -console_scripts = - deeplabcut-docker = deeplabcut_docker:main - -[options.packages.find] -where = . - -[options.data_files] -bin = deeplabcut_docker.sh - -[bdist_wheel] -universal = 1