diff --git a/.secrets.baseline b/.secrets.baseline index d9c801cbb..5aa28eab2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2781,7 +2781,7 @@ "filename": "src/backend/tests/unit/api/v1/test_deployment_schemas.py", "hashed_secret": "99091d046a81493ef2545d8c3cd8e881e8702893", "is_verified": false, - "line_number": 45, + "line_number": 47, "is_secret": false }, { @@ -2789,7 +2789,7 @@ "filename": "src/backend/tests/unit/api/v1/test_deployment_schemas.py", "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", "is_verified": false, - "line_number": 67, + "line_number": 69, "is_secret": false } ], @@ -7378,5 +7378,5 @@ } ] }, - "generated_at": "2026-03-10T15:42:06Z" + "generated_at": "2026-03-11T18:20:16Z" } diff --git a/src/backend/base/langflow/api/v1/schemas/deployments.py b/src/backend/base/langflow/api/v1/schemas/deployments.py index 68b4f6d8a..2f684d2b3 100644 --- a/src/backend/base/langflow/api/v1/schemas/deployments.py +++ b/src/backend/base/langflow/api/v1/schemas/deployments.py @@ -63,23 +63,27 @@ from pydantic import AfterValidator, BaseModel, Field, SecretStr, ValidationInfo def _validate_str_id_list(values: list[str], *, field_name: str) -> list[str]: - """Strip, reject empty values, reject empty lists, and reject duplicates in a list of string identifiers.""" + """Strip, reject empty/whitespace values, reject empty lists, and deduplicate preserving order.""" if not values: msg = f"{field_name} must not be empty." raise ValueError(msg) - cleaned: list[str] = [] - seen: set[str] = set() + stripped = [] for raw in values: value = raw.strip() if not value: msg = f"{field_name} must not contain empty values." raise ValueError(msg) - if value in seen: - msg = f"{field_name} must not contain duplicate values: '{value}'." - raise ValueError(msg) - seen.add(value) - cleaned.append(value) - return cleaned + stripped.append(value) + return list(dict.fromkeys(stripped)) + + +def _validate_uuid_list(values: list[UUID], *, field_name: str) -> list[UUID]: + """Deduplicate (preserving order) and reject empty lists.""" + deduped = list(dict.fromkeys(values)) + if not deduped: + msg = f"{field_name} must not be empty." + raise ValueError(msg) + return deduped def _normalize_str(value: str, *, field_name: str = "Field") -> str: @@ -297,18 +301,15 @@ class FlowVersionsAttach(BaseModel): model_config = {"extra": "forbid"} - # Typed as str (not UUID) because the service layer uses a flexible IdLike - # type (UUID | NormalizedId). The same str typing is used for the - # query-parameter variant in list_deployments for consistency. - ids: list[str] = Field( + ids: list[UUID] = Field( min_length=1, description="Langflow flow version ids to attach to the deployment.", ) @field_validator("ids") @classmethod - def validate_ids(cls, values: list[str]) -> list[str]: - return _validate_str_id_list(values, field_name="ids") + def validate_ids(cls, values: list[UUID]) -> list[UUID]: + return _validate_uuid_list(values, field_name="ids") class FlowVersionsPatch(BaseModel): @@ -316,21 +317,21 @@ class FlowVersionsPatch(BaseModel): model_config = {"extra": "forbid"} - add: list[str] | None = Field( + add: list[UUID] | None = Field( None, description="Langflow flow version ids to attach to the deployment. Omit to leave unchanged.", ) - remove: list[str] | None = Field( + remove: list[UUID] | None = Field( None, description="Langflow flow version ids to detach from the deployment. Omit to leave unchanged.", ) @field_validator("add", "remove") @classmethod - def validate_id_lists(cls, values: list[str] | None, info: ValidationInfo) -> list[str] | None: + def validate_id_lists(cls, values: list[UUID] | None, info: ValidationInfo) -> list[UUID] | None: if values is None: return None - return _validate_str_id_list(values, field_name=info.field_name) + return _validate_uuid_list(values, field_name=info.field_name) @model_validator(mode="after") def validate_operations(self): @@ -343,7 +344,7 @@ class FlowVersionsPatch(BaseModel): overlap = set(add_values).intersection(remove_values) if overlap: - ids = ", ".join(sorted(overlap)) + ids = ", ".join(sorted(str(v) for v in overlap)) msg = f"Flow version ids cannot be present in both 'add' and 'remove': {ids}." raise ValueError(msg) return self @@ -403,15 +404,47 @@ class DeploymentConfigCreate(BaseModel): class DeploymentConfigBindingUpdate(BaseModel): - """Config binding patch for an existing deployment.""" + """Config binding patch for an existing deployment. + + Exactly one of ``config_id``, ``raw_payload``, or ``unbind`` must be + provided: + + * ``config_id`` — bind an existing config by reference. + * ``raw_payload`` — create a new config and bind it. + * ``unbind = true`` — detach the current config. + """ model_config = {"extra": "forbid"} config_id: NonEmptyStr | None = Field( default=None, - description="Provider-owned config id to bind to the deployment. Use null to unbind.", + description="Provider-owned config id to bind to the deployment.", ) + raw_payload: _StrictDeploymentConfig | None = Field( + default=None, + description="Config payload to create and bind to the deployment.", + ) + + unbind: bool = Field( + default=False, + description="Set to true to detach the current config from the deployment.", + ) + + @model_validator(mode="after") + def validate_config_update(self) -> DeploymentConfigBindingUpdate: + provided = sum( + [ + self.config_id is not None, + self.raw_payload is not None, + self.unbind, + ] + ) + if provided != 1: + msg = "Exactly one of 'config_id', 'raw_payload', or 'unbind=true' must be provided." + raise ValueError(msg) + return self + # --------------------------------------------------------------------------- # Deployment create / update request schemas @@ -445,11 +478,18 @@ class DeploymentUpdateRequest(BaseModel): description="Flow version attach/detach operations.", ) config: DeploymentConfigBindingUpdate | None = Field(default=None, description="Deployment configuration update.") + provider_data: dict[str, Any] | None = Field( + default=None, + description="Provider-owned opaque update payload.", + ) @model_validator(mode="after") def ensure_any_field_provided(self) -> DeploymentUpdateRequest: if not self.model_fields_set: - msg = "At least one of 'spec', 'flow_version_ids', or 'config' must be provided." + msg = "At least one of 'spec', 'flow_version_ids', 'config', or 'provider_data' must be provided." + raise ValueError(msg) + if self.spec is None and self.flow_version_ids is None and self.config is None and self.provider_data is None: + msg = "At least one of 'spec', 'flow_version_ids', 'config', or 'provider_data' must be provided." raise ValueError(msg) return self diff --git a/src/backend/tests/unit/api/v1/test_deployment_schemas.py b/src/backend/tests/unit/api/v1/test_deployment_schemas.py index bf076fb93..029adc724 100644 --- a/src/backend/tests/unit/api/v1/test_deployment_schemas.py +++ b/src/backend/tests/unit/api/v1/test_deployment_schemas.py @@ -7,9 +7,11 @@ from uuid import uuid4 import pytest from langflow.api.v1.schemas.deployments import ( + DeploymentConfigBindingUpdate, DeploymentProviderAccountCreateRequest, DeploymentProviderAccountGetResponse, DeploymentProviderAccountUpdateRequest, + DeploymentUpdateRequest, FlowVersionsAttach, FlowVersionsPatch, ) @@ -90,19 +92,101 @@ class TestNonEmptyStr: # --------------------------------------------------------------------------- -class TestIdListDuplicateRejection: - def test_flow_versions_attach_rejects_duplicates(self): - with pytest.raises(ValidationError, match="duplicate"): - FlowVersionsAttach(ids=["id1", "id1"]) +class TestUuidIdListDedup: + """UUID id lists silently deduplicate while preserving order.""" - def test_flow_versions_patch_rejects_duplicates_in_add(self): - with pytest.raises(ValidationError, match="duplicate"): - FlowVersionsPatch(add=["id1", "id1"]) + def test_flow_versions_attach_deduplicates(self): + u1, u2 = uuid4(), uuid4() + result = FlowVersionsAttach(ids=[u1, u2, u1]) + assert result.ids == [u1, u2] - def test_flow_versions_patch_rejects_duplicates_in_remove(self): - with pytest.raises(ValidationError, match="duplicate"): - FlowVersionsPatch(remove=["id1", "id1"]) + def test_flow_versions_patch_deduplicates_add(self): + u1, u2 = uuid4(), uuid4() + result = FlowVersionsPatch(add=[u1, u2, u1, u2]) + assert result.add == [u1, u2] + + def test_flow_versions_patch_deduplicates_remove(self): + u1, u2 = uuid4(), uuid4() + result = FlowVersionsPatch(remove=[u2, u1, u2, u1]) + assert result.remove == [u2, u1] def test_flow_versions_patch_rejects_overlap(self): + u1 = uuid4() with pytest.raises(ValidationError, match="both"): - FlowVersionsPatch(add=["id1"], remove=["id1"]) + FlowVersionsPatch(add=[u1], remove=[u1]) + + +# --------------------------------------------------------------------------- +# DeploymentConfigBindingUpdate validation +# --------------------------------------------------------------------------- + + +class TestDeploymentConfigBindingUpdate: + def test_accepts_config_id_only(self): + update = DeploymentConfigBindingUpdate(config_id="cfg_1") + assert update.config_id == "cfg_1" + assert update.raw_payload is None + assert update.unbind is False + + def test_accepts_raw_payload_only(self): + raw_payload = { + "name": "new cfg", + "description": "cfg desc", + "environment_variables": { + "OPENAI_API_KEY": {"value": "OPENAI_API_KEY", "source": "variable"}, + }, + "provider_config": {"region": "us-east-1", "flags": {"dry_run": True}}, + } + update = DeploymentConfigBindingUpdate(raw_payload=raw_payload) + assert update.raw_payload is not None + assert update.raw_payload.model_dump() == raw_payload + assert update.config_id is None + assert update.unbind is False + + def test_accepts_unbind(self): + update = DeploymentConfigBindingUpdate(unbind=True) + assert update.unbind is True + assert update.config_id is None + assert update.raw_payload is None + + def test_rejects_both_config_id_and_raw_payload(self): + with pytest.raises(ValidationError, match="Exactly one of"): + DeploymentConfigBindingUpdate(config_id="cfg_1", raw_payload={"name": "cfg"}) + + def test_rejects_config_id_with_unbind(self): + with pytest.raises(ValidationError, match="Exactly one of"): + DeploymentConfigBindingUpdate(config_id="cfg_1", unbind=True) + + def test_rejects_raw_payload_with_unbind(self): + with pytest.raises(ValidationError, match="Exactly one of"): + DeploymentConfigBindingUpdate(raw_payload={"name": "cfg"}, unbind=True) + + def test_rejects_all_three(self): + with pytest.raises(ValidationError, match="Exactly one of"): + DeploymentConfigBindingUpdate(config_id="cfg_1", raw_payload={"name": "cfg"}, unbind=True) + + def test_rejects_noop_empty_payload(self): + with pytest.raises(ValidationError, match="Exactly one of"): + DeploymentConfigBindingUpdate() + + def test_rejects_unbind_false_alone(self): + with pytest.raises(ValidationError, match="Exactly one of"): + DeploymentConfigBindingUpdate(unbind=False) + + def test_rejects_extra_fields(self): + with pytest.raises(ValidationError, match="Extra inputs"): + DeploymentConfigBindingUpdate(config_id="cfg_1", unknown_field="x") + + +class TestDeploymentUpdateRequest: + def test_accepts_provider_data_only(self): + payload = DeploymentUpdateRequest(provider_data={"mode": "dry_run"}) + assert payload.provider_data == {"mode": "dry_run"} + + def test_rejects_empty_payload(self): + with pytest.raises(ValidationError, match="At least one of"): + DeploymentUpdateRequest() + + def test_rejects_explicit_null_only_payload(self): + with pytest.raises(ValidationError, match="At least one of"): + DeploymentUpdateRequest(spec=None) diff --git a/src/lfx/src/lfx/services/adapters/deployment/base.py b/src/lfx/src/lfx/services/adapters/deployment/base.py index 236675819..90042ea32 100644 --- a/src/lfx/src/lfx/services/adapters/deployment/base.py +++ b/src/lfx/src/lfx/services/adapters/deployment/base.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: DeploymentListResult, DeploymentListTypesResult, DeploymentStatusResult, + DeploymentType, DeploymentUpdate, DeploymentUpdateResult, ExecutionCreate, @@ -77,6 +78,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentGetResult: """Return deployment metadata by provider ID.""" @@ -87,6 +89,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, payload: DeploymentUpdate, db: AsyncSession, ) -> DeploymentUpdateResult: @@ -98,6 +101,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> RedeployResult: """Re-apply current deployment inputs without changing them.""" @@ -108,6 +112,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentDuplicateResult: """Create a new deployment using the same inputs as the source.""" @@ -118,6 +123,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentDeleteResult: """Delete the deployment from the provider.""" @@ -128,6 +134,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentStatusResult: """Return provider-reported health/status for the deployment.""" @@ -137,6 +144,7 @@ class BaseDeploymentService(Service, ABC): self, *, user_id: IdLike, + deployment_type: DeploymentType | None = None, payload: ExecutionCreate, db: AsyncSession, ) -> ExecutionCreateResult: @@ -148,6 +156,7 @@ class BaseDeploymentService(Service, ABC): *, user_id: IdLike, execution_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> ExecutionStatusResult: """Get provider-agnostic deployment execution state/output.""" diff --git a/src/lfx/src/lfx/services/adapters/deployment/schema.py b/src/lfx/src/lfx/services/adapters/deployment/schema.py index dd90f2b53..c283dc186 100644 --- a/src/lfx/src/lfx/services/adapters/deployment/schema.py +++ b/src/lfx/src/lfx/services/adapters/deployment/schema.py @@ -93,21 +93,30 @@ class SnapshotItems(BaseModel): class SnapshotDeploymentBindingUpdate(BaseModel): """Snapshot deployment binding patch payload. - Add or remove snapshot bindings for the deployment by reference ids. + Supports three operations: bind existing snapshots by ID, create new + snapshots from raw payloads, or unbind snapshots by ID. At least one + of the three fields must be provided. """ - add: list[IdLike] | None = Field( + add_ids: list[IdLike] | None = Field( None, - description="Snapshot reference ids to attach to the deployment. Omit to leave unchanged.", + description="Existing snapshot ids to attach to the deployment. Omit to leave unchanged.", ) - remove: list[IdLike] | None = Field( + add_raw_payloads: SnapshotList | None = Field( None, - description="Snapshot reference ids to detach from the deployment. Omit to leave unchanged.", + description="Raw snapshot payloads to create and attach to the deployment. Omit to leave unchanged.", + ) + remove_ids: list[IdLike] | None = Field( + None, + description="Snapshot ids to detach from the deployment. Omit to leave unchanged.", ) - @field_validator("add", "remove") + @field_validator("add_ids", "remove_ids") @classmethod def validate_id_lists(cls, v: list[IdLike] | None) -> list[str] | None: + # Post-validation: values are always normalized strings (UUIDs + # are stringified by _normalize_and_dedupe_id_list). The field + # annotation remains list[IdLike] so Pydantic accepts UUID input. if v is None: return None return _normalize_and_dedupe_id_list(v, field_name="snapshot_id") @@ -115,17 +124,21 @@ class SnapshotDeploymentBindingUpdate(BaseModel): @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 [] + add_values = self.add_ids or [] + raw_values = self.add_raw_payloads or [] + remove_values = self.remove_ids or [] - if not add_values and not remove_values: - msg = "At least one of 'add' or 'remove' must be provided." + if not add_values and not raw_values and not remove_values: + msg = "At least one of 'add_ids', 'add_raw_payloads', or 'remove_ids' must be provided." raise ValueError(msg) + # Overlap check covers add_ids vs remove_ids only. + # add_raw_payloads carry flow-artifact IDs (Langflow domain), + # while add_ids/remove_ids carry snapshot IDs (provider domain). 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}." + msg = f"Snapshot ids cannot be present in both 'add_ids' and 'remove_ids': {ids}." raise ValueError(msg) return self @@ -196,11 +209,27 @@ class ConfigItem(BaseModel): class ConfigDeploymentBindingUpdate(BaseModel): - """Config deployment binding patch payload.""" + """Config deployment binding patch payload. + + Exactly one of ``config_id``, ``raw_payload``, or ``unbind`` must be + provided: + + * ``config_id`` — bind an existing config by reference. + * ``raw_payload`` — create a new config and bind it. + * ``unbind = True`` — detach the current config. + """ config_id: IdLike | None = Field( None, - description="Config reference id to bind to the deployment. Use null to unbind.", + description="Config reference id to bind to the deployment.", + ) + raw_payload: DeploymentConfig | None = Field( + None, + description="Config payload to create and bind to the deployment.", + ) + unbind: bool = Field( + default=False, + description="Set to true to detach the current config from the deployment.", ) @field_validator("config_id") @@ -210,6 +239,20 @@ class ConfigDeploymentBindingUpdate(BaseModel): return _normalize_and_validate_id(v, field_name="config_id") return v + @model_validator(mode="after") + def validate_config_source(self) -> "ConfigDeploymentBindingUpdate": + provided = sum( + [ + self.config_id is not None, + self.raw_payload is not None, + self.unbind, + ] + ) + if provided != 1: + msg = "Exactly one of 'config_id', 'raw_payload', or 'unbind=true' must be provided." + raise ValueError(msg) + return self + class ProviderDataModel(BaseModel): """Base model for provider metadata payloads.""" @@ -366,11 +409,18 @@ class DeploymentUpdate(BaseModel): 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") + provider_data: ProviderPayload | None = Field( + None, + description="Provider-specific opaque payload for deployment update operations.", + ) @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." + if not self.model_fields_set: + msg = "At least one of 'spec', 'snapshot', 'config', or 'provider_data' must be provided." + raise ValueError(msg) + if self.spec is None and self.snapshot is None and self.config is None and self.provider_data is None: + msg = "At least one of 'spec', 'snapshot', 'config', or 'provider_data' must be provided." raise ValueError(msg) return self @@ -378,6 +428,11 @@ class DeploymentUpdate(BaseModel): class DeploymentUpdateResult(DeploymentOperationResult): """Model representing a result for a deployment update operation.""" + snapshot_ids: list[IdLike] = Field( + default_factory=list, + description="Snapshot ids produced or bound during the update.", + ) + class RedeployResult(DeploymentOperationResult): """Model representing a deployment redeployment operation result.""" diff --git a/src/lfx/src/lfx/services/adapters/deployment/service.py b/src/lfx/src/lfx/services/adapters/deployment/service.py index ca0bdb4a9..b92915bb9 100644 --- a/src/lfx/src/lfx/services/adapters/deployment/service.py +++ b/src/lfx/src/lfx/services/adapters/deployment/service.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: DeploymentListResult, DeploymentListTypesResult, DeploymentStatusResult, + DeploymentType, DeploymentUpdate, DeploymentUpdateResult, ExecutionCreate, @@ -79,6 +80,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentGetResult: """Return deployment metadata by provider ID.""" @@ -89,6 +91,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, payload: DeploymentUpdate, db: AsyncSession, ) -> DeploymentUpdateResult: @@ -100,6 +103,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> RedeployResult: """Re-apply current deployment inputs without changing them.""" @@ -110,6 +114,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentDuplicateResult: """Create a new deployment using the same inputs as the source.""" @@ -120,6 +125,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentDeleteResult: """Delete the deployment from the provider.""" @@ -130,6 +136,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentStatusResult: """Return provider-reported health/status for the deployment.""" @@ -139,6 +146,7 @@ class DeploymentService(BaseDeploymentService): self, *, user_id: IdLike, + deployment_type: DeploymentType | None = None, payload: ExecutionCreate, db: AsyncSession, ) -> ExecutionCreateResult: @@ -150,6 +158,7 @@ class DeploymentService(BaseDeploymentService): *, user_id: IdLike, execution_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> ExecutionStatusResult: """Get provider-agnostic deployment execution state/output.""" diff --git a/src/lfx/src/lfx/services/interfaces.py b/src/lfx/src/lfx/services/interfaces.py index e6269f23a..bb707c6d7 100644 --- a/src/lfx/src/lfx/services/interfaces.py +++ b/src/lfx/src/lfx/services/interfaces.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: DeploymentListResult, DeploymentListTypesResult, DeploymentStatusResult, + DeploymentType, DeploymentUpdate, DeploymentUpdateResult, ExecutionCreate, @@ -235,6 +236,9 @@ class DeploymentServiceProtocol(Protocol): Keep this protocol intentionally narrow (consumer-facing CRUD + status). Adapter-specific or advanced operations are defined on concrete deployment service classes. + + ``deployment_type`` is accepted as an optional routing hint by all + operations that act on a specific deployment (including executions). """ @abstractmethod @@ -275,6 +279,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentGetResult: """Return deployment metadata by provider ID.""" @@ -286,6 +291,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, payload: DeploymentUpdate, db: AsyncSession, ) -> DeploymentUpdateResult: @@ -298,6 +304,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> RedeployResult: """Re-apply current deployment inputs without changing them.""" @@ -309,6 +316,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentDuplicateResult: """Create a new deployment using the same inputs as the source.""" @@ -320,6 +328,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentDeleteResult: """Delete the deployment from the provider.""" @@ -331,6 +340,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, deployment_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> DeploymentStatusResult: """Return provider-reported health/status for the deployment.""" @@ -341,6 +351,7 @@ class DeploymentServiceProtocol(Protocol): self, *, user_id: IdLike, + deployment_type: DeploymentType | None = None, payload: ExecutionCreate, db: AsyncSession, ) -> ExecutionCreateResult: @@ -353,6 +364,7 @@ class DeploymentServiceProtocol(Protocol): *, user_id: IdLike, execution_id: IdLike, + deployment_type: DeploymentType | None = None, db: AsyncSession, ) -> ExecutionStatusResult: """Get provider-agnostic deployment execution state/output.""" diff --git a/src/lfx/tests/unit/services/deployment/test_deployment_schema.py b/src/lfx/tests/unit/services/deployment/test_deployment_schema.py index 69ff60eac..106758b5e 100644 --- a/src/lfx/tests/unit/services/deployment/test_deployment_schema.py +++ b/src/lfx/tests/unit/services/deployment/test_deployment_schema.py @@ -110,54 +110,94 @@ def test_deployment_list_params_rejects_blank_filter_ids() -> None: DeploymentListParams(deployment_ids=[" "]) -def test_snapshot_binding_update_accepts_idlike_and_dedupes() -> None: +def test_snapshot_binding_update_add_ids_dedupes() -> None: snapshot_uuid = uuid4() - payload = SnapshotDeploymentBindingUpdate( - add=[snapshot_uuid, f" {snapshot_uuid} ", "snap_1", "snap_1"], - remove=[" snap_2 ", "snap_2"], + add_ids=[snapshot_uuid, f" {snapshot_uuid} ", "snap_1", "snap_1"], ) - - assert payload.add == [str(snapshot_uuid), "snap_1"] - assert payload.remove == ["snap_2"] + assert payload.add_ids == [str(snapshot_uuid), "snap_1"] -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_ids_dedupes() -> None: + snapshot_uuid = uuid4() + payload = SnapshotDeploymentBindingUpdate( + remove_ids=[snapshot_uuid, f" {snapshot_uuid} ", "snap_2", "snap_2"], + ) + assert payload.remove_ids == [str(snapshot_uuid), "snap_2"] -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_add_ids_only() -> None: + payload = SnapshotDeploymentBindingUpdate(add_ids=["snap_1"]) + assert payload.add_ids == ["snap_1"] + assert payload.add_raw_payloads is None + assert payload.remove_ids is None + + +def test_snapshot_binding_update_add_raw_payloads_only() -> None: + flow_id = uuid4() + payload = SnapshotDeploymentBindingUpdate( + add_raw_payloads=[ + { + "id": flow_id, + "name": "Flow", + "data": {"nodes": [], "edges": []}, + } + ] + ) + assert payload.add_raw_payloads is not None + assert len(payload.add_raw_payloads) == 1 + assert payload.add_ids is None + assert payload.remove_ids is None + + +def test_snapshot_binding_update_remove_ids_only() -> None: + payload = SnapshotDeploymentBindingUpdate(remove_ids=["snap_1"]) + assert payload.remove_ids == ["snap_1"] + assert payload.add_ids is None + assert payload.add_raw_payloads 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'"): + with pytest.raises(ValidationError, match="cannot be present in both"): SnapshotDeploymentBindingUpdate( - add=[snapshot_uuid, " snap_1 "], - remove=[str(snapshot_uuid), "snap_1"], + add_ids=[snapshot_uuid, " snap_1 "], + remove_ids=[str(snapshot_uuid), "snap_1"], ) def test_snapshot_binding_update_rejects_blank_ids() -> None: with pytest.raises(ValidationError): - SnapshotDeploymentBindingUpdate(add=[" "]) + SnapshotDeploymentBindingUpdate(add_ids=[" "]) def test_snapshot_binding_update_preserves_order_while_deduping() -> None: - payload = SnapshotDeploymentBindingUpdate(add=["b", "a", "b", "c", "a"]) - assert payload.add == ["b", "a", "c"] + payload = SnapshotDeploymentBindingUpdate(add_ids=["b", "a", "b", "c", "a"]) + assert payload.add_ids == ["b", "a", "c"] def test_snapshot_binding_update_rejects_noop_payload() -> None: - with pytest.raises(ValidationError, match="At least one of 'add' or 'remove'"): + with pytest.raises(ValidationError, match="At least one of"): SnapshotDeploymentBindingUpdate() +def test_snapshot_binding_update_add_ids_and_raw_payloads_together() -> None: + flow_id = uuid4() + payload = SnapshotDeploymentBindingUpdate( + add_ids=["existing_snap"], + add_raw_payloads=[ + { + "id": flow_id, + "name": "New Flow", + "data": {"nodes": [], "edges": []}, + } + ], + ) + assert payload.add_ids == ["existing_snap"] + assert payload.add_raw_payloads is not None + assert len(payload.add_raw_payloads) == 1 + + def test_config_item_reference_id_rejects_blank() -> None: with pytest.raises(ValidationError): ConfigItem(reference_id=" ") @@ -178,6 +218,50 @@ def test_config_deployment_binding_update_rejects_blank() -> None: ConfigDeploymentBindingUpdate(config_id=" ") +def test_config_deployment_binding_update_accepts_raw_payload() -> None: + update = ConfigDeploymentBindingUpdate(raw_payload={"name": "new cfg"}) + assert update.raw_payload is not None + assert update.config_id is None + assert update.unbind is False + + +def test_config_deployment_binding_update_accepts_unbind() -> None: + update = ConfigDeploymentBindingUpdate(unbind=True) + assert update.unbind is True + assert update.config_id is None + assert update.raw_payload is None + + +def test_config_deployment_binding_update_rejects_both_config_id_and_raw_payload() -> None: + with pytest.raises(ValidationError, match="Exactly one of"): + ConfigDeploymentBindingUpdate(config_id="cfg_1", raw_payload={"name": "cfg"}) + + +def test_config_deployment_binding_update_rejects_config_id_with_unbind() -> None: + with pytest.raises(ValidationError, match="Exactly one of"): + ConfigDeploymentBindingUpdate(config_id="cfg_1", unbind=True) + + +def test_config_deployment_binding_update_rejects_raw_payload_with_unbind() -> None: + with pytest.raises(ValidationError, match="Exactly one of"): + ConfigDeploymentBindingUpdate(raw_payload={"name": "cfg"}, unbind=True) + + +def test_config_deployment_binding_update_rejects_all_three() -> None: + with pytest.raises(ValidationError, match="Exactly one of"): + ConfigDeploymentBindingUpdate(config_id="cfg_1", raw_payload={"name": "cfg"}, unbind=True) + + +def test_config_deployment_binding_update_rejects_noop() -> None: + with pytest.raises(ValidationError, match="Exactly one of"): + ConfigDeploymentBindingUpdate() + + +def test_config_deployment_binding_update_rejects_unbind_false_alone() -> None: + with pytest.raises(ValidationError, match="Exactly one of"): + ConfigDeploymentBindingUpdate(unbind=False) + + def test_deployment_create_rejects_invalid_deployment_type() -> None: with pytest.raises(ValidationError, match="type"): DeploymentCreate(spec={"name": "my deployment", "type": "invalid-type"}) @@ -316,6 +400,16 @@ def test_execution_create_and_status_results_have_same_shape() -> None: assert create_result.model_dump() == status_result.model_dump() +def test_deployment_update_result_snapshot_ids_defaults_empty() -> None: + result = DeploymentUpdateResult(id="dep_1") + assert result.snapshot_ids == [] + + +def test_deployment_update_result_carries_snapshot_ids() -> None: + result = DeploymentUpdateResult(id="dep_1", snapshot_ids=["snap_1", "snap_2"]) + assert result.snapshot_ids == ["snap_1", "snap_2"] + + def test_operation_results_share_provider_result_contract() -> None: provider_result = {"accepted": True} @@ -334,10 +428,15 @@ def test_base_deployment_data_update_requires_at_least_one_field() -> None: def test_deployment_update_requires_at_least_one_section() -> None: - with pytest.raises(ValidationError, match="At least one of 'spec', 'snapshot', or 'config'"): + with pytest.raises(ValidationError, match="At least one of"): DeploymentUpdate() +def test_deployment_update_rejects_explicit_null_only_payload() -> None: + with pytest.raises(ValidationError, match="At least one of"): + DeploymentUpdate(spec=None) + + def test_deployment_update_accepts_spec_only() -> None: update = DeploymentUpdate(spec={"name": "new name"}) assert update.spec is not None @@ -353,12 +452,20 @@ def test_deployment_update_accepts_config_only() -> None: def test_deployment_update_accepts_snapshot_only() -> None: - update = DeploymentUpdate(snapshot={"add": ["snap_1"]}) + update = DeploymentUpdate(snapshot={"add_ids": ["snap_1"]}) assert update.snapshot is not None assert update.spec is None assert update.config is None +def test_deployment_update_accepts_provider_data_only() -> None: + update = DeploymentUpdate(provider_data={"mode": "dry_run"}) + assert update.provider_data == {"mode": "dry_run"} + assert update.spec is None + assert update.snapshot is None + assert update.config is None + + def test_env_var_config_accepts_raw_and_variable_sources() -> None: config = DeploymentConfig( name="cfg",