diff --git a/changelog.d/20250114_155651_rra_DM_48390.md b/changelog.d/20250114_155651_rra_DM_48390.md new file mode 100644 index 00000000..7c6caea9 --- /dev/null +++ b/changelog.d/20250114_155651_rra_DM_48390.md @@ -0,0 +1,3 @@ +### New features + +- Add a flag to notebook quotas, defaulting to true, that indicates whether the user is allowed to spawn a new lab. This is not enforced by Gafaelfawr; it will be read and acted on by [Nublado](https://nublado.lsst.io). \ No newline at end of file diff --git a/docs/user-guide/helm.rst b/docs/user-guide/helm.rst index db9d6969..86f5909f 100644 --- a/docs/user-guide/helm.rst +++ b/docs/user-guide/helm.rst @@ -523,14 +523,26 @@ Here is an example: groups: g_developers: notebook: - cpu: 8.0 + cpu: 0.0 memory: 4.0 + g_limited: + notebook: + cpu: 0.0 + memory: 0.0 + spawn: false + bypass: + - "g_admins" API quotas are in requests per 15 minutes. Notebook quotas are in CPU equivalents and GiB of memory. +If spawn is set to false, users should not be allowed to spawn a new user notebook. +Members of groups listed in ``bypass`` ignore all quota restrictions. The above example sets an API quota for the ``datalinker`` service of 1000 requests per 15 minutes, and a default quota for user notebooks of 2.0 CPU equivalents and 4.0GiB of memory. Users who are members of the ``g_developers`` group get an additional 4.0GiB of memory for their notebooks. +Users who are members of the ``g_limited`` group are not allowed to spawn notebooks. +(Note that the CPU and memory quota additions must be specified, even if they are zero.) +Users who are members of the ``g_admins`` group ignore all quota restrictions. The keys for API quotas are names of services. This is the same name the service should use in the ``config.service`` key of a ``GafaelfawrIngress`` resource (see :ref:`ingress`). diff --git a/src/gafaelfawr/config.py b/src/gafaelfawr/config.py index 634df1dd..2aae8e3a 100644 --- a/src/gafaelfawr/config.py +++ b/src/gafaelfawr/config.py @@ -706,6 +706,7 @@ def calculate_quota(self, groups: set[str]) -> Quota | None: if notebook: notebook.cpu += extra.notebook.cpu notebook.memory += extra.notebook.memory + notebook.spawn &= extra.notebook.spawn else: notebook = extra.notebook.model_copy() for service, quota in extra.api.items(): diff --git a/src/gafaelfawr/models/userinfo.py b/src/gafaelfawr/models/userinfo.py index b0f08a3f..c3baeb8f 100644 --- a/src/gafaelfawr/models/userinfo.py +++ b/src/gafaelfawr/models/userinfo.py @@ -85,6 +85,12 @@ class NotebookQuota(BaseModel): ..., title="Maximum memory use (GiB)", examples=[16.0] ) + spawn: bool = Field( + True, + title="Spawning allowed", + description="Whether the user is allowed to spawn a notebook", + ) + class Quota(BaseModel): """Quota information for a user.""" diff --git a/tests/data/config/github-quota.yaml b/tests/data/config/github-quota.yaml index ce13c345..c3fad6b0 100644 --- a/tests/data/config/github-quota.yaml +++ b/tests/data/config/github-quota.yaml @@ -26,6 +26,11 @@ quota: cpu: 8 memory: 4.0 groups: + blocked: + notebook: + cpu: 0 + memory: 0 + spawn: false foo: api: test: 1 diff --git a/tests/handlers/quota_test.py b/tests/handlers/quota_test.py index 591c6ecc..0b5d503d 100644 --- a/tests/handlers/quota_test.py +++ b/tests/handlers/quota_test.py @@ -10,6 +10,7 @@ from gafaelfawr.models.userinfo import Group from ..support.config import reconfigure +from ..support.tokens import create_session_token @pytest.mark.asyncio @@ -32,7 +33,7 @@ async def test_info(client: AsyncClient, factory: Factory) -> None: "groups": [{"name": "bar", "id": 12312}], "quota": { "api": {"datalinker": 1000, "test": 1}, - "notebook": {"cpu": 8.0, "memory": 4.0}, + "notebook": {"cpu": 8.0, "memory": 4.0, "spawn": True}, }, } @@ -50,6 +51,36 @@ async def test_info(client: AsyncClient, factory: Factory) -> None: "groups": [{"name": "foo", "id": 12313}], "quota": { "api": {"datalinker": 1000, "test": 2}, - "notebook": {"cpu": 8.0, "memory": 8.0}, + "notebook": {"cpu": 8.0, "memory": 8.0, "spawn": True}, + }, + } + + +@pytest.mark.asyncio +async def test_no_spawn(client: AsyncClient, factory: Factory) -> None: + await reconfigure("github-quota", factory) + token_data = await create_session_token( + factory, group_names=["blocked", "bar"], scopes={"read:all"} + ) + assert token_data.groups + + r = await client.get( + "/auth/api/v1/user-info", + headers={"Authorization": f"bearer {token_data.token}"}, + ) + assert r.status_code == 200 + assert r.json() == { + "username": token_data.username, + "name": token_data.name, + "email": token_data.email, + "uid": token_data.uid, + "gid": token_data.gid, + "groups": [ + g.model_dump(mode="json") + for g in sorted(token_data.groups, key=lambda g: g.name) + ], + "quota": { + "api": {"datalinker": 1000, "test": 1}, + "notebook": {"cpu": 8.0, "memory": 4.0, "spawn": False}, }, }