mcp-hangar 0.2.0__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.
- mcp_hangar/__init__.py +139 -0
- mcp_hangar/application/__init__.py +1 -0
- mcp_hangar/application/commands/__init__.py +67 -0
- mcp_hangar/application/commands/auth_commands.py +118 -0
- mcp_hangar/application/commands/auth_handlers.py +296 -0
- mcp_hangar/application/commands/commands.py +59 -0
- mcp_hangar/application/commands/handlers.py +189 -0
- mcp_hangar/application/discovery/__init__.py +21 -0
- mcp_hangar/application/discovery/discovery_metrics.py +283 -0
- mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
- mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
- mcp_hangar/application/discovery/security_validator.py +414 -0
- mcp_hangar/application/event_handlers/__init__.py +50 -0
- mcp_hangar/application/event_handlers/alert_handler.py +191 -0
- mcp_hangar/application/event_handlers/audit_handler.py +203 -0
- mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
- mcp_hangar/application/event_handlers/logging_handler.py +69 -0
- mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
- mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
- mcp_hangar/application/event_handlers/security_handler.py +604 -0
- mcp_hangar/application/mcp/tooling.py +158 -0
- mcp_hangar/application/ports/__init__.py +9 -0
- mcp_hangar/application/ports/observability.py +237 -0
- mcp_hangar/application/queries/__init__.py +52 -0
- mcp_hangar/application/queries/auth_handlers.py +237 -0
- mcp_hangar/application/queries/auth_queries.py +118 -0
- mcp_hangar/application/queries/handlers.py +227 -0
- mcp_hangar/application/read_models/__init__.py +11 -0
- mcp_hangar/application/read_models/provider_views.py +139 -0
- mcp_hangar/application/sagas/__init__.py +11 -0
- mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
- mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
- mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
- mcp_hangar/application/services/__init__.py +9 -0
- mcp_hangar/application/services/provider_service.py +208 -0
- mcp_hangar/application/services/traced_provider_service.py +211 -0
- mcp_hangar/bootstrap/runtime.py +328 -0
- mcp_hangar/context.py +178 -0
- mcp_hangar/domain/__init__.py +117 -0
- mcp_hangar/domain/contracts/__init__.py +57 -0
- mcp_hangar/domain/contracts/authentication.py +225 -0
- mcp_hangar/domain/contracts/authorization.py +229 -0
- mcp_hangar/domain/contracts/event_store.py +178 -0
- mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
- mcp_hangar/domain/contracts/persistence.py +383 -0
- mcp_hangar/domain/contracts/provider_runtime.py +146 -0
- mcp_hangar/domain/discovery/__init__.py +20 -0
- mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
- mcp_hangar/domain/discovery/discovered_provider.py +185 -0
- mcp_hangar/domain/discovery/discovery_service.py +412 -0
- mcp_hangar/domain/discovery/discovery_source.py +192 -0
- mcp_hangar/domain/events.py +433 -0
- mcp_hangar/domain/exceptions.py +525 -0
- mcp_hangar/domain/model/__init__.py +70 -0
- mcp_hangar/domain/model/aggregate.py +58 -0
- mcp_hangar/domain/model/circuit_breaker.py +152 -0
- mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
- mcp_hangar/domain/model/event_sourced_provider.py +423 -0
- mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
- mcp_hangar/domain/model/health_tracker.py +183 -0
- mcp_hangar/domain/model/load_balancer.py +185 -0
- mcp_hangar/domain/model/provider.py +810 -0
- mcp_hangar/domain/model/provider_group.py +656 -0
- mcp_hangar/domain/model/tool_catalog.py +105 -0
- mcp_hangar/domain/policies/__init__.py +19 -0
- mcp_hangar/domain/policies/provider_health.py +187 -0
- mcp_hangar/domain/repository.py +249 -0
- mcp_hangar/domain/security/__init__.py +85 -0
- mcp_hangar/domain/security/input_validator.py +710 -0
- mcp_hangar/domain/security/rate_limiter.py +387 -0
- mcp_hangar/domain/security/roles.py +237 -0
- mcp_hangar/domain/security/sanitizer.py +387 -0
- mcp_hangar/domain/security/secrets.py +501 -0
- mcp_hangar/domain/services/__init__.py +20 -0
- mcp_hangar/domain/services/audit_service.py +376 -0
- mcp_hangar/domain/services/image_builder.py +328 -0
- mcp_hangar/domain/services/provider_launcher.py +1046 -0
- mcp_hangar/domain/value_objects.py +1138 -0
- mcp_hangar/errors.py +818 -0
- mcp_hangar/fastmcp_server.py +1105 -0
- mcp_hangar/gc.py +134 -0
- mcp_hangar/infrastructure/__init__.py +79 -0
- mcp_hangar/infrastructure/async_executor.py +133 -0
- mcp_hangar/infrastructure/auth/__init__.py +37 -0
- mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
- mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
- mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
- mcp_hangar/infrastructure/auth/middleware.py +340 -0
- mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
- mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
- mcp_hangar/infrastructure/auth/projections.py +366 -0
- mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
- mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
- mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
- mcp_hangar/infrastructure/command_bus.py +112 -0
- mcp_hangar/infrastructure/discovery/__init__.py +110 -0
- mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
- mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
- mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
- mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
- mcp_hangar/infrastructure/event_bus.py +260 -0
- mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
- mcp_hangar/infrastructure/event_store.py +396 -0
- mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
- mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
- mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
- mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
- mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
- mcp_hangar/infrastructure/metrics_publisher.py +36 -0
- mcp_hangar/infrastructure/observability/__init__.py +10 -0
- mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
- mcp_hangar/infrastructure/persistence/__init__.py +33 -0
- mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
- mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
- mcp_hangar/infrastructure/persistence/database.py +333 -0
- mcp_hangar/infrastructure/persistence/database_common.py +330 -0
- mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
- mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
- mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
- mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
- mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
- mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
- mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
- mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
- mcp_hangar/infrastructure/query_bus.py +153 -0
- mcp_hangar/infrastructure/saga_manager.py +401 -0
- mcp_hangar/logging_config.py +209 -0
- mcp_hangar/metrics.py +1007 -0
- mcp_hangar/models.py +31 -0
- mcp_hangar/observability/__init__.py +54 -0
- mcp_hangar/observability/health.py +487 -0
- mcp_hangar/observability/metrics.py +319 -0
- mcp_hangar/observability/tracing.py +433 -0
- mcp_hangar/progress.py +542 -0
- mcp_hangar/retry.py +613 -0
- mcp_hangar/server/__init__.py +120 -0
- mcp_hangar/server/__main__.py +6 -0
- mcp_hangar/server/auth_bootstrap.py +340 -0
- mcp_hangar/server/auth_cli.py +335 -0
- mcp_hangar/server/auth_config.py +305 -0
- mcp_hangar/server/bootstrap.py +735 -0
- mcp_hangar/server/cli.py +161 -0
- mcp_hangar/server/config.py +224 -0
- mcp_hangar/server/context.py +215 -0
- mcp_hangar/server/http_auth_middleware.py +165 -0
- mcp_hangar/server/lifecycle.py +467 -0
- mcp_hangar/server/state.py +117 -0
- mcp_hangar/server/tools/__init__.py +16 -0
- mcp_hangar/server/tools/discovery.py +186 -0
- mcp_hangar/server/tools/groups.py +75 -0
- mcp_hangar/server/tools/health.py +301 -0
- mcp_hangar/server/tools/provider.py +939 -0
- mcp_hangar/server/tools/registry.py +320 -0
- mcp_hangar/server/validation.py +113 -0
- mcp_hangar/stdio_client.py +229 -0
- mcp_hangar-0.2.0.dist-info/METADATA +347 -0
- mcp_hangar-0.2.0.dist-info/RECORD +160 -0
- mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
- mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_hangar-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Discovery Domain Service.
|
|
2
|
+
|
|
3
|
+
Coordinates provider discovery across multiple sources
|
|
4
|
+
and applies business rules for registration and lifecycle management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Dict, List, Optional, Set
|
|
10
|
+
|
|
11
|
+
from ...logging_config import get_logger
|
|
12
|
+
from .conflict_resolver import ConflictResolution, ConflictResolver
|
|
13
|
+
from .discovered_provider import DiscoveredProvider
|
|
14
|
+
from .discovery_source import DiscoveryMode, DiscoverySource
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DiscoveryCycleResult:
|
|
21
|
+
"""Result of a discovery cycle.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
discovered_count: Number of providers discovered
|
|
25
|
+
registered_count: Number of new providers registered
|
|
26
|
+
updated_count: Number of providers updated
|
|
27
|
+
deregistered_count: Number of providers deregistered
|
|
28
|
+
quarantined_count: Number of providers quarantined
|
|
29
|
+
error_count: Number of errors during discovery
|
|
30
|
+
duration_ms: Duration of the cycle in milliseconds
|
|
31
|
+
source_results: Results per source
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
discovered_count: int = 0
|
|
35
|
+
registered_count: int = 0
|
|
36
|
+
updated_count: int = 0
|
|
37
|
+
deregistered_count: int = 0
|
|
38
|
+
quarantined_count: int = 0
|
|
39
|
+
error_count: int = 0
|
|
40
|
+
duration_ms: float = 0.0
|
|
41
|
+
source_results: Dict[str, int] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> Dict:
|
|
44
|
+
"""Convert to dictionary for serialization."""
|
|
45
|
+
return {
|
|
46
|
+
"discovered_count": self.discovered_count,
|
|
47
|
+
"registered_count": self.registered_count,
|
|
48
|
+
"updated_count": self.updated_count,
|
|
49
|
+
"deregistered_count": self.deregistered_count,
|
|
50
|
+
"quarantined_count": self.quarantined_count,
|
|
51
|
+
"error_count": self.error_count,
|
|
52
|
+
"duration_ms": self.duration_ms,
|
|
53
|
+
"source_results": self.source_results,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SourceStatus:
|
|
59
|
+
"""Status of a discovery source.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
source_type: Type of the source
|
|
63
|
+
mode: Discovery mode (additive/authoritative)
|
|
64
|
+
is_healthy: Whether the source is healthy
|
|
65
|
+
is_enabled: Whether the source is enabled
|
|
66
|
+
last_discovery: Timestamp of last discovery
|
|
67
|
+
providers_count: Number of providers from this source
|
|
68
|
+
error_message: Last error message (if any)
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
source_type: str
|
|
72
|
+
mode: DiscoveryMode
|
|
73
|
+
is_healthy: bool
|
|
74
|
+
is_enabled: bool
|
|
75
|
+
last_discovery: Optional[datetime] = None
|
|
76
|
+
providers_count: int = 0
|
|
77
|
+
error_message: Optional[str] = None
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> Dict:
|
|
80
|
+
"""Convert to dictionary for serialization."""
|
|
81
|
+
return {
|
|
82
|
+
"source_type": self.source_type,
|
|
83
|
+
"mode": self.mode.value,
|
|
84
|
+
"is_healthy": self.is_healthy,
|
|
85
|
+
"is_enabled": self.is_enabled,
|
|
86
|
+
"last_discovery": (self.last_discovery.isoformat() if self.last_discovery else None),
|
|
87
|
+
"providers_count": self.providers_count,
|
|
88
|
+
"error_message": self.error_message,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DiscoveryService:
|
|
93
|
+
"""Domain service for provider discovery.
|
|
94
|
+
|
|
95
|
+
This service coordinates multiple discovery sources and applies
|
|
96
|
+
business rules for provider registration, conflict resolution,
|
|
97
|
+
and lifecycle management.
|
|
98
|
+
|
|
99
|
+
Responsibilities:
|
|
100
|
+
- Register and manage discovery sources
|
|
101
|
+
- Run discovery cycles across all sources
|
|
102
|
+
- Resolve conflicts using ConflictResolver
|
|
103
|
+
- Track source health and status
|
|
104
|
+
- Manage pending and quarantined providers
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
conflict_resolver: Optional[ConflictResolver] = None,
|
|
110
|
+
auto_register: bool = True,
|
|
111
|
+
):
|
|
112
|
+
"""Initialize discovery service.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
conflict_resolver: Resolver for handling conflicts
|
|
116
|
+
auto_register: Whether to auto-register discovered providers
|
|
117
|
+
"""
|
|
118
|
+
self._sources: Dict[str, DiscoverySource] = {}
|
|
119
|
+
self._conflict_resolver = conflict_resolver or ConflictResolver()
|
|
120
|
+
self._auto_register = auto_register
|
|
121
|
+
|
|
122
|
+
# Track providers by source
|
|
123
|
+
self._providers_by_source: Dict[str, Set[str]] = {}
|
|
124
|
+
|
|
125
|
+
# Pending providers (discovered but not registered)
|
|
126
|
+
self._pending: Dict[str, DiscoveredProvider] = {}
|
|
127
|
+
|
|
128
|
+
# Quarantined providers (failed validation)
|
|
129
|
+
self._quarantine: Dict[str, tuple[DiscoveredProvider, str]] = {}
|
|
130
|
+
|
|
131
|
+
# Source status tracking
|
|
132
|
+
self._source_status: Dict[str, SourceStatus] = {}
|
|
133
|
+
|
|
134
|
+
def register_source(self, source: DiscoverySource) -> None:
|
|
135
|
+
"""Register a discovery source.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
source: Discovery source to register
|
|
139
|
+
"""
|
|
140
|
+
source_type = source.source_type
|
|
141
|
+
if source_type in self._sources:
|
|
142
|
+
logger.warning(f"Replacing existing source: {source_type}")
|
|
143
|
+
|
|
144
|
+
self._sources[source_type] = source
|
|
145
|
+
self._providers_by_source[source_type] = set()
|
|
146
|
+
self._source_status[source_type] = SourceStatus(
|
|
147
|
+
source_type=source_type,
|
|
148
|
+
mode=source.mode,
|
|
149
|
+
is_healthy=False,
|
|
150
|
+
is_enabled=source.is_enabled,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
logger.info(f"Registered discovery source: {source_type} (mode={source.mode})")
|
|
154
|
+
|
|
155
|
+
def unregister_source(self, source_type: str) -> Optional[DiscoverySource]:
|
|
156
|
+
"""Unregister a discovery source.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
source_type: Type of source to unregister
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The unregistered source, or None if not found
|
|
163
|
+
"""
|
|
164
|
+
source = self._sources.pop(source_type, None)
|
|
165
|
+
if source:
|
|
166
|
+
self._providers_by_source.pop(source_type, None)
|
|
167
|
+
self._source_status.pop(source_type, None)
|
|
168
|
+
logger.info(f"Unregistered discovery source: {source_type}")
|
|
169
|
+
return source
|
|
170
|
+
|
|
171
|
+
def get_source(self, source_type: str) -> Optional[DiscoverySource]:
|
|
172
|
+
"""Get a registered source by type.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
source_type: Type of source
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
The source, or None if not found
|
|
179
|
+
"""
|
|
180
|
+
return self._sources.get(source_type)
|
|
181
|
+
|
|
182
|
+
def get_all_sources(self) -> List[DiscoverySource]:
|
|
183
|
+
"""Get all registered sources.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
List of registered sources
|
|
187
|
+
"""
|
|
188
|
+
return list(self._sources.values())
|
|
189
|
+
|
|
190
|
+
async def run_discovery_cycle(self) -> DiscoveryCycleResult:
|
|
191
|
+
"""Run a discovery cycle across all sources.
|
|
192
|
+
|
|
193
|
+
This method:
|
|
194
|
+
1. Runs discovery on all enabled sources
|
|
195
|
+
2. Resolves conflicts using ConflictResolver
|
|
196
|
+
3. Handles provider registration/deregistration
|
|
197
|
+
4. Updates source status and metrics
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
DiscoveryCycleResult with cycle statistics
|
|
201
|
+
"""
|
|
202
|
+
import time
|
|
203
|
+
|
|
204
|
+
start_time = time.perf_counter()
|
|
205
|
+
|
|
206
|
+
result = DiscoveryCycleResult()
|
|
207
|
+
all_discovered: Dict[str, DiscoveredProvider] = {}
|
|
208
|
+
|
|
209
|
+
# Run discovery on all enabled sources
|
|
210
|
+
for source_type, source in self._sources.items():
|
|
211
|
+
if not source.is_enabled:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
providers = await source.discover()
|
|
216
|
+
result.source_results[source_type] = len(providers)
|
|
217
|
+
|
|
218
|
+
# Update source status
|
|
219
|
+
self._source_status[source_type].is_healthy = True
|
|
220
|
+
self._source_status[source_type].last_discovery = datetime.now(timezone.utc)
|
|
221
|
+
self._source_status[source_type].providers_count = len(providers)
|
|
222
|
+
self._source_status[source_type].error_message = None
|
|
223
|
+
|
|
224
|
+
# Track providers from this source
|
|
225
|
+
current_names = set()
|
|
226
|
+
|
|
227
|
+
for provider in providers:
|
|
228
|
+
result.discovered_count += 1
|
|
229
|
+
current_names.add(provider.name)
|
|
230
|
+
|
|
231
|
+
# Resolve conflicts
|
|
232
|
+
conflict_result = self._conflict_resolver.resolve(provider)
|
|
233
|
+
|
|
234
|
+
if conflict_result.should_register:
|
|
235
|
+
if conflict_result.resolution == ConflictResolution.REGISTERED:
|
|
236
|
+
result.registered_count += 1
|
|
237
|
+
elif conflict_result.resolution == ConflictResolution.UPDATED:
|
|
238
|
+
result.updated_count += 1
|
|
239
|
+
|
|
240
|
+
if self._auto_register and conflict_result.winner:
|
|
241
|
+
self._conflict_resolver.register(conflict_result.winner)
|
|
242
|
+
all_discovered[provider.name] = conflict_result.winner
|
|
243
|
+
elif conflict_result.winner:
|
|
244
|
+
self._pending[provider.name] = conflict_result.winner
|
|
245
|
+
|
|
246
|
+
elif conflict_result.should_update_seen and conflict_result.winner:
|
|
247
|
+
# Just update last_seen
|
|
248
|
+
self._conflict_resolver.update(conflict_result.winner)
|
|
249
|
+
all_discovered[provider.name] = conflict_result.winner
|
|
250
|
+
|
|
251
|
+
# Handle authoritative mode - deregister missing providers
|
|
252
|
+
if source.mode == DiscoveryMode.AUTHORITATIVE:
|
|
253
|
+
previous_names = self._providers_by_source.get(source_type, set())
|
|
254
|
+
lost_names = previous_names - current_names
|
|
255
|
+
|
|
256
|
+
for name in lost_names:
|
|
257
|
+
existing = self._conflict_resolver.get_registered(name)
|
|
258
|
+
if existing and existing.source_type == source_type:
|
|
259
|
+
# Check if expired
|
|
260
|
+
if existing.is_expired():
|
|
261
|
+
self._conflict_resolver.deregister(name)
|
|
262
|
+
result.deregistered_count += 1
|
|
263
|
+
logger.info(f"Deregistered expired provider: {name}")
|
|
264
|
+
|
|
265
|
+
# Update tracked providers for this source
|
|
266
|
+
self._providers_by_source[source_type] = current_names
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
logger.error(f"Discovery failed for source {source_type}: {e}")
|
|
270
|
+
result.error_count += 1
|
|
271
|
+
result.source_results[source_type] = 0
|
|
272
|
+
|
|
273
|
+
# Update source status
|
|
274
|
+
self._source_status[source_type].is_healthy = False
|
|
275
|
+
self._source_status[source_type].error_message = str(e)
|
|
276
|
+
|
|
277
|
+
# Calculate duration
|
|
278
|
+
result.duration_ms = (time.perf_counter() - start_time) * 1000
|
|
279
|
+
|
|
280
|
+
logger.info(
|
|
281
|
+
f"Discovery cycle complete: {result.discovered_count} discovered, "
|
|
282
|
+
f"{result.registered_count} registered, {result.updated_count} updated, "
|
|
283
|
+
f"{result.deregistered_count} deregistered in {result.duration_ms:.2f}ms"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
async def discover_from_source(self, source_type: str) -> List[DiscoveredProvider]:
|
|
289
|
+
"""Run discovery from a single source.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
source_type: Type of source to discover from
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of discovered providers
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ValueError: If source not found
|
|
299
|
+
"""
|
|
300
|
+
source = self._sources.get(source_type)
|
|
301
|
+
if not source:
|
|
302
|
+
raise ValueError(f"Source not found: {source_type}")
|
|
303
|
+
|
|
304
|
+
return await source.discover()
|
|
305
|
+
|
|
306
|
+
async def get_sources_status(self) -> List[SourceStatus]:
|
|
307
|
+
"""Get status of all discovery sources.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
List of SourceStatus objects
|
|
311
|
+
"""
|
|
312
|
+
# Update health status
|
|
313
|
+
for source_type, source in self._sources.items():
|
|
314
|
+
try:
|
|
315
|
+
is_healthy = await source.health_check()
|
|
316
|
+
self._source_status[source_type].is_healthy = is_healthy
|
|
317
|
+
self._source_status[source_type].is_enabled = source.is_enabled
|
|
318
|
+
except Exception as e:
|
|
319
|
+
self._source_status[source_type].is_healthy = False
|
|
320
|
+
self._source_status[source_type].error_message = str(e)
|
|
321
|
+
|
|
322
|
+
return list(self._source_status.values())
|
|
323
|
+
|
|
324
|
+
def get_pending_providers(self) -> List[DiscoveredProvider]:
|
|
325
|
+
"""Get providers pending registration.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of pending providers
|
|
329
|
+
"""
|
|
330
|
+
return list(self._pending.values())
|
|
331
|
+
|
|
332
|
+
def approve_pending(self, name: str) -> Optional[DiscoveredProvider]:
|
|
333
|
+
"""Approve a pending provider for registration.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
name: Provider name to approve
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
The approved provider, or None if not found
|
|
340
|
+
"""
|
|
341
|
+
provider = self._pending.pop(name, None)
|
|
342
|
+
if provider:
|
|
343
|
+
self._conflict_resolver.register(provider)
|
|
344
|
+
logger.info(f"Approved pending provider: {name}")
|
|
345
|
+
return provider
|
|
346
|
+
|
|
347
|
+
def quarantine(self, provider: DiscoveredProvider, reason: str) -> None:
|
|
348
|
+
"""Move a provider to quarantine.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
provider: Provider to quarantine
|
|
352
|
+
reason: Reason for quarantine
|
|
353
|
+
"""
|
|
354
|
+
self._quarantine[provider.name] = (provider, reason)
|
|
355
|
+
logger.warning(f"Quarantined provider '{provider.name}': {reason}")
|
|
356
|
+
|
|
357
|
+
def approve_quarantined(self, name: str) -> Optional[DiscoveredProvider]:
|
|
358
|
+
"""Approve a quarantined provider for registration.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
name: Provider name to approve
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The approved provider, or None if not found
|
|
365
|
+
"""
|
|
366
|
+
if name in self._quarantine:
|
|
367
|
+
provider, _ = self._quarantine.pop(name)
|
|
368
|
+
self._conflict_resolver.register(provider)
|
|
369
|
+
logger.info(f"Approved quarantined provider: {name}")
|
|
370
|
+
return provider
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
def get_quarantined(self) -> Dict[str, tuple[DiscoveredProvider, str]]:
|
|
374
|
+
"""Get all quarantined providers with reasons.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Dictionary of name -> (provider, reason)
|
|
378
|
+
"""
|
|
379
|
+
return dict(self._quarantine)
|
|
380
|
+
|
|
381
|
+
def get_registered_providers(self) -> Dict[str, DiscoveredProvider]:
|
|
382
|
+
"""Get all registered providers.
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
Dictionary of name -> DiscoveredProvider
|
|
386
|
+
"""
|
|
387
|
+
return self._conflict_resolver.get_all_registered()
|
|
388
|
+
|
|
389
|
+
def set_static_providers(self, names: Set[str]) -> None:
|
|
390
|
+
"""Set the static providers (from config).
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
names: Set of static provider names
|
|
394
|
+
"""
|
|
395
|
+
for name in names:
|
|
396
|
+
self._conflict_resolver.add_static_provider(name)
|
|
397
|
+
|
|
398
|
+
async def start(self) -> None:
|
|
399
|
+
"""Start all discovery sources."""
|
|
400
|
+
for source in self._sources.values():
|
|
401
|
+
try:
|
|
402
|
+
await source.start()
|
|
403
|
+
except Exception as e:
|
|
404
|
+
logger.error(f"Failed to start source {source.source_type}: {e}")
|
|
405
|
+
|
|
406
|
+
async def stop(self) -> None:
|
|
407
|
+
"""Stop all discovery sources."""
|
|
408
|
+
for source in self._sources.values():
|
|
409
|
+
try:
|
|
410
|
+
await source.stop()
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.error(f"Failed to stop source {source.source_type}: {e}")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Discovery Source Port (ABC).
|
|
2
|
+
|
|
3
|
+
Defines the interface for provider discovery sources.
|
|
4
|
+
Implementations include Kubernetes, Docker, Filesystem, and Python entrypoints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .discovered_provider import DiscoveredProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DiscoveryMode(Enum):
|
|
15
|
+
"""How the source handles provider lifecycle.
|
|
16
|
+
|
|
17
|
+
ADDITIVE: Only adds new providers, never removes existing ones.
|
|
18
|
+
Safe for production environments.
|
|
19
|
+
|
|
20
|
+
AUTHORITATIVE: Can add AND remove providers based on what's discovered.
|
|
21
|
+
Use for dynamic environments like K8s where pods come and go.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
ADDITIVE = "additive"
|
|
25
|
+
AUTHORITATIVE = "authoritative"
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
return self.value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Type alias for event handlers
|
|
32
|
+
EventHandler = Callable[..., Coroutine[Any, Any, None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DiscoverySource(ABC):
|
|
36
|
+
"""Port for provider discovery sources.
|
|
37
|
+
|
|
38
|
+
This abstract base class defines the contract for all discovery sources.
|
|
39
|
+
Implementations discover providers from various infrastructure sources
|
|
40
|
+
and report changes via event hooks.
|
|
41
|
+
|
|
42
|
+
Lifecycle:
|
|
43
|
+
1. Source is configured and registered with orchestrator
|
|
44
|
+
2. Orchestrator calls discover() periodically
|
|
45
|
+
3. Source reports new/changed/lost providers via event hooks
|
|
46
|
+
4. Orchestrator handles registration/deregistration
|
|
47
|
+
|
|
48
|
+
Example:
|
|
49
|
+
class MySource(DiscoverySource):
|
|
50
|
+
@property
|
|
51
|
+
def source_type(self) -> str:
|
|
52
|
+
return "my_source"
|
|
53
|
+
|
|
54
|
+
async def discover(self) -> List[DiscoveredProvider]:
|
|
55
|
+
# Implementation
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
async def health_check(self) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, mode: DiscoveryMode = DiscoveryMode.ADDITIVE):
|
|
63
|
+
"""Initialize discovery source.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
mode: Discovery mode (additive or authoritative)
|
|
67
|
+
"""
|
|
68
|
+
self.mode = mode
|
|
69
|
+
self._event_handlers: Dict[str, EventHandler] = {}
|
|
70
|
+
self._enabled = True
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def source_type(self) -> str:
|
|
75
|
+
"""Return source identifier.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
One of: kubernetes, docker, filesystem, entrypoint
|
|
79
|
+
"""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
async def discover(self) -> List[DiscoveredProvider]:
|
|
84
|
+
"""Discover providers from this source.
|
|
85
|
+
|
|
86
|
+
This method is called periodically by the discovery orchestrator.
|
|
87
|
+
It should return all currently available providers from this source.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of discovered providers
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
Exception: If discovery fails (will be logged and retried)
|
|
94
|
+
"""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
async def health_check(self) -> bool:
|
|
99
|
+
"""Check if source is available and healthy.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if source can perform discovery, False otherwise
|
|
103
|
+
"""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def is_enabled(self) -> bool:
|
|
108
|
+
"""Check if source is enabled."""
|
|
109
|
+
return self._enabled
|
|
110
|
+
|
|
111
|
+
def enable(self) -> None:
|
|
112
|
+
"""Enable this discovery source."""
|
|
113
|
+
self._enabled = True
|
|
114
|
+
|
|
115
|
+
def disable(self) -> None:
|
|
116
|
+
"""Disable this discovery source."""
|
|
117
|
+
self._enabled = False
|
|
118
|
+
|
|
119
|
+
# Event hooks for observability
|
|
120
|
+
|
|
121
|
+
async def on_provider_discovered(self, provider: DiscoveredProvider) -> None:
|
|
122
|
+
"""Hook called when a new provider is found.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
provider: Newly discovered provider
|
|
126
|
+
"""
|
|
127
|
+
handler = self._event_handlers.get("discovered")
|
|
128
|
+
if handler:
|
|
129
|
+
await handler(provider)
|
|
130
|
+
|
|
131
|
+
async def on_provider_lost(self, provider_name: str) -> None:
|
|
132
|
+
"""Hook called when a previously discovered provider disappears.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
provider_name: Name of the lost provider
|
|
136
|
+
"""
|
|
137
|
+
handler = self._event_handlers.get("lost")
|
|
138
|
+
if handler:
|
|
139
|
+
await handler(provider_name)
|
|
140
|
+
|
|
141
|
+
async def on_provider_changed(self, old: DiscoveredProvider, new: DiscoveredProvider) -> None:
|
|
142
|
+
"""Hook called when provider config changes (fingerprint mismatch).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
old: Previous provider configuration
|
|
146
|
+
new: New provider configuration
|
|
147
|
+
"""
|
|
148
|
+
handler = self._event_handlers.get("changed")
|
|
149
|
+
if handler:
|
|
150
|
+
await handler(old, new)
|
|
151
|
+
|
|
152
|
+
def register_handler(self, event: str, handler: EventHandler) -> None:
|
|
153
|
+
"""Register event handler.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
event: Event name (discovered, lost, changed)
|
|
157
|
+
handler: Async callback function
|
|
158
|
+
"""
|
|
159
|
+
self._event_handlers[event] = handler
|
|
160
|
+
|
|
161
|
+
def unregister_handler(self, event: str) -> Optional[EventHandler]:
|
|
162
|
+
"""Unregister event handler.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
event: Event name to unregister
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
The removed handler, or None if not found
|
|
169
|
+
"""
|
|
170
|
+
return self._event_handlers.pop(event, None)
|
|
171
|
+
|
|
172
|
+
async def start(self) -> None:
|
|
173
|
+
"""Start the discovery source (optional lifecycle hook).
|
|
174
|
+
|
|
175
|
+
Override this method to perform initialization tasks like
|
|
176
|
+
starting file watchers or establishing connections.
|
|
177
|
+
"""
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
async def stop(self) -> None:
|
|
181
|
+
"""Stop the discovery source (optional lifecycle hook).
|
|
182
|
+
|
|
183
|
+
Override this method to perform cleanup tasks like
|
|
184
|
+
stopping file watchers or closing connections.
|
|
185
|
+
"""
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
def __str__(self) -> str:
|
|
189
|
+
return f"{self.__class__.__name__}(type={self.source_type}, mode={self.mode})"
|
|
190
|
+
|
|
191
|
+
def __repr__(self) -> str:
|
|
192
|
+
return f"{self.__class__.__name__}(source_type={self.source_type!r}, mode={self.mode!r})"
|