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,497 @@
|
|
|
1
|
+
"""Discovery Orchestrator.
|
|
2
|
+
|
|
3
|
+
Main coordination component for provider discovery.
|
|
4
|
+
Manages discovery sources, validation, and integration with the registry.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
|
11
|
+
|
|
12
|
+
from mcp_hangar.domain.discovery.conflict_resolver import ConflictResolver
|
|
13
|
+
from mcp_hangar.domain.discovery.discovered_provider import DiscoveredProvider
|
|
14
|
+
from mcp_hangar.domain.discovery.discovery_service import DiscoveryCycleResult, DiscoveryService
|
|
15
|
+
from mcp_hangar.domain.discovery.discovery_source import DiscoverySource
|
|
16
|
+
from mcp_hangar.logging_config import get_logger
|
|
17
|
+
|
|
18
|
+
# Import main metrics for unified observability
|
|
19
|
+
from mcp_hangar import metrics as main_metrics
|
|
20
|
+
|
|
21
|
+
from .discovery_metrics import get_discovery_metrics
|
|
22
|
+
from .lifecycle_manager import DiscoveryLifecycleManager
|
|
23
|
+
from .security_validator import SecurityConfig, SecurityValidator
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DiscoveryConfig:
|
|
30
|
+
"""Configuration for discovery orchestrator.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
enabled: Master switch for discovery
|
|
34
|
+
refresh_interval_s: Interval between discovery cycles
|
|
35
|
+
auto_register: Whether to auto-register discovered providers
|
|
36
|
+
security: Security configuration
|
|
37
|
+
lifecycle: Lifecycle configuration
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
enabled: bool = True
|
|
41
|
+
refresh_interval_s: int = 30
|
|
42
|
+
auto_register: bool = True
|
|
43
|
+
|
|
44
|
+
# Security settings
|
|
45
|
+
security: SecurityConfig = field(default_factory=SecurityConfig)
|
|
46
|
+
|
|
47
|
+
# Lifecycle settings
|
|
48
|
+
default_ttl_s: int = 90
|
|
49
|
+
check_interval_s: int = 10
|
|
50
|
+
drain_timeout_s: int = 30
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, data: Dict[str, Any]) -> "DiscoveryConfig":
|
|
54
|
+
"""Create from dictionary (e.g., from config.yaml).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: Configuration dictionary
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
DiscoveryConfig instance
|
|
61
|
+
"""
|
|
62
|
+
security_data = data.get("security", {})
|
|
63
|
+
lifecycle_data = data.get("lifecycle", {})
|
|
64
|
+
|
|
65
|
+
return cls(
|
|
66
|
+
enabled=data.get("enabled", True),
|
|
67
|
+
refresh_interval_s=data.get("refresh_interval_s", 30),
|
|
68
|
+
auto_register=data.get("auto_register", True),
|
|
69
|
+
security=SecurityConfig.from_dict(security_data),
|
|
70
|
+
default_ttl_s=lifecycle_data.get("default_ttl_s", 90),
|
|
71
|
+
check_interval_s=lifecycle_data.get("check_interval_s", 10),
|
|
72
|
+
drain_timeout_s=lifecycle_data.get("drain_timeout_s", 30),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Type for registry registration callback
|
|
77
|
+
RegistrationCallback = Callable[[DiscoveredProvider], Awaitable[bool]]
|
|
78
|
+
DeregistrationCallback = Callable[[str, str], Awaitable[None]]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DiscoveryOrchestrator:
|
|
82
|
+
"""Main coordination component for provider discovery.
|
|
83
|
+
|
|
84
|
+
Orchestrates:
|
|
85
|
+
- Multiple discovery sources
|
|
86
|
+
- Security validation pipeline
|
|
87
|
+
- Lifecycle management (TTL, quarantine)
|
|
88
|
+
- Integration with main registry
|
|
89
|
+
- Metrics and observability
|
|
90
|
+
|
|
91
|
+
Usage:
|
|
92
|
+
orchestrator = DiscoveryOrchestrator(config)
|
|
93
|
+
orchestrator.add_source(KubernetesDiscoverySource())
|
|
94
|
+
orchestrator.add_source(DockerDiscoverySource())
|
|
95
|
+
|
|
96
|
+
# Set callbacks for registry integration
|
|
97
|
+
orchestrator.on_register = async_register_fn
|
|
98
|
+
orchestrator.on_deregister = async_deregister_fn
|
|
99
|
+
|
|
100
|
+
# Start discovery
|
|
101
|
+
await orchestrator.start()
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
config: Optional[DiscoveryConfig] = None,
|
|
107
|
+
static_providers: Optional[Set[str]] = None,
|
|
108
|
+
):
|
|
109
|
+
"""Initialize discovery orchestrator.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
config: Discovery configuration
|
|
113
|
+
static_providers: Set of static provider names (from config)
|
|
114
|
+
"""
|
|
115
|
+
self.config = config or DiscoveryConfig()
|
|
116
|
+
|
|
117
|
+
# Core components
|
|
118
|
+
self._conflict_resolver = ConflictResolver(static_providers)
|
|
119
|
+
self._discovery_service = DiscoveryService(
|
|
120
|
+
conflict_resolver=self._conflict_resolver,
|
|
121
|
+
auto_register=self.config.auto_register,
|
|
122
|
+
)
|
|
123
|
+
self._validator = SecurityValidator(self.config.security)
|
|
124
|
+
self._lifecycle_manager = DiscoveryLifecycleManager(
|
|
125
|
+
default_ttl=self.config.default_ttl_s,
|
|
126
|
+
check_interval=self.config.check_interval_s,
|
|
127
|
+
drain_timeout=self.config.drain_timeout_s,
|
|
128
|
+
)
|
|
129
|
+
self._metrics = get_discovery_metrics()
|
|
130
|
+
|
|
131
|
+
# Callbacks for registry integration
|
|
132
|
+
self.on_register: Optional[RegistrationCallback] = None
|
|
133
|
+
self.on_deregister: Optional[DeregistrationCallback] = None
|
|
134
|
+
|
|
135
|
+
# Discovery loop state
|
|
136
|
+
self._running = False
|
|
137
|
+
self._discovery_task: Optional[asyncio.Task] = None
|
|
138
|
+
self._last_cycle: Optional[datetime] = None
|
|
139
|
+
|
|
140
|
+
def add_source(self, source: DiscoverySource) -> None:
|
|
141
|
+
"""Add a discovery source.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
source: Discovery source to add
|
|
145
|
+
"""
|
|
146
|
+
self._discovery_service.register_source(source)
|
|
147
|
+
logger.info(f"Added discovery source: {source.source_type}")
|
|
148
|
+
|
|
149
|
+
def remove_source(self, source_type: str) -> Optional[DiscoverySource]:
|
|
150
|
+
"""Remove a discovery source.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
source_type: Type of source to remove
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Removed source, or None if not found
|
|
157
|
+
"""
|
|
158
|
+
return self._discovery_service.unregister_source(source_type)
|
|
159
|
+
|
|
160
|
+
def set_static_providers(self, names: Set[str]) -> None:
|
|
161
|
+
"""Set static provider names (from config).
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
names: Set of static provider names
|
|
165
|
+
"""
|
|
166
|
+
self._discovery_service.set_static_providers(names)
|
|
167
|
+
|
|
168
|
+
async def start(self) -> None:
|
|
169
|
+
"""Start the discovery orchestrator."""
|
|
170
|
+
if not self.config.enabled:
|
|
171
|
+
logger.info("Discovery is disabled in configuration")
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if self._running:
|
|
175
|
+
logger.warning("Discovery orchestrator already running")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
self._running = True
|
|
179
|
+
|
|
180
|
+
# Set up lifecycle manager callback
|
|
181
|
+
self._lifecycle_manager.on_deregister = self._handle_deregister
|
|
182
|
+
|
|
183
|
+
# Start components
|
|
184
|
+
await self._discovery_service.start()
|
|
185
|
+
await self._lifecycle_manager.start()
|
|
186
|
+
|
|
187
|
+
# Start discovery loop
|
|
188
|
+
self._discovery_task = asyncio.create_task(self._discovery_loop())
|
|
189
|
+
|
|
190
|
+
logger.info(f"Discovery orchestrator started (refresh_interval={self.config.refresh_interval_s}s)")
|
|
191
|
+
|
|
192
|
+
async def stop(self) -> None:
|
|
193
|
+
"""Stop the discovery orchestrator."""
|
|
194
|
+
self._running = False
|
|
195
|
+
|
|
196
|
+
# Cancel discovery loop
|
|
197
|
+
if self._discovery_task:
|
|
198
|
+
self._discovery_task.cancel()
|
|
199
|
+
try:
|
|
200
|
+
await self._discovery_task
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
pass
|
|
203
|
+
self._discovery_task = None
|
|
204
|
+
|
|
205
|
+
# Stop components
|
|
206
|
+
await self._lifecycle_manager.stop()
|
|
207
|
+
await self._discovery_service.stop()
|
|
208
|
+
|
|
209
|
+
logger.info("Discovery orchestrator stopped")
|
|
210
|
+
|
|
211
|
+
async def _discovery_loop(self) -> None:
|
|
212
|
+
"""Main discovery loop."""
|
|
213
|
+
# Initial discovery
|
|
214
|
+
await self.run_discovery_cycle()
|
|
215
|
+
|
|
216
|
+
while self._running:
|
|
217
|
+
try:
|
|
218
|
+
await asyncio.sleep(self.config.refresh_interval_s)
|
|
219
|
+
if self._running:
|
|
220
|
+
await self.run_discovery_cycle()
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
break
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Error in discovery loop: {e}")
|
|
225
|
+
self._metrics.inc_errors(source="orchestrator", error_type=type(e).__name__)
|
|
226
|
+
|
|
227
|
+
async def run_discovery_cycle(self) -> DiscoveryCycleResult:
|
|
228
|
+
"""Run a single discovery cycle.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
DiscoveryCycleResult with cycle statistics
|
|
232
|
+
"""
|
|
233
|
+
import time
|
|
234
|
+
|
|
235
|
+
start_time = time.perf_counter()
|
|
236
|
+
|
|
237
|
+
result = DiscoveryCycleResult()
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Run discovery on all sources
|
|
241
|
+
cycle_result = await self._discovery_service.run_discovery_cycle()
|
|
242
|
+
result.discovered_count = cycle_result.discovered_count
|
|
243
|
+
result.source_results = cycle_result.source_results
|
|
244
|
+
|
|
245
|
+
# Process discovered providers through validation
|
|
246
|
+
for provider in self._discovery_service.get_registered_providers().values():
|
|
247
|
+
validation_result = await self._process_provider(provider)
|
|
248
|
+
|
|
249
|
+
if validation_result == "registered":
|
|
250
|
+
result.registered_count += 1
|
|
251
|
+
elif validation_result == "updated":
|
|
252
|
+
result.updated_count += 1
|
|
253
|
+
elif validation_result == "quarantined":
|
|
254
|
+
result.quarantined_count += 1
|
|
255
|
+
|
|
256
|
+
# Check for deregistrations
|
|
257
|
+
result.deregistered_count = cycle_result.deregistered_count
|
|
258
|
+
result.error_count = cycle_result.error_count
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
logger.error(f"Discovery cycle failed: {e}")
|
|
262
|
+
result.error_count += 1
|
|
263
|
+
self._metrics.inc_errors(source="orchestrator", error_type=type(e).__name__)
|
|
264
|
+
|
|
265
|
+
# Calculate duration
|
|
266
|
+
duration_seconds = time.perf_counter() - start_time
|
|
267
|
+
result.duration_ms = duration_seconds * 1000
|
|
268
|
+
|
|
269
|
+
# Update internal metrics
|
|
270
|
+
self._metrics.observe_cycle_duration(duration_seconds)
|
|
271
|
+
self._last_cycle = datetime.now(timezone.utc)
|
|
272
|
+
|
|
273
|
+
# Update main metrics for unified observability
|
|
274
|
+
for source in self._discovery_service.get_all_sources():
|
|
275
|
+
source_count = result.source_results.get(source.source_type, 0)
|
|
276
|
+
main_metrics.record_discovery_cycle(
|
|
277
|
+
source_type=source.source_type,
|
|
278
|
+
duration=duration_seconds,
|
|
279
|
+
discovered=source_count,
|
|
280
|
+
registered=result.registered_count,
|
|
281
|
+
quarantined=result.quarantined_count,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
logger.debug(
|
|
285
|
+
f"Discovery cycle complete: {result.discovered_count} discovered, "
|
|
286
|
+
f"{result.registered_count} registered in {result.duration_ms:.2f}ms"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
async def _process_provider(self, provider: DiscoveredProvider) -> str:
|
|
292
|
+
"""Process a discovered provider through validation.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
provider: Provider to process
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
Status string: "registered", "updated", "quarantined", "skipped"
|
|
299
|
+
"""
|
|
300
|
+
# Check if already tracked
|
|
301
|
+
existing = self._lifecycle_manager.get_provider(provider.name)
|
|
302
|
+
if existing:
|
|
303
|
+
if existing.fingerprint == provider.fingerprint:
|
|
304
|
+
# Just update last_seen
|
|
305
|
+
self._lifecycle_manager.update_seen(provider.name)
|
|
306
|
+
return "skipped"
|
|
307
|
+
else:
|
|
308
|
+
# Config changed, need to validate again
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
# Validate provider
|
|
312
|
+
validation_report = await self._validator.validate(provider)
|
|
313
|
+
|
|
314
|
+
self._metrics.observe_validation_duration(
|
|
315
|
+
source=provider.source_type,
|
|
316
|
+
duration_seconds=validation_report.duration_ms / 1000,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if not validation_report.is_passed:
|
|
320
|
+
# Handle validation failure
|
|
321
|
+
logger.warning(f"Provider '{provider.name}' failed validation: {validation_report.reason}")
|
|
322
|
+
|
|
323
|
+
self._metrics.inc_validation_failures(
|
|
324
|
+
source=provider.source_type,
|
|
325
|
+
validation_type=validation_report.result.value,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
if self.config.security.quarantine_on_failure:
|
|
329
|
+
self._lifecycle_manager.quarantine(provider, validation_report.reason)
|
|
330
|
+
self._metrics.inc_quarantine(reason=validation_report.result.value)
|
|
331
|
+
main_metrics.record_discovery_quarantine(reason=validation_report.result.value)
|
|
332
|
+
return "quarantined"
|
|
333
|
+
|
|
334
|
+
return "skipped"
|
|
335
|
+
|
|
336
|
+
# Register with main registry
|
|
337
|
+
if self.on_register:
|
|
338
|
+
try:
|
|
339
|
+
success = await self.on_register(provider)
|
|
340
|
+
if not success:
|
|
341
|
+
logger.warning(f"Registry rejected provider: {provider.name}")
|
|
342
|
+
return "skipped"
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.error(f"Error registering provider {provider.name}: {e}")
|
|
345
|
+
return "skipped"
|
|
346
|
+
|
|
347
|
+
# Track in lifecycle manager
|
|
348
|
+
if existing:
|
|
349
|
+
self._lifecycle_manager.update_provider(provider)
|
|
350
|
+
self._metrics.inc_registrations(source=provider.source_type)
|
|
351
|
+
return "updated"
|
|
352
|
+
else:
|
|
353
|
+
self._lifecycle_manager.add_provider(provider)
|
|
354
|
+
self._validator.record_registration(provider)
|
|
355
|
+
self._metrics.inc_registrations(source=provider.source_type)
|
|
356
|
+
return "registered"
|
|
357
|
+
|
|
358
|
+
async def _handle_deregister(self, name: str, reason: str) -> None:
|
|
359
|
+
"""Handle provider deregistration.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
name: Provider name
|
|
363
|
+
reason: Reason for deregistration
|
|
364
|
+
"""
|
|
365
|
+
provider = self._lifecycle_manager.get_provider(name)
|
|
366
|
+
if provider:
|
|
367
|
+
self._validator.record_deregistration(provider)
|
|
368
|
+
self._metrics.inc_deregistrations(source=provider.source_type, reason=reason)
|
|
369
|
+
main_metrics.record_discovery_deregistration(source_type=provider.source_type, reason=reason)
|
|
370
|
+
|
|
371
|
+
if self.on_deregister:
|
|
372
|
+
try:
|
|
373
|
+
await self.on_deregister(name, reason)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.error(f"Error in deregister callback for {name}: {e}")
|
|
376
|
+
|
|
377
|
+
# Public API for tools
|
|
378
|
+
|
|
379
|
+
async def trigger_discovery(self) -> Dict[str, Any]:
|
|
380
|
+
"""Trigger immediate discovery cycle.
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Discovery results
|
|
384
|
+
"""
|
|
385
|
+
result = await self.run_discovery_cycle()
|
|
386
|
+
return result.to_dict()
|
|
387
|
+
|
|
388
|
+
def get_pending_providers(self) -> List[DiscoveredProvider]:
|
|
389
|
+
"""Get providers pending registration.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
List of pending providers
|
|
393
|
+
"""
|
|
394
|
+
return self._discovery_service.get_pending_providers()
|
|
395
|
+
|
|
396
|
+
def get_quarantined(self) -> Dict[str, Dict[str, Any]]:
|
|
397
|
+
"""Get quarantined providers with reasons.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Dictionary of name -> {provider, reason, quarantine_time}
|
|
401
|
+
"""
|
|
402
|
+
quarantined = self._lifecycle_manager.get_quarantined()
|
|
403
|
+
return {
|
|
404
|
+
name: {
|
|
405
|
+
"provider": provider.to_dict(),
|
|
406
|
+
"reason": reason,
|
|
407
|
+
"quarantine_time": qtime.isoformat(),
|
|
408
|
+
}
|
|
409
|
+
for name, (provider, reason, qtime) in quarantined.items()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async def approve_provider(self, name: str) -> Dict[str, Any]:
|
|
413
|
+
"""Approve a quarantined provider.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
name: Provider name
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Result dictionary
|
|
420
|
+
"""
|
|
421
|
+
provider = self._lifecycle_manager.approve(name)
|
|
422
|
+
|
|
423
|
+
if provider:
|
|
424
|
+
# Register with main registry
|
|
425
|
+
if self.on_register:
|
|
426
|
+
try:
|
|
427
|
+
await self.on_register(provider)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.error(f"Error registering approved provider {name}: {e}")
|
|
430
|
+
return {"approved": False, "provider": name, "error": str(e)}
|
|
431
|
+
|
|
432
|
+
self._validator.record_registration(provider)
|
|
433
|
+
self._metrics.inc_registrations(source=provider.source_type)
|
|
434
|
+
|
|
435
|
+
return {"approved": True, "provider": name, "status": "registered"}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
"approved": False,
|
|
439
|
+
"provider": name,
|
|
440
|
+
"error": "Provider not found in quarantine",
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async def reject_provider(self, name: str) -> Dict[str, Any]:
|
|
444
|
+
"""Reject a quarantined provider.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
name: Provider name
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
Result dictionary
|
|
451
|
+
"""
|
|
452
|
+
provider = self._lifecycle_manager.reject(name)
|
|
453
|
+
|
|
454
|
+
if provider:
|
|
455
|
+
return {"rejected": True, "provider": name}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
"rejected": False,
|
|
459
|
+
"provider": name,
|
|
460
|
+
"error": "Provider not found in quarantine",
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async def get_sources_status(self) -> List[Dict[str, Any]]:
|
|
464
|
+
"""Get status of all discovery sources.
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
List of source status dictionaries
|
|
468
|
+
"""
|
|
469
|
+
statuses = await self._discovery_service.get_sources_status()
|
|
470
|
+
|
|
471
|
+
# Update main metrics for each source
|
|
472
|
+
for status in statuses:
|
|
473
|
+
main_metrics.update_discovery_source(
|
|
474
|
+
source_type=status.source_type,
|
|
475
|
+
mode=status.mode,
|
|
476
|
+
is_healthy=status.is_healthy,
|
|
477
|
+
providers_count=status.providers_count,
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return [s.to_dict() for s in statuses]
|
|
481
|
+
|
|
482
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
483
|
+
"""Get orchestrator statistics.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Statistics dictionary
|
|
487
|
+
"""
|
|
488
|
+
lifecycle_stats = self._lifecycle_manager.get_stats()
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
"enabled": self.config.enabled,
|
|
492
|
+
"running": self._running,
|
|
493
|
+
"last_cycle": self._last_cycle.isoformat() if self._last_cycle else None,
|
|
494
|
+
"refresh_interval_s": self.config.refresh_interval_s,
|
|
495
|
+
"sources_count": len(self._discovery_service.get_all_sources()),
|
|
496
|
+
**lifecycle_stats,
|
|
497
|
+
}
|