diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 378ccab2..18ce9174 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v3 - name: Validate REST API definition - uses: char0n/swagger-editor-validate@v1.3.1 + uses: char0n/swagger-editor-validate@v1.3.2 with: definition-file: docs/source/_static/rest-api.yml diff --git a/docs/source/reference/config-user-env.md b/docs/source/reference/config-user-env.md index a8328a44..503d2661 100644 --- a/docs/source/reference/config-user-env.md +++ b/docs/source/reference/config-user-env.md @@ -183,12 +183,28 @@ as well as the admin page: Named servers can be accessed, created, started, stopped, and deleted from these pages. Activity tracking is now per-server as well. -The number of named servers per user can be limited by setting +The number of named servers per user can be limited by setting a constant value: ```python c.JupyterHub.named_server_limit_per_user = 5 ``` +or a callable/awaitable based on the handler object: + +```python +def named_server_limit_per_user_fn(handler): + user = handler.current_user + if user and user.admin: + return 0 + return 5 + +c.JupyterHub.named_server_limit_per_user = named_server_limit_per_user_fn +``` + +This can be useful for quota service implementations. The example above limits the number of named servers for non-admin users only. + +If `named_server_limit_per_user` is set to `0`, no limit is enforced. + (classic-notebook-ui)= ## Switching back to classic notebook diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index deb58d8d..ac38345c 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -502,17 +502,19 @@ class UserServerAPIHandler(APIHandler): if server_name: if not self.allow_named_servers: raise web.HTTPError(400, "Named servers are not enabled.") - if ( - self.named_server_limit_per_user > 0 - and server_name not in user.orm_spawners - ): + + named_server_limit_per_user = ( + await self.get_current_user_named_server_limit() + ) + + if named_server_limit_per_user > 0 and server_name not in user.orm_spawners: named_spawners = list(user.all_spawners(include_default=False)) - if self.named_server_limit_per_user <= len(named_spawners): + if named_server_limit_per_user <= len(named_spawners): raise web.HTTPError( 400, "User {} already has the maximum of {} named servers." " One must be deleted before a new server can be created".format( - user_name, self.named_server_limit_per_user + user_name, named_server_limit_per_user ), ) spawner = user.get_spawner(server_name, replace_failed=True) diff --git a/jupyterhub/app.py b/jupyterhub/app.py index 9a4bb2ea..dda1f2d6 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -1150,14 +1150,27 @@ class JupyterHub(Application): False, help="Allow named single-user servers per user" ).tag(config=True) - named_server_limit_per_user = Integer( - 0, + named_server_limit_per_user = Union( + [Integer(), Callable()], + default_value=0, help=""" Maximum number of concurrent named servers that can be created by a user at a time. Setting this can limit the total resources a user can consume. If set to 0, no limit is enforced. + + Can be an integer or a callable/awaitable based on the handler object: + + :: + + def named_server_limit_per_user_fn(handler): + user = handler.current_user + if user and user.admin: + return 0 + return 5 + + c.JupyterHub.named_server_limit_per_user = named_server_limit_per_user_fn """, ).tag(config=True) diff --git a/jupyterhub/handlers/base.py b/jupyterhub/handlers/base.py index a850a7c8..33b5a089 100644 --- a/jupyterhub/handlers/base.py +++ b/jupyterhub/handlers/base.py @@ -248,6 +248,17 @@ class BaseHandler(RequestHandler): def authenticate_prometheus(self): return self.settings.get('authenticate_prometheus', True) + async def get_current_user_named_server_limit(self): + """ + Return named server limit for current user. + """ + named_server_limit_per_user = self.named_server_limit_per_user + + if callable(named_server_limit_per_user): + return await maybe_future(named_server_limit_per_user(self)) + + return named_server_limit_per_user + def get_auth_token(self): """Get the authorization token from Authorization header""" auth_header = self.request.headers.get('Authorization', '') diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py index dacf5f44..4086f7b2 100644 --- a/jupyterhub/handlers/pages.py +++ b/jupyterhub/handlers/pages.py @@ -72,7 +72,7 @@ class HomeHandler(BaseHandler): user=user, url=url, allow_named_servers=self.allow_named_servers, - named_server_limit_per_user=self.named_server_limit_per_user, + named_server_limit_per_user=await self.get_current_user_named_server_limit(), url_path_join=url_path_join, # can't use user.spawners because the stop method of User pops named servers from user.spawners when they're stopped spawners=user.orm_user._orm_spawners, @@ -129,17 +129,19 @@ class SpawnHandler(BaseHandler): if server_name: if not self.allow_named_servers: raise web.HTTPError(400, "Named servers are not enabled.") - if ( - self.named_server_limit_per_user > 0 - and server_name not in user.orm_spawners - ): + + named_server_limit_per_user = ( + await self.get_current_user_named_server_limit() + ) + + if named_server_limit_per_user > 0 and server_name not in user.orm_spawners: named_spawners = list(user.all_spawners(include_default=False)) - if self.named_server_limit_per_user <= len(named_spawners): + if named_server_limit_per_user <= len(named_spawners): raise web.HTTPError( 400, "User {} already has the maximum of {} named servers." " One must be deleted before a new server can be created".format( - user.name, self.named_server_limit_per_user + user.name, named_server_limit_per_user ), ) @@ -458,7 +460,7 @@ class AdminHandler(BaseHandler): auth_state=auth_state, admin_access=True, allow_named_servers=self.allow_named_servers, - named_server_limit_per_user=self.named_server_limit_per_user, + named_server_limit_per_user=await self.get_current_user_named_server_limit(), server_version=f'{__version__} {self.version_hash}', api_page_limit=self.settings["api_page_default_limit"], base_url=self.settings["base_url"], diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index 8af29edf..0c1c9d69 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -25,6 +25,25 @@ def named_servers(app): yield +@pytest.fixture +def named_servers_with_callable_limit(app): + def named_server_limit_per_user_fn(handler): + """Limit number of named servers to `2` for non-admin users. No limit for admin users.""" + user = handler.current_user + if user and user.admin: + return 0 + return 2 + + with mock.patch.dict( + app.tornado_settings, + { + 'allow_named_servers': True, + 'named_server_limit_per_user': named_server_limit_per_user_fn, + }, + ): + yield + + @pytest.fixture def default_server_name(app, named_servers): """configure app to use a default server name""" @@ -292,6 +311,57 @@ async def test_named_server_limit(app, named_servers): assert r.text == '' +@pytest.mark.parametrize( + 'username,admin', + [ + ('nonsuperfoo', False), + ('superfoo', True), + ], +) +async def test_named_server_limit_as_callable( + app, named_servers_with_callable_limit, username, admin +): + """Test named server limit based on `named_server_limit_per_user_fn` callable""" + user = add_user(app.db, app, name=username, admin=admin) + cookies = await app.login_user(username) + + # Create 1st named server + servername1 = 'bar-1' + r = await api_request( + app, 'users', username, 'servers', servername1, method='post', cookies=cookies + ) + r.raise_for_status() + assert r.status_code == 201 + assert r.text == '' + + # Create 2nd named server + servername2 = 'bar-2' + r = await api_request( + app, 'users', username, 'servers', servername2, method='post', cookies=cookies + ) + r.raise_for_status() + assert r.status_code == 201 + assert r.text == '' + + # Create 3rd named server + servername3 = 'bar-3' + r = await api_request( + app, 'users', username, 'servers', servername3, method='post', cookies=cookies + ) + + # No named server limit for admin users as in `named_server_limit_per_user_fn` callable + if admin: + r.raise_for_status() + assert r.status_code == 201 + assert r.text == '' + else: + assert r.status_code == 400 + assert r.json() == { + "status": 400, + "message": f"User {username} already has the maximum of 2 named servers. One must be deleted before a new server can be created", + } + + async def test_named_server_spawn_form(app, username, named_servers): server_name = "myserver" base_url = public_url(app)