constellation-node-sdk 1.0.1__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.
- constellation_node_sdk/__init__.py +123 -0
- constellation_node_sdk/gate/__init__.py +47 -0
- constellation_node_sdk/gate/client.py +108 -0
- constellation_node_sdk/gate/config.py +153 -0
- constellation_node_sdk/gate/errors.py +17 -0
- constellation_node_sdk/gate/policy.py +70 -0
- constellation_node_sdk/gate/registration.py +144 -0
- constellation_node_sdk/orchestrator/__init__.py +3 -0
- constellation_node_sdk/orchestrator/base.py +57 -0
- constellation_node_sdk/orchestrator/merge.py +47 -0
- constellation_node_sdk/orchestrator/packet_builder.py +49 -0
- constellation_node_sdk/orchestrator/retry.py +61 -0
- constellation_node_sdk/orchestrator/state.py +78 -0
- constellation_node_sdk/orchestrator/step_executor.py +87 -0
- constellation_node_sdk/py.typed +0 -0
- constellation_node_sdk/runtime/__init__.py +52 -0
- constellation_node_sdk/runtime/app.py +274 -0
- constellation_node_sdk/runtime/config.py +307 -0
- constellation_node_sdk/runtime/errors.py +112 -0
- constellation_node_sdk/runtime/execution.py +249 -0
- constellation_node_sdk/runtime/handlers.py +45 -0
- constellation_node_sdk/runtime/inbound_policy.py +86 -0
- constellation_node_sdk/runtime/lifecycle.py +29 -0
- constellation_node_sdk/runtime/observability.py +72 -0
- constellation_node_sdk/runtime/preflight.py +122 -0
- constellation_node_sdk/security/__init__.py +36 -0
- constellation_node_sdk/security/delegation.py +57 -0
- constellation_node_sdk/security/errors.py +25 -0
- constellation_node_sdk/security/signing.py +121 -0
- constellation_node_sdk/security/validation.py +230 -0
- constellation_node_sdk/security/verification.py +107 -0
- constellation_node_sdk/transport/__init__.py +88 -0
- constellation_node_sdk/transport/codec.py +21 -0
- constellation_node_sdk/transport/errors.py +41 -0
- constellation_node_sdk/transport/hashing.py +73 -0
- constellation_node_sdk/transport/hop_trace.py +378 -0
- constellation_node_sdk/transport/lineage.py +28 -0
- constellation_node_sdk/transport/models.py +447 -0
- constellation_node_sdk/transport/packet.py +331 -0
- constellation_node_sdk/transport/provenance.py +53 -0
- constellation_node_sdk/transport/tenant.py +76 -0
- constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
- constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
- constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
- constellation_node_sdk-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.gate import (
|
|
4
|
+
GateClient,
|
|
5
|
+
GateClientConfig,
|
|
6
|
+
GateRegistrationConfig,
|
|
7
|
+
assert_gate_only_destination,
|
|
8
|
+
assert_local_node_identity,
|
|
9
|
+
assert_node_origin_packet,
|
|
10
|
+
build_registration_payload,
|
|
11
|
+
get_gate_client_config_from_env,
|
|
12
|
+
get_gate_registration_config_from_env,
|
|
13
|
+
load_node_spec,
|
|
14
|
+
register_from_env,
|
|
15
|
+
register_with_gate,
|
|
16
|
+
validate_outbound_gate_packet,
|
|
17
|
+
)
|
|
18
|
+
from constellation_node_sdk.runtime import (
|
|
19
|
+
LifecycleHook,
|
|
20
|
+
NodeRuntimeConfig,
|
|
21
|
+
NoOpLifecycle,
|
|
22
|
+
PreflightFailure,
|
|
23
|
+
clear_handlers,
|
|
24
|
+
create_error_transport_packet,
|
|
25
|
+
create_node_app,
|
|
26
|
+
execute_transport_packet,
|
|
27
|
+
get_handler,
|
|
28
|
+
get_runtime_config,
|
|
29
|
+
register_handler,
|
|
30
|
+
registered_actions,
|
|
31
|
+
run_preflight,
|
|
32
|
+
)
|
|
33
|
+
from constellation_node_sdk.security import (
|
|
34
|
+
recompute_transport_core,
|
|
35
|
+
sign_transport_packet,
|
|
36
|
+
validate_derived_transport_packet,
|
|
37
|
+
validate_transport_packet,
|
|
38
|
+
verify_transport_packet_signature,
|
|
39
|
+
)
|
|
40
|
+
from constellation_node_sdk.transport import (
|
|
41
|
+
DelegationLink,
|
|
42
|
+
RoutingProvenance,
|
|
43
|
+
TenantContext,
|
|
44
|
+
TransportAddress,
|
|
45
|
+
TransportAttachment,
|
|
46
|
+
TransportAuthenticationError,
|
|
47
|
+
TransportAuthorizationError,
|
|
48
|
+
TransportError,
|
|
49
|
+
TransportExpiredError,
|
|
50
|
+
TransportGovernance,
|
|
51
|
+
TransportHeader,
|
|
52
|
+
TransportHop,
|
|
53
|
+
TransportIntegrityError,
|
|
54
|
+
TransportLineage,
|
|
55
|
+
TransportNotYetValidError,
|
|
56
|
+
TransportPacket,
|
|
57
|
+
TransportSecurity,
|
|
58
|
+
TransportValidationError,
|
|
59
|
+
canonical_json,
|
|
60
|
+
compute_payload_hash,
|
|
61
|
+
compute_transport_hash,
|
|
62
|
+
create_transport_packet,
|
|
63
|
+
ensure_tenant_context,
|
|
64
|
+
utc_now,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"DelegationLink",
|
|
69
|
+
"GateClient",
|
|
70
|
+
"GateClientConfig",
|
|
71
|
+
"GateRegistrationConfig",
|
|
72
|
+
"LifecycleHook",
|
|
73
|
+
"NoOpLifecycle",
|
|
74
|
+
"NodeRuntimeConfig",
|
|
75
|
+
"PreflightFailure",
|
|
76
|
+
"RoutingProvenance",
|
|
77
|
+
"TenantContext",
|
|
78
|
+
"TransportAddress",
|
|
79
|
+
"TransportAttachment",
|
|
80
|
+
"TransportAuthenticationError",
|
|
81
|
+
"TransportAuthorizationError",
|
|
82
|
+
"TransportError",
|
|
83
|
+
"TransportExpiredError",
|
|
84
|
+
"TransportGovernance",
|
|
85
|
+
"TransportHeader",
|
|
86
|
+
"TransportHop",
|
|
87
|
+
"TransportIntegrityError",
|
|
88
|
+
"TransportLineage",
|
|
89
|
+
"TransportNotYetValidError",
|
|
90
|
+
"TransportPacket",
|
|
91
|
+
"TransportSecurity",
|
|
92
|
+
"TransportValidationError",
|
|
93
|
+
"assert_gate_only_destination",
|
|
94
|
+
"assert_local_node_identity",
|
|
95
|
+
"assert_node_origin_packet",
|
|
96
|
+
"build_registration_payload",
|
|
97
|
+
"canonical_json",
|
|
98
|
+
"clear_handlers",
|
|
99
|
+
"compute_payload_hash",
|
|
100
|
+
"compute_transport_hash",
|
|
101
|
+
"create_error_transport_packet",
|
|
102
|
+
"create_node_app",
|
|
103
|
+
"create_transport_packet",
|
|
104
|
+
"ensure_tenant_context",
|
|
105
|
+
"execute_transport_packet",
|
|
106
|
+
"get_gate_client_config_from_env",
|
|
107
|
+
"get_gate_registration_config_from_env",
|
|
108
|
+
"get_handler",
|
|
109
|
+
"get_runtime_config",
|
|
110
|
+
"load_node_spec",
|
|
111
|
+
"recompute_transport_core",
|
|
112
|
+
"register_from_env",
|
|
113
|
+
"register_handler",
|
|
114
|
+
"register_with_gate",
|
|
115
|
+
"registered_actions",
|
|
116
|
+
"run_preflight",
|
|
117
|
+
"sign_transport_packet",
|
|
118
|
+
"utc_now",
|
|
119
|
+
"validate_derived_transport_packet",
|
|
120
|
+
"validate_outbound_gate_packet",
|
|
121
|
+
"validate_transport_packet",
|
|
122
|
+
"verify_transport_packet_signature",
|
|
123
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.gate.client import GateClient
|
|
4
|
+
from constellation_node_sdk.gate.config import (
|
|
5
|
+
GateClientConfig,
|
|
6
|
+
GateRegistrationConfig,
|
|
7
|
+
get_gate_client_config_from_env,
|
|
8
|
+
get_gate_registration_config_from_env,
|
|
9
|
+
)
|
|
10
|
+
from constellation_node_sdk.gate.errors import (
|
|
11
|
+
GateClientError,
|
|
12
|
+
GatePolicyError,
|
|
13
|
+
GateRegistrationError,
|
|
14
|
+
GateResponseError,
|
|
15
|
+
)
|
|
16
|
+
from constellation_node_sdk.gate.policy import (
|
|
17
|
+
assert_gate_only_destination,
|
|
18
|
+
assert_local_node_identity,
|
|
19
|
+
assert_node_origin_packet,
|
|
20
|
+
validate_outbound_gate_packet,
|
|
21
|
+
)
|
|
22
|
+
from constellation_node_sdk.gate.registration import (
|
|
23
|
+
build_registration_payload,
|
|
24
|
+
load_node_spec,
|
|
25
|
+
register_from_env,
|
|
26
|
+
register_with_gate,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"GateClient",
|
|
31
|
+
"GateClientConfig",
|
|
32
|
+
"GateClientError",
|
|
33
|
+
"GatePolicyError",
|
|
34
|
+
"GateRegistrationConfig",
|
|
35
|
+
"GateRegistrationError",
|
|
36
|
+
"GateResponseError",
|
|
37
|
+
"assert_gate_only_destination",
|
|
38
|
+
"assert_local_node_identity",
|
|
39
|
+
"assert_node_origin_packet",
|
|
40
|
+
"build_registration_payload",
|
|
41
|
+
"get_gate_client_config_from_env",
|
|
42
|
+
"get_gate_registration_config_from_env",
|
|
43
|
+
"load_node_spec",
|
|
44
|
+
"register_from_env",
|
|
45
|
+
"register_with_gate",
|
|
46
|
+
"validate_outbound_gate_packet",
|
|
47
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from constellation_node_sdk.security.signing import sign_transport_packet
|
|
9
|
+
from constellation_node_sdk.security.validation import validate_transport_packet
|
|
10
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
11
|
+
|
|
12
|
+
from .config import GateClientConfig
|
|
13
|
+
from .policy import validate_outbound_gate_packet
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GateClient:
|
|
17
|
+
"""
|
|
18
|
+
Canonical Gate-only transport client for nodes.
|
|
19
|
+
|
|
20
|
+
This client is the only allowed outbound inter-node transport surface.
|
|
21
|
+
It never accepts an arbitrary peer URL.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: GateClientConfig) -> None:
|
|
25
|
+
self._config = config
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def gate_url(self) -> str:
|
|
29
|
+
return self._config.gate_url
|
|
30
|
+
|
|
31
|
+
def _transport_key_resolver(
|
|
32
|
+
self,
|
|
33
|
+
) -> Callable[[str | None], str | bytes | None] | Mapping[str, str | bytes] | None:
|
|
34
|
+
if not self._config.verifying_keys and self._config.signing_key is None:
|
|
35
|
+
return None
|
|
36
|
+
return self._config.resolve_verifying_key
|
|
37
|
+
|
|
38
|
+
def _maybe_sign(self, packet: TransportPacket) -> TransportPacket:
|
|
39
|
+
if self._config.signing_key is None:
|
|
40
|
+
return packet
|
|
41
|
+
if self._config.signing_key_id is None or self._config.signing_algorithm is None:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"signing_key_id and signing_algorithm are required when signing_key is configured"
|
|
44
|
+
)
|
|
45
|
+
return sign_transport_packet(
|
|
46
|
+
packet,
|
|
47
|
+
key=self._config.signing_key,
|
|
48
|
+
key_id=self._config.signing_key_id,
|
|
49
|
+
algorithm=self._config.signing_algorithm,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _validate_outbound(self, packet: TransportPacket) -> None:
|
|
53
|
+
validate_outbound_gate_packet(
|
|
54
|
+
packet,
|
|
55
|
+
local_node=self._config.local_node,
|
|
56
|
+
gate_node_name=self._config.allowed_gate_destination,
|
|
57
|
+
)
|
|
58
|
+
validate_transport_packet(
|
|
59
|
+
packet,
|
|
60
|
+
key_resolver=self._transport_key_resolver(),
|
|
61
|
+
require_signature=self._config.require_signature,
|
|
62
|
+
dev_mode=not self._config.require_signature,
|
|
63
|
+
verify_hop_signatures=self._config.verify_hop_signatures,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _validate_inbound_response(self, packet: TransportPacket) -> None:
|
|
67
|
+
validate_transport_packet(
|
|
68
|
+
packet,
|
|
69
|
+
key_resolver=self._transport_key_resolver(),
|
|
70
|
+
require_signature=self._config.verify_response_signatures,
|
|
71
|
+
dev_mode=not self._config.verify_response_signatures,
|
|
72
|
+
verify_hop_signatures=self._config.verify_hop_signatures,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def send_to_gate(self, packet: TransportPacket) -> TransportPacket:
|
|
76
|
+
"""
|
|
77
|
+
Send a canonical TransportPacket to Gate and decode a canonical TransportPacket response.
|
|
78
|
+
"""
|
|
79
|
+
self._validate_outbound(packet)
|
|
80
|
+
signed_packet = self._maybe_sign(packet)
|
|
81
|
+
|
|
82
|
+
async with httpx.AsyncClient(timeout=self._config.timeout_seconds) as client:
|
|
83
|
+
response = await client.post(
|
|
84
|
+
f"{self._config.gate_url}/v1/execute",
|
|
85
|
+
json=signed_packet.model_dump_json_dict(),
|
|
86
|
+
headers={"Content-Type": "application/json"},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
body = response.json()
|
|
91
|
+
if not isinstance(body, dict):
|
|
92
|
+
raise ValueError("Gate response body must be a JSON object")
|
|
93
|
+
|
|
94
|
+
response_packet = TransportPacket.model_validate(body)
|
|
95
|
+
self._validate_inbound_response(response_packet)
|
|
96
|
+
return response_packet
|
|
97
|
+
|
|
98
|
+
async def health(self) -> dict[str, Any]:
|
|
99
|
+
"""
|
|
100
|
+
Query Gate health endpoint.
|
|
101
|
+
"""
|
|
102
|
+
async with httpx.AsyncClient(timeout=self._config.timeout_seconds) as client:
|
|
103
|
+
response = await client.get(f"{self._config.gate_url}/v1/health")
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
body = response.json()
|
|
106
|
+
if not isinstance(body, dict):
|
|
107
|
+
raise ValueError("Gate health response must be a JSON object")
|
|
108
|
+
return body
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
9
|
+
raw = os.getenv(name)
|
|
10
|
+
if raw is None:
|
|
11
|
+
return default
|
|
12
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GateClientConfig(BaseModel):
|
|
16
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
17
|
+
|
|
18
|
+
gate_url: str
|
|
19
|
+
local_node: str
|
|
20
|
+
timeout_seconds: float = Field(default=30.0, gt=0.0)
|
|
21
|
+
require_signature: bool = False
|
|
22
|
+
signing_key: str | bytes | None = None
|
|
23
|
+
signing_key_id: str | None = None
|
|
24
|
+
signing_algorithm: str | None = None
|
|
25
|
+
verify_response_signatures: bool = False
|
|
26
|
+
verifying_keys: dict[str, str] = Field(default_factory=dict)
|
|
27
|
+
verify_hop_signatures: bool = False
|
|
28
|
+
allowed_gate_destination: str = "gate"
|
|
29
|
+
|
|
30
|
+
@field_validator("gate_url")
|
|
31
|
+
@classmethod
|
|
32
|
+
def validate_gate_url(cls, value: str) -> str:
|
|
33
|
+
normalized = value.strip().rstrip("/")
|
|
34
|
+
if not normalized:
|
|
35
|
+
raise ValueError("gate_url must not be empty")
|
|
36
|
+
if not (normalized.startswith("http://") or normalized.startswith("https://")):
|
|
37
|
+
raise ValueError("gate_url must start with http:// or https://")
|
|
38
|
+
return normalized
|
|
39
|
+
|
|
40
|
+
@field_validator("local_node", "allowed_gate_destination")
|
|
41
|
+
@classmethod
|
|
42
|
+
def validate_node_fields(cls, value: str) -> str:
|
|
43
|
+
normalized = value.strip().lower()
|
|
44
|
+
if not normalized:
|
|
45
|
+
raise ValueError("node fields must not be empty")
|
|
46
|
+
return normalized
|
|
47
|
+
|
|
48
|
+
@field_validator("signing_key_id", "signing_algorithm")
|
|
49
|
+
@classmethod
|
|
50
|
+
def validate_optional_strings(cls, value: str | None) -> str | None:
|
|
51
|
+
if value is None:
|
|
52
|
+
return None
|
|
53
|
+
normalized = value.strip()
|
|
54
|
+
if not normalized:
|
|
55
|
+
raise ValueError("optional string fields must not be blank")
|
|
56
|
+
return normalized
|
|
57
|
+
|
|
58
|
+
@field_validator("verifying_keys")
|
|
59
|
+
@classmethod
|
|
60
|
+
def validate_verifying_keys(cls, value: dict[str, str]) -> dict[str, str]:
|
|
61
|
+
normalized: dict[str, str] = {}
|
|
62
|
+
for key_id, key_value in value.items():
|
|
63
|
+
normalized_key = key_id.strip()
|
|
64
|
+
normalized_value = key_value.strip()
|
|
65
|
+
if not normalized_key or not normalized_value:
|
|
66
|
+
raise ValueError("verifying_keys must not contain blank keys or values")
|
|
67
|
+
normalized[normalized_key] = normalized_value
|
|
68
|
+
return normalized
|
|
69
|
+
|
|
70
|
+
def resolve_verifying_key(self, key_id: str | None) -> str | bytes | None:
|
|
71
|
+
if key_id is None:
|
|
72
|
+
return None
|
|
73
|
+
if key_id in self.verifying_keys:
|
|
74
|
+
return self.verifying_keys[key_id]
|
|
75
|
+
if (
|
|
76
|
+
self.signing_key_id is not None
|
|
77
|
+
and key_id == self.signing_key_id
|
|
78
|
+
and self.signing_key is not None
|
|
79
|
+
):
|
|
80
|
+
return self.signing_key
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class GateRegistrationConfig(BaseModel):
|
|
85
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
86
|
+
|
|
87
|
+
gate_url: str
|
|
88
|
+
admin_token: str | None = None
|
|
89
|
+
spec_path: str = "engine/spec.yaml"
|
|
90
|
+
registration_enabled: bool = True
|
|
91
|
+
retries: int = Field(default=3, ge=1)
|
|
92
|
+
overwrite: bool = True
|
|
93
|
+
|
|
94
|
+
@field_validator("gate_url")
|
|
95
|
+
@classmethod
|
|
96
|
+
def validate_gate_url(cls, value: str) -> str:
|
|
97
|
+
normalized = value.strip().rstrip("/")
|
|
98
|
+
if not normalized:
|
|
99
|
+
raise ValueError("gate_url must not be empty")
|
|
100
|
+
if not (normalized.startswith("http://") or normalized.startswith("https://")):
|
|
101
|
+
raise ValueError("gate_url must start with http:// or https://")
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
@field_validator("admin_token", "spec_path")
|
|
105
|
+
@classmethod
|
|
106
|
+
def validate_optional_strings(cls, value: str | None) -> str | None:
|
|
107
|
+
if value is None:
|
|
108
|
+
return None
|
|
109
|
+
normalized = value.strip()
|
|
110
|
+
if not normalized:
|
|
111
|
+
raise ValueError("optional string fields must not be blank")
|
|
112
|
+
return normalized
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_gate_client_config_from_env() -> GateClientConfig:
|
|
116
|
+
gate_url = os.getenv("GATE_URL", "").strip()
|
|
117
|
+
if not gate_url:
|
|
118
|
+
raise ValueError("GATE_URL is required")
|
|
119
|
+
|
|
120
|
+
local_node = os.getenv("L9_NODE_NAME", "unknown-node").strip().lower() or "unknown-node"
|
|
121
|
+
signing_key = os.getenv("L9_SIGNING_KEY") or os.getenv("L9_SIGNING_SECRET")
|
|
122
|
+
signing_key_id = os.getenv("L9_SIGNING_KEY_ID")
|
|
123
|
+
signing_algorithm = os.getenv("L9_SIGNING_ALGORITHM")
|
|
124
|
+
verify_response_signatures = _env_bool("L9_REQUIRE_SIGNATURE", False)
|
|
125
|
+
|
|
126
|
+
return GateClientConfig(
|
|
127
|
+
gate_url=gate_url,
|
|
128
|
+
local_node=local_node,
|
|
129
|
+
timeout_seconds=float(os.getenv("GATE_CLIENT_TIMEOUT_SECONDS", "30.0")),
|
|
130
|
+
require_signature=_env_bool("L9_REQUIRE_SIGNATURE", False),
|
|
131
|
+
signing_key=signing_key,
|
|
132
|
+
signing_key_id=signing_key_id,
|
|
133
|
+
signing_algorithm=signing_algorithm,
|
|
134
|
+
verify_response_signatures=verify_response_signatures,
|
|
135
|
+
verifying_keys={},
|
|
136
|
+
verify_hop_signatures=_env_bool("L9_VERIFY_HOP_SIGNATURES", False),
|
|
137
|
+
allowed_gate_destination=os.getenv("GATE_ALLOWED_DESTINATION", "gate"),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_gate_registration_config_from_env() -> GateRegistrationConfig:
|
|
142
|
+
gate_url = os.getenv("GATE_URL", "").strip()
|
|
143
|
+
if not gate_url:
|
|
144
|
+
raise ValueError("GATE_URL is required")
|
|
145
|
+
|
|
146
|
+
return GateRegistrationConfig(
|
|
147
|
+
gate_url=gate_url,
|
|
148
|
+
admin_token=os.getenv("GATE_ADMIN_TOKEN") or None,
|
|
149
|
+
spec_path=os.getenv("GATE_NODE_SPEC_PATH", "engine/spec.yaml"),
|
|
150
|
+
registration_enabled=_env_bool("GATE_REGISTRATION_ENABLED", True),
|
|
151
|
+
retries=int(os.getenv("GATE_REGISTER_RETRIES", "3")),
|
|
152
|
+
overwrite=_env_bool("GATE_REGISTER_OVERWRITE", True),
|
|
153
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GateClientError(Exception):
|
|
5
|
+
"""Base exception for Gate client failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GatePolicyError(GateClientError):
|
|
9
|
+
"""Raised when an outbound packet violates Gate-only routing policy."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GateResponseError(GateClientError):
|
|
13
|
+
"""Raised when Gate returns an invalid or non-canonical response."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GateRegistrationError(GateClientError):
|
|
17
|
+
"""Raised when Gate registration cannot be completed safely."""
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from constellation_node_sdk.transport.packet import TransportPacket
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def assert_node_origin_packet(packet: TransportPacket) -> None:
|
|
7
|
+
"""
|
|
8
|
+
Require that a packet sent through the Gate client is node-originated.
|
|
9
|
+
|
|
10
|
+
External clients should speak to Gate directly without going through the node SDK.
|
|
11
|
+
"""
|
|
12
|
+
source = packet.address.source_node.strip().lower()
|
|
13
|
+
if source in {"", "client"}:
|
|
14
|
+
raise ValueError(
|
|
15
|
+
"Gate client only accepts node-originated packets with non-client source_node"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
provenance_origin = packet.provenance.origin_kind.strip().lower()
|
|
19
|
+
if provenance_origin != "node":
|
|
20
|
+
raise ValueError("Gate client requires packet.provenance.origin_kind == 'node'")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def assert_gate_only_destination(packet: TransportPacket, *, gate_node_name: str = "gate") -> None:
|
|
24
|
+
"""
|
|
25
|
+
Enforce the architectural rule that node-originated packets target Gate only.
|
|
26
|
+
"""
|
|
27
|
+
normalized_gate = gate_node_name.strip().lower()
|
|
28
|
+
destination = packet.address.destination_node.strip().lower()
|
|
29
|
+
|
|
30
|
+
if destination != normalized_gate:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"node-originated packets must target {normalized_gate!r}, got {destination!r}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
requested_action = packet.provenance.requested_action.strip().lower()
|
|
36
|
+
if requested_action != packet.header.action:
|
|
37
|
+
raise ValueError("packet.provenance.requested_action must match packet.header.action")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assert_local_node_identity(packet: TransportPacket, *, local_node: str) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Ensure the packet source matches the local runtime node identity.
|
|
43
|
+
"""
|
|
44
|
+
normalized_local = local_node.strip().lower()
|
|
45
|
+
if packet.address.source_node != normalized_local:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"packet source_node {packet.address.source_node!r} does not match"
|
|
48
|
+
f" local node {normalized_local!r}"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
original_source = packet.provenance.original_source_node
|
|
52
|
+
if original_source is not None and original_source != normalized_local:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"packet.provenance.original_source_node must match local node"
|
|
55
|
+
" for node-originated packets"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_outbound_gate_packet(
|
|
60
|
+
packet: TransportPacket,
|
|
61
|
+
*,
|
|
62
|
+
local_node: str,
|
|
63
|
+
gate_node_name: str = "gate",
|
|
64
|
+
) -> None:
|
|
65
|
+
"""
|
|
66
|
+
Apply the full outbound routing policy for the Gate client.
|
|
67
|
+
"""
|
|
68
|
+
assert_node_origin_packet(packet)
|
|
69
|
+
assert_local_node_identity(packet, local_node=local_node)
|
|
70
|
+
assert_gate_only_destination(packet, gate_node_name=gate_node_name)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .config import GateRegistrationConfig, get_gate_registration_config_from_env
|
|
11
|
+
|
|
12
|
+
_DEFAULT_RETRY_BASE_SECONDS = 1.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_node_spec(spec_path: str) -> dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
Load node spec.yaml and require a top-level mapping.
|
|
18
|
+
"""
|
|
19
|
+
path = Path(spec_path)
|
|
20
|
+
if not path.exists():
|
|
21
|
+
raise FileNotFoundError(f"node spec not found: {path.resolve()}")
|
|
22
|
+
|
|
23
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
24
|
+
if not isinstance(raw, dict):
|
|
25
|
+
raise ValueError(f"node spec must be a YAML mapping: {path}")
|
|
26
|
+
return raw
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_registration_payload(spec: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""
|
|
31
|
+
Convert spec.yaml into Gate admin registration payload.
|
|
32
|
+
|
|
33
|
+
Required:
|
|
34
|
+
- node.id
|
|
35
|
+
- node.actions
|
|
36
|
+
"""
|
|
37
|
+
node = spec.get("node", {})
|
|
38
|
+
if not isinstance(node, dict) or not node:
|
|
39
|
+
raise ValueError("spec.yaml missing required node block")
|
|
40
|
+
|
|
41
|
+
node_id = str(node.get("id", "")).strip().lower()
|
|
42
|
+
if not node_id:
|
|
43
|
+
raise ValueError("spec.yaml node.id is required")
|
|
44
|
+
|
|
45
|
+
actions_raw = node.get("actions", [])
|
|
46
|
+
if not isinstance(actions_raw, list) or not actions_raw:
|
|
47
|
+
raise ValueError(f"spec.yaml node.actions must not be empty (node: {node_id})")
|
|
48
|
+
|
|
49
|
+
actions = [str(action).strip().lower() for action in actions_raw if str(action).strip()]
|
|
50
|
+
if not actions:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"spec.yaml node.actions must contain at least one non-blank action (node: {node_id})"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
internal_url = str(node.get("internal_url", f"http://{node_id}:8000")).strip().rstrip("/")
|
|
56
|
+
if not internal_url:
|
|
57
|
+
raise ValueError("spec.yaml node.internal_url must not be blank")
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
node_id: {
|
|
61
|
+
"internal_url": internal_url,
|
|
62
|
+
"supported_actions": actions,
|
|
63
|
+
"priority_class": str(node.get("priority_class", "P2")).strip() or "P2",
|
|
64
|
+
"max_concurrent": int(node.get("max_concurrent", 50)),
|
|
65
|
+
"health_endpoint": str(node.get("health_endpoint", "/v1/health")).strip()
|
|
66
|
+
or "/v1/health",
|
|
67
|
+
"timeout_ms": int(node.get("timeout_ms", 30000)),
|
|
68
|
+
"metadata": {
|
|
69
|
+
"version": str(node.get("version", "1.0.0")).strip() or "1.0.0",
|
|
70
|
+
"type": str(node.get("type", "custom")).strip() or "custom",
|
|
71
|
+
"generated_by": "constellation-node-sdk",
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def register_with_gate(
|
|
78
|
+
*,
|
|
79
|
+
gate_url: str,
|
|
80
|
+
admin_token: str | None = None,
|
|
81
|
+
spec_path: str,
|
|
82
|
+
retries: int = 3,
|
|
83
|
+
overwrite: bool = True,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Register the current node with Gate via POST /v1/admin/register.
|
|
87
|
+
|
|
88
|
+
Returns True on success, False on rejection or after retry exhaustion.
|
|
89
|
+
Registration failure is intentionally non-fatal for node startup.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
spec = load_node_spec(spec_path)
|
|
93
|
+
payload = build_registration_payload(spec)
|
|
94
|
+
except (FileNotFoundError, ValueError):
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
_node_id = next(iter(payload)) # noqa: F841 — used for future logging
|
|
98
|
+
url = f"{gate_url.rstrip('/')}/v1/admin/register"
|
|
99
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
100
|
+
if admin_token:
|
|
101
|
+
headers["X-Admin-Token"] = admin_token
|
|
102
|
+
params = {"overwrite": "true" if overwrite else "false"}
|
|
103
|
+
|
|
104
|
+
for attempt in range(1, retries + 1):
|
|
105
|
+
try:
|
|
106
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
107
|
+
response = await client.post(
|
|
108
|
+
url,
|
|
109
|
+
json=payload,
|
|
110
|
+
headers=headers,
|
|
111
|
+
params=params,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if response.status_code == 200:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
if response.status_code in {400, 401, 403, 409, 422}:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
except httpx.TransportError:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
if attempt < retries:
|
|
124
|
+
backoff = _DEFAULT_RETRY_BASE_SECONDS * (2 ** (attempt - 1))
|
|
125
|
+
await asyncio.sleep(backoff)
|
|
126
|
+
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def register_from_env() -> bool:
|
|
131
|
+
"""
|
|
132
|
+
Convenience wrapper for Gate registration using environment-derived config.
|
|
133
|
+
"""
|
|
134
|
+
config: GateRegistrationConfig = get_gate_registration_config_from_env()
|
|
135
|
+
if not config.registration_enabled:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
return await register_with_gate(
|
|
139
|
+
gate_url=config.gate_url,
|
|
140
|
+
admin_token=config.admin_token,
|
|
141
|
+
spec_path=config.spec_path,
|
|
142
|
+
retries=config.retries,
|
|
143
|
+
overwrite=config.overwrite,
|
|
144
|
+
)
|