From d9793ba5ecfb9d82c6f05786ea44a6277817a0c3 Mon Sep 17 00:00:00 2001 From: Gabor Torok Date: Wed, 21 Oct 2026 17:08:25 -0700 Subject: [PATCH] persistent services proposal --- Makefile | 4 +- app/main.py | 2 + app/routers/persistent_service/__init__.py | 0 .../persistent_service/facility_adapter.py | 59 ++++++++ app/routers/persistent_service/models.py | 141 ++++++++++++++++++ .../persistent_service/persistent_service.py | 124 +++++++++++++++ examples/demo-adapter | 2 +- pyproject.toml | 3 + 8 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 app/routers/persistent_service/__init__.py create mode 100644 app/routers/persistent_service/facility_adapter.py create mode 100644 app/routers/persistent_service/models.py create mode 100644 app/routers/persistent_service/persistent_service.py diff --git a/Makefile b/Makefile index c4e9844f..e6725ee7 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ dev: deps IRI_API_ADAPTER_filesystem=demo_adapter.combined.DemoAdapter \ IRI_API_ADAPTER_storage=demo_adapter.combined.DemoAdapter \ IRI_API_ADAPTER_task=demo_adapter.combined.DemoAdapter \ + IRI_API_ADAPTER_persistent_service=demo_adapter.persistent_service.adapter.PersistentServiceDemoAdapter \ IRI_IDEMPOTENCY_STORE=demo_adapter.compute.idempotency.InMemoryIdempotencyStore \ IRI_LOG_FILE="$${IRI_LOG_FILE:-$${LOG_FILE:-$(IRI_LOG_FILE)}}" \ IRI_LOG_ROTATION_DAYS="$${IRI_LOG_ROTATION_DAYS:-$${LOG_ROTATION_DAYS:-$(IRI_LOG_ROTATION_DAYS)}}" \ @@ -69,9 +70,6 @@ redis-clean: ## Stop and remove the local Redis container docker rm -f $(REDIS_CONTAINER) 2>/dev/null || true -test: deps ## Run unit tests - $(BIN)/python -m pytest test/ -v - .PHONY: clean clean: rm -rf iri_sandbox diff --git a/app/main.py b/app/main.py index d86db12e..f70c13e9 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from app.routers.filesystem import filesystem from app.routers.storage import storage from app.routers.task import task +from app.routers.persistent_service import persistent_service configure_logging(config.LOG_LEVEL) @@ -95,5 +96,6 @@ async def dispatch(self, request: Request, call_next): APP.include_router(filesystem.router, prefix=api_prefix) APP.include_router(storage.router, prefix=api_prefix) APP.include_router(task.router, prefix=api_prefix) +APP.include_router(persistent_service.router, prefix=api_prefix) logging.getLogger().info(f"API path: {api_prefix}") diff --git a/app/routers/persistent_service/__init__.py b/app/routers/persistent_service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/routers/persistent_service/facility_adapter.py b/app/routers/persistent_service/facility_adapter.py new file mode 100644 index 00000000..c63c0872 --- /dev/null +++ b/app/routers/persistent_service/facility_adapter.py @@ -0,0 +1,59 @@ +from abc import abstractmethod +from ...types.user import User +from . import models +from ..iri_router import AuthenticatedAdapter + + +class FacilityAdapter(AuthenticatedAdapter): + """ + Facility-specific code is handled by the implementation of this interface. + Use the `IRI_API_ADAPTER_` environment variable to install your + facility adapter before the API starts. + """ + + @abstractmethod + async def get_servers( + self: "FacilityAdapter", + user: User, + ) -> models.ServerList: + pass + + + @abstractmethod + async def get_clusters( + self: "FacilityAdapter", + user: User, + server_id: str, + ) -> models.ClusterList: + pass + + + @abstractmethod + async def get_projects( + self: "FacilityAdapter", + user: User, + server_id: str, + cluster_id: str, + ) -> models.ProjectList: + pass + + + @abstractmethod + async def create_namespace( + self: "FacilityAdapter", + user: User, + server_id: str, + cluster_id: str, + namespace_request: models.NamespaceCreateRequest + ) -> models.Namespace: + pass + + + @abstractmethod + async def create_kubeconfig( + self: "FacilityAdapter", + user: User, + server_id: str, + kubeconfig_request: models.KubeconfigRequest + ) -> models.Namespace: + pass diff --git a/app/routers/persistent_service/models.py b/app/routers/persistent_service/models.py new file mode 100644 index 00000000..c28da760 --- /dev/null +++ b/app/routers/persistent_service/models.py @@ -0,0 +1,141 @@ +from enum import Enum +from typing import Annotated, Any +from ...types.base import IRIBaseModel + +from pydantic import Field, StringConstraints, field_validator + +# --- Shared types ----------------------------------------------------------- + +# Kubernetes DNS-1123 label rules. Provider-specific naming rules belong in the adapter. +UrlSafeName = Annotated[ + str, + StringConstraints(min_length=1, max_length=63, pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$"), +] + +# Opaque provider-assigned identifier. The router must never parse or validate its format. +OpaqueId = Annotated[str, StringConstraints(min_length=1, max_length=255)] + + +# --- Servers ---------------------------------------------------------------- + +class ServerCapabilities(IRIBaseModel): + supports_projects: bool = Field( + description="Whether this server has a project (grouping) concept." + ) + project_required_for_namespace: bool = Field( + description="If true, namespace creation requires project_id." + ) + supports_kubeconfig: bool = Field( + description="Whether a kubeconfig can be generated for this server." + ) + + +class Server(IRIBaseModel): + id: UrlSafeName + display_name: str + labels: dict[str, str] = Field( + default_factory=dict, description="Generic labels, e.g. environment=prod." + ) + capabilities: ServerCapabilities + + +class ServerList(IRIBaseModel): + items: list[Server] + + +# --- Clusters --------------------------------------------------------------- + +class ClusterState(str, Enum): + ACTIVE = "active" + PROVISIONING = "provisioning" + UPDATING = "updating" + UNAVAILABLE = "unavailable" + ERROR = "error" + UNKNOWN = "unknown" + + +class Cluster(IRIBaseModel): + id: OpaqueId + name: str + state: ClusterState + labels: dict[str, str] = Field(default_factory=dict) + metadata: dict[str, str] = Field( + default_factory=dict, + description="Provider-specific extras. Clients must not depend on specific keys.", + ) + + +class ClusterList(IRIBaseModel): + items: list[Cluster] + + +# --- Projects (optional grouping) -------------------------------------------- + +class Project(IRIBaseModel): + id: OpaqueId + name: str + role: str | None = Field(default=None, description="The caller's role in this project, if known.") + metadata: dict[str, str] = Field(default_factory=dict) + + +class ProjectList(IRIBaseModel): + """An empty list is ambiguous on its own. Check the server's supports_projects capability.""" + items: list[Project] + + +# --- Namespaces ------------------------------------------------------------- + +class NamespaceCreateRequest(IRIBaseModel): + id: UrlSafeName + project_id: OpaqueId | None = Field( + default=None, + description="Required when the server's project_required_for_namespace is true.", + ) + # Labels only, no annotations. Provider-significant keys (e.g. Rancher's project + # annotation) must be rejected or stripped by the adapter, ideally via an allowlist. + labels: dict[str, str] = Field(default_factory=dict) + + +class Namespace(IRIBaseModel): + id: str + cluster_id: OpaqueId + project_id: OpaqueId | None = None + labels: dict[str, str] = Field(default_factory=dict) + metadata: dict[str, str] = Field(default_factory=dict) + + +# --- Kubeconfig ------------------------------------------------------------- + +class KubeconfigRequest(IRIBaseModel): + cluster_ids: list[OpaqueId] | None = Field( + default=None, + description="Restrict the kubeconfig to these clusters. Omit for all accessible clusters.", + ) + + @field_validator("cluster_ids") + @classmethod + def _non_empty_unique(cls, v: list[str] | None) -> list[str] | None: + if v is None: + return v + if not v: + raise ValueError("cluster_ids must not be empty; omit it to include all clusters") + if len(set(v)) != len(v): + raise ValueError("cluster_ids must not contain duplicates") + return v + + +# --- Errors ----------------------------------------------------------------- + +class ErrorCode(str, Enum): + """Mirrors the domain exceptions adapters raise.""" + NOT_FOUND = "not_found" + FORBIDDEN = "forbidden" + CONFLICT = "conflict" + UNAVAILABLE = "unavailable" + UNSUPPORTED = "unsupported" + INVALID_INPUT = "invalid_input" + + +class ErrorResponse(IRIBaseModel): + code: ErrorCode + message: str diff --git a/app/routers/persistent_service/persistent_service.py b/app/routers/persistent_service/persistent_service.py new file mode 100644 index 00000000..38e75052 --- /dev/null +++ b/app/routers/persistent_service/persistent_service.py @@ -0,0 +1,124 @@ +"""Persistent services API router""" + +from fastapi import Depends, Header, Request, Response + +from ...types.http import forbidExtraQueryParams +from ...types.user import User +from .. import iri_router +from ..error_handlers import DEFAULT_RESPONSES +from ..iri_meta import iri_meta_dict +from . import facility_adapter, models + +router = iri_router.IriRouter( + facility_adapter.FacilityAdapter, + prefix="/persistent_service", + tags=["persistent_service"], +) + + +@router.get( + "/servers", + response_model=models.ServerList, + response_model_exclude_unset=True, + responses=DEFAULT_RESPONSES, + operation_id="getPersistentServiceServers", + openapi_extra=iri_meta_dict("planned"), +) +async def get_servers( + request: Request, + user: User = Depends(router.current_user), + _forbid=Depends(forbidExtraQueryParams()), +): + """Get a list of resources that can be used in this endpoint""" + return await router.adapter.get_servers(user=user) + + +@router.get( + "/servers/{server_id:str}/clusters", + response_model=models.ClusterList, + response_model_exclude_unset=True, + responses=DEFAULT_RESPONSES, + operation_id="getPersistentServiceClusters", + openapi_extra=iri_meta_dict("planned"), +) +async def get_clusters( + server_id: str, + request: Request, + user: User = Depends(router.current_user), + _forbid=Depends(forbidExtraQueryParams()), +): + """Get a list of resources that can be used in this endpoint""" + return await router.adapter.get_clusters(user=user, server_id=server_id) + + +@router.get( + "/servers/{server_id:str}/clusters/{cluster_id:str}/projects", + response_model=models.ProjectList, + response_model_exclude_unset=True, + responses=DEFAULT_RESPONSES, + operation_id="getPersistentServiceProjects", + openapi_extra=iri_meta_dict("planned"), +) +async def get_projects( + server_id: str, + cluster_id: str, + request: Request, + user: User = Depends(router.current_user), + _forbid=Depends(forbidExtraQueryParams()), +): + """Get a list of resources that can be used in this endpoint""" + return await router.adapter.get_projects(user=user, server_id=server_id, cluster_id=cluster_id) + + +@router.post( + "/servers/{server_id:str}/clusters/{cluster_id:str}/namespaces", + response_model=models.Namespace, + response_model_exclude_unset=True, + responses=DEFAULT_RESPONSES, + operation_id="createPersistentServiceNamespace", + openapi_extra=iri_meta_dict("planned"), +) +async def create_namespace( + server_id: str, + cluster_id: str, + namespace_request: models.NamespaceCreateRequest, + request: Request, + user: User = Depends(router.current_user), + _forbid=Depends(forbidExtraQueryParams()), +): + return await router.adapter.create_namespace(user=user, server_id=server_id, cluster_id=cluster_id, namespace_request=namespace_request) + + +@router.post( + "/servers/{server_id:str}/kubeconfig", + response_class=Response, + responses={ + **{ + 200: { + "description": "Kubeconfig file", + "content": { + "application/yaml": {"schema": {"type": "string", "format": "binary"}} + }, + } + }, + **DEFAULT_RESPONSES, + }, + operation_id="createPersistentServiceKubeconfig", + openapi_extra=iri_meta_dict("planned"), +) +async def create_kubeconfig( + server_id: str, + kubeconfig_request: models.KubeconfigRequest, + request: Request, + user: User = Depends(router.current_user), + _forbid=Depends(forbidExtraQueryParams()), +): + kubeconfig = await router.adapter.create_kubeconfig(user=user, server_id=server_id, kubeconfig_request=kubeconfig_request) + return Response( + content=kubeconfig, + media_type="application/yaml", + headers={ + "Content-Disposition": f'attachment; filename="{server_id}-kubeconfig.yaml"', + "Cache-Control": "no-store", + }, + ) diff --git a/examples/demo-adapter b/examples/demo-adapter index 115ff84c..76d036f9 160000 --- a/examples/demo-adapter +++ b/examples/demo-adapter @@ -1 +1 @@ -Subproject commit 115ff84cfbc6db4bc00f41e1c960e3e2e28b12dd +Subproject commit 76d036f911b9daf1f95c8400efec6d6efdc2d9d3 diff --git a/pyproject.toml b/pyproject.toml index 0a777972..8f64b03a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,6 @@ dev = ["pytest>=9"] [tool.ruff] line-length = 200 exclude = [".venv", "__pycache__", "build", "dist"] + +[tool.setuptools] +packages = ["app"]