mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Compare commits
222 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9fe7822098 | ||
![]() |
e70658c015 | ||
![]() |
13ae9247f9 | ||
![]() |
cb81f309a6 | ||
![]() |
b5359545db | ||
![]() |
640c688519 | ||
![]() |
ce1269c1c8 | ||
![]() |
d1a412b354 | ||
![]() |
fd9f86cf49 | ||
![]() |
4a67babe7d | ||
![]() |
1aa220ee2c | ||
![]() |
286b85cc78 | ||
![]() |
8002cbb873 | ||
![]() |
7522d2c73a | ||
![]() |
ca733312a1 | ||
![]() |
a75e0095c9 | ||
![]() |
7fda625102 | ||
![]() |
e099579ff3 | ||
![]() |
2457813432 | ||
![]() |
d45472a7fc | ||
![]() |
ca730cbed4 | ||
![]() |
fd3ae8b2b6 | ||
![]() |
b7621ea82b | ||
![]() |
ba25ee9e9c | ||
![]() |
239902934a | ||
![]() |
e63d6bfbb1 | ||
![]() |
ae434dd866 | ||
![]() |
15efe6b7c1 | ||
![]() |
5fbf787066 | ||
![]() |
b486f9465c | ||
![]() |
5e77ca22e3 | ||
![]() |
cd79f17d90 | ||
![]() |
742de1311e | ||
![]() |
f76cc42363 | ||
![]() |
7854ed56d1 | ||
![]() |
f2cab7c5ef | ||
![]() |
bd8bb9e5ec | ||
![]() |
25c1469658 | ||
![]() |
b64b4e45c2 | ||
![]() |
24d99afffd | ||
![]() |
470d7624a3 | ||
![]() |
d0120ef56c | ||
![]() |
44b81f662a | ||
![]() |
43a868d00b | ||
![]() |
52e852e8f9 | ||
![]() |
1c5607ca1d | ||
![]() |
9c4aefc424 | ||
![]() |
66995952ab | ||
![]() |
1b2417678b | ||
![]() |
8c9e6fd82b | ||
![]() |
325dd21845 | ||
![]() |
abdc3850ff | ||
![]() |
6caa969708 | ||
![]() |
89f4385735 | ||
![]() |
2b77b1e507 | ||
![]() |
98ad6fd4e6 | ||
![]() |
5322243367 | ||
![]() |
17f11970bb | ||
![]() |
66922889c0 | ||
![]() |
f820e5fde2 | ||
![]() |
41a80e4009 | ||
![]() |
1a2e5d2e9d | ||
![]() |
6619524d1f | ||
![]() |
44a02299c1 | ||
![]() |
09d552ad3d | ||
![]() |
721e73f433 | ||
![]() |
6c40c05166 | ||
![]() |
044d7ac000 | ||
![]() |
b74f1b1b14 | ||
![]() |
b9a59768d0 | ||
![]() |
3ce05d42b6 | ||
![]() |
6a36812e4a | ||
![]() |
b535985c25 | ||
![]() |
cc77b828d2 | ||
![]() |
410fa0f36a | ||
![]() |
4ec51ce0cf | ||
![]() |
e9613bfb2f | ||
![]() |
0a27724540 | ||
![]() |
04121b0e3d | ||
![]() |
e0b6c46b4f | ||
![]() |
c473a35459 | ||
![]() |
27b1759f8a | ||
![]() |
7d2e416d0f | ||
![]() |
6455fa13b8 | ||
![]() |
350cb83b7b | ||
![]() |
a880fc4d6c | ||
![]() |
61577c1540 | ||
![]() |
3885affd68 | ||
![]() |
c8317074aa | ||
![]() |
29936e0d2b | ||
![]() |
5d71fbb2a2 | ||
![]() |
3de1145a69 | ||
![]() |
44326bda12 | ||
![]() |
681a7ae840 | ||
![]() |
57f4e9cb7c | ||
![]() |
5eb1bea3b0 | ||
![]() |
611b91799c | ||
![]() |
6013f55ef8 | ||
![]() |
916a4bb784 | ||
![]() |
befc4785b0 | ||
![]() |
04175ae3bd | ||
![]() |
7add99c09a | ||
![]() |
6be4893bfa | ||
![]() |
ee913f98fe | ||
![]() |
464b5ef31f | ||
![]() |
c5c4ea60fe | ||
![]() |
cf352f8a0d | ||
![]() |
b86734653c | ||
![]() |
6d0dc488f7 | ||
![]() |
718f01e600 | ||
![]() |
0521270862 | ||
![]() |
260f5ce35b | ||
![]() |
bf28242d9d | ||
![]() |
18d0270af1 | ||
![]() |
ee4a8e593d | ||
![]() |
e65b7c3c15 | ||
![]() |
16f07dda70 | ||
![]() |
de461be7a9 | ||
![]() |
7c71e517ef | ||
![]() |
b9ea57a2f9 | ||
![]() |
320b589037 | ||
![]() |
ea7bedec49 | ||
![]() |
49fa9e6b98 | ||
![]() |
d9ce3dbe5d | ||
![]() |
4fbc737152 | ||
![]() |
0b4c181bf7 | ||
![]() |
6a10070602 | ||
![]() |
5b02d9c222 | ||
![]() |
948e112bde | ||
![]() |
79af8ea264 | ||
![]() |
ec83356261 | ||
![]() |
c7bb995f29 | ||
![]() |
f887a7b547 | ||
![]() |
a2ba05d7b8 | ||
![]() |
0cc382012e | ||
![]() |
9fc16bb3f7 | ||
![]() |
cddeeb9da4 | ||
![]() |
d2ee8472a3 | ||
![]() |
0563a95dc1 | ||
![]() |
ff823fa8cf | ||
![]() |
bf09419377 | ||
![]() |
a2a238f81d | ||
![]() |
1ec169a8a1 | ||
![]() |
2550d24048 | ||
![]() |
c3bfedf0a2 | ||
![]() |
dce25e065f | ||
![]() |
8f9723f0a7 | ||
![]() |
8391d1d5cf | ||
![]() |
7a76cfd89d | ||
![]() |
4d57412361 | ||
![]() |
5cc6da1421 | ||
![]() |
3003b8482a | ||
![]() |
2cf8681748 | ||
![]() |
165364e752 | ||
![]() |
4eb2d6d8a4 | ||
![]() |
1effa17666 | ||
![]() |
cab45ea60c | ||
![]() |
4d1904d25f | ||
![]() |
8372079db4 | ||
![]() |
b7002c12fa | ||
![]() |
d8503534c3 | ||
![]() |
f3d96f8f60 | ||
![]() |
7a550e38cb | ||
![]() |
ccc26d5f50 | ||
![]() |
5acb25d024 | ||
![]() |
413321beee | ||
![]() |
4ccf4fa4cf | ||
![]() |
df6d2cb045 | ||
![]() |
0d57ce2e33 | ||
![]() |
e0d27849b8 | ||
![]() |
a2877c7be2 | ||
![]() |
def928f1b7 | ||
![]() |
ed675f20e4 | ||
![]() |
95c551c316 | ||
![]() |
ff7d37c3ab | ||
![]() |
2bcb24c56e | ||
![]() |
ed76db02e2 | ||
![]() |
cc623cc2cb | ||
![]() |
55e660aa3a | ||
![]() |
3e0588f82c | ||
![]() |
b6c7b6bf91 | ||
![]() |
f10198a859 | ||
![]() |
388a990928 | ||
![]() |
fb6fb87621 | ||
![]() |
a8500a31a9 | ||
![]() |
bffdd3969c | ||
![]() |
5941314d1e | ||
![]() |
296511699e | ||
![]() |
40e2ffc368 | ||
![]() |
07fe2fcff6 | ||
![]() |
886ce6cbdf | ||
![]() |
3effd05f06 | ||
![]() |
183ab22018 | ||
![]() |
5bef758f34 | ||
![]() |
27f978807d | ||
![]() |
2478a1ac6e | ||
![]() |
1db1be22c5 | ||
![]() |
e9002bfec9 | ||
![]() |
95a7c97052 | ||
![]() |
9749b6eb6a | ||
![]() |
979b47d1e0 | ||
![]() |
c12ccafe22 | ||
![]() |
acc51dbe24 | ||
![]() |
51dcbe4c80 | ||
![]() |
6da70e9960 | ||
![]() |
1cb98ce9ff | ||
![]() |
f2ecf6a307 | ||
![]() |
0a4c3bbfd3 | ||
![]() |
e4ae7ce4fe | ||
![]() |
ab43f6beb8 | ||
![]() |
e8806372c6 | ||
![]() |
6e353df033 | ||
![]() |
06507b426d | ||
![]() |
e282205139 | ||
![]() |
e4ff84b7c9 | ||
![]() |
8c4dbd7a32 | ||
![]() |
1336df621b | ||
![]() |
b66931306e | ||
![]() |
83003c7e3d | ||
![]() |
23b9400c53 | ||
![]() |
98e9117633 | ||
![]() |
b2d9f93601 |
2
.github/dependabot.yaml
vendored
2
.github/dependabot.yaml
vendored
@@ -48,7 +48,7 @@ updates:
|
|||||||
# group major bumps of webpack-related dependencies
|
# group major bumps of webpack-related dependencies
|
||||||
jsx-webpack:
|
jsx-webpack:
|
||||||
patterns:
|
patterns:
|
||||||
- "webpack*"
|
- "*webpack*"
|
||||||
- "@babel/*"
|
- "@babel/*"
|
||||||
- "*-loader"
|
- "*-loader"
|
||||||
update-types:
|
update-types:
|
||||||
|
54
.github/workflows/registry-overviews.yml
vendored
54
.github/workflows/registry-overviews.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Update Registry overviews
|
|
||||||
|
|
||||||
env:
|
|
||||||
OWNER: ${{ github.repository_owner }}
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/registry-overviews.yml"
|
|
||||||
|
|
||||||
- "README.md"
|
|
||||||
- "onbuild/README.md"
|
|
||||||
- "demo-image/README.md"
|
|
||||||
- "singleuser/README.md"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-overview:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: update-overview (${{matrix.image}})
|
|
||||||
if: github.repository_owner == 'jupyterhub'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout Repo ⚡️
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Push README to Registry 🐳
|
|
||||||
uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1
|
|
||||||
env:
|
|
||||||
DOCKER_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
DOCKER_PASS: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
destination_container_repo: ${{ env.OWNER }}/${{ matrix.image }}
|
|
||||||
provider: dockerhub
|
|
||||||
short_description: ${{ matrix.description }}
|
|
||||||
readme_file: ${{ matrix.readme_file }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- image: jupyterhub
|
|
||||||
description: "JupyterHub: multi-user Jupyter notebook server"
|
|
||||||
readme_file: README.md
|
|
||||||
- image: jupyterhub-onbuild
|
|
||||||
description: onbuild version of JupyterHub images
|
|
||||||
readme_file: onbuild/README.md
|
|
||||||
- image: jupyterhub-demo
|
|
||||||
description: Demo JupyterHub Docker image with a quick overview of what JupyterHub is and how it works
|
|
||||||
readme_file: demo-image/README.md
|
|
||||||
- image: singleuser
|
|
||||||
description: "single-user docker images for use with JupyterHub and DockerSpawner see also: jupyter/docker-stacks"
|
|
||||||
readme_file: singleuser/README.md
|
|
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
#
|
#
|
||||||
# Test build release artifacts (PyPI package, Docker images) and publish them on
|
# Test build release artifacts (PyPI package) and publish them on
|
||||||
# pushed git tags.
|
# pushed git tags.
|
||||||
#
|
#
|
||||||
name: Release
|
name: Release
|
||||||
@@ -82,150 +82,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install twine
|
pip install twine
|
||||||
twine upload --skip-existing dist/*
|
twine upload --skip-existing dist/*
|
||||||
|
|
||||||
publish-docker:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
timeout-minutes: 30
|
|
||||||
|
|
||||||
services:
|
|
||||||
# So that we can test this in PRs/branches
|
|
||||||
local-registry:
|
|
||||||
image: registry:2
|
|
||||||
ports:
|
|
||||||
- 5000:5000
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Should we push this image to a public registry?
|
|
||||||
run: |
|
|
||||||
if [ "${{ startsWith(github.ref, 'refs/tags/') || (github.ref == 'refs/heads/main') }}" = "true" ]; then
|
|
||||||
echo "REGISTRY=quay.io/" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Setup docker to build for multiple platforms, see:
|
|
||||||
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
|
||||||
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
|
||||||
- name: Set up QEMU (for docker buildx)
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx (for multi-arch builds)
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
# Allows pushing to registry on localhost:5000
|
|
||||||
driver-opts: network=host
|
|
||||||
|
|
||||||
- name: Setup push rights to Docker Hub
|
|
||||||
# This was setup by...
|
|
||||||
# 1. Creating a [Robot Account](https://quay.io/organization/jupyterhub?tab=robots) in the JupyterHub
|
|
||||||
# . Quay.io org
|
|
||||||
# 2. Giving it enough permissions to push to the jupyterhub and singleuser images
|
|
||||||
# 3. Putting the robot account's username and password in GitHub actions environment
|
|
||||||
if: env.REGISTRY != 'localhost:5000/'
|
|
||||||
run: |
|
|
||||||
docker login -u "${{ secrets.QUAY_USERNAME }}" -p "${{ secrets.QUAY_PASSWORD }}" "${{ env.REGISTRY }}"
|
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}" docker.io
|
|
||||||
|
|
||||||
# image: jupyterhub/jupyterhub
|
|
||||||
#
|
|
||||||
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
|
||||||
# If this is a tagged build this will return additional parent tags.
|
|
||||||
# E.g. 1.2.3 is expanded to Docker tags
|
|
||||||
# [{prefix}:1.2.3, {prefix}:1.2, {prefix}:1, {prefix}:latest] unless
|
|
||||||
# this is a backported tag in which case the newer tags aren't updated.
|
|
||||||
# For branches this will return the branch name.
|
|
||||||
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
|
|
||||||
- name: Get list of jupyterhub tags
|
|
||||||
id: jupyterhubtags
|
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v3
|
|
||||||
with:
|
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
prefix: >-
|
|
||||||
${{ env.REGISTRY }}jupyterhub/jupyterhub:
|
|
||||||
jupyterhub/jupyterhub:
|
|
||||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub:noref"
|
|
||||||
branchRegex: ^\w[\w-.]*$
|
|
||||||
|
|
||||||
- name: Build and push jupyterhub
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
# tags parameter must be a string input so convert `gettags` JSON
|
|
||||||
# array into a comma separated list of tags
|
|
||||||
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
|
||||||
|
|
||||||
# image: jupyterhub/jupyterhub-onbuild
|
|
||||||
#
|
|
||||||
- name: Get list of jupyterhub-onbuild tags
|
|
||||||
id: onbuildtags
|
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v3
|
|
||||||
with:
|
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
prefix: >-
|
|
||||||
${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:
|
|
||||||
jupyterhub/jupyterhub-onbuild:
|
|
||||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:noref"
|
|
||||||
branchRegex: ^\w[\w-.]*$
|
|
||||||
|
|
||||||
- name: Build and push jupyterhub-onbuild
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
build-args: |
|
|
||||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
|
||||||
context: onbuild
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
|
||||||
|
|
||||||
# image: jupyterhub/jupyterhub-demo
|
|
||||||
#
|
|
||||||
- name: Get list of jupyterhub-demo tags
|
|
||||||
id: demotags
|
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v3
|
|
||||||
with:
|
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
prefix: >-
|
|
||||||
${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:
|
|
||||||
jupyterhub/jupyterhub-demo:
|
|
||||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:noref"
|
|
||||||
branchRegex: ^\w[\w-.]*$
|
|
||||||
|
|
||||||
- name: Build and push jupyterhub-demo
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
build-args: |
|
|
||||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
|
||||||
context: demo-image
|
|
||||||
# linux/arm64 currently fails:
|
|
||||||
# ERROR: Could not build wheels for argon2-cffi which use PEP 517 and cannot be installed directly
|
|
||||||
# ERROR: executor failed running [/bin/sh -c python3 -m pip install notebook]: exit code: 1
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
|
||||||
|
|
||||||
# image: jupyterhub/singleuser
|
|
||||||
#
|
|
||||||
- name: Get list of jupyterhub/singleuser tags
|
|
||||||
id: singleusertags
|
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v3
|
|
||||||
with:
|
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
prefix: >-
|
|
||||||
${{ env.REGISTRY }}jupyterhub/singleuser:
|
|
||||||
jupyterhub/singleuser:
|
|
||||||
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
|
|
||||||
branchRegex: ^\w[\w-.]*$
|
|
||||||
|
|
||||||
- name: Build and push jupyterhub/singleuser
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
build-args: |
|
|
||||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
|
||||||
context: singleuser
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}
|
|
||||||
|
25
.github/workflows/test.yml
vendored
25
.github/workflows/test.yml
vendored
@@ -252,31 +252,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Ensure browsers are installed for playwright
|
- name: Ensure browsers are installed for playwright
|
||||||
if: matrix.browser
|
if: matrix.browser
|
||||||
run: python -m playwright install --with-deps
|
run: python -m playwright install --with-deps firefox
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
run: |
|
run: |
|
||||||
pytest -k "${{ matrix.subset }}" --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
pytest -k "${{ matrix.subset }}" --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||||
|
|
||||||
- uses: codecov/codecov-action@v4
|
- uses: codecov/codecov-action@v5
|
||||||
|
|
||||||
docker-build:
|
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: build images
|
|
||||||
run: |
|
|
||||||
DOCKER_BUILDKIT=1 docker build -t jupyterhub/jupyterhub .
|
|
||||||
docker build -t jupyterhub/jupyterhub-onbuild onbuild
|
|
||||||
docker build -t jupyterhub/singleuser singleuser
|
|
||||||
|
|
||||||
- name: smoke test jupyterhub
|
|
||||||
run: |
|
|
||||||
docker run --rm -t jupyterhub/jupyterhub jupyterhub --help
|
|
||||||
|
|
||||||
- name: verify static files
|
|
||||||
run: |
|
|
||||||
docker run --rm -t -v $PWD/dockerfiles:/io jupyterhub/jupyterhub python3 /io/test.py
|
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,8 +7,6 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
docs/_build
|
docs/_build
|
||||||
docs/build
|
docs/build
|
||||||
docs/source/_static/rest-api
|
|
||||||
docs/source/rbac/scope-table.md
|
|
||||||
docs/source/reference/metrics.md
|
docs/source/reference/metrics.md
|
||||||
|
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
@@ -16,7 +16,7 @@ ci:
|
|||||||
repos:
|
repos:
|
||||||
# autoformat and lint Python code
|
# autoformat and lint Python code
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.6.3
|
rev: v0.9.9
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
types_or:
|
types_or:
|
||||||
@@ -33,11 +33,11 @@ repos:
|
|||||||
rev: v4.0.0-alpha.8
|
rev: v4.0.0-alpha.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
exclude: .*/templates/.*
|
exclude: .*/templates/.*|docs/source/_static/rest-api.yml|docs/source/rbac/scope-table.md
|
||||||
|
|
||||||
# autoformat HTML templates
|
# autoformat HTML templates
|
||||||
- repo: https://github.com/djlint/djLint
|
- repo: https://github.com/djlint/djLint
|
||||||
rev: v1.35.2
|
rev: v1.36.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: djlint-reformat-jinja
|
- id: djlint-reformat-jinja
|
||||||
files: ".*templates/.*.html"
|
files: ".*templates/.*.html"
|
||||||
@@ -49,10 +49,38 @@ repos:
|
|||||||
|
|
||||||
# Autoformat and linting, misc. details
|
# Autoformat and linting, misc. details
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.6.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
exclude: share/jupyterhub/static/js/admin-react.js
|
exclude: share/jupyterhub/static/js/admin-react.js
|
||||||
- id: requirements-txt-fixer
|
- id: requirements-txt-fixer
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-executables-have-shebangs
|
- id: check-executables-have-shebangs
|
||||||
|
|
||||||
|
# source docs: rest-api.yml and scope-table.md are autogenerated
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: update-api-and-scope-docs
|
||||||
|
name: Update rest-api.yml and scope-table.md based on scopes.py
|
||||||
|
language: python
|
||||||
|
additional_dependencies: ["pytablewriter", "ruamel.yaml"]
|
||||||
|
entry: python docs/source/rbac/generate-scope-table.py
|
||||||
|
args:
|
||||||
|
- --update
|
||||||
|
files: jupyterhub/scopes.py
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# run eslint in the jsx directory
|
||||||
|
# need to pass through 'jsx:install-run' hook in
|
||||||
|
# top-level package.json to ensure dependencies are installed
|
||||||
|
# eslint pre-commit hook doesn't really work with eslint 9,
|
||||||
|
# so use `npm run lint:fix`
|
||||||
|
- id: jsx-eslint
|
||||||
|
name: eslint in jsx/
|
||||||
|
entry: npm run jsx:install-run lint:fix
|
||||||
|
pass_filenames: false
|
||||||
|
language: node
|
||||||
|
files: "jsx/.*"
|
||||||
|
# can't run on pre-commit; hangs, for some reason
|
||||||
|
stages:
|
||||||
|
- manual
|
||||||
|
@@ -8,10 +8,9 @@ sphinx:
|
|||||||
configuration: docs/source/conf.py
|
configuration: docs/source/conf.py
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-22.04
|
os: ubuntu-24.04
|
||||||
tools:
|
tools:
|
||||||
nodejs: "20"
|
python: "3.13"
|
||||||
python: "3.11"
|
|
||||||
|
|
||||||
python:
|
python:
|
||||||
install:
|
install:
|
||||||
|
@@ -12,3 +12,29 @@ Please see our documentation on
|
|||||||
- [Testing JupyterHub and linting code](https://jupyterhub.readthedocs.io/en/latest/contributing/tests.html)
|
- [Testing JupyterHub and linting code](https://jupyterhub.readthedocs.io/en/latest/contributing/tests.html)
|
||||||
|
|
||||||
If you need some help, feel free to ask on [Gitter](https://gitter.im/jupyterhub/jupyterhub) or [Discourse](https://discourse.jupyter.org/).
|
If you need some help, feel free to ask on [Gitter](https://gitter.im/jupyterhub/jupyterhub) or [Discourse](https://discourse.jupyter.org/).
|
||||||
|
|
||||||
|
## Our Copyright Policy
|
||||||
|
|
||||||
|
Jupyter uses a shared copyright model. Each contributor maintains copyright
|
||||||
|
over their contributions to Jupyter. But, it is important to note that these
|
||||||
|
contributions are typically only changes to the repositories. Thus, the Jupyter
|
||||||
|
source code, in its entirety is not the copyright of any single person or
|
||||||
|
institution. Instead, it is the collective copyright of the entire Jupyter
|
||||||
|
Development Team. If individual contributors want to maintain a record of what
|
||||||
|
changes/contributions they have specific copyright on, they should indicate
|
||||||
|
their copyright in the commit message of the change, when they commit the
|
||||||
|
change to one of the Jupyter repositories.
|
||||||
|
|
||||||
|
With this in mind, the following banner should be used in any source code file
|
||||||
|
to indicate the copyright and license terms:
|
||||||
|
|
||||||
|
# Copyright (c) Jupyter Development Team.
|
||||||
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
|
### About the Jupyter Development Team
|
||||||
|
|
||||||
|
The Jupyter Development Team is the set of all contributors to the Jupyter project.
|
||||||
|
This includes all of the Jupyter subprojects.
|
||||||
|
|
||||||
|
The team that coordinates JupyterHub subproject can be found here:
|
||||||
|
https://jupyterhub-team-compass.readthedocs.io/en/latest/governance.html
|
||||||
|
59
COPYING.md
59
COPYING.md
@@ -1,59 +0,0 @@
|
|||||||
# The Jupyter multi-user notebook server licensing terms
|
|
||||||
|
|
||||||
Jupyter multi-user notebook server is licensed under the terms of the Modified BSD License
|
|
||||||
(also known as New or Revised or 3-Clause BSD), as follows:
|
|
||||||
|
|
||||||
- Copyright (c) 2014-, Jupyter Development Team
|
|
||||||
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
Redistributions in binary form must reproduce the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer in the documentation and/or
|
|
||||||
other materials provided with the distribution.
|
|
||||||
|
|
||||||
Neither the name of the Jupyter Development Team nor the names of its
|
|
||||||
contributors may be used to endorse or promote products derived from this
|
|
||||||
software without specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
|
|
||||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
|
|
||||||
## About the Jupyter Development Team
|
|
||||||
|
|
||||||
The Jupyter Development Team is the set of all contributors to the Jupyter project.
|
|
||||||
This includes all of the Jupyter subprojects.
|
|
||||||
|
|
||||||
The core team that coordinates development on GitHub can be found here:
|
|
||||||
https://github.com/jupyter/.
|
|
||||||
|
|
||||||
## Our Copyright Policy
|
|
||||||
|
|
||||||
Jupyter uses a shared copyright model. Each contributor maintains copyright
|
|
||||||
over their contributions to Jupyter. But, it is important to note that these
|
|
||||||
contributions are typically only changes to the repositories. Thus, the Jupyter
|
|
||||||
source code, in its entirety is not the copyright of any single person or
|
|
||||||
institution. Instead, it is the collective copyright of the entire Jupyter
|
|
||||||
Development Team. If individual contributors want to maintain a record of what
|
|
||||||
changes/contributions they have specific copyright on, they should indicate
|
|
||||||
their copyright in the commit message of the change, when they commit the
|
|
||||||
change to one of the Jupyter repositories.
|
|
||||||
|
|
||||||
With this in mind, the following banner should be used in any source code file
|
|
||||||
to indicate the copyright and license terms:
|
|
||||||
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
|
||||||
# Distributed under the terms of the Modified BSD License.
|
|
146
Dockerfile
146
Dockerfile
@@ -1,146 +0,0 @@
|
|||||||
# An incomplete base Docker image for running JupyterHub
|
|
||||||
#
|
|
||||||
# Add your configuration to create a complete derivative Docker image.
|
|
||||||
#
|
|
||||||
# Include your configuration settings by starting with one of two options:
|
|
||||||
#
|
|
||||||
# Option 1:
|
|
||||||
#
|
|
||||||
# FROM quay.io/jupyterhub/jupyterhub:latest
|
|
||||||
#
|
|
||||||
# And put your configuration file jupyterhub_config.py in /srv/jupyterhub/jupyterhub_config.py.
|
|
||||||
#
|
|
||||||
# Option 2:
|
|
||||||
#
|
|
||||||
# Or you can create your jupyterhub config and database on the host machine, and mount it with:
|
|
||||||
#
|
|
||||||
# docker run -v $PWD:/srv/jupyterhub -t quay.io/jupyterhub/jupyterhub
|
|
||||||
#
|
|
||||||
# NOTE
|
|
||||||
# If you base on quay.io/jupyterhub/jupyterhub-onbuild
|
|
||||||
# your jupyterhub_config.py will be added automatically
|
|
||||||
# from your docker directory.
|
|
||||||
|
|
||||||
######################################################################
|
|
||||||
# This Dockerfile uses multi-stage builds with optimisations to build
|
|
||||||
# the JupyterHub wheel on the native architecture only
|
|
||||||
# https://www.docker.com/blog/faster-multi-platform-builds-dockerfile-cross-compilation-guide/
|
|
||||||
|
|
||||||
ARG BASE_IMAGE=ubuntu:22.04
|
|
||||||
|
|
||||||
|
|
||||||
######################################################################
|
|
||||||
# The JupyterHub wheel is pure Python so can be built for any platform
|
|
||||||
# on the native architecture (avoiding QEMU emulation)
|
|
||||||
FROM --platform=${BUILDPLATFORM:-linux/amd64} $BASE_IMAGE AS jupyterhub-builder
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
# Don't clear apt cache, and don't combine RUN commands, so that cached layers can
|
|
||||||
# be reused in other stages
|
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
|
||||||
&& apt-get install -yqq --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
gnupg \
|
|
||||||
locales \
|
|
||||||
python3-dev \
|
|
||||||
python3-pip \
|
|
||||||
python3-pycurl \
|
|
||||||
python3-venv \
|
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade setuptools pip build wheel
|
|
||||||
# Ubuntu 22.04 comes with Nodejs 12 which is too old for building JupyterHub JS
|
|
||||||
# It's fine at runtime though (used only by configurable-http-proxy)
|
|
||||||
ARG NODE_MAJOR=20
|
|
||||||
RUN mkdir -p /etc/apt/keyrings \
|
|
||||||
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
|
||||||
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
|
|
||||||
&& apt-get update \
|
|
||||||
&& apt-get install -yqq --no-install-recommends \
|
|
||||||
nodejs
|
|
||||||
|
|
||||||
WORKDIR /src/jupyterhub
|
|
||||||
# copy everything except whats in .dockerignore, its a
|
|
||||||
# compromise between needing to rebuild and maintaining
|
|
||||||
# what needs to be part of the build
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG PIP_CACHE_DIR=/tmp/pip-cache
|
|
||||||
RUN --mount=type=cache,target=${PIP_CACHE_DIR} \
|
|
||||||
python3 -m build --wheel
|
|
||||||
|
|
||||||
# verify installed files
|
|
||||||
RUN --mount=type=cache,target=${PIP_CACHE_DIR} \
|
|
||||||
python3 -m pip install ./dist/*.whl \
|
|
||||||
&& cd ci \
|
|
||||||
&& python3 check_installed_data.py
|
|
||||||
|
|
||||||
######################################################################
|
|
||||||
# All other wheels required by JupyterHub, some are platform specific
|
|
||||||
FROM $BASE_IMAGE AS wheel-builder
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
|
||||||
&& apt-get install -yqq --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
locales \
|
|
||||||
python3-dev \
|
|
||||||
python3-pip \
|
|
||||||
python3-pycurl \
|
|
||||||
python3-venv \
|
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade setuptools pip build wheel
|
|
||||||
|
|
||||||
WORKDIR /src/jupyterhub
|
|
||||||
|
|
||||||
COPY --from=jupyterhub-builder /src/jupyterhub/dist/*.whl /src/jupyterhub/dist/
|
|
||||||
ARG PIP_CACHE_DIR=/tmp/pip-cache
|
|
||||||
RUN --mount=type=cache,target=${PIP_CACHE_DIR} \
|
|
||||||
python3 -m pip wheel --wheel-dir wheelhouse dist/*.whl
|
|
||||||
|
|
||||||
|
|
||||||
######################################################################
|
|
||||||
# The final JupyterHub image, platform specific
|
|
||||||
FROM $BASE_IMAGE AS jupyterhub
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
SHELL=/bin/bash \
|
|
||||||
LC_ALL=en_US.UTF-8 \
|
|
||||||
LANG=en_US.UTF-8 \
|
|
||||||
LANGUAGE=en_US.UTF-8 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
LABEL maintainer="Jupyter Project <jupyter@googlegroups.com>"
|
|
||||||
LABEL org.jupyter.service="jupyterhub"
|
|
||||||
|
|
||||||
WORKDIR /srv/jupyterhub
|
|
||||||
|
|
||||||
RUN apt-get update -qq \
|
|
||||||
&& apt-get install -yqq --no-install-recommends \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
gnupg \
|
|
||||||
locales \
|
|
||||||
python-is-python3 \
|
|
||||||
python3-pip \
|
|
||||||
python3-pycurl \
|
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
&& locale-gen $LC_ALL \
|
|
||||||
&& npm install -g configurable-http-proxy@^4.2.0 \
|
|
||||||
# clean cache and logs
|
|
||||||
&& rm -rf /var/lib/apt/lists/* /var/log/* /var/tmp/* ~/.npm
|
|
||||||
# install the wheels we built in the previous stage
|
|
||||||
RUN --mount=type=cache,from=wheel-builder,source=/src/jupyterhub/wheelhouse,target=/tmp/wheelhouse \
|
|
||||||
# always make sure pip is up to date!
|
|
||||||
python3 -m pip install --no-compile --no-cache-dir --upgrade setuptools pip \
|
|
||||||
&& python3 -m pip install --no-compile --no-cache-dir /tmp/wheelhouse/*
|
|
||||||
|
|
||||||
CMD ["jupyterhub"]
|
|
11
LICENSE
Normal file
11
LICENSE
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Copyright 2014-, Jupyter Development Team
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
@@ -220,7 +220,7 @@ docker container or Linux VM.
|
|||||||
We use a shared copyright model that enables all contributors to maintain the
|
We use a shared copyright model that enables all contributors to maintain the
|
||||||
copyright on their contributions.
|
copyright on their contributions.
|
||||||
|
|
||||||
All code is licensed under the terms of the [revised BSD license](./COPYING.md).
|
All code is licensed under the terms of the [revised BSD license](./LICENSE).
|
||||||
|
|
||||||
## Help and resources
|
## Help and resources
|
||||||
|
|
||||||
|
@@ -1,16 +0,0 @@
|
|||||||
# Demo JupyterHub Docker image
|
|
||||||
#
|
|
||||||
# This should only be used for demo or testing and not as a base image to build on.
|
|
||||||
#
|
|
||||||
# It includes the notebook package and it uses the DummyAuthenticator and the SimpleLocalProcessSpawner.
|
|
||||||
ARG BASE_IMAGE=quay.io/jupyterhub/jupyterhub-onbuild
|
|
||||||
FROM ${BASE_IMAGE}
|
|
||||||
|
|
||||||
# Install the notebook package
|
|
||||||
RUN python3 -m pip install notebook
|
|
||||||
|
|
||||||
# Create a demo user
|
|
||||||
RUN useradd --create-home demo
|
|
||||||
RUN chown demo .
|
|
||||||
|
|
||||||
USER demo
|
|
@@ -1,26 +0,0 @@
|
|||||||
## Demo Dockerfile
|
|
||||||
|
|
||||||
This is a demo JupyterHub Docker image to help you get a quick overview of what
|
|
||||||
JupyterHub is and how it works.
|
|
||||||
|
|
||||||
It uses the SimpleLocalProcessSpawner to spawn new user servers and
|
|
||||||
DummyAuthenticator for authentication.
|
|
||||||
The DummyAuthenticator allows you to log in with any username & password and the
|
|
||||||
SimpleLocalProcessSpawner allows starting servers without having to create a
|
|
||||||
local user for each JupyterHub user.
|
|
||||||
|
|
||||||
### Important!
|
|
||||||
|
|
||||||
This should only be used for demo or testing purposes!
|
|
||||||
It shouldn't be used as a base image to build on.
|
|
||||||
|
|
||||||
### Try it
|
|
||||||
|
|
||||||
1. `cd` to the root of your jupyterhub repo.
|
|
||||||
|
|
||||||
2. Build the demo image with `docker build -t jupyterhub-demo demo-image`.
|
|
||||||
|
|
||||||
3. Run the demo image with `docker run -d -p 8000:8000 jupyterhub-demo`.
|
|
||||||
|
|
||||||
4. Visit http://localhost:8000 and login with any username and password
|
|
||||||
5. Happy demo-ing :tada:!
|
|
@@ -1,7 +0,0 @@
|
|||||||
# Configuration file for jupyterhub-demo
|
|
||||||
|
|
||||||
c = get_config() # noqa
|
|
||||||
|
|
||||||
# Use DummyAuthenticator and SimpleSpawner
|
|
||||||
c.JupyterHub.spawner_class = "simple"
|
|
||||||
c.JupyterHub.authenticator_class = "dummy"
|
|
@@ -1,14 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from jupyterhub._data import DATA_FILES_PATH
|
|
||||||
|
|
||||||
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
|
|
||||||
|
|
||||||
for sub_path in (
|
|
||||||
"templates",
|
|
||||||
"static/components",
|
|
||||||
"static/css/style.min.css",
|
|
||||||
"static/js/admin-react.js",
|
|
||||||
):
|
|
||||||
path = os.path.join(DATA_FILES_PATH, sub_path)
|
|
||||||
assert os.path.exists(path), path
|
|
@@ -35,7 +35,7 @@ help:
|
|||||||
# - NOTE: If the pre-requisites for the html target is updated, also update the
|
# - NOTE: If the pre-requisites for the html target is updated, also update the
|
||||||
# Read The Docs section in docs/source/conf.py.
|
# Read The Docs section in docs/source/conf.py.
|
||||||
#
|
#
|
||||||
html: metrics scopes
|
html: metrics
|
||||||
$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS)
|
$(SPHINXBUILD) -b html "$(SOURCEDIR)" "$(BUILDDIR)/html" $(SPHINXOPTS)
|
||||||
@echo
|
@echo
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||||
@@ -44,10 +44,6 @@ metrics: source/reference/metrics.md
|
|||||||
source/reference/metrics.md:
|
source/reference/metrics.md:
|
||||||
python3 generate-metrics.py
|
python3 generate-metrics.py
|
||||||
|
|
||||||
scopes: source/rbac/scope-table.md
|
|
||||||
source/rbac/scope-table.md:
|
|
||||||
python3 source/rbac/generate-scope-table.py
|
|
||||||
|
|
||||||
|
|
||||||
# Manually added targets - related to development
|
# Manually added targets - related to development
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -56,7 +52,7 @@ source/rbac/scope-table.md:
|
|||||||
# - requires sphinx-autobuild, see
|
# - requires sphinx-autobuild, see
|
||||||
# https://sphinxcontrib-spelling.readthedocs.io/en/latest/
|
# https://sphinxcontrib-spelling.readthedocs.io/en/latest/
|
||||||
# - builds and rebuilds html on changes to source, but does not re-generate
|
# - builds and rebuilds html on changes to source, but does not re-generate
|
||||||
# metrics/scopes files
|
# metrics files
|
||||||
# - starts a livereload enabled webserver and opens up a browser
|
# - starts a livereload enabled webserver and opens up a browser
|
||||||
devenv: html
|
devenv: html
|
||||||
sphinx-autobuild -b html --open-browser "$(SOURCEDIR)" "$(BUILDDIR)/html"
|
sphinx-autobuild -b html --open-browser "$(SOURCEDIR)" "$(BUILDDIR)/html"
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
# don't depend on it here, as that often results in a duplicate
|
# don't depend on it here, as that often results in a duplicate
|
||||||
# installation of jupyterhub that's already installed
|
# installation of jupyterhub that's already installed
|
||||||
autodoc-traits
|
autodoc-traits
|
||||||
|
intersphinx-registry
|
||||||
jupyterhub-sphinx-theme
|
jupyterhub-sphinx-theme
|
||||||
myst-parser>=0.19
|
myst-parser>=0.19
|
||||||
pre-commit
|
pre-commit
|
||||||
|
@@ -7,7 +7,7 @@ info:
|
|||||||
license:
|
license:
|
||||||
name: BSD-3-Clause
|
name: BSD-3-Clause
|
||||||
identifier: BSD-3-Clause
|
identifier: BSD-3-Clause
|
||||||
version: 5.2.0
|
version: 5.3.0rc0
|
||||||
servers:
|
servers:
|
||||||
- url: /hub/api
|
- url: /hub/api
|
||||||
security:
|
security:
|
||||||
@@ -62,8 +62,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
class:
|
class:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The Python class currently active for JupyterHub
|
||||||
The Python class currently active for JupyterHub
|
|
||||||
Authentication
|
Authentication
|
||||||
version:
|
version:
|
||||||
type: string
|
type: string
|
||||||
@@ -73,8 +72,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
class:
|
class:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The Python class currently active for spawning
|
||||||
The Python class currently active for spawning
|
|
||||||
single-user notebook servers
|
single-user notebook servers
|
||||||
version:
|
version:
|
||||||
type: string
|
type: string
|
||||||
@@ -258,8 +256,7 @@ paths:
|
|||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/userName"
|
- $ref: "#/components/parameters/userName"
|
||||||
requestBody:
|
requestBody:
|
||||||
description:
|
description: Updated user info. At least one key to be updated (name or admin)
|
||||||
Updated user info. At least one key to be updated (name or admin)
|
|
||||||
is required.
|
is required.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
@@ -268,13 +265,11 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: the new name (optional, if another key is updated i.e.
|
||||||
the new name (optional, if another key is updated i.e.
|
|
||||||
admin)
|
admin)
|
||||||
admin:
|
admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description:
|
description: update admin (optional, if another key is updated i.e.
|
||||||
update admin (optional, if another key is updated i.e.
|
|
||||||
name)
|
name)
|
||||||
required: true
|
required: true
|
||||||
responses:
|
responses:
|
||||||
@@ -291,8 +286,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
operationId: post-user-activity
|
operationId: post-user-activity
|
||||||
summary: Notify Hub of activity for a given user
|
summary: Notify Hub of activity for a given user
|
||||||
description:
|
description: Notify the Hub of activity by the user, e.g. accessing a service
|
||||||
Notify the Hub of activity by the user, e.g. accessing a service
|
|
||||||
or (more likely) actively using a server.
|
or (more likely) actively using a server.
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/userName"
|
- $ref: "#/components/parameters/userName"
|
||||||
@@ -372,8 +366,7 @@ paths:
|
|||||||
description: The user's notebook server has started
|
description: The user's notebook server has started
|
||||||
content: {}
|
content: {}
|
||||||
202:
|
202:
|
||||||
description:
|
description: The user's notebook server has not yet started, but has been
|
||||||
The user's notebook server has not yet started, but has been
|
|
||||||
requested
|
requested
|
||||||
content: {}
|
content: {}
|
||||||
security:
|
security:
|
||||||
@@ -387,8 +380,7 @@ paths:
|
|||||||
- $ref: "#/components/parameters/userName"
|
- $ref: "#/components/parameters/userName"
|
||||||
responses:
|
responses:
|
||||||
202:
|
202:
|
||||||
description:
|
description: The user's notebook server has not yet stopped as it is taking
|
||||||
The user's notebook server has not yet stopped as it is taking
|
|
||||||
a while to stop
|
a while to stop
|
||||||
content: {}
|
content: {}
|
||||||
204:
|
204:
|
||||||
@@ -420,8 +412,7 @@ paths:
|
|||||||
description: The user's notebook named-server has started
|
description: The user's notebook named-server has started
|
||||||
content: {}
|
content: {}
|
||||||
202:
|
202:
|
||||||
description:
|
description: The user's notebook named-server has not yet started, but has
|
||||||
The user's notebook named-server has not yet started, but has
|
|
||||||
been requested
|
been requested
|
||||||
content: {}
|
content: {}
|
||||||
security:
|
security:
|
||||||
@@ -457,8 +448,7 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
responses:
|
responses:
|
||||||
202:
|
202:
|
||||||
description:
|
description: The user's notebook named-server has not yet stopped as it
|
||||||
The user's notebook named-server has not yet stopped as it
|
|
||||||
is taking a while to stop
|
is taking a while to stop
|
||||||
content: {}
|
content: {}
|
||||||
204:
|
204:
|
||||||
@@ -472,8 +462,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: get-user-shared
|
operationId: get-user-shared
|
||||||
summary: List servers shared with user
|
summary: List servers shared with user
|
||||||
description:
|
description: Returns list of Shares granting the user access to servers owned
|
||||||
Returns list of Shares granting the user access to servers owned
|
|
||||||
by others (new in 5.0)
|
by others (new in 5.0)
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/userName"
|
- $ref: "#/components/parameters/userName"
|
||||||
@@ -587,8 +576,7 @@ paths:
|
|||||||
expires_in:
|
expires_in:
|
||||||
type: number
|
type: number
|
||||||
example: 3600
|
example: 3600
|
||||||
description:
|
description: lifetime (in seconds) after which the requested token
|
||||||
lifetime (in seconds) after which the requested token
|
|
||||||
will expire. Omit, or specify null or 0 for no expiration.
|
will expire. Omit, or specify null or 0 for no expiration.
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
@@ -1262,8 +1250,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: get-proxy
|
operationId: get-proxy
|
||||||
summary: Get the proxy's routing table
|
summary: Get the proxy's routing table
|
||||||
description:
|
description: A convenience alias for getting the routing table directly from
|
||||||
A convenience alias for getting the routing table directly from
|
|
||||||
the proxy
|
the proxy
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/paginationOffset"
|
- $ref: "#/components/parameters/paginationOffset"
|
||||||
@@ -1275,8 +1262,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
description:
|
description: configurable-http-proxy routing table (see configurable-http-proxy
|
||||||
configurable-http-proxy routing table (see configurable-http-proxy
|
|
||||||
docs for details)
|
docs for details)
|
||||||
security:
|
security:
|
||||||
- oauth2:
|
- oauth2:
|
||||||
@@ -1296,8 +1282,7 @@ paths:
|
|||||||
summary: Notify the Hub about a new proxy
|
summary: Notify the Hub about a new proxy
|
||||||
description: Notifies the Hub of a new proxy to use.
|
description: Notifies the Hub of a new proxy to use.
|
||||||
requestBody:
|
requestBody:
|
||||||
description:
|
description: Any values that have changed for the new proxy. All keys are
|
||||||
Any values that have changed for the new proxy. All keys are
|
|
||||||
optional.
|
optional.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
@@ -1389,8 +1374,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: get-auth-cookie
|
operationId: get-auth-cookie
|
||||||
summary: Identify a user from a cookie
|
summary: Identify a user from a cookie
|
||||||
description:
|
description: Used by single-user notebook servers to hand off cookie authentication
|
||||||
Used by single-user notebook servers to hand off cookie authentication
|
|
||||||
to the Hub
|
to the Hub
|
||||||
parameters:
|
parameters:
|
||||||
- name: cookie_name
|
- name: cookie_name
|
||||||
@@ -1515,13 +1499,11 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
proxy:
|
proxy:
|
||||||
type: boolean
|
type: boolean
|
||||||
description:
|
description: Whether the proxy should be shutdown as well (default
|
||||||
Whether the proxy should be shutdown as well (default
|
|
||||||
from Hub config)
|
from Hub config)
|
||||||
servers:
|
servers:
|
||||||
type: boolean
|
type: boolean
|
||||||
description:
|
description: Whether users' notebook servers should be shutdown
|
||||||
Whether users' notebook servers should be shutdown
|
|
||||||
as well (default from Hub config)
|
as well (default from Hub config)
|
||||||
required: false
|
required: false
|
||||||
responses:
|
responses:
|
||||||
@@ -1646,6 +1628,11 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: The user's name
|
description: The user's name
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
description: the string 'user' to distinguish from 'service'
|
||||||
|
enum:
|
||||||
|
- user
|
||||||
admin:
|
admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the user is an admin
|
description: Whether the user is an admin
|
||||||
@@ -1661,8 +1648,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
server:
|
server:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The user's notebook server's base URL, if running; null if
|
||||||
The user's notebook server's base URL, if running; null if
|
|
||||||
not.
|
not.
|
||||||
pending:
|
pending:
|
||||||
type: string
|
type: string
|
||||||
@@ -1694,8 +1680,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The server's name. The user's default server has an empty name
|
||||||
The server's name. The user's default server has an empty name
|
|
||||||
('')
|
('')
|
||||||
ready:
|
ready:
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -1758,15 +1743,13 @@ components:
|
|||||||
state:
|
state:
|
||||||
type: object
|
type: object
|
||||||
properties: {}
|
properties: {}
|
||||||
description:
|
description: Arbitrary internal state from this server's spawner. Only available
|
||||||
Arbitrary internal state from this server's spawner. Only available
|
|
||||||
on the hub's users list or get-user-by-name method, and only with admin:users:server_state
|
on the hub's users list or get-user-by-name method, and only with admin:users:server_state
|
||||||
scope. None otherwise.
|
scope. None otherwise.
|
||||||
user_options:
|
user_options:
|
||||||
type: object
|
type: object
|
||||||
properties: {}
|
properties: {}
|
||||||
description:
|
description: User specified options for the user's spawned instance of a
|
||||||
User specified options for the user's spawned instance of a
|
|
||||||
single-user server.
|
single-user server.
|
||||||
RequestIdentity:
|
RequestIdentity:
|
||||||
description: |
|
description: |
|
||||||
@@ -1784,6 +1767,13 @@ components:
|
|||||||
service: "#/components/schemas/Service"
|
service: "#/components/schemas/Service"
|
||||||
- type: object
|
- type: object
|
||||||
properties:
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
'user' or 'service' depending on the entity which owns the token
|
||||||
|
enum:
|
||||||
|
- user
|
||||||
|
- service
|
||||||
session_id:
|
session_id:
|
||||||
type:
|
type:
|
||||||
- string
|
- string
|
||||||
@@ -1820,6 +1810,11 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: The group's name
|
description: The group's name
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
description: Always the string 'group'
|
||||||
|
enum:
|
||||||
|
- group
|
||||||
users:
|
users:
|
||||||
type: array
|
type: array
|
||||||
description: The names of users who are members of this group
|
description: The names of users who are members of this group
|
||||||
@@ -1845,6 +1840,11 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
description: The service's name
|
description: The service's name
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
description: the string 'service' to distinguish from 'user'
|
||||||
|
enum:
|
||||||
|
- service
|
||||||
admin:
|
admin:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Whether the service is an admin
|
description: Whether the service is an admin
|
||||||
@@ -1918,8 +1918,7 @@ components:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
group:
|
group:
|
||||||
description:
|
description: the group being shared with (exactly one of 'user' or 'group'
|
||||||
the group being shared with (exactly one of 'user' or 'group'
|
|
||||||
will be non-null, the other will be null)
|
will be non-null, the other will be null)
|
||||||
type:
|
type:
|
||||||
- object
|
- object
|
||||||
@@ -1928,8 +1927,7 @@ components:
|
|||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
user:
|
user:
|
||||||
description:
|
description: the user being shared with (exactly one of 'user' or 'group'
|
||||||
the user being shared with (exactly one of 'user' or 'group'
|
|
||||||
will be non-null, the other will be null)
|
will be non-null, the other will be null)
|
||||||
type:
|
type:
|
||||||
- object
|
- object
|
||||||
@@ -1943,8 +1941,7 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
|
|
||||||
ShareCode:
|
ShareCode:
|
||||||
description:
|
description: A single sharing code. There is at most one of these objects per
|
||||||
A single sharing code. There is at most one of these objects per
|
|
||||||
(server, user) or (server, group) combination.
|
(server, user) or (server, group) combination.
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1980,8 +1977,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The id of the API token. Used for modifying or deleting the
|
||||||
The id of the API token. Used for modifying or deleting the
|
|
||||||
token.
|
token.
|
||||||
user:
|
user:
|
||||||
type: string
|
type: string
|
||||||
@@ -1991,22 +1987,19 @@ components:
|
|||||||
description: The service that owns the token (undefined of owned by a user)
|
description: The service that owns the token (undefined of owned by a user)
|
||||||
roles:
|
roles:
|
||||||
type: array
|
type: array
|
||||||
description:
|
description: Deprecated in JupyterHub 3, always an empty list. Tokens have
|
||||||
Deprecated in JupyterHub 3, always an empty list. Tokens have
|
|
||||||
'scopes' starting from JupyterHub 3.
|
'scopes' starting from JupyterHub 3.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
scopes:
|
scopes:
|
||||||
type: array
|
type: array
|
||||||
description:
|
description: List of scopes this token has been assigned. New in JupyterHub
|
||||||
List of scopes this token has been assigned. New in JupyterHub
|
|
||||||
3. In JupyterHub 2.x, tokens were assigned 'roles' instead of scopes.
|
3. In JupyterHub 2.x, tokens were assigned 'roles' instead of scopes.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: A note about the token, typically describing what it was created
|
||||||
A note about the token, typically describing what it was created
|
|
||||||
for.
|
for.
|
||||||
created:
|
created:
|
||||||
type: string
|
type: string
|
||||||
@@ -2037,13 +2030,11 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The token itself. Only present in responses to requests for
|
||||||
The token itself. Only present in responses to requests for
|
|
||||||
a new token.
|
a new token.
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: The id of the API token. Used for modifying or deleting the
|
||||||
The id of the API token. Used for modifying or deleting the
|
|
||||||
token.
|
token.
|
||||||
user:
|
user:
|
||||||
type: string
|
type: string
|
||||||
@@ -2053,22 +2044,19 @@ components:
|
|||||||
description: The service that owns the token (undefined of owned by a user)
|
description: The service that owns the token (undefined of owned by a user)
|
||||||
roles:
|
roles:
|
||||||
type: array
|
type: array
|
||||||
description:
|
description: Deprecated in JupyterHub 3, always an empty list. Tokens have
|
||||||
Deprecated in JupyterHub 3, always an empty list. Tokens have
|
|
||||||
'scopes' starting from JupyterHub 3.
|
'scopes' starting from JupyterHub 3.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
scopes:
|
scopes:
|
||||||
type: array
|
type: array
|
||||||
description:
|
description: List of scopes this token has been assigned. New in JupyterHub
|
||||||
List of scopes this token has been assigned. New in JupyterHub
|
|
||||||
3. In JupyterHub 2.x, tokens were assigned 'roles' instead of scopes.
|
3. In JupyterHub 2.x, tokens were assigned 'roles' instead of scopes.
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
description:
|
description: A note about the token, typically describing what it was created
|
||||||
A note about the token, typically describing what it was created
|
|
||||||
for.
|
for.
|
||||||
created:
|
created:
|
||||||
type: string
|
type: string
|
||||||
@@ -2106,28 +2094,22 @@ components:
|
|||||||
tokenUrl: /hub/api/oauth2/token
|
tokenUrl: /hub/api/oauth2/token
|
||||||
scopes:
|
scopes:
|
||||||
(no_scope): Identify the owner of the requesting entity.
|
(no_scope): Identify the owner of the requesting entity.
|
||||||
self:
|
self: The user’s own resources _(metascope for users, resolves to (no_scope)
|
||||||
The user’s own resources _(metascope for users, resolves to (no_scope)
|
|
||||||
for services)_
|
for services)_
|
||||||
inherit:
|
inherit: Everything that the token-owning entity can access _(metascope
|
||||||
Everything that the token-owning entity can access _(metascope
|
|
||||||
for tokens)_
|
for tokens)_
|
||||||
admin-ui:
|
admin-ui: Access the admin page. Permission to take actions via the admin
|
||||||
Access the admin page. Permission to take actions via the admin
|
|
||||||
page granted separately.
|
page granted separately.
|
||||||
admin:users:
|
admin:users: Read, modify, create, and delete users and their authentication
|
||||||
Read, modify, create, and delete users and their authentication
|
|
||||||
state, not including their servers or tokens. This is an extremely privileged
|
state, not including their servers or tokens. This is an extremely privileged
|
||||||
scope and should be considered tantamount to superuser.
|
scope and should be considered tantamount to superuser.
|
||||||
admin:auth_state: Read a user’s authentication state.
|
admin:auth_state: Read a user’s authentication state.
|
||||||
users:
|
users: Read and write permissions to user models (excluding servers, tokens
|
||||||
Read and write permissions to user models (excluding servers, tokens
|
|
||||||
and authentication state).
|
and authentication state).
|
||||||
delete:users: Delete users.
|
delete:users: Delete users.
|
||||||
list:users: List users, including at least their names.
|
list:users: List users, including at least their names.
|
||||||
read:users:
|
read:users: Read user models (including the URL of the default server
|
||||||
Read user models (including servers, tokens and authentication
|
if it is running).
|
||||||
state).
|
|
||||||
read:users:name: Read names of users.
|
read:users:name: Read names of users.
|
||||||
read:users:groups: Read users’ group membership.
|
read:users:groups: Read users’ group membership.
|
||||||
read:users:activity: Read time of last user activity.
|
read:users:activity: Read time of last user activity.
|
||||||
@@ -2136,27 +2118,23 @@ components:
|
|||||||
read:roles:services: Read service role assignments.
|
read:roles:services: Read service role assignments.
|
||||||
read:roles:groups: Read group role assignments.
|
read:roles:groups: Read group role assignments.
|
||||||
users:activity: Update time of last user activity.
|
users:activity: Update time of last user activity.
|
||||||
admin:servers:
|
admin:servers: Read, start, stop, create and delete user servers and their
|
||||||
Read, start, stop, create and delete user servers and their
|
|
||||||
state.
|
state.
|
||||||
admin:server_state: Read and write users’ server state.
|
admin:server_state: Read and write users’ server state.
|
||||||
servers: Start and stop user servers.
|
servers: Start and stop user servers.
|
||||||
read:servers:
|
read:servers: Read users’ names and their server models (excluding the
|
||||||
Read users’ names and their server models (excluding the
|
|
||||||
server state).
|
server state).
|
||||||
delete:servers: Stop and delete users' servers.
|
delete:servers: Stop and delete users' servers.
|
||||||
tokens: Read, write, create and delete user tokens.
|
tokens: Read, write, create and delete user tokens.
|
||||||
read:tokens: Read user tokens.
|
read:tokens: Read user tokens.
|
||||||
admin:groups: Read and write group information, create and delete groups.
|
admin:groups: Read and write group information, create and delete groups.
|
||||||
groups:
|
groups: 'Read and write group information, including adding/removing any
|
||||||
"Read and write group information, including adding/removing any
|
users to/from groups. Note: adding users to groups may affect permissions.'
|
||||||
users to/from groups. Note: adding users to groups may affect permissions."
|
|
||||||
list:groups: List groups, including at least their names.
|
list:groups: List groups, including at least their names.
|
||||||
read:groups: Read group models.
|
read:groups: Read group models.
|
||||||
read:groups:name: Read group names.
|
read:groups:name: Read group names.
|
||||||
delete:groups: Delete groups.
|
delete:groups: Delete groups.
|
||||||
admin:services:
|
admin:services: Create, read, update, delete services, not including services
|
||||||
Create, read, update, delete services, not including services
|
|
||||||
defined from config files.
|
defined from config files.
|
||||||
list:services: List services, including at least their names.
|
list:services: List services, including at least their names.
|
||||||
read:services: Read service models.
|
read:services: Read service models.
|
||||||
@@ -2170,8 +2148,7 @@ components:
|
|||||||
read:groups:shares: Read servers shared with a group.
|
read:groups:shares: Read servers shared with a group.
|
||||||
read:shares: Read information about shared access to servers.
|
read:shares: Read information about shared access to servers.
|
||||||
shares: Manage access to shared servers.
|
shares: Manage access to shared servers.
|
||||||
proxy:
|
proxy: Read information about the proxy’s routing table, sync the Hub
|
||||||
Read information about the proxy’s routing table, sync the Hub
|
|
||||||
with the proxy and notify the Hub about a new proxy.
|
with the proxy and notify the Hub about a new proxy.
|
||||||
shutdown: Shutdown the hub.
|
shutdown: Shutdown the hub.
|
||||||
read:metrics: Read prometheus metrics.
|
read:metrics: Read prometheus metrics.
|
||||||
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
from urllib.request import urlretrieve
|
from urllib.request import urlretrieve
|
||||||
|
|
||||||
from docutils import nodes
|
from docutils import nodes
|
||||||
|
from intersphinx_registry import get_intersphinx_mapping
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
from sphinx.directives.other import SphinxDirective
|
from sphinx.directives.other import SphinxDirective
|
||||||
from sphinx.util import logging
|
from sphinx.util import logging
|
||||||
@@ -294,6 +295,8 @@ linkcheck_ignore = [
|
|||||||
r"https://linux.die.net/.*", # linux.die.net seems to block requests from CI with 403 sometimes
|
r"https://linux.die.net/.*", # linux.die.net seems to block requests from CI with 403 sometimes
|
||||||
# don't check links to unpublished advisories
|
# don't check links to unpublished advisories
|
||||||
r"https://github.com/jupyterhub/jupyterhub/security/advisories/.*",
|
r"https://github.com/jupyterhub/jupyterhub/security/advisories/.*",
|
||||||
|
# Occasionally blocks CI checks with 403
|
||||||
|
r"https://www\.mysql\.com",
|
||||||
]
|
]
|
||||||
linkcheck_anchors_ignore = [
|
linkcheck_anchors_ignore = [
|
||||||
"/#!",
|
"/#!",
|
||||||
@@ -303,12 +306,15 @@ linkcheck_anchors_ignore = [
|
|||||||
# -- Intersphinx -------------------------------------------------------------
|
# -- Intersphinx -------------------------------------------------------------
|
||||||
# ref: https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration
|
# ref: https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration
|
||||||
#
|
#
|
||||||
intersphinx_mapping = {
|
|
||||||
"python": ("https://docs.python.org/3/", None),
|
intersphinx_mapping = get_intersphinx_mapping(
|
||||||
"tornado": ("https://www.tornadoweb.org/en/stable/", None),
|
packages={
|
||||||
"jupyter-server": ("https://jupyter-server.readthedocs.io/en/stable/", None),
|
"python",
|
||||||
"nbgitpuller": ("https://nbgitpuller.readthedocs.io/en/latest", None),
|
"tornado",
|
||||||
}
|
"jupyter-server",
|
||||||
|
"nbgitpuller",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# -- Options for the opengraph extension -------------------------------------
|
# -- Options for the opengraph extension -------------------------------------
|
||||||
# ref: https://github.com/wpilibsuite/sphinxext-opengraph#options
|
# ref: https://github.com/wpilibsuite/sphinxext-opengraph#options
|
||||||
|
@@ -306,6 +306,52 @@ notebook servers to default to JupyterLab:
|
|||||||
|
|
||||||
Users will need a GitHub account to log in and be authenticated by the Hub.
|
Users will need a GitHub account to log in and be authenticated by the Hub.
|
||||||
|
|
||||||
|
### I'm seeing "403 Forbidden XSRF cookie does not match POST" when users try to login
|
||||||
|
|
||||||
|
During login, JupyterHub takes the request IP into account for CSRF protection.
|
||||||
|
If proxies are not configured to properly set forwarded ips,
|
||||||
|
JupyterHub will see all requests as coming from an internal ip,
|
||||||
|
likely the ip of the proxy itself.
|
||||||
|
You can see this in the JupyterHub logs, which log the ip address of requests.
|
||||||
|
If most requests look like they are coming from a small number `10.0.x.x` or `172.16.x.x` ips, the proxy is not forwarding the true request ip properly.
|
||||||
|
If the proxy has multiple replicas,
|
||||||
|
then it is likely the ip may change from one request to the next,
|
||||||
|
leading to this error during login:
|
||||||
|
|
||||||
|
> 403 Forbidden XSRF cookie does not match POST argument
|
||||||
|
|
||||||
|
The best way to fix this is to ensure your proxies set the forwarded headers, e.g. for nginx:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
```
|
||||||
|
|
||||||
|
But if this is not available to you, you can instruct jupyterhub to ignore IPs from certain networks
|
||||||
|
with the environment variable `$JUPYTERHUB_XSRF_ANONYMOUS_IP_CIDRS`.
|
||||||
|
For example, to ignore the common [private networks](https://en.wikipedia.org/wiki/Private_network#Private_IPv4_addresses):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_XSRF_ANONYMOUS_IP_CIDRS="10.0.0.0/8;172.16.0.0/12;192.168.0.0/16"
|
||||||
|
```
|
||||||
|
|
||||||
|
The result will be that any request from an ip on one of these networks will be treated as coming from the same source.
|
||||||
|
|
||||||
|
To totally disable taking the ip into consideration, set
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_XSRF_ANONYMOUS_IP_CIDRS="0.0.0.0/0"
|
||||||
|
```
|
||||||
|
|
||||||
|
If your proxy sets its own headers to identify a browser origin, you can instruct JupyterHub to use those:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_XSRF_ANONYMOUS_ID_HEADERS="My-Custom-Header;User-Agent"
|
||||||
|
```
|
||||||
|
|
||||||
|
Again, these things are only used to compute the XSRF token used while a user is not logged in (i.e. during login itself).
|
||||||
|
|
||||||
### How do I set up rotating daily logs?
|
### How do I set up rotating daily logs?
|
||||||
|
|
||||||
You can do this with [logrotate](https://linux.die.net/man/8/logrotate),
|
You can do this with [logrotate](https://linux.die.net/man/8/logrotate),
|
||||||
|
@@ -14,26 +14,52 @@ The files are:
|
|||||||
scopes descriptions are updated in it.
|
scopes descriptions are updated in it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import run
|
|
||||||
|
|
||||||
from pytablewriter import MarkdownTableWriter
|
from pytablewriter import MarkdownTableWriter
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
from jupyterhub import __version__
|
HERE = Path(__file__).parent.absolute()
|
||||||
from jupyterhub.scopes import scope_definitions
|
DOCS = HERE / ".." / ".."
|
||||||
|
|
||||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
|
||||||
DOCS = Path(HERE).parent.parent.absolute()
|
|
||||||
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
||||||
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
|
SCOPE_TABLE_MD = HERE.joinpath("scope-table.md")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_jupyterhub_info():
|
||||||
|
"""
|
||||||
|
The equivalent of
|
||||||
|
|
||||||
|
from jupyterhub import __version__
|
||||||
|
from jupyterhub.scopes import scope_definitions
|
||||||
|
|
||||||
|
but without needing to install JupyterHub and dependencies
|
||||||
|
so that we can run this pre-commit
|
||||||
|
"""
|
||||||
|
root = HERE / ".." / ".." / ".."
|
||||||
|
g = {}
|
||||||
|
exec((root / "jupyterhub" / "_version.py").read_text(), g)
|
||||||
|
|
||||||
|
# To avoid parsing the whole of scope_definitions.py just pull out
|
||||||
|
# the relevant lines
|
||||||
|
scopes_file = root / "jupyterhub" / "scopes.py"
|
||||||
|
scopes_lines = []
|
||||||
|
for line in scopes_file.read_text().splitlines():
|
||||||
|
if not scopes_lines and line == "scope_definitions = {":
|
||||||
|
scopes_lines.append(line)
|
||||||
|
elif scopes_lines:
|
||||||
|
scopes_lines.append(line)
|
||||||
|
if line == "}":
|
||||||
|
break
|
||||||
|
|
||||||
|
exec("\n".join(scopes_lines), g)
|
||||||
|
|
||||||
|
return g["__version__"], g["scope_definitions"]
|
||||||
|
|
||||||
|
|
||||||
class ScopeTableGenerator:
|
class ScopeTableGenerator:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.scopes = scope_definitions
|
self.version, self.scopes = _load_jupyterhub_info()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_writer(cls, table_name, headers, values):
|
def create_writer(cls, table_name, headers, values):
|
||||||
@@ -131,7 +157,7 @@ class ScopeTableGenerator:
|
|||||||
with open(filename) as f:
|
with open(filename) as f:
|
||||||
content = yaml.load(f.read())
|
content = yaml.load(f.read())
|
||||||
|
|
||||||
content["info"]["version"] = __version__
|
content["info"]["version"] = self.version
|
||||||
for scope in self.scopes:
|
for scope in self.scopes:
|
||||||
description = self.scopes[scope]['description']
|
description = self.scopes[scope]['description']
|
||||||
doc_description = self.scopes[scope].get('doc_description', '')
|
doc_description = self.scopes[scope].get('doc_description', '')
|
||||||
@@ -145,12 +171,6 @@ class ScopeTableGenerator:
|
|||||||
with open(filename, 'w') as f:
|
with open(filename, 'w') as f:
|
||||||
yaml.dump(content, f)
|
yaml.dump(content, f)
|
||||||
|
|
||||||
run(
|
|
||||||
['pre-commit', 'run', 'prettier', '--files', filename],
|
|
||||||
cwd=HERE,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
table_generator = ScopeTableGenerator()
|
table_generator = ScopeTableGenerator()
|
||||||
|
58
docs/source/rbac/scope-table.md
Normal file
58
docs/source/rbac/scope-table.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
Table 1. Available scopes and their hierarchy
|
||||||
|
| Scope | Grants permission to: |
|
||||||
|
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `(no_scope)` | Identify the owner of the requesting entity. |
|
||||||
|
| `self` | The user’s own resources _(metascope for users, resolves to (no_scope) for services)_ |
|
||||||
|
| `inherit` | Everything that the token-owning entity can access _(metascope for tokens)_ |
|
||||||
|
| `admin-ui` | Access the admin page. Permission to take actions via the admin page granted separately. |
|
||||||
|
| `admin:users` | Read, modify, create, and delete users and their authentication state, not including their servers or tokens. This is an extremely privileged scope and should be considered tantamount to superuser. |
|
||||||
|
| `admin:auth_state` | Read a user’s authentication state. |
|
||||||
|
| `users` | Read and write permissions to user models (excluding servers, tokens and authentication state). |
|
||||||
|
| `read:users` | Read user models (including the URL of the default server if it is running). |
|
||||||
|
| `read:users:name` | Read names of users. |
|
||||||
|
| `read:users:groups` | Read users’ group membership. |
|
||||||
|
| `read:users:activity` | Read time of last user activity. |
|
||||||
|
| `list:users` | List users, including at least their names. |
|
||||||
|
| `read:users:name` | Read names of users. |
|
||||||
|
| `users:activity` | Update time of last user activity. |
|
||||||
|
| `read:users:activity` | Read time of last user activity. |
|
||||||
|
| `read:roles:users` | Read user role assignments. |
|
||||||
|
| `delete:users` | Delete users. |
|
||||||
|
| `read:roles` | Read role assignments. |
|
||||||
|
| `read:roles:users` | Read user role assignments. |
|
||||||
|
| `read:roles:services` | Read service role assignments. |
|
||||||
|
| `read:roles:groups` | Read group role assignments. |
|
||||||
|
| `admin:servers` | Read, start, stop, create and delete user servers and their state. |
|
||||||
|
| `admin:server_state` | Read and write users’ server state. |
|
||||||
|
| `servers` | Start and stop user servers. |
|
||||||
|
| `read:servers` | Read users’ names and their server models (excluding the server state). |
|
||||||
|
| `read:users:name` | Read names of users. |
|
||||||
|
| `delete:servers` | Stop and delete users' servers. |
|
||||||
|
| `tokens` | Read, write, create and delete user tokens. |
|
||||||
|
| `read:tokens` | Read user tokens. |
|
||||||
|
| `admin:groups` | Read and write group information, create and delete groups. |
|
||||||
|
| `groups` | Read and write group information, including adding/removing any users to/from groups. Note: adding users to groups may affect permissions. |
|
||||||
|
| `read:groups` | Read group models. |
|
||||||
|
| `read:groups:name` | Read group names. |
|
||||||
|
| `list:groups` | List groups, including at least their names. |
|
||||||
|
| `read:groups:name` | Read group names. |
|
||||||
|
| `read:roles:groups` | Read group role assignments. |
|
||||||
|
| `delete:groups` | Delete groups. |
|
||||||
|
| `admin:services` | Create, read, update, delete services, not including services defined from config files. |
|
||||||
|
| `list:services` | List services, including at least their names. |
|
||||||
|
| `read:services:name` | Read service names. |
|
||||||
|
| `read:services` | Read service models. |
|
||||||
|
| `read:services:name` | Read service names. |
|
||||||
|
| `read:roles:services` | Read service role assignments. |
|
||||||
|
| `read:hub` | Read detailed information about the Hub. |
|
||||||
|
| `access:services` | Access services via API or browser. |
|
||||||
|
| `shares` | Manage access to shared servers. |
|
||||||
|
| `access:servers` | Access user servers via API or browser. |
|
||||||
|
| `read:shares` | Read information about shared access to servers. |
|
||||||
|
| `users:shares` | Read and revoke a user's access to shared servers. |
|
||||||
|
| `read:users:shares` | Read servers shared with a user. |
|
||||||
|
| `groups:shares` | Read and revoke a group's access to shared servers. |
|
||||||
|
| `read:groups:shares` | Read servers shared with a group. |
|
||||||
|
| `proxy` | Read information about the proxy’s routing table, sync the Hub with the proxy and notify the Hub about a new proxy. |
|
||||||
|
| `shutdown` | Shutdown the hub. |
|
||||||
|
| `read:metrics` | Read prometheus metrics. |
|
@@ -1,33 +1,42 @@
|
|||||||
# Authenticators
|
# Authenticators
|
||||||
|
|
||||||
## Module: {mod}`jupyterhub.auth`
|
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: jupyterhub.auth
|
.. module:: jupyterhub.auth
|
||||||
```
|
```
|
||||||
|
|
||||||
### {class}`Authenticator`
|
## {class}`Authenticator`
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoconfigurable:: Authenticator
|
.. autoconfigurable:: Authenticator
|
||||||
:members:
|
:members:
|
||||||
```
|
```
|
||||||
|
|
||||||
### {class}`LocalAuthenticator`
|
## {class}`LocalAuthenticator`
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoconfigurable:: LocalAuthenticator
|
.. autoconfigurable:: LocalAuthenticator
|
||||||
:members:
|
:members:
|
||||||
```
|
```
|
||||||
|
|
||||||
### {class}`PAMAuthenticator`
|
## {class}`PAMAuthenticator`
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoconfigurable:: PAMAuthenticator
|
.. autoconfigurable:: PAMAuthenticator
|
||||||
```
|
```
|
||||||
|
|
||||||
### {class}`DummyAuthenticator`
|
## {class}`DummyAuthenticator`
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoconfigurable:: DummyAuthenticator
|
.. autoconfigurable:: DummyAuthenticator
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. module:: jupyterhub.authenticators.shared
|
||||||
|
```
|
||||||
|
|
||||||
|
## {class}`SharedPasswordAuthenticator`
|
||||||
|
|
||||||
|
```{eval-rst}
|
||||||
|
.. autoconfigurable:: SharedPasswordAuthenticator
|
||||||
|
:no-inherited-members:
|
||||||
|
```
|
||||||
|
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoconfigurable:: Spawner
|
.. autoconfigurable:: Spawner
|
||||||
:members: options_from_form, poll, start, stop, get_args, get_env, get_state, template_namespace, format_string, create_certs, move_certs
|
:members: options_from_form, user_options, poll, start, stop, get_args, get_env, get_state, template_namespace, format_string, create_certs, move_certs
|
||||||
```
|
```
|
||||||
|
|
||||||
### {class}`LocalProcessSpawner`
|
### {class}`LocalProcessSpawner`
|
||||||
|
@@ -36,16 +36,56 @@ A [generic implementation](https://github.com/jupyterhub/oauthenticator/blob/mas
|
|||||||
|
|
||||||
## The Dummy Authenticator
|
## The Dummy Authenticator
|
||||||
|
|
||||||
When testing, it may be helpful to use the
|
When testing, it may be helpful to use the {class}`~.jupyterhub.auth.DummyAuthenticator`:
|
||||||
{class}`~.jupyterhub.auth.DummyAuthenticator`. This allows for any username and
|
|
||||||
password unless a global password has been set. Once set, any username will
|
```python
|
||||||
still be accepted but the correct password will need to be provided.
|
c.JupyterHub.authenticator_class = "dummy"
|
||||||
|
# always a good idea to limit to localhost when testing with an insecure config
|
||||||
|
c.JupyterHub.ip = "127.0.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows for any username and password to login, and is _wildly_ insecure.
|
||||||
|
|
||||||
|
To use, specify
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.authenticator_class = "dummy"
|
||||||
|
```
|
||||||
|
|
||||||
:::{versionadded} 5.0
|
:::{versionadded} 5.0
|
||||||
The DummyAuthenticator's default `allow_all` is True,
|
The DummyAuthenticator's default `allow_all` is True,
|
||||||
unlike most other Authenticators.
|
unlike most other Authenticators.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::{deprecated} 5.3
|
||||||
|
Setting a password on DummyAuthenticator is deprecated.
|
||||||
|
Use the new {class}`~.jupyterhub.authenticators.shared.SharedPasswordAuthenticator`
|
||||||
|
if you want to set a shared password for users.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Shared Password Authenticator
|
||||||
|
|
||||||
|
:::{versionadded} 5.3
|
||||||
|
{class}`~.jupyterhub.authenticators.shared.SharedPasswordAuthenticator` is added and [DummyAuthenticator.password](#DummyAuthenticator.password) is deprecated.
|
||||||
|
:::
|
||||||
|
|
||||||
|
For short-term deployments like workshops where there is no real user data to protect and you trust users to not abuse the system or each other,
|
||||||
|
{class}`~.jupyterhub.authenticators.shared.SharedPasswordAuthenticator` can be used.
|
||||||
|
|
||||||
|
Set a [user password](#SharedPasswordAuthenticator.user_password) for users to login:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.authenticator_class = "shared-password"
|
||||||
|
c.SharedPasswordAuthenticator.user_password = "my-workshop-2042"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also grant admin users access by adding them to `admin_users` and setting a separate [admin password](#SharedPasswordAuthenticator.admin_password):
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.admin_users = {"danger", "eggs"}
|
||||||
|
c.SharedPasswordAuthenticator.admin_password = "extra-super-secret-secure-password"
|
||||||
|
```
|
||||||
|
|
||||||
## Additional Authenticators
|
## Additional Authenticators
|
||||||
|
|
||||||
Additional authenticators can be found on GitHub
|
Additional authenticators can be found on GitHub
|
||||||
@@ -469,8 +509,19 @@ which is a list of group names the user should be a member of:
|
|||||||
- If `None` is returned, no changes are made to the user's group membership
|
- If `None` is returned, no changes are made to the user's group membership
|
||||||
|
|
||||||
If authenticator-managed groups are enabled,
|
If authenticator-managed groups are enabled,
|
||||||
all group-management via the API is disabled,
|
groups cannot be specified with `load_groups` traitlet.
|
||||||
and roles cannot be specified with `load_groups` traitlet.
|
|
||||||
|
:::{warning}
|
||||||
|
When `manage_groups` is True,
|
||||||
|
managing groups via the API is still permitted via the `admin:groups` scope (starting with 5.3),
|
||||||
|
but any time a user logs in their group membership is completely reset via the login process.
|
||||||
|
So it only really makes sense to make manual changes via the API that reflect upstream changes which are not automatically propagated, such as group deletion.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::{versionchanged} 5.3
|
||||||
|
Prior to JupyterHub 5.3, all group management via the API was disabled if `Authenticator.manage_groups` is True.
|
||||||
|
:::
|
||||||
|
|
||||||
(authenticator-roles)=
|
(authenticator-roles)=
|
||||||
|
|
||||||
|
@@ -20,8 +20,104 @@ Contributors to major version bumps in JupyterHub include:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## 5.3
|
||||||
|
|
||||||
|
### 5.3.0 - 2025-04
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/5.2.1...HEAD))
|
||||||
|
|
||||||
|
#### New features added
|
||||||
|
|
||||||
|
- Add SharedPasswordAuthenticator [#5037](https://github.com/jupyterhub/jupyterhub/pull/5037) ([@yuvipanda](https://github.com/yuvipanda), [@minrk](https://github.com/minrk), [@jules32](https://github.com/jules32), [@manics](https://github.com/manics), [@ateucher](https://github.com/ateucher))
|
||||||
|
- add apply_user_options hook [#5012](https://github.com/jupyterhub/jupyterhub/pull/5012) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@akhmerov](https://github.com/akhmerov))
|
||||||
|
- allow group management API when managed_groups is True [#5004](https://github.com/jupyterhub/jupyterhub/pull/5004) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- Add `$JUPYTERHUB_XSRF_ANONYMOUS{IP_CIDRS|HEADERS}` config for managing anonymous xsrf ids [#4991](https://github.com/jupyterhub/jupyterhub/pull/4991) ([@minrk](https://github.com/minrk), [@manics](https://github.com/manics), [@samyuh](https://github.com/samyuh))
|
||||||
|
- Allow custom login input validation [#4979](https://github.com/jupyterhub/jupyterhub/pull/4979) ([@tlvu](https://github.com/tlvu), [@minrk](https://github.com/minrk))
|
||||||
|
- Allow configuration of bucket sizes in metrics - #4833 [#4967](https://github.com/jupyterhub/jupyterhub/pull/4967) ([@kireetb](https://github.com/kireetb), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- improve xsrf errors on login [#5022](https://github.com/jupyterhub/jupyterhub/pull/5022) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- More IPv6: Use bare IPv6 for configuration, use `[ipv6]` when displaying IPv6 outputs [#4988](https://github.com/jupyterhub/jupyterhub/pull/4988) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- Allow CORS requests to /hub/api by default [#4966](https://github.com/jupyterhub/jupyterhub/pull/4966) ([@agoose77](https://github.com/agoose77), [@minrk](https://github.com/minrk), [@yuvipanda](https://github.com/yuvipanda), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- url_path_join: handle empty trailing components [#5033](https://github.com/jupyterhub/jupyterhub/pull/5033) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- Try to improve admin paging consistency [#5025](https://github.com/jupyterhub/jupyterhub/pull/5025) ([@minrk](https://github.com/minrk))
|
||||||
|
- make sure custom error messages are shown on regular error pages [#5020](https://github.com/jupyterhub/jupyterhub/pull/5020) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- raise spawn error if spawn failed while polling [#5011](https://github.com/jupyterhub/jupyterhub/pull/5011) ([@minrk](https://github.com/minrk))
|
||||||
|
- Singleuser: listen on IPv4 and IPv6 if ip=="" [#4986](https://github.com/jupyterhub/jupyterhub/pull/4986) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- make insecure-login-warning visible in dark mode [#4982](https://github.com/jupyterhub/jupyterhub/pull/4982) ([@mishaschwartz](https://github.com/mishaschwartz), [@minrk](https://github.com/minrk))
|
||||||
|
- Fix bug in `GroupEdit`: Users being reset when clicking the button to edit a group [#4968](https://github.com/jupyterhub/jupyterhub/pull/4968) ([@oboki](https://github.com/oboki), [@minrk](https://github.com/minrk))
|
||||||
|
- Fixed code formatting for implicit_spawn_seconds (#4949) [#4950](https://github.com/jupyterhub/jupyterhub/pull/4950) ([@millenniumhand](https://github.com/millenniumhand), [@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics))
|
||||||
|
- Stop All: include named servers [#4939](https://github.com/jupyterhub/jupyterhub/pull/4939) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Maintenance and upkeep improvements
|
||||||
|
|
||||||
|
- skip js build on readthedocs [#5036](https://github.com/jupyterhub/jupyterhub/pull/5036) ([@minrk](https://github.com/minrk))
|
||||||
|
- jsx: update and address eslint [#5030](https://github.com/jupyterhub/jupyterhub/pull/5030) ([@minrk](https://github.com/minrk))
|
||||||
|
- stop publishing images from jupyterhub/jupyterhub [#5024](https://github.com/jupyterhub/jupyterhub/pull/5024) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics))
|
||||||
|
- try to fix flaky spawn_pending browser test [#5023](https://github.com/jupyterhub/jupyterhub/pull/5023) ([@minrk](https://github.com/minrk))
|
||||||
|
- add some debugging output for intermittent share code failure [#5021](https://github.com/jupyterhub/jupyterhub/pull/5021) ([@minrk](https://github.com/minrk))
|
||||||
|
- temporarily disable docker build on ci [#5010](https://github.com/jupyterhub/jupyterhub/pull/5010) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- MockHub: randomize hub and proxy API ports [#5007](https://github.com/jupyterhub/jupyterhub/pull/5007) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- don't install unused browsers for playwright [#4990](https://github.com/jupyterhub/jupyterhub/pull/4990) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- close tornado FDs without closing asyncio loop [#4984](https://github.com/jupyterhub/jupyterhub/pull/4984) ([@minrk](https://github.com/minrk))
|
||||||
|
- Standard formatting in LICENSE [#4975](https://github.com/jupyterhub/jupyterhub/pull/4975) ([@SamuelMarks](https://github.com/SamuelMarks), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- Replace react-router-dom@6 with react-router@7 [#4961](https://github.com/jupyterhub/jupyterhub/pull/4961) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- Use intersphinx-registry to keep intersphinx URLs up to date. [#4948](https://github.com/jupyterhub/jupyterhub/pull/4948) ([@Carreau](https://github.com/Carreau), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Add instruction on how to select dummy authenticator [#5041](https://github.com/jupyterhub/jupyterhub/pull/5041) ([@ktaletsk](https://github.com/ktaletsk), [@GeorgianaElena](https://github.com/GeorgianaElena), [@minrk](https://github.com/minrk))
|
||||||
|
- rm outdated claim that "copy shareable link" does not work in JupyterHub [#5018](https://github.com/jupyterhub/jupyterhub/pull/5018) ([@ctcjab](https://github.com/ctcjab), [@manics](https://github.com/manics))
|
||||||
|
- Automatically generate rest-api.yml and scopes.md using pre-commit [#5009](https://github.com/jupyterhub/jupyterhub/pull/5009) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- doc: read:users only includes server not servers [#5006](https://github.com/jupyterhub/jupyterhub/pull/5006) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- make sure 'kind' shows up in rest api [#4995](https://github.com/jupyterhub/jupyterhub/pull/4995) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- Missing breaking change in 5.0.0 changelog: URL tokens [#4978](https://github.com/jupyterhub/jupyterhub/pull/4978) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- Update `load_groups` config in collaboration-users.md [#4964](https://github.com/jupyterhub/jupyterhub/pull/4964) ([@jrdnbradford](https://github.com/jrdnbradford), [@minrk](https://github.com/minrk), [@manics](https://github.com/manics))
|
||||||
|
- Document Spawner.oauth_client_allowed_scopes always allows access [#4955](https://github.com/jupyterhub/jupyterhub/pull/4955) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
- mention that `auth_refresh_age = 0` disables time-based refresh_user [#4947](https://github.com/jupyterhub/jupyterhub/pull/4947) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
- add traitlets default import to auth allow_all override example for completeness [#4946](https://github.com/jupyterhub/jupyterhub/pull/4946) ([@kellyrowland](https://github.com/kellyrowland), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
The following people contributed discussions, new ideas, code and documentation contributions, and review.
|
||||||
|
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-10-21&to=2025-04-07&type=c))
|
||||||
|
|
||||||
|
@agoose77 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aagoose77+updated%3A2024-10-21..2025-04-07&type=Issues)) | @akhmerov ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aakhmerov+updated%3A2024-10-21..2025-04-07&type=Issues)) | @ateucher ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aateucher+updated%3A2024-10-21..2025-04-07&type=Issues)) | @Carreau ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ACarreau+updated%3A2024-10-21..2025-04-07&type=Issues)) | @consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-10-21..2025-04-07&type=Issues)) | @ctcjab ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Actcjab+updated%3A2024-10-21..2025-04-07&type=Issues)) | @davidbrochart ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Adavidbrochart+updated%3A2024-10-21..2025-04-07&type=Issues)) | @GeorgianaElena ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AGeorgianaElena+updated%3A2024-10-21..2025-04-07&type=Issues)) | @jrdnbradford ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajrdnbradford+updated%3A2024-10-21..2025-04-07&type=Issues)) | @jules32 ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ajules32+updated%3A2024-10-21..2025-04-07&type=Issues)) | @kellyrowland ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akellyrowland+updated%3A2024-10-21..2025-04-07&type=Issues)) | @kireetb ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Akireetb+updated%3A2024-10-21..2025-04-07&type=Issues)) | @ktaletsk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aktaletsk+updated%3A2024-10-21..2025-04-07&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2024-10-21..2025-04-07&type=Issues)) | @millenniumhand ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amillenniumhand+updated%3A2024-10-21..2025-04-07&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-10-21..2025-04-07&type=Issues)) | @mishaschwartz ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amishaschwartz+updated%3A2024-10-21..2025-04-07&type=Issues)) | @oboki ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aoboki+updated%3A2024-10-21..2025-04-07&type=Issues)) | @SamuelMarks ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3ASamuelMarks+updated%3A2024-10-21..2025-04-07&type=Issues)) | @samyuh ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Asamyuh+updated%3A2024-10-21..2025-04-07&type=Issues)) | @tlvu ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Atlvu+updated%3A2024-10-21..2025-04-07&type=Issues)) | @yuvipanda ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Ayuvipanda+updated%3A2024-10-21..2025-04-07&type=Issues))
|
||||||
|
|
||||||
## 5.2
|
## 5.2
|
||||||
|
|
||||||
|
### 5.2.1 - 2024-10-21
|
||||||
|
|
||||||
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/5.2.0...5.2.1))
|
||||||
|
|
||||||
|
#### Enhancements made
|
||||||
|
|
||||||
|
- informative error on missing dependencies for singleuser server [#4934](https://github.com/jupyterhub/jupyterhub/pull/4934) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
|
|
||||||
|
#### Bugs fixed
|
||||||
|
|
||||||
|
- Abort jupyterhub startup only if managed services fail [#4930](https://github.com/jupyterhub/jupyterhub/pull/4930) ([@consideRatio](https://github.com/consideRatio), [@minrk](https://github.com/minrk))
|
||||||
|
- Loaded EntryPointTypes are types, not instances [#4922](https://github.com/jupyterhub/jupyterhub/pull/4922) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Documentation improvements
|
||||||
|
|
||||||
|
- Remove out-of-date info from subdomain_hook doc [#4932](https://github.com/jupyterhub/jupyterhub/pull/4932) ([@manics](https://github.com/manics), [@minrk](https://github.com/minrk))
|
||||||
|
|
||||||
|
#### Contributors to this release
|
||||||
|
|
||||||
|
The following people contributed discussions, new ideas, code and documentation contributions, and review.
|
||||||
|
See [our definition of contributors](https://github-activity.readthedocs.io/en/latest/#how-does-this-tool-define-contributions-in-the-reports).
|
||||||
|
|
||||||
|
([GitHub contributors page for this release](https://github.com/jupyterhub/jupyterhub/graphs/contributors?from=2024-10-01&to=2024-10-21&type=c))
|
||||||
|
|
||||||
|
@consideRatio ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3AconsideRatio+updated%3A2024-10-01..2024-10-21&type=Issues)) | @manics ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Amanics+updated%3A2024-10-01..2024-10-21&type=Issues)) | @minrk ([activity](https://github.com/search?q=repo%3Ajupyterhub%2Fjupyterhub+involves%3Aminrk+updated%3A2024-10-01..2024-10-21&type=Issues))
|
||||||
|
|
||||||
### 5.2.0 - 2024-10-01
|
### 5.2.0 - 2024-10-01
|
||||||
|
|
||||||
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/5.1.0...5.2.0))
|
([full changelog](https://github.com/jupyterhub/jupyterhub/compare/5.1.0...5.2.0))
|
||||||
@@ -142,6 +238,7 @@ Changes that are likely to require effort to upgrade:
|
|||||||
- update bootstrap to v5 [#4774](https://github.com/jupyterhub/jupyterhub/pull/4774) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics))
|
- update bootstrap to v5 [#4774](https://github.com/jupyterhub/jupyterhub/pull/4774) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@manics](https://github.com/manics))
|
||||||
- explicitly require groups in auth model when Authenticator.manage_groups is enabled [#4645](https://github.com/jupyterhub/jupyterhub/pull/4645) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
- explicitly require groups in auth model when Authenticator.manage_groups is enabled [#4645](https://github.com/jupyterhub/jupyterhub/pull/4645) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio))
|
||||||
- add JupyterHub.subdomain_hook [#4471](https://github.com/jupyterhub/jupyterhub/pull/4471) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@akhmerov](https://github.com/akhmerov))
|
- add JupyterHub.subdomain_hook [#4471](https://github.com/jupyterhub/jupyterhub/pull/4471) ([@minrk](https://github.com/minrk), [@consideRatio](https://github.com/consideRatio), [@akhmerov](https://github.com/akhmerov))
|
||||||
|
- Using a token as a query parameter to start a user session is disabled by default, set `JUPYTERHUB_ALLOW_TOKEN_IN_URL=true` to enable it
|
||||||
|
|
||||||
#### New features added
|
#### New features added
|
||||||
|
|
||||||
|
@@ -33,6 +33,16 @@ export JUPYTERHUB_METRICS_PREFIX=jupyterhub_prod
|
|||||||
|
|
||||||
would result in the metric `jupyterhub_prod_active_users`, etc.
|
would result in the metric `jupyterhub_prod_active_users`, etc.
|
||||||
|
|
||||||
|
## Customizing spawn bucket sizes
|
||||||
|
|
||||||
|
As of JupyterHub 5.3, override `JUPYTERHUB_SERVER_SPAWN_DURATION_SECONDS_BUCKETS` env variable in Hub's environment to allow custom bucket sizes. Otherwise default to, [0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, 180, 300, 600, float("inf")]
|
||||||
|
|
||||||
|
For example,
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_SERVER_SPAWN_DURATION_SECONDS_BUCKETS="1,2,4,6,12,30,60,120"
|
||||||
|
```
|
||||||
|
|
||||||
## Configuring metrics
|
## Configuring metrics
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Spawners
|
# Spawners
|
||||||
|
|
||||||
A [Spawner][] starts each single-user notebook server.
|
A [Spawner](#Spawner) starts each single-user notebook server.
|
||||||
The Spawner represents an abstract interface to a process,
|
The Spawner represents an abstract interface to a process,
|
||||||
and a custom Spawner needs to be able to take three actions:
|
and a custom Spawner needs to be able to take three actions:
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Some examples include:
|
|||||||
|
|
||||||
### Spawner.start
|
### Spawner.start
|
||||||
|
|
||||||
`Spawner.start` should start a single-user server for a single user.
|
[](#Spawner.start) should start a single-user server for a single user.
|
||||||
Information about the user can be retrieved from `self.user`,
|
Information about the user can be retrieved from `self.user`,
|
||||||
an object encapsulating the user's name, authentication, and server info.
|
an object encapsulating the user's name, authentication, and server info.
|
||||||
|
|
||||||
@@ -68,11 +68,11 @@ async def start(self):
|
|||||||
When `Spawner.start` returns, the single-user server process should actually be running,
|
When `Spawner.start` returns, the single-user server process should actually be running,
|
||||||
not just requested. JupyterHub can handle `Spawner.start` being very slow
|
not just requested. JupyterHub can handle `Spawner.start` being very slow
|
||||||
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
||||||
via relaxing the `Spawner.start_timeout` config value.
|
via relaxing the [](#Spawner.start_timeout) config value.
|
||||||
|
|
||||||
#### Note on IPs and ports
|
#### Note on IPs and ports
|
||||||
|
|
||||||
`Spawner.ip` and `Spawner.port` attributes set the _bind_ URL,
|
[](#Spawner.ip) and [](#Spawner.port) attributes set the _bind_ URL,
|
||||||
which the single-user server should listen on
|
which the single-user server should listen on
|
||||||
(passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable).
|
(passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable).
|
||||||
The _return_ value is the IP and port (or full URL) the Hub should _connect to_.
|
The _return_ value is the IP and port (or full URL) the Hub should _connect to_.
|
||||||
@@ -124,7 +124,7 @@ If both attributes are not present, the Exception will be shown to the user as u
|
|||||||
|
|
||||||
### Spawner.poll
|
### Spawner.poll
|
||||||
|
|
||||||
`Spawner.poll` checks if the spawner is still running.
|
[](#Spawner.poll) checks if the spawner is still running.
|
||||||
It should return `None` if it is still running,
|
It should return `None` if it is still running,
|
||||||
and an integer exit status, otherwise.
|
and an integer exit status, otherwise.
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ to check if the local process is still running. On Windows, it uses `psutil.pid_
|
|||||||
|
|
||||||
### Spawner.stop
|
### Spawner.stop
|
||||||
|
|
||||||
`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
|
[](#Spawner.stop) should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
|
||||||
|
|
||||||
## Spawner state
|
## Spawner state
|
||||||
|
|
||||||
@@ -168,15 +168,14 @@ def clear_state(self):
|
|||||||
|
|
||||||
## Spawner options form
|
## Spawner options form
|
||||||
|
|
||||||
(new in 0.4)
|
|
||||||
|
|
||||||
Some deployments may want to offer options to users to influence how their servers are started.
|
Some deployments may want to offer options to users to influence how their servers are started.
|
||||||
This may include cluster-based deployments, where users specify what resources should be available,
|
This may include cluster-based deployments, where users specify what memory or cpu resources should be available,
|
||||||
or docker-based deployments where users can select from a list of base images.
|
or container-based deployments where users can select from a list of base images,
|
||||||
|
or more complex configurations where users select a "profile" representing a bundle of settings to be applied together.
|
||||||
|
|
||||||
This feature is enabled by setting `Spawner.options_form`, which is an HTML form snippet
|
This feature is enabled by setting [](#Spawner.options_form), which is an HTML form snippet
|
||||||
inserted unmodified into the spawn form.
|
inserted unmodified into the spawn form.
|
||||||
If the `Spawner.options_form` is defined, when a user tries to start their server, they will be directed to a form page, like this:
|
If the `Spawner.options_form` is defined, when a user tries to start their server they will be directed to a form page, like this:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -186,28 +185,40 @@ See [this example](https://github.com/jupyterhub/jupyterhub/blob/HEAD/examples/s
|
|||||||
|
|
||||||
### `Spawner.options_from_form`
|
### `Spawner.options_from_form`
|
||||||
|
|
||||||
Options from this form will always be a dictionary of lists of strings, e.g.:
|
Inputs from an HTML form always arrive as a dictionary of lists of strings, e.g.:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
{
|
formdata = {
|
||||||
'integer': ['5'],
|
'integer': ['5'],
|
||||||
|
'checkbox': ['on'],
|
||||||
'text': ['some text'],
|
'text': ['some text'],
|
||||||
'select': ['a', 'b'],
|
'select': ['a', 'b'],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When `formdata` arrives, it is passed through `Spawner.options_from_form(formdata)`,
|
When `formdata` arrives, it is passed through [](#Spawner.options_from_form):
|
||||||
which is a method to turn the form data into the correct structure.
|
|
||||||
This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types. For example, the `options_from_form` for the above form would look like:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def options_from_form(self, formdata):
|
spawner.user_options = spawner.options_from_form(formdata, spawner=spawner)
|
||||||
|
```
|
||||||
|
|
||||||
|
to create `spawner.user_options`.
|
||||||
|
|
||||||
|
[](#Spawner.options_from_form) is a configurable function to turn the HTTP form data into the correct structure for [](#Spawner.user_options).
|
||||||
|
`options_from_form` must return a dictionary, _may_ be async, and is meant to interpret the lists-of-strings a web form produces into the correct types.
|
||||||
|
For example, the `options_from_form` for the above form might look like:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def options_from_form(formdata, spawner=None):
|
||||||
options = {}
|
options = {}
|
||||||
options['integer'] = int(formdata['integer'][0]) # single integer value
|
options['integer'] = int(formdata['integer'][0]) # single integer value
|
||||||
|
options['checkbox'] = formdata['checkbox'] == ['on']
|
||||||
options['text'] = formdata['text'][0] # single string value
|
options['text'] = formdata['text'][0] # single string value
|
||||||
options['select'] = formdata['select'] # list already correct
|
options['select'] = formdata['select'] # list already correct
|
||||||
options['notinform'] = 'extra info' # not in the form at all
|
options['notinform'] = 'extra info' # not in the form at all
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
c.Spawner.options_from_form = options_from_form
|
||||||
```
|
```
|
||||||
|
|
||||||
which would return:
|
which would return:
|
||||||
@@ -215,15 +226,115 @@ which would return:
|
|||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
'integer': 5,
|
'integer': 5,
|
||||||
|
'checkbox': True,
|
||||||
'text': 'some text',
|
'text': 'some text',
|
||||||
'select': ['a', 'b'],
|
'select': ['a', 'b'],
|
||||||
'notinform': 'extra info',
|
'notinform': 'extra info',
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When `Spawner.start` is called, this dictionary is accessible as `self.user_options`.
|
### Applying user options
|
||||||
|
|
||||||
[spawner]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/spawner.py
|
The base Spawner class doesn't do anything with `user_options`, that is also up to your deployment and/or chosen Spawner.
|
||||||
|
This is because the users can specify arbitrary option dictionary by using the API,
|
||||||
|
so it is part of your Spawner and/or deployment configuration to expose the options you trust your users to set.
|
||||||
|
|
||||||
|
[](#Spawner.apply_user_options) is the hook for taking `user_options` and applying whatever configuration it may represent.
|
||||||
|
It is critical that `apply_user_options` validates all input, since these are provided by the user.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def apply_user_options(spawner, user_options):
|
||||||
|
if "image" in user_options and isinstance(user_options["image"], str):
|
||||||
|
spawner.image = user_options["image"]
|
||||||
|
|
||||||
|
c.Spawner.apply_user_options = apply_user_options
|
||||||
|
```
|
||||||
|
|
||||||
|
:::{versionadded} 5.3
|
||||||
|
JupyterHub 5.3 introduces [](#Spawner.apply_user_options) configuration.
|
||||||
|
Previously, [](#Spawner.user_options) could only be consumed during [](#Spawner.start),
|
||||||
|
at which point `user_options` is available to the Spawner instance as `self.user_options`.
|
||||||
|
This approach requires subclassing, so it was not possible to apply new `user_options` via configuration.
|
||||||
|
In JupyterHub 5.3, it is possible to fully expose user options,
|
||||||
|
and for some simple cases, fully with _declarative_ configuration.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Declarative configuration for user options
|
||||||
|
|
||||||
|
While [](#Spawner.options_from_form) and [](#Spawner.apply_user_options) are callables by nature,
|
||||||
|
some simple cases can be represented by declarative configuration,
|
||||||
|
which is most conveniently expressed in e.g. the yaml of the JupyterHub helm chart.
|
||||||
|
The cases currently handled are:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Spawner.options_form = """
|
||||||
|
<input name="image_input" type="text" value="quay.io/jupyterhub/singleuser:5.2"/>
|
||||||
|
<input name="debug_checkbox" type="checkbox" />
|
||||||
|
"""
|
||||||
|
c.Spawner.options_from_form = "simple"
|
||||||
|
c.Spawner.apply_user_options = {"image_input": "image", "debug_checkbox": "debug"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`options_from_form = "simple"` uses a built-in method to do the very simplest interpretation of an html form,
|
||||||
|
casting the lists of strings to single strings by getting the first item when there is only one.
|
||||||
|
The only extra processing it performs is casting the checkbox value of `on` to True.
|
||||||
|
|
||||||
|
So it turns this formdata:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"image_input": ["my_image"],
|
||||||
|
"debug_checkbox": ["on"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
into this `user_options`
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"image_input": "my_image",
|
||||||
|
"debug_checkbox": True
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `apply_user_options` is a dictionary, any input in `user_options` is looked up in this dictionary,
|
||||||
|
and assigned to the corresponding Spawner attribute.
|
||||||
|
Strings are passed through traitlets' `from_string` logic (what is used for setting values on the command-line),
|
||||||
|
which means you can set numbers and things this way as well,
|
||||||
|
even though `options_from_form` leaves these as strings.
|
||||||
|
|
||||||
|
So in the above configuration, we have exposed `Spawner.debug` and `Spawner.image` without needing to write any functions.
|
||||||
|
In the JupyterHub helm chart YAML, this would look like:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
hub:
|
||||||
|
config:
|
||||||
|
KubeSpawner:
|
||||||
|
options_form: |
|
||||||
|
<input name="image_input" type="text" value="quay.io/jupyterhub/singleuser:5.2"/>
|
||||||
|
<input name="debug_checkbox" type="checkbox" />
|
||||||
|
options_from_form: simple
|
||||||
|
apply_user_options:
|
||||||
|
image_input: image
|
||||||
|
debug_checkbox: debug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting `user_options` directly via the REST API
|
||||||
|
|
||||||
|
In addition to going through the options form, `user_options` may be set directly, via the REST API.
|
||||||
|
The body of a POST request to spawn a server may be a JSON dictionary,
|
||||||
|
which will be used to set `user_options` directly.
|
||||||
|
When used this way, neither `options_form` nor `options_from_form` are involved,
|
||||||
|
`user_options` is set directly, and only `apply_user_options` is called.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /hub/api/users/servers/:name
|
||||||
|
{
|
||||||
|
"option": 5,
|
||||||
|
"bool": True,
|
||||||
|
"string": "value"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Writing a custom spawner
|
## Writing a custom spawner
|
||||||
|
|
||||||
|
@@ -183,13 +183,6 @@ will send user `hortense` to `/user/hortense/notebooks/Index.ipynb`
|
|||||||
This will not work in general,
|
This will not work in general,
|
||||||
unless you grant those users access to your server.
|
unless you grant those users access to your server.
|
||||||
|
|
||||||
**Contributions welcome:** The JupyterLab "shareable link" should share this link
|
|
||||||
when run with JupyterHub, but it does not.
|
|
||||||
See [jupyterlab-hub](https://github.com/jupyterhub/jupyterlab-hub)
|
|
||||||
where this should probably be done and
|
|
||||||
[this issue in JupyterLab](https://github.com/jupyterlab/jupyterlab/issues/5388)
|
|
||||||
that is intended to make it possible.
|
|
||||||
|
|
||||||
## Spawning
|
## Spawning
|
||||||
|
|
||||||
### `/hub/spawn[/:username[/:servername]]`
|
### `/hub/spawn[/:username[/:servername]]`
|
||||||
|
@@ -78,7 +78,7 @@ c.JupyterHub.load_roles = []
|
|||||||
c.JupyterHub.load_groups = {
|
c.JupyterHub.load_groups = {
|
||||||
# collaborative accounts get added to this group
|
# collaborative accounts get added to this group
|
||||||
# so it's easy to see which accounts are collaboration accounts
|
# so it's easy to see which accounts are collaboration accounts
|
||||||
"collaborative": [],
|
"collaborative": {"users": []},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -102,12 +102,12 @@ for project_name, project in project_config["projects"].items():
|
|||||||
members = project.get("members", [])
|
members = project.get("members", [])
|
||||||
print(f"Adding project {project_name} with members {members}")
|
print(f"Adding project {project_name} with members {members}")
|
||||||
# add them to a group for the project
|
# add them to a group for the project
|
||||||
c.JupyterHub.load_groups[project_name] = members
|
c.JupyterHub.load_groups[project_name] = {"users": members}
|
||||||
# define a new user for the collaboration
|
# define a new user for the collaboration
|
||||||
collab_user = f"{project_name}-collab"
|
collab_user = f"{project_name}-collab"
|
||||||
# add the collab user to the 'collaborative' group
|
# add the collab user to the 'collaborative' group
|
||||||
# so we can identify it as a collab account
|
# so we can identify it as a collab account
|
||||||
c.JupyterHub.load_groups["collaborative"].append(collab_user)
|
c.JupyterHub.load_groups["collaborative"]["users"].append(collab_user)
|
||||||
|
|
||||||
# finally, grant members of the project collaboration group
|
# finally, grant members of the project collaboration group
|
||||||
# access to the collab user's server,
|
# access to the collab user's server,
|
||||||
|
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["plugin:react/recommended"],
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": 2018,
|
|
||||||
"sourceType": "module",
|
|
||||||
"ecmaFeatures": {
|
|
||||||
"jsx": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"react": {
|
|
||||||
"version": "detect"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plugins": ["eslint-plugin-react", "prettier", "unused-imports"],
|
|
||||||
"env": {
|
|
||||||
"es6": true,
|
|
||||||
"browser": true
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"semi": "off",
|
|
||||||
"quotes": "off",
|
|
||||||
"prettier/prettier": "warn",
|
|
||||||
"no-unused-vars": "off",
|
|
||||||
"unused-imports/no-unused-imports": "error",
|
|
||||||
"unused-imports/no-unused-vars": [
|
|
||||||
"warn",
|
|
||||||
{
|
|
||||||
"vars": "all",
|
|
||||||
"varsIgnorePattern": "^regeneratorRuntime|^_",
|
|
||||||
"args": "after-used",
|
|
||||||
"argsIgnorePattern": "^_"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["**/*.test.js", "**/*.test.jsx"],
|
|
||||||
"env": {
|
|
||||||
"jest": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
77
jsx/eslint.config.mjs
Normal file
77
jsx/eslint.config.mjs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
|
import prettier from "eslint-plugin-prettier";
|
||||||
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
import globals from "globals";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
recommendedConfig: js.configs.recommended,
|
||||||
|
allConfig: js.configs.all,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
extends: compat.extends("plugin:react/recommended"),
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
react,
|
||||||
|
prettier,
|
||||||
|
"unused-imports": unusedImports,
|
||||||
|
},
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
|
||||||
|
ecmaVersion: 2018,
|
||||||
|
sourceType: "module",
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
semi: "off",
|
||||||
|
quotes: "off",
|
||||||
|
"prettier/prettier": "warn",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"unused-imports/no-unused-imports": "error",
|
||||||
|
|
||||||
|
"unused-imports/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
vars: "all",
|
||||||
|
varsIgnorePattern: "^regeneratorRuntime|^_",
|
||||||
|
args: "after-used",
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.test.js", "**/*.test.jsx"],
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
4075
jsx/package-lock.json
generated
4075
jsx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,9 @@
|
|||||||
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
|
||||||
"\\.(css|less)$": "identity-obj-proxy"
|
"\\.(css|less)$": "identity-obj-proxy"
|
||||||
},
|
},
|
||||||
|
"setupFiles": [
|
||||||
|
"./testing/setup.jest.js"
|
||||||
|
],
|
||||||
"testEnvironment": "jsdom"
|
"testEnvironment": "jsdom"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -36,40 +39,41 @@
|
|||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^18.3.1",
|
"react": "^19.1.0",
|
||||||
"react-bootstrap": "^2.10.4",
|
"react-bootstrap": "^2.10.9",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^19.1.0",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-multi-select-component": "^4.3.4",
|
"react-redux": "^9.2.0",
|
||||||
"react-redux": "^9.1.2",
|
"react-router": "^7.4.1",
|
||||||
"react-router-dom": "^6.26.1",
|
|
||||||
"recompose": "npm:react-recompose@^0.33.0",
|
|
||||||
"redux": "^5.0.1",
|
"redux": "^5.0.1",
|
||||||
"regenerator-runtime": "^0.14.1"
|
"regenerator-runtime": "^0.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.21.4",
|
"@babel/core": "^7.26.10",
|
||||||
"@babel/preset-env": "^7.25.4",
|
"@babel/preset-env": "^7.26.9",
|
||||||
"@babel/preset-react": "^7.24.7",
|
"@babel/preset-react": "^7.26.3",
|
||||||
"@testing-library/jest-dom": "^6.5.0",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@eslint/js": "^9.23.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@webpack-cli/serve": "^2.0.1",
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@webpack-cli/serve": "^3.0.1",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"babel-loader": "^9.1.3",
|
"babel-loader": "^10.0.0",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.23.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^5.2.5",
|
||||||
"eslint-plugin-react": "^7.35.0",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-unused-imports": "^4.1.3",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.5.3",
|
||||||
"style-loader": "^4.0.0",
|
"style-loader": "^4.0.0",
|
||||||
"webpack": "^5.94.0",
|
"webpack": "^5.98.0",
|
||||||
"webpack-cli": "^5.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-dev-server": "^5.0.4"
|
"webpack-dev-server": "^5.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,10 +2,10 @@ import React from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { compose } from "recompose";
|
import { compose } from "./util/_recompose";
|
||||||
import { initialState, reducers } from "./Store";
|
import { initialState, reducers } from "./Store";
|
||||||
import withAPI from "./util/withAPI";
|
import withAPI from "./util/withAPI";
|
||||||
import { HashRouter, Routes, Route } from "react-router-dom";
|
import { HashRouter, Routes, Route } from "react-router";
|
||||||
|
|
||||||
import ServerDashboard from "./components/ServerDashboard/ServerDashboard";
|
import ServerDashboard from "./components/ServerDashboard/ServerDashboard";
|
||||||
import Groups from "./components/Groups/Groups";
|
import Groups from "./components/Groups/Groups";
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { Button, Col } from "react-bootstrap";
|
import { Button, Col } from "react-bootstrap";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import ErrorAlert from "../../util/error";
|
import ErrorAlert from "../../util/error";
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { Button, Card } from "react-bootstrap";
|
import { Button, Card } from "react-bootstrap";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { MainContainer } from "../../util/layout";
|
import { MainContainer } from "../../util/layout";
|
||||||
|
@@ -4,7 +4,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
import CreateGroup from "./CreateGroup";
|
import CreateGroup from "./CreateGroup";
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
import { Link, useLocation, useNavigate } from "react-router";
|
||||||
import { Button, Card } from "react-bootstrap";
|
import { Button, Card } from "react-bootstrap";
|
||||||
import { MainContainer } from "../../util/layout";
|
import { MainContainer } from "../../util/layout";
|
||||||
|
|
||||||
|
@@ -3,7 +3,7 @@ import "@testing-library/jest-dom";
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ jest.mock("react-redux", () => ({
|
|||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("react-router-dom", () => ({
|
jest.mock("react-router", () => ({
|
||||||
...jest.requireActual("react-router-dom"),
|
...jest.requireActual("react-router"),
|
||||||
useLocation: jest.fn().mockImplementation(() => {
|
useLocation: jest.fn().mockImplementation(() => {
|
||||||
return { state: { username: "foo", has_admin: false } };
|
return { state: { username: "foo", has_admin: false } };
|
||||||
}),
|
}),
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
import { Link, useNavigate, useLocation } from "react-router";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Button, Card } from "react-bootstrap";
|
import { Button, Card } from "react-bootstrap";
|
||||||
import GroupSelect from "../GroupSelect/GroupSelect";
|
import GroupSelect from "../GroupSelect/GroupSelect";
|
||||||
@@ -42,6 +42,10 @@ const GroupEdit = (props) => {
|
|||||||
}
|
}
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(group_data.users);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { group_data } = location.state || {};
|
const { group_data } = location.state || {};
|
||||||
if (!group_data) return <div></div>;
|
if (!group_data) return <div></div>;
|
||||||
const [propobject, setProp] = useState(group_data.properties);
|
const [propobject, setProp] = useState(group_data.properties);
|
||||||
@@ -175,6 +179,7 @@ GroupEdit.propTypes = {
|
|||||||
removeFromGroup: PropTypes.func,
|
removeFromGroup: PropTypes.func,
|
||||||
deleteGroup: PropTypes.func,
|
deleteGroup: PropTypes.func,
|
||||||
updateGroups: PropTypes.func,
|
updateGroups: PropTypes.func,
|
||||||
|
updateProp: PropTypes.func,
|
||||||
validateUser: PropTypes.func,
|
validateUser: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -4,7 +4,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ jest.mock("react-redux", () => ({
|
|||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("react-router-dom", () => ({
|
jest.mock("react-router", () => ({
|
||||||
...jest.requireActual("react-router-dom"),
|
...jest.requireActual("react-router"),
|
||||||
useLocation: jest.fn().mockImplementation(() => {
|
useLocation: jest.fn().mockImplementation(() => {
|
||||||
return { state: { group_data: { users: ["foo"], name: "group" } } };
|
return { state: { group_data: { users: ["foo"], name: "group" } } };
|
||||||
}),
|
}),
|
||||||
|
@@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import { Button, Card } from "react-bootstrap";
|
import { Button, Card } from "react-bootstrap";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { usePaginationParams } from "../../util/paginationParams";
|
import { usePaginationParams } from "../../util/paginationParams";
|
||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
import { MainContainer } from "../../util/layout";
|
import { MainContainer } from "../../util/layout";
|
||||||
@@ -14,14 +14,13 @@ const Groups = (props) => {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { offset, handleLimit, limit, setPagination } = usePaginationParams();
|
const { offset, setOffset, handleLimit, limit } = usePaginationParams();
|
||||||
|
|
||||||
const total = groups_page ? groups_page.total : undefined;
|
const total = groups_page ? groups_page.total : undefined;
|
||||||
|
|
||||||
const { updateGroups } = props;
|
const { updateGroups } = props;
|
||||||
|
|
||||||
const dispatchPageUpdate = (data, page) => {
|
const dispatchPageUpdate = (data, page) => {
|
||||||
setPagination(page);
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "GROUPS_PAGE",
|
type: "GROUPS_PAGE",
|
||||||
value: {
|
value: {
|
||||||
@@ -32,21 +31,39 @@ const Groups = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// single callback to reload the page
|
// single callback to reload the page
|
||||||
// uses current state, or params can be specified if state
|
// uses current state
|
||||||
// should be updated _after_ load, e.g. offset
|
|
||||||
const loadPageData = (params) => {
|
const loadPageData = (params) => {
|
||||||
params = params || {};
|
const abortHandle = { cancelled: false };
|
||||||
return updateGroups(
|
(async () => {
|
||||||
params.offset === undefined ? offset : params.offset,
|
try {
|
||||||
params.limit === undefined ? limit : params.limit,
|
const data = await updateGroups(offset, limit);
|
||||||
)
|
// cancelled (e.g. param changed while waiting for response)
|
||||||
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
if (abortHandle.cancelled) return;
|
||||||
.catch((err) => setErrorAlert("Failed to update group list."));
|
if (
|
||||||
|
data._pagination.offset &&
|
||||||
|
data._pagination.total <= data._pagination.offset
|
||||||
|
) {
|
||||||
|
// reset offset if we're out of bounds,
|
||||||
|
// then load again
|
||||||
|
setOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// actually update page data
|
||||||
|
dispatchPageUpdate(data.items, data._pagination);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update group list.", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// returns cancellation callback
|
||||||
|
return () => {
|
||||||
|
// cancel stale load
|
||||||
|
abortHandle.cancelled = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPageData();
|
return loadPageData();
|
||||||
}, [limit]);
|
}, [limit, offset]);
|
||||||
|
|
||||||
if (!groups_data || !groups_page) {
|
if (!groups_data || !groups_page) {
|
||||||
return <div data-testid="no-show"></div>;
|
return <div data-testid="no-show"></div>;
|
||||||
@@ -78,13 +95,15 @@ const Groups = (props) => {
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
offset={offset}
|
offset={groups_page.offset}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
visible={groups_data.length}
|
visible={groups_data.length}
|
||||||
total={total}
|
total={total}
|
||||||
next={() => loadPageData({ offset: offset + limit })}
|
next={() => setOffset(groups_page.offset + limit)}
|
||||||
prev={() =>
|
prev={() =>
|
||||||
loadPageData({ offset: limit > offset ? 0 : offset - limit })
|
setOffset(
|
||||||
|
limit > groups_page.offset ? 0 : groups_page.offset - limit,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
handleLimit={handleLimit}
|
handleLimit={handleLimit}
|
||||||
/>
|
/>
|
||||||
|
@@ -3,7 +3,7 @@ import "@testing-library/jest-dom";
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter, useSearchParams } from "react-router-dom";
|
import { HashRouter, useSearchParams } from "react-router";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime";
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ jest.mock("react-redux", () => ({
|
|||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("react-router-dom", () => ({
|
jest.mock("react-router", () => ({
|
||||||
...jest.requireActual("react-router-dom"),
|
...jest.requireActual("react-router"),
|
||||||
useSearchParams: jest.fn(),
|
useSearchParams: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -57,13 +57,34 @@ var mockAppState = () =>
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var mockUpdateGroups = () => {
|
||||||
|
const state = mockAppState();
|
||||||
|
return jest.fn().mockImplementation((offset, limit) =>
|
||||||
|
Promise.resolve({
|
||||||
|
items: state.groups_data.slice(0, limit),
|
||||||
|
_pagination: {
|
||||||
|
offset: offset,
|
||||||
|
limit: limit || 2,
|
||||||
|
total: state.groups_page.total,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let searchParams = new URLSearchParams();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
useSearchParams.mockImplementation(() => {
|
searchParams = new URLSearchParams();
|
||||||
return [new URLSearchParams(), jest.fn()];
|
searchParams.set("limit", "2");
|
||||||
});
|
useSearchParams.mockImplementation(() => [
|
||||||
|
searchParams,
|
||||||
|
(callback) => {
|
||||||
|
searchParams = callback(searchParams);
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -74,7 +95,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Renders", async () => {
|
test("Renders", async () => {
|
||||||
let callbackSpy = mockAsync();
|
let callbackSpy = mockUpdateGroups();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(groupsJsx(callbackSpy));
|
render(groupsJsx(callbackSpy));
|
||||||
@@ -84,7 +105,7 @@ test("Renders", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Renders groups_data prop into links", async () => {
|
test("Renders groups_data prop into links", async () => {
|
||||||
let callbackSpy = mockAsync();
|
let callbackSpy = mockUpdateGroups();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(groupsJsx(callbackSpy));
|
render(groupsJsx(callbackSpy));
|
||||||
@@ -102,7 +123,7 @@ test("Renders nothing if required data is not available", async () => {
|
|||||||
return callback({});
|
return callback({});
|
||||||
});
|
});
|
||||||
|
|
||||||
let callbackSpy = mockAsync();
|
let callbackSpy = mockUpdateGroups();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(groupsJsx(callbackSpy));
|
render(groupsJsx(callbackSpy));
|
||||||
@@ -113,20 +134,9 @@ test("Renders nothing if required data is not available", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Interacting with PaginationFooter causes page refresh", async () => {
|
test("Interacting with PaginationFooter causes page refresh", async () => {
|
||||||
let updateGroupsSpy = mockAsync();
|
let updateGroupsSpy = mockUpdateGroups();
|
||||||
let setSearchParamsSpy = mockAsync();
|
|
||||||
let searchParams = new URLSearchParams({ limit: "2" });
|
|
||||||
useSearchParams.mockImplementation(() => [
|
|
||||||
searchParams,
|
|
||||||
(callback) => {
|
|
||||||
searchParams = callback(searchParams);
|
|
||||||
setSearchParamsSpy(searchParams.toString());
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
let _, setSearchParams;
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
render(groupsJsx(updateGroupsSpy));
|
render(groupsJsx(updateGroupsSpy));
|
||||||
[_, setSearchParams] = useSearchParams();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(updateGroupsSpy).toBeCalledWith(0, 2);
|
expect(updateGroupsSpy).toBeCalledWith(0, 2);
|
||||||
@@ -135,12 +145,13 @@ test("Interacting with PaginationFooter causes page refresh", async () => {
|
|||||||
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
mockReducers.mock.results[mockReducers.mock.results.length - 1].value;
|
||||||
expect(lastState.groups_page.offset).toEqual(0);
|
expect(lastState.groups_page.offset).toEqual(0);
|
||||||
expect(lastState.groups_page.limit).toEqual(2);
|
expect(lastState.groups_page.limit).toEqual(2);
|
||||||
|
expect(searchParams.get("offset")).toEqual(null);
|
||||||
|
|
||||||
let next = screen.getByTestId("paginate-next");
|
let next = screen.getByTestId("paginate-next");
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await fireEvent.click(next);
|
await fireEvent.click(next);
|
||||||
});
|
});
|
||||||
expect(updateGroupsSpy).toBeCalledWith(2, 2);
|
expect(searchParams.get("offset")).toEqual("2");
|
||||||
// mocked updateGroups means callback after load doesn't fire
|
// FIXME: useSelector mocks prevent updateGroups from being called
|
||||||
// expect(setSearchParamsSpy).toBeCalledWith("limit=2&offset=2");
|
// expect(updateGroupsSpy).toBeCalledWith(2, 2);
|
||||||
});
|
});
|
||||||
|
@@ -3,6 +3,7 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import ErrorAlert from "../../util/error";
|
import ErrorAlert from "../../util/error";
|
||||||
|
import { User, Server } from "../../util/jhapiUtil";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
} from "react-bootstrap";
|
} from "react-bootstrap";
|
||||||
import ReactObjectTableViewer from "../ReactObjectTableViewer/ReactObjectTableViewer";
|
import ReactObjectTableViewer from "../ReactObjectTableViewer/ReactObjectTableViewer";
|
||||||
|
|
||||||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
import { Link, useSearchParams, useNavigate } from "react-router";
|
||||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||||
|
|
||||||
import "./server-dashboard.css";
|
import "./server-dashboard.css";
|
||||||
@@ -41,7 +42,7 @@ const ServerDashboard = (props) => {
|
|||||||
let user_data = useSelector((state) => state.user_data);
|
let user_data = useSelector((state) => state.user_data);
|
||||||
const user_page = useSelector((state) => state.user_page);
|
const user_page = useSelector((state) => state.user_page);
|
||||||
|
|
||||||
const { offset, setLimit, handleLimit, limit, setPagination } =
|
const { offset, setOffset, setLimit, handleLimit, limit } =
|
||||||
usePaginationParams();
|
usePaginationParams();
|
||||||
|
|
||||||
const name_filter = searchParams.get("name_filter") || "";
|
const name_filter = searchParams.get("name_filter") || "";
|
||||||
@@ -64,12 +65,6 @@ const ServerDashboard = (props) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const dispatchPageUpdate = (data, page) => {
|
const dispatchPageUpdate = (data, page) => {
|
||||||
// trigger page update in state
|
|
||||||
// in response to fetching updated user list
|
|
||||||
// data is list of user records
|
|
||||||
// page is _pagination part of response
|
|
||||||
// persist page info in url query
|
|
||||||
setPagination(page);
|
|
||||||
// persist user data, triggers rerender
|
// persist user data, triggers rerender
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "USER_PAGE",
|
type: "USER_PAGE",
|
||||||
@@ -127,35 +122,47 @@ const ServerDashboard = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// the callback to update the displayed user list
|
|
||||||
const updateUsersWithParams = (params) => {
|
|
||||||
if (params) {
|
|
||||||
if (params.offset !== undefined && params.offset < 0) {
|
|
||||||
params.offset = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return updateUsers({
|
|
||||||
offset: offset,
|
|
||||||
limit,
|
|
||||||
name_filter,
|
|
||||||
sort,
|
|
||||||
state: state_filter,
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// single callback to reload the page
|
// single callback to reload the page
|
||||||
// uses current state, or params can be specified if state
|
// uses current state
|
||||||
// should be updated _after_ load, e.g. offset
|
const loadPageData = () => {
|
||||||
const loadPageData = (params) => {
|
const abortHandle = { cancelled: false };
|
||||||
return updateUsersWithParams(params)
|
(async () => {
|
||||||
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
try {
|
||||||
.catch((err) => setErrorAlert("Failed to update user list."));
|
const data = await updateUsers({
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
name_filter,
|
||||||
|
sort,
|
||||||
|
state: state_filter,
|
||||||
|
});
|
||||||
|
// cancelled (e.g. param changed while waiting for response)
|
||||||
|
if (abortHandle.cancelled) return;
|
||||||
|
if (
|
||||||
|
data._pagination.offset &&
|
||||||
|
data._pagination.total <= data._pagination.offset
|
||||||
|
) {
|
||||||
|
// reset offset if we're out of bounds,
|
||||||
|
// then load again
|
||||||
|
setOffset(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// actually update page data
|
||||||
|
dispatchPageUpdate(data.items, data._pagination);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update user list.", e);
|
||||||
|
setErrorAlert("Failed to update user list.");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// returns cancellation callback
|
||||||
|
return () => {
|
||||||
|
// cancel stale load
|
||||||
|
abortHandle.cancelled = true;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPageData();
|
return loadPageData();
|
||||||
}, [limit, name_filter, sort, state_filter]);
|
}, [limit, name_filter, offset, sort, state_filter]);
|
||||||
|
|
||||||
if (!user_data || !user_page) {
|
if (!user_data || !user_page) {
|
||||||
return <div data-testid="no-show"></div>;
|
return <div data-testid="no-show"></div>;
|
||||||
@@ -203,6 +210,15 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ServerButton.propTypes = {
|
||||||
|
server: Server,
|
||||||
|
user: User,
|
||||||
|
action: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
variant: PropTypes.string,
|
||||||
|
extraClass: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
const StopServerButton = ({ server, user }) => {
|
const StopServerButton = ({ server, user }) => {
|
||||||
if (!server.ready) {
|
if (!server.ready) {
|
||||||
return null;
|
return null;
|
||||||
@@ -216,6 +232,12 @@ const ServerDashboard = (props) => {
|
|||||||
extraClass: "stop-button",
|
extraClass: "stop-button",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
StopServerButton.propTypes = {
|
||||||
|
server: Server,
|
||||||
|
user: User,
|
||||||
|
};
|
||||||
|
|
||||||
const DeleteServerButton = ({ server, user }) => {
|
const DeleteServerButton = ({ server, user }) => {
|
||||||
if (!server.name) {
|
if (!server.name) {
|
||||||
// It's not possible to delete unnamed servers
|
// It's not possible to delete unnamed servers
|
||||||
@@ -234,6 +256,11 @@ const ServerDashboard = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
DeleteServerButton.propTypes = {
|
||||||
|
server: Server,
|
||||||
|
user: User,
|
||||||
|
};
|
||||||
|
|
||||||
const StartServerButton = ({ server, user }) => {
|
const StartServerButton = ({ server, user }) => {
|
||||||
if (server.ready) {
|
if (server.ready) {
|
||||||
return null;
|
return null;
|
||||||
@@ -248,6 +275,11 @@ const ServerDashboard = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
StartServerButton.propTypes = {
|
||||||
|
server: Server,
|
||||||
|
user: User,
|
||||||
|
};
|
||||||
|
|
||||||
const SpawnPageButton = ({ server, user }) => {
|
const SpawnPageButton = ({ server, user }) => {
|
||||||
if (server.ready) {
|
if (server.ready) {
|
||||||
return null;
|
return null;
|
||||||
@@ -265,6 +297,11 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SpawnPageButton.propTypes = {
|
||||||
|
server: Server,
|
||||||
|
user: User,
|
||||||
|
};
|
||||||
|
|
||||||
const AccessServerButton = ({ server }) => {
|
const AccessServerButton = ({ server }) => {
|
||||||
if (!server.ready) {
|
if (!server.ready) {
|
||||||
return null;
|
return null;
|
||||||
@@ -277,6 +314,9 @@ const ServerDashboard = (props) => {
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
AccessServerButton.propTypes = {
|
||||||
|
server: Server,
|
||||||
|
};
|
||||||
|
|
||||||
const EditUserButton = ({ user }) => {
|
const EditUserButton = ({ user }) => {
|
||||||
return (
|
return (
|
||||||
@@ -297,10 +337,17 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ServerRowTable = ({ data }) => {
|
EditUserButton.propTypes = {
|
||||||
|
user: User,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerRowTable = ({ data, exclude }) => {
|
||||||
const sortedData = Object.keys(data)
|
const sortedData = Object.keys(data)
|
||||||
.sort()
|
.sort()
|
||||||
.reduce(function (result, key) {
|
.reduce(function (result, key) {
|
||||||
|
if (exclude && exclude.includes(key)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
let value = data[key];
|
let value = data[key];
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "last_activity":
|
case "last_activity":
|
||||||
@@ -340,88 +387,101 @@ const ServerDashboard = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverRow = (user, server) => {
|
ServerRowTable.propTypes = {
|
||||||
const { servers, ...userNoServers } = user;
|
data: Server,
|
||||||
|
exclude: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerRow = ({ user, server }) => {
|
||||||
const serverNameDash = server.name ? `-${server.name}` : "";
|
const serverNameDash = server.name ? `-${server.name}` : "";
|
||||||
const userServerName = user.name + serverNameDash;
|
const userServerName = user.name + serverNameDash;
|
||||||
const open = collapseStates[userServerName] || false;
|
const open = collapseStates[userServerName] || false;
|
||||||
return [
|
return (
|
||||||
<tr
|
<Fragment key={`${userServerName}-row`}>
|
||||||
key={`${userServerName}-row`}
|
<tr
|
||||||
data-testid={`user-row-${userServerName}`}
|
key={`${userServerName}-row`}
|
||||||
className="user-row"
|
data-testid={`user-row-${userServerName}`}
|
||||||
>
|
className="user-row"
|
||||||
<td data-testid="user-row-name">
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
setCollapseStates({
|
|
||||||
...collapseStates,
|
|
||||||
[userServerName]: !open,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
aria-controls={`${userServerName}-collapse`}
|
|
||||||
aria-expanded={open}
|
|
||||||
data-testid={`${userServerName}-collapse-button`}
|
|
||||||
variant={open ? "secondary" : "primary"}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<span className="fa fa-caret-down"></span>
|
|
||||||
</Button>{" "}
|
|
||||||
</span>
|
|
||||||
<span data-testid={`user-name-div-${userServerName}`}>
|
|
||||||
{user.name}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
|
||||||
|
|
||||||
<td data-testid="user-row-server">
|
|
||||||
<p className="text-secondary">{server.name}</p>
|
|
||||||
</td>
|
|
||||||
<td data-testid="user-row-last-activity">
|
|
||||||
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
|
||||||
</td>
|
|
||||||
<td data-testid="user-row-server-activity" className="actions">
|
|
||||||
<StartServerButton server={server} user={user} />
|
|
||||||
<StopServerButton server={server} user={user} />
|
|
||||||
<DeleteServerButton server={server} user={user} />
|
|
||||||
<AccessServerButton server={server} />
|
|
||||||
<SpawnPageButton server={server} user={user} />
|
|
||||||
<EditUserButton user={user} />
|
|
||||||
</td>
|
|
||||||
</tr>,
|
|
||||||
<tr key={`${userServerName}-detail`}>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
data-testid={`${userServerName}-td`}
|
|
||||||
>
|
>
|
||||||
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
|
<td data-testid="user-row-name">
|
||||||
<CardGroup
|
<span>
|
||||||
id={`${userServerName}-card-group`}
|
<Button
|
||||||
style={{ width: "100%", margin: "0 auto", float: "none" }}
|
onClick={() =>
|
||||||
>
|
setCollapseStates({
|
||||||
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
...collapseStates,
|
||||||
<Card.Title>User</Card.Title>
|
[userServerName]: !open,
|
||||||
<ServerRowTable data={userNoServers} />
|
})
|
||||||
</Card>
|
}
|
||||||
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
aria-controls={`${userServerName}-collapse`}
|
||||||
<Card.Title>Server</Card.Title>
|
aria-expanded={open}
|
||||||
<ServerRowTable data={server} />
|
data-testid={`${userServerName}-collapse-button`}
|
||||||
</Card>
|
variant={open ? "secondary" : "primary"}
|
||||||
</CardGroup>
|
size="sm"
|
||||||
</Collapse>
|
>
|
||||||
</td>
|
<span className="fa fa-caret-down"></span>
|
||||||
</tr>,
|
</Button>{" "}
|
||||||
];
|
</span>
|
||||||
|
<span data-testid={`user-name-div-${userServerName}`}>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
||||||
|
|
||||||
|
<td data-testid="user-row-server">
|
||||||
|
<p className="text-secondary">{server.name}</p>
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-last-activity">
|
||||||
|
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-server-activity" className="actions">
|
||||||
|
<StartServerButton server={server} user={user} />
|
||||||
|
<StopServerButton server={server} user={user} />
|
||||||
|
<DeleteServerButton server={server} user={user} />
|
||||||
|
<AccessServerButton server={server} />
|
||||||
|
<SpawnPageButton server={server} user={user} />
|
||||||
|
<EditUserButton user={user} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr key={`${userServerName}-detail`}>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
data-testid={`${userServerName}-td`}
|
||||||
|
>
|
||||||
|
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
|
||||||
|
<CardGroup
|
||||||
|
id={`${userServerName}-card-group`}
|
||||||
|
style={{ width: "100%", margin: "0 auto", float: "none" }}
|
||||||
|
>
|
||||||
|
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||||
|
<Card.Title>User</Card.Title>
|
||||||
|
<ServerRowTable data={user} exclude={["server", "servers"]} />
|
||||||
|
</Card>
|
||||||
|
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||||
|
<Card.Title>Server</Card.Title>
|
||||||
|
<ServerRowTable data={server} />
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
|
</Collapse>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
let servers = user_data.flatMap((user) => {
|
ServerRow.propTypes = {
|
||||||
let userServers = Object.values({
|
user: User,
|
||||||
|
server: Server,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverRows = user_data.flatMap((user) => {
|
||||||
|
const userServers = Object.values({
|
||||||
|
// eslint-disable-next-line react/prop-types
|
||||||
"": user.server || {},
|
"": user.server || {},
|
||||||
|
// eslint-disable-next-line react/prop-types
|
||||||
...(user.servers || {}),
|
...(user.servers || {}),
|
||||||
});
|
});
|
||||||
return userServers.map((server) => [user, server]);
|
return userServers.map((server) => ServerRow({ user, server }));
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -508,7 +568,7 @@ const ServerDashboard = (props) => {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
className="start-all"
|
className="start-all"
|
||||||
data-testid="start-all"
|
data-testid="start-all"
|
||||||
title="start all servers on the current page"
|
title="Start all default servers on the current page"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Promise.all(startAll(user_data.map((e) => e.name)))
|
Promise.all(startAll(user_data.map((e) => e.name)))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -539,11 +599,12 @@ const ServerDashboard = (props) => {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
className="stop-all"
|
className="stop-all"
|
||||||
data-testid="stop-all"
|
data-testid="stop-all"
|
||||||
title="stop all servers on the current page"
|
title="Stop all servers including named servers on the current page"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Promise.all(stopAll(user_data.map((e) => e.name)))
|
Promise.all(stopAll(user_data.map((e) => e.name)))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
let failedServers = res.filter((e) => !e.ok);
|
// Array of arrays of servers for each user
|
||||||
|
let failedServers = res.flat().filter((e) => !e.ok);
|
||||||
if (failedServers.length > 0) {
|
if (failedServers.length > 0) {
|
||||||
setErrorAlert(
|
setErrorAlert(
|
||||||
`Failed to stop ${failedServers.length} ${
|
`Failed to stop ${failedServers.length} ${
|
||||||
@@ -576,20 +637,20 @@ const ServerDashboard = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
{serverRows}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
offset={offset}
|
// use user_page for display, which is what's on the page
|
||||||
|
// setOffset immediately updates url state and _requests_ an update
|
||||||
|
// but takes finite time before user_page is updated
|
||||||
|
offset={user_page.offset}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
visible={user_data.length}
|
visible={user_data.length}
|
||||||
total={total}
|
total={total}
|
||||||
// don't trigger via setOffset state change,
|
next={() => setOffset(user_page.offset + limit)}
|
||||||
// which can cause infinite cycles.
|
|
||||||
// offset state will be set upon reply via setPagination
|
|
||||||
next={() => loadPageData({ offset: offset + limit })}
|
|
||||||
prev={() =>
|
prev={() =>
|
||||||
loadPageData({ offset: limit > offset ? 0 : offset - limit })
|
setOffset(limit > user_page.offset ? 0 : user_page.offset - limit)
|
||||||
}
|
}
|
||||||
handleLimit={handleLimit}
|
handleLimit={handleLimit}
|
||||||
/>
|
/>
|
||||||
@@ -600,7 +661,7 @@ const ServerDashboard = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ServerDashboard.propTypes = {
|
ServerDashboard.propTypes = {
|
||||||
user_data: PropTypes.array,
|
user_data: PropTypes.arrayOf(User),
|
||||||
updateUsers: PropTypes.func,
|
updateUsers: PropTypes.func,
|
||||||
shutdownHub: PropTypes.func,
|
shutdownHub: PropTypes.func,
|
||||||
startServer: PropTypes.func,
|
startServer: PropTypes.func,
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { withProps } from "recompose";
|
import { withProps } from "../../util/_recompose";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import {
|
import {
|
||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
getByText,
|
getByText,
|
||||||
getAllByRole,
|
getAllByRole,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { HashRouter, Routes, Route, useSearchParams } from "react-router-dom";
|
import { HashRouter, Routes, Route, useSearchParams } from "react-router";
|
||||||
// import { CompatRouter, } from "react-router-dom-v5-compat";
|
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
@@ -23,8 +22,8 @@ jest.mock("react-redux", () => ({
|
|||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
jest.mock("react-router-dom", () => ({
|
jest.mock("react-router", () => ({
|
||||||
...jest.requireActual("react-router-dom"),
|
...jest.requireActual("react-router"),
|
||||||
useSearchParams: jest.fn(),
|
useSearchParams: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -618,11 +617,12 @@ test("Interacting with PaginationFooter requests page update", async () => {
|
|||||||
fireEvent.click(next);
|
fireEvent.click(next);
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
});
|
});
|
||||||
|
expect(searchParams.get("offset")).toEqual("2");
|
||||||
expect(mockUpdateUsers).toBeCalledWith({
|
// FIXME: useSelector mocks prevent updateUsers from being called
|
||||||
...defaultUpdateUsersParams,
|
// expect(mockUpdateUsers).toBeCalledWith({
|
||||||
offset: 2,
|
// ...defaultUpdateUsersParams,
|
||||||
});
|
// offset: 2,
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Server delete button exists for named servers", async () => {
|
test("Server delete button exists for named servers", async () => {
|
||||||
|
35
jsx/src/util/_recompose.js
Normal file
35
jsx/src/util/_recompose.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// extracted tiny subset we use from react-recompose
|
||||||
|
// we probably don't need these at all,
|
||||||
|
// but vendor before refactoring
|
||||||
|
|
||||||
|
// https://github.com/acdlite/recompose
|
||||||
|
// License: MIT
|
||||||
|
// Copyright (c) 2015-2018 Andrew Clark
|
||||||
|
|
||||||
|
import { createElement } from "react";
|
||||||
|
|
||||||
|
function createFactory(type) {
|
||||||
|
return createElement.bind(null, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const compose = (...funcs) =>
|
||||||
|
funcs.reduce(
|
||||||
|
(a, b) =>
|
||||||
|
(...args) =>
|
||||||
|
a(b(...args)),
|
||||||
|
(arg) => arg,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapProps = (propsMapper) => (BaseComponent) => {
|
||||||
|
const factory = createFactory(BaseComponent);
|
||||||
|
const MapProps = (props) => factory(propsMapper(props));
|
||||||
|
return MapProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withProps = (input) => {
|
||||||
|
const hoc = mapProps((props) => ({
|
||||||
|
...props,
|
||||||
|
...(typeof input === "function" ? input(props) : input),
|
||||||
|
}));
|
||||||
|
return hoc;
|
||||||
|
};
|
@@ -1,3 +1,5 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
const jhdata = window.jhdata || {};
|
const jhdata = window.jhdata || {};
|
||||||
const base_url = jhdata.base_url || "/";
|
const base_url = jhdata.base_url || "/";
|
||||||
const xsrfToken = jhdata.xsrf_token;
|
const xsrfToken = jhdata.xsrf_token;
|
||||||
@@ -17,3 +19,21 @@ export const jhapiRequest = (endpoint, method, data) => {
|
|||||||
body: data ? JSON.stringify(data) : null,
|
body: data ? JSON.stringify(data) : null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// need to declare the subset of fields we use, at least
|
||||||
|
export const Server = PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
url: PropTypes.string,
|
||||||
|
active: PropTypes.boolean,
|
||||||
|
pending: PropTypes.string,
|
||||||
|
last_activity: PropTypes.string,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const User = PropTypes.shape({
|
||||||
|
admin: PropTypes.boolean,
|
||||||
|
name: PropTypes.string,
|
||||||
|
last_activity: PropTypes.string,
|
||||||
|
url: PropTypes.string,
|
||||||
|
server: Server,
|
||||||
|
servers: PropTypes.objectOf(Server),
|
||||||
|
});
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { withProps } from "recompose";
|
|
||||||
import { Col, Row, Container } from "react-bootstrap";
|
import { Col, Row, Container } from "react-bootstrap";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
import { withProps } from "./_recompose";
|
||||||
import ErrorAlert from "./error";
|
import ErrorAlert from "./error";
|
||||||
|
|
||||||
export const MainCol = (props) => {
|
export const MainCol = (props) => {
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
export const usePaginationParams = () => {
|
export const usePaginationParams = () => {
|
||||||
// get offset, limit, name filter from URL
|
// get offset, limit, name filter from URL
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { withProps } from "recompose";
|
import { withProps } from "./_recompose";
|
||||||
import { jhapiRequest } from "./jhapiUtil";
|
import { jhapiRequest } from "./jhapiUtil";
|
||||||
|
|
||||||
const withAPI = withProps(() => ({
|
const withAPI = withProps(() => ({
|
||||||
@@ -30,7 +30,17 @@ const withAPI = withProps(() => ({
|
|||||||
startAll: (names) =>
|
startAll: (names) =>
|
||||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
||||||
stopAll: (names) =>
|
stopAll: (names) =>
|
||||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "DELETE")),
|
names.map((name) =>
|
||||||
|
jhapiRequest("/users/" + name, "GET")
|
||||||
|
.then((data) => data.json())
|
||||||
|
.then((data) =>
|
||||||
|
Promise.all(
|
||||||
|
Object.keys(data.servers).map((server) =>
|
||||||
|
jhapiRequest("/users/" + name + "/servers/" + server, "DELETE"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
addToGroup: (users, groupname) =>
|
addToGroup: (users, groupname) =>
|
||||||
jhapiRequest("/groups/" + groupname + "/users", "POST", { users }),
|
jhapiRequest("/groups/" + groupname + "/users", "POST", { users }),
|
||||||
updateProp: (propobject, groupname) =>
|
updateProp: (propobject, groupname) =>
|
||||||
|
5
jsx/testing/setup.jest.js
Normal file
5
jsx/testing/setup.jest.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Workaround "ReferenceError: TextEncoder is not defined"
|
||||||
|
// https://stackoverflow.com/questions/68468203/why-am-i-getting-textencoder-is-not-defined-in-jest/68468204#68468204
|
||||||
|
// https://jestjs.io/docs/configuration#setupfiles-array
|
||||||
|
import { TextEncoder, TextDecoder } from "util";
|
||||||
|
Object.assign(global, { TextDecoder, TextEncoder });
|
@@ -3,7 +3,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
# version_info updated by running `tbump`
|
# version_info updated by running `tbump`
|
||||||
version_info = (5, 2, 0, "", "")
|
version_info = (5, 3, 0, "rc0", "")
|
||||||
|
|
||||||
# pep 440 version: no dot before beta/rc, but before .dev
|
# pep 440 version: no dot before beta/rc, but before .dev
|
||||||
# 0.1.0rc1
|
# 0.1.0rc1
|
||||||
|
@@ -10,7 +10,9 @@ in both Hub and single-user code
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import os
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
|
from ipaddress import ip_address, ip_network
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
@@ -104,9 +106,12 @@ def _get_xsrf_token_cookie(handler):
|
|||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
def _set_xsrf_cookie(handler, xsrf_id, *, cookie_path="", authenticated=None):
|
def _set_xsrf_cookie(
|
||||||
|
handler, xsrf_id, *, cookie_path="", authenticated=None, xsrf_token=None
|
||||||
|
):
|
||||||
"""Set xsrf token cookie"""
|
"""Set xsrf token cookie"""
|
||||||
xsrf_token = _create_signed_value_urlsafe(handler, "_xsrf", xsrf_id)
|
if xsrf_token is None:
|
||||||
|
xsrf_token = _create_signed_value_urlsafe(handler, "_xsrf", xsrf_id)
|
||||||
xsrf_cookie_kwargs = {}
|
xsrf_cookie_kwargs = {}
|
||||||
xsrf_cookie_kwargs.update(handler.settings.get('xsrf_cookie_kwargs', {}))
|
xsrf_cookie_kwargs.update(handler.settings.get('xsrf_cookie_kwargs', {}))
|
||||||
xsrf_cookie_kwargs.setdefault("path", cookie_path)
|
xsrf_cookie_kwargs.setdefault("path", cookie_path)
|
||||||
@@ -128,6 +133,7 @@ def _set_xsrf_cookie(handler, xsrf_id, *, cookie_path="", authenticated=None):
|
|||||||
xsrf_cookie_kwargs,
|
xsrf_cookie_kwargs,
|
||||||
)
|
)
|
||||||
handler.set_cookie("_xsrf", xsrf_token, **xsrf_cookie_kwargs)
|
handler.set_cookie("_xsrf", xsrf_token, **xsrf_cookie_kwargs)
|
||||||
|
return xsrf_token
|
||||||
|
|
||||||
|
|
||||||
def get_xsrf_token(handler, cookie_path=""):
|
def get_xsrf_token(handler, cookie_path=""):
|
||||||
@@ -173,7 +179,9 @@ def get_xsrf_token(handler, cookie_path=""):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if _set_cookie:
|
if _set_cookie:
|
||||||
_set_xsrf_cookie(handler, xsrf_id, cookie_path=cookie_path)
|
_set_xsrf_cookie(
|
||||||
|
handler, xsrf_id, cookie_path=cookie_path, xsrf_token=xsrf_token
|
||||||
|
)
|
||||||
handler._xsrf_token = xsrf_token
|
handler._xsrf_token = xsrf_token
|
||||||
return xsrf_token
|
return xsrf_token
|
||||||
|
|
||||||
@@ -230,18 +238,71 @@ def check_xsrf_cookie(handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# allow disabling using ip in anonymous id
|
||||||
|
def _get_anonymous_ip_cidrs():
|
||||||
|
"""
|
||||||
|
List of CIDRs to consider anonymous from $JUPYTERHUB_XSRF_ANONYMOUS_IP_CIDRS
|
||||||
|
|
||||||
|
e.g. private network IPs, which likely mean proxy ips
|
||||||
|
and do not meaningfully distinguish users
|
||||||
|
(fixing proxy headers would usually fix this).
|
||||||
|
"""
|
||||||
|
cidr_list_env = os.environ.get("JUPYTERHUB_XSRF_ANONYMOUS_IP_CIDRS")
|
||||||
|
if not cidr_list_env:
|
||||||
|
return []
|
||||||
|
return [ip_network(cidr) for cidr in cidr_list_env.split(";")]
|
||||||
|
|
||||||
|
|
||||||
|
_anonymous_ip_cidrs = _get_anonymous_ip_cidrs()
|
||||||
|
|
||||||
|
# allow specifying which headers to use for anonymous id
|
||||||
|
# (default: User-Agent)
|
||||||
|
# these should be stable (over a few minutes) for a single client and unlikely
|
||||||
|
# to be shared across users
|
||||||
|
_anonymous_id_headers = os.environ.get(
|
||||||
|
"JUPYTERHUB_XSRF_ANONYMOUS_ID_HEADERS", "User-Agent"
|
||||||
|
).split(";")
|
||||||
|
|
||||||
|
|
||||||
def _anonymous_xsrf_id(handler):
|
def _anonymous_xsrf_id(handler):
|
||||||
"""Generate an appropriate xsrf token id for an anonymous request
|
"""Generate an appropriate xsrf token id for an anonymous request
|
||||||
|
|
||||||
Currently uses hash of request ip and user-agent
|
Currently uses hash of request ip and user-agent
|
||||||
|
|
||||||
|
These are typically used only for the initial login page,
|
||||||
|
and don't need to be perfectly unique, just:
|
||||||
|
|
||||||
|
1. reasonably stable for a single user for the duration of a login
|
||||||
|
(a few requests, which may pass through different proxies)
|
||||||
|
2. somewhat unlikely to be shared across users
|
||||||
|
|
||||||
These are typically used only for the initial login page,
|
These are typically used only for the initial login page,
|
||||||
so only need to be valid for a few seconds to a few minutes
|
so only need to be valid for a few seconds to a few minutes
|
||||||
(enough to submit a login form with MFA).
|
(enough to submit a login form with MFA).
|
||||||
"""
|
"""
|
||||||
hasher = hashlib.sha256()
|
hasher = hashlib.sha256()
|
||||||
hasher.update(handler.request.remote_ip.encode("ascii"))
|
ip = ip_to_hash = handler.request.remote_ip
|
||||||
hasher.update(
|
try:
|
||||||
handler.request.headers.get("User-Agent", "").encode("utf8", "replace")
|
ip_addr = ip_address(ip)
|
||||||
)
|
except ValueError as e:
|
||||||
|
# invalid ip ?!
|
||||||
|
app_log.error("Error parsing remote ip %r: %s", ip, e)
|
||||||
|
else:
|
||||||
|
# if the ip is private (e.g. a cluster ip),
|
||||||
|
# this is almost certainly a proxy ip and not useful
|
||||||
|
# for distinguishing request origin.
|
||||||
|
# A proxy has the double downside of multiple replicas
|
||||||
|
# meaning the value can change from one request to the next for the
|
||||||
|
# same 'true' origin, resulting in unavoidable xsrf mismatch errors
|
||||||
|
for cidr in _anonymous_ip_cidrs:
|
||||||
|
if ip_addr in cidr:
|
||||||
|
# use matching cidr
|
||||||
|
ip_to_hash = str(cidr)
|
||||||
|
break
|
||||||
|
hasher.update(ip_to_hash.encode("ascii"))
|
||||||
|
for name in _anonymous_id_headers:
|
||||||
|
header = handler.request.headers.get(name, "")
|
||||||
|
hasher.update(header.encode("utf8", "replace"))
|
||||||
|
# field delimiter (should be something not valid utf8)
|
||||||
|
hasher.update(b"\xff")
|
||||||
return base64.urlsafe_b64encode(hasher.digest()).decode("ascii")
|
return base64.urlsafe_b64encode(hasher.digest()).decode("ascii")
|
||||||
|
@@ -3,6 +3,7 @@
|
|||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
import json
|
import json
|
||||||
|
from warnings import warn
|
||||||
|
|
||||||
from tornado import web
|
from tornado import web
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ class _GroupAPIHandler(APIHandler):
|
|||||||
|
|
||||||
def check_authenticator_managed_groups(self):
|
def check_authenticator_managed_groups(self):
|
||||||
"""Raise error on group-management APIs if Authenticator is managing groups"""
|
"""Raise error on group-management APIs if Authenticator is managing groups"""
|
||||||
|
warn(
|
||||||
|
"check_authenticator_managed_groups is deprecated in JupyterHub 5.3.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
if self.authenticator.manage_groups:
|
if self.authenticator.manage_groups:
|
||||||
raise web.HTTPError(400, "Group management via API is disabled")
|
raise web.HTTPError(400, "Group management via API is disabled")
|
||||||
|
|
||||||
@@ -73,9 +79,6 @@ class GroupListAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('admin:groups')
|
@needs_scope('admin:groups')
|
||||||
async def post(self):
|
async def post(self):
|
||||||
"""POST creates Multiple groups"""
|
"""POST creates Multiple groups"""
|
||||||
|
|
||||||
self.check_authenticator_managed_groups()
|
|
||||||
|
|
||||||
model = self.get_json_body()
|
model = self.get_json_body()
|
||||||
if not model or not isinstance(model, dict) or not model.get('groups'):
|
if not model or not isinstance(model, dict) or not model.get('groups'):
|
||||||
raise web.HTTPError(400, "Must specify at least one group to create")
|
raise web.HTTPError(400, "Must specify at least one group to create")
|
||||||
@@ -115,7 +118,6 @@ class GroupAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('admin:groups')
|
@needs_scope('admin:groups')
|
||||||
async def post(self, group_name):
|
async def post(self, group_name):
|
||||||
"""POST creates a group by name"""
|
"""POST creates a group by name"""
|
||||||
self.check_authenticator_managed_groups()
|
|
||||||
model = self.get_json_body()
|
model = self.get_json_body()
|
||||||
if model is None:
|
if model is None:
|
||||||
model = {}
|
model = {}
|
||||||
@@ -143,7 +145,6 @@ class GroupAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('delete:groups')
|
@needs_scope('delete:groups')
|
||||||
def delete(self, group_name):
|
def delete(self, group_name):
|
||||||
"""Delete a group by name"""
|
"""Delete a group by name"""
|
||||||
self.check_authenticator_managed_groups()
|
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
self.log.info("Deleting group %s", group_name)
|
self.log.info("Deleting group %s", group_name)
|
||||||
self.db.delete(group)
|
self.db.delete(group)
|
||||||
@@ -157,7 +158,6 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('groups')
|
@needs_scope('groups')
|
||||||
def post(self, group_name):
|
def post(self, group_name):
|
||||||
"""POST adds users to a group"""
|
"""POST adds users to a group"""
|
||||||
self.check_authenticator_managed_groups()
|
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
data = self.get_json_body()
|
data = self.get_json_body()
|
||||||
self._check_group_model(data)
|
self._check_group_model(data)
|
||||||
@@ -176,7 +176,6 @@ class GroupUsersAPIHandler(_GroupAPIHandler):
|
|||||||
@needs_scope('groups')
|
@needs_scope('groups')
|
||||||
async def delete(self, group_name):
|
async def delete(self, group_name):
|
||||||
"""DELETE removes users from a group"""
|
"""DELETE removes users from a group"""
|
||||||
self.check_authenticator_managed_groups()
|
|
||||||
group = self.find_group(group_name)
|
group = self.find_group(group_name)
|
||||||
data = self.get_json_body()
|
data = self.get_json_body()
|
||||||
self._check_group_model(data)
|
self._check_group_model(data)
|
||||||
|
@@ -52,6 +52,17 @@ class ShutdownAPIHandler(APIHandler):
|
|||||||
|
|
||||||
|
|
||||||
class RootAPIHandler(APIHandler):
|
class RootAPIHandler(APIHandler):
|
||||||
|
def set_default_headers(self):
|
||||||
|
"""
|
||||||
|
Set any headers passed as tornado_settings['headers'].
|
||||||
|
|
||||||
|
Also responsible for setting content-type header
|
||||||
|
"""
|
||||||
|
if 'Access-Control-Allow-Origin' not in self.settings.get("headers", {}):
|
||||||
|
# allow CORS requests to this endpoint by default
|
||||||
|
self.set_header('Access-Control-Allow-Origin', '*')
|
||||||
|
super().set_default_headers()
|
||||||
|
|
||||||
def check_xsrf_cookie(self):
|
def check_xsrf_cookie(self):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@@ -260,7 +260,7 @@ class UserListAPIHandler(APIHandler):
|
|||||||
raise web.HTTPError(400, msg)
|
raise web.HTTPError(400, msg)
|
||||||
|
|
||||||
if not to_create:
|
if not to_create:
|
||||||
raise web.HTTPError(409, "All %i users already exist" % len(usernames))
|
raise web.HTTPError(409, f"All {len(usernames)} users already exist")
|
||||||
|
|
||||||
created = []
|
created = []
|
||||||
for name in to_create:
|
for name in to_create:
|
||||||
|
@@ -282,7 +282,7 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
@default('classes')
|
@default('classes')
|
||||||
def _load_classes(self):
|
def _load_classes(self):
|
||||||
classes = [Spawner, Authenticator, CryptKeeper]
|
classes = {Spawner, Authenticator, CryptKeeper}
|
||||||
for name, trait in self.traits(config=True).items():
|
for name, trait in self.traits(config=True).items():
|
||||||
# load entry point groups into configurable class list
|
# load entry point groups into configurable class list
|
||||||
# so that they show up in config files, etc.
|
# so that they show up in config files, etc.
|
||||||
@@ -298,9 +298,9 @@ class JupyterHub(Application):
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if cls not in classes and isinstance(cls, Configurable):
|
if issubclass(cls, Configurable):
|
||||||
classes.append(cls)
|
classes.add(cls)
|
||||||
return classes
|
return list(classes)
|
||||||
|
|
||||||
load_groups = Dict(
|
load_groups = Dict(
|
||||||
Union([Dict(), List()]),
|
Union([Dict(), List()]),
|
||||||
@@ -873,13 +873,7 @@ class JupyterHub(Application):
|
|||||||
but your identity provider is likely much more strict,
|
but your identity provider is likely much more strict,
|
||||||
allowing you to make assumptions about the name.
|
allowing you to make assumptions about the name.
|
||||||
|
|
||||||
The default behavior is to have all services
|
The 'idna' hook should produce a valid domain name for any user,
|
||||||
on a single `services.{domain}` subdomain,
|
|
||||||
and each user on `{username}.{domain}`.
|
|
||||||
This is the 'legacy' scheme,
|
|
||||||
and doesn't work for all usernames.
|
|
||||||
|
|
||||||
The 'idna' scheme is a new scheme that should produce a valid domain name for any user,
|
|
||||||
using IDNA encoding for unicode usernames, and a truncate-and-hash approach for
|
using IDNA encoding for unicode usernames, and a truncate-and-hash approach for
|
||||||
any usernames that can't be easily encoded into a domain component.
|
any usernames that can't be easily encoded into a domain component.
|
||||||
|
|
||||||
@@ -1698,7 +1692,11 @@ class JupyterHub(Application):
|
|||||||
"""add a url prefix to handlers"""
|
"""add a url prefix to handlers"""
|
||||||
for i, tup in enumerate(handlers):
|
for i, tup in enumerate(handlers):
|
||||||
lis = list(tup)
|
lis = list(tup)
|
||||||
lis[0] = url_path_join(prefix, tup[0])
|
if tup[0]:
|
||||||
|
lis[0] = url_path_join(prefix, tup[0])
|
||||||
|
else:
|
||||||
|
# the '' route should match /prefix not /prefix/
|
||||||
|
lis[0] = prefix.rstrip("/")
|
||||||
handlers[i] = tuple(lis)
|
handlers[i] = tuple(lis)
|
||||||
return handlers
|
return handlers
|
||||||
|
|
||||||
@@ -1928,7 +1926,11 @@ class JupyterHub(Application):
|
|||||||
self.internal_ssl_components_trust
|
self.internal_ssl_components_trust
|
||||||
)
|
)
|
||||||
|
|
||||||
default_alt_names = ["IP:127.0.0.1", "DNS:localhost"]
|
default_alt_names = [
|
||||||
|
"IP:127.0.0.1",
|
||||||
|
"IP:0:0:0:0:0:0:0:1",
|
||||||
|
"DNS:localhost",
|
||||||
|
]
|
||||||
if self.subdomain_host:
|
if self.subdomain_host:
|
||||||
default_alt_names.append(
|
default_alt_names.append(
|
||||||
f"DNS:{urlparse(self.subdomain_host).hostname}"
|
f"DNS:{urlparse(self.subdomain_host).hostname}"
|
||||||
@@ -3326,7 +3328,7 @@ class JupyterHub(Application):
|
|||||||
if self.pid_file:
|
if self.pid_file:
|
||||||
self.log.debug("Writing PID %i to %s", pid, self.pid_file)
|
self.log.debug("Writing PID %i to %s", pid, self.pid_file)
|
||||||
with open(self.pid_file, 'w') as f:
|
with open(self.pid_file, 'w') as f:
|
||||||
f.write('%i' % pid)
|
f.write(str(pid))
|
||||||
|
|
||||||
@catch_config_error
|
@catch_config_error
|
||||||
async def initialize(self, *args, **kwargs):
|
async def initialize(self, *args, **kwargs):
|
||||||
@@ -3640,7 +3642,7 @@ class JupyterHub(Application):
|
|||||||
if service.managed:
|
if service.managed:
|
||||||
status = await service.spawner.poll()
|
status = await service.spawner.poll()
|
||||||
if status is not None:
|
if status is not None:
|
||||||
self.log.error(
|
self.log.critical(
|
||||||
"Service %s exited with status %s",
|
"Service %s exited with status %s",
|
||||||
service_name,
|
service_name,
|
||||||
status,
|
status,
|
||||||
@@ -3649,12 +3651,19 @@ class JupyterHub(Application):
|
|||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
self.log.error(
|
if service.managed:
|
||||||
"Cannot connect to %s service %s at %s. Is it running?",
|
self.log.critical(
|
||||||
service.kind,
|
"Cannot connect to %s service %s",
|
||||||
service_name,
|
service_name,
|
||||||
service.url,
|
service.kind,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self.log.warning(
|
||||||
|
"Cannot connect to %s service %s at %s. Is it running?",
|
||||||
|
service.kind,
|
||||||
|
service_name,
|
||||||
|
service.url,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -3745,18 +3754,8 @@ class JupyterHub(Application):
|
|||||||
# start the service(s)
|
# start the service(s)
|
||||||
for service_name, service in self._service_map.items():
|
for service_name, service in self._service_map.items():
|
||||||
service_ready = await self.start_service(service_name, service, ssl_context)
|
service_ready = await self.start_service(service_name, service, ssl_context)
|
||||||
if not service_ready:
|
if not service_ready and service.managed:
|
||||||
if service.from_config:
|
self.exit(1)
|
||||||
# Stop the application if a config-based service failed to start.
|
|
||||||
self.exit(1)
|
|
||||||
else:
|
|
||||||
# Only warn for database-based service, so that admin can connect
|
|
||||||
# to hub to remove the service.
|
|
||||||
self.log.error(
|
|
||||||
"Failed to reach externally managed service %s",
|
|
||||||
service_name,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.proxy.check_routes(self.users, self._service_map)
|
await self.proxy.check_routes(self.users, self._service_map)
|
||||||
|
|
||||||
|
@@ -77,10 +77,16 @@ class Authenticator(LoggingConfigurable):
|
|||||||
help="""The max age (in seconds) of authentication info
|
help="""The max age (in seconds) of authentication info
|
||||||
before forcing a refresh of user auth info.
|
before forcing a refresh of user auth info.
|
||||||
|
|
||||||
Refreshing auth info allows, e.g. requesting/re-validating auth tokens.
|
Authenticators that support it may re-load managed groups,
|
||||||
|
refresh auth tokens, etc., or force a new login if auth info cannot be refreshed.
|
||||||
|
|
||||||
See :meth:`.refresh_user` for what happens when user auth info is refreshed
|
See :meth:`.refresh_user` for what happens when user auth info is refreshed,
|
||||||
(nothing by default).
|
which varies by authenticator.
|
||||||
|
If an Authenticator does not implement `refresh_user`,
|
||||||
|
auth info will never be considered stale.
|
||||||
|
|
||||||
|
Set `auth_refresh_age = 0` to disable time-based calls to `refresh_user`.
|
||||||
|
You can still use :attr:`refresh_pre_spawn` if `auth_refresh_age` is disabled.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -223,6 +229,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
|
|
||||||
Authenticator subclasses may override the default with e.g.::
|
Authenticator subclasses may override the default with e.g.::
|
||||||
|
|
||||||
|
from traitlets import default
|
||||||
@default("allow_all")
|
@default("allow_all")
|
||||||
def _default_allow_all(self):
|
def _default_allow_all(self):
|
||||||
# if _any_ auth config (depends on the Authenticator)
|
# if _any_ auth config (depends on the Authenticator)
|
||||||
@@ -1218,7 +1225,7 @@ class LocalAuthenticator(Authenticator):
|
|||||||
cmd = [arg.replace('USERNAME', name) for arg in self.add_user_cmd]
|
cmd = [arg.replace('USERNAME', name) for arg in self.add_user_cmd]
|
||||||
try:
|
try:
|
||||||
uid = self.uids[name]
|
uid = self.uids[name]
|
||||||
cmd += ['--uid', '%d' % uid]
|
cmd += ['--uid', str(uid)]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
self.log.debug(f"No UID for user {name}")
|
self.log.debug(f"No UID for user {name}")
|
||||||
cmd += [name]
|
cmd += [name]
|
||||||
@@ -1497,12 +1504,19 @@ class DummyAuthenticator(Authenticator):
|
|||||||
password = Unicode(
|
password = Unicode(
|
||||||
config=True,
|
config=True,
|
||||||
help="""
|
help="""
|
||||||
Set a global password for all users wanting to log in.
|
.. deprecated:: 5.3
|
||||||
|
|
||||||
This allows users with any username to log in with the same static password.
|
Setting a password in DummyAuthenticator is deprecated.
|
||||||
|
Use `SharedPasswordAuthenticator` instead.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@observe("password")
|
||||||
|
def _password_changed(self, change):
|
||||||
|
msg = "DummyAuthenticator.password is deprecated in JupyterHub 5.3. Use SharedPasswordAuthenticator.user_password instead."
|
||||||
|
warnings.warn(msg, DeprecationWarning)
|
||||||
|
self.log.warning(msg)
|
||||||
|
|
||||||
def check_allow_config(self):
|
def check_allow_config(self):
|
||||||
super().check_allow_config()
|
super().check_allow_config()
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
@@ -1513,7 +1527,7 @@ class DummyAuthenticator(Authenticator):
|
|||||||
"""Checks against a global password if it's been set. If not, allow any user/pass combo"""
|
"""Checks against a global password if it's been set. If not, allow any user/pass combo"""
|
||||||
if self.password:
|
if self.password:
|
||||||
if data['password'] == self.password:
|
if data['password'] == self.password:
|
||||||
return data['username']
|
return data["username"]
|
||||||
return None
|
return None
|
||||||
return data['username']
|
return data['username']
|
||||||
|
|
||||||
|
0
jupyterhub/authenticators/__init__.py
Normal file
0
jupyterhub/authenticators/__init__.py
Normal file
149
jupyterhub/authenticators/shared.py
Normal file
149
jupyterhub/authenticators/shared.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from secrets import compare_digest
|
||||||
|
|
||||||
|
from traitlets import Unicode, validate
|
||||||
|
|
||||||
|
from ..auth import Authenticator
|
||||||
|
|
||||||
|
|
||||||
|
class SharedPasswordAuthenticator(Authenticator):
|
||||||
|
"""
|
||||||
|
Authenticator with static shared passwords.
|
||||||
|
|
||||||
|
For use in short-term deployments with negligible security concerns.
|
||||||
|
|
||||||
|
Enable with::
|
||||||
|
|
||||||
|
c.JupyterHub.authenticator_class = "shared-password"
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
This is an insecure Authenticator only appropriate for short-term
|
||||||
|
deployments with no requirement to protect users from each other.
|
||||||
|
|
||||||
|
- The password is stored in plain text at rest in config
|
||||||
|
- Anyone with the password can login as **any user**
|
||||||
|
- All users are able to login as all other (non-admin) users with the same password
|
||||||
|
"""
|
||||||
|
|
||||||
|
_USER_PASSWORD_MIN_LENGTH = 8
|
||||||
|
_ADMIN_PASSWORD_MIN_LENGTH = 32
|
||||||
|
|
||||||
|
user_password = Unicode(
|
||||||
|
None,
|
||||||
|
allow_none=True,
|
||||||
|
config=True,
|
||||||
|
help=f"""
|
||||||
|
Set a global password for all *non admin* users wanting to log in.
|
||||||
|
|
||||||
|
Must be {_USER_PASSWORD_MIN_LENGTH} characters or longer.
|
||||||
|
|
||||||
|
If not set, regular users cannot login.
|
||||||
|
|
||||||
|
If `allow_all` is True, anybody can register unlimited new users with any username by logging in with this password.
|
||||||
|
Users may be allowed by name by specifying `allowed_users`.
|
||||||
|
|
||||||
|
Any user will also be able to login as **any other non-admin user** with this password.
|
||||||
|
|
||||||
|
If `admin_users` is set, those users *must* use `admin_password` to log in.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_password = Unicode(
|
||||||
|
None,
|
||||||
|
allow_none=True,
|
||||||
|
config=True,
|
||||||
|
help=f"""
|
||||||
|
Set a global password that grants *admin* privileges to users logging in with this password.
|
||||||
|
Only usernames declared in `admin_users` may login with this password.
|
||||||
|
|
||||||
|
Must meet the following requirements:
|
||||||
|
|
||||||
|
- Be {_ADMIN_PASSWORD_MIN_LENGTH} characters or longer
|
||||||
|
- Not be the same as `user_password`
|
||||||
|
|
||||||
|
If not set, admin users cannot login.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
@validate("admin_password")
|
||||||
|
def _validate_admin_password(self, proposal):
|
||||||
|
new = proposal.value
|
||||||
|
trait_name = f"{self.__class__.__name__}.{proposal.trait.name}"
|
||||||
|
|
||||||
|
if not new:
|
||||||
|
# no admin password; do nothing
|
||||||
|
return None
|
||||||
|
if len(new) < self._ADMIN_PASSWORD_MIN_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"{trait_name} must be at least {self._ADMIN_PASSWORD_MIN_LENGTH} characters, not {len(new)}."
|
||||||
|
)
|
||||||
|
if self.user_password == new:
|
||||||
|
# Checked here and in validating password, to ensure we don't miss issues due to ordering
|
||||||
|
raise ValueError(
|
||||||
|
f"{self.__class__.__name__}.user_password and {trait_name} cannot be the same"
|
||||||
|
)
|
||||||
|
return new
|
||||||
|
|
||||||
|
@validate("user_password")
|
||||||
|
def _validate_password(self, proposal):
|
||||||
|
new = proposal.value
|
||||||
|
trait_name = f"{self.__class__.__name__}.{proposal.trait.name}"
|
||||||
|
|
||||||
|
if not new:
|
||||||
|
# no user password; do nothing
|
||||||
|
return None
|
||||||
|
if len(new) < self._USER_PASSWORD_MIN_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"{trait_name} must be at least {self._USER_PASSWORD_MIN_LENGTH} characters long, got {len(new)} characters"
|
||||||
|
)
|
||||||
|
if self.admin_password == new:
|
||||||
|
# Checked here and in validating password, to ensure we don't miss issues due to ordering
|
||||||
|
raise ValueError(
|
||||||
|
f"{trait_name} and {self.__class__.__name__}.admin_password cannot be the same"
|
||||||
|
)
|
||||||
|
return new
|
||||||
|
|
||||||
|
def check_allow_config(self):
|
||||||
|
"""Validate and warn about any suspicious allow config"""
|
||||||
|
super().check_allow_config()
|
||||||
|
clsname = self.__class__.__name__
|
||||||
|
if self.admin_password and not self.admin_users:
|
||||||
|
self.log.warning(
|
||||||
|
f"{clsname}.admin_password set, but {clsname}.admin_users is not."
|
||||||
|
" No admin users will be able to login."
|
||||||
|
f" Add usernames to {clsname}.admin_users to grant users admin permissions."
|
||||||
|
)
|
||||||
|
if self.admin_users and not self.admin_password:
|
||||||
|
self.log.warning(
|
||||||
|
f"{clsname}.admin_users set, but {clsname}.admin_password is not."
|
||||||
|
" No admin users will be able to login."
|
||||||
|
f" Set {clsname}.admin_password to allow admins to login."
|
||||||
|
)
|
||||||
|
if not self.user_password:
|
||||||
|
if not self.admin_password:
|
||||||
|
# log as an error, but don't raise, because disabling all login is valid
|
||||||
|
self.log.error(
|
||||||
|
f"Neither {clsname}.admin_password nor {clsname}.user_password is set."
|
||||||
|
" Nobody will be able to login!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log.warning(
|
||||||
|
f"{clsname}.user_password not set."
|
||||||
|
" No non-admin users will be able to login."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def authenticate(self, handler, data):
|
||||||
|
"""Checks against shared password"""
|
||||||
|
if data["username"] in self.admin_users:
|
||||||
|
# Admin user
|
||||||
|
if self.admin_password and compare_digest(
|
||||||
|
data["password"], self.admin_password
|
||||||
|
):
|
||||||
|
return {"name": data["username"], "admin": True}
|
||||||
|
else:
|
||||||
|
if self.user_password and compare_digest(
|
||||||
|
data["password"], self.user_password
|
||||||
|
):
|
||||||
|
# Anyone logging in with the standard password is *never* admin
|
||||||
|
return {"name": data["username"], "admin": False}
|
||||||
|
|
||||||
|
return None
|
@@ -1061,10 +1061,12 @@ class BaseHandler(RequestHandler):
|
|||||||
# round suggestion to nicer human value (nearest 10 seconds or minute)
|
# round suggestion to nicer human value (nearest 10 seconds or minute)
|
||||||
if retry_time <= 90:
|
if retry_time <= 90:
|
||||||
# round human seconds up to nearest 10
|
# round human seconds up to nearest 10
|
||||||
human_retry_time = "%i0 seconds" % math.ceil(retry_time / 10.0)
|
delay = math.ceil(retry_time / 10.0)
|
||||||
|
human_retry_time = f"{delay}0 seconds"
|
||||||
else:
|
else:
|
||||||
# round number of minutes
|
# round number of minutes
|
||||||
human_retry_time = "%i minutes" % round(retry_time / 60.0)
|
delay = round(retry_time / 60.0)
|
||||||
|
human_retry_time = f"{delay} minutes"
|
||||||
|
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
'%s pending spawns, throttling. Suggested retry in %s seconds.',
|
'%s pending spawns, throttling. Suggested retry in %s seconds.',
|
||||||
@@ -1099,12 +1101,12 @@ class BaseHandler(RequestHandler):
|
|||||||
self.log.debug(
|
self.log.debug(
|
||||||
"%i%s concurrent spawns",
|
"%i%s concurrent spawns",
|
||||||
spawn_pending_count,
|
spawn_pending_count,
|
||||||
'/%i' % concurrent_spawn_limit if concurrent_spawn_limit else '',
|
f'/{concurrent_spawn_limit}' if concurrent_spawn_limit else '',
|
||||||
)
|
)
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"%i%s active servers",
|
"%i%s active servers",
|
||||||
active_count,
|
active_count,
|
||||||
'/%i' % active_server_limit if active_server_limit else '',
|
f'/{active_server_limit}' if active_server_limit else '',
|
||||||
)
|
)
|
||||||
|
|
||||||
spawner = user.spawners[server_name]
|
spawner = user.spawners[server_name]
|
||||||
@@ -1246,6 +1248,20 @@ class BaseHandler(RequestHandler):
|
|||||||
status=ServerSpawnStatus.failure
|
status=ServerSpawnStatus.failure
|
||||||
).observe(time.perf_counter() - spawn_start_time)
|
).observe(time.perf_counter() - spawn_start_time)
|
||||||
|
|
||||||
|
# if it stopped, give the original spawn future a second chance to raise
|
||||||
|
# this avoids storing the generic 500 error as the spawn failure,
|
||||||
|
# when the original may be more informative
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.shield(finish_spawn_future), timeout=1
|
||||||
|
)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if finish_spawn_future.exception():
|
||||||
|
# raise original exception if it already failed
|
||||||
|
await finish_spawn_future
|
||||||
|
|
||||||
raise web.HTTPError(
|
raise web.HTTPError(
|
||||||
500,
|
500,
|
||||||
f"Spawner failed to start [status={status}]. The logs for {spawner._log_name} may contain details.",
|
f"Spawner failed to start [status={status}]. The logs for {spawner._log_name} may contain details.",
|
||||||
@@ -1471,6 +1487,7 @@ class BaseHandler(RequestHandler):
|
|||||||
"""render custom error pages"""
|
"""render custom error pages"""
|
||||||
exc_info = kwargs.get('exc_info')
|
exc_info = kwargs.get('exc_info')
|
||||||
message = ''
|
message = ''
|
||||||
|
message_html = ''
|
||||||
exception = None
|
exception = None
|
||||||
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
status_message = responses.get(status_code, 'Unknown HTTP Error')
|
||||||
if exc_info:
|
if exc_info:
|
||||||
@@ -1480,12 +1497,17 @@ class BaseHandler(RequestHandler):
|
|||||||
message = exception.log_message % exception.args
|
message = exception.log_message % exception.args
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
# allow custom html messages
|
||||||
|
message_html = getattr(exception, "jupyterhub_html_message", "")
|
||||||
|
|
||||||
# construct the custom reason, if defined
|
# construct the custom reason, if defined
|
||||||
reason = getattr(exception, 'reason', '')
|
reason = getattr(exception, 'reason', '')
|
||||||
if reason:
|
if reason:
|
||||||
message = reasons.get(reason, reason)
|
message = reasons.get(reason, reason)
|
||||||
|
|
||||||
|
# get special jupyterhub_message, if defined
|
||||||
|
message = getattr(exception, "jupyterhub_message", message)
|
||||||
|
|
||||||
if exception and isinstance(exception, SQLAlchemyError):
|
if exception and isinstance(exception, SQLAlchemyError):
|
||||||
self.log.warning("Rolling back session due to database error %s", exception)
|
self.log.warning("Rolling back session due to database error %s", exception)
|
||||||
self.db.rollback()
|
self.db.rollback()
|
||||||
@@ -1495,6 +1517,7 @@ class BaseHandler(RequestHandler):
|
|||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
status_message=status_message,
|
status_message=status_message,
|
||||||
message=message,
|
message=message,
|
||||||
|
message_html=message_html,
|
||||||
extra_error_html=getattr(self, 'extra_error_html', ''),
|
extra_error_html=getattr(self, 'extra_error_html', ''),
|
||||||
exception=exception,
|
exception=exception,
|
||||||
)
|
)
|
||||||
|
@@ -9,6 +9,7 @@ from tornado import web
|
|||||||
from tornado.escape import url_escape
|
from tornado.escape import url_escape
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
|
from .._xsrf_utils import _set_xsrf_cookie
|
||||||
from ..utils import maybe_future
|
from ..utils import maybe_future
|
||||||
from .base import BaseHandler
|
from .base import BaseHandler
|
||||||
|
|
||||||
@@ -94,7 +95,37 @@ class LogoutHandler(BaseHandler):
|
|||||||
class LoginHandler(BaseHandler):
|
class LoginHandler(BaseHandler):
|
||||||
"""Render the login page."""
|
"""Render the login page."""
|
||||||
|
|
||||||
def _render(self, login_error=None, username=None):
|
def render_template(self, name, **ns):
|
||||||
|
# intercept error page rendering for form submissions
|
||||||
|
if (
|
||||||
|
name == "error.html"
|
||||||
|
and self.request.method.lower() == "post"
|
||||||
|
and self.request.headers.get("Sec-Fetch-Mode", "navigate") == "navigate"
|
||||||
|
):
|
||||||
|
# regular login form submission
|
||||||
|
# render login form with error message
|
||||||
|
ns["login_error"] = ns.get("message") or ns.get("status_message", "")
|
||||||
|
ns["username"] = self.get_argument("username", strip=True, default="")
|
||||||
|
return self._render(**ns)
|
||||||
|
else:
|
||||||
|
return super().render_template(name, **ns)
|
||||||
|
|
||||||
|
def check_xsrf_cookie(self):
|
||||||
|
try:
|
||||||
|
return super().check_xsrf_cookie()
|
||||||
|
except web.HTTPError as e:
|
||||||
|
# rewrite xsrf error on login form for nicer message
|
||||||
|
# suggest retry, which is likely to succeed
|
||||||
|
# log the original error so admins can debug
|
||||||
|
self.log.error("XSRF error on login form: %s", e)
|
||||||
|
if self.request.headers.get("Sec-Fetch-Mode", "navigate") == "navigate":
|
||||||
|
raise web.HTTPError(
|
||||||
|
e.status_code, "Login form invalid or expired. Try again."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _render(self, login_error=None, username=None, **kwargs):
|
||||||
context = {
|
context = {
|
||||||
"next": url_escape(self.get_argument('next', default='')),
|
"next": url_escape(self.get_argument('next', default='')),
|
||||||
"username": username,
|
"username": username,
|
||||||
@@ -116,6 +147,7 @@ class LoginHandler(BaseHandler):
|
|||||||
'login.html',
|
'login.html',
|
||||||
**context,
|
**context,
|
||||||
custom_html=custom_html,
|
custom_html=custom_html,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get(self):
|
async def get(self):
|
||||||
@@ -147,6 +179,18 @@ class LoginHandler(BaseHandler):
|
|||||||
self.redirect(auto_login_url)
|
self.redirect(auto_login_url)
|
||||||
return
|
return
|
||||||
username = self.get_argument('username', default='')
|
username = self.get_argument('username', default='')
|
||||||
|
|
||||||
|
# always set a fresh xsrf cookie when the login page is rendered
|
||||||
|
# ensures we are as far from expiration as possible
|
||||||
|
# to restart the timer
|
||||||
|
xsrf_token = self.xsrf_token
|
||||||
|
if self.request.headers.get("Sec-Fetch-Mode", "navigate") == "navigate":
|
||||||
|
_set_xsrf_cookie(
|
||||||
|
self,
|
||||||
|
self._xsrf_token_id,
|
||||||
|
cookie_path=self.hub.base_url,
|
||||||
|
xsrf_token=xsrf_token,
|
||||||
|
)
|
||||||
self.finish(await self._render(username=username))
|
self.finish(await self._render(username=username))
|
||||||
|
|
||||||
async def post(self):
|
async def post(self):
|
||||||
@@ -169,6 +213,7 @@ class LoginHandler(BaseHandler):
|
|||||||
self._jupyterhub_user = user
|
self._jupyterhub_user = user
|
||||||
self.redirect(self.get_next_url(user))
|
self.redirect(self.get_next_url(user))
|
||||||
else:
|
else:
|
||||||
|
self.set_status(403)
|
||||||
html = await self._render(
|
html = await self._render(
|
||||||
login_error='Invalid username or password', username=data['username']
|
login_error='Invalid username or password', username=data['username']
|
||||||
)
|
)
|
||||||
|
@@ -375,7 +375,10 @@ class SpawnPendingHandler(BaseHandler):
|
|||||||
spawn_url = url_path_join(
|
spawn_url = url_path_join(
|
||||||
self.hub.base_url, "spawn", user.escaped_name, escaped_server_name
|
self.hub.base_url, "spawn", user.escaped_name, escaped_server_name
|
||||||
)
|
)
|
||||||
self.set_status(500)
|
status_code = 500
|
||||||
|
if isinstance(exc, web.HTTPError):
|
||||||
|
status_code = exc.status_code
|
||||||
|
self.set_status(status_code)
|
||||||
html = await self.render_template(
|
html = await self.render_template(
|
||||||
"not_running.html",
|
"not_running.html",
|
||||||
user=user,
|
user=user,
|
||||||
|
@@ -37,6 +37,28 @@ from . import orm
|
|||||||
from .utils import utcnow
|
from .utils import utcnow
|
||||||
|
|
||||||
metrics_prefix = os.getenv('JUPYTERHUB_METRICS_PREFIX', 'jupyterhub')
|
metrics_prefix = os.getenv('JUPYTERHUB_METRICS_PREFIX', 'jupyterhub')
|
||||||
|
_env_buckets = os.environ.get(
|
||||||
|
'JUPYTERHUB_SERVER_SPAWN_DURATION_SECONDS_BUCKETS', ""
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
if _env_buckets:
|
||||||
|
spawn_duration_buckets = [float(_s) for _s in _env_buckets.split(",")]
|
||||||
|
else:
|
||||||
|
spawn_duration_buckets = [
|
||||||
|
0.5,
|
||||||
|
1,
|
||||||
|
2.5,
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
15,
|
||||||
|
30,
|
||||||
|
60,
|
||||||
|
120,
|
||||||
|
180,
|
||||||
|
300,
|
||||||
|
600,
|
||||||
|
float("inf"),
|
||||||
|
]
|
||||||
|
|
||||||
REQUEST_DURATION_SECONDS = Histogram(
|
REQUEST_DURATION_SECONDS = Histogram(
|
||||||
'request_duration_seconds',
|
'request_duration_seconds',
|
||||||
@@ -51,7 +73,7 @@ SERVER_SPAWN_DURATION_SECONDS = Histogram(
|
|||||||
['status'],
|
['status'],
|
||||||
# Use custom bucket sizes, since the default bucket ranges
|
# Use custom bucket sizes, since the default bucket ranges
|
||||||
# are meant for quick running processes. Spawns can take a while!
|
# are meant for quick running processes. Spawns can take a while!
|
||||||
buckets=[0.5, 1, 2.5, 5, 10, 15, 30, 60, 120, 180, 300, 600, float("inf")],
|
buckets=spawn_duration_buckets,
|
||||||
namespace=metrics_prefix,
|
namespace=metrics_prefix,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -12,6 +12,7 @@ from . import orm
|
|||||||
from .traitlets import URLPrefix
|
from .traitlets import URLPrefix
|
||||||
from .utils import (
|
from .utils import (
|
||||||
can_connect,
|
can_connect,
|
||||||
|
fmt_ip_url,
|
||||||
make_ssl_context,
|
make_ssl_context,
|
||||||
random_port,
|
random_port,
|
||||||
url_path_join,
|
url_path_join,
|
||||||
@@ -50,7 +51,7 @@ class Server(HasTraits):
|
|||||||
since it can be non-connectable value, such as '', meaning all interfaces.
|
since it can be non-connectable value, such as '', meaning all interfaces.
|
||||||
"""
|
"""
|
||||||
if self.ip in {'', '0.0.0.0', '::'}:
|
if self.ip in {'', '0.0.0.0', '::'}:
|
||||||
return self.url.replace(self._connect_ip, self.ip or '*', 1)
|
return self.url.replace(self._connect_ip, fmt_ip_url(self.ip) or '*', 1)
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
@observe('bind_url')
|
@observe('bind_url')
|
||||||
@@ -216,4 +217,4 @@ class Hub(Server):
|
|||||||
return url_path_join(self.url, 'api')
|
return url_path_join(self.url, 'api')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} {self.ip}:{self.port}>"
|
return f"<{self.__class__.__name__} {fmt_ip_url(self.ip)}:{self.port}>"
|
||||||
|
@@ -46,7 +46,7 @@ from sqlalchemy.pool import StaticPool
|
|||||||
from sqlalchemy.types import LargeBinary, Text, TypeDecorator
|
from sqlalchemy.types import LargeBinary, Text, TypeDecorator
|
||||||
from tornado.log import app_log
|
from tornado.log import app_log
|
||||||
|
|
||||||
from .utils import compare_token, hash_token, new_token, random_port, utcnow
|
from .utils import compare_token, fmt_ip_url, hash_token, new_token, random_port, utcnow
|
||||||
|
|
||||||
# top-level variable for easier mocking in tests
|
# top-level variable for easier mocking in tests
|
||||||
utcnow = partial(utcnow, with_tz=False)
|
utcnow = partial(utcnow, with_tz=False)
|
||||||
@@ -157,7 +157,7 @@ class Server(Base):
|
|||||||
spawner = relationship("Spawner", back_populates="server", uselist=False)
|
spawner = relationship("Spawner", back_populates="server", uselist=False)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Server({self.ip}:{self.port})>"
|
return f"<Server({fmt_ip_url(self.ip)}:{self.port})>"
|
||||||
|
|
||||||
|
|
||||||
# lots of things have roles
|
# lots of things have roles
|
||||||
@@ -945,7 +945,7 @@ class ShareCode(_Share, Hashed, Base):
|
|||||||
else:
|
else:
|
||||||
server_name = "unknown/deleted"
|
server_name = "unknown/deleted"
|
||||||
|
|
||||||
return f"<{self.__class__.__name__}(server={server_name}, scopes={self.scopes}, expires_at={self.expires_at})>"
|
return f"<{self.__class__.__name__}(id={self.id}, server={server_name}, scopes={self.scopes}, expires_at={self.expires_at})>"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def new(
|
def new(
|
||||||
@@ -1050,7 +1050,7 @@ class APIToken(Hashed, Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def api_id(self):
|
def api_id(self):
|
||||||
return 'a%i' % self.id
|
return f"a{self.id}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def owner(self):
|
def owner(self):
|
||||||
|
@@ -31,8 +31,11 @@ from tornado.log import app_log
|
|||||||
from . import orm, roles
|
from . import orm, roles
|
||||||
from ._memoize import DoNotCache, FrozenDict, lru_cache_key
|
from ._memoize import DoNotCache, FrozenDict, lru_cache_key
|
||||||
|
|
||||||
"""when modifying the scope definitions, make sure that `docs/source/rbac/generate-scope-table.py` is run
|
"""when modifying the scope definitions
|
||||||
so that changes are reflected in the documentation and REST API description."""
|
`docs/source/rbac/generate-scope-table.py` must be run
|
||||||
|
so that changes are reflected in the documentation and REST API description.
|
||||||
|
`pre-commit run -a` should automatically take care of this.
|
||||||
|
"""
|
||||||
scope_definitions = {
|
scope_definitions = {
|
||||||
'(no_scope)': {'description': 'Identify the owner of the requesting entity.'},
|
'(no_scope)': {'description': 'Identify the owner of the requesting entity.'},
|
||||||
'self': {
|
'self': {
|
||||||
@@ -64,7 +67,7 @@ scope_definitions = {
|
|||||||
'subscopes': ['read:users:name'],
|
'subscopes': ['read:users:name'],
|
||||||
},
|
},
|
||||||
'read:users': {
|
'read:users': {
|
||||||
'description': 'Read user models (including servers, tokens and authentication state).',
|
'description': 'Read user models (including the URL of the default server if it is running).',
|
||||||
'subscopes': [
|
'subscopes': [
|
||||||
'read:users:name',
|
'read:users:name',
|
||||||
'read:users:groups',
|
'read:users:groups',
|
||||||
|
@@ -436,7 +436,10 @@ class Service(LoggingConfigurable):
|
|||||||
# since they are always local subprocesses
|
# since they are always local subprocesses
|
||||||
hub = copy.deepcopy(self.hub)
|
hub = copy.deepcopy(self.hub)
|
||||||
hub.connect_url = ''
|
hub.connect_url = ''
|
||||||
hub.connect_ip = '127.0.0.1'
|
if self.hub.ip and ":" in self.hub.ip:
|
||||||
|
hub.connect_ip = "::1"
|
||||||
|
else:
|
||||||
|
hub.connect_ip = "127.0.0.1"
|
||||||
|
|
||||||
self.spawner = _ServiceSpawner(
|
self.spawner = _ServiceSpawner(
|
||||||
cmd=self.command,
|
cmd=self.command,
|
||||||
|
@@ -63,9 +63,29 @@ if _as_extension:
|
|||||||
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION={_extension_env} with JUPYTERHUB_SINGLEUSER_APP={_app_env}."
|
f"Cannot use JUPYTERHUB_SINGLEUSER_EXTENSION={_extension_env} with JUPYTERHUB_SINGLEUSER_APP={_app_env}."
|
||||||
" Please pick one or the other."
|
" Please pick one or the other."
|
||||||
)
|
)
|
||||||
from .extension import main
|
try:
|
||||||
|
from .extension import main
|
||||||
|
except ImportError as e:
|
||||||
|
# raise from to preserve original import error
|
||||||
|
raise ImportError(
|
||||||
|
"Failed to import JupyterHub singleuser extension."
|
||||||
|
" Make sure to install dependencies for your single-user server, e.g.\n"
|
||||||
|
" pip install jupyterlab"
|
||||||
|
) from e
|
||||||
else:
|
else:
|
||||||
from .app import SingleUserNotebookApp, main
|
try:
|
||||||
|
from .app import SingleUserNotebookApp, main
|
||||||
|
except ImportError as e:
|
||||||
|
# raise from to preserve original import error
|
||||||
|
if _app_env:
|
||||||
|
_app_env_log = f"JUPYTERHUB_SINGLEUSER_APP={_app_env}"
|
||||||
|
else:
|
||||||
|
_app_env_log = "default single-user server"
|
||||||
|
raise ImportError(
|
||||||
|
f"Failed to import {_app_env_log}."
|
||||||
|
" Make sure to install dependencies for your single-user server, e.g.\n"
|
||||||
|
" pip install jupyterlab"
|
||||||
|
) from e
|
||||||
|
|
||||||
# backward-compatibility
|
# backward-compatibility
|
||||||
if SingleUserNotebookApp is not None:
|
if SingleUserNotebookApp is not None:
|
||||||
|
@@ -22,8 +22,6 @@ rather than keeing these monkey patches around.
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from jupyter_core import paths
|
|
||||||
|
|
||||||
|
|
||||||
def _is_relative_to(path, prefix):
|
def _is_relative_to(path, prefix):
|
||||||
"""
|
"""
|
||||||
@@ -68,6 +66,10 @@ def _disable_user_config(serverapp):
|
|||||||
2. Search paths for extensions, etc.
|
2. Search paths for extensions, etc.
|
||||||
3. import path
|
3. import path
|
||||||
"""
|
"""
|
||||||
|
# delayed import to avoid triggering early ImportError
|
||||||
|
# with unmet dependencies
|
||||||
|
from jupyter_core import paths
|
||||||
|
|
||||||
original_jupyter_path = paths.jupyter_path()
|
original_jupyter_path = paths.jupyter_path()
|
||||||
jupyter_path_without_home = list(_exclude_home(original_jupyter_path))
|
jupyter_path_without_home = list(_exclude_home(original_jupyter_path))
|
||||||
|
|
||||||
|
@@ -518,7 +518,8 @@ class JupyterHubSingleUser(ExtensionApp):
|
|||||||
if url.hostname:
|
if url.hostname:
|
||||||
cfg.ip = url.hostname
|
cfg.ip = url.hostname
|
||||||
else:
|
else:
|
||||||
cfg.ip = "127.0.0.1"
|
# All interfaces (ipv4+ipv6)
|
||||||
|
cfg.ip = ""
|
||||||
|
|
||||||
cfg.base_url = os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
|
cfg.base_url = os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
|
||||||
|
|
||||||
|
@@ -288,6 +288,8 @@ class SingleUserNotebookAppMixin(Configurable):
|
|||||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
if url.hostname:
|
if url.hostname:
|
||||||
return url.hostname
|
return url.hostname
|
||||||
|
# All interfaces (ipv4+ipv6)
|
||||||
|
return ""
|
||||||
return '127.0.0.1'
|
return '127.0.0.1'
|
||||||
|
|
||||||
# disable some single-user configurables
|
# disable some single-user configurables
|
||||||
|
@@ -12,7 +12,7 @@ import shutil
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import warnings
|
||||||
from inspect import signature
|
from inspect import isawaitable, signature
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
@@ -24,6 +24,7 @@ else:
|
|||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
|
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
from tornado import web
|
||||||
from tornado.ioloop import PeriodicCallback
|
from tornado.ioloop import PeriodicCallback
|
||||||
from traitlets import (
|
from traitlets import (
|
||||||
Any,
|
Any,
|
||||||
@@ -48,6 +49,7 @@ from .traitlets import ByteSpecification, Callable, Command
|
|||||||
from .utils import (
|
from .utils import (
|
||||||
AnyTimeoutError,
|
AnyTimeoutError,
|
||||||
exponential_backoff,
|
exponential_backoff,
|
||||||
|
fmt_ip_url,
|
||||||
maybe_future,
|
maybe_future,
|
||||||
random_port,
|
random_port,
|
||||||
recursive_update,
|
recursive_update,
|
||||||
@@ -365,14 +367,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
help="""Allowed roles for oauth tokens.
|
help="""Allowed roles for oauth tokens.
|
||||||
|
|
||||||
Deprecated in 3.0: use oauth_client_allowed_scopes
|
Deprecated in 3.0: use oauth_client_allowed_scopes
|
||||||
|
|
||||||
This sets the maximum and default roles
|
|
||||||
assigned to oauth tokens issued by a single-user server's
|
|
||||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
|
||||||
defining what actions the server can take on behalf of logged-in users.
|
|
||||||
|
|
||||||
Default is an empty list, meaning minimal permissions to identify users,
|
|
||||||
no actions can be taken on their behalf.
|
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@@ -385,6 +379,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
oauth client (i.e. tokens stored in browsers after authenticating with the server),
|
||||||
defining what actions the server can take on behalf of logged-in users.
|
defining what actions the server can take on behalf of logged-in users.
|
||||||
|
|
||||||
|
Access to the current server will always be included in this list.
|
||||||
|
This property contains additional scopes.
|
||||||
Default is an empty list, meaning minimal permissions to identify users,
|
Default is an empty list, meaning minimal permissions to identify users,
|
||||||
no actions can be taken on their behalf.
|
no actions can be taken on their behalf.
|
||||||
|
|
||||||
@@ -412,7 +408,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
allowed_scopes = self.oauth_client_allowed_scopes
|
allowed_scopes = self.oauth_client_allowed_scopes
|
||||||
if callable(allowed_scopes):
|
if callable(allowed_scopes):
|
||||||
allowed_scopes = allowed_scopes(self)
|
allowed_scopes = allowed_scopes(self)
|
||||||
if inspect.isawaitable(allowed_scopes):
|
if isawaitable(allowed_scopes):
|
||||||
allowed_scopes = await allowed_scopes
|
allowed_scopes = await allowed_scopes
|
||||||
scopes.extend(allowed_scopes)
|
scopes.extend(allowed_scopes)
|
||||||
|
|
||||||
@@ -475,20 +471,40 @@ class Spawner(LoggingConfigurable):
|
|||||||
The IP address (or hostname) the single-user server should listen on.
|
The IP address (or hostname) the single-user server should listen on.
|
||||||
|
|
||||||
Usually either '127.0.0.1' (default) or '0.0.0.0'.
|
Usually either '127.0.0.1' (default) or '0.0.0.0'.
|
||||||
|
On IPv6 only networks use '::1' or '::'.
|
||||||
|
|
||||||
|
If the spawned singleuser server is running JupyterHub 5.3.0 later
|
||||||
|
You can set this to the empty string '' to indicate both IPv4 and IPv6.
|
||||||
|
|
||||||
The JupyterHub proxy implementation should be able to send packets to this interface.
|
The JupyterHub proxy implementation should be able to send packets to this interface.
|
||||||
|
|
||||||
Subclasses which launch remotely or in containers
|
Subclasses which launch remotely or in containers
|
||||||
should override the default to '0.0.0.0'.
|
should override the default to '0.0.0.0'.
|
||||||
|
|
||||||
|
.. versionchanged:: 5.3
|
||||||
|
An empty string '' means all interfaces (IPv4 and IPv6). Prior to this
|
||||||
|
the behaviour of '' was not defined.
|
||||||
|
|
||||||
.. versionchanged:: 2.0
|
.. versionchanged:: 2.0
|
||||||
Default changed to '127.0.0.1', from ''.
|
Default changed to '127.0.0.1', from unspecified.
|
||||||
In most cases, this does not result in a change in behavior,
|
|
||||||
as '' was interpreted as 'unspecified',
|
|
||||||
which used the subprocesses' own default, itself usually '127.0.0.1'.
|
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
@validate("ip")
|
||||||
|
def _strip_ipv6(self, proposal):
|
||||||
|
"""
|
||||||
|
Prior to 5.3.0 it was necessary to use [] when specifying an
|
||||||
|
[ipv6] due to the IP being concatenated with the port when forming URLs
|
||||||
|
without [].
|
||||||
|
|
||||||
|
To avoid breaking existing workarounds strip [].
|
||||||
|
"""
|
||||||
|
v = proposal["value"]
|
||||||
|
if v.startswith("[") and v.endswith("]"):
|
||||||
|
self.log.warning("Removing '[' ']' from Spawner.ip %s", self.ip)
|
||||||
|
v = v[1:-1]
|
||||||
|
return v
|
||||||
|
|
||||||
port = Integer(
|
port = Integer(
|
||||||
0,
|
0,
|
||||||
help="""
|
help="""
|
||||||
@@ -617,7 +633,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
return options_form
|
return options_form
|
||||||
|
|
||||||
options_from_form = Callable(
|
options_from_form = Union(
|
||||||
|
[Callable(), Unicode()],
|
||||||
help="""
|
help="""
|
||||||
Interpret HTTP form data
|
Interpret HTTP form data
|
||||||
|
|
||||||
@@ -629,7 +646,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
though it can contain bytes in addition to standard JSON data types.
|
though it can contain bytes in addition to standard JSON data types.
|
||||||
|
|
||||||
This method should not have any side effects.
|
This method should not have any side effects.
|
||||||
Any handling of `user_options` should be done in `.start()`
|
Any handling of `user_options` should be done in `.apply_user_options()` (JupyterHub 5.3)
|
||||||
|
or `.start()` (JupyterHub 5.2 or older)
|
||||||
to ensure consistent behavior across servers
|
to ensure consistent behavior across servers
|
||||||
spawned via the API and form submission page.
|
spawned via the API and form submission page.
|
||||||
|
|
||||||
@@ -643,16 +661,88 @@ class Spawner(LoggingConfigurable):
|
|||||||
(with additional support for bytes in case of uploaded file data),
|
(with additional support for bytes in case of uploaded file data),
|
||||||
and any non-bytes non-jsonable values will be replaced with None
|
and any non-bytes non-jsonable values will be replaced with None
|
||||||
if the user_options are re-used.
|
if the user_options are re-used.
|
||||||
|
|
||||||
|
.. versionadded:: 5.3
|
||||||
|
The strings `'simple'` and `'passthrough'` may be specified to select some predefined behavior.
|
||||||
|
These are the only string values accepted.
|
||||||
|
|
||||||
|
`'passthrough'` is the longstanding default behavior,
|
||||||
|
where form data is stored in `user_options` without modification.
|
||||||
|
With `'passthrough'`, `user_options` from a form will always be a dict of lists of strings.
|
||||||
|
|
||||||
|
`'simple'` applies some minimal processing that works for most simple forms:
|
||||||
|
|
||||||
|
- Single-value fields get unpacked from lists.
|
||||||
|
They are still always strings, no attempt is made to parse numbers, etc..
|
||||||
|
- Multi-value fields are left alone.
|
||||||
|
- The default checked value of "on" for a checkbox is converted to True.
|
||||||
|
This is the only non-string value that can be produced.
|
||||||
|
|
||||||
|
Example for `'simple'`::
|
||||||
|
|
||||||
|
{
|
||||||
|
"image": ["myimage"],
|
||||||
|
"checked": ["on"], # checkbox
|
||||||
|
"multi-select": ["a", "b"],
|
||||||
|
}
|
||||||
|
# becomes
|
||||||
|
{
|
||||||
|
"image": "myimage",
|
||||||
|
"checked": True,
|
||||||
|
"multi-select": ["a", "b"],
|
||||||
|
}
|
||||||
""",
|
""",
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
@default("options_from_form")
|
@default("options_from_form")
|
||||||
def _options_from_form(self):
|
def _options_from_form(self):
|
||||||
return self._default_options_from_form
|
return self._passthrough_options_from_form
|
||||||
|
|
||||||
def _default_options_from_form(self, form_data):
|
@validate("options_from_form")
|
||||||
|
def _validate_options_from_form(self, proposal):
|
||||||
|
# coerce special string values to callable
|
||||||
|
if proposal.value == "passthrough":
|
||||||
|
return self._passthrough_options_from_form
|
||||||
|
elif proposal.value == "simple":
|
||||||
|
return self._simple_options_from_form
|
||||||
|
else:
|
||||||
|
return proposal.value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _passthrough_options_from_form(form_data):
|
||||||
|
"""The longstanding default behavior for options_from_form
|
||||||
|
|
||||||
|
explicit opt-in via `options_from_form = 'passthrough'`
|
||||||
|
"""
|
||||||
return form_data
|
return form_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _simple_options_from_form(form_data):
|
||||||
|
"""Simple options_from_form
|
||||||
|
|
||||||
|
Enable via `options_from_form = 'simple'
|
||||||
|
|
||||||
|
Transforms simple single-value string inputs to actual strings,
|
||||||
|
when they arrive as length-1 lists.
|
||||||
|
|
||||||
|
The default "checked" value of "on" for checkboxes is converted to True.
|
||||||
|
Note: when a checkbox is unchecked in a form, its value is generally omitted, not set to any false value.
|
||||||
|
|
||||||
|
Multi-value inputs are left unmodifed as lists of strings.
|
||||||
|
"""
|
||||||
|
user_options = {}
|
||||||
|
for key, value_list in form_data.items():
|
||||||
|
if len(value_list) == 1:
|
||||||
|
value = value_list[0]
|
||||||
|
if value == "on":
|
||||||
|
# default for checkbox
|
||||||
|
value = True
|
||||||
|
else:
|
||||||
|
value = value_list
|
||||||
|
|
||||||
|
user_options[key] = value
|
||||||
|
return user_options
|
||||||
|
|
||||||
def run_options_from_form(self, form_data):
|
def run_options_from_form(self, form_data):
|
||||||
sig = signature(self.options_from_form)
|
sig = signature(self.options_from_form)
|
||||||
if 'spawner' in sig.parameters:
|
if 'spawner' in sig.parameters:
|
||||||
@@ -691,12 +781,140 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
return self.options_from_form(query_data)
|
return self.options_from_form(query_data)
|
||||||
|
|
||||||
|
apply_user_options = Union(
|
||||||
|
[Callable(), Dict()],
|
||||||
|
config=True,
|
||||||
|
default_value=None,
|
||||||
|
allow_none=True,
|
||||||
|
help="""
|
||||||
|
Hook to apply inputs from user_options to the Spawner.
|
||||||
|
|
||||||
|
Typically takes values in user_options, validates them, and updates Spawner attributes::
|
||||||
|
|
||||||
|
def apply_user_options(spawner, user_options):
|
||||||
|
if "image" in user_options and isinstance(user_options["image"], str):
|
||||||
|
spawner.image = user_options["image"]
|
||||||
|
|
||||||
|
c.Spawner.apply_user_options = apply_user_options
|
||||||
|
|
||||||
|
`apply_user_options` *may* be async.
|
||||||
|
|
||||||
|
Default: do nothing.
|
||||||
|
|
||||||
|
Typically a callable which takes `(spawner: Spawner, user_options: dict)`,
|
||||||
|
but for simple cases this can be a dict mapping user option fields to Spawner attribute names,
|
||||||
|
e.g.::
|
||||||
|
|
||||||
|
c.Spawner.apply_user_options = {"image_input": "image"}
|
||||||
|
c.Spawner.options_from_form = "simple"
|
||||||
|
|
||||||
|
allows users to specify the image attribute, but not any others.
|
||||||
|
Because `user_options` generally comes in as strings in form data,
|
||||||
|
the dictionary mode uses traitlets `from_string` to coerce strings to values,
|
||||||
|
which allows setting simple values from strings (e.g. numbers)
|
||||||
|
without needing to implement callable hooks.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Because `user_options` is user input
|
||||||
|
and may be set directly via the REST API,
|
||||||
|
no assumptions should be made on its structure or contents.
|
||||||
|
An empty dict should always be supported.
|
||||||
|
Make sure to validate any inputs before applying them,
|
||||||
|
either in this callable, or in whatever is consuming the value
|
||||||
|
if this is a dict.
|
||||||
|
|
||||||
|
.. versionadded:: 5.3
|
||||||
|
|
||||||
|
Prior to 5.3, applying user options must be done in `Spawner.start()`
|
||||||
|
or `Spawner.pre_spawn_hook()`.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_apply_user_options(self, user_options):
|
||||||
|
"""Run the apply_user_options hook
|
||||||
|
|
||||||
|
and turn errors into HTTP 400
|
||||||
|
"""
|
||||||
|
r = None
|
||||||
|
try:
|
||||||
|
if isinstance(self.apply_user_options, dict):
|
||||||
|
r = self._apply_user_options_dict(user_options)
|
||||||
|
elif self.apply_user_options:
|
||||||
|
r = self.apply_user_options(self, user_options)
|
||||||
|
elif user_options:
|
||||||
|
keys = list(user_options)
|
||||||
|
self.log.warning(
|
||||||
|
f"Received unhandled user_options for {self._log_name}: {', '.join(keys)}"
|
||||||
|
)
|
||||||
|
if isawaitable(r):
|
||||||
|
await r
|
||||||
|
except Exception as e:
|
||||||
|
# this may not be the users' fault...
|
||||||
|
# should we catch less?
|
||||||
|
# likely user errors are ValueError, TraitError, TypeError
|
||||||
|
self.log.exception("Exception applying user_options for %s", self._log_name)
|
||||||
|
if isinstance(e, web.HTTPError):
|
||||||
|
# passthrough hook's HTTPError, so it can display a custom message
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise web.HTTPError(400, "Invalid user options")
|
||||||
|
|
||||||
|
def _apply_user_options_dict(self, user_options):
|
||||||
|
"""if apply_user_options is a dict
|
||||||
|
|
||||||
|
Allows fully declarative apply_user_options configuration
|
||||||
|
for simple cases where users may set attributes directly
|
||||||
|
from values in user_options.
|
||||||
|
"""
|
||||||
|
traits = self.traits()
|
||||||
|
for key, value in user_options.items():
|
||||||
|
attr = self.apply_user_options.get(key, None)
|
||||||
|
if attr is None:
|
||||||
|
self.log.warning(f"Unhandled user option {key} for {self._log_name}")
|
||||||
|
elif hasattr(self, attr):
|
||||||
|
# require traits? I think not, but we should require declaration, at least
|
||||||
|
# use trait from_string for string coercion if available, though
|
||||||
|
try:
|
||||||
|
setattr(self, attr, value)
|
||||||
|
except Exception as e:
|
||||||
|
# try coercion from string via traits
|
||||||
|
# this will mostly affect numbers
|
||||||
|
if attr in traits and isinstance(value, str):
|
||||||
|
# try coercion, may not work
|
||||||
|
try:
|
||||||
|
value = traits[attr].from_string(value)
|
||||||
|
except Exception:
|
||||||
|
# raise original assignment error, likely more informative
|
||||||
|
raise e
|
||||||
|
else:
|
||||||
|
setattr(self, attr, value)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
self.log.error(
|
||||||
|
f"No such Spawner attribute {attr} for user option {key} on {self._log_name}"
|
||||||
|
)
|
||||||
|
|
||||||
user_options = Dict(
|
user_options = Dict(
|
||||||
help="""
|
help="""
|
||||||
Dict of user specified options for the user's spawned instance of a single-user server.
|
Dict of user specified options for the user's spawned instance of a single-user server.
|
||||||
|
|
||||||
These user options are usually provided by the `options_form` displayed to the user when they start
|
These user options are usually provided by the `options_form` displayed to the user when they start
|
||||||
their server.
|
their server.
|
||||||
|
If specified via an `options_form`, form data is passed through `options_from_form` before storing
|
||||||
|
in `user_options`.
|
||||||
|
`user_options` may also be passed as the JSON body to a spawn request via the REST API,
|
||||||
|
in which case it is stored directly, unmodifed.
|
||||||
|
|
||||||
|
`user_options` has no effect on its own, it must be handled by the Spawner in `spawner.start`,
|
||||||
|
or via deployment configuration in `apply_user_options` or `pre_spawn_hook`.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
- :attr:`options_form`
|
||||||
|
- :attr:`options_from_form`
|
||||||
|
- :attr:`apply_user_options`
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1099,7 +1317,7 @@ class Spawner(LoggingConfigurable):
|
|||||||
base_url = '/'
|
base_url = '/'
|
||||||
|
|
||||||
proto = 'https' if self.internal_ssl else 'http'
|
proto = 'https' if self.internal_ssl else 'http'
|
||||||
bind_url = f"{proto}://{self.ip}:{self.port}{base_url}"
|
bind_url = f"{proto}://{fmt_ip_url(self.ip)}:{self.port}{base_url}"
|
||||||
env["JUPYTERHUB_SERVICE_URL"] = bind_url
|
env["JUPYTERHUB_SERVICE_URL"] = bind_url
|
||||||
|
|
||||||
# the public URLs of this server and the Hub
|
# the public URLs of this server and the Hub
|
||||||
|
@@ -291,7 +291,8 @@ async def test_spawn_pending_progress(
|
|||||||
"Spawning server...",
|
"Spawning server...",
|
||||||
f"Server ready at {app.base_url}user/{urlname}/",
|
f"Server ready at {app.base_url}user/{urlname}/",
|
||||||
]
|
]
|
||||||
while not user.spawner.ready:
|
logs_list = []
|
||||||
|
while not user.spawner.ready and len(logs_list) < len(expected_messages):
|
||||||
logs_list = [
|
logs_list = [
|
||||||
await log.text_content()
|
await log.text_content()
|
||||||
for log in await browser.locator("div.progress-log-event").all()
|
for log in await browser.locator("div.progress-log-event").all()
|
||||||
@@ -1233,18 +1234,16 @@ async def test_search_on_admin_page(
|
|||||||
await element_search.fill(search_value, force=True)
|
await element_search.fill(search_value, force=True)
|
||||||
await browser.wait_for_load_state("networkidle")
|
await browser.wait_for_load_state("networkidle")
|
||||||
# get the result of the search from db
|
# get the result of the search from db
|
||||||
users_count_db_filtered = (
|
total = (
|
||||||
app.db.query(orm.User).filter(orm.User.name.like(f'%{search_value}%')).count()
|
app.db.query(orm.User).filter(orm.User.name.like(f'%{search_value}%')).count()
|
||||||
)
|
)
|
||||||
# get the result of the search
|
# get the result of the search
|
||||||
filtered_list_on_page = browser.locator('//tr[@class="user-row"]')
|
filtered_list_on_page = browser.locator('//tr[@class="user-row"]')
|
||||||
displaying = browser.get_by_text("Displaying")
|
displaying = browser.get_by_text("Displaying")
|
||||||
if users_count_db_filtered <= 50:
|
if total <= 50:
|
||||||
await expect(filtered_list_on_page).to_have_count(users_count_db_filtered)
|
await expect(filtered_list_on_page).to_have_count(total)
|
||||||
start = 1 if users_count_db_filtered else 0
|
start = 1 if total else 0
|
||||||
await expect(displaying).to_contain_text(
|
await expect(displaying).to_contain_text(f"{start}-{total}")
|
||||||
re.compile(f"{start}-{users_count_db_filtered}")
|
|
||||||
)
|
|
||||||
# check that users names contain the search value in the filtered list
|
# check that users names contain the search value in the filtered list
|
||||||
for element in await filtered_list_on_page.get_by_test_id(
|
for element in await filtered_list_on_page.get_by_test_id(
|
||||||
"user-row-name"
|
"user-row-name"
|
||||||
@@ -1252,12 +1251,19 @@ async def test_search_on_admin_page(
|
|||||||
await expect(element).to_contain_text(re.compile(f".*{search_value}.*"))
|
await expect(element).to_contain_text(re.compile(f".*{search_value}.*"))
|
||||||
else:
|
else:
|
||||||
await expect(filtered_list_on_page).to_have_count(50)
|
await expect(filtered_list_on_page).to_have_count(50)
|
||||||
await expect(displaying).to_contain_text(re.compile("1-50"))
|
# make sure we wait for 'of {total}', otherwise we might not have waited
|
||||||
|
# until the name filter has been applied
|
||||||
|
await expect(displaying).to_contain_text(f"1-50 of {total}")
|
||||||
|
# check that users names contain the search value in the filtered list
|
||||||
|
for element in await filtered_list_on_page.get_by_test_id(
|
||||||
|
"user-row-name"
|
||||||
|
).all():
|
||||||
|
await expect(element).to_contain_text(re.compile(f".*{search_value}.*"))
|
||||||
# click on Next button to verify that the rest part of filtered list is displayed on the next page
|
# click on Next button to verify that the rest part of filtered list is displayed on the next page
|
||||||
await browser.get_by_role("button", name="Next").click()
|
await browser.get_by_role("button", name="Next").click()
|
||||||
await browser.wait_for_load_state("networkidle")
|
await browser.wait_for_load_state("networkidle")
|
||||||
filtered_list_on_next_page = browser.locator('//tr[@class="user-row"]')
|
filtered_list_on_next_page = browser.locator('//tr[@class="user-row"]')
|
||||||
await expect(filtered_list_on_page).to_have_count(users_count_db_filtered - 50)
|
await expect(filtered_list_on_page).to_have_count(total - 50)
|
||||||
for element in await filtered_list_on_next_page.get_by_test_id(
|
for element in await filtered_list_on_next_page.get_by_test_id(
|
||||||
"user-row-name"
|
"user-row-name"
|
||||||
).all():
|
).all():
|
||||||
@@ -1414,15 +1420,31 @@ async def test_login_xsrf_initial_cookies(app, browser, case, username):
|
|||||||
# after visiting page, cookies get re-established
|
# after visiting page, cookies get re-established
|
||||||
await browser.goto(login_url)
|
await browser.goto(login_url)
|
||||||
cookies = await browser.context.cookies()
|
cookies = await browser.context.cookies()
|
||||||
|
cookies = sorted(cookies, key=lambda cookie: len(cookie['path'] or ''))
|
||||||
print(cookies)
|
print(cookies)
|
||||||
cookie = cookies[0]
|
cookie = cookies[-1]
|
||||||
assert cookie['name'] == '_xsrf'
|
assert cookie['name'] == '_xsrf'
|
||||||
assert cookie["path"] == app.hub.base_url
|
assert cookie["path"] == app.hub.base_url
|
||||||
|
# make sure cookie matches form input
|
||||||
|
xsrf_input = browser.locator('//input[@name="_xsrf"]')
|
||||||
|
await expect(xsrf_input).to_have_value(cookie["value"])
|
||||||
|
|
||||||
# next page visit, cookies don't change
|
# every visit to login page resets the xsrf cookie
|
||||||
|
# value will only change if timestamp advances
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
await browser.goto(login_url)
|
await browser.goto(login_url)
|
||||||
cookies_2 = await browser.context.cookies()
|
cookies_2 = await browser.context.cookies()
|
||||||
assert cookies == cookies_2
|
cookies_2 = sorted(cookies_2, key=lambda cookie: len(cookie['path'] or ''))
|
||||||
|
print(cookies_2)
|
||||||
|
new_cookie = cookies_2[-1]
|
||||||
|
# xsrf cookie reset
|
||||||
|
assert new_cookie['name'] == "_xsrf"
|
||||||
|
assert new_cookie != cookie
|
||||||
|
assert new_cookie["expires"] > cookie["expires"]
|
||||||
|
# make sure cookie matches form input
|
||||||
|
xsrf_input = browser.locator('//input[@name="_xsrf"]')
|
||||||
|
await expect(xsrf_input).to_have_value(new_cookie["value"])
|
||||||
|
|
||||||
# login is successful
|
# login is successful
|
||||||
await login(browser, username, username)
|
await login(browser, username, username)
|
||||||
|
|
||||||
|
@@ -32,6 +32,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from subprocess import TimeoutExpired
|
from subprocess import TimeoutExpired
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
from warnings import warn
|
||||||
|
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from packaging.version import parse as parse_version
|
from packaging.version import parse as parse_version
|
||||||
@@ -175,8 +176,21 @@ async def io_loop(request):
|
|||||||
assert io_loop.asyncio_loop is event_loop
|
assert io_loop.asyncio_loop is event_loop
|
||||||
|
|
||||||
def _close():
|
def _close():
|
||||||
|
# cleanup everything
|
||||||
|
try:
|
||||||
|
event_loop.run_until_complete(event_loop.shutdown_asyncgens())
|
||||||
|
except (asyncio.CancelledError, RuntimeError):
|
||||||
|
pass
|
||||||
io_loop.close(all_fds=True)
|
io_loop.close(all_fds=True)
|
||||||
|
|
||||||
|
# workaround pytest-asyncio trying to cleanup after loop is closed
|
||||||
|
# problem introduced in pytest-asyncio 0.25.2
|
||||||
|
def noop(*args, **kwargs):
|
||||||
|
warn("Loop used after close...", RuntimeWarning, stacklevel=2)
|
||||||
|
return
|
||||||
|
|
||||||
|
event_loop.run_until_complete = noop
|
||||||
|
|
||||||
request.addfinalizer(_close)
|
request.addfinalizer(_close)
|
||||||
return io_loop
|
return io_loop
|
||||||
|
|
||||||
@@ -358,14 +372,15 @@ async def _mockservice(request, app, name, external=False, url=False):
|
|||||||
(as opposed to headless, API-only).
|
(as opposed to headless, API-only).
|
||||||
"""
|
"""
|
||||||
spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
|
spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
|
||||||
|
port = random_port()
|
||||||
if url:
|
if url:
|
||||||
if app.internal_ssl:
|
if app.internal_ssl:
|
||||||
spec['url'] = 'https://127.0.0.1:%i' % random_port()
|
spec['url'] = f'https://127.0.0.1:{port}'
|
||||||
else:
|
else:
|
||||||
spec['url'] = 'http://127.0.0.1:%i' % random_port()
|
spec['url'] = f'http://127.0.0.1:{port}'
|
||||||
|
|
||||||
if external:
|
if external:
|
||||||
spec['oauth_redirect_uri'] = 'http://127.0.0.1:%i' % random_port()
|
spec['oauth_redirect_uri'] = f'http://127.0.0.1:{port}'
|
||||||
|
|
||||||
event_loop = asyncio.get_running_loop()
|
event_loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
|
@@ -246,6 +246,13 @@ class MockHub(JupyterHub):
|
|||||||
if 'allow_all' not in self.config.Authenticator:
|
if 'allow_all' not in self.config.Authenticator:
|
||||||
self.config.Authenticator.allow_all = True
|
self.config.Authenticator.allow_all = True
|
||||||
|
|
||||||
|
if 'api_url' not in self.config.ConfigurableHTTPProxy:
|
||||||
|
proxy_port = random_port()
|
||||||
|
proxy_proto = "https" if self.internal_ssl else "http"
|
||||||
|
self.config.ConfigurableHTTPProxy.api_url = (
|
||||||
|
f"{proxy_proto}://127.0.0.1:{proxy_port}"
|
||||||
|
)
|
||||||
|
|
||||||
@default('subdomain_host')
|
@default('subdomain_host')
|
||||||
def _subdomain_host_default(self):
|
def _subdomain_host_default(self):
|
||||||
return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
|
return os.environ.get('JUPYTERHUB_TEST_SUBDOMAIN_HOST', '')
|
||||||
@@ -256,7 +263,7 @@ class MockHub(JupyterHub):
|
|||||||
port = urlparse(self.subdomain_host).port
|
port = urlparse(self.subdomain_host).port
|
||||||
else:
|
else:
|
||||||
port = random_port()
|
port = random_port()
|
||||||
return 'http://127.0.0.1:%i/@/space%%20word/' % (port,)
|
return f'http://127.0.0.1:{port}/@/space%20word/'
|
||||||
|
|
||||||
@default('ip')
|
@default('ip')
|
||||||
def _ip_default(self):
|
def _ip_default(self):
|
||||||
@@ -270,6 +277,10 @@ class MockHub(JupyterHub):
|
|||||||
return port
|
return port
|
||||||
return random_port()
|
return random_port()
|
||||||
|
|
||||||
|
@default('hub_port')
|
||||||
|
def _hub_port_default(self):
|
||||||
|
return random_port()
|
||||||
|
|
||||||
@default('authenticator_class')
|
@default('authenticator_class')
|
||||||
def _authenticator_class_default(self):
|
def _authenticator_class_default(self):
|
||||||
return MockPAMAuthenticator
|
return MockPAMAuthenticator
|
||||||
|
@@ -2238,13 +2238,15 @@ async def test_auth_managed_groups(request, app, group, user):
|
|||||||
app.authenticator.manage_groups = True
|
app.authenticator.manage_groups = True
|
||||||
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
|
request.addfinalizer(lambda: setattr(app.authenticator, "manage_groups", False))
|
||||||
# create groups
|
# create groups
|
||||||
r = await api_request(app, 'groups', method='post')
|
r = await api_request(
|
||||||
assert r.status_code == 400
|
app,
|
||||||
|
'groups',
|
||||||
|
method='post',
|
||||||
|
data=json.dumps({"groups": {"groupname": [user.name]}}),
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
r = await api_request(app, 'groups/newgroup', method='post')
|
r = await api_request(app, 'groups/newgroup', method='post')
|
||||||
assert r.status_code == 400
|
assert r.status_code == 201
|
||||||
# delete groups
|
|
||||||
r = await api_request(app, f'groups/{group.name}', method='delete')
|
|
||||||
assert r.status_code == 400
|
|
||||||
# add users to group
|
# add users to group
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app,
|
app,
|
||||||
@@ -2252,7 +2254,7 @@ async def test_auth_managed_groups(request, app, group, user):
|
|||||||
method='post',
|
method='post',
|
||||||
data=json.dumps({"users": [user.name]}),
|
data=json.dumps({"users": [user.name]}),
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 200
|
||||||
# remove users from group
|
# remove users from group
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app,
|
app,
|
||||||
@@ -2260,7 +2262,10 @@ async def test_auth_managed_groups(request, app, group, user):
|
|||||||
method='delete',
|
method='delete',
|
||||||
data=json.dumps({"users": [user.name]}),
|
data=json.dumps({"users": [user.name]}),
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 200
|
||||||
|
# delete groups
|
||||||
|
r = await api_request(app, f'groups/{group.name}', method='delete')
|
||||||
|
assert r.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
# -----------------
|
# -----------------
|
||||||
|
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from .. import crypto
|
from .. import crypto
|
||||||
from ..crypto import decrypt, encrypt
|
from ..crypto import decrypt, encrypt
|
||||||
|
|
||||||
keys = [('%i' % i).encode('ascii') * 32 for i in range(3)]
|
keys = [str(i).encode('ascii') * 32 for i in range(3)]
|
||||||
hex_keys = [b2a_hex(key).decode('ascii') for key in keys]
|
hex_keys = [b2a_hex(key).decode('ascii') for key in keys]
|
||||||
b64_keys = [b2a_base64(key).decode('ascii').strip() for key in keys]
|
b64_keys = [b2a_base64(key).decode('ascii').strip() for key in keys]
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ def test_env_constructor(key_env, keys):
|
|||||||
"key",
|
"key",
|
||||||
[
|
[
|
||||||
'a' * 44, # base64, not 32 bytes
|
'a' * 44, # base64, not 32 bytes
|
||||||
('%44s' % 'notbase64'), # not base64
|
f"{'notbase64':44}", # not base64
|
||||||
b'x' * 64, # not hex
|
b'x' * 64, # not hex
|
||||||
b'short', # not 32 bytes
|
b'short', # not 32 bytes
|
||||||
],
|
],
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# Copyright (c) Jupyter Development Team.
|
# Copyright (c) Jupyter Development Team.
|
||||||
# Distributed under the terms of the Modified BSD License.
|
# Distributed under the terms of the Modified BSD License.
|
||||||
|
|
||||||
from jupyterhub.auth import DummyAuthenticator
|
from jupyterhub.auth import DummyAuthenticator
|
||||||
|
|
||||||
|
|
||||||
|
@@ -33,19 +33,19 @@ def test_server(db):
|
|||||||
|
|
||||||
# test wrapper
|
# test wrapper
|
||||||
server = objects.Server(orm_server=server)
|
server = objects.Server(orm_server=server)
|
||||||
assert server.host == 'http://%s:%i' % (socket.gethostname(), server.port)
|
assert server.host == f'http://{socket.gethostname()}:{server.port}'
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
assert server.bind_url == 'http://*:%i/' % server.port
|
assert server.bind_url == f'http://*:{server.port}/'
|
||||||
server.ip = '127.0.0.1'
|
server.ip = '127.0.0.1'
|
||||||
assert server.host == 'http://127.0.0.1:%i' % server.port
|
assert server.host == f'http://127.0.0.1:{server.port}'
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
|
|
||||||
server.connect_ip = 'hub'
|
server.connect_ip = 'hub'
|
||||||
assert server.host == 'http://hub:%i' % server.port
|
assert server.host == f'http://hub:{server.port}'
|
||||||
assert server.url == server.host + '/'
|
assert server.url == server.host + '/'
|
||||||
|
|
||||||
server.connect_url = 'http://hub-url:%i/connect' % server.port
|
server.connect_url = f'http://hub-url:{server.port}/connect'
|
||||||
assert server.host == 'http://hub-url:%i' % server.port
|
assert server.host == f'http://hub-url:{server.port}'
|
||||||
|
|
||||||
server.bind_url = 'http://127.0.0.1/'
|
server.bind_url = 'http://127.0.0.1/'
|
||||||
assert server.port == 80
|
assert server.port == 80
|
||||||
|
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from functools import partial
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse
|
from urllib.parse import parse_qs, urlencode, urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from tornado import web
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
from .. import orm, roles, scopes
|
from .. import orm, roles, scopes
|
||||||
@@ -720,14 +723,50 @@ async def test_page_with_token(app, user, url, token_in):
|
|||||||
|
|
||||||
|
|
||||||
async def test_login_fail(app):
|
async def test_login_fail(app):
|
||||||
|
name = 'wash'
|
||||||
|
base_url = public_url(app)
|
||||||
|
login_url = base_url + 'hub/login'
|
||||||
|
r = await async_requests.get(login_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
xsrf = r.cookies['_xsrf']
|
||||||
|
r = await async_requests.get(login_url)
|
||||||
|
assert set(r.cookies.keys()).issubset({"_xsrf"})
|
||||||
|
r = await async_requests.post(
|
||||||
|
login_url,
|
||||||
|
data={'username': name, 'password': 'wrong', '_xsrf': xsrf},
|
||||||
|
allow_redirects=False,
|
||||||
|
cookies=r.cookies,
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert set(r.cookies.keys()).issubset({"_xsrf"})
|
||||||
|
page = BeautifulSoup(r.content, "html.parser")
|
||||||
|
assert "Sign in" in page.text
|
||||||
|
login = page.find("form")
|
||||||
|
login_error = login.find(class_="login_error")
|
||||||
|
assert login_error
|
||||||
|
assert "Invalid user" in login_error.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_fail_xsrf_expired(app):
|
||||||
name = 'wash'
|
name = 'wash'
|
||||||
base_url = public_url(app)
|
base_url = public_url(app)
|
||||||
r = await async_requests.post(
|
r = await async_requests.post(
|
||||||
base_url + 'hub/login',
|
base_url + 'hub/login',
|
||||||
data={'username': name, 'password': 'wrong'},
|
data={
|
||||||
|
'username': name,
|
||||||
|
'password': name,
|
||||||
|
'_xsrf': "wrong",
|
||||||
|
},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
assert set(r.cookies.keys()).issubset({"_xsrf"})
|
assert set(r.cookies.keys()).issubset({"_xsrf"})
|
||||||
|
page = BeautifulSoup(r.content, "html.parser")
|
||||||
|
assert "Sign in" in page.text
|
||||||
|
login = page.find("form")
|
||||||
|
login_error = login.find(class_="login_error")
|
||||||
|
assert login_error
|
||||||
|
assert "Try again" in login_error.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -1060,7 +1099,7 @@ async def test_oauth_token_page(app):
|
|||||||
|
|
||||||
@pytest.mark.parametrize("error_status", [503, 404])
|
@pytest.mark.parametrize("error_status", [503, 404])
|
||||||
async def test_proxy_error(app, error_status):
|
async def test_proxy_error(app, error_status):
|
||||||
r = await get_page('/error/%i' % error_status, app)
|
r = await get_page(f'/error/{error_status}', app)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@@ -1335,3 +1374,86 @@ async def test_services_nav_links(
|
|||||||
assert service.href in nav_urls
|
assert service.href in nav_urls
|
||||||
else:
|
else:
|
||||||
assert service.href not in nav_urls
|
assert service.href not in nav_urls
|
||||||
|
|
||||||
|
|
||||||
|
class TeapotError(web.HTTPError):
|
||||||
|
text = "I'm a <🫖>"
|
||||||
|
html = "<b>🕸️🫖</b>"
|
||||||
|
|
||||||
|
def __init__(self, log_msg, kind="text"):
|
||||||
|
super().__init__(418, log_msg)
|
||||||
|
self.jupyterhub_message = self.text
|
||||||
|
if kind == "html":
|
||||||
|
self.jupyterhub_html_message = self.html
|
||||||
|
|
||||||
|
|
||||||
|
def hook_fail_fast(spawner, kind):
|
||||||
|
if kind == "unhandled":
|
||||||
|
raise RuntimeError("unhandle me!!!")
|
||||||
|
raise TeapotError("log_msg", kind=kind)
|
||||||
|
|
||||||
|
|
||||||
|
async def hook_fail_slow(spawner, kind):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
hook_fail_fast(spawner, kind)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("speed", ["fast", "slow"])
|
||||||
|
@pytest.mark.parametrize("kind", ["text", "html", "unhandled"])
|
||||||
|
async def test_spawn_fails_custom_message(app, user, kind, speed):
|
||||||
|
if speed == 'slow':
|
||||||
|
speed_context = mock.patch.dict(
|
||||||
|
app.tornado_settings, {'slow_spawn_timeout': 0.1}
|
||||||
|
)
|
||||||
|
hook = hook_fail_slow
|
||||||
|
else:
|
||||||
|
speed_context = nullcontext()
|
||||||
|
hook = hook_fail_fast
|
||||||
|
# test the response when spawn fails before redirecting to progress
|
||||||
|
with mock.patch.dict(
|
||||||
|
app.config.Spawner, {"pre_spawn_hook": partial(hook, kind=kind)}
|
||||||
|
), speed_context:
|
||||||
|
cookies = await app.login_user(user.name)
|
||||||
|
assert user.spawner.pre_spawn_hook
|
||||||
|
r = await get_page("spawn", app, cookies=cookies)
|
||||||
|
if speed == "slow":
|
||||||
|
# go through spawn_pending, render not_running.html
|
||||||
|
assert r.ok
|
||||||
|
assert "spawn-pending" in r.url
|
||||||
|
# wait for ready signal before checking next redirect
|
||||||
|
while user.spawner.active:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
app.log.info(
|
||||||
|
f"pending {user.spawner.active=}, {user.spawner._spawn_future=}"
|
||||||
|
)
|
||||||
|
# this should fetch the not-running page
|
||||||
|
app.log.info("getting again")
|
||||||
|
r = await get_page(
|
||||||
|
f"spawn-pending/{user.escaped_name}", app, cookies=cookies
|
||||||
|
)
|
||||||
|
target_class = "container"
|
||||||
|
unhandled_text = "Spawn failed"
|
||||||
|
else:
|
||||||
|
unhandled_text = "Unhandled error"
|
||||||
|
target_class = "error"
|
||||||
|
page = BeautifulSoup(r.content)
|
||||||
|
if kind == "unhandled":
|
||||||
|
assert r.status_code == 500
|
||||||
|
else:
|
||||||
|
assert r.status_code == 418
|
||||||
|
error = page.find(class_=target_class)
|
||||||
|
# check escaping properly
|
||||||
|
error_html = str(error)
|
||||||
|
if kind == "text":
|
||||||
|
assert "<🫖>" in error.text
|
||||||
|
assert "🕸️" not in error.text
|
||||||
|
assert "<🫖>" in error_html
|
||||||
|
elif kind == "html":
|
||||||
|
assert "<🫖>" not in error.text
|
||||||
|
assert "🕸️" in error.text
|
||||||
|
assert "<b>🕸️🫖</b>" in error_html
|
||||||
|
elif kind == "unhandled":
|
||||||
|
assert unhandled_text in error.text
|
||||||
|
assert "unhandle me" not in error.text
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unexpected {kind=}")
|
||||||
|
@@ -35,7 +35,7 @@ async def test_external_proxy(request):
|
|||||||
proxy_port = random_port()
|
proxy_port = random_port()
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
cfg.ConfigurableHTTPProxy.auth_token = auth_token
|
cfg.ConfigurableHTTPProxy.auth_token = auth_token
|
||||||
cfg.ConfigurableHTTPProxy.api_url = 'http://%s:%i' % (proxy_ip, proxy_port)
|
cfg.ConfigurableHTTPProxy.api_url = f'http://{proxy_ip}:{proxy_port}'
|
||||||
cfg.ConfigurableHTTPProxy.should_start = False
|
cfg.ConfigurableHTTPProxy.should_start = False
|
||||||
|
|
||||||
app = MockHub.instance(config=cfg)
|
app = MockHub.instance(config=cfg)
|
||||||
@@ -76,7 +76,7 @@ async def test_external_proxy(request):
|
|||||||
request.addfinalizer(_cleanup_proxy)
|
request.addfinalizer(_cleanup_proxy)
|
||||||
|
|
||||||
def wait_for_proxy():
|
def wait_for_proxy():
|
||||||
return wait_for_http_server('http://%s:%i' % (proxy_ip, proxy_port))
|
return wait_for_http_server(f'http://{proxy_ip}:{proxy_port}')
|
||||||
|
|
||||||
await wait_for_proxy()
|
await wait_for_proxy()
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ async def test_external_proxy(request):
|
|||||||
'--api-port',
|
'--api-port',
|
||||||
str(proxy_port),
|
str(proxy_port),
|
||||||
'--default-target',
|
'--default-target',
|
||||||
'http://%s:%i' % (app.hub_ip, app.hub_port),
|
f'http://{app.hub_ip}:{app.hub_port}',
|
||||||
]
|
]
|
||||||
if app.subdomain_host:
|
if app.subdomain_host:
|
||||||
cmd.append('--host-routing')
|
cmd.append('--host-routing')
|
||||||
|
@@ -27,7 +27,7 @@ async def external_service(app, name='mockservice'):
|
|||||||
'JUPYTERHUB_API_TOKEN': hexlify(os.urandom(5)),
|
'JUPYTERHUB_API_TOKEN': hexlify(os.urandom(5)),
|
||||||
'JUPYTERHUB_SERVICE_NAME': name,
|
'JUPYTERHUB_SERVICE_NAME': name,
|
||||||
'JUPYTERHUB_API_URL': url_path_join(app.hub.url, 'api/'),
|
'JUPYTERHUB_API_URL': url_path_join(app.hub.url, 'api/'),
|
||||||
'JUPYTERHUB_SERVICE_URL': 'http://127.0.0.1:%i' % random_port(),
|
'JUPYTERHUB_SERVICE_URL': f'http://127.0.0.1:{random_port()}',
|
||||||
}
|
}
|
||||||
proc = Popen(mockservice_cmd, env=env)
|
proc = Popen(mockservice_cmd, env=env)
|
||||||
try:
|
try:
|
||||||
|
162
jupyterhub/tests/test_shared_password.py
Normal file
162
jupyterhub/tests/test_shared_password.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import pytest
|
||||||
|
from traitlets.config import Config
|
||||||
|
|
||||||
|
from jupyterhub.authenticators.shared import SharedPasswordAuthenticator
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_password():
|
||||||
|
return "a" * 32
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_password():
|
||||||
|
return "user_password"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authenticator(admin_password, user_password):
|
||||||
|
return SharedPasswordAuthenticator(
|
||||||
|
admin_password=admin_password,
|
||||||
|
user_password=user_password,
|
||||||
|
admin_users={"admin"},
|
||||||
|
allow_all=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_password_validation():
|
||||||
|
authenticator = SharedPasswordAuthenticator()
|
||||||
|
# Validate length
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="admin_password must be at least 32 characters",
|
||||||
|
):
|
||||||
|
authenticator.admin_password = "a" * 31
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="user_password must be at least 8 characters",
|
||||||
|
):
|
||||||
|
authenticator.user_password = "a" * 7
|
||||||
|
|
||||||
|
# Validate that the passwords aren't the same
|
||||||
|
authenticator.user_password = "a" * 32
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="SharedPasswordAuthenticator.user_password and SharedPasswordAuthenticator.admin_password cannot be the same",
|
||||||
|
):
|
||||||
|
authenticator.admin_password = "a" * 32
|
||||||
|
|
||||||
|
# ok
|
||||||
|
authenticator.admin_password = "a" * 33
|
||||||
|
|
||||||
|
# check collision in the other order
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match="SharedPasswordAuthenticator.user_password and SharedPasswordAuthenticator.admin_password cannot be the same",
|
||||||
|
):
|
||||||
|
authenticator.user_password = "a" * 33
|
||||||
|
|
||||||
|
|
||||||
|
async def test_admin_password(authenticator, user_password, admin_password):
|
||||||
|
# Regular user, regular password
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'test_user', 'password': user_password}
|
||||||
|
)
|
||||||
|
assert authorized['name'] == 'test_user'
|
||||||
|
assert not authorized['admin']
|
||||||
|
|
||||||
|
# Regular user, admin password
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'test_user', 'password': admin_password}
|
||||||
|
)
|
||||||
|
assert not authorized
|
||||||
|
|
||||||
|
# Admin user, admin password
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'admin', 'password': admin_password}
|
||||||
|
)
|
||||||
|
assert authorized['name'] == 'admin'
|
||||||
|
assert authorized['admin']
|
||||||
|
|
||||||
|
# Admin user, regular password
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'admin', 'password': user_password}
|
||||||
|
)
|
||||||
|
assert not authorized
|
||||||
|
|
||||||
|
# Regular user, wrong password
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'test_user', 'password': 'blah'}
|
||||||
|
)
|
||||||
|
assert not authorized
|
||||||
|
|
||||||
|
# New username, allow_all is False
|
||||||
|
authenticator.allow_all = False
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'new_user', 'password': 'user_password'}
|
||||||
|
)
|
||||||
|
assert not authorized
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_passwords():
|
||||||
|
authenticator = SharedPasswordAuthenticator(
|
||||||
|
allow_all=True,
|
||||||
|
admin_users={"admin"},
|
||||||
|
user_password="",
|
||||||
|
admin_password="",
|
||||||
|
)
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'admin', 'password': ''}
|
||||||
|
)
|
||||||
|
assert not authorized
|
||||||
|
authorized = await authenticator.get_authenticated_user(
|
||||||
|
None, {'username': 'user', 'password': ''}
|
||||||
|
)
|
||||||
|
assert not authorized
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"auth_config, warns, not_warns",
|
||||||
|
[
|
||||||
|
pytest.param({}, "nobody can login", "", id="default"),
|
||||||
|
pytest.param(
|
||||||
|
{"allow_all": True},
|
||||||
|
"Nobody will be able to login",
|
||||||
|
"regular users",
|
||||||
|
id="no passwords",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"admin_password": "a" * 32}, "admin_users is not", "", id="no admin_users"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"admin_users": {"admin"}},
|
||||||
|
"admin_password is not",
|
||||||
|
"",
|
||||||
|
id="no admin_password",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"admin_users": {"admin"}, "admin_password": "a" * 32, "allow_all": True},
|
||||||
|
"No non-admin users will be able to login",
|
||||||
|
"",
|
||||||
|
id="only_admin",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_check_allow_config(caplog, auth_config, warns, not_warns):
|
||||||
|
# check log warnings
|
||||||
|
config = Config()
|
||||||
|
for key, value in auth_config.items():
|
||||||
|
setattr(config.SharedPasswordAuthenticator, key, value)
|
||||||
|
authenticator = SharedPasswordAuthenticator(config=config)
|
||||||
|
authenticator.check_allow_config()
|
||||||
|
if warns:
|
||||||
|
if isinstance(warns, str):
|
||||||
|
warns = [warns]
|
||||||
|
for snippet in warns:
|
||||||
|
assert snippet in caplog.text
|
||||||
|
if not_warns:
|
||||||
|
if isinstance(not_warns, str):
|
||||||
|
not_warns = [not_warns]
|
||||||
|
for snippet in not_warns:
|
||||||
|
assert snippet not in caplog.text
|
@@ -1342,6 +1342,13 @@ async def test_share_codes_api_revoke(
|
|||||||
method="delete",
|
method="delete",
|
||||||
name=requester.name,
|
name=requester.name,
|
||||||
)
|
)
|
||||||
|
if r.status_code != status:
|
||||||
|
# debug intermittent failure
|
||||||
|
print(f"{share_code=}")
|
||||||
|
print(f"{code=}")
|
||||||
|
print(f"{url=}")
|
||||||
|
for sc in db.query(orm.ShareCode):
|
||||||
|
print(f"{sc.id=}, {sc.prefix=}, {sc.hashed=}, {sc=}")
|
||||||
assert r.status_code == status
|
assert r.status_code == status
|
||||||
|
|
||||||
# other code unaffected
|
# other code unaffected
|
||||||
|
@@ -103,7 +103,7 @@ async def test_singleuser_auth(
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
# logout
|
# logout
|
||||||
r = await s.get(url_path_join(url, 'logout'))
|
r = await s.get(url_path_join(url, 'logout'), allow_redirects=False)
|
||||||
assert len(r.cookies) == 0
|
assert len(r.cookies) == 0
|
||||||
|
|
||||||
# accessing another user's server hits the oauth confirmation page
|
# accessing another user's server hits the oauth confirmation page
|
||||||
@@ -201,9 +201,9 @@ async def test_disable_user_config(request, app, tmp_path, full_spawn):
|
|||||||
# (symlink and real)
|
# (symlink and real)
|
||||||
def assert_not_in_home(path, name):
|
def assert_not_in_home(path, name):
|
||||||
path = Path(path).resolve()
|
path = Path(path).resolve()
|
||||||
assert not (str(path) + os.path.sep).startswith(
|
assert not (str(path) + os.path.sep).startswith(str(tmp_path) + os.path.sep), (
|
||||||
str(tmp_path) + os.path.sep
|
f"{name}: {path} is in home {tmp_path}"
|
||||||
), f"{name}: {path} is in home {tmp_path}"
|
)
|
||||||
|
|
||||||
for path in info['config_file_paths']:
|
for path in info['config_file_paths']:
|
||||||
assert_not_in_home(path, 'config_file_paths')
|
assert_not_in_home(path, 'config_file_paths')
|
||||||
|
@@ -260,7 +260,7 @@ async def test_shell_cmd(db, tmpdir, request):
|
|||||||
s.server.port = port
|
s.server.port = port
|
||||||
db.commit()
|
db.commit()
|
||||||
await wait_for_spawner(s)
|
await wait_for_spawner(s)
|
||||||
r = await async_requests.get('http://%s:%i/env' % (ip, port))
|
r = await async_requests.get(f'http://{ip}:{port}/env')
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
env = r.json()
|
env = r.json()
|
||||||
assert env['TESTVAR'] == 'foo'
|
assert env['TESTVAR'] == 'foo'
|
||||||
@@ -524,14 +524,53 @@ async def test_spawner_oauth_roles_bad(app, user):
|
|||||||
|
|
||||||
async def test_spawner_options_from_form(db):
|
async def test_spawner_options_from_form(db):
|
||||||
def options_from_form(form_data):
|
def options_from_form(form_data):
|
||||||
return form_data
|
options = {}
|
||||||
|
for key, value in form_data.items():
|
||||||
|
options[key] = value[0]
|
||||||
|
options["default"] = "added"
|
||||||
|
return options
|
||||||
|
|
||||||
spawner = new_spawner(db, options_from_form=options_from_form)
|
spawner = new_spawner(db, options_from_form=options_from_form)
|
||||||
form_data = {"key": ["value"]}
|
form_data = {"key": ["value"]}
|
||||||
result = spawner.run_options_from_form(form_data)
|
result = spawner.run_options_from_form(form_data)
|
||||||
for key, value in form_data.items():
|
assert result == {
|
||||||
assert key in result
|
"key": "value",
|
||||||
assert result[key] == value
|
"default": "added",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"options_from_form, expected",
|
||||||
|
[
|
||||||
|
pytest.param(None, "unchanged", id="default"),
|
||||||
|
pytest.param("passthrough", "unchanged", id="passthrough"),
|
||||||
|
pytest.param(
|
||||||
|
"simple",
|
||||||
|
{
|
||||||
|
"single": "value",
|
||||||
|
"multiple": ["a", "b"],
|
||||||
|
"checkbox": True,
|
||||||
|
"number": "1",
|
||||||
|
},
|
||||||
|
id="simple",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_predefined_options_from_form(db, options_from_form, expected):
|
||||||
|
kwargs = {}
|
||||||
|
if options_from_form:
|
||||||
|
kwargs["options_from_form"] = options_from_form
|
||||||
|
spawner = new_spawner(db, **kwargs)
|
||||||
|
form_data = {
|
||||||
|
"single": ["value"],
|
||||||
|
"multiple": ["a", "b"],
|
||||||
|
"checkbox": ["on"],
|
||||||
|
"number": ["1"],
|
||||||
|
}
|
||||||
|
if expected == "unchanged":
|
||||||
|
expected = form_data
|
||||||
|
result = spawner.run_options_from_form(form_data)
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
async def test_spawner_options_from_form_with_spawner(db):
|
async def test_spawner_options_from_form_with_spawner(db):
|
||||||
@@ -546,6 +585,44 @@ async def test_spawner_options_from_form_with_spawner(db):
|
|||||||
assert result[key] == value
|
assert result[key] == value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_apply_user_options_dict(db):
|
||||||
|
apply_user_options = {
|
||||||
|
# from_string doesn't work,
|
||||||
|
# but string assignment does
|
||||||
|
"mem": "mem_limit",
|
||||||
|
"notebook_dir": "notebook_dir",
|
||||||
|
"term_timeout": "term_timeout",
|
||||||
|
"start_timeout": "start_timeout",
|
||||||
|
"environment": "environment",
|
||||||
|
"unsupported": "unsupported",
|
||||||
|
}
|
||||||
|
user_options = {
|
||||||
|
"mem": "1G",
|
||||||
|
"notebook_dir": "/tmp",
|
||||||
|
"term_timeout": 1,
|
||||||
|
"start_timeout": "10",
|
||||||
|
"environment": {
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
# shouldn't set these values:
|
||||||
|
# unsupported, but declared;
|
||||||
|
"unsupported": 5,
|
||||||
|
# undeclared, but available
|
||||||
|
"cpu_limit": "1m",
|
||||||
|
}
|
||||||
|
if sys.version_info < (3, 9):
|
||||||
|
# traitlets added `from_string` after requiring Python 3.9
|
||||||
|
user_options["start_timeout"] = int(user_options["start_timeout"])
|
||||||
|
spawner = new_spawner(db, apply_user_options=apply_user_options)
|
||||||
|
await spawner._run_apply_user_options(user_options)
|
||||||
|
assert spawner.mem_limit == 1 << 30
|
||||||
|
assert spawner.notebook_dir == "/tmp"
|
||||||
|
assert spawner.term_timeout == 1
|
||||||
|
assert spawner.environment == {"key": "value"}
|
||||||
|
assert not hasattr(spawner, "unsupported")
|
||||||
|
assert spawner.cpu_limit is None
|
||||||
|
|
||||||
|
|
||||||
def test_spawner_server(db):
|
def test_spawner_server(db):
|
||||||
spawner = new_spawner(db)
|
spawner = new_spawner(db)
|
||||||
spawner.orm_spawner = None
|
spawner.orm_spawner = None
|
||||||
|
@@ -100,6 +100,29 @@ async def test_tornado_coroutines():
|
|||||||
assert (await t.tornado_coroutine()) == "gen.coroutine"
|
assert (await t.tornado_coroutine()) == "gen.coroutine"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"pieces, expected",
|
||||||
|
[
|
||||||
|
(("/"), "/"),
|
||||||
|
(("/", "/"), "/"),
|
||||||
|
(("/base", ""), "/base"),
|
||||||
|
(("/base/", ""), "/base/"),
|
||||||
|
(("/base", "abc", "def"), "/base/abc/def"),
|
||||||
|
(("/base/", "/abc/", "/def/"), "/base/abc/def/"),
|
||||||
|
(("/base", "", "/", ""), "/base/"),
|
||||||
|
((""), ""),
|
||||||
|
(("", ""), ""),
|
||||||
|
(("", "part", ""), "part"),
|
||||||
|
(("", "/part"), "part"),
|
||||||
|
(("", "part", "", "after"), "part/after"),
|
||||||
|
(("", "part", "", "after/", "", ""), "part/after/"),
|
||||||
|
(("abc", "def"), "abc/def"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_url_path_join(pieces, expected):
|
||||||
|
assert utils.url_path_join(*pieces) == expected
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"forwarded, x_scheme, x_forwarded_proto, expected",
|
"forwarded, x_scheme, x_forwarded_proto, expected",
|
||||||
[
|
[
|
||||||
|
@@ -786,7 +786,7 @@ class User:
|
|||||||
if handler:
|
if handler:
|
||||||
await self.refresh_auth(handler)
|
await self.refresh_auth(handler)
|
||||||
|
|
||||||
base_url = url_path_join(self.base_url, url_escape_path(server_name)) + '/'
|
base_url = url_path_join(self.base_url, url_escape_path(server_name), "/")
|
||||||
|
|
||||||
orm_server = orm.Server(base_url=base_url)
|
orm_server = orm.Server(base_url=base_url)
|
||||||
db.add(orm_server)
|
db.add(orm_server)
|
||||||
@@ -877,8 +877,7 @@ class User:
|
|||||||
api_token,
|
api_token,
|
||||||
url_path_join(self.url, url_escape_path(server_name), 'oauth_callback'),
|
url_path_join(self.url, url_escape_path(server_name), 'oauth_callback'),
|
||||||
allowed_scopes=allowed_scopes,
|
allowed_scopes=allowed_scopes,
|
||||||
description="Server at %s"
|
description=f"Server at {url_path_join(self.base_url, server_name, '/')}",
|
||||||
% (url_path_join(self.base_url, server_name) + '/'),
|
|
||||||
)
|
)
|
||||||
spawner.orm_spawner.oauth_client = oauth_client
|
spawner.orm_spawner.oauth_client = oauth_client
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -905,6 +904,7 @@ class User:
|
|||||||
# wait for spawner.start to return
|
# wait for spawner.start to return
|
||||||
# run optional preparation work to bootstrap the notebook
|
# run optional preparation work to bootstrap the notebook
|
||||||
await spawner.apply_group_overrides()
|
await spawner.apply_group_overrides()
|
||||||
|
await spawner._run_apply_user_options(spawner.user_options)
|
||||||
await maybe_future(spawner.run_pre_spawn_hook())
|
await maybe_future(spawner.run_pre_spawn_hook())
|
||||||
if self.settings.get('internal_ssl'):
|
if self.settings.get('internal_ssl'):
|
||||||
self.log.debug("Creating internal SSL certs for %s", spawner._log_name)
|
self.log.debug("Creating internal SSL certs for %s", spawner._log_name)
|
||||||
@@ -920,19 +920,16 @@ class User:
|
|||||||
await asyncio.wait_for(f, timeout=spawner.start_timeout)
|
await asyncio.wait_for(f, timeout=spawner.start_timeout)
|
||||||
url = f.result()
|
url = f.result()
|
||||||
if url:
|
if url:
|
||||||
# get ip, port info from return value of start()
|
# get url from return value of start()
|
||||||
if isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
# >= 0.9 can return a full URL string
|
# older Spawners return (ip, port)
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# >= 0.7 returns (ip, port)
|
|
||||||
proto = 'https' if self.settings['internal_ssl'] else 'http'
|
proto = 'https' if self.settings['internal_ssl'] else 'http'
|
||||||
|
ip, port = url
|
||||||
# check if spawner returned an IPv6 address
|
# check if spawner returned an IPv6 address
|
||||||
if ':' in url[0]:
|
if ':' in ip:
|
||||||
url = '%s://[%s]:%i' % ((proto,) + url)
|
# ipv6 needs [::] in url
|
||||||
else:
|
ip = f'[{ip}]'
|
||||||
url = '%s://%s:%i' % ((proto,) + url)
|
url = f'{proto}://{ip}:{int(port)}'
|
||||||
urlinfo = urlparse(url)
|
urlinfo = urlparse(url)
|
||||||
server.proto = urlinfo.scheme
|
server.proto = urlinfo.scheme
|
||||||
server.ip = urlinfo.hostname
|
server.ip = urlinfo.hostname
|
||||||
|
@@ -108,8 +108,10 @@ def can_connect(ip, port):
|
|||||||
|
|
||||||
Return True if we can connect, False otherwise.
|
Return True if we can connect, False otherwise.
|
||||||
"""
|
"""
|
||||||
if ip in {'', '0.0.0.0', '::'}:
|
if ip in {'', '0.0.0.0'}:
|
||||||
ip = '127.0.0.1'
|
ip = '127.0.0.1'
|
||||||
|
elif ip == "::":
|
||||||
|
ip = "::1"
|
||||||
try:
|
try:
|
||||||
socket.create_connection((ip, port)).close()
|
socket.create_connection((ip, port)).close()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -267,17 +269,20 @@ async def exponential_backoff(
|
|||||||
|
|
||||||
async def wait_for_server(ip, port, timeout=10):
|
async def wait_for_server(ip, port, timeout=10):
|
||||||
"""Wait for any server to show up at ip:port."""
|
"""Wait for any server to show up at ip:port."""
|
||||||
if ip in {'', '0.0.0.0', '::'}:
|
if ip in {'', '0.0.0.0'}:
|
||||||
ip = '127.0.0.1'
|
ip = '127.0.0.1'
|
||||||
app_log.debug("Waiting %ss for server at %s:%s", timeout, ip, port)
|
elif ip == "::":
|
||||||
|
ip = "::1"
|
||||||
|
display_ip = fmt_ip_url(ip)
|
||||||
|
app_log.debug("Waiting %ss for server at %s:%s", timeout, display_ip, port)
|
||||||
tic = time.perf_counter()
|
tic = time.perf_counter()
|
||||||
await exponential_backoff(
|
await exponential_backoff(
|
||||||
lambda: can_connect(ip, port),
|
lambda: can_connect(ip, port),
|
||||||
f"Server at {ip}:{port} didn't respond in {timeout} seconds",
|
f"Server at {display_ip}:{port} didn't respond in {timeout} seconds",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
toc = time.perf_counter()
|
toc = time.perf_counter()
|
||||||
app_log.debug("Server at %s:%s responded in %.2fs", ip, port, toc - tic)
|
app_log.debug("Server at %s:%s responded in %.2fs", display_ip, port, toc - tic)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_http_server(url, timeout=10, ssl_context=None):
|
async def wait_for_http_server(url, timeout=10, ssl_context=None):
|
||||||
@@ -466,9 +471,15 @@ def url_path_join(*pieces):
|
|||||||
|
|
||||||
Use to prevent double slash when joining subpath. This will leave the
|
Use to prevent double slash when joining subpath. This will leave the
|
||||||
initial and final / in place.
|
initial and final / in place.
|
||||||
|
Empty trailing items are ignored.
|
||||||
|
|
||||||
Copied from `notebook.utils.url_path_join`.
|
Based on `notebook.utils.url_path_join`.
|
||||||
"""
|
"""
|
||||||
|
pieces = list(pieces)
|
||||||
|
while pieces and not pieces[-1]:
|
||||||
|
del pieces[-1]
|
||||||
|
if not pieces:
|
||||||
|
return ""
|
||||||
initial = pieces[0].startswith('/')
|
initial = pieces[0].startswith('/')
|
||||||
final = pieces[-1].endswith('/')
|
final = pieces[-1].endswith('/')
|
||||||
stripped = [s.strip('/') for s in pieces]
|
stripped = [s.strip('/') for s in pieces]
|
||||||
@@ -502,20 +513,20 @@ def print_ps_info(file=sys.stderr):
|
|||||||
# format CPU percentage
|
# format CPU percentage
|
||||||
cpu = p.cpu_percent(0.1)
|
cpu = p.cpu_percent(0.1)
|
||||||
if cpu >= 10:
|
if cpu >= 10:
|
||||||
cpu_s = "%i" % cpu
|
cpu_s = str(int(cpu))
|
||||||
else:
|
else:
|
||||||
cpu_s = f"{cpu:.1f}"
|
cpu_s = f"{cpu:.1f}"
|
||||||
|
|
||||||
# format memory (only resident set)
|
# format memory (only resident set)
|
||||||
rss = p.memory_info().rss
|
rss = p.memory_info().rss
|
||||||
if rss >= 1e9:
|
if rss >= 1e9:
|
||||||
mem_s = '%.1fG' % (rss / 1e9)
|
mem_s = f'{rss / 1e9:.1f}G'
|
||||||
elif rss >= 1e7:
|
elif rss >= 1e7:
|
||||||
mem_s = '%.0fM' % (rss / 1e6)
|
mem_s = f'{rss / 1e6:.0f}M'
|
||||||
elif rss >= 1e6:
|
elif rss >= 1e6:
|
||||||
mem_s = '%.1fM' % (rss / 1e6)
|
mem_s = f'{rss / 1e6:.1f}M'
|
||||||
else:
|
else:
|
||||||
mem_s = '%.0fk' % (rss / 1e3)
|
mem_s = f'{rss / 1e3:.0f}k'
|
||||||
|
|
||||||
# left-justify and shrink-to-fit columns
|
# left-justify and shrink-to-fit columns
|
||||||
cpulen = max(len(cpu_s), 4)
|
cpulen = max(len(cpu_s), 4)
|
||||||
@@ -560,7 +571,7 @@ def print_stacks(file=sys.stderr):
|
|||||||
|
|
||||||
from .log import coroutine_frames
|
from .log import coroutine_frames
|
||||||
|
|
||||||
print("Active threads: %i" % threading.active_count(), file=file)
|
print(f"Active threads: {threading.active_count()}", file=file)
|
||||||
for thread in threading.enumerate():
|
for thread in threading.enumerate():
|
||||||
print(f"Thread {thread.name}:", end='', file=file)
|
print(f"Thread {thread.name}:", end='', file=file)
|
||||||
frame = sys._current_frames()[thread.ident]
|
frame = sys._current_frames()[thread.ident]
|
||||||
@@ -592,7 +603,7 @@ def print_stacks(file=sys.stderr):
|
|||||||
# coroutines to native `async def`
|
# coroutines to native `async def`
|
||||||
tasks = asyncio_all_tasks()
|
tasks = asyncio_all_tasks()
|
||||||
if tasks:
|
if tasks:
|
||||||
print("AsyncIO tasks: %i" % len(tasks))
|
print(f"AsyncIO tasks: {len(tasks)}")
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
task.print_stack(file=file)
|
task.print_stack(file=file)
|
||||||
|
|
||||||
@@ -962,3 +973,13 @@ def recursive_update(target, new):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
target[k] = v
|
target[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_ip_url(ip):
|
||||||
|
"""
|
||||||
|
Format an IP for use in URLs. IPv6 is wrapped with [], everything else is
|
||||||
|
unchanged
|
||||||
|
"""
|
||||||
|
if ":" in ip:
|
||||||
|
return f"[{ip}]"
|
||||||
|
return ip
|
||||||
|
@@ -1,12 +0,0 @@
|
|||||||
# JupyterHub Dockerfile that loads your jupyterhub_config.py
|
|
||||||
#
|
|
||||||
# Adds ONBUILD step to jupyter/jupyterhub to load your jupyterhub_config.py into the image
|
|
||||||
#
|
|
||||||
# Derivative images must have jupyterhub_config.py next to the Dockerfile.
|
|
||||||
|
|
||||||
ARG BASE_IMAGE=quay.io/jupyterhub/jupyterhub:latest
|
|
||||||
FROM $BASE_IMAGE
|
|
||||||
|
|
||||||
ONBUILD COPY jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py
|
|
||||||
|
|
||||||
CMD ["jupyterhub", "-f", "/srv/jupyterhub/jupyterhub_config.py"]
|
|
@@ -1,17 +0,0 @@
|
|||||||
# JupyterHub onbuild image
|
|
||||||
|
|
||||||
If you base a Dockerfile on this image:
|
|
||||||
|
|
||||||
FROM quay.io/jupyterhub/jupyterhub-onbuild:4.0.2
|
|
||||||
...
|
|
||||||
|
|
||||||
then your `jupyterhub_config.py` adjacent to your Dockerfile will be loaded into the image and used by JupyterHub.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Inherit from a tag that corresponds to the version of JupyterHub you want to use.
|
|
||||||
> See our [Quay.io page](https://quay.io/repository/jupyterhub/jupyterhub?tab=tags) for the list of
|
|
||||||
> available tags.
|
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> Automatically loading the `jupyterhub_config.py` file was the default behavior of the `quay.io/jupyterhub/jupyterhub`
|
|
||||||
> image prior to `0.6`.
|
|
442
package-lock.json
generated
442
package-lock.json
generated
@@ -21,13 +21,287 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fortawesome/fontawesome-free": {
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
"version": "6.6.0",
|
"version": "6.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz",
|
||||||
"integrity": "sha512-60G28ke/sXdtS9KZCpZSHHkCbdsOGEhIUGlwq6yhY74UpTiToIh8np7A8yphhM4BWsvNFtIvLpi4co+h9Mr9Ow==",
|
"integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@parcel/watcher": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^1.0.3",
|
||||||
|
"is-glob": "^4.0.3",
|
||||||
|
"micromatch": "^4.0.5",
|
||||||
|
"node-addon-api": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher-android-arm64": "2.4.1",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.4.1",
|
||||||
|
"@parcel/watcher-darwin-x64": "2.4.1",
|
||||||
|
"@parcel/watcher-freebsd-x64": "2.4.1",
|
||||||
|
"@parcel/watcher-linux-arm-glibc": "2.4.1",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.4.1",
|
||||||
|
"@parcel/watcher-linux-arm64-musl": "2.4.1",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.4.1",
|
||||||
|
"@parcel/watcher-linux-x64-musl": "2.4.1",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.4.1",
|
||||||
|
"@parcel/watcher-win32-ia32": "2.4.1",
|
||||||
|
"@parcel/watcher-win32-x64": "2.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-android-arm64": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-darwin-x64": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-arm64": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-ia32": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@parcel/watcher-win32-x64": {
|
||||||
|
"version": "2.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz",
|
||||||
|
"integrity": "sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@popperjs/core": {
|
"node_modules/@popperjs/core": {
|
||||||
"version": "2.11.8",
|
"version": "2.11.8",
|
||||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||||
@@ -38,31 +312,6 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/anymatch": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"normalize-path": "^3.0.0",
|
|
||||||
"picomatch": "^2.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/binary-extensions": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bootstrap": {
|
"node_modules/bootstrap": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz",
|
||||||
@@ -86,6 +335,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
},
|
},
|
||||||
@@ -94,27 +344,31 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"anymatch": "~3.1.2",
|
"readdirp": "^4.0.1"
|
||||||
"braces": "~3.0.2",
|
|
||||||
"glob-parent": "~5.1.2",
|
|
||||||
"is-binary-path": "~2.1.0",
|
|
||||||
"is-glob": "~4.0.1",
|
|
||||||
"normalize-path": "~3.0.0",
|
|
||||||
"readdirp": "~3.6.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8.10.0"
|
"node": ">= 14.16.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"detect-libc": "bin/detect-libc.js"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"engines": {
|
||||||
"fsevents": "~2.3.2"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
@@ -122,6 +376,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -129,55 +384,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "4.3.5",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
|
||||||
"integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==",
|
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/is-binary-path": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"license": "MIT"
|
||||||
"binary-extensions": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -187,6 +406,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
},
|
},
|
||||||
@@ -199,6 +419,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
@@ -208,6 +429,20 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/moment": {
|
"node_modules/moment": {
|
||||||
"version": "2.30.1",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
@@ -216,20 +451,19 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "3.0.0",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"optional": true
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
},
|
},
|
||||||
@@ -238,15 +472,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz",
|
||||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
"integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
|
||||||
"picomatch": "^2.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/requirejs": {
|
"node_modules/requirejs": {
|
||||||
@@ -263,13 +498,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sass": {
|
"node_modules/sass": {
|
||||||
"version": "1.77.8",
|
"version": "1.86.1",
|
||||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
|
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.1.tgz",
|
||||||
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
|
"integrity": "sha512-Yaok4XELL1L9Im/ZUClKu//D2OP1rOljKj0Gf34a+GzLbMveOzL7CfqYo+JUa5Xt1nhTCW+OcKp/FtR7/iqj1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": ">=3.0.0 <4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^4.0.0",
|
"immutable": "^5.0.2",
|
||||||
"source-map-js": ">=0.6.2 <2.0.0"
|
"source-map-js": ">=0.6.2 <2.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -277,6 +513,9 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@parcel/watcher": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
@@ -293,6 +532,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
},
|
},
|
||||||
|
@@ -11,7 +11,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "python3 ./bower-lite",
|
"postinstall": "python3 ./bower-lite",
|
||||||
"css": "sass --style compressed -I share/jupyterhub/static/components --source-map share/jupyterhub/static/scss/style.scss:share/jupyterhub/static/css/style.min.css",
|
"css": "sass --style compressed -I share/jupyterhub/static/components --source-map share/jupyterhub/static/scss/style.scss:share/jupyterhub/static/css/style.min.css",
|
||||||
"build:watch": "npm run css -- --watch"
|
"build:watch": "npm run css -- --watch",
|
||||||
|
"jsx:install-run": "npm install --prefix jsx && npm run --prefix jsx",
|
||||||
|
"jsx:run": "npm run --prefix jsx"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"sass": "^1.74.1"
|
"sass": "^1.74.1"
|
||||||
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
# ref: https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html
|
||||||
[project]
|
[project]
|
||||||
name = "jupyterhub"
|
name = "jupyterhub"
|
||||||
version = "5.2.0"
|
version = "5.3.0rc0"
|
||||||
dynamic = ["readme", "dependencies"]
|
dynamic = ["readme", "dependencies"]
|
||||||
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
description = "JupyterHub: A multi-user server for Jupyter notebooks"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -67,6 +67,7 @@ jupyterhub-singleuser = "jupyterhub.singleuser:main"
|
|||||||
default = "jupyterhub.auth:PAMAuthenticator"
|
default = "jupyterhub.auth:PAMAuthenticator"
|
||||||
pam = "jupyterhub.auth:PAMAuthenticator"
|
pam = "jupyterhub.auth:PAMAuthenticator"
|
||||||
dummy = "jupyterhub.auth:DummyAuthenticator"
|
dummy = "jupyterhub.auth:DummyAuthenticator"
|
||||||
|
shared-password = "jupyterhub.authenticators.shared:SharedPasswordAuthenticator"
|
||||||
null = "jupyterhub.auth:NullAuthenticator"
|
null = "jupyterhub.auth:NullAuthenticator"
|
||||||
|
|
||||||
[project.entry-points."jupyterhub.proxies"]
|
[project.entry-points."jupyterhub.proxies"]
|
||||||
@@ -80,7 +81,7 @@ simple = "jupyterhub.spawner:SimpleLocalProcessSpawner"
|
|||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
zip-safe = false
|
zip-safe = false
|
||||||
license-files = ["COPYING.md"]
|
license-files = ["LICENSE"]
|
||||||
include-package-data = true
|
include-package-data = true
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
@@ -146,7 +147,7 @@ indent_size = 2
|
|||||||
github_url = "https://github.com/jupyterhub/jupyterhub"
|
github_url = "https://github.com/jupyterhub/jupyterhub"
|
||||||
|
|
||||||
[tool.tbump.version]
|
[tool.tbump.version]
|
||||||
current = "5.2.0"
|
current = "5.3.0rc0"
|
||||||
|
|
||||||
# Example of a semver regexp.
|
# Example of a semver regexp.
|
||||||
# Make sure this matches current_version before
|
# Make sure this matches current_version before
|
||||||
|
@@ -11,6 +11,9 @@ asyncio_default_fixture_loop_scope = module
|
|||||||
# jupyter_server plugin is incompatible with notebook imports
|
# jupyter_server plugin is incompatible with notebook imports
|
||||||
addopts = -p no:jupyter_server -m 'not browser' --color yes --durations 10 --verbose
|
addopts = -p no:jupyter_server -m 'not browser' --color yes --durations 10 --verbose
|
||||||
|
|
||||||
|
log_format = %(asctime)s %(levelname)s %(filename)s:%(lineno)s %(message)s
|
||||||
|
log_date_format = %H:%M:%S
|
||||||
|
|
||||||
python_files = test_*.py
|
python_files = test_*.py
|
||||||
markers =
|
markers =
|
||||||
gen_test: marks an async tornado test
|
gen_test: marks an async tornado test
|
||||||
|
8
setup.py
8
setup.py
@@ -172,10 +172,6 @@ class JSX(BaseCommand):
|
|||||||
js_target = pjoin(static, 'js', 'admin-react.js')
|
js_target = pjoin(static, 'js', 'admin-react.js')
|
||||||
|
|
||||||
def should_run(self):
|
def should_run(self):
|
||||||
if os.getenv('READTHEDOCS'):
|
|
||||||
# yarn not available on RTD
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not os.path.exists(self.js_target):
|
if not os.path.exists(self.js_target):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -213,6 +209,10 @@ class JSX(BaseCommand):
|
|||||||
|
|
||||||
|
|
||||||
def js_css_first(cls, strict=True):
|
def js_css_first(cls, strict=True):
|
||||||
|
if os.getenv('READTHEDOCS'):
|
||||||
|
# don't need to build frontend for the docs
|
||||||
|
return cls
|
||||||
|
|
||||||
class Command(cls):
|
class Command(cls):
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user