Files
lda-wf/src/wf_client/deployments.py
T

334 lines
12 KiB
Python

"""Immutable deployment snapshots and strict artifact deployment selection."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
from wf_artifacts import DependencyDiagnostic, DriftPolicy, WorkflowDeployment
from ._repr import html_repr, short_repr
from .codec import (
decode_dependency_diagnostics,
decode_deployment,
decode_deployment_validation,
decode_deployments,
)
from .errors import DeploymentNotRunnable, DeploymentRequired, InvalidResponse
from .protocols import WorkflowClientPort
from .runs import Run
@dataclass(frozen=True, slots=True)
class DeploymentValidation:
"""Server readiness result for one saved deployment snapshot."""
deployment_id: str
artifact_id: str
artifact_version: int
status: str
diagnostics: tuple[DependencyDiagnostic, ...]
@property
def runnable(self) -> bool:
return self.status == "runnable"
def __repr__(self) -> str:
return short_repr(
type(self).__name__,
deployment_id=self.deployment_id,
status=self.status,
diagnostics=f"{len(self.diagnostics)} diagnostics",
)
def _repr_html_(self) -> str:
return html_repr(
type(self).__name__,
deployment_id=self.deployment_id,
status=self.status,
diagnostics=f"{len(self.diagnostics)} diagnostics",
)
@dataclass(frozen=True, slots=True, init=False)
class Deployment:
"""Immutable snapshot of one configured artifact deployment."""
_port: WorkflowClientPort = field(repr=False, compare=False)
_model: WorkflowDeployment = field(repr=False)
_diagnostics: tuple[DependencyDiagnostic, ...] = field(repr=False)
runnable: bool | None = None
def __init__(
self,
_port: WorkflowClientPort,
model: WorkflowDeployment,
diagnostics: tuple[DependencyDiagnostic, ...] = (),
runnable: bool | None = None,
) -> None:
# Pydantic models remain mutable even inside a frozen dataclass. Keep
# private copies so public inspection cannot retarget later calls.
object.__setattr__(self, "_port", _port)
object.__setattr__(self, "_model", model.model_copy(deep=True))
object.__setattr__(
self,
"_diagnostics",
tuple(item.model_copy(deep=True) for item in diagnostics),
)
object.__setattr__(self, "runnable", runnable)
@classmethod
def from_payload(cls, port: WorkflowClientPort, payload: object) -> Deployment:
return cls(_port=port, model=decode_deployment(payload))
def with_validation(
self,
*,
diagnostics: tuple[DependencyDiagnostic, ...],
runnable: bool,
) -> Deployment:
"""Return a new snapshot enriched with one validation result."""
return type(self)(
_port=self._port,
model=self._model,
diagnostics=diagnostics,
runnable=runnable,
)
@property
def model(self) -> WorkflowDeployment:
"""Return a defensive copy of the deployment domain model."""
return self._model.model_copy(deep=True)
@property
def diagnostics(self) -> tuple[DependencyDiagnostic, ...]:
"""Return defensive copies of loaded dependency diagnostics."""
return tuple(item.model_copy(deep=True) for item in self._diagnostics)
@property
def deployment_id(self) -> str:
return self._model.id
def __repr__(self) -> str:
return short_repr(
type(self).__name__,
deployment_id=self.deployment_id,
artifact=f"{self.artifact_id}.v{self.artifact_version}",
runnable=self.runnable,
diagnostics=f"{len(self.diagnostics)} diagnostics",
)
def _repr_html_(self) -> str:
return html_repr(
type(self).__name__,
deployment_id=self.deployment_id,
artifact=f"{self.artifact_id}.v{self.artifact_version}",
bindings=f"{len(self.bindings)} bindings",
runnable=self.runnable,
diagnostics=f"{len(self.diagnostics)} diagnostics",
)
@property
def artifact_id(self) -> str:
return self._model.artifact_id
@property
def artifact_version(self) -> int:
return self._model.artifact_version
@property
def bindings(self) -> dict[str, str]:
return self._model.binding_map()
@property
def drift_policy(self) -> DriftPolicy:
return self._model.drift_policy
async def validate(self) -> DeploymentValidation:
payload = await self._port.validate_deployment(
deployment_id=self.deployment_id,
)
result = decode_deployment_validation(payload)
if result["deployment_id"] != self.deployment_id:
raise InvalidResponse(
operation="workflow.deployments.validate",
details=(
f"validated deployment {result['deployment_id']!r} does not "
f"match requested {self.deployment_id!r}"
),
)
if (
result["artifact_id"] != self.artifact_id
or result["artifact_version"] != self.artifact_version
):
raise InvalidResponse(
operation="workflow.deployments.validate",
details=(
f"validation for {self.deployment_id!r} targets artifact "
f"{result['artifact_id']!r} version {result['artifact_version']}, "
f"expected {self.artifact_id!r} version {self.artifact_version}"
),
)
diagnostics = decode_dependency_diagnostics(result["diagnostics"])
return DeploymentValidation(
deployment_id=result["deployment_id"],
artifact_id=result["artifact_id"],
artifact_version=result["artifact_version"],
status=result["status"],
diagnostics=diagnostics,
)
async def run(self, workflow_input: Mapping[str, Any]) -> Run:
from .codec import decode_run_result
from .runs import _run_from_decoded
decoded = decode_run_result(
await self._port.run_deployment(
deployment_id=self.deployment_id,
workflow_input=dict(workflow_input),
),
operation="workflow.runs.start",
)
if decoded.deployment_id != self.deployment_id:
raise InvalidResponse(
operation="workflow.runs.start",
details=(
f"returned deployment {decoded.deployment_id!r} does not "
f"match requested {self.deployment_id!r}"
),
)
if (
decoded.artifact_id != self.artifact_id
or decoded.artifact_version != self.artifact_version
):
raise InvalidResponse(
operation="workflow.runs.start",
details=(
f"start result for {self.deployment_id!r} targets artifact "
f"{decoded.artifact_id!r} version {decoded.artifact_version}, "
f"expected {self.artifact_id!r} version {self.artifact_version}"
),
)
if decoded.run_id is None or decoded.status in {"unrunnable", "rejected"}:
raise DeploymentNotRunnable(
deployment_id=self.deployment_id,
diagnostics=tuple(
diagnostic.model_copy(deep=True)
for diagnostic in decoded.diagnostics
),
outcome=decoded.outcome,
error=decoded.error,
)
return _run_from_decoded(
self._port,
decoded,
operation="workflow.runs.start",
)
def _decode_summaries(payload: object) -> list[dict[str, Any]]:
"""Validate list metadata while keeping summaries as local dictionaries."""
result = decode_deployments(payload)
return [dict(item) for item in result["deployments"]]
async def run_artifact(
artifact: Any,
workflow_input: Mapping[str, Any],
*,
deployment_id: str | None,
bindings: Mapping[str, str] | None,
drift_policy: DriftPolicy,
) -> Run:
"""Apply the artifact's strict deployment-selection policy."""
if deployment_id is not None:
deployment = Deployment.from_payload(
artifact._port,
await artifact._port.inspect_deployment(deployment_id=deployment_id),
)
if deployment.deployment_id != deployment_id:
raise InvalidResponse(
operation="workflow.deployments.inspect",
details=(
f"inspected deployment {deployment.deployment_id!r} does not "
f"match requested {deployment_id!r}"
),
)
if (
deployment.artifact_id != artifact.ref.artifact_id
or deployment.artifact_version != artifact.ref.version
):
raise InvalidResponse(
operation="workflow.deployments.inspect",
details=(
f"deployment {deployment_id!r} does not target artifact "
f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
),
)
return await deployment.run(workflow_input)
summaries = _decode_summaries(await artifact._port.list_deployments())
matches = sorted(
(
summary
for summary in summaries
if summary["artifact_id"] == artifact.ref.artifact_id
and summary["artifact_version"] == artifact.ref.version
),
key=lambda summary: summary["id"],
)
if len(matches) > 1:
raise DeploymentRequired(
candidate_deployment_ids=tuple(summary["id"] for summary in matches)
)
default_id = f"{artifact.ref.artifact_id}.v{artifact.ref.version}.default"
if not matches:
conflicting = next(
(
summary
for summary in summaries
if summary["id"] == default_id
and (
summary["artifact_id"] != artifact.ref.artifact_id
or summary["artifact_version"] != artifact.ref.version
)
),
None,
)
if conflicting is not None:
raise DeploymentRequired(candidate_deployment_ids=(default_id,))
deployment = await artifact.deploy(
default_id,
bindings=bindings,
drift_policy=drift_policy,
)
if deployment.runnable is not True:
raise DeploymentRequired(diagnostics=deployment.diagnostics)
return await deployment.run(workflow_input)
deployment = Deployment.from_payload(
artifact._port,
await artifact._port.inspect_deployment(deployment_id=matches[0]["id"]),
)
if deployment.deployment_id != matches[0]["id"]:
raise InvalidResponse(
operation="workflow.deployments.inspect",
details=(
f"inspected deployment {deployment.deployment_id!r} does not "
f"match requested {matches[0]['id']!r}"
),
)
if (
deployment.artifact_id != artifact.ref.artifact_id
or deployment.artifact_version != artifact.ref.version
):
raise InvalidResponse(
operation="workflow.deployments.inspect",
details=(
f"deployment {matches[0]['id']!r} does not target artifact "
f"{artifact.ref.artifact_id!r} version {artifact.ref.version}"
),
)
return await deployment.run(workflow_input)