From bdf3c6bd1e3d03b739258d4550c0626d70f8059b Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 12 May 2026 13:24:28 +0200 Subject: [PATCH 01/28] api spec and models --- .../renku_apps/__init__.py | 1 + .../renku_apps/api.spec.yaml | 154 ++++++++++++++++++ .../renku_data_services/renku_apps/models.py | 41 +++++ 3 files changed, 196 insertions(+) create mode 100644 components/renku_data_services/renku_apps/__init__.py create mode 100644 components/renku_data_services/renku_apps/api.spec.yaml create mode 100644 components/renku_data_services/renku_apps/models.py diff --git a/components/renku_data_services/renku_apps/__init__.py b/components/renku_data_services/renku_apps/__init__.py new file mode 100644 index 000000000..113f165f4 --- /dev/null +++ b/components/renku_data_services/renku_apps/__init__.py @@ -0,0 +1 @@ +"""Blueprints for Apps.""" diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml new file mode 100644 index 000000000..f23d81413 --- /dev/null +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -0,0 +1,154 @@ +openapi: 3.0.2 +info: + title: Renku Data Services API + description: | + A service that allows users to manage apps on Renku. + version: v1 +servers: + - url: /api/data +paths: + /projects/{project_id}/app: + post: + summary: Create an app + parameters: + - in: path + name: project_id + required: true + schema: + $ref: "#/components/schemas/Ulid" + description: The ID of the project to create the app for. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AppPost" + responses: + "201": + description: App created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/App" + default: + $ref: "#/components/responses/Error" + tags: + - apps + get: + summary: Retrieve an app + parameters: + - in: path + name: project_id + required: true + schema: + $ref: "#/components/schemas/Ulid" + description: The ID of the project to retrieve the app for. + responses: + "200": + description: The app for the specified project. + content: + application/json: + schema: + $ref: "#/components/schemas/App" + default: + $ref: "#/components/responses/Error" + tags: + - apps +components: + schemas: + Ulid: + description: ULID identifier + type: string + minLength: 26 + maxLength: 26 + pattern: "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" + + AppState: + type: string + enum: + - pending + - ready + - failed + + App: + type: object + additionalProperties: false + properties: + id: + $ref: "#/components/schemas/Ulid" + project_id: + $ref: "#/components/schemas/Ulid" + image: + type: string + status: + $ref: "#/components/schemas/AppState" + url: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + created_by: + type: string + required: + - id + - project_id + - image + - status + - url + - created_at + - updated_at + - created_by + + AppPost: + type: object + additionalProperties: false + properties: + image: + type: string + required: + - image + + AppPatch: + type: object + additionalProperties: false + properties: + image: + type: string + + ErrorResponse: + type: object + properties: + error: + type: object + properties: + code: + type: integer + minimum: 0 + exclusiveMinimum: true + example: 1404 + detail: + type: string + example: A more detailed optional message showing what the problem was + message: + type: string + example: Something went wrong - please try again later + trace_id: + description: Sentry trace ID for linking to corresponding log entries + example: ac93950e9e114a55c67fb8e5ef519bbe + type: string + required: + - code + - message + required: + - error + + responses: + Error: + description: The schema for all 4xx and 5xx responses + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py new file mode 100644 index 000000000..299d8febe --- /dev/null +++ b/components/renku_data_services/renku_apps/models.py @@ -0,0 +1,41 @@ +"""Models for apps.""" + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from ulid import ULID + + +class AppState(StrEnum): + """The status of an app.""" + + PENDING = "pending" + READY = "ready" + FAILED = "failed" + + +@dataclass(frozen=True, eq=True, kw_only=True) +class UnsavedApp: + """An unsaved app.""" + + project_id: ULID + image: str + created_by: str + status: AppState = AppState.PENDING + + +@dataclass(frozen=True, eq=True, kw_only=True) +class App(UnsavedApp): + """An app stored in the database.""" + + id: ULID + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, eq=True, kw_only=True) +class AppPatch: + """A patch for an existing app.""" + + image: str | None = None From 57358b57b02967d680849f8542573749c3a15bcf Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 13 May 2026 16:59:00 +0200 Subject: [PATCH 02/28] add knative servive crd pydantic types --- Makefile | 11 +- .../renku_data_services/renku_apps/cr_base.py | 12 + .../renku_apps/cr_knative_service.py | 1152 +++++++++++++++++ .../renku_data_services/renku_apps/crs.py | 31 + 4 files changed, 1199 insertions(+), 7 deletions(-) create mode 100644 components/renku_data_services/renku_apps/cr_base.py create mode 100644 components/renku_data_services/renku_apps/cr_knative_service.py create mode 100644 components/renku_data_services/renku_apps/crs.py diff --git a/Makefile b/Makefile index d50c13841..3c3475235 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ AMALTHEA_SESSIONS_VERSION ?= 0.25.0 +KNATIVE_SERVING_VERSION ?= knative-v1.22.0 COMMON_CODEGEN_PARAMS := \ --output-model-type pydantic_v2.BaseModel \ --use-double-quotes \ @@ -119,13 +120,9 @@ amalthea_schema: ## Updates generates pydantic classes from CRDs shipwright_schema: ## Updates the Shipwright pydantic classes curl https://raw.githubusercontent.com/shipwright-io/build/refs/tags/v0.15.2/deploy/crds/shipwright.io_buildruns.yaml | yq '.spec.versions[] | select(.name == "v1beta1") | .schema.openAPIV3Schema' | poetry run datamodel-codegen --output components/renku_data_services/session/cr_shipwright_buildrun.py --base-class renku_data_services.session.cr_base.BaseCRD ${CR_CODEGEN_PARAMS} -.PHONY: oci_schema -oci_schema: ## Updates the OCI classes - poetry run datamodel-codegen --url "https://raw.githubusercontent.com/opencontainers/image-spec/26647a49f642c7d22a1cd3aa0a48e4650a542269/schema/config-schema.json" --output components/renku_data_services/notebooks/oci/image_config.py --class-name ImageConfig --base-class renku_data_services.notebooks.oci.base_model.BaseOciModel ${CR_CODEGEN_PARAMS} - poetry run datamodel-codegen --url "https://raw.githubusercontent.com/opencontainers/image-spec/refs/tags/v1.1.1/schema/image-index-schema.json" --output components/renku_data_services/notebooks/oci/image_index.py --class-name ImageIndex --base-class renku_data_services.notebooks.oci.base_model.BaseOciModel ${CR_CODEGEN_PARAMS} - poetry run datamodel-codegen --url "https://raw.githubusercontent.com/opencontainers/image-spec/refs/tags/v1.1.1/schema/image-manifest-schema.json" --output components/renku_data_services/notebooks/oci/image_manifest.py --class-name ImageManifest --base-class renku_data_services.notebooks.oci.base_model.BaseOciModel ${CR_CODEGEN_PARAMS} - -##@ Devcontainer +.PHONY: knative_serving_schema +knative_serving_schema: ## Updates the Knative Serving pydantic classes + curl https://raw.githubusercontent.com/knative/serving/refs/tags/${KNATIVE_SERVING_VERSION}/config/core/300-resources/service.yaml | yq '.spec.versions[] | select(.name == "v1") | .schema.openAPIV3Schema' | poetry run datamodel-codegen --output components/renku_data_services/renku_apps/cr_knative_service.py --base-class renku_data_services.renku_apps.cr_base.BaseCRD ${CR_CODEGEN_PARAMS} .PHONY: devcontainer_up devcontainer_up: ## Start dev containers diff --git a/components/renku_data_services/renku_apps/cr_base.py b/components/renku_data_services/renku_apps/cr_base.py new file mode 100644 index 000000000..92d29285d --- /dev/null +++ b/components/renku_data_services/renku_apps/cr_base.py @@ -0,0 +1,12 @@ +"""Base models for K8s CRD specifications.""" + +from pydantic import BaseModel, ConfigDict + + +class BaseCRD(BaseModel): + """Base CRD specification.""" + + model_config = ConfigDict( + # Do not exclude unknown properties. + extra="allow" + ) diff --git a/components/renku_data_services/renku_apps/cr_knative_service.py b/components/renku_data_services/renku_apps/cr_knative_service.py new file mode 100644 index 000000000..6fefa0329 --- /dev/null +++ b/components/renku_data_services/renku_apps/cr_knative_service.py @@ -0,0 +1,1152 @@ +# generated by datamodel-codegen: +# filename: +# timestamp: 2026-05-13T09:03:12+00:00 + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Sequence, Union + +from pydantic import ConfigDict, Field, RootModel +from renku_data_services.renku_apps.cr_base import BaseCRD + + +class Metadata(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + annotations: Optional[Mapping[str, str]] = None + finalizers: Optional[Sequence[str]] = None + labels: Optional[Mapping[str, str]] = None + name: Optional[str] = None + namespace: Optional[str] = None + + +class ConfigMapKeyRef(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field(..., description="The key to select.") + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, + description="Specify whether the ConfigMap or its key must be defined", + ) + + +class SecretKeyRef(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field( + ..., + description="The key of the secret to select from. Must be a valid secret key.", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, + description="Specify whether the Secret or its key must be defined", + ) + + +class ValueFrom(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + configMapKeyRef: Optional[ConfigMapKeyRef] = Field( + default=None, description="Selects a key of a ConfigMap." + ) + fieldRef: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-fieldref", + ) + resourceFieldRef: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-fieldref", + ) + secretKeyRef: Optional[SecretKeyRef] = Field( + default=None, description="Selects a key of a secret in the pod's namespace" + ) + + +class EnvItem(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + name: str = Field( + ..., + description="Name of the environment variable.\nMay consist of any printable ASCII characters except '='.", + ) + value: Optional[str] = Field( + default=None, + description='Variable references $(VAR_NAME) are expanded\nusing the previously defined environment variables in the container and\nany service environment variables. If a variable cannot be resolved,\nthe reference in the input string will be unchanged. Double $$ are reduced\nto a single $, which allows for escaping the $(VAR_NAME) syntax: i.e.\n"$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)".\nEscaped references will never be expanded, regardless of whether the variable\nexists or not.\nDefaults to "".', + ) + valueFrom: Optional[ValueFrom] = Field( + default=None, + description="Source for the environment variable's value. Cannot be used if value is not empty.", + ) + + +class ConfigMapRef(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, description="Specify whether the ConfigMap must be defined" + ) + + +class SecretRef(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, description="Specify whether the Secret must be defined" + ) + + +class EnvFromItem(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + configMapRef: Optional[ConfigMapRef] = Field( + default=None, description="The ConfigMap to select from" + ) + prefix: Optional[str] = Field( + default=None, + description="Optional text to prepend to the name of each environment variable.\nMay consist of any printable ASCII characters except '='.", + ) + secretRef: Optional[SecretRef] = Field( + default=None, description="The Secret to select from" + ) + + +class Exec(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + command: Optional[Sequence[str]] = Field( + default=None, + description="Command is the command line to execute inside the container, the working directory for the\ncommand is root ('/') in the container's filesystem. The command is simply exec'd, it is\nnot run inside a shell, so traditional shell instructions ('|', etc) won't work. To use\na shell, you need to explicitly call out to that shell.\nExit status of 0 is treated as live/healthy and non-zero is unhealthy.", + ) + + +class Grpc(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + port: Optional[int] = Field( + default=None, + description="Port number of the gRPC service. Number must be in the range 1 to 65535.", + ) + service: str = Field( + default="", + description="Service is the name of the service to place in the gRPC HealthCheckRequest\n(see https://github.com/grpc/grpc/blob/master/doc/health-checking.md).\n\nIf this is not specified, the default behavior is defined by gRPC.", + ) + + +class HttpHeader(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + name: str = Field( + ..., + description="The header field name.\nThis will be canonicalized upon output, so case-variant names will be understood as the same header.", + ) + value: str = Field(..., description="The header field value") + + +class HttpGet(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + host: Optional[str] = Field( + default=None, + description='Host name to connect to, defaults to the pod IP. You probably want to set\n"Host" in httpHeaders instead.', + ) + httpHeaders: Optional[Sequence[HttpHeader]] = Field( + default=None, + description="Custom headers to set in the request. HTTP allows repeated headers.", + ) + path: Optional[str] = Field( + default=None, description="Path to access on the HTTP server." + ) + port: Optional[Union[int, str]] = Field( + default=None, + description="Name or number of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", + ) + scheme: Optional[str] = Field( + default=None, + description="Scheme to use for connecting to the host.\nDefaults to HTTP.", + ) + + +class TcpSocket(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + host: Optional[str] = Field( + default=None, + description="Optional: Host name to connect to, defaults to the pod IP.", + ) + port: Optional[Union[int, str]] = Field( + default=None, + description="Number or name of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", + ) + + +class LivenessProbe(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + exec: Optional[Exec] = Field( + default=None, + description="Exec specifies a command to execute in the container.", + ) + failureThreshold: Optional[int] = Field( + default=None, + description="Minimum consecutive failures for the probe to be considered failed after having succeeded.\nDefaults to 3. Minimum value is 1.", + ) + grpc: Optional[Grpc] = Field( + default=None, description="GRPC specifies a GRPC HealthCheckRequest." + ) + httpGet: Optional[HttpGet] = Field( + default=None, description="HTTPGet specifies an HTTP GET request to perform." + ) + initialDelaySeconds: Optional[int] = Field( + default=None, + description="Number of seconds after the container has started before liveness probes are initiated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + periodSeconds: Optional[int] = Field( + default=None, description="How often (in seconds) to perform the probe." + ) + successThreshold: Optional[int] = Field( + default=None, + description="Minimum consecutive successes for the probe to be considered successful after having failed.\nDefaults to 1. Must be 1 for liveness and startup. Minimum value is 1.", + ) + tcpSocket: Optional[TcpSocket] = Field( + default=None, description="TCPSocket specifies a connection to a TCP port." + ) + timeoutSeconds: Optional[int] = Field( + default=None, + description="Number of seconds after which the probe times out.\nDefaults to 1 second. Minimum value is 1.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + + +class Port(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + containerPort: Optional[int] = Field( + default=None, + description="Number of port to expose on the pod's IP address.\nThis must be a valid port number, 0 < x < 65536.", + ) + name: Optional[str] = Field( + default=None, + description="If specified, this must be an IANA_SVC_NAME and unique within the pod. Each\nnamed port in a pod must have a unique name. Name for the port that can be\nreferred to by services.", + ) + protocol: str = Field( + default="TCP", + description='Protocol for port. Must be UDP, TCP, or SCTP.\nDefaults to "TCP".', + ) + + +class HttpGet1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + host: Optional[str] = Field( + default=None, + description='Host name to connect to, defaults to the pod IP. You probably want to set\n"Host" in httpHeaders instead.', + ) + httpHeaders: Optional[Sequence[HttpHeader]] = Field( + default=None, + description="Custom headers to set in the request. HTTP allows repeated headers.", + ) + path: Optional[str] = Field( + default=None, description="Path to access on the HTTP server." + ) + port: Optional[Union[int, str]] = Field( + default=None, + description="Name or number of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", + ) + scheme: Optional[str] = Field( + default=None, + description="Scheme to use for connecting to the host.\nDefaults to HTTP.", + ) + + +class ReadinessProbe(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + exec: Optional[Exec] = Field( + default=None, + description="Exec specifies a command to execute in the container.", + ) + failureThreshold: Optional[int] = Field( + default=None, + description="Minimum consecutive failures for the probe to be considered failed after having succeeded.\nDefaults to 3. Minimum value is 1.", + ) + grpc: Optional[Grpc] = Field( + default=None, description="GRPC specifies a GRPC HealthCheckRequest." + ) + httpGet: Optional[HttpGet1] = Field( + default=None, description="HTTPGet specifies an HTTP GET request to perform." + ) + initialDelaySeconds: Optional[int] = Field( + default=None, + description="Number of seconds after the container has started before liveness probes are initiated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + periodSeconds: Optional[int] = Field( + default=None, description="How often (in seconds) to perform the probe." + ) + successThreshold: Optional[int] = Field( + default=None, + description="Minimum consecutive successes for the probe to be considered successful after having failed.\nDefaults to 1. Must be 1 for liveness and startup. Minimum value is 1.", + ) + tcpSocket: Optional[TcpSocket] = Field( + default=None, description="TCPSocket specifies a connection to a TCP port." + ) + timeoutSeconds: Optional[int] = Field( + default=None, + description="Number of seconds after which the probe times out.\nDefaults to 1 second. Minimum value is 1.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + + +class Limits(RootModel[int]): + root: int = Field( + ..., + pattern="^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + ) + + +class Limits1(RootModel[str]): + root: str = Field( + ..., + pattern="^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + ) + + +class Requests(RootModel[int]): + root: int = Field( + ..., + pattern="^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + ) + + +class Requests1(RootModel[str]): + root: str = Field( + ..., + pattern="^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + ) + + +class Resources(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + limits: Optional[Mapping[str, Union[Limits, Limits1]]] = Field( + default=None, + description="Limits describes the maximum amount of compute resources allowed.\nMore info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + ) + requests: Optional[Mapping[str, Union[Requests, Requests1]]] = Field( + default=None, + description="Requests describes the minimum amount of compute resources required.\nIf Requests is omitted for a container, it defaults to Limits if that is explicitly specified,\notherwise to an implementation-defined value. Requests cannot exceed Limits.\nMore info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + ) + + +class Capabilities(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + add: Optional[Sequence[str]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.containerspec-addcapabilities", + ) + drop: Optional[Sequence[str]] = Field( + default=None, description="Removed capabilities" + ) + + +class SeccompProfile(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + localhostProfile: Optional[str] = Field( + default=None, + description='localhostProfile indicates a profile defined in a file on the node should be used.\nThe profile must be preconfigured on the node to work.\nMust be a descending path, relative to the kubelet\'s configured seccomp profile location.\nMust be set if type is "Localhost". Must NOT be set for any other type.', + ) + type: str = Field( + ..., + description="type indicates which kind of seccomp profile will be applied.\nValid options are:\n\nLocalhost - a profile defined in a file on the node should be used.\nRuntimeDefault - the container runtime default profile should be used.\nUnconfined - no profile should be applied.", + ) + + +class SecurityContext(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + allowPrivilegeEscalation: Optional[bool] = Field( + default=None, + description="AllowPrivilegeEscalation controls whether a process can gain more\nprivileges than its parent process. This bool directly controls if\nthe no_new_privs flag will be set on the container process.\nAllowPrivilegeEscalation is true always when the container is:\n1) run as Privileged\n2) has CAP_SYS_ADMIN\nNote that this field cannot be set when spec.os.name is windows.", + ) + capabilities: Optional[Capabilities] = Field( + default=None, + description="The capabilities to add/drop when running containers.\nDefaults to the default set of capabilities granted by the container runtime.\nNote that this field cannot be set when spec.os.name is windows.", + ) + privileged: Optional[bool] = Field( + default=None, + description="Run container in privileged mode. This can only be set to explicitly to 'false'", + ) + readOnlyRootFilesystem: Optional[bool] = Field( + default=None, + description="Whether this container has a read-only root filesystem.\nDefault is false.\nNote that this field cannot be set when spec.os.name is windows.", + ) + runAsGroup: Optional[int] = Field( + default=None, + description="The GID to run the entrypoint of the container process.\nUses runtime default if unset.\nMay also be set in PodSecurityContext. If set in both SecurityContext and\nPodSecurityContext, the value specified in SecurityContext takes precedence.\nNote that this field cannot be set when spec.os.name is windows.", + ) + runAsNonRoot: Optional[bool] = Field( + default=None, + description="Indicates that the container must run as a non-root user.\nIf true, the Kubelet will validate the image at runtime to ensure that it\ndoes not run as UID 0 (root) and fail to start the container if it does.\nIf unset or false, no such validation will be performed.\nMay also be set in PodSecurityContext. If set in both SecurityContext and\nPodSecurityContext, the value specified in SecurityContext takes precedence.", + ) + runAsUser: Optional[int] = Field( + default=None, + description="The UID to run the entrypoint of the container process.\nDefaults to user specified in image metadata if unspecified.\nMay also be set in PodSecurityContext. If set in both SecurityContext and\nPodSecurityContext, the value specified in SecurityContext takes precedence.\nNote that this field cannot be set when spec.os.name is windows.", + ) + seccompProfile: Optional[SeccompProfile] = Field( + default=None, + description="The seccomp options to use by this container. If seccomp options are\nprovided at both the pod & container level, the container options\noverride the pod options.\nNote that this field cannot be set when spec.os.name is windows.", + ) + + +class HttpGet2(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + host: Optional[str] = Field( + default=None, + description='Host name to connect to, defaults to the pod IP. You probably want to set\n"Host" in httpHeaders instead.', + ) + httpHeaders: Optional[Sequence[HttpHeader]] = Field( + default=None, + description="Custom headers to set in the request. HTTP allows repeated headers.", + ) + path: Optional[str] = Field( + default=None, description="Path to access on the HTTP server." + ) + port: Optional[Union[int, str]] = Field( + default=None, + description="Name or number of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", + ) + scheme: Optional[str] = Field( + default=None, + description="Scheme to use for connecting to the host.\nDefaults to HTTP.", + ) + + +class StartupProbe(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + exec: Optional[Exec] = Field( + default=None, + description="Exec specifies a command to execute in the container.", + ) + failureThreshold: Optional[int] = Field( + default=None, + description="Minimum consecutive failures for the probe to be considered failed after having succeeded.\nDefaults to 3. Minimum value is 1.", + ) + grpc: Optional[Grpc] = Field( + default=None, description="GRPC specifies a GRPC HealthCheckRequest." + ) + httpGet: Optional[HttpGet2] = Field( + default=None, description="HTTPGet specifies an HTTP GET request to perform." + ) + initialDelaySeconds: Optional[int] = Field( + default=None, + description="Number of seconds after the container has started before liveness probes are initiated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + periodSeconds: Optional[int] = Field( + default=None, description="How often (in seconds) to perform the probe." + ) + successThreshold: Optional[int] = Field( + default=None, + description="Minimum consecutive successes for the probe to be considered successful after having failed.\nDefaults to 1. Must be 1 for liveness and startup. Minimum value is 1.", + ) + tcpSocket: Optional[TcpSocket] = Field( + default=None, description="TCPSocket specifies a connection to a TCP port." + ) + timeoutSeconds: Optional[int] = Field( + default=None, + description="Number of seconds after which the probe times out.\nDefaults to 1 second. Minimum value is 1.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + + +class VolumeMount(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + mountPath: str = Field( + ..., + description="Path within the container at which the volume should be mounted. Must\nnot contain ':'.", + ) + mountPropagation: Optional[str] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-volumes-mount-propagation", + ) + name: str = Field(..., description="This must match the Name of a Volume.") + readOnly: Optional[bool] = Field( + default=None, + description="Mounted read-only if true, read-write otherwise (false or unspecified).\nDefaults to false.", + ) + subPath: Optional[str] = Field( + default=None, + description="Path within the volume from which the container's volume should be mounted.\nDefaults to \"\" (volume's root).", + ) + + +class Container(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + args: Optional[Sequence[str]] = Field( + default=None, + description='Arguments to the entrypoint.\nThe container image\'s CMD is used if this is not provided.\nVariable references $(VAR_NAME) are expanded using the container\'s environment. If a variable\ncannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced\nto a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will\nproduce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless\nof whether the variable exists or not. Cannot be updated.\nMore info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell', + ) + command: Optional[Sequence[str]] = Field( + default=None, + description='Entrypoint array. Not executed within a shell.\nThe container image\'s ENTRYPOINT is used if this is not provided.\nVariable references $(VAR_NAME) are expanded using the container\'s environment. If a variable\ncannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced\nto a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will\nproduce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless\nof whether the variable exists or not. Cannot be updated.\nMore info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell', + ) + env: Optional[Sequence[EnvItem]] = Field( + default=None, + description="List of environment variables to set in the container.\nCannot be updated.", + ) + envFrom: Optional[Sequence[EnvFromItem]] = Field( + default=None, + description="List of sources to populate environment variables in the container.\nThe keys defined within a source may consist of any printable ASCII characters except '='.\nWhen a key exists in multiple\nsources, the value associated with the last source will take precedence.\nValues defined by an Env with a duplicate key will take precedence.\nCannot be updated.", + ) + image: Optional[str] = Field( + default=None, + description="Container image name.\nMore info: https://kubernetes.io/docs/concepts/containers/images\nThis field is optional to allow higher level config management to default or override\ncontainer images in workload controllers like Deployments and StatefulSets.", + ) + imagePullPolicy: Optional[str] = Field( + default=None, + description="Image pull policy.\nOne of Always, Never, IfNotPresent.\nDefaults to Always if :latest tag is specified, or IfNotPresent otherwise.\nCannot be updated.\nMore info: https://kubernetes.io/docs/concepts/containers/images#updating-images", + ) + livenessProbe: Optional[LivenessProbe] = Field( + default=None, + description="Periodic probe of container liveness.\nContainer will be restarted if the probe fails.\nCannot be updated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + name: Optional[str] = Field( + default=None, + description="Name of the container specified as a DNS_LABEL.\nEach container in a pod must have a unique name (DNS_LABEL).\nCannot be updated.", + ) + ports: Optional[Sequence[Port]] = Field( + default=None, + description='List of ports to expose from the container. Not specifying a port here\nDOES NOT prevent that port from being exposed. Any port which is\nlistening on the default "0.0.0.0" address inside a container will be\naccessible from the network.\nModifying this array with strategic merge patch may corrupt the data.\nFor more information See https://github.com/kubernetes/kubernetes/issues/108255.\nCannot be updated.', + ) + readinessProbe: Optional[ReadinessProbe] = Field( + default=None, + description="Periodic probe of container service readiness.\nContainer will be removed from service endpoints if the probe fails.\nCannot be updated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + resources: Optional[Resources] = Field( + default=None, + description="Compute Resources required by this container.\nCannot be updated.\nMore info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/", + ) + securityContext: Optional[SecurityContext] = Field( + default=None, + description="SecurityContext defines the security options the container should be run with.\nIf set, the fields of SecurityContext override the equivalent fields of PodSecurityContext.\nMore info: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/", + ) + startupProbe: Optional[StartupProbe] = Field( + default=None, + description="StartupProbe indicates that the Pod has successfully initialized.\nIf specified, no other probes are executed until this completes successfully.\nIf this probe fails, the Pod will be restarted, just as if the livenessProbe failed.\nThis can be used to provide different probe parameters at the beginning of a Pod's lifecycle,\nwhen it might take a long time to load data or warm a cache, than during steady-state operation.\nThis cannot be updated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", + ) + terminationMessagePath: Optional[str] = Field( + default=None, + description="Optional: Path at which the file to which the container's termination message\nwill be written is mounted into the container's filesystem.\nMessage written is intended to be brief final status, such as an assertion failure message.\nWill be truncated by the node if greater than 4096 bytes. The total message length across\nall containers will be limited to 12kb.\nDefaults to /dev/termination-log.\nCannot be updated.", + ) + terminationMessagePolicy: Optional[str] = Field( + default=None, + description="Indicate how the termination message should be populated. File will use the contents of\nterminationMessagePath to populate the container status message on both success and failure.\nFallbackToLogsOnError will use the last chunk of container log output if the termination\nmessage file is empty and the container exited with an error.\nThe log output is limited to 2048 bytes or 80 lines, whichever is smaller.\nDefaults to File.\nCannot be updated.", + ) + volumeMounts: Optional[Sequence[VolumeMount]] = Field( + default=None, + description="Pod volumes to mount into the container's filesystem.\nCannot be updated.", + ) + workingDir: Optional[str] = Field( + default=None, + description="Container's working directory.\nIf not specified, the container runtime's default will be used, which\nmight be configured in the container image.\nCannot be updated.", + ) + + +class ImagePullSecret(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + + +class Item(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field(..., description="key is the key to project.") + mode: Optional[int] = Field( + default=None, + description="mode is Optional: mode bits used to set permissions on this file.\nMust be an octal value between 0000 and 0777 or a decimal value between 0 and 511.\nYAML accepts both octal and decimal values, JSON requires decimal values for mode bits.\nIf not specified, the volume defaultMode will be used.\nThis might be in conflict with other options that affect the file\nmode, like fsGroup, and the result can be other mode bits set.", + ) + path: str = Field( + ..., + description="path is the relative path of the file to map the key to.\nMay not be an absolute path.\nMay not contain the path element '..'.\nMay not start with the string '..'.", + ) + + +class ConfigMap(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + defaultMode: Optional[int] = Field( + default=None, + description="defaultMode is optional: mode bits used to set permissions on created files by default.\nMust be an octal value between 0000 and 0777 or a decimal value between 0 and 511.\nYAML accepts both octal and decimal values, JSON requires decimal values for mode bits.\nDefaults to 0644.\nDirectories within the path are not affected by this setting.\nThis might be in conflict with other options that affect the file\nmode, like fsGroup, and the result can be other mode bits set.", + ) + items: Optional[Sequence[Item]] = Field( + default=None, + description="items if unspecified, each key-value pair in the Data field of the referenced\nConfigMap will be projected into the volume as a file whose name is the\nkey and content is the value. If specified, the listed keys will be\nprojected into the specified paths, and unlisted keys will not be\npresent. If a key is specified which is not present in the ConfigMap,\nthe volume setup will error unless it is marked optional. Paths must be\nrelative and may not contain the '..' path or start with '..'.", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, + description="optional specify whether the ConfigMap or its keys must be defined", + ) + + +class ConfigMap1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + items: Optional[Sequence[Item]] = Field( + default=None, + description="items if unspecified, each key-value pair in the Data field of the referenced\nConfigMap will be projected into the volume as a file whose name is the\nkey and content is the value. If specified, the listed keys will be\nprojected into the specified paths, and unlisted keys will not be\npresent. If a key is specified which is not present in the ConfigMap,\nthe volume setup will error unless it is marked optional. Paths must be\nrelative and may not contain the '..' path or start with '..'.", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, + description="optional specify whether the ConfigMap or its keys must be defined", + ) + + +class FieldRef(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + apiVersion: Optional[str] = Field( + default=None, + description='Version of the schema the FieldPath is written in terms of, defaults to "v1".', + ) + fieldPath: str = Field( + ..., description="Path of the field to select in the specified API version." + ) + + +class Divisor(RootModel[int]): + root: int = Field( + ..., + description='Specifies the output format of the exposed resources, defaults to "1"', + pattern="^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + ) + + +class Divisor1(RootModel[str]): + root: str = Field( + ..., + description='Specifies the output format of the exposed resources, defaults to "1"', + pattern="^(\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\\+|-)?(([0-9]+(\\.[0-9]*)?)|(\\.[0-9]+))))?$", + ) + + +class ResourceFieldRef(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + containerName: Optional[str] = Field( + default=None, + description="Container name: required for volumes, optional for env vars", + ) + divisor: Optional[Union[Divisor, Divisor1]] = Field( + default=None, + description='Specifies the output format of the exposed resources, defaults to "1"', + ) + resource: str = Field(..., description="Required: resource to select") + + +class Item2(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + fieldRef: Optional[FieldRef] = Field( + default=None, + description="Required: Selects a field of the pod: only annotations, labels, name, namespace and uid are supported.", + ) + mode: Optional[int] = Field( + default=None, + description="Optional: mode bits used to set permissions on this file, must be an octal value\nbetween 0000 and 0777 or a decimal value between 0 and 511.\nYAML accepts both octal and decimal values, JSON requires decimal values for mode bits.\nIf not specified, the volume defaultMode will be used.\nThis might be in conflict with other options that affect the file\nmode, like fsGroup, and the result can be other mode bits set.", + ) + path: str = Field( + ..., + description="Required: Path is the relative path name of the file to be created. Must not be absolute or contain the '..' path. Must be utf-8 encoded. The first item of the relative path must not start with '..'", + ) + resourceFieldRef: Optional[ResourceFieldRef] = Field( + default=None, + description="Selects a resource of the container: only resources limits and requests\n(limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.", + ) + + +class DownwardAPI(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + items: Optional[Sequence[Item2]] = Field( + default=None, description="Items is a list of DownwardAPIVolume file" + ) + + +class Item3(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + key: str = Field(..., description="key is the key to project.") + mode: Optional[int] = Field( + default=None, + description="mode is Optional: mode bits used to set permissions on this file.\nMust be an octal value between 0000 and 0777 or a decimal value between 0 and 511.\nYAML accepts both octal and decimal values, JSON requires decimal values for mode bits.\nIf not specified, the volume defaultMode will be used.\nThis might be in conflict with other options that affect the file\nmode, like fsGroup, and the result can be other mode bits set.", + ) + path: str = Field( + ..., + description="path is the relative path of the file to map the key to.\nMay not be an absolute path.\nMay not contain the path element '..'.\nMay not start with the string '..'.", + ) + + +class Secret(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + items: Optional[Sequence[Item3]] = Field( + default=None, + description="items if unspecified, each key-value pair in the Data field of the referenced\nSecret will be projected into the volume as a file whose name is the\nkey and content is the value. If specified, the listed keys will be\nprojected into the specified paths, and unlisted keys will not be\npresent. If a key is specified which is not present in the Secret,\nthe volume setup will error unless it is marked optional. Paths must be\nrelative and may not contain the '..' path or start with '..'.", + ) + name: str = Field( + default="", + description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + optional: Optional[bool] = Field( + default=None, + description="optional field specify whether the Secret or its key must be defined", + ) + + +class ServiceAccountToken(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + audience: Optional[str] = Field( + default=None, + description="audience is the intended audience of the token. A recipient of a token\nmust identify itself with an identifier specified in the audience of the\ntoken, and otherwise should reject the token. The audience defaults to the\nidentifier of the apiserver.", + ) + expirationSeconds: Optional[int] = Field( + default=None, + description="expirationSeconds is the requested duration of validity of the service\naccount token. As the token approaches expiration, the kubelet volume\nplugin will proactively rotate the service account token. The kubelet will\nstart trying to rotate the token if the token is older than 80 percent of\nits time to live or if the token is older than 24 hours.Defaults to 1 hour\nand must be at least 10 minutes.", + ) + path: str = Field( + ..., + description="path is the path relative to the mount point of the file to project the\ntoken into.", + ) + + +class Source(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + configMap: Optional[ConfigMap1] = Field( + default=None, + description="configMap information about the configMap data to project", + ) + downwardAPI: Optional[DownwardAPI] = Field( + default=None, + description="downwardAPI information about the downwardAPI data to project", + ) + secret: Optional[Secret] = Field( + default=None, description="secret information about the secret data to project" + ) + serviceAccountToken: Optional[ServiceAccountToken] = Field( + default=None, + description="serviceAccountToken is information about the serviceAccountToken data to project", + ) + + +class Projected(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + defaultMode: Optional[int] = Field( + default=None, + description="defaultMode are the mode bits used to set permissions on created files by default.\nMust be an octal value between 0000 and 0777 or a decimal value between 0 and 511.\nYAML accepts both octal and decimal values, JSON requires decimal values for mode bits.\nDirectories within the path are not affected by this setting.\nThis might be in conflict with other options that affect the file\nmode, like fsGroup, and the result can be other mode bits set.", + ) + sources: Optional[Sequence[Source]] = Field( + default=None, + description="sources is the list of volume projections. Each entry in this list\nhandles one source.", + ) + + +class Secret1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + defaultMode: Optional[int] = Field( + default=None, + description="defaultMode is Optional: mode bits used to set permissions on created files by default.\nMust be an octal value between 0000 and 0777 or a decimal value between 0 and 511.\nYAML accepts both octal and decimal values, JSON requires decimal values\nfor mode bits. Defaults to 0644.\nDirectories within the path are not affected by this setting.\nThis might be in conflict with other options that affect the file\nmode, like fsGroup, and the result can be other mode bits set.", + ) + items: Optional[Sequence[Item3]] = Field( + default=None, + description="items If unspecified, each key-value pair in the Data field of the referenced\nSecret will be projected into the volume as a file whose name is the\nkey and content is the value. If specified, the listed keys will be\nprojected into the specified paths, and unlisted keys will not be\npresent. If a key is specified which is not present in the Secret,\nthe volume setup will error unless it is marked optional. Paths must be\nrelative and may not contain the '..' path or start with '..'.", + ) + optional: Optional[bool] = Field( + default=None, + description="optional field specify whether the Secret or its keys must be defined", + ) + secretName: Optional[str] = Field( + default=None, + description="secretName is the name of the secret in the pod's namespace to use.\nMore info: https://kubernetes.io/docs/concepts/storage/volumes#secret", + ) + + +class Volume(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + configMap: Optional[ConfigMap] = Field( + default=None, + description="configMap represents a configMap that should populate this volume", + ) + csi: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-volumes-csi", + ) + emptyDir: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-volumes-emptydir", + ) + hostPath: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-volumes-hostpath", + ) + image: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-volumes-image", + ) + name: str = Field( + ..., + description="name of the volume.\nMust be a DNS_LABEL and unique within the pod.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", + ) + persistentVolumeClaim: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-persistent-volume-claim", + ) + projected: Optional[Projected] = Field( + default=None, + description="projected items for all in one resources secrets, configmaps, and downward API", + ) + secret: Optional[Secret1] = Field( + default=None, + description="secret represents a secret that should populate this volume.\nMore info: https://kubernetes.io/docs/concepts/storage/volumes#secret", + ) + + +class Spec1(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + affinity: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-affinity", + ) + automountServiceAccountToken: Optional[bool] = Field( + default=None, + description="AutomountServiceAccountToken indicates whether a service account token should be automatically mounted.", + ) + containerConcurrency: Optional[int] = Field( + default=None, + description="ContainerConcurrency specifies the maximum allowed in-flight (concurrent)\nrequests per container of the Revision. Defaults to `0` which means\nconcurrency to the application is not limited, and the system decides the\ntarget concurrency for the autoscaler.", + ) + containers: Sequence[Container] = Field( + ..., + description="List of containers belonging to the pod.\nContainers cannot currently be added or removed.\nThere must be at least one container in a Pod.\nCannot be updated.", + ) + dnsConfig: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-dnsconfig", + ) + dnsPolicy: Optional[str] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-dnspolicy", + ) + enableServiceLinks: Optional[bool] = Field( + default=None, + description="EnableServiceLinks indicates whether information aboutservices should be injected into pod's environment variables, matching the syntax of Docker links. Optional: Knative defaults this to false.", + ) + hostAliases: Optional[Sequence[Mapping[str, Any]]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-hostaliases", + ) + hostIPC: Optional[bool] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-hostipc", + ) + hostNetwork: Optional[bool] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-hostnetwork", + ) + hostPID: Optional[bool] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-hostpid", + ) + idleTimeoutSeconds: Optional[int] = Field( + default=None, + description="IdleTimeoutSeconds is the maximum duration in seconds a request will be allowed\nto stay open while not receiving any bytes from the user's application. If\nunspecified, a system default will be provided.", + ) + imagePullSecrets: Optional[Sequence[ImagePullSecret]] = Field( + default=None, + description="ImagePullSecrets is an optional list of references to secrets in the same namespace to use for pulling any of the images used by this PodSpec.\nIf specified, these secrets will be passed to individual puller implementations for them to use.\nMore info: https://kubernetes.io/docs/concepts/containers/images#specifying-imagepullsecrets-on-a-pod", + ) + initContainers: Optional[Sequence[Mapping[str, Any]]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-init-containers", + ) + nodeSelector: Optional[Mapping[str, str]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-nodeselector", + ) + priorityClassName: Optional[str] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-priorityclassname", + ) + responseStartTimeoutSeconds: Optional[int] = Field( + default=None, + description="ResponseStartTimeoutSeconds is the maximum duration in seconds that the request\nrouting layer will wait for a request delivered to a container to begin\nsending any network traffic.", + ) + runtimeClassName: Optional[str] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-runtimeclassname", + ) + schedulerName: Optional[str] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-schedulername", + ) + securityContext: Optional[Mapping[str, Any]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-securitycontext", + ) + serviceAccountName: Optional[str] = Field( + default=None, + description="ServiceAccountName is the name of the ServiceAccount to use to run this pod.\nMore info: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/", + ) + shareProcessNamespace: Optional[bool] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-shareprocessnamespace", + ) + timeoutSeconds: Optional[int] = Field( + default=None, + description="TimeoutSeconds is the maximum duration in seconds that the request instance\nis allowed to respond to a request. If unspecified, a system default will\nbe provided.", + ) + tolerations: Optional[Sequence[Mapping[str, Any]]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-tolerations", + ) + topologySpreadConstraints: Optional[Sequence[Mapping[str, Any]]] = Field( + default=None, + description="This is accessible behind a feature flag - kubernetes.podspec-topologyspreadconstraints", + ) + volumes: Optional[Sequence[Volume]] = Field( + default=None, + description="List of volumes that can be mounted by containers belonging to the pod.\nMore info: https://kubernetes.io/docs/concepts/storage/volumes", + ) + + +class Template(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + metadata: Optional[Metadata] = None + spec: Optional[Spec1] = Field( + default=None, + description="RevisionSpec holds the desired state of the Revision (from the client).", + ) + + +class TrafficItem(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + configurationName: Optional[str] = Field( + default=None, + description='ConfigurationName of a configuration to whose latest revision we will send\nthis portion of traffic. When the "status.latestReadyRevisionName" of the\nreferenced configuration changes, we will automatically migrate traffic\nfrom the prior "latest ready" revision to the new one. This field is never\nset in Route\'s status, only its spec. This is mutually exclusive with\nRevisionName.', + ) + latestRevision: Optional[bool] = Field( + default=None, + description="LatestRevision may be optionally provided to indicate that the latest\nready Revision of the Configuration should be used for this traffic\ntarget. When provided LatestRevision must be true if RevisionName is\nempty; it must be false when RevisionName is non-empty.", + ) + percent: Optional[int] = Field( + default=None, + description="Percent indicates that percentage based routing should be used and\nthe value indicates the percent of traffic that is be routed to this\nRevision or Configuration. `0` (zero) mean no traffic, `100` means all\ntraffic.\nWhen percentage based routing is being used the follow rules apply:\n- the sum of all percent values must equal 100\n- when not specified, the implied value for `percent` is zero for\n that particular Revision or Configuration", + ) + revisionName: Optional[str] = Field( + default=None, + description="RevisionName of a specific revision to which to send this portion of\ntraffic. This is mutually exclusive with ConfigurationName.", + ) + tag: Optional[str] = Field( + default=None, + description="Tag is optionally used to expose a dedicated url for referencing\nthis target exclusively.", + ) + url: Optional[str] = Field( + default=None, + description="URL displays the URL for accessing named traffic targets. URL is displayed in\nstatus, and is disallowed on spec. URL must contain a scheme (e.g. http://) and\na hostname, but may not contain anything else (e.g. basic auth, url path, etc.)", + ) + + +class Spec(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + template: Optional[Template] = Field( + default=None, + description="Template holds the latest specification for the Revision to be stamped out.", + ) + traffic: Optional[Sequence[TrafficItem]] = Field( + default=None, + description="Traffic specifies how to distribute traffic over a collection of\nrevisions and configurations.", + ) + + +class Address(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + CACerts: Optional[str] = Field( + default=None, + description="CACerts is the Certification Authority (CA) certificates in PEM format\naccording to https://www.rfc-editor.org/rfc/rfc7468.", + ) + audience: Optional[str] = Field( + default=None, description="Audience is the OIDC audience for this address." + ) + name: Optional[str] = Field( + default=None, description="Name is the name of the address." + ) + url: Optional[str] = None + + +class Condition(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + lastTransitionTime: Optional[str] = Field( + default=None, + description="LastTransitionTime is the last time the condition transitioned from one status to another.\nWe use VolatileTime in place of metav1.Time to exclude this from creating equality.Semantic\ndifferences (all other things held constant).", + ) + message: Optional[str] = Field( + default=None, + description="A human readable message indicating details about the transition.", + ) + reason: Optional[str] = Field( + default=None, description="The reason for the condition's last transition." + ) + severity: Optional[str] = Field( + default=None, + description="Severity with which to treat failures of this type of condition.\nWhen this is not specified, it defaults to Error.", + ) + status: str = Field( + ..., description="Status of the condition, one of True, False, Unknown." + ) + type: str = Field(..., description="Type of condition.") + + +class Status(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + address: Optional[Address] = Field( + default=None, + description="Address holds the information needed for a Route to be the target of an event.", + ) + annotations: Optional[Mapping[str, str]] = Field( + default=None, + description="Annotations is additional Status fields for the Resource to save some\nadditional State as well as convey more information to the user. This is\nroughly akin to Annotations on any k8s resource, just the reconciler conveying\nricher information outwards.", + ) + conditions: Optional[Sequence[Condition]] = Field( + default=None, + description="Conditions the latest available observations of a resource's current state.", + ) + latestCreatedRevisionName: Optional[str] = Field( + default=None, + description="LatestCreatedRevisionName is the last revision that was created from this\nConfiguration. It might not be ready yet, for that use LatestReadyRevisionName.", + ) + latestReadyRevisionName: Optional[str] = Field( + default=None, + description='LatestReadyRevisionName holds the name of the latest Revision stamped out\nfrom this Configuration that has had its "Ready" condition become "True".', + ) + observedGeneration: Optional[int] = Field( + default=None, + description="ObservedGeneration is the 'Generation' of the Service that\nwas last processed by the controller.", + ) + traffic: Optional[Sequence[TrafficItem]] = Field( + default=None, + description="Traffic holds the configured traffic distribution.\nThese entries will always contain RevisionName references.\nWhen ConfigurationName appears in the spec, this will hold the\nLatestReadyRevisionName that we last observed.", + ) + url: Optional[str] = Field( + default=None, + description="URL holds the url that will distribute traffic over the provided traffic targets.\nIt generally has the form http[s]://{route-name}.{route-namespace}.{cluster-level-suffix}", + ) + + +class Model(BaseCRD): + model_config = ConfigDict( + extra="allow", + ) + apiVersion: Optional[str] = Field( + default=None, + description="APIVersion defines the versioned schema of this representation of an object.\nServers should convert recognized schemas to the latest internal value, and\nmay reject unrecognized values.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + ) + kind: Optional[str] = Field( + default=None, + description="Kind is a string value representing the REST resource this object represents.\nServers may infer this from the endpoint the client submits requests to.\nCannot be updated.\nIn CamelCase.\nMore info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + ) + metadata: Optional[Mapping[str, Any]] = None + spec: Optional[Spec] = Field( + default=None, + description='ServiceSpec represents the configuration for the Service object.\nA Service\'s specification is the union of the specifications for a Route\nand Configuration. The Service restricts what can be expressed in these\nfields, e.g. the Route must reference the provided Configuration;\nhowever, these limitations also enable friendlier defaulting,\ne.g. Route never needs a Configuration name, and may be defaulted to\nthe appropriate "run latest" spec.', + ) + status: Optional[Status] = Field( + default=None, + description="ServiceStatus represents the Status stanza of the Service resource.", + ) diff --git a/components/renku_data_services/renku_apps/crs.py b/components/renku_data_services/renku_apps/crs.py new file mode 100644 index 000000000..75306f01d --- /dev/null +++ b/components/renku_data_services/renku_apps/crs.py @@ -0,0 +1,31 @@ +"""Custom resource definition with proper names from the autogenerated code.""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field +from renku_data_services.renku_apps.cr_knative_service import Model as _KnativeService + + +class Metadata(BaseModel): + """Basic k8s metadata spec.""" + + model_config = ConfigDict( + # Do not exclude unknown properties. + extra="allow", + ) + + name: str + namespace: str | None = None + labels: dict[str, str] = Field(default_factory=dict) + annotations: dict[str, str] = Field(default_factory=dict) + uid: str | None = None + creationTimestamp: datetime | None = None + deletionTimestamp: datetime | None = None + + +class KnativeService(_KnativeService): + """Knative Service.""" + + kind: str = "Service" + apiVersion: str = "serving.knative.dev/v1" + metadata: Metadata # type: ignore[assignment] From b197e1f2bac71e087a39ba1fe351cbbe6edce9bf Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 18 May 2026 11:21:23 +0200 Subject: [PATCH 03/28] reshape api spec for /apps endpoints --- Makefile | 1 + .../renku_apps/api.spec.yaml | 92 +++++++++---------- .../renku_data_services/renku_apps/apispec.py | 75 +++++++++++++++ .../renku_apps/apispec_base.py | 23 +++++ 4 files changed, 142 insertions(+), 49 deletions(-) create mode 100644 components/renku_data_services/renku_apps/apispec.py create mode 100644 components/renku_data_services/renku_apps/apispec_base.py diff --git a/Makefile b/Makefile index 3c3475235..8f2641bc2 100644 --- a/Makefile +++ b/Makefile @@ -56,6 +56,7 @@ API_SPECS := \ components/renku_data_services/notifications/apispec.py \ components/renku_data_services/capacity_reservation/apispec.py \ components/renku_data_services/resource_usage/apispec.py \ + components/renku_data_services/renku_apps/apispec.py \ components/renku_data_services/authn/api/apispec.py schemas: ${API_SPECS} ## Generate pydantic classes from apispec yaml files diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index f23d81413..bceffaade 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -7,49 +7,49 @@ info: servers: - url: /api/data paths: - /projects/{project_id}/app: + /apps: post: - summary: Create an app - parameters: - - in: path - name: project_id - required: true - schema: - $ref: "#/components/schemas/Ulid" - description: The ID of the project to create the app for. + summary: Launch a new app requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/AppPost" + $ref: "#/components/schemas/AppPostRequest" responses: "201": - description: App created successfully + description: The app was created + content: + application/json: + schema: + $ref: "#/components/schemas/AppResponse" + "200": + description: The app already exists content: application/json: schema: - $ref: "#/components/schemas/App" + $ref: "#/components/schemas/AppResponse" default: $ref: "#/components/responses/Error" tags: - apps + /apps/{app_name}: get: summary: Retrieve an app parameters: - in: path - name: project_id + name: app_name required: true schema: - $ref: "#/components/schemas/Ulid" - description: The ID of the project to retrieve the app for. + $ref: "#/components/schemas/AppName" + description: The name of the app to retrieve responses: "200": - description: The app for the specified project. + description: The app for the given name content: application/json: schema: - $ref: "#/components/schemas/App" + $ref: "#/components/schemas/AppResponse" default: $ref: "#/components/responses/Error" tags: @@ -63,60 +63,54 @@ components: maxLength: 26 pattern: "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" - AppState: + AppStatus: type: string enum: - pending - ready - failed - App: + AppName: + type: string + minLength: 5 + maxLength: 50 + pattern: "^[a-z]([-a-z0-9]*[a-z0-9])?$" + example: d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08 + + AppResponse: type: object - additionalProperties: false properties: - id: - $ref: "#/components/schemas/Ulid" - project_id: + name: + $ref: "#/components/schemas/AppName" + launcher_id: $ref: "#/components/schemas/Ulid" - image: - type: string status: - $ref: "#/components/schemas/AppState" + $ref: "#/components/schemas/AppStatus" url: type: string - created_at: - type: string - format: date-time - updated_at: + project_id: + $ref: "#/components/schemas/Ulid" + started: type: string format: date-time - created_by: + nullable: true + image: type: string + nullable: true required: - - id - - project_id - - image + - name + - launcher_id - status - url - - created_at - - updated_at - - created_by + - project_id - AppPost: + AppPostRequest: type: object - additionalProperties: false properties: - image: - type: string + launcher_id: + $ref: "#/components/schemas/Ulid" required: - - image - - AppPatch: - type: object - additionalProperties: false - properties: - image: - type: string + - launcher_id ErrorResponse: type: object diff --git a/components/renku_data_services/renku_apps/apispec.py b/components/renku_data_services/renku_apps/apispec.py new file mode 100644 index 000000000..1d0fa5281 --- /dev/null +++ b/components/renku_data_services/renku_apps/apispec.py @@ -0,0 +1,75 @@ +# generated by datamodel-codegen: +# filename: api.spec.yaml +# timestamp: 2026-05-18T09:17:07+00:00 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import Field, RootModel +from renku_data_services.renku_apps.apispec_base import BaseAPISpec + + +class AppStatus(Enum): + pending = "pending" + ready = "ready" + failed = "failed" + + +class AppResponse(BaseAPISpec): + name: str = Field( + ..., + examples=["d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08"], + max_length=50, + min_length=5, + pattern="^[a-z]([-a-z0-9]*[a-z0-9])?$", + ) + launcher_id: str = Field( + ..., + description="ULID identifier", + max_length=26, + min_length=26, + pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + ) + status: AppStatus + url: str + project_id: str = Field( + ..., + description="ULID identifier", + max_length=26, + min_length=26, + pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + ) + started: Optional[datetime] = None + image: Optional[str] = None + + +class AppPostRequest(BaseAPISpec): + launcher_id: str = Field( + ..., + description="ULID identifier", + max_length=26, + min_length=26, + pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + ) + + +class Error(BaseAPISpec): + code: int = Field(..., examples=[1404], gt=0) + detail: Optional[str] = Field( + None, examples=["A more detailed optional message showing what the problem was"] + ) + message: str = Field( + ..., examples=["Something went wrong - please try again later"] + ) + trace_id: Optional[str] = Field( + None, + description="Sentry trace ID for linking to corresponding log entries", + examples=["ac93950e9e114a55c67fb8e5ef519bbe"], + ) + + +class ErrorResponse(BaseAPISpec): + error: Error diff --git a/components/renku_data_services/renku_apps/apispec_base.py b/components/renku_data_services/renku_apps/apispec_base.py new file mode 100644 index 000000000..de2de247d --- /dev/null +++ b/components/renku_data_services/renku_apps/apispec_base.py @@ -0,0 +1,23 @@ +"""Base models for API specifications.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, field_validator +from ulid import ULID + + +class BaseAPISpec(BaseModel): + """Base API specification.""" + + # Enables orm mode for pydantic. + model_config = ConfigDict( + from_attributes=True, + ) + + @field_validator("*", mode="before", check_fields=False) + @classmethod + def serialize_ulid(cls, value: Any) -> Any: + """Handle ULIDs.""" + if isinstance(value, ULID): + return str(value) + return value From 081b4f67f6433b7f027dc9820b01e6326e6eeef6 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 19 May 2026 09:58:24 +0200 Subject: [PATCH 04/28] fix quantity types and collapse models --- .../renku_data_services/renku_apps/crs.py | 33 +++++++++++++++++++ .../renku_data_services/renku_apps/models.py | 29 +++++----------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/components/renku_data_services/renku_apps/crs.py b/components/renku_data_services/renku_apps/crs.py index 75306f01d..52de0a318 100644 --- a/components/renku_data_services/renku_apps/crs.py +++ b/components/renku_data_services/renku_apps/crs.py @@ -1,9 +1,15 @@ """Custom resource definition with proper names from the autogenerated code.""" +from collections.abc import Mapping from datetime import datetime from pydantic import BaseModel, ConfigDict, Field +from renku_data_services.renku_apps.cr_knative_service import Limits as _Limits +from renku_data_services.renku_apps.cr_knative_service import Limits1 as LimitsStr from renku_data_services.renku_apps.cr_knative_service import Model as _KnativeService +from renku_data_services.renku_apps.cr_knative_service import Requests as _Requests +from renku_data_services.renku_apps.cr_knative_service import Requests1 as RequestsStr +from renku_data_services.renku_apps.cr_knative_service import Resources as _Resources class Metadata(BaseModel): @@ -23,6 +29,33 @@ class Metadata(BaseModel): deletionTimestamp: datetime | None = None +class Requests(_Requests): + """Resource requests of type integer.""" + + root: int + + +class Limits(_Limits): + """Resource limits of type integer.""" + + root: int + + +class Resources(_Resources): + """Resource requests and limits spec. + + Overriding these is necessary because of + https://docs.pydantic.dev/2.11/errors/validation_errors/#string_type. + An integer model cannot have a regex pattern for validation in pydantic. + But the code generation applies the pattern constraint to both the int and string variations + of the fields. But the int variation runs and blows up at runtime only when an int is passed + for validation. + """ + + limits: Mapping[str, LimitsStr | Limits] | None = None + requests: Mapping[str, RequestsStr | Requests] | None = None + + class KnativeService(_KnativeService): """Knative Service.""" diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index 299d8febe..72e26d5ee 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -7,7 +7,7 @@ from ulid import ULID -class AppState(StrEnum): +class AppStatus(StrEnum): """The status of an app.""" PENDING = "pending" @@ -16,26 +16,13 @@ class AppState(StrEnum): @dataclass(frozen=True, eq=True, kw_only=True) -class UnsavedApp: - """An unsaved app.""" +class App: + """An App.""" + name: str + launcher_id: ULID project_id: ULID - image: str - created_by: str - status: AppState = AppState.PENDING - - -@dataclass(frozen=True, eq=True, kw_only=True) -class App(UnsavedApp): - """An app stored in the database.""" - - id: ULID - created_at: datetime - updated_at: datetime - - -@dataclass(frozen=True, eq=True, kw_only=True) -class AppPatch: - """A patch for an existing app.""" - + status: AppStatus + url: str + started: datetime | None = None image: str | None = None From fb608f8398b48854e27176f007a7a61ba12a5188 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 19 May 2026 14:34:10 +0200 Subject: [PATCH 05/28] add k8s client --- .../renku_apps/k8s_client.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 components/renku_data_services/renku_apps/k8s_client.py diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py new file mode 100644 index 000000000..6918f0e91 --- /dev/null +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -0,0 +1,72 @@ +"""K8s client wrapper for Renku apps""" + +from collections.abc import AsyncGenerator + +from renku_data_services.session.models import SessionLauncher +from renku_data_services.crc.db import ClusterRepository +from renku_data_services.k8s.clients import K8sClusterClientsPool +from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, ClusterId +from renku_data_services.k8s.models import GVK, K8sObjectMeta +from renku_data_services.renku_apps.crs import KnativeService + +KNATIVE_SERVICE_GVK = GVK(kind="Service", group="serving.knative.dev", version="v1") + + +def _generate_app_name(session_launcher: SessionLauncher) -> str: + """Generate a name for an app.""" + return f"app-{session_launcher.id}".lower()[:63] + + +class RenkuAppsK8sClient: + """K8s client for Renku apps operations""" + + def __init__(self, client: K8sClusterClientsPool, cluster_repo: ClusterRepository) -> None: + self.__client = client + self.__cluster_repo = cluster_repo + + async def create_app_deployment(self, session_launcher: SessionLauncher) -> str: + """Create a deployment for the given app and return the deployment name""" + cluster_id: ClusterId = DEFAULT_K8S_CLUSTER + cluster = await self.__client.cluster_by_id(cluster_id) + app_name = _generate_app_name(session_launcher) + manifest = _build_app_deployment_manifest(session_launcher, app_name) + meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) + await self.__client.create(meta.with_manifest(manifest), refresh=False) + return app_name + + async def get_app_deployment(self, app_name: str) -> KnativeService | None: + """Get the deployment for the given app name. NOT IMPLEMENTED""" + raise NotImplementedError("Getting app deployment is not implemented yet") + + async def delete_app_deployment(self, app_name: str) -> None: + """Delete the deployment for the given app name. NOT IMPLEMENTED""" + raise NotImplementedError("Deleting app deployment is not implemented yet") + + async def list_app_deployments(self) -> AsyncGenerator[KnativeService, None]: + """List all app deployments. NOT IMPLEMENTED""" + raise NotImplementedError("Listing app deployments is not implemented yet") + + async def update_app_deployment(self, app_name: str, session_launcher: SessionLauncher) -> KnativeService: + """Update the deployment for the given app name. NOT IMPLEMENTED""" + raise NotImplementedError("Updating app deployment is not implemented yet") + + +def _build_app_deployment_manifest(session_launcher: SessionLauncher, app_name: str) -> KnativeService: + """Build an app deployment manifest for the given app and session launcher""" + return KnativeService( + apiVersion="serving.knative.dev/v1", + kind="Service", + metadata={"name": app_name}, + spec={ + "template": { + "spec": { + "containers": [ + { + "image": "docker.io/library/nginx:latest", + "ports": [{"containerPort": 80}], + } + ] + } + } + }, + ) From 85ad1ee66c7fe4e468dc51e7272c656ccb7e58f8 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 19 May 2026 16:47:58 +0200 Subject: [PATCH 06/28] add knative service converter and make url optional --- .../renku_data_services/renku_apps/core.py | 37 +++++++++++++++++++ .../renku_data_services/renku_apps/models.py | 4 +- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 components/renku_data_services/renku_apps/core.py diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py new file mode 100644 index 000000000..ebea11450 --- /dev/null +++ b/components/renku_data_services/renku_apps/core.py @@ -0,0 +1,37 @@ +"""Business logic for Renku apps.""" + +from renku_data_services.renku_apps import models +from renku_data_services.renku_apps.crs import KnativeService +from renku_data_services.session.models import SessionLauncher + + +def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService) -> models.App: + """Convert a Knative service to an app.""" + + app_service_url = knative_service.status.url if knative_service.status else None + + return models.App( + name=knative_service.metadata.name, + launcher_id=session_launcher.id, + project_id=session_launcher.project_id, + status=_project_app_status(knative_service), + url=app_service_url, + started=knative_service.started, + image=knative_service.image, + ) + + +def _project_app_status(knative_service: KnativeService) -> models.AppStatus: + """Convert a Knative service statuses to an app status.""" + if not knative_service.status or not knative_service.status.conditions: + return models.AppStatus("pending") + + statuses = {condition.type: condition.status for condition in knative_service.status.conditions} + status = statuses.get("Ready") + + if status == "True": + return models.AppStatus("ready") + if status == "False": + return models.AppStatus("failed") + + return models.AppStatus("pending") diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index 72e26d5ee..82f1791d5 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -1,4 +1,4 @@ -"""Models for apps.""" +"""Models for Renku apps.""" from dataclasses import dataclass from datetime import datetime @@ -23,6 +23,6 @@ class App: launcher_id: ULID project_id: ULID status: AppStatus - url: str + url: str | None = None started: datetime | None = None image: str | None = None From f5b5b5c1cac0a1a9841f19d70ed80c472fd417e8 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 21 May 2026 10:46:06 +0200 Subject: [PATCH 07/28] make url nullable --- .../renku_apps/api.spec.yaml | 2 +- .../renku_data_services/renku_apps/apispec.py | 4 +- .../renku_apps/blueprints.py | 27 ++++++++++++++ .../renku_data_services/renku_apps/core.py | 37 +++++++++++++------ .../renku_data_services/renku_apps/models.py | 14 +++++++ 5 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 components/renku_data_services/renku_apps/blueprints.py diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index bceffaade..5049072b6 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -88,6 +88,7 @@ components: $ref: "#/components/schemas/AppStatus" url: type: string + nullable: true project_id: $ref: "#/components/schemas/Ulid" started: @@ -101,7 +102,6 @@ components: - name - launcher_id - status - - url - project_id AppPostRequest: diff --git a/components/renku_data_services/renku_apps/apispec.py b/components/renku_data_services/renku_apps/apispec.py index 1d0fa5281..f2e0aef1f 100644 --- a/components/renku_data_services/renku_apps/apispec.py +++ b/components/renku_data_services/renku_apps/apispec.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2026-05-18T09:17:07+00:00 +# timestamp: 2026-05-21T08:38:42+00:00 from __future__ import annotations @@ -34,7 +34,7 @@ class AppResponse(BaseAPISpec): pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", ) status: AppStatus - url: str + url: Optional[str] = None project_id: str = Field( ..., description="ULID identifier", diff --git a/components/renku_data_services/renku_apps/blueprints.py b/components/renku_data_services/renku_apps/blueprints.py new file mode 100644 index 000000000..a66fd2f8f --- /dev/null +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -0,0 +1,27 @@ +"""Renku apps blueprints.""" + +from dataclasses import dataclass + +from sanic import HTTPResponse, Request +from sanic.response import JSONResponse +from sanic_ext import validate + +from renku_data_services.base_api.auth import ( + authenticate, +) + +from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint +from renku_data_services.renku_apps import apispec + +@dataclass(kw_only=True) +class RenkuAppBP: + """Handlers for renku apps.""" + + def post(self) -> BlueprintFactoryResponse: + """Create a new renku app.""" + + @authenticate(self.authenticator) + @validate(json=apispec.AppPostRequest) + async def _post(_: Request, user: base_models.APIUser, body: apispec.AppPostRequest) -> JSONResponse: + + return "/apps", ["POST"], _post diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index ebea11450..71b4bf9ba 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -1,13 +1,15 @@ """Business logic for Renku apps.""" +from datetime import datetime + from renku_data_services.renku_apps import models +from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService from renku_data_services.session.models import SessionLauncher def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService) -> models.App: """Convert a Knative service to an app.""" - app_service_url = knative_service.status.url if knative_service.status else None return models.App( @@ -16,22 +18,33 @@ def knative_service_to_app(session_launcher: SessionLauncher, knative_service: K project_id=session_launcher.project_id, status=_project_app_status(knative_service), url=app_service_url, - started=knative_service.started, - image=knative_service.image, + started=_started_at(knative_service), + image=session_launcher.environment.container_image, ) -def _project_app_status(knative_service: KnativeService) -> models.AppStatus: - """Convert a Knative service statuses to an app status.""" - if not knative_service.status or not knative_service.status.conditions: - return models.AppStatus("pending") +def _ready_condition(knative_service: KnativeService) -> Condition | None: + """Get the Ready condition from a Knative service, or None if it doesn't exist.""" + if knative_service.status is None or not knative_service.status.conditions: + return None + return next((c for c in knative_service.status.conditions if c.type == "Ready"), None) + - statuses = {condition.type: condition.status for condition in knative_service.status.conditions} - status = statuses.get("Ready") +def _started_at(knative_service: KnativeService) -> datetime | None: + """Get the time the Knative service became Ready, or None if not yet ready.""" + ready = _ready_condition(knative_service) + if ready is None or ready.status != "True" or ready.lastTransitionTime is None: + return None + return datetime.fromisoformat(ready.lastTransitionTime) - if status == "True": + +def _project_app_status(knative_service: KnativeService) -> models.AppStatus: + """Convert a Knative service's Ready condition into an app status.""" + ready = _ready_condition(knative_service) + if ready is None: + return models.AppStatus("pending") + if ready.status == "True": return models.AppStatus("ready") - if status == "False": + if ready.status == "False": return models.AppStatus("failed") - return models.AppStatus("pending") diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index 82f1791d5..7bd667485 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -6,6 +6,8 @@ from ulid import ULID +from renku_data_services.renku_apps import apispec + class AppStatus(StrEnum): """The status of an app.""" @@ -26,3 +28,15 @@ class App: url: str | None = None started: datetime | None = None image: str | None = None + + def as_apispec(self) -> apispec.AppResponse: + """Convert the app to an API response model.""" + return apispec.AppResponse( + name=self.name, + launcher_id=str(self.launcher_id), + status=self.status, + url=self.url, + project_id=str(self.project_id), + started=self.started, + image=self.image, + ) From 83e5521d734dd3b04ded99b9cf4da904a1af82ae Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 22 May 2026 14:22:51 +0200 Subject: [PATCH 08/28] add /apps POST and GET endpoints --- bases/renku_data_services/data_api/app.py | 8 ++ .../data_api/dependencies.py | 14 +++ .../renku_apps/blueprints.py | 35 ++++-- .../renku_apps/cr_knative_service.py | 112 +++++------------- .../renku_data_services/renku_apps/crs.py | 22 ++++ .../renku_apps/k8s_client.py | 72 ++++++----- .../renku_data_services/renku_apps/models.py | 2 +- .../renku_apps/repository.py | 53 +++++++++ pyproject.toml | 2 + 9 files changed, 198 insertions(+), 122 deletions(-) create mode 100644 components/renku_data_services/renku_apps/repository.py diff --git a/bases/renku_data_services/data_api/app.py b/bases/renku_data_services/data_api/app.py index cb2a07fec..8f64c3cee 100644 --- a/bases/renku_data_services/data_api/app.py +++ b/bases/renku_data_services/data_api/app.py @@ -32,6 +32,7 @@ from renku_data_services.notifications.blueprints import NotificationsBP from renku_data_services.platform.blueprints import PlatformConfigBP, PlatformUrlRedirectBP from renku_data_services.project.blueprints import ProjectsBP, ProjectSessionSecretBP +from renku_data_services.renku_apps.blueprints import RenkuAppBP from renku_data_services.repositories.blueprints import RepositoriesBP from renku_data_services.resource_usage.blueprints import ResourceUsageBP from renku_data_services.search.blueprints import SearchBP @@ -177,6 +178,12 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: authenticator=dm.authenticator, metrics=dm.metrics, ) + renku_apps = RenkuAppBP( + name="renku_apps", + url_prefix=url_prefix, + apps_repo=dm.apps_repo, + authenticator=dm.authenticator, + ) builds = ( BuildsBP( name="builds", @@ -333,6 +340,7 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: group.blueprint(), session_environments.blueprint(), session_launchers.blueprint(), + renku_apps.blueprint(), oauth2_clients.blueprint(), oauth2_connections.blueprint(), repositories.blueprint(), diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index 093a1574f..319092d10 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -17,6 +17,7 @@ import renku_data_services.data_connectors import renku_data_services.notifications import renku_data_services.platform +import renku_data_services.renku_apps import renku_data_services.repositories import renku_data_services.search import renku_data_services.storage @@ -67,6 +68,8 @@ ProjectRepository, ProjectSessionSecretRepository, ) +from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient +from renku_data_services.renku_apps.repository import RenkuAppsRepository from renku_data_services.repositories.db import GitRepositoriesRepository from renku_data_services.resource_usage.core import ResourceUsageService from renku_data_services.resource_usage.db import ResourceRequestsRepo @@ -145,6 +148,8 @@ class DependencyManager: search_updates_repo: SearchUpdatesRepo search_reprovisioning: SearchReprovision session_repo: SessionRepository + apps_k8s_client: RenkuAppsK8sClient + apps_repo: RenkuAppsRepository user_preferences_repo: UserPreferencesRepository kc_user_repo: KcUserRepo low_level_user_secrets_repo: LowLevelUserSecretsRepo @@ -196,6 +201,7 @@ def load_apispec() -> dict[str, Any]: renku_data_services.project.__file__, renku_data_services.namespace.__file__, renku_data_services.session.__file__, + renku_data_services.renku_apps.__file__, renku_data_services.connected_services.__file__, renku_data_services.repositories.__file__, renku_data_services.notebooks.__file__, @@ -384,6 +390,12 @@ def from_env(cls) -> DependencyManager: builds_config=config.builds, git_repositories_repo=git_repositories_repo, ) + apps_k8s_client = RenkuAppsK8sClient(client=client, cluster_repo=cluster_repo) + apps_repo = RenkuAppsRepository( + authz=authz, + session_repo=session_repo, + k8s_client=apps_k8s_client, + ) project_migration_repo = ProjectMigrationRepository( session_maker=config.db.async_session_maker, authz=authz, @@ -484,6 +496,8 @@ def from_env(cls) -> DependencyManager: project_session_secret_repo=project_session_secret_repo, group_repo=group_repo, session_repo=session_repo, + apps_k8s_client=apps_k8s_client, + apps_repo=apps_repo, user_preferences_repo=user_preferences_repo, kc_user_repo=kc_user_repo, user_secrets_repo=user_secrets_repo, diff --git a/components/renku_data_services/renku_apps/blueprints.py b/components/renku_data_services/renku_apps/blueprints.py index a66fd2f8f..75a4bdf07 100644 --- a/components/renku_data_services/renku_apps/blueprints.py +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -2,26 +2,43 @@ from dataclasses import dataclass -from sanic import HTTPResponse, Request -from sanic.response import JSONResponse +from sanic import Request +from sanic.response import JSONResponse, json from sanic_ext import validate +from ulid import ULID -from renku_data_services.base_api.auth import ( - authenticate, -) - +from renku_data_services import base_models +from renku_data_services.base_api.auth import authenticate, only_authenticated from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint from renku_data_services.renku_apps import apispec +from renku_data_services.renku_apps.repository import RenkuAppsRepository + @dataclass(kw_only=True) -class RenkuAppBP: - """Handlers for renku apps.""" +class RenkuAppBP(CustomBlueprint): + """Handlers for Renku apps.""" + + apps_repo: RenkuAppsRepository + authenticator: base_models.Authenticator def post(self) -> BlueprintFactoryResponse: - """Create a new renku app.""" + """Launch a new app from a session launcher.""" @authenticate(self.authenticator) + @only_authenticated @validate(json=apispec.AppPostRequest) async def _post(_: Request, user: base_models.APIUser, body: apispec.AppPostRequest) -> JSONResponse: + app = await self.apps_repo.create_app(user=user, launcher_id=ULID.from_str(body.launcher_id)) + return json(app.as_apispec().model_dump(exclude_none=True, mode="json"), status=201) return "/apps", ["POST"], _post + + def get_one(self) -> BlueprintFactoryResponse: + """Retrieve an app by name.""" + + @authenticate(self.authenticator) + async def _get_one(_: Request, user: base_models.APIUser, app_name: str) -> JSONResponse: + app = await self.apps_repo.get_app(user=user, app_name=app_name) + return json(app.as_apispec().model_dump(exclude_none=True, mode="json")) + + return "/apps/", ["GET"], _get_one diff --git a/components/renku_data_services/renku_apps/cr_knative_service.py b/components/renku_data_services/renku_apps/cr_knative_service.py index 6fefa0329..3f905b047 100644 --- a/components/renku_data_services/renku_apps/cr_knative_service.py +++ b/components/renku_data_services/renku_apps/cr_knative_service.py @@ -58,9 +58,7 @@ class ValueFrom(BaseCRD): model_config = ConfigDict( extra="allow", ) - configMapKeyRef: Optional[ConfigMapKeyRef] = Field( - default=None, description="Selects a key of a ConfigMap." - ) + configMapKeyRef: Optional[ConfigMapKeyRef] = Field(default=None, description="Selects a key of a ConfigMap.") fieldRef: Optional[Mapping[str, Any]] = Field( default=None, description="This is accessible behind a feature flag - kubernetes.podspec-fieldref", @@ -100,9 +98,7 @@ class ConfigMapRef(BaseCRD): default="", description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", ) - optional: Optional[bool] = Field( - default=None, description="Specify whether the ConfigMap must be defined" - ) + optional: Optional[bool] = Field(default=None, description="Specify whether the ConfigMap must be defined") class SecretRef(BaseCRD): @@ -113,25 +109,19 @@ class SecretRef(BaseCRD): default="", description="Name of the referent.\nThis field is effectively required, but due to backwards compatibility is\nallowed to be empty. Instances of this type with an empty value here are\nalmost certainly wrong.\nMore info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names", ) - optional: Optional[bool] = Field( - default=None, description="Specify whether the Secret must be defined" - ) + optional: Optional[bool] = Field(default=None, description="Specify whether the Secret must be defined") class EnvFromItem(BaseCRD): model_config = ConfigDict( extra="allow", ) - configMapRef: Optional[ConfigMapRef] = Field( - default=None, description="The ConfigMap to select from" - ) + configMapRef: Optional[ConfigMapRef] = Field(default=None, description="The ConfigMap to select from") prefix: Optional[str] = Field( default=None, description="Optional text to prepend to the name of each environment variable.\nMay consist of any printable ASCII characters except '='.", ) - secretRef: Optional[SecretRef] = Field( - default=None, description="The Secret to select from" - ) + secretRef: Optional[SecretRef] = Field(default=None, description="The Secret to select from") class Exec(BaseCRD): @@ -181,9 +171,7 @@ class HttpGet(BaseCRD): default=None, description="Custom headers to set in the request. HTTP allows repeated headers.", ) - path: Optional[str] = Field( - default=None, description="Path to access on the HTTP server." - ) + path: Optional[str] = Field(default=None, description="Path to access on the HTTP server.") port: Optional[Union[int, str]] = Field( default=None, description="Name or number of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", @@ -220,26 +208,18 @@ class LivenessProbe(BaseCRD): default=None, description="Minimum consecutive failures for the probe to be considered failed after having succeeded.\nDefaults to 3. Minimum value is 1.", ) - grpc: Optional[Grpc] = Field( - default=None, description="GRPC specifies a GRPC HealthCheckRequest." - ) - httpGet: Optional[HttpGet] = Field( - default=None, description="HTTPGet specifies an HTTP GET request to perform." - ) + grpc: Optional[Grpc] = Field(default=None, description="GRPC specifies a GRPC HealthCheckRequest.") + httpGet: Optional[HttpGet] = Field(default=None, description="HTTPGet specifies an HTTP GET request to perform.") initialDelaySeconds: Optional[int] = Field( default=None, description="Number of seconds after the container has started before liveness probes are initiated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", ) - periodSeconds: Optional[int] = Field( - default=None, description="How often (in seconds) to perform the probe." - ) + periodSeconds: Optional[int] = Field(default=None, description="How often (in seconds) to perform the probe.") successThreshold: Optional[int] = Field( default=None, description="Minimum consecutive successes for the probe to be considered successful after having failed.\nDefaults to 1. Must be 1 for liveness and startup. Minimum value is 1.", ) - tcpSocket: Optional[TcpSocket] = Field( - default=None, description="TCPSocket specifies a connection to a TCP port." - ) + tcpSocket: Optional[TcpSocket] = Field(default=None, description="TCPSocket specifies a connection to a TCP port.") timeoutSeconds: Optional[int] = Field( default=None, description="Number of seconds after which the probe times out.\nDefaults to 1 second. Minimum value is 1.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", @@ -276,9 +256,7 @@ class HttpGet1(BaseCRD): default=None, description="Custom headers to set in the request. HTTP allows repeated headers.", ) - path: Optional[str] = Field( - default=None, description="Path to access on the HTTP server." - ) + path: Optional[str] = Field(default=None, description="Path to access on the HTTP server.") port: Optional[Union[int, str]] = Field( default=None, description="Name or number of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", @@ -301,26 +279,18 @@ class ReadinessProbe(BaseCRD): default=None, description="Minimum consecutive failures for the probe to be considered failed after having succeeded.\nDefaults to 3. Minimum value is 1.", ) - grpc: Optional[Grpc] = Field( - default=None, description="GRPC specifies a GRPC HealthCheckRequest." - ) - httpGet: Optional[HttpGet1] = Field( - default=None, description="HTTPGet specifies an HTTP GET request to perform." - ) + grpc: Optional[Grpc] = Field(default=None, description="GRPC specifies a GRPC HealthCheckRequest.") + httpGet: Optional[HttpGet1] = Field(default=None, description="HTTPGet specifies an HTTP GET request to perform.") initialDelaySeconds: Optional[int] = Field( default=None, description="Number of seconds after the container has started before liveness probes are initiated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", ) - periodSeconds: Optional[int] = Field( - default=None, description="How often (in seconds) to perform the probe." - ) + periodSeconds: Optional[int] = Field(default=None, description="How often (in seconds) to perform the probe.") successThreshold: Optional[int] = Field( default=None, description="Minimum consecutive successes for the probe to be considered successful after having failed.\nDefaults to 1. Must be 1 for liveness and startup. Minimum value is 1.", ) - tcpSocket: Optional[TcpSocket] = Field( - default=None, description="TCPSocket specifies a connection to a TCP port." - ) + tcpSocket: Optional[TcpSocket] = Field(default=None, description="TCPSocket specifies a connection to a TCP port.") timeoutSeconds: Optional[int] = Field( default=None, description="Number of seconds after which the probe times out.\nDefaults to 1 second. Minimum value is 1.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", @@ -377,9 +347,7 @@ class Capabilities(BaseCRD): default=None, description="This is accessible behind a feature flag - kubernetes.containerspec-addcapabilities", ) - drop: Optional[Sequence[str]] = Field( - default=None, description="Removed capabilities" - ) + drop: Optional[Sequence[str]] = Field(default=None, description="Removed capabilities") class SeccompProfile(BaseCRD): @@ -446,9 +414,7 @@ class HttpGet2(BaseCRD): default=None, description="Custom headers to set in the request. HTTP allows repeated headers.", ) - path: Optional[str] = Field( - default=None, description="Path to access on the HTTP server." - ) + path: Optional[str] = Field(default=None, description="Path to access on the HTTP server.") port: Optional[Union[int, str]] = Field( default=None, description="Name or number of the port to access on the container.\nNumber must be in the range 1 to 65535.\nName must be an IANA_SVC_NAME.", @@ -471,26 +437,18 @@ class StartupProbe(BaseCRD): default=None, description="Minimum consecutive failures for the probe to be considered failed after having succeeded.\nDefaults to 3. Minimum value is 1.", ) - grpc: Optional[Grpc] = Field( - default=None, description="GRPC specifies a GRPC HealthCheckRequest." - ) - httpGet: Optional[HttpGet2] = Field( - default=None, description="HTTPGet specifies an HTTP GET request to perform." - ) + grpc: Optional[Grpc] = Field(default=None, description="GRPC specifies a GRPC HealthCheckRequest.") + httpGet: Optional[HttpGet2] = Field(default=None, description="HTTPGet specifies an HTTP GET request to perform.") initialDelaySeconds: Optional[int] = Field( default=None, description="Number of seconds after the container has started before liveness probes are initiated.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", ) - periodSeconds: Optional[int] = Field( - default=None, description="How often (in seconds) to perform the probe." - ) + periodSeconds: Optional[int] = Field(default=None, description="How often (in seconds) to perform the probe.") successThreshold: Optional[int] = Field( default=None, description="Minimum consecutive successes for the probe to be considered successful after having failed.\nDefaults to 1. Must be 1 for liveness and startup. Minimum value is 1.", ) - tcpSocket: Optional[TcpSocket] = Field( - default=None, description="TCPSocket specifies a connection to a TCP port." - ) + tcpSocket: Optional[TcpSocket] = Field(default=None, description="TCPSocket specifies a connection to a TCP port.") timeoutSeconds: Optional[int] = Field( default=None, description="Number of seconds after which the probe times out.\nDefaults to 1 second. Minimum value is 1.\nMore info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes", @@ -667,9 +625,7 @@ class FieldRef(BaseCRD): default=None, description='Version of the schema the FieldPath is written in terms of, defaults to "v1".', ) - fieldPath: str = Field( - ..., description="Path of the field to select in the specified API version." - ) + fieldPath: str = Field(..., description="Path of the field to select in the specified API version.") class Divisor(RootModel[int]): @@ -729,9 +685,7 @@ class DownwardAPI(BaseCRD): model_config = ConfigDict( extra="allow", ) - items: Optional[Sequence[Item2]] = Field( - default=None, description="Items is a list of DownwardAPIVolume file" - ) + items: Optional[Sequence[Item2]] = Field(default=None, description="Items is a list of DownwardAPIVolume file") class Item3(BaseCRD): @@ -797,9 +751,7 @@ class Source(BaseCRD): default=None, description="downwardAPI information about the downwardAPI data to project", ) - secret: Optional[Secret] = Field( - default=None, description="secret information about the secret data to project" - ) + secret: Optional[Secret] = Field(default=None, description="secret information about the secret data to project") serviceAccountToken: Optional[ServiceAccountToken] = Field( default=None, description="serviceAccountToken is information about the serviceAccountToken data to project", @@ -1057,12 +1009,8 @@ class Address(BaseCRD): default=None, description="CACerts is the Certification Authority (CA) certificates in PEM format\naccording to https://www.rfc-editor.org/rfc/rfc7468.", ) - audience: Optional[str] = Field( - default=None, description="Audience is the OIDC audience for this address." - ) - name: Optional[str] = Field( - default=None, description="Name is the name of the address." - ) + audience: Optional[str] = Field(default=None, description="Audience is the OIDC audience for this address.") + name: Optional[str] = Field(default=None, description="Name is the name of the address.") url: Optional[str] = None @@ -1078,16 +1026,12 @@ class Condition(BaseCRD): default=None, description="A human readable message indicating details about the transition.", ) - reason: Optional[str] = Field( - default=None, description="The reason for the condition's last transition." - ) + reason: Optional[str] = Field(default=None, description="The reason for the condition's last transition.") severity: Optional[str] = Field( default=None, description="Severity with which to treat failures of this type of condition.\nWhen this is not specified, it defaults to Error.", ) - status: str = Field( - ..., description="Status of the condition, one of True, False, Unknown." - ) + status: str = Field(..., description="Status of the condition, one of True, False, Unknown.") type: str = Field(..., description="Type of condition.") diff --git a/components/renku_data_services/renku_apps/crs.py b/components/renku_data_services/renku_apps/crs.py index 52de0a318..2f921ef3f 100644 --- a/components/renku_data_services/renku_apps/crs.py +++ b/components/renku_data_services/renku_apps/crs.py @@ -2,8 +2,12 @@ from collections.abc import Mapping from datetime import datetime +from typing import cast from pydantic import BaseModel, ConfigDict, Field +from ulid import ULID + +from renku_data_services.errors import errors from renku_data_services.renku_apps.cr_knative_service import Limits as _Limits from renku_data_services.renku_apps.cr_knative_service import Limits1 as LimitsStr from renku_data_services.renku_apps.cr_knative_service import Model as _KnativeService @@ -62,3 +66,21 @@ class KnativeService(_KnativeService): kind: str = "Service" apiVersion: str = "serving.knative.dev/v1" metadata: Metadata # type: ignore[assignment] + + @property + def launcher_id(self) -> ULID: + """Get the session launcher ID from the annotations.""" + if "renku.io/launcher_id" not in self.metadata.annotations: + raise errors.ProgrammingError( + message=f"The app with name {self.metadata.name} is missing its launcher_id annotation" + ) + return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/launcher_id"])) + + @property + def project_id(self) -> ULID: + """Get the project ID from the annotations.""" + if "renku.io/project_id" not in self.metadata.annotations: + raise errors.ProgrammingError( + message=f"The app with name {self.metadata.name} is missing its project_id annotation" + ) + return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/project_id"])) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 6918f0e91..e890eac2c 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -1,13 +1,13 @@ -"""K8s client wrapper for Renku apps""" +"""K8s client wrapper for Renku apps.""" from collections.abc import AsyncGenerator -from renku_data_services.session.models import SessionLauncher from renku_data_services.crc.db import ClusterRepository from renku_data_services.k8s.clients import K8sClusterClientsPool from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, ClusterId from renku_data_services.k8s.models import GVK, K8sObjectMeta from renku_data_services.renku_apps.crs import KnativeService +from renku_data_services.session.models import SessionLauncher KNATIVE_SERVICE_GVK = GVK(kind="Service", group="serving.knative.dev", version="v1") @@ -18,55 +18,71 @@ def _generate_app_name(session_launcher: SessionLauncher) -> str: class RenkuAppsK8sClient: - """K8s client for Renku apps operations""" + """K8s client for Renku apps operations.""" def __init__(self, client: K8sClusterClientsPool, cluster_repo: ClusterRepository) -> None: self.__client = client self.__cluster_repo = cluster_repo - async def create_app_deployment(self, session_launcher: SessionLauncher) -> str: - """Create a deployment for the given app and return the deployment name""" + async def create_app_deployment(self, session_launcher: SessionLauncher) -> KnativeService: + """Create a deployment for the given app and return the created Knative Service.""" cluster_id: ClusterId = DEFAULT_K8S_CLUSTER cluster = await self.__client.cluster_by_id(cluster_id) app_name = _generate_app_name(session_launcher) manifest = _build_app_deployment_manifest(session_launcher, app_name) meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) - await self.__client.create(meta.with_manifest(manifest), refresh=False) - return app_name + created = await self.__client.create( + meta.with_manifest(manifest.model_dump(exclude_none=True, mode="json")), refresh=True + ) + return KnativeService.model_validate(created.manifest) async def get_app_deployment(self, app_name: str) -> KnativeService | None: - """Get the deployment for the given app name. NOT IMPLEMENTED""" - raise NotImplementedError("Getting app deployment is not implemented yet") + """Get the deployment for the given app name, or None if it does not exist.""" + cluster_id: ClusterId = DEFAULT_K8S_CLUSTER + cluster = await self.__client.cluster_by_id(cluster_id) + meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) + obj = await self.__client.get(meta) + if obj is None: + return None + return KnativeService.model_validate(obj.manifest) async def delete_app_deployment(self, app_name: str) -> None: - """Delete the deployment for the given app name. NOT IMPLEMENTED""" + """Delete the deployment for the given app name. NOT IMPLEMENTED.""" raise NotImplementedError("Deleting app deployment is not implemented yet") async def list_app_deployments(self) -> AsyncGenerator[KnativeService, None]: - """List all app deployments. NOT IMPLEMENTED""" + """List all app deployments. NOT IMPLEMENTED.""" raise NotImplementedError("Listing app deployments is not implemented yet") async def update_app_deployment(self, app_name: str, session_launcher: SessionLauncher) -> KnativeService: - """Update the deployment for the given app name. NOT IMPLEMENTED""" + """Update the deployment for the given app name. NOT IMPLEMENTED.""" raise NotImplementedError("Updating app deployment is not implemented yet") def _build_app_deployment_manifest(session_launcher: SessionLauncher, app_name: str) -> KnativeService: - """Build an app deployment manifest for the given app and session launcher""" - return KnativeService( - apiVersion="serving.knative.dev/v1", - kind="Service", - metadata={"name": app_name}, - spec={ - "template": { - "spec": { - "containers": [ - { - "image": "docker.io/library/nginx:latest", - "ports": [{"containerPort": 80}], - } - ] + """Build an app deployment manifest for the given app and session launcher.""" + return KnativeService.model_validate( + { + "apiVersion": "serving.knative.dev/v1", + "kind": "Service", + "metadata": { + "name": app_name, + "annotations": { + "renku.io/launcher_id": str(session_launcher.id), + "renku.io/project_id": str(session_launcher.project_id), + }, + }, + "spec": { + "template": { + "spec": { + "containers": [ + { + "image": "docker.io/library/nginx:latest", + "ports": [{"containerPort": 80}], + } + ] + } } - } - }, + }, + } ) diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index 7bd667485..7accfeedf 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -34,7 +34,7 @@ def as_apispec(self) -> apispec.AppResponse: return apispec.AppResponse( name=self.name, launcher_id=str(self.launcher_id), - status=self.status, + status=apispec.AppStatus(self.status.value), url=self.url, project_id=str(self.project_id), started=self.started, diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py new file mode 100644 index 000000000..ae8086a10 --- /dev/null +++ b/components/renku_data_services/renku_apps/repository.py @@ -0,0 +1,53 @@ +"""Repository for Renku apps backed by Knative Services in k8s.""" + +from ulid import ULID + +import renku_data_services.base_models as base_models +from renku_data_services import errors +from renku_data_services.authz.authz import Authz, ResourceType +from renku_data_services.authz.models import Scope +from renku_data_services.renku_apps.core import knative_service_to_app +from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient +from renku_data_services.renku_apps.models import App +from renku_data_services.session.db import SessionRepository + + +class RenkuAppsRepository: + """Use-case-focused API for Renku apps, dispatching to k8s rather than SQL.""" + + def __init__( + self, + authz: Authz, + session_repo: SessionRepository, + k8s_client: RenkuAppsK8sClient, + ) -> None: + self.authz = authz + self.session_repo = session_repo + self.k8s_client = k8s_client + + async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: + """Launch a new app from a session launcher.""" + if not user.is_authenticated or user.id is None: + raise errors.UnauthorizedError(message="You do not have the required permissions for this operation.") + + launcher = await self.session_repo.get_launcher(user, launcher_id) + + authorized = await self.authz.has_permission(user, ResourceType.project, launcher.project_id, Scope.WRITE) + if not authorized: + raise errors.MissingResourceError( + message=f"Project with id '{launcher.project_id}' does not exist or you do not have access to it." + ) + + service = await self.k8s_client.create_app_deployment(launcher) + return knative_service_to_app(launcher, service) + + async def get_app(self, user: base_models.APIUser, app_name: str) -> App: + """Retrieve an app by its name.""" + service = await self.k8s_client.get_app_deployment(app_name) + if service is None: + raise errors.MissingResourceError( + message=f"App with name '{app_name}' does not exist or you do not have access to it." + ) + + launcher = await self.session_repo.get_launcher(user, service.launcher_id) + return knative_service_to_app(launcher, service) diff --git a/pyproject.toml b/pyproject.toml index 21763ac61..1779e2a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ exclude = [ ".devcontainer/", "components/renku_data_services/notebooks/cr_amalthea_session.py", "components/renku_data_services/notebooks/cr_jupyter_server.py", + "components/renku_data_services/renku_apps/cr_knative_service.py", "components/renku_data_services/session/cr_shipwright_buildrun.py", "components/renku_data_services/notebooks/oci/image_config.py", "components/renku_data_services/notebooks/oci/image_index.py", @@ -191,6 +192,7 @@ exclude_dirs = [ "components/renku_data_services/connected_services/apispec.py", "components/renku_data_services/notebooks/cr_amalthea_session.py", "components/renku_data_services/notebooks/cr_jupyter_server.py", + "components/renku_data_services/renku_apps/cr_knative_service.py", "components/renku_data_services/authn/api/apispec.py", ] From 84c8a3e75bf52a1be76623c84c6e60a8ec441605 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 27 May 2026 09:49:04 +0200 Subject: [PATCH 09/28] fix: bundle renku_apps component in data_service wheel --- projects/renku_data_service/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/renku_data_service/pyproject.toml b/projects/renku_data_service/pyproject.toml index 034163357..d364ee0cd 100644 --- a/projects/renku_data_service/pyproject.toml +++ b/projects/renku_data_service/pyproject.toml @@ -37,6 +37,7 @@ packages = [ { include = "renku_data_services/utils", from = "../../components" }, { include = "renku_data_services/data_connectors", from = "../../components" }, { include = "renku_data_services/notebooks", from = "../../components" }, + { include = "renku_data_services/renku_apps", from = "../../components" }, # Note: poetry poly does not detect the migrations as dependencies, but they are. Don't remove these! { include = "renku_data_services/migrations", from = "../../components" }, { include = "renku_data_services/solr", from = "../../components" }, From 5256b3e1105e1ba204ec3bd8e7a7af49bcf77c13 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 27 May 2026 14:30:35 +0200 Subject: [PATCH 10/28] get apps kantive manifest from session launcher --- .../data_api/dependencies.py | 1 + .../renku_apps/k8s_client.py | 64 +++++++++++++++---- .../renku_apps/repository.py | 10 ++- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index 319092d10..e54418d1c 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -394,6 +394,7 @@ def from_env(cls) -> DependencyManager: apps_repo = RenkuAppsRepository( authz=authz, session_repo=session_repo, + rp_repo=rp_repo, k8s_client=apps_k8s_client, ) project_migration_repo = ProjectMigrationRepository( diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index e890eac2c..073278e28 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -1,8 +1,10 @@ """K8s client wrapper for Renku apps.""" from collections.abc import AsyncGenerator +from typing import Any from renku_data_services.crc.db import ClusterRepository +from renku_data_services.crc.models import ResourceClass from renku_data_services.k8s.clients import K8sClusterClientsPool from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, ClusterId from renku_data_services.k8s.models import GVK, K8sObjectMeta @@ -11,6 +13,12 @@ KNATIVE_SERVICE_GVK = GVK(kind="Service", group="serving.knative.dev", version="v1") +_APP_AUTOSCALING_ANNOTATIONS = { + "autoscaling.knative.dev/min-scale": "0", + "autoscaling.knative.dev/max-scale": "3", + "autoscaling.knative.dev/scale-to-zero-pod-retention-period": "2m", +} + def _generate_app_name(session_launcher: SessionLauncher) -> str: """Generate a name for an app.""" @@ -24,12 +32,14 @@ def __init__(self, client: K8sClusterClientsPool, cluster_repo: ClusterRepositor self.__client = client self.__cluster_repo = cluster_repo - async def create_app_deployment(self, session_launcher: SessionLauncher) -> KnativeService: + async def create_app_deployment( + self, session_launcher: SessionLauncher, resource_class: ResourceClass | None + ) -> KnativeService: """Create a deployment for the given app and return the created Knative Service.""" cluster_id: ClusterId = DEFAULT_K8S_CLUSTER cluster = await self.__client.cluster_by_id(cluster_id) app_name = _generate_app_name(session_launcher) - manifest = _build_app_deployment_manifest(session_launcher, app_name) + manifest = _build_app_deployment_manifest(session_launcher, app_name, resource_class) meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) created = await self.__client.create( meta.with_manifest(manifest.model_dump(exclude_none=True, mode="json")), refresh=True @@ -59,8 +69,42 @@ async def update_app_deployment(self, app_name: str, session_launcher: SessionLa raise NotImplementedError("Updating app deployment is not implemented yet") -def _build_app_deployment_manifest(session_launcher: SessionLauncher, app_name: str) -> KnativeService: - """Build an app deployment manifest for the given app and session launcher.""" +def _resources_from_resource_class(resource_class: ResourceClass) -> dict[str, Any]: + """Build a k8s container resources block from a resource class.""" + return { + "requests": { + "cpu": f"{round(resource_class.cpu * 1000)}m", + "memory": f"{resource_class.memory}Gi", + }, + "limits": {"memory": f"{resource_class.memory}Gi"}, + } + + +def _build_app_deployment_manifest( + session_launcher: SessionLauncher, app_name: str, resource_class: ResourceClass | None +) -> KnativeService: + """Build a Knative Service manifest derived from the session launcher.""" + environment = session_launcher.environment + + container: dict[str, Any] = { + "image": environment.container_image, + "ports": [{"containerPort": environment.port}], + "securityContext": { + "runAsUser": environment.uid, + "runAsGroup": environment.gid, + }, + } + if resource_class is not None: + container["resources"] = _resources_from_resource_class(resource_class) + if session_launcher.env_variables: + container["env"] = [{"name": var.name, "value": var.value} for var in session_launcher.env_variables] + if environment.command: + container["command"] = environment.command + if environment.args: + container["args"] = environment.args + if environment.working_directory is not None: + container["workingDir"] = str(environment.working_directory) + return KnativeService.model_validate( { "apiVersion": "serving.knative.dev/v1", @@ -74,15 +118,9 @@ def _build_app_deployment_manifest(session_launcher: SessionLauncher, app_name: }, "spec": { "template": { - "spec": { - "containers": [ - { - "image": "docker.io/library/nginx:latest", - "ports": [{"containerPort": 80}], - } - ] - } - } + "metadata": {"annotations": _APP_AUTOSCALING_ANNOTATIONS}, + "spec": {"containers": [container]}, + }, }, } ) diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index ae8086a10..3d0470d7e 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -6,6 +6,8 @@ from renku_data_services import errors from renku_data_services.authz.authz import Authz, ResourceType from renku_data_services.authz.models import Scope +from renku_data_services.crc.db import ResourcePoolRepository +from renku_data_services.crc.models import ResourceClass from renku_data_services.renku_apps.core import knative_service_to_app from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient from renku_data_services.renku_apps.models import App @@ -19,10 +21,12 @@ def __init__( self, authz: Authz, session_repo: SessionRepository, + rp_repo: ResourcePoolRepository, k8s_client: RenkuAppsK8sClient, ) -> None: self.authz = authz self.session_repo = session_repo + self.rp_repo = rp_repo self.k8s_client = k8s_client async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: @@ -38,7 +42,11 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: message=f"Project with id '{launcher.project_id}' does not exist or you do not have access to it." ) - service = await self.k8s_client.create_app_deployment(launcher) + resource_class: ResourceClass | None = None + if launcher.resource_class_id is not None: + resource_class = await self.rp_repo.get_resource_class(user, launcher.resource_class_id) + + service = await self.k8s_client.create_app_deployment(launcher, resource_class) return knative_service_to_app(launcher, service) async def get_app(self, user: base_models.APIUser, app_name: str) -> App: From b49d236dccd7a4c88a0384c3e24c5ecde1368c9e Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 27 May 2026 15:00:08 +0200 Subject: [PATCH 11/28] derive app URL from project path --- bases/renku_data_services/data_api/config.py | 2 ++ bases/renku_data_services/data_api/dependencies.py | 2 ++ components/renku_data_services/renku_apps/core.py | 12 ++++++++---- .../renku_data_services/renku_apps/repository.py | 13 ++++++++++--- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/bases/renku_data_services/data_api/config.py b/bases/renku_data_services/data_api/config.py index 045176de5..5736a1349 100644 --- a/bases/renku_data_services/data_api/config.py +++ b/bases/renku_data_services/data_api/config.py @@ -44,6 +44,7 @@ class Config: user_preferences: UserPreferencesConfig internal_authn_config: InternalAuthenticationConfig gitlab_url: str | None + apps_base_domain: str log_cfg: LoggingConfig version: str alertmanager_webhook_role: str @@ -92,6 +93,7 @@ def from_env(cls, db: DBConfig | None = None) -> Self: user_preferences=UserPreferencesConfig.from_env(), internal_authn_config=InternalAuthenticationConfig.from_env(), gitlab_url=gitlab_url, + apps_base_domain=os.environ.get("RENKU_APPS__BASE_DOMAIN", "apps.renku.local"), log_cfg=LoggingConfig.from_env(), alertmanager_webhook_role=os.environ.get("ALERTMANAGER_WEBHOOK_ROLE", "alertmanager-webhook"), deposit_config=DepositConfig.from_env(nb_config.sessions.renku_url), diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index e54418d1c..072fab90e 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -395,7 +395,9 @@ def from_env(cls) -> DependencyManager: authz=authz, session_repo=session_repo, rp_repo=rp_repo, + project_repo=project_repo, k8s_client=apps_k8s_client, + apps_base_domain=config.apps_base_domain, ) project_migration_repo = ProjectMigrationRepository( session_maker=config.db.async_session_maker, diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index 71b4bf9ba..23043936c 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -2,22 +2,26 @@ from datetime import datetime +from renku_data_services.project.models import Project from renku_data_services.renku_apps import models from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService from renku_data_services.session.models import SessionLauncher -def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService) -> models.App: - """Convert a Knative service to an app.""" - app_service_url = knative_service.status.url if knative_service.status else None +def app_url(project: Project, base_domain: str) -> str: + """Build the public URL for an app from its project path and the configured base domain.""" + return f"https://{project.slug}.{project.namespace.path.serialize()}.{base_domain}" + +def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService, url: str) -> models.App: + """Convert a Knative service to an app.""" return models.App( name=knative_service.metadata.name, launcher_id=session_launcher.id, project_id=session_launcher.project_id, status=_project_app_status(knative_service), - url=app_service_url, + url=url, started=_started_at(knative_service), image=session_launcher.environment.container_image, ) diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 3d0470d7e..351af9e30 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -8,7 +8,8 @@ from renku_data_services.authz.models import Scope from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.crc.models import ResourceClass -from renku_data_services.renku_apps.core import knative_service_to_app +from renku_data_services.project.db import ProjectRepository +from renku_data_services.renku_apps.core import app_url, knative_service_to_app from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient from renku_data_services.renku_apps.models import App from renku_data_services.session.db import SessionRepository @@ -22,12 +23,16 @@ def __init__( authz: Authz, session_repo: SessionRepository, rp_repo: ResourcePoolRepository, + project_repo: ProjectRepository, k8s_client: RenkuAppsK8sClient, + apps_base_domain: str, ) -> None: self.authz = authz self.session_repo = session_repo self.rp_repo = rp_repo + self.project_repo = project_repo self.k8s_client = k8s_client + self.apps_base_domain = apps_base_domain async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: """Launch a new app from a session launcher.""" @@ -46,8 +51,9 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: if launcher.resource_class_id is not None: resource_class = await self.rp_repo.get_resource_class(user, launcher.resource_class_id) + project = await self.project_repo.get_project(user, launcher.project_id) service = await self.k8s_client.create_app_deployment(launcher, resource_class) - return knative_service_to_app(launcher, service) + return knative_service_to_app(launcher, service, app_url(project, self.apps_base_domain)) async def get_app(self, user: base_models.APIUser, app_name: str) -> App: """Retrieve an app by its name.""" @@ -58,4 +64,5 @@ async def get_app(self, user: base_models.APIUser, app_name: str) -> App: ) launcher = await self.session_repo.get_launcher(user, service.launcher_id) - return knative_service_to_app(launcher, service) + project = await self.project_repo.get_project(user, launcher.project_id) + return knative_service_to_app(launcher, service, app_url(project, self.apps_base_domain)) From 7c3481e66c82cab4ad4ffbc1b1f683dbd1055bfc Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 28 May 2026 09:50:21 +0200 Subject: [PATCH 12/28] get URL from knative status. name services per project --- bases/renku_data_services/data_api/config.py | 2 -- .../data_api/dependencies.py | 1 - .../renku_data_services/renku_apps/core.py | 17 +++++++++-------- .../renku_apps/k8s_client.py | 16 +++++++++++----- .../renku_apps/repository.py | 13 ++++++------- test/utils.py | 12 ++++++++++++ 6 files changed, 38 insertions(+), 23 deletions(-) diff --git a/bases/renku_data_services/data_api/config.py b/bases/renku_data_services/data_api/config.py index 5736a1349..045176de5 100644 --- a/bases/renku_data_services/data_api/config.py +++ b/bases/renku_data_services/data_api/config.py @@ -44,7 +44,6 @@ class Config: user_preferences: UserPreferencesConfig internal_authn_config: InternalAuthenticationConfig gitlab_url: str | None - apps_base_domain: str log_cfg: LoggingConfig version: str alertmanager_webhook_role: str @@ -93,7 +92,6 @@ def from_env(cls, db: DBConfig | None = None) -> Self: user_preferences=UserPreferencesConfig.from_env(), internal_authn_config=InternalAuthenticationConfig.from_env(), gitlab_url=gitlab_url, - apps_base_domain=os.environ.get("RENKU_APPS__BASE_DOMAIN", "apps.renku.local"), log_cfg=LoggingConfig.from_env(), alertmanager_webhook_role=os.environ.get("ALERTMANAGER_WEBHOOK_ROLE", "alertmanager-webhook"), deposit_config=DepositConfig.from_env(nb_config.sessions.renku_url), diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index 072fab90e..a0dcf48af 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -397,7 +397,6 @@ def from_env(cls) -> DependencyManager: rp_repo=rp_repo, project_repo=project_repo, k8s_client=apps_k8s_client, - apps_base_domain=config.apps_base_domain, ) project_migration_repo = ProjectMigrationRepository( session_maker=config.db.async_session_maker, diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index 23043936c..489d4480e 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -2,31 +2,32 @@ from datetime import datetime -from renku_data_services.project.models import Project from renku_data_services.renku_apps import models from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService from renku_data_services.session.models import SessionLauncher -def app_url(project: Project, base_domain: str) -> str: - """Build the public URL for an app from its project path and the configured base domain.""" - return f"https://{project.slug}.{project.namespace.path.serialize()}.{base_domain}" - - -def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService, url: str) -> models.App: +def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService) -> models.App: """Convert a Knative service to an app.""" return models.App( name=knative_service.metadata.name, launcher_id=session_launcher.id, project_id=session_launcher.project_id, status=_project_app_status(knative_service), - url=url, + url=_url(knative_service), started=_started_at(knative_service), image=session_launcher.environment.container_image, ) +def _url(knative_service: KnativeService) -> str | None: + """Get the public URL Knative assigned to the service, or None if it is not yet routed.""" + if knative_service.status is None: + return None + return knative_service.status.url + + def _ready_condition(knative_service: KnativeService) -> Condition | None: """Get the Ready condition from a Knative service, or None if it doesn't exist.""" if knative_service.status is None or not knative_service.status.conditions: diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 073278e28..0c4d3841c 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -8,6 +8,7 @@ from renku_data_services.k8s.clients import K8sClusterClientsPool from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, ClusterId from renku_data_services.k8s.models import GVK, K8sObjectMeta +from renku_data_services.project.models import Project from renku_data_services.renku_apps.crs import KnativeService from renku_data_services.session.models import SessionLauncher @@ -20,9 +21,10 @@ } -def _generate_app_name(session_launcher: SessionLauncher) -> str: - """Generate a name for an app.""" - return f"app-{session_launcher.id}".lower()[:63] +def _generate_app_name(project: Project) -> str: + """Generate a DNS-1035 label name for an app from its project path.""" + namespace = project.namespace.path.serialize().replace("/", "-") + return f"{project.slug}-{namespace}".lower()[:63] class RenkuAppsK8sClient: @@ -33,12 +35,12 @@ def __init__(self, client: K8sClusterClientsPool, cluster_repo: ClusterRepositor self.__cluster_repo = cluster_repo async def create_app_deployment( - self, session_launcher: SessionLauncher, resource_class: ResourceClass | None + self, session_launcher: SessionLauncher, resource_class: ResourceClass | None, project: Project ) -> KnativeService: """Create a deployment for the given app and return the created Knative Service.""" cluster_id: ClusterId = DEFAULT_K8S_CLUSTER cluster = await self.__client.cluster_by_id(cluster_id) - app_name = _generate_app_name(session_launcher) + app_name = _generate_app_name(project) manifest = _build_app_deployment_manifest(session_launcher, app_name, resource_class) meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) created = await self.__client.create( @@ -56,6 +58,10 @@ async def get_app_deployment(self, app_name: str) -> KnativeService | None: return None return KnativeService.model_validate(obj.manifest) + async def get_app_deployment_for_project(self, project: Project) -> KnativeService | None: + """Get the app deployment for the given project, or None if it does not exist.""" + return await self.get_app_deployment(_generate_app_name(project)) + async def delete_app_deployment(self, app_name: str) -> None: """Delete the deployment for the given app name. NOT IMPLEMENTED.""" raise NotImplementedError("Deleting app deployment is not implemented yet") diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 351af9e30..30be6c088 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -9,7 +9,7 @@ from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.crc.models import ResourceClass from renku_data_services.project.db import ProjectRepository -from renku_data_services.renku_apps.core import app_url, knative_service_to_app +from renku_data_services.renku_apps.core import knative_service_to_app from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient from renku_data_services.renku_apps.models import App from renku_data_services.session.db import SessionRepository @@ -25,14 +25,12 @@ def __init__( rp_repo: ResourcePoolRepository, project_repo: ProjectRepository, k8s_client: RenkuAppsK8sClient, - apps_base_domain: str, ) -> None: self.authz = authz self.session_repo = session_repo self.rp_repo = rp_repo self.project_repo = project_repo self.k8s_client = k8s_client - self.apps_base_domain = apps_base_domain async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: """Launch a new app from a session launcher.""" @@ -52,8 +50,10 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: resource_class = await self.rp_repo.get_resource_class(user, launcher.resource_class_id) project = await self.project_repo.get_project(user, launcher.project_id) - service = await self.k8s_client.create_app_deployment(launcher, resource_class) - return knative_service_to_app(launcher, service, app_url(project, self.apps_base_domain)) + if await self.k8s_client.get_app_deployment_for_project(project) is not None: + raise errors.ConflictError(message=f"An app already exists for project '{launcher.project_id}'.") + service = await self.k8s_client.create_app_deployment(launcher, resource_class, project) + return knative_service_to_app(launcher, service) async def get_app(self, user: base_models.APIUser, app_name: str) -> App: """Retrieve an app by its name.""" @@ -64,5 +64,4 @@ async def get_app(self, user: base_models.APIUser, app_name: str) -> App: ) launcher = await self.session_repo.get_launcher(user, service.launcher_id) - project = await self.project_repo.get_project(user, launcher.project_id) - return knative_service_to_app(launcher, service, app_url(project, self.apps_base_domain)) + return knative_service_to_app(launcher, service) diff --git a/test/utils.py b/test/utils.py index 720357324..8c02f7fa5 100644 --- a/test/utils.py +++ b/test/utils.py @@ -64,6 +64,8 @@ ProjectRepository, ProjectSessionSecretRepository, ) +from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient +from renku_data_services.renku_apps.repository import RenkuAppsRepository from renku_data_services.repositories import models as repositories_models from renku_data_services.repositories.db import GitRepositoriesRepository from renku_data_services.repositories.git_url import GitUrl, GitUrlError @@ -471,9 +473,19 @@ def from_env( resource_requests_repo = ResourceRequestsRepo(session_maker=config.db.async_session_maker) resource_usage_service = ResourceUsageService(resource_requests_repo) + apps_k8s_client = RenkuAppsK8sClient(client=client, cluster_repo=cluster_repo) + apps_repo = RenkuAppsRepository( + authz=authz, + session_repo=session_repo, + rp_repo=rp_repo, + project_repo=project_repo, + k8s_client=apps_k8s_client, + ) return cls( config=config, k8s_client=client, + apps_k8s_client=apps_k8s_client, + apps_repo=apps_repo, authenticator=authenticator, gitlab_authenticator=gitlab_authenticator, internal_authenticator=internal_authenticator, From 7af9966eb1afd6ea38679f0dd1c5b7c97d77987a Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 28 May 2026 16:51:19 +0200 Subject: [PATCH 13/28] cache KnativeService GVK --- bases/renku_data_services/data_api/dependencies.py | 6 +++--- components/renku_data_services/renku_apps/api.spec.yaml | 6 ------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index a0dcf48af..e6b5d9a50 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -68,7 +68,7 @@ ProjectRepository, ProjectSessionSecretRepository, ) -from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient +from renku_data_services.renku_apps.k8s_client import KNATIVE_SERVICE_GVK, RenkuAppsK8sClient from renku_data_services.renku_apps.repository import RenkuAppsRepository from renku_data_services.repositories.db import GitRepositoriesRepository from renku_data_services.resource_usage.core import ResourceUsageService @@ -263,7 +263,7 @@ def from_env(cls) -> DependencyManager: default_kubeconfig=default_kubeconfig, cluster_repo=cluster_repo, cache=k8s_db_cache, - kinds_to_cache=[AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK, BUILD_RUN_GVK, TASK_RUN_GVK], + kinds_to_cache=[AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK, BUILD_RUN_GVK, TASK_RUN_GVK, KNATIVE_SERVICE_GVK], ), ) quota_repo = QuotaRepository(K8sResourceQuotaClient(client), K8sPriorityClassClient(client)) @@ -310,7 +310,7 @@ def from_env(cls) -> DependencyManager: default_kubeconfig=default_kubeconfig, cluster_repo=cluster_repo, cache=k8s_db_cache, - kinds_to_cache=[AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK, BUILD_RUN_GVK, TASK_RUN_GVK], + kinds_to_cache=[AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK, BUILD_RUN_GVK, TASK_RUN_GVK, KNATIVE_SERVICE_GVK], ), ), namespace=config.k8s_namespace, diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index 5049072b6..4e95330e7 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -23,12 +23,6 @@ paths: application/json: schema: $ref: "#/components/schemas/AppResponse" - "200": - description: The app already exists - content: - application/json: - schema: - $ref: "#/components/schemas/AppResponse" default: $ref: "#/components/responses/Error" tags: From b8627b7583149140efd21e12c451bdf3f8e3890d Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 29 May 2026 10:07:45 +0200 Subject: [PATCH 14/28] add dummy renku apps user id --- .../renku_data_services/k8s/constants.py | 7 +++++++ .../renku_apps/k8s_client.py | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/components/renku_data_services/k8s/constants.py b/components/renku_data_services/k8s/constants.py index 1215e2fc5..a45b1060c 100644 --- a/components/renku_data_services/k8s/constants.py +++ b/components/renku_data_services/k8s/constants.py @@ -18,3 +18,10 @@ Note: we can't curently propagate labels to TaskRuns through shipwright, so we just use a dummy user id for all of them. This might change if shipwright SHIP-0034 gets implemented. """ + +DUMMY_RENKU_APP_USER_ID: Final[str] = "DummyRenkuAppUser" +"""The user id to use for Renku App Knative Services in the k8s cache. + +Renku apps are public and shared across users, so they don't fit the per-user cache model. A fixed sentinel +ensures the cache row is shared across all readers instead of being written once per user. +""" diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 0c4d3841c..506f7e5af 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -6,7 +6,7 @@ from renku_data_services.crc.db import ClusterRepository from renku_data_services.crc.models import ResourceClass from renku_data_services.k8s.clients import K8sClusterClientsPool -from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, ClusterId +from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, DUMMY_RENKU_APP_USER_ID, ClusterId from renku_data_services.k8s.models import GVK, K8sObjectMeta from renku_data_services.project.models import Project from renku_data_services.renku_apps.crs import KnativeService @@ -42,7 +42,13 @@ async def create_app_deployment( cluster = await self.__client.cluster_by_id(cluster_id) app_name = _generate_app_name(project) manifest = _build_app_deployment_manifest(session_launcher, app_name, resource_class) - meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) + meta = K8sObjectMeta( + name=app_name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + ) created = await self.__client.create( meta.with_manifest(manifest.model_dump(exclude_none=True, mode="json")), refresh=True ) @@ -52,7 +58,13 @@ async def get_app_deployment(self, app_name: str) -> KnativeService | None: """Get the deployment for the given app name, or None if it does not exist.""" cluster_id: ClusterId = DEFAULT_K8S_CLUSTER cluster = await self.__client.cluster_by_id(cluster_id) - meta = K8sObjectMeta(name=app_name, namespace=cluster.namespace, cluster=cluster.id, gvk=KNATIVE_SERVICE_GVK) + meta = K8sObjectMeta( + name=app_name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + ) obj = await self.__client.get(meta) if obj is None: return None From 1310765ba9972b5194cbda702926157736a89105 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 29 May 2026 10:45:42 +0200 Subject: [PATCH 15/28] subscribe k8s watcher to Knative Services --- bases/renku_data_services/k8s_cache/main.py | 3 ++- components/renku_data_services/k8s/models.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bases/renku_data_services/k8s_cache/main.py b/bases/renku_data_services/k8s_cache/main.py index fb0434361..4b9f88d51 100644 --- a/bases/renku_data_services/k8s_cache/main.py +++ b/bases/renku_data_services/k8s_cache/main.py @@ -13,6 +13,7 @@ from renku_data_services.k8s.watcher import K8sWatcher, k8s_object_handler from renku_data_services.k8s_cache.dependencies import DependencyManager from renku_data_services.notebooks.constants import AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK +from renku_data_services.renku_apps.k8s_client import KNATIVE_SERVICE_GVK from renku_data_services.session.constants import BUILD_RUN_GVK, TASK_RUN_GVK logger = logging.getLogger(__name__) @@ -47,7 +48,7 @@ async def main() -> None: ): clusters[client.get_cluster().id] = client - kinds = [AMALTHEA_SESSION_GVK] + kinds = [AMALTHEA_SESSION_GVK, KNATIVE_SERVICE_GVK] if dm.config.v1_services.enabled: kinds.append(JUPYTER_SESSION_GVK) if dm.config.image_builders.enabled: diff --git a/components/renku_data_services/k8s/models.py b/components/renku_data_services/k8s/models.py index 8d4f49bfc..22eb6b0f4 100644 --- a/components/renku_data_services/k8s/models.py +++ b/components/renku_data_services/k8s/models.py @@ -15,7 +15,7 @@ from kubernetes.client import V1Secret from renku_data_services.errors import ProgrammingError, errors -from renku_data_services.k8s.constants import DUMMY_TASK_RUN_USER_ID, ClusterId +from renku_data_services.k8s.constants import DUMMY_RENKU_APP_USER_ID, DUMMY_TASK_RUN_USER_ID, ClusterId sanitizer = kubernetes.client.ApiClient().sanitize_for_serialization K8sPatch = dict[str, Any] @@ -426,6 +426,8 @@ def user_id(self) -> str | None: return labels.get("renku.io/safe-username", None) case "taskrun": return DUMMY_TASK_RUN_USER_ID + case "service" if self.obj.version == "serving.knative.dev/v1": + return DUMMY_RENKU_APP_USER_ID case _: return None From 858b4ffd6ffa0fa2c573f621137da18733b52e4b Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 29 May 2026 11:14:14 +0200 Subject: [PATCH 16/28] bundle renku_apps component in k8s_watcher --- projects/k8s_watcher/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/k8s_watcher/pyproject.toml b/projects/k8s_watcher/pyproject.toml index a5b8fd2df..861a96ead 100644 --- a/projects/k8s_watcher/pyproject.toml +++ b/projects/k8s_watcher/pyproject.toml @@ -37,6 +37,7 @@ packages = [ { include = "renku_data_services/utils", from = "../../components" }, { include = "renku_data_services/data_connectors", from = "../../components" }, { include = "renku_data_services/notebooks", from = "../../components" }, + { include = "renku_data_services/renku_apps", from = "../../components" }, # Note: poetry poly does not detect the migrations as dependencies, but they are. Don't remove these! { include = "renku_data_services/migrations", from = "../../components" }, { include = "renku_data_services/solr", from = "../../components" }, From 814be8fa74ab2ac4054b1d5234e4b4b1e71b7b83 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 3 Jun 2026 13:01:12 +0200 Subject: [PATCH 17/28] keep K8s types out of core.py and repository --- .../data_api/dependencies.py | 16 ++++- .../renku_data_services/renku_apps/core.py | 68 ++++++------------- .../renku_apps/k8s_client.py | 58 +++++++++++++--- .../renku_data_services/renku_apps/models.py | 18 +++++ .../renku_apps/repository.py | 14 ++-- 5 files changed, 109 insertions(+), 65 deletions(-) diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index e6b5d9a50..43e709312 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -263,7 +263,13 @@ def from_env(cls) -> DependencyManager: default_kubeconfig=default_kubeconfig, cluster_repo=cluster_repo, cache=k8s_db_cache, - kinds_to_cache=[AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK, BUILD_RUN_GVK, TASK_RUN_GVK, KNATIVE_SERVICE_GVK], + kinds_to_cache=[ + AMALTHEA_SESSION_GVK, + JUPYTER_SESSION_GVK, + BUILD_RUN_GVK, + TASK_RUN_GVK, + KNATIVE_SERVICE_GVK, + ], ), ) quota_repo = QuotaRepository(K8sResourceQuotaClient(client), K8sPriorityClassClient(client)) @@ -310,7 +316,13 @@ def from_env(cls) -> DependencyManager: default_kubeconfig=default_kubeconfig, cluster_repo=cluster_repo, cache=k8s_db_cache, - kinds_to_cache=[AMALTHEA_SESSION_GVK, JUPYTER_SESSION_GVK, BUILD_RUN_GVK, TASK_RUN_GVK, KNATIVE_SERVICE_GVK], + kinds_to_cache=[ + AMALTHEA_SESSION_GVK, + JUPYTER_SESSION_GVK, + BUILD_RUN_GVK, + TASK_RUN_GVK, + KNATIVE_SERVICE_GVK, + ], ), ), namespace=config.k8s_namespace, diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index 489d4480e..9f1a2ffeb 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -1,55 +1,31 @@ """Business logic for Renku apps.""" -from datetime import datetime - -from renku_data_services.renku_apps import models -from renku_data_services.renku_apps.cr_knative_service import Condition -from renku_data_services.renku_apps.crs import KnativeService +from renku_data_services.renku_apps.models import App, AppRuntimeState, AppStatus from renku_data_services.session.models import SessionLauncher -def knative_service_to_app(session_launcher: SessionLauncher, knative_service: KnativeService) -> models.App: - """Convert a Knative service to an app.""" - return models.App( - name=knative_service.metadata.name, - launcher_id=session_launcher.id, - project_id=session_launcher.project_id, - status=_project_app_status(knative_service), - url=_url(knative_service), - started=_started_at(knative_service), - image=session_launcher.environment.container_image, +def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App: + """Compose an App from its launcher and the runtime state observed in the cluster.""" + return App( + name=runtime.name, + launcher_id=launcher.id, + project_id=launcher.project_id, + status=app_status_from_ready(runtime.ready_status), + url=runtime.url, + started=runtime.started_at, + image=launcher.environment.container_image, ) -def _url(knative_service: KnativeService) -> str | None: - """Get the public URL Knative assigned to the service, or None if it is not yet routed.""" - if knative_service.status is None: - return None - return knative_service.status.url - - -def _ready_condition(knative_service: KnativeService) -> Condition | None: - """Get the Ready condition from a Knative service, or None if it doesn't exist.""" - if knative_service.status is None or not knative_service.status.conditions: - return None - return next((c for c in knative_service.status.conditions if c.type == "Ready"), None) - - -def _started_at(knative_service: KnativeService) -> datetime | None: - """Get the time the Knative service became Ready, or None if not yet ready.""" - ready = _ready_condition(knative_service) - if ready is None or ready.status != "True" or ready.lastTransitionTime is None: - return None - return datetime.fromisoformat(ready.lastTransitionTime) - +def app_status_from_ready(ready_status: str | None) -> AppStatus: + """Map a Kubernetes Ready-condition status value to an app status. -def _project_app_status(knative_service: KnativeService) -> models.AppStatus: - """Convert a Knative service's Ready condition into an app status.""" - ready = _ready_condition(knative_service) - if ready is None: - return models.AppStatus("pending") - if ready.status == "True": - return models.AppStatus("ready") - if ready.status == "False": - return models.AppStatus("failed") - return models.AppStatus("pending") + Inputs follow the Kubernetes condition convention: "True", "False", + "Unknown", or None when the condition is absent. Unknown and absent + both collapse to PENDING. + """ + if ready_status == "True": + return AppStatus.READY + if ready_status == "False": + return AppStatus.FAILED + return AppStatus.PENDING diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 506f7e5af..70abc6647 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -1,6 +1,7 @@ """K8s client wrapper for Renku apps.""" from collections.abc import AsyncGenerator +from datetime import datetime from typing import Any from renku_data_services.crc.db import ClusterRepository @@ -9,7 +10,9 @@ from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, DUMMY_RENKU_APP_USER_ID, ClusterId from renku_data_services.k8s.models import GVK, K8sObjectMeta from renku_data_services.project.models import Project +from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService +from renku_data_services.renku_apps.models import AppRuntimeState from renku_data_services.session.models import SessionLauncher KNATIVE_SERVICE_GVK = GVK(kind="Service", group="serving.knative.dev", version="v1") @@ -36,8 +39,8 @@ def __init__(self, client: K8sClusterClientsPool, cluster_repo: ClusterRepositor async def create_app_deployment( self, session_launcher: SessionLauncher, resource_class: ResourceClass | None, project: Project - ) -> KnativeService: - """Create a deployment for the given app and return the created Knative Service.""" + ) -> AppRuntimeState: + """Create a deployment for the given app and return its observed runtime state.""" cluster_id: ClusterId = DEFAULT_K8S_CLUSTER cluster = await self.__client.cluster_by_id(cluster_id) app_name = _generate_app_name(project) @@ -52,10 +55,10 @@ async def create_app_deployment( created = await self.__client.create( meta.with_manifest(manifest.model_dump(exclude_none=True, mode="json")), refresh=True ) - return KnativeService.model_validate(created.manifest) + return _extract_runtime_state(KnativeService.model_validate(created.manifest)) - async def get_app_deployment(self, app_name: str) -> KnativeService | None: - """Get the deployment for the given app name, or None if it does not exist.""" + async def get_app_deployment(self, app_name: str) -> AppRuntimeState | None: + """Get the runtime state for the given app name, or None if it does not exist.""" cluster_id: ClusterId = DEFAULT_K8S_CLUSTER cluster = await self.__client.cluster_by_id(cluster_id) meta = K8sObjectMeta( @@ -68,21 +71,21 @@ async def get_app_deployment(self, app_name: str) -> KnativeService | None: obj = await self.__client.get(meta) if obj is None: return None - return KnativeService.model_validate(obj.manifest) + return _extract_runtime_state(KnativeService.model_validate(obj.manifest)) - async def get_app_deployment_for_project(self, project: Project) -> KnativeService | None: - """Get the app deployment for the given project, or None if it does not exist.""" + async def get_app_deployment_for_project(self, project: Project) -> AppRuntimeState | None: + """Get the runtime state for the given project's app, or None if it does not exist.""" return await self.get_app_deployment(_generate_app_name(project)) async def delete_app_deployment(self, app_name: str) -> None: """Delete the deployment for the given app name. NOT IMPLEMENTED.""" raise NotImplementedError("Deleting app deployment is not implemented yet") - async def list_app_deployments(self) -> AsyncGenerator[KnativeService, None]: + async def list_app_deployments(self) -> AsyncGenerator[AppRuntimeState, None]: """List all app deployments. NOT IMPLEMENTED.""" raise NotImplementedError("Listing app deployments is not implemented yet") - async def update_app_deployment(self, app_name: str, session_launcher: SessionLauncher) -> KnativeService: + async def update_app_deployment(self, app_name: str, session_launcher: SessionLauncher) -> AppRuntimeState: """Update the deployment for the given app name. NOT IMPLEMENTED.""" raise NotImplementedError("Updating app deployment is not implemented yet") @@ -142,3 +145,38 @@ def _build_app_deployment_manifest( }, } ) + + +def _url(knative_service: KnativeService) -> str | None: + """Get the public URL Knative assigned to the service, or None if it is not yet routed.""" + if knative_service.status is None: + return None + return knative_service.status.url + + +def _ready_condition(knative_service: KnativeService) -> Condition | None: + """Get the Ready condition from a Knative service, or None if it doesn't exist.""" + if knative_service.status is None or not knative_service.status.conditions: + return None + return next((c for c in knative_service.status.conditions if c.type == "Ready"), None) + + +def _started_at(knative_service: KnativeService) -> datetime | None: + """Get the time the Knative service became Ready, or None if not yet ready.""" + ready = _ready_condition(knative_service) + if ready is None or ready.status != "True" or ready.lastTransitionTime is None: + return None + return datetime.fromisoformat(ready.lastTransitionTime) + + +def _extract_runtime_state(knative_service: KnativeService) -> AppRuntimeState: + """Read app runtime state primitives off a Knative Service.""" + ready = _ready_condition(knative_service) + return AppRuntimeState( + name=knative_service.metadata.name, + launcher_id=knative_service.launcher_id, + project_id=knative_service.project_id, + ready_status=ready.status if ready is not None else None, + url=_url(knative_service), + started_at=_started_at(knative_service), + ) diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index 7accfeedf..eb04fc1bc 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -40,3 +40,21 @@ def as_apispec(self) -> apispec.AppResponse: started=self.started, image=self.image, ) + + +@dataclass(frozen=True, kw_only=True) +class AppRuntimeState: + """Runtime state of an app deployment, as observed in the cluster. + + Carries the primitives that the K8s adapter extracts from a Knative Service + so that domain logic can compose an App without depending on K8s types. + The ready_status field holds the raw Kubernetes Ready-condition status value + ("True", "False", "Unknown", or None if the condition is absent). + """ + + name: str + launcher_id: ULID + project_id: ULID + ready_status: str | None + url: str | None + started_at: datetime | None diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 30be6c088..a81cf92d0 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -9,7 +9,7 @@ from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.crc.models import ResourceClass from renku_data_services.project.db import ProjectRepository -from renku_data_services.renku_apps.core import knative_service_to_app +from renku_data_services.renku_apps.core import build_app from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient from renku_data_services.renku_apps.models import App from renku_data_services.session.db import SessionRepository @@ -52,16 +52,16 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: project = await self.project_repo.get_project(user, launcher.project_id) if await self.k8s_client.get_app_deployment_for_project(project) is not None: raise errors.ConflictError(message=f"An app already exists for project '{launcher.project_id}'.") - service = await self.k8s_client.create_app_deployment(launcher, resource_class, project) - return knative_service_to_app(launcher, service) + runtime_state = await self.k8s_client.create_app_deployment(launcher, resource_class, project) + return build_app(launcher, runtime_state) async def get_app(self, user: base_models.APIUser, app_name: str) -> App: """Retrieve an app by its name.""" - service = await self.k8s_client.get_app_deployment(app_name) - if service is None: + runtime_state = await self.k8s_client.get_app_deployment(app_name) + if runtime_state is None: raise errors.MissingResourceError( message=f"App with name '{app_name}' does not exist or you do not have access to it." ) - launcher = await self.session_repo.get_launcher(user, service.launcher_id) - return knative_service_to_app(launcher, service) + launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) + return build_app(launcher, runtime_state) From 3b2dbcfc3bc8963398ed903f310609b3d85f73ed Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 3 Jun 2026 14:26:46 +0200 Subject: [PATCH 18/28] restore oci_schema target --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8f2641bc2..1fb0fa34c 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ API_SPECS := \ components/renku_data_services/notifications/apispec.py \ components/renku_data_services/capacity_reservation/apispec.py \ components/renku_data_services/resource_usage/apispec.py \ - components/renku_data_services/renku_apps/apispec.py \ + components/renku_data_services/renku_apps/apispec.py \ components/renku_data_services/authn/api/apispec.py schemas: ${API_SPECS} ## Generate pydantic classes from apispec yaml files @@ -125,6 +125,14 @@ shipwright_schema: ## Updates the Shipwright pydantic classes knative_serving_schema: ## Updates the Knative Serving pydantic classes curl https://raw.githubusercontent.com/knative/serving/refs/tags/${KNATIVE_SERVING_VERSION}/config/core/300-resources/service.yaml | yq '.spec.versions[] | select(.name == "v1") | .schema.openAPIV3Schema' | poetry run datamodel-codegen --output components/renku_data_services/renku_apps/cr_knative_service.py --base-class renku_data_services.renku_apps.cr_base.BaseCRD ${CR_CODEGEN_PARAMS} +.PHONY: oci_schema +oci_schema: ## Updates the OCI classes + poetry run datamodel-codegen --url "https://raw.githubusercontent.com/opencontainers/image-spec/26647a49f642c7d22a1cd3aa0a48e4650a542269/schema/config-schema.json" --output components/renku_data_services/notebooks/oci/image_config.py --class-name ImageConfig --base-class renku_data_services.notebooks.oci.base_model.BaseOciModel ${CR_CODEGEN_PARAMS} + poetry run datamodel-codegen --url "https://raw.githubusercontent.com/opencontainers/image-spec/refs/tags/v1.1.1/schema/image-index-schema.json" --output components/renku_data_services/notebooks/oci/image_index.py --class-name ImageIndex --base-class renku_data_services.notebooks.oci.base_model.BaseOciModel ${CR_CODEGEN_PARAMS} + poetry run datamodel-codegen --url "https://raw.githubusercontent.com/opencontainers/image-spec/refs/tags/v1.1.1/schema/image-manifest-schema.json" --output components/renku_data_services/notebooks/oci/image_manifest.py --class-name ImageManifest --base-class renku_data_services.notebooks.oci.base_model.BaseOciModel ${CR_CODEGEN_PARAMS} + +##@ Devcontainer + .PHONY: devcontainer_up devcontainer_up: ## Start dev containers devcontainer up --workspace-folder . From 651da95539ca44bdcee515e1fcd0353b14173689 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Thu, 4 Jun 2026 16:40:43 +0200 Subject: [PATCH 19/28] address PR review comments --- bases/renku_data_services/data_api/app.py | 17 +++--- bases/renku_data_services/data_api/config.py | 3 ++ .../data_api/dependencies.py | 23 ++++---- bases/renku_data_services/k8s_cache/config.py | 16 ++++++ bases/renku_data_services/k8s_cache/main.py | 4 +- .../renku_data_services/renku_apps/config.py | 17 ++++++ .../renku_data_services/renku_apps/crs.py | 16 +++--- .../renku_apps/k8s_client.py | 52 ++++++++++++------- .../renku_apps/repository.py | 2 +- 9 files changed, 105 insertions(+), 45 deletions(-) create mode 100644 components/renku_data_services/renku_apps/config.py diff --git a/bases/renku_data_services/data_api/app.py b/bases/renku_data_services/data_api/app.py index 8f64c3cee..8ba9e2441 100644 --- a/bases/renku_data_services/data_api/app.py +++ b/bases/renku_data_services/data_api/app.py @@ -178,11 +178,15 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: authenticator=dm.authenticator, metrics=dm.metrics, ) - renku_apps = RenkuAppBP( - name="renku_apps", - url_prefix=url_prefix, - apps_repo=dm.apps_repo, - authenticator=dm.authenticator, + renku_apps = ( + RenkuAppBP( + name="renku_apps", + url_prefix=url_prefix, + apps_repo=dm.apps_repo, + authenticator=dm.authenticator, + ) + if dm.config.apps.enabled and dm.apps_repo is not None + else None ) builds = ( BuildsBP( @@ -340,7 +344,6 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: group.blueprint(), session_environments.blueprint(), session_launchers.blueprint(), - renku_apps.blueprint(), oauth2_clients.blueprint(), oauth2_connections.blueprint(), repositories.blueprint(), @@ -357,6 +360,8 @@ def register_all_handlers(app: Sanic, dm: DependencyManager) -> Sanic: ) if builds is not None: app.blueprint(builds.blueprint()) + if renku_apps is not None: + app.blueprint(renku_apps.blueprint()) # We need to patch sanic_ext as since version 24.12 they only send a string representation of errors import sanic_ext.extras.validation.setup diff --git a/bases/renku_data_services/data_api/config.py b/bases/renku_data_services/data_api/config.py index 045176de5..565505904 100644 --- a/bases/renku_data_services/data_api/config.py +++ b/bases/renku_data_services/data_api/config.py @@ -17,6 +17,7 @@ from renku_data_services.data_connectors.config import DepositConfig from renku_data_services.db_config.config import DBConfig from renku_data_services.notebooks.config import NotebooksConfig +from renku_data_services.renku_apps.config import AppsConfig from renku_data_services.secrets.config import PublicSecretsConfig from renku_data_services.session.config import BuildsConfig from renku_data_services.solr.solr_client import SolrClientConfig @@ -33,6 +34,7 @@ class Config: k8s_config_root: str db: DBConfig builds: BuildsConfig + apps: AppsConfig nb_config: NotebooksConfig secrets: PublicSecretsConfig sentry: SentryConfig @@ -81,6 +83,7 @@ def from_env(cls, db: DBConfig | None = None) -> Self: k8s_config_root=os.environ.get("K8S_CONFIGS_ROOT", "/secrets/kube_configs"), db=db, builds=BuildsConfig.from_env(), + apps=AppsConfig.from_env(), nb_config=nb_config, secrets=PublicSecretsConfig.from_env(), sentry=SentryConfig.from_env(), diff --git a/bases/renku_data_services/data_api/dependencies.py b/bases/renku_data_services/data_api/dependencies.py index 43e709312..4c3f94bb2 100644 --- a/bases/renku_data_services/data_api/dependencies.py +++ b/bases/renku_data_services/data_api/dependencies.py @@ -148,8 +148,8 @@ class DependencyManager: search_updates_repo: SearchUpdatesRepo search_reprovisioning: SearchReprovision session_repo: SessionRepository - apps_k8s_client: RenkuAppsK8sClient - apps_repo: RenkuAppsRepository + apps_k8s_client: RenkuAppsK8sClient | None + apps_repo: RenkuAppsRepository | None user_preferences_repo: UserPreferencesRepository kc_user_repo: KcUserRepo low_level_user_secrets_repo: LowLevelUserSecretsRepo @@ -402,14 +402,17 @@ def from_env(cls) -> DependencyManager: builds_config=config.builds, git_repositories_repo=git_repositories_repo, ) - apps_k8s_client = RenkuAppsK8sClient(client=client, cluster_repo=cluster_repo) - apps_repo = RenkuAppsRepository( - authz=authz, - session_repo=session_repo, - rp_repo=rp_repo, - project_repo=project_repo, - k8s_client=apps_k8s_client, - ) + apps_k8s_client: RenkuAppsK8sClient | None = None + apps_repo: RenkuAppsRepository | None = None + if config.apps.enabled: + apps_k8s_client = RenkuAppsK8sClient(client=client, cluster_repo=cluster_repo) + apps_repo = RenkuAppsRepository( + authz=authz, + session_repo=session_repo, + rp_repo=rp_repo, + project_repo=project_repo, + k8s_client=apps_k8s_client, + ) project_migration_repo = ProjectMigrationRepository( session_maker=config.db.async_session_maker, authz=authz, diff --git a/bases/renku_data_services/k8s_cache/config.py b/bases/renku_data_services/k8s_cache/config.py index dfedf00f3..250a8a28b 100644 --- a/bases/renku_data_services/k8s_cache/config.py +++ b/bases/renku_data_services/k8s_cache/config.py @@ -65,6 +65,19 @@ def from_env(cls) -> _V1ServicesConfig: return cls(enabled=enabled) +@dataclass +class _AppsConfig: + """Configuration for Renku apps.""" + + enabled: bool + + @classmethod + def from_env(cls) -> _AppsConfig: + """Load values from environment variables.""" + enabled = os.environ.get("APPS_ENABLED", "false").lower() == "true" + return cls(enabled=enabled) + + @dataclass class Config: """K8s cache config.""" @@ -74,6 +87,7 @@ class Config: metrics: _MetricsConfig image_builders: _ImageBuilderConfig v1_services: _V1ServicesConfig + apps: _AppsConfig sentry: SentryConfig @classmethod @@ -84,6 +98,7 @@ def from_env(cls) -> Config: metrics = _MetricsConfig.from_env() image_builders = _ImageBuilderConfig.from_env() v1_services = _V1ServicesConfig.from_env() + apps = _AppsConfig.from_env() sentry = SentryConfig.from_env() return cls( db=db, @@ -91,5 +106,6 @@ def from_env(cls) -> Config: metrics=metrics, image_builders=image_builders, v1_services=v1_services, + apps=apps, sentry=sentry, ) diff --git a/bases/renku_data_services/k8s_cache/main.py b/bases/renku_data_services/k8s_cache/main.py index 4b9f88d51..ab486fe3b 100644 --- a/bases/renku_data_services/k8s_cache/main.py +++ b/bases/renku_data_services/k8s_cache/main.py @@ -48,11 +48,13 @@ async def main() -> None: ): clusters[client.get_cluster().id] = client - kinds = [AMALTHEA_SESSION_GVK, KNATIVE_SERVICE_GVK] + kinds = [AMALTHEA_SESSION_GVK] if dm.config.v1_services.enabled: kinds.append(JUPYTER_SESSION_GVK) if dm.config.image_builders.enabled: kinds.extend([BUILD_RUN_GVK, TASK_RUN_GVK]) + if dm.config.apps.enabled: + kinds.append(KNATIVE_SERVICE_GVK) logger.info(f"Resources: {kinds}") watcher = K8sWatcher( handler=k8s_object_handler(dm.k8s_cache(), dm.metrics(), rp_repo=dm.rp_repo()), diff --git a/components/renku_data_services/renku_apps/config.py b/components/renku_data_services/renku_apps/config.py new file mode 100644 index 000000000..52b5820ef --- /dev/null +++ b/components/renku_data_services/renku_apps/config.py @@ -0,0 +1,17 @@ +"""Configuration for Renku apps.""" + +import os +from dataclasses import dataclass + + +@dataclass +class AppsConfig: + """Configuration for Renku apps.""" + + enabled: bool = False + + @classmethod + def from_env(cls) -> "AppsConfig": + """Create a config from environment variables.""" + enabled = os.environ.get("APPS_ENABLED", "false").lower() == "true" + return cls(enabled=enabled) diff --git a/components/renku_data_services/renku_apps/crs.py b/components/renku_data_services/renku_apps/crs.py index 2f921ef3f..20e513bc5 100644 --- a/components/renku_data_services/renku_apps/crs.py +++ b/components/renku_data_services/renku_apps/crs.py @@ -69,18 +69,18 @@ class KnativeService(_KnativeService): @property def launcher_id(self) -> ULID: - """Get the session launcher ID from the annotations.""" - if "renku.io/launcher_id" not in self.metadata.annotations: + """Get the session launcher ID from the labels.""" + if "renku.io/launcher-id" not in self.metadata.labels: raise errors.ProgrammingError( - message=f"The app with name {self.metadata.name} is missing its launcher_id annotation" + message=f"The app with name {self.metadata.name} is missing its launcher-id label" ) - return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/launcher_id"])) + return cast(ULID, ULID.from_str(self.metadata.labels["renku.io/launcher-id"])) @property def project_id(self) -> ULID: - """Get the project ID from the annotations.""" - if "renku.io/project_id" not in self.metadata.annotations: + """Get the project ID from the labels.""" + if "renku.io/project-id" not in self.metadata.labels: raise errors.ProgrammingError( - message=f"The app with name {self.metadata.name} is missing its project_id annotation" + message=f"The app with name {self.metadata.name} is missing its project-id label" ) - return cast(ULID, ULID.from_str(self.metadata.annotations["renku.io/project_id"])) + return cast(ULID, ULID.from_str(self.metadata.labels["renku.io/project-id"])) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 70abc6647..737aae1fd 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -24,27 +24,32 @@ } -def _generate_app_name(project: Project) -> str: - """Generate a DNS-1035 label name for an app from its project path.""" - namespace = project.namespace.path.serialize().replace("/", "-") - return f"{project.slug}-{namespace}".lower()[:63] +def _generate_app_name(project: Project, session_launcher: SessionLauncher) -> str: + """Generate a DNS-1035 label name for an app.""" + launcher_id_slice = str(session_launcher.id)[18:26].lower() + return f"{project.slug.lower()[:54]}-{launcher_id_slice}" class RenkuAppsK8sClient: """K8s client for Renku apps operations.""" - def __init__(self, client: K8sClusterClientsPool, cluster_repo: ClusterRepository) -> None: + def __init__( + self, + client: K8sClusterClientsPool, + cluster_repo: ClusterRepository, + cluster_id: ClusterId = DEFAULT_K8S_CLUSTER, + ) -> None: self.__client = client self.__cluster_repo = cluster_repo + self.__cluster_id = cluster_id async def create_app_deployment( self, session_launcher: SessionLauncher, resource_class: ResourceClass | None, project: Project ) -> AppRuntimeState: """Create a deployment for the given app and return its observed runtime state.""" - cluster_id: ClusterId = DEFAULT_K8S_CLUSTER - cluster = await self.__client.cluster_by_id(cluster_id) - app_name = _generate_app_name(project) - manifest = _build_app_deployment_manifest(session_launcher, app_name, resource_class) + cluster = await self.__client.cluster_by_id(self.__cluster_id) + app_name = _generate_app_name(project, session_launcher) + manifest = _build_app_deployment_manifest(session_launcher, app_name, resource_class, project) meta = K8sObjectMeta( name=app_name, namespace=cluster.namespace, @@ -59,8 +64,7 @@ async def create_app_deployment( async def get_app_deployment(self, app_name: str) -> AppRuntimeState | None: """Get the runtime state for the given app name, or None if it does not exist.""" - cluster_id: ClusterId = DEFAULT_K8S_CLUSTER - cluster = await self.__client.cluster_by_id(cluster_id) + cluster = await self.__client.cluster_by_id(self.__cluster_id) meta = K8sObjectMeta( name=app_name, namespace=cluster.namespace, @@ -73,9 +77,11 @@ async def get_app_deployment(self, app_name: str) -> AppRuntimeState | None: return None return _extract_runtime_state(KnativeService.model_validate(obj.manifest)) - async def get_app_deployment_for_project(self, project: Project) -> AppRuntimeState | None: + async def get_app_deployment_for_project( + self, project: Project, session_launcher: SessionLauncher + ) -> AppRuntimeState | None: """Get the runtime state for the given project's app, or None if it does not exist.""" - return await self.get_app_deployment(_generate_app_name(project)) + return await self.get_app_deployment(_generate_app_name(project, session_launcher)) async def delete_app_deployment(self, app_name: str) -> None: """Delete the deployment for the given app name. NOT IMPLEMENTED.""" @@ -102,7 +108,7 @@ def _resources_from_resource_class(resource_class: ResourceClass) -> dict[str, A def _build_app_deployment_manifest( - session_launcher: SessionLauncher, app_name: str, resource_class: ResourceClass | None + session_launcher: SessionLauncher, app_name: str, resource_class: ResourceClass | None, project: Project ) -> KnativeService: """Build a Knative Service manifest derived from the session launcher.""" environment = session_launcher.environment @@ -126,20 +132,28 @@ def _build_app_deployment_manifest( if environment.working_directory is not None: container["workingDir"] = str(environment.working_directory) + labels = { + "renku.io/safe-username": DUMMY_RENKU_APP_USER_ID, + "renku.io/project-slug": project.slug.lower(), + "renku.io/project-namespace": project.namespace.path.serialize().replace("/", "-").lower(), + "renku.io/project-id": str(project.id), + "renku.io/launcher-id": str(session_launcher.id), + } + return KnativeService.model_validate( { "apiVersion": "serving.knative.dev/v1", "kind": "Service", "metadata": { "name": app_name, - "annotations": { - "renku.io/launcher_id": str(session_launcher.id), - "renku.io/project_id": str(session_launcher.project_id), - }, + "labels": labels, }, "spec": { "template": { - "metadata": {"annotations": _APP_AUTOSCALING_ANNOTATIONS}, + "metadata": { + "labels": labels, + "annotations": _APP_AUTOSCALING_ANNOTATIONS, + }, "spec": {"containers": [container]}, }, }, diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index a81cf92d0..4f120ec58 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -50,7 +50,7 @@ async def create_app(self, user: base_models.APIUser, launcher_id: ULID) -> App: resource_class = await self.rp_repo.get_resource_class(user, launcher.resource_class_id) project = await self.project_repo.get_project(user, launcher.project_id) - if await self.k8s_client.get_app_deployment_for_project(project) is not None: + if await self.k8s_client.get_app_deployment_for_project(project, launcher) is not None: raise errors.ConflictError(message=f"An app already exists for project '{launcher.project_id}'.") runtime_state = await self.k8s_client.create_app_deployment(launcher, resource_class, project) return build_app(launcher, runtime_state) From 65ca6ec3fa01cda4a41caef50c2e825edc7e25e2 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 1 Jun 2026 14:17:53 +0200 Subject: [PATCH 20/28] add delete to api spec --- .../renku_data_services/renku_apps/api.spec.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index 4e95330e7..19d38d7b1 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -48,6 +48,22 @@ paths: $ref: "#/components/responses/Error" tags: - apps + delete: + summary: Delete an app + parameters: + - in: path + name: app_name + required: true + schema: + $ref: "#/components/schemas/AppName" + description: The name of the app to delete + responses: + "204": + description: The app was successfully deleted or did not exist + default: + $ref: "#/components/responses/Error" + tags: + - apps components: schemas: Ulid: From 6c6be10fc703974635bd83504291ce73b9271102 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 1 Jun 2026 14:56:15 +0200 Subject: [PATCH 21/28] add delete app function to repository --- .../renku_apps/repository.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index 4f120ec58..dad27b50a 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -4,6 +4,7 @@ import renku_data_services.base_models as base_models from renku_data_services import errors +from renku_data_services.app_config import logger from renku_data_services.authz.authz import Authz, ResourceType from renku_data_services.authz.models import Scope from renku_data_services.crc.db import ResourcePoolRepository @@ -65,3 +66,25 @@ async def get_app(self, user: base_models.APIUser, app_name: str) -> App: launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) return build_app(launcher, runtime_state) + + async def delete_app(self, user: base_models.APIUser, app_name: str) -> None: + """Delete an app by its name.""" + if not user.is_authenticated or user.id is None: + raise errors.UnauthorizedError(message="You do not have the required permissions for this operation.") + + runtime_state = await self.k8s_client.get_app_deployment(app_name) + if runtime_state is None: + logger.info(f"App with name {app_name} was not found.") + return None + + launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) + + authorized = await self.authz.has_permission(user, ResourceType.project, launcher.project_id, Scope.WRITE) + if not authorized: + raise errors.MissingResourceError( + message=f"App with name '{app_name}' does not exist or you do not have authorization to modify it." + ) + + await self.k8s_client.delete_app_deployment(app_name) + logger.info(f"App with name {app_name} has been deleted.") + return None From 73e7473b7e613960792e9815324184f6cacaf4f7 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 1 Jun 2026 16:01:52 +0200 Subject: [PATCH 22/28] add delete apps endpoint --- .../renku_data_services/renku_apps/blueprints.py | 13 ++++++++++++- .../renku_data_services/renku_apps/k8s_client.py | 14 ++++++++++++-- .../renku_data_services/renku_apps/repository.py | 4 +++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/components/renku_data_services/renku_apps/blueprints.py b/components/renku_data_services/renku_apps/blueprints.py index 75a4bdf07..62d09688c 100644 --- a/components/renku_data_services/renku_apps/blueprints.py +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -2,7 +2,7 @@ from dataclasses import dataclass -from sanic import Request +from sanic import HTTPResponse, Request from sanic.response import JSONResponse, json from sanic_ext import validate from ulid import ULID @@ -42,3 +42,14 @@ async def _get_one(_: Request, user: base_models.APIUser, app_name: str) -> JSON return json(app.as_apispec().model_dump(exclude_none=True, mode="json")) return "/apps/", ["GET"], _get_one + + def delete_one(self) -> BlueprintFactoryResponse: + """Delete an app by name.""" + + @authenticate(self.authenticator) + @only_authenticated + async def _delete_one(_: Request, user: base_models.APIUser, app_name: str) -> HTTPResponse: + await self.apps_repo.delete_app(user=user, app_name=app_name) + return HTTPResponse(status=204) + + return "/apps/", ["DELETE"], _delete_one diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 737aae1fd..5ebbf4db4 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -84,8 +84,18 @@ async def get_app_deployment_for_project( return await self.get_app_deployment(_generate_app_name(project, session_launcher)) async def delete_app_deployment(self, app_name: str) -> None: - """Delete the deployment for the given app name. NOT IMPLEMENTED.""" - raise NotImplementedError("Deleting app deployment is not implemented yet") + """Delete the deployment for the given app name.""" + cluster_id: ClusterId = DEFAULT_K8S_CLUSTER + cluster = await self.__client.cluster_by_id(cluster_id) + meta = K8sObjectMeta( + name=app_name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + ) + await self.__client.delete(meta) + return None async def list_app_deployments(self) -> AsyncGenerator[AppRuntimeState, None]: """List all app deployments. NOT IMPLEMENTED.""" diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index dad27b50a..a1ed32b63 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -4,7 +4,7 @@ import renku_data_services.base_models as base_models from renku_data_services import errors -from renku_data_services.app_config import logger +from renku_data_services.app_config import logging from renku_data_services.authz.authz import Authz, ResourceType from renku_data_services.authz.models import Scope from renku_data_services.crc.db import ResourcePoolRepository @@ -15,6 +15,8 @@ from renku_data_services.renku_apps.models import App from renku_data_services.session.db import SessionRepository +logger = logging.getLogger(__name__) + class RenkuAppsRepository: """Use-case-focused API for Renku apps, dispatching to k8s rather than SQL.""" From ea0d84d23afb97b97eaf062b608e087ea881c540 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 5 Jun 2026 14:24:03 +0200 Subject: [PATCH 23/28] use injected cluster_id in delete_app_deployment --- components/renku_data_services/renku_apps/k8s_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index 5ebbf4db4..a3a1ff8c7 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -85,8 +85,7 @@ async def get_app_deployment_for_project( async def delete_app_deployment(self, app_name: str) -> None: """Delete the deployment for the given app name.""" - cluster_id: ClusterId = DEFAULT_K8S_CLUSTER - cluster = await self.__client.cluster_by_id(cluster_id) + cluster = await self.__client.cluster_by_id(self.__cluster_id) meta = K8sObjectMeta( name=app_name, namespace=cluster.namespace, @@ -95,7 +94,6 @@ async def delete_app_deployment(self, app_name: str) -> None: user_id=DUMMY_RENKU_APP_USER_ID, ) await self.__client.delete(meta) - return None async def list_app_deployments(self) -> AsyncGenerator[AppRuntimeState, None]: """List all app deployments. NOT IMPLEMENTED.""" From 2861d65c73b2c965a9b9b4faefda486973c47b22 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Fri, 5 Jun 2026 17:23:19 +0200 Subject: [PATCH 24/28] add list and patch to api spec --- .../renku_apps/api.spec.yaml | 65 +++++++++++++++++++ .../renku_data_services/renku_apps/apispec.py | 28 +++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index 19d38d7b1..e62fc359e 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -8,6 +8,26 @@ servers: - url: /api/data paths: /apps: + get: + summary: List apps + parameters: + - in: query + name: project_id + required: false + schema: + $ref: "#/components/schemas/Ulid" + description: If set, only return apps belonging to this project + responses: + "200": + description: The list of apps the caller can see + content: + application/json: + schema: + $ref: "#/components/schemas/AppListResponse" + default: + $ref: "#/components/responses/Error" + tags: + - apps post: summary: Launch a new app requestBody: @@ -48,6 +68,32 @@ paths: $ref: "#/components/responses/Error" tags: - apps + patch: + summary: Update an app + parameters: + - in: path + name: app_name + required: true + schema: + $ref: "#/components/schemas/AppName" + description: The name of the app to update + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AppPatchRequest" + responses: + "200": + description: The updated app + content: + application/json: + schema: + $ref: "#/components/schemas/AppResponse" + default: + $ref: "#/components/responses/Error" + tags: + - apps delete: summary: Delete an app parameters: @@ -79,6 +125,14 @@ components: - pending - ready - failed + - hibernated + + AppState: + description: The desired state of an app. `hibernated` scales the deployment to zero. + type: string + enum: + - running + - hibernated AppName: type: string @@ -114,6 +168,11 @@ components: - status - project_id + AppListResponse: + type: array + items: + $ref: "#/components/schemas/AppResponse" + AppPostRequest: type: object properties: @@ -122,6 +181,12 @@ components: required: - launcher_id + AppPatchRequest: + type: object + properties: + state: + $ref: "#/components/schemas/AppState" + ErrorResponse: type: object properties: diff --git a/components/renku_data_services/renku_apps/apispec.py b/components/renku_data_services/renku_apps/apispec.py index f2e0aef1f..c02a7f459 100644 --- a/components/renku_data_services/renku_apps/apispec.py +++ b/components/renku_data_services/renku_apps/apispec.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2026-05-21T08:38:42+00:00 +# timestamp: 2026-06-05T15:14:57+00:00 from __future__ import annotations from datetime import datetime from enum import Enum -from typing import Optional +from typing import List, Optional from pydantic import Field, RootModel from renku_data_services.renku_apps.apispec_base import BaseAPISpec @@ -16,6 +16,12 @@ class AppStatus(Enum): pending = "pending" ready = "ready" failed = "failed" + hibernated = "hibernated" + + +class AppState(Enum): + running = "running" + hibernated = "hibernated" class AppResponse(BaseAPISpec): @@ -46,6 +52,10 @@ class AppResponse(BaseAPISpec): image: Optional[str] = None +class AppListResponse(RootModel[List[AppResponse]]): + root: List[AppResponse] + + class AppPostRequest(BaseAPISpec): launcher_id: str = Field( ..., @@ -56,6 +66,10 @@ class AppPostRequest(BaseAPISpec): ) +class AppPatchRequest(BaseAPISpec): + state: Optional[AppState] = None + + class Error(BaseAPISpec): code: int = Field(..., examples=[1404], gt=0) detail: Optional[str] = Field( @@ -73,3 +87,13 @@ class Error(BaseAPISpec): class ErrorResponse(BaseAPISpec): error: Error + + +class AppsGetParametersQuery(BaseAPISpec): + project_id: Optional[str] = Field( + None, + description="ULID identifier", + max_length=26, + min_length=26, + pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$", + ) From 1b18a2d820cf70f1cf38154d2eaeb67be460bdd7 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Mon, 8 Jun 2026 15:57:43 +0200 Subject: [PATCH 25/28] implement patching apps --- .../renku_apps/api.spec.yaml | 2 + .../renku_data_services/renku_apps/apispec.py | 3 +- .../renku_apps/blueprints.py | 33 ++++++ .../renku_data_services/renku_apps/core.py | 18 ++-- .../renku_apps/k8s_client.py | 100 ++++++++++++++++-- .../renku_data_services/renku_apps/models.py | 3 + .../renku_apps/repository.py | 54 +++++++++- 7 files changed, 193 insertions(+), 20 deletions(-) diff --git a/components/renku_data_services/renku_apps/api.spec.yaml b/components/renku_data_services/renku_apps/api.spec.yaml index e62fc359e..5bf932eaf 100644 --- a/components/renku_data_services/renku_apps/api.spec.yaml +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -186,6 +186,8 @@ components: properties: state: $ref: "#/components/schemas/AppState" + resource_class_id: + type: integer ErrorResponse: type: object diff --git a/components/renku_data_services/renku_apps/apispec.py b/components/renku_data_services/renku_apps/apispec.py index c02a7f459..ab97ae7b8 100644 --- a/components/renku_data_services/renku_apps/apispec.py +++ b/components/renku_data_services/renku_apps/apispec.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2026-06-05T15:14:57+00:00 +# timestamp: 2026-06-08T12:56:33+00:00 from __future__ import annotations @@ -68,6 +68,7 @@ class AppPostRequest(BaseAPISpec): class AppPatchRequest(BaseAPISpec): state: Optional[AppState] = None + resource_class_id: Optional[int] = None class Error(BaseAPISpec): diff --git a/components/renku_data_services/renku_apps/blueprints.py b/components/renku_data_services/renku_apps/blueprints.py index 62d09688c..b98976418 100644 --- a/components/renku_data_services/renku_apps/blueprints.py +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -53,3 +53,36 @@ async def _delete_one(_: Request, user: base_models.APIUser, app_name: str) -> H return HTTPResponse(status=204) return "/apps/", ["DELETE"], _delete_one + + def patch_one(self) -> BlueprintFactoryResponse: + """Patch an app.""" + + @authenticate(self.authenticator) + @only_authenticated + @validate(json=apispec.AppPatchRequest) + async def _patch_one( + _: Request, user: base_models.APIUser, body: apispec.AppPatchRequest, app_name: str + ) -> JSONResponse: + app = await self.apps_repo.update_app( + user=user, + app_name=app_name, + state=body.state, + resource_class_id=body.resource_class_id, + ) + return json(app.as_apispec().model_dump(exclude_none=True, mode="json")) + + return "/apps/", ["PATCH"], _patch_one + + def get_all(self) -> BlueprintFactoryResponse: + """Get all apps, optionally filtered by project ID.""" + + @authenticate(self.authenticator) + @validate(query=apispec.AppsGetParametersQuery) + async def _get_all( + _: Request, user: base_models.APIUser, query: apispec.AppsGetParametersQuery + ) -> JSONResponse: + project_id = ULID.from_str(query.project_id) if query.project_id is not None else None + apps = await self.apps_repo.list_apps(user=user, project_id=project_id) + return json([app.as_apispec().model_dump(exclude_none=True, mode="json") for app in apps]) + + return "/apps", ["GET"], _get_all diff --git a/components/renku_data_services/renku_apps/core.py b/components/renku_data_services/renku_apps/core.py index 9f1a2ffeb..329943b08 100644 --- a/components/renku_data_services/renku_apps/core.py +++ b/components/renku_data_services/renku_apps/core.py @@ -10,22 +10,20 @@ def build_app(launcher: SessionLauncher, runtime: AppRuntimeState) -> App: name=runtime.name, launcher_id=launcher.id, project_id=launcher.project_id, - status=app_status_from_ready(runtime.ready_status), + status=derive_app_status(runtime), url=runtime.url, started=runtime.started_at, - image=launcher.environment.container_image, + image=runtime.image, ) -def app_status_from_ready(ready_status: str | None) -> AppStatus: - """Map a Kubernetes Ready-condition status value to an app status. +def derive_app_status(runtime: AppRuntimeState) -> AppStatus: + """Derive an app status from the runtime state.""" - Inputs follow the Kubernetes condition convention: "True", "False", - "Unknown", or None when the condition is absent. Unknown and absent - both collapse to PENDING. - """ - if ready_status == "True": + if runtime.is_hibernated: + return AppStatus.HIBERNATED + if runtime.ready_status == "True": return AppStatus.READY - if ready_status == "False": + if runtime.ready_status == "False": return AppStatus.FAILED return AppStatus.PENDING diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index a3a1ff8c7..a6609c531 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -1,14 +1,18 @@ """K8s client wrapper for Renku apps.""" +from __future__ import annotations + from collections.abc import AsyncGenerator from datetime import datetime from typing import Any +from ulid import ULID + from renku_data_services.crc.db import ClusterRepository from renku_data_services.crc.models import ResourceClass from renku_data_services.k8s.clients import K8sClusterClientsPool from renku_data_services.k8s.constants import DEFAULT_K8S_CLUSTER, DUMMY_RENKU_APP_USER_ID, ClusterId -from renku_data_services.k8s.models import GVK, K8sObjectMeta +from renku_data_services.k8s.models import GVK, K8sObjectFilter, K8sObjectMeta from renku_data_services.project.models import Project from renku_data_services.renku_apps.cr_knative_service import Condition from renku_data_services.renku_apps.crs import KnativeService @@ -17,9 +21,13 @@ KNATIVE_SERVICE_GVK = GVK(kind="Service", group="serving.knative.dev", version="v1") +_MAX_SCALE_ANNOTATION = "autoscaling.knative.dev/max-scale" +_MAX_SCALE_RUNNING = "3" +_MAX_SCALE_HIBERNATED = "0" + _APP_AUTOSCALING_ANNOTATIONS = { "autoscaling.knative.dev/min-scale": "0", - "autoscaling.knative.dev/max-scale": "3", + _MAX_SCALE_ANNOTATION: _MAX_SCALE_RUNNING, "autoscaling.knative.dev/scale-to-zero-pod-retention-period": "2m", } @@ -95,13 +103,62 @@ async def delete_app_deployment(self, app_name: str) -> None: ) await self.__client.delete(meta) - async def list_app_deployments(self) -> AsyncGenerator[AppRuntimeState, None]: - """List all app deployments. NOT IMPLEMENTED.""" - raise NotImplementedError("Listing app deployments is not implemented yet") + async def list_app_deployments(self, project_id: ULID | None = None) -> AsyncGenerator[AppRuntimeState, None]: + """List all app deployments.""" + cluster = await self.__client.cluster_by_id(self.__cluster_id) + obj_filter = K8sObjectFilter( + name=None, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + label_selector={"renku.io/project-id": str(project_id)} if project_id is not None else None, + ) + async for obj in self.__client.list(obj_filter): + yield _extract_runtime_state(KnativeService.model_validate(obj.manifest)) - async def update_app_deployment(self, app_name: str, session_launcher: SessionLauncher) -> AppRuntimeState: - """Update the deployment for the given app name. NOT IMPLEMENTED.""" - raise NotImplementedError("Updating app deployment is not implemented yet") + async def hibernate_app_deployment(self, app_name: str) -> AppRuntimeState: + """Hibernate the app by patching its max-scale annotation to zero.""" + return await self._patch_max_scale(app_name, _MAX_SCALE_HIBERNATED) + + async def resume_app_deployment(self, app_name: str) -> AppRuntimeState: + """Resume the app by restoring the default max-scale annotation.""" + return await self._patch_max_scale(app_name, _MAX_SCALE_RUNNING) + + async def set_app_deployment_resources(self, app_name: str, resource_class: ResourceClass) -> AppRuntimeState: + """Update the container resources of the app to match the given resource class.""" + cluster = await self.__client.cluster_by_id(self.__cluster_id) + meta = K8sObjectMeta( + name=app_name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + ) + patch_body: dict[str, Any] = { + "spec": { + "template": { + "spec": {"containers": [{"resources": _resources_from_resource_class(resource_class)}]}, + } + } + } + updated = await self.__client.patch(meta, patch_body) + return _extract_runtime_state(KnativeService.model_validate(updated.manifest)) + + async def _patch_max_scale(self, app_name: str, max_scale: str) -> AppRuntimeState: + cluster = await self.__client.cluster_by_id(self.__cluster_id) + meta = K8sObjectMeta( + name=app_name, + namespace=cluster.namespace, + cluster=cluster.id, + gvk=KNATIVE_SERVICE_GVK, + user_id=DUMMY_RENKU_APP_USER_ID, + ) + patch_body: dict[str, Any] = { + "spec": {"template": {"metadata": {"annotations": {_MAX_SCALE_ANNOTATION: max_scale}}}} + } + updated = await self.__client.patch(meta, patch_body) + return _extract_runtime_state(KnativeService.model_validate(updated.manifest)) def _resources_from_resource_class(resource_class: ResourceClass) -> dict[str, Any]: @@ -191,6 +248,31 @@ def _started_at(knative_service: KnativeService) -> datetime | None: return datetime.fromisoformat(ready.lastTransitionTime) +def _is_hibernated(knative_service: KnativeService) -> bool: + """Determine if the Knative service is hibernated based on its annotations.""" + if ( + knative_service.spec is None + or knative_service.spec.template is None + or knative_service.spec.template.metadata is None + or knative_service.spec.template.metadata.annotations is None + ): + return False + max_scale = knative_service.spec.template.metadata.annotations.get(_MAX_SCALE_ANNOTATION) + return max_scale == _MAX_SCALE_HIBERNATED + + +def _container_image(knative_service: KnativeService) -> str | None: + """Get the container image actually configured on the Knative service, or None if absent.""" + if ( + knative_service.spec is None + or knative_service.spec.template is None + or knative_service.spec.template.spec is None + or not knative_service.spec.template.spec.containers + ): + return None + return knative_service.spec.template.spec.containers[0].image + + def _extract_runtime_state(knative_service: KnativeService) -> AppRuntimeState: """Read app runtime state primitives off a Knative Service.""" ready = _ready_condition(knative_service) @@ -199,6 +281,8 @@ def _extract_runtime_state(knative_service: KnativeService) -> AppRuntimeState: launcher_id=knative_service.launcher_id, project_id=knative_service.project_id, ready_status=ready.status if ready is not None else None, + is_hibernated=_is_hibernated(knative_service), + image=_container_image(knative_service), url=_url(knative_service), started_at=_started_at(knative_service), ) diff --git a/components/renku_data_services/renku_apps/models.py b/components/renku_data_services/renku_apps/models.py index eb04fc1bc..167564893 100644 --- a/components/renku_data_services/renku_apps/models.py +++ b/components/renku_data_services/renku_apps/models.py @@ -15,6 +15,7 @@ class AppStatus(StrEnum): PENDING = "pending" READY = "ready" FAILED = "failed" + HIBERNATED = "hibernated" @dataclass(frozen=True, eq=True, kw_only=True) @@ -56,5 +57,7 @@ class AppRuntimeState: launcher_id: ULID project_id: ULID ready_status: str | None + is_hibernated: bool + image: str | None url: str | None started_at: datetime | None diff --git a/components/renku_data_services/renku_apps/repository.py b/components/renku_data_services/renku_apps/repository.py index a1ed32b63..f777d0c3e 100644 --- a/components/renku_data_services/renku_apps/repository.py +++ b/components/renku_data_services/renku_apps/repository.py @@ -10,9 +10,10 @@ from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.crc.models import ResourceClass from renku_data_services.project.db import ProjectRepository +from renku_data_services.renku_apps import apispec from renku_data_services.renku_apps.core import build_app from renku_data_services.renku_apps.k8s_client import RenkuAppsK8sClient -from renku_data_services.renku_apps.models import App +from renku_data_services.renku_apps.models import App, AppRuntimeState from renku_data_services.session.db import SessionRepository logger = logging.getLogger(__name__) @@ -90,3 +91,54 @@ async def delete_app(self, user: base_models.APIUser, app_name: str) -> None: await self.k8s_client.delete_app_deployment(app_name) logger.info(f"App with name {app_name} has been deleted.") return None + + async def update_app( + self, + user: base_models.APIUser, + app_name: str, + state: apispec.AppState | None = None, + resource_class_id: int | None = None, + ) -> App: + """Update an app.""" + if not user.is_authenticated or user.id is None: + raise errors.UnauthorizedError(message="You do not have the required permissions for this operation.") + + runtime_state = await self.k8s_client.get_app_deployment(app_name) + if runtime_state is None: + raise errors.MissingResourceError( + message=f"App with name '{app_name}' does not exist or you do not have access to it." + ) + + launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) + + authorized = await self.authz.has_permission(user, ResourceType.project, launcher.project_id, Scope.WRITE) + if not authorized: + raise errors.MissingResourceError( + message=f"App with name '{app_name}' does not exist or you do not have authorization to modify it." + ) + + latest: AppRuntimeState = runtime_state + if resource_class_id is not None: + resource_class = await self.rp_repo.get_resource_class(user, resource_class_id) + latest = await self.k8s_client.set_app_deployment_resources(app_name, resource_class) + if state == apispec.AppState.hibernated: + latest = await self.k8s_client.hibernate_app_deployment(app_name) + elif state == apispec.AppState.running: + latest = await self.k8s_client.resume_app_deployment(app_name) + + return build_app(launcher, latest) + + async def list_apps(self, user: base_models.APIUser, project_id: ULID | None = None) -> list[App]: + """List all apps, optionally filtered by project.""" + + apps: list[App] = [] + async for runtime_state in self.k8s_client.list_app_deployments(project_id): + try: + launcher = await self.session_repo.get_launcher(user, runtime_state.launcher_id) + except errors.MissingResourceError: + logger.warning( + f"Launcher with id '{runtime_state.launcher_id}' for app '{runtime_state.name}' was not found." + ) + continue + apps.append(build_app(launcher, runtime_state)) + return apps From 649aa4cfb797a0fb1e86e0f639713a0068a80343 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 9 Jun 2026 09:09:17 +0200 Subject: [PATCH 26/28] add lowercase project id slug --- components/renku_data_services/renku_apps/k8s_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index a6609c531..c88c1dee0 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -202,6 +202,7 @@ def _build_app_deployment_manifest( "renku.io/project-slug": project.slug.lower(), "renku.io/project-namespace": project.namespace.path.serialize().replace("/", "-").lower(), "renku.io/project-id": str(project.id), + "renku.io/project-id-slug": str(project.id)[18:26].lower(), "renku.io/launcher-id": str(session_launcher.id), } From a6c9f4af7ff7f02d500412f8821738bb1304e1e7 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Tue, 9 Jun 2026 15:38:42 +0200 Subject: [PATCH 27/28] fix resource patch --- .../renku_data_services/renku_apps/k8s_client.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/components/renku_data_services/renku_apps/k8s_client.py b/components/renku_data_services/renku_apps/k8s_client.py index c88c1dee0..3305443fa 100644 --- a/components/renku_data_services/renku_apps/k8s_client.py +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -135,13 +135,13 @@ async def set_app_deployment_resources(self, app_name: str, resource_class: Reso gvk=KNATIVE_SERVICE_GVK, user_id=DUMMY_RENKU_APP_USER_ID, ) - patch_body: dict[str, Any] = { - "spec": { - "template": { - "spec": {"containers": [{"resources": _resources_from_resource_class(resource_class)}]}, - } + patch_body: list[dict[str, Any]] = [ + { + "op": "replace", + "path": "/spec/template/spec/containers/0/resources", + "value": _resources_from_resource_class(resource_class), } - } + ] updated = await self.__client.patch(meta, patch_body) return _extract_runtime_state(KnativeService.model_validate(updated.manifest)) From 9b864d42bf787900520b158e7276c139863013c2 Mon Sep 17 00:00:00 2001 From: Wes Johnson Date: Wed, 10 Jun 2026 11:32:34 +0200 Subject: [PATCH 28/28] thread Resources override through KnativeService chain --- .../renku_data_services/renku_apps/crs.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/components/renku_data_services/renku_apps/crs.py b/components/renku_data_services/renku_apps/crs.py index 20e513bc5..eeaa174bb 100644 --- a/components/renku_data_services/renku_apps/crs.py +++ b/components/renku_data_services/renku_apps/crs.py @@ -1,6 +1,6 @@ """Custom resource definition with proper names from the autogenerated code.""" -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime from typing import cast @@ -8,12 +8,16 @@ from ulid import ULID from renku_data_services.errors import errors +from renku_data_services.renku_apps.cr_knative_service import Container as _Container from renku_data_services.renku_apps.cr_knative_service import Limits as _Limits from renku_data_services.renku_apps.cr_knative_service import Limits1 as LimitsStr from renku_data_services.renku_apps.cr_knative_service import Model as _KnativeService from renku_data_services.renku_apps.cr_knative_service import Requests as _Requests from renku_data_services.renku_apps.cr_knative_service import Requests1 as RequestsStr from renku_data_services.renku_apps.cr_knative_service import Resources as _Resources +from renku_data_services.renku_apps.cr_knative_service import Spec as _Spec +from renku_data_services.renku_apps.cr_knative_service import Spec1 as _Spec1 +from renku_data_services.renku_apps.cr_knative_service import Template as _Template class Metadata(BaseModel): @@ -60,12 +64,37 @@ class Resources(_Resources): requests: Mapping[str, RequestsStr | Requests] | None = None +class Container(_Container): + """Pod container overridden to use the fixed Resources.""" + + resources: Resources | None = None + + +class PodSpec(_Spec1): + """Pod spec overridden to use the fixed Container.""" + + containers: Sequence[Container] + + +class Template(_Template): + """Revision template overridden to use the fixed PodSpec.""" + + spec: PodSpec | None = None + + +class ServiceSpec(_Spec): + """Knative Service spec overridden to use the fixed Template.""" + + template: Template | None = None + + class KnativeService(_KnativeService): """Knative Service.""" kind: str = "Service" apiVersion: str = "serving.knative.dev/v1" metadata: Metadata # type: ignore[assignment] + spec: ServiceSpec | None = None @property def launcher_id(self) -> ULID: