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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user