diff --git a/docker/.dockerignore b/docker/.dockerignore index 293ac63198..33f15e99aa 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -7,6 +7,7 @@ !studio_launch.sh !unsloth_studio_update.sh !unsloth_llama_update.sh +!unsloth_jupyter_tunnel.sh !unsloth_nb_compat.py !unsloth_pip_shim.py !unsloth_ipython_startup.py diff --git a/docker/Dockerfile.studio b/docker/Dockerfile.studio index 8fabfbc938..60f92a02db 100644 --- a/docker/Dockerfile.studio +++ b/docker/Dockerfile.studio @@ -167,9 +167,13 @@ COPY unsloth_llama_update.sh /usr/local/bin/unsloth-llama-update # API, so it is not rate-limited; deterministic portable bundle that runs on CPU # and every supported GPU) rather than the host-probing installer. COPY fetch_llama_prebuilt.py /usr/local/lib/unsloth/fetch_llama_prebuilt.py +# Optional public Cloudflare tunnel for JupyterLab (UNSLOTH_JUPYTER_CLOUDFLARE=1, +# or `unsloth-jupyter-tunnel --force`); supervisord runs it as jupyter-cloudflare. +COPY unsloth_jupyter_tunnel.sh /usr/local/bin/unsloth-jupyter-tunnel RUN chmod +x /usr/local/bin/unsloth-studio-launch \ /usr/local/bin/unsloth-studio-update \ - /usr/local/bin/unsloth-llama-update + /usr/local/bin/unsloth-llama-update \ + /usr/local/bin/unsloth-jupyter-tunnel # Studio web UI, JupyterLab, sshd. All bind 0.0.0.0 inside the container's # network namespace; the operator publishes them explicitly with -p. diff --git a/docker/studio_launch.sh b/docker/studio_launch.sh index a39e056398..9c60077472 100644 --- a/docker/studio_launch.sh +++ b/docker/studio_launch.sh @@ -17,6 +17,9 @@ set -euo pipefail export JUPYTER_PORT="${JUPYTER_PORT:-8888}" export UNSLOTH_STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-/opt/unsloth-studio}" +# Default off so supervisord's %(ENV_UNSLOTH_JUPYTER_CLOUDFLARE)s autostart gate +# resolves; set to 1 (docker run -e) to expose JupyterLab on a trycloudflare URL. +export UNSLOTH_JUPYTER_CLOUDFLARE="${UNSLOTH_JUPYTER_CLOUDFLARE:-0}" # Make the runtime env visible to SSH sessions, which get a fresh login shell # without the `docker run -e` vars. Secrets are excluded on purpose: tokens, @@ -79,6 +82,11 @@ fi mkdir -p /workspace echo "Unsloth Studio -> http://localhost:8000 (first-boot password below)" echo "JupyterLab -> http://localhost:${JUPYTER_PORT} (${JUPYTER_NOTE})" +if [[ "${UNSLOTH_JUPYTER_CLOUDFLARE}" == "1" ]]; then + echo "JupyterLab tunnel-> enabled; public trycloudflare URL appears below once it is up" +else + echo "JupyterLab tunnel-> off (set UNSLOTH_JUPYTER_CLOUDFLARE=1 for a public link)" +fi if [[ "${UNSLOTH_ENABLE_SSHD}" == "true" ]]; then echo "sshd -> port 22 (key-only)" fi diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 943bbd62b5..0367f4ea83 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -53,6 +53,21 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +; Optional public Cloudflare quick-tunnel for JupyterLab. Started only when +; UNSLOTH_JUPYTER_CLOUDFLARE=1 (studio_launch.sh exports a 0 default so this +; expands). The trycloudflare URL is printed to docker logs by cloudflared. +[program:jupyter-cloudflare] +command=/usr/local/bin/unsloth-jupyter-tunnel +directory=/workspace +autostart=%(ENV_UNSLOTH_JUPYTER_CLOUDFLARE)s +autorestart=true +startsecs=5 +environment=HOME="/root",USER="root" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [program:sshd] command=/usr/sbin/sshd -D -e autostart=%(ENV_UNSLOTH_ENABLE_SSHD)s diff --git a/docker/unsloth_jupyter_tunnel.sh b/docker/unsloth_jupyter_tunnel.sh new file mode 100755 index 0000000000..d30218412f --- /dev/null +++ b/docker/unsloth_jupyter_tunnel.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Optional public Cloudflare quick-tunnel for JupyterLab, mirroring the tunnel +# Studio creates for its own UI. Off by default. Two ways to use it: +# +# * at run time: docker run -e UNSLOTH_JUPYTER_CLOUDFLARE=1 ... unsloth/unsloth +# -> the https://.trycloudflare.com URL is printed in +# `docker logs` once JupyterLab is up. +# * on demand: docker exec unsloth-jupyter-tunnel --force +# +# The tunnel gives a public https URL that works from anywhere with no account +# or open inbound port. JupyterLab still requires its password, so the notebook +# is not open to the world; treat the URL as sensitive all the same. +set -u + +FORCE=0 +[ "${1:-}" = "--force" ] && FORCE=1 +if [ "$FORCE" != "1" ] && [ "${UNSLOTH_JUPYTER_CLOUDFLARE:-0}" != "1" ]; then + echo "[jupyter-tunnel] disabled (set UNSLOTH_JUPYTER_CLOUDFLARE=1, or run with --force)" + exit 0 +fi + +PORT="${JUPYTER_PORT:-8888}" + +echo "[jupyter-tunnel] waiting for JupyterLab on port ${PORT} ..." +for _ in $(seq 1 90); do + if curl -fsS -o /dev/null "http://localhost:${PORT}/login" 2>/dev/null; then + break + fi + sleep 2 +done + +# Reuse a cloudflared already on the host (Studio caches one for its own +# tunnel); otherwise fetch the static binary for this arch. No account needed. +CFD="" +for cand in \ + "${UNSLOTH_STUDIO_HOME:-/opt/unsloth-studio}/bin/cloudflared" \ + /usr/local/bin/cloudflared \ + cloudflared; do + if command -v "$cand" >/dev/null 2>&1; then CFD="$(command -v "$cand")"; break; fi + [ -x "$cand" ] && { CFD="$cand"; break; } +done +if [ -z "$CFD" ]; then + case "$(uname -m)" in + x86_64|amd64) A=amd64;; + aarch64|arm64) A=arm64;; + *) A=amd64;; + esac + CFD=/usr/local/bin/cloudflared + echo "[jupyter-tunnel] downloading cloudflared (${A}) ..." + if ! curl -fsSL -o "$CFD" \ + "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${A}"; then + echo "[jupyter-tunnel] could not download cloudflared" >&2 + exit 1 + fi + chmod +x "$CFD" +fi + +echo "[jupyter-tunnel] starting Cloudflare quick-tunnel to JupyterLab (port ${PORT})." +echo "[jupyter-tunnel] the https://.trycloudflare.com URL appears below; log in with your Jupyter password." +exec "$CFD" tunnel --no-autoupdate --url "http://localhost:${PORT}"