omnibase_infra 0.2.5__py3-none-any.whl → 0.2.7__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.
- omnibase_infra/constants_topic_patterns.py +26 -0
- omnibase_infra/enums/__init__.py +3 -0
- omnibase_infra/enums/enum_consumer_group_purpose.py +92 -0
- omnibase_infra/enums/enum_handler_source_mode.py +16 -2
- omnibase_infra/errors/__init__.py +4 -0
- omnibase_infra/errors/error_binding_resolution.py +128 -0
- omnibase_infra/event_bus/configs/kafka_event_bus_config.yaml +0 -2
- omnibase_infra/event_bus/event_bus_inmemory.py +64 -10
- omnibase_infra/event_bus/event_bus_kafka.py +105 -47
- omnibase_infra/event_bus/mixin_kafka_broadcast.py +3 -7
- omnibase_infra/event_bus/mixin_kafka_dlq.py +12 -6
- omnibase_infra/event_bus/models/config/model_kafka_event_bus_config.py +0 -81
- omnibase_infra/event_bus/testing/__init__.py +26 -0
- omnibase_infra/event_bus/testing/adapter_protocol_event_publisher_inmemory.py +418 -0
- omnibase_infra/event_bus/testing/model_publisher_metrics.py +64 -0
- omnibase_infra/handlers/handler_consul.py +2 -0
- omnibase_infra/handlers/mixins/__init__.py +5 -0
- omnibase_infra/handlers/mixins/mixin_consul_service.py +274 -10
- omnibase_infra/handlers/mixins/mixin_consul_topic_index.py +585 -0
- omnibase_infra/handlers/models/model_filesystem_config.py +4 -4
- omnibase_infra/migrations/001_create_event_ledger.sql +166 -0
- omnibase_infra/migrations/001_drop_event_ledger.sql +18 -0
- omnibase_infra/mixins/mixin_node_introspection.py +189 -19
- omnibase_infra/models/__init__.py +8 -0
- omnibase_infra/models/bindings/__init__.py +59 -0
- omnibase_infra/models/bindings/constants.py +144 -0
- omnibase_infra/models/bindings/model_binding_resolution_result.py +103 -0
- omnibase_infra/models/bindings/model_operation_binding.py +44 -0
- omnibase_infra/models/bindings/model_operation_bindings_subcontract.py +152 -0
- omnibase_infra/models/bindings/model_parsed_binding.py +52 -0
- omnibase_infra/models/discovery/model_introspection_config.py +25 -17
- omnibase_infra/models/dispatch/__init__.py +8 -0
- omnibase_infra/models/dispatch/model_debug_trace_snapshot.py +114 -0
- omnibase_infra/models/dispatch/model_materialized_dispatch.py +141 -0
- omnibase_infra/models/handlers/model_handler_source_config.py +1 -1
- omnibase_infra/models/model_node_identity.py +126 -0
- omnibase_infra/models/projection/model_snapshot_topic_config.py +3 -2
- omnibase_infra/models/registration/__init__.py +9 -0
- omnibase_infra/models/registration/model_event_bus_topic_entry.py +59 -0
- omnibase_infra/models/registration/model_node_event_bus_config.py +99 -0
- omnibase_infra/models/registration/model_node_introspection_event.py +11 -0
- omnibase_infra/models/runtime/__init__.py +9 -0
- omnibase_infra/models/validation/model_coverage_metrics.py +2 -2
- omnibase_infra/nodes/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/__init__.py +29 -0
- omnibase_infra/nodes/contract_registry_reducer/contract.yaml +255 -0
- omnibase_infra/nodes/contract_registry_reducer/models/__init__.py +38 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_contract_registry_state.py +266 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_cleanup_topic_references.py +55 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_deactivate_contract.py +58 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_mark_stale.py +49 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_heartbeat.py +71 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_update_topic.py +66 -0
- omnibase_infra/nodes/contract_registry_reducer/models/model_payload_upsert_contract.py +92 -0
- omnibase_infra/nodes/contract_registry_reducer/node.py +121 -0
- omnibase_infra/nodes/contract_registry_reducer/reducer.py +784 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/__init__.py +9 -0
- omnibase_infra/nodes/contract_registry_reducer/registry/registry_infra_contract_registry_reducer.py +101 -0
- omnibase_infra/nodes/handlers/consul/contract.yaml +85 -0
- omnibase_infra/nodes/handlers/db/contract.yaml +72 -0
- omnibase_infra/nodes/handlers/graph/contract.yaml +127 -0
- omnibase_infra/nodes/handlers/http/contract.yaml +74 -0
- omnibase_infra/nodes/handlers/intent/contract.yaml +66 -0
- omnibase_infra/nodes/handlers/mcp/contract.yaml +69 -0
- omnibase_infra/nodes/handlers/vault/contract.yaml +91 -0
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +50 -0
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +104 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +284 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/__init__.py +29 -0
- omnibase_infra/nodes/node_ledger_projection_compute/registry/registry_infra_ledger_projection.py +118 -0
- omnibase_infra/nodes/node_ledger_write_effect/__init__.py +82 -0
- omnibase_infra/nodes/node_ledger_write_effect/contract.yaml +200 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/__init__.py +22 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_append.py +372 -0
- omnibase_infra/nodes/node_ledger_write_effect/handlers/handler_ledger_query.py +597 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/__init__.py +31 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_append_result.py +54 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_entry.py +92 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query.py +53 -0
- omnibase_infra/nodes/node_ledger_write_effect/models/model_ledger_query_result.py +41 -0
- omnibase_infra/nodes/node_ledger_write_effect/node.py +89 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/__init__.py +13 -0
- omnibase_infra/nodes/node_ledger_write_effect/protocols/protocol_ledger_persistence.py +127 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/__init__.py +9 -0
- omnibase_infra/nodes/node_ledger_write_effect/registry/registry_infra_ledger_write.py +121 -0
- omnibase_infra/nodes/node_registration_orchestrator/registry/registry_infra_node_registration_orchestrator.py +7 -5
- omnibase_infra/nodes/reducers/models/__init__.py +7 -2
- omnibase_infra/nodes/reducers/models/model_payload_consul_register.py +11 -0
- omnibase_infra/nodes/reducers/models/model_payload_ledger_append.py +133 -0
- omnibase_infra/nodes/reducers/registration_reducer.py +1 -0
- omnibase_infra/protocols/__init__.py +3 -0
- omnibase_infra/protocols/protocol_dispatch_engine.py +152 -0
- omnibase_infra/runtime/__init__.py +60 -0
- omnibase_infra/runtime/binding_resolver.py +753 -0
- omnibase_infra/runtime/constants_security.py +70 -0
- omnibase_infra/runtime/contract_loaders/__init__.py +9 -0
- omnibase_infra/runtime/contract_loaders/operation_bindings_loader.py +789 -0
- omnibase_infra/runtime/emit_daemon/__init__.py +97 -0
- omnibase_infra/runtime/emit_daemon/cli.py +844 -0
- omnibase_infra/runtime/emit_daemon/client.py +811 -0
- omnibase_infra/runtime/emit_daemon/config.py +535 -0
- omnibase_infra/runtime/emit_daemon/daemon.py +812 -0
- omnibase_infra/runtime/emit_daemon/event_registry.py +477 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_request.py +139 -0
- omnibase_infra/runtime/emit_daemon/model_daemon_response.py +191 -0
- omnibase_infra/runtime/emit_daemon/queue.py +618 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +466 -0
- omnibase_infra/runtime/handler_source_resolver.py +43 -2
- omnibase_infra/runtime/kafka_contract_source.py +984 -0
- omnibase_infra/runtime/models/__init__.py +13 -0
- omnibase_infra/runtime/models/model_contract_load_result.py +224 -0
- omnibase_infra/runtime/models/model_runtime_contract_config.py +268 -0
- omnibase_infra/runtime/models/model_runtime_scheduler_config.py +4 -3
- omnibase_infra/runtime/models/model_security_config.py +109 -0
- omnibase_infra/runtime/publisher_topic_scoped.py +294 -0
- omnibase_infra/runtime/runtime_contract_config_loader.py +406 -0
- omnibase_infra/runtime/service_kernel.py +76 -6
- omnibase_infra/runtime/service_message_dispatch_engine.py +558 -15
- omnibase_infra/runtime/service_runtime_host_process.py +770 -20
- omnibase_infra/runtime/transition_notification_publisher.py +3 -2
- omnibase_infra/runtime/util_wiring.py +206 -62
- omnibase_infra/services/mcp/service_mcp_tool_sync.py +27 -9
- omnibase_infra/services/session/config_consumer.py +25 -8
- omnibase_infra/services/session/config_store.py +2 -2
- omnibase_infra/services/session/consumer.py +1 -1
- omnibase_infra/topics/__init__.py +45 -0
- omnibase_infra/topics/platform_topic_suffixes.py +140 -0
- omnibase_infra/topics/util_topic_composition.py +95 -0
- omnibase_infra/types/typed_dict/__init__.py +9 -1
- omnibase_infra/types/typed_dict/typed_dict_envelope_build_params.py +115 -0
- omnibase_infra/utils/__init__.py +9 -0
- omnibase_infra/utils/util_consumer_group.py +232 -0
- omnibase_infra/validation/infra_validators.py +18 -1
- omnibase_infra/validation/validation_exemptions.yaml +192 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/RECORD +139 -52
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/entry_points.txt +1 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.5.dist-info → omnibase_infra-0.2.7.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Topic-scoped publisher that validates against contract-declared publish topics.
|
|
4
|
+
|
|
5
|
+
This module implements a publisher that enforces topic-level access control
|
|
6
|
+
based on the contract's `event_bus.publish_topics` section. Handlers can only
|
|
7
|
+
publish to topics explicitly declared in their contract, preventing unauthorized
|
|
8
|
+
event emission and maintaining clean architectural boundaries.
|
|
9
|
+
|
|
10
|
+
Design Principles:
|
|
11
|
+
- **Contract-Driven Access Control**: Topics must be declared in contract
|
|
12
|
+
- **Environment-Aware Routing**: Topic suffixes are prefixed with environment
|
|
13
|
+
- **Fail-Fast Validation**: Invalid topics raise immediately, not at delivery
|
|
14
|
+
- **Duck-Typed Protocol**: Implements publisher protocol without explicit inheritance
|
|
15
|
+
|
|
16
|
+
Architecture Context:
|
|
17
|
+
In the ONEX handler architecture, each handler receives a topic-scoped
|
|
18
|
+
publisher configured with only the topics from its contract. This ensures:
|
|
19
|
+
|
|
20
|
+
1. Handlers cannot publish to arbitrary topics
|
|
21
|
+
2. Topic dependencies are explicit and auditable
|
|
22
|
+
3. Contract changes required to add new publish targets
|
|
23
|
+
4. Clear separation between handler capabilities
|
|
24
|
+
|
|
25
|
+
Example Usage:
|
|
26
|
+
```python
|
|
27
|
+
from omnibase_infra.runtime.publisher_topic_scoped import PublisherTopicScoped
|
|
28
|
+
from omnibase_infra.event_bus.event_bus_kafka import EventBusKafka
|
|
29
|
+
|
|
30
|
+
# Create topic-scoped publisher from contract
|
|
31
|
+
publisher = PublisherTopicScoped(
|
|
32
|
+
event_bus=kafka_event_bus,
|
|
33
|
+
allowed_topics={"onex.fsm.state.transitions.v1", "onex.events.v1"},
|
|
34
|
+
environment="dev",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Publish to allowed topic (succeeds)
|
|
38
|
+
await publisher.publish(
|
|
39
|
+
event_type="state.transition",
|
|
40
|
+
payload={"from": "pending", "to": "active"},
|
|
41
|
+
topic="onex.fsm.state.transitions.v1",
|
|
42
|
+
correlation_id="abc-123",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Publish to disallowed topic (raises ProtocolConfigurationError)
|
|
46
|
+
await publisher.publish(
|
|
47
|
+
event_type="audit.log",
|
|
48
|
+
payload={"action": "login"},
|
|
49
|
+
topic="onex.audit.v1", # Not in allowed_topics
|
|
50
|
+
correlation_id="xyz-789",
|
|
51
|
+
)
|
|
52
|
+
# ProtocolConfigurationError: Topic 'onex.audit.v1' not in contract's publish_topics.
|
|
53
|
+
# Allowed: ['onex.events.v1', 'onex.fsm.state.transitions.v1']
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Thread Safety:
|
|
57
|
+
This class is coroutine-safe for concurrent async publishing. The underlying
|
|
58
|
+
event bus handles synchronization. No mutable state is shared between
|
|
59
|
+
publish operations.
|
|
60
|
+
|
|
61
|
+
Related Tickets:
|
|
62
|
+
- OMN-1621: Runtime consumes event_bus subcontract for contract-driven topic wiring
|
|
63
|
+
|
|
64
|
+
See Also:
|
|
65
|
+
- ProtocolEventBusLike: Event bus protocol
|
|
66
|
+
- ModelKafkaEventBusConfig: Kafka configuration model
|
|
67
|
+
- EventBusKafka: Production Kafka event bus implementation
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import json
|
|
73
|
+
import logging
|
|
74
|
+
from typing import TYPE_CHECKING
|
|
75
|
+
from uuid import UUID
|
|
76
|
+
|
|
77
|
+
from omnibase_core.types import JsonType
|
|
78
|
+
from omnibase_infra.errors import ProtocolConfigurationError
|
|
79
|
+
from omnibase_infra.protocols.protocol_event_bus_like import ProtocolEventBusLike
|
|
80
|
+
|
|
81
|
+
if TYPE_CHECKING:
|
|
82
|
+
from omnibase_infra.event_bus.event_bus_inmemory import EventBusInmemory
|
|
83
|
+
from omnibase_infra.event_bus.event_bus_kafka import EventBusKafka
|
|
84
|
+
|
|
85
|
+
logger = logging.getLogger(__name__)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PublisherTopicScoped:
|
|
89
|
+
"""Publisher that validates against allowed topics from contract.
|
|
90
|
+
|
|
91
|
+
This publisher ensures handlers can only publish to topics explicitly
|
|
92
|
+
declared in their contract's event_bus.publish_topics section.
|
|
93
|
+
Implements a publisher protocol via duck typing (no explicit inheritance
|
|
94
|
+
required per ONEX conventions).
|
|
95
|
+
|
|
96
|
+
Features:
|
|
97
|
+
- Contract-driven topic access control
|
|
98
|
+
- Environment-aware topic resolution
|
|
99
|
+
- Fail-fast validation on disallowed topics
|
|
100
|
+
- JSON serialization for payloads
|
|
101
|
+
- Correlation ID propagation for distributed tracing
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
_event_bus: The underlying event bus for publishing
|
|
105
|
+
_allowed_topics: Set of topic suffixes allowed by contract
|
|
106
|
+
_environment: Environment prefix for topic resolution
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> publisher = PublisherTopicScoped(
|
|
110
|
+
... event_bus=kafka_bus,
|
|
111
|
+
... allowed_topics={"events.v1", "commands.v1"},
|
|
112
|
+
... environment="dev",
|
|
113
|
+
... )
|
|
114
|
+
>>> await publisher.publish(
|
|
115
|
+
... event_type="user.created",
|
|
116
|
+
... payload={"user_id": "123"},
|
|
117
|
+
... topic="events.v1",
|
|
118
|
+
... )
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
event_bus: ProtocolEventBusLike,
|
|
124
|
+
allowed_topics: set[str],
|
|
125
|
+
environment: str,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Initialize topic-scoped publisher.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
event_bus: The event bus implementation for actual publishing
|
|
131
|
+
(EventBusKafka or EventBusInmemory).
|
|
132
|
+
Must implement publish(topic, key, value) method. Duck typed per ONEX.
|
|
133
|
+
allowed_topics: Set of topic suffixes from contract's publish_topics.
|
|
134
|
+
These are the ONLY topics this publisher can publish to.
|
|
135
|
+
environment: Environment prefix (e.g., 'dev', 'staging', 'prod').
|
|
136
|
+
Used to construct full topic names.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
>>> publisher = PublisherTopicScoped(
|
|
140
|
+
... event_bus=EventBusKafka.default(),
|
|
141
|
+
... allowed_topics={"onex.events.v1"},
|
|
142
|
+
... environment="dev",
|
|
143
|
+
... )
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: If environment is empty or whitespace-only.
|
|
147
|
+
"""
|
|
148
|
+
if not environment or not environment.strip():
|
|
149
|
+
raise ValueError("environment must be a non-empty string")
|
|
150
|
+
|
|
151
|
+
self._event_bus = event_bus
|
|
152
|
+
self._allowed_topics = frozenset(allowed_topics)
|
|
153
|
+
self._environment = environment
|
|
154
|
+
self._logger = logging.getLogger(__name__)
|
|
155
|
+
|
|
156
|
+
def _normalize_correlation_id(
|
|
157
|
+
self,
|
|
158
|
+
correlation_id: str | UUID | None,
|
|
159
|
+
) -> bytes | None:
|
|
160
|
+
"""Normalize correlation ID to bytes for Kafka message key.
|
|
161
|
+
|
|
162
|
+
Correlation IDs in ONEX can be either UUID objects (in-memory canonical)
|
|
163
|
+
or strings (wire canonical). Both serialize deterministically to the
|
|
164
|
+
same byte representation.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
correlation_id: Correlation ID as string, UUID, or None.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
UTF-8 encoded bytes for use as Kafka message key, or None.
|
|
171
|
+
"""
|
|
172
|
+
if correlation_id is None:
|
|
173
|
+
return None
|
|
174
|
+
return str(correlation_id).encode("utf-8")
|
|
175
|
+
|
|
176
|
+
def resolve_topic(self, topic_suffix: str) -> str:
|
|
177
|
+
"""Resolve topic suffix to full topic name with environment prefix.
|
|
178
|
+
|
|
179
|
+
The full topic name follows the ONEX convention:
|
|
180
|
+
`{environment}.{topic_suffix}`
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
topic_suffix: ONEX format topic suffix (e.g., 'onex.events.v1')
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Full topic name with environment prefix (e.g., 'dev.onex.events.v1')
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
>>> publisher.resolve_topic("onex.events.v1")
|
|
190
|
+
'dev.onex.events.v1'
|
|
191
|
+
"""
|
|
192
|
+
return f"{self._environment}.{topic_suffix}"
|
|
193
|
+
|
|
194
|
+
async def publish(
|
|
195
|
+
self,
|
|
196
|
+
event_type: str,
|
|
197
|
+
payload: JsonType,
|
|
198
|
+
topic: str | None = None,
|
|
199
|
+
correlation_id: str | UUID | None = None,
|
|
200
|
+
**kwargs: object,
|
|
201
|
+
) -> bool:
|
|
202
|
+
"""Publish to allowed topic. Raises if topic not in contract.
|
|
203
|
+
|
|
204
|
+
Validates the topic against the contract's publish_topics whitelist,
|
|
205
|
+
serializes the payload to JSON, and publishes via the underlying
|
|
206
|
+
event bus.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
event_type: Type of event being published (for logging/tracing).
|
|
210
|
+
payload: Event payload (must be JSON-serializable).
|
|
211
|
+
Accepts any JsonType: str, int, float, bool, None,
|
|
212
|
+
list[JsonType], or dict[str, JsonType].
|
|
213
|
+
topic: Topic suffix to publish to (required).
|
|
214
|
+
Must be in the contract's publish_topics.
|
|
215
|
+
correlation_id: Optional correlation ID for distributed tracing.
|
|
216
|
+
If provided, used as the message key for partitioning.
|
|
217
|
+
**kwargs: Additional keyword arguments (ignored, for protocol flexibility).
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if publish succeeded.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ProtocolConfigurationError: If topic is None or not in contract's publish_topics.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
>>> await publisher.publish(
|
|
227
|
+
... event_type="user.created",
|
|
228
|
+
... payload={"user_id": "123", "name": "John"},
|
|
229
|
+
... topic="onex.events.v1",
|
|
230
|
+
... correlation_id="corr-abc-123",
|
|
231
|
+
... )
|
|
232
|
+
True
|
|
233
|
+
"""
|
|
234
|
+
if topic is None:
|
|
235
|
+
raise ProtocolConfigurationError(
|
|
236
|
+
"topic is required for PublisherTopicScoped"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if topic not in self._allowed_topics:
|
|
240
|
+
raise ProtocolConfigurationError(
|
|
241
|
+
f"Topic '{topic}' not in contract's publish_topics. "
|
|
242
|
+
f"Allowed: {sorted(self._allowed_topics)}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
full_topic = self.resolve_topic(topic)
|
|
246
|
+
|
|
247
|
+
# Serialize payload to JSON bytes
|
|
248
|
+
value = json.dumps(payload).encode("utf-8")
|
|
249
|
+
key = self._normalize_correlation_id(correlation_id)
|
|
250
|
+
|
|
251
|
+
# Publish to event bus
|
|
252
|
+
await self._event_bus.publish(
|
|
253
|
+
topic=full_topic,
|
|
254
|
+
key=key,
|
|
255
|
+
value=value,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
self._logger.debug(
|
|
259
|
+
"Published to topic=%s, event_type=%s, correlation_id=%s",
|
|
260
|
+
full_topic,
|
|
261
|
+
event_type,
|
|
262
|
+
correlation_id,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def allowed_topics(self) -> frozenset[str]:
|
|
269
|
+
"""Return immutable set of allowed topics.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Frozen set of topic suffixes allowed by this publisher.
|
|
273
|
+
|
|
274
|
+
Example:
|
|
275
|
+
>>> publisher.allowed_topics
|
|
276
|
+
frozenset({'onex.events.v1', 'onex.commands.v1'})
|
|
277
|
+
"""
|
|
278
|
+
return self._allowed_topics
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def environment(self) -> str:
|
|
282
|
+
"""Return the environment prefix.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Environment string used for topic resolution.
|
|
286
|
+
|
|
287
|
+
Example:
|
|
288
|
+
>>> publisher.environment
|
|
289
|
+
'dev'
|
|
290
|
+
"""
|
|
291
|
+
return self._environment
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
__all__: list[str] = ["PublisherTopicScoped"]
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Unified loader for runtime contract configuration.
|
|
4
|
+
|
|
5
|
+
This module provides the RuntimeContractConfigLoader class that scans
|
|
6
|
+
directories for contract.yaml files at startup and loads all subcontracts
|
|
7
|
+
(handler_routing, operation_bindings) into a consolidated configuration.
|
|
8
|
+
|
|
9
|
+
Part of OMN-1519: Runtime contract config loader.
|
|
10
|
+
|
|
11
|
+
Design Pattern:
|
|
12
|
+
RuntimeContractConfigLoader acts as an orchestrator for the individual
|
|
13
|
+
subcontract loaders (handler_routing_loader, operation_bindings_loader).
|
|
14
|
+
It scans directories, delegates loading to specialized loaders, and
|
|
15
|
+
aggregates results into a single ModelRuntimeContractConfig.
|
|
16
|
+
|
|
17
|
+
Error Handling:
|
|
18
|
+
The loader uses a graceful error handling strategy - individual contract
|
|
19
|
+
failures are logged and collected but do not stop the loading of other
|
|
20
|
+
contracts. This ensures the runtime can start even if some contracts
|
|
21
|
+
are malformed.
|
|
22
|
+
|
|
23
|
+
Thread Safety:
|
|
24
|
+
RuntimeContractConfigLoader is designed for single-threaded use during
|
|
25
|
+
startup. The resulting ModelRuntimeContractConfig is immutable and
|
|
26
|
+
thread-safe for concurrent read access.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> from pathlib import Path
|
|
30
|
+
>>> from omnibase_infra.runtime import RuntimeContractConfigLoader
|
|
31
|
+
>>>
|
|
32
|
+
>>> loader = RuntimeContractConfigLoader()
|
|
33
|
+
>>> config = loader.load_all_contracts(
|
|
34
|
+
... search_paths=[Path("src/omnibase_infra/nodes")],
|
|
35
|
+
... )
|
|
36
|
+
>>> print(f"Loaded {config.total_contracts_loaded} contracts")
|
|
37
|
+
>>> for path, routing in config.handler_routing_configs.items():
|
|
38
|
+
... print(f" {path}: {len(routing.handlers)} handlers")
|
|
39
|
+
|
|
40
|
+
.. versionadded:: 0.2.8
|
|
41
|
+
Created as part of OMN-1519.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from uuid import UUID, uuid4
|
|
49
|
+
|
|
50
|
+
from omnibase_infra.enums import EnumInfraTransportType
|
|
51
|
+
from omnibase_infra.errors import ModelInfraErrorContext, ProtocolConfigurationError
|
|
52
|
+
from omnibase_infra.models.bindings import ModelOperationBindingsSubcontract
|
|
53
|
+
from omnibase_infra.models.routing import ModelRoutingSubcontract
|
|
54
|
+
from omnibase_infra.runtime.contract_loaders.handler_routing_loader import (
|
|
55
|
+
load_handler_routing_subcontract,
|
|
56
|
+
)
|
|
57
|
+
from omnibase_infra.runtime.contract_loaders.operation_bindings_loader import (
|
|
58
|
+
load_operation_bindings_subcontract,
|
|
59
|
+
)
|
|
60
|
+
from omnibase_infra.runtime.models.model_contract_load_result import (
|
|
61
|
+
ModelContractLoadResult,
|
|
62
|
+
)
|
|
63
|
+
from omnibase_infra.runtime.models.model_runtime_contract_config import (
|
|
64
|
+
ModelRuntimeContractConfig,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
logger = logging.getLogger(__name__)
|
|
68
|
+
|
|
69
|
+
# Contract file name to search for
|
|
70
|
+
CONTRACT_YAML_FILENAME: str = "contract.yaml"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RuntimeContractConfigLoader:
|
|
74
|
+
"""Unified loader for runtime contract configuration.
|
|
75
|
+
|
|
76
|
+
Scans directories for contract.yaml files and loads all subcontracts
|
|
77
|
+
(handler_routing, operation_bindings) at startup. Individual loaders
|
|
78
|
+
are delegated to for specific subcontract types.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
>>> loader = RuntimeContractConfigLoader()
|
|
82
|
+
>>> config = loader.load_all_contracts(
|
|
83
|
+
... search_paths=[Path("src/nodes")],
|
|
84
|
+
... )
|
|
85
|
+
>>> if config.all_successful:
|
|
86
|
+
... print("All contracts loaded successfully")
|
|
87
|
+
|
|
88
|
+
Note:
|
|
89
|
+
Namespace allowlisting for handler imports is configured at the
|
|
90
|
+
HandlerPluginLoader layer, not at this config loading layer.
|
|
91
|
+
See CLAUDE.md Handler Plugin Loader security patterns.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
"""Initialize the contract config loader.
|
|
96
|
+
|
|
97
|
+
The loader is stateless and delegates to individual subcontract
|
|
98
|
+
loaders for handler_routing and operation_bindings sections.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def load_all_contracts(
|
|
102
|
+
self,
|
|
103
|
+
search_paths: list[Path],
|
|
104
|
+
correlation_id: UUID | None = None,
|
|
105
|
+
) -> ModelRuntimeContractConfig:
|
|
106
|
+
"""Scan directories and load all contract.yaml files.
|
|
107
|
+
|
|
108
|
+
Recursively scans the provided directories for contract.yaml files,
|
|
109
|
+
loading handler_routing and operation_bindings subcontracts from each.
|
|
110
|
+
Errors are collected per-contract without stopping the overall load.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
search_paths: Directories to scan for contract.yaml files.
|
|
114
|
+
correlation_id: Optional correlation ID for tracing. If not
|
|
115
|
+
provided, a new UUID is generated.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
ModelRuntimeContractConfig containing consolidated configuration
|
|
119
|
+
from all loaded contracts, including any errors encountered.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> config = loader.load_all_contracts(
|
|
123
|
+
... search_paths=[
|
|
124
|
+
... Path("src/omnibase_infra/nodes"),
|
|
125
|
+
... Path("src/myapp/nodes"),
|
|
126
|
+
... ],
|
|
127
|
+
... )
|
|
128
|
+
>>> print(f"Success rate: {config.success_rate:.1%}")
|
|
129
|
+
"""
|
|
130
|
+
correlation_id = correlation_id or uuid4()
|
|
131
|
+
|
|
132
|
+
logger.info(
|
|
133
|
+
"Starting contract config load with correlation_id=%s, search_paths=%s",
|
|
134
|
+
correlation_id,
|
|
135
|
+
[str(p) for p in search_paths],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Find all contract.yaml files
|
|
139
|
+
contract_paths = self._scan_for_contracts(search_paths)
|
|
140
|
+
total_found = len(contract_paths)
|
|
141
|
+
|
|
142
|
+
logger.info(
|
|
143
|
+
"Found %d contract.yaml files to load (correlation_id=%s)",
|
|
144
|
+
total_found,
|
|
145
|
+
correlation_id,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Load each contract
|
|
149
|
+
results: list[ModelContractLoadResult] = []
|
|
150
|
+
for contract_path in contract_paths:
|
|
151
|
+
result = self._load_single_contract(contract_path, correlation_id)
|
|
152
|
+
results.append(result)
|
|
153
|
+
|
|
154
|
+
# Calculate totals
|
|
155
|
+
total_loaded = sum(1 for r in results if r.success)
|
|
156
|
+
total_errors = sum(1 for r in results if not r.success)
|
|
157
|
+
|
|
158
|
+
# Log summary
|
|
159
|
+
if total_errors > 0:
|
|
160
|
+
logger.warning(
|
|
161
|
+
"Contract loading completed with errors: "
|
|
162
|
+
"found=%d, loaded=%d, errors=%d (correlation_id=%s)",
|
|
163
|
+
total_found,
|
|
164
|
+
total_loaded,
|
|
165
|
+
total_errors,
|
|
166
|
+
correlation_id,
|
|
167
|
+
)
|
|
168
|
+
for result in results:
|
|
169
|
+
if not result.success:
|
|
170
|
+
logger.warning(
|
|
171
|
+
" Failed: %s - %s",
|
|
172
|
+
result.contract_path,
|
|
173
|
+
result.error,
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
logger.info(
|
|
177
|
+
"Contract loading completed successfully: "
|
|
178
|
+
"found=%d, loaded=%d (correlation_id=%s)",
|
|
179
|
+
total_found,
|
|
180
|
+
total_loaded,
|
|
181
|
+
correlation_id,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return ModelRuntimeContractConfig(
|
|
185
|
+
contract_results=results,
|
|
186
|
+
total_contracts_found=total_found,
|
|
187
|
+
total_contracts_loaded=total_loaded,
|
|
188
|
+
total_errors=total_errors,
|
|
189
|
+
correlation_id=correlation_id,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def _scan_for_contracts(self, search_paths: list[Path]) -> list[Path]:
|
|
193
|
+
"""Find all contract.yaml files in search paths.
|
|
194
|
+
|
|
195
|
+
Recursively scans each search path for contract.yaml files.
|
|
196
|
+
Paths that don't exist are logged as warnings and skipped.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
search_paths: Directories to scan.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of paths to contract.yaml files, sorted by path for
|
|
203
|
+
deterministic ordering.
|
|
204
|
+
"""
|
|
205
|
+
contract_paths: list[Path] = []
|
|
206
|
+
|
|
207
|
+
for search_path in search_paths:
|
|
208
|
+
if not search_path.exists():
|
|
209
|
+
logger.warning(
|
|
210
|
+
"Search path does not exist, skipping: %s",
|
|
211
|
+
search_path,
|
|
212
|
+
)
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
if not search_path.is_dir():
|
|
216
|
+
logger.warning(
|
|
217
|
+
"Search path is not a directory, skipping: %s",
|
|
218
|
+
search_path,
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# Use glob to find all contract.yaml files recursively
|
|
223
|
+
found = list(search_path.glob(f"**/{CONTRACT_YAML_FILENAME}"))
|
|
224
|
+
logger.debug(
|
|
225
|
+
"Found %d contract.yaml files in %s",
|
|
226
|
+
len(found),
|
|
227
|
+
search_path,
|
|
228
|
+
)
|
|
229
|
+
contract_paths.extend(found)
|
|
230
|
+
|
|
231
|
+
# Sort for deterministic ordering
|
|
232
|
+
return sorted(contract_paths)
|
|
233
|
+
|
|
234
|
+
def _load_single_contract(
|
|
235
|
+
self,
|
|
236
|
+
contract_path: Path,
|
|
237
|
+
correlation_id: UUID,
|
|
238
|
+
) -> ModelContractLoadResult:
|
|
239
|
+
"""Load handler_routing and operation_bindings from a single contract.
|
|
240
|
+
|
|
241
|
+
Attempts to load both subcontracts from the contract.yaml file.
|
|
242
|
+
Either or both may be missing - only present sections are loaded.
|
|
243
|
+
Errors from either loader are caught and reported.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
contract_path: Path to the contract.yaml file.
|
|
247
|
+
correlation_id: Correlation ID for tracing.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
ModelContractLoadResult with loaded subcontracts or error.
|
|
251
|
+
|
|
252
|
+
Note:
|
|
253
|
+
Empty operation_bindings sections (present in YAML but containing
|
|
254
|
+
no bindings or global_bindings) are intentionally treated as "not
|
|
255
|
+
present" and result in operation_bindings=None. This simplifies
|
|
256
|
+
downstream consumers who only need to check for None rather than
|
|
257
|
+
also checking for empty collections. Callers cannot distinguish
|
|
258
|
+
between "section missing from YAML" and "section present but empty"
|
|
259
|
+
- both result in operation_bindings=None.
|
|
260
|
+
"""
|
|
261
|
+
logger.debug(
|
|
262
|
+
"Loading contract: %s (correlation_id=%s)",
|
|
263
|
+
contract_path,
|
|
264
|
+
correlation_id,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
handler_routing: ModelRoutingSubcontract | None = None
|
|
268
|
+
operation_bindings: ModelOperationBindingsSubcontract | None = None
|
|
269
|
+
errors: list[str] = []
|
|
270
|
+
|
|
271
|
+
# Try to load handler_routing
|
|
272
|
+
try:
|
|
273
|
+
handler_routing = load_handler_routing_subcontract(contract_path)
|
|
274
|
+
logger.debug(
|
|
275
|
+
"Loaded handler_routing from %s: %d handlers",
|
|
276
|
+
contract_path,
|
|
277
|
+
len(handler_routing.handlers),
|
|
278
|
+
)
|
|
279
|
+
except ProtocolConfigurationError as e:
|
|
280
|
+
# Check if this is "missing section" which is OK
|
|
281
|
+
if (
|
|
282
|
+
"MISSING_HANDLER_ROUTING" in str(e)
|
|
283
|
+
or "handler_routing" in str(e).lower()
|
|
284
|
+
):
|
|
285
|
+
logger.debug(
|
|
286
|
+
"No handler_routing section in %s (this is OK)",
|
|
287
|
+
contract_path,
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
error_msg = f"handler_routing load failed: {e}"
|
|
291
|
+
logger.warning(
|
|
292
|
+
"Failed to load handler_routing from %s: %s",
|
|
293
|
+
contract_path,
|
|
294
|
+
e,
|
|
295
|
+
)
|
|
296
|
+
errors.append(error_msg)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
error_msg = f"handler_routing load failed: {type(e).__name__}: {e}"
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Unexpected error loading handler_routing from %s: %s",
|
|
301
|
+
contract_path,
|
|
302
|
+
e,
|
|
303
|
+
)
|
|
304
|
+
errors.append(error_msg)
|
|
305
|
+
|
|
306
|
+
# Try to load operation_bindings
|
|
307
|
+
try:
|
|
308
|
+
operation_bindings = load_operation_bindings_subcontract(contract_path)
|
|
309
|
+
if operation_bindings.bindings or operation_bindings.global_bindings:
|
|
310
|
+
logger.debug(
|
|
311
|
+
"Loaded operation_bindings from %s: %d operations, %d global bindings",
|
|
312
|
+
contract_path,
|
|
313
|
+
len(operation_bindings.bindings),
|
|
314
|
+
len(operation_bindings.global_bindings or []),
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
# NOTE: Empty operation_bindings sections (present but no bindings) are
|
|
318
|
+
# intentionally converted to None. This simplifies downstream consumers
|
|
319
|
+
# who only care about actionable configuration. Callers cannot distinguish
|
|
320
|
+
# "missing section" from "empty section" - both result in None.
|
|
321
|
+
logger.debug(
|
|
322
|
+
"No operation_bindings content in %s (empty section)",
|
|
323
|
+
contract_path,
|
|
324
|
+
)
|
|
325
|
+
operation_bindings = None
|
|
326
|
+
except ProtocolConfigurationError as e:
|
|
327
|
+
# Check if this is "missing section" or "file not found" which is OK
|
|
328
|
+
if "CONTRACT_NOT_FOUND" in str(e) or "operation_bindings" in str(e).lower():
|
|
329
|
+
logger.debug(
|
|
330
|
+
"No operation_bindings section in %s (this is OK)",
|
|
331
|
+
contract_path,
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
error_msg = f"operation_bindings load failed: {e}"
|
|
335
|
+
logger.warning(
|
|
336
|
+
"Failed to load operation_bindings from %s: %s",
|
|
337
|
+
contract_path,
|
|
338
|
+
e,
|
|
339
|
+
)
|
|
340
|
+
errors.append(error_msg)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
error_msg = f"operation_bindings load failed: {type(e).__name__}: {e}"
|
|
343
|
+
logger.warning(
|
|
344
|
+
"Unexpected error loading operation_bindings from %s: %s",
|
|
345
|
+
contract_path,
|
|
346
|
+
e,
|
|
347
|
+
)
|
|
348
|
+
errors.append(error_msg)
|
|
349
|
+
|
|
350
|
+
# Build result
|
|
351
|
+
if errors:
|
|
352
|
+
combined_error = "; ".join(errors)
|
|
353
|
+
return ModelContractLoadResult.failed(
|
|
354
|
+
contract_path=contract_path,
|
|
355
|
+
error=combined_error,
|
|
356
|
+
correlation_id=correlation_id,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return ModelContractLoadResult.succeeded(
|
|
360
|
+
contract_path=contract_path,
|
|
361
|
+
handler_routing=handler_routing,
|
|
362
|
+
operation_bindings=operation_bindings,
|
|
363
|
+
correlation_id=correlation_id,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def load_single_contract(
|
|
367
|
+
self,
|
|
368
|
+
contract_path: Path,
|
|
369
|
+
correlation_id: UUID | None = None,
|
|
370
|
+
) -> ModelContractLoadResult:
|
|
371
|
+
"""Load a single contract.yaml file (public API).
|
|
372
|
+
|
|
373
|
+
Convenience method for loading a single contract without scanning.
|
|
374
|
+
Useful for testing or targeted loading.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
contract_path: Path to the contract.yaml file.
|
|
378
|
+
correlation_id: Optional correlation ID for tracing.
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
ModelContractLoadResult with loaded subcontracts or error.
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
ProtocolConfigurationError: If contract_path does not exist.
|
|
385
|
+
"""
|
|
386
|
+
correlation_id = correlation_id or uuid4()
|
|
387
|
+
|
|
388
|
+
if not contract_path.exists():
|
|
389
|
+
ctx = ModelInfraErrorContext.with_correlation(
|
|
390
|
+
correlation_id=correlation_id,
|
|
391
|
+
transport_type=EnumInfraTransportType.FILESYSTEM,
|
|
392
|
+
operation="load_single_contract",
|
|
393
|
+
target_name=str(contract_path),
|
|
394
|
+
)
|
|
395
|
+
raise ProtocolConfigurationError(
|
|
396
|
+
f"Contract file not found: {contract_path}",
|
|
397
|
+
context=ctx,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
return self._load_single_contract(contract_path, correlation_id)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = [
|
|
404
|
+
"CONTRACT_YAML_FILENAME",
|
|
405
|
+
"RuntimeContractConfigLoader",
|
|
406
|
+
]
|