diff --git a/components/renku_data_services/crc/api.spec.yaml b/components/renku_data_services/crc/api.spec.yaml index 7c5bb2517..5c573fb3f 100644 --- a/components/renku_data_services/crc/api.spec.yaml +++ b/components/renku_data_services/crc/api.spec.yaml @@ -996,7 +996,17 @@ components: $ref: "#/components/schemas/StorageClassName" service_account_name: $ref: "#/components/schemas/K8sResourceName" - required: [ "name", "config_name", "session_protocol", "session_host", "session_port", "session_path", "session_ingress_annotations", "session_tls_secret_name" ] + required: + [ + "name", + "config_name", + "session_protocol", + "session_host", + "session_port", + "session_path", + "session_ingress_annotations", + "session_tls_secret_name", + ] ClusterPatch: type: object additionalProperties: false @@ -1051,7 +1061,18 @@ components: $ref: "#/components/schemas/StorageClassName" service_account_name: type: string - required: [ "name", "config_name", "session_protocol", "session_host", "session_port", "session_path", "session_ingress_annotations", "session_tls_secret_name", "id" ] + required: + [ + "name", + "config_name", + "session_protocol", + "session_host", + "session_port", + "session_path", + "session_ingress_annotations", + "session_tls_secret_name", + "id", + ] ClustersWithId: type: array items: @@ -1078,7 +1099,16 @@ components: $ref: "#/components/schemas/K8sLabelList" node_affinities: $ref: "#/components/schemas/NodeAffinityList" - required: ["cpu", "memory", "gpu", "max_storage", "name", "default", "default_storage"] + required: + [ + "cpu", + "memory", + "gpu", + "max_storage", + "name", + "default", + "default_storage", + ] example: name: "resource class" cpu: 1.5 @@ -1164,7 +1194,20 @@ components: $ref: "#/components/schemas/K8sLabelList" node_affinities: $ref: "#/components/schemas/NodeAffinityList" - required: ["cpu", "memory", "gpu", "max_storage", "name", "id", "default", "default_storage"] + resource_pool_id: + $ref: "#/components/schemas/IntegerId" + required: + [ + "cpu", + "memory", + "gpu", + "max_storage", + "name", + "id", + "default", + "default_storage", + "resource_pool_id", + ] example: name: "resource class" cpu: 1.5 @@ -1174,6 +1217,7 @@ components: default_storage: 10 id: 1 default: true + resource_pool_id: 1 ResourceClassWithIdFiltered: type: object additionalProperties: false @@ -1200,7 +1244,20 @@ components: $ref: "#/components/schemas/K8sLabelList" node_affinities: $ref: "#/components/schemas/NodeAffinityList" - required: ["cpu", "memory", "gpu", "max_storage", "name", "id", "default", "default_storage"] + resource_pool_id: + $ref: "#/components/schemas/IntegerId" + required: + [ + "cpu", + "memory", + "gpu", + "max_storage", + "name", + "id", + "default", + "default_storage", + "resource_pool_id", + ] example: name: "resource class" cpu: 1.5 @@ -1211,6 +1268,7 @@ components: id: 1 default: true matching: true + resource_pool_id: 1 ResourceClasses: type: array items: @@ -1229,6 +1287,7 @@ components: id: 1 default: true default_storage: 10 + resource_pool_id: 1 - name: "resource class 2" cpu: 4.5 memory: 10 @@ -1237,6 +1296,7 @@ components: max_storage: 10000 id: 2 default: false + resource_pool_id: 1 ResourceClassesWithIdResponse: # Note: this needs to be separate from ResourceClassesWithId or it doesn't get generated type: array @@ -1252,6 +1312,7 @@ components: id: 1 default: true default_storage: 10 + resource_pool_id: 1 - name: "resource class 2" cpu: 4.5 memory: 10 @@ -1260,6 +1321,7 @@ components: max_storage: 10000 id: 2 default: false + resource_pool_id: 1 ResourceClassesPatchWithId: type: array items: @@ -1360,6 +1422,21 @@ components: max_storage: 10000 name: "resource pool name" cluster_id: "4QZ886777NTN8GHQ551GSVAXSA" + ResourceClassWithIdWithoutResourcePoolId: + allOf: + - $ref: "#/components/schemas/ResourceClass" + - properties: + id: + $ref: "#/components/schemas/IntegerId" + - required: + - "cpu" + - "memory" + - "gpu" + - "max_storage" + - "name" + - "id" + - "default" + - "default_storage" ResourcePoolPut: type: object additionalProperties: false @@ -1367,7 +1444,10 @@ components: quota: $ref: "#/components/schemas/QuotaWithId" classes: - $ref: "#/components/schemas/ResourceClassesWithId" + type: array + items: + $ref: "#/components/schemas/ResourceClassWithIdWithoutResourcePoolId" + uniqueItems: true name: $ref: "#/components/schemas/Name" public: @@ -1746,7 +1826,7 @@ components: properties: kind: type: string - enum: [ "firecrest" ] + enum: ["firecrest"] description: Kind of remote resource pool example: "firecrest" provider_id: @@ -1799,7 +1879,7 @@ components: properties: kind: type: string - enum: [ "firecrest" ] + enum: ["firecrest"] description: Kind of remote resource pool example: "firecrest" provider_id: @@ -1888,7 +1968,7 @@ components: description: A list of k8s labels used for tolerations and/or node affinity items: $ref: "#/components/schemas/NodeAffinity" - example: [{"key": "test-label-1", "required_during_scheduling": false}] + example: [{ "key": "test-label-1", "required_during_scheduling": false }] uniqueItems: true default: [] minItems: 0 @@ -1897,7 +1977,7 @@ components: description: A list of k8s labels used for tolerations and/or node affinity items: $ref: "#/components/schemas/NodeAffinity" - example: [{"key": "test-label-1", "required_during_scheduling": false}] + example: [{ "key": "test-label-1", "required_during_scheduling": false }] uniqueItems: true default: [] minItems: 0 @@ -1930,11 +2010,11 @@ components: type: string minLength: 26 maxLength: 26 - pattern: "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" # This is case-insensitive + pattern: "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" # This is case-insensitive Protocol: description: Allowed Protocol strings type: string - enum: [ "http", "https" ] + enum: ["http", "https"] Port: type: integer minimum: 0 diff --git a/components/renku_data_services/crc/apispec.py b/components/renku_data_services/crc/apispec.py index c6b31902f..19f395471 100644 --- a/components/renku_data_services/crc/apispec.py +++ b/components/renku_data_services/crc/apispec.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2025-12-12T08:13:17+00:00 +# timestamp: 2026-01-15T12:13:55+00:00 from __future__ import annotations @@ -680,6 +680,12 @@ class ResourceClassWithId(BaseAPISpec): examples=[[{"key": "test-label-1", "required_during_scheduling": False}]], min_length=0, ) + resource_pool_id: int = Field( + ..., + description="An integer ID used to identify different resources", + examples=[1], + ge=0, + ) class ResourceClassWithIdFiltered(BaseAPISpec): @@ -741,6 +747,44 @@ class ResourceClassWithIdFiltered(BaseAPISpec): examples=[[{"key": "test-label-1", "required_during_scheduling": False}]], min_length=0, ) + resource_pool_id: int = Field( + ..., + description="An integer ID used to identify different resources", + examples=[1], + ge=0, + ) + + +class ResourceClassesWithId(RootModel[List[ResourceClassWithId]]): + root: List[ResourceClassWithId] = Field( + ..., + examples=[ + [ + { + "name": "resource class 1", + "cpu": 1.5, + "memory": 2, + "gpu": 0, + "max_storage": 100, + "id": 1, + "default": True, + "default_storage": 10, + "resource_pool_id": 1, + }, + { + "name": "resource class 2", + "cpu": 4.5, + "memory": 10, + "gpu": 2, + "default_storage": 10, + "max_storage": 10000, + "id": 2, + "default": False, + "resource_pool_id": 1, + }, + ] + ], + ) class ResourceClassesWithIdResponse(RootModel[List[ResourceClassWithId]]): @@ -757,6 +801,7 @@ class ResourceClassesWithIdResponse(RootModel[List[ResourceClassWithId]]): "id": 1, "default": True, "default_storage": 10, + "resource_pool_id": 1, }, { "name": "resource class 2", @@ -767,6 +812,7 @@ class ResourceClassesWithIdResponse(RootModel[List[ResourceClassWithId]]): "max_storage": 10000, "id": 2, "default": False, + "resource_pool_id": 1, }, ] ], @@ -824,7 +870,7 @@ class ResourcePoolPatch(BaseAPISpec): ) hibernation_warning_period: Optional[int] = Field( None, - description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be expired. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", + description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) @@ -838,6 +884,15 @@ class ResourcePoolPatch(BaseAPISpec): platform: Optional[RuntimePlatform] = None +class ResourceClassWithIdWithoutResourcePoolId(ResourceClass): + id: int = Field( + ..., + description="An integer ID used to identify different resources", + examples=[1], + ge=0, + ) + + class ResourcePool(BaseAPISpec): model_config = ConfigDict( extra="forbid", @@ -878,7 +933,7 @@ class ResourcePool(BaseAPISpec): ) hibernation_warning_period: Optional[int] = Field( None, - description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be expired. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", + description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) @@ -897,33 +952,7 @@ class ResourcePoolPut(BaseAPISpec): extra="forbid", ) quota: Optional[QuotaWithId] = None - classes: List[ResourceClassWithId] = Field( - ..., - examples=[ - [ - { - "name": "resource class 1", - "cpu": 1.5, - "memory": 2, - "gpu": 0, - "max_storage": 100, - "id": 1, - "default": True, - "default_storage": 10, - }, - { - "name": "resource class 2", - "cpu": 4.5, - "memory": 10, - "gpu": 2, - "default_storage": 10, - "max_storage": 10000, - "id": 2, - "default": False, - }, - ] - ], - ) + classes: List[ResourceClassWithIdWithoutResourcePoolId] name: str = Field( ..., description="A name for a specific resource", @@ -958,7 +987,7 @@ class ResourcePoolPut(BaseAPISpec): ) hibernation_warning_period: Optional[int] = Field( None, - description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be expired. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", + description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) @@ -1018,7 +1047,7 @@ class ResourcePoolWithId(BaseAPISpec): ) hibernation_warning_period: Optional[int] = Field( None, - description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be expired. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", + description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) @@ -1072,7 +1101,7 @@ class ResourcePoolWithIdFiltered(BaseAPISpec): ) hibernation_warning_period: Optional[int] = Field( None, - description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be expired. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", + description="A duration in seconds, lower than `HibernationThreshold`,\nwhich indicates when to let the user know about a session that\nis going to be hibernated. If `0` is specified, some default\nvalue will be chosen. If no `HibernationThreshold` is defined,\nthis value is ignored.\n", ge=0, le=2147483647, ) diff --git a/components/renku_data_services/crc/core.py b/components/renku_data_services/crc/core.py index b449e5a3d..eedd8cc9c 100644 --- a/components/renku_data_services/crc/core.py +++ b/components/renku_data_services/crc/core.py @@ -59,21 +59,36 @@ def validate_resource_class(body: apispec.ResourceClass) -> models.UnsavedResour @overload def validate_resource_class_patch_or_put( - body: apispec.ResourceClassPatch | apispec.ResourceClass, method: Literal["PATCH", "PUT"] -) -> models.ResourceClassPatch: ... + body: apispec.ResourceClassPatchWithId + | apispec.ResourceClassWithIdWithoutResourcePoolId + | apispec.ResourceClassWithId, + method: Literal["PATCH", "PUT"], +) -> models.ResourceClassPatchWithId: ... @overload def validate_resource_class_patch_or_put( - body: apispec.ResourceClassPatchWithId | apispec.ResourceClassWithId, method: Literal["PATCH", "PUT"] -) -> models.ResourceClassPatchWithId: ... + body: apispec.ResourceClassPatch | apispec.ResourceClass, method: Literal["PATCH", "PUT"] +) -> models.ResourceClassPatch: ... def validate_resource_class_patch_or_put( body: apispec.ResourceClassPatch | apispec.ResourceClassPatchWithId + | apispec.ResourceClassWithIdWithoutResourcePoolId | apispec.ResourceClass | apispec.ResourceClassWithId, method: Literal["PATCH", "PUT"], ) -> models.ResourceClassPatch | models.ResourceClassPatchWithId: """Validate the patch to a resource class.""" - rc_id = body.id if isinstance(body, (apispec.ResourceClassPatchWithId, apispec.ResourceClassWithId)) else None + rc_id = ( + body.id + if isinstance( + body, + ( + apispec.ResourceClassPatchWithId, + apispec.ResourceClassWithId, + apispec.ResourceClassWithIdWithoutResourcePoolId, + ), + ) + else None + ) node_affinities: list[models.NodeAffinity] | None = [] if method == "PUT" else None if body.node_affinities: node_affinities = sorted( @@ -278,6 +293,7 @@ def validate_resource_pool_update(existing: models.ResourcePool, update: models. default_storage=rc.default_storage if rc.default_storage is not None else existing_rc.default_storage, node_affinities=rc.node_affinities if rc.node_affinities is not None else existing_rc.node_affinities, tolerations=rc.tolerations if rc.tolerations is not None else existing_rc.tolerations, + resource_pool_id=existing.id, ) quota: models.Quota | models.UnsavedQuota | ResetType = existing.quota if existing.quota else RESET if update.quota is RESET: diff --git a/components/renku_data_services/crc/models.py b/components/renku_data_services/crc/models.py index 1efbae22b..0907f2eec 100644 --- a/components/renku_data_services/crc/models.py +++ b/components/renku_data_services/crc/models.py @@ -101,6 +101,7 @@ class ResourceClass(ResourcesCompareMixin): max_storage: int gpu: int id: int + resource_pool_id: int default: bool = False default_storage: int = 1 matching: Optional[bool] = None diff --git a/components/renku_data_services/crc/orm.py b/components/renku_data_services/crc/orm.py index 2c20f0efd..27dc03910 100644 --- a/components/renku_data_services/crc/orm.py +++ b/components/renku_data_services/crc/orm.py @@ -141,6 +141,8 @@ def dump( and self.gpu >= matching_criteria.gpu and self.max_storage >= matching_criteria.max_storage ) + if self.resource_pool_id is None: + raise errors.ProgrammingError(message="Cannot dump a resource class without a reource pool id.") return models.ResourceClass( id=self.id, name=self.name, @@ -154,6 +156,7 @@ def dump( tolerations=[toleration.key for toleration in self.tolerations], matching=matching, quota=self.resource_pool.quota if self.resource_pool else None, + resource_pool_id=self.resource_pool_id, ) diff --git a/components/renku_data_services/notebooks/api/classes/data_service.py b/components/renku_data_services/notebooks/api/classes/data_service.py index 3abcee82c..6d0e7f805 100644 --- a/components/renku_data_services/notebooks/api/classes/data_service.py +++ b/components/renku_data_services/notebooks/api/classes/data_service.py @@ -140,6 +140,7 @@ async def get_default_class(self) -> ResourceClass: id=1, default_storage=1, default=True, + resource_pool_id=1, ) async def find_acceptable_class( diff --git a/test/bases/renku_data_services/data_api/test_resource_pools.py b/test/bases/renku_data_services/data_api/test_resource_pools.py index 684dbd190..dc29d59bf 100644 --- a/test/bases/renku_data_services/data_api/test_resource_pools.py +++ b/test/bases/renku_data_services/data_api/test_resource_pools.py @@ -279,6 +279,7 @@ async def test_resource_class_filtering( matching_class = matching_classes[0] matching_class.pop("id") matching_class.pop("matching") + matching_class.pop("resource_pool_id") assert matching_class == new_classes[2] # Test without any filtering _, res = await sanic_client.get( @@ -471,6 +472,7 @@ async def test_put_resource_class( res_cls_payload = {**res.json.get("classes", [])[0], "cpu": 5.0} res_cls_expected_response = {**res.json.get("classes", [])[0], "cpu": 5.0} res_cls_payload.pop("id", None) + res_cls_payload.pop("resource_pool_id", None) _, res = await sanic_client.put( "/api/data/resource_pools/1/classes/1", headers=admin_headers, @@ -973,6 +975,7 @@ async def test_remove_all_tolerations_put( assert res_class["tolerations"][0] == "toleration1" new_class = deepcopy(res_class) new_class.pop("id") + new_class.pop("resource_pool_id") new_class["tolerations"] = [] _, res = await sanic_client.put( f"/api/data/resource_pools/{rp_id}/classes/{res_class_id}", @@ -1009,6 +1012,7 @@ async def test_remove_all_affinities_put( assert res_class["node_affinities"][0] == {"key": "affinity1", "required_during_scheduling": False} new_class = deepcopy(res_class) new_class.pop("id") + new_class.pop("resource_pool_id") new_class["node_affinities"] = [] _, res = await sanic_client.put( f"/api/data/resource_pools/{rp_id}/classes/{res_class_id}", @@ -1045,6 +1049,7 @@ async def test_put_tolerations( assert res_class["tolerations"][0] == "toleration1" new_class = deepcopy(res_class) new_class.pop("id") + new_class.pop("resource_pool_id") new_class["tolerations"] = ["toleration2", "toleration3"] _, res = await sanic_client.put( f"/api/data/resource_pools/{rp_id}/classes/{res_class_id}", @@ -1081,6 +1086,7 @@ async def test_put_affinities( assert res_class["node_affinities"][0] == {"key": "affinity1", "required_during_scheduling": False} new_class = deepcopy(res_class) new_class.pop("id") + new_class.pop("resource_pool_id") new_class["node_affinities"] = [{"key": "affinity1", "required_during_scheduling": True}, {"key": "affinity2"}] _, res = await sanic_client.put( f"/api/data/resource_pools/{rp_id}/classes/{res_class_id}", @@ -1281,6 +1287,8 @@ async def _resource_pools_request( check_payload["id"] = resource_pool_id if "id" not in check_payload["quota"]: check_payload["quota"]["id"] = rp["quota"]["id"] + for clss in check_payload.get("classes", []): + clss["resource_pool_id"] = resource_pool_id url = f"{base_url}/{resource_pool_id}" @@ -1473,6 +1481,7 @@ async def test_resource_pool_patch_platform( put = deepcopy(resource_pool_payload_2) put["quota"] = rp["quota"] put["classes"] = rp["classes"] + [clss.pop("resource_pool_id") for clss in put["classes"]] put["platform"] = "linux/amd64" _, res = await sanic_client.put(f"/api/data/resource_pools/{rp_id}", headers=admin_headers, json=put)