diff --git a/Makefile b/Makefile index d50c13841..1fb0fa34c 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 \ @@ -55,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 @@ -119,6 +121,10 @@ 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: 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: 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} diff --git a/bases/renku_data_services/data_api/app.py b/bases/renku_data_services/data_api/app.py index cb2a07fec..8ba9e2441 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,16 @@ 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, + ) + if dm.config.apps.enabled and dm.apps_repo is not None + else None + ) builds = ( BuildsBP( name="builds", @@ -349,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 093a1574f..4c3f94bb2 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 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 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 | None + apps_repo: RenkuAppsRepository | None 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__, @@ -257,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], + 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)) @@ -304,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], + kinds_to_cache=[ + AMALTHEA_SESSION_GVK, + JUPYTER_SESSION_GVK, + BUILD_RUN_GVK, + TASK_RUN_GVK, + KNATIVE_SERVICE_GVK, + ], ), ), namespace=config.k8s_namespace, @@ -384,6 +402,17 @@ def from_env(cls) -> DependencyManager: builds_config=config.builds, git_repositories_repo=git_repositories_repo, ) + 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, @@ -484,6 +513,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/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 fb0434361..ab486fe3b 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__) @@ -52,6 +53,8 @@ async def main() -> None: 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/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/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 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..5bf932eaf --- /dev/null +++ b/components/renku_data_services/renku_apps/api.spec.yaml @@ -0,0 +1,225 @@ +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: + /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: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AppPostRequest" + responses: + "201": + description: The app was created + content: + application/json: + schema: + $ref: "#/components/schemas/AppResponse" + default: + $ref: "#/components/responses/Error" + tags: + - apps + /apps/{app_name}: + get: + summary: Retrieve an app + parameters: + - in: path + name: app_name + required: true + schema: + $ref: "#/components/schemas/AppName" + description: The name of the app to retrieve + responses: + "200": + description: The app for the given name + content: + application/json: + schema: + $ref: "#/components/schemas/AppResponse" + default: + $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: + - 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: + description: ULID identifier + type: string + minLength: 26 + maxLength: 26 + pattern: "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" + + AppStatus: + type: string + enum: + - 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 + minLength: 5 + maxLength: 50 + pattern: "^[a-z]([-a-z0-9]*[a-z0-9])?$" + example: d185e68d-d43-renku-2-b9ac279a4e8a85ac28d08 + + AppResponse: + type: object + properties: + name: + $ref: "#/components/schemas/AppName" + launcher_id: + $ref: "#/components/schemas/Ulid" + status: + $ref: "#/components/schemas/AppStatus" + url: + type: string + nullable: true + project_id: + $ref: "#/components/schemas/Ulid" + started: + type: string + format: date-time + nullable: true + image: + type: string + nullable: true + required: + - name + - launcher_id + - status + - project_id + + AppListResponse: + type: array + items: + $ref: "#/components/schemas/AppResponse" + + AppPostRequest: + type: object + properties: + launcher_id: + $ref: "#/components/schemas/Ulid" + required: + - launcher_id + + AppPatchRequest: + type: object + properties: + state: + $ref: "#/components/schemas/AppState" + resource_class_id: + type: integer + + 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/apispec.py b/components/renku_data_services/renku_apps/apispec.py new file mode 100644 index 000000000..ab97ae7b8 --- /dev/null +++ b/components/renku_data_services/renku_apps/apispec.py @@ -0,0 +1,100 @@ +# generated by datamodel-codegen: +# filename: api.spec.yaml +# timestamp: 2026-06-08T12:56:33+00:00 + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import List, 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" + hibernated = "hibernated" + + +class AppState(Enum): + running = "running" + hibernated = "hibernated" + + +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: Optional[str] = None + 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 AppListResponse(RootModel[List[AppResponse]]): + root: List[AppResponse] + + +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 AppPatchRequest(BaseAPISpec): + state: Optional[AppState] = None + resource_class_id: Optional[int] = None + + +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 + + +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}$", + ) 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 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..b98976418 --- /dev/null +++ b/components/renku_data_services/renku_apps/blueprints.py @@ -0,0 +1,88 @@ +"""Renku apps blueprints.""" + +from dataclasses import dataclass + +from sanic import HTTPResponse, Request +from sanic.response import JSONResponse, json +from sanic_ext import validate +from ulid import ULID + +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(CustomBlueprint): + """Handlers for Renku apps.""" + + apps_repo: RenkuAppsRepository + authenticator: base_models.Authenticator + + def post(self) -> BlueprintFactoryResponse: + """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 + + 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 + + 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/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/core.py b/components/renku_data_services/renku_apps/core.py new file mode 100644 index 000000000..329943b08 --- /dev/null +++ b/components/renku_data_services/renku_apps/core.py @@ -0,0 +1,29 @@ +"""Business logic for Renku apps.""" + +from renku_data_services.renku_apps.models import App, AppRuntimeState, AppStatus +from renku_data_services.session.models import SessionLauncher + + +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=derive_app_status(runtime), + url=runtime.url, + started=runtime.started_at, + image=runtime.image, + ) + + +def derive_app_status(runtime: AppRuntimeState) -> AppStatus: + """Derive an app status from the runtime state.""" + + if runtime.is_hibernated: + return AppStatus.HIBERNATED + if runtime.ready_status == "True": + return AppStatus.READY + if runtime.ready_status == "False": + return AppStatus.FAILED + return AppStatus.PENDING 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..3f905b047 --- /dev/null +++ b/components/renku_data_services/renku_apps/cr_knative_service.py @@ -0,0 +1,1096 @@ +# 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..eeaa174bb --- /dev/null +++ b/components/renku_data_services/renku_apps/crs.py @@ -0,0 +1,115 @@ +"""Custom resource definition with proper names from the autogenerated code.""" + +from collections.abc import Mapping, Sequence +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 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): + """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 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 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: + """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 label" + ) + 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 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 label" + ) + 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 new file mode 100644 index 000000000..3305443fa --- /dev/null +++ b/components/renku_data_services/renku_apps/k8s_client.py @@ -0,0 +1,289 @@ +"""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, 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 +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") + +_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", + _MAX_SCALE_ANNOTATION: _MAX_SCALE_RUNNING, + "autoscaling.knative.dev/scale-to-zero-pod-retention-period": "2m", +} + + +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, + 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 = 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, + 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 + ) + return _extract_runtime_state(KnativeService.model_validate(created.manifest)) + + 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 = 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, + ) + obj = await self.__client.get(meta) + if obj is None: + return None + return _extract_runtime_state(KnativeService.model_validate(obj.manifest)) + + 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, session_launcher)) + + async def delete_app_deployment(self, app_name: str) -> None: + """Delete the deployment for the given app name.""" + 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, + ) + await self.__client.delete(meta) + + 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 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: 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)) + + 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]: + """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, project: Project +) -> 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) + + 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/project-id-slug": str(project.id)[18:26].lower(), + "renku.io/launcher-id": str(session_launcher.id), + } + + return KnativeService.model_validate( + { + "apiVersion": "serving.knative.dev/v1", + "kind": "Service", + "metadata": { + "name": app_name, + "labels": labels, + }, + "spec": { + "template": { + "metadata": { + "labels": labels, + "annotations": _APP_AUTOSCALING_ANNOTATIONS, + }, + "spec": {"containers": [container]}, + }, + }, + } + ) + + +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 _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) + 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, + 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 new file mode 100644 index 000000000..167564893 --- /dev/null +++ b/components/renku_data_services/renku_apps/models.py @@ -0,0 +1,63 @@ +"""Models for Renku apps.""" + +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from ulid import ULID + +from renku_data_services.renku_apps import apispec + + +class AppStatus(StrEnum): + """The status of an app.""" + + PENDING = "pending" + READY = "ready" + FAILED = "failed" + HIBERNATED = "hibernated" + + +@dataclass(frozen=True, eq=True, kw_only=True) +class App: + """An App.""" + + name: str + launcher_id: ULID + project_id: ULID + status: AppStatus + 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=apispec.AppStatus(self.status.value), + url=self.url, + project_id=str(self.project_id), + 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 + 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 new file mode 100644 index 000000000..f777d0c3e --- /dev/null +++ b/components/renku_data_services/renku_apps/repository.py @@ -0,0 +1,144 @@ +"""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.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 +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, AppRuntimeState +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.""" + + def __init__( + self, + authz: Authz, + session_repo: SessionRepository, + rp_repo: ResourcePoolRepository, + project_repo: ProjectRepository, + k8s_client: RenkuAppsK8sClient, + ) -> None: + self.authz = authz + self.session_repo = session_repo + self.rp_repo = rp_repo + self.project_repo = project_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." + ) + + 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) + + project = await self.project_repo.get_project(user, launcher.project_id) + 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) + + async def get_app(self, user: base_models.APIUser, app_name: str) -> App: + """Retrieve an app by its name.""" + 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) + 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 + + 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 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" }, 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" }, 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", ] 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,