diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbee9967..88893f05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,7 +114,7 @@ jobs: # https://github.com/jupyterhub/jupyterhub/settings/secrets/actions if: env.REGISTRY != 'localhost:5000/' run: | - docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}" + docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}" # https://github.com/jupyterhub/action-major-minor-tag-calculator # If this is a tagged build this will return additional parent tags. diff --git a/.gitignore b/.gitignore index 5ff18d83..338f2f00 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ htmlcov pip-wheel-metadata docs/source/reference/metrics.rst oldest-requirements.txt +jupyterhub-proxy.pid diff --git a/docs/rest-api.yml b/docs/rest-api.yml index 49963d54..838dfc9f 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -143,6 +143,22 @@ paths: inactive: all users who have *no* active servers (complement of active) Added in JupyterHub 1.3 + - name: offset + in: query + required: false + type: number + description: | + Return a number users starting at the given offset. + Can be used with limit to paginate. + If unspecified, return all users. + - name: limit + in: query + requred: false + type: number + description: | + Return a finite number of users. + Can be used with offset to paginate. + If unspecified, use api_page_default_limit. responses: "200": description: The Hub's user list @@ -379,7 +395,7 @@ paths: type: string - name: server_name description: | - name given to a named-server. + name given to a named-server. Note that depending on your JupyterHub infrastructure there are chracterter size limitation to `server_name`. Default spawner with K8s pod will not allow Jupyter Notebooks to be spawned with a name that contains more than 253 characters (keep in mind that the pod will be spawned with extra characters to identify the user and hub). in: path @@ -540,6 +556,23 @@ paths: security: - oauth2: - read:groups + parameters: + - name: offset + in: query + required: false + type: number + description: | + Return a number of groups starting at the specified offset. + Can be used with limit to paginate. + If unspecified, return all groups. + - name: limit + in: query + required: false + type: number + description: | + Return a finite number of groups. + Can be used with offset to paginate. + If unspecified, use api_page_default_limit. responses: "200": description: The list of groups @@ -686,6 +719,23 @@ paths: security: - oauth2: - proxy + parameters: + - name: offset + in: query + required: false + type: number + description: | + Return a number of routes starting at the given offset. + Can be used with limit to paginate. + If unspecified, return all routes. + - name: limit + in: query + requred: false + type: number + description: | + Return a finite number of routes. + Can be used with offset to paginate. + If unspecified, use api_page_default_limit responses: "200": description: Routing table diff --git a/docs/source/getting-started/authenticators-users-basics.md b/docs/source/getting-started/authenticators-users-basics.md index 266ebde2..e5fc9f5c 100644 --- a/docs/source/getting-started/authenticators-users-basics.md +++ b/docs/source/getting-started/authenticators-users-basics.md @@ -22,7 +22,7 @@ Admin users of JupyterHub, `admin_users`, can add and remove users from the user `allowed_users` set. `admin_users` can take actions on other users' behalf, such as stopping and restarting their servers. -A set of initial admin users, `admin_users` can configured be as follows: +A set of initial admin users, `admin_users` can be configured as follows: ```python c.Authenticator.admin_users = {'mal', 'zoe'} @@ -32,9 +32,9 @@ Users in the admin set are automatically added to the user `allowed_users` set, if they are not already present. Each authenticator may have different ways of determining whether a user is an -administrator. By default JupyterHub use the PAMAuthenticator which provide the -`admin_groups` option and can determine administrator status base on a user -groups. For example we can let any users in the `wheel` group be admin: +administrator. By default JupyterHub uses the PAMAuthenticator which provides the +`admin_groups` option and can set administrator status based on a user +group. For example we can let any user in the `wheel` group be admin: ```python c.PAMAuthenticator.admin_groups = {'wheel'} @@ -42,9 +42,9 @@ c.PAMAuthenticator.admin_groups = {'wheel'} ## Give admin access to other users' notebook servers (`admin_access`) -Since the default `JupyterHub.admin_access` setting is False, the admins +Since the default `JupyterHub.admin_access` setting is `False`, the admins do not have permission to log in to the single user notebook servers -owned by _other users_. If `JupyterHub.admin_access` is set to True, +owned by _other users_. If `JupyterHub.admin_access` is set to `True`, then admins have permission to log in _as other users_ on their respective machines, for debugging. **As a courtesy, you should make sure your users know if admin_access is enabled.** @@ -53,8 +53,8 @@ sure your users know if admin_access is enabled.** Users can be added to and removed from the Hub via either the admin panel or the REST API. When a user is **added**, the user will be -automatically added to the allowed users set and database. Restarting the Hub -will not require manually updating the allowed users set in your config file, +automatically added to the `allowed_users` set and database. Restarting the Hub +will not require manually updating the `allowed_users` set in your config file, as the users will be loaded from the database. After starting the Hub once, it is not sufficient to **remove** a user @@ -107,8 +107,8 @@ with any provider, is also available. ## Use DummyAuthenticator for testing -The :class:`~jupyterhub.auth.DummyAuthenticator` is a simple authenticator that -allows for any username/password unless if a global password has been set. If +The `DummyAuthenticator` is a simple authenticator that +allows for any username/password unless a global password has been set. If set, it will allow for any username as long as the correct password is provided. To set a global password, add this to the config file: diff --git a/docs/source/getting-started/config-basics.md b/docs/source/getting-started/config-basics.md index f1438fd7..8b4babe6 100644 --- a/docs/source/getting-started/config-basics.md +++ b/docs/source/getting-started/config-basics.md @@ -44,7 +44,7 @@ jupyterhub -f /etc/jupyterhub/jupyterhub_config.py ``` The IPython documentation provides additional information on the -[config system](http://ipython.readthedocs.io/en/stable/development/config) +[config system](http://ipython.readthedocs.io/en/stable/development/config.html) that Jupyter uses. ## Configure using command line options @@ -63,11 +63,11 @@ would enter: jupyterhub --ip 10.0.1.2 --port 443 --ssl-key my_ssl.key --ssl-cert my_ssl.cert ``` -All configurable options may technically be set on the command-line, +All configurable options may technically be set on the command line, though some are inconvenient to type. To set a particular configuration parameter, `c.Class.trait`, you would use the command line option, `--Class.trait`, when starting JupyterHub. For example, to configure the -`c.Spawner.notebook_dir` trait from the command-line, use the +`c.Spawner.notebook_dir` trait from the command line, use the `--Spawner.notebook_dir` option: ```bash @@ -89,11 +89,11 @@ meant as illustration, are: ## Run the proxy separately This is _not_ strictly necessary, but useful in many cases. If you -use a custom proxy (e.g. Traefik), this also not needed. +use a custom proxy (e.g. Traefik), this is also not needed. Connections to user servers go through the proxy, and _not_ the hub itself. If the proxy stays running when the hub restarts (for -maintenance, re-configuration, etc.), then use connections are not +maintenance, re-configuration, etc.), then user connections are not interrupted. For simplicity, by default the hub starts the proxy automatically, so if the hub restarts, the proxy restarts, and user connections are interrupted. It is easy to run the proxy separately, diff --git a/docs/source/getting-started/faq.md b/docs/source/getting-started/faq.md index 6a49902b..4b169ebf 100644 --- a/docs/source/getting-started/faq.md +++ b/docs/source/getting-started/faq.md @@ -26,7 +26,7 @@ so Breq would open `/user/breq/notebooks/foo.ipynb` and Seivarden would open `/user/seivarden/notebooks/foo.ipynb`, etc. JupyterHub has a special URL that does exactly this! -It's called `/hub/user-redirect/...` and after the visitor logs in, +It's called `/hub/user-redirect/...`. So if you replace `/user/yourname` in your URL bar with `/hub/user-redirect` any visitor should get the same URL on their own server, rather than visiting yours. diff --git a/docs/source/getting-started/institutional-faq.md b/docs/source/getting-started/institutional-faq.md index c5e88798..d7f1a5dd 100644 --- a/docs/source/getting-started/institutional-faq.md +++ b/docs/source/getting-started/institutional-faq.md @@ -11,7 +11,7 @@ Yes! JupyterHub has been used at-scale for large pools of users, as well as complex and high-performance computing. For example, UC Berkeley uses JupyterHub for its Data Science Education Program courses (serving over 3,000 students). The Pangeo project uses JupyterHub to provide access -to scalable cloud computing with Dask. JupyterHub is stable customizable +to scalable cloud computing with Dask. JupyterHub is stable and customizable to the use-cases of large organizations. ### I keep hearing about Jupyter Notebook, JupyterLab, and now JupyterHub. What’s the difference? @@ -27,14 +27,14 @@ Here is a quick breakdown of these three tools: for other parts of the data science stack. - **JupyterHub** is an application that manages interactive computing sessions for **multiple users**. It also connects them with infrastructure those users wish to access. It can provide - remote access to Jupyter Notebooks and Jupyter Lab for many people. + remote access to Jupyter Notebooks and JupyterLab for many people. ## For management ### Briefly, what problem does JupyterHub solve for us? JupyterHub provides a shared platform for data science and collaboration. -It allows users to utilize familiar data science workflows (such as the scientific python stack, +It allows users to utilize familiar data science workflows (such as the scientific Python stack, the R tidyverse, and Jupyter Notebooks) on institutional infrastructure. It also allows administrators some control over access to resources, security, environments, and authentication. @@ -55,7 +55,7 @@ industry, and government research labs. It is most-commonly used by two kinds of - Large teams (e.g., a department, a large class, or a large group of remote users) to provide access to organizational hardware, data, and analytics environments at scale. -Here are a sample of organizations that use JupyterHub: +Here is a sample of organizations that use JupyterHub: - **Universities and colleges**: UC Berkeley, UC San Diego, Cal Poly SLO, Harvard University, University of Chicago, University of Oslo, University of Sheffield, Université Paris Sud, University of Versailles @@ -99,7 +99,7 @@ that we currently suggest are: guide that runs on Kubernetes. Better for larger or dynamic user groups (50-10,000) or more complex compute/data needs. - [The Littlest JupyterHub](https://tljh.jupyter.org) is a lightweight JupyterHub that runs on a single - single machine (in the cloud or under your desk). Better for smaller usergroups (4-80) or more + single machine (in the cloud or under your desk). Better for smaller user groups (4-80) or more lightweight computational resources. ### Does JupyterHub run well in the cloud? @@ -123,7 +123,7 @@ level for several years, and makes a number of "default" security decisions that users. - For security considerations in the base JupyterHub application, - [see the JupyterHub security page](https://jupyterhub.readthedocs.io/en/stable/reference/websecurity.html) + [see the JupyterHub security page](https://jupyterhub.readthedocs.io/en/stable/reference/websecurity.html). - For security considerations when deploying JupyterHub on Kubernetes, see the [JupyterHub on Kubernetes security page](https://zero-to-jupyterhub.readthedocs.io/en/latest/security.html). @@ -183,7 +183,7 @@ how those resources are controlled is taken care of by the non-JupyterHub applic Yes - JupyterHub can provide access to many kinds of computing infrastructure. Especially when combined with other open-source schedulers such as Dask, you can manage fairly -complex computing infrastructure from the interactive sessions of a JupyterHub. For example +complex computing infrastructures from the interactive sessions of a JupyterHub. For example [see the Dask HPC page](https://docs.dask.org/en/latest/setup/hpc.html). ### How much resources do user sessions take? @@ -192,7 +192,7 @@ This is highly configurable by the administrator. If you wish for your users to data analytics environments for prototyping and light data exploring, you can restrict their memory and CPU based on the resources that you have available. If you'd like your JupyterHub to serve as a gateway to high-performance compute or data resources, you may increase the -resources available on user machines, or connect them with computing infrastructure elsewhere. +resources available on user machines, or connect them with computing infrastructures elsewhere. ### Can I customize the look and feel of a JupyterHub? @@ -217,7 +217,7 @@ your JupyterHub with the various services and tools that you wish to provide to ### How well does JupyterHub scale? What are JupyterHub's limitations? JupyterHub works well at both a small scale (e.g., a single VM or machine) as well as a -high scale (e.g., a scalable Kubernetes cluster). It can be used for teams as small a 2, and +high scale (e.g., a scalable Kubernetes cluster). It can be used for teams as small as 2, and for user bases as large as 10,000. The scalability of JupyterHub largely depends on the infrastructure on which it is deployed. JupyterHub has been designed to be lightweight and flexible, so you can tailor your JupyterHub deployment to your needs. @@ -249,7 +249,7 @@ share their results with one another. JupyterHub also provides a computational framework to share computational narratives between different levels of an organization. For example, data scientists can share Jupyter Notebooks -rendered as [voila dashboards](https://voila.readthedocs.io/en/stable/) with those who are not +rendered as [Voilà dashboards](https://voila.readthedocs.io/en/stable/) with those who are not familiar with programming, or create publicly-available interactive analyses to allow others to interact with your work. diff --git a/docs/source/getting-started/networking-basics.md b/docs/source/getting-started/networking-basics.md index b844afe3..6d4d62d9 100644 --- a/docs/source/getting-started/networking-basics.md +++ b/docs/source/getting-started/networking-basics.md @@ -43,7 +43,7 @@ port. By default, this REST API listens on port 8001 of `localhost` only. The Hub service talks to the proxy via a REST API on a secondary port. The -API URL can be configured separately and override the default settings. +API URL can be configured separately to override the default settings. ### Set api_url @@ -82,13 +82,13 @@ c.JupyterHub.hub_ip = '10.0.1.4' c.JupyterHub.hub_port = 54321 ``` -**Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the ip address or +**Added in 0.8:** The `c.JupyterHub.hub_connect_ip` setting is the IP address or hostname that other services should use to connect to the Hub. A common configuration for, e.g. docker, is: ```python c.JupyterHub.hub_ip = '0.0.0.0' # listen on all interfaces -c.JupyterHub.hub_connect_ip = '10.0.1.4' # ip as seen on the docker network. Can also be a hostname. +c.JupyterHub.hub_connect_ip = '10.0.1.4' # IP as seen on the docker network. Can also be a hostname. ``` ## Adjusting the hub's URL diff --git a/docs/source/getting-started/services-basics.md b/docs/source/getting-started/services-basics.md index 5db9a6cb..ba472fc8 100644 --- a/docs/source/getting-started/services-basics.md +++ b/docs/source/getting-started/services-basics.md @@ -2,7 +2,7 @@ When working with JupyterHub, a **Service** is defined as a process that interacts with the Hub's REST API. A Service may perform a specific -or action or task. For example, shutting down individuals' single user +action or task. For example, shutting down individuals' single user notebook servers that have been idle for some time is a good example of a task that could be automated by a Service. Let's look at how the [jupyterhub_idle_culler][] script can be used as a Service. @@ -114,7 +114,7 @@ interact with it. This will run the idle culler service manually. It can be run as a standalone script anywhere with access to the Hub, and will periodically check for idle servers and shut them down via the Hub's REST API. In order to shutdown the -servers, the token given to cull-idle must have admin privileges. +servers, the token given to `cull-idle` must have admin privileges. Generate an API token and store it in the `JUPYTERHUB_API_TOKEN` environment variable. Run `jupyterhub_idle_culler` manually. diff --git a/docs/source/getting-started/spawners-basics.md b/docs/source/getting-started/spawners-basics.md index c30d89f6..9988c2f8 100644 --- a/docs/source/getting-started/spawners-basics.md +++ b/docs/source/getting-started/spawners-basics.md @@ -1,8 +1,8 @@ # Spawners and single-user notebook servers Since the single-user server is an instance of `jupyter notebook`, an entire separate -multi-process application, there are many aspect of that server can configure, and a lot of ways -to express that configuration. +multi-process application, there are many aspects of that server that can be configured, and a lot +of ways to express that configuration. At the JupyterHub level, you can set some values on the Spawner. The simplest of these is `Spawner.notebook_dir`, which lets you set the root directory for a user's server. This root @@ -14,7 +14,7 @@ expanded to the user's home directory. c.Spawner.notebook_dir = '~/notebooks' ``` -You can also specify extra command-line arguments to the notebook server with: +You can also specify extra command line arguments to the notebook server with: ```python c.Spawner.args = ['--debug', '--profile=PHYS131'] diff --git a/docs/source/reference/rest.md b/docs/source/reference/rest.md index 4b040ec1..626c0da0 100644 --- a/docs/source/reference/rest.md +++ b/docs/source/reference/rest.md @@ -158,6 +158,27 @@ provided by notebook servers managed by JupyterHub if one of the following is tr 1. The token is for the same user as the owner of the notebook 2. The token is tied to an admin user or service **and** `c.JupyterHub.admin_access` is set to `True` +## Paginating API requests + +Pagination is available through the `offset` and `limit` query parameters on +certain endpoints, which can be used to return ideally sized windows of results. +Here's example code demonstrating pagination on the `GET /users` +endpoint to fetch the first 20 records. + +```python +import requests + +api_url = 'http://127.0.0.1:8081/hub/api' + +r = requests.get(api_url + '/users?offset=0&limit=20') +r.raise_for_status() +r.json() +``` + +By default, pagination limit will be specified by the `JupyterHub.api_page_default_limit` config variable. + +Pagination is enabled on the `GET /users`, `GET /groups`, and `GET /proxy` REST endpoints. + ## Enabling users to spawn multiple named-servers via the API With JupyterHub version 0.8, support for multiple servers per user has landed. diff --git a/jupyterhub/apihandlers/base.py b/jupyterhub/apihandlers/base.py index f630e350..3ead420f 100644 --- a/jupyterhub/apihandlers/base.py +++ b/jupyterhub/apihandlers/base.py @@ -352,6 +352,22 @@ class APIHandler(BaseHandler): 400, ("group names must be str, not %r", type(groupname)) ) + def get_api_pagination(self): + default_limit = self.settings["app"].api_page_default_limit + max_limit = self.settings["app"].api_page_max_limit + offset = self.get_argument("offset", None) + limit = self.get_argument("limit", default_limit) + try: + offset = abs(int(offset)) if offset is not None else 0 + limit = abs(int(limit)) + if limit > max_limit: + limit = max_limit + except Exception as e: + raise web.HTTPError( + 400, "Invalid argument type, offset and limit must be integers" + ) + return offset, limit + def options(self, *args, **kwargs): self.finish() diff --git a/jupyterhub/apihandlers/groups.py b/jupyterhub/apihandlers/groups.py index b55dcd94..f49bbbd3 100644 --- a/jupyterhub/apihandlers/groups.py +++ b/jupyterhub/apihandlers/groups.py @@ -37,9 +37,11 @@ class GroupListAPIHandler(_GroupAPIHandler): @needs_scope('read:groups') def get(self): """List groups""" - groups = self.db.query(orm.Group) + query = self.db.query(orm.Group) + offset, limit = self.get_api_pagination() + query = query.offset(offset).limit(limit) scope_filter = self.get_scope_filter('read:groups') - data = [self.group_model(g) for g in groups if scope_filter(g, kind='group')] + data = [self.group_model(g) for g in query if scope_filter(g, kind='group')] self.write(json.dumps(data)) @needs_scope('admin:groups') diff --git a/jupyterhub/apihandlers/proxy.py b/jupyterhub/apihandlers/proxy.py index 048e36fa..ad01ed03 100644 --- a/jupyterhub/apihandlers/proxy.py +++ b/jupyterhub/apihandlers/proxy.py @@ -17,7 +17,16 @@ class ProxyAPIHandler(APIHandler): This is the same as fetching the routing table directly from the proxy, but without clients needing to maintain separate """ + offset, limit = self.get_api_pagination() + routes = await self.proxy.get_all_routes() + + routes = { + key: routes[key] + for key in list(routes.keys())[offset:limit] + if key in routes + } + self.write(json.dumps(routes)) @needs_scope('proxy') diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 2c2a4ae8..a0dfbe2f 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -70,6 +70,7 @@ class UserListAPIHandler(APIHandler): ) def get(self): state_filter = self.get_argument("state", None) + offset, limit = self.get_api_pagination() # post_filter post_filter = None @@ -109,12 +110,16 @@ class UserListAPIHandler(APIHandler): else: # no filter, return all users query = self.db.query(orm.User) + + query = query.offset(offset).limit(limit) + data = [] for u in query: if post_filter is None or post_filter(u): user_model = self.user_model(u) if user_model: data.append(user_model) + self.write(json.dumps(data)) @needs_scope('admin:users') @@ -242,11 +247,7 @@ class UserAPIHandler(APIHandler): await maybe_future(self.authenticator.delete_user(user)) - # allow the spawner to cleanup any persistent resources associated with the user - try: - await user.spawner.delete_forever() - except Exception as e: - self.log.error("Error cleaning up persistent resources: %s" % e) + await user.delete_spawners() # remove from registry self.users.delete(user) @@ -488,10 +489,18 @@ class UserServerAPIHandler(APIHandler): options = self.get_json_body() remove = (options or {}).get('remove', False) - def _remove_spawner(f=None): - if f and f.exception(): - return + async def _remove_spawner(f=None): + """Remove the spawner object + + only called after it stops successfully + """ + if f: + # await f, stop on error, + # leaving resources in the db in case of failure to stop + await f self.log.info("Deleting spawner %s", spawner._log_name) + await maybe_future(user._delete_spawner(spawner)) + self.db.delete(spawner.orm_spawner) user.spawners.pop(server_name, None) self.db.commit() @@ -512,7 +521,8 @@ class UserServerAPIHandler(APIHandler): self.set_header('Content-Type', 'text/plain') self.set_status(202) if remove: - spawner._stop_future.add_done_callback(_remove_spawner) + # schedule remove when stop completes + asyncio.ensure_future(_remove_spawner(spawner._stop_future)) return if spawner.pending: @@ -530,9 +540,10 @@ class UserServerAPIHandler(APIHandler): if remove: if stop_future: - stop_future.add_done_callback(_remove_spawner) + # schedule remove when stop completes + asyncio.ensure_future(_remove_spawner(spawner._stop_future)) else: - _remove_spawner() + await _remove_spawner() status = 202 if spawner._stop_pending else 204 self.set_header('Content-Type', 'text/plain') diff --git a/jupyterhub/app.py b/jupyterhub/app.py index d7ab38f2..6eb26bc5 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1021,6 +1021,15 @@ class JupyterHub(Application): """, ).tag(config=True) + api_page_default_limit = Integer( + 50, + help="The default amount of records returned by a paginated endpoint", + ).tag(config=True) + + api_page_max_limit = Integer( + 200, help="The maximum amount of records that can be returned at once" + ) + authenticate_prometheus = Bool( True, help="Authentication for prometheus metrics" ).tag(config=True) @@ -2386,10 +2395,6 @@ class JupyterHub(Application): for user in self.users.values(): for spawner in user.spawners.values(): oauth_client_ids.add(spawner.oauth_client_id) - # avoid deleting clients created by 0.8 - # 0.9 uses `jupyterhub-user-...` for the client id, while - # 0.8 uses just `user-...` - oauth_client_ids.add(spawner.oauth_client_id.split('-', 1)[1]) for i, oauth_client in enumerate(self.db.query(orm.OAuthClient)): if oauth_client.identifier not in oauth_client_ids: diff --git a/jupyterhub/proxy.py b/jupyterhub/proxy.py index 82587191..5f706970 100644 --- a/jupyterhub/proxy.py +++ b/jupyterhub/proxy.py @@ -24,6 +24,7 @@ import time from functools import wraps from subprocess import Popen from urllib.parse import quote +from weakref import WeakKeyDictionary from tornado.httpclient import AsyncHTTPClient from tornado.httpclient import HTTPError @@ -44,7 +45,6 @@ from .metrics import CHECK_ROUTES_DURATION_SECONDS from .metrics import PROXY_POLL_DURATION_SECONDS from .objects import Server from .utils import exponential_backoff -from .utils import make_ssl_context from .utils import url_path_join from jupyterhub.traitlets import Command @@ -55,11 +55,18 @@ def _one_at_a_time(method): If multiple concurrent calls to this method are made, queue them instead of allowing them to be concurrently outstanding. """ - method._lock = asyncio.Lock() + # use weak dict for locks + # so that the lock is always acquired within the current asyncio loop + # should only be relevant in testing, where eventloops are created and destroyed often + method._locks = WeakKeyDictionary() @wraps(method) async def locked_method(*args, **kwargs): - async with method._lock: + loop = asyncio.get_event_loop() + lock = method._locks.get(loop, None) + if lock is None: + lock = method._locks[loop] = asyncio.Lock() + async with lock: return await method(*args, **kwargs) return locked_method @@ -123,7 +130,7 @@ class Proxy(LoggingConfigurable): a URL as target. The hub will ensure this route is present in the proxy. - If the hub is running in host based mode (with + If the hub is running in host based mode (with JupyterHub.subdomain_host set), the routespec *must* have a domain component (example.com/my-url/). If the hub is not running in host based mode, the routespec diff --git a/jupyterhub/singleuser/mixins.py b/jupyterhub/singleuser/mixins.py index b0e64de0..d6d66ba1 100755 --- a/jupyterhub/singleuser/mixins.py +++ b/jupyterhub/singleuser/mixins.py @@ -10,9 +10,11 @@ with JupyterHub authentication mixins enabled. # Distributed under the terms of the Modified BSD License. import asyncio import json +import logging import os import random import secrets +import sys import warnings from datetime import datetime from datetime import timezone @@ -99,19 +101,26 @@ class JupyterHubLoginHandlerMixin: Thus shouldn't be called anymore because HubAuthenticatedHandler should have already overridden get_current_user(). - Keep here to prevent unlikely circumstance from losing auth. + Keep here to protect uncommon circumstance of multiple BaseHandlers + from missing auth. + + e.g. when multiple BaseHandler classes are used. """ if HubAuthenticatedHandler not in handler.__class__.mro(): warnings.warn( - f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro()", + f"Expected to see HubAuthenticatedHandler in {handler.__class__}.mro()," + " patching in at call time. Hub authentication is still applied.", RuntimeWarning, stacklevel=2, ) + # patch HubAuthenticated into the instance handler.__class__ = type( handler.__class__.__name__, (HubAuthenticatedHandler, handler.__class__), {}, ) + # patch into the class itself so this doesn't happen again for the same class + patch_base_handler(handler.__class__) return handler.get_current_user() @classmethod @@ -682,6 +691,97 @@ def detect_base_package(App): return None +def _nice_cls_repr(cls): + """Nice repr of classes, e.g. 'module.submod.Class' + + Also accepts tuples of classes + """ + return f"{cls.__module__}.{cls.__name__}" + + +def patch_base_handler(BaseHandler, log=None): + """Patch HubAuthenticated into a base handler class + + so anything inheriting from BaseHandler uses Hub authentication. + This works *even after* subclasses have imported and inherited from BaseHandler. + + .. versionadded: 1.5 + Made available as an importable utility + """ + if log is None: + log = logging.getLogger() + + if HubAuthenticatedHandler not in BaseHandler.__bases__: + new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__ + log.info( + "Patching auth into {mod}.{name}({old_bases}) -> {name}({new_bases})".format( + mod=BaseHandler.__module__, + name=BaseHandler.__name__, + old_bases=', '.join( + _nice_cls_repr(cls) for cls in BaseHandler.__bases__ + ), + new_bases=', '.join(_nice_cls_repr(cls) for cls in new_bases), + ) + ) + BaseHandler.__bases__ = new_bases + # We've now inserted our class as a parent of BaseHandler, + # but we also need to ensure BaseHandler *itself* doesn't + # override the public tornado API methods we have inserted. + # If they are defined in BaseHandler, explicitly replace them with our methods. + for name in ("get_current_user", "get_login_url"): + if name in BaseHandler.__dict__: + log.debug( + f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}" + ) + method = getattr(HubAuthenticatedHandler, name) + setattr(BaseHandler, name, method) + return BaseHandler + + +def _patch_app_base_handlers(app): + """Patch Hub Authentication into the base handlers of an app + + Patches HubAuthenticatedHandler into: + + - App.base_handler_class (if defined) + - jupyter_server's JupyterHandler (if already imported) + - notebook's IPythonHandler (if already imported) + """ + BaseHandler = app_base_handler = getattr(app, "base_handler_class", None) + + base_handlers = [] + if BaseHandler is not None: + base_handlers.append(BaseHandler) + + # patch juptyer_server and notebook handlers if they have been imported + for base_handler_name in [ + "jupyter_server.base.handlers.JupyterHandler", + "notebook.base.handlers.IPythonHandler", + ]: + modname, _ = base_handler_name.rsplit(".", 1) + if modname in sys.modules: + base_handlers.append(import_item(base_handler_name)) + + if not base_handlers: + pkg = detect_base_package(app.__class__) + if pkg == "jupyter_server": + BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler") + elif pkg == "notebook": + BaseHandler = import_item("notebook.base.handlers.IPythonHandler") + else: + raise ValueError( + "{}.base_handler_class must be defined".format(app.__class__.__name__) + ) + base_handlers.append(BaseHandler) + + # patch-in HubAuthenticatedHandler to base handler classes + for BaseHandler in base_handlers: + patch_base_handler(BaseHandler) + + # return the first entry + return base_handlers[0] + + def make_singleuser_app(App): """Make and return a singleuser notebook app @@ -705,37 +805,7 @@ def make_singleuser_app(App): # detect base classes LoginHandler = empty_parent_app.login_handler_class LogoutHandler = empty_parent_app.logout_handler_class - BaseHandler = getattr(empty_parent_app, "base_handler_class", None) - if BaseHandler is None: - pkg = detect_base_package(App) - if pkg == "jupyter_server": - BaseHandler = import_item("jupyter_server.base.handlers.JupyterHandler") - elif pkg == "notebook": - BaseHandler = import_item("notebook.base.handlers.IPythonHandler") - else: - raise ValueError( - "{}.base_handler_class must be defined".format(App.__name__) - ) - - # patch-in HubAuthenticatedHandler to BaseHandler, - # so anything inheriting from BaseHandler uses Hub authentication - if HubAuthenticatedHandler not in BaseHandler.__bases__: - new_bases = (HubAuthenticatedHandler,) + BaseHandler.__bases__ - log.debug( - f"Patching {BaseHandler}{BaseHandler.__bases__} -> {BaseHandler}{new_bases}" - ) - BaseHandler.__bases__ = new_bases - # We've now inserted our class as a parent of BaseHandler, - # but we also need to ensure BaseHandler *itself* doesn't - # override the public tornado API methods we have inserted. - # If they are defined in BaseHandler, explicitly replace them with our methods. - for name in ("get_current_user", "get_login_url"): - if name in BaseHandler.__dict__: - log.debug( - f"Overriding {BaseHandler}.{name} with HubAuthenticatedHandler.{name}" - ) - method = getattr(HubAuthenticatedHandler, name) - setattr(BaseHandler, name, method) + BaseHandler = _patch_app_base_handlers(empty_parent_app) # create Handler classes from mixins + bases class JupyterHubLoginHandler(JupyterHubLoginHandlerMixin, LoginHandler): @@ -765,4 +835,11 @@ def make_singleuser_app(App): logout_handler_class = JupyterHubLogoutHandler oauth_callback_handler_class = OAuthCallbackHandler + def initialize(self, *args, **kwargs): + result = super().initialize(*args, **kwargs) + # run patch again after initialize, so extensions have already been loaded + # probably a no-op most of the time + _patch_app_base_handlers(self) + return result + return SingleUserNotebookApp diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py index d96dd54d..0b417fc3 100644 --- a/jupyterhub/spawner.py +++ b/jupyterhub/spawner.py @@ -1155,6 +1155,18 @@ class Spawner(LoggingConfigurable): """ raise NotImplementedError("Override in subclass. Must be a coroutine.") + def delete_forever(self): + """Called when a user or server is deleted. + + This can do things like request removal of resources such as persistent storage. + Only called on stopped spawners, and is usually the last action ever taken for the user. + + Will only be called once on each Spawner, immediately prior to removal. + + Stopping a server does *not* call this method. + """ + pass + def add_poll_callback(self, callback, *args, **kwargs): """Add a callback to fire when the single-user server stops""" if args or kwargs: diff --git a/jupyterhub/tests/mocking.py b/jupyterhub/tests/mocking.py index 5ea51b61..5acb039a 100644 --- a/jupyterhub/tests/mocking.py +++ b/jupyterhub/tests/mocking.py @@ -94,16 +94,6 @@ class MockSpawner(SimpleLocalProcessSpawner): def _cmd_default(self): return [sys.executable, '-m', 'jupyterhub.tests.mocksu'] - async def delete_forever(self): - """Called when a user is deleted. - - This can do things like request removal of resources such as persistent storage. - Only called on stopped spawners, and is likely the last action ever taken for the user. - - Will only be called once on the user's default Spawner. - """ - pass - use_this_api_token = None def start(self): diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index f8330f08..4635e660 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -192,6 +192,30 @@ async def test_get_users(app): r_user_model = r.json()[0] assert r_user_model['name'] == user_model['name'] + # Tests offset for pagination + r = await api_request(app, 'users?offset=1') + assert r.status_code == 200 + + users = sorted(r.json(), key=lambda d: d['name']) + users = [normalize_user(u) for u in users] + assert users == [fill_user({'name': 'user', 'admin': False})] + + r = await api_request(app, 'users?offset=20') + assert r.status_code == 200 + assert r.json() == [] + + # Test limit for pagination + r = await api_request(app, 'users?limit=1') + assert r.status_code == 200 + + users = sorted(r.json(), key=lambda d: d['name']) + users = [normalize_user(u) for u in users] + assert users == [fill_user({'name': 'admin', 'admin': True})] + + r = await api_request(app, 'users?limit=0') + assert r.status_code == 200 + assert r.json() == [] + @mark.user @mark.parametrize( @@ -1384,15 +1408,44 @@ async def test_groups_list(app): reply = r.json() assert reply == [] - # create a group + # create two groups group = orm.Group(name='alphaflight') + group_2 = orm.Group(name='betaflight') app.db.add(group) + app.db.add(group_2) app.db.commit() r = await api_request(app, 'groups') r.raise_for_status() reply = r.json() - assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}] + assert reply == [ + {'kind': 'group', 'name': 'alphaflight', 'users': [], 'roles': []}, + {'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []}, + ] + + # Test offset for pagination + r = await api_request(app, "groups?offset=1") + r.raise_for_status() + reply = r.json() + assert r.status_code == 200 + assert reply == [{'kind': 'group', 'name': 'betaflight', 'users': [], 'roles': []}] + + r = await api_request(app, "groups?offset=10") + r.raise_for_status() + reply = r.json() + assert reply == [] + + # Test limit for pagination + r = await api_request(app, "groups?limit=1") + r.raise_for_status() + reply = r.json() + assert r.status_code == 200 + assert reply == [{'kind': 'group', 'name': 'alphaflight', 'users': []}] + + r = await api_request(app, "groups?limit=0") + r.raise_for_status() + reply = r.json() + assert reply == [] @mark.group diff --git a/jupyterhub/user.py b/jupyterhub/user.py index 4aaedf4e..85e1b88b 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -252,6 +252,35 @@ class User: await self.save_auth_state(auth_state) return auth_state + async def delete_spawners(self): + """Call spawner cleanup methods + + Allows the spawner to cleanup persistent resources + """ + for name in self.orm_user.orm_spawners.keys(): + await self._delete_spawner(name) + + async def _delete_spawner(self, name_or_spawner): + """Delete a single spawner""" + # always ensure full Spawner + # this may instantiate the Spawner if it wasn't already running, + # just to delete it + if isinstance(name_or_spawner, str): + spawner = self.spawners[name_or_spawner] + else: + spawner = name_or_spawner + + if spawner.active: + raise RuntimeError( + f"Spawner {spawner._log_name} is active and cannot be deleted." + ) + try: + await maybe_future(spawner.delete_forever()) + except Exception as e: + self.log.exception( + f"Error cleaning up persistent resources on {spawner._log_name}" + ) + def all_spawners(self, include_default=True): """Generator yielding all my spawners @@ -810,14 +839,8 @@ class User: if orm_token: self.db.delete(orm_token) # remove oauth client as well - # handle upgrades from 0.8, where client id will be `user-USERNAME`, - # not just `jupyterhub-user-USERNAME` - client_ids = ( - spawner.oauth_client_id, - spawner.oauth_client_id.split('-', 1)[1], - ) - for oauth_client in self.db.query(orm.OAuthClient).filter( - orm.OAuthClient.identifier.in_(client_ids) + for oauth_client in self.db.query(orm.OAuthClient).filter_by( + identifier=spawner.oauth_client_id, ): self.log.debug("Deleting oauth client %s", oauth_client.identifier) self.db.delete(oauth_client)