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.
Files changed (160) hide show
  1. mcp_hangar/__init__.py +139 -0
  2. mcp_hangar/application/__init__.py +1 -0
  3. mcp_hangar/application/commands/__init__.py +67 -0
  4. mcp_hangar/application/commands/auth_commands.py +118 -0
  5. mcp_hangar/application/commands/auth_handlers.py +296 -0
  6. mcp_hangar/application/commands/commands.py +59 -0
  7. mcp_hangar/application/commands/handlers.py +189 -0
  8. mcp_hangar/application/discovery/__init__.py +21 -0
  9. mcp_hangar/application/discovery/discovery_metrics.py +283 -0
  10. mcp_hangar/application/discovery/discovery_orchestrator.py +497 -0
  11. mcp_hangar/application/discovery/lifecycle_manager.py +315 -0
  12. mcp_hangar/application/discovery/security_validator.py +414 -0
  13. mcp_hangar/application/event_handlers/__init__.py +50 -0
  14. mcp_hangar/application/event_handlers/alert_handler.py +191 -0
  15. mcp_hangar/application/event_handlers/audit_handler.py +203 -0
  16. mcp_hangar/application/event_handlers/knowledge_base_handler.py +120 -0
  17. mcp_hangar/application/event_handlers/logging_handler.py +69 -0
  18. mcp_hangar/application/event_handlers/metrics_handler.py +152 -0
  19. mcp_hangar/application/event_handlers/persistent_audit_store.py +217 -0
  20. mcp_hangar/application/event_handlers/security_handler.py +604 -0
  21. mcp_hangar/application/mcp/tooling.py +158 -0
  22. mcp_hangar/application/ports/__init__.py +9 -0
  23. mcp_hangar/application/ports/observability.py +237 -0
  24. mcp_hangar/application/queries/__init__.py +52 -0
  25. mcp_hangar/application/queries/auth_handlers.py +237 -0
  26. mcp_hangar/application/queries/auth_queries.py +118 -0
  27. mcp_hangar/application/queries/handlers.py +227 -0
  28. mcp_hangar/application/read_models/__init__.py +11 -0
  29. mcp_hangar/application/read_models/provider_views.py +139 -0
  30. mcp_hangar/application/sagas/__init__.py +11 -0
  31. mcp_hangar/application/sagas/group_rebalance_saga.py +137 -0
  32. mcp_hangar/application/sagas/provider_failover_saga.py +266 -0
  33. mcp_hangar/application/sagas/provider_recovery_saga.py +172 -0
  34. mcp_hangar/application/services/__init__.py +9 -0
  35. mcp_hangar/application/services/provider_service.py +208 -0
  36. mcp_hangar/application/services/traced_provider_service.py +211 -0
  37. mcp_hangar/bootstrap/runtime.py +328 -0
  38. mcp_hangar/context.py +178 -0
  39. mcp_hangar/domain/__init__.py +117 -0
  40. mcp_hangar/domain/contracts/__init__.py +57 -0
  41. mcp_hangar/domain/contracts/authentication.py +225 -0
  42. mcp_hangar/domain/contracts/authorization.py +229 -0
  43. mcp_hangar/domain/contracts/event_store.py +178 -0
  44. mcp_hangar/domain/contracts/metrics_publisher.py +59 -0
  45. mcp_hangar/domain/contracts/persistence.py +383 -0
  46. mcp_hangar/domain/contracts/provider_runtime.py +146 -0
  47. mcp_hangar/domain/discovery/__init__.py +20 -0
  48. mcp_hangar/domain/discovery/conflict_resolver.py +267 -0
  49. mcp_hangar/domain/discovery/discovered_provider.py +185 -0
  50. mcp_hangar/domain/discovery/discovery_service.py +412 -0
  51. mcp_hangar/domain/discovery/discovery_source.py +192 -0
  52. mcp_hangar/domain/events.py +433 -0
  53. mcp_hangar/domain/exceptions.py +525 -0
  54. mcp_hangar/domain/model/__init__.py +70 -0
  55. mcp_hangar/domain/model/aggregate.py +58 -0
  56. mcp_hangar/domain/model/circuit_breaker.py +152 -0
  57. mcp_hangar/domain/model/event_sourced_api_key.py +413 -0
  58. mcp_hangar/domain/model/event_sourced_provider.py +423 -0
  59. mcp_hangar/domain/model/event_sourced_role_assignment.py +268 -0
  60. mcp_hangar/domain/model/health_tracker.py +183 -0
  61. mcp_hangar/domain/model/load_balancer.py +185 -0
  62. mcp_hangar/domain/model/provider.py +810 -0
  63. mcp_hangar/domain/model/provider_group.py +656 -0
  64. mcp_hangar/domain/model/tool_catalog.py +105 -0
  65. mcp_hangar/domain/policies/__init__.py +19 -0
  66. mcp_hangar/domain/policies/provider_health.py +187 -0
  67. mcp_hangar/domain/repository.py +249 -0
  68. mcp_hangar/domain/security/__init__.py +85 -0
  69. mcp_hangar/domain/security/input_validator.py +710 -0
  70. mcp_hangar/domain/security/rate_limiter.py +387 -0
  71. mcp_hangar/domain/security/roles.py +237 -0
  72. mcp_hangar/domain/security/sanitizer.py +387 -0
  73. mcp_hangar/domain/security/secrets.py +501 -0
  74. mcp_hangar/domain/services/__init__.py +20 -0
  75. mcp_hangar/domain/services/audit_service.py +376 -0
  76. mcp_hangar/domain/services/image_builder.py +328 -0
  77. mcp_hangar/domain/services/provider_launcher.py +1046 -0
  78. mcp_hangar/domain/value_objects.py +1138 -0
  79. mcp_hangar/errors.py +818 -0
  80. mcp_hangar/fastmcp_server.py +1105 -0
  81. mcp_hangar/gc.py +134 -0
  82. mcp_hangar/infrastructure/__init__.py +79 -0
  83. mcp_hangar/infrastructure/async_executor.py +133 -0
  84. mcp_hangar/infrastructure/auth/__init__.py +37 -0
  85. mcp_hangar/infrastructure/auth/api_key_authenticator.py +388 -0
  86. mcp_hangar/infrastructure/auth/event_sourced_store.py +567 -0
  87. mcp_hangar/infrastructure/auth/jwt_authenticator.py +360 -0
  88. mcp_hangar/infrastructure/auth/middleware.py +340 -0
  89. mcp_hangar/infrastructure/auth/opa_authorizer.py +243 -0
  90. mcp_hangar/infrastructure/auth/postgres_store.py +659 -0
  91. mcp_hangar/infrastructure/auth/projections.py +366 -0
  92. mcp_hangar/infrastructure/auth/rate_limiter.py +311 -0
  93. mcp_hangar/infrastructure/auth/rbac_authorizer.py +323 -0
  94. mcp_hangar/infrastructure/auth/sqlite_store.py +624 -0
  95. mcp_hangar/infrastructure/command_bus.py +112 -0
  96. mcp_hangar/infrastructure/discovery/__init__.py +110 -0
  97. mcp_hangar/infrastructure/discovery/docker_source.py +289 -0
  98. mcp_hangar/infrastructure/discovery/entrypoint_source.py +249 -0
  99. mcp_hangar/infrastructure/discovery/filesystem_source.py +383 -0
  100. mcp_hangar/infrastructure/discovery/kubernetes_source.py +247 -0
  101. mcp_hangar/infrastructure/event_bus.py +260 -0
  102. mcp_hangar/infrastructure/event_sourced_repository.py +443 -0
  103. mcp_hangar/infrastructure/event_store.py +396 -0
  104. mcp_hangar/infrastructure/knowledge_base/__init__.py +259 -0
  105. mcp_hangar/infrastructure/knowledge_base/contracts.py +202 -0
  106. mcp_hangar/infrastructure/knowledge_base/memory.py +177 -0
  107. mcp_hangar/infrastructure/knowledge_base/postgres.py +545 -0
  108. mcp_hangar/infrastructure/knowledge_base/sqlite.py +513 -0
  109. mcp_hangar/infrastructure/metrics_publisher.py +36 -0
  110. mcp_hangar/infrastructure/observability/__init__.py +10 -0
  111. mcp_hangar/infrastructure/observability/langfuse_adapter.py +534 -0
  112. mcp_hangar/infrastructure/persistence/__init__.py +33 -0
  113. mcp_hangar/infrastructure/persistence/audit_repository.py +371 -0
  114. mcp_hangar/infrastructure/persistence/config_repository.py +398 -0
  115. mcp_hangar/infrastructure/persistence/database.py +333 -0
  116. mcp_hangar/infrastructure/persistence/database_common.py +330 -0
  117. mcp_hangar/infrastructure/persistence/event_serializer.py +280 -0
  118. mcp_hangar/infrastructure/persistence/event_upcaster.py +166 -0
  119. mcp_hangar/infrastructure/persistence/in_memory_event_store.py +150 -0
  120. mcp_hangar/infrastructure/persistence/recovery_service.py +312 -0
  121. mcp_hangar/infrastructure/persistence/sqlite_event_store.py +386 -0
  122. mcp_hangar/infrastructure/persistence/unit_of_work.py +409 -0
  123. mcp_hangar/infrastructure/persistence/upcasters/README.md +13 -0
  124. mcp_hangar/infrastructure/persistence/upcasters/__init__.py +7 -0
  125. mcp_hangar/infrastructure/query_bus.py +153 -0
  126. mcp_hangar/infrastructure/saga_manager.py +401 -0
  127. mcp_hangar/logging_config.py +209 -0
  128. mcp_hangar/metrics.py +1007 -0
  129. mcp_hangar/models.py +31 -0
  130. mcp_hangar/observability/__init__.py +54 -0
  131. mcp_hangar/observability/health.py +487 -0
  132. mcp_hangar/observability/metrics.py +319 -0
  133. mcp_hangar/observability/tracing.py +433 -0
  134. mcp_hangar/progress.py +542 -0
  135. mcp_hangar/retry.py +613 -0
  136. mcp_hangar/server/__init__.py +120 -0
  137. mcp_hangar/server/__main__.py +6 -0
  138. mcp_hangar/server/auth_bootstrap.py +340 -0
  139. mcp_hangar/server/auth_cli.py +335 -0
  140. mcp_hangar/server/auth_config.py +305 -0
  141. mcp_hangar/server/bootstrap.py +735 -0
  142. mcp_hangar/server/cli.py +161 -0
  143. mcp_hangar/server/config.py +224 -0
  144. mcp_hangar/server/context.py +215 -0
  145. mcp_hangar/server/http_auth_middleware.py +165 -0
  146. mcp_hangar/server/lifecycle.py +467 -0
  147. mcp_hangar/server/state.py +117 -0
  148. mcp_hangar/server/tools/__init__.py +16 -0
  149. mcp_hangar/server/tools/discovery.py +186 -0
  150. mcp_hangar/server/tools/groups.py +75 -0
  151. mcp_hangar/server/tools/health.py +301 -0
  152. mcp_hangar/server/tools/provider.py +939 -0
  153. mcp_hangar/server/tools/registry.py +320 -0
  154. mcp_hangar/server/validation.py +113 -0
  155. mcp_hangar/stdio_client.py +229 -0
  156. mcp_hangar-0.2.0.dist-info/METADATA +347 -0
  157. mcp_hangar-0.2.0.dist-info/RECORD +160 -0
  158. mcp_hangar-0.2.0.dist-info/WHEEL +4 -0
  159. mcp_hangar-0.2.0.dist-info/entry_points.txt +2 -0
  160. 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})"