feat: revise deployment schemas (#12150)

* checkout schema revisions

* fix: harden deployment schema validation and close coverage gaps
- Add exactly-one validation to ConfigDeploymentBindingUpdate (both layers)
  using model_fields_set XOR for config_id vs raw_payload
- Add raw_payload field to service-layer ConfigDeploymentBindingUpdate for
  symmetry with the snapshot passthrough pattern
- Add deployment_type routing hint to execution methods (protocol, ABC, service)
- Unify duplicate handling: silent dedup everywhere (dict.fromkeys)
- Fix broken API tests after FlowVersionsAttach/Patch str→UUID migration
- Restore deleted test coverage (blank ids, order-preserving dedup) and add
  new tests for raw_payload, mutual exclusion, snapshot_ids, and noop rejection
- Add clarifying comments: overlap check ID domains, post-validation types,
  deployment_type scope in protocol docstring

* tighten update payload validation and add provider_data

* add validation for None fields and harden tests

* use explicit boolean to unbind config
This commit is contained in:
Hamza Rashid
2026-03-11 17:40:10 -04:00
committed by GitHub
parent 911bc9d70a
commit 9012a5476a
8 changed files with 392 additions and 76 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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