334 lines
12 KiB
Python
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)
|