mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-14 21:43:01 +00:00
Merge main into query-performance
This commit is contained in:
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -150,7 +150,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub
|
- name: Build and push jupyterhub
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-onbuild
|
- name: Build and push jupyterhub-onbuild
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||||
@@ -192,7 +192,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-demo
|
- name: Build and push jupyterhub-demo
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||||
@@ -216,7 +216,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub/singleuser
|
- name: Build and push jupyterhub/singleuser
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||||
|
@@ -16,7 +16,7 @@ ci:
|
|||||||
repos:
|
repos:
|
||||||
# Autoformat: Python code, syntax patterns are modernized
|
# Autoformat: Python code, syntax patterns are modernized
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v3.4.0
|
rev: v3.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
@@ -24,7 +24,7 @@ repos:
|
|||||||
|
|
||||||
# Autoformat: Python code
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/PyCQA/autoflake
|
- repo: https://github.com/PyCQA/autoflake
|
||||||
rev: v2.1.1
|
rev: v2.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: autoflake
|
- id: autoflake
|
||||||
# args ref: https://github.com/PyCQA/autoflake#advanced-usage
|
# args ref: https://github.com/PyCQA/autoflake#advanced-usage
|
||||||
@@ -39,13 +39,13 @@ repos:
|
|||||||
|
|
||||||
# Autoformat: Python code
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 23.3.0
|
rev: 23.7.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
|
|
||||||
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v3.0.0-alpha.9-for-vscode
|
rev: v3.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
|
|
||||||
@@ -61,6 +61,6 @@ repos:
|
|||||||
|
|
||||||
# Linting: Python code (see the file .flake8)
|
# Linting: Python code (see the file .flake8)
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: "6.0.0"
|
rev: "6.1.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
@@ -21,7 +21,7 @@ fi
|
|||||||
# Configure a set of databases in the database server for upgrade tests
|
# Configure a set of databases in the database server for upgrade tests
|
||||||
# this list must be in sync with versions in test_db.py:test_upgrade
|
# this list must be in sync with versions in test_db.py:test_upgrade
|
||||||
set -x
|
set -x
|
||||||
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do
|
for SUFFIX in '' _upgrade_110 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211 _upgrade_311; do
|
||||||
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
||||||
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
||||||
done
|
done
|
||||||
|
@@ -1498,6 +1498,9 @@ components:
|
|||||||
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:
|
||||||
|
Create, read, update, delete services, not including services
|
||||||
|
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.
|
||||||
read:services:name: Read service names.
|
read:services:name: Read service names.
|
||||||
|
@@ -173,3 +173,46 @@ python3 setup.py js # fetch updated client-side js
|
|||||||
python3 setup.py css # recompile CSS from LESS sources
|
python3 setup.py css # recompile CSS from LESS sources
|
||||||
python3 setup.py jsx # build React admin app
|
python3 setup.py jsx # build React admin app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Failed to bind XXX to `http://127.0.0.1:<port>/<path>`
|
||||||
|
|
||||||
|
This error can happen when there's already an application or a service using this
|
||||||
|
port.
|
||||||
|
|
||||||
|
Use the following command to find out which service is using this port.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -P -i TCP:<port> -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
If nothing shows up, it likely means there's a system service that uses it but
|
||||||
|
your current user cannot list it. Reuse the same command with sudo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo lsof -P -i TCP:<port> -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Depending on the result of the above commands, the most simple solution is to
|
||||||
|
configure JupyterHub to use a different port for the service that is failing.
|
||||||
|
|
||||||
|
As an example, the following is a frequently seen issue:
|
||||||
|
|
||||||
|
`Failed to bind hub to http://127.0.0.1:8081/hub/`
|
||||||
|
|
||||||
|
Using the procedure described above, start with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -P -i TCP:8081 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
and if nothing shows up:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo lsof -P -i TCP:8081 -sTCP:LISTEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally, depending on your findings, you can apply the following change and start JupyterHub again:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.hub_port = 9081 # Or any other free port
|
||||||
|
```
|
||||||
|
@@ -145,7 +145,7 @@ There is additional configuration required for MySQL that is not needed for Post
|
|||||||
|
|
||||||
For example, to connect to a postgres database with psycopg2:
|
For example, to connect to a postgres database with psycopg2:
|
||||||
|
|
||||||
1. install psycopg2: `pip instal psycopg2` (or `psycopg2-binary` to avoid compilation, which is [not recommended for production][psycopg2-binary])
|
1. install psycopg2: `pip install psycopg2` (or `psycopg2-binary` to avoid compilation, which is [not recommended for production][psycopg2-binary])
|
||||||
2. set authentication via environment variables `PGUSER` and `PGPASSWORD`
|
2. set authentication via environment variables `PGUSER` and `PGPASSWORD`
|
||||||
3. configure [](JupyterHub.db_url):
|
3. configure [](JupyterHub.db_url):
|
||||||
|
|
||||||
|
@@ -45,7 +45,7 @@ additional packages.
|
|||||||
|
|
||||||
## Configuring Jupyter and IPython
|
## Configuring Jupyter and IPython
|
||||||
|
|
||||||
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/config_overview.html)
|
[Jupyter](https://jupyter-notebook.readthedocs.io/en/stable/configuring/config_overview.html)
|
||||||
and [IPython](https://ipython.readthedocs.io/en/stable/development/config.html)
|
and [IPython](https://ipython.readthedocs.io/en/stable/development/config.html)
|
||||||
have their own configuration systems.
|
have their own configuration systems.
|
||||||
|
|
||||||
@@ -212,13 +212,31 @@ By default, the single-user server launches JupyterLab,
|
|||||||
which is based on [Jupyter Server][].
|
which is based on [Jupyter Server][].
|
||||||
|
|
||||||
This is the default server when running JupyterHub ≥ 2.0.
|
This is the default server when running JupyterHub ≥ 2.0.
|
||||||
To switch to using the legacy Jupyter Notebook server, you can set the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
To switch to using the legacy Jupyter Notebook server (notebook < 7.0), you can set the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
||||||
(in the single-user environment) to:
|
(in the single-user environment) to:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::{note}
|
||||||
|
|
||||||
|
```
|
||||||
|
JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||||
|
```
|
||||||
|
|
||||||
|
is only valid for notebook < 7. notebook v7 is based on jupyter-server,
|
||||||
|
and the default jupyter-server application must be used.
|
||||||
|
Selecting the new notebook UI is no longer a matter of selecting the server app to launch,
|
||||||
|
but only the default URL for users to visit.
|
||||||
|
To use notebook v7 with JupyterHub, leave the default singleuser app config alone (or specify `JUPYTERHUB_SINGLEUSER_APP=jupyter-server`) and set the default _URL_ for user servers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Spawner.default_url = '/tree/'
|
||||||
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
[jupyter server]: https://jupyter-server.readthedocs.io
|
[jupyter server]: https://jupyter-server.readthedocs.io
|
||||||
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
|
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
|
||||||
|
|
||||||
|
@@ -24,6 +24,7 @@ such as:
|
|||||||
|
|
||||||
- Checking which users are active
|
- Checking which users are active
|
||||||
- Adding or removing users
|
- Adding or removing users
|
||||||
|
- Adding or removing services
|
||||||
- Stopping or starting single user notebook servers
|
- Stopping or starting single user notebook servers
|
||||||
- Authenticating services
|
- Authenticating services
|
||||||
- Communicating with an individual Jupyter server's REST API
|
- Communicating with an individual Jupyter server's REST API
|
||||||
|
@@ -174,6 +174,47 @@ c.JupyterHub.services = [
|
|||||||
In this case, the `url` field will be passed along to the Service as
|
In this case, the `url` field will be passed along to the Service as
|
||||||
`JUPYTERHUB_SERVICE_URL`.
|
`JUPYTERHUB_SERVICE_URL`.
|
||||||
|
|
||||||
|
## Adding or removing services at runtime
|
||||||
|
|
||||||
|
Only externally-managed services can be added at runtime by using JupyterHub’s REST API.
|
||||||
|
|
||||||
|
### Add a new service
|
||||||
|
|
||||||
|
To add a new service, send a POST request to this endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /hub/api/services/:servicename
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required scope: `admin:services`**
|
||||||
|
|
||||||
|
**Payload**: The payload should contain the definition of the service to be created. The endpoint supports the same properties as externally-managed services defined in the config file.
|
||||||
|
|
||||||
|
**Possible responses**
|
||||||
|
|
||||||
|
- `201 Created`: The service and related objects are created (and started in case of a Hub-managed one) successfully.
|
||||||
|
- `400 Bad Request`: The payload is invalid or JupyterHub can not create the service.
|
||||||
|
- `409 Conflict`: The service with the same name already exists.
|
||||||
|
|
||||||
|
### Remove an existing service
|
||||||
|
|
||||||
|
To remove an existing service, send a DELETE request to this endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE /hub/api/services/:servicename
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required scope: `admin:services`**
|
||||||
|
|
||||||
|
**Payload**: `None`
|
||||||
|
|
||||||
|
**Possible responses**
|
||||||
|
|
||||||
|
- `200 OK`: The service and related objects are removed (and stopped in case of a Hub-managed one) successfully.
|
||||||
|
- `400 Bad Request`: JupyterHub can not remove the service.
|
||||||
|
- `404 Not Found`: The requested service does not exist.
|
||||||
|
- `405 Not Allowed`: The requested service is created from the config file, it can not be removed at runtime.
|
||||||
|
|
||||||
## Writing your own Services
|
## Writing your own Services
|
||||||
|
|
||||||
When writing your own services, you have a few decisions to make (in addition
|
When writing your own services, you have a few decisions to make (in addition
|
||||||
|
@@ -22,6 +22,21 @@ started.
|
|||||||
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## One Time Passwords ( request_otp )
|
||||||
|
|
||||||
|
By setting `request_otp` to true, the login screen will show and additional password input field
|
||||||
|
to accept an OTP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.request_otp = True
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the prompt label is `OTP:`, but this can be changed by setting `otp_prompt`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.otp_prompt = 'Google Authenticator:'
|
||||||
|
```
|
||||||
|
|
||||||
## Configure admins (`admin_users`)
|
## Configure admins (`admin_users`)
|
||||||
|
|
||||||
```{note}
|
```{note}
|
||||||
|
@@ -21,14 +21,6 @@ import "./server-dashboard.css";
|
|||||||
import { timeSince } from "../../util/timeSince";
|
import { timeSince } from "../../util/timeSince";
|
||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
|
|
||||||
const AccessServerButton = ({ url }) => (
|
|
||||||
<a href={url || ""}>
|
|
||||||
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
|
||||||
Access Server
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
|
|
||||||
const RowListItem = ({ text }) => (
|
const RowListItem = ({ text }) => (
|
||||||
<span className="server-dashboard-row-list-item">{text}</span>
|
<span className="server-dashboard-row-list-item">{text}</span>
|
||||||
);
|
);
|
||||||
@@ -56,7 +48,6 @@ const ServerDashboard = (props) => {
|
|||||||
|
|
||||||
var [errorAlert, setErrorAlert] = useState(null);
|
var [errorAlert, setErrorAlert] = useState(null);
|
||||||
var [sortMethod, setSortMethod] = useState(null);
|
var [sortMethod, setSortMethod] = useState(null);
|
||||||
var [disabledButtons, setDisabledButtons] = useState({});
|
|
||||||
var [collapseStates, setCollapseStates] = useState({});
|
var [collapseStates, setCollapseStates] = useState({});
|
||||||
|
|
||||||
var user_data = useSelector((state) => state.user_data),
|
var user_data = useSelector((state) => state.user_data),
|
||||||
@@ -128,15 +119,15 @@ const ServerDashboard = (props) => {
|
|||||||
user_data = sortMethod(user_data);
|
user_data = sortMethod(user_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const StopServerButton = ({ serverName, userName }) => {
|
const ServerButton = ({ server, user, action, name, extraClass }) => {
|
||||||
var [isDisabled, setIsDisabled] = useState(false);
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger btn-xs stop-button"
|
className={`btn btn-xs ${extraClass}`}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled || server.pending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDisabled(true);
|
setIsDisabled(true);
|
||||||
stopServer(userName, serverName)
|
action(user.name, server.name)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status < 300) {
|
if (res.status < 300) {
|
||||||
updateUsers(...slice)
|
updateUsers(...slice)
|
||||||
@@ -152,103 +143,87 @@ const ServerDashboard = (props) => {
|
|||||||
setErrorAlert(`Failed to update users list.`);
|
setErrorAlert(`Failed to update users list.`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setErrorAlert(`Failed to stop server.`);
|
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
|
||||||
setIsDisabled(false);
|
setIsDisabled(false);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setErrorAlert(`Failed to stop server.`);
|
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
|
||||||
setIsDisabled(false);
|
setIsDisabled(false);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Stop Server
|
{name}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeleteServerButton = ({ serverName, userName }) => {
|
const StopServerButton = ({ server, user }) => {
|
||||||
if (serverName === "") {
|
if (!server.ready) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ServerButton({
|
||||||
|
server,
|
||||||
|
user,
|
||||||
|
action: stopServer,
|
||||||
|
name: "Stop Server",
|
||||||
|
extraClass: "btn-danger stop-button",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const DeleteServerButton = ({ server, user }) => {
|
||||||
|
if (server.name === "") {
|
||||||
|
// It's not possible to delete unnamed servers
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (server.ready || server.pending) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ServerButton({
|
||||||
|
server,
|
||||||
|
user,
|
||||||
|
action: deleteServer,
|
||||||
|
name: "Delete Server",
|
||||||
|
extraClass: "btn-danger stop-button",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const StartServerButton = ({ server, user }) => {
|
||||||
|
if (server.ready) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ServerButton({
|
||||||
|
server,
|
||||||
|
user,
|
||||||
|
action: startServer,
|
||||||
|
name: server.pending ? "Server is pending" : "Start Server",
|
||||||
|
extraClass: "btn-success start-button",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SpawnPageButton = ({ server, user }) => {
|
||||||
|
if (server.ready) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
var [isDisabled, setIsDisabled] = useState(false);
|
|
||||||
return (
|
return (
|
||||||
<button
|
<a
|
||||||
className="btn btn-danger btn-xs stop-button"
|
href={`${base_url}spawn/${user.name}${
|
||||||
// It's not possible to delete unnamed servers
|
server.name ? "/" + server.name : ""
|
||||||
disabled={isDisabled}
|
}`}
|
||||||
onClick={() => {
|
|
||||||
setIsDisabled(true);
|
|
||||||
deleteServer(userName, serverName)
|
|
||||||
.then((res) => {
|
|
||||||
if (res.status < 300) {
|
|
||||||
updateUsers(...slice)
|
|
||||||
.then((data) => {
|
|
||||||
dispatchPageUpdate(
|
|
||||||
data.items,
|
|
||||||
data._pagination,
|
|
||||||
name_filter,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setIsDisabled(false);
|
|
||||||
setErrorAlert(`Failed to update users list.`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setErrorAlert(`Failed to delete server.`);
|
|
||||||
setIsDisabled(false);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setErrorAlert(`Failed to delete server.`);
|
|
||||||
setIsDisabled(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Delete Server
|
<button className="btn btn-light btn-xs">Spawn Page</button>
|
||||||
</button>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const StartServerButton = ({ serverName, userName }) => {
|
const AccessServerButton = ({ server }) => {
|
||||||
var [isDisabled, setIsDisabled] = useState(false);
|
if (!server.ready) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<a href={server.url || ""}>
|
||||||
className="btn btn-success btn-xs start-button"
|
<button className="btn btn-primary btn-xs">Access Server</button>
|
||||||
disabled={isDisabled}
|
</a>
|
||||||
onClick={() => {
|
|
||||||
setIsDisabled(true);
|
|
||||||
startServer(userName, serverName)
|
|
||||||
.then((res) => {
|
|
||||||
if (res.status < 300) {
|
|
||||||
updateUsers(...slice)
|
|
||||||
.then((data) => {
|
|
||||||
dispatchPageUpdate(
|
|
||||||
data.items,
|
|
||||||
data._pagination,
|
|
||||||
name_filter,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setErrorAlert(`Failed to update users list.`);
|
|
||||||
setIsDisabled(false);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setErrorAlert(`Failed to start server.`);
|
|
||||||
setIsDisabled(false);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setErrorAlert(`Failed to start server.`);
|
|
||||||
setIsDisabled(false);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start Server
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,43 +333,16 @@ const ServerDashboard = (props) => {
|
|||||||
<td data-testid="user-row-last-activity">
|
<td data-testid="user-row-last-activity">
|
||||||
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||||
</td>
|
</td>
|
||||||
<td data-testid="user-row-server-activity">
|
<td data-testid="user-row-server-activity" className="actions">
|
||||||
{server.ready ? (
|
<StartServerButton server={server} user={user} />
|
||||||
// Stop Single-user server
|
<StopServerButton server={server} user={user} />
|
||||||
<>
|
<DeleteServerButton server={server} user={user} />
|
||||||
<StopServerButton serverName={server.name} userName={user.name} />
|
<AccessServerButton server={server} />
|
||||||
<AccessServerButton url={server.url} />
|
<SpawnPageButton server={server} user={user} />
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Start Single-user server
|
|
||||||
<>
|
|
||||||
<StartServerButton
|
|
||||||
serverName={server.name}
|
|
||||||
userName={user.name}
|
|
||||||
style={{ marginRight: 20 }}
|
|
||||||
/>
|
|
||||||
<DeleteServerButton
|
|
||||||
serverName={server.name}
|
|
||||||
userName={user.name}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={`${base_url}spawn/${user.name}${
|
|
||||||
server.name ? "/" + server.name : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-xs"
|
|
||||||
style={{ marginRight: 20 }}
|
|
||||||
>
|
|
||||||
Spawn Page
|
|
||||||
</button>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<EditUserCell user={user} />
|
<EditUserCell user={user} />
|
||||||
</tr>,
|
</tr>,
|
||||||
<tr>
|
<tr key={`${userServerName}-detail`}>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={6}
|
||||||
style={{ padding: 0 }}
|
style={{ padding: 0 }}
|
||||||
@@ -514,9 +462,11 @@ const ServerDashboard = (props) => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr className="noborder">
|
<tr className="noborder">
|
||||||
<td>
|
<td>
|
||||||
<Button variant="light" className="add-users-button">
|
<Link to="/add-users">
|
||||||
<Link to="/add-users">Add Users</Link>
|
<Button variant="light" className="add-users-button">
|
||||||
</Button>
|
Add Users
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td></td>
|
<td></td>
|
||||||
@@ -611,6 +561,7 @@ const ServerDashboard = (props) => {
|
|||||||
Shutdown Hub
|
Shutdown Hub
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@@ -2,7 +2,13 @@ import React from "react";
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
import { act } from "react-dom/test-utils";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { render, screen, fireEvent, getByText } from "@testing-library/react";
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
getByText,
|
||||||
|
getAllByRole,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { HashRouter, Switch } from "react-router-dom";
|
import { HashRouter, Switch } from "react-router-dom";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
@@ -697,3 +703,59 @@ test("Server delete button exists for named servers", async () => {
|
|||||||
expect(delete_button).toBeEnabled();
|
expect(delete_button).toBeEnabled();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Start server and confirm pending state", async () => {
|
||||||
|
let spy = mockAsync();
|
||||||
|
|
||||||
|
let mockStartServer = jest.fn(() => {
|
||||||
|
return new Promise(async (resolve) =>
|
||||||
|
clock.setTimeout(() => {
|
||||||
|
resolve({ status: 200 });
|
||||||
|
}, 100),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockUpdateUsers = jest.fn(() => Promise.resolve(mockAppState()));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Provider store={createStore(mockReducers, {})}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
updateUsers={mockUpdateUsers}
|
||||||
|
shutdownHub={spy}
|
||||||
|
startServer={mockStartServer}
|
||||||
|
stopServer={spy}
|
||||||
|
startAll={spy}
|
||||||
|
stopAll={spy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let actions = screen.getAllByTestId("user-row-server-activity")[1];
|
||||||
|
let buttons = getAllByRole(actions, "button");
|
||||||
|
|
||||||
|
expect(buttons.length).toBe(2);
|
||||||
|
expect(buttons[0].textContent).toBe("Start Server");
|
||||||
|
expect(buttons[1].textContent).toBe("Spawn Page");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(buttons[0]);
|
||||||
|
});
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(buttons.length).toBe(2);
|
||||||
|
expect(buttons[0].textContent).toBe("Start Server");
|
||||||
|
expect(buttons[0]).toBeDisabled();
|
||||||
|
expect(buttons[1].textContent).toBe("Spawn Page");
|
||||||
|
expect(buttons[1]).toBeEnabled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await clock.tick(100);
|
||||||
|
});
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||||
|
});
|
||||||
|
@@ -7,7 +7,7 @@
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-dashboard-container .add-users-button {
|
.server-dashboard-container .btn-light {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,3 +38,11 @@ tr.noborder > td {
|
|||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table > tbody > tr.user-row > td {
|
||||||
|
vertical-align: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-row .actions > * {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
7
jsx/testing/group.json
Normal file
7
jsx/testing/group.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "kind": "group", "name": "testgroup", "users": [] },
|
||||||
|
{ "kind": "group", "name": "testgroup2", "users": ["foo", "bar"] }
|
||||||
|
],
|
||||||
|
"_pagination": { "offset": 0, "limit": 50, "total": 2, "next": null }
|
||||||
|
}
|
21
jsx/testing/index.html
Normal file
21
jsx/testing/index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
|
||||||
|
<title>JupyterHub</title>
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="chrome=1" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/static/css/style.min.css" type="text/css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="react-admin-hook">
|
||||||
|
<script id="jupyterhub-admin-config">
|
||||||
|
window.api_page_limit = parseInt("50");
|
||||||
|
window.base_url = "/";
|
||||||
|
</script>
|
||||||
|
<script src="admin-react.js"></script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
142
jsx/testing/user.json
Normal file
142
jsx/testing/user.json
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"last_activity": "2022-08-04T23:01:40.770831Z",
|
||||||
|
"groups": [],
|
||||||
|
"created": "2022-08-04T23:01:15.074531Z",
|
||||||
|
"roles": ["user"],
|
||||||
|
"auth_state": null,
|
||||||
|
"pending": null,
|
||||||
|
"kind": "user",
|
||||||
|
"server": null,
|
||||||
|
"name": "userA",
|
||||||
|
"servers": {
|
||||||
|
"": {
|
||||||
|
"name": "",
|
||||||
|
"last_activity": "2022-08-04T23:01:40.770831Z",
|
||||||
|
"started": null,
|
||||||
|
"pending": null,
|
||||||
|
"ready": false,
|
||||||
|
"stopped": true,
|
||||||
|
"url": "/user/usera/",
|
||||||
|
"user_options": null,
|
||||||
|
"progress_url": "/hub/api/users/usera/server/progress",
|
||||||
|
"state": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"last_activity": "2022-08-05T16:43:44.442068Z",
|
||||||
|
"groups": [],
|
||||||
|
"admin": true,
|
||||||
|
"created": "2022-08-04T23:01:27.819148Z",
|
||||||
|
"roles": ["user", "admin"],
|
||||||
|
"auth_state": null,
|
||||||
|
"pending": null,
|
||||||
|
"kind": "user",
|
||||||
|
"server": null,
|
||||||
|
"name": "userB",
|
||||||
|
"servers": {
|
||||||
|
"": {
|
||||||
|
"name": "",
|
||||||
|
"last_activity": "2022-08-05T16:43:44.442068Z",
|
||||||
|
"started": null,
|
||||||
|
"pending": null,
|
||||||
|
"ready": true,
|
||||||
|
"stopped": false,
|
||||||
|
"url": "/user/userb/",
|
||||||
|
"user_options": null,
|
||||||
|
"progress_url": "/hub/api/users/userb/server/progress",
|
||||||
|
"state": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"last_activity": "2022-08-05T16:43:44.442068Z",
|
||||||
|
"groups": [],
|
||||||
|
"created": "2022-08-04T23:01:27.819148Z",
|
||||||
|
"roles": ["user"],
|
||||||
|
"auth_state": null,
|
||||||
|
"pending": "spawn",
|
||||||
|
"kind": "user",
|
||||||
|
"server": null,
|
||||||
|
"name": "userC",
|
||||||
|
"servers": {
|
||||||
|
"": {
|
||||||
|
"last_activity": "2023-06-11T16:22:02.228468Z",
|
||||||
|
"name": "",
|
||||||
|
"pending": "spawn",
|
||||||
|
"progress_url": "/hub/api/users/userc/server/progress",
|
||||||
|
"ready": false,
|
||||||
|
"started": "2023-06-11T16:22:02.228468Z",
|
||||||
|
"state": { "pid": 68137 },
|
||||||
|
"stopped": false,
|
||||||
|
"url": "/user/userc/",
|
||||||
|
"user_options": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"last_activity": "2023-06-11T15:19:49.786502Z",
|
||||||
|
"groups": [],
|
||||||
|
"created": "2023-05-14T09:05:48.996574Z",
|
||||||
|
"roles": ["user"],
|
||||||
|
"auth_state": null,
|
||||||
|
"pending": null,
|
||||||
|
"kind": "user",
|
||||||
|
"server": "/user/userD/",
|
||||||
|
"name": "userD",
|
||||||
|
"servers": {
|
||||||
|
"": {
|
||||||
|
"name": "",
|
||||||
|
"last_activity": "2023-06-11T13:39:27.017000Z",
|
||||||
|
"started": "2023-06-11T13:39:24.679829Z",
|
||||||
|
"pending": null,
|
||||||
|
"ready": true,
|
||||||
|
"stopped": false,
|
||||||
|
"url": "/user/userd/",
|
||||||
|
"user_options": {},
|
||||||
|
"progress_url": "/hub/api/users/userd/server/progress",
|
||||||
|
"state": { "pid": 41517 }
|
||||||
|
},
|
||||||
|
"serverA": {
|
||||||
|
"name": "serverA",
|
||||||
|
"last_activity": "2023-05-14T13:59:06.931642Z",
|
||||||
|
"started": null,
|
||||||
|
"pending": null,
|
||||||
|
"ready": false,
|
||||||
|
"stopped": true,
|
||||||
|
"url": "/user/userd/servera/",
|
||||||
|
"user_options": null,
|
||||||
|
"progress_url": "/hub/api/users/userd/servers/servera/progress",
|
||||||
|
"state": {}
|
||||||
|
},
|
||||||
|
"serverB": {
|
||||||
|
"last_activity": "2023-06-11T16:22:02.228468Z",
|
||||||
|
"name": "serverB",
|
||||||
|
"pending": "spawn",
|
||||||
|
"progress_url": "/hub/api/users/userb/servers/serverb/progress",
|
||||||
|
"ready": false,
|
||||||
|
"started": "2023-06-11T16:22:02.228468Z",
|
||||||
|
"state": { "pid": 68137 },
|
||||||
|
"stopped": false,
|
||||||
|
"url": "/user/userd/serverb/",
|
||||||
|
"user_options": {}
|
||||||
|
},
|
||||||
|
"serverC": {
|
||||||
|
"name": "serverC",
|
||||||
|
"last_activity": "2023-05-14T13:59:06.931642Z",
|
||||||
|
"started": null,
|
||||||
|
"pending": null,
|
||||||
|
"ready": true,
|
||||||
|
"stopped": false,
|
||||||
|
"url": "/user/userd/serverc/",
|
||||||
|
"user_options": null,
|
||||||
|
"progress_url": "/hub/api/users/userd/servers/serverc/progress",
|
||||||
|
"state": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_pagination": { "offset": 0, "limit": 50, "total": 4, "next": null }
|
||||||
|
}
|
@@ -1,5 +1,7 @@
|
|||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const user_json = require("./testing/user.json");
|
||||||
|
const group_json = require("./testing/group.json");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: path.resolve(__dirname, "src", "App.jsx"),
|
entry: path.resolve(__dirname, "src", "App.jsx"),
|
||||||
@@ -33,31 +35,21 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: [new webpack.HotModuleReplacementPlugin()],
|
plugins: [new webpack.HotModuleReplacementPlugin()],
|
||||||
devServer: {
|
devServer: {
|
||||||
static: {
|
client: {
|
||||||
directory: path.resolve(__dirname, "build"),
|
overlay: false,
|
||||||
},
|
},
|
||||||
|
static: ["build", "testing", "../share/jupyterhub"],
|
||||||
port: 9000,
|
port: 9000,
|
||||||
onBeforeSetupMiddleware: (devServer) => {
|
onBeforeSetupMiddleware: (devServer) => {
|
||||||
const app = devServer.app;
|
const app = devServer.app;
|
||||||
|
|
||||||
var user_data = JSON.parse(
|
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]',
|
|
||||||
);
|
|
||||||
var group_data = JSON.parse(
|
|
||||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]',
|
|
||||||
);
|
|
||||||
|
|
||||||
// get user_data
|
// get user_data
|
||||||
app.get("/hub/api/users", (req, res) => {
|
app.get("/hub/api/users", (req, res) => {
|
||||||
res
|
res.set("Content-Type", "application/json").send(user_json);
|
||||||
.set("Content-Type", "application/json")
|
|
||||||
.send(JSON.stringify(user_data));
|
|
||||||
});
|
});
|
||||||
// get group_data
|
// get group_data
|
||||||
app.get("/hub/api/groups", (req, res) => {
|
app.get("/hub/api/groups", (req, res) => {
|
||||||
res
|
res.set("Content-Type", "application/json").send(group_json);
|
||||||
.set("Content-Type", "application/json")
|
|
||||||
.send(JSON.stringify(group_data));
|
|
||||||
});
|
});
|
||||||
// add users to group
|
// add users to group
|
||||||
app.post("/hub/api/groups/*/users", (req, res) => {
|
app.post("/hub/api/groups/*/users", (req, res) => {
|
||||||
|
16077
jsx/yarn.lock
16077
jsx/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
"""Add from_config column to the services table
|
||||||
|
|
||||||
|
Revision ID: 3c2384c5aae1
|
||||||
|
Revises: 0eee8c825d24
|
||||||
|
Create Date: 2023-02-27 16:22:26.196231
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '3c2384c5aae1'
|
||||||
|
down_revision = '0eee8c825d24'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from jupyterhub.orm import JSONDict, JSONList
|
||||||
|
|
||||||
|
COL_DATA = [
|
||||||
|
{'name': 'url', 'type': sa.Unicode(length=2047)},
|
||||||
|
{'name': 'oauth_client_allowed_scopes', 'type': JSONDict()},
|
||||||
|
{'name': 'info', 'type': JSONDict()},
|
||||||
|
{'name': 'display', 'type': sa.Boolean},
|
||||||
|
{'name': 'oauth_no_confirm', 'type': sa.Boolean},
|
||||||
|
{'name': 'command', 'type': JSONList()},
|
||||||
|
{'name': 'cwd', 'type': sa.Unicode(length=2047)},
|
||||||
|
{'name': 'environment', 'type': JSONDict()},
|
||||||
|
{'name': 'user', 'type': sa.Unicode(255)},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
engine = op.get_bind().engine
|
||||||
|
tables = sa.inspect(engine).get_table_names()
|
||||||
|
if 'services' in tables:
|
||||||
|
op.add_column(
|
||||||
|
'services',
|
||||||
|
sa.Column('from_config', sa.Boolean, default=True),
|
||||||
|
)
|
||||||
|
op.execute('UPDATE services SET from_config = true')
|
||||||
|
for item in COL_DATA:
|
||||||
|
op.add_column(
|
||||||
|
'services',
|
||||||
|
sa.Column(item['name'], item['type'], nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column('services', sa.Column('from_config'))
|
||||||
|
for item in COL_DATA:
|
||||||
|
op.drop_column('services', sa.Column(item['name']))
|
@@ -13,16 +13,37 @@ depends_on = None
|
|||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from sqlalchemy import Column, ForeignKey, Table
|
from sqlalchemy import Column, ForeignKey, Table, text
|
||||||
from sqlalchemy.orm import raiseload, relationship, selectinload
|
from sqlalchemy.orm import raiseload, relationship, selectinload
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from jupyterhub import orm, roles, scopes
|
from jupyterhub import orm, roles
|
||||||
|
|
||||||
|
|
||||||
|
def access_scopes(oauth_client: orm.OAuthClient, db: Session):
|
||||||
|
"""Return scope(s) required to access an oauth client
|
||||||
|
This is a clone of `scopes.access_scopes` without using
|
||||||
|
the `orm.Service`
|
||||||
|
"""
|
||||||
|
scopes = set()
|
||||||
|
if oauth_client.identifier == "jupyterhub":
|
||||||
|
return frozenset()
|
||||||
|
spawner = oauth_client.spawner
|
||||||
|
if spawner:
|
||||||
|
scopes.add(f"access:servers!server={spawner.user.name}/{spawner.name}")
|
||||||
|
else:
|
||||||
|
statement = "SELECT * FROM services WHERE oauth_client_id = :identifier"
|
||||||
|
service = db.execute(
|
||||||
|
text(statement), {"identifier": oauth_client.identifier}
|
||||||
|
).fetchall()
|
||||||
|
if len(service) > 0:
|
||||||
|
scopes.add(f"access:services!service={service[0].name}")
|
||||||
|
|
||||||
|
return frozenset(scopes)
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
def upgrade():
|
||||||
c = op.get_bind()
|
c = op.get_bind()
|
||||||
|
|
||||||
tables = sa.inspect(c.engine).get_table_names()
|
tables = sa.inspect(c.engine).get_table_names()
|
||||||
|
|
||||||
# oauth codes are short lived, no need to upgrade them
|
# oauth codes are short lived, no need to upgrade them
|
||||||
@@ -103,7 +124,7 @@ def upgrade():
|
|||||||
db = Session(bind=c)
|
db = Session(bind=c)
|
||||||
for oauth_client in db.query(orm.OAuthClient):
|
for oauth_client in db.query(orm.OAuthClient):
|
||||||
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
|
allowed_scopes = set(roles.roles_to_scopes(oauth_client.allowed_roles))
|
||||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
allowed_scopes.update(access_scopes(oauth_client, db))
|
||||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||||
db.commit()
|
db.commit()
|
||||||
# drop token-role relationship
|
# drop token-role relationship
|
||||||
|
@@ -421,6 +421,23 @@ class APIHandler(BaseHandler):
|
|||||||
|
|
||||||
_group_model_types = {'name': str, 'users': list, 'roles': list}
|
_group_model_types = {'name': str, 'users': list, 'roles': list}
|
||||||
|
|
||||||
|
_service_model_types = {
|
||||||
|
'name': str,
|
||||||
|
'admin': bool,
|
||||||
|
'url': str,
|
||||||
|
'oauth_client_allowed_scopes': list,
|
||||||
|
'api_token': str,
|
||||||
|
'info': dict,
|
||||||
|
'display': bool,
|
||||||
|
'oauth_no_confirm': bool,
|
||||||
|
'command': list,
|
||||||
|
'cwd': str,
|
||||||
|
'environment': dict,
|
||||||
|
'user': str,
|
||||||
|
'oauth_client_id': str,
|
||||||
|
'oauth_redirect_uri': str,
|
||||||
|
}
|
||||||
|
|
||||||
def _check_model(self, model, model_types, name):
|
def _check_model(self, model, model_types, name):
|
||||||
"""Check a model provided by a REST API request
|
"""Check a model provided by a REST API request
|
||||||
|
|
||||||
@@ -459,6 +476,15 @@ class APIHandler(BaseHandler):
|
|||||||
400, ("group names must be str, not %r", type(groupname))
|
400, ("group names must be str, not %r", type(groupname))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _check_service_model(self, model):
|
||||||
|
"""Check a request-provided service model from a REST API"""
|
||||||
|
self._check_model(model, self._service_model_types, 'service')
|
||||||
|
service_name = model.get('name')
|
||||||
|
if not isinstance(service_name, str):
|
||||||
|
raise web.HTTPError(
|
||||||
|
400, ("Service name must be str, not %r", type(service_name))
|
||||||
|
)
|
||||||
|
|
||||||
def get_api_pagination(self):
|
def get_api_pagination(self):
|
||||||
default_limit = self.settings["api_page_default_limit"]
|
default_limit = self.settings["api_page_default_limit"]
|
||||||
max_limit = self.settings["api_page_max_limit"]
|
max_limit = self.settings["api_page_max_limit"]
|
||||||
|
@@ -5,8 +5,14 @@ Currently GET-only, no actions can be taken to modify services.
|
|||||||
# 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 typing import Optional, Tuple
|
||||||
|
|
||||||
from ..scopes import Scope, needs_scope
|
from tornado import web
|
||||||
|
|
||||||
|
from .. import orm
|
||||||
|
from ..roles import get_default_roles
|
||||||
|
from ..scopes import Scope, _check_token_scopes, needs_scope
|
||||||
|
from ..services.service import Service
|
||||||
from .base import APIHandler
|
from .base import APIHandler
|
||||||
|
|
||||||
|
|
||||||
@@ -25,9 +31,171 @@ class ServiceListAPIHandler(APIHandler):
|
|||||||
class ServiceAPIHandler(APIHandler):
|
class ServiceAPIHandler(APIHandler):
|
||||||
@needs_scope('read:services', 'read:services:name', 'read:roles:services')
|
@needs_scope('read:services', 'read:services:name', 'read:roles:services')
|
||||||
def get(self, service_name):
|
def get(self, service_name):
|
||||||
|
if service_name not in self.services:
|
||||||
|
raise web.HTTPError(404, f"No such service: {service_name}")
|
||||||
service = self.services[service_name]
|
service = self.services[service_name]
|
||||||
self.write(json.dumps(self.service_model(service)))
|
self.write(json.dumps(self.service_model(service)))
|
||||||
|
|
||||||
|
def _check_service_scopes(self, spec: dict):
|
||||||
|
user = self.current_user
|
||||||
|
requested_scopes = []
|
||||||
|
if spec.get('admin'):
|
||||||
|
default_roles = get_default_roles()
|
||||||
|
admin_scopes = [
|
||||||
|
role['scopes'] for role in default_roles if role['name'] == 'admin'
|
||||||
|
]
|
||||||
|
requested_scopes.extend(admin_scopes[0])
|
||||||
|
|
||||||
|
requested_client_scope = spec.get('oauth_client_allowed_scopes')
|
||||||
|
if requested_client_scope is not None:
|
||||||
|
requested_scopes.extend(requested_client_scope)
|
||||||
|
if len(requested_scopes) > 0:
|
||||||
|
try:
|
||||||
|
_check_token_scopes(requested_scopes, user, None)
|
||||||
|
except ValueError as e:
|
||||||
|
raise web.HTTPError(400, str(e))
|
||||||
|
|
||||||
|
async def add_service(self, spec: dict) -> Service:
|
||||||
|
"""Add a new service and related objects to the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec (dict): The service specification
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
web.HTTPError: Raise if the service is not created
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service: Returns the service instance.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._check_service_model(spec)
|
||||||
|
self._check_service_scopes(spec)
|
||||||
|
|
||||||
|
service_name = spec["name"]
|
||||||
|
managed = bool(spec.get('command'))
|
||||||
|
if managed:
|
||||||
|
msg = f"Can not create managed service {service_name} at runtime"
|
||||||
|
self.log.error(msg, exc_info=True)
|
||||||
|
raise web.HTTPError(400, msg)
|
||||||
|
try:
|
||||||
|
new_service = self.service_from_spec(spec)
|
||||||
|
except Exception:
|
||||||
|
msg = f"Failed to create service {service_name}"
|
||||||
|
self.log.error(msg, exc_info=True)
|
||||||
|
raise web.HTTPError(400, msg)
|
||||||
|
|
||||||
|
if new_service is None:
|
||||||
|
raise web.HTTPError(400, f"Failed to create service {service_name}")
|
||||||
|
|
||||||
|
if new_service.api_token:
|
||||||
|
# Add api token to database
|
||||||
|
await self.app._add_tokens(
|
||||||
|
{new_service.api_token: new_service.name}, kind='service'
|
||||||
|
)
|
||||||
|
if new_service.url:
|
||||||
|
# Start polling for external service
|
||||||
|
service_status = await self.app.start_service(service_name, new_service)
|
||||||
|
if not service_status:
|
||||||
|
self.log.error(
|
||||||
|
'Failed to start service %s',
|
||||||
|
service_name,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_service.oauth_no_confirm:
|
||||||
|
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
|
||||||
|
msg = f"Allowing service {new_service.name} to complete OAuth without confirmation on an authorization web page"
|
||||||
|
self.log.warning(msg)
|
||||||
|
oauth_no_confirm_list.add(new_service.oauth_client_id)
|
||||||
|
|
||||||
|
return new_service
|
||||||
|
|
||||||
|
@needs_scope('admin:services')
|
||||||
|
async def post(self, service_name: str):
|
||||||
|
data = self.get_json_body()
|
||||||
|
service, _ = self.find_service(service_name)
|
||||||
|
|
||||||
|
if service is not None:
|
||||||
|
raise web.HTTPError(409, f"Service {service_name} already exists")
|
||||||
|
|
||||||
|
if not data or not isinstance(data, dict):
|
||||||
|
raise web.HTTPError(400, "Invalid service data")
|
||||||
|
|
||||||
|
data['name'] = service_name
|
||||||
|
new_service = await self.add_service(data)
|
||||||
|
self.write(json.dumps(self.service_model(new_service)))
|
||||||
|
self.set_status(201)
|
||||||
|
|
||||||
|
@needs_scope('admin:services')
|
||||||
|
async def delete(self, service_name: str):
|
||||||
|
service, orm_service = self.find_service(service_name)
|
||||||
|
|
||||||
|
if service is None:
|
||||||
|
raise web.HTTPError(404, f"Service {service_name} does not exist")
|
||||||
|
|
||||||
|
if service.from_config:
|
||||||
|
raise web.HTTPError(
|
||||||
|
405, f"Service {service_name} is not modifiable at runtime"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.remove_service(service, orm_service)
|
||||||
|
self.services.pop(service_name)
|
||||||
|
except Exception:
|
||||||
|
msg = f"Failed to remove service {service_name}"
|
||||||
|
self.log.error(msg, exc_info=True)
|
||||||
|
raise web.HTTPError(400, msg)
|
||||||
|
|
||||||
|
self.set_status(200)
|
||||||
|
|
||||||
|
async def remove_service(self, service: Service, orm_service: orm.Service) -> None:
|
||||||
|
"""Remove a service and all related objects from the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service (Service): the service object to be removed
|
||||||
|
|
||||||
|
orm_service (orm.Service): The `orm.Service` object linked
|
||||||
|
with `service`
|
||||||
|
"""
|
||||||
|
if service.managed:
|
||||||
|
await service.stop()
|
||||||
|
|
||||||
|
if service.oauth_client:
|
||||||
|
self.oauth_provider.remove_client(service.oauth_client_id)
|
||||||
|
|
||||||
|
if orm_service._server_id is not None:
|
||||||
|
orm_server = (
|
||||||
|
self.db.query(orm.Server).filter_by(id=orm_service._server_id).first()
|
||||||
|
)
|
||||||
|
if orm_server is not None:
|
||||||
|
self.db.delete(orm_server)
|
||||||
|
|
||||||
|
if service.oauth_no_confirm:
|
||||||
|
oauth_no_confirm_list = self.settings.get('oauth_no_confirm_list')
|
||||||
|
oauth_no_confirm_list.discard(service.oauth_client_id)
|
||||||
|
|
||||||
|
self.db.delete(orm_service)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def service_from_spec(self, spec) -> Optional[Service]:
|
||||||
|
"""Create service from api request"""
|
||||||
|
service = self.app.service_from_spec(spec, from_config=False)
|
||||||
|
self.db.commit()
|
||||||
|
return service
|
||||||
|
|
||||||
|
def find_service(
|
||||||
|
self, name: str
|
||||||
|
) -> Tuple[Optional[Service], Optional[orm.Service]]:
|
||||||
|
"""Get a service by name
|
||||||
|
return None if no such service
|
||||||
|
"""
|
||||||
|
orm_service = orm.Service.find(db=self.db, name=name)
|
||||||
|
if orm_service is not None:
|
||||||
|
service = self.services.get(name)
|
||||||
|
return service, orm_service
|
||||||
|
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
default_handlers = [
|
default_handlers = [
|
||||||
(r"/api/services", ServiceListAPIHandler),
|
(r"/api/services", ServiceListAPIHandler),
|
||||||
|
@@ -21,6 +21,7 @@ from functools import partial
|
|||||||
from getpass import getuser
|
from getpass import getuser
|
||||||
from operator import itemgetter
|
from operator import itemgetter
|
||||||
from textwrap import dedent
|
from textwrap import dedent
|
||||||
|
from typing import Optional
|
||||||
from urllib.parse import unquote, urlparse, urlunparse
|
from urllib.parse import unquote, urlparse, urlunparse
|
||||||
|
|
||||||
if sys.version_info[:2] < (3, 3):
|
if sys.version_info[:2] < (3, 3):
|
||||||
@@ -32,6 +33,7 @@ from dateutil.parser import parse as parse_date
|
|||||||
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
|
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
|
||||||
from jupyter_telemetry.eventlog import EventLog
|
from jupyter_telemetry.eventlog import EventLog
|
||||||
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
from tornado import gen, web
|
from tornado import gen, web
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
from tornado.httpclient import AsyncHTTPClient
|
||||||
from tornado.ioloop import IOLoop, PeriodicCallback
|
from tornado.ioloop import IOLoop, PeriodicCallback
|
||||||
@@ -2250,22 +2252,53 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
if self.authenticator.allowed_users:
|
if self.authenticator.allowed_users:
|
||||||
self.log.debug(
|
user_role = orm.Role.find(db, "user")
|
||||||
f"Assigning {len(self.authenticator.allowed_users)} allowed_users to the user role"
|
self.log.debug("Assigning allowed_users to the user role")
|
||||||
)
|
# query only those that need the user role _and don't have it_
|
||||||
allowed_users = db.query(orm.User).filter(
|
needs_user_role = db.query(orm.User).filter(
|
||||||
orm.User.name.in_(self.authenticator.allowed_users)
|
orm.User.name.in_(self.authenticator.allowed_users)
|
||||||
|
& ~orm.User.roles.any(id=user_role.id)
|
||||||
)
|
)
|
||||||
for user in allowed_users:
|
if self.log.isEnabledFor(logging.DEBUG):
|
||||||
roles.grant_role(db, user, 'user')
|
# filter on isEnabledFor to skip the extra `count()` query if we aren't going to log it
|
||||||
|
self.log.debug(
|
||||||
|
f"Assigning {needs_user_role.count()} allowed_users to the user role"
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in needs_user_role:
|
||||||
|
roles.grant_role(db, user, user_role)
|
||||||
|
|
||||||
admin_role = orm.Role.find(db, 'admin')
|
admin_role = orm.Role.find(db, 'admin')
|
||||||
for kind in admin_role_objects:
|
for kind in admin_role_objects:
|
||||||
Class = orm.get_class(kind)
|
Class = orm.get_class(kind)
|
||||||
for admin_obj in db.query(Class).filter_by(admin=True):
|
|
||||||
|
# sync obj.admin with admin role
|
||||||
|
# query only those objects that do not match config
|
||||||
|
# to avoid expensive query for no-op updates
|
||||||
|
|
||||||
|
# always: in admin role sets admin = True
|
||||||
|
for is_admin in db.query(Class).filter(
|
||||||
|
(Class.admin == False) & Class.roles.any(id=admin_role.id)
|
||||||
|
):
|
||||||
|
self.log.info(f"Setting admin=True on {is_admin}")
|
||||||
|
is_admin.admin = True
|
||||||
|
|
||||||
|
# iterate over users with admin=True
|
||||||
|
# who are not in the admin role.
|
||||||
|
for not_admin_obj in db.query(Class).filter(
|
||||||
|
(Class.admin == True) & ~Class.roles.any(id=admin_role.id)
|
||||||
|
):
|
||||||
if has_admin_role_spec[kind]:
|
if has_admin_role_spec[kind]:
|
||||||
admin_obj.admin = admin_role in admin_obj.roles
|
# role membership specified exactly in config,
|
||||||
|
# already populated above.
|
||||||
|
# make sure user.admin matches admin role
|
||||||
|
# setting .admin=False for anyone no longer in admin role
|
||||||
|
self.log.warning(f"Removing admin=True from {not_admin_obj}")
|
||||||
|
not_admin_obj.admin = False
|
||||||
else:
|
else:
|
||||||
roles.grant_role(db, admin_obj, 'admin')
|
# no admin role membership declared,
|
||||||
|
# populate admin role from admin attribute (the old way, only additive)
|
||||||
|
roles.grant_role(db, not_admin_obj, admin_role)
|
||||||
db.commit()
|
db.commit()
|
||||||
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
|
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
|
||||||
if getattr(self, '_rbac_upgrade', False):
|
if getattr(self, '_rbac_upgrade', False):
|
||||||
@@ -2291,7 +2324,7 @@ class JupyterHub(Application):
|
|||||||
if not self.authenticator.validate_username(name):
|
if not self.authenticator.validate_username(name):
|
||||||
raise ValueError("Token user name %r is not valid" % name)
|
raise ValueError("Token user name %r is not valid" % name)
|
||||||
if kind == 'service':
|
if kind == 'service':
|
||||||
if not any(service["name"] == name for service in self.services):
|
if not any(service_name == name for service_name in self._service_map):
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"service {name} not in services, creating implicitly. It is recommended to register services using services list."
|
f"service {name} not in services, creating implicitly. It is recommended to register services using services list."
|
||||||
)
|
)
|
||||||
@@ -2354,8 +2387,20 @@ class JupyterHub(Application):
|
|||||||
)
|
)
|
||||||
pc.start()
|
pc.start()
|
||||||
|
|
||||||
def init_services(self):
|
def service_from_orm(
|
||||||
self._service_map.clear()
|
self,
|
||||||
|
orm_service: orm.Service,
|
||||||
|
) -> Service:
|
||||||
|
"""Create the service instance and related objects from
|
||||||
|
ORM data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
orm_service (orm.Service): The `orm.Service` object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Service: the created service
|
||||||
|
"""
|
||||||
|
|
||||||
if self.domain:
|
if self.domain:
|
||||||
domain = 'services.' + self.domain
|
domain = 'services.' + self.domain
|
||||||
parsed = urlparse(self.subdomain_host)
|
parsed = urlparse(self.subdomain_host)
|
||||||
@@ -2363,118 +2408,208 @@ class JupyterHub(Application):
|
|||||||
else:
|
else:
|
||||||
domain = host = ''
|
domain = host = ''
|
||||||
|
|
||||||
for spec in self.services:
|
name = orm_service.name
|
||||||
if 'name' not in spec:
|
service = Service(
|
||||||
raise ValueError('service spec must have a name: %r' % spec)
|
parent=self,
|
||||||
name = spec['name']
|
app=self,
|
||||||
# get/create orm
|
base_url=self.base_url,
|
||||||
orm_service = orm.Service.find(self.db, name=name)
|
db=self.db,
|
||||||
if orm_service is None:
|
orm=orm_service,
|
||||||
# not found, create a new one
|
roles=orm_service.roles,
|
||||||
orm_service = orm.Service(name=name)
|
domain=domain,
|
||||||
self.db.add(orm_service)
|
host=host,
|
||||||
if spec.get('admin', False):
|
hub=self.hub,
|
||||||
self.log.warning(
|
)
|
||||||
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
|
traits = service.traits(input=True)
|
||||||
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
|
for key, trait in traits.items():
|
||||||
" If you specify services in the admin role configuration, "
|
if not trait.metadata.get("in_db", True):
|
||||||
"the Service admin flag will be ignored."
|
continue
|
||||||
|
orm_value = getattr(orm_service, key)
|
||||||
|
if orm_value is not None:
|
||||||
|
setattr(service, key, orm_value)
|
||||||
|
|
||||||
|
if orm_service.oauth_client is not None:
|
||||||
|
service.oauth_client_id = orm_service.oauth_client.identifier
|
||||||
|
service.oauth_redirect_uri = orm_service.oauth_client.redirect_uri
|
||||||
|
|
||||||
|
self._service_map[name] = service
|
||||||
|
|
||||||
|
return service
|
||||||
|
|
||||||
|
def service_from_spec(
|
||||||
|
self,
|
||||||
|
spec: Dict,
|
||||||
|
from_config=True,
|
||||||
|
) -> Optional[Service]:
|
||||||
|
"""Create the service instance and related objects from
|
||||||
|
config data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
spec (Dict): The spec of service, defined in the config file.
|
||||||
|
from_config (bool, optional): `True` if the service will be created
|
||||||
|
from the config file, `False` if it is created from REST API.
|
||||||
|
Defaults to `True`.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Service]: The created service
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.domain:
|
||||||
|
domain = 'services.' + self.domain
|
||||||
|
parsed = urlparse(self.subdomain_host)
|
||||||
|
host = f'{parsed.scheme}://services.{parsed.netloc}'
|
||||||
|
else:
|
||||||
|
domain = host = ''
|
||||||
|
|
||||||
|
if 'name' not in spec:
|
||||||
|
raise ValueError('service spec must have a name: %r' % spec)
|
||||||
|
|
||||||
|
name = spec['name']
|
||||||
|
# get/create orm
|
||||||
|
orm_service = orm.Service.find(self.db, name=name)
|
||||||
|
if orm_service is None:
|
||||||
|
# not found, create a new one
|
||||||
|
orm_service = orm.Service(name=name, from_config=from_config)
|
||||||
|
self.db.add(orm_service)
|
||||||
|
if spec.get('admin', False):
|
||||||
|
self.log.warning(
|
||||||
|
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
|
||||||
|
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
|
||||||
|
" If you specify services in the admin role configuration, "
|
||||||
|
"the Service admin flag will be ignored."
|
||||||
|
)
|
||||||
|
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
|
||||||
|
else:
|
||||||
|
# Do nothing if the config file tries to modify a API-base service
|
||||||
|
# or vice versa.
|
||||||
|
if orm_service.from_config != from_config:
|
||||||
|
if from_config:
|
||||||
|
self.log.error(
|
||||||
|
f"The service {name} from the config file is trying to modify a runtime-created service with the same name"
|
||||||
)
|
)
|
||||||
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
|
else:
|
||||||
orm_service.admin = spec.get('admin', False)
|
self.log.error(
|
||||||
self.db.commit()
|
f"The runtime-created service {name} is trying to modify a config-based service with the same name"
|
||||||
service = Service(
|
)
|
||||||
parent=self,
|
return
|
||||||
app=self,
|
orm_service.admin = spec.get('admin', False)
|
||||||
base_url=self.base_url,
|
|
||||||
db=self.db,
|
self.db.commit()
|
||||||
orm=orm_service,
|
service = Service(
|
||||||
roles=orm_service.roles,
|
parent=self,
|
||||||
domain=domain,
|
app=self,
|
||||||
host=host,
|
base_url=self.base_url,
|
||||||
hub=self.hub,
|
db=self.db,
|
||||||
|
orm=orm_service,
|
||||||
|
roles=orm_service.roles,
|
||||||
|
domain=domain,
|
||||||
|
host=host,
|
||||||
|
hub=self.hub,
|
||||||
|
)
|
||||||
|
|
||||||
|
traits = service.traits(input=True)
|
||||||
|
for key, value in spec.items():
|
||||||
|
trait = traits.get(key)
|
||||||
|
if trait is None:
|
||||||
|
raise AttributeError("No such service field: %s" % key)
|
||||||
|
setattr(service, key, value)
|
||||||
|
# also set the value on the orm object
|
||||||
|
# unless it's marked as not in the db
|
||||||
|
# (e.g. on the oauth object)
|
||||||
|
if trait.metadata.get("in_db", True):
|
||||||
|
setattr(orm_service, key, value)
|
||||||
|
|
||||||
|
if service.api_token:
|
||||||
|
self.service_tokens[service.api_token] = service.name
|
||||||
|
elif service.managed:
|
||||||
|
# generate new token
|
||||||
|
# TODO: revoke old tokens?
|
||||||
|
service.api_token = service.orm.new_api_token(note="generated at startup")
|
||||||
|
|
||||||
|
if service.url:
|
||||||
|
parsed = urlparse(service.url)
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported scheme in URL for service {name}: {service.url}. Must be http[s]"
|
||||||
|
)
|
||||||
|
|
||||||
|
port = None
|
||||||
|
if parsed.port is not None:
|
||||||
|
port = parsed.port
|
||||||
|
elif parsed.scheme == 'http':
|
||||||
|
port = 80
|
||||||
|
elif parsed.scheme == 'https':
|
||||||
|
port = 443
|
||||||
|
|
||||||
|
server = service.orm.server = orm.Server(
|
||||||
|
proto=parsed.scheme,
|
||||||
|
ip=parsed.hostname,
|
||||||
|
port=port,
|
||||||
|
cookie_name=service.oauth_client_id,
|
||||||
|
base_url=service.prefix,
|
||||||
)
|
)
|
||||||
|
self.db.add(server)
|
||||||
|
else:
|
||||||
|
service.orm.server = None
|
||||||
|
|
||||||
traits = service.traits(input=True)
|
if service.oauth_available:
|
||||||
for key, value in spec.items():
|
allowed_scopes = set()
|
||||||
if key not in traits:
|
if service.oauth_client_allowed_scopes:
|
||||||
raise AttributeError("No such service field: %s" % key)
|
allowed_scopes.update(service.oauth_client_allowed_scopes)
|
||||||
setattr(service, key, value)
|
if service.oauth_roles:
|
||||||
|
if not allowed_scopes:
|
||||||
if service.api_token:
|
# DEPRECATED? It's still convenient and valid,
|
||||||
self.service_tokens[service.api_token] = service.name
|
# e.g. 'admin'
|
||||||
elif service.managed:
|
allowed_roles = list(
|
||||||
# generate new token
|
self.db.query(orm.Role).filter(
|
||||||
# TODO: revoke old tokens?
|
orm.Role.name.in_(service.oauth_roles)
|
||||||
service.api_token = service.orm.new_api_token(
|
|
||||||
note="generated at startup"
|
|
||||||
)
|
|
||||||
|
|
||||||
if service.url:
|
|
||||||
parsed = urlparse(service.url)
|
|
||||||
if parsed.port is not None:
|
|
||||||
port = parsed.port
|
|
||||||
elif parsed.scheme == 'http':
|
|
||||||
port = 80
|
|
||||||
elif parsed.scheme == 'https':
|
|
||||||
port = 443
|
|
||||||
server = service.orm.server = orm.Server(
|
|
||||||
proto=parsed.scheme,
|
|
||||||
ip=parsed.hostname,
|
|
||||||
port=port,
|
|
||||||
cookie_name=service.oauth_client_id,
|
|
||||||
base_url=service.prefix,
|
|
||||||
)
|
|
||||||
self.db.add(server)
|
|
||||||
|
|
||||||
else:
|
|
||||||
service.orm.server = None
|
|
||||||
|
|
||||||
if service.oauth_available:
|
|
||||||
allowed_scopes = set()
|
|
||||||
if service.oauth_client_allowed_scopes:
|
|
||||||
allowed_scopes.update(service.oauth_client_allowed_scopes)
|
|
||||||
if service.oauth_roles:
|
|
||||||
if not allowed_scopes:
|
|
||||||
# DEPRECATED? It's still convenient and valid,
|
|
||||||
# e.g. 'admin'
|
|
||||||
allowed_roles = list(
|
|
||||||
self.db.query(orm.Role).filter(
|
|
||||||
orm.Role.name.in_(service.oauth_roles)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
|
)
|
||||||
else:
|
allowed_scopes.update(roles.roles_to_scopes(allowed_roles))
|
||||||
self.log.warning(
|
else:
|
||||||
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
|
self.log.warning(
|
||||||
f" using oauth_client_allowed_scopes={allowed_scopes}."
|
f"Ignoring oauth_roles for {service.name}: {service.oauth_roles},"
|
||||||
)
|
f" using oauth_client_allowed_scopes={allowed_scopes}."
|
||||||
oauth_client = self.oauth_provider.add_client(
|
)
|
||||||
client_id=service.oauth_client_id,
|
oauth_client = self.oauth_provider.add_client(
|
||||||
client_secret=service.api_token,
|
client_id=service.oauth_client_id,
|
||||||
redirect_uri=service.oauth_redirect_uri,
|
client_secret=service.api_token,
|
||||||
description="JupyterHub service %s" % service.name,
|
redirect_uri=service.oauth_redirect_uri,
|
||||||
)
|
description="JupyterHub service %s" % service.name,
|
||||||
service.orm.oauth_client = oauth_client
|
)
|
||||||
# add access-scopes, derived from OAuthClient itself
|
service.orm.oauth_client = oauth_client
|
||||||
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
# add access-scopes, derived from OAuthClient itself
|
||||||
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
allowed_scopes.update(scopes.access_scopes(oauth_client))
|
||||||
|
oauth_client.allowed_scopes = sorted(allowed_scopes)
|
||||||
|
else:
|
||||||
|
if service.oauth_client:
|
||||||
|
self.db.delete(service.oauth_client)
|
||||||
|
|
||||||
|
self._service_map[name] = service
|
||||||
|
|
||||||
|
return service
|
||||||
|
|
||||||
|
def init_services(self):
|
||||||
|
self._service_map.clear()
|
||||||
|
for spec in self.services:
|
||||||
|
self.service_from_spec(spec, from_config=True)
|
||||||
|
|
||||||
|
for service_orm in self.db.query(orm.Service):
|
||||||
|
if service_orm.from_config:
|
||||||
|
# delete config-based services from db
|
||||||
|
# that are not in current config file:
|
||||||
|
if service_orm.name not in self._service_map:
|
||||||
|
self.db.delete(service_orm)
|
||||||
else:
|
else:
|
||||||
if service.oauth_client:
|
self.service_from_orm(service_orm)
|
||||||
self.db.delete(service.oauth_client)
|
|
||||||
|
|
||||||
self._service_map[name] = service
|
|
||||||
|
|
||||||
# delete services from db not in service config:
|
|
||||||
for service in self.db.query(orm.Service):
|
|
||||||
if service.name not in self._service_map:
|
|
||||||
self.db.delete(service)
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
async def check_services_health(self):
|
async def check_services_health(self):
|
||||||
"""Check connectivity of all services"""
|
"""Check connectivity of all services"""
|
||||||
for name, service in self._service_map.items():
|
for name, service in self._service_map.items():
|
||||||
if not service.url:
|
if not service.url:
|
||||||
|
# no URL to check, nothing to do
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
await Server.from_orm(service.orm.server).wait_up(timeout=1, http=True)
|
||||||
@@ -2591,19 +2726,22 @@ class JupyterHub(Application):
|
|||||||
# Server objects can be associated with either a Spawner or a Service,
|
# Server objects can be associated with either a Spawner or a Service,
|
||||||
# we are only interested in the ones associated with a Spawner
|
# we are only interested in the ones associated with a Spawner
|
||||||
check_futures = []
|
check_futures = []
|
||||||
for orm_server in db.query(orm.Server):
|
|
||||||
orm_spawner = orm_server.spawner
|
for orm_user, orm_spawner in (
|
||||||
if not orm_spawner:
|
self.db.query(orm.User, orm.Spawner)
|
||||||
# check for orphaned Server rows
|
# join filters out any Users with no Spawners
|
||||||
# this shouldn't happen if we've got our sqlachemy right
|
.join(orm.Spawner, orm.User._orm_spawners)
|
||||||
if not orm_server.service:
|
# this gets Users with *any* active server
|
||||||
self.log.warning("deleting orphaned server %s", orm_server)
|
.filter(orm.Spawner.server != None)
|
||||||
self.db.delete(orm_server)
|
# pre-load relationships to avoid O(N active servers) queries
|
||||||
self.db.commit()
|
.options(
|
||||||
continue
|
joinedload(orm.User._orm_spawners),
|
||||||
|
joinedload(orm.Spawner.server),
|
||||||
|
)
|
||||||
|
):
|
||||||
# instantiate Spawner wrapper and check if it's still alive
|
# instantiate Spawner wrapper and check if it's still alive
|
||||||
# spawner should be running
|
# spawner should be running
|
||||||
user = self.users[orm_spawner.user]
|
user = self.users[orm_user]
|
||||||
spawner = user.spawners[orm_spawner.name]
|
spawner = user.spawners[orm_spawner.name]
|
||||||
self.log.debug("Loading state for %s from db", spawner._log_name)
|
self.log.debug("Loading state for %s from db", spawner._log_name)
|
||||||
# signal that check is pending to avoid race conditions
|
# signal that check is pending to avoid race conditions
|
||||||
@@ -2652,7 +2790,6 @@ class JupyterHub(Application):
|
|||||||
for user in self.users.values():
|
for user in self.users.values():
|
||||||
for spawner in user.spawners.values():
|
for spawner in user.spawners.values():
|
||||||
oauth_client_ids.add(spawner.oauth_client_id)
|
oauth_client_ids.add(spawner.oauth_client_id)
|
||||||
|
|
||||||
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)):
|
||||||
if oauth_client.identifier not in oauth_client_ids:
|
if oauth_client.identifier not in oauth_client_ids:
|
||||||
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
self.log.warning("Deleting OAuth client %s", oauth_client.identifier)
|
||||||
@@ -3094,6 +3231,72 @@ class JupyterHub(Application):
|
|||||||
|
|
||||||
await self.proxy.check_routes(self.users, self._service_map, routes)
|
await self.proxy.check_routes(self.users, self._service_map, routes)
|
||||||
|
|
||||||
|
async def start_service(
|
||||||
|
self,
|
||||||
|
service_name: str,
|
||||||
|
service: Service,
|
||||||
|
ssl_context: Optional[ssl.SSLContext] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Start a managed service or poll for external service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service_name (str): Name of the service.
|
||||||
|
service (Service): The service object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
boolean: Returns `True` if the service is started successfully,
|
||||||
|
returns `False` otherwise.
|
||||||
|
"""
|
||||||
|
if ssl_context is None:
|
||||||
|
ssl_context = make_ssl_context(
|
||||||
|
self.internal_ssl_key,
|
||||||
|
self.internal_ssl_cert,
|
||||||
|
cafile=self.internal_ssl_ca,
|
||||||
|
purpose=ssl.Purpose.CLIENT_AUTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = f'{service_name} at {service.url}' if service.url else service_name
|
||||||
|
if service.managed:
|
||||||
|
self.log.info("Starting managed service %s", msg)
|
||||||
|
try:
|
||||||
|
await service.start()
|
||||||
|
except Exception as e:
|
||||||
|
self.log.critical(
|
||||||
|
"Failed to start service %s", service_name, exc_info=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.log.info("Adding external service %s", msg)
|
||||||
|
|
||||||
|
if service.url:
|
||||||
|
tries = 10 if service.managed else 1
|
||||||
|
for i in range(tries):
|
||||||
|
try:
|
||||||
|
await Server.from_orm(service.orm.server).wait_up(
|
||||||
|
http=True, timeout=1, ssl_context=ssl_context
|
||||||
|
)
|
||||||
|
except AnyTimeoutError:
|
||||||
|
if service.managed:
|
||||||
|
status = await service.spawner.poll()
|
||||||
|
if status is not None:
|
||||||
|
self.log.error(
|
||||||
|
"Service %s exited with status %s",
|
||||||
|
service_name,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self.log.error(
|
||||||
|
"Cannot connect to %s service %s at %s. Is it running?",
|
||||||
|
service.kind,
|
||||||
|
service_name,
|
||||||
|
service.url,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Start the whole thing"""
|
"""Start the whole thing"""
|
||||||
self.io_loop = loop = IOLoop.current()
|
self.io_loop = loop = IOLoop.current()
|
||||||
@@ -3179,55 +3382,29 @@ 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():
|
||||||
msg = f'{service_name} at {service.url}' if service.url else service_name
|
service_ready = await self.start_service(service_name, service, ssl_context)
|
||||||
if service.managed:
|
if not service_ready:
|
||||||
self.log.info("Starting managed service %s", msg)
|
if service.from_config:
|
||||||
try:
|
# Stop the application if a config-based service failed to start.
|
||||||
await service.start()
|
|
||||||
except Exception as e:
|
|
||||||
self.log.critical(
|
|
||||||
"Failed to start service %s", service_name, exc_info=True
|
|
||||||
)
|
|
||||||
self.exit(1)
|
self.exit(1)
|
||||||
else:
|
|
||||||
self.log.info("Adding external service %s", msg)
|
|
||||||
|
|
||||||
if service.url:
|
|
||||||
tries = 10 if service.managed else 1
|
|
||||||
for i in range(tries):
|
|
||||||
try:
|
|
||||||
await Server.from_orm(service.orm.server).wait_up(
|
|
||||||
http=True, timeout=1, ssl_context=ssl_context
|
|
||||||
)
|
|
||||||
except AnyTimeoutError:
|
|
||||||
if service.managed:
|
|
||||||
status = await service.spawner.poll()
|
|
||||||
if status is not None:
|
|
||||||
self.log.error(
|
|
||||||
"Service %s exited with status %s",
|
|
||||||
service_name,
|
|
||||||
status,
|
|
||||||
)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
else:
|
else:
|
||||||
|
# Only warn for database-based service, so that admin can connect
|
||||||
|
# to hub to remove the service.
|
||||||
self.log.error(
|
self.log.error(
|
||||||
"Cannot connect to %s service %s at %s. Is it running?",
|
"Failed to reach externally managed service %s",
|
||||||
service.kind,
|
|
||||||
service_name,
|
service_name,
|
||||||
service.url,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.proxy.check_routes(self.users, self._service_map)
|
await self.proxy.check_routes(self.users, self._service_map)
|
||||||
|
|
||||||
if self.service_check_interval and any(
|
# Check services health
|
||||||
s.url for s in self._service_map.values()
|
self._check_services_health_callback = None
|
||||||
):
|
if self.service_check_interval:
|
||||||
pc = PeriodicCallback(
|
self._check_services_health_callback = PeriodicCallback(
|
||||||
self.check_services_health, 1e3 * self.service_check_interval
|
self.check_services_health, 1e3 * self.service_check_interval
|
||||||
)
|
)
|
||||||
pc.start()
|
self._check_services_health_callback.start()
|
||||||
|
|
||||||
if self.last_activity_interval:
|
if self.last_activity_interval:
|
||||||
pc = PeriodicCallback(
|
pc = PeriodicCallback(
|
||||||
|
@@ -157,6 +157,25 @@ class Authenticator(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
).tag(config=True)
|
).tag(config=True)
|
||||||
|
|
||||||
|
otp_prompt = Any(
|
||||||
|
"OTP:",
|
||||||
|
help="""
|
||||||
|
The prompt string for the extra OTP (One Time Password) field.
|
||||||
|
|
||||||
|
.. versionadded:: 5.0
|
||||||
|
""",
|
||||||
|
).tag(config=True)
|
||||||
|
|
||||||
|
request_otp = Bool(
|
||||||
|
False,
|
||||||
|
config=True,
|
||||||
|
help="""
|
||||||
|
Prompt for OTP (One Time Password) in the login form.
|
||||||
|
|
||||||
|
.. versionadded:: 5.0
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
_deprecated_aliases = {
|
_deprecated_aliases = {
|
||||||
"whitelist": ("allowed_users", "1.2"),
|
"whitelist": ("allowed_users", "1.2"),
|
||||||
"blacklist": ("blocked_users", "1.2"),
|
"blacklist": ("blocked_users", "1.2"),
|
||||||
@@ -485,6 +504,8 @@ class Authenticator(LoggingConfigurable):
|
|||||||
- `authenticate` turns formdata into a username
|
- `authenticate` turns formdata into a username
|
||||||
- `normalize_username` normalizes the username
|
- `normalize_username` normalizes the username
|
||||||
- `check_allowed` checks against the allowed usernames
|
- `check_allowed` checks against the allowed usernames
|
||||||
|
- `check_blocked_users` check against the blocked usernames
|
||||||
|
- `is_admin` check if a user is an admin
|
||||||
|
|
||||||
.. versionchanged:: 0.8
|
.. versionchanged:: 0.8
|
||||||
return dict instead of username
|
return dict instead of username
|
||||||
@@ -603,8 +624,7 @@ class Authenticator(LoggingConfigurable):
|
|||||||
The Authenticator may return a dict instead, which MUST have a
|
The Authenticator may return a dict instead, which MUST have a
|
||||||
key `name` holding the username, and MAY have additional keys:
|
key `name` holding the username, and MAY have additional keys:
|
||||||
|
|
||||||
- `auth_state`, a dictionary of of auth state that will be
|
- `auth_state`, a dictionary of auth state that will be persisted;
|
||||||
persisted;
|
|
||||||
- `admin`, the admin setting value for the user
|
- `admin`, the admin setting value for the user
|
||||||
- `groups`, the list of group names the user should be a member of,
|
- `groups`, the list of group names the user should be a member of,
|
||||||
if Authenticator.manage_groups is True.
|
if Authenticator.manage_groups is True.
|
||||||
@@ -1103,9 +1123,16 @@ class PAMAuthenticator(LocalAuthenticator):
|
|||||||
Return None otherwise.
|
Return None otherwise.
|
||||||
"""
|
"""
|
||||||
username = data['username']
|
username = data['username']
|
||||||
|
password = data["password"]
|
||||||
|
if "otp" in data:
|
||||||
|
# OTP given, pass as tuple (requires pamela 1.1)
|
||||||
|
password = (data["password"], data["otp"])
|
||||||
try:
|
try:
|
||||||
pamela.authenticate(
|
pamela.authenticate(
|
||||||
username, data['password'], service=self.service, encoding=self.encoding
|
username,
|
||||||
|
password,
|
||||||
|
service=self.service,
|
||||||
|
encoding=self.encoding,
|
||||||
)
|
)
|
||||||
except pamela.PAMError as e:
|
except pamela.PAMError as e:
|
||||||
if handler is not None:
|
if handler is not None:
|
||||||
|
@@ -105,6 +105,7 @@ class LoginHandler(BaseHandler):
|
|||||||
'next': self.get_argument('next', ''),
|
'next': self.get_argument('next', ''),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
"authenticator": self.authenticator,
|
||||||
"xsrf": self.xsrf_token.decode('ascii'),
|
"xsrf": self.xsrf_token.decode('ascii'),
|
||||||
}
|
}
|
||||||
custom_html = Template(
|
custom_html = Template(
|
||||||
|
@@ -668,6 +668,18 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
return orm_client
|
return orm_client
|
||||||
|
|
||||||
|
def remove_client(self, client_id):
|
||||||
|
"""Remove a client by its id if it is existed."""
|
||||||
|
orm_client = (
|
||||||
|
self.db.query(orm.OAuthClient).filter_by(identifier=client_id).one_or_none()
|
||||||
|
)
|
||||||
|
if orm_client is not None:
|
||||||
|
self.db.delete(orm_client)
|
||||||
|
self.db.commit()
|
||||||
|
app_log.info("Removed client %s", client_id)
|
||||||
|
else:
|
||||||
|
app_log.warning("No such client %s", client_id)
|
||||||
|
|
||||||
def fetch_by_client_id(self, client_id):
|
def fetch_by_client_id(self, client_id):
|
||||||
"""Find a client by its id"""
|
"""Find a client by its id"""
|
||||||
client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
|
client = self.db.query(orm.OAuthClient).filter_by(identifier=client_id).first()
|
||||||
|
@@ -404,6 +404,26 @@ class Service(Base):
|
|||||||
'Role', secondary='service_role_map', back_populates='services', lazy="selectin"
|
'Role', secondary='service_role_map', back_populates='services', lazy="selectin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
url = Column(Unicode(2047), nullable=True)
|
||||||
|
|
||||||
|
oauth_client_allowed_scopes = Column(JSONList, nullable=True)
|
||||||
|
|
||||||
|
info = Column(JSONDict, nullable=True)
|
||||||
|
|
||||||
|
display = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
oauth_no_confirm = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
command = Column(JSONList, nullable=True)
|
||||||
|
|
||||||
|
cwd = Column(Unicode(4095), nullable=True)
|
||||||
|
|
||||||
|
environment = Column(JSONDict, nullable=True)
|
||||||
|
|
||||||
|
user = Column(Unicode(255), nullable=True)
|
||||||
|
|
||||||
|
from_config = Column(Boolean, default=True)
|
||||||
|
|
||||||
api_tokens = relationship(
|
api_tokens = relationship(
|
||||||
"APIToken", back_populates="service", cascade="all, delete-orphan"
|
"APIToken", back_populates="service", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
@@ -426,6 +446,7 @@ class Service(Base):
|
|||||||
ondelete='SET NULL',
|
ondelete='SET NULL',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
oauth_client = relationship(
|
oauth_client = relationship(
|
||||||
'OAuthClient',
|
'OAuthClient',
|
||||||
back_populates="service",
|
back_populates="service",
|
||||||
|
@@ -34,6 +34,7 @@ def get_default_roles():
|
|||||||
'admin-ui',
|
'admin-ui',
|
||||||
'admin:users',
|
'admin:users',
|
||||||
'admin:servers',
|
'admin:servers',
|
||||||
|
'admin:services',
|
||||||
'tokens',
|
'tokens',
|
||||||
'admin:groups',
|
'admin:groups',
|
||||||
'list:services',
|
'list:services',
|
||||||
|
@@ -123,6 +123,10 @@ scope_definitions = {
|
|||||||
'delete:groups': {
|
'delete:groups': {
|
||||||
'description': "Delete groups.",
|
'description': "Delete groups.",
|
||||||
},
|
},
|
||||||
|
'admin:services': {
|
||||||
|
'description': 'Create, read, update, delete services, not including services defined from config files.',
|
||||||
|
'subscopes': ['list:services', 'read:services', 'read:roles:services'],
|
||||||
|
},
|
||||||
'list:services': {
|
'list:services': {
|
||||||
'description': 'List services, including at least their names.',
|
'description': 'List services, including at least their names.',
|
||||||
'subscopes': ['read:services:name'],
|
'subscopes': ['read:services:name'],
|
||||||
@@ -435,7 +439,7 @@ def _expand_self_scope(username):
|
|||||||
|
|
||||||
@lru_cache(maxsize=65535)
|
@lru_cache(maxsize=65535)
|
||||||
def _expand_scope(scope):
|
def _expand_scope(scope):
|
||||||
"""Returns a scope and all all subscopes
|
"""Returns a scope and all subscopes
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
scope (str): the scope to expand
|
scope (str): the scope to expand
|
||||||
|
@@ -180,9 +180,12 @@ class Service(LoggingConfigurable):
|
|||||||
- user: str
|
- user: str
|
||||||
The name of a system user to become.
|
The name of a system user to become.
|
||||||
If unspecified, run as the same user as the Hub.
|
If unspecified, run as the same user as the Hub.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# inputs:
|
# traits tagged with `input=True` are accepted as input from configuration / API
|
||||||
|
# input traits are also persisted to the db UNLESS they are also tagged with `in_db=False`
|
||||||
|
|
||||||
name = Unicode(
|
name = Unicode(
|
||||||
help="""The name of the service.
|
help="""The name of the service.
|
||||||
|
|
||||||
@@ -205,7 +208,7 @@ class Service(LoggingConfigurable):
|
|||||||
|
|
||||||
DEPRECATED in 3.0: use oauth_client_allowed_scopes
|
DEPRECATED in 3.0: use oauth_client_allowed_scopes
|
||||||
"""
|
"""
|
||||||
).tag(input=True)
|
).tag(input=True, in_db=False)
|
||||||
|
|
||||||
oauth_client_allowed_scopes = List(
|
oauth_client_allowed_scopes = List(
|
||||||
help="""OAuth allowed scopes.
|
help="""OAuth allowed scopes.
|
||||||
@@ -225,7 +228,7 @@ class Service(LoggingConfigurable):
|
|||||||
|
|
||||||
If unspecified, an API token will be generated for managed services.
|
If unspecified, an API token will be generated for managed services.
|
||||||
"""
|
"""
|
||||||
).tag(input=True)
|
).tag(input=True, in_db=False)
|
||||||
|
|
||||||
info = Dict(
|
info = Dict(
|
||||||
help="""Provide a place to include miscellaneous information about the service,
|
help="""Provide a place to include miscellaneous information about the service,
|
||||||
@@ -310,7 +313,7 @@ class Service(LoggingConfigurable):
|
|||||||
You shouldn't generally need to change this.
|
You shouldn't generally need to change this.
|
||||||
Default: `service-<name>`
|
Default: `service-<name>`
|
||||||
"""
|
"""
|
||||||
).tag(input=True)
|
).tag(input=True, in_db=False)
|
||||||
|
|
||||||
@default('oauth_client_id')
|
@default('oauth_client_id')
|
||||||
def _default_client_id(self):
|
def _default_client_id(self):
|
||||||
@@ -331,7 +334,7 @@ class Service(LoggingConfigurable):
|
|||||||
You shouldn't generally need to change this.
|
You shouldn't generally need to change this.
|
||||||
Default: `/services/:name/oauth_callback`
|
Default: `/services/:name/oauth_callback`
|
||||||
"""
|
"""
|
||||||
).tag(input=True)
|
).tag(input=True, in_db=False)
|
||||||
|
|
||||||
@default('oauth_redirect_uri')
|
@default('oauth_redirect_uri')
|
||||||
def _default_redirect_uri(self):
|
def _default_redirect_uri(self):
|
||||||
@@ -371,6 +374,11 @@ class Service(LoggingConfigurable):
|
|||||||
else:
|
else:
|
||||||
return self.server.base_url
|
return self.server.base_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def from_config(self):
|
||||||
|
"""Is the service defined from config file?"""
|
||||||
|
return self.orm.from_config
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{cls}(name={name}{managed})>".format(
|
return "<{cls}(name={name}{managed})>".format(
|
||||||
cls=self.__class__.__name__,
|
cls=self.__class__.__name__,
|
||||||
|
@@ -6,7 +6,7 @@
|
|||||||
.. versionchanged:: 2.0
|
.. versionchanged:: 2.0
|
||||||
|
|
||||||
Default app changed to launch `jupyter labhub`.
|
Default app changed to launch `jupyter labhub`.
|
||||||
Use JUPYTERHUB_SINGLEUSER_APP=notebook.notebookapp.NotebookApp for the legacy 'classic' notebook server.
|
Use JUPYTERHUB_SINGLEUSER_APP='notebook' for the legacy 'classic' notebook server (requires notebook<7).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -27,7 +27,25 @@ JUPYTERHUB_SINGLEUSER_APP = _app_shortcuts.get(
|
|||||||
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
|
JUPYTERHUB_SINGLEUSER_APP.replace("_", "-"), JUPYTERHUB_SINGLEUSER_APP
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if JUPYTERHUB_SINGLEUSER_APP:
|
if JUPYTERHUB_SINGLEUSER_APP:
|
||||||
|
if JUPYTERHUB_SINGLEUSER_APP in {"notebook", _app_shortcuts["notebook"]}:
|
||||||
|
# better error for notebook v7, which uses jupyter-server
|
||||||
|
# when the legacy notebook server is requested
|
||||||
|
try:
|
||||||
|
from notebook import __version__
|
||||||
|
except ImportError:
|
||||||
|
# will raise later
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# check if this failed because of notebook v7
|
||||||
|
_notebook_major_version = int(__version__.split(".", 1)[0])
|
||||||
|
if _notebook_major_version >= 7:
|
||||||
|
raise ImportError(
|
||||||
|
f"JUPYTERHUB_SINGLEUSER_APP={JUPYTERHUB_SINGLEUSER_APP} is not valid with notebook>=7 (have notebook=={__version__}).\n"
|
||||||
|
f"Leave $JUPYTERHUB_SINGLEUSER_APP unspecified (or use the default JUPYTERHUB_SINGLEUSER_APP=jupyter-server), "
|
||||||
|
'and set `c.Spawner.default_url = "/tree"` to make notebook v7 the default UI.'
|
||||||
|
)
|
||||||
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
App = import_item(JUPYTERHUB_SINGLEUSER_APP)
|
||||||
else:
|
else:
|
||||||
App = None
|
App = None
|
||||||
|
@@ -483,6 +483,11 @@ class JupyterHubSingleUser(ExtensionApp):
|
|||||||
cfg.answer_yes = True
|
cfg.answer_yes = True
|
||||||
self.config.FileContentsManager.delete_to_trash = False
|
self.config.FileContentsManager.delete_to_trash = False
|
||||||
|
|
||||||
|
# load Spawner.notebook_dir configuration, if given
|
||||||
|
root_dir = os.getenv("JUPYTERHUB_ROOT_DIR", None)
|
||||||
|
if root_dir:
|
||||||
|
cfg.root_dir = os.path.expanduser(root_dir)
|
||||||
|
|
||||||
# load http server config from environment
|
# load http server config from environment
|
||||||
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
if url.port:
|
if url.port:
|
||||||
@@ -639,7 +644,7 @@ class JupyterHubSingleUser(ExtensionApp):
|
|||||||
disable_user_config = Bool()
|
disable_user_config = Bool()
|
||||||
|
|
||||||
@default("disable_user_config")
|
@default("disable_user_config")
|
||||||
def _defaut_disable_user_config(self):
|
def _default_disable_user_config(self):
|
||||||
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
return _bool_env("JUPYTERHUB_DISABLE_USER_CONFIG")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@@ -274,8 +274,6 @@ class Spawner(LoggingConfigurable):
|
|||||||
api_token = Unicode()
|
api_token = Unicode()
|
||||||
oauth_client_id = Unicode()
|
oauth_client_id = Unicode()
|
||||||
|
|
||||||
oauth_scopes = List(Unicode())
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def oauth_scopes(self):
|
def oauth_scopes(self):
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from unittest import mock
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -143,6 +144,53 @@ async def test_login_with_invalid_credantials(app, browser, username, pass_w):
|
|||||||
await expect(browser).to_have_url(re.compile(".*/hub/login"))
|
await expect(browser).to_have_url(re.compile(".*/hub/login"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("request_otp", [True, False])
|
||||||
|
async def test_login_otp(request, app, browser, username, request_otp):
|
||||||
|
def _reset():
|
||||||
|
app.authenticator.request_otp = False
|
||||||
|
|
||||||
|
request.addfinalizer(_reset)
|
||||||
|
|
||||||
|
app.authenticator.request_otp = request_otp
|
||||||
|
login_url = url_concat(
|
||||||
|
url_path_join(public_host(app), app.hub.base_url, "login"),
|
||||||
|
{"next": ujoin(public_url(app), "/hub/home/")},
|
||||||
|
)
|
||||||
|
await browser.goto(login_url)
|
||||||
|
# check for otp element
|
||||||
|
otp_label = browser.locator("label[for=otp_input]")
|
||||||
|
otp_input = browser.locator("input#otp_input")
|
||||||
|
if not request_otp:
|
||||||
|
# request_otp is False, no OTP prompt
|
||||||
|
assert await otp_label.count() == 0
|
||||||
|
assert await otp_input.count() == 0
|
||||||
|
return
|
||||||
|
|
||||||
|
await expect(otp_label).to_be_visible()
|
||||||
|
await expect(otp_input).to_be_visible()
|
||||||
|
await expect(otp_label).to_have_text(app.authenticator.otp_prompt)
|
||||||
|
|
||||||
|
# fill it out
|
||||||
|
await browser.get_by_label("Username:").fill(username)
|
||||||
|
await browser.get_by_label("Password:").fill(username)
|
||||||
|
await browser.get_by_label("OTP:").fill("otp!")
|
||||||
|
|
||||||
|
# submit form
|
||||||
|
with mock.patch(
|
||||||
|
"jupyterhub.tests.mocking.mock_authenticate",
|
||||||
|
spec=True,
|
||||||
|
return_value={"username": username},
|
||||||
|
) as mock_otp_auth:
|
||||||
|
await browser.get_by_role("button", name="Sign in").click()
|
||||||
|
expected_url = ujoin(public_url(app), "/hub/home/")
|
||||||
|
await expect(browser).to_have_url(expected_url)
|
||||||
|
|
||||||
|
# check that OTP was passed
|
||||||
|
assert mock_otp_auth.called
|
||||||
|
assert mock_otp_auth.call_args.args[0] == username
|
||||||
|
assert mock_otp_auth.call_args.args[1] == (username, "otp!")
|
||||||
|
|
||||||
|
|
||||||
# SPAWNING
|
# SPAWNING
|
||||||
|
|
||||||
|
|
||||||
@@ -985,7 +1033,7 @@ async def test_start_stop_server_on_admin_page(
|
|||||||
|
|
||||||
async def click_spawn_page(browser, username):
|
async def click_spawn_page(browser, username):
|
||||||
"""spawn the server for one user via the Spawn page button, index = 0 or 1"""
|
"""spawn the server for one user via the Spawn page button, index = 0 or 1"""
|
||||||
spawn_btn_xpath = f'//a[contains(@href, "spawn/{username}")]/button[contains(@class, "secondary")]'
|
spawn_btn_xpath = f'//a[contains(@href, "spawn/{username}")]/button[contains(@class, "btn-light")]'
|
||||||
spawn_btn = browser.locator(spawn_btn_xpath)
|
spawn_btn = browser.locator(spawn_btn_xpath)
|
||||||
await expect(spawn_btn).to_be_enabled()
|
await expect(spawn_btn).to_be_enabled()
|
||||||
async with browser.expect_navigation(url=f"**/user/{username}/"):
|
async with browser.expect_navigation(url=f"**/user/{username}/"):
|
||||||
@@ -993,7 +1041,7 @@ async def test_start_stop_server_on_admin_page(
|
|||||||
|
|
||||||
async def click_access_server(browser, username):
|
async def click_access_server(browser, username):
|
||||||
"""access to the server for users via the Access Server button"""
|
"""access to the server for users via the Access Server button"""
|
||||||
access_btn_xpath = f'//a[contains(@href, "user/{username}")]/button[contains(@class, "primary")]'
|
access_btn_xpath = f'//a[contains(@href, "user/{username}")]/button[contains(@class, "btn-primary")]'
|
||||||
access_btn = browser.locator(access_btn_xpath)
|
access_btn = browser.locator(access_btn_xpath)
|
||||||
await expect(access_btn).to_be_enabled()
|
await expect(access_btn).to_be_enabled()
|
||||||
await access_btn.click()
|
await access_btn.click()
|
||||||
|
@@ -167,6 +167,8 @@ async def cleanup_after(request, io_loop):
|
|||||||
app = MockHub.instance()
|
app = MockHub.instance()
|
||||||
if app.db_file.closed:
|
if app.db_file.closed:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# cleanup users
|
||||||
for orm_user in app.db.query(orm.User):
|
for orm_user in app.db.query(orm.User):
|
||||||
user = app.users[orm_user]
|
user = app.users[orm_user]
|
||||||
for name, spawner in list(user.spawners.items()):
|
for name, spawner in list(user.spawners.items()):
|
||||||
@@ -182,6 +184,16 @@ async def cleanup_after(request, io_loop):
|
|||||||
# delete groups
|
# delete groups
|
||||||
for group in app.db.query(orm.Group):
|
for group in app.db.query(orm.Group):
|
||||||
app.db.delete(group)
|
app.db.delete(group)
|
||||||
|
|
||||||
|
# clear services
|
||||||
|
for name, service in app._service_map.items():
|
||||||
|
if service.managed:
|
||||||
|
service.stop()
|
||||||
|
for orm_service in app.db.query(orm.Service):
|
||||||
|
if orm_service.oauth_client:
|
||||||
|
app.oauth_provider.remove_client(orm_service.oauth_client_id)
|
||||||
|
app.db.delete(orm_service)
|
||||||
|
app._service_map.clear()
|
||||||
app.db.commit()
|
app.db.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -263,10 +275,7 @@ class MockServiceSpawner(jupyterhub.services.service._ServiceSpawner):
|
|||||||
poll_interval = 1
|
poll_interval = 1
|
||||||
|
|
||||||
|
|
||||||
_mock_service_counter = 0
|
async def _mockservice(request, app, name, external=False, url=False):
|
||||||
|
|
||||||
|
|
||||||
async def _mockservice(request, app, external=False, url=False):
|
|
||||||
"""
|
"""
|
||||||
Add a service to the application
|
Add a service to the application
|
||||||
|
|
||||||
@@ -282,9 +291,6 @@ async def _mockservice(request, app, external=False, url=False):
|
|||||||
If True, register the service at a URL
|
If True, register the service at a URL
|
||||||
(as opposed to headless, API-only).
|
(as opposed to headless, API-only).
|
||||||
"""
|
"""
|
||||||
global _mock_service_counter
|
|
||||||
_mock_service_counter += 1
|
|
||||||
name = 'mock-service-%i' % _mock_service_counter
|
|
||||||
spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
|
spec = {'name': name, 'command': mockservice_cmd, 'admin': True}
|
||||||
if url:
|
if url:
|
||||||
if app.internal_ssl:
|
if app.internal_ssl:
|
||||||
@@ -331,22 +337,33 @@ async def _mockservice(request, app, external=False, url=False):
|
|||||||
return service
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
_service_name_counter = 0
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
async def mockservice(request, app):
|
def service_name():
|
||||||
|
global _service_name_counter
|
||||||
|
_service_name_counter += 1
|
||||||
|
name = f'test-service-{_service_name_counter}'
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
async def mockservice(request, app, service_name):
|
||||||
"""Mock a service with no external service url"""
|
"""Mock a service with no external service url"""
|
||||||
yield await _mockservice(request, app, url=False)
|
yield await _mockservice(request, app, name=service_name, url=False)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
async def mockservice_external(request, app):
|
async def mockservice_external(request, app, service_name):
|
||||||
"""Mock an externally managed service (don't start anything)"""
|
"""Mock an externally managed service (don't start anything)"""
|
||||||
yield await _mockservice(request, app, external=True, url=False)
|
yield await _mockservice(request, app, name=service_name, external=True, url=False)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
async def mockservice_url(request, app):
|
async def mockservice_url(request, app, service_name):
|
||||||
"""Mock a service with its own url to test external services"""
|
"""Mock a service with its own url to test external services"""
|
||||||
yield await _mockservice(request, app, url=True)
|
yield await _mockservice(request, app, name=service_name, url=True)
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@fixture
|
||||||
@@ -535,3 +552,17 @@ def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|||||||
queries = db_counts[nodeid]
|
queries = db_counts[nodeid]
|
||||||
if queries:
|
if queries:
|
||||||
terminalreporter.line(f"{queries:<6} {nodeid}")
|
terminalreporter.line(f"{queries:<6} {nodeid}")
|
||||||
|
|
||||||
|
|
||||||
|
@fixture
|
||||||
|
def service_data(service_name):
|
||||||
|
"""Data used to create service at runtime"""
|
||||||
|
return {
|
||||||
|
"name": service_name,
|
||||||
|
"oauth_client_id": f"service-{service_name}",
|
||||||
|
"api_token": f"api_token-{service_name}",
|
||||||
|
"oauth_redirect_uri": "http://127.0.0.1:5555/oauth_callback-from-api",
|
||||||
|
"oauth_no_confirm": True,
|
||||||
|
"oauth_client_allowed_scopes": ["inherit"],
|
||||||
|
"info": {'foo': 'bar'},
|
||||||
|
}
|
||||||
|
@@ -30,6 +30,7 @@ class JupyterHubTestHandler(JupyterHandler):
|
|||||||
info = {
|
info = {
|
||||||
"current_user": self.current_user,
|
"current_user": self.current_user,
|
||||||
"config": self.app.config,
|
"config": self.app.config,
|
||||||
|
"root_dir": self.contents_manager.root_dir,
|
||||||
"disable_user_config": getattr(self.app, "disable_user_config", None),
|
"disable_user_config": getattr(self.app, "disable_user_config", None),
|
||||||
"settings": self.settings,
|
"settings": self.settings,
|
||||||
"config_file_paths": self.app.config_file_paths,
|
"config_file_paths": self.app.config_file_paths,
|
||||||
|
@@ -4,10 +4,12 @@ import json
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
|
import pytest
|
||||||
from pytest import fixture, mark
|
from pytest import fixture, mark
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
|
|
||||||
@@ -2062,7 +2064,7 @@ async def test_get_services(app, mockservice_url):
|
|||||||
async def test_get_service(app, mockservice_url):
|
async def test_get_service(app, mockservice_url):
|
||||||
mockservice = mockservice_url
|
mockservice = mockservice_url
|
||||||
db = app.db
|
db = app.db
|
||||||
r = await api_request(app, 'services/%s' % mockservice.name)
|
r = await api_request(app, f"services/{mockservice.name}")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
@@ -2081,19 +2083,271 @@ async def test_get_service(app, mockservice_url):
|
|||||||
}
|
}
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app,
|
app,
|
||||||
'services/%s' % mockservice.name,
|
f"services/{mockservice.name}",
|
||||||
headers={'Authorization': 'token %s' % mockservice.api_token},
|
headers={'Authorization': 'token %s' % mockservice.api_token},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
r = await api_request(
|
r = await api_request(
|
||||||
app, 'services/%s' % mockservice.name, headers=auth_header(db, 'user')
|
app, f"services/{mockservice.name}", headers=auth_header(db, 'user')
|
||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
r = await api_request(app, "services/nosuchservice")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_admin_user(create_user_with_scopes):
|
||||||
|
return create_user_with_scopes('admin:services')
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_service(app, service_admin_user, service_name, service_data):
|
||||||
|
db = app.db
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.json()['name'] == service_name
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is not None
|
||||||
|
|
||||||
|
oath_client = (
|
||||||
|
db.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=service_data['oauth_client_id'])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert oath_client.redirect_uri == service_data['oauth_redirect_uri']
|
||||||
|
|
||||||
|
assert service_name in app._service_map
|
||||||
|
assert (
|
||||||
|
app._service_map[service_name].oauth_no_confirm
|
||||||
|
== service_data['oauth_no_confirm']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_service_no_role(app, service_name, service_data):
|
||||||
|
db = app.db
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, 'user'),
|
||||||
|
data=json.dumps(service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_service_conflict(
|
||||||
|
app, service_admin_user, mockservice, service_data, service_name
|
||||||
|
):
|
||||||
|
db = app.db
|
||||||
|
app.services = [{'name': service_name}]
|
||||||
|
app.init_services()
|
||||||
|
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_service_duplication(
|
||||||
|
app, service_admin_user, service_name, service_data
|
||||||
|
):
|
||||||
|
db = app.db
|
||||||
|
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_managed_service(
|
||||||
|
app, service_admin_user, service_name, service_data
|
||||||
|
):
|
||||||
|
db = app.db
|
||||||
|
managed_service_data = deepcopy(service_data)
|
||||||
|
managed_service_data['command'] = ['foo']
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(managed_service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert 'Can not create managed service' in r.json()['message']
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is None
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_admin_service(app, admin_user, service_name, service_data):
|
||||||
|
db = app.db
|
||||||
|
managed_service_data = deepcopy(service_data)
|
||||||
|
managed_service_data['admin'] = True
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, admin_user.name),
|
||||||
|
data=json.dumps(managed_service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 201
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is not None
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_admin_service_without_admin_right(
|
||||||
|
app, service_admin_user, service_data, service_name
|
||||||
|
):
|
||||||
|
db = app.db
|
||||||
|
managed_service_data = deepcopy(service_data)
|
||||||
|
managed_service_data['admin'] = True
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(managed_service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert 'Not assigning requested scopes' in r.json()['message']
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is None
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_service_with_scope(
|
||||||
|
app, create_user_with_scopes, service_name, service_data
|
||||||
|
):
|
||||||
|
db = app.db
|
||||||
|
managed_service_data = deepcopy(service_data)
|
||||||
|
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
|
||||||
|
managed_service_data['oauth_client_id'] = "service-client-with-scope"
|
||||||
|
user_with_scope = create_user_with_scopes('admin:services', 'admin:users')
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, user_with_scope.name),
|
||||||
|
data=json.dumps(managed_service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 201
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is not None
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_create_service_without_requested_scope(
|
||||||
|
app,
|
||||||
|
service_admin_user,
|
||||||
|
service_data,
|
||||||
|
service_name,
|
||||||
|
):
|
||||||
|
db = app.db
|
||||||
|
managed_service_data = deepcopy(service_data)
|
||||||
|
managed_service_data['oauth_client_allowed_scopes'] = ["admin:users"]
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(managed_service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 400
|
||||||
|
assert 'Not assigning requested scopes' in r.json()['message']
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is None
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_delete_service(app, service_admin_user, service_name, service_data):
|
||||||
|
db = app.db
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
data=json.dumps(service_data),
|
||||||
|
method='post',
|
||||||
|
)
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
method='delete',
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
orm_service = orm.Service.find(db, service_name)
|
||||||
|
assert orm_service is None
|
||||||
|
|
||||||
|
oath_client = (
|
||||||
|
db.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=service_data['oauth_client_id'])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert oath_client is None
|
||||||
|
|
||||||
|
assert service_name not in app._service_map
|
||||||
|
|
||||||
|
r = await api_request(app, f"services/{service_name}", method="delete")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@mark.services
|
||||||
|
async def test_delete_service_from_config(app, service_admin_user, mockservice):
|
||||||
|
db = app.db
|
||||||
|
service_name = mockservice.name
|
||||||
|
r = await api_request(
|
||||||
|
app,
|
||||||
|
f'services/{service_name}',
|
||||||
|
headers=auth_header(db, service_admin_user.name),
|
||||||
|
method='delete',
|
||||||
|
)
|
||||||
|
assert r.status_code == 405
|
||||||
|
assert r.json()['message'] == f'Service {service_name} is not modifiable at runtime'
|
||||||
|
|
||||||
|
|
||||||
async def test_root_api(app):
|
async def test_root_api(app):
|
||||||
base_url = app.hub.url
|
|
||||||
url = ujoin(base_url, 'api')
|
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if app.internal_ssl:
|
if app.internal_ssl:
|
||||||
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
kwargs['cert'] = (app.internal_ssl_cert, app.internal_ssl_key)
|
||||||
|
@@ -220,7 +220,7 @@ def test_cookie_secret_env(tmpdir, request):
|
|||||||
assert not os.path.exists(hub.cookie_secret_file)
|
assert not os.path.exists(hub.cookie_secret_file)
|
||||||
|
|
||||||
|
|
||||||
def test_cookie_secret_string_():
|
def test_cookie_secret_string():
|
||||||
cfg = Config()
|
cfg = Config()
|
||||||
|
|
||||||
cfg.JupyterHub.cookie_secret = "not hex"
|
cfg.JupyterHub.cookie_secret = "not hex"
|
||||||
@@ -270,18 +270,41 @@ async def test_load_groups(tmpdir, request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_resume_spawners(tmpdir, request):
|
@pytest.fixture
|
||||||
if not os.getenv('JUPYTERHUB_TEST_DB_URL'):
|
def persist_db(tmpdir):
|
||||||
p = patch.dict(
|
"""ensure db will persist (overrides default sqlite://:memory:)"""
|
||||||
os.environ,
|
if os.getenv('JUPYTERHUB_TEST_DB_URL'):
|
||||||
{
|
# already using a db, no need
|
||||||
'JUPYTERHUB_TEST_DB_URL': 'sqlite:///%s'
|
yield
|
||||||
% tmpdir.join('jupyterhub.sqlite')
|
return
|
||||||
},
|
with patch.dict(
|
||||||
)
|
os.environ,
|
||||||
p.start()
|
{'JUPYTERHUB_TEST_DB_URL': f"sqlite:///{tmpdir.join('jupyterhub.sqlite')}"},
|
||||||
request.addfinalizer(p.stop)
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def new_hub(request, tmpdir, persist_db):
|
||||||
|
"""Fixture to launch a new hub for testing"""
|
||||||
|
|
||||||
|
async def new_hub():
|
||||||
|
kwargs = {}
|
||||||
|
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||||
|
if ssl_enabled:
|
||||||
|
kwargs['internal_certs_location'] = str(tmpdir)
|
||||||
|
app = MockHub(test_clean_db=False, **kwargs)
|
||||||
|
app.config.ConfigurableHTTPProxy.should_start = False
|
||||||
|
app.config.ConfigurableHTTPProxy.auth_token = 'unused'
|
||||||
|
request.addfinalizer(app.stop)
|
||||||
|
await app.initialize([])
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
return new_hub
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resume_spawners(tmpdir, request, new_hub):
|
||||||
async def new_hub():
|
async def new_hub():
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||||
@@ -473,3 +496,42 @@ async def test_user_creation(tmpdir, request):
|
|||||||
"in-group",
|
"in-group",
|
||||||
"in-role",
|
"in-role",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recreate_service_from_database(
|
||||||
|
request, new_hub, service_name, service_data
|
||||||
|
):
|
||||||
|
# create a hub and add a service (not from config)
|
||||||
|
app = await new_hub()
|
||||||
|
app.service_from_spec(service_data, from_config=False)
|
||||||
|
app.stop()
|
||||||
|
|
||||||
|
# new hub, should load service from db
|
||||||
|
app = await new_hub()
|
||||||
|
assert service_name in app._service_map
|
||||||
|
|
||||||
|
# verify keys
|
||||||
|
service = app._service_map[service_name]
|
||||||
|
for key, value in service_data.items():
|
||||||
|
if key in {'api_token'}:
|
||||||
|
# skip some keys
|
||||||
|
continue
|
||||||
|
assert getattr(service, key) == value
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service_data['oauth_client_id'] in app.tornado_settings['oauth_no_confirm_list']
|
||||||
|
)
|
||||||
|
oauth_client = (
|
||||||
|
app.db.query(orm.OAuthClient)
|
||||||
|
.filter_by(identifier=service_data['oauth_client_id'])
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
assert oauth_client.redirect_uri == service_data['oauth_redirect_uri']
|
||||||
|
|
||||||
|
# delete service from db, start one more
|
||||||
|
app.db.delete(service.orm)
|
||||||
|
app.db.commit()
|
||||||
|
|
||||||
|
# start one more, service should be gone
|
||||||
|
app = await new_hub()
|
||||||
|
assert service_name not in app._service_map
|
||||||
|
@@ -105,7 +105,8 @@ async def test_pam_auth_admin_groups():
|
|||||||
_getgrouplist=getgrouplist,
|
_getgrouplist=getgrouplist,
|
||||||
):
|
):
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'also_group_admin', 'password': 'also_group_admin'}
|
None,
|
||||||
|
{'username': 'also_group_admin', 'password': 'also_group_admin'},
|
||||||
)
|
)
|
||||||
assert authorized['name'] == 'also_group_admin'
|
assert authorized['name'] == 'also_group_admin'
|
||||||
assert authorized['admin'] is True
|
assert authorized['admin'] is True
|
||||||
@@ -118,7 +119,8 @@ async def test_pam_auth_admin_groups():
|
|||||||
_getgrouplist=getgrouplist,
|
_getgrouplist=getgrouplist,
|
||||||
):
|
):
|
||||||
authorized = await authenticator.get_authenticated_user(
|
authorized = await authenticator.get_authenticated_user(
|
||||||
None, {'username': 'override_admin', 'password': 'override_admin'}
|
None,
|
||||||
|
{'username': 'override_admin', 'password': 'override_admin'},
|
||||||
)
|
)
|
||||||
assert authorized['name'] == 'override_admin'
|
assert authorized['name'] == 'override_admin'
|
||||||
assert authorized['admin'] is True
|
assert authorized['admin'] is True
|
||||||
|
@@ -44,7 +44,9 @@ def generate_old_db(env_dir, hub_version, db_url):
|
|||||||
|
|
||||||
# changes to this version list must also be reflected
|
# changes to this version list must also be reflected
|
||||||
# in ci/init-db.sh
|
# in ci/init-db.sh
|
||||||
@pytest.mark.parametrize('hub_version', ["1.1.0", "1.2.2", "1.3.0", "1.5.0", "2.1.1"])
|
@pytest.mark.parametrize(
|
||||||
|
'hub_version', ['1.1.0', '1.2.2', '1.3.0', '1.5.0', '2.1.1', '3.1.1']
|
||||||
|
)
|
||||||
async def test_upgrade(tmpdir, hub_version):
|
async def test_upgrade(tmpdir, hub_version):
|
||||||
db_url = os.getenv('JUPYTERHUB_TEST_DB_URL')
|
db_url = os.getenv('JUPYTERHUB_TEST_DB_URL')
|
||||||
if db_url:
|
if db_url:
|
||||||
|
@@ -236,6 +236,16 @@ def test_orm_roles_delete_cascade(db):
|
|||||||
['tokens!group=hobbits'],
|
['tokens!group=hobbits'],
|
||||||
{'tokens!group=hobbits', 'read:tokens!group=hobbits'},
|
{'tokens!group=hobbits', 'read:tokens!group=hobbits'},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
['admin:services'],
|
||||||
|
{
|
||||||
|
'read:roles:services',
|
||||||
|
'read:services:name',
|
||||||
|
'admin:services',
|
||||||
|
'list:services',
|
||||||
|
'read:services',
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_get_expanded_scopes(db, scopes, expected_scopes):
|
def test_get_expanded_scopes(db, scopes, expected_scopes):
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
|
from pprint import pprint
|
||||||
from subprocess import CalledProcessError, check_output
|
from subprocess import CalledProcessError, check_output
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from urllib.parse import urlencode, urlparse
|
from urllib.parse import urlencode, urlparse
|
||||||
@@ -171,9 +172,7 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
|||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
info = r.json()
|
info = r.json()
|
||||||
import pprint
|
pprint(info)
|
||||||
|
|
||||||
pprint.pprint(info)
|
|
||||||
assert info['disable_user_config']
|
assert info['disable_user_config']
|
||||||
server_config = info['config']
|
server_config = info['config']
|
||||||
settings = info['settings']
|
settings = info['settings']
|
||||||
@@ -198,6 +197,79 @@ async def test_disable_user_config(request, app, tmpdir, full_spawn):
|
|||||||
assert_not_in_home(path, key)
|
assert_not_in_home(path, key)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("extension", [True, False])
|
||||||
|
@pytest.mark.parametrize("notebook_dir", ["", "~", "~/sub", "ABS"])
|
||||||
|
async def test_notebook_dir(
|
||||||
|
request, app, tmpdir, user, full_spawn, extension, notebook_dir
|
||||||
|
):
|
||||||
|
if extension:
|
||||||
|
try:
|
||||||
|
import jupyter_server # noqa
|
||||||
|
except ImportError:
|
||||||
|
pytest.skip("needs jupyter-server 2")
|
||||||
|
else:
|
||||||
|
if jupyter_server.version_info < (2,):
|
||||||
|
pytest.skip("needs jupyter-server 2")
|
||||||
|
|
||||||
|
token = user.new_api_token(scopes=["access:servers!user"])
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
spawner = user.spawner
|
||||||
|
if extension:
|
||||||
|
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "1"
|
||||||
|
else:
|
||||||
|
user.spawner.environment["JUPYTERHUB_SINGLEUSER_EXTENSION"] = "0"
|
||||||
|
|
||||||
|
home_dir = tmpdir.join("home").mkdir()
|
||||||
|
sub_dir = home_dir.join("sub").mkdir()
|
||||||
|
with sub_dir.join("subfile.txt").open("w") as f:
|
||||||
|
f.write("txt\n")
|
||||||
|
abs_dir = tmpdir.join("abs").mkdir()
|
||||||
|
with abs_dir.join("absfile.txt").open("w") as f:
|
||||||
|
f.write("absfile\n")
|
||||||
|
|
||||||
|
if notebook_dir:
|
||||||
|
expected_root_dir = notebook_dir.replace("ABS", str(abs_dir)).replace(
|
||||||
|
"~", str(home_dir)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
expected_root_dir = str(home_dir)
|
||||||
|
|
||||||
|
spawner.notebook_dir = notebook_dir.replace("ABS", str(abs_dir))
|
||||||
|
|
||||||
|
# home_dir is defined on SimpleSpawner
|
||||||
|
user.spawner.home_dir = home = str(home_dir)
|
||||||
|
spawner.environment["HOME"] = home
|
||||||
|
await user.spawn()
|
||||||
|
await app.proxy.add_user(user)
|
||||||
|
url = public_url(app, user)
|
||||||
|
r = await async_requests.get(
|
||||||
|
url_path_join(public_url(app, user), 'jupyterhub-test-info'), headers=headers
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
info = r.json()
|
||||||
|
pprint(info)
|
||||||
|
|
||||||
|
assert info["root_dir"] == expected_root_dir
|
||||||
|
# secondary check: make sure it has the intended effect on root_dir
|
||||||
|
r = await async_requests.get(
|
||||||
|
url_path_join(public_url(app, user), 'api/contents/'), headers=headers
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
root_contents = sorted(item['name'] for item in r.json()['content'])
|
||||||
|
|
||||||
|
# check contents
|
||||||
|
if not notebook_dir or notebook_dir == "~":
|
||||||
|
# use any to avoid counting possible automatically created files in $HOME
|
||||||
|
assert 'sub' in root_contents
|
||||||
|
elif notebook_dir == "ABS":
|
||||||
|
assert 'absfile.txt' in root_contents
|
||||||
|
elif notebook_dir == "~/sub":
|
||||||
|
assert 'subfile.txt' in root_contents
|
||||||
|
else:
|
||||||
|
raise ValueError(f"No contents check for {notebook_dir=}")
|
||||||
|
|
||||||
|
|
||||||
def test_help_output():
|
def test_help_output():
|
||||||
out = check_output(
|
out = check_output(
|
||||||
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
[sys.executable, '-m', 'jupyterhub.singleuser', '--help-all']
|
||||||
|
@@ -6,7 +6,7 @@ jinja2>=2.11.0
|
|||||||
jupyter_telemetry>=0.1.0
|
jupyter_telemetry>=0.1.0
|
||||||
oauthlib>=3.0
|
oauthlib>=3.0
|
||||||
packaging
|
packaging
|
||||||
pamela; sys_platform != 'win32'
|
pamela>=1.1.0; sys_platform != 'win32'
|
||||||
prometheus_client>=0.4.0
|
prometheus_client>=0.4.0
|
||||||
psutil>=5.6.5; sys_platform == 'win32'
|
psutil>=5.6.5; sys_platform == 'win32'
|
||||||
python-dateutil
|
python-dateutil
|
||||||
|
@@ -52,7 +52,6 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
name="username"
|
name="username"
|
||||||
val="{{username}}"
|
val="{{username}}"
|
||||||
tabindex="1"
|
|
||||||
autofocus="autofocus"
|
autofocus="autofocus"
|
||||||
/>
|
/>
|
||||||
<label for='password_input'>Password:</label>
|
<label for='password_input'>Password:</label>
|
||||||
@@ -62,8 +61,16 @@
|
|||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
name="password"
|
name="password"
|
||||||
id="password_input"
|
id="password_input"
|
||||||
tabindex="2"
|
|
||||||
/>
|
/>
|
||||||
|
{% if authenticator.request_otp %}
|
||||||
|
<label for='otp_input'>{{ authenticator.otp_prompt }}</label>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="one-time-password"
|
||||||
|
name="otp"
|
||||||
|
id="otp_input"
|
||||||
|
/>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="feedback-container">
|
<div class="feedback-container">
|
||||||
<input
|
<input
|
||||||
|
Reference in New Issue
Block a user