execution-plane-client 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Shared command contracts and participant-owned handler registration."""
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.5'
22
+ __version_tuple__ = version_tuple = (0, 1, 5)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,61 @@
1
+ """Supported command revisions and their wire payload contracts."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from execution_plane_client.commands.base import CommandPayload
6
+ from execution_plane_client.commands.clusters import ClusterDelete, ClusterProvision, ClusterRegister, ClusterUpdate
7
+ from execution_plane_client.commands.control import CancelCommand, Completion
8
+ from execution_plane_client.commands.credentials import CredentialResolve
9
+ from execution_plane_client.commands.targets import TargetDelete, TargetRegister, TargetUpdate
10
+ from execution_plane_client.commands.work import WorkExecute
11
+ from execution_plane_client.errors import UnsupportedCommand
12
+ from execution_plane_client.protocol import MessageEnvelope
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CommandDefinition:
17
+ """One command type, revision and payload schema."""
18
+
19
+ command_type: str
20
+ revision: int
21
+ payload_schema: type[CommandPayload]
22
+
23
+
24
+ class CommandCatalog:
25
+ """Validate known commands independently of installed handlers."""
26
+
27
+ def __init__(self) -> None:
28
+ """Install version-one work, resource and completion contracts."""
29
+ payloads = (
30
+ WorkExecute,
31
+ Completion,
32
+ CredentialResolve,
33
+ ClusterRegister,
34
+ ClusterProvision,
35
+ ClusterUpdate,
36
+ ClusterDelete,
37
+ TargetRegister,
38
+ TargetUpdate,
39
+ TargetDelete,
40
+ CancelCommand,
41
+ )
42
+ self._definitions = {
43
+ (schema.command_type, schema.command_revision): CommandDefinition(
44
+ schema.command_type, schema.command_revision, schema
45
+ )
46
+ for schema in payloads
47
+ }
48
+
49
+ def definition(self, command_type: str, revision: int) -> CommandDefinition:
50
+ """Return a supported revision or an explicit rejection."""
51
+ try:
52
+ return self._definitions[command_type, revision]
53
+ except KeyError:
54
+ raise UnsupportedCommand from None
55
+
56
+ def validate(self, message: MessageEnvelope) -> CommandPayload:
57
+ """Validate the payload and completion parent, returning the typed payload."""
58
+ definition = self.definition(message.command.command_type, message.command.command_revision)
59
+ payload = definition.payload_schema.model_validate(message.command.payload)
60
+ definition.payload_schema.validate_parent(message.command.parent_command_id)
61
+ return payload
@@ -0,0 +1,37 @@
1
+ """Shared resource identities and HTTPS configuration validation."""
2
+
3
+ from urllib.parse import urlsplit
4
+ from uuid import UUID
5
+
6
+ from execution_plane_client.protocol import PositiveInt, WireModel
7
+
8
+
9
+ class ResourceIdentity(WireModel):
10
+ """Caller-supplied physical resource identity."""
11
+
12
+ resource_id: UUID
13
+
14
+
15
+ class ResourceRevision(WireModel):
16
+ """Stable cluster integration identity and desired-state revision."""
17
+
18
+ resource_key: UUID
19
+ desired_revision: PositiveInt
20
+
21
+
22
+ def validate_endpoint(value: str) -> str:
23
+ """Require HTTPS without embedded credentials, queries or fragments."""
24
+ parsed = urlsplit(value)
25
+ if (
26
+ parsed.scheme != "https"
27
+ or not parsed.hostname
28
+ or parsed.username is not None
29
+ or parsed.password is not None
30
+ or parsed.query
31
+ or parsed.fragment
32
+ or any(char.isspace() for char in value)
33
+ ):
34
+ message = "Endpoint must be an HTTPS URL without userinfo, query or fragment"
35
+ raise ValueError(message)
36
+ _ = parsed.port
37
+ return value
@@ -0,0 +1,64 @@
1
+ """Construct transport-neutral messages from typed command payloads."""
2
+
3
+ from datetime import UTC, datetime
4
+ from typing import ClassVar
5
+ from uuid import UUID, uuid4
6
+
7
+ from execution_plane_client.errors import ProtocolConflict
8
+ from execution_plane_client.protocol import Command, CommandScope, MessageEnvelope, WireModel
9
+
10
+
11
+ class CommandPayload(WireModel):
12
+ """A typed command knows its wire identity and builds its delivery envelope."""
13
+
14
+ command_type: ClassVar[str]
15
+ command_revision: ClassVar[int] = 1
16
+ requires_parent: ClassVar[bool] = False
17
+
18
+ @classmethod
19
+ def validate_parent(cls, parent_command_id: UUID | None) -> None:
20
+ """Require correlation for commands that report another command's outcome."""
21
+ if cls.requires_parent and parent_command_id is None:
22
+ raise ProtocolConflict
23
+
24
+ def _message_payload(self) -> dict[str, object]:
25
+ return self.model_dump(mode="json")
26
+
27
+ def to_message(
28
+ self,
29
+ *,
30
+ source_id: str,
31
+ destination_id: str,
32
+ client_id: str,
33
+ command_id: UUID | None = None,
34
+ parent_command_id: UUID | None = None,
35
+ occurred_at: datetime | None = None,
36
+ message_id: UUID | None = None,
37
+ sequence: int = 0,
38
+ ) -> MessageEnvelope:
39
+ """Build a validated message, generating a command UUID and UTC time if omitted.
40
+
41
+ The message ID defaults to the command ID. Retain the returned message for
42
+ retries, or supply the original IDs and timestamp when reconstructing it.
43
+ Routing, authentication, HTTP delivery and persistence belong to the caller.
44
+ """
45
+ self.validate_parent(parent_command_id)
46
+ payload = self._message_payload()
47
+ type(self).model_validate(payload)
48
+ identity = uuid4() if command_id is None else command_id
49
+ return MessageEnvelope(
50
+ message_id=identity if message_id is None else message_id,
51
+ command_id=identity,
52
+ source_id=source_id,
53
+ destination_id=destination_id,
54
+ scope=CommandScope(client_id=client_id),
55
+ sequence=sequence,
56
+ command=Command(
57
+ command_id=identity,
58
+ parent_command_id=parent_command_id,
59
+ command_type=self.command_type,
60
+ command_revision=self.command_revision,
61
+ payload=payload,
62
+ occurred_at=datetime.now(UTC) if occurred_at is None else occurred_at,
63
+ ),
64
+ )
@@ -0,0 +1,96 @@
1
+ """Cluster-binding desired state, independent of EP persistence models."""
2
+
3
+ import ssl
4
+ from typing import Annotated, ClassVar, Self
5
+ from uuid import UUID
6
+
7
+ from pydantic import Field, StrictBool, StrictStr, field_validator, model_validator
8
+
9
+ from execution_plane_client.commands import ResourceRevision, validate_endpoint
10
+ from execution_plane_client.commands.base import CommandPayload
11
+ from execution_plane_client.protocol import WireModel
12
+
13
+
14
+ class ClusterConfigBase(WireModel):
15
+ """Registration configuration fields used by handlers to copy and compare bindings."""
16
+
17
+ name: Annotated[StrictStr, Field(min_length=1, max_length=255)]
18
+ endpoint: Annotated[StrictStr, Field(max_length=2048)]
19
+ namespace: Annotated[StrictStr, Field(min_length=1, max_length=63, pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")]
20
+ credential_ref: UUID
21
+ ca_certificate: Annotated[StrictStr, Field(max_length=65536)] | None = Field(default=None, repr=False)
22
+ project_ids: list[UUID] | None = None
23
+ labels: dict[str, str] = Field(default_factory=dict)
24
+ enabled: StrictBool = True
25
+ is_default: StrictBool = False
26
+
27
+
28
+ class ClusterRegister(ClusterConfigBase, ResourceRevision, CommandPayload):
29
+ """Create or replace the complete desired configuration of a binding."""
30
+
31
+ command_type: ClassVar[str] = "cluster.register"
32
+
33
+ @field_validator("endpoint")
34
+ @classmethod
35
+ def check_endpoint(cls, value: str) -> str:
36
+ """Protect the management credential in transit."""
37
+ return validate_endpoint(value)
38
+
39
+ @field_validator("ca_certificate")
40
+ @classmethod
41
+ def check_ca(cls, value: str | None) -> str | None:
42
+ """Reject invalid PEM before accepting desired state."""
43
+ if value is not None:
44
+ try:
45
+ ssl.create_default_context().load_verify_locations(cadata=value)
46
+ except ssl.SSLError as exc:
47
+ message = "CA certificate must contain valid PEM"
48
+ raise ValueError(message) from exc
49
+ return value
50
+
51
+ @model_validator(mode="after")
52
+ def check_credential_ref(self) -> Self:
53
+ """Require a resolvable credential reference for enabled bindings."""
54
+ if self.enabled and self.credential_ref == UUID(int=0):
55
+ message = "Enabled cluster bindings require a credential_ref"
56
+ raise ValueError(message)
57
+ return self
58
+
59
+
60
+ class ClusterProvision(ClusterRegister):
61
+ """Provision through the same binding and default-target reconciler."""
62
+
63
+ command_type: ClassVar[str] = "cluster.provision"
64
+
65
+
66
+ class ClusterDelete(ResourceRevision, CommandPayload):
67
+ """Disable an existing binding at the requested desired-state revision."""
68
+
69
+ command_type: ClassVar[str] = "cluster.delete"
70
+
71
+
72
+ class ClusterUpdate(ResourceRevision, CommandPayload):
73
+ """Patch a binding; only CA and project grants support explicit null."""
74
+
75
+ command_type: ClassVar[str] = "cluster.update"
76
+
77
+ name: StrictStr | None = None
78
+ endpoint: StrictStr | None = None
79
+ namespace: StrictStr | None = None
80
+ credential_ref: UUID | None = None
81
+ ca_certificate: StrictStr | None = Field(default=None, repr=False)
82
+ project_ids: list[UUID] | None = None
83
+ labels: dict[str, str] | None = None
84
+ enabled: StrictBool | None = None
85
+ is_default: StrictBool | None = None
86
+
87
+ def _message_payload(self) -> dict[str, object]:
88
+ return self.model_dump(mode="json", exclude_unset=True)
89
+
90
+ @model_validator(mode="after")
91
+ def check_nulls(self) -> Self:
92
+ """Preserve omitted values but reject clearing required fields."""
93
+ if any(getattr(self, key) is None for key in self.model_fields_set - {"ca_certificate", "project_ids"}):
94
+ message = "Required binding fields cannot be null"
95
+ raise ValueError(message)
96
+ return self
@@ -0,0 +1,35 @@
1
+ """Cancellation requests and work completion commands."""
2
+
3
+ from typing import ClassVar, Literal
4
+ from uuid import UUID
5
+
6
+ from pydantic import Field, field_validator
7
+
8
+ from execution_plane_client.commands.base import CommandPayload
9
+ from execution_plane_client.protocol import ResourceReference, validate_json
10
+
11
+
12
+ class CancelCommand(CommandPayload):
13
+ """Cancel an existing work command, whose UUID is its work-item UUID."""
14
+
15
+ command_type: ClassVar[str] = "command.cancel"
16
+
17
+ target_command_id: UUID
18
+
19
+
20
+ class Completion(CommandPayload):
21
+ """Terminal or uncertain work outcome delivered to the initiating client."""
22
+
23
+ command_type: ClassVar[str] = "work.execute.completed"
24
+ requires_parent: ClassVar[bool] = True
25
+
26
+ status: Literal["succeeded", "failed", "cancelled", "outcome_unknown"]
27
+ result: dict[str, object] = Field(default_factory=dict, repr=False)
28
+ resource: ResourceReference | None = None
29
+ error_code: str | None = None
30
+
31
+ @field_validator("result", mode="before")
32
+ @classmethod
33
+ def check_result(cls, value: object) -> object:
34
+ """Require finite JSON; work output limits are set by the invocation."""
35
+ return validate_json(value, max_bytes=None)
@@ -0,0 +1,25 @@
1
+ """Credential-reference resolution commands and responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, ClassVar
6
+ from uuid import UUID # noqa: TC003 - Pydantic evaluates fields.
7
+
8
+ from pydantic import Field, StrictStr
9
+
10
+ from execution_plane_client.commands.base import CommandPayload
11
+ from execution_plane_client.protocol import WireModel
12
+
13
+
14
+ class CredentialResolve(CommandPayload):
15
+ """Resolve an opaque credential reference into a short-lived value."""
16
+
17
+ command_type: ClassVar[str] = "credential.resolve"
18
+ credential_ref: UUID
19
+
20
+
21
+ class CredentialResolution(WireModel):
22
+ """The resolved credential value for one submitted command."""
23
+
24
+ message_id: UUID
25
+ credential: Annotated[StrictStr, Field(min_length=1, max_length=65536, repr=False)]
@@ -0,0 +1,151 @@
1
+ """Execution-target configuration and independent registration commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Annotated, ClassVar, Literal, Self
7
+ from uuid import UUID # noqa: TC003 - Pydantic evaluates fields.
8
+
9
+ from pydantic import Field, StrictBool, StrictStr, field_validator, model_validator
10
+
11
+ from execution_plane_client.commands import ResourceIdentity, validate_endpoint
12
+ from execution_plane_client.commands.base import CommandPayload
13
+ from execution_plane_client.protocol import WireModel
14
+
15
+ _LABEL_NAME = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9_.-]*[A-Za-z0-9])?$")
16
+ _DNS_LABEL = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
17
+ _LABEL_LIMIT = 63
18
+ _DOMAIN_LIMIT = 253
19
+
20
+
21
+ def _label_key(value: str) -> bool:
22
+ prefix, separator, name = value.partition("/")
23
+ if separator:
24
+ if (
25
+ not prefix
26
+ or len(prefix) > _DOMAIN_LIMIT
27
+ or any(len(part) > _LABEL_LIMIT or not _DNS_LABEL.fullmatch(part) for part in prefix.split("."))
28
+ ):
29
+ return False
30
+ else:
31
+ name = prefix
32
+ return len(name) <= _LABEL_LIMIT and bool(_LABEL_NAME.fullmatch(name))
33
+
34
+
35
+ def _label_value(value: str) -> bool:
36
+ return len(value) <= _LABEL_LIMIT and (value == "" or bool(_LABEL_NAME.fullmatch(value)))
37
+
38
+
39
+ class KubernetesPlacement(WireModel):
40
+ """Kubernetes placement contract, independent of Kubernetes libraries."""
41
+
42
+ type: Literal["kubernetes"] = "kubernetes"
43
+ namespace: Annotated[StrictStr, Field(min_length=1, max_length=63, pattern=r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")]
44
+ node_selectors: list[str] = Field(default_factory=list)
45
+ tolerations: list[str] = Field(default_factory=list)
46
+
47
+ @field_validator("node_selectors")
48
+ @classmethod
49
+ def check_selectors(cls, values: list[str]) -> list[str]:
50
+ """Accept only Kubernetes key=value selectors."""
51
+ for value in values:
52
+ key, separator, label = value.partition("=")
53
+ if not separator or not _label_key(key) or not _label_value(label):
54
+ message = "Invalid Kubernetes node selector"
55
+ raise ValueError(message)
56
+ return values
57
+
58
+ @field_validator("tolerations")
59
+ @classmethod
60
+ def check_tolerations(cls, values: list[str]) -> list[str]:
61
+ """Accept compact tolerations with supported scheduling effects."""
62
+ for value in values:
63
+ key_value, separator, effect = value.rpartition(":")
64
+ if not separator:
65
+ key_value, effect = value, ""
66
+ key, equals, label = key_value.partition("=")
67
+ valid_key = bool(key) and _label_key(key) if equals else not key or _label_key(key)
68
+ if (
69
+ not valid_key
70
+ or not _label_value(label)
71
+ or effect not in {"", "NoSchedule", "PreferNoSchedule", "NoExecute"}
72
+ ):
73
+ message = "Invalid Kubernetes toleration"
74
+ raise ValueError(message)
75
+ return values
76
+
77
+
78
+ class RHELPlacement(WireModel):
79
+ """Placement for a supported RHEL backend."""
80
+
81
+ type: Literal["rhel"] = "rhel"
82
+
83
+
84
+ Placement = Annotated[KubernetesPlacement | RHELPlacement, Field(discriminator="type")]
85
+
86
+
87
+ class TargetDefinition(WireModel):
88
+ """Desired execution-target configuration."""
89
+
90
+ name: Annotated[StrictStr, Field(min_length=1, max_length=255)]
91
+ backend_type: Literal["vanilla_k8s", "openshell"]
92
+ placement: Placement
93
+ endpoint: Annotated[StrictStr, Field(min_length=1, max_length=2048)]
94
+ credential_ref: UUID
95
+ labels: dict[str, str] = Field(default_factory=dict)
96
+
97
+ @field_validator("endpoint")
98
+ @classmethod
99
+ def check_endpoint(cls, value: str) -> str:
100
+ """Preserve verified HTTPS for provider management credentials."""
101
+ return validate_endpoint(value)
102
+
103
+ @model_validator(mode="after")
104
+ def check_backend(self) -> Self:
105
+ """Reject backend/placement pairs unsupported by the provider adapters."""
106
+ if (self.backend_type == "vanilla_k8s") != isinstance(self.placement, KubernetesPlacement):
107
+ message = "Backend does not support this placement"
108
+ raise ValueError(message)
109
+ return self
110
+
111
+
112
+ class TargetRegister(TargetDefinition, ResourceIdentity, CommandPayload):
113
+ """Register an independently identified target on an existing cluster."""
114
+
115
+ command_type: ClassVar[str] = "execution_target.register"
116
+
117
+ cluster_id: UUID
118
+ is_default: StrictBool = False
119
+
120
+
121
+ class TargetDelete(ResourceIdentity, CommandPayload):
122
+ """Request draining of an independently managed execution target."""
123
+
124
+ command_type: ClassVar[str] = "execution_target.delete"
125
+
126
+
127
+ class TargetUpdate(ResourceIdentity, CommandPayload):
128
+ """Mutable target fields; `is_default=true` promotes this target."""
129
+
130
+ command_type: ClassVar[str] = "execution_target.update"
131
+
132
+ name: Annotated[StrictStr, Field(min_length=1, max_length=255)] | None = None
133
+ endpoint: Annotated[StrictStr, Field(min_length=1, max_length=2048)] | None = None
134
+ placement: Placement | None = None
135
+ credential_ref: UUID | None = None
136
+ labels: dict[str, str] | None = None
137
+ is_default: StrictBool | None = None
138
+
139
+ def _message_payload(self) -> dict[str, object]:
140
+ payload = self.model_dump(mode="json", exclude_unset=True)
141
+ if self.placement is not None:
142
+ payload["placement"] = self.placement.model_dump(
143
+ mode="json", include=self.placement.model_fields_set | {"type"}
144
+ )
145
+ return payload
146
+
147
+ @field_validator("endpoint")
148
+ @classmethod
149
+ def check_endpoint(cls, value: str | None) -> str | None:
150
+ """Validate explicit endpoint updates before applying them."""
151
+ return None if value is None else validate_endpoint(value)
@@ -0,0 +1,39 @@
1
+ """Versioned work invocation values without node-provider imports."""
2
+
3
+ from typing import Annotated, ClassVar, Literal
4
+
5
+ from pydantic import Field, StrictStr, field_validator
6
+
7
+ from execution_plane_client.commands.base import CommandPayload
8
+ from execution_plane_client.protocol import PositiveInt, WireModel, validate_json
9
+
10
+
11
+ class NodeInvocation(WireModel):
12
+ """Version-one invocation consumed by the existing node protocol."""
13
+
14
+ version: Literal[1] = 1
15
+ operation: Literal["execute"] = "execute"
16
+ inputs: dict[str, object]
17
+ credentials: dict[str, object] = Field(repr=False)
18
+ workflow_context: dict[str, object]
19
+ settings: dict[str, object]
20
+ timeout_seconds: PositiveInt
21
+ max_output_bytes: PositiveInt
22
+
23
+ @field_validator("inputs", "credentials", "workflow_context", "settings", mode="before")
24
+ @classmethod
25
+ def check_json(cls, value: object) -> object:
26
+ """Keep all nested invocation data portable and bounded."""
27
+ return validate_json(value)
28
+
29
+
30
+ class WorkExecute(CommandPayload):
31
+ """Script command with stable caller correlation and provider-neutral input."""
32
+
33
+ command_type: ClassVar[str] = "work.execute"
34
+
35
+ image: Annotated[StrictStr, Field(min_length=1, max_length=2048)]
36
+ selectors: dict[StrictStr, StrictStr] = Field(default_factory=dict)
37
+ invocation: NodeInvocation = Field(repr=False)
38
+ output_config: dict[str, str] | None = None
39
+ workload_type: Literal["script"] = "script"
@@ -0,0 +1,11 @@
1
+ """Safe protocol errors, without request bodies or infrastructure details."""
2
+
3
+ # ruff: noqa: N818
4
+
5
+
6
+ class ProtocolConflict(Exception):
7
+ """Duplicate registration or conflicting resource state."""
8
+
9
+
10
+ class UnsupportedCommand(Exception):
11
+ """The command type or revision is unsupported by this participant."""
@@ -0,0 +1,22 @@
1
+ """Participant-owned command handling, without database or HTTP dependencies."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Protocol
5
+
6
+ from execution_plane_client.commands.base import CommandPayload
7
+ from execution_plane_client.protocol import MessageEnvelope, ResourceReference
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class HandlingResult:
12
+ """Optional resource created or affected by a command."""
13
+
14
+ resource: ResourceReference | None = None
15
+
16
+
17
+ class CommandHandler[ContextT](Protocol):
18
+ """A participant supplies its own authenticated execution context."""
19
+
20
+ async def handle(self, message: MessageEnvelope, payload: CommandPayload, context: ContextT) -> HandlingResult:
21
+ """Apply domain effects using the catalog's validated payload and original envelope."""
22
+ ...
@@ -0,0 +1,133 @@
1
+ """Strict, versioned command envelopes and receipt contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import UTC, datetime
7
+ from typing import Annotated, Literal, Self
8
+ from uuid import UUID # noqa: TC003 - Pydantic resolves these model fields at runtime.
9
+
10
+ from pydantic import BaseModel, Field, StrictInt, StrictStr, ValidationInfo, field_validator, model_validator
11
+
12
+ MAX_PAYLOAD_BYTES = 512 * 1024
13
+ Identifier = Annotated[StrictStr, Field(min_length=1, max_length=255, pattern=r"^[A-Za-z0-9_.:@/-]+$")]
14
+ PositiveInt = Annotated[StrictInt, Field(gt=0)]
15
+ Sequence = Annotated[StrictInt, Field(ge=0)]
16
+
17
+
18
+ class WireModel(BaseModel):
19
+ """Forbid silent field loss and freeze top-level wire values."""
20
+
21
+ model_config = {"extra": "forbid", "frozen": True, "validate_default": True}
22
+
23
+
24
+ def validate_json(value: object, *, max_bytes: int | None = MAX_PAYLOAD_BYTES) -> object:
25
+ """Reject coercions and non-finite JSON, optionally bounding its encoded size."""
26
+ try:
27
+ encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8")
28
+ except (TypeError, ValueError, UnicodeError, RecursionError) as exc:
29
+ message = "Payload is not finite UTF-8 JSON"
30
+ raise ValueError(message) from exc
31
+ if max_bytes is not None and len(encoded) > max_bytes:
32
+ message = f"Payload exceeds {max_bytes // 1024} KiB"
33
+ raise ValueError(message)
34
+ pending = [value]
35
+ while pending:
36
+ item = pending.pop()
37
+ if type(item) is dict:
38
+ if any(not isinstance(key, str) for key in item):
39
+ message = "JSON object keys must be strings"
40
+ raise ValueError(message)
41
+ pending.extend(item.values())
42
+ elif type(item) is list:
43
+ pending.extend(item)
44
+ elif item is not None and type(item) not in (str, bool, int, float):
45
+ message = "Payload must contain JSON values only"
46
+ raise ValueError(message)
47
+ return value
48
+
49
+
50
+ class CommandScope(WireModel):
51
+ """Initiating service client; project authorization belongs to the caller."""
52
+
53
+ client_id: Identifier
54
+
55
+
56
+ class Command(WireModel):
57
+ """One versioned operation; participants persist only its domain effects."""
58
+
59
+ command_id: UUID
60
+ parent_command_id: UUID | None = None
61
+ command_type: Annotated[StrictStr, Field(pattern=r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")]
62
+ command_revision: PositiveInt
63
+ payload: dict[str, object] = Field(repr=False)
64
+ occurred_at: datetime
65
+
66
+ @field_validator("payload", mode="before")
67
+ @classmethod
68
+ def check_payload(cls, value: object, info: ValidationInfo) -> object:
69
+ """Preserve the payload's JSON types and enforce its size limit."""
70
+ limit = None if info.data.get("command_type") == "work.execute.completed" else MAX_PAYLOAD_BYTES
71
+ return validate_json(value, max_bytes=limit)
72
+
73
+ @field_validator("occurred_at")
74
+ @classmethod
75
+ def check_time(cls, value: datetime) -> datetime:
76
+ """Require an explicit timezone and serialize instants in UTC."""
77
+ if value.tzinfo is None or value.utcoffset() is None:
78
+ message = "occurred_at requires a timezone"
79
+ raise ValueError(message)
80
+ return value.astimezone(UTC)
81
+
82
+ @model_validator(mode="after")
83
+ def check_parent(self) -> Self:
84
+ """Reject a command naming itself as its parent."""
85
+ if self.parent_command_id == self.command_id:
86
+ message = "A command cannot be its own parent"
87
+ raise ValueError(message)
88
+ return self
89
+
90
+
91
+ class MessageEnvelope(WireModel):
92
+ """Participant identity around one logical command."""
93
+
94
+ message_id: UUID
95
+ message_type: Literal["command.submit"] = "command.submit"
96
+ command_id: UUID
97
+ sequence: Sequence = 0
98
+ source_id: Identifier
99
+ destination_id: Identifier
100
+ scope: CommandScope
101
+ command: Command
102
+
103
+ @model_validator(mode="after")
104
+ def check_identity(self) -> Self:
105
+ """Keep the envelope and nested command identity consistent."""
106
+ if self.command_id != self.command.command_id:
107
+ message = "Envelope command_id must match command.command_id"
108
+ raise ValueError(message)
109
+ return self
110
+
111
+
112
+ class ResourceReference(WireModel):
113
+ """Safe reference from a command to a participant-owned resource."""
114
+
115
+ kind: Identifier
116
+ resource_id: UUID
117
+ resource_key: UUID | None = None
118
+ revision: PositiveInt | None = None
119
+
120
+
121
+ class CapabilitiesRead(WireModel):
122
+ """Command revisions installed by this participant."""
123
+
124
+ protocol_revision: Literal[1] = 1
125
+ commands: dict[str, list[int]]
126
+
127
+
128
+ class TransportReceipt(WireModel):
129
+ """Receipt of a handled request, without a persisted command record."""
130
+
131
+ received: Literal[True] = True
132
+ message_id: UUID
133
+ resource: ResourceReference | None = None
File without changes
@@ -0,0 +1,35 @@
1
+ """In-memory registration of participant-owned handlers."""
2
+
3
+ from execution_plane_client.catalog import CommandDefinition
4
+ from execution_plane_client.errors import ProtocolConflict, UnsupportedCommand
5
+ from execution_plane_client.handlers import CommandHandler
6
+ from execution_plane_client.protocol import CapabilitiesRead
7
+
8
+
9
+ class CommandRegistry[ContextT]:
10
+ """Map installed command revisions to exactly one handler."""
11
+
12
+ def __init__(self) -> None:
13
+ """Start without handlers or advertised capabilities."""
14
+ self._handlers: dict[tuple[str, int], CommandHandler[ContextT]] = {}
15
+
16
+ def register(self, definition: CommandDefinition, handler: CommandHandler[ContextT]) -> None:
17
+ """Reject duplicate registrations."""
18
+ key = (definition.command_type, definition.revision)
19
+ if key in self._handlers:
20
+ raise ProtocolConflict
21
+ self._handlers[key] = handler
22
+
23
+ def get(self, command_type: str, revision: int) -> CommandHandler[ContextT]:
24
+ """Reject commands the participant cannot handle."""
25
+ try:
26
+ return self._handlers[command_type, revision]
27
+ except KeyError:
28
+ raise UnsupportedCommand from None
29
+
30
+ def capabilities(self) -> CapabilitiesRead:
31
+ """Advertise only installed revisions."""
32
+ commands: dict[str, list[int]] = {}
33
+ for name, revision in sorted(self._handlers):
34
+ commands.setdefault(name, []).append(revision)
35
+ return CapabilitiesRead(commands=commands)
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: execution-plane-client
3
+ Version: 0.1.5
4
+ Summary: Shared execution-plane command contracts and handler registry
5
+ License-Expression: Apache-2.0
6
+ License-File: LICENSE
7
+ Requires-Python: <3.15,>=3.12
8
+ Requires-Dist: pydantic>=2.0.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Execution Plane Client
12
+
13
+ Shared Pydantic command contracts, command catalog, in-memory handler registry
14
+ and a generic handler protocol. No HTTP router, network client, persistence,
15
+ authentication implementation or EP imports are included.
16
+
17
+ Participants register their own handlers. EP implements command submission and
18
+ resource queries; the caller handles `work.execute.completed` using this same
19
+ protocol. Commands have no persisted status or child history. A receipt confirms
20
+ that the domain handler returned, not that asynchronous resource work is complete.
21
+
22
+ ## Constructing messages
23
+
24
+ Instantiate a typed command and call `to_message()`; the command supplies its
25
+ wire type and revision and builds the envelope:
26
+
27
+ ```python
28
+ from uuid import uuid4
29
+
30
+ from execution_plane_client.commands.clusters import ClusterRegister
31
+
32
+ message = ClusterRegister(
33
+ resource_key=uuid4(),
34
+ desired_revision=1,
35
+ name="workers",
36
+ endpoint="https://cluster.example",
37
+ namespace="execution-plane",
38
+ credential_ref=uuid4(),
39
+ ).to_message(
40
+ source_id="syntara-orchestration",
41
+ destination_id="execution-plane",
42
+ client_id="syntara-orchestration",
43
+ )
44
+
45
+ body = message.model_dump(mode="json") # Send using your application's HTTP client.
46
+ ```
47
+
48
+ Every concrete command supports this method, including `ClusterDelete` and
49
+ `TargetDelete`. Partial updates serialize only supplied fields, retaining explicit
50
+ nulls and placement discriminators without resetting omitted configuration.
51
+
52
+ By default, each call generates a command UUID and UTC timestamp; `message_id`
53
+ defaults to that command UUID. Keep the returned message for retries. To rebuild
54
+ it from existing domain state, pass `command_id` and `occurred_at` explicitly,
55
+ plus `message_id` if it differs. `parent_command_id` and `sequence` are optional;
56
+ `Completion` requires a parent work command UUID. Source, destination and client
57
+ scope are always supplied by the caller; the helper does not authenticate them.
58
+
59
+ ## Handling messages
60
+
61
+ After parsing a `MessageEnvelope`, validate it through the catalog and pass the
62
+ returned typed payload to the registered handler:
63
+
64
+ ```python
65
+ payload = catalog.validate(message)
66
+ handler = registry.get(message.command.command_type, message.command.command_revision)
67
+ result = await handler.handle(message, payload, context)
68
+ ```
69
+
70
+ Handlers implement `handle(message, payload, context)`. The catalog selects the
71
+ payload model by command type and revision, validates it and checks required
72
+ parent correlation. Handlers narrow the payload to their supported command
73
+ types without parsing the raw dictionary again. The original envelope remains
74
+ available for identity and correlation; partial-update payloads retain which
75
+ fields were supplied. Authentication, authorization and domain-state validation
76
+ (including validation after merging a patch) remain participant responsibilities.
77
+
78
+ Build with `uv build src/execution_plane_client`. See the service's integration
79
+ documentation for command examples and the current HTTP authentication contract.
@@ -0,0 +1,19 @@
1
+ execution_plane_client/__init__.py,sha256=B_1B86MgamC9arJ1IjoZ_pdMg6oRU-1dyqmOtlQUrGY,75
2
+ execution_plane_client/_version.py,sha256=MYViowQy3gBKIlctXQ6CgExkQCBPcL8ycJEVEexVzdI,520
3
+ execution_plane_client/catalog.py,sha256=xVWRTDHtykjqWfDvhA-cDZgxPZQPBA9doynn0G0Wazg,2420
4
+ execution_plane_client/errors.py,sha256=FQrlqkbWuWUYyIYM6x3Y-PPOuuVW8nq-v6ZQ6tBSils,313
5
+ execution_plane_client/handlers.py,sha256=mHSzEdbDzy8JnUM4SqM0zYR9cen1pWEwFSJ_MKOHAOI,788
6
+ execution_plane_client/protocol.py,sha256=XQf_REdMj_V2tRt4Q_lJ40vCkjz_Apc_pW30tVZ27Nw,4847
7
+ execution_plane_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ execution_plane_client/registry.py,sha256=qJbHm1Y9bdCAsc_XODHXmJahHX-X6SlFzW-LjBcIdDw,1503
9
+ execution_plane_client/commands/__init__.py,sha256=NgNbTP525RcOy7WKS0CBXNc3Ym1zpbJ3U2ooxYev0vY,1041
10
+ execution_plane_client/commands/base.py,sha256=daf83pxxWWuFz7mRwj0xodldALaqBRJVvWe54Los6yw,2486
11
+ execution_plane_client/commands/clusters.py,sha256=B_wSi6vG2pco_gx9vhXsZjl-nQxZaE-Q2vNQYD9qOJU,3784
12
+ execution_plane_client/commands/control.py,sha256=oO5d6TCCXoZs6xRq9GA6IDr8wYbwL8auMXslQu2laYg,1199
13
+ execution_plane_client/commands/credentials.py,sha256=KsBkOKgRndZ-E7fZ5FQzHhp0ulwzJeoesgvtJ1EjTzs,779
14
+ execution_plane_client/commands/targets.py,sha256=Q2zoQWwkDsERxsUM4i6WrIlJXFaCLm3WJFRNMjQ1j8c,5733
15
+ execution_plane_client/commands/work.py,sha256=21939Oe2jCuQ60JgrGZcg6S-GMZ_ipFc_mmx5PQN7ds,1455
16
+ execution_plane_client-0.1.5.dist-info/METADATA,sha256=JrmtdfOWrGYUrA34XmTKu5LHWPzAtEeYOJ0InhuzAvE,3310
17
+ execution_plane_client-0.1.5.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
18
+ execution_plane_client-0.1.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
19
+ execution_plane_client-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.