mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 10:32:22 +08:00
feat: schemas for pluggable deployment service (#11979)
* checkout deployment serice schemas
* simplify schemas
* internal facing schema improvements and structuring
* add tests
* use string for name property
* address coderabbit suggestions for implementing todos and adding tests
* address claude maximus comments
* remove teardown from base.add tests for uncovered exception classes
* stub db as AsyncSession
* address review findings: align protocol with ABC, tighten schemas and exceptions
- Add redeploy, duplicate, create_execution, get_execution to DeploymentServiceProtocol so it matches BaseDeploymentService
- Add @runtime_checkable to DeploymentServiceProtocol
- Use IdLike alias in base.py, service.py, and interfaces.py instead of
raw UUID | str
- Make DeploymentError.error_code required (all subclasses already provide one)
- Store deployment_id on DeploymentNotFoundError regardless of custom message
- Add isinstance checks for nodes/edges in BaseFlowArtifact.validate_data
- Add min_length=1 to BaseFlowArtifact.name
- Remove redundant provider_data re-declaration from DeploymentStatusResult
- Add descriptive messages to all NotImplementedError raises in stub service
- Remove misleading set_ready() from stub DeploymentService
- Export BaseDeploymentService and DeploymentError from package __init__
- Fix "Auhtenticated" typo in AuthUserProtocol docstring
- Parametrize stub method tests to cover all 11 methods
- Add tests for protocol conformance, get_deployment_create_schema,
DeploymentNotFoundError.deployment_id, flow name validation, nodes/edges
type validation, ConfigItem/DeploymentUpdate happy paths
* Add domain-specific exception type
* [autofix.ci] apply automated fixes
---------
Co-authored-by: Jordan Frazier <jordan.frazier@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
13
src/lfx/src/lfx/services/deployment/__init__.py
Normal file
13
src/lfx/src/lfx/services/deployment/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""Deployment service implementations for LFX."""
|
||||
|
||||
from .base import BaseDeploymentService
|
||||
from .exceptions import DeploymentError, DeploymentNotConfiguredError, DeploymentServiceError
|
||||
from .service import DeploymentService
|
||||
|
||||
__all__ = [
|
||||
"BaseDeploymentService",
|
||||
"DeploymentError",
|
||||
"DeploymentNotConfiguredError",
|
||||
"DeploymentService",
|
||||
"DeploymentServiceError",
|
||||
]
|
||||
152
src/lfx/src/lfx/services/deployment/base.py
Normal file
152
src/lfx/src/lfx/services/deployment/base.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Deployment service base class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lfx.services.base import Service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from lfx.services.deployment.schema import (
|
||||
DeploymentCreate,
|
||||
DeploymentCreateResult,
|
||||
DeploymentDeleteResult,
|
||||
DeploymentDuplicateResult,
|
||||
DeploymentGetResult,
|
||||
DeploymentListParams,
|
||||
DeploymentListResult,
|
||||
DeploymentListTypesResult,
|
||||
DeploymentStatusResult,
|
||||
DeploymentUpdate,
|
||||
DeploymentUpdateResult,
|
||||
ExecutionCreate,
|
||||
ExecutionCreateResult,
|
||||
ExecutionStatusResult,
|
||||
IdLike,
|
||||
RedeployResult,
|
||||
)
|
||||
|
||||
|
||||
class BaseDeploymentService(Service, ABC):
|
||||
"""Abstract base class for deployment provider services.
|
||||
|
||||
Defines the minimal interface that all deployment service implementations
|
||||
must provide, whether minimal (LFX) or full-featured (Langflow).
|
||||
|
||||
Note:
|
||||
``db`` parameters are typed as ``AsyncSession`` to align with current
|
||||
LFX dependency injection and service protocols.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
payload: DeploymentCreate,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentCreateResult:
|
||||
"""Create a new deployment in the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_types(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentListTypesResult:
|
||||
"""List deployment types supported by the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
params: DeploymentListParams | None = None,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentListResult:
|
||||
"""List deployments visible to this adapter."""
|
||||
|
||||
@abstractmethod
|
||||
async def get(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentGetResult:
|
||||
"""Return deployment metadata by provider ID."""
|
||||
|
||||
@abstractmethod
|
||||
async def update(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
payload: DeploymentUpdate,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentUpdateResult:
|
||||
"""Update deployment inputs and apply changes in the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def redeploy(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> RedeployResult:
|
||||
"""Re-apply current deployment inputs without changing them."""
|
||||
|
||||
@abstractmethod
|
||||
async def duplicate(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentDuplicateResult:
|
||||
"""Create a new deployment using the same inputs as the source."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentDeleteResult:
|
||||
"""Delete the deployment from the provider."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentStatusResult:
|
||||
"""Return provider-reported health/status for the deployment."""
|
||||
|
||||
@abstractmethod
|
||||
async def create_execution(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
payload: ExecutionCreate,
|
||||
db: AsyncSession,
|
||||
) -> ExecutionCreateResult:
|
||||
"""Run a provider-agnostic deployment execution."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_execution(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
execution_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> ExecutionStatusResult:
|
||||
"""Get provider-agnostic deployment execution state/output."""
|
||||
142
src/lfx/src/lfx/services/deployment/exceptions.py
Normal file
142
src/lfx/src/lfx/services/deployment/exceptions.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""Framework-agnostic deployment exceptions for LFX deployment service.
|
||||
|
||||
Shared exception types for all deployment service implementations,
|
||||
regardless of adapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DeploymentServiceError(Exception):
|
||||
"""Root exception for all deployment service failures.
|
||||
|
||||
Both operational errors (:class:`DeploymentError`) and authentication
|
||||
errors (:class:`AuthenticationError`) inherit from this class.
|
||||
Catch this only when you truly want to handle *every* deployment-related
|
||||
failure uniformly.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, error_code: str, cause: Exception | None = None):
|
||||
self.message = message
|
||||
self.error_code = error_code
|
||||
self.cause = cause
|
||||
super().__init__(message)
|
||||
if cause is not None:
|
||||
self.__cause__ = cause
|
||||
|
||||
|
||||
class DeploymentError(DeploymentServiceError):
|
||||
"""Base exception for deployment operation failures (non-auth)."""
|
||||
|
||||
|
||||
class AuthenticationError(DeploymentServiceError):
|
||||
"""Base exception for authentication failures.
|
||||
|
||||
Intentionally a sibling of :class:`DeploymentError`, not a child.
|
||||
This ensures ``except DeploymentError`` will not accidentally swallow
|
||||
authentication failures, allowing API layers to return the correct
|
||||
HTTP status (401/403) separately from deployment errors (400/404/409/422).
|
||||
|
||||
Please ensure that the message does not leak sensitive information.
|
||||
"""
|
||||
|
||||
|
||||
class CredentialResolutionError(AuthenticationError):
|
||||
"""Raised when credentials resolution fails.
|
||||
|
||||
Please ensure that the message does not leak sensitive information.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "Credentials resolution failed", *, cause: Exception | None = None):
|
||||
super().__init__(message, error_code="credentials_resolution_error", cause=cause)
|
||||
|
||||
|
||||
class DeploymentConflictError(DeploymentError):
|
||||
"""Raised when a deployment conflict occurs."""
|
||||
|
||||
def __init__(self, message: str = "Deployment conflict occurred", *, cause: Exception | None = None):
|
||||
super().__init__(message, error_code="deployment_conflict", cause=cause)
|
||||
|
||||
|
||||
class InvalidContentError(DeploymentError):
|
||||
"""Raised when a deployment request entity is unprocessable."""
|
||||
|
||||
def __init__(self, message: str = "Deployment request entity is unprocessable", *, cause: Exception | None = None):
|
||||
super().__init__(message, error_code="unprocessable_content_error", cause=cause)
|
||||
|
||||
|
||||
class InvalidDeploymentOperationError(DeploymentError):
|
||||
"""Raised when a deployment operation is invalid for current adapter semantics."""
|
||||
|
||||
def __init__(self, message: str = "Invalid deployment operation", *, cause: Exception | None = None):
|
||||
super().__init__(message, error_code="invalid_deployment_operation", cause=cause)
|
||||
|
||||
|
||||
class DeploymentSupportError(DeploymentError):
|
||||
"""Raised when a known deployment type is unsupported by this adapter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "Deployment type is unsupported by this adapter",
|
||||
*,
|
||||
cause: Exception | None = None,
|
||||
):
|
||||
super().__init__(message, error_code="unsupported_deployment_type", cause=cause)
|
||||
|
||||
|
||||
class AuthSchemeError(AuthenticationError):
|
||||
"""Raised when no matching authentication scheme was found.
|
||||
|
||||
Please ensure that the message does not leak sensitive information.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = "No matching authentication scheme was found", *, cause: Exception | None = None):
|
||||
super().__init__(message, error_code="unsupported_auth_type", cause=cause)
|
||||
|
||||
|
||||
class InvalidDeploymentTypeError(DeploymentError):
|
||||
"""Raised when an input deployment type is malformed or not recognized."""
|
||||
|
||||
def __init__(self, message: str = "Deployment type is malformed or unknown", *, cause: Exception | None = None):
|
||||
super().__init__(message, error_code="invalid_deployment_type", cause=cause)
|
||||
|
||||
|
||||
class DeploymentNotFoundError(DeploymentError):
|
||||
"""Raised when a deployment is not found."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
*,
|
||||
deployment_id: str | None = None,
|
||||
cause: Exception | None = None,
|
||||
):
|
||||
self.deployment_id = deployment_id
|
||||
if message is None:
|
||||
message = "Deployment not found"
|
||||
if deployment_id:
|
||||
message = f"Deployment not found: {deployment_id}"
|
||||
super().__init__(message, error_code="deployment_not_found", cause=cause)
|
||||
|
||||
|
||||
class DeploymentNotConfiguredError(DeploymentError):
|
||||
"""Raised when no concrete deployment adapter has been registered.
|
||||
|
||||
The stub :class:`~lfx.services.deployment.service.DeploymentService` raises
|
||||
this for every operation so that callers receive a domain exception rather
|
||||
than a bare ``NotImplementedError``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "No deployment adapter is registered. Register a concrete adapter to enable deployment operations.",
|
||||
*,
|
||||
method: str | None = None,
|
||||
cause: Exception | None = None,
|
||||
):
|
||||
if method is not None:
|
||||
message = (
|
||||
f"DeploymentService.{method}() requires a concrete deployment adapter. "
|
||||
"Register one to enable deployment operations."
|
||||
)
|
||||
super().__init__(message, error_code="deployment_not_configured", cause=cause)
|
||||
505
src/lfx/src/lfx/services/deployment/schema.py
Normal file
505
src/lfx/src/lfx/services/deployment/schema.py
Normal file
@ -0,0 +1,505 @@
|
||||
import datetime
|
||||
import json
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator, model_validator
|
||||
|
||||
DeploymentProviderName = Annotated[
|
||||
str,
|
||||
StringConstraints(strip_whitespace=True, min_length=1, max_length=128),
|
||||
] # the name of the deployment provider.
|
||||
|
||||
NormalizedId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
IdLike = UUID | NormalizedId
|
||||
ProviderPayload = dict[str, Any]
|
||||
|
||||
|
||||
class DeploymentType(str, Enum):
|
||||
"""Core deployment types recognized by LFX contracts."""
|
||||
|
||||
# First-class deployment types recognized by the core schema.
|
||||
# Adapters may carry additional provider-specific classification in `provider_data`.
|
||||
AGENT = "agent"
|
||||
|
||||
|
||||
class BaseFlowArtifact(BaseModel):
|
||||
"""Model representing a payload for a flow."""
|
||||
|
||||
model_config = ConfigDict(extra="allow") # e.g., viewport - good for viewing the flow in the UI
|
||||
|
||||
id: UUID = Field(description="Unique identifier for the flow")
|
||||
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(
|
||||
description="The name of the flow"
|
||||
)
|
||||
description: str | None = Field(None, description="The description of the flow")
|
||||
data: dict = Field(description="The data of the flow")
|
||||
tags: list[str] | None = Field(None, description="The tags of the flow")
|
||||
provider_data: dict | None = Field(
|
||||
None,
|
||||
description="Provider-specific flow metadata consumed only by the active deployment adapter.",
|
||||
)
|
||||
|
||||
@field_validator("data")
|
||||
@classmethod
|
||||
def validate_data(cls, value: dict) -> dict:
|
||||
"""Validate flow payload shape.
|
||||
|
||||
Keep validation aligned with backend flow model expectations.
|
||||
"""
|
||||
if "nodes" not in value:
|
||||
msg = "Flow must have nodes"
|
||||
raise ValueError(msg)
|
||||
if "edges" not in value:
|
||||
msg = "Flow must have edges"
|
||||
raise ValueError(msg)
|
||||
if not isinstance(value["nodes"], list):
|
||||
msg = "Flow 'nodes' must be a list"
|
||||
raise ValueError(msg)
|
||||
if not isinstance(value["edges"], list):
|
||||
msg = "Flow 'edges' must be a list"
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
|
||||
SnapshotList = Annotated[list[BaseFlowArtifact], Field(min_length=1)]
|
||||
|
||||
|
||||
class SnapshotItem(BaseModel):
|
||||
"""Model representing a result for a snapshot item."""
|
||||
|
||||
id: IdLike = Field(description="The id of the snapshot item")
|
||||
name: str = Field(description="The name of the snapshot item")
|
||||
description: str | None = Field(None, description="The description of the snapshot item")
|
||||
provider_data: dict | None = Field(None, description="The data of the snapshot item from the provider")
|
||||
|
||||
|
||||
class SnapshotItems(BaseModel):
|
||||
"""Snapshot input for deployment create.
|
||||
|
||||
Accept raw snapshot artifact payloads for deployment create.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
raw_payloads: SnapshotList = Field(
|
||||
...,
|
||||
description="Raw snapshot payloads to create and bind for this deployment.",
|
||||
)
|
||||
|
||||
|
||||
class SnapshotDeploymentBindingUpdate(BaseModel):
|
||||
"""Snapshot deployment binding patch payload.
|
||||
|
||||
Add or remove snapshot bindings for the deployment by reference ids.
|
||||
"""
|
||||
|
||||
add: list[IdLike] | None = Field(
|
||||
None,
|
||||
description="Snapshot reference ids to attach to the deployment. Omit to leave unchanged.",
|
||||
)
|
||||
remove: list[IdLike] | None = Field(
|
||||
None,
|
||||
description="Snapshot reference ids to detach from the deployment. Omit to leave unchanged.",
|
||||
)
|
||||
|
||||
@field_validator("add", "remove")
|
||||
@classmethod
|
||||
def validate_id_lists(cls, v: list[IdLike] | None) -> list[str] | None:
|
||||
if v is None:
|
||||
return None
|
||||
return _normalize_and_dedupe_id_list(v, field_name="snapshot_id")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_operations(self):
|
||||
"""Ensure patch contains explicit and non-conflicting operations."""
|
||||
add_values = self.add or []
|
||||
remove_values = self.remove or []
|
||||
|
||||
if not add_values and not remove_values:
|
||||
msg = "At least one of 'add' or 'remove' must be provided."
|
||||
raise ValueError(msg)
|
||||
|
||||
overlap = set(add_values).intersection(remove_values)
|
||||
if overlap:
|
||||
ids = ", ".join(sorted(overlap))
|
||||
msg = f"Snapshot ids cannot be present in both 'add' and 'remove': {ids}."
|
||||
raise ValueError(msg)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
EnvVarKey = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
|
||||
|
||||
class EnvVarSource(str, Enum):
|
||||
RAW = "raw"
|
||||
VARIABLE = "variable"
|
||||
|
||||
|
||||
class EnvVarValueSpec(BaseModel):
|
||||
"""Environment variable resolution spec."""
|
||||
|
||||
value: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(
|
||||
description="Raw value or variable name, depending on the selected source."
|
||||
)
|
||||
source: EnvVarSource = Field(
|
||||
default=EnvVarSource.VARIABLE,
|
||||
description="How to interpret `value`: resolve from variable service or use raw value as-is.",
|
||||
)
|
||||
|
||||
|
||||
class DeploymentConfig(BaseModel):
|
||||
"""Deployment configuration payload, including environment variables and provider-specific settings."""
|
||||
|
||||
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] = Field(
|
||||
description="The name of the config"
|
||||
)
|
||||
description: str | None = Field(None, description="The description of the config")
|
||||
environment_variables: dict[EnvVarKey, EnvVarValueSpec] | None = Field(None, description="Environment variables")
|
||||
# the provider might have additional configuration options that are not covered here
|
||||
provider_config: ProviderPayload | None = Field(None, description="Provider configuration")
|
||||
|
||||
|
||||
class ConfigItem(BaseModel):
|
||||
"""Config input for deployment create.
|
||||
|
||||
Exactly one of `reference_id` or `raw_payload` must be provided.
|
||||
"""
|
||||
|
||||
reference_id: NormalizedId | None = Field(
|
||||
None,
|
||||
description="Existing config reference id to bind to the deployment.",
|
||||
)
|
||||
raw_payload: DeploymentConfig | None = Field(
|
||||
None,
|
||||
description="Config payload to create and bind to the deployment.",
|
||||
)
|
||||
|
||||
@field_validator("reference_id")
|
||||
@classmethod
|
||||
def validate_reference_id(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
return _normalize_and_validate_id(v, field_name="reference_id")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_config_source(self) -> "ConfigItem":
|
||||
_validate_exactly_one_of(
|
||||
self,
|
||||
first_field="reference_id",
|
||||
second_field="raw_payload",
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ConfigDeploymentBindingUpdate(BaseModel):
|
||||
"""Config deployment binding patch payload."""
|
||||
|
||||
config_id: IdLike | None = Field(
|
||||
None,
|
||||
description="Config reference id to bind to the deployment. Use null to unbind.",
|
||||
)
|
||||
|
||||
@field_validator("config_id")
|
||||
@classmethod
|
||||
def validate_config_id(cls, v: IdLike | None) -> IdLike | None:
|
||||
if isinstance(v, str):
|
||||
return _normalize_and_validate_id(v, field_name="config_id")
|
||||
return v
|
||||
|
||||
|
||||
class ProviderDataModel(BaseModel):
|
||||
"""Base model for provider metadata payloads."""
|
||||
|
||||
provider_data: ProviderPayload | None = Field(None, description="The data from the provider")
|
||||
|
||||
|
||||
class ProviderResultModel(BaseModel):
|
||||
"""Base model for provider operation payloads."""
|
||||
|
||||
provider_result: ProviderPayload | None = Field(None, description="The result from the provider")
|
||||
|
||||
|
||||
class ProviderSpecModel(BaseModel):
|
||||
"""Base model for provider-specific input payloads."""
|
||||
|
||||
provider_spec: ProviderPayload | None = Field(None, description="The data of the deployment from the provider")
|
||||
|
||||
|
||||
class BaseDeploymentData(ProviderSpecModel):
|
||||
"""Model representing a data for a deployment."""
|
||||
|
||||
name: str = Field(description="The name of the deployment")
|
||||
description: str = Field(default="", description="The description of the deployment")
|
||||
type: DeploymentType = Field(description="The type of the deployment")
|
||||
|
||||
|
||||
class DeploymentCreateResult(BaseDeploymentData, ProviderResultModel):
|
||||
"""Model representing a result for a deployment creation operation."""
|
||||
|
||||
id: IdLike = Field(description="The id of the created deployment")
|
||||
config_id: IdLike | None = Field(
|
||||
default=None,
|
||||
description="Config id produced or bound during deployment creation.",
|
||||
)
|
||||
snapshot_ids: list[IdLike] = Field(
|
||||
default_factory=list,
|
||||
description="Snapshot ids produced during deployment creation.",
|
||||
)
|
||||
|
||||
|
||||
class DeploymentOperationResult(ProviderResultModel):
|
||||
"""Base model for deployment operation responses by deployment id."""
|
||||
|
||||
id: IdLike = Field(description="The id of the deployment")
|
||||
|
||||
|
||||
class DeploymentDeleteResult(DeploymentOperationResult):
|
||||
"""Model representing a result for a deployment deletion operation."""
|
||||
|
||||
|
||||
class ItemResult(ProviderDataModel):
|
||||
"""Model representing a result for a deployment list item."""
|
||||
|
||||
id: IdLike = Field(description="The id of the deployment")
|
||||
name: str = Field(description="The name of the deployment")
|
||||
type: DeploymentType = Field(description="The type of the deployment")
|
||||
created_at: datetime.datetime | None = Field(None, description="The created timestamp of the deployment")
|
||||
updated_at: datetime.datetime | None = Field(None, description="The last updated timestamp of the deployment")
|
||||
|
||||
|
||||
class DeploymentGetResult(ItemResult):
|
||||
"""Model representing a detailed deployment payload."""
|
||||
|
||||
description: str | None = Field(None, description="The description of the deployment")
|
||||
|
||||
|
||||
class DeploymentDuplicateResult(ItemResult):
|
||||
"""Model representing the result of a deployment duplication operation."""
|
||||
|
||||
|
||||
class DeploymentListResult(ProviderResultModel):
|
||||
"""Model representing a result for a deployment list operation."""
|
||||
|
||||
deployments: list[ItemResult] = Field(description="The list of deployments")
|
||||
|
||||
|
||||
class DeploymentListParams(BaseModel):
|
||||
"""Query params for deployment list operations."""
|
||||
|
||||
provider_params: ProviderPayload | None = Field(
|
||||
None,
|
||||
description="Provider-specific query params payload.",
|
||||
)
|
||||
deployment_types: list[DeploymentType] | None = Field(
|
||||
None,
|
||||
description="Deployment types to include in the result set.",
|
||||
)
|
||||
deployment_ids: list[IdLike] | None = Field(
|
||||
None,
|
||||
description="Deployment ids to include in the result set.",
|
||||
)
|
||||
snapshot_ids: list[IdLike] | None = Field(
|
||||
None,
|
||||
description="Snapshot ids to include in the result set.",
|
||||
)
|
||||
config_ids: list[IdLike] | None = Field(
|
||||
None,
|
||||
description="Config ids to include in the result set.",
|
||||
)
|
||||
|
||||
@field_validator("deployment_types")
|
||||
@classmethod
|
||||
def validate_deployment_types(cls, value: list[DeploymentType] | None) -> list[DeploymentType] | None:
|
||||
if value is None:
|
||||
return None
|
||||
# Keep first occurrence order while removing duplicates.
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
@field_validator("deployment_ids", "snapshot_ids", "config_ids")
|
||||
@classmethod
|
||||
def validate_filter_ids(cls, value: list[IdLike] | None, info) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized_ids = _normalize_and_validate_id_list(
|
||||
[str(item) for item in value],
|
||||
field_name=info.field_name,
|
||||
)
|
||||
# Keep first occurrence order while removing duplicates.
|
||||
return list(dict.fromkeys(normalized_ids))
|
||||
|
||||
|
||||
class DeploymentCreate(BaseModel):
|
||||
"""Deployment create payload."""
|
||||
|
||||
spec: BaseDeploymentData = Field(description="The base metadata of the deployment")
|
||||
snapshot: SnapshotItems | None = Field(None, description="The snapshots of the deployment")
|
||||
config: ConfigItem | None = Field(None, description="The config of the deployment")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_deployment_create_schema() -> str:
|
||||
"""Return serialized JSON schema for deployment create payload."""
|
||||
return json.dumps(DeploymentCreate.model_json_schema(), indent=2)
|
||||
|
||||
|
||||
class BaseDeploymentDataUpdate(BaseModel):
|
||||
"""Deployment base update payload."""
|
||||
|
||||
name: str | None = Field(None, description="The name of the deployment")
|
||||
description: str | None = Field(None, description="The description of the deployment")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_has_changes(self) -> "BaseDeploymentDataUpdate":
|
||||
if self.name is None and self.description is None:
|
||||
msg = "At least one of 'name' or 'description' must be provided."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class DeploymentUpdate(BaseModel):
|
||||
"""Deployment update payload."""
|
||||
|
||||
spec: BaseDeploymentDataUpdate | None = Field(None, description="The metadata of the deployment")
|
||||
snapshot: SnapshotDeploymentBindingUpdate | None = Field(None, description="The snapshot of the deployment")
|
||||
config: ConfigDeploymentBindingUpdate | None = Field(None, description="The config of the deployment")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_has_changes(self) -> "DeploymentUpdate":
|
||||
if self.spec is None and self.snapshot is None and self.config is None:
|
||||
msg = "At least one of 'spec', 'snapshot', or 'config' must be provided."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class DeploymentUpdateResult(DeploymentOperationResult):
|
||||
"""Model representing a result for a deployment update operation."""
|
||||
|
||||
|
||||
class RedeployResult(DeploymentOperationResult):
|
||||
"""Model representing a deployment redeployment operation result."""
|
||||
|
||||
|
||||
class DeploymentStatusResult(ProviderDataModel):
|
||||
"""Model representing a deployment status response.
|
||||
|
||||
Inherits ``provider_data`` from ``ProviderDataModel`` to carry
|
||||
provider-reported health information.
|
||||
"""
|
||||
|
||||
id: IdLike = Field(description="The id of the deployment")
|
||||
|
||||
|
||||
class ExecutionCreate(BaseModel):
|
||||
"""Provider-agnostic deployment execution payload."""
|
||||
|
||||
deployment_id: IdLike = Field(description="The id of the deployment to create an execution for.")
|
||||
|
||||
provider_data: ProviderPayload | None = Field(
|
||||
None,
|
||||
description="Provider-specific execution data.",
|
||||
)
|
||||
|
||||
@field_validator("deployment_id")
|
||||
@classmethod
|
||||
def validate_deployment_id(cls, value: IdLike) -> IdLike:
|
||||
if isinstance(value, str):
|
||||
return _normalize_and_validate_id(value, field_name="deployment_id")
|
||||
return value
|
||||
|
||||
|
||||
class ExecutionResultBase(ProviderResultModel):
|
||||
"""Base model for deployment execution responses."""
|
||||
|
||||
execution_id: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque execution identifier for status polling.",
|
||||
)
|
||||
deployment_id: IdLike = Field(description="The id of the deployment that was executed.")
|
||||
|
||||
|
||||
class ExecutionCreateResult(ExecutionResultBase):
|
||||
"""Result returned when an execution is created.
|
||||
|
||||
This model intentionally remains distinct from ``ExecutionStatusResult`` even
|
||||
though both currently share the same shape. Callers should not mix the two
|
||||
response types: create responses and status responses represent different API
|
||||
stages and may diverge as the contract evolves.
|
||||
"""
|
||||
|
||||
|
||||
class ExecutionStatusResult(ExecutionResultBase):
|
||||
"""Result returned when querying an execution status.
|
||||
|
||||
This model intentionally remains distinct from ``ExecutionCreateResult`` even
|
||||
though both currently share the same shape. Callers should not mix the two
|
||||
response types: create responses and status responses represent different API
|
||||
stages and may diverge as the contract evolves.
|
||||
"""
|
||||
|
||||
|
||||
class DeploymentListTypesResult(ProviderResultModel):
|
||||
"""Model representing deployment types listing response."""
|
||||
|
||||
deployment_types: list[DeploymentType] = Field(description="Supported deployment types.")
|
||||
|
||||
|
||||
def _normalize_and_validate_id(value: str, *, field_name: str) -> str:
|
||||
"""Normalize identifier values and reject blank strings."""
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
msg = f"'{field_name}' must not be empty or whitespace."
|
||||
raise ValueError(msg)
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_and_validate_id_list(values: list[str], *, field_name: str) -> list[str]:
|
||||
"""Normalize identifier lists and reject blank entries."""
|
||||
return [_normalize_and_validate_id(value, field_name=field_name) for value in values]
|
||||
|
||||
|
||||
def _normalize_and_dedupe_id_list(values: list[IdLike], *, field_name: str) -> list[str]:
|
||||
"""Normalize identifier lists and remove duplicates while preserving order."""
|
||||
normalized_values = _normalize_and_validate_id_list(
|
||||
[str(value) for value in values],
|
||||
field_name=field_name,
|
||||
)
|
||||
return list(dict.fromkeys(normalized_values))
|
||||
|
||||
|
||||
def _validate_exactly_one_of(
|
||||
model: BaseModel,
|
||||
*,
|
||||
first_field: str,
|
||||
second_field: str,
|
||||
) -> None:
|
||||
"""Ensure exactly one of two model fields is provided."""
|
||||
has_first = getattr(model, first_field) is not None
|
||||
has_second = getattr(model, second_field) is not None
|
||||
|
||||
if has_first == has_second:
|
||||
msg = f"Exactly one of '{first_field}' or '{second_field}' must be provided."
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def get_str_id(v: IdLike) -> str:
|
||||
return str(v) if isinstance(v, UUID) else v
|
||||
|
||||
|
||||
def get_uuid(v: UUID | str) -> UUID:
|
||||
"""Return a UUID from a UUID object or UUID-formatted string.
|
||||
|
||||
This helper is intentionally strict: opaque deployment identifiers like
|
||||
``dep_1`` are valid ``IdLike`` values, but are not valid UUIDs.
|
||||
Use ``get_str_id`` when callers need to preserve opaque ids.
|
||||
"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return UUID(v)
|
||||
except ValueError as exc:
|
||||
msg = f"Cannot convert identifier '{v}' to UUID. Use get_str_id() for opaque IDs."
|
||||
raise ValueError(msg) from exc
|
||||
return v
|
||||
158
src/lfx/src/lfx/services/deployment/service.py
Normal file
158
src/lfx/src/lfx/services/deployment/service.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""Default (no-op) deployment service implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lfx.log.logger import logger
|
||||
from lfx.services.deployment.base import BaseDeploymentService
|
||||
from lfx.services.deployment.exceptions import DeploymentNotConfiguredError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from lfx.services.deployment.schema import (
|
||||
DeploymentCreate,
|
||||
DeploymentCreateResult,
|
||||
DeploymentDeleteResult,
|
||||
DeploymentDuplicateResult,
|
||||
DeploymentGetResult,
|
||||
DeploymentListParams,
|
||||
DeploymentListResult,
|
||||
DeploymentListTypesResult,
|
||||
DeploymentStatusResult,
|
||||
DeploymentUpdate,
|
||||
DeploymentUpdateResult,
|
||||
ExecutionCreate,
|
||||
ExecutionCreateResult,
|
||||
ExecutionStatusResult,
|
||||
IdLike,
|
||||
RedeployResult,
|
||||
)
|
||||
|
||||
|
||||
# Not registered yet — no ServiceType.DEPLOYMENT_SERVICE enum value exists.
|
||||
# This stub defines the concrete class so the protocol and ABC are testable.
|
||||
# A future PR will add the enum value and register the service.
|
||||
class DeploymentService(BaseDeploymentService):
|
||||
"""Null deployment service for LFX.
|
||||
|
||||
All operations raise :class:`DeploymentNotConfiguredError` because LFX does
|
||||
not ship a deployment adapter. Concrete adapters (e.g. in Langflow) should
|
||||
subclass :class:`BaseDeploymentService` to provide real behaviour.
|
||||
"""
|
||||
|
||||
name = "deployment_service"
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
payload: DeploymentCreate,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentCreateResult:
|
||||
"""Create a new deployment in the provider."""
|
||||
raise DeploymentNotConfiguredError(method="create")
|
||||
|
||||
async def list_types(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentListTypesResult:
|
||||
"""List deployment types supported by the provider."""
|
||||
raise DeploymentNotConfiguredError(method="list_types")
|
||||
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
params: DeploymentListParams | None = None,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentListResult:
|
||||
"""List deployments visible to this adapter."""
|
||||
raise DeploymentNotConfiguredError(method="list")
|
||||
|
||||
async def get(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentGetResult:
|
||||
"""Return deployment metadata by provider ID."""
|
||||
raise DeploymentNotConfiguredError(method="get")
|
||||
|
||||
async def update(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
payload: DeploymentUpdate,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentUpdateResult:
|
||||
"""Update deployment inputs and apply changes in the provider."""
|
||||
raise DeploymentNotConfiguredError(method="update")
|
||||
|
||||
async def redeploy(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> RedeployResult:
|
||||
"""Re-apply current deployment inputs without changing them."""
|
||||
raise DeploymentNotConfiguredError(method="redeploy")
|
||||
|
||||
async def duplicate(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentDuplicateResult:
|
||||
"""Create a new deployment using the same inputs as the source."""
|
||||
raise DeploymentNotConfiguredError(method="duplicate")
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentDeleteResult:
|
||||
"""Delete the deployment from the provider."""
|
||||
raise DeploymentNotConfiguredError(method="delete")
|
||||
|
||||
async def get_status(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentStatusResult:
|
||||
"""Return provider-reported health/status for the deployment."""
|
||||
raise DeploymentNotConfiguredError(method="get_status")
|
||||
|
||||
async def create_execution(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
payload: ExecutionCreate,
|
||||
db: AsyncSession,
|
||||
) -> ExecutionCreateResult:
|
||||
"""Run a provider-agnostic deployment execution."""
|
||||
raise DeploymentNotConfiguredError(method="create_execution")
|
||||
|
||||
async def get_execution(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
execution_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> ExecutionStatusResult:
|
||||
"""Get provider-agnostic deployment execution state/output."""
|
||||
raise DeploymentNotConfiguredError(method="get_execution")
|
||||
|
||||
async def teardown(self) -> None:
|
||||
logger.debug("Deployment service teardown")
|
||||
@ -11,11 +11,29 @@ if TYPE_CHECKING:
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from lfx.services.deployment.schema import (
|
||||
DeploymentCreate,
|
||||
DeploymentCreateResult,
|
||||
DeploymentDeleteResult,
|
||||
DeploymentDuplicateResult,
|
||||
DeploymentGetResult,
|
||||
DeploymentListParams,
|
||||
DeploymentListResult,
|
||||
DeploymentListTypesResult,
|
||||
DeploymentStatusResult,
|
||||
DeploymentUpdate,
|
||||
DeploymentUpdateResult,
|
||||
ExecutionCreate,
|
||||
ExecutionCreateResult,
|
||||
ExecutionStatusResult,
|
||||
IdLike,
|
||||
RedeployResult,
|
||||
)
|
||||
from lfx.services.settings.base import Settings
|
||||
|
||||
|
||||
class AuthUserProtocol(Protocol):
|
||||
"""Auhtenticated user object (id, username, is_active, is_superuser).
|
||||
"""Authenticated user object (id, username, is_active, is_superuser).
|
||||
|
||||
Implementations may use User or UserRead from the database layer; this protocol
|
||||
describes the surface needed by consumers of the auth service.
|
||||
@ -204,3 +222,143 @@ class TransactionServiceProtocol(Protocol):
|
||||
True if transaction logging is enabled, False otherwise.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeploymentServiceProtocol(Protocol):
|
||||
"""Protocol for deployment provider services.
|
||||
|
||||
This protocol exposes adapter-agnostic deployment contracts:
|
||||
top-level fields are minimal generic metadata, while provider-specific
|
||||
details are carried in ``provider_data``/``provider_result`` fields.
|
||||
|
||||
Keep this protocol intentionally narrow (consumer-facing CRUD + status).
|
||||
Adapter-specific or advanced operations are defined on concrete deployment
|
||||
service classes.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
payload: DeploymentCreate,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentCreateResult:
|
||||
"""Create a new deployment in the provider."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_types(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentListTypesResult:
|
||||
"""List deployment types supported by the provider."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
params: DeploymentListParams | None = None,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentListResult:
|
||||
"""List deployments visible to this adapter."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentGetResult:
|
||||
"""Return deployment metadata by provider ID."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def update(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
payload: DeploymentUpdate,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentUpdateResult:
|
||||
"""Update deployment inputs and apply changes in the provider."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def redeploy(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> RedeployResult:
|
||||
"""Re-apply current deployment inputs without changing them."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def duplicate(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentDuplicateResult:
|
||||
"""Create a new deployment using the same inputs as the source."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def delete(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentDeleteResult:
|
||||
"""Delete the deployment from the provider."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_status(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
deployment_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> DeploymentStatusResult:
|
||||
"""Return provider-reported health/status for the deployment."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def create_execution(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
payload: ExecutionCreate,
|
||||
db: AsyncSession,
|
||||
) -> ExecutionCreateResult:
|
||||
"""Run a provider-agnostic deployment execution."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_execution(
|
||||
self,
|
||||
*,
|
||||
user_id: IdLike,
|
||||
execution_id: IdLike,
|
||||
db: AsyncSession,
|
||||
) -> ExecutionStatusResult:
|
||||
"""Get provider-agnostic deployment execution state/output."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def teardown(self) -> None:
|
||||
"""Teardown the deployment service."""
|
||||
...
|
||||
|
||||
1
src/lfx/tests/unit/services/deployment/__init__.py
Normal file
1
src/lfx/tests/unit/services/deployment/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Deployment service schema/protocol unit tests package."""
|
||||
@ -0,0 +1,166 @@
|
||||
"""Tests for deployment exception hierarchy, metadata, and service instantiation."""
|
||||
|
||||
import pytest
|
||||
from lfx.services.deployment import (
|
||||
BaseDeploymentService,
|
||||
DeploymentError,
|
||||
DeploymentNotConfiguredError,
|
||||
DeploymentService,
|
||||
DeploymentServiceError,
|
||||
)
|
||||
from lfx.services.deployment.exceptions import (
|
||||
AuthenticationError,
|
||||
AuthSchemeError,
|
||||
CredentialResolutionError,
|
||||
DeploymentConflictError,
|
||||
DeploymentNotFoundError,
|
||||
DeploymentSupportError,
|
||||
InvalidContentError,
|
||||
InvalidDeploymentOperationError,
|
||||
InvalidDeploymentTypeError,
|
||||
)
|
||||
from lfx.services.interfaces import DeploymentServiceProtocol
|
||||
|
||||
|
||||
def test_exception_hierarchy_is_preserved() -> None:
|
||||
# DeploymentServiceError is the common root
|
||||
assert issubclass(DeploymentError, DeploymentServiceError)
|
||||
assert issubclass(AuthenticationError, DeploymentServiceError)
|
||||
|
||||
# AuthenticationError is a sibling of DeploymentError, NOT a child
|
||||
assert not issubclass(AuthenticationError, DeploymentError)
|
||||
assert not issubclass(DeploymentError, AuthenticationError)
|
||||
|
||||
# Auth subtypes
|
||||
assert issubclass(CredentialResolutionError, AuthenticationError)
|
||||
assert issubclass(AuthSchemeError, AuthenticationError)
|
||||
|
||||
# Deployment operation subtypes
|
||||
assert issubclass(DeploymentConflictError, DeploymentError)
|
||||
assert issubclass(InvalidContentError, DeploymentError)
|
||||
assert issubclass(InvalidDeploymentOperationError, DeploymentError)
|
||||
assert issubclass(DeploymentNotConfiguredError, DeploymentError)
|
||||
|
||||
|
||||
def test_exception_error_codes_are_set() -> None:
|
||||
assert CredentialResolutionError().error_code == "credentials_resolution_error"
|
||||
assert DeploymentConflictError().error_code == "deployment_conflict"
|
||||
assert DeploymentSupportError().error_code == "unsupported_deployment_type"
|
||||
assert InvalidDeploymentTypeError().error_code == "invalid_deployment_type"
|
||||
assert InvalidContentError().error_code == "unprocessable_content_error"
|
||||
assert InvalidDeploymentOperationError().error_code == "invalid_deployment_operation"
|
||||
assert AuthSchemeError().error_code == "unsupported_auth_type"
|
||||
assert DeploymentNotConfiguredError().error_code == "deployment_not_configured"
|
||||
|
||||
|
||||
def test_deployment_type_exceptions_have_distinct_default_messages() -> None:
|
||||
assert str(DeploymentSupportError()) == "Deployment type is unsupported by this adapter"
|
||||
assert str(InvalidDeploymentTypeError()) == "Deployment type is malformed or unknown"
|
||||
|
||||
|
||||
def test_deployment_not_found_includes_context_when_id_is_provided() -> None:
|
||||
err = DeploymentNotFoundError(deployment_id="dep_1")
|
||||
assert str(err) == "Deployment not found: dep_1"
|
||||
assert err.error_code == "deployment_not_found"
|
||||
assert err.deployment_id == "dep_1"
|
||||
|
||||
|
||||
def test_deployment_not_found_default_message() -> None:
|
||||
err = DeploymentNotFoundError()
|
||||
assert str(err) == "Deployment not found"
|
||||
assert err.deployment_id is None
|
||||
|
||||
|
||||
def test_deployment_not_found_preserves_deployment_id_with_custom_message() -> None:
|
||||
err = DeploymentNotFoundError(message="Custom error", deployment_id="dep_1")
|
||||
assert str(err) == "Custom error"
|
||||
assert err.deployment_id == "dep_1"
|
||||
|
||||
|
||||
def test_base_error_supports_cause_chaining() -> None:
|
||||
root = ValueError("boom")
|
||||
err = DeploymentError("failed", error_code="deployment_error", cause=root)
|
||||
assert err.cause is root
|
||||
assert err.__cause__ is root
|
||||
|
||||
|
||||
def test_base_error_requires_error_code() -> None:
|
||||
err = DeploymentError("failed", error_code="test_code")
|
||||
assert err.error_code == "test_code"
|
||||
|
||||
|
||||
def test_deployment_service_is_instantiable() -> None:
|
||||
svc = DeploymentService()
|
||||
assert svc.name == "deployment_service"
|
||||
|
||||
|
||||
def test_deployment_service_satisfies_protocol() -> None:
|
||||
svc = DeploymentService()
|
||||
assert isinstance(svc, DeploymentServiceProtocol)
|
||||
|
||||
|
||||
def test_deployment_service_is_base_deployment_service() -> None:
|
||||
assert issubclass(DeploymentService, BaseDeploymentService)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method_name", "kwargs"),
|
||||
[
|
||||
("create", {"user_id": "u1", "payload": None, "db": None}),
|
||||
("list_types", {"user_id": "u1", "db": None}),
|
||||
("list", {"user_id": "u1", "db": None}),
|
||||
("get", {"user_id": "u1", "deployment_id": "d1", "db": None}),
|
||||
("update", {"user_id": "u1", "deployment_id": "d1", "payload": None, "db": None}),
|
||||
("redeploy", {"user_id": "u1", "deployment_id": "d1", "db": None}),
|
||||
("duplicate", {"user_id": "u1", "deployment_id": "d1", "db": None}),
|
||||
("delete", {"user_id": "u1", "deployment_id": "d1", "db": None}),
|
||||
("get_status", {"user_id": "u1", "deployment_id": "d1", "db": None}),
|
||||
("create_execution", {"user_id": "u1", "payload": None, "db": None}),
|
||||
("get_execution", {"user_id": "u1", "execution_id": "e1", "db": None}),
|
||||
],
|
||||
)
|
||||
async def test_deployment_service_stub_methods_raise(method_name: str, kwargs: dict) -> None:
|
||||
svc = DeploymentService()
|
||||
with pytest.raises(DeploymentNotConfiguredError, match="requires a concrete deployment adapter"):
|
||||
await getattr(svc, method_name)(**kwargs)
|
||||
|
||||
|
||||
async def test_deployment_service_teardown_is_noop() -> None:
|
||||
svc = DeploymentService()
|
||||
await svc.teardown()
|
||||
|
||||
|
||||
def test_deployment_not_configured_includes_method_name() -> None:
|
||||
err = DeploymentNotConfiguredError(method="create")
|
||||
assert "DeploymentService.create()" in str(err)
|
||||
assert err.error_code == "deployment_not_configured"
|
||||
|
||||
|
||||
def test_deployment_not_configured_default_message() -> None:
|
||||
err = DeploymentNotConfiguredError()
|
||||
assert "No deployment adapter is registered" in str(err)
|
||||
|
||||
|
||||
def test_auth_errors_not_caught_by_deployment_error() -> None:
|
||||
"""Ensure except DeploymentError does NOT catch auth failures."""
|
||||
with pytest.raises(AuthenticationError):
|
||||
try:
|
||||
raise CredentialResolutionError()
|
||||
except DeploymentError:
|
||||
pytest.fail("DeploymentError should not catch AuthenticationError")
|
||||
|
||||
|
||||
def test_deployment_service_error_catches_both_hierarchies() -> None:
|
||||
"""DeploymentServiceError catches both deployment and auth errors."""
|
||||
with pytest.raises(DeploymentServiceError):
|
||||
raise CredentialResolutionError()
|
||||
with pytest.raises(DeploymentServiceError):
|
||||
raise DeploymentNotFoundError()
|
||||
|
||||
|
||||
def test_package_exports_base_and_error() -> None:
|
||||
from lfx.services.deployment import BaseDeploymentService, DeploymentError, DeploymentService
|
||||
|
||||
assert BaseDeploymentService is not None
|
||||
assert DeploymentError is not None
|
||||
assert DeploymentService is not None
|
||||
421
src/lfx/tests/unit/services/deployment/test_deployment_schema.py
Normal file
421
src/lfx/tests/unit/services/deployment/test_deployment_schema.py
Normal file
@ -0,0 +1,421 @@
|
||||
"""Tests for deployment schema validation and shared model shapes."""
|
||||
|
||||
import json
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from lfx.services.deployment.schema import (
|
||||
BaseDeploymentDataUpdate,
|
||||
BaseFlowArtifact,
|
||||
ConfigDeploymentBindingUpdate,
|
||||
ConfigItem,
|
||||
DeploymentConfig,
|
||||
DeploymentCreate,
|
||||
DeploymentCreateResult,
|
||||
DeploymentDeleteResult,
|
||||
DeploymentListParams,
|
||||
DeploymentType,
|
||||
DeploymentUpdate,
|
||||
DeploymentUpdateResult,
|
||||
EnvVarSource,
|
||||
EnvVarValueSpec,
|
||||
ExecutionCreate,
|
||||
ExecutionCreateResult,
|
||||
ExecutionStatusResult,
|
||||
RedeployResult,
|
||||
SnapshotDeploymentBindingUpdate,
|
||||
SnapshotItems,
|
||||
get_deployment_create_schema,
|
||||
get_str_id,
|
||||
get_uuid,
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def test_snapshot_items_requires_raw_payloads() -> None:
|
||||
with pytest.raises(ValidationError, match="Field required"):
|
||||
SnapshotItems()
|
||||
|
||||
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
|
||||
SnapshotItems(
|
||||
raw_payloads=[
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "Flow",
|
||||
"description": "test",
|
||||
"data": {},
|
||||
"tags": [],
|
||||
}
|
||||
],
|
||||
reference_ids=["snap_1"],
|
||||
)
|
||||
|
||||
|
||||
def test_config_item_requires_exactly_one_source() -> None:
|
||||
with pytest.raises(ValidationError, match="Exactly one of 'reference_id' or 'raw_payload'"):
|
||||
ConfigItem()
|
||||
|
||||
with pytest.raises(ValidationError, match="Exactly one of 'reference_id' or 'raw_payload'"):
|
||||
ConfigItem(reference_id="cfg_1", raw_payload={"name": "cfg"})
|
||||
|
||||
|
||||
def test_config_item_accepts_reference_id_only() -> None:
|
||||
item = ConfigItem(reference_id="cfg_1")
|
||||
assert item.reference_id == "cfg_1"
|
||||
assert item.raw_payload is None
|
||||
|
||||
|
||||
def test_config_item_accepts_raw_payload_only() -> None:
|
||||
item = ConfigItem(raw_payload={"name": "cfg"})
|
||||
assert item.raw_payload is not None
|
||||
assert item.reference_id is None
|
||||
|
||||
|
||||
def test_deployment_list_params_normalizes_and_dedupes_id_filters() -> None:
|
||||
dep_uuid = uuid4()
|
||||
cfg_uuid = uuid4()
|
||||
|
||||
params = DeploymentListParams(
|
||||
deployment_ids=[dep_uuid, " dep-id ", "dep-id"],
|
||||
snapshot_ids=[" snap-id ", "snap-id"],
|
||||
config_ids=[cfg_uuid, str(cfg_uuid)],
|
||||
)
|
||||
|
||||
assert params.deployment_ids == [str(dep_uuid), "dep-id"]
|
||||
assert params.snapshot_ids == ["snap-id"]
|
||||
assert params.config_ids == [str(cfg_uuid)]
|
||||
|
||||
|
||||
def test_deployment_list_params_defaults_to_none() -> None:
|
||||
params = DeploymentListParams()
|
||||
assert params.deployment_ids is None
|
||||
assert params.snapshot_ids is None
|
||||
assert params.config_ids is None
|
||||
assert params.deployment_types is None
|
||||
assert params.provider_params is None
|
||||
|
||||
|
||||
def test_deployment_list_params_dedupes_types_preserving_order() -> None:
|
||||
params = DeploymentListParams(
|
||||
deployment_types=[
|
||||
DeploymentType.AGENT,
|
||||
DeploymentType.AGENT,
|
||||
]
|
||||
)
|
||||
assert params.deployment_types == [DeploymentType.AGENT]
|
||||
|
||||
|
||||
def test_deployment_list_params_rejects_blank_filter_ids() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
DeploymentListParams(deployment_ids=[" "])
|
||||
|
||||
|
||||
def test_snapshot_binding_update_accepts_idlike_and_dedupes() -> None:
|
||||
snapshot_uuid = uuid4()
|
||||
|
||||
payload = SnapshotDeploymentBindingUpdate(
|
||||
add=[snapshot_uuid, f" {snapshot_uuid} ", "snap_1", "snap_1"],
|
||||
remove=[" snap_2 ", "snap_2"],
|
||||
)
|
||||
|
||||
assert payload.add == [str(snapshot_uuid), "snap_1"]
|
||||
assert payload.remove == ["snap_2"]
|
||||
|
||||
|
||||
def test_snapshot_binding_update_add_only() -> None:
|
||||
payload = SnapshotDeploymentBindingUpdate(add=["snap_1"])
|
||||
assert payload.add == ["snap_1"]
|
||||
assert payload.remove is None
|
||||
|
||||
|
||||
def test_snapshot_binding_update_remove_only() -> None:
|
||||
payload = SnapshotDeploymentBindingUpdate(remove=["snap_1"])
|
||||
assert payload.remove == ["snap_1"]
|
||||
assert payload.add is None
|
||||
|
||||
|
||||
def test_snapshot_binding_update_rejects_overlap_after_normalization() -> None:
|
||||
snapshot_uuid = uuid4()
|
||||
with pytest.raises(ValidationError, match="cannot be present in both 'add' and 'remove'"):
|
||||
SnapshotDeploymentBindingUpdate(
|
||||
add=[snapshot_uuid, " snap_1 "],
|
||||
remove=[str(snapshot_uuid), "snap_1"],
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_binding_update_rejects_blank_ids() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SnapshotDeploymentBindingUpdate(add=[" "])
|
||||
|
||||
|
||||
def test_snapshot_binding_update_preserves_order_while_deduping() -> None:
|
||||
payload = SnapshotDeploymentBindingUpdate(add=["b", "a", "b", "c", "a"])
|
||||
assert payload.add == ["b", "a", "c"]
|
||||
|
||||
|
||||
def test_snapshot_binding_update_rejects_noop_payload() -> None:
|
||||
with pytest.raises(ValidationError, match="At least one of 'add' or 'remove'"):
|
||||
SnapshotDeploymentBindingUpdate()
|
||||
|
||||
|
||||
def test_config_item_reference_id_rejects_blank() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConfigItem(reference_id=" ")
|
||||
|
||||
|
||||
def test_config_deployment_binding_update_normalizes_and_accepts_uuid() -> None:
|
||||
cfg_uuid = uuid4()
|
||||
|
||||
normalized = ConfigDeploymentBindingUpdate(config_id=" cfg_1 ")
|
||||
assert normalized.config_id == "cfg_1"
|
||||
|
||||
passthrough = ConfigDeploymentBindingUpdate(config_id=cfg_uuid)
|
||||
assert passthrough.config_id == cfg_uuid
|
||||
|
||||
|
||||
def test_config_deployment_binding_update_rejects_blank() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ConfigDeploymentBindingUpdate(config_id=" ")
|
||||
|
||||
|
||||
def test_deployment_create_rejects_invalid_deployment_type() -> None:
|
||||
with pytest.raises(ValidationError, match="type"):
|
||||
DeploymentCreate(spec={"name": "my deployment", "type": "invalid-type"})
|
||||
|
||||
|
||||
def test_snapshot_items_rejects_empty_raw_payload_list() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
SnapshotItems(raw_payloads=[])
|
||||
|
||||
|
||||
def test_base_flow_artifact_allows_extra_fields() -> None:
|
||||
flow = BaseFlowArtifact(
|
||||
id=uuid4(),
|
||||
name="Flow",
|
||||
description="desc",
|
||||
data={"nodes": [], "edges": []},
|
||||
tags=["tag"],
|
||||
viewport={"x": 10, "y": 20},
|
||||
)
|
||||
assert flow.model_extra is not None
|
||||
assert flow.model_extra["viewport"] == {"x": 10, "y": 20}
|
||||
|
||||
|
||||
def test_base_flow_artifact_requires_nodes_and_edges() -> None:
|
||||
with pytest.raises(ValidationError, match="Flow must have nodes"):
|
||||
BaseFlowArtifact(
|
||||
id=uuid4(),
|
||||
name="Flow",
|
||||
data={"edges": []},
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError, match="Flow must have edges"):
|
||||
BaseFlowArtifact(
|
||||
id=uuid4(),
|
||||
name="Flow",
|
||||
data={"nodes": []},
|
||||
)
|
||||
|
||||
|
||||
def test_base_flow_artifact_validates_nodes_and_edges_are_lists() -> None:
|
||||
with pytest.raises(ValidationError, match="Flow 'nodes' must be a list"):
|
||||
BaseFlowArtifact(
|
||||
id=uuid4(),
|
||||
name="Flow",
|
||||
data={"nodes": "not a list", "edges": []},
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError, match="Flow 'edges' must be a list"):
|
||||
BaseFlowArtifact(
|
||||
id=uuid4(),
|
||||
name="Flow",
|
||||
data={"nodes": [], "edges": 42},
|
||||
)
|
||||
|
||||
|
||||
def test_base_flow_artifact_rejects_empty_name() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
BaseFlowArtifact(
|
||||
id=uuid4(),
|
||||
name="",
|
||||
data={"nodes": [], "edges": []},
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_items_accepts_starter_project_data_shape() -> None:
|
||||
payload = SnapshotItems(
|
||||
raw_payloads=[
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "Starter Project Flow",
|
||||
"description": "starter project payload shape",
|
||||
"data": {"nodes": [], "edges": []},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert payload.raw_payloads is not None
|
||||
assert payload.raw_payloads[0].data == {"nodes": [], "edges": []}
|
||||
|
||||
|
||||
def test_snapshot_items_rejects_wrapped_data_shape() -> None:
|
||||
with pytest.raises(ValidationError, match="Flow must have nodes"):
|
||||
SnapshotItems(
|
||||
raw_payloads=[
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "Wrapped Flow",
|
||||
# Invalid for BaseFlowArtifact.data: this is one level too high.
|
||||
"data": {"data": {"nodes": [], "edges": []}},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_execution_create_normalizes_string_deployment_id() -> None:
|
||||
payload = ExecutionCreate(deployment_id=" dep_1 ")
|
||||
assert payload.deployment_id == "dep_1"
|
||||
|
||||
|
||||
def test_execution_create_accepts_uuid_deployment_id() -> None:
|
||||
dep_uuid = uuid4()
|
||||
payload = ExecutionCreate(deployment_id=dep_uuid)
|
||||
assert payload.deployment_id == dep_uuid
|
||||
|
||||
|
||||
def test_execution_create_rejects_blank_deployment_id() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExecutionCreate(deployment_id=" ")
|
||||
|
||||
|
||||
def test_get_id_helpers_round_trip() -> None:
|
||||
value = uuid4()
|
||||
value_str = get_str_id(value)
|
||||
assert isinstance(value_str, str)
|
||||
assert get_uuid(value_str) == value
|
||||
|
||||
passthrough = "dep_1"
|
||||
assert get_str_id(passthrough) == passthrough
|
||||
assert get_uuid(str(value)) == UUID(str(value))
|
||||
|
||||
|
||||
def test_get_uuid_rejects_non_uuid_strings() -> None:
|
||||
with pytest.raises(ValueError, match="Use get_str_id\\(\\) for opaque IDs"):
|
||||
get_uuid("dep_1")
|
||||
|
||||
|
||||
def test_execution_create_and_status_results_have_same_shape() -> None:
|
||||
payload = {
|
||||
"execution_id": "exec_1",
|
||||
"deployment_id": "dep_1",
|
||||
"provider_result": {"status": "running"},
|
||||
}
|
||||
|
||||
create_result = ExecutionCreateResult(**payload)
|
||||
status_result = ExecutionStatusResult(**payload)
|
||||
|
||||
assert create_result.model_dump() == status_result.model_dump()
|
||||
|
||||
|
||||
def test_operation_results_share_provider_result_contract() -> None:
|
||||
provider_result = {"accepted": True}
|
||||
|
||||
deleted = DeploymentDeleteResult(id="dep_1", provider_result=provider_result)
|
||||
updated = DeploymentUpdateResult(id="dep_1", provider_result=provider_result)
|
||||
redeployed = RedeployResult(id="dep_1", provider_result=provider_result)
|
||||
|
||||
assert deleted.provider_result == provider_result
|
||||
assert updated.provider_result == provider_result
|
||||
assert redeployed.provider_result == provider_result
|
||||
|
||||
|
||||
def test_base_deployment_data_update_requires_at_least_one_field() -> None:
|
||||
with pytest.raises(ValidationError, match="At least one of 'name' or 'description'"):
|
||||
BaseDeploymentDataUpdate()
|
||||
|
||||
|
||||
def test_deployment_update_requires_at_least_one_section() -> None:
|
||||
with pytest.raises(ValidationError, match="At least one of 'spec', 'snapshot', or 'config'"):
|
||||
DeploymentUpdate()
|
||||
|
||||
|
||||
def test_deployment_update_accepts_spec_only() -> None:
|
||||
update = DeploymentUpdate(spec={"name": "new name"})
|
||||
assert update.spec is not None
|
||||
assert update.snapshot is None
|
||||
assert update.config is None
|
||||
|
||||
|
||||
def test_deployment_update_accepts_config_only() -> None:
|
||||
update = DeploymentUpdate(config={"config_id": "cfg_1"})
|
||||
assert update.config is not None
|
||||
assert update.spec is None
|
||||
assert update.snapshot is None
|
||||
|
||||
|
||||
def test_deployment_update_accepts_snapshot_only() -> None:
|
||||
update = DeploymentUpdate(snapshot={"add": ["snap_1"]})
|
||||
assert update.snapshot is not None
|
||||
assert update.spec is None
|
||||
assert update.config is None
|
||||
|
||||
|
||||
def test_env_var_config_accepts_raw_and_variable_sources() -> None:
|
||||
config = DeploymentConfig(
|
||||
name="cfg",
|
||||
environment_variables={
|
||||
"RAW_TOKEN": EnvVarValueSpec(value="literal", source=EnvVarSource.RAW),
|
||||
"VAR_TOKEN": EnvVarValueSpec(value="OPENAI_API_KEY", source=EnvVarSource.VARIABLE),
|
||||
},
|
||||
)
|
||||
assert config.environment_variables is not None
|
||||
assert config.environment_variables["RAW_TOKEN"].source == EnvVarSource.RAW
|
||||
assert config.environment_variables["VAR_TOKEN"].source == EnvVarSource.VARIABLE
|
||||
|
||||
|
||||
def test_env_var_value_spec_rejects_blank_value() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EnvVarValueSpec(value=" ")
|
||||
|
||||
|
||||
def test_deployment_create_happy_path_with_snapshot_and_config() -> None:
|
||||
payload = DeploymentCreate(
|
||||
spec={"name": "my deployment", "description": "desc", "type": DeploymentType.AGENT},
|
||||
snapshot={
|
||||
"raw_payloads": [
|
||||
{
|
||||
"id": uuid4(),
|
||||
"name": "Flow",
|
||||
"description": "flow description",
|
||||
"data": {"nodes": [], "edges": []},
|
||||
}
|
||||
]
|
||||
},
|
||||
config={"raw_payload": {"name": "cfg", "description": "cfg desc"}},
|
||||
)
|
||||
assert payload.spec.type == DeploymentType.AGENT
|
||||
assert payload.snapshot is not None
|
||||
assert payload.config is not None
|
||||
|
||||
|
||||
def test_deployment_create_result_defaults() -> None:
|
||||
result = DeploymentCreateResult(
|
||||
id="dep_1",
|
||||
name="deployment",
|
||||
description="",
|
||||
type=DeploymentType.AGENT,
|
||||
)
|
||||
assert result.snapshot_ids == []
|
||||
assert result.config_id is None
|
||||
|
||||
|
||||
def test_get_deployment_create_schema_returns_valid_json() -> None:
|
||||
schema_str = get_deployment_create_schema()
|
||||
schema = json.loads(schema_str)
|
||||
assert "properties" in schema
|
||||
assert "spec" in schema["properties"]
|
||||
|
||||
|
||||
def test_get_deployment_create_schema_is_cached() -> None:
|
||||
first = get_deployment_create_schema()
|
||||
second = get_deployment_create_schema()
|
||||
assert first is second
|
||||
Reference in New Issue
Block a user