diff --git a/src/lfx/src/lfx/services/deployment/__init__.py b/src/lfx/src/lfx/services/deployment/__init__.py new file mode 100644 index 0000000000..9dc380ebf6 --- /dev/null +++ b/src/lfx/src/lfx/services/deployment/__init__.py @@ -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", +] diff --git a/src/lfx/src/lfx/services/deployment/base.py b/src/lfx/src/lfx/services/deployment/base.py new file mode 100644 index 0000000000..f630a06931 --- /dev/null +++ b/src/lfx/src/lfx/services/deployment/base.py @@ -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.""" diff --git a/src/lfx/src/lfx/services/deployment/exceptions.py b/src/lfx/src/lfx/services/deployment/exceptions.py new file mode 100644 index 0000000000..3e05f2b09b --- /dev/null +++ b/src/lfx/src/lfx/services/deployment/exceptions.py @@ -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) diff --git a/src/lfx/src/lfx/services/deployment/schema.py b/src/lfx/src/lfx/services/deployment/schema.py new file mode 100644 index 0000000000..3e0664fc12 --- /dev/null +++ b/src/lfx/src/lfx/services/deployment/schema.py @@ -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 diff --git a/src/lfx/src/lfx/services/deployment/service.py b/src/lfx/src/lfx/services/deployment/service.py new file mode 100644 index 0000000000..c3dbc6e7c7 --- /dev/null +++ b/src/lfx/src/lfx/services/deployment/service.py @@ -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") diff --git a/src/lfx/src/lfx/services/interfaces.py b/src/lfx/src/lfx/services/interfaces.py index 616eee6562..92bd143f95 100644 --- a/src/lfx/src/lfx/services/interfaces.py +++ b/src/lfx/src/lfx/services/interfaces.py @@ -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.""" + ... diff --git a/src/lfx/tests/unit/services/deployment/__init__.py b/src/lfx/tests/unit/services/deployment/__init__.py new file mode 100644 index 0000000000..94ee09c6a4 --- /dev/null +++ b/src/lfx/tests/unit/services/deployment/__init__.py @@ -0,0 +1 @@ +"""Deployment service schema/protocol unit tests package.""" diff --git a/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py b/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py new file mode 100644 index 0000000000..22916859a4 --- /dev/null +++ b/src/lfx/tests/unit/services/deployment/test_deployment_exceptions.py @@ -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 diff --git a/src/lfx/tests/unit/services/deployment/test_deployment_schema.py b/src/lfx/tests/unit/services/deployment/test_deployment_schema.py new file mode 100644 index 0000000000..68e6461c1d --- /dev/null +++ b/src/lfx/tests/unit/services/deployment/test_deployment_schema.py @@ -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