From ab8bb1f6ea7ebeba7df56e4491a874beebd5e9a8 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Thu, 30 Jul 2026 15:07:06 -0400 Subject: [PATCH 01/10] feat: add Brev Launchable provisioning script Provision a Brev VM-Mode Launchable that gives a customer a ready-to-run JupyterLab with Safe Synthesizer installed, so trying NSS needs no local CUDA, driver, or Python setup. setup.sh is pasted into the Launchable's Setup Script field. It installs the cu129 build into a dedicated venv, registers that venv as the default Jupyter kernel, fetches the tutorial notebooks, and writes a short README into $HOME. README.md records the console configuration, which otherwise lives only in the Brev web UI, plus the non-obvious constraints found while testing on real instances. Co-Authored-By: Claude Opus 5 Signed-off-by: Yunfeng Zhang --- script/brev/README.md | 160 ++++++++++++++++++++ script/brev/setup.sh | 344 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 script/brev/README.md create mode 100755 script/brev/setup.sh diff --git a/script/brev/README.md b/script/brev/README.md new file mode 100644 index 000000000..72509e979 --- /dev/null +++ b/script/brev/README.md @@ -0,0 +1,160 @@ + + + +### NeMo Safe Synthesizer Brev Launchable + +This directory holds the provisioning script and recorded console settings for the +[NVIDIA Brev](https://brev.nvidia.com) Launchable, a one-click GPU environment for trying +NeMo Safe Synthesizer without setting up CUDA, drivers, or Python locally. + +**Nothing in this directory is executed by the repo or by CI.** A Brev Launchable is +configured in the Brev web console, and the setup script is pasted into a form field +there. This directory exists so that configuration is versioned and reviewable rather +than living only in a browser. When you change `setup.sh`, you must also paste the new +contents into the console for the change to take effect. + +### Files + +- `setup.sh`: Pasted into the Launchable's **Setup Script** field. Installs the CUDA + build of Safe Synthesizer into a dedicated venv, registers it as the default Jupyter + kernel, and drops the tutorial notebooks in `$HOME`. + +### Console configuration + +Recreate the Launchable at → **Launchables** → **Create +Launchable** with these settings. + +| Section | Field | Value | +| ------- | ----- | ----- | +| Software | Runtime mode | VM Mode | +| Software | Install Jupyter on the host | Enabled | +| Software | Run a Setup Script | Enabled, contents of `setup.sh` | +| Software | Image ID | Leave blank | +| Source | Code source | No code files (`setup.sh` clones the tutorials itself) | +| Hardware | GPU | 1× 80 GiB VRAM, single GPU | +| Hardware | Disk | 200 GiB or more -- **not resizable after creation** | +| Network | Ports | 8888, named `jupyter` | +| Access | Visibility | Anyone with the link | + +Multi-GPU instances are pointless here: training is single-GPU only, so a second GPU +adds cost and no capability. + +### Launch parameters + +All optional. Per Brev's guidance, do not put credential values in parameter defaults. + +| Name | Type | Purpose | +| ---- | ---- | ------- | +| `NSS_INFERENCE_KEY` | Text | NVIDIA NIM key for column classification. Without it, classification runs in degraded mode. | +| `HF_TOKEN` | Text | Only needed for gated Hugging Face models. | + +Brev passes these to `setup.sh` as environment variables. + +Deliberately kept to two. A dataset-URL parameter and a model-prewarm toggle were both +tried and removed: a customer can fetch a URL with one `!curl` line in a notebook, and +prewarming moves the model download earlier without making it shorter, while hiding the +progress bar the notebook would otherwise show. Every parameter is visible to every +deployer, so the bar for adding one is high. + +### What the customer gets + +`$HOME` is the JupyterLab file browser root, so only what a customer needs is visible +there. Everything operational is a dotfile, which the browser hides by default. + +```text +$HOME/ + tutorials/ the three tutorial notebooks and their datasets + README.md where to start, rendered on double-click + + .nss-venv/ cu129 venv, registered as the default kernel + .cache/huggingface/ model cache (Hugging Face's default location) + .nss-env.sh PATH and VIRTUAL_ENV, sourced from .bashrc + .nss-setup.log full provisioning log +``` + +The script deliberately does **not** create a data folder. Customers put their files +wherever they like and pass the path to `with_data_source()`. Bring-your-own-data +options, in rough order of convenience: drag and drop into the JupyterLab file browser; +`!curl -O ` in a notebook cell; or `brev shell` plus `scp` for large files. + +### Implementation notes + +These are the non-obvious constraints the script works around. They were each found the +hard way on a real instance. + +- **The setup script has a 16 KiB limit.** Brev rejects anything larger, which is why + the script carries short comments pointing here rather than full explanations. Check + `wc -c script/brev/setup.sh` before pasting. +- **The script runs unprivileged.** Brev executes it as the instance user, not root, so + `/usr/local/bin`, `/var/log`, and `/etc/profile.d` are all unwritable. Everything + installs under `$HOME`. +- **The venv is deliberately separate from Brev's.** Installing `[cu129,engine]` into + `$HOME/.venv` would resolve torch, vllm, and flashinfer alongside jupyterlab's own + pins. If that breaks the notebook server, the customer's only interface is gone and + there is no error they can act on. A separate venv keeps a failed install recoverable. +- **The instance username varies by provider** -- `shadeform` on the Shadeform-brokered + offerings, likely `ubuntu` on direct ones. The script never names an account; it uses + `$HOME` and `id -un`. +- **The image ships its own `$HOME/.venv` on Python 3.12**, which is what the Brev + Jupyter server runs in. `uv` discovers it by walking up from the working directory, so + the script pins `VIRTUAL_ENV` in both the kernelspec and `.nss-env.sh` to keep + `uv pip install` from silently targeting it. Do not delete or upgrade that venv -- + breaking it breaks the notebook server, which is the customer's only interface. +- **The kernel is registered as `python3`, not a custom name**, because the tutorial + notebooks declare `kernelspec.name = "python3"`. Any other name would force the + customer to switch kernels by hand. +- **Kernel precedence is counterintuitive.** `jupyter_core` puts the server's own + environment *ahead* of `~/.local/share/jupyter` whenever the server runs inside a + virtualenv (`prefer_environment_over_user()`), and Brev's does. Registering only at + user scope is silently shadowed by Brev's 3.12 kernel. The script asks the server's + interpreter for `jupyter_path("kernels")[0]` and writes there, backing up any existing + `kernel.json` to `kernel.json.orig`. +- **The kernelspec holds secrets, so it stays mode 0600 inside `$HOME`.** An earlier + version also mirrored it to `/usr/local/share/jupyter` at 0644 via sudo, which would + have put `NSS_INFERENCE_KEY` and `HF_TOKEN` in a world-readable file. Do not + reintroduce that -- it also could not win precedence over a venv-scoped kernel anyway. +- **Most providers do not support stop/start.** The instance bills from creation until + deletion, with no pause. Nebius was the exception at the time of writing. +- **Tutorials arrive via tarball, not `git clone`.** `--strip-components` lands them flat + in `tutorials/` rather than the repo's `docs/tutorials/` nesting, and the image is not + required to have `git`. The tarball is pinned to the tag matching the installed wheel, + falling back to `main`, so the notebooks can never call APIs newer than the package + they run against. +- **The tar member is an exact path, not a glob.** GNU tar does not enable wildcards for + member names by default, and bsdtar rejects `--wildcards` outright, so no single glob + form works on both. The archive's top-level directory name also varies by ref + (`Safe-Synthesizer-0.1.8` for a tag, `Safe-Synthesizer-main` for a branch), so the + script reads it from the archive rather than guessing. +- **Never pipe `tar -t` into `head`.** Closing the pipe early makes GNU tar fail with + `stdout: write error` on SIGPIPE, and `pipefail` turns that into a failed run. bsdtar + exits silently on SIGPIPE, so this passes on macOS and fails on the instance. The + script reads the listing into a variable and takes the prefix with `${listing%%/*}`. + +### Verification + +After deploying, check `$HOME/nss-setup.log` for the `setup complete` banner, then in a +notebook confirm the environment resolved correctly: + +```python +import sys +print(sys.executable) # expect $HOME/nss-venv/bin/python +``` + +That must hold **without** touching the kernel picker -- the customer should never have +to switch kernels. If it reports a 3.12 path instead, kernel registration landed in a +directory the server does not consult; check the `registered kernel at ...` lines in the +log against `jupyter kernelspec list`. + +Then run `tutorials/safe-synthesizer-101.ipynb` end to end -- roughly 15 minutes on +an A100 or H100. + +### Tested configurations + +| Date | Provider | GPU | Result | +| ---- | -------- | --- | ------ | +| 2026-07-29 | Shadeform-brokered | H100 PCIe 80 GiB | Package install and workspace setup verified | + +Add a row when you validate another provider or GPU class. The A100-or-larger figure in +the main docs looks conservative for the default 3B model; if a 48 GiB card proves +sufficient, record it here and correct the requirement in `README.md`, +`docs/index.md`, and `docs/user-guide/getting-started.md`. diff --git a/script/brev/setup.sh b/script/brev/setup.sh new file mode 100755 index 000000000..9af831288 --- /dev/null +++ b/script/brev/setup.sh @@ -0,0 +1,344 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# setup.sh -- provision a Brev VM-Mode Launchable for NeMo Safe Synthesizer. +# +# Brev runs this once as the unprivileged instance user (the account name +# varies by provider), after the VM is created and Jupyter is installed. It +# installs the CUDA build of Safe Synthesizer into a dedicated venv, registers +# it as the default Jupyter kernel, and drops the tutorials in $HOME. +# +# Everything lands under $HOME; the script never requires root. Paste it into +# the Launchable's Setup Script field. See README.md in this directory for the +# console settings and the reasoning behind the non-obvious steps below. +# +# Launch parameters (Brev exposes them as environment variables): +# NSS_INFERENCE_KEY Optional. NVIDIA NIM key for column classification. +# Without it, classification runs in degraded mode. +# HF_TOKEN Optional. Needed only for gated Hugging Face models. +# +# The script is idempotent: rerunning it is safe and skips completed stages. + +set -euo pipefail + +readonly UV_VERSION="0.9.30" +readonly PYTHON_VERSION="3.13" +# No .git suffix: this is only used to build archive URLs, not to clone. +readonly REPO_URL="https://github.com/NVIDIA-NeMo/Safe-Synthesizer" + +: "${HOME:?HOME is not set}" + +# $HOME is the JupyterLab file browser root, so anything visible there is part +# of the first impression. Only tutorials/ and README.md are; everything +# operational is a dotfile, which the browser hides by default. Customers pick +# their own location for data -- the script does not create a folder for it. +readonly TUTORIALS_DIR="${HOME}/tutorials" +readonly README_FILE="${HOME}/README.md" + +readonly BIN_DIR="${HOME}/.local/bin" +readonly VENV_DIR="${HOME}/.nss-venv" +readonly USER_KERNEL_DIR="${HOME}/.local/share/jupyter/kernels/python3" +readonly ENV_FILE="${HOME}/.nss-env.sh" +readonly LOG_FILE="${HOME}/.nss-setup.log" + +# vLLM publishes cu129 wheels from a pinned commit rather than a release index. +# Keep these three in sync with the install command in docs/user-guide/getting-started.md. +readonly INDEX_FLASHINFER="https://flashinfer.ai/whl/cu129" +readonly INDEX_TORCH="https://download.pytorch.org/whl/cu129" +readonly INDEX_VLLM="https://wheels.vllm.ai/ee0da84ab9e04ac7610e28580af62c365e898389/cu129" + +mkdir -p "${BIN_DIR}" +exec > >(tee -a "${LOG_FILE}") 2>&1 + +log() { printf '\n=== [nss-setup] %s\n' "$*"; } +fail() { printf '\n!!! [nss-setup] FAILED at line %s\n' "$1" >&2; } +trap 'fail "${LINENO}"' ERR + +export PATH="${BIN_DIR}:${PATH}" + +log "user=$(id -un) home=${HOME}" + +# --------------------------------------------------------------------------- +# Preflight: this Launchable only makes sense on an NVIDIA GPU instance. +# --------------------------------------------------------------------------- + +if command -v nvidia-smi >/dev/null 2>&1; then + nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader || true +else + log "WARNING: nvidia-smi not found. Training and generation will not work." +fi + +# --------------------------------------------------------------------------- +# uv -- installed into ~/.local/bin, which needs no privileges. +# --------------------------------------------------------------------------- + +if [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then + log "installing uv ${UV_VERSION} into ${BIN_DIR}" + curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" \ + | env UV_INSTALL_DIR="${BIN_DIR}" INSTALLER_NO_MODIFY_PATH=1 sh +else + log "uv ${UV_VERSION} already present" +fi + +# The cu129 wheels are large and their indexes are not always fast. +export UV_HTTP_TIMEOUT=300 + +# --------------------------------------------------------------------------- +# Virtual environment -- deliberately separate from the one Jupyter runs on, so +# a failed cu129 install cannot take the notebook server down with it. +# --------------------------------------------------------------------------- + +if [[ ! -x "${VENV_DIR}/bin/python" ]]; then + log "creating venv at ${VENV_DIR} (python ${PYTHON_VERSION})" + uv venv --python "${PYTHON_VERSION}" "${VENV_DIR}" +else + log "venv already present at ${VENV_DIR}" +fi + +if "${VENV_DIR}/bin/safe-synthesizer" --version >/dev/null 2>&1; then + log "safe-synthesizer already installed; skipping package install" +else + log "installing nemo-safe-synthesizer[cu129,engine] -- this takes several minutes" + VIRTUAL_ENV="${VENV_DIR}" uv pip install "nemo-safe-synthesizer[cu129,engine]" \ + --index "${INDEX_FLASHINFER}" \ + --index "${INDEX_TORCH}" \ + --index "${INDEX_VLLM}" \ + --index-strategy unsafe-best-match + + log "installing notebook support" + VIRTUAL_ENV="${VENV_DIR}" uv pip install ipykernel ipywidgets +fi + +# --------------------------------------------------------------------------- +# Tutorial notebooks. Tarball extract rather than a clone, so they land flat in +# tutorials/ and git is not required. Pinned to the tag matching the installed +# wheel so the notebooks cannot outrun the package. See README.md. +# --------------------------------------------------------------------------- + +if compgen -G "${TUTORIALS_DIR}/*.ipynb" >/dev/null 2>&1; then + log "tutorials already present" +else + NSS_VERSION="$("${VENV_DIR}/bin/python" -c \ + 'from importlib.metadata import version; print(version("nemo-safe-synthesizer"))')" + log "fetching tutorials for version ${NSS_VERSION}" + + mkdir -p "${TUTORIALS_DIR}" + tarball_dir="$(mktemp -d)" + tarball="${tarball_dir}/src.tar.gz" + fetched=0 + + for ref in "refs/tags/v${NSS_VERSION}" "refs/heads/main"; do + if ! curl -fsSL "${REPO_URL}/archive/${ref}.tar.gz" -o "${tarball}"; then + log "WARNING: ${ref} not downloadable; trying next ref" + continue + fi + + # Exact member path, not a glob, and the listing read into a variable + # rather than piped to `head` -- both are GNU/BSD tar portability traps + # documented in README.md. `%%/*` yields the archive's top-level directory. + listing="$(tar -tzf "${tarball}")" + top="${listing%%/*}" + if tar -xzf "${tarball}" -C "${TUTORIALS_DIR}" --strip-components=3 \ + "${top}/docs/tutorials"; then + log "tutorials extracted from ${ref}" + fetched=1 + break + fi + log "WARNING: no tutorials in ${ref}; trying next ref" + done + + rm -rf "${tarball_dir}" + if [[ "${fetched}" -ne 1 ]]; then + log "ERROR: could not fetch tutorial notebooks" + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# Environment. Set in two places: this file for terminals, and the kernelspec +# below for notebooks, since the Jupyter server never reads shell rc files. +# --------------------------------------------------------------------------- + +log "writing ${ENV_FILE}" +{ + echo "# Managed by setup.sh -- do not edit." + echo "export PATH=\"${VENV_DIR}/bin:${BIN_DIR}:\${PATH}\"" + # Pin VIRTUAL_ENV or uv walks up and finds Brev's own ~/.venv instead. + echo "export VIRTUAL_ENV=\"${VENV_DIR}\"" + if [[ -n "${NSS_INFERENCE_KEY:-}" ]]; then + printf 'export NSS_INFERENCE_KEY=%q\n' "${NSS_INFERENCE_KEY}" + fi + if [[ -n "${HF_TOKEN:-}" ]]; then + printf 'export HF_TOKEN=%q\n' "${HF_TOKEN}" + printf 'export HUGGING_FACE_HUB_TOKEN=%q\n' "${HF_TOKEN}" + fi +} >"${ENV_FILE}" +chmod 0600 "${ENV_FILE}" + +if ! grep -qF "${ENV_FILE}" "${HOME}/.bashrc" 2>/dev/null; then + printf '\n[ -f %s ] && . %s\n' "${ENV_FILE}" "${ENV_FILE}" >>"${HOME}/.bashrc" +fi + +# --------------------------------------------------------------------------- +# Jupyter kernel. Registered as `python3` -- the name the notebooks declare -- +# so nobody has to switch kernels. Which directory wins is not obvious, and the +# user one usually loses; see README.md. Ask the server rather than guess. +# --------------------------------------------------------------------------- + +log "building kernelspec" +KERNEL_JSON="$(mktemp)" +# `env` rather than a command-prefix assignment: bash rejects prefix +# assignments to readonly variables, and these three are readonly. +env VENV_DIR="${VENV_DIR}" BIN_DIR="${BIN_DIR}" \ + "${VENV_DIR}/bin/python" - "${KERNEL_JSON}" <<'PY' +import json +import os +import sys + +venv = os.environ["VENV_DIR"] +env = { + "PATH": f"{venv}/bin:{os.environ['BIN_DIR']}:/usr/local/bin:/usr/bin:/bin", + # Keeps `!uv pip install ...` in a notebook from resolving to the Brev + # image's own ~/.venv, which uv would otherwise discover by walking up. + "VIRTUAL_ENV": venv, +} +# Secrets live in the kernelspec because the Jupyter server is not launched +# from a login shell. The VM is single-tenant and the file is mode 0600. +for key in ("NSS_INFERENCE_KEY", "HF_TOKEN"): + value = os.environ.get(key) + if value: + env[key] = value +if "HF_TOKEN" in env: + env["HUGGING_FACE_HUB_TOKEN"] = env["HF_TOKEN"] + +spec = { + "argv": [f"{venv}/bin/python", "-m", "ipykernel_launcher", "-f", "{connection_file}"], + "display_name": "Safe Synthesizer", + "language": "python", + "env": env, +} +with open(sys.argv[1], "w", encoding="utf-8") as handle: + json.dump(spec, handle, indent=2) +PY + +# Find the interpreter the Jupyter server runs on, so we can ask it -- rather +# than guess -- where its highest-precedence kernels directory is. +JUPYTER_PY="" +if command -v jupyter >/dev/null 2>&1; then + candidate="$(dirname "$(command -v jupyter)")/python" + if [[ -x "${candidate}" ]]; then + JUPYTER_PY="${candidate}" + fi +fi +if [[ -z "${JUPYTER_PY}" && -x "${HOME}/.venv/bin/python" ]]; then + JUPYTER_PY="${HOME}/.venv/bin/python" +fi + +kernel_targets=("${USER_KERNEL_DIR}") +if [[ -n "${JUPYTER_PY}" ]]; then + primary="$("${JUPYTER_PY}" -c \ + 'from jupyter_core.paths import jupyter_path; print(jupyter_path("kernels")[0])' \ + 2>/dev/null || true)" + if [[ -n "${primary}" ]]; then + kernel_targets=("${primary}/python3" "${kernel_targets[@]}") + fi +else + log "WARNING: could not locate the Jupyter interpreter; kernel may not be picked up" +fi + +# First writable target wins. Writing both would leave a duplicate python3 +# kernelspec that never gets consulted and only confuses `kernelspec list`. +registered=0 +for target in "${kernel_targets[@]}"; do + mkdir -p "${target}" 2>/dev/null || { log "WARNING: cannot write ${target}"; continue; } + # Keep whatever was there, once, so the original kernel stays recoverable. + if [[ -f "${target}/kernel.json" && ! -f "${target}/kernel.json.orig" ]]; then + cp "${target}/kernel.json" "${target}/kernel.json.orig" + fi + cp "${KERNEL_JSON}" "${target}/kernel.json" + chmod 0600 "${target}/kernel.json" + log "registered kernel at ${target}" + registered=1 + break +done +rm -f "${KERNEL_JSON}" +if [[ "${registered}" -ne 1 ]]; then + log "WARNING: kernel not registered; notebooks may open on the wrong Python" +fi + +# --------------------------------------------------------------------------- +# Smoke check -- fail provisioning loudly rather than handing over a broken VM. +# --------------------------------------------------------------------------- + +log "verifying install" +"${VENV_DIR}/bin/safe-synthesizer" --version +"${VENV_DIR}/bin/python" \ + -c "import torch; print('cuda available:', torch.cuda.is_available())" + +# --------------------------------------------------------------------------- +# Customer-facing README, rendered by JupyterLab on double-click. +# --------------------------------------------------------------------------- + +log "writing ${README_FILE}" +cat >"${README_FILE}" < +- Source: <${REPO_URL}> +EOF + +trap - ERR +log "setup complete" +cat < Date: Thu, 30 Jul 2026 15:40:16 -0400 Subject: [PATCH 02/10] fix(brev): address review findings in launchable setup Idempotency: check ipykernel separately from the package install, so a rerun after a failed notebook-support step does not skip it and leave a registered kernel that cannot start. Stage the tutorial extract in a temp directory and move it into place only on success, so a partial extract is never mistaken for a complete one. Kernel registration: mkdir -p succeeds on an existing directory the user cannot write, so the unguarded cp that followed would abort the whole run under set -e instead of falling through to the next target. Test writability and guard the copies. uv install: fetch the release tarball and verify its published SHA-256 instead of piping astral.sh/install.sh into a shell, which reports "no checksums to verify". Matches the GPG-verified mise install in tools/install-mise.sh. Docs: correct "clones" to "downloads", restore the leading dots on .nss-setup.log and .nss-venv, narrow the tested-configurations row to what actually ran, and drop decorative bold per STYLE_GUIDE.md. Signed-off-by: Yunfeng Zhang --- script/brev/README.md | 44 +++++++++++++++--------------- script/brev/setup.sh | 62 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/script/brev/README.md b/script/brev/README.md index 72509e979..f7d61cccf 100644 --- a/script/brev/README.md +++ b/script/brev/README.md @@ -7,7 +7,7 @@ This directory holds the provisioning script and recorded console settings for t [NVIDIA Brev](https://brev.nvidia.com) Launchable, a one-click GPU environment for trying NeMo Safe Synthesizer without setting up CUDA, drivers, or Python locally. -**Nothing in this directory is executed by the repo or by CI.** A Brev Launchable is +Nothing in this directory is executed by the repo or by CI. A Brev Launchable is configured in the Brev web console, and the setup script is pasted into a form field there. This directory exists so that configuration is versioned and reviewable rather than living only in a browser. When you change `setup.sh`, you must also paste the new @@ -15,14 +15,14 @@ contents into the console for the change to take effect. ### Files -- `setup.sh`: Pasted into the Launchable's **Setup Script** field. Installs the CUDA +- `setup.sh`: Pasted into the Launchable's Setup Script field. Installs the CUDA build of Safe Synthesizer into a dedicated venv, registers it as the default Jupyter kernel, and drops the tutorial notebooks in `$HOME`. ### Console configuration -Recreate the Launchable at → **Launchables** → **Create -Launchable** with these settings. +Recreate the Launchable at → Launchables → Create +Launchable with these settings. | Section | Field | Value | | ------- | ----- | ----- | @@ -30,7 +30,7 @@ Launchable** with these settings. | Software | Install Jupyter on the host | Enabled | | Software | Run a Setup Script | Enabled, contents of `setup.sh` | | Software | Image ID | Leave blank | -| Source | Code source | No code files (`setup.sh` clones the tutorials itself) | +| Source | Code source | No code files (`setup.sh` downloads the tutorials itself) | | Hardware | GPU | 1× 80 GiB VRAM, single GPU | | Hardware | Disk | 200 GiB or more -- **not resizable after creation** | | Network | Ports | 8888, named `jupyter` | @@ -72,7 +72,7 @@ $HOME/ .nss-setup.log full provisioning log ``` -The script deliberately does **not** create a data folder. Customers put their files +The script deliberately does not create a data folder. Customers put their files wherever they like and pass the path to `with_data_source()`. Bring-your-own-data options, in rough order of convenience: drag and drop into the JupyterLab file browser; `!curl -O ` in a notebook cell; or `brev shell` plus `scp` for large files. @@ -82,65 +82,65 @@ options, in rough order of convenience: drag and drop into the JupyterLab file b These are the non-obvious constraints the script works around. They were each found the hard way on a real instance. -- **The setup script has a 16 KiB limit.** Brev rejects anything larger, which is why +- The setup script has a 16 KiB limit. Brev rejects anything larger, which is why the script carries short comments pointing here rather than full explanations. Check `wc -c script/brev/setup.sh` before pasting. -- **The script runs unprivileged.** Brev executes it as the instance user, not root, so +- The script runs unprivileged. Brev executes it as the instance user, not root, so `/usr/local/bin`, `/var/log`, and `/etc/profile.d` are all unwritable. Everything installs under `$HOME`. -- **The venv is deliberately separate from Brev's.** Installing `[cu129,engine]` into +- The venv is deliberately separate from Brev's. Installing `[cu129,engine]` into `$HOME/.venv` would resolve torch, vllm, and flashinfer alongside jupyterlab's own pins. If that breaks the notebook server, the customer's only interface is gone and there is no error they can act on. A separate venv keeps a failed install recoverable. -- **The instance username varies by provider** -- `shadeform` on the Shadeform-brokered +- The instance username varies by provider -- `shadeform` on the Shadeform-brokered offerings, likely `ubuntu` on direct ones. The script never names an account; it uses `$HOME` and `id -un`. -- **The image ships its own `$HOME/.venv` on Python 3.12**, which is what the Brev +- The image ships its own `$HOME/.venv` on Python 3.12, which is what the Brev Jupyter server runs in. `uv` discovers it by walking up from the working directory, so the script pins `VIRTUAL_ENV` in both the kernelspec and `.nss-env.sh` to keep `uv pip install` from silently targeting it. Do not delete or upgrade that venv -- breaking it breaks the notebook server, which is the customer's only interface. -- **The kernel is registered as `python3`, not a custom name**, because the tutorial +- The kernel is registered as `python3`, not a custom name, because the tutorial notebooks declare `kernelspec.name = "python3"`. Any other name would force the customer to switch kernels by hand. -- **Kernel precedence is counterintuitive.** `jupyter_core` puts the server's own +- Kernel precedence is counterintuitive. `jupyter_core` puts the server's own environment *ahead* of `~/.local/share/jupyter` whenever the server runs inside a virtualenv (`prefer_environment_over_user()`), and Brev's does. Registering only at user scope is silently shadowed by Brev's 3.12 kernel. The script asks the server's interpreter for `jupyter_path("kernels")[0]` and writes there, backing up any existing `kernel.json` to `kernel.json.orig`. -- **The kernelspec holds secrets, so it stays mode 0600 inside `$HOME`.** An earlier +- The kernelspec holds secrets, so it stays mode 0600 inside `$HOME`. An earlier version also mirrored it to `/usr/local/share/jupyter` at 0644 via sudo, which would have put `NSS_INFERENCE_KEY` and `HF_TOKEN` in a world-readable file. Do not reintroduce that -- it also could not win precedence over a venv-scoped kernel anyway. -- **Most providers do not support stop/start.** The instance bills from creation until +- Most providers do not support stop/start. The instance bills from creation until deletion, with no pause. Nebius was the exception at the time of writing. -- **Tutorials arrive via tarball, not `git clone`.** `--strip-components` lands them flat +- Tutorials arrive via tarball, not `git clone`. `--strip-components` lands them flat in `tutorials/` rather than the repo's `docs/tutorials/` nesting, and the image is not required to have `git`. The tarball is pinned to the tag matching the installed wheel, falling back to `main`, so the notebooks can never call APIs newer than the package they run against. -- **The tar member is an exact path, not a glob.** GNU tar does not enable wildcards for +- The tar member is an exact path, not a glob. GNU tar does not enable wildcards for member names by default, and bsdtar rejects `--wildcards` outright, so no single glob form works on both. The archive's top-level directory name also varies by ref (`Safe-Synthesizer-0.1.8` for a tag, `Safe-Synthesizer-main` for a branch), so the script reads it from the archive rather than guessing. -- **Never pipe `tar -t` into `head`.** Closing the pipe early makes GNU tar fail with +- Never pipe `tar -t` into `head`. Closing the pipe early makes GNU tar fail with `stdout: write error` on SIGPIPE, and `pipefail` turns that into a failed run. bsdtar exits silently on SIGPIPE, so this passes on macOS and fails on the instance. The script reads the listing into a variable and takes the prefix with `${listing%%/*}`. ### Verification -After deploying, check `$HOME/nss-setup.log` for the `setup complete` banner, then in a +After deploying, check `$HOME/.nss-setup.log` for the `setup complete` banner, then in a notebook confirm the environment resolved correctly: ```python import sys -print(sys.executable) # expect $HOME/nss-venv/bin/python +print(sys.executable) # expect $HOME/.nss-venv/bin/python ``` -That must hold **without** touching the kernel picker -- the customer should never have +That must hold without touching the kernel picker -- the customer should never have to switch kernels. If it reports a 3.12 path instead, kernel registration landed in a directory the server does not consult; check the `registered kernel at ...` lines in the log against `jupyter kernelspec list`. @@ -152,7 +152,7 @@ an A100 or H100. | Date | Provider | GPU | Result | | ---- | -------- | --- | ------ | -| 2026-07-29 | Shadeform-brokered | H100 PCIe 80 GiB | Package install and workspace setup verified | +| 2026-07-29 | Shadeform-brokered | H100 PCIe 80 GiB | uv install, venv creation, `[cu129,engine]` install, tutorial fetch | Add a row when you validate another provider or GPU class. The A100-or-larger figure in the main docs looks conservative for the default 3B model; if a 48 GiB card proves diff --git a/script/brev/setup.sh b/script/brev/setup.sh index 9af831288..0bd6a4357 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -31,7 +31,7 @@ readonly REPO_URL="https://github.com/NVIDIA-NeMo/Safe-Synthesizer" # $HOME is the JupyterLab file browser root, so anything visible there is part # of the first impression. Only tutorials/ and README.md are; everything -# operational is a dotfile, which the browser hides by default. Customers pick +# operational is a dotfile, which the browser hides by default. Users pick # their own location for data -- the script does not create a folder for it. readonly TUTORIALS_DIR="${HOME}/tutorials" readonly README_FILE="${HOME}/README.md" @@ -75,8 +75,30 @@ fi if [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then log "installing uv ${UV_VERSION} into ${BIN_DIR}" - curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" \ - | env UV_INSTALL_DIR="${BIN_DIR}" INSTALLER_NO_MODIFY_PATH=1 sh + # Fetch the release tarball and verify its published SHA-256 rather than + # piping astral.sh/install.sh into a shell -- that installer logs "no + # checksums to verify", so nothing validates what it downloads. Mirrors the + # GPG-verified mise install in tools/install-mise.sh. + case "$(uname -m)" in + x86_64) uv_target="x86_64-unknown-linux-gnu" ;; + aarch64 | arm64) uv_target="aarch64-unknown-linux-gnu" ;; + *) + log "ERROR: unsupported architecture $(uname -m)" + exit 1 + ;; + esac + uv_dir="$(mktemp -d)" + uv_url="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${uv_target}.tar.gz" + curl -fsSL "${uv_url}" -o "${uv_dir}/uv.tar.gz" + uv_want="$(curl -fsSL "${uv_url}.sha256" | awk '{print $1}')" + uv_got="$(sha256sum "${uv_dir}/uv.tar.gz" | awk '{print $1}')" + if [[ "${uv_want}" != "${uv_got}" ]]; then + log "ERROR: uv checksum mismatch (want ${uv_want}, got ${uv_got})" + exit 1 + fi + tar -xzf "${uv_dir}/uv.tar.gz" -C "${uv_dir}" --strip-components=1 + install -m 0755 "${uv_dir}/uv" "${uv_dir}/uvx" "${BIN_DIR}/" + rm -rf "${uv_dir}" else log "uv ${UV_VERSION} already present" fi @@ -105,7 +127,12 @@ else --index "${INDEX_TORCH}" \ --index "${INDEX_VLLM}" \ --index-strategy unsafe-best-match +fi +# Checked separately from the package install: if this step is what failed, a +# rerun would otherwise see a working safe-synthesizer, skip the whole block, +# and leave a registered kernel that cannot start. +if ! "${VENV_DIR}/bin/python" -c "import ipykernel" 2>/dev/null; then log "installing notebook support" VIRTUAL_ENV="${VENV_DIR}" uv pip install ipykernel ipywidgets fi @@ -123,9 +150,12 @@ else 'from importlib.metadata import version; print(version("nemo-safe-synthesizer"))')" log "fetching tutorials for version ${NSS_VERSION}" - mkdir -p "${TUTORIALS_DIR}" - tarball_dir="$(mktemp -d)" + # Staged under $HOME so the final move is a rename on the same filesystem, + # and so a half-written extract never becomes the visible tutorials/ -- the + # ".ipynb exists" check above would otherwise treat a partial result as done. + tarball_dir="$(mktemp -d "${HOME}/.nss-fetch.XXXXXX")" tarball="${tarball_dir}/src.tar.gz" + stage="${tarball_dir}/stage" fetched=0 for ref in "refs/tags/v${NSS_VERSION}" "refs/heads/main"; do @@ -139,8 +169,12 @@ else # documented in README.md. `%%/*` yields the archive's top-level directory. listing="$(tar -tzf "${tarball}")" top="${listing%%/*}" - if tar -xzf "${tarball}" -C "${TUTORIALS_DIR}" --strip-components=3 \ + rm -rf "${stage}" + mkdir -p "${stage}" + if tar -xzf "${tarball}" -C "${stage}" --strip-components=3 \ "${top}/docs/tutorials"; then + rm -rf "${TUTORIALS_DIR}" + mv "${stage}" "${TUTORIALS_DIR}" log "tutorials extracted from ${ref}" fetched=1 break @@ -251,12 +285,22 @@ fi # kernelspec that never gets consulted and only confuses `kernelspec list`. registered=0 for target in "${kernel_targets[@]}"; do - mkdir -p "${target}" 2>/dev/null || { log "WARNING: cannot write ${target}"; continue; } + # `mkdir -p` succeeds on an existing directory even without write permission, + # so test writability separately -- otherwise an unwritable first target + # reaches `cp`, and `set -e` aborts the run instead of trying the next one. + mkdir -p "${target}" 2>/dev/null || { log "WARNING: cannot create ${target}"; continue; } + if [[ ! -w "${target}" ]]; then + log "WARNING: ${target} is not writable" + continue + fi # Keep whatever was there, once, so the original kernel stays recoverable. if [[ -f "${target}/kernel.json" && ! -f "${target}/kernel.json.orig" ]]; then - cp "${target}/kernel.json" "${target}/kernel.json.orig" + cp "${target}/kernel.json" "${target}/kernel.json.orig" 2>/dev/null || true fi - cp "${KERNEL_JSON}" "${target}/kernel.json" + cp "${KERNEL_JSON}" "${target}/kernel.json" 2>/dev/null || { + log "WARNING: cannot write ${target}/kernel.json" + continue + } chmod 0600 "${target}/kernel.json" log "registered kernel at ${target}" registered=1 From 298dfab6e9decf3df95720469f4b7fb473afb829 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Thu, 30 Jul 2026 15:48:34 -0400 Subject: [PATCH 03/10] fix(brev): guard notebook install on both ipykernel and ipywidgets Checking only ipykernel would skip the install when ipywidgets is the missing one, leaving notebook support incomplete. Signed-off-by: Yunfeng Zhang --- script/brev/setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/brev/setup.sh b/script/brev/setup.sh index 0bd6a4357..2b198810e 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -132,7 +132,7 @@ fi # Checked separately from the package install: if this step is what failed, a # rerun would otherwise see a working safe-synthesizer, skip the whole block, # and leave a registered kernel that cannot start. -if ! "${VENV_DIR}/bin/python" -c "import ipykernel" 2>/dev/null; then +if ! "${VENV_DIR}/bin/python" -c "import ipykernel, ipywidgets" 2>/dev/null; then log "installing notebook support" VIRTUAL_ENV="${VENV_DIR}" uv pip install ipykernel ipywidgets fi From 10567210b3c7193f9ba25db7ff5ee03299ceb1ac Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 13:02:47 -0400 Subject: [PATCH 04/10] fix(brev): derive package indexes from the installed release The three index URLs were hardcoded, so they tracked this repo's main while the script installed whatever was latest on PyPI. Index URLs are install-time configuration rather than wheel metadata, so they must match the release being installed; nothing kept the two in step. Resolve the latest version from the PyPI JSON API, read the CUDA index URLs from that tag's pyproject.toml, and pin the install to that exact version. Select indexes by URL rather than by index name: the flashinfer entry was renamed flashinfer-jit-cache -> flashinfer-jit-cache-cu129 between 0.1.8 and 0.1.9, so a name-keyed lookup would have silently found only two of three. Signed-off-by: Yunfeng Zhang --- script/brev/README.md | 16 +++++++++++ script/brev/setup.sh | 67 ++++++++++++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/script/brev/README.md b/script/brev/README.md index f7d61cccf..8574b6759 100644 --- a/script/brev/README.md +++ b/script/brev/README.md @@ -82,6 +82,22 @@ options, in rough order of convenience: drag and drop into the JupyterLab file b These are the non-obvious constraints the script works around. They were each found the hard way on a real instance. +- Package indexes are derived at runtime, not hardcoded. Index URLs are + install-time configuration rather than wheel metadata, so the installer must supply + them -- and they have to match the release being installed, not this repo's `main`. + The script resolves the latest version from the PyPI JSON API, fetches that tag's + `pyproject.toml`, and reads the CUDA index URLs out of it, then pins the install to + that exact version so the two cannot drift. Selection is keyed on the URL containing + `cu129`, not on the index name: the flashinfer entry was renamed + `flashinfer-jit-cache` → `flashinfer-jit-cache-cu129` between 0.1.8 and 0.1.9, so + names are not stable across releases. The parse runs inside a process substitution and + therefore cannot fail the script, so the count of discovered indexes is what validates + it. +- uv is installed from a checksum-verified tarball, not `curl | sh`. The + `astral.sh/install.sh` path logs `no checksums to verify`, so nothing validated what + it downloaded. The script fetches the pinned release tarball, compares it against the + published `.sha256`, and installs the binaries -- matching the GPG-verified mise + install in `tools/install-mise.sh`. - The setup script has a 16 KiB limit. Brev rejects anything larger, which is why the script carries short comments pointing here rather than full explanations. Check `wc -c script/brev/setup.sh` before pasting. diff --git a/script/brev/setup.sh b/script/brev/setup.sh index 2b198810e..e15ef5949 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -29,10 +29,9 @@ readonly REPO_URL="https://github.com/NVIDIA-NeMo/Safe-Synthesizer" : "${HOME:?HOME is not set}" -# $HOME is the JupyterLab file browser root, so anything visible there is part -# of the first impression. Only tutorials/ and README.md are; everything -# operational is a dotfile, which the browser hides by default. Users pick -# their own location for data -- the script does not create a folder for it. +# $HOME is the JupyterLab file browser root, so only tutorials/ and README.md +# are visible there; everything operational is a dotfile. No data folder is +# created -- users keep their files wherever they like. readonly TUTORIALS_DIR="${HOME}/tutorials" readonly README_FILE="${HOME}/README.md" @@ -42,11 +41,7 @@ readonly USER_KERNEL_DIR="${HOME}/.local/share/jupyter/kernels/python3" readonly ENV_FILE="${HOME}/.nss-env.sh" readonly LOG_FILE="${HOME}/.nss-setup.log" -# vLLM publishes cu129 wheels from a pinned commit rather than a release index. -# Keep these three in sync with the install command in docs/user-guide/getting-started.md. -readonly INDEX_FLASHINFER="https://flashinfer.ai/whl/cu129" -readonly INDEX_TORCH="https://download.pytorch.org/whl/cu129" -readonly INDEX_VLLM="https://wheels.vllm.ai/ee0da84ab9e04ac7610e28580af62c365e898389/cu129" +readonly CUDA_EXTRA="cu129" mkdir -p "${BIN_DIR}" exec > >(tee -a "${LOG_FILE}") 2>&1 @@ -75,10 +70,8 @@ fi if [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then log "installing uv ${UV_VERSION} into ${BIN_DIR}" - # Fetch the release tarball and verify its published SHA-256 rather than - # piping astral.sh/install.sh into a shell -- that installer logs "no - # checksums to verify", so nothing validates what it downloads. Mirrors the - # GPG-verified mise install in tools/install-mise.sh. + # Verify the published SHA-256 rather than piping astral.sh/install.sh into + # a shell -- that installer logs "no checksums to verify". See README. case "$(uname -m)" in x86_64) uv_target="x86_64-unknown-linux-gnu" ;; aarch64 | arm64) uv_target="aarch64-unknown-linux-gnu" ;; @@ -121,11 +114,44 @@ fi if "${VENV_DIR}/bin/safe-synthesizer" --version >/dev/null 2>&1; then log "safe-synthesizer already installed; skipping package install" else - log "installing nemo-safe-synthesizer[cu129,engine] -- this takes several minutes" - VIRTUAL_ENV="${VENV_DIR}" uv pip install "nemo-safe-synthesizer[cu129,engine]" \ - --index "${INDEX_FLASHINFER}" \ - --index "${INDEX_TORCH}" \ - --index "${INDEX_VLLM}" \ + NSS_VERSION="$(curl -fsSL https://pypi.org/pypi/nemo-safe-synthesizer/json \ + | "${VENV_DIR}/bin/python" -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])')" + + # Index URLs are install-time config, not wheel metadata, so they must match + # the release being installed rather than this repo's main. Read them from + # that release's own pyproject.toml, keyed on URL not index name. See README. + pyproject="$(mktemp)" + curl -fsSL "${REPO_URL}/raw/v${NSS_VERSION}/pyproject.toml" -o "${pyproject}" + index_args=() + index_count=0 + while IFS= read -r url; do + [[ -n "${url}" ]] || continue + index_args+=(--index "${url}") + index_count=$((index_count + 1)) + log "index: ${url}" + done < <(env CUDA_EXTRA="${CUDA_EXTRA}" "${VENV_DIR}/bin/python" - "${pyproject}" <<'PY' +import os +import sys +import tomllib + +with open(sys.argv[1], "rb") as handle: + indexes = tomllib.load(handle)["tool"]["uv"]["index"] +print("\n".join(i["url"] for i in indexes if os.environ["CUDA_EXTRA"] in i["url"])) +PY + ) + rm -f "${pyproject}" + + # The parse runs in a process substitution, so it cannot fail the script. + # The count is what actually validates it. + if [[ "${index_count}" -ne 3 ]]; then + log "ERROR: expected 3 ${CUDA_EXTRA} indexes in v${NSS_VERSION}, got ${index_count}" + exit 1 + fi + + log "installing nemo-safe-synthesizer ${NSS_VERSION} -- this takes several minutes" + VIRTUAL_ENV="${VENV_DIR}" uv pip install \ + "nemo-safe-synthesizer[${CUDA_EXTRA},engine]==${NSS_VERSION}" \ + "${index_args[@]}" \ --index-strategy unsafe-best-match fi @@ -146,8 +172,9 @@ fi if compgen -G "${TUTORIALS_DIR}/*.ipynb" >/dev/null 2>&1; then log "tutorials already present" else - NSS_VERSION="$("${VENV_DIR}/bin/python" -c \ - 'from importlib.metadata import version; print(version("nemo-safe-synthesizer"))')" + # Set above if this run installed; read off the package if the install ran previously. + NSS_VERSION="${NSS_VERSION:-$("${VENV_DIR}/bin/python" -c \ + 'from importlib.metadata import version; print(version("nemo-safe-synthesizer"))')}" log "fetching tutorials for version ${NSS_VERSION}" # Staged under $HOME so the final move is a rename on the same filesystem, From 454d73656406e905d55ffd966fe68f3ac04806b0 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 13:20:20 -0400 Subject: [PATCH 05/10] feat(brev): show a setup-in-progress notice and move welcome text to a file JupyterLab accepts connections well before provisioning finishes, so a user who opens it early saw an empty or half-populated file browser with no explanation. Write SETUP-IN-PROGRESS.md before any slow work, rewrite it from the ERR trap if provisioning fails, and replace it with README.md on success. Move the customer-facing welcome text out of a heredoc into welcome.md, fetched from the same tarball as the tutorials so the two always match. The heredoc was ~1.6 KiB of the 16 KiB Brev allows for the setup script; deriving indexes at runtime had pushed the file over that limit. Signed-off-by: Yunfeng Zhang --- script/brev/README.md | 14 +++++++ script/brev/setup.sh | 92 +++++++++++++++++++----------------------- script/brev/welcome.md | 49 ++++++++++++++++++++++ 3 files changed, 104 insertions(+), 51 deletions(-) create mode 100644 script/brev/welcome.md diff --git a/script/brev/README.md b/script/brev/README.md index 8574b6759..6766bb435 100644 --- a/script/brev/README.md +++ b/script/brev/README.md @@ -18,6 +18,9 @@ contents into the console for the change to take effect. - `setup.sh`: Pasted into the Launchable's Setup Script field. Installs the CUDA build of Safe Synthesizer into a dedicated venv, registers it as the default Jupyter kernel, and drops the tutorial notebooks in `$HOME`. +- `welcome.md`: Becomes the customer's `$HOME/README.md`. Fetched at provisioning + time from the same tarball as the tutorials, not baked into `setup.sh` -- it would + otherwise consume a tenth of the 16 KiB script budget. ### Console configuration @@ -65,6 +68,7 @@ there. Everything operational is a dotfile, which the browser hides by default. $HOME/ tutorials/ the three tutorial notebooks and their datasets README.md where to start, rendered on double-click + (SETUP-IN-PROGRESS.md until setup finishes) .nss-venv/ cu129 venv, registered as the default kernel .cache/huggingface/ model cache (Hugging Face's default location) @@ -98,6 +102,16 @@ hard way on a real instance. it downloaded. The script fetches the pinned release tarball, compares it against the published `.sha256`, and installs the binaries -- matching the GPG-verified mise install in `tools/install-mise.sh`. +- A placeholder file marks the home directory while setup runs. JupyterLab + accepts connections well before this script finishes, so a user who opens it early + would otherwise see an empty or half-populated file browser and assume the Launchable + is broken. `SETUP-IN-PROGRESS.md` is written before any slow work, rewritten by the + `ERR` trap if provisioning fails, and replaced by `README.md` on success. +- The welcome text lives in `welcome.md`, not a heredoc. It is pulled from the + same tarball as the tutorials, so the two always match, and it is staged as a dotfile + until the final step so it never appears while setup is still running. The fetch is + non-fatal: `script/brev/` exists in no released tag, so it resolves only from the + `main` fallback until a release includes it. - The setup script has a 16 KiB limit. Brev rejects anything larger, which is why the script carries short comments pointing here rather than full explanations. Check `wc -c script/brev/setup.sh` before pasting. diff --git a/script/brev/setup.sh b/script/brev/setup.sh index e15ef5949..c2f1a570a 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -34,6 +34,8 @@ readonly REPO_URL="https://github.com/NVIDIA-NeMo/Safe-Synthesizer" # created -- users keep their files wherever they like. readonly TUTORIALS_DIR="${HOME}/tutorials" readonly README_FILE="${HOME}/README.md" +readonly WAIT_FILE="${HOME}/SETUP-IN-PROGRESS.md" +readonly WELCOME_STAGED="${HOME}/.nss-welcome.md" readonly BIN_DIR="${HOME}/.local/bin" readonly VENV_DIR="${HOME}/.nss-venv" @@ -47,9 +49,32 @@ mkdir -p "${BIN_DIR}" exec > >(tee -a "${LOG_FILE}") 2>&1 log() { printf '\n=== [nss-setup] %s\n' "$*"; } -fail() { printf '\n!!! [nss-setup] FAILED at line %s\n' "$1" >&2; } +fail() { + printf '\n!!! [nss-setup] FAILED at line %s\n' "$1" >&2 + # Leave the waiting user something truthful rather than "please wait" forever. + cat >"${WAIT_FILE}" <"${WAIT_FILE}" <<'EOF' +# Setting up -- please wait + +NeMo Safe Synthesizer is still installing -- roughly 5-10 minutes from when +the instance started. Files appear as it progresses, so a partly-filled file +browser is expected. Nothing here is ready to run yet. + +When setup finishes, this file is replaced by README.md. Refresh to check. +EOF + export PATH="${BIN_DIR}:${PATH}" log "user=$(id -un) home=${HOME}" @@ -203,6 +228,14 @@ else rm -rf "${TUTORIALS_DIR}" mv "${stage}" "${TUTORIALS_DIR}" log "tutorials extracted from ${ref}" + # Same tarball, so the welcome text matches the tutorials. Staged + # hidden, moved into place at the end. Non-fatal -- see README. + if tar -xzf "${tarball}" -C "${tarball_dir}" --strip-components=3 \ + "${top}/script/brev/welcome.md" 2>/dev/null; then + mv "${tarball_dir}/welcome.md" "${WELCOME_STAGED}" + else + log "WARNING: welcome.md not present in ${ref}" + fi fetched=1 break fi @@ -348,58 +381,15 @@ log "verifying install" -c "import torch; print('cuda available:', torch.cuda.is_available())" # --------------------------------------------------------------------------- -# Customer-facing README, rendered by JupyterLab on double-click. +# Hand over: swap the "please wait" file for the welcome text. # --------------------------------------------------------------------------- -log "writing ${README_FILE}" -cat >"${README_FILE}" < -- Source: <${REPO_URL}> -EOF +if [[ -f "${WELCOME_STAGED}" ]]; then + mv "${WELCOME_STAGED}" "${README_FILE}" +else + log "WARNING: no welcome.md staged; skipping ${README_FILE}" +fi +rm -f "${WAIT_FILE}" trap - ERR log "setup complete" diff --git a/script/brev/welcome.md b/script/brev/welcome.md new file mode 100644 index 000000000..0156e8d27 --- /dev/null +++ b/script/brev/welcome.md @@ -0,0 +1,49 @@ + + + +# NeMo Safe Synthesizer + +Create private, safe versions of sensitive tabular data -- entirely synthetic +records with no one-to-one mapping back to your originals. + +Everything is installed and ready. Nothing to set up. + +## Start here + +Open `tutorials/safe-synthesizer-101.ipynb` and run the cells top to bottom. +It takes about 15 minutes and walks through the full pipeline on a sample +dataset. + +The other two notebooks go deeper: + +- `tutorials/differential-privacy.ipynb` -- formal privacy guarantees (~1 hour) +- `tutorials/time-series-financial-transactions.ipynb` -- sequential data (~20 minutes) + +## Using your own data + +Upload a CSV anywhere you like -- drag and drop into the file browser on the +left -- and point the notebook at it: + +```python +from nemo_safe_synthesizer import SafeSynthesizer + +results = SafeSynthesizer().with_data_source("my-data.csv").run() +``` + +There is no required location for your files. Put them wherever suits you. + +## Good to know + +- Notebooks already run on the right Python. You should never need to change + the kernel; if you do switch it, pick "Safe Synthesizer". +- Model weights download on first use, so the first run is slower than later + ones. +- This instance bills continuously and cannot be paused. Delete it when you are + finished, and download anything you want to keep first. +- Provisioning log: `.nss-setup.log` (hidden files are off by default in the + file browser). + +## Learn more + +- Documentation: +- Source: From b505549763f4b28b2f56abac9cd36429064072a0 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 13:39:40 -0400 Subject: [PATCH 06/10] docs: add Brev Launchable badge to README and docs Links the published Launchable so a reader can try Safe Synthesizer on a provisioned GPU instance without a local CUDA, driver, or Python setup. Placed on the README Quick Start, the docs landing page, the Getting Started install section, and the tutorials index -- the four places someone lands before deciding whether to install. The README uses a element so the badge follows GitHub's theme; the docs use Material's #only-light / #only-dark suffixes for the same reason. Each mention states that the instance bills continuously and cannot be paused, since most Brev providers do not support stopping one. Signed-off-by: Yunfeng Zhang --- README.md | 9 +++++++++ docs/index.md | 15 +++++++++++++++ docs/tutorials/index.md | 6 ++++++ docs/user-guide/getting-started.md | 8 ++++++++ 4 files changed, 38 insertions(+) diff --git a/README.md b/README.md index d49f60f53..49f2e8a18 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,15 @@ NVIDIA NeMo Safe Synthesizer creates private, safe versions of sensitive tabular ## Quick Start + + + + Launch on Brev + + + +Try it without installing anything: the launchable above deploys a GPU instance with NeMo Safe Synthesizer and the tutorial notebooks preinstalled. Note that the instance bills continuously and cannot be paused -- delete it when you are finished. + Read detailed usage below, or jump to the documentation with [Getting Started](https://nvidia-nemo.github.io/Safe-Synthesizer/user-guide/getting-started/) or the [Safe Synthesizer 101](https://nvidia-nemo.github.io/Safe-Synthesizer/tutorials/safe-synthesizer-101/) notebook. diff --git a/docs/index.md b/docs/index.md index cc0b36f1a..6880c771d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,6 +17,21 @@ NeMo Safe Synthesizer creates private, safe versions of sensitive tabular datase - Comprehensive evaluation -- Privacy and quality metrics in an in-depth HTML report - Flexible interfaces -- CLI for scripting, Python SDK for programmatic workflows, YAML configuration +## Try It Without Installing + +[![Launch on Brev](https://brev-assets.s3.us-west-1.amazonaws.com/nv-lb-dark.svg#only-light)](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17) +[![Launch on Brev](https://brev-assets.s3.us-west-1.amazonaws.com/nv-lb-light.svg#only-dark)](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17) + +Deploys an NVIDIA GPU instance with NeMo Safe Synthesizer and the tutorial notebooks +already installed, so there is no CUDA, driver, or Python setup to do. The +[Safe Synthesizer 101](tutorials/safe-synthesizer-101.ipynb) notebook runs end to end in +about 15 minutes. + +!!! warning "The instance bills continuously" + Most GPU providers on Brev do not support stopping an instance. Billing runs from + creation until deletion, so delete the instance when you are finished and download + anything you want to keep first. + !!! info "System Requirements" NeMo Safe Synthesizer requires a Linux machine with an NVIDIA GPU (A100 80GB+ recommended) and CUDA 12.9+ to run the training and generation pipeline. macOS, Windows, and Apple Silicon are not supported for pipeline execution. A CPU-only install is available for development and configuration validation -- see [Getting Started](user-guide/getting-started.md#install-the-package). diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 9d481e756..75c10eaf5 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -5,6 +5,12 @@ Interactive Jupyter notebook tutorials for NeMo Safe Synthesizer. +[![Launch on Brev](https://brev-assets.s3.us-west-1.amazonaws.com/nv-lb-dark.svg#only-light)](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17) +[![Launch on Brev](https://brev-assets.s3.us-west-1.amazonaws.com/nv-lb-light.svg#only-dark)](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17) + +These notebooks need a Linux machine with an NVIDIA GPU. The launchable above provides +one with Safe Synthesizer and all three notebooks already installed. + ## Available Tutorials - [Safe Synthesizer 101](safe-synthesizer-101.ipynb) -- learn the fundamentals diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index 7fc4aea49..cf900d36b 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -26,6 +26,14 @@ does at each stage. ### Install the Package +!!! tip "Skip installation entirely" + [![Launch on Brev](https://brev-assets.s3.us-west-1.amazonaws.com/nv-lb-dark.svg#only-light)](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17) + [![Launch on Brev](https://brev-assets.s3.us-west-1.amazonaws.com/nv-lb-light.svg#only-dark)](https://brev.nvidia.com/launchable/deploy/now?launchableID=env-3HBtA2NKQaBukL2TyDphWUcvQ17) + + Deploys a GPU instance with everything below already done, plus the tutorial + notebooks. Useful for evaluating Safe Synthesizer without a local NVIDIA GPU. The + instance bills continuously and cannot be paused -- delete it when you are finished. + The CUDA and CPU extras depend on packages (PyTorch, FlashInfer) hosted on indexes outside PyPI. You must pass the extra index URLs shown below. From 0dffb0c10cf1b1beb75ce10fb809fd0d7590ca9e Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 14:07:36 -0400 Subject: [PATCH 07/10] fix(brev): gate tutorial fetch on a completion marker Keying the skip on "any .ipynb exists" meant a partial tutorials/ directory left behind by an interrupted earlier run was treated as complete. Write a .fetched marker after the staged directory is moved into place, and guard on that instead. Also add the continuous-billing warning to the tutorials index, which exposed a launch badge without it, and drop a leftover decorative bold from a table data cell. Signed-off-by: Yunfeng Zhang --- docs/tutorials/index.md | 5 +++++ script/brev/README.md | 2 +- script/brev/setup.sh | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md index 75c10eaf5..126354691 100644 --- a/docs/tutorials/index.md +++ b/docs/tutorials/index.md @@ -11,6 +11,11 @@ Interactive Jupyter notebook tutorials for NeMo Safe Synthesizer. These notebooks need a Linux machine with an NVIDIA GPU. The launchable above provides one with Safe Synthesizer and all three notebooks already installed. +!!! warning "The instance bills continuously" + Most GPU providers on Brev do not support stopping an instance. Billing runs from + creation until deletion, so delete the instance when you are finished and download + anything you want to keep first. + ## Available Tutorials - [Safe Synthesizer 101](safe-synthesizer-101.ipynb) -- learn the fundamentals diff --git a/script/brev/README.md b/script/brev/README.md index 6766bb435..084df2f88 100644 --- a/script/brev/README.md +++ b/script/brev/README.md @@ -35,7 +35,7 @@ Launchable with these settings. | Software | Image ID | Leave blank | | Source | Code source | No code files (`setup.sh` downloads the tutorials itself) | | Hardware | GPU | 1× 80 GiB VRAM, single GPU | -| Hardware | Disk | 200 GiB or more -- **not resizable after creation** | +| Hardware | Disk | 200 GiB or more -- not resizable after creation | | Network | Ports | 8888, named `jupyter` | | Access | Visibility | Anyone with the link | diff --git a/script/brev/setup.sh b/script/brev/setup.sh index c2f1a570a..073719145 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -194,7 +194,7 @@ fi # wheel so the notebooks cannot outrun the package. See README.md. # --------------------------------------------------------------------------- -if compgen -G "${TUTORIALS_DIR}/*.ipynb" >/dev/null 2>&1; then +if [[ -f "${TUTORIALS_DIR}/.fetched" ]]; then log "tutorials already present" else # Set above if this run installed; read off the package if the install ran previously. @@ -227,6 +227,9 @@ else "${top}/docs/tutorials"; then rm -rf "${TUTORIALS_DIR}" mv "${stage}" "${TUTORIALS_DIR}" + # Written last: the guard above keys on this, not on "a notebook + # exists", so a partial directory left by any earlier run is redone. + : >"${TUTORIALS_DIR}/.fetched" log "tutorials extracted from ${ref}" # Same tarball, so the welcome text matches the tutorials. Staged # hidden, moved into place at the end. Non-fatal -- see README. From dc517c2927b4146ffd03192b4541143734f06d03 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 14:47:32 -0400 Subject: [PATCH 08/10] fix(brev): require at least three CUDA indexes, not exactly three The index count guards against a rename slipping past the URL filter, but equality also fails if pyproject legitimately gains an index. A floor keeps the regression check while tolerating growth. Signed-off-by: Yunfeng Zhang --- script/brev/setup.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/script/brev/setup.sh b/script/brev/setup.sh index 073719145..ed870d907 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -166,10 +166,11 @@ PY ) rm -f "${pyproject}" - # The parse runs in a process substitution, so it cannot fail the script. - # The count is what actually validates it. - if [[ "${index_count}" -ne 3 ]]; then - log "ERROR: expected 3 ${CUDA_EXTRA} indexes in v${NSS_VERSION}, got ${index_count}" + # The parse runs in a process substitution, so it cannot fail the script; the + # count is what validates it. A floor rather than equality: pyproject may + # gain indexes, but dropping below three means a rename slipped past. + if [[ "${index_count}" -lt 3 ]]; then + log "ERROR: expected 3+ ${CUDA_EXTRA} indexes in v${NSS_VERSION}, got ${index_count}" exit 1 fi From 28246454cd72e604126b7c49768c07702a4dcf79 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 14:58:10 -0400 Subject: [PATCH 09/10] refactor(brev): compress setup.sh comments to reclaim script budget The file was 43 bytes under Brev's 16 KiB limit, so any further change would not fit. Comments were 41% of it, largely restating what script/brev/README.md already documents at length. Drop the section banner rules, shorten the header, and reduce each multi-line comment to a single line pointing at the README. No code changed: the non-comment, non-blank lines are byte-identical before and after, and both embedded Python blocks were executed to confirm. 16341 -> 13020 bytes, leaving 3364 under the cap. Signed-off-by: Yunfeng Zhang --- script/brev/setup.sh | 94 +++++++++++--------------------------------- 1 file changed, 24 insertions(+), 70 deletions(-) diff --git a/script/brev/setup.sh b/script/brev/setup.sh index ed870d907..39c41adf7 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -3,23 +3,15 @@ # SPDX-License-Identifier: Apache-2.0 # # setup.sh -- provision a Brev VM-Mode Launchable for NeMo Safe Synthesizer. +# Paste into the Launchable's Setup Script field. Runs once, unprivileged, +# after Jupyter is installed; everything lands under $HOME. Idempotent. # -# Brev runs this once as the unprivileged instance user (the account name -# varies by provider), after the VM is created and Jupyter is installed. It -# installs the CUDA build of Safe Synthesizer into a dedicated venv, registers -# it as the default Jupyter kernel, and drops the tutorials in $HOME. +# Launch parameters, passed by Brev as environment variables: +# NSS_INFERENCE_KEY NIM key for column classification (degraded without it) +# HF_TOKEN only for gated Hugging Face models # -# Everything lands under $HOME; the script never requires root. Paste it into -# the Launchable's Setup Script field. See README.md in this directory for the -# console settings and the reasoning behind the non-obvious steps below. -# -# Launch parameters (Brev exposes them as environment variables): -# NSS_INFERENCE_KEY Optional. NVIDIA NIM key for column classification. -# Without it, classification runs in degraded mode. -# HF_TOKEN Optional. Needed only for gated Hugging Face models. -# -# The script is idempotent: rerunning it is safe and skips completed stages. - +# script/brev/README.md explains every non-obvious step below. Each one exists +# because a deploy failed without it -- read it before simplifying anything. set -euo pipefail readonly UV_VERSION="0.9.30" @@ -29,9 +21,7 @@ readonly REPO_URL="https://github.com/NVIDIA-NeMo/Safe-Synthesizer" : "${HOME:?HOME is not set}" -# $HOME is the JupyterLab file browser root, so only tutorials/ and README.md -# are visible there; everything operational is a dotfile. No data folder is -# created -- users keep their files wherever they like. +# $HOME is the file browser root: only tutorials/ and README.md are visible. readonly TUTORIALS_DIR="${HOME}/tutorials" readonly README_FILE="${HOME}/README.md" readonly WAIT_FILE="${HOME}/SETUP-IN-PROGRESS.md" @@ -51,7 +41,7 @@ exec > >(tee -a "${LOG_FILE}") 2>&1 log() { printf '\n=== [nss-setup] %s\n' "$*"; } fail() { printf '\n!!! [nss-setup] FAILED at line %s\n' "$1" >&2 - # Leave the waiting user something truthful rather than "please wait" forever. + # Truthful, rather than leaving the wait notice up forever. cat >"${WAIT_FILE}" <"${WAIT_FILE}" <<'EOF' # Setting up -- please wait @@ -79,9 +68,7 @@ export PATH="${BIN_DIR}:${PATH}" log "user=$(id -un) home=${HOME}" -# --------------------------------------------------------------------------- # Preflight: this Launchable only makes sense on an NVIDIA GPU instance. -# --------------------------------------------------------------------------- if command -v nvidia-smi >/dev/null 2>&1; then nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv,noheader || true @@ -89,14 +76,11 @@ else log "WARNING: nvidia-smi not found. Training and generation will not work." fi -# --------------------------------------------------------------------------- # uv -- installed into ~/.local/bin, which needs no privileges. -# --------------------------------------------------------------------------- if [[ "$(uv --version 2>/dev/null | awk '{print $2}')" != "${UV_VERSION}" ]]; then log "installing uv ${UV_VERSION} into ${BIN_DIR}" - # Verify the published SHA-256 rather than piping astral.sh/install.sh into - # a shell -- that installer logs "no checksums to verify". See README. + # Verify the published SHA-256; astral's installer verifies nothing. case "$(uname -m)" in x86_64) uv_target="x86_64-unknown-linux-gnu" ;; aarch64 | arm64) uv_target="aarch64-unknown-linux-gnu" ;; @@ -124,10 +108,7 @@ fi # The cu129 wheels are large and their indexes are not always fast. export UV_HTTP_TIMEOUT=300 -# --------------------------------------------------------------------------- -# Virtual environment -- deliberately separate from the one Jupyter runs on, so -# a failed cu129 install cannot take the notebook server down with it. -# --------------------------------------------------------------------------- +# Venv kept separate from Jupyter's, so a failed install cannot break it. if [[ ! -x "${VENV_DIR}/bin/python" ]]; then log "creating venv at ${VENV_DIR} (python ${PYTHON_VERSION})" @@ -142,9 +123,7 @@ else NSS_VERSION="$(curl -fsSL https://pypi.org/pypi/nemo-safe-synthesizer/json \ | "${VENV_DIR}/bin/python" -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])')" - # Index URLs are install-time config, not wheel metadata, so they must match - # the release being installed rather than this repo's main. Read them from - # that release's own pyproject.toml, keyed on URL not index name. See README. + # Indexes come from the installed release's pyproject, keyed on URL not name. pyproject="$(mktemp)" curl -fsSL "${REPO_URL}/raw/v${NSS_VERSION}/pyproject.toml" -o "${pyproject}" index_args=() @@ -166,9 +145,7 @@ PY ) rm -f "${pyproject}" - # The parse runs in a process substitution, so it cannot fail the script; the - # count is what validates it. A floor rather than equality: pyproject may - # gain indexes, but dropping below three means a rename slipped past. + # The parse cannot fail the script from a process substitution; count validates. if [[ "${index_count}" -lt 3 ]]; then log "ERROR: expected 3+ ${CUDA_EXTRA} indexes in v${NSS_VERSION}, got ${index_count}" exit 1 @@ -181,19 +158,13 @@ PY --index-strategy unsafe-best-match fi -# Checked separately from the package install: if this step is what failed, a -# rerun would otherwise see a working safe-synthesizer, skip the whole block, -# and leave a registered kernel that cannot start. +# Checked separately, or a rerun skips it and the kernel cannot start. if ! "${VENV_DIR}/bin/python" -c "import ipykernel, ipywidgets" 2>/dev/null; then log "installing notebook support" VIRTUAL_ENV="${VENV_DIR}" uv pip install ipykernel ipywidgets fi -# --------------------------------------------------------------------------- -# Tutorial notebooks. Tarball extract rather than a clone, so they land flat in -# tutorials/ and git is not required. Pinned to the tag matching the installed -# wheel so the notebooks cannot outrun the package. See README.md. -# --------------------------------------------------------------------------- +# Tutorials: tarball extract pinned to the installed release. See README. if [[ -f "${TUTORIALS_DIR}/.fetched" ]]; then log "tutorials already present" @@ -203,9 +174,7 @@ else 'from importlib.metadata import version; print(version("nemo-safe-synthesizer"))')}" log "fetching tutorials for version ${NSS_VERSION}" - # Staged under $HOME so the final move is a rename on the same filesystem, - # and so a half-written extract never becomes the visible tutorials/ -- the - # ".ipynb exists" check above would otherwise treat a partial result as done. + # Staged under $HOME: same-filesystem rename, and never half-visible. tarball_dir="$(mktemp -d "${HOME}/.nss-fetch.XXXXXX")" tarball="${tarball_dir}/src.tar.gz" stage="${tarball_dir}/stage" @@ -217,9 +186,7 @@ else continue fi - # Exact member path, not a glob, and the listing read into a variable - # rather than piped to `head` -- both are GNU/BSD tar portability traps - # documented in README.md. `%%/*` yields the archive's top-level directory. + # Exact member path, and no pipe to head: GNU/BSD tar traps. See README. listing="$(tar -tzf "${tarball}")" top="${listing%%/*}" rm -rf "${stage}" @@ -228,12 +195,10 @@ else "${top}/docs/tutorials"; then rm -rf "${TUTORIALS_DIR}" mv "${stage}" "${TUTORIALS_DIR}" - # Written last: the guard above keys on this, not on "a notebook - # exists", so a partial directory left by any earlier run is redone. + # Written last; the guard keys on this, so partial runs are redone. : >"${TUTORIALS_DIR}/.fetched" log "tutorials extracted from ${ref}" - # Same tarball, so the welcome text matches the tutorials. Staged - # hidden, moved into place at the end. Non-fatal -- see README. + # Same tarball as the tutorials. Non-fatal -- see README. if tar -xzf "${tarball}" -C "${tarball_dir}" --strip-components=3 \ "${top}/script/brev/welcome.md" 2>/dev/null; then mv "${tarball_dir}/welcome.md" "${WELCOME_STAGED}" @@ -253,10 +218,7 @@ else fi fi -# --------------------------------------------------------------------------- -# Environment. Set in two places: this file for terminals, and the kernelspec -# below for notebooks, since the Jupyter server never reads shell rc files. -# --------------------------------------------------------------------------- +# Environment for terminals; the kernelspec below covers notebooks. log "writing ${ENV_FILE}" { @@ -278,16 +240,12 @@ if ! grep -qF "${ENV_FILE}" "${HOME}/.bashrc" 2>/dev/null; then printf '\n[ -f %s ] && . %s\n' "${ENV_FILE}" "${ENV_FILE}" >>"${HOME}/.bashrc" fi -# --------------------------------------------------------------------------- -# Jupyter kernel. Registered as `python3` -- the name the notebooks declare -- -# so nobody has to switch kernels. Which directory wins is not obvious, and the -# user one usually loses; see README.md. Ask the server rather than guess. -# --------------------------------------------------------------------------- +# Kernel registered as `python3` (the name the notebooks declare), in the +# directory the server actually consults -- not the user one. See README. log "building kernelspec" KERNEL_JSON="$(mktemp)" -# `env` rather than a command-prefix assignment: bash rejects prefix -# assignments to readonly variables, and these three are readonly. +# `env`, because bash rejects prefix assignments to readonly variables. env VENV_DIR="${VENV_DIR}" BIN_DIR="${BIN_DIR}" \ "${VENV_DIR}/bin/python" - "${KERNEL_JSON}" <<'PY' import json @@ -375,18 +333,14 @@ if [[ "${registered}" -ne 1 ]]; then log "WARNING: kernel not registered; notebooks may open on the wrong Python" fi -# --------------------------------------------------------------------------- # Smoke check -- fail provisioning loudly rather than handing over a broken VM. -# --------------------------------------------------------------------------- log "verifying install" "${VENV_DIR}/bin/safe-synthesizer" --version "${VENV_DIR}/bin/python" \ -c "import torch; print('cuda available:', torch.cuda.is_available())" -# --------------------------------------------------------------------------- # Hand over: swap the "please wait" file for the welcome text. -# --------------------------------------------------------------------------- if [[ -f "${WELCOME_STAGED}" ]]; then mv "${WELCOME_STAGED}" "${README_FILE}" From 6358cac6e99519c03dd124dede9cd19b1a094b09 Mon Sep 17 00:00:00 2001 From: Yunfeng Zhang Date: Mon, 3 Aug 2026 15:35:46 -0400 Subject: [PATCH 10/10] fix(brev): harden CUDA index discovery Signed-off-by: Yunfeng Zhang --- script/brev/setup.sh | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/script/brev/setup.sh b/script/brev/setup.sh index 39c41adf7..a123e5cf7 100755 --- a/script/brev/setup.sh +++ b/script/brev/setup.sh @@ -123,7 +123,8 @@ else NSS_VERSION="$(curl -fsSL https://pypi.org/pypi/nemo-safe-synthesizer/json \ | "${VENV_DIR}/bin/python" -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])')" - # Indexes come from the installed release's pyproject, keyed on URL not name. + # Indexes come from the installed release's pyproject. Match both generated + # names and URLs because static index names do not enforce the CUDA suffix. pyproject="$(mktemp)" curl -fsSL "${REPO_URL}/raw/v${NSS_VERSION}/pyproject.toml" -o "${pyproject}" index_args=() @@ -140,7 +141,16 @@ import tomllib with open(sys.argv[1], "rb") as handle: indexes = tomllib.load(handle)["tool"]["uv"]["index"] -print("\n".join(i["url"] for i in indexes if os.environ["CUDA_EXTRA"] in i["url"])) + +cuda_extra = os.environ["CUDA_EXTRA"] +print( + "\n".join( + index["url"] + for index in indexes + if index["name"].endswith(f"-{cuda_extra}") + or f"/{cuda_extra}" in index["url"] + ) +) PY ) rm -f "${pyproject}"