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.
Files changed (45) hide show
  1. constellation_node_sdk/__init__.py +123 -0
  2. constellation_node_sdk/gate/__init__.py +47 -0
  3. constellation_node_sdk/gate/client.py +108 -0
  4. constellation_node_sdk/gate/config.py +153 -0
  5. constellation_node_sdk/gate/errors.py +17 -0
  6. constellation_node_sdk/gate/policy.py +70 -0
  7. constellation_node_sdk/gate/registration.py +144 -0
  8. constellation_node_sdk/orchestrator/__init__.py +3 -0
  9. constellation_node_sdk/orchestrator/base.py +57 -0
  10. constellation_node_sdk/orchestrator/merge.py +47 -0
  11. constellation_node_sdk/orchestrator/packet_builder.py +49 -0
  12. constellation_node_sdk/orchestrator/retry.py +61 -0
  13. constellation_node_sdk/orchestrator/state.py +78 -0
  14. constellation_node_sdk/orchestrator/step_executor.py +87 -0
  15. constellation_node_sdk/py.typed +0 -0
  16. constellation_node_sdk/runtime/__init__.py +52 -0
  17. constellation_node_sdk/runtime/app.py +274 -0
  18. constellation_node_sdk/runtime/config.py +307 -0
  19. constellation_node_sdk/runtime/errors.py +112 -0
  20. constellation_node_sdk/runtime/execution.py +249 -0
  21. constellation_node_sdk/runtime/handlers.py +45 -0
  22. constellation_node_sdk/runtime/inbound_policy.py +86 -0
  23. constellation_node_sdk/runtime/lifecycle.py +29 -0
  24. constellation_node_sdk/runtime/observability.py +72 -0
  25. constellation_node_sdk/runtime/preflight.py +122 -0
  26. constellation_node_sdk/security/__init__.py +36 -0
  27. constellation_node_sdk/security/delegation.py +57 -0
  28. constellation_node_sdk/security/errors.py +25 -0
  29. constellation_node_sdk/security/signing.py +121 -0
  30. constellation_node_sdk/security/validation.py +230 -0
  31. constellation_node_sdk/security/verification.py +107 -0
  32. constellation_node_sdk/transport/__init__.py +88 -0
  33. constellation_node_sdk/transport/codec.py +21 -0
  34. constellation_node_sdk/transport/errors.py +41 -0
  35. constellation_node_sdk/transport/hashing.py +73 -0
  36. constellation_node_sdk/transport/hop_trace.py +378 -0
  37. constellation_node_sdk/transport/lineage.py +28 -0
  38. constellation_node_sdk/transport/models.py +447 -0
  39. constellation_node_sdk/transport/packet.py +331 -0
  40. constellation_node_sdk/transport/provenance.py +53 -0
  41. constellation_node_sdk/transport/tenant.py +76 -0
  42. constellation_node_sdk-1.0.1.dist-info/METADATA +141 -0
  43. constellation_node_sdk-1.0.1.dist-info/RECORD +45 -0
  44. constellation_node_sdk-1.0.1.dist-info/WHEEL +5 -0
  45. 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
+ )
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __all__: list[str] = []