Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)}}" \
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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}")
Empty file.
59 changes: 59 additions & 0 deletions app/routers/persistent_service/facility_adapter.py
Original file line number Diff line number Diff line change
@@ -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_<domain>` 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
141 changes: 141 additions & 0 deletions app/routers/persistent_service/models.py
Original file line number Diff line number Diff line change
@@ -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
124 changes: 124 additions & 0 deletions app/routers/persistent_service/persistent_service.py
Original file line number Diff line number Diff line change
@@ -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",
},
)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ dev = ["pytest>=9"]
[tool.ruff]
line-length = 200
exclude = [".venv", "__pycache__", "build", "dist"]

[tool.setuptools]
packages = ["app"]
Loading