diff --git a/README.md b/README.md index 18891272..37eb6219 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The simplest way to deploy AUP Learning Cloud on a single machine in a developme ```bash # Ryzen AI APU only: OEM kernel for ROCm on Ubuntu 24.04 (reboot required) -sudo apt update && sudo apt install linux-image-6.14.0-1018-oem +sudo apt update && sudo apt install linux-oem-6.14 # Install Docker curl -fsSL https://get.docker.com | sh diff --git a/auplc_installer/rocm.py b/auplc_installer/rocm.py index c3e3b709..08d8e206 100644 --- a/auplc_installer/rocm.py +++ b/auplc_installer/rocm.py @@ -58,8 +58,8 @@ def _wait_daemonset_ready(name: str) -> None: def _patch_image_pull_policy(daemonset: str) -> None: - """For offline mode: avoid pulling the device plugin image from a registry.""" - patch = '[{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"IfNotPresent"}]' + """Avoid pulling ROCm DaemonSet images when they already exist locally.""" + patch = '[{"op":"add","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"IfNotPresent"}]' run( [ "kubectl", @@ -90,7 +90,6 @@ def deploy_rocm_gpu_device_plugin(*, offline_mode: bool, bundle_dir: Path | None str(bundle_dir / "manifests/k8s-ds-amdgpu-dp.yaml"), ] ) - _patch_image_pull_policy("amdgpu-device-plugin-daemonset") else: url = ( "https://raw.githubusercontent.com/ROCm/k8s-device-plugin/" @@ -101,9 +100,11 @@ def deploy_rocm_gpu_device_plugin(*, offline_mode: bool, bundle_dir: Path | None verify_sha256(tmp, ROCM_DEVICE_PLUGIN_SHA256) run(["kubectl", "create", "-f", tmp]) os.remove(tmp) - _wait_daemonset_ready("amdgpu-device-plugin-daemonset") log("Successfully deployed ROCm GPU device plugin.") + _patch_image_pull_policy("amdgpu-device-plugin-daemonset") + _wait_daemonset_ready("amdgpu-device-plugin-daemonset") + deploy_rocm_gpu_node_labeller(offline_mode=offline_mode, bundle_dir=bundle_dir) @@ -117,28 +118,27 @@ def deploy_rocm_gpu_node_labeller(*, offline_mode: bool, bundle_dir: Path | None log("Deploying ROCm GPU node labeller...") if _exists_daemonset("amdgpu-labeller-daemonset"): log("ROCm GPU node labeller already exists.") - return - - if offline_mode and bundle_dir is not None: - run( - [ - "kubectl", - "create", - "-f", - str(bundle_dir / "manifests/k8s-ds-amdgpu-labeller.yaml"), - ] - ) - _patch_image_pull_policy("amdgpu-labeller-daemonset") else: - url = ( - "https://raw.githubusercontent.com/ROCm/k8s-device-plugin/" - f"{ROCM_DEVICE_PLUGIN_COMMIT}/k8s-ds-amdgpu-labeller.yaml" - ) - tmp = "/tmp/k8s-ds-amdgpu-labeller.yaml" - run(["wget", url, "-O", tmp]) - verify_sha256(tmp, ROCM_LABELLER_SHA256) - run(["kubectl", "create", "-f", tmp]) - os.remove(tmp) + if offline_mode and bundle_dir is not None: + run( + [ + "kubectl", + "create", + "-f", + str(bundle_dir / "manifests/k8s-ds-amdgpu-labeller.yaml"), + ] + ) + else: + url = ( + "https://raw.githubusercontent.com/ROCm/k8s-device-plugin/" + f"{ROCM_DEVICE_PLUGIN_COMMIT}/k8s-ds-amdgpu-labeller.yaml" + ) + tmp = "/tmp/k8s-ds-amdgpu-labeller.yaml" + run(["wget", url, "-O", tmp]) + verify_sha256(tmp, ROCM_LABELLER_SHA256) + run(["kubectl", "create", "-f", tmp]) + os.remove(tmp) + log("Successfully deployed ROCm GPU node labeller.") + _patch_image_pull_policy("amdgpu-labeller-daemonset") _wait_daemonset_ready("amdgpu-labeller-daemonset") - log("Successfully deployed ROCm GPU node labeller.") diff --git a/deploy/ansible/playbooks/pb-pxe-controller.yml b/deploy/ansible/playbooks/pb-pxe-controller.yml index 951821ca..425b939e 100644 --- a/deploy/ansible/playbooks/pb-pxe-controller.yml +++ b/deploy/ansible/playbooks/pb-pxe-controller.yml @@ -72,8 +72,8 @@ pxe_apt_mirror: "http://tw.archive.ubuntu.com/ubuntu" pxe_rootfs_packages: - - linux-image-6.14.0-1018-oem - - linux-headers-6.14.0-1018-oem + - linux-image-oem-6.14 + - linux-headers-oem-6.14 - initramfs-tools - linux-firmware - nfs-common diff --git a/deploy/ansible/roles/pxe_controller/defaults/main.yml b/deploy/ansible/roles/pxe_controller/defaults/main.yml index cc131711..e381140f 100644 --- a/deploy/ansible/roles/pxe_controller/defaults/main.yml +++ b/deploy/ansible/roles/pxe_controller/defaults/main.yml @@ -106,8 +106,8 @@ pxe_web_port: 8080 pxe_ubuntu_codename: "noble" pxe_rootfs_packages: - - linux-image-6.14.0-1018-oem - - linux-headers-6.14.0-1018-oem + - linux-image-oem-6.14 + - linux-headers-oem-6.14 - initramfs-tools - linux-firmware - nfs-common diff --git a/deploy/docs/images/software-stack.png b/deploy/docs/images/software-stack.png index 6bb0efc2..359c7a48 100644 Binary files a/deploy/docs/images/software-stack.png and b/deploy/docs/images/software-stack.png differ diff --git a/dockerfiles/Base/Dockerfile.cpu b/dockerfiles/Base/Dockerfile.cpu index 1b21b52f..68789db9 100644 --- a/dockerfiles/Base/Dockerfile.cpu +++ b/dockerfiles/Base/Dockerfile.cpu @@ -94,3 +94,5 @@ COPY --from=runtime-status-builder --chown=1000:100 /build/runtime/notebook/jupy RUN python3 -mpip install --no-cache-dir /tmp/auplc-jupyterlab-runtime-status && \ rm -rf /tmp/auplc-jupyterlab-runtime-status + +WORKDIR /home/jovyan diff --git a/dockerfiles/Base/Dockerfile.rocm b/dockerfiles/Base/Dockerfile.rocm index dada2a75..43cf8c25 100644 --- a/dockerfiles/Base/Dockerfile.rocm +++ b/dockerfiles/Base/Dockerfile.rocm @@ -129,6 +129,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ run-one \ libatomic1 \ unzip \ + libavutil-dev \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -266,6 +267,6 @@ RUN echo '#!/bin/bash' > /entrypoint.sh && \ EXPOSE 8888 USER $NB_UID -WORKDIR /opt/workspace +WORKDIR /home/jovyan CMD ["/bin/bash", "/entrypoint.sh"] diff --git a/dockerfiles/Base/README.md b/dockerfiles/Base/README.md index cca9cae3..d5d06bdc 100644 --- a/dockerfiles/Base/README.md +++ b/dockerfiles/Base/README.md @@ -86,6 +86,32 @@ docker build \ docker build -t ghcr.io/amdresearch/auplc-default:latest --file Dockerfile.cpu . ``` +## Resource Path Contract + +Resource metadata in `runtime/values.yaml` can set `defaultPath` for the +initial landing path inside the container. It controls where JupyterLab or +code-server opens first. It is not a security boundary, an access boundary, or a +runtime guarantee that the directory exists. + +The Hub chooses the target path in this order: + +1. Custom Repo clone path, when the user supplies a repository. +2. Resource `defaultPath`, when configured. +3. The image or single-user application default, normally the image `WORKDIR`. + +For official images, keep `custom.resources.metadata..defaultPath` in +sync with the image `WORKDIR`. Check the local image contracts with: + +```bash +make -C dockerfiles verify-resource-contracts +``` + +That verifier checks the official image contract. Runtime spawning still does +not check path existence for arbitrary or custom images. If an environment +points at a custom image, make sure the configured `defaultPath` exists in that +image, or omit `defaultPath` to let the image `WORKDIR` control the initial +folder. + ## Generic Code Images The base images remain the foundation for notebook and coding environments. Generic code-server images are built separately from `dockerfiles/Code/`: diff --git a/dockerfiles/Code/README.md b/dockerfiles/Code/README.md index db77c112..79bb033f 100644 --- a/dockerfiles/Code/README.md +++ b/dockerfiles/Code/README.md @@ -38,7 +38,7 @@ those tools to Base notebook images or Course images. CPU and GPU Code images are built from the same Dockerfile and differ only by `BASE_IMAGE`, so the Code layer stays consistent across hardware targets. -The default Hub resource keys are `code-cpu` and `code-gpu`. Code-server launch behavior is configured through `custom.resources.metadata..launchMode: code-server`, alongside the same `custom.resources.images`, `custom.resources.requirements`, and `custom.teams.mapping` model as notebook resources in `runtime/values.yaml`. +The default Hub resource keys are `code-cpu` and `code-gpu`. Code-server launch behavior is configured through `custom.resources.metadata..launchMode: code-server`, alongside the same `custom.resources.images`, `custom.resources.requirements`, `custom.resources.metadata..defaultPath`, and `custom.teams.mapping` model as notebook resources in `runtime/values.yaml`. ## Build Commands @@ -86,7 +86,7 @@ images continue to share one package set and the Dockerfile remains small. The start script launches: ```bash -code-server --auth none --bind-addr 127.0.0.1:8889 --ignore-last-opened "${AUPLC_CODE_WORKDIR:-/home/jovyan}" +code-server --auth none --bind-addr 127.0.0.1:8889 --ignore-last-opened "${AUPLC_CODE_WORKDIR:-$(pwd)}" nginx -c /tmp/auplc-code-server-nginx.conf -g 'daemon off;' ``` @@ -96,6 +96,14 @@ code-server on loopback. The proxy must preserve the full browser `Host` value with `X-Forwarded-Host` so code-server's WebSocket origin check succeeds behind JupyterHub and NodePort-style local URLs. +`defaultPath` is the initial landing path for a resource. The target path is +chosen in this order: Custom Repo clone path, resource `defaultPath`, then the +image or single-user application default, normally the image `WORKDIR`. Omitted +and `null` `defaultPath` values do not force a Hub landing override; empty +strings are invalid; `/` means land at the container root. This setting doesn't +limit what users can access. It only selects the first workspace shown by the +application. + Git, Node.js LTS, `npm`, `npx`, `corepack`, pinned `pnpm`, TypeScript/frontend helpers, Pixi, and native build tools are installed in the image so cloned projects can use source control, frontend workflows, sudo-free user package @@ -108,6 +116,13 @@ home directory instead of `/usr/local`: `NPM_CONFIG_PREFIX` defaults to This lets users install small project CLIs with commands such as `npm install -g cowsay` without sudo or write access to system directories. +For code-server's outgoing link protection, the Hub automatically injects the +current public Hub host into `AUPLC_CODE_TRUSTED_DOMAINS` so the built-in +Back-to-Hub action can open `/hub/home` without an external-site confirmation. +Deployments that intentionally open additional trusted sites can set +`custom.codeServer.extraTrustedDomains` to host/domain entries such as +`docs.example.edu`; do not include URL schemes or paths. + Pixi is provided as the sudo-free, apt-like package manager for user-space native tools and project environments. The image writes `/etc/pixi/config.toml` so requests for `https://conda.anaconda.org/conda-forge` are redirected to the @@ -138,7 +153,28 @@ downgrading a user-installed newer copy. `--auth none` is acceptable only because JupyterHub and the JupyterHub proxy remain the authentication boundary. The user pod's port `8888` must stay private to the Hub/proxy path and must not be exposed directly through an unauthenticated service, ingress, or port-forward shared with untrusted users. -When users provide a Git repository on the spawn form, the existing init-container clone flow is reused. For resources with `launchMode: code-server`, the spawner points `AUPLC_CODE_WORKDIR` and the code-server `folder` URL parameter at the cloned directory so code-server opens the repository workspace. The launcher also passes `--ignore-last-opened` so a persisted previous workspace cannot override the requested folder. +When users provide a Git repository on the spawn form, the existing init-container clone flow is reused. For resources with `launchMode: code-server`, the spawner points `AUPLC_CODE_WORKDIR` at the cloned directory or explicit resource target path, and the launcher starts code-server with that folder so it opens the requested workspace. If neither Custom Repo nor `defaultPath` is set, the launcher opens the image `WORKDIR`. The launcher also passes `--ignore-last-opened` so a persisted previous workspace cannot override the requested folder. + +A direct code-server URL with `?folder=` works when the browser reaches +the proxied code-server root route. Hub spawn completion, however, redirects the +browser to the server base URL, and code-server doesn't consume +`JUPYTERHUB_DEFAULT_URL` by itself. AUPLC therefore keeps `AUPLC_CODE_WORKDIR` as +the reliable adapter between Hub resource selection and the code-server process. +The local proof is recorded in +`.sisyphus/evidence/task-1-codeserver-default-url-proof.md`. + +Official Code images are checked by the resource contract verifier: + +```bash +make -C dockerfiles verify-resource-contracts +``` + +The verifier checks official image metadata, image `WORKDIR`, path existence, +and the code-server launcher contract. Runtime spawning doesn't check path +existence for arbitrary or custom images. If a custom code image sets a +`defaultPath`, create that path in the image or code-server may show its own +landing error. If the image already declares the desired `WORKDIR`, omit +`defaultPath` to preserve it. ## Extensions diff --git a/dockerfiles/Code/start-code-server.sh b/dockerfiles/Code/start-code-server.sh index cfd22193..4d188b69 100755 --- a/dockerfiles/Code/start-code-server.sh +++ b/dockerfiles/Code/start-code-server.sh @@ -10,10 +10,12 @@ export NPM_CONFIG_PREFIX="${NPM_CONFIG_PREFIX:-${HOME:-/home/jovyan}/.local}" public_port="${PORT:-8888}" code_server_port="${AUPLC_CODE_SERVER_PORT:-8889}" service_prefix="${JUPYTERHUB_SERVICE_PREFIX:-/}" -workdir="${AUPLC_CODE_WORKDIR:-/home/jovyan}" +# Without a Hub-provided launch override, open code-server in the image WORKDIR. +workdir="${AUPLC_CODE_WORKDIR:-$(pwd)}" extensions_list="${AUPLC_CODE_EXTENSIONS_LIST:-/opt/auplc/extensions/extensions.txt}" local_extensions_dir="${AUPLC_CODE_LOCAL_EXTENSIONS_DIR:-/opt/auplc/extensions/local}" extensions_dir="${AUPLC_CODE_EXTENSIONS_DIR:-/home/jovyan/.local/share/code-server/extensions}" +trusted_domains="${AUPLC_CODE_TRUSTED_DOMAINS:-}" mkdir -p "${NPM_CONFIG_PREFIX}/bin" mkdir -p "${PIXI_HOME}/bin" @@ -49,6 +51,28 @@ seed_builtin_extensions() { fi } +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "${value}" +} + +build_trusted_domain_args() { + local domains_csv="$1" + local -n output_args="$2" + local -a domains=() + local domain + + IFS=',' read -ra domains <<<"${domains_csv}" + for domain in "${domains[@]}"; do + domain="$(trim "${domain}")" + if [ -n "${domain}" ]; then + output_args+=(--link-protection-trusted-domains "${domain}") + fi + done +} + case "${service_prefix}" in /*) ;; *) service_prefix="/${service_prefix}" ;; @@ -65,6 +89,8 @@ nginx_conf="/tmp/auplc-code-server-nginx.conf" redirect_block="" seed_builtin_extensions +trusted_domain_args=() +build_trusted_domain_args "${trusted_domains}" trusted_domain_args if [ "${service_prefix}" != "/" ]; then redirect_block=" @@ -120,6 +146,7 @@ code-server \ --auth none \ --bind-addr "127.0.0.1:${code_server_port}" \ --extensions-dir "${extensions_dir}" \ + "${trusted_domain_args[@]}" \ --ignore-last-opened \ "${workdir}" & code_server_pid="$!" diff --git a/dockerfiles/Courses/CV/Dockerfile b/dockerfiles/Courses/CV/Dockerfile index 7d332ac4..8fb41d87 100644 --- a/dockerfiles/Courses/CV/Dockerfile +++ b/dockerfiles/Courses/CV/Dockerfile @@ -22,9 +22,10 @@ ARG BASE_IMAGE=ghcr.io/amdresearch/auplc-base:latest FROM ${BASE_IMAGE} # Copy related rocm notebooks into the docker +USER root RUN mkdir -p /opt/workspace/CV COPY ./course_data /opt/workspace/CV -USER root RUN chown -R jovyan:1000 /opt/workspace USER jovyan +WORKDIR /opt/workspace/CV diff --git a/dockerfiles/Courses/DL/Dockerfile b/dockerfiles/Courses/DL/Dockerfile index a3c3b487..402b1823 100644 --- a/dockerfiles/Courses/DL/Dockerfile +++ b/dockerfiles/Courses/DL/Dockerfile @@ -33,3 +33,4 @@ RUN cd /opt/workspace/DL/data/FashionMNIST && bash ./download_data.sh RUN chown -R jovyan:1000 /opt/workspace USER jovyan +WORKDIR /opt/workspace/DL diff --git a/dockerfiles/Courses/PhySim/Dockerfile b/dockerfiles/Courses/PhySim/Dockerfile index 9e20babf..69d123bf 100644 --- a/dockerfiles/Courses/PhySim/Dockerfile +++ b/dockerfiles/Courses/PhySim/Dockerfile @@ -54,3 +54,4 @@ COPY ./course_data /opt/workspace/PhySim USER root RUN chown -R jovyan:1000 /opt/workspace USER jovyan +WORKDIR /opt/workspace/PhySim diff --git a/dockerfiles/Courses/README.md b/dockerfiles/Courses/README.md index 3fcb7eee..981061e4 100644 --- a/dockerfiles/Courses/README.md +++ b/dockerfiles/Courses/README.md @@ -36,6 +36,21 @@ Use the existing course target for course notebook images: make -C dockerfiles courses GPU_TARGET=gfx1151 ``` +Course resources should land on their course content. Set each official course +resource's `custom.resources.metadata..defaultPath` to the same path as +the image `WORKDIR`, such as `/opt/workspace/CV`. The official image contract +verifier checks that metadata and image contract: + +```bash +make -C dockerfiles verify-resource-contracts +``` + +The verifier is for official images. At runtime, the Hub doesn't check whether a +configured path exists in arbitrary or custom images. Custom course images must +create their configured landing path themselves. For non-official images that +already declare the desired Docker `WORKDIR`, omit `defaultPath` to preserve the +image's initial folder. + Use the code targets only for generic code-server environments: ```bash diff --git a/dockerfiles/Makefile b/dockerfiles/Makefile index 00de17ff..5aae172c 100644 --- a/dockerfiles/Makefile +++ b/dockerfiles/Makefile @@ -40,11 +40,14 @@ ifneq ($(MIRROR_NPM),) BUILD_ARGS += --build-arg NPM_REGISTRY=$(MIRROR_NPM) endif -.PHONY: all base base-cpu base-rocm base-gfx1151 hub code code-cpu code-gpu courses cv dl llm physim +.PHONY: all base base-cpu base-rocm base-gfx1151 hub code code-cpu code-gpu courses cv dl llm physim verify-resource-contracts # Build all images all: base hub code courses +verify-resource-contracts: + python3 ../scripts/verify-resource-contracts.py + # --- Base Images --- base: base-cpu base-rocm diff --git a/projects/CV/DL08_Diffusion_Model.ipynb b/projects/CV/CV08_Diffusion_Model.ipynb similarity index 100% rename from projects/CV/DL08_Diffusion_Model.ipynb rename to projects/CV/CV08_Diffusion_Model.ipynb diff --git a/runtime/chart/values.schema.json b/runtime/chart/values.schema.json index a018f20c..ef7efffc 100644 --- a/runtime/chart/values.schema.json +++ b/runtime/chart/values.schema.json @@ -1 +1 @@ -{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"required":["imagePullSecrets","hub","proxy","singleuser","ingress","prePuller","custom","cull","debug","rbac","global"],"properties":{"enabled":{"type":["boolean","null"]},"fullnameOverride":{"type":["string","null"]},"nameOverride":{"type":["string","null"]},"imagePullSecret":{"type":"object","required":["create"],"if":{"properties":{"create":{"const":true}}},"then":{"additionalProperties":false,"required":["registry","username","password"],"properties":{"create":{"type":"boolean"},"automaticReferenceInjection":{"type":"boolean"},"registry":{"type":"string"},"username":{"type":"string"},"password":{"type":"string"},"email":{"type":["string","null"]}}}},"imagePullSecrets":{"type":"array"},"hub":{"type":"object","additionalProperties":false,"required":["baseUrl"],"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"config":{"type":"object","additionalProperties":false,"patternProperties":{"^[A-Z].*$":{"type":"object","additionalProperties":true}},"properties":{"JupyterHub":{"type":"object","additionalProperties":true,"properties":{"subdomain_host":{"type":"string"}}}}},"extraFiles":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"object","additionalProperties":false,"required":["mountPath"],"oneOf":[{"required":["data"]},{"required":["stringData"]},{"required":["binaryData"]}],"properties":{"mountPath":{"type":"string"},"data":{"type":"object","additionalProperties":true},"stringData":{"type":"string"},"binaryData":{"type":"string"},"mode":{"type":"number"}}}}},"baseUrl":{"type":"string"},"command":{"type":"array"},"args":{"type":"array"},"cookieSecret":{"type":["string","null"]},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"db":{"type":"object","additionalProperties":false,"properties":{"type":{"enum":["sqlite-pvc","sqlite-memory","mysql","postgres","other"]},"pvc":{"type":"object","additionalProperties":false,"required":["storage"],"properties":{"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"selector":{"type":"object","additionalProperties":true},"storage":{"type":"string"},"accessModes":{"type":"array","items":{"type":["string","null"]}},"storageClassName":{"type":["string","null"]},"subPath":{"type":["string","null"]}}},"upgrade":{"type":["boolean","null"]},"url":{"type":["string","null"]},"password":{"type":["string","null"]}}},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"initContainers":{"type":"array"},"extraEnv":{"type":["object","array"],"additionalProperties":true},"extraConfig":{"type":"object","additionalProperties":true},"fsGid":{"type":["integer","null"],"minimum":0},"service":{"type":"object","additionalProperties":false,"properties":{"type":{"enum":["ClusterIP","NodePort","LoadBalancer","ExternalName"]},"ports":{"type":"object","additionalProperties":false,"properties":{"appProtocol":{"type":["string","null"]},"nodePort":{"type":["integer","null"],"minimum":0}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraPorts":{"type":"array"},"loadBalancerIP":{"type":["string","null"]},"ipFamilyPolicy":{"type":["string"]},"ipFamilies":{"type":"array"}}},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"existingSecret":{"type":["string","null"]},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"activeServerLimit":{"type":["integer","null"]},"allowNamedServers":{"type":["boolean","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"concurrentSpawnLimit":{"type":["integer","null"]},"consecutiveFailureLimit":{"type":["integer","null"]},"podSecurityContext":{"additionalProperties":true},"containerSecurityContext":{"type":"object","additionalProperties":true},"deploymentStrategy":{"type":"object","additionalProperties":false,"properties":{"rollingUpdate":{"type":["string","null"]},"type":{"type":["string","null"]}}},"extraContainers":{"type":"array"},"extraVolumeMounts":{"type":"array"},"extraVolumes":{"type":"array"},"livenessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"readinessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"namedServerLimitPerUser":{"type":["integer","null"]},"redirectToServer":{"type":["boolean","null"]},"resources":{"type":"object","additionalProperties":true},"lifecycle":{"type":"object","additionalProperties":false,"properties":{"postStart":{"type":"object","additionalProperties":true},"preStop":{"type":"object","additionalProperties":true}}},"services":{"type":"object","additionalProperties":true,"properties":{"name":{"type":"string"},"admin":{"type":"boolean"},"command":{"type":["string","array"]},"url":{"type":"string"},"api_token":{"type":["string","null"]},"apiToken":{"type":["string","null"]}}},"loadRoles":{"type":"object","additionalProperties":true},"shutdownOnLogout":{"type":["boolean","null"]},"templatePaths":{"type":"array"},"templateVars":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"extraPodSpec":{"type":"object","additionalProperties":true}}},"proxy":{"type":"object","additionalProperties":false,"properties":{"chp":{"type":"object","additionalProperties":false,"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"extraCommandLineFlags":{"type":"array"},"extraEnv":{"type":["object","array"],"additionalProperties":true},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"livenessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"readinessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"resources":{"type":"object","additionalProperties":true},"defaultTarget":{"type":["string","null"]},"errorTarget":{"type":["string","null"]},"extraPodSpec":{"type":"object","additionalProperties":true}}},"secretToken":{"type":["string","null"]},"service":{"type":"object","additionalProperties":false,"properties":{"type":{"enum":["ClusterIP","NodePort","LoadBalancer","ExternalName"]},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"nodePorts":{"type":"object","additionalProperties":false,"properties":{"http":{"type":["integer","null"]},"https":{"type":["integer","null"]}}},"loadBalancerPort":{"type":"object","additionalProperties":false,"properties":{"http":{"type":["integer","null"]},"https":{"type":["integer","null"]}}},"disableHttpPort":{"type":"boolean"},"extraPorts":{"type":"array"},"externalIPs":{"type":"array"},"loadBalancerIP":{"type":["string","null"]},"loadBalancerSourceRanges":{"type":"array"},"ipFamilyPolicy":{"type":["string"]},"ipFamilies":{"type":"array"}}},"https":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":["boolean","null"]},"type":{"enum":[null,"","letsencrypt","manual","offload","secret"]},"letsencrypt":{"type":"object","additionalProperties":false,"properties":{"contactEmail":{"type":["string","null"]},"acmeServer":{"type":["string","null"]}}},"manual":{"type":"object","additionalProperties":false,"properties":{"key":{"type":["string","null"]},"cert":{"type":["string","null"]}}},"secret":{"type":"object","additionalProperties":false,"properties":{"name":{"type":["string","null"]},"key":{"type":["string","null"]},"crt":{"type":["string","null"]}}},"hosts":{"type":"array"}}},"traefik":{"type":"object","additionalProperties":false,"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"extraInitContainers":{"type":"array"},"extraEnv":{"type":["object","array"],"additionalProperties":true},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"containerSecurityContext":{"type":"object","additionalProperties":true},"extraDynamicConfig":{"type":"object","additionalProperties":true},"extraPorts":{"type":"array"},"extraStaticConfig":{"type":"object","additionalProperties":true},"extraVolumes":{"type":"array"},"extraVolumeMounts":{"type":"array"},"hsts":{"type":"object","additionalProperties":false,"required":["includeSubdomains","maxAge","preload"],"properties":{"includeSubdomains":{"type":"boolean"},"maxAge":{"type":"integer"},"preload":{"type":"boolean"}}},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"extraPodSpec":{"type":"object","additionalProperties":true}}},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"deploymentStrategy":{"type":"object","additionalProperties":false,"properties":{"rollingUpdate":{"type":["string","null"]},"type":{"type":["string","null"]}}},"secretSync":{"type":"object","additionalProperties":false,"properties":{"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true}}}}},"monitoring":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false},"namespace":{"type":"string","default":"monitoring"},"releaseLabel":{"type":"string","default":"monitoring"},"hubMetrics":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false},"allowUnauthenticatedScrape":{"type":"boolean","default":false},"serviceAnnotations":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false}}}}},"serviceMonitor":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false},"interval":{"type":"string","default":"15s"},"authorization":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":true},"type":{"type":"string","default":"Bearer"},"hubServiceName":{"type":"string","minLength":1,"default":"prometheus-metrics"},"secret":{"type":"object","additionalProperties":false,"properties":{"create":{"type":"boolean","default":true},"name":{"type":"string","default":""},"key":{"type":"string","minLength":1,"default":"token"}}}}}}},"grafana":{"type":"object","additionalProperties":false,"properties":{"dashboard":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false}}}}},"prometheusRule":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false}}}}},"singleuser":{"type":"object","additionalProperties":false,"properties":{"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"podNameTemplate":{"type":["string","null"]},"cpu":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":["number","null"]},"guarantee":{"type":["number","null"]}}},"memory":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":["number","string","null"]},"guarantee":{"type":["number","string","null"]}}},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"initContainers":{"type":"array"},"profileList":{"type":"array"},"extraFiles":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"object","additionalProperties":false,"required":["mountPath"],"oneOf":[{"required":["data"]},{"required":["stringData"]},{"required":["binaryData"]}],"properties":{"mountPath":{"type":"string"},"data":{"type":"object","additionalProperties":true},"stringData":{"type":"string"},"binaryData":{"type":"string"},"mode":{"type":"number"}}}}},"extraEnv":{"type":["object","array"],"additionalProperties":true},"nodeSelector":{"type":"object","additionalProperties":true},"extraTolerations":{"type":"array"},"extraNodeAffinity":{"type":"object","additionalProperties":false,"properties":{"required":{"type":"array"},"preferred":{"type":"array"}}},"extraPodAffinity":{"type":"object","additionalProperties":false,"properties":{"required":{"type":"array"},"preferred":{"type":"array"}}},"extraPodAntiAffinity":{"type":"object","additionalProperties":false,"properties":{"required":{"type":"array"},"preferred":{"type":"array"}}},"cloudMetadata":{"type":"object","additionalProperties":false,"required":["blockWithIptables","ip"],"properties":{"blockWithIptables":{"type":"boolean"},"ip":{"type":"string"}}},"cmd":{"type":["array","string","null"]},"defaultUrl":{"type":["string","null"]},"events":{"type":["boolean","null"]},"extraAnnotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraContainers":{"type":"array"},"extraLabels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraPodConfig":{"type":"object","additionalProperties":true},"extraResource":{"type":"object","additionalProperties":false,"properties":{"guarantees":{"type":"object","additionalProperties":true},"limits":{"type":"object","additionalProperties":true}}},"fsGid":{"type":["integer","null"]},"lifecycleHooks":{"type":"object","additionalProperties":false,"properties":{"postStart":{"type":"object","additionalProperties":true},"preStop":{"type":"object","additionalProperties":true}}},"networkTools":{"type":"object","additionalProperties":false,"properties":{"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true}}},"serviceAccountName":{"type":["string","null"]},"startTimeout":{"type":["integer","null"]},"storage":{"type":"object","additionalProperties":false,"required":["type","homeMountPath"],"properties":{"capacity":{"type":["string","null"]},"dynamic":{"type":"object","additionalProperties":false,"properties":{"pvcNameTemplate":{"type":["string","null"]},"storageAccessModes":{"type":"array","items":{"type":["string","null"]}},"storageClass":{"type":["string","null"]},"subPath":{"type":["string","null"]},"volumeNameTemplate":{"type":["string","null"]}}},"extraLabels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraVolumeMounts":{"type":["object","array","null"]},"extraVolumes":{"type":["object","array","null"]},"homeMountPath":{"type":"string"},"static":{"type":"object","additionalProperties":false,"properties":{"pvcName":{"type":["string","null"]},"subPath":{"type":["string","null"]}}},"type":{"enum":["dynamic","static","none"]}}},"allowPrivilegeEscalation":{"type":["boolean","null"]},"uid":{"type":["integer","null"]}}},"scheduling":{"type":"object","additionalProperties":false,"properties":{"userScheduler":{"type":"object","additionalProperties":false,"required":["enabled","plugins","pluginConfig","logLevel"],"properties":{"enabled":{"type":"boolean"},"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"replicas":{"type":"integer"},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"containerSecurityContext":{"type":"object","additionalProperties":true},"logLevel":{"type":"integer"},"plugins":{"type":"object","additionalProperties":true},"pluginConfig":{"type":"array"},"resources":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"extraPodSpec":{"type":"object","additionalProperties":true}}},"podPriority":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"globalDefault":{"type":"boolean"},"defaultPriority":{"type":"integer"},"imagePullerPriority":{"type":"integer"},"userPlaceholderPriority":{"type":"integer"}}},"userPlaceholder":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"replicas":{"type":"integer"},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"resources":{"type":"object","additionalProperties":true},"containerSecurityContext":{"type":"object","additionalProperties":true},"extraPodSpec":{"type":"object","additionalProperties":true}}},"corePods":{"type":"object","additionalProperties":false,"properties":{"tolerations":{"type":"array"},"nodeAffinity":{"type":"object","additionalProperties":false,"properties":{"matchNodePurpose":{"enum":["ignore","prefer","require"]}}}}},"userPods":{"type":"object","additionalProperties":false,"properties":{"tolerations":{"type":"array"},"nodeAffinity":{"type":"object","additionalProperties":false,"properties":{"matchNodePurpose":{"enum":["ignore","prefer","require"]}}}}}}},"ingress":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"ingressClassName":{"type":["string","null"]},"hosts":{"type":"array"},"pathSuffix":{"type":["string","null"]},"pathType":{"enum":["Prefix","Exact","ImplementationSpecific"]},"tls":{"type":"array"},"extraPaths":{"type":"array"}}},"httpRoute":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"hostnames":{"type":"array"},"gateway":{"type":"object","additionalProperties":false,"required":["name"],"properties":{"name":{"type":"string"},"namespace":{"type":"string"},"sectionName":{"type":"string"}}}}},"prePuller":{"type":"object","additionalProperties":false,"required":["hook","continuous"],"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"resources":{"type":"object","additionalProperties":true},"extraTolerations":{"type":"array"},"hook":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"pullOnlyOnChanges":{"type":"boolean"},"podSchedulingWaitDuration":{"type":"integer"},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"serviceAccountImagePuller":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"daemonsetAnnotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"continuous":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"daemonsetAnnotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"pullProfileListImages":{"type":"boolean"},"extraImages":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]}}}}},"containerSecurityContext":{"type":"object","additionalProperties":true},"pause":{"type":"object","additionalProperties":false,"properties":{"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}}}}}},"custom":{"type":"object","additionalProperties":true,"properties":{"authMode":{"type":"string","enum":["auto-login","dummy","github","multi"]},"adminUser":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"}}},"notifications":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"topbar":{"type":"object","additionalProperties":false,"if":{"required":["enabled"],"properties":{"enabled":{"const":true}}},"then":{"required":["id"],"properties":{"id":{"minLength":1}},"anyOf":[{"required":["title"],"properties":{"title":{"minLength":1}}},{"required":["message"],"properties":{"message":{"minLength":1}}}]},"properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"version":{"type":"string"},"severity":{"type":"string","enum":["info","success","warning","danger"]},"dismissible":{"type":"boolean"},"eyebrow":{"type":"string"},"title":{"type":"string"},"message":{"type":"string"},"format":{"type":"string","enum":["text","markdown","html"]},"link":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string"},"url":{"type":"string"}}},"startsAt":{"type":"string"},"endsAt":{"type":"string"}}},"homepage":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"legacyAnnouncementFallback":{"type":"boolean"},"items":{"type":"array","items":{"type":"object","additionalProperties":false,"if":{"required":["enabled"],"properties":{"enabled":{"const":true}}},"then":{"required":["id"],"properties":{"id":{"minLength":1}},"anyOf":[{"required":["title"],"properties":{"title":{"minLength":1}}},{"required":["message"],"properties":{"message":{"minLength":1}}}]},"properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"version":{"type":"string"},"severity":{"type":"string","enum":["info","success","warning","danger"]},"dismissible":{"type":"boolean"},"eyebrow":{"type":"string"},"title":{"type":"string"},"message":{"type":"string"},"format":{"type":"string","enum":["text","markdown","html"]},"link":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string"},"url":{"type":"string"}}},"startsAt":{"type":"string"},"endsAt":{"type":"string"}}}}}}}},"accelerators":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"displayName":{"type":"string"},"description":{"type":"string"},"nodeSelector":{"type":"object","additionalProperties":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"quotaRate":{"type":"integer","minimum":1}}}},"resources":{"type":"object","additionalProperties":false,"properties":{"images":{"type":"object","additionalProperties":{"type":"string"}},"groupOrder":{"type":"array","items":{"type":"string"}},"requirements":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"cpu":{"type":"string"},"memory":{"type":"string"},"memory_limit":{"type":"string"},"amd.com/gpu":{"type":"string"}}}},"metadata":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"group":{"type":"string"},"description":{"type":"string"},"subDescription":{"type":"string"},"accelerator":{"type":"string"},"acceleratorKeys":{"type":"array","items":{"type":"string"}},"allowGitClone":{"type":"boolean"},"launchMode":{"type":"string","enum":["jupyterlab","code-server"]},"resourceType":{"type":"string","enum":["notebook","browser-ide"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"acceleratorOverrides":{"type":"object","additionalProperties":{"type":"object","properties":{"image":{"type":"string"},"env":{"type":"object","additionalProperties":{"type":"string"}}}}}}}}}},"teams":{"type":"object","additionalProperties":false,"properties":{"mapping":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}}},"quota":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":["boolean","null"]},"cpuRate":{"type":"integer","minimum":1},"minimumToStart":{"type":"integer","minimum":0},"defaultQuota":{"type":"integer","minimum":0},"refreshRules":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"schedule":{"type":"string"},"action":{"type":"string","enum":["add","set"]},"amount":{"type":"integer"},"maxBalance":{"type":["integer","null"]},"minBalance":{"type":["integer","null"]},"targets":{"type":"object","additionalProperties":false,"properties":{"includeUnlimited":{"type":"boolean"},"balanceBelow":{"type":["integer","null"]},"balanceAbove":{"type":["integer","null"]},"includeUsers":{"type":"array","items":{"type":"string"}},"excludeUsers":{"type":"array","items":{"type":"string"}},"usernamePattern":{"type":"string"}}}}}}}},"gitClone":{"type":"object","additionalProperties":false,"properties":{"initContainerImage":{"type":"string"},"allowedProviders":{"type":"array","items":{"type":"string"}},"maxCloneTimeout":{"type":"integer","minimum":10},"githubAppName":{"type":"string"},"defaultAccessToken":{"type":"string"}}},"hub":{"type":"object","additionalProperties":false,"properties":{"allowedOrigins":{"type":"array","items":{"type":"string"}}}},"notebook":{"type":"object","additionalProperties":false,"properties":{"allowedOrigins":{"type":"array","items":{"type":"string"}}}},"apiService":{"type":"object","additionalProperties":false,"properties":{"image":{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":["","IfNotPresent","Always","Never","null"]}}}}}}},"cull":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"users":{"type":["boolean","null"]},"adminUsers":{"type":["boolean","null"]},"removeNamedServers":{"type":["boolean","null"]},"timeout":{"type":["integer","null"]},"every":{"type":["integer","null"]},"concurrency":{"type":["integer","null"]},"maxAge":{"type":["integer","null"]}}},"debug":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"}}},"rbac":{"type":"object","additionalProperties":false,"required":["create"],"properties":{"enabled":{"type":"boolean"},"create":{"type":"boolean"}}},"global":{"type":"object","additionalProperties":true,"properties":{"safeToShowValues":{"type":"boolean"}}}}} \ No newline at end of file +{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"required":["imagePullSecrets","hub","proxy","singleuser","ingress","prePuller","custom","cull","debug","rbac","global"],"properties":{"enabled":{"type":["boolean","null"]},"fullnameOverride":{"type":["string","null"]},"nameOverride":{"type":["string","null"]},"imagePullSecret":{"type":"object","required":["create"],"if":{"properties":{"create":{"const":true}}},"then":{"additionalProperties":false,"required":["registry","username","password"],"properties":{"create":{"type":"boolean"},"automaticReferenceInjection":{"type":"boolean"},"registry":{"type":"string"},"username":{"type":"string"},"password":{"type":"string"},"email":{"type":["string","null"]}}}},"imagePullSecrets":{"type":"array"},"hub":{"type":"object","additionalProperties":false,"required":["baseUrl"],"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"config":{"type":"object","additionalProperties":false,"patternProperties":{"^[A-Z].*$":{"type":"object","additionalProperties":true}},"properties":{"JupyterHub":{"type":"object","additionalProperties":true,"properties":{"subdomain_host":{"type":"string"}}}}},"extraFiles":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"object","additionalProperties":false,"required":["mountPath"],"oneOf":[{"required":["data"]},{"required":["stringData"]},{"required":["binaryData"]}],"properties":{"mountPath":{"type":"string"},"data":{"type":"object","additionalProperties":true},"stringData":{"type":"string"},"binaryData":{"type":"string"},"mode":{"type":"number"}}}}},"baseUrl":{"type":"string"},"command":{"type":"array"},"args":{"type":"array"},"cookieSecret":{"type":["string","null"]},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"db":{"type":"object","additionalProperties":false,"properties":{"type":{"enum":["sqlite-pvc","sqlite-memory","mysql","postgres","other"]},"pvc":{"type":"object","additionalProperties":false,"required":["storage"],"properties":{"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"selector":{"type":"object","additionalProperties":true},"storage":{"type":"string"},"accessModes":{"type":"array","items":{"type":["string","null"]}},"storageClassName":{"type":["string","null"]},"subPath":{"type":["string","null"]}}},"upgrade":{"type":["boolean","null"]},"url":{"type":["string","null"]},"password":{"type":["string","null"]}}},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"initContainers":{"type":"array"},"extraEnv":{"type":["object","array"],"additionalProperties":true},"extraConfig":{"type":"object","additionalProperties":true},"fsGid":{"type":["integer","null"],"minimum":0},"service":{"type":"object","additionalProperties":false,"properties":{"type":{"enum":["ClusterIP","NodePort","LoadBalancer","ExternalName"]},"ports":{"type":"object","additionalProperties":false,"properties":{"appProtocol":{"type":["string","null"]},"nodePort":{"type":["integer","null"],"minimum":0}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraPorts":{"type":"array"},"loadBalancerIP":{"type":["string","null"]},"ipFamilyPolicy":{"type":["string"]},"ipFamilies":{"type":"array"}}},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"existingSecret":{"type":["string","null"]},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"activeServerLimit":{"type":["integer","null"]},"allowNamedServers":{"type":["boolean","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"concurrentSpawnLimit":{"type":["integer","null"]},"consecutiveFailureLimit":{"type":["integer","null"]},"podSecurityContext":{"additionalProperties":true},"containerSecurityContext":{"type":"object","additionalProperties":true},"deploymentStrategy":{"type":"object","additionalProperties":false,"properties":{"rollingUpdate":{"type":["string","null"]},"type":{"type":["string","null"]}}},"extraContainers":{"type":"array"},"extraVolumeMounts":{"type":"array"},"extraVolumes":{"type":"array"},"livenessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"readinessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"namedServerLimitPerUser":{"type":["integer","null"]},"redirectToServer":{"type":["boolean","null"]},"resources":{"type":"object","additionalProperties":true},"lifecycle":{"type":"object","additionalProperties":false,"properties":{"postStart":{"type":"object","additionalProperties":true},"preStop":{"type":"object","additionalProperties":true}}},"services":{"type":"object","additionalProperties":true,"properties":{"name":{"type":"string"},"admin":{"type":"boolean"},"command":{"type":["string","array"]},"url":{"type":"string"},"api_token":{"type":["string","null"]},"apiToken":{"type":["string","null"]}}},"loadRoles":{"type":"object","additionalProperties":true},"shutdownOnLogout":{"type":["boolean","null"]},"templatePaths":{"type":"array"},"templateVars":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"extraPodSpec":{"type":"object","additionalProperties":true}}},"proxy":{"type":"object","additionalProperties":false,"properties":{"chp":{"type":"object","additionalProperties":false,"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"extraCommandLineFlags":{"type":"array"},"extraEnv":{"type":["object","array"],"additionalProperties":true},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"livenessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"readinessProbe":{"type":"object","additionalProperties":true,"required":["enabled"],"if":{"properties":{"enabled":{"const":true}}},"then":{}},"resources":{"type":"object","additionalProperties":true},"defaultTarget":{"type":["string","null"]},"errorTarget":{"type":["string","null"]},"extraPodSpec":{"type":"object","additionalProperties":true}}},"secretToken":{"type":["string","null"]},"service":{"type":"object","additionalProperties":false,"properties":{"type":{"enum":["ClusterIP","NodePort","LoadBalancer","ExternalName"]},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"nodePorts":{"type":"object","additionalProperties":false,"properties":{"http":{"type":["integer","null"]},"https":{"type":["integer","null"]}}},"loadBalancerPort":{"type":"object","additionalProperties":false,"properties":{"http":{"type":["integer","null"]},"https":{"type":["integer","null"]}}},"disableHttpPort":{"type":"boolean"},"extraPorts":{"type":"array"},"externalIPs":{"type":"array"},"loadBalancerIP":{"type":["string","null"]},"loadBalancerSourceRanges":{"type":"array"},"ipFamilyPolicy":{"type":["string"]},"ipFamilies":{"type":"array"}}},"https":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":["boolean","null"]},"type":{"enum":[null,"","letsencrypt","manual","offload","secret"]},"letsencrypt":{"type":"object","additionalProperties":false,"properties":{"contactEmail":{"type":["string","null"]},"acmeServer":{"type":["string","null"]}}},"manual":{"type":"object","additionalProperties":false,"properties":{"key":{"type":["string","null"]},"cert":{"type":["string","null"]}}},"secret":{"type":"object","additionalProperties":false,"properties":{"name":{"type":["string","null"]},"key":{"type":["string","null"]},"crt":{"type":["string","null"]}}},"hosts":{"type":"array"}}},"traefik":{"type":"object","additionalProperties":false,"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"extraInitContainers":{"type":"array"},"extraEnv":{"type":["object","array"],"additionalProperties":true},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"containerSecurityContext":{"type":"object","additionalProperties":true},"extraDynamicConfig":{"type":"object","additionalProperties":true},"extraPorts":{"type":"array"},"extraStaticConfig":{"type":"object","additionalProperties":true},"extraVolumes":{"type":"array"},"extraVolumeMounts":{"type":"array"},"hsts":{"type":"object","additionalProperties":false,"required":["includeSubdomains","maxAge","preload"],"properties":{"includeSubdomains":{"type":"boolean"},"maxAge":{"type":"integer"},"preload":{"type":"boolean"}}},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"extraPodSpec":{"type":"object","additionalProperties":true}}},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"deploymentStrategy":{"type":"object","additionalProperties":false,"properties":{"rollingUpdate":{"type":["string","null"]},"type":{"type":["string","null"]}}},"secretSync":{"type":"object","additionalProperties":false,"properties":{"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true}}}}},"monitoring":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false},"namespace":{"type":"string","default":"monitoring"},"releaseLabel":{"type":"string","default":"monitoring"},"hubMetrics":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false},"allowUnauthenticatedScrape":{"type":"boolean","default":false},"serviceAnnotations":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false}}}}},"serviceMonitor":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false},"interval":{"type":"string","default":"15s"},"authorization":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":true},"type":{"type":"string","default":"Bearer"},"hubServiceName":{"type":"string","minLength":1,"default":"prometheus-metrics"},"secret":{"type":"object","additionalProperties":false,"properties":{"create":{"type":"boolean","default":true},"name":{"type":"string","default":""},"key":{"type":"string","minLength":1,"default":"token"}}}}}}},"grafana":{"type":"object","additionalProperties":false,"properties":{"dashboard":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false}}}}},"prometheusRule":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean","default":false}}}}},"singleuser":{"type":"object","additionalProperties":false,"properties":{"networkPolicy":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"ingress":{"type":"array"},"egress":{"type":"array"},"egressAllowRules":{"type":"object","additionalProperties":false,"properties":{"cloudMetadataServer":{"type":"boolean"},"dnsPortsCloudMetadataServer":{"type":"boolean"},"dnsPortsKubeSystemNamespace":{"type":"boolean"},"dnsPortsPrivateIPs":{"type":"boolean"},"nonPrivateIPs":{"type":"boolean"},"privateIPs":{"type":"boolean"}}},"interNamespaceAccessLabels":{"enum":["accept","ignore"]},"allowedIngressPorts":{"type":"array"}}},"podNameTemplate":{"type":["string","null"]},"cpu":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":["number","null"]},"guarantee":{"type":["number","null"]}}},"memory":{"type":"object","additionalProperties":false,"properties":{"limit":{"type":["number","string","null"]},"guarantee":{"type":["number","string","null"]}}},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"initContainers":{"type":"array"},"profileList":{"type":"array"},"extraFiles":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"object","additionalProperties":false,"required":["mountPath"],"oneOf":[{"required":["data"]},{"required":["stringData"]},{"required":["binaryData"]}],"properties":{"mountPath":{"type":"string"},"data":{"type":"object","additionalProperties":true},"stringData":{"type":"string"},"binaryData":{"type":"string"},"mode":{"type":"number"}}}}},"extraEnv":{"type":["object","array"],"additionalProperties":true},"nodeSelector":{"type":"object","additionalProperties":true},"extraTolerations":{"type":"array"},"extraNodeAffinity":{"type":"object","additionalProperties":false,"properties":{"required":{"type":"array"},"preferred":{"type":"array"}}},"extraPodAffinity":{"type":"object","additionalProperties":false,"properties":{"required":{"type":"array"},"preferred":{"type":"array"}}},"extraPodAntiAffinity":{"type":"object","additionalProperties":false,"properties":{"required":{"type":"array"},"preferred":{"type":"array"}}},"cloudMetadata":{"type":"object","additionalProperties":false,"required":["blockWithIptables","ip"],"properties":{"blockWithIptables":{"type":"boolean"},"ip":{"type":"string"}}},"cmd":{"type":["array","string","null"]},"defaultUrl":{"type":["string","null"]},"events":{"type":["boolean","null"]},"extraAnnotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraContainers":{"type":"array"},"extraLabels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraPodConfig":{"type":"object","additionalProperties":true},"extraResource":{"type":"object","additionalProperties":false,"properties":{"guarantees":{"type":"object","additionalProperties":true},"limits":{"type":"object","additionalProperties":true}}},"fsGid":{"type":["integer","null"]},"lifecycleHooks":{"type":"object","additionalProperties":false,"properties":{"postStart":{"type":"object","additionalProperties":true},"preStop":{"type":"object","additionalProperties":true}}},"networkTools":{"type":"object","additionalProperties":false,"properties":{"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true}}},"serviceAccountName":{"type":["string","null"]},"startTimeout":{"type":["integer","null"]},"storage":{"type":"object","additionalProperties":false,"required":["type","homeMountPath"],"properties":{"capacity":{"type":["string","null"]},"dynamic":{"type":"object","additionalProperties":false,"properties":{"pvcNameTemplate":{"type":["string","null"]},"storageAccessModes":{"type":"array","items":{"type":["string","null"]}},"storageClass":{"type":["string","null"]},"subPath":{"type":["string","null"]},"volumeNameTemplate":{"type":["string","null"]}}},"extraLabels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"extraVolumeMounts":{"type":["object","array","null"]},"extraVolumes":{"type":["object","array","null"]},"homeMountPath":{"type":"string"},"static":{"type":"object","additionalProperties":false,"properties":{"pvcName":{"type":["string","null"]},"subPath":{"type":["string","null"]}}},"type":{"enum":["dynamic","static","none"]}}},"allowPrivilegeEscalation":{"type":["boolean","null"]},"uid":{"type":["integer","null"]}}},"scheduling":{"type":"object","additionalProperties":false,"properties":{"userScheduler":{"type":"object","additionalProperties":false,"required":["enabled","plugins","pluginConfig","logLevel"],"properties":{"enabled":{"type":"boolean"},"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"replicas":{"type":"integer"},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"pdb":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"maxUnavailable":{"type":["integer","null"]},"minAvailable":{"type":["integer","null"]}}},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"containerSecurityContext":{"type":"object","additionalProperties":true},"logLevel":{"type":"integer"},"plugins":{"type":"object","additionalProperties":true},"pluginConfig":{"type":"array"},"resources":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"extraPodSpec":{"type":"object","additionalProperties":true}}},"podPriority":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"globalDefault":{"type":"boolean"},"defaultPriority":{"type":"integer"},"imagePullerPriority":{"type":"integer"},"userPlaceholderPriority":{"type":"integer"}}},"userPlaceholder":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"replicas":{"type":"integer"},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"resources":{"type":"object","additionalProperties":true},"containerSecurityContext":{"type":"object","additionalProperties":true},"extraPodSpec":{"type":"object","additionalProperties":true}}},"corePods":{"type":"object","additionalProperties":false,"properties":{"tolerations":{"type":"array"},"nodeAffinity":{"type":"object","additionalProperties":false,"properties":{"matchNodePurpose":{"enum":["ignore","prefer","require"]}}}}},"userPods":{"type":"object","additionalProperties":false,"properties":{"tolerations":{"type":"array"},"nodeAffinity":{"type":"object","additionalProperties":false,"properties":{"matchNodePurpose":{"enum":["ignore","prefer","require"]}}}}}}},"ingress":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"ingressClassName":{"type":["string","null"]},"hosts":{"type":"array"},"pathSuffix":{"type":["string","null"]},"pathType":{"enum":["Prefix","Exact","ImplementationSpecific"]},"tls":{"type":"array"},"extraPaths":{"type":"array"}}},"httpRoute":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"hostnames":{"type":"array"},"gateway":{"type":"object","additionalProperties":false,"required":["name"],"properties":{"name":{"type":"string"},"namespace":{"type":"string"},"sectionName":{"type":"string"}}}}},"prePuller":{"type":"object","additionalProperties":false,"required":["hook","continuous"],"properties":{"revisionHistoryLimit":{"type":["integer","null"],"minimum":0},"labels":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}},"resources":{"type":"object","additionalProperties":true},"extraTolerations":{"type":"array"},"hook":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"pullOnlyOnChanges":{"type":"boolean"},"podSchedulingWaitDuration":{"type":"integer"},"nodeSelector":{"type":"object","additionalProperties":true},"tolerations":{"type":"array"},"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}},"resources":{"type":"object","additionalProperties":true},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"serviceAccountImagePuller":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"daemonsetAnnotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"continuous":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"serviceAccount":{"type":"object","required":["create"],"additionalProperties":false,"properties":{"create":{"type":"boolean"},"name":{"type":["string","null"]},"annotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"daemonsetAnnotations":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"string"}}}}},"pullProfileListImages":{"type":"boolean"},"extraImages":{"type":"object","additionalProperties":false,"patternProperties":{".*":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]}}}}},"containerSecurityContext":{"type":"object","additionalProperties":true},"pause":{"type":"object","additionalProperties":false,"properties":{"containerSecurityContext":{"type":"object","additionalProperties":true},"image":{"type":"object","additionalProperties":false,"required":["name","tag"],"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":[null,"","IfNotPresent","Always","Never"]},"pullSecrets":{"type":"array"}}}}}}},"custom":{"type":"object","additionalProperties":true,"properties":{"authMode":{"type":"string","enum":["auto-login","dummy","github","multi"]},"adminUser":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"}}},"notifications":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"topbar":{"type":"object","additionalProperties":false,"if":{"required":["enabled"],"properties":{"enabled":{"const":true}}},"then":{"required":["id"],"properties":{"id":{"minLength":1}},"anyOf":[{"required":["title"],"properties":{"title":{"minLength":1}}},{"required":["message"],"properties":{"message":{"minLength":1}}}]},"properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"version":{"type":"string"},"severity":{"type":"string","enum":["info","success","warning","danger"]},"dismissible":{"type":"boolean"},"eyebrow":{"type":"string"},"title":{"type":"string"},"message":{"type":"string"},"format":{"type":"string","enum":["text","markdown","html"]},"link":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string"},"url":{"type":"string"}}},"startsAt":{"type":"string"},"endsAt":{"type":"string"}}},"homepage":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"legacyAnnouncementFallback":{"type":"boolean"},"items":{"type":"array","items":{"type":"object","additionalProperties":false,"if":{"required":["enabled"],"properties":{"enabled":{"const":true}}},"then":{"required":["id"],"properties":{"id":{"minLength":1}},"anyOf":[{"required":["title"],"properties":{"title":{"minLength":1}}},{"required":["message"],"properties":{"message":{"minLength":1}}}]},"properties":{"enabled":{"type":"boolean"},"id":{"type":"string"},"version":{"type":"string"},"severity":{"type":"string","enum":["info","success","warning","danger"]},"dismissible":{"type":"boolean"},"eyebrow":{"type":"string"},"title":{"type":"string"},"message":{"type":"string"},"format":{"type":"string","enum":["text","markdown","html"]},"link":{"type":"object","additionalProperties":false,"properties":{"label":{"type":"string"},"url":{"type":"string"}}},"startsAt":{"type":"string"},"endsAt":{"type":"string"}}}}}}}},"accelerators":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"displayName":{"type":"string"},"description":{"type":"string"},"nodeSelector":{"type":"object","additionalProperties":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"quotaRate":{"type":"integer","minimum":1}}}},"resources":{"type":"object","additionalProperties":false,"properties":{"images":{"type":"object","additionalProperties":{"type":"string"}},"groupOrder":{"type":"array","items":{"type":"string"}},"requirements":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"cpu":{"type":"string"},"memory":{"type":"string"},"memory_limit":{"type":"string"},"amd.com/gpu":{"type":"string"}}}},"metadata":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"group":{"type":"string"},"description":{"type":"string"},"subDescription":{"type":"string"},"accelerator":{"type":"string"},"acceleratorKeys":{"type":"array","items":{"type":"string"}},"allowGitClone":{"type":"boolean"},"defaultPath":{"type":["string","null"]},"launchMode":{"type":"string","enum":["jupyterlab","code-server"]},"resourceType":{"type":"string","enum":["notebook","browser-ide"]},"env":{"type":"object","additionalProperties":{"type":"string"}},"acceleratorOverrides":{"type":"object","additionalProperties":{"type":"object","properties":{"image":{"type":"string"},"env":{"type":"object","additionalProperties":{"type":"string"}}}}}}}}}},"teams":{"type":"object","additionalProperties":false,"properties":{"mapping":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}}},"quota":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":["boolean","null"]},"cpuRate":{"type":"integer","minimum":1},"minimumToStart":{"type":"integer","minimum":0},"defaultQuota":{"type":"integer","minimum":0},"refreshRules":{"type":"object","additionalProperties":{"type":"object","additionalProperties":false,"properties":{"enabled":{"type":"boolean"},"schedule":{"type":"string"},"action":{"type":"string","enum":["add","set"]},"amount":{"type":"integer"},"maxBalance":{"type":["integer","null"]},"minBalance":{"type":["integer","null"]},"targets":{"type":"object","additionalProperties":false,"properties":{"includeUnlimited":{"type":"boolean"},"balanceBelow":{"type":["integer","null"]},"balanceAbove":{"type":["integer","null"]},"includeUsers":{"type":"array","items":{"type":"string"}},"excludeUsers":{"type":"array","items":{"type":"string"}},"usernamePattern":{"type":"string"}}}}}}}},"gitClone":{"type":"object","additionalProperties":false,"properties":{"initContainerImage":{"type":"string"},"allowedProviders":{"type":"array","items":{"type":"string"}},"maxCloneTimeout":{"type":"integer","minimum":10},"githubAppName":{"type":"string"},"defaultAccessToken":{"type":"string"},"defaultPersistence":{"type":"boolean"},"allowPersistenceChoice":{"type":"boolean"}}},"hub":{"type":"object","additionalProperties":false,"properties":{"allowedOrigins":{"type":"array","items":{"type":"string"}}}},"notebook":{"type":"object","additionalProperties":false,"properties":{"allowedOrigins":{"type":"array","items":{"type":"string"}}}},"codeServer":{"type":"object","additionalProperties":false,"properties":{"extraTrustedDomains":{"type":"array","items":{"type":"string"}}}},"apiService":{"type":"object","additionalProperties":false,"properties":{"image":{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string"},"tag":{"type":"string"},"pullPolicy":{"enum":["","IfNotPresent","Always","Never","null"]}}}}}}},"cull":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"},"users":{"type":["boolean","null"]},"adminUsers":{"type":["boolean","null"]},"removeNamedServers":{"type":["boolean","null"]},"timeout":{"type":["integer","null"]},"every":{"type":["integer","null"]},"concurrency":{"type":["integer","null"]},"maxAge":{"type":["integer","null"]}}},"debug":{"type":"object","additionalProperties":false,"required":["enabled"],"properties":{"enabled":{"type":"boolean"}}},"rbac":{"type":"object","additionalProperties":false,"required":["create"],"properties":{"enabled":{"type":"boolean"},"create":{"type":"boolean"}}},"global":{"type":"object","additionalProperties":true,"properties":{"safeToShowValues":{"type":"boolean"}}}}} \ No newline at end of file diff --git a/runtime/chart/values.schema.yaml b/runtime/chart/values.schema.yaml index e722c67f..22ff5fec 100644 --- a/runtime/chart/values.schema.yaml +++ b/runtime/chart/values.schema.yaml @@ -3443,6 +3443,22 @@ properties: Allow users to clone a Git repository into their home directory at spawn time. Should be false for course resources that already include their own content. + defaultPath: + type: + - string + - 'null' + description: | + Initial landing path inside the container for this resource. + The effective target path is Custom Repo clone path, then + `defaultPath`, then the image or single-user application + default, normally the image `WORKDIR`. Omit the field or set + it to null to avoid a Hub landing override; an empty string + is invalid; `/` lands at the container root. The Hub + validates the path syntax and normalizes it before launch, + but it does not check whether the path exists in the image. + This value is not a security boundary or an access boundary. + Use an absolute POSIX container path such as + `/opt/workspace/CV`. launchMode: type: string enum: @@ -3649,8 +3665,10 @@ properties: additionalProperties: false description: | Git repository cloning configuration. - Users can optionally provide a Git URL on the spawn form; the repo is + Users can optionally provide a Git URL on the spawn form. The repo is cloned into their home directory via an init container at startup. + By default, cloned repositories are kept after the user's server stops. + Existing unmanaged same-name directories are preserved and not overwritten. properties: initContainerImage: type: string @@ -3683,6 +3701,19 @@ properties: Used as fallback when user provides no PAT and no OAuth token is available. Priority: user PAT > OAuth token > defaultAccessToken. Helm auto-creates a K8s Secret (jupyterhub-git-default-token) from this value. + defaultPersistence: + type: boolean + description: | + Whether cloned repositories are kept by default after the user's server stops. + Defaults to true, so cloned repos persist unless an admin sets this + to false or allows users to choose ephemeral cleanup. Persistent mode + does not auto-pull, reset, or sync repository contents after clone. + allowPersistenceChoice: + type: boolean + description: | + Allow users to choose whether cloned repositories persist in the spawn form. + When false, the admin default is enforced and no user toggle is shown. + When true, the spawn form choice defaults to `defaultPersistence`. hub: type: object @@ -3715,6 +3746,23 @@ properties: connections go through a reverse proxy or non-standard domain. Use ["*"] to allow all, or list specific domains. + codeServer: + type: object + additionalProperties: false + description: | + code-server settings for browser IDE resources. + properties: + extraTrustedDomains: + type: array + items: + type: string + description: | + Additional host/domain entries trusted by code-server's outgoing + link protection. The Hub automatically adds the current public Hub + host for Back-to-Hub links; use this list only for extra trusted + sites such as internal documentation or Git services. Provide + host/domain entries, not full URLs. + apiService: type: object additionalProperties: false diff --git a/runtime/chart/values.yaml b/runtime/chart/values.yaml index 2e0a07ff..a548691f 100644 --- a/runtime/chart/values.yaml +++ b/runtime/chart/values.yaml @@ -38,6 +38,10 @@ custom: # - multi: GitHub App + Local accounts authMode: "auto-login" + # Cluster display name (optional). Appended to "AUP Learning Cloud" in the UI. + # Example: "City/University" → "AUP Learning Cloud City/University" + clusterName: "" + # Auto-create admin user on first install (optional) adminUser: enabled: false @@ -69,6 +73,11 @@ custom: notebook: allowedOrigins: [] + # code-server automatically trusts the current public Hub host for Back-to-Hub + # links. Use this only to add host/domain entries for additional trusted sites. + codeServer: + extraTrustedDomains: [] + # User quota management system quota: # Enable/disable quota system (auto-disabled for auto-login/dummy modes if not set) diff --git a/runtime/hub/core/authenticators/__init__.py b/runtime/hub/core/authenticators/__init__.py index ca0e3255..7a491341 100644 --- a/runtime/hub/core/authenticators/__init__.py +++ b/runtime/hub/core/authenticators/__init__.py @@ -25,7 +25,7 @@ from core.authenticators.auto_login import AutoLoginAuthenticator from core.authenticators.firstuse import CustomFirstUseAuthenticator -from core.authenticators.github_app import CustomGitHubOAuthenticator +from core.authenticators.github_app import GITHUB_USERNAME_PREFIX, CustomGitHubOAuthenticator from core.authenticators.jwt import RemoteLabAuthenticator from core.authenticators.multi import CustomMultiAuthenticator @@ -64,4 +64,5 @@ def create_authenticator(auth_mode: str, **kwargs): "CustomMultiAuthenticator", "create_authenticator", "LOCAL_ACCOUNT_PREFIX", + "GITHUB_USERNAME_PREFIX", ] diff --git a/runtime/hub/core/authenticators/github_app.py b/runtime/hub/core/authenticators/github_app.py index f9939644..6e04b2c7 100644 --- a/runtime/hub/core/authenticators/github_app.py +++ b/runtime/hub/core/authenticators/github_app.py @@ -35,6 +35,8 @@ log = logging.getLogger("jupyterhub.auth.github") +GITHUB_USERNAME_PREFIX = "github:" + class _GitHubAppInstallCallbackHandler(OAuthCallbackHandler): """Callback handler that gracefully handles GitHub App installation redirects. @@ -57,6 +59,7 @@ class CustomGitHubOAuthenticator(GitHubOAuthenticator): """GitHub App authenticator with access token preservation and refresh.""" name = "github" + prefix = GITHUB_USERNAME_PREFIX callback_handler = _GitHubAppInstallCallbackHandler app_id = Unicode( diff --git a/runtime/hub/core/config.py b/runtime/hub/core/config.py index 469d5de3..3925bbf0 100644 --- a/runtime/hub/core/config.py +++ b/runtime/hub/core/config.py @@ -43,7 +43,7 @@ from typing import Any, Literal import yaml -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator # ============================================================================= # YAML Configuration Models @@ -94,6 +94,34 @@ class AcceleratorOverride(BaseModel): model_config = {"extra": "allow"} +def _normalize_container_path(value: str | None) -> str | None: + """Normalize an absolute container path without touching the host filesystem.""" + + if value is None: + return None + + if not isinstance(value, str): + raise ValueError("defaultPath must be a string or null") + + stripped_value = value.strip() + if not stripped_value: + raise ValueError("defaultPath cannot be empty") + if "\x00" in stripped_value: + raise ValueError("defaultPath cannot contain NUL bytes") + if not stripped_value.startswith("/"): + raise ValueError("defaultPath must be an absolute container path") + + normalized_segments: list[str] = [] + for segment in stripped_value.split("/"): + if segment in {"", "."}: + continue + if segment == "..": + raise ValueError("defaultPath cannot contain '..' segments") + normalized_segments.append(segment) + + return "/" if not normalized_segments else "/" + "/".join(normalized_segments) + + class ResourceMetadata(BaseModel): """Metadata for a resource (course/tutorial).""" @@ -103,11 +131,19 @@ class ResourceMetadata(BaseModel): accelerator: str = "" acceleratorKeys: list[str] = Field(default_factory=list) allowGitClone: bool = False + defaultPath: str | None = None resourceType: Literal["notebook", "browser-ide"] | None = None launchMode: str | None = None env: dict[str, str] = Field(default_factory=dict) acceleratorOverrides: dict[str, AcceleratorOverride] | None = None + @field_validator("defaultPath", mode="before") + @classmethod + def validate_default_path(cls, value: str | None) -> str | None: + """Validate and normalize a resource's default container path.""" + + return _normalize_container_path(value) + model_config = {"extra": "allow"} @@ -138,6 +174,8 @@ class GitCloneSettings(BaseModel): maxCloneTimeout: int = 300 githubAppName: str = "" defaultAccessToken: str = "" + allowPersistenceChoice: bool = False + defaultPersistence: bool = True model_config = {"extra": "allow"} @@ -158,6 +196,14 @@ class NotebookNetworkSettings(BaseModel): model_config = {"extra": "allow"} +class CodeServerSettings(BaseModel): + """Settings applied to code-server resources.""" + + extraTrustedDomains: list[str] = Field(default_factory=list) + + model_config = {"extra": "allow"} + + class ParsedConfig(BaseModel): """Parsed configuration from values.yaml custom section.""" @@ -168,6 +214,7 @@ class ParsedConfig(BaseModel): gitClone: GitCloneSettings = Field(default_factory=GitCloneSettings) hub: HubNetworkSettings = Field(default_factory=HubNetworkSettings) notebook: NotebookNetworkSettings = Field(default_factory=NotebookNetworkSettings) + codeServer: CodeServerSettings = Field(default_factory=CodeServerSettings) notifications: dict[str, Any] = Field(default_factory=dict) model_config = {"extra": "allow"} @@ -182,6 +229,7 @@ def from_dicts( git_clone: dict | None = None, hub: dict | None = None, notebook: dict | None = None, + code_server: dict | None = None, notifications: dict | None = None, ) -> ParsedConfig: """Create configuration from individual dicts.""" @@ -201,6 +249,8 @@ def from_dicts( raw_config["hub"] = hub if notebook: raw_config["notebook"] = notebook + if code_server: + raw_config["codeServer"] = code_server if notifications is not None: raw_config["notifications"] = notifications @@ -231,6 +281,7 @@ def __init__(self): self.auth_mode: str = "auto-login" self.single_node_mode: bool = False self.github_org_name: str = "" + self.cluster_name: str = "" self.quota_enabled: bool = False # Parsed configuration @@ -268,6 +319,7 @@ def init(cls, config_path: str | Path) -> HubConfig: # Extract runtime settings instance.auth_mode = raw_config.get("authMode", "auto-login") instance.github_org_name = raw_config.get("githubOrgName", "") + instance.cluster_name = raw_config.get("clusterName", "") # Single-node mode: from config or auto-enable for auto-login single_node_mode = raw_config.get("singleNodeMode") @@ -285,6 +337,7 @@ def init(cls, config_path: str | Path) -> HubConfig: git_clone=raw_config.get("gitClone"), hub=raw_config.get("hub"), notebook=raw_config.get("notebook"), + code_server=raw_config.get("codeServer"), notifications=raw_config.get("notifications"), ) @@ -331,6 +384,13 @@ def is_initialized(cls) -> bool: # Convenience Properties # ========================================================================= + @property + def platform_display_name(self) -> str: + base = "AUP Learning Cloud" + if self.cluster_name: + return f"{base} {self.cluster_name}" + return base + @property def resources(self) -> ResourcesConfig: """Get resources configuration.""" @@ -366,6 +426,11 @@ def notebook_network(self) -> NotebookNetworkSettings: """Get notebook server network settings.""" return self._config.notebook + @property + def code_server(self) -> CodeServerSettings: + """Get code-server settings.""" + return self._config.codeServer + @property def notifications(self) -> dict[str, Any]: """Get raw notification configuration for backend normalization.""" diff --git a/runtime/hub/core/groups.py b/runtime/hub/core/groups.py index f245d9fd..30d7b085 100644 --- a/runtime/hub/core/groups.py +++ b/runtime/hub/core/groups.py @@ -39,6 +39,8 @@ from jupyterhub.user import User as JupyterHubUser from sqlalchemy.orm import Session +from core.authenticators.github_app import GITHUB_USERNAME_PREFIX + log = logging.getLogger("jupyterhub.groups") GITHUB_TEAM_SOURCE = "github-team" @@ -548,7 +550,7 @@ async def sync_github_teams_for_user( protection. Concurrent spawns for the same user coalesce into one set of GitHub team membership checks within the TTL window. """ - if not user.name.startswith("github:") or not app_id: + if not user.name.startswith(GITHUB_USERNAME_PREFIX) or not app_id: return False lock = _GITHUB_TEAM_SYNC_LOCKS.setdefault(user.name, asyncio.Lock()) @@ -714,7 +716,7 @@ def resolve_resources_for_user( if available_resources: return available_resources - if not username.startswith("github:"): + if not username.startswith(GITHUB_USERNAME_PREFIX): return team_resource_mapping.get("native-users", team_resource_mapping.get("official", [])) return ["none"] diff --git a/runtime/hub/core/handlers.py b/runtime/hub/core/handlers.py index 483433de..964a42fd 100644 --- a/runtime/hub/core/handlers.py +++ b/runtime/hub/core/handlers.py @@ -41,7 +41,7 @@ from pydantic import ValidationError from tornado import web -from core.authenticators import CustomFirstUseAuthenticator +from core.authenticators import GITHUB_USERNAME_PREFIX, CustomFirstUseAuthenticator from core.git_validation import validate_and_sanitize_repo_url from core.notifications import get_normalized_notifications from core.quota import ( @@ -73,6 +73,7 @@ "default_quota": 0, "team_resource_mapping": {}, "auth_mode": "auto-login", + "platform_name": "AUP Learning Cloud", } @@ -121,6 +122,7 @@ def configure_handlers( team_resource_mapping: dict[str, list[str]] | None = None, github_org: str = "", auth_mode: str = "auto-login", + platform_name: str = "AUP Learning Cloud", ) -> None: """Configure handler module with runtime settings.""" if accelerator_options is not None: @@ -134,6 +136,7 @@ def configure_handlers( _handler_config["team_resource_mapping"] = team_resource_mapping _handler_config["github_org"] = github_org _handler_config["auth_mode"] = auth_mode + _handler_config["platform_name"] = platform_name # ============================================================================= @@ -265,7 +268,7 @@ def _render_error(msg: str): return self.finish(html) username = user.name - if username.startswith("github:"): + if username.startswith(GITHUB_USERNAME_PREFIX): html = await _render_error("GitHub users cannot change password here") self.set_status(400) return self.finish(html) @@ -322,7 +325,7 @@ async def get(self): from jupyterhub.orm import User for user in self.db.query(User).all(): - if not user.name.startswith("github:") and user.name != "admin": + if not user.name.startswith(GITHUB_USERNAME_PREFIX) and user.name != "admin": native_users.append(user.name) html = await self.render_template( @@ -356,7 +359,7 @@ async def post(self): ) username = target_user - if username.startswith("github:"): + if username.startswith(GITHUB_USERNAME_PREFIX): return self.redirect( self.hub.base_url + f"admin/reset-password?user={target_user}&error=Cannot+reset+password+for+GitHub+users" @@ -438,7 +441,7 @@ async def post(self): self.set_header("Content-Type", "application/json") return self.finish(json.dumps({"error": "Username and password are required"})) - if username.startswith("github:"): + if username.startswith(GITHUB_USERNAME_PREFIX): self.set_status(400) self.set_header("Content-Type", "application/json") return self.finish(json.dumps({"error": "Cannot set password for GitHub users"})) @@ -532,7 +535,7 @@ async def post(self): self.set_status(400) self.set_header("Content-Type", "application/json") return self.finish(json.dumps({"error": "Each entry must have username and password"})) - if entry.get("username", "").startswith("github:"): + if entry.get("username", "").startswith(GITHUB_USERNAME_PREFIX): self.set_status(400) self.set_header("Content-Type", "application/json") return self.finish( @@ -998,6 +1001,8 @@ def group_sort_key(group_name: str) -> tuple[int, int, bool, str]: "acceleratorKeys": list(config.accelerators.keys()), "allowedGitProviders": list(config.git_clone.allowedProviders), "githubAppName": config.git_clone.githubAppName, + "allowPersistenceChoice": config.git_clone.allowPersistenceChoice, + "defaultPersistence": config.git_clone.defaultPersistence, } ) ) @@ -1313,14 +1318,15 @@ class PlatformInfoHandler(APIHandler): """ async def get(self): + name = _handler_config.get("platform_name", "AUP Learning Cloud") self.set_header("Content-Type", "application/json") - self.set_header("X-Powered-By", "AUP Learning Cloud") + self.set_header("X-Powered-By", name) self.finish( json.dumps( { - "platform": "AUP Learning Cloud", + "platform": name, "vendor": "Advanced Micro Devices, Inc.", - "powered_by": "AUP Learning Cloud", + "powered_by": name, "website": "https://github.com/AMDResearch/aup-learning-cloud", } ) @@ -1591,7 +1597,7 @@ async def post(self): skipped = 0 for user in self.users.values(): - if not user.name.startswith("github:"): + if not user.name.startswith(GITHUB_USERNAME_PREFIX): skipped += 1 continue diff --git a/runtime/hub/core/scripts/git-clone.sh b/runtime/hub/core/scripts/git-clone.sh index c127dbce..9dff2a9b 100644 --- a/runtime/hub/core/scripts/git-clone.sh +++ b/runtime/hub/core/scripts/git-clone.sh @@ -20,9 +20,9 @@ # Git repository clone script for JupyterHub init container. # -# Clones a repository onto the user's home PVC. The clone directory is -# cleaned up by a preStop lifecycle hook when the session ends, so the -# repository does not persist between sessions. +# Clones a repository onto the user's home PVC. Existing clone directories are +# reused or replaced only when repo-external AUPLC metadata proves they are +# managed by this script. # # Environment variables (required): # REPO_URL - HTTPS URL of the git repository to clone @@ -31,6 +31,8 @@ # # Environment variables (optional): # BRANCH - Branch or tag to check out (default: repository's default branch) +# PERSIST_CLONED_REPO - true to reuse a compatible managed clone (default: true) +# AUPLC_GIT_METADATA_DIR - Directory for repo-external clone metadata export HOME=/tmp @@ -43,38 +45,173 @@ git config --global http.sslVerify true git config --global user.email jupyterhub@local git config --global user.name JupyterHub +case "${PERSIST_CLONED_REPO:-true}" in + true|True|TRUE|1|yes|Yes|YES) + _persistence_mode="persistent" + ;; + false|False|FALSE|0|no|No|NO) + _persistence_mode="ephemeral" + ;; + *) + echo "Invalid PERSIST_CLONED_REPO value. Use true or false." + exit 1 + ;; +esac + +_metadata_dir=${AUPLC_GIT_METADATA_DIR:-"$(dirname "$CLONE_DIR")/.auplc/git-clones"} +_clone_base=$(basename "$CLONE_DIR" | tr -c 'A-Za-z0-9._-' '_' | sed 's/^_*//; s/_*$//') +if [ -z "$_clone_base" ]; then + _clone_base="clone" +fi +_clone_hash=$(printf '%s' "$CLONE_DIR" | cksum | cut -d ' ' -f 1) +_metadata_file="$_metadata_dir/${_clone_base}-${_clone_hash}.metadata" +_metadata_version="1" +_branch_value=${BRANCH:-} + +sanitize_repo_url() { + _url=$1 + case "$_url" in + https://*) + _sanitize_scheme="https://" + _sanitize_rest=${_url#https://} + ;; + http://*) + _sanitize_scheme="http://" + _sanitize_rest=${_url#http://} + ;; + *) + printf '%s' "$_url" + return 0 + ;; + esac + + _sanitize_authority=${_sanitize_rest%%/*} + case "$_sanitize_authority" in + *@*) + _sanitize_authority=${_sanitize_authority##*@} + ;; + *) + ;; + esac + + case "$_sanitize_rest" in + */*) + printf '%s%s/%s' "$_sanitize_scheme" "$_sanitize_authority" "${_sanitize_rest#*/}" + ;; + *) + printf '%s%s' "$_sanitize_scheme" "$_sanitize_authority" + ;; + esac +} + +metadata_value() { + _key=$1 + _file=$2 + if [ ! -f "$_file" ]; then + return 1 + fi + sed -n "s/^${_key}=//p" "$_file" | sed -n '1p' +} + +is_metadata_compatible() { + _file=$1 + [ "$(metadata_value state "$_file")" = "complete" ] || return 1 + [ "$(metadata_value version "$_file")" = "$_metadata_version" ] || return 1 + [ "$(metadata_value clone_dir "$_file")" = "$CLONE_DIR" ] || return 1 + [ "$(metadata_value repo_url "$_file")" = "$_sanitized_repo_url" ] || return 1 + [ "$(metadata_value branch "$_file")" = "$_branch_value" ] || return 1 + return 0 +} + +write_metadata() { + mkdir -p "$_metadata_dir" + _tmp_metadata="$_metadata_file.tmp.$$" + { + printf 'version=%s\n' "$_metadata_version" + printf 'state=complete\n' + printf 'clone_dir=%s\n' "$CLONE_DIR" + printf 'repo_url=%s\n' "$_sanitized_repo_url" + printf 'branch=%s\n' "$_branch_value" + printf 'persistence_mode=%s\n' "$_persistence_mode" + date -u '+timestamp=%Y-%m-%dT%H:%M:%SZ' + } > "$_tmp_metadata" + mv "$_tmp_metadata" "$_metadata_file" +} + +clone_repo() { + rm -f "$_metadata_file" "$_metadata_file.tmp.$$" + mkdir -p "$(dirname "$CLONE_DIR")" + if [ -n "${BRANCH:-}" ]; then + echo "Cloning $_sanitized_repo_url (branch: $BRANCH) into $CLONE_DIR" + if ! timeout "$MAX_CLONE_TIMEOUT" git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$CLONE_DIR"; then + rm -f "$_metadata_file.tmp.$$" + echo "Clone failed - check URL, branch name, and network access" + exit 1 + fi + else + echo "Cloning $_sanitized_repo_url into $CLONE_DIR" + if ! timeout "$MAX_CLONE_TIMEOUT" git clone --depth 1 "$REPO_URL" "$CLONE_DIR"; then + rm -f "$_metadata_file.tmp.$$" + echo "Clone failed - check URL and network access" + exit 1 + fi + fi + write_metadata +} + +_sanitized_repo_url=$(sanitize_repo_url "$REPO_URL") + # Inject token into HTTPS URLs via git URL rewriting. # This approach works for all token types (classic PAT, fine-grained PAT, OAuth) # because the token is embedded directly in the URL rather than going through # the credential challenge/response flow. # The rewrite targets only the actual host in REPO_URL, so it works for any provider. if [ -n "${GIT_ACCESS_TOKEN:-}" ]; then - _repo_host=$(echo "$REPO_URL" | sed 's|https://||' | cut -d/ -f1) - git config --global \ - url."https://x-access-token:${GIT_ACCESS_TOKEN}@${_repo_host}/".insteadOf \ - "https://${_repo_host}/" + case "$_sanitized_repo_url" in + https://*) + _repo_host=$(printf '%s' "$_sanitized_repo_url" | sed 's|https://||' | cut -d/ -f1) + git config --global \ + url."https://x-access-token:${GIT_ACCESS_TOKEN}@${_repo_host}/".insteadOf \ + "https://${_repo_host}/" + ;; + *) + ;; + esac unset _repo_host fi -# Remove leftover clone directory from a previous session (e.g. preStop hook -# was skipped due to force-delete or node failure). if [ -d "$CLONE_DIR" ]; then - echo "Removing leftover directory $CLONE_DIR" - rm -rf "$CLONE_DIR" -fi - -if [ -n "${BRANCH:-}" ]; then - echo "Cloning $REPO_URL (branch: $BRANCH) into $CLONE_DIR" - if ! timeout "$MAX_CLONE_TIMEOUT" git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$CLONE_DIR"; then - echo "Clone failed - check URL, branch name, and network access" - exit 1 - fi -else - echo "Cloning $REPO_URL into $CLONE_DIR" - if ! timeout "$MAX_CLONE_TIMEOUT" git clone --depth 1 "$REPO_URL" "$CLONE_DIR"; then - echo "Clone failed - check URL and network access" + if ! is_metadata_compatible "$_metadata_file"; then + echo "Refusing to modify existing directory $CLONE_DIR without compatible AUPLC clone metadata" exit 1 fi + + _metadata_persistence_mode=$(metadata_value persistence_mode "$_metadata_file") + case "$_persistence_mode:$_metadata_persistence_mode" in + persistent:persistent) + echo "Reusing existing managed clone $CLONE_DIR" + exit 0 + ;; + persistent:ephemeral) + write_metadata + echo "Reusing existing managed clone $CLONE_DIR" + exit 0 + ;; + ephemeral:ephemeral) + echo "Replacing existing managed ephemeral clone $CLONE_DIR" + rm -rf "$CLONE_DIR" + ;; + ephemeral:persistent) + echo "Refusing to replace persistent managed clone $CLONE_DIR for an ephemeral request" + exit 1 + ;; + *) + echo "Refusing to modify existing directory $CLONE_DIR with unknown metadata persistence mode" + exit 1 + ;; + esac fi +clone_repo + echo "Done" diff --git a/runtime/hub/core/setup.py b/runtime/hub/core/setup.py index 84d395ee..df5e2b6c 100644 --- a/runtime/hub/core/setup.py +++ b/runtime/hub/core/setup.py @@ -65,6 +65,7 @@ def setup_hub(c: Any) -> None: """ from core import z2jh from core.authenticators import ( + GITHUB_USERNAME_PREFIX, CustomFirstUseAuthenticator, CustomGitHubOAuthenticator, create_authenticator, @@ -121,7 +122,7 @@ async def auth_state_hook(spawner, auth_state): if auth_state is None: spawner.github_access_token = None # Still assign native users to their default group - if not spawner.user.name.startswith("github:"): + if not spawner.user.name.startswith(GITHUB_USERNAME_PREFIX): try: from core.groups import assign_user_to_group @@ -131,7 +132,7 @@ async def auth_state_hook(spawner, auth_state): return spawner.github_access_token = auth_state.get("access_token") - if spawner.user.name.startswith("github:"): + if spawner.user.name.startswith(GITHUB_USERNAME_PREFIX): try: from core.groups import sync_github_teams_for_user @@ -160,7 +161,7 @@ async def auth_state_hook(spawner, auth_state): assign_user_to_group(spawner.user, "github-users", spawner.user.db) except Exception as e: print(f"[GROUPS] Warning: Failed to assign github-users group for {spawner.user.name}: {e}") - elif not spawner.user.name.startswith("github:"): + elif not spawner.user.name.startswith(GITHUB_USERNAME_PREFIX): # Native user with auth_state but no GitHub teams try: from core.groups import assign_user_to_group @@ -185,7 +186,7 @@ async def auth_state_hook(spawner, auth_state): { "authenticator_class": CustomFirstUseAuthenticator, "url_prefix": "/native", - "config": {"prefix": ""}, + "config": {"prefix": "", "allow_all": True}, }, ] @@ -202,6 +203,7 @@ async def auth_state_hook(spawner, auth_state): team_resource_mapping=dict(config.teams.mapping), github_org=config.github_org_name, auth_mode=config.auth_mode, + platform_name=config.platform_display_name, ) if not hasattr(c.JupyterHub, "extra_handlers") or c.JupyterHub.extra_handlers is None: @@ -381,6 +383,8 @@ async def delete(self, group_name): c.JupyterHub.template_vars = {} c.JupyterHub.template_vars["authenticator_mode"] = config.auth_mode # type: ignore[assignment] c.JupyterHub.template_vars["hide_logout"] = config.auth_mode == "auto-login" # type: ignore[assignment] + c.JupyterHub.template_vars["cluster_name"] = config.cluster_name # type: ignore[assignment] + c.JupyterHub.template_vars["platform_name"] = config.platform_display_name # type: ignore[assignment] print(f"[SETUP] Hub setup complete: auth_mode={config.auth_mode}") print(f"[SETUP] template_vars: {c.JupyterHub.template_vars}") diff --git a/runtime/hub/core/spawner/kubernetes.py b/runtime/hub/core/spawner/kubernetes.py index 0b85fe08..e9d57fbd 100644 --- a/runtime/hub/core/spawner/kubernetes.py +++ b/runtime/hub/core/spawner/kubernetes.py @@ -68,6 +68,7 @@ LEGACY_CODE_SERVER_RESOURCES = {"code-cpu", "code-gpu"} DEFAULT_LAUNCH_MODE = "jupyterlab" CODE_SERVER_LAUNCH_MODE = "code-server" +DEFAULT_TARGET_PATH = "/home/jovyan" class RemoteLabKubeSpawner(KubeSpawner): @@ -117,6 +118,9 @@ class RemoteLabKubeSpawner(KubeSpawner): # Allowed origins for notebook server WebSocket connections notebook_allowed_origins: list[str] = [] + # Extra domains trusted by code-server's outgoing link protection. + code_server_extra_trusted_domains: list[str] = [] + @classmethod def configure_from_config(cls, config: HubConfig) -> None: """ @@ -162,6 +166,9 @@ def configure_from_config(cls, config: HubConfig) -> None: # Extract singleuser allowed origins cls.notebook_allowed_origins = list(config.notebook_network.allowedOrigins) + # Extract code-server link protection settings + cls.code_server_extra_trusted_domains = list(config.code_server.extraTrustedDomains) + async def get_user_resources(self) -> list[str]: """Get available resources for the user based on their JupyterHub group memberships. @@ -312,11 +319,34 @@ def options_from_form(self, formdata) -> dict[str, Any]: if repo_url: options["repo_url"] = repo_url + options["repo_persist"] = self._resolve_repo_persist_option(formdata) if repo_branch: options["repo_branch"] = repo_branch return options + def _resolve_repo_persist_option(self, formdata) -> bool: + git_config = self._hub_config.git_clone if self._hub_config else None + allow_choice = bool(getattr(git_config, "allowPersistenceChoice", False)) + default_persistence = bool(getattr(git_config, "defaultPersistence", True)) + + if not allow_choice: + return default_persistence + + try: + raw_value = formdata.get("repo_persist", [None])[0] + except Exception: + raw_value = None + + if isinstance(raw_value, str): + normalized_value = raw_value.strip().lower() + if normalized_value == "true": + return True + if normalized_value == "false": + return False + + return default_persistence + def _get_public_hub_home_url(self) -> str: """Return the public Hub home URL for links opened from code-server.""" hub_path = "/hub/home" @@ -339,6 +369,53 @@ def _get_public_hub_home_url(self) -> str: return hub_path + @staticmethod + def _trusted_domain_from_url(url: str) -> str | None: + """Extract a code-server trusted-domain host from an absolute URL.""" + + try: + parsed = urlparse(str(url).strip()) + except Exception: + return None + + if parsed.scheme not in {"http", "https"}: + return None + return (parsed.hostname or "").strip().lower() or None + + @staticmethod + def _normalize_code_server_trusted_domain(domain: str) -> str | None: + """Normalize a configured code-server trusted domain entry.""" + + value = str(domain).strip().lower() + if not value: + return None + + # Administrators should provide host/domain patterns, not full URLs. + if "://" in value or "/" in value or "\x00" in value or any(char.isspace() for char in value): + return None + if value in {"*", "*."}: + return None + + return value + + def _get_code_server_trusted_domains(self, hub_url: str) -> str: + """Return comma-separated domains trusted by code-server link protection.""" + + domains: list[str] = [] + auto_domain = self._trusted_domain_from_url(hub_url) + if auto_domain: + domains.append(auto_domain) + + domains.extend(self.code_server_extra_trusted_domains) + + normalized_domains: list[str] = [] + for domain in domains: + normalized = self._normalize_code_server_trusted_domain(domain) + if normalized and normalized not in normalized_domains: + normalized_domains.append(normalized) + + return ",".join(normalized_domains) + def _validate_and_sanitize_repo_url(self, url: str) -> tuple[bool, str, str]: """ Validate and normalize a repository URL. @@ -412,7 +489,41 @@ def _get_home_mount_path(self, home_volume_name: str) -> str: for vm in self.volume_mounts: if vm.get("name") == home_volume_name: return vm["mountPath"] - return "/home/jovyan" + return DEFAULT_TARGET_PATH + + @staticmethod + def _jupyterlab_tree_url_for_target_path(target_path: str) -> str: + """Map an absolute container path to a JupyterLab tree URL.""" + path_without_leading_slash = target_path.lstrip("/") + return f"/lab/tree/{quote(path_without_leading_slash, safe='/')}" + + def _resolve_target_path(self, resource_type: str, custom_repo_path: str | None = None) -> str | None: + """Resolve the effective landing path for the selected launch target.""" + if custom_repo_path: + return custom_repo_path + + resource_metadata = self._hub_config.get_resource_metadata(resource_type) if self._hub_config else None + default_path = str(getattr(resource_metadata, "defaultPath", "") or "").strip() + + # defaultPath is a Hub launch-dir override, not the image WORKDIR. + # If it is omitted, preserve the image/application default instead of + # guessing a path; this keeps non-standard images in their own WORKDIR. + return default_path or None + + def _apply_target_path_mapping(self, resource_type: str, target_path: str | None) -> None: + """Apply the effective target path to the selected single-user adapter.""" + if not target_path: + return + + if self._launches_code_server(resource_type): + # Hub Spawner.default_url becomes JUPYTERHUB_DEFAULT_URL for single-user servers. + # Hub spawn-pending redirects browsers to the server base URL, and code-server + # does not consume JUPYTERHUB_DEFAULT_URL. Direct ?folder= URLs work, but spawn + # completion does not deliver them, so AUPLC_CODE_WORKDIR is the reliable adapter. + self.environment["AUPLC_CODE_WORKDIR"] = target_path + else: + self.notebook_dir = "/" + self.default_url = self._jupyterlab_tree_url_for_target_path(target_path) async def _create_git_token_secret(self, access_token: str) -> str: """Create a K8s Secret containing the git access token. @@ -476,6 +587,7 @@ async def _build_git_init_container( repo_name: str, home_volume_name: str, home_mount_path: str, + repo_persist: bool, repo_branch: str = "", access_token: str = "", ) -> dict: @@ -483,8 +595,8 @@ async def _build_git_init_container( Build an init container spec that clones a repository into the home mount path. The init container mounts the same home PVC as the main container and clones - into /. A preStop lifecycle hook on the main container - removes the directory when the session ends so it does not persist. + into /. In ephemeral mode, a preStop lifecycle hook + on the main container removes the directory when the session ends. The script is read from core/scripts/git-clone.sh, base64-encoded and decoded at runtime to prevent KubeSpawner's _expand_all from treating shell braces as @@ -495,10 +607,13 @@ async def _build_git_init_container( encoded = base64.b64encode(f.read()).decode() clone_dir = f"{home_mount_path}/{repo_name}" + metadata_dir = f"{home_mount_path}/.auplc/git-clones" env: list[dict[str, Any]] = [ {"name": "REPO_URL", "value": repo_url}, {"name": "CLONE_DIR", "value": clone_dir}, + {"name": "PERSIST_CLONED_REPO", "value": "true" if repo_persist else "false"}, + {"name": "AUPLC_GIT_METADATA_DIR", "value": metadata_dir}, {"name": "MAX_CLONE_TIMEOUT", "value": str(self.MAX_CLONE_TIMEOUT)}, ] if repo_branch: @@ -609,6 +724,13 @@ def _build_runtime_metadata_env( return env + @staticmethod + def _with_ephemeral_clone_cleanup(extra_container_config: dict | None, clone_dir: str) -> dict: + extra = copy.deepcopy(extra_container_config or {}) + lifecycle = extra.setdefault("lifecycle", {}) + lifecycle["preStop"] = {"exec": {"command": ["rm", "-rf", clone_dir]}} + return extra + def _get_launch_mode(self, resource_type: str) -> str: """Return the configured launch mode for a resource.""" resource_metadata = self._hub_config.get_resource_metadata(resource_type) if self._hub_config else None @@ -761,7 +883,6 @@ def _configure_spawner(self, resource_type: str, gpu_selection: str | None = Non self.args = [] self.environment["AUPLC_HUB_URL"] = "/hub/home" self.environment["AUPLC_LAUNCH_MODE"] = CODE_SERVER_LAUNCH_MODE - self.environment["AUPLC_CODE_WORKDIR"] = "/home/jovyan" # Special configuration for NPU resources if resource_type in ["Tutorial-NPU-Resnet", "ROSCON2025-GPU", "ROSCON2025-NPU"]: @@ -853,7 +974,13 @@ async def start(self): launches_code_server = self._launches_code_server(resource_type) if launches_code_server: - self.environment["AUPLC_HUB_URL"] = self._get_public_hub_home_url() + hub_url = self._get_public_hub_home_url() + self.environment["AUPLC_HUB_URL"] = hub_url + trusted_domains = self._get_code_server_trusted_domains(hub_url) + if trusted_domains: + self.environment["AUPLC_CODE_TRUSTED_DOMAINS"] = trusted_domains + else: + self.environment.pop("AUPLC_CODE_TRUSTED_DOMAINS", None) # Inject allowed origins into notebook server startup args if self.notebook_allowed_origins and not launches_code_server: @@ -906,6 +1033,8 @@ async def start(self): self.log.warning(f"Invalid branch name for user {self.user.name}: {repo_branch!r}") repo_branch = "" + custom_repo_path = None + if repo_url: is_valid, err_msg, sanitized_url = self._validate_and_sanitize_repo_url(repo_url) if not is_valid: @@ -917,23 +1046,26 @@ async def start(self): safe_username = self._expand_user_properties("{username}") home_volume_name = f"volume-{safe_username}" home_mount_path = self._get_home_mount_path(home_volume_name) + repo_persist = bool(self.user_options.get("repo_persist", True)) init_container = await self._build_git_init_container( - sanitized_url, repo_name, home_volume_name, home_mount_path, repo_branch, access_token + sanitized_url, + repo_name, + home_volume_name, + home_mount_path, + repo_persist, + repo_branch, + access_token, ) self.init_containers = [init_container] + list(self.init_containers or []) - # preStop lifecycle hook: remove the cloned directory on session end - extra = dict(self.extra_container_config or {}) clone_dir = f"{home_mount_path}/{repo_name}" - extra["lifecycle"] = {"preStop": {"exec": {"command": ["rm", "-rf", clone_dir]}}} - self.extra_container_config = extra + custom_repo_path = clone_dir + if not repo_persist: + self.extra_container_config = self._with_ephemeral_clone_cleanup( + self.extra_container_config, + clone_dir, + ) - self.notebook_dir = home_mount_path - if self._launches_code_server(resource_type): - self.environment["AUPLC_CODE_WORKDIR"] = clone_dir - self.default_url = f"/?folder={quote(clone_dir, safe='/')}" - else: - self.default_url = f"/lab/tree/{repo_name}" self._has_git_init_container = True branch_info = f" (branch: {repo_branch})" if repo_branch else "" self.log.info( @@ -942,6 +1074,9 @@ async def start(self): except Exception as e: self.log.warning(f"Failed to configure git init container: {e}") + target_path = self._resolve_target_path(resource_type, custom_repo_path) + self._apply_target_path_mapping(resource_type, target_path) + if getattr(self, "_has_git_init_container", False): ref_key = f"{self.namespace}/{self.pod_name}" start_task = asyncio.ensure_future(super().start()) diff --git a/runtime/hub/frontend/apps/admin/src/App.tsx b/runtime/hub/frontend/apps/admin/src/App.tsx index 30f77ea5..0ea4215c 100644 --- a/runtime/hub/frontend/apps/admin/src/App.tsx +++ b/runtime/hub/frontend/apps/admin/src/App.tsx @@ -22,7 +22,8 @@ import { UserList } from './pages/UserList'; import { GroupList } from './pages/GroupList'; import { Dashboard } from './pages/Dashboard'; import { NavBar } from './components/NavBar'; -import { PLATFORM_NAME } from '@auplc/shared'; +import { useState, useEffect } from 'react'; +import { PLATFORM_NAME, fetchPlatformInfo } from '@auplc/shared'; function App() { @@ -30,9 +31,14 @@ function App() { const baseUrl = (jhdata.base_url || '/hub/').replace(/\/+$/, ''); const basePath = `${baseUrl}/admin`; + const [platformName, setPlatformName] = useState(PLATFORM_NAME); + useEffect(() => { + fetchPlatformInfo().then(info => setPlatformName(info.platform)).catch(() => {}); + }, []); + return ( -
+
} /> diff --git a/runtime/hub/frontend/apps/home/src/App.notifications.test.tsx b/runtime/hub/frontend/apps/home/src/App.notifications.test.tsx index 2b357981..b8b8ecd7 100644 --- a/runtime/hub/frontend/apps/home/src/App.notifications.test.tsx +++ b/runtime/hub/frontend/apps/home/src/App.notifications.test.tsx @@ -22,6 +22,7 @@ import { render, screen, within } from "@testing-library/react"; import { act } from "react"; const sharedMocks = vi.hoisted(() => ({ + fetchPlatformInfo: vi.fn(), getNotifications: vi.fn(), getMyQuota: vi.fn(), getMyUsage: vi.fn(), @@ -32,6 +33,7 @@ const sharedMocks = vi.hoisted(() => ({ vi.mock("@auplc/shared", () => ({ PLATFORM_NAME: "AUP Learning Cloud", + fetchPlatformInfo: sharedMocks.fetchPlatformInfo, getNotifications: sharedMocks.getNotifications, getMyQuota: sharedMocks.getMyQuota, getMyUsage: sharedMocks.getMyUsage, @@ -108,6 +110,9 @@ async function renderApp() { beforeEach(() => { vi.clearAllMocks(); + sharedMocks.fetchPlatformInfo.mockResolvedValue({ + platform: "AUP Learning Cloud", + }); sharedMocks.getMyQuota.mockResolvedValue(null); sharedMocks.getMyUsage.mockResolvedValue(null); sharedMocks.getResources.mockResolvedValue({ groups: [] }); diff --git a/runtime/hub/frontend/apps/home/src/App.resources.test.tsx b/runtime/hub/frontend/apps/home/src/App.resources.test.tsx new file mode 100644 index 00000000..d842750d --- /dev/null +++ b/runtime/hub/frontend/apps/home/src/App.resources.test.tsx @@ -0,0 +1,257 @@ +// Copyright (C) 2025 Advanced Micro Devices, Inc. All rights reserved. +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { act } from "react"; + +const sharedMocks = vi.hoisted(() => ({ + fetchPlatformInfo: vi.fn(), + getNotifications: vi.fn(), + getMyQuota: vi.fn(), + getMyUsage: vi.fn(), + getResources: vi.fn(), + getResourceType: vi.fn(), + getResourceTypeLabel: vi.fn(), +})); + +vi.mock("@auplc/shared", () => ({ + PLATFORM_NAME: "AUP Learning Cloud", + fetchPlatformInfo: sharedMocks.fetchPlatformInfo, + getNotifications: sharedMocks.getNotifications, + getMyQuota: sharedMocks.getMyQuota, + getMyUsage: sharedMocks.getMyUsage, + getResources: sharedMocks.getResources, + getResourceType: sharedMocks.getResourceType, + getResourceTypeLabel: sharedMocks.getResourceTypeLabel, +})); + +vi.mock("./onboarding-launch-workspace.png", () => ({ + default: "onboarding-launch-workspace.png", +})); +vi.mock("./onboarding-resource-picker.png", () => ({ + default: "onboarding-resource-picker.png", +})); +vi.mock("./onboarding-developer-program-qr.png", () => ({ + default: "onboarding-developer-program-qr.png", +})); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + + return { promise, resolve, reject }; +} + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + json: async () => body, + } as Response; +} + +async function renderApp() { + vi.resetModules(); + const { default: App } = await import("./App"); + return render(); +} + +beforeEach(() => { + vi.clearAllMocks(); + + sharedMocks.fetchPlatformInfo.mockResolvedValue({ + platform: "AUP Learning Cloud", + }); + sharedMocks.getNotifications.mockResolvedValue({ + enabled: true, + topbar: null, + homepage: { + enabled: true, + legacyAnnouncementFallback: false, + items: [], + }, + }); + sharedMocks.getMyQuota.mockResolvedValue(null); + sharedMocks.getMyUsage.mockResolvedValue(null); + sharedMocks.getResourceType.mockReturnValue("notebook"); + sharedMocks.getResourceTypeLabel.mockReturnValue("Notebook"); + + window.localStorage.clear(); + window.AVAILABLE_RESOURCES = undefined; + window.jhdata = { + base_url: "/hub/", + xsrf_token: "test-xsrf", + user: "student", + } as never; + window.HOME_DATA = { + server_active: false, + server_url: "/hub/user/student/", + } as never; + window.matchMedia = vi.fn().mockReturnValue({ + matches: false, + media: "(prefers-color-scheme: dark)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }); + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/hub/api/onboarding/me") { + return Promise.resolve(jsonResponse({ should_show: false, dismissed_at: null })); + } + return Promise.reject(new Error(`Unexpected fetch: ${url} ${init?.method ?? "GET"}`)); + })); +}); + +describe("App resource list", () => { + it("renders all non-empty groups returned by getResources when window.AVAILABLE_RESOURCES is stale", async () => { + window.AVAILABLE_RESOURCES = ["gpu-course"]; + + const platformInfo = deferred<{ platform: string }>(); + const quota = deferred(); + const notifications = deferred<{ + enabled: boolean; + topbar: null; + homepage: { + enabled: boolean; + legacyAnnouncementFallback: boolean; + items: []; + }; + }>(); + const resources = deferred<{ + resources: []; + acceleratorKeys: []; + allowedGitProviders: []; + allowPersistenceChoice: boolean; + defaultPersistence: boolean; + groups: Array<{ + name: string; + displayName: string; + resources: Array<{ + key: string; + image: string; + requirements: { + cpu: string; + memory: string; + "amd.com/gpu"?: string; + }; + metadata: { + group: string; + description: string; + subDescription: string; + }; + }>; + }>; + }>(); + const onboarding = deferred(); + + sharedMocks.fetchPlatformInfo.mockReturnValueOnce(platformInfo.promise); + sharedMocks.getMyQuota.mockReturnValueOnce(quota.promise); + sharedMocks.getNotifications.mockReturnValueOnce(notifications.promise); + sharedMocks.getResources.mockReturnValueOnce(resources.promise); + + vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/hub/api/onboarding/me") { + return onboarding.promise; + } + return Promise.reject(new Error(`Unexpected fetch: ${url} ${init?.method ?? "GET"}`)); + })); + + await renderApp(); + + await act(async () => { + platformInfo.resolve({ platform: "AUP Learning Cloud" }); + quota.resolve(null); + notifications.resolve({ + enabled: true, + topbar: null, + homepage: { + enabled: true, + legacyAnnouncementFallback: false, + items: [], + }, + }); + resources.resolve({ + resources: [], + acceleratorKeys: [], + allowedGitProviders: [], + allowPersistenceChoice: false, + defaultPersistence: false, + groups: [ + { + name: "accelerated-courses", + displayName: "Accelerated Courses", + resources: [ + { + key: "gpu-course", + image: "ghcr.io/example/gpu-course:test", + requirements: { + cpu: "2", + memory: "8Gi", + "amd.com/gpu": "1", + }, + metadata: { + group: "accelerated-courses", + description: "GPU Course from API", + subDescription: "Visible because the backend returned it", + }, + }, + ], + }, + { + name: "cpu-courses", + displayName: "CPU Courses", + resources: [ + { + key: "cpu-course", + image: "ghcr.io/example/cpu-course:test", + requirements: { + cpu: "1", + memory: "4Gi", + }, + metadata: { + group: "cpu-courses", + description: "CPU Course from API", + subDescription: "Would be hidden by stale client-side filtering", + }, + }, + ], + }, + ], + }); + onboarding.resolve(jsonResponse({ should_show: false, dismissed_at: null }) as unknown as Response); + await Promise.resolve(); + }); + + expect(await screen.findByRole("heading", { name: "GPU Course from API" })).not.toBeNull(); + expect(await screen.findByRole("heading", { name: "CPU Course from API" })).not.toBeNull(); + expect(screen.getByRole("heading", { name: "Accelerated Courses" })).not.toBeNull(); + expect(screen.getByRole("heading", { name: "CPU Courses" })).not.toBeNull(); + }); +}); diff --git a/runtime/hub/frontend/apps/home/src/App.tsx b/runtime/hub/frontend/apps/home/src/App.tsx index f9e9d121..4100d9bc 100644 --- a/runtime/hub/frontend/apps/home/src/App.tsx +++ b/runtime/hub/frontend/apps/home/src/App.tsx @@ -27,6 +27,7 @@ import { getResourceType, getResourceTypeLabel, PLATFORM_NAME, + fetchPlatformInfo, } from "@auplc/shared"; import onboardingLaunchWorkspaceUrl from "./onboarding-launch-workspace.png"; import onboardingResourcePickerUrl from "./onboarding-resource-picker.png"; @@ -193,6 +194,8 @@ function App() { const [showOnboardingModal, setShowOnboardingModal] = useState(false); const [onboardingStep, setOnboardingStep] = useState(0); + const [platformName, setPlatformName] = useState(PLATFORM_NAME); + const [theme, setTheme] = useState(getInitialTheme); const toggleTheme = useCallback(() => { setTheme(t => { @@ -202,6 +205,10 @@ function App() { }); }, []); + useEffect(() => { + fetchPlatformInfo().then(info => setPlatformName(info.platform)).catch(() => {}); + }, []); + useEffect(() => { getMyQuota().then(setQuota).catch(() => {}); }, []); @@ -357,20 +364,7 @@ function App() { useEffect(() => { getResources() .then((data) => { - const allowedKeys = window.AVAILABLE_RESOURCES ?? []; - const hasFilter = allowedKeys.length > 0; - const allowedSet = new Set(allowedKeys); - - const filtered = hasFilter - ? data.groups - .map((g) => ({ - ...g, - resources: g.resources.filter((r) => allowedSet.has(r.key)), - })) - .filter((g) => g.resources.length > 0) - : data.groups.filter((g) => g.resources.length > 0); - - setGroups(filtered); + setGroups(data.groups.filter((g) => g.resources.length > 0)); }) .catch((err) => { setResourcesError( @@ -457,7 +451,7 @@ function App() { {getGreeting()}, {jhdata.user ?? "student"}

- Welcome to {PLATFORM_NAME} + Welcome to {platformName}

Experience next-generation AI acceleration with AMD ROCm. Launch @@ -874,7 +868,7 @@ function App() {

Welcome -

Welcome to {PLATFORM_NAME}

+

Welcome to {platformName}

This short guide will show you how to get started, where to launch your environment, and where to find AMD developer resources later. diff --git a/runtime/hub/frontend/apps/spawn/scripts/check-git-persistence-ui.mjs b/runtime/hub/frontend/apps/spawn/scripts/check-git-persistence-ui.mjs new file mode 100644 index 00000000..a40f0b1a --- /dev/null +++ b/runtime/hub/frontend/apps/spawn/scripts/check-git-persistence-ui.mjs @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const appSource = readFileSync(resolve(scriptDir, '../src/App.tsx'), 'utf8'); + +function assertMatch(description, pattern) { + if (!pattern.test(appSource)) { + throw new Error(`Missing spawn git persistence UI contract: ${description}`); + } + console.log(`${description}=ok`); +} + +assertMatch( + 'checkbox_visibility_requires_selected_resource_git_clone_and_admin_choice', + /const showRepoPersistenceChoice = Boolean\(selectedResource && allowGitClone && allowPersistenceChoice\);/ +); +assertMatch( + 'default_checked_state_follows_admin_default', + /const repoPersistValue = repoPersist \?\? defaultPersistence;/ +); +assertMatch( + 'default_checked_state_resyncs_after_config_load', + /setRepoPersist\(defaultPersistence\);/ +); +assertMatch( + 'checkbox_checked_state_uses_resolved_value', + /checked=\{repoPersistValue\}/ +); +assertMatch( + 'checkbox_label_text_is_exact', + />Keep this repository after the server stops\s*If enabled, an existing repository folder will be reused and not overwritten\.\s*\}/ +); +assertMatch( + 'checkbox_toggle_updates_hidden_field_source_state', + /onChange=\{e => setRepoPersist\(e\.target\.checked\)\}/ +); + +console.log('spawn_git_persistence_ui_source_check=ok'); diff --git a/runtime/hub/frontend/apps/spawn/src/App.tsx b/runtime/hub/frontend/apps/spawn/src/App.tsx index 8d9556b4..ec2a5c98 100644 --- a/runtime/hub/frontend/apps/spawn/src/App.tsx +++ b/runtime/hub/frontend/apps/spawn/src/App.tsx @@ -19,7 +19,7 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import type { Resource, Accelerator, GitHubRepo } from '@auplc/shared'; -import { validateRepo, fetchGitHubRepos, isCurrentUserGitHub, PLATFORM_NAME } from '@auplc/shared'; +import { validateRepo, fetchGitHubRepos, isCurrentUserGitHub, PLATFORM_NAME, fetchPlatformInfo } from '@auplc/shared'; type Theme = 'light' | 'dark'; function getInitialTheme(): Theme { @@ -80,7 +80,16 @@ function App() { const initialResourceKey = searchParams.get('resource') ?? ''; const initialAcceleratorKey = searchParams.get('accelerator') ?? ''; - const { resources, groups, allowedGitProviders, githubAppName, loading: resourcesLoading, error: resourcesError } = useResources(); + const { + resources, + groups, + allowedGitProviders, + githubAppName, + allowPersistenceChoice, + defaultPersistence, + loading: resourcesLoading, + error: resourcesError, + } = useResources(); const { accelerators, loading: acceleratorsLoading } = useAccelerators(); const { quota, loading: quotaLoading } = useQuota(); @@ -91,7 +100,9 @@ function App() { const [expandedGroup, setExpandedGroup] = useState(null); const [runtime, setRuntime] = useState(20); const [runtimeInput, setRuntimeInput] = useState('20'); + const [platformName, setPlatformName] = useState(PLATFORM_NAME); const [repoUrl, setRepoUrl] = useState(initialRepoUrl); + const [repoPersist, setRepoPersist] = useState(null); const [repoUrlError, setRepoUrlError] = useState(''); const [repoValidating, setRepoValidating] = useState(false); const [repoValid, setRepoValid] = useState(false); @@ -117,6 +128,10 @@ function App() { const loading = resourcesLoading || acceleratorsLoading || quotaLoading; + useEffect(() => { + fetchPlatformInfo().then(info => setPlatformName(info.platform)).catch(() => {}); + }, []); + useEffect(() => { if (!initialRepoUrl || allowedGitProviders.length === 0) return; const { url } = normalizeRepoUrl(initialRepoUrl); @@ -180,6 +195,12 @@ function App() { }, [availableAccelerators, selectedAcceleratorKey]); const allowGitClone = selectedResource?.metadata?.allowGitClone ?? false; + const repoPersistValue = repoPersist ?? defaultPersistence; + const showRepoPersistenceChoice = Boolean(selectedResource && allowGitClone && allowPersistenceChoice); + + useEffect(() => { + setRepoPersist(defaultPersistence); + }, [defaultPersistence]); const shareableUrl = useMemo(() => { if (!selectedResource) return ''; @@ -339,6 +360,7 @@ function App() { )} {allowGitClone && } + {allowGitClone && } {/* Page header */}

@@ -351,7 +373,7 @@ function App() {

Launch Your Server

-

Select a resource, configure your environment, and launch on {PLATFORM_NAME}

+

Select a resource, configure your environment, and launch on {platformName}

{/* Warnings */} @@ -504,6 +526,22 @@ function App() { )} {repoUrlError && !repoValidating && {repoUrlError}} + {showRepoPersistenceChoice && ( + + )} + {showRepoPersistenceChoice && ( + + If enabled, an existing repository folder will be reused and not overwritten. + + )} {githubAppName && isGitHub && ( ? - The repository will be cloned at startup and available during this session only. + The repository will be cloned at startup. {allowedGitProviders.length > 0 && ` Supports: ${allowedGitProviders.join(', ')}.`} diff --git a/runtime/hub/frontend/apps/spawn/src/hooks/useResources.ts b/runtime/hub/frontend/apps/spawn/src/hooks/useResources.ts index 9201da44..a3310730 100644 --- a/runtime/hub/frontend/apps/spawn/src/hooks/useResources.ts +++ b/runtime/hub/frontend/apps/spawn/src/hooks/useResources.ts @@ -35,6 +35,8 @@ interface UseResourcesResult { acceleratorKeys: string[]; allowedGitProviders: string[]; githubAppName: string; + allowPersistenceChoice: boolean; + defaultPersistence: boolean; loading: boolean; error: string | null; } @@ -45,6 +47,8 @@ export function useResources(): UseResourcesResult { const [acceleratorKeys, setAcceleratorKeys] = useState([]); const [allowedGitProviders, setAllowedGitProviders] = useState([]); const [githubAppName, setGithubAppName] = useState(''); + const [allowPersistenceChoice, setAllowPersistenceChoice] = useState(false); + const [defaultPersistence, setDefaultPersistence] = useState(true); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -81,6 +85,8 @@ export function useResources(): UseResourcesResult { setAcceleratorKeys(response.acceleratorKeys); setAllowedGitProviders(response.allowedGitProviders ?? []); setGithubAppName(response.githubAppName ?? ''); + setAllowPersistenceChoice(response.allowPersistenceChoice ?? false); + setDefaultPersistence(response.defaultPersistence ?? true); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load resources'); } finally { @@ -91,5 +97,15 @@ export function useResources(): UseResourcesResult { fetchResources(); }, []); - return { resources, groups, acceleratorKeys, allowedGitProviders, githubAppName, loading, error }; + return { + resources, + groups, + acceleratorKeys, + allowedGitProviders, + githubAppName, + allowPersistenceChoice, + defaultPersistence, + loading, + error, + }; } diff --git a/runtime/hub/frontend/packages/login-css/src/login.css b/runtime/hub/frontend/packages/login-css/src/login.css index 1918693f..355780d9 100644 --- a/runtime/hub/frontend/packages/login-css/src/login.css +++ b/runtime/hub/frontend/packages/login-css/src/login.css @@ -41,3 +41,142 @@ */ @import "tailwindcss" source(none); @source "../../../templates/**/*.html"; + +@layer components { + .login-main-panel { + background: #f9fafb; + } + + .login-card { + background: #ffffff; + border: 1px solid transparent; + } + + .login-heading { + color: #1f2937; + } + + .login-announcement { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e3a8a; + } + + .login-error { + color: #ef4444; + } + + .login-dev-mode { + color: #ea580c; + } + + .login-github-button, + .login-input { + background: #ffffff; + border: 1px solid #d1d5db; + color: #374151; + } + + .login-github-button:hover { + background: #f9fafb; + } + + .login-helper { + color: #4b5563; + } + + .login-field-label { + color: #374151; + } + + .login-input { + color: #111827; + } + + .login-input::placeholder { + color: #9ca3af; + } + + .login-submit { + background: #2563eb; + } + + .login-submit:hover { + background: #1d4ed8; + } + + .login-divider .border-t { + border-color: #d1d5db; + } + + .login-divider span { + background: #ffffff; + color: #6b7280; + } + + [data-bs-theme="dark"] .login-main-panel { + background: #030712 !important; + } + + [data-bs-theme="dark"] .login-card { + background: #111827 !important; + border-color: #374151 !important; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4) !important; + } + + [data-bs-theme="dark"] .login-heading { + color: #f9fafb !important; + } + + [data-bs-theme="dark"] .login-announcement { + background: #172554 !important; + border-color: #1e40af !important; + color: #dbeafe !important; + } + + [data-bs-theme="dark"] .login-error { + color: #fca5a5 !important; + } + + [data-bs-theme="dark"] .login-dev-mode { + color: #fdba74 !important; + } + + [data-bs-theme="dark"] .login-github-button, + [data-bs-theme="dark"] .login-input { + background: #1f2937 !important; + border-color: #4b5563 !important; + color: #f9fafb !important; + } + + [data-bs-theme="dark"] .login-github-button:hover { + background: #374151 !important; + } + + [data-bs-theme="dark"] .login-helper, + [data-bs-theme="dark"] .login-divider span { + color: #d1d5db !important; + } + + [data-bs-theme="dark"] .login-field-label { + color: #e5e7eb !important; + } + + [data-bs-theme="dark"] .login-input::placeholder { + color: #9ca3af !important; + } + + [data-bs-theme="dark"] .login-divider .border-t { + border-color: #374151 !important; + } + + [data-bs-theme="dark"] .login-divider span { + background: #111827 !important; + } + + [data-bs-theme="dark"] .login-submit:focus, + [data-bs-theme="dark"] .login-github-button:focus, + [data-bs-theme="dark"] .login-input:focus { + --tw-ring-offset-color: #111827; + } +} diff --git a/runtime/hub/frontend/packages/shared/src/types/resource.ts b/runtime/hub/frontend/packages/shared/src/types/resource.ts index c1797a4b..79d092af 100644 --- a/runtime/hub/frontend/packages/shared/src/types/resource.ts +++ b/runtime/hub/frontend/packages/shared/src/types/resource.ts @@ -58,4 +58,6 @@ export interface ResourcesResponse { acceleratorKeys: string[]; allowedGitProviders: string[]; githubAppName?: string; + allowPersistenceChoice: boolean; + defaultPersistence: boolean; } diff --git a/runtime/hub/frontend/templates/admin.html b/runtime/hub/frontend/templates/admin.html index 6922f1a3..31342b84 100644 --- a/runtime/hub/frontend/templates/admin.html +++ b/runtime/hub/frontend/templates/admin.html @@ -30,5 +30,5 @@
{% endblock main %} {% block footer %} - + {% endblock footer %} diff --git a/runtime/hub/frontend/templates/home.html b/runtime/hub/frontend/templates/home.html index 36f4bb0c..54db7732 100644 --- a/runtime/hub/frontend/templates/home.html +++ b/runtime/hub/frontend/templates/home.html @@ -24,7 +24,7 @@ {% set announcement = announcement_home %} {% endif %} -{%- block title -%}Home - AUP Learning Cloud{%- endblock title -%} +{%- block title -%}Home - {{ platform_name or 'AUP Learning Cloud' }}{%- endblock title -%} {% block stylesheet %} {{ super() }} diff --git a/runtime/hub/frontend/templates/login.html b/runtime/hub/frontend/templates/login.html index b342ef70..66edba21 100755 --- a/runtime/hub/frontend/templates/login.html +++ b/runtime/hub/frontend/templates/login.html @@ -27,12 +27,13 @@ {% if announcement_login is string %} {% set announcement = announcement_login %} {% endif %} +{% set github_helper_text = login_github_helper_text|default("", true)|trim %} {% block login_widget %} {% endblock login_widget %} {%- block title -%} -AUP Learning Cloud +{{ platform_name or 'AUP Learning Cloud' }} {%- endblock title -%} {% block stylesheet %} @@ -88,21 +89,24 @@ -

AUP Learning Cloud

+

{{ platform_name or 'AUP Learning Cloud' }}

Experience the next generation of AI acceleration with AMD ROCm™.

- {% if login_service %} + {% if login_service and not authenticator_mode.startswith('multi') %}
Sign in with {{ login_service }} + {% if 'github' in login_service|lower and github_helper_text %} + + {% endif %} {% endif %}
-
+