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:
Hamza Rashid
2026-03-02 18:30:34 -05:00
committed by GitHub
parent ca9ba72981
commit 91c016aaed
9 changed files with 1717 additions and 1 deletions

View 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",
]

View 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."""

View 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)

View 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

View 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")

View File

@ -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."""
...

View File

@ -0,0 +1 @@
"""Deployment service schema/protocol unit tests package."""

View File

@ -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

View 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