diff --git a/Makefile b/Makefile index cb050f2a0..f81bc9331 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: schemas tests test_setup main_tests schemathesis_tests collect_coverage style_checks pre_commit_checks run download_avro check_avro avro_models update_avro k3d_cluster install_amaltheas all -AMALTHEA_JS_VERSION ?= 0.12.2 -AMALTHEA_SESSIONS_VERSION ?= 0.0.10-new-operator-chart +AMALTHEA_JS_VERSION ?= 0.13.0 +AMALTHEA_SESSIONS_VERSION ?= 0.13.0 codegen_params = --input-file-type openapi --output-model-type pydantic_v2.BaseModel --use-double-quotes --target-python-version 3.12 --collapse-root-models --field-constraints --strict-nullable --set-default-enum-member --openapi-scopes schemas paths parameters --set-default-enum-member --use-one-literal-as-default --use-default define test_apispec_up_to_date @@ -163,5 +163,5 @@ install_amaltheas: ## Installs both version of amalthea in the. NOTE: It uses t # TODO: Add the version variables from the top of the file here when the charts are fully published amalthea_schema: ## Updates generates pydantic classes from CRDs - curl https://raw.githubusercontent.com/SwissDataScienceCenter/amalthea/feat-add-cloud-storage/config/crd/bases/amalthea.dev_amaltheasessions.yaml | yq '.spec.versions[0].schema.openAPIV3Schema' | poetry run datamodel-codegen --input-file-type jsonschema --output-model-type pydantic_v2.BaseModel --output components/renku_data_services/notebooks/cr_amalthea_session.py --use-double-quotes --target-python-version 3.12 --collapse-root-models --field-constraints --strict-nullable --base-class renku_data_services.notebooks.cr_base.BaseCRD --allow-extra-fields --use-default-kwarg + curl https://raw.githubusercontent.com/SwissDataScienceCenter/amalthea/main/config/crd/bases/amalthea.dev_amaltheasessions.yaml | yq '.spec.versions[0].schema.openAPIV3Schema' | poetry run datamodel-codegen --input-file-type jsonschema --output-model-type pydantic_v2.BaseModel --output components/renku_data_services/notebooks/cr_amalthea_session.py --use-double-quotes --target-python-version 3.12 --collapse-root-models --field-constraints --strict-nullable --base-class renku_data_services.notebooks.cr_base.BaseCRD --allow-extra-fields --use-default-kwarg curl https://raw.githubusercontent.com/SwissDataScienceCenter/amalthea/main/controller/crds/jupyter_server.yaml | yq '.spec.versions[0].schema.openAPIV3Schema' | poetry run datamodel-codegen --input-file-type jsonschema --output-model-type pydantic_v2.BaseModel --output components/renku_data_services/notebooks/cr_jupyter_server.py --use-double-quotes --target-python-version 3.12 --collapse-root-models --field-constraints --strict-nullable --base-class renku_data_services.notebooks.cr_base.BaseCRD --allow-extra-fields --use-default-kwarg diff --git a/components/renku_data_services/notebooks/blueprints.py b/components/renku_data_services/notebooks/blueprints.py index 9fe1a6bfb..55c07ada1 100644 --- a/components/renku_data_services/notebooks/blueprints.py +++ b/components/renku_data_services/notebooks/blueprints.py @@ -40,6 +40,7 @@ ) from renku_data_services.notebooks.config import NotebooksConfig from renku_data_services.notebooks.crs import ( + Affinity, AmaltheaSessionSpec, AmaltheaSessionV1Alpha1, Authentication, @@ -62,6 +63,7 @@ State, Storage, TlsSecret, + Toleration, ) from renku_data_services.notebooks.errors.intermittent import AnonymousUserPatchError from renku_data_services.notebooks.util.kubernetes_ import ( @@ -317,18 +319,19 @@ async def _handler( ) if csr.target_path is not None and not PurePosixPath(csr.target_path).is_absolute(): csr.target_path = (work_dir / csr.target_path).as_posix() - if csr_id in dcs_secrets and csr.configuration is not None: - raise errors.ValidationError( - message=f"Overriding the storage configuration for storage with ID {csr_id} " - "is not allowed because the storage has an associated saved secret.", - ) + # TODO: The UI always does this check if it is acceptable/safe + # if csr_id in dcs_secrets and csr.configuration is not None: + # raise errors.ValidationError( + # message=f"Overriding the storage configuration for storage with ID {csr_id} " + # "is not allowed because the storage has an associated saved secret.", + # ) dcs[csr_id] = dcs[csr_id].with_override(csr) repositories = [Repository(url=i) for i in project.repositories] secrets_to_create: list[V1Secret] = [] # Generate the cloud starge secrets data_sources: list[DataSource] = [] for cs_id, cs in dcs.items(): - secret_name = f"{server_name}-ds-{cs_id}" + secret_name = f"{server_name}-ds-{cs_id.lower()}" secrets_to_create.append(cs.secret(secret_name, self.nb_config.k8s_client.preferred_namespace)) data_sources.append( DataSource(mountPath=cs.mount_folder, secretRef=SecretRefWhole(name=secret_name, adopt=True)) @@ -433,6 +436,12 @@ async def _handler( else [], ), dataSources=data_sources, + tolerations=[ + Toleration.model_validate(toleration) for toleration in self.nb_config.sessions.tolerations + ], + affinity=Affinity.model_validate(self.nb_config.sessions.affinity) + if len(self.nb_config.sessions.affinity.keys()) > 0 + else None, ), ) parsed_proxy_url = urlparse(urljoin(base_server_url + "/", "oauth2")) @@ -483,7 +492,7 @@ async def _handler( headers = {"Authorization": f"bearer {user.access_token}"} for s_id, secrets in dcs_secrets.items(): request_data = { - "name": f"{server_name}-ds-{s_id}-secrets", + "name": f"{server_name}-ds-{s_id.lower()}-secrets", "namespace": self.nb_config.k8s_v2_client.preferred_namespace, "secret_ids": [str(secret.secret_id) for secret in secrets], "owner_references": [owner_reference], diff --git a/components/renku_data_services/notebooks/cr_amalthea_session.py b/components/renku_data_services/notebooks/cr_amalthea_session.py index a4c2e3fd9..df79c7db3 100644 --- a/components/renku_data_services/notebooks/cr_amalthea_session.py +++ b/components/renku_data_services/notebooks/cr_amalthea_session.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: -# timestamp: 2024-09-04T21:22:45+00:00 +# timestamp: 2024-10-24T01:41:50+00:00 from __future__ import annotations @@ -12,6 +12,404 @@ from renku_data_services.notebooks.cr_base import BaseCRD +class MatchExpression(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field(..., description="The label key that the selector applies to.") + operator: str = Field( + ..., + description="Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + ) + values: Optional[List[str]] = Field( + default=None, + description="An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + ) + + +class MatchField(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field(..., description="The label key that the selector applies to.") + operator: str = Field( + ..., + description="Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + ) + values: Optional[List[str]] = Field( + default=None, + description="An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + ) + + +class Preference(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression]] = Field( + default=None, + description="A list of node selector requirements by node's labels.", + ) + matchFields: Optional[List[MatchField]] = Field( + default=None, + description="A list of node selector requirements by node's fields.", + ) + + +class PreferredDuringSchedulingIgnoredDuringExecutionItem(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + preference: Preference = Field( + ..., + description="A node selector term, associated with the corresponding weight.", + ) + weight: int = Field( + ..., + description="Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.", + ) + + +class NodeSelectorTerm(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression]] = Field( + default=None, + description="A list of node selector requirements by node's labels.", + ) + matchFields: Optional[List[MatchField]] = Field( + default=None, + description="A list of node selector requirements by node's fields.", + ) + + +class RequiredDuringSchedulingIgnoredDuringExecution(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + nodeSelectorTerms: List[NodeSelectorTerm] = Field( + ..., description="Required. A list of node selector terms. The terms are ORed." + ) + + +class NodeAffinity(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + preferredDuringSchedulingIgnoredDuringExecution: Optional[ + List[PreferredDuringSchedulingIgnoredDuringExecutionItem] + ] = Field( + default=None, + description='The scheduler will prefer to schedule pods to nodes that satisfy\nthe affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n"weight" to the sum if the node matches the corresponding matchExpressions; the\nnode(s) with the highest sum are the most preferred.', + ) + requiredDuringSchedulingIgnoredDuringExecution: Optional[ + RequiredDuringSchedulingIgnoredDuringExecution + ] = Field( + default=None, + description="If the affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to an update), the system\nmay or may not try to eventually evict the pod from its node.", + ) + + +class MatchExpression2(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field( + ..., description="key is the label key that the selector applies to." + ) + operator: str = Field( + ..., + description="operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + ) + values: Optional[List[str]] = Field( + default=None, + description="values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + ) + + +class LabelSelector(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class NamespaceSelector(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class PodAffinityTerm(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + labelSelector: Optional[LabelSelector] = Field( + default=None, + description="A label query over a set of resources, in this case pods.", + ) + namespaceSelector: Optional[NamespaceSelector] = Field( + default=None, + description='A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means "this pod\'s namespace".\nAn empty selector ({}) matches all namespaces.', + ) + namespaces: Optional[List[str]] = Field( + default=None, + description='namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means "this pod\'s namespace".', + ) + topologyKey: str = Field( + ..., + description="This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + ) + + +class PreferredDuringSchedulingIgnoredDuringExecutionItem1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + podAffinityTerm: PodAffinityTerm = Field( + ..., + description="Required. A pod affinity term, associated with the corresponding weight.", + ) + weight: int = Field( + ..., + description="weight associated with matching the corresponding podAffinityTerm,\nin the range 1-100.", + ) + + +class LabelSelector1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class NamespaceSelector1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class RequiredDuringSchedulingIgnoredDuringExecutionItem(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + labelSelector: Optional[LabelSelector1] = Field( + default=None, + description="A label query over a set of resources, in this case pods.", + ) + namespaceSelector: Optional[NamespaceSelector1] = Field( + default=None, + description='A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means "this pod\'s namespace".\nAn empty selector ({}) matches all namespaces.', + ) + namespaces: Optional[List[str]] = Field( + default=None, + description='namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means "this pod\'s namespace".', + ) + topologyKey: str = Field( + ..., + description="This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + ) + + +class PodAffinity(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + preferredDuringSchedulingIgnoredDuringExecution: Optional[ + List[PreferredDuringSchedulingIgnoredDuringExecutionItem1] + ] = Field( + default=None, + description='The scheduler will prefer to schedule pods to nodes that satisfy\nthe affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the\nnode(s) with the highest sum are the most preferred.', + ) + requiredDuringSchedulingIgnoredDuringExecution: Optional[ + List[RequiredDuringSchedulingIgnoredDuringExecutionItem] + ] = Field( + default=None, + description="If the affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to a pod label update), the\nsystem may or may not try to eventually evict the pod from its node.\nWhen there are multiple elements, the lists of nodes corresponding to each\npodAffinityTerm are intersected, i.e. all terms must be satisfied.", + ) + + +class LabelSelector2(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class NamespaceSelector2(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class PodAffinityTerm1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + labelSelector: Optional[LabelSelector2] = Field( + default=None, + description="A label query over a set of resources, in this case pods.", + ) + namespaceSelector: Optional[NamespaceSelector2] = Field( + default=None, + description='A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means "this pod\'s namespace".\nAn empty selector ({}) matches all namespaces.', + ) + namespaces: Optional[List[str]] = Field( + default=None, + description='namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means "this pod\'s namespace".', + ) + topologyKey: str = Field( + ..., + description="This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + ) + + +class PreferredDuringSchedulingIgnoredDuringExecutionItem2(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + podAffinityTerm: PodAffinityTerm1 = Field( + ..., + description="Required. A pod affinity term, associated with the corresponding weight.", + ) + weight: int = Field( + ..., + description="weight associated with matching the corresponding podAffinityTerm,\nin the range 1-100.", + ) + + +class LabelSelector3(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class NamespaceSelector3(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + matchExpressions: Optional[List[MatchExpression2]] = Field( + default=None, + description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", + ) + matchLabels: Optional[Dict[str, str]] = Field( + default=None, + description='matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is "key", the\noperator is "In", and the values array contains only "value". The requirements are ANDed.', + ) + + +class RequiredDuringSchedulingIgnoredDuringExecutionItem1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + labelSelector: Optional[LabelSelector3] = Field( + default=None, + description="A label query over a set of resources, in this case pods.", + ) + namespaceSelector: Optional[NamespaceSelector3] = Field( + default=None, + description='A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means "this pod\'s namespace".\nAn empty selector ({}) matches all namespaces.', + ) + namespaces: Optional[List[str]] = Field( + default=None, + description='namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means "this pod\'s namespace".', + ) + topologyKey: str = Field( + ..., + description="This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + ) + + +class PodAntiAffinity(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + preferredDuringSchedulingIgnoredDuringExecution: Optional[ + List[PreferredDuringSchedulingIgnoredDuringExecutionItem2] + ] = Field( + default=None, + description='The scheduler will prefer to schedule pods to nodes that satisfy\nthe anti-affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling anti-affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n"weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the\nnode(s) with the highest sum are the most preferred.', + ) + requiredDuringSchedulingIgnoredDuringExecution: Optional[ + List[RequiredDuringSchedulingIgnoredDuringExecutionItem1] + ] = Field( + default=None, + description="If the anti-affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the anti-affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to a pod label update), the\nsystem may or may not try to eventually evict the pod from its node.\nWhen there are multiple elements, the lists of nodes corresponding to each\npodAffinityTerm are intersected, i.e. all terms must be satisfied.", + ) + + +class Affinity(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + nodeAffinity: Optional[NodeAffinity] = Field( + default=None, + description="Describes node affinity scheduling rules for the pod.", + ) + podAffinity: Optional[PodAffinity] = Field( + default=None, + description="Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).", + ) + podAntiAffinity: Optional[PodAntiAffinity] = Field( + default=None, + description="Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).", + ) + + class ExtraVolumeMount(BaseCRD): model_config = ConfigDict( extra="allow", @@ -1299,28 +1697,11 @@ class Resources1(BaseCRD): ) -class MatchExpression(BaseCRD): - model_config = ConfigDict( - extra="allow", - ) - key: str = Field( - ..., description="key is the label key that the selector applies to." - ) - operator: str = Field( - ..., - description="operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", - ) - values: Optional[List[str]] = Field( - default=None, - description="values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", - ) - - class Selector(BaseCRD): model_config = ConfigDict( extra="allow", ) - matchExpressions: Optional[List[MatchExpression]] = Field( + matchExpressions: Optional[List[MatchExpression2]] = Field( default=None, description="matchExpressions is a list of label selector requirements. The requirements are ANDed.", ) @@ -2644,6 +3025,12 @@ class InitContainer(BaseCRD): ) +class ReconcileSrategy(Enum): + never = "never" + always = "always" + whenFailedOrHibernated = "whenFailedOrHibernated" + + class ValueFrom2(BaseCRD): model_config = ConfigDict( extra="allow", @@ -2740,7 +3127,7 @@ class Session(BaseCRD): ) runAsGroup: int = Field( default=1000, - description="The group is set on the session and this value is also set as the fsgroup for the whole pod and all session\ncontianers.", + description="The group is set on the session and this value is also set as the fsgroup for the whole pod and all session\ncontainers.", ge=0, ) runAsUser: int = Field(default=1000, ge=0) @@ -2758,10 +3145,40 @@ class Session(BaseCRD): ) +class Toleration(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + effect: Optional[str] = Field( + default=None, + description="Effect indicates the taint effect to match. Empty means match all taint effects.\nWhen specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute.", + ) + key: Optional[str] = Field( + default=None, + description="Key is the taint key that the toleration applies to. Empty means match all taint keys.\nIf the key is empty, operator must be Exists; this combination means to match all values and all keys.", + ) + operator: Optional[str] = Field( + default=None, + description="Operator represents a key's relationship to the value.\nValid operators are Exists and Equal. Defaults to Equal.\nExists is equivalent to wildcard for value, so that a pod can\ntolerate all taints of a particular category.", + ) + tolerationSeconds: Optional[int] = Field( + default=None, + description="TolerationSeconds represents the period of time the toleration (which must be\nof effect NoExecute, otherwise this field is ignored) tolerates the taint. By default,\nit is not set, which means tolerate the taint forever (do not evict). Zero and\nnegative values will be treated as 0 (evict immediately) by the system.", + ) + value: Optional[str] = Field( + default=None, + description="Value is the taint value the toleration matches to.\nIf the operator is Exists, the value should be empty, otherwise just a regular string.", + ) + + class Spec(BaseCRD): model_config = ConfigDict( extra="allow", ) + affinity: Optional[Affinity] = Field( + default=None, + description="If specified, the pod's scheduling constraints\nPassed right through to the Statefulset used for the session.", + ) authentication: Optional[Authentication] = Field( default=None, description="Authentication configuration for the session" ) @@ -2782,7 +3199,7 @@ class Spec(BaseCRD): ) extraVolumes: Optional[List[ExtraVolume]] = Field( default=None, - description="Additional volumes to include in the statefulset for a session", + description="Additional volumes to include in the statefulset for a session\nVolumes used internally by amalthea are all prefixed with 'amalthea-' so as long as you\navoid that naming you will avoid conflicts with the volumes that amalthea generates.", ) hibernated: bool = Field( ..., @@ -2796,10 +3213,22 @@ class Spec(BaseCRD): default=None, description="Additional init containers to add to the session statefulset\nNOTE: The container names provided will be partially overwritten and randomized to avoid collisions", ) + nodeSelector: Optional[Dict[str, str]] = Field( + default=None, + description="Selector which must match a node's labels for the pod to be scheduled on that node.\nPassed right through to the Statefulset used for the session.", + ) + reconcileSrategy: ReconcileSrategy = Field( + default="always", + description="Indicates how Amalthea should reconcile the child resources for a session. This can be problematic because\nnewer versions of Amalthea may include new versions of the sidecars or other changes not reflected\nin the AmaltheaSession CRD, so simply updating Amalthea could cause existing sessions to restart\nbecause the sidecars will have a newer image or for other reasons because the code changed.\nHibernating the session and deleting it will always work as expected regardless of the strategy.\nThe status of the session and all hibernation or auto-cleanup functionality will always work as expected.\nA few values are possible:\n- never: Amalthea will never update any of the child resources and will ignore any changes to the CR\n- always: This is the expected method of operation for an operator, changes to the spec are always reconciled\n- whenHibernatedOrFailed: To avoid interrupting a running session, reconciliation of the child components\n are only done when the session has a Failed or Hibernated status", + ) session: Session = Field( ..., description="Specification for the main session container that the user will access and use", ) + tolerations: Optional[List[Toleration]] = Field( + default=None, + description="If specified, the pod's tolerations.\nPassed right through to the Statefulset used for the session.", + ) class Condition(BaseCRD): diff --git a/components/renku_data_services/notebooks/crs.py b/components/renku_data_services/notebooks/crs.py index 8d86ed9ad..a04c6121a 100644 --- a/components/renku_data_services/notebooks/crs.py +++ b/components/renku_data_services/notebooks/crs.py @@ -12,6 +12,7 @@ from renku_data_services.errors import errors from renku_data_services.notebooks import apispec from renku_data_services.notebooks.cr_amalthea_session import ( + Affinity, Authentication, CodeRepository, Culling, @@ -21,12 +22,14 @@ ExtraVolumeMount, Ingress, InitContainer, + NodeAffinity, SecretRef, Session, State, Status, Storage, TlsSecret, + Toleration, ) from renku_data_services.notebooks.cr_amalthea_session import EnvItem2 as SessionEnvItem from renku_data_services.notebooks.cr_amalthea_session import Item4 as SecretAsVolumeItem diff --git a/components/renku_data_services/notebooks/util/kubernetes_.py b/components/renku_data_services/notebooks/util/kubernetes_.py index 7cf289c95..257697de0 100644 --- a/components/renku_data_services/notebooks/util/kubernetes_.py +++ b/components/renku_data_services/notebooks/util/kubernetes_.py @@ -62,9 +62,9 @@ def renku_2_make_server_name(safe_username: str, project_id: str, launcher_id: s # Must be no more than 63 characters because the name is used to create a k8s Service and Services # have more restrictions for their names beacuse their names have to make a valid hostname. # NOTE: We use server name as a label value, so, server name must be less than 63 characters. - # !NOTE: For now we limit the server name to a max of 42 characters. - # NOTE: This is 12 + 9 + 21 = 42 characters - return f"{prefix[:12]}-renku-2-{server_hash[:21]}" + # !NOTE: For now we limit the server name to a max of 25 characters. + # NOTE: This is 12 + 1 + 12 = 25 characters + return f"{prefix[:12]}-{server_hash[:12]}" def find_env_var(container: V1Container, env_name: str) -> tuple[int, str] | None: diff --git a/test/bases/renku_data_services/data_api/test_projects.py b/test/bases/renku_data_services/data_api/test_projects.py index 07d94fc72..6c6f4df5d 100644 --- a/test/bases/renku_data_services/data_api/test_projects.py +++ b/test/bases/renku_data_services/data_api/test_projects.py @@ -960,7 +960,9 @@ async def test_project_slug_case( assert res.json.get("slug") == uppercase_slug etag = res.headers["ETag"] # Get it by the namespace - _, res = await sanic_client.get(f"/api/data/projects/{group['slug']}/{uppercase_slug}", headers=user_headers) + _, res = await sanic_client.get( + f"/api/data/namespaces/{group['slug']}/projects/{uppercase_slug}", headers=user_headers + ) assert res.status_code == 200 assert res.json.get("slug") == uppercase_slug # Patch the project