diff --git a/docs/rest-api.yml b/docs/rest-api.yml index eb7f4274..1eff3a53 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -217,6 +217,13 @@ paths: in: path required: true type: string + - name: remove + description: | + Whether to fully remove the server, rather than just stop it. + Removing a server deletes things like the state of the stopped server. + in: body + required: false + type: boolean responses: '201': description: The user's notebook named-server has started diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 54158fd9..b12320c1 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -378,29 +378,52 @@ class UserServerAPIHandler(APIHandler): @admin_or_self async def delete(self, name, server_name=''): user = self.find_user(name) + options = self.get_json_body() + remove = (options or {}).get('remove', False) + + + def _remove_spawner(f=None): + if f and f.exception(): + return + self.log.info("Deleting spawner %s", spawner._log_name) + self.db.delete(spawner.orm_spawner) + self.db.commit() + if server_name: if not self.allow_named_servers: raise web.HTTPError(400, "Named servers are not enabled.") - if server_name not in user.spawners: + if server_name not in user.orm_spawners: raise web.HTTPError(404, "%s has no server named '%s'" % (name, server_name)) + elif remove: + raise web.HTTPError(400, "Cannot delete the default server") spawner = user.spawners[server_name] if spawner.pending == 'stop': self.log.debug("%s already stopping", spawner._log_name) self.set_header('Content-Type', 'text/plain') self.set_status(202) + if remove: + spawner._stop_future.add_done_callback(_remove_spawner) return - if not spawner.ready: + if spawner.pending: raise web.HTTPError( - 400, "%s is not running %s" % - (spawner._log_name, '(pending: %s)' % spawner.pending if spawner.pending else '') + 400, "%s is pending %s, please wait" % (spawner._log_name, spawner.pending) ) - # include notify, so that a server that died is noticed immediately - status = await spawner.poll_and_notify() - if status is not None: - raise web.HTTPError(400, "%s is not running" % spawner._log_name) - await self.stop_single_user(user, server_name) + + stop_future = None + if spawner.ready: + # include notify, so that a server that died is noticed immediately + status = await spawner.poll_and_notify() + if status is None: + stop_future = await self.stop_single_user(user, server_name) + + if remove: + if stop_future: + stop_future.add_done_callback(_remove_spawner) + else: + _remove_spawner() + status = 202 if spawner._stop_pending else 204 self.set_header('Content-Type', 'text/plain') self.set_status(status) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 019e7bf2..7f1994b7 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -677,7 +677,8 @@ def test_slow_spawn(app, no_patience, slow_spawn): assert not app_user.spawner._stop_pending assert app_user.spawner is not None r = yield api_request(app, 'users', name, 'server', method='delete') - assert r.status_code == 400 + # 204 deleted if there's no such server + assert r.status_code == 204 assert app.users.count_active_users()['pending'] == 0 assert app.users.count_active_users()['active'] == 0 diff --git a/jupyterhub/tests/test_named_servers.py b/jupyterhub/tests/test_named_servers.py index 04ae83df..8f808d33 100644 --- a/jupyterhub/tests/test_named_servers.py +++ b/jupyterhub/tests/test_named_servers.py @@ -1,4 +1,5 @@ """Tests for named servers""" +import json from unittest import mock import pytest @@ -134,6 +135,21 @@ def test_delete_named_server(app, named_servers): 'auth_state': None, 'servers': {}, }) + # wrapper Spawner is gone + assert servername not in user.spawners + # low-level record still exists + assert servername in user.orm_spawners + + r = yield api_request( + app, 'users', username, 'servers', servername, + method='delete', + data=json.dumps({'remove': True}), + ) + r.raise_for_status() + assert r.status_code == 204 + # low-level record is now removes + assert servername not in user.orm_spawners + @pytest.mark.gen_test def test_named_server_disabled(app):