diff --git a/config/jupyterhub/01-spawner.py b/config/jupyterhub/01-spawner.py index 917ee4b..56faa12 100644 --- a/config/jupyterhub/01-spawner.py +++ b/config/jupyterhub/01-spawner.py @@ -317,6 +317,41 @@ def _setup_trust_bundle(spawner): if _hub_external_host: env["NEBI_SERVER_ALLOWED_ORIGINS"] = f"https://{_hub_external_host}" +# code-server >= 4.106 exits this many seconds after its last browser +# connection closes. Keyed to singleuserCuller.server.shutdownNoActivityTimeout +# (via _CHART_DERIVED), NOT the hub-level cull.timeout: the hub culler never +# fires while a tab is connected (CHP counts websocket data as route +# activity), so the in-pod timeout is the schedule idle pods actually cull +# on, and a lingering code-server child (tab closed, laptop asleep) should +# die on that same clock (issue #208). Values <= 60 are rejected by +# code-server at startup, so skip them rather than break every pod's VS +# Code; 0 (in-pod culling disabled) lands there too, coherently disabling +# this timer. The value is deployer-supplied and may not parse as an int +# (bad YAML, a stray string); guard the conversion so a bad value just +# disables this feature instead of raising and taking down the whole +# spawner config file. +try: + _vscode_idle_timeout = int( + get_chart_config("shutdown-no-activity-timeout", 0) or 0 + ) +except (TypeError, ValueError): + _vscode_idle_timeout = 0 +if _vscode_idle_timeout > 60: + env["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] = str(_vscode_idle_timeout) + +# Escape hatch for the interaction-based VS Code idle culling (issue #208). +# The image defaults to the OLD behavior (counting raw proxied traffic as +# activity) when this env var is absent, so chart/image skew fails safe +# (pods over-spend, rather than culling active users with no reporter +# installed). When vscodeActivity.enabled is true (the chart default), +# actively opt the pod into the new behavior by setting the env var to +# "false"; the image's jupyter_server_config.py reads it when building +# c.ServerProxy.servers["vscode"]. When the deployer sets +# vscodeActivity.enabled=false, set nothing so the image's fail-safe +# default (True) applies. +if get_chart_config("vscode-activity-enabled", True): + env["VSCODE_PROXY_UPDATE_LAST_ACTIVITY"] = "false" + c.KubeSpawner.environment = env @@ -1003,6 +1038,27 @@ async def _setup_nss_wrapper(spawner, username, groups): + " > /tmp/group", ] + # Install the bundled VS Code activity-reporter extension (issue #208) + # into the user's PVC-backed extensions dir. Runs every spawn: + # idempotent, and --force re-installs on image upgrades (new vsix + # version). Wrapped in { ... || true; } so an install failure neither + # CrashLoops the pod nor (via `&&`/`||` left-associativity) masks a + # failure of the preceding nss-wrapper commands. Silent breakage is + # covered by the e2e extension-installed test. `timeout 60` bounds a + # hung install (e.g. a wedged extensions-dir mount) so it can't stall + # pod startup; timeout(1) is coreutils, present in the ubuntu base + # image. `${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"}` + # mirrors the --extensions-dir flag the vscode proxy entry + # (images/nebi/jupyter_server_config.py) passes when CODE_EXTENSIONSDIR + # is set, keeping the install location in sync with where code-server + # actually reads extensions from. + nss_cmds.append( + '{ timeout 60 code-server --install-extension ' + '/opt/code-server-extensions/nebari-activity-reporter.vsix ' + '${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"} ' + '--force || true; }' + ) + # Group membership changes between spawns (gain, lose, swap) are a # normal operational scenario. The home PVC persists, so the shape # `~/shared` took on the LAST spawn is still there at the start of diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index f25dd47..4d1fad9 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -16,6 +16,7 @@ Every derived value can still be overridden explicitly. Values under | `nebariapp` | Whether/how the `NebariApp` CRD is rendered — routing, auth, landing-page card. See [NebariApp Integration](/nebariapp-integration/) | | `singleuser` | Egress NetworkPolicy allowing user pods to reach the Nebari gateway | | `singleuserCuller` | In-pod idle culling for kernels, terminals, and the server itself (separate from the hub-level `jupyterhub.cull`) | +| `vscodeActivity` | Interaction-based idle culling for VS Code; `enabled: false` reverts to counting raw proxied traffic as activity | | `sharedStorage` | Per-group RWX directories and the transitional in-cluster NFS mode. See [Shared Storage](/shared-storage/) | | `nebi` | The companion Nebi service — image, external/internal URLs, namespace, release name | | `rbac.bootstrap` | One-shot Keycloak Job that adds the groups-claim mapper and the shared-mount client role | @@ -45,3 +46,56 @@ The dummy authenticator is used by default so any username/password works without a Keycloak dependency. To test against real OAuth, configure `jupyterhub.hub.config` per the [Zero to JupyterHub authentication docs](https://z2jh.jupyter.org/en/stable/administrator/authentication.html). + +## VS Code idle culling + +An open VS Code tab holds a websocket whose keepalives used to count as +Jupyter activity, so pods with an idle VS Code tab were never culled +([#208](https://github.com/nebari-dev/data-science-pack/issues/208)). The +pack now handles VS Code idleness like notebook idleness: + +- **Real interaction counts.** A bundled extension + (`nebari-activity-reporter`, installed automatically on every spawn) + reports typing, scrolling, terminal use, and window focus to the Jupyter + server. A running terminal command also counts as active — same policy + as `cullBusy: false` for kernels — provided the shell has VS Code shell + integration (automatic for bash/zsh; exotic shells running long jobs are + not detected). +- **Raw traffic no longer defeats the in-pod culler.** The `/vscode/` proxy + route runs with `update_last_activity` disabled, so keepalives from an + idle tab no longer keep jupyter-server's own activity clock fresh. The + hub-level `jupyterhub.cull` culler is **not** fixed by this: proxied + websocket traffic is still visible to configurable-http-proxy at the + route level, so the hub keeps seeing activity for as long as a tab stays + connected, regardless of `update_last_activity`. The setting that + actually culls an idle-tab pod is the in-pod + `singleuserCuller.server.shutdownNoActivityTimeout` (default `900` + seconds / 15 minutes), which jupyter-server evaluates from its own + activity clock; set it to `0` to disable this feature. +- **Disconnected sessions exit promptly.** `CODE_SERVER_IDLE_TIMEOUT_SECONDS` + is set to `singleuserCuller.server.shutdownNoActivityTimeout` (skipped + when the value is ≤ 60 seconds, which code-server rejects — including + `0`, i.e. in-pod culling disabled), so a code-server process whose last + browser connection has closed exits on the same schedule that culls idle + pods instead of lingering. +- **Delivery failure fails safe.** The proxy-activity opt-out only takes + effect when the reporter extension is actually present in the extensions + directory. If the per-spawn install fails, proxied traffic counts as + activity again — the pod over-spends rather than culling an active user + who has no keep-alive channel. + +To revert to the previous behavior (any open tab keeps the pod alive), set: + +```yaml +vscodeActivity: + enabled: false +``` + +For a hard cost cap regardless of activity — e.g. a tab left open on an +always-awake machine — the hub culler's max-age is available separately: + +```yaml +jupyterhub: + cull: + maxAge: 86400 # kill servers after 24h no matter what +``` diff --git a/images/jupyterlab/pixi.lock b/images/jupyterlab/pixi.lock index 362926a..4f4e481 100644 --- a/images/jupyterlab/pixi.lock +++ b/images/jupyterlab/pixi.lock @@ -430,7 +430,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2025.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.21.0-pyhd8ed1ab_1.conda - - pypi: git+https://github.com/betatim/vscode-binder.git#3e69a137988539f24381e07dd373a1a372098ff4 - pypi: https://files.pythonhosted.org/packages/02/11/9cae49425dbb3e89a7bb3a4d2e974711ad86baa6cc1f47c9bb4e009adced/jupyterlab_jhub_apps-0.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/06/ad/5ba858ffc307ed2b51693d87642f9e9dd6013579840981ac364c6e24c38c/backports_zstd-1.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -909,7 +908,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/xyzservices-2025.1.0-pyhd8ed1ab_0.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zict-3.0.0-pyhd8ed1ab_1.conda - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.21.0-pyhd8ed1ab_1.conda - - pypi: git+https://github.com/betatim/vscode-binder.git#3e69a137988539f24381e07dd373a1a372098ff4 - pypi: https://files.pythonhosted.org/packages/02/11/9cae49425dbb3e89a7bb3a4d2e974711ad86baa6cc1f47c9bb4e009adced/jupyterlab_jhub_apps-0.3.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/57/7163ed06a2d9bf1f34d89dcc7c5881119beeed287022c997b0a706edcfbe/dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl - pypi: https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl @@ -9107,10 +9105,6 @@ packages: - pkg:pypi/zipp?source=hash-mapping size: 21809 timestamp: 1732827613585 -- pypi: git+https://github.com/betatim/vscode-binder.git#3e69a137988539f24381e07dd373a1a372098ff4 - name: jupyter-vscode-proxy - version: 0.7.post4+g3e69a1379 - requires_python: '>=3.8' - pypi: https://files.pythonhosted.org/packages/02/11/9cae49425dbb3e89a7bb3a4d2e974711ad86baa6cc1f47c9bb4e009adced/jupyterlab_jhub_apps-0.3.1-py3-none-any.whl name: jupyterlab-jhub-apps version: 0.3.1 diff --git a/images/jupyterlab/pixi.toml b/images/jupyterlab/pixi.toml index 65f9719..f1fce60 100644 --- a/images/jupyterlab/pixi.toml +++ b/images/jupyterlab/pixi.toml @@ -72,7 +72,6 @@ gnupg = "*" pyjwt = ">=2.13.0,<3" [pypi-dependencies] -jupyter-vscode-proxy = { git = "https://github.com/betatim/vscode-binder.git" } nb_nebi_kernels = "==0.3" jupyterlab_nvdashboard = "==0.12.0" # argo-jupyter-scheduler = "==2024.6.1" # disabled until service is configured diff --git a/images/jupyterlab/postBuild b/images/jupyterlab/postBuild index 76c087e..11730b1 100755 --- a/images/jupyterlab/postBuild +++ b/images/jupyterlab/postBuild @@ -5,4 +5,12 @@ set -x # install code-server extension sh /opt/scripts/install-code-server.sh "/opt/jupyterlab/.pixi/envs/${DEFAULT_ENV}/share" +# Package the activity-reporter extension as a vsix. Installed per-user by a +# postStart hook (01-spawner.py) because the extensions dir lives on the +# home PVC, which would shadow a build-time install. The pixi env's python +# is not on PATH at this point in the build — use it explicitly. +"/opt/jupyterlab/.pixi/envs/${DEFAULT_ENV}/bin/python" /opt/scripts/build-vsix.py \ + /opt/jupyterlab/vscode-activity-reporter \ + /opt/code-server-extensions/nebari-activity-reporter.vsix + fix-permissions "/opt/jupyterlab/.pixi/envs/${DEFAULT_ENV}" diff --git a/images/jupyterlab/vscode-activity-reporter/extension.js b/images/jupyterlab/vscode-activity-reporter/extension.js new file mode 100644 index 0000000..544485a --- /dev/null +++ b/images/jupyterlab/vscode-activity-reporter/extension.js @@ -0,0 +1,123 @@ +"use strict"; +// Reports real user interaction to the local Jupyter server. +// +// The vscode proxy route runs with update_last_activity=False (see +// images/nebi/jupyter_server_config.py), so VS Code traffic no longer +// counts as jupyter activity. Without this extension, actively working +// VS Code users would be idle-culled — this is the load-bearing half of +// nebari-dev/data-science-pack#208. +// +// Endpoint choice: /api/status and /api/ set _track_activity=False +// upstream (so pollers don't defeat culling); /api/contents/ is tracked. +const vscode = require("vscode"); +const http = require("http"); + +const PING_INTERVAL_MS = 60 * 1000; + +let lastPingMs = 0; +let busyExecutions = 0; // in-flight terminal shell executions +let output; + +function pingUrl() { + let base = process.env.JUPYTERHUB_SERVICE_URL; + if (!base) { + return null; // not running under JupyterHub — nothing to report to + } + try { + // Normalize IPv6 any-host (::) to bracketed form before URL parsing + base = base.replace("://:", "://[::]"); + const url = new URL(base); + if (url.hostname === "0.0.0.0" || url.hostname === "[::]") { + url.hostname = "127.0.0.1"; + } + url.pathname = url.pathname.replace(/\/?$/, "/") + "api/contents/"; + url.search = "?content=0"; + return url; + } catch (e) { + output.appendLine(`bad JUPYTERHUB_SERVICE_URL: ${e.message}`); + return null; + } +} + +function ping(reason) { + const url = pingUrl(); + const token = process.env.JUPYTERHUB_API_TOKEN; + if (!url || !token) { + return; + } + try { + const req = http.get( + url, + { headers: { Authorization: `token ${token}` } }, + (res) => { + res.resume(); // drain — only the request itself matters + if (res.statusCode < 200 || res.statusCode >= 300) { + output.appendLine(`activity ping (${reason}): HTTP ${res.statusCode}`); + } + }, + ); + req.on("error", (e) => { + // Never throw out of an event handler; next interaction retries. + output.appendLine(`activity ping (${reason}) failed: ${e.message}`); + }); + } catch (e) { + output.appendLine(`activity ping (${reason}) failed: ${e.message}`); + } +} + +function recordActivity(reason) { + const now = Date.now(); + if (now - lastPingMs < PING_INTERVAL_MS) { + return; + } + lastPingMs = now; + ping(reason); +} + +function activate(context) { + output = vscode.window.createOutputChannel("Nebari Activity Reporter"); + output.appendLine("activated"); + + const on = (event, reason) => { + context.subscriptions.push(event(() => recordActivity(reason))); + }; + on(vscode.workspace.onDidChangeTextDocument, "edit"); + on(vscode.window.onDidChangeTextEditorSelection, "selection"); + on(vscode.window.onDidChangeTextEditorVisibleRanges, "scroll"); + on(vscode.window.onDidChangeWindowState, "focus"); + on(vscode.window.onDidOpenTerminal, "terminal-open"); + on(vscode.window.onDidCloseTerminal, "terminal-close"); + + // Busy = active: a running terminal command keeps the pod alive, like + // cullBusy=false does for kernels. Requires shell integration (auto- + // injected for bash/zsh). Guarded: API is stable since 1.93 but cheap + // to feature-detect. + if (vscode.window.onDidStartTerminalShellExecution) { + context.subscriptions.push( + vscode.window.onDidStartTerminalShellExecution(() => { + busyExecutions += 1; + recordActivity("exec-start"); + }), + ); + context.subscriptions.push( + vscode.window.onDidEndTerminalShellExecution(() => { + busyExecutions = Math.max(0, busyExecutions - 1); + recordActivity("exec-end"); + }), + ); + } + + const busyTimer = setInterval(() => { + if (busyExecutions > 0) { + recordActivity("busy"); + } + }, PING_INTERVAL_MS); + context.subscriptions.push({ dispose: () => clearInterval(busyTimer) }); + + // A user just opened/reconnected VS Code — that is activity. + recordActivity("startup"); +} + +function deactivate() {} + +module.exports = { activate, deactivate }; diff --git a/images/jupyterlab/vscode-activity-reporter/package.json b/images/jupyterlab/vscode-activity-reporter/package.json new file mode 100644 index 0000000..94ef9a9 --- /dev/null +++ b/images/jupyterlab/vscode-activity-reporter/package.json @@ -0,0 +1,13 @@ +{ + "name": "nebari-activity-reporter", + "displayName": "Nebari Activity Reporter", + "description": "Reports real user interaction to the Jupyter server so idle culling works correctly with VS Code (nebari-dev/data-science-pack#208).", + "publisher": "nebari", + "version": "0.1.0", + "license": "BSD-3-Clause", + "engines": { "vscode": "^1.93.0" }, + "categories": ["Other"], + "activationEvents": ["onStartupFinished"], + "main": "./extension.js", + "contributes": {} +} diff --git a/images/nebi/icons/code-server.svg b/images/nebi/icons/code-server.svg new file mode 100644 index 0000000..85b701d --- /dev/null +++ b/images/nebi/icons/code-server.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/images/nebi/jupyter_server_config.py b/images/nebi/jupyter_server_config.py index e37dcd2..c0d91c2 100644 --- a/images/nebi/jupyter_server_config.py +++ b/images/nebi/jupyter_server_config.py @@ -1,3 +1,4 @@ +import glob import mimetypes import os import shutil @@ -80,3 +81,81 @@ }, } } + +# jupyter-server-proxy configuration for VS Code (code-server). +# Registered here instead of via the jupyter-vscode-proxy package so the +# entry can set update_last_activity=False: with it True (the packaged +# default), the VS Code browser client's websocket keepalives count as +# jupyter API activity, and the in-pod shutdown_no_activity_timeout — the +# mechanism that actually culls idle-tab pods — never fires while a tab is +# open (https://github.com/nebari-dev/data-science-pack/issues/208). (The +# hub-level idle culler is defeated separately and regardless of this +# setting: configurable-http-proxy counts websocket data as route +# activity.) Real user interaction is reported instead by the bundled +# nebari-activity-reporter extension (see +# images/jupyterlab/vscode-activity-reporter/). +VSCODE_ICON_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "icons", + "code-server.svg", +) + + +def _vscode_command(): + # Mirrors the command jupyter-vscode-proxy generated (minus unix-socket + # support, which nothing here used). {port} is templated by + # jupyter-server-proxy at launch. + cmd = ["code-server", "--auth", "none", "--disable-telemetry", "--port={port}"] + extensions_dir = os.environ.get("CODE_EXTENSIONSDIR") + if extensions_dir: + cmd += ["--extensions-dir", extensions_dir] + cmd.append(os.environ.get("CODE_WORKINGDIR", ".")) + return cmd + + +def _vscode_reporter_installed(): + # Shared fate with the keep-alive channel: the chart env var (set + # reliably by the spawner) and the reporter vsix install (per-pod + # postStart under `|| true`) live in separate failure domains, so a + # pod can otherwise land with proxy activity disabled AND no reporter + # — and cull an actively-working user at shutdown_no_activity_timeout. + # Gate on the installed artifact so a failed install degrades to + # over-spending (proxied traffic counts as activity again) instead. + # Caveat: postStart runs concurrently with the container entrypoint, + # so on a user's first-ever spawn the install may not have finished + # when this file is evaluated; that session over-spends, and the + # PVC-backed extensions dir makes every later spawn see the artifact. + ext_dir = os.environ.get("CODE_EXTENSIONSDIR") or os.path.expanduser( + "~/.local/share/code-server/extensions" + ) + return bool( + glob.glob(os.path.join(ext_dir, "nebari.nebari-activity-reporter-*")) + ) + + +# Fail-safe default: absent/empty means the OLD behavior (count proxied +# traffic as activity) applies. The chart actively opts pods into the new +# interaction-based behavior by setting this env var to "false" when +# vscodeActivity.enabled is true (config/jupyterhub/01-spawner.py). This +# polarity means chart/image skew (e.g. a newer image tag paired with an +# older chart release that doesn't yet set the env var) degrades to the +# safe failure mode (pods over-spend on proxied traffic staying "active") +# rather than the dangerous one (culling active VS Code users who have no +# activity-reporter extension installed to keep them alive). The same +# polarity covers per-pod install failure via _vscode_reporter_installed(). +_value = os.environ.get("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "").strip().lower() +_vscode_count_proxy_traffic = ( + _value not in ("0", "false", "no") or not _vscode_reporter_installed() +) + +c.ServerProxy.servers["vscode"] = { + "command": _vscode_command(), + "timeout": 300, + "new_browser_tab": True, + "update_last_activity": _vscode_count_proxy_traffic, + "launcher_entry": { + "title": "VS Code", + "enabled": True, + "icon_path": VSCODE_ICON_PATH, + }, +} diff --git a/images/scripts/build-vsix.py b/images/scripts/build-vsix.py new file mode 100644 index 0000000..aa53988 --- /dev/null +++ b/images/scripts/build-vsix.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# Copyright (c) Nebari Development Team. +# Distributed under the terms of the Modified BSD License. +"""Package a VS Code extension directory as a .vsix, without vsce. + +A .vsix is a zip containing extension.vsixmanifest, [Content_Types].xml +and the extension files under extension/. `code-server +--install-extension` reads the manifest Identity plus +extension/package.json. Used at image build time because the build +environment has no node/npm toolchain. + +Usage: build-vsix.py +""" + +import json +import sys +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +MANIFEST = """ + + + + {display} + {description} + Other + + + + + + + + + +""" + +CONTENT_TYPES = """ + + + + + +""" + + +def main(src, dest): + src, dest = Path(src), Path(dest) + pkg = json.loads((src / "package.json").read_text()) + # Escape XML special characters for both attributes and element text + # For attributes, also escape quotes + manifest = MANIFEST.format( + name=escape(pkg["name"], {'"': """}), + version=escape(pkg["version"], {'"': """}), + publisher=escape(pkg["publisher"], {'"': """}), + display=escape(pkg.get("displayName", pkg["name"])), + description=escape(pkg.get("description", "")), + ) + dest.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("extension.vsixmanifest", manifest) + z.writestr("[Content_Types].xml", CONTENT_TYPES) + for f in sorted(src.rglob("*")): + if f.is_file(): + z.write(f, "extension/" + str(f.relative_to(src))) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + main(sys.argv[1], sys.argv[2]) diff --git a/images/scripts/install-code-server.sh b/images/scripts/install-code-server.sh index bbe9733..a354cd3 100755 --- a/images/scripts/install-code-server.sh +++ b/images/scripts/install-code-server.sh @@ -5,13 +5,13 @@ set -xe DEFAULT_PREFIX="${1}" shift # path to environment yaml or lock file -CODE_SERVER_VERSION=4.104.3 +CODE_SERVER_VERSION=4.133.0 mkdir -p ${DEFAULT_PREFIX}/code-server cd ${DEFAULT_PREFIX}/code-server # Fetch the snapshot of https://code-server.dev/install.sh as of the time of writing -wget --quiet https://raw.githubusercontent.com/coder/code-server/v4.104.3/install.sh +wget --quiet https://raw.githubusercontent.com/coder/code-server/v4.133.0/install.sh expected_sum=e86784e9fec81106c74941e55dbbcb85dc963a06ad6c3f1a870d4a22cf432e1d if [[ ! $(sha256sum install.sh) == "${expected_sum} install.sh" ]]; then diff --git a/templates/hub-config.yaml b/templates/hub-config.yaml index 7a3ebc2..3624f95 100644 --- a/templates/hub-config.yaml +++ b/templates/hub-config.yaml @@ -54,6 +54,13 @@ data: # both. Deployer override via jupyterhub.custom.shared-storage-enabled # still wins via z2jh. "shared-storage-enabled": {{ ternary "True" "False" .Values.sharedStorage.enabled }}, + # Mirrors .Values.vscodeActivity.enabled — drives the vscode proxy + # route's update_last_activity escape hatch in 01-spawner.py. + "vscode-activity-enabled": {{ ternary "True" "False" .Values.vscodeActivity.enabled }}, + # Drives CODE_SERVER_IDLE_TIMEOUT_SECONDS in 01-spawner.py: the + # code-server exit timer mirrors the in-pod culler's schedule, the + # one that actually culls idle pods (issue #208). + "shutdown-no-activity-timeout": {{ .Values.singleuserCuller.server.shutdownNoActivityTimeout | quote }}, } diff --git a/tests/e2e/test_vscode_idle_culling.py b/tests/e2e/test_vscode_idle_culling.py new file mode 100644 index 0000000..4d888a2 --- /dev/null +++ b/tests/e2e/test_vscode_idle_culling.py @@ -0,0 +1,135 @@ +"""VS Code idle-culling behavior (issue #208), verified inside a live pod. + +What e2e can and cannot cover: these tests exercise the proxy-activity +plumbing, the extension delivery, and the reporting endpoint — via HTTP +from inside the pod. Extension *activation* needs a real VS Code browser +client, which this harness doesn't have; that path is validated by manual +soak (see the design spec). + +These tests curl `127.0.0.1:8888` directly from inside the pod, bypassing +configurable-http-proxy (CHP) entirely. That means they cannot observe +CHP-level route activity tracking: the mechanism that keeps the hub-level +`jupyterhub.cull` culler's last-activity fresh independent of +`update_last_activity` while a tab stays connected (see the design spec's +corrected mental model). What they DO verify is the in-pod +`api_last_activity` signal that `singleuserCuller.server. +shutdownNoActivityTimeout` actually reads. Also: because the co-installed +`nebari-activity-reporter` extension never activates without a real VS Code +client in this harness, it never fires its own contents-API pings during +these tests, which is exactly why +`test_vscode_proxy_traffic_does_not_count_as_activity` below can assert a +stable `last_activity` across repeated proxied requests: nothing else in +the pod is nudging it forward. +""" + +import json +import time + +import pytest + +# JUPYTERHUB_SERVICE_PREFIX ends with "/" — concatenate with ${VAR} (no +# added slash): the proxy route regex does not tolerate "//vscode/". +CURL_STATUS = ( + 'curl -sf -H "Authorization: token $JUPYTERHUB_API_TOKEN" ' + '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}api/status"' +) + + +def _wait_for_server(user, timeout_s=180): + """Block until the singleuser jupyter server answers on :8888. + + `spawn_user` waits for the POD Ready condition, but singleuser pods + have no readiness probe on the jupyter port, so `kubectl exec` can win + the race against `jupyterhub-singleuser` binding :8888 (first observed + as curl rc=7 in CI). Poll the status endpoint until it answers; every + other exec in these tests can then assume the server is up. + """ + deadline = time.time() + timeout_s + rc, out = 1, "" + while time.time() < deadline: + rc, out = user.exec("bash", "-c", CURL_STATUS) + if rc == 0: + return + time.sleep(3) + pytest.fail( + f"singleuser server never answered /api/status within {timeout_s}s " + f"(last rc={rc}: {out})" + ) + + +def _last_activity(user): + rc, out = user.exec("bash", "-c", CURL_STATUS) + assert rc == 0, f"/api/status failed (rc={rc}): {out}" + return json.loads(out)["last_activity"] + + +def test_code_server_idle_timeout_env_matches_inpod_culler(spawn_user): + """Chart default singleuserCuller.server.shutdownNoActivityTimeout=900 + must reach the pod env verbatim — the code-server exit timer mirrors + the in-pod culler (the schedule idle pods actually cull on), not the + hub-level cull.timeout.""" + u = spawn_user("alice-data") + rc, out = u.exec("printenv", "CODE_SERVER_IDLE_TIMEOUT_SECONDS") + assert rc == 0, "CODE_SERVER_IDLE_TIMEOUT_SECONDS not set on the pod" + assert out.strip() == "900" + + +def test_activity_reporter_extension_installed(spawn_user): + """postStart must install the bundled vsix. This is the tripwire for + the worst failure mode: silently-broken delivery would get active VS + Code users culled mid-session.""" + u = spawn_user("alice-data") + rc, out = u.exec( + "bash", "-c", "ls /home/jovyan/.local/share/code-server/extensions/" + ) + assert rc == 0, out + assert "nebari.nebari-activity-reporter" in out + + +def test_vscode_proxy_traffic_does_not_count_as_activity(spawn_user): + """The core #208 behavior: requests through /vscode/ (which is exactly + what an open tab's keepalives are) must NOT advance last_activity.""" + u = spawn_user("alice-data") + _wait_for_server(u) + # First hit starts code-server via jupyter-server-proxy (timeout 300 in + # the server entry; jsp blocks the request until the backend is up). + rc, out = u.exec( + "bash", "-c", + 'curl -sf -o /dev/null -H "Authorization: token $JUPYTERHUB_API_TOKEN" ' + '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}vscode/"', + ) + assert rc == 0, f"vscode proxy route failed to start code-server: {out}" + + before = _last_activity(u) + for _ in range(3): + time.sleep(2) + u.exec( + "bash", "-c", + 'curl -s -o /dev/null -H "Authorization: token $JUPYTERHUB_API_TOKEN" ' + '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}vscode/"', + ) + after = _last_activity(u) + assert after == before, ( + f"proxied vscode traffic advanced last_activity {before} -> {after}; " + "update_last_activity=False is not applied on the vscode entry" + ) + + +def test_contents_api_ping_counts_as_activity(spawn_user): + """The extension's reporting mechanism: an authenticated contents-API + request must advance last_activity (ISO8601 compares lexicographically).""" + u = spawn_user("alice-data") + _wait_for_server(u) + before = _last_activity(u) + time.sleep(1.1) # ensure a strictly later timestamp is observable + rc, out = u.exec( + "bash", "-c", + 'curl -sf -o /dev/null -H "Authorization: token $JUPYTERHUB_API_TOKEN" ' + '"http://127.0.0.1:8888${JUPYTERHUB_SERVICE_PREFIX}api/contents/?content=0"', + ) + assert rc == 0, out + after = _last_activity(u) + assert after > before, ( + "contents-API ping did not advance last_activity — the extension's " + "reporting endpoint would be ineffective" + ) diff --git a/tests/unit/test_build_vsix.py b/tests/unit/test_build_vsix.py new file mode 100644 index 0000000..f9378f3 --- /dev/null +++ b/tests/unit/test_build_vsix.py @@ -0,0 +1,85 @@ +"""build-vsix.py packages an extension dir into a code-server-installable +.vsix (zip with manifest) without needing node/vsce in the image build.""" + +from __future__ import annotations + +import importlib.util +import json +import xml.etree.ElementTree +import zipfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / "images" / "scripts" / "build-vsix.py" + + +def _build(tmp_path: Path) -> Path: + src = tmp_path / "ext" + src.mkdir() + (src / "package.json").write_text(json.dumps({ + "name": "nebari-activity-reporter", + "publisher": "nebari", + "version": "0.1.0", + "displayName": "Nebari Activity Reporter", + "description": "test fixture", + })) + (src / "extension.js").write_text("module.exports = {};\n") + dest = tmp_path / "out" / "ext.vsix" + + spec = importlib.util.spec_from_file_location("_build_vsix", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main(str(src), str(dest)) + return dest + + +def test_vsix_layout(tmp_path): + dest = _build(tmp_path) + with zipfile.ZipFile(dest) as z: + names = set(z.namelist()) + assert "extension.vsixmanifest" in names + assert "[Content_Types].xml" in names + assert "extension/package.json" in names + assert "extension/extension.js" in names + + +def test_vsix_manifest_identity(tmp_path): + dest = _build(tmp_path) + with zipfile.ZipFile(dest) as z: + manifest = z.read("extension.vsixmanifest").decode() + assert 'Id="nebari-activity-reporter"' in manifest + assert 'Publisher="nebari"' in manifest + assert 'Version="0.1.0"' in manifest + + +def test_vsix_manifest_xml_escaping(tmp_path): + """Manifest field values with &, <, > and " must be XML-escaped.""" + src = tmp_path / "ext" + src.mkdir() + (src / "package.json").write_text(json.dumps({ + "name": "test-extension", + "publisher": "Foo & \"baz\"", + "version": "0.1.0", + "displayName": "Test & \"Extension\"", + "description": "Foo & \"baz\"", + })) + (src / "extension.js").write_text("module.exports = {};\n") + dest = tmp_path / "out" / "ext.vsix" + + spec = importlib.util.spec_from_file_location("_build_vsix", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main(str(src), str(dest)) + + with zipfile.ZipFile(dest) as z: + manifest = z.read("extension.vsixmanifest").decode() + + # Verify escaped forms are present + # Attributes must escape &, <, >, and " + assert 'Publisher="Foo & <Bar> "baz""' in manifest + # Element text must escape &, <, > but not " + assert 'Test & <Unsafe> "Extension"' in manifest + assert 'Foo & <Bar> "baz"' in manifest + + # Verify manifest is valid XML + xml.etree.ElementTree.fromstring(manifest) diff --git a/tests/unit/test_chart_derived.py b/tests/unit/test_chart_derived.py index fe94183..0111621 100644 --- a/tests/unit/test_chart_derived.py +++ b/tests/unit/test_chart_derived.py @@ -148,3 +148,21 @@ def test_get_chart_config_explicit_override_wins(rendered_chart_derived): ) got = ns["get_chart_config"]("external-url") assert got == "explicit.example.com" + + +def test_vscode_activity_enabled_defaults_true(rendered_chart_derived): + """`vscodeActivity.enabled` must reach the spawner via _CHART_DERIVED so + the update_last_activity escape hatch (issue #208) is wireable without + a jupyterhub.custom.* override.""" + ns = _exec_chart_derived(rendered_chart_derived, z2jh_values={}) + assert ns["get_chart_config"]("vscode-activity-enabled") is True + + +def test_shutdown_no_activity_timeout_reaches_spawner(rendered_chart_derived): + """`singleuserCuller.server.shutdownNoActivityTimeout` drives + CODE_SERVER_IDLE_TIMEOUT_SECONDS (issue #208): the code-server exit + timer must mirror the in-pod culler's schedule, so the value has to be + visible to 01-spawner.py via _CHART_DERIVED (rendered as a quoted + string; the spawner int-parses it).""" + ns = _exec_chart_derived(rendered_chart_derived, z2jh_values={}) + assert ns["get_chart_config"]("shutdown-no-activity-timeout") == "900" diff --git a/tests/unit/test_nss_wrapper_shared_dir.py b/tests/unit/test_nss_wrapper_shared_dir.py index 26a28e0..9ea0627 100644 --- a/tests/unit/test_nss_wrapper_shared_dir.py +++ b/tests/unit/test_nss_wrapper_shared_dir.py @@ -154,3 +154,32 @@ def test_groups_without_shared_storage_preserves_user_data_in_per_group_dirs(): assert "mkdir -p /home/jovyan/shared" in cmd assert "mkdir -p /home/jovyan/shared/data" in cmd assert "mkdir -p /home/jovyan/shared/ml" in cmd + + +def test_poststart_installs_activity_reporter_extension_nonfatally(): + """Every spawn installs the bundled activity-reporter vsix into the + PVC-backed extensions dir (idempotent; --force handles upgrades). The + braces + `|| true` isolate the install: with a bare `a && b || true`, + a failure of the nss-wrapper printf commands would ALSO be masked, + and a failed install would CrashLoop the pod without the guard. + + Two additional properties pinned here: + * `timeout 60` bounds the install's runtime so a hung + `code-server --install-extension` (e.g. a wedged extensions-dir + mount) cannot stall pod startup indefinitely. + * `${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"}` + mirrors the --extensions-dir flag the vscode proxy entry + (images/nebi/jupyter_server_config.py) passes when + CODE_EXTENSIONSDIR is set, so the extension is installed into the + SAME directory VS Code itself reads extensions from. + """ + mod = _load_spawner_module(shared_storage_enabled=False) + spawner = FakeSpawner() + asyncio.run(mod._setup_nss_wrapper(spawner, "alice", [])) + cmd = _poststart_cmd(spawner) + assert ( + '{ timeout 60 code-server --install-extension ' + '/opt/code-server-extensions/nebari-activity-reporter.vsix ' + '${CODE_EXTENSIONSDIR:+--extensions-dir "$CODE_EXTENSIONSDIR"} ' + '--force || true; }' in cmd + ) diff --git a/tests/unit/test_spawner_code_server_idle.py b/tests/unit/test_spawner_code_server_idle.py new file mode 100644 index 0000000..e5e16aa --- /dev/null +++ b/tests/unit/test_spawner_code_server_idle.py @@ -0,0 +1,118 @@ +"""CODE_SERVER_IDLE_TIMEOUT_SECONDS wiring in `01-spawner.py`. + +code-server >= 4.106 exits N seconds after its last browser connection +closes when this env var is set. It must mirror the in-pod culler's +`singleuserCuller.server.shutdownNoActivityTimeout` (issue #208) — the +schedule idle pods actually cull on, since the hub-level culler never +fires while a tab is connected (CHP counts websocket data as route +activity). It must be ABSENT when the value is <= 60, because code-server +refuses to start for values <= 60 and that would take down every user +pod's VS Code; 0 (in-pod culling disabled) lands there too. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types + +# 01-spawner.py imports `z2jh.get_config`; stub it so the module exec's standalone. +_z2jh = types.ModuleType("z2jh") +_z2jh.get_config = lambda key, default=None: default +sys.modules.setdefault("z2jh", _z2jh) + +from conftest import CONFIG_DIR, FakeConfig # noqa: E402 + + +def _load_spawner(c: FakeConfig, z2jh_values: dict | None = None, + chart_values: dict | None = None): + """Exec raw 01-spawner.py with per-test z2jh + chart config stubs.""" + z2jh_values = z2jh_values or {} + chart_values = chart_values or {} + z2jh = sys.modules["z2jh"] + orig = z2jh.get_config + z2jh.get_config = lambda key, default=None: z2jh_values.get(key, default) + try: + path = CONFIG_DIR / "01-spawner.py" + spec = importlib.util.spec_from_file_location("_spawner_idle", path) + module = importlib.util.module_from_spec(spec) + module.__dict__["c"] = c + module.__dict__["get_chart_config"] = ( + lambda key, default="": chart_values.get(key, default) + ) + spec.loader.exec_module(module) + finally: + z2jh.get_config = orig + + +def test_idle_timeout_matches_shutdown_no_activity_timeout(): + """_CHART_DERIVED renders the value as a quoted string — it must parse + and reach the env verbatim.""" + c = FakeConfig() + _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "900"}) + assert c.KubeSpawner.environment["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] == "900" + + +def test_idle_timeout_absent_when_inpod_culling_disabled(): + """shutdownNoActivityTimeout: 0 disables in-pod culling — there is no + schedule to mirror, so the code-server timer must be off too.""" + c = FakeConfig() + _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "0"}) + assert "CODE_SERVER_IDLE_TIMEOUT_SECONDS" not in c.KubeSpawner.environment + + +def test_idle_timeout_independent_of_hub_culler(): + """Regression for the review-flagged coupling: disabling the hub-level + `cull` (which CHP defeats anyway) must NOT turn off the code-server + timer — it keys off the in-pod culler alone.""" + c = FakeConfig() + _load_spawner( + c, + z2jh_values={"cull.enabled": False, "cull.timeout": 1800}, + chart_values={"shutdown-no-activity-timeout": "900"}, + ) + assert c.KubeSpawner.environment["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] == "900" + + +def test_idle_timeout_absent_when_60_or_less(): + """code-server errors out at startup for values <= 60 — never set them.""" + c = FakeConfig() + _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "60"}) + assert "CODE_SERVER_IDLE_TIMEOUT_SECONDS" not in c.KubeSpawner.environment + + +def test_idle_timeout_set_at_boundary_61(): + """61 is the smallest value code-server accepts; must be set verbatim.""" + c = FakeConfig() + _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "61"}) + assert c.KubeSpawner.environment["CODE_SERVER_IDLE_TIMEOUT_SECONDS"] == "61" + + +def test_idle_timeout_ignores_non_numeric_value(): + """The value is deployer-supplied and may not parse as an int; a bad + value must disable the feature rather than raise and take down the + whole spawner config file.""" + c = FakeConfig() + _load_spawner(c, chart_values={"shutdown-no-activity-timeout": "not-a-number"}) + assert "CODE_SERVER_IDLE_TIMEOUT_SECONDS" not in c.KubeSpawner.environment + + +def test_proxy_activity_env_set_false_by_default(): + """Default (vscodeActivity.enabled=true): the chart actively opts the + pod into the new interaction-based behavior by setting the env var to + "false". The image defaults to the OLD behavior (True) when the var is + absent, so this active opt-in is what the chart is responsible for.""" + c = FakeConfig() + _load_spawner(c, chart_values={"vscode-activity-enabled": True}) + assert ( + c.KubeSpawner.environment["VSCODE_PROXY_UPDATE_LAST_ACTIVITY"] == "false" + ) + + +def test_proxy_activity_env_absent_when_vscode_activity_disabled(): + """vscodeActivity.enabled=false is the field escape hatch: the chart + sets nothing, so the image's fail-safe default (True, pre-#208 + behavior, proxied traffic counts as activity again) applies.""" + c = FakeConfig() + _load_spawner(c, chart_values={"vscode-activity-enabled": False}) + assert "VSCODE_PROXY_UPDATE_LAST_ACTIVITY" not in c.KubeSpawner.environment diff --git a/tests/unit/test_vscode_server_registration.py b/tests/unit/test_vscode_server_registration.py new file mode 100644 index 0000000..4734638 --- /dev/null +++ b/tests/unit/test_vscode_server_registration.py @@ -0,0 +1,140 @@ +"""The image-owned jupyter_server_config.py must register the `vscode` +jupyter-server-proxy entry itself (the jupyter-vscode-proxy package was +dropped) with update_last_activity=False, so VS Code keepalive traffic +stops defeating the idle cullers (issue #208). + +This file exec's like JupyterHub does: with `c` in scope. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +from conftest import FakeConfig + +REPO_ROOT = Path(__file__).resolve().parents[2] +IMAGE_CONFIG = REPO_ROOT / "images" / "nebi" / "jupyter_server_config.py" + + +def _load_image_config(c: FakeConfig): + spec = importlib.util.spec_from_file_location("_img_jsc", IMAGE_CONFIG) + module = importlib.util.module_from_spec(spec) + module.__dict__["c"] = c + spec.loader.exec_module(module) + return module + + +def _install_reporter(monkeypatch, tmp_path, installed=True): + """Point CODE_EXTENSIONSDIR at a temp extensions dir, optionally + containing the artifact the postStart vsix install would leave behind + (code-server unpacks to .-/).""" + ext_dir = tmp_path / "extensions" + ext_dir.mkdir(exist_ok=True) + if installed: + (ext_dir / "nebari.nebari-activity-reporter-0.1.0").mkdir() + monkeypatch.setenv("CODE_EXTENSIONSDIR", str(ext_dir)) + return ext_dir + + +def test_vscode_entry_registered_alongside_nebi(monkeypatch): + monkeypatch.delenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", raising=False) + monkeypatch.delenv("CODE_EXTENSIONSDIR", raising=False) + c = FakeConfig() + _load_image_config(c) + servers = c.ServerProxy.servers + assert "nebi" in servers, "vscode registration must not clobber nebi" + assert "vscode" in servers + + +def test_vscode_counts_proxy_traffic_by_default_without_chart_plumbing(monkeypatch): + """Fail-safe default: with the env var absent (e.g. an image deployed + without the chart's opt-in plumbing), the image falls back to the OLD + behavior (counting proxied traffic as activity), so chart/image skew + over-spends rather than culling active users with no reporter.""" + monkeypatch.delenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", raising=False) + c = FakeConfig() + _load_image_config(c) + assert c.ServerProxy.servers["vscode"]["update_last_activity"] is True + + +def test_vscode_chart_optin_disables_activity_counting(monkeypatch, tmp_path): + """The chart opts pods into the new behavior by setting the env var to + "false" when vscodeActivity.enabled is true — effective only with the + reporter artifact present (shared fate).""" + monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "false") + _install_reporter(monkeypatch, tmp_path, installed=True) + c = FakeConfig() + _load_image_config(c) + assert c.ServerProxy.servers["vscode"]["update_last_activity"] is False + + +def test_vscode_optin_ineffective_without_reporter_artifact(monkeypatch, tmp_path): + """Shared fate: the chart env var and the postStart vsix install live in + separate failure domains. If the install failed (no artifact in the + extensions dir), disabling proxy activity would cull actively-working + users with no keep-alive channel — so the opt-in must NOT take effect + and the pod degrades to over-spending instead.""" + monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "false") + _install_reporter(monkeypatch, tmp_path, installed=False) + c = FakeConfig() + _load_image_config(c) + assert c.ServerProxy.servers["vscode"]["update_last_activity"] is True + + +def test_vscode_reporter_check_uses_default_extensions_dir(monkeypatch, tmp_path): + """With CODE_EXTENSIONSDIR unset, the shared-fate check must look in + code-server's default extensions dir (~/.local/share/code-server/ + extensions) — the same place the postStart install writes to.""" + monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "false") + monkeypatch.delenv("CODE_EXTENSIONSDIR", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + default_dir = tmp_path / ".local/share/code-server/extensions" + (default_dir / "nebari.nebari-activity-reporter-0.1.0").mkdir(parents=True) + c = FakeConfig() + _load_image_config(c) + assert c.ServerProxy.servers["vscode"]["update_last_activity"] is False + + +def test_vscode_escape_hatch_env_restores_activity_counting(monkeypatch, tmp_path): + """An explicit "true" env var (e.g. set manually via extraEnv) restores + the old activity-counting behavior regardless of chart plumbing — + even with the reporter installed.""" + monkeypatch.setenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", "true") + _install_reporter(monkeypatch, tmp_path, installed=True) + c = FakeConfig() + _load_image_config(c) + assert c.ServerProxy.servers["vscode"]["update_last_activity"] is True + + +def test_vscode_command_matches_previous_package_contract(monkeypatch): + """Drop-in replacement for jupyter-vscode-proxy's generated command.""" + monkeypatch.delenv("CODE_EXTENSIONSDIR", raising=False) + monkeypatch.delenv("CODE_WORKINGDIR", raising=False) + c = FakeConfig() + _load_image_config(c) + cmd = c.ServerProxy.servers["vscode"]["command"] + assert cmd[0] == "code-server" + assert "--auth" in cmd and "none" in cmd + assert "--disable-telemetry" in cmd + assert "--port={port}" in cmd + assert cmd[-1] == "." # CODE_WORKINGDIR default + + +def test_vscode_command_honors_code_extensionsdir(monkeypatch): + monkeypatch.setenv("CODE_EXTENSIONSDIR", "/custom/ext") + c = FakeConfig() + _load_image_config(c) + cmd = c.ServerProxy.servers["vscode"]["command"] + assert "--extensions-dir" in cmd + assert "/custom/ext" in cmd + + +def test_vscode_launcher_entry_and_icon_exist(monkeypatch): + monkeypatch.delenv("VSCODE_PROXY_UPDATE_LAST_ACTIVITY", raising=False) + c = FakeConfig() + _load_image_config(c) + entry = c.ServerProxy.servers["vscode"]["launcher_entry"] + assert entry["title"] == "VS Code" + assert Path(entry["icon_path"]).name == "code-server.svg" + assert (REPO_ROOT / "images" / "nebi" / "icons" / "code-server.svg").exists() diff --git a/values.yaml b/values.yaml index c8b73a5..0d71d4f 100644 --- a/values.yaml +++ b/values.yaml @@ -179,6 +179,16 @@ singleuserCuller: server: shutdownNoActivityTimeout: 900 # 15 min — seconds after last kernel/terminal gone before server self-terminates +# VS Code (code-server) idle-culling behavior. When enabled (default), VS +# Code browser traffic does NOT count as jupyter activity — the bundled +# nebari-activity-reporter extension reports real user interaction (typing, +# scrolling, terminal use) instead, so idle VS Code tabs cull on the normal +# schedule (https://github.com/nebari-dev/data-science-pack/issues/208). +# Set enabled: false to revert to counting raw proxied traffic (the +# pre-#208 behavior, which keeps pods alive while any tab is open). +vscodeActivity: + enabled: true + # Shared storage — per-group directories mounted at /shared/ in user pods. # Requires an RWX StorageClass on the cluster. # @@ -504,7 +514,7 @@ jupyterhub: # The same value sits inside profile_options.image.choices.default # so the JupyterLab profile selector keeps showing it too. # scripts/bump_image_tags.py syncs all three on every bump. - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be cpu_limit: 1 cpu_guarantee: 0.5 mem_limit: "2G" @@ -514,15 +524,15 @@ jupyterhub: display_name: Image choices: default: - display_name: "nebari-data-science-pack-jupyterlab:sha-16c1922" + display_name: "nebari-data-science-pack-jupyterlab:sha-ce941be" default: true kubespawner_override: - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be - slug: medium-instance display_name: "Medium Instance" description: "4 CPU / 8 GB RAM — pandas / scikit-learn workloads on medium datasets." kubespawner_override: - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be cpu_limit: 4 cpu_guarantee: 2 mem_limit: "8G" @@ -532,10 +542,10 @@ jupyterhub: display_name: Image choices: default: - display_name: "nebari-data-science-pack-jupyterlab:sha-16c1922" + display_name: "nebari-data-science-pack-jupyterlab:sha-ce941be" default: true kubespawner_override: - image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-16c1922 + image: quay.io/nebari/nebari-data-science-pack-jupyterlab:sha-ce941be # Terminal customization: controls Starship prompt in JupyterLab terminals. # When false, falls back to the default bash prompt. terminal-customization: true @@ -597,7 +607,7 @@ jupyterhub: # class clears current_user — id_token_hint now reaches KC). image: name: quay.io/nebari/nebari-data-science-pack-jupyterhub - tag: "sha-16c1922" + tag: "sha-ce941be" config: JupyterHub: @@ -690,7 +700,7 @@ jupyterhub: # keeps the two in sync on every automated bump instead. initContainers: - name: merge-ca-bundle - image: quay.io/nebari/nebari-data-science-pack-jupyterhub:sha-16c1922 + image: quay.io/nebari/nebari-data-science-pack-jupyterhub:sha-ce941be imagePullPolicy: IfNotPresent # z2jh's pod-level securityContext sets ``runAsNonRoot: true`` but # no explicit uid, and the hub image's default ``USER`` is root, @@ -757,7 +767,7 @@ jupyterhub: singleuser: image: name: quay.io/nebari/nebari-data-science-pack-jupyterlab - tag: "sha-16c1922" + tag: "sha-ce941be" defaultUrl: "/lab" extraEnv: JUPYTERHUB_SINGLEUSER_APP: "jupyter_server.serverapp.ServerApp"