diff --git a/CHANGELOG.md b/CHANGELOG.md index be9520e..854a7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- **`QUANTUI_ENABLE_SLURM` site gate** — SLURM batch mode stays hidden and + undispatchable until an operator sets this environment variable. Student CPU + images and JupyterHub profiles can omit it (default off); instructors enable + cluster testing with `QUANTUI_ENABLE_SLURM=1` without any extra student + command. + ### Fixed - **`apptainer/quantui-gpu.def` never installed the `xtb` extra**, so GFN-FF diff --git a/apptainer/slurm/README.md b/apptainer/slurm/README.md index 3b3a400..8975805 100644 --- a/apptainer/slurm/README.md +++ b/apptainer/slurm/README.md @@ -19,6 +19,7 @@ See the [NCShare SLURM batch runbook](https://github.com/The-Schultz-Lab/QuantUI | Variable | Default | Purpose | |----------|---------|---------| +| `QUANTUI_ENABLE_SLURM` | *(unset — off)* | Show **SLURM batch (cluster)** in Settings and allow cluster dispatch. Requires `sbatch` on PATH. Leave unset in student CPU images; set on instructor/test profiles when validating NCShare. | | `QUANTUI_MAX_CONCURRENT_JOBS` | `2` | Active SLURM job cap | | `QUANTUI_SLURM_SUBMIT_COOLDOWN_S` | `30` | Min seconds between submits (`0` disables) | | `QUANTUI_SLURM_STALE_NO_ID_S` | `600` | Stale registry rows without SLURM id | diff --git a/docs/CLI.md b/docs/CLI.md index 8976689..a593c0a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -251,6 +251,7 @@ successfully; only the auto-open is best-effort. | --- | --- | | `QUANTUI_LOG_DIR` | Override the default `~/.quantui/logs/` location. The dashboard's default output (`~/.quantui/dashboard.html`) follows: it lives one level up from the active `QUANTUI_LOG_DIR`. | | `QUANTUI_DISABLE_GPU` | Force CPU mode even when gpu4pyscf is installed. `quantui gpu check` reports this as the reason. Accepted truthy values: `1`, `true`, `True`. | +| `QUANTUI_ENABLE_SLURM` | Opt in to **SLURM batch (cluster)** on the System Settings → Execution backend dropdown. Requires `sbatch` on PATH. Off by default so student deployments stay in-kernel until an operator enables cluster mode in the Apptainer image, JupyterHub spawner, or shell. Accepted truthy values: `1`, `true`, `yes`, `on`. | | `QUANTUI_FREQ_PARALLEL` | Opt in to parallel **CPU** workers for the IR-intensity finite-difference loop in frequency calculations (`6N` displaced SCFs). Same effect as the **Parallelize IR intensity displacements** checkbox on the System Settings tab; when this env var is set it overrides the saved setting. Reference SCF and Hessian still use gpu4pyscf when available. Requires ≥4 cores and ≥2 atoms. Off by default. Accepted truthy values: `1`, `true`, `yes`, `on`. | --- diff --git a/quantui/app_builders.py b/quantui/app_builders.py index bc0ba84..f66fdb5 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -18,6 +18,7 @@ sync_textarea_from_table, ) from quantui.backends.cluster_config import max_concurrent_jobs +from quantui.backends.dispatch import slurm_unavailable_note from quantui.help_content import HELP_TOPICS from quantui.live_log import LiveLog from quantui.orbital_visualization import ( @@ -349,7 +350,7 @@ def _render_status(gpu_state: Any) -> str: if not slurm_available: exec_backend_note = widgets.HTML( f'
' - "SLURM unavailable here (sbatch not found).
" + f"{slurm_unavailable_note()}" ) else: limit = max_concurrent_jobs() diff --git a/quantui/app_slurm.py b/quantui/app_slurm.py index f4b66db..c1294b9 100644 --- a/quantui/app_slurm.py +++ b/quantui/app_slurm.py @@ -25,6 +25,7 @@ calc_type_key_from_app, is_slurm_available, slurm_backend_for_app, + slurm_unavailable_user_message, ) from quantui.backends.registry import JobRecord, JobRegistry from quantui.backends.slurm_errors import format_error_html @@ -140,9 +141,7 @@ def startup_slurm_check(app: Any) -> None: def submit_slurm_run(app: Any) -> None: """Submit the current configuration to SLURM and start the monitor thread.""" if not is_slurm_available(): - app.run_status.value = ( - "SLURM is not available on this system (sbatch not found)." - ) + app.run_status.value = slurm_unavailable_user_message() return calc_type = calc_type_key_from_app(app) diff --git a/quantui/backends/dispatch.py b/quantui/backends/dispatch.py index 0bd4b6b..4cdfd27 100644 --- a/quantui/backends/dispatch.py +++ b/quantui/backends/dispatch.py @@ -4,6 +4,7 @@ from __future__ import annotations +import os import shutil from pathlib import Path from typing import Any @@ -17,12 +18,53 @@ # Calc types that may run a DFT geometry optimization before the main step. _PREOPT_CALC_TYPES = frozenset({"frequency", "tddft"}) +_TRUTHY_ENV = frozenset({"1", "true", "yes", "on"}) -def is_slurm_available() -> bool: - """Return True when ``sbatch`` is on PATH.""" + +def _env_truthy(name: str) -> bool: + """Return True when ``name`` is set to a truthy value in the environment.""" + return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV + + +def is_slurm_cli_present() -> bool: + """Return True when the SLURM CLI (``sbatch``) is on PATH.""" return shutil.which("sbatch") is not None +def is_slurm_site_enabled() -> bool: + """Return True when the operator has opted in via ``QUANTUI_ENABLE_SLURM``.""" + return _env_truthy("QUANTUI_ENABLE_SLURM") + + +def is_slurm_available() -> bool: + """Return True when SLURM batch mode is available for the UI and dispatch.""" + return is_slurm_cli_present() and is_slurm_site_enabled() + + +def slurm_unavailable_note() -> str: + """Return a short Settings-tab note when SLURM batch mode is not offered.""" + if is_slurm_available(): + return "" + if not is_slurm_cli_present(): + return "SLURM unavailable here (sbatch not found)." + return ( + "SLURM batch mode is off for this deployment " + "(operator: set QUANTUI_ENABLE_SLURM=1 to enable)." + ) + + +def slurm_unavailable_user_message() -> str: + """Return a user-facing run-status message when SLURM dispatch is blocked.""" + if is_slurm_available(): + return "" + if not is_slurm_cli_present(): + return "SLURM is not available on this system (sbatch not found)." + return ( + "SLURM batch mode is disabled on this deployment. " + "Ask your instructor to set QUANTUI_ENABLE_SLURM=1." + ) + + def calc_type_key_from_app(app: Any) -> str: from quantui.app_runflow import calc_type_key diff --git a/tests/test_app_slurm.py b/tests/test_app_slurm.py index ca013d9..fbec24a 100644 --- a/tests/test_app_slurm.py +++ b/tests/test_app_slurm.py @@ -20,6 +20,10 @@ build_calculation_request, calc_type_key_from_app, is_slurm_available, + is_slurm_cli_present, + is_slurm_site_enabled, + slurm_unavailable_note, + slurm_unavailable_user_message, ) from quantui.backends.registry import JobRegistry from quantui.security import SecurityError @@ -60,12 +64,36 @@ def test_calc_type_key_from_app(self): assert calc_type_key_from_app(app) == "frequency" @patch("quantui.backends.dispatch.shutil.which") - def test_is_slurm_available(self, mock_which): + def test_is_slurm_available(self, mock_which, monkeypatch): mock_which.return_value = "/usr/bin/sbatch" + monkeypatch.delenv("QUANTUI_ENABLE_SLURM", raising=False) + assert is_slurm_cli_present() is True + assert is_slurm_site_enabled() is False + assert is_slurm_available() is False + + monkeypatch.setenv("QUANTUI_ENABLE_SLURM", "1") + assert is_slurm_site_enabled() is True assert is_slurm_available() is True + mock_which.return_value = None + assert is_slurm_cli_present() is False assert is_slurm_available() is False + @patch("quantui.backends.dispatch.shutil.which") + def test_slurm_unavailable_messages(self, mock_which, monkeypatch): + mock_which.return_value = None + monkeypatch.delenv("QUANTUI_ENABLE_SLURM", raising=False) + assert "sbatch not found" in slurm_unavailable_note() + assert "sbatch not found" in slurm_unavailable_user_message() + + mock_which.return_value = "/usr/bin/sbatch" + assert "QUANTUI_ENABLE_SLURM=1" in slurm_unavailable_note() + assert "QUANTUI_ENABLE_SLURM=1" in slurm_unavailable_user_message() + + monkeypatch.setenv("QUANTUI_ENABLE_SLURM", "yes") + assert slurm_unavailable_note() == "" + assert slurm_unavailable_user_message() == "" + class TestUseSlurmExecution: @patch("quantui.app_slurm.is_slurm_available", return_value=True)