omnibase_infra 0.2.7__py3-none-any.whl → 0.2.9__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/__init__.py +1 -1
- omnibase_infra/enums/__init__.py +4 -0
- omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
- omnibase_infra/event_bus/adapters/__init__.py +31 -0
- omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
- omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
- omnibase_infra/models/__init__.py +9 -0
- omnibase_infra/models/event_bus/__init__.py +22 -0
- omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
- omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
- omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
- omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
- omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
- omnibase_infra/models/validation/__init__.py +8 -0
- omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
- omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
- omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
- omnibase_infra/nodes/architecture_validator/constants.py +36 -0
- omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
- omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
- omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
- omnibase_infra/nodes/architecture_validator/node.py +1 -0
- omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
- omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
- omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
- omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
- omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
- omnibase_infra/nodes/node_registry_effect/node.py +20 -73
- omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
- omnibase_infra/runtime/__init__.py +11 -0
- omnibase_infra/runtime/baseline_subscriptions.py +150 -0
- omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
- omnibase_infra/runtime/kafka_contract_source.py +13 -5
- omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
- omnibase_infra/runtime/service_runtime_host_process.py +6 -11
- omnibase_infra/services/__init__.py +36 -0
- omnibase_infra/services/contract_publisher/__init__.py +95 -0
- omnibase_infra/services/contract_publisher/config.py +199 -0
- omnibase_infra/services/contract_publisher/errors.py +243 -0
- omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
- omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
- omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
- omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
- omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
- omnibase_infra/services/contract_publisher/service.py +617 -0
- omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
- omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
- omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
- omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
- omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
- omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
- omnibase_infra/services/observability/__init__.py +40 -0
- omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
- omnibase_infra/services/observability/agent_actions/config.py +209 -0
- omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
- omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
- omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
- omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
- omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
- omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
- omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
- omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
- omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
- omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
- omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
- omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
- omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
- omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
- omnibase_infra/validation/__init__.py +12 -0
- omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
- omnibase_infra/validation/validation_exemptions.yaml +93 -0
- omnibase_infra/validation/validator_declarative_node.py +850 -0
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/METADATA +3 -3
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/RECORD +79 -27
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/WHEEL +0 -0
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/entry_points.txt +0 -0
- {omnibase_infra-0.2.7.dist-info → omnibase_infra-0.2.9.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Contract Publisher Service.
|
|
4
|
+
|
|
5
|
+
Main service for discovering and publishing contracts to Kafka.
|
|
6
|
+
Follows the flow: Source → Validate → Normalize → Publish → Report.
|
|
7
|
+
|
|
8
|
+
This service standardizes the publishing *engine* while apps provide
|
|
9
|
+
*source configuration*. It enforces ARCH-002: "Runtime owns all Kafka plumbing."
|
|
10
|
+
|
|
11
|
+
Related:
|
|
12
|
+
- OMN-1752: Extract ContractPublisher to omnibase_infra
|
|
13
|
+
- ARCH-002: Runtime owns all Kafka plumbing
|
|
14
|
+
|
|
15
|
+
.. versionadded:: 0.3.0
|
|
16
|
+
Created as part of OMN-1752 (ContractPublisher extraction).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import time
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
from uuid import uuid4
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
from pydantic import ValidationError
|
|
28
|
+
|
|
29
|
+
from omnibase_core.constants import TOPIC_SUFFIX_CONTRACT_REGISTERED
|
|
30
|
+
from omnibase_core.models.contracts.model_handler_contract import ModelHandlerContract
|
|
31
|
+
from omnibase_core.models.events import ModelContractRegisteredEvent
|
|
32
|
+
from omnibase_core.protocols.event_bus import ProtocolEventBusPublisher
|
|
33
|
+
from omnibase_infra.errors import (
|
|
34
|
+
InfraConnectionError,
|
|
35
|
+
InfraTimeoutError,
|
|
36
|
+
InfraUnavailableError,
|
|
37
|
+
)
|
|
38
|
+
from omnibase_infra.services.contract_publisher.config import (
|
|
39
|
+
ModelContractPublisherConfig,
|
|
40
|
+
)
|
|
41
|
+
from omnibase_infra.services.contract_publisher.errors import (
|
|
42
|
+
ContractPublishingInfraError,
|
|
43
|
+
ContractSourceNotConfiguredError,
|
|
44
|
+
NoContractsFoundError,
|
|
45
|
+
)
|
|
46
|
+
from omnibase_infra.services.contract_publisher.models import (
|
|
47
|
+
ModelContractError,
|
|
48
|
+
ModelInfraError,
|
|
49
|
+
ModelPublishResult,
|
|
50
|
+
ModelPublishStats,
|
|
51
|
+
)
|
|
52
|
+
from omnibase_infra.services.contract_publisher.sources import (
|
|
53
|
+
ModelDiscoveredContract,
|
|
54
|
+
ProtocolContractPublisherSource,
|
|
55
|
+
SourceContractComposite,
|
|
56
|
+
SourceContractFilesystem,
|
|
57
|
+
SourceContractPackage,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from omnibase_core.container import ModelONEXContainer
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger(__name__)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ServiceContractPublisher:
|
|
67
|
+
"""Contract publishing service with injectable sources.
|
|
68
|
+
|
|
69
|
+
Discovers contracts from configured sources (filesystem, package, composite)
|
|
70
|
+
and publishes them to Kafka for dynamic discovery by KafkaContractSource.
|
|
71
|
+
|
|
72
|
+
Architecture:
|
|
73
|
+
Container → ServiceContractPublisher → ProtocolContractPublisherSource → Kafka
|
|
74
|
+
↓
|
|
75
|
+
ModelPublishResult
|
|
76
|
+
|
|
77
|
+
Flow:
|
|
78
|
+
1. Discover: Get contracts from source
|
|
79
|
+
2. Sort: Order by (handler_id, origin, ref) for determinism
|
|
80
|
+
3. Validate: Parse YAML, validate schema, extract handler_id
|
|
81
|
+
4. Normalize: Compute SHA-256 hash for change detection
|
|
82
|
+
5. Publish: Create ModelContractRegisteredEvent, publish to Kafka
|
|
83
|
+
6. Report: Return ModelPublishResult with errors and stats
|
|
84
|
+
|
|
85
|
+
Thread Safety:
|
|
86
|
+
This service is async-safe. Each call to publish_all() is independent.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
>>> config = ModelContractPublisherConfig(
|
|
90
|
+
... mode="filesystem",
|
|
91
|
+
... filesystem_root=Path("/app/contracts"),
|
|
92
|
+
... )
|
|
93
|
+
>>> publisher = await ServiceContractPublisher.from_container(container, config)
|
|
94
|
+
>>> result = await publisher.publish_all()
|
|
95
|
+
>>> if result:
|
|
96
|
+
... print(f"Published {len(result.published)} contracts")
|
|
97
|
+
|
|
98
|
+
.. versionadded:: 0.3.0
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
__slots__ = ("_config", "_environment", "_publisher", "_source")
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
publisher: ProtocolEventBusPublisher,
|
|
106
|
+
source: ProtocolContractPublisherSource,
|
|
107
|
+
config: ModelContractPublisherConfig,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Initialize service with publisher and source.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
publisher: Event bus publisher for Kafka
|
|
113
|
+
source: Contract source for discovery
|
|
114
|
+
config: Publishing configuration
|
|
115
|
+
"""
|
|
116
|
+
self._publisher = publisher
|
|
117
|
+
self._source = source
|
|
118
|
+
self._config = config
|
|
119
|
+
self._environment = config.resolve_environment()
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
async def from_container(
|
|
123
|
+
cls,
|
|
124
|
+
container: ModelONEXContainer,
|
|
125
|
+
config: ModelContractPublisherConfig,
|
|
126
|
+
) -> ServiceContractPublisher:
|
|
127
|
+
"""Factory method for DI resolution.
|
|
128
|
+
|
|
129
|
+
Resolves ProtocolEventBusPublisher from container and creates
|
|
130
|
+
appropriate source based on config.mode.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
container: ONEX container with event bus publisher
|
|
134
|
+
config: Publishing configuration
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Configured ServiceContractPublisher instance
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ContractSourceNotConfiguredError: If required source not configured
|
|
141
|
+
RuntimeError: If publisher not available in container
|
|
142
|
+
"""
|
|
143
|
+
# Resolve publisher from container
|
|
144
|
+
# NOTE: Protocol type passed for duck-typed resolution per ONEX patterns
|
|
145
|
+
publisher = await container.get_service_async(
|
|
146
|
+
ProtocolEventBusPublisher # type: ignore[type-abstract]
|
|
147
|
+
)
|
|
148
|
+
if publisher is None:
|
|
149
|
+
raise ContractSourceNotConfiguredError(
|
|
150
|
+
mode=config.mode,
|
|
151
|
+
missing_field="publisher",
|
|
152
|
+
message="ProtocolEventBusPublisher not available in container",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Create source based on mode
|
|
156
|
+
source = cls._create_source(config)
|
|
157
|
+
|
|
158
|
+
return cls(publisher, source, config)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _create_source(
|
|
162
|
+
config: ModelContractPublisherConfig,
|
|
163
|
+
) -> ProtocolContractPublisherSource:
|
|
164
|
+
"""Create source based on config mode.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
config: Publishing configuration
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Appropriate source implementation
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ContractSourceNotConfiguredError: If required source not configured
|
|
174
|
+
"""
|
|
175
|
+
match config.mode:
|
|
176
|
+
case "filesystem":
|
|
177
|
+
if not config.filesystem_root:
|
|
178
|
+
raise ContractSourceNotConfiguredError(
|
|
179
|
+
mode="filesystem",
|
|
180
|
+
missing_field="filesystem_root",
|
|
181
|
+
)
|
|
182
|
+
return SourceContractFilesystem(config.filesystem_root)
|
|
183
|
+
|
|
184
|
+
case "package":
|
|
185
|
+
if not config.package_module:
|
|
186
|
+
raise ContractSourceNotConfiguredError(
|
|
187
|
+
mode="package",
|
|
188
|
+
missing_field="package_module",
|
|
189
|
+
)
|
|
190
|
+
return SourceContractPackage(config.package_module)
|
|
191
|
+
|
|
192
|
+
case "composite":
|
|
193
|
+
filesystem_source = None
|
|
194
|
+
package_source = None
|
|
195
|
+
|
|
196
|
+
if config.filesystem_root:
|
|
197
|
+
filesystem_source = SourceContractFilesystem(config.filesystem_root)
|
|
198
|
+
if config.package_module:
|
|
199
|
+
package_source = SourceContractPackage(config.package_module)
|
|
200
|
+
|
|
201
|
+
if not filesystem_source and not package_source:
|
|
202
|
+
raise ContractSourceNotConfiguredError(
|
|
203
|
+
mode="composite",
|
|
204
|
+
missing_field="filesystem_root or package_module",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return SourceContractComposite(filesystem_source, package_source)
|
|
208
|
+
|
|
209
|
+
case _:
|
|
210
|
+
raise ContractSourceNotConfiguredError(
|
|
211
|
+
mode=config.mode,
|
|
212
|
+
missing_field="mode",
|
|
213
|
+
message=f"Unknown mode: {config.mode}",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def resolve_topic(self, topic_suffix: str) -> str:
|
|
217
|
+
"""Resolve topic suffix to full topic name with environment prefix.
|
|
218
|
+
|
|
219
|
+
Uses the same pattern as EventBusSubcontractWiring.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
topic_suffix: Topic suffix (e.g., "onex.evt.contract-registered.v1")
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Full topic name (e.g., "dev.onex.evt.contract-registered.v1")
|
|
226
|
+
"""
|
|
227
|
+
return f"{self._environment}.{topic_suffix}"
|
|
228
|
+
|
|
229
|
+
async def publish_all(self) -> ModelPublishResult:
|
|
230
|
+
"""Discover and publish all contracts from configured source.
|
|
231
|
+
|
|
232
|
+
Executes the full flow: Discover → Sort → Validate → Publish → Report
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
ModelPublishResult with published handlers and any errors
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
NoContractsFoundError: If no contracts found and allow_zero_contracts=False
|
|
239
|
+
ContractPublishingInfraError: If infrastructure error and fail_fast=True
|
|
240
|
+
"""
|
|
241
|
+
start_time = time.perf_counter()
|
|
242
|
+
correlation_id = uuid4()
|
|
243
|
+
|
|
244
|
+
# Initialize result tracking
|
|
245
|
+
published: list[str] = []
|
|
246
|
+
contract_errors: list[ModelContractError] = []
|
|
247
|
+
infra_errors: list[ModelInfraError] = []
|
|
248
|
+
filesystem_count = 0
|
|
249
|
+
package_count = 0
|
|
250
|
+
|
|
251
|
+
# Phase 1: Discover
|
|
252
|
+
discover_start = time.perf_counter()
|
|
253
|
+
contracts, source_errors, dedup_count = await self._discover()
|
|
254
|
+
discover_ms = (time.perf_counter() - discover_start) * 1000
|
|
255
|
+
|
|
256
|
+
# Add any source errors (from composite conflict detection)
|
|
257
|
+
contract_errors.extend(source_errors)
|
|
258
|
+
|
|
259
|
+
# Count per-origin
|
|
260
|
+
for contract in contracts:
|
|
261
|
+
if contract.origin == "filesystem":
|
|
262
|
+
filesystem_count += 1
|
|
263
|
+
elif contract.origin == "package":
|
|
264
|
+
package_count += 1
|
|
265
|
+
|
|
266
|
+
discovered_count = len(contracts)
|
|
267
|
+
|
|
268
|
+
logger.info(
|
|
269
|
+
"Contract discovery complete: %d contracts from %s",
|
|
270
|
+
discovered_count,
|
|
271
|
+
self._source.source_description,
|
|
272
|
+
extra={"correlation_id": str(correlation_id)},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Phase 2: Validate
|
|
276
|
+
validate_start = time.perf_counter()
|
|
277
|
+
valid_contracts: list[tuple[ModelDiscoveredContract, ModelHandlerContract]] = []
|
|
278
|
+
|
|
279
|
+
for contract in contracts:
|
|
280
|
+
parsed, error = self._validate_contract(contract)
|
|
281
|
+
if error:
|
|
282
|
+
contract_errors.append(error)
|
|
283
|
+
elif parsed:
|
|
284
|
+
valid_contracts.append((contract, parsed))
|
|
285
|
+
|
|
286
|
+
validate_ms = (time.perf_counter() - validate_start) * 1000
|
|
287
|
+
valid_count = len(valid_contracts)
|
|
288
|
+
|
|
289
|
+
logger.info(
|
|
290
|
+
"Contract validation complete: %d valid, %d errors",
|
|
291
|
+
valid_count,
|
|
292
|
+
len(contract_errors),
|
|
293
|
+
extra={"correlation_id": str(correlation_id)},
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Check for zero contracts
|
|
297
|
+
if valid_count == 0 and not self._config.allow_zero_contracts:
|
|
298
|
+
raise NoContractsFoundError(
|
|
299
|
+
source_description=self._source.source_description,
|
|
300
|
+
discovered_count=discovered_count,
|
|
301
|
+
valid_count=0,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Phase 3: Publish
|
|
305
|
+
publish_start = time.perf_counter()
|
|
306
|
+
topic = self.resolve_topic(TOPIC_SUFFIX_CONTRACT_REGISTERED)
|
|
307
|
+
|
|
308
|
+
for contract, parsed in valid_contracts:
|
|
309
|
+
handler_id = parsed.handler_id
|
|
310
|
+
|
|
311
|
+
# Create event
|
|
312
|
+
try:
|
|
313
|
+
event = ModelContractRegisteredEvent(
|
|
314
|
+
node_name=handler_id,
|
|
315
|
+
node_version=parsed.contract_version,
|
|
316
|
+
contract_hash=contract.content_hash or "",
|
|
317
|
+
contract_yaml=contract.text,
|
|
318
|
+
correlation_id=correlation_id,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Serialize to bytes
|
|
322
|
+
event_bytes = event.model_dump_json().encode("utf-8")
|
|
323
|
+
key_bytes = handler_id.encode("utf-8")
|
|
324
|
+
|
|
325
|
+
# Publish
|
|
326
|
+
await self._publisher.publish(
|
|
327
|
+
topic=topic,
|
|
328
|
+
key=key_bytes,
|
|
329
|
+
value=event_bytes,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
published.append(handler_id)
|
|
333
|
+
|
|
334
|
+
logger.debug(
|
|
335
|
+
"Published contract: %s to %s",
|
|
336
|
+
handler_id,
|
|
337
|
+
topic,
|
|
338
|
+
extra={"correlation_id": str(correlation_id)},
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
except ValidationError as e:
|
|
342
|
+
# Pydantic model validation error during event creation
|
|
343
|
+
infra_error = ModelInfraError(
|
|
344
|
+
error_type="serialization_failed",
|
|
345
|
+
message=f"Contract event validation failed for {handler_id}: {e}",
|
|
346
|
+
retriable=False, # Validation errors won't fix on retry
|
|
347
|
+
)
|
|
348
|
+
infra_errors.append(infra_error)
|
|
349
|
+
|
|
350
|
+
logger.exception(
|
|
351
|
+
"Contract event validation failed for %s",
|
|
352
|
+
handler_id,
|
|
353
|
+
extra={"correlation_id": str(correlation_id)},
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
if self._config.fail_fast:
|
|
357
|
+
raise ContractPublishingInfraError(infra_errors) from e
|
|
358
|
+
|
|
359
|
+
except (TypeError, ValueError, UnicodeEncodeError) as e:
|
|
360
|
+
# Serialization or encoding error
|
|
361
|
+
infra_error = ModelInfraError(
|
|
362
|
+
error_type="serialization_failed",
|
|
363
|
+
message=f"Failed to serialize contract for {handler_id}: {e}",
|
|
364
|
+
retriable=False, # Serialization errors won't fix on retry
|
|
365
|
+
)
|
|
366
|
+
infra_errors.append(infra_error)
|
|
367
|
+
|
|
368
|
+
logger.exception(
|
|
369
|
+
"Contract serialization failed for %s: %s",
|
|
370
|
+
handler_id,
|
|
371
|
+
type(e).__name__,
|
|
372
|
+
extra={"correlation_id": str(correlation_id)},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if self._config.fail_fast:
|
|
376
|
+
raise ContractPublishingInfraError(infra_errors) from e
|
|
377
|
+
|
|
378
|
+
except InfraTimeoutError as e:
|
|
379
|
+
# Kafka timeout during publish
|
|
380
|
+
infra_error = ModelInfraError(
|
|
381
|
+
error_type="kafka_timeout",
|
|
382
|
+
message=f"Timeout publishing contract {handler_id}: {e}",
|
|
383
|
+
retriable=True,
|
|
384
|
+
)
|
|
385
|
+
infra_errors.append(infra_error)
|
|
386
|
+
|
|
387
|
+
logger.warning(
|
|
388
|
+
"Timeout publishing contract %s",
|
|
389
|
+
handler_id,
|
|
390
|
+
extra={"correlation_id": str(correlation_id)},
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
if self._config.fail_fast:
|
|
394
|
+
raise ContractPublishingInfraError(infra_errors) from e
|
|
395
|
+
|
|
396
|
+
except InfraUnavailableError as e:
|
|
397
|
+
# Publisher not available
|
|
398
|
+
infra_error = ModelInfraError(
|
|
399
|
+
error_type="publisher_unavailable",
|
|
400
|
+
message=f"Publisher unavailable for {handler_id}: {e}",
|
|
401
|
+
retriable=True,
|
|
402
|
+
)
|
|
403
|
+
infra_errors.append(infra_error)
|
|
404
|
+
|
|
405
|
+
logger.warning(
|
|
406
|
+
"Publisher unavailable for contract %s",
|
|
407
|
+
handler_id,
|
|
408
|
+
extra={"correlation_id": str(correlation_id)},
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if self._config.fail_fast:
|
|
412
|
+
raise ContractPublishingInfraError(infra_errors) from e
|
|
413
|
+
|
|
414
|
+
except InfraConnectionError as e:
|
|
415
|
+
# Kafka connection or broker error
|
|
416
|
+
infra_error = ModelInfraError(
|
|
417
|
+
error_type="broker_down",
|
|
418
|
+
message=f"Kafka connection failed for {handler_id}: {e}",
|
|
419
|
+
retriable=True,
|
|
420
|
+
)
|
|
421
|
+
infra_errors.append(infra_error)
|
|
422
|
+
|
|
423
|
+
logger.warning(
|
|
424
|
+
"Kafka connection failed for contract %s",
|
|
425
|
+
handler_id,
|
|
426
|
+
extra={"correlation_id": str(correlation_id)},
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if self._config.fail_fast:
|
|
430
|
+
raise ContractPublishingInfraError(infra_errors) from e
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
# Unexpected error - log as warning since this catch-all is unexpected
|
|
434
|
+
infra_error = ModelInfraError(
|
|
435
|
+
error_type="publish_failed",
|
|
436
|
+
message=f"Unexpected error publishing {handler_id}: {type(e).__name__}: {e}",
|
|
437
|
+
retriable=True,
|
|
438
|
+
)
|
|
439
|
+
infra_errors.append(infra_error)
|
|
440
|
+
|
|
441
|
+
logger.warning(
|
|
442
|
+
"Unexpected error type during contract publish for %s: %s",
|
|
443
|
+
handler_id,
|
|
444
|
+
type(e).__name__,
|
|
445
|
+
extra={"correlation_id": str(correlation_id)},
|
|
446
|
+
)
|
|
447
|
+
logger.exception(
|
|
448
|
+
"Full exception details for contract %s",
|
|
449
|
+
handler_id,
|
|
450
|
+
extra={"correlation_id": str(correlation_id)},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if self._config.fail_fast:
|
|
454
|
+
raise ContractPublishingInfraError(infra_errors) from e
|
|
455
|
+
|
|
456
|
+
publish_ms = (time.perf_counter() - publish_start) * 1000
|
|
457
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
458
|
+
|
|
459
|
+
# Build stats
|
|
460
|
+
stats = ModelPublishStats(
|
|
461
|
+
discovered_count=discovered_count,
|
|
462
|
+
valid_count=valid_count,
|
|
463
|
+
published_count=len(published),
|
|
464
|
+
errored_count=len(contract_errors),
|
|
465
|
+
dedup_count=dedup_count,
|
|
466
|
+
duration_ms=duration_ms,
|
|
467
|
+
discover_ms=discover_ms,
|
|
468
|
+
validate_ms=validate_ms,
|
|
469
|
+
publish_ms=publish_ms,
|
|
470
|
+
environment=self._environment,
|
|
471
|
+
filesystem_count=filesystem_count,
|
|
472
|
+
package_count=package_count,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
# Build result
|
|
476
|
+
result = ModelPublishResult(
|
|
477
|
+
published=published,
|
|
478
|
+
contract_errors=contract_errors,
|
|
479
|
+
infra_errors=infra_errors,
|
|
480
|
+
stats=stats,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
logger.info(
|
|
484
|
+
"Contract publishing complete: %d published, %d contract errors, %d infra errors",
|
|
485
|
+
len(published),
|
|
486
|
+
len(contract_errors),
|
|
487
|
+
len(infra_errors),
|
|
488
|
+
extra={
|
|
489
|
+
"correlation_id": str(correlation_id),
|
|
490
|
+
"duration_ms": duration_ms,
|
|
491
|
+
},
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
return result
|
|
495
|
+
|
|
496
|
+
async def _discover(
|
|
497
|
+
self,
|
|
498
|
+
) -> tuple[list[ModelDiscoveredContract], list[ModelContractError], int]:
|
|
499
|
+
"""Discover contracts from source.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
Tuple of (contracts, errors from composite merge, dedup_count)
|
|
503
|
+
|
|
504
|
+
Note:
|
|
505
|
+
**Deduplication Tracking**
|
|
506
|
+
|
|
507
|
+
The ``dedup_count`` return value is only meaningful for composite sources
|
|
508
|
+
(``SourceContractComposite``). For single sources (filesystem-only or
|
|
509
|
+
package-only), ``dedup_count`` is always 0.
|
|
510
|
+
|
|
511
|
+
This is correct behavior, not a bug:
|
|
512
|
+
|
|
513
|
+
- **Single sources cannot have duplicates**: A filesystem source reads
|
|
514
|
+
from one directory tree; a package source reads from one module.
|
|
515
|
+
Duplicate handler_ids within a single source indicate a configuration
|
|
516
|
+
error, not a merge scenario.
|
|
517
|
+
|
|
518
|
+
- **Deduplication is a merge operation**: When ``SourceContractComposite``
|
|
519
|
+
merges filesystem and package sources, it may encounter the same
|
|
520
|
+
``handler_id`` from both. The composite source tracks how many such
|
|
521
|
+
duplicates were resolved (filesystem wins by default).
|
|
522
|
+
|
|
523
|
+
- **Semantic clarity**: Reporting dedup_count=0 for single sources
|
|
524
|
+
accurately reflects that no deduplication occurred, rather than
|
|
525
|
+
hiding the metric entirely.
|
|
526
|
+
"""
|
|
527
|
+
# All sources now return list[ModelDiscoveredContract]
|
|
528
|
+
contracts = await self._source.discover_contracts()
|
|
529
|
+
|
|
530
|
+
# Extract handler_id BEFORE sorting for ALL contracts
|
|
531
|
+
# (composite source does this internally, but filesystem/package do not)
|
|
532
|
+
# This ensures consistent sorting by (handler_id, origin, ref) regardless of source
|
|
533
|
+
contracts = [c.extract_handler_id() for c in contracts]
|
|
534
|
+
|
|
535
|
+
# Add content hash only to contracts that don't already have one
|
|
536
|
+
# (composite source already computes hashes, avoid double-hashing)
|
|
537
|
+
contracts = [c if c.content_hash else c.with_content_hash() for c in contracts]
|
|
538
|
+
|
|
539
|
+
# Sort for determinism - now sorts by (handler_id, origin, ref) correctly
|
|
540
|
+
contracts.sort(key=lambda c: c.sort_key())
|
|
541
|
+
|
|
542
|
+
# Get merge errors and dedup count from composite source (duck typing)
|
|
543
|
+
errors: list[ModelContractError] = []
|
|
544
|
+
dedup_count = 0
|
|
545
|
+
|
|
546
|
+
get_merge_errors = getattr(self._source, "get_merge_errors", None)
|
|
547
|
+
if callable(get_merge_errors):
|
|
548
|
+
errors = get_merge_errors()
|
|
549
|
+
|
|
550
|
+
get_dedup_count = getattr(self._source, "get_dedup_count", None)
|
|
551
|
+
if callable(get_dedup_count):
|
|
552
|
+
dedup_count = get_dedup_count()
|
|
553
|
+
|
|
554
|
+
return contracts, errors, dedup_count
|
|
555
|
+
|
|
556
|
+
def _validate_contract(
|
|
557
|
+
self,
|
|
558
|
+
contract: ModelDiscoveredContract,
|
|
559
|
+
) -> tuple[ModelHandlerContract | None, ModelContractError | None]:
|
|
560
|
+
"""Validate contract YAML and extract handler_id.
|
|
561
|
+
|
|
562
|
+
Parses YAML exactly once and returns both the parsed contract (on success)
|
|
563
|
+
or the error details (on failure). This avoids double-parsing when
|
|
564
|
+
validation fails.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
contract: Discovered contract to validate
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
Tuple of (parsed_contract, error). Exactly one will be non-None:
|
|
571
|
+
- (ModelHandlerContract, None) on success
|
|
572
|
+
- (None, ModelContractError) on failure
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
# Parse YAML (only once)
|
|
576
|
+
data = yaml.safe_load(contract.text)
|
|
577
|
+
if not isinstance(data, dict):
|
|
578
|
+
return None, ModelContractError(
|
|
579
|
+
contract_path=str(contract.ref),
|
|
580
|
+
handler_id=None,
|
|
581
|
+
error_type="yaml_parse",
|
|
582
|
+
message="Contract YAML must be a dictionary",
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
# Validate against ModelHandlerContract
|
|
586
|
+
try:
|
|
587
|
+
parsed = ModelHandlerContract.model_validate(data)
|
|
588
|
+
return parsed, None
|
|
589
|
+
except ValidationError as e:
|
|
590
|
+
# Extract first error for detailed message
|
|
591
|
+
first_error = e.errors()[0] if e.errors() else None
|
|
592
|
+
if first_error:
|
|
593
|
+
field = ".".join(str(loc) for loc in first_error.get("loc", []))
|
|
594
|
+
msg = first_error.get("msg", "Validation failed")
|
|
595
|
+
return None, ModelContractError(
|
|
596
|
+
contract_path=str(contract.ref),
|
|
597
|
+
handler_id=data.get("handler_id"),
|
|
598
|
+
error_type="schema_validation",
|
|
599
|
+
message=f"Validation error in '{field}': {msg}",
|
|
600
|
+
)
|
|
601
|
+
return None, ModelContractError(
|
|
602
|
+
contract_path=str(contract.ref),
|
|
603
|
+
handler_id=data.get("handler_id"),
|
|
604
|
+
error_type="schema_validation",
|
|
605
|
+
message="Contract validation failed",
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
except yaml.YAMLError as e:
|
|
609
|
+
return None, ModelContractError(
|
|
610
|
+
contract_path=str(contract.ref),
|
|
611
|
+
handler_id=None,
|
|
612
|
+
error_type="yaml_parse",
|
|
613
|
+
message=f"Invalid YAML: {e}",
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
__all__ = ["ServiceContractPublisher"]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2025 OmniNode Team
|
|
3
|
+
"""Contract Publisher Sources Module.
|
|
4
|
+
|
|
5
|
+
Provides contract source implementations for discovering contracts from
|
|
6
|
+
different backends:
|
|
7
|
+
|
|
8
|
+
- SourceContractFilesystem: Discovers from directory tree
|
|
9
|
+
- SourceContractPackage: Discovers from package resources
|
|
10
|
+
- SourceContractComposite: Merges multiple sources
|
|
11
|
+
|
|
12
|
+
All sources implement ProtocolContractPublisherSource.
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
>>> from omnibase_infra.services.contract_publisher.sources import (
|
|
16
|
+
... SourceContractFilesystem,
|
|
17
|
+
... SourceContractPackage,
|
|
18
|
+
... SourceContractComposite,
|
|
19
|
+
... )
|
|
20
|
+
>>> filesystem = SourceContractFilesystem(Path("/app/contracts"))
|
|
21
|
+
>>> contracts = await filesystem.discover_contracts()
|
|
22
|
+
|
|
23
|
+
.. versionadded:: 0.3.0
|
|
24
|
+
Created as part of OMN-1752 (ContractPublisher extraction).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from omnibase_infra.services.contract_publisher.sources.model_discovered import (
|
|
28
|
+
ModelDiscoveredContract,
|
|
29
|
+
)
|
|
30
|
+
from omnibase_infra.services.contract_publisher.sources.protocol import (
|
|
31
|
+
ProtocolContractPublisherSource,
|
|
32
|
+
)
|
|
33
|
+
from omnibase_infra.services.contract_publisher.sources.source_composite import (
|
|
34
|
+
SourceContractComposite,
|
|
35
|
+
)
|
|
36
|
+
from omnibase_infra.services.contract_publisher.sources.source_filesystem import (
|
|
37
|
+
SourceContractFilesystem,
|
|
38
|
+
)
|
|
39
|
+
from omnibase_infra.services.contract_publisher.sources.source_package import (
|
|
40
|
+
SourceContractPackage,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# Protocol
|
|
45
|
+
"ProtocolContractPublisherSource",
|
|
46
|
+
# Model
|
|
47
|
+
"ModelDiscoveredContract",
|
|
48
|
+
# Implementations
|
|
49
|
+
"SourceContractFilesystem",
|
|
50
|
+
"SourceContractPackage",
|
|
51
|
+
"SourceContractComposite",
|
|
52
|
+
]
|