From d0d5f3c27f2fed3df38857cf4fef9c43662ef3b1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 15 Jun 2026 06:34:54 +0000 Subject: [PATCH] docker: pre-load unslothai/notebooks into JupyterLab, edit-safe refresh JupyterLab now opens with the unslothai/notebooks collection already present, so people can open and run a notebook directly without a git clone or wget. - Bake the repo into the image as a read-only template at /opt/unsloth-notebooks (~206MB, .git stripped, build commit recorded). Inherited by the studio image. - On boot the entrypoint populates /workspace/unsloth-notebooks from the template (instant, works offline) and best-effort refreshes from GitHub, but only when upstream has actually advanced (cheap git ls-remote gate, no download otherwise). - The user's edits always win. We record the content hash of every file we write; on refresh a file whose hash differs from what we last wrote is treated as user-modified and is left untouched, so the refresh only updates files the user has not changed and adds new ones. It never overwrites an edited notebook and never produces merge conflicts. Verified: an edited notebook stays the user's version across repeated upstream changes. - Fully best-effort and gated: UNSLOTH_SKIP_NOTEBOOK_SYNC=1 disables it, UNSLOTH_SKIP_NOTEBOOK_REFRESH=1 keeps the baked copy and never hits the network. Offline boots keep what is there and never error. base 18.45 -> 18.67GB, studio 24.88 -> 25.10GB (+~206MB baked notebooks). --- docker/.dockerignore | 1 + docker/Dockerfile | 22 ++++-- docker/entrypoint.sh | 13 ++++ docker/unsloth_sync_notebooks.sh | 111 +++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 docker/unsloth_sync_notebooks.sh diff --git a/docker/.dockerignore b/docker/.dockerignore index 1bd005c9c1..df3d08f8c8 100644 --- a/docker/.dockerignore +++ b/docker/.dockerignore @@ -9,3 +9,4 @@ !unsloth_pip_shim.py !unsloth_ipython_startup.py !unsloth_run.py +!unsloth_sync_notebooks.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index fd3a8d1cdc..77c09061ad 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -642,22 +642,36 @@ RUN mkdir -p ${HF_HOME} ${TRITON_CACHE_DIR} # * unsloth-run: headless `unsloth-run ` that auto-picks the # sidecar and executes every cell -- the robust driven path. # --------------------------------------------------------------------------- -COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_ipython_startup.py unsloth_run.py /opt/unsloth-nb/ +COPY unsloth_nb_compat.py unsloth_pip_shim.py unsloth_ipython_startup.py unsloth_run.py unsloth_sync_notebooks.sh /opt/unsloth-nb/ RUN set -eux \ && SP=/opt/unsloth-venv/lib/python${PYTHON_VERSION}/site-packages \ && cp /opt/unsloth-nb/unsloth_nb_compat.py "$SP/unsloth_nb_compat.py" \ - && chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py \ + && chmod +x /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/unsloth_run.py /opt/unsloth-nb/unsloth_sync_notebooks.sh \ && mkdir -p /opt/unsloth-nb/bin \ && for t in pip pip3 uv; do ln -sf /opt/unsloth-nb/unsloth_pip_shim.py /opt/unsloth-nb/bin/$t; done \ && ln -sf /opt/unsloth-nb/unsloth_run.py /usr/local/bin/unsloth-run \ + && ln -sf /opt/unsloth-nb/unsloth_sync_notebooks.sh /usr/local/bin/unsloth-sync-notebooks \ && mkdir -p /root/.ipython/profile_default/startup \ && cp /opt/unsloth-nb/unsloth_ipython_startup.py /root/.ipython/profile_default/startup/00-unsloth-nb.py \ && /opt/unsloth-venv/bin/python -c "import sys, glob; sys.path.insert(0, '$SP'); import unsloth_nb_compat; print('nb-compat OK; baked sidecars:', sorted(glob.glob('/opt/unsloth-venv/tf-sidecars/t_*')))" # Shim dir AHEAD of the venv bin so `!pip`/`!uv` resolve to the shim, not the real tool. ENV PATH=/opt/unsloth-nb/bin:${PATH} -# JupyterLab lives in the venv (see builder stage). Persistent notebooks -# should be bind-mounted onto /workspace. +# Pre-clone unslothai/notebooks so JupyterLab opens with the notebooks already +# present (no git clone or wget needed). Baked here as a READ-ONLY template +# (~206MB, .git stripped); on boot the entrypoint copies it to +# /workspace/unsloth-notebooks and best-effort refreshes from GitHub when +# upstream has advanced, never overwriting a notebook the user has edited (see +# unsloth_sync_notebooks.sh). Inherited as-is by the studio image (FROM base). +RUN set -eux \ + && git clone --depth 1 https://github.com/unslothai/notebooks /opt/unsloth-notebooks \ + && git -C /opt/unsloth-notebooks rev-parse HEAD > /opt/unsloth-notebooks/.unsloth_template_commit \ + && rm -rf /opt/unsloth-notebooks/.git \ + && du -sh /opt/unsloth-notebooks + +# JupyterLab lives in the venv (see builder stage). The unslothai/notebooks +# collection is pre-populated into /workspace/unsloth-notebooks on boot; mount a +# volume on /workspace to persist your own notebooks and outputs across runs. EXPOSE 8888 COPY smoke_test.py /workspace/smoke_test.py diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c365d86ad2..8195646ba3 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,7 +25,18 @@ if [[ -x /usr/local/cuda-13.0/bin/ptxas ]] && [[ -z "${TRITON_PTXAS_PATH:-}" ]]; export TRITON_PTXAS_PATH=/usr/local/cuda-13.0/bin/ptxas fi +# Make the unslothai/notebooks collection available under /workspace before the +# user command runs (JupyterLab, unsloth-run, or a shell). Best-effort: it is +# fully gated by UNSLOTH_SKIP_NOTEBOOK_SYNC and never blocks or fails the +# container (see unsloth_sync_notebooks.sh). +sync_notebooks() { + if [[ -x /usr/local/bin/unsloth-sync-notebooks ]]; then + /usr/local/bin/unsloth-sync-notebooks || true + fi +} + if [[ "${UNSLOTH_SKIP_GPU_CHECK:-0}" == "1" ]]; then + sync_notebooks exec "$@" fi @@ -44,6 +55,7 @@ if [[ "${UNSLOTH_ALLOW_CPU:-0}" == "1" ]]; then if ! command -v nvidia-smi >/dev/null 2>&1 || ! nvidia-smi -L 2>/dev/null | grep -q '^GPU'; then warn "UNSLOTH_ALLOW_CPU=1 and no GPU visible -- continuing on CPU." warn "Training requires an NVIDIA GPU. CPU mode covers Jupyter, GGUF tooling and Studio chat." + sync_notebooks exec "$@" fi fi @@ -146,4 +158,5 @@ if major < 8: print(" Unsloth will fall back to fp16. Training works but is slightly slower.") PY +sync_notebooks exec "$@" diff --git a/docker/unsloth_sync_notebooks.sh b/docker/unsloth_sync_notebooks.sh new file mode 100644 index 0000000000..7c50525ea3 --- /dev/null +++ b/docker/unsloth_sync_notebooks.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Populate and refresh /workspace/unsloth-notebooks from unslothai/notebooks. +# +# The image bakes a read-only template at /opt/unsloth-notebooks so the +# notebooks are present in JupyterLab instantly and offline. On boot this script +# copies the template into /workspace/unsloth-notebooks (first run only) and then +# best-effort refreshes from GitHub when upstream has actually advanced. +# +# The user's edits ALWAYS win. We remember the content hash of every file we +# wrote; on refresh a file whose current hash differs from what we last wrote is +# treated as user-modified and is left untouched. So a refresh only updates files +# the user has not changed and adds new ones -- it never clobbers an edited +# notebook and never produces merge conflicts. +# +# Opt-out / tuning (all optional): +# UNSLOTH_SKIP_NOTEBOOK_SYNC=1 do nothing (no populate, no refresh) +# UNSLOTH_SKIP_NOTEBOOK_REFRESH=1 populate from the baked template only; +# never touch the network +# UNSLOTH_NOTEBOOKS_DIR= target dir (default /workspace/unsloth-notebooks) +# UNSLOTH_NOTEBOOKS_REPO= source repo (default unslothai/notebooks) +# UNSLOTH_NOTEBOOK_FETCH_TIMEOUT=N seconds for each network op (default 60) +set -u + +TEMPLATE="${UNSLOTH_NOTEBOOKS_TEMPLATE:-/opt/unsloth-notebooks}" +DEST="${UNSLOTH_NOTEBOOKS_DIR:-/workspace/unsloth-notebooks}" +REMOTE="${UNSLOTH_NOTEBOOKS_REPO:-https://github.com/unslothai/notebooks}" +STATE="$DEST/.unsloth_sync_state" # "sha256 relpath" of what we last wrote +SYNCED="$DEST/.unsloth_sync_commit" # upstream commit we last synced to +TIMEOUT="${UNSLOTH_NOTEBOOK_FETCH_TIMEOUT:-60}" + +[ "${UNSLOTH_SKIP_NOTEBOOK_SYNC:-0}" = "1" ] && exit 0 +[ -d "$TEMPLATE" ] || exit 0 +mkdir -p "$DEST" 2>/dev/null || exit 0 + +hash_of() { sha256sum "$1" 2>/dev/null | cut -d' ' -f1; } + +# Record " " for every file currently under DEST (skip metadata). +record_state() { + : > "$STATE.tmp" 2>/dev/null || return 0 + ( cd "$DEST" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do + rel="${rel#./}" + case "$rel" in + .unsloth_sync_state|.unsloth_sync_commit) continue ;; + esac + printf '%s %s\n' "$(hash_of "$DEST/$rel")" "$rel" >> "$STATE.tmp" + done + mv "$STATE.tmp" "$STATE" 2>/dev/null || rm -f "$STATE.tmp" +} + +# 1) First-boot populate from the baked template (instant, works offline). +if [ ! -f "$STATE" ]; then + ( cd "$TEMPLATE" && find . -type f -print0 ) | while IFS= read -r -d '' rel; do + rel="${rel#./}" + case "$rel" in .unsloth_template_commit) continue ;; esac + mkdir -p "$DEST/$(dirname "$rel")" 2>/dev/null || true + cp -a "$TEMPLATE/$rel" "$DEST/$rel" 2>/dev/null || true + done + record_state + cp -a "$TEMPLATE/.unsloth_template_commit" "$SYNCED" 2>/dev/null || true + echo "[unsloth-nb] notebooks ready at $DEST" +fi + +# 2) Best-effort GitHub refresh -- only when upstream has advanced. Edits win. +[ "${UNSLOTH_SKIP_NOTEBOOK_REFRESH:-0}" = "1" ] && exit 0 +command -v git >/dev/null 2>&1 || exit 0 +command -v sha256sum >/dev/null 2>&1 || exit 0 + +last="$(cat "$SYNCED" 2>/dev/null || true)" +remote="$(timeout "$TIMEOUT" git ls-remote "$REMOTE" HEAD 2>/dev/null | cut -f1)" +[ -z "$remote" ] && exit 0 # offline / unreachable -> keep what we have +[ "$remote" = "$last" ] && exit 0 # nothing new since last sync -> done + +TMP="$(mktemp -d)" +if ! timeout "$TIMEOUT" git clone -q --depth 1 "$REMOTE" "$TMP" 2>/dev/null; then + rm -rf "$TMP"; exit 0 # network died mid-way -> keep what we have +fi + +declare -A LAST +if [ -f "$STATE" ]; then + while read -r h p; do + [ -n "${p:-}" ] && LAST["$p"]="$h" + done < "$STATE" +fi + +TMPSTATE="$(mktemp)" +updated=0; kept=0 +while IFS= read -r -d '' f; do + rel="${f#"$TMP"/}" + case "$rel" in .git|.git/*) continue ;; esac + dst="$DEST/$rel" + if [ -e "$dst" ]; then + rec="${LAST[$rel]:-}" + if [ -n "$rec" ] && [ "$(hash_of "$dst")" != "$rec" ]; then + # User changed this file since we wrote it -> keep theirs, keep marker. + printf '%s %s\n' "$rec" "$rel" >> "$TMPSTATE" + kept=$((kept + 1)) + continue + fi + fi + mkdir -p "$(dirname "$dst")" 2>/dev/null || true + if cp -a "$f" "$dst" 2>/dev/null; then + printf '%s %s\n' "$(hash_of "$dst")" "$rel" >> "$TMPSTATE" + updated=$((updated + 1)) + fi +done < <(find "$TMP" -type f -print0) + +mv "$TMPSTATE" "$STATE" 2>/dev/null || rm -f "$TMPSTATE" +echo "$remote" > "$SYNCED" 2>/dev/null || true +rm -rf "$TMP" +echo "[unsloth-nb] notebooks refreshed from GitHub: $updated updated, $kept kept (your edits)" +exit 0