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,229 @@
1
+ """Authorization contracts (ports) for the domain layer.
2
+
3
+ These protocols define the interfaces for authorization components.
4
+ Infrastructure layer provides concrete implementations.
5
+ """
6
+
7
+ from abc import abstractmethod
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ from ..value_objects import Permission, Principal, Role
12
+
13
+
14
+ @dataclass
15
+ class AuthorizationRequest:
16
+ """Request to check authorization.
17
+
18
+ Contains all information needed to make an authorization decision.
19
+
20
+ Attributes:
21
+ principal: The authenticated principal requesting access.
22
+ action: The action being requested (create, read, update, delete, invoke, etc.).
23
+ resource_type: Type of resource (provider, tool, config, audit, metrics).
24
+ resource_id: Specific resource identifier or '*' for any.
25
+ context: Additional context for policy evaluation (rate limits, time, etc.).
26
+ """
27
+
28
+ principal: Principal
29
+ action: str
30
+ resource_type: str
31
+ resource_id: str
32
+ context: dict[str, Any] = field(default_factory=dict)
33
+
34
+ def __post_init__(self) -> None:
35
+ if not self.action:
36
+ raise ValueError("AuthorizationRequest action cannot be empty")
37
+ if not self.resource_type:
38
+ raise ValueError("AuthorizationRequest resource_type cannot be empty")
39
+
40
+
41
+ @dataclass
42
+ class AuthorizationResult:
43
+ """Result of authorization check.
44
+
45
+ Attributes:
46
+ allowed: Whether the action is permitted.
47
+ reason: Human-readable reason for the decision.
48
+ matched_permission: The permission that granted access (if allowed).
49
+ matched_role: The role that provided the permission (if allowed).
50
+ """
51
+
52
+ allowed: bool
53
+ reason: str = ""
54
+ matched_permission: Permission | None = None
55
+ matched_role: str | None = None
56
+
57
+ @classmethod
58
+ def allow(
59
+ cls,
60
+ reason: str = "",
61
+ permission: Permission | None = None,
62
+ role: str | None = None,
63
+ ) -> "AuthorizationResult":
64
+ """Create an allow result."""
65
+ return cls(
66
+ allowed=True,
67
+ reason=reason,
68
+ matched_permission=permission,
69
+ matched_role=role,
70
+ )
71
+
72
+ @classmethod
73
+ def deny(cls, reason: str = "") -> "AuthorizationResult":
74
+ """Create a deny result."""
75
+ return cls(allowed=False, reason=reason)
76
+
77
+
78
+ @runtime_checkable
79
+ class IAuthorizer(Protocol):
80
+ """Checks if a principal is authorized for an action.
81
+
82
+ Authorizers make access control decisions based on:
83
+ - Principal identity and attributes
84
+ - Requested action
85
+ - Target resource
86
+ - Optional context (rate limits, time-based rules, etc.)
87
+ """
88
+
89
+ @abstractmethod
90
+ def authorize(self, request: AuthorizationRequest) -> AuthorizationResult:
91
+ """Check if the principal is authorized.
92
+
93
+ Args:
94
+ request: The authorization request with principal, action, and resource.
95
+
96
+ Returns:
97
+ AuthorizationResult with allowed status and reason.
98
+
99
+ Note:
100
+ This method should never raise exceptions for authorization failures.
101
+ Authorization denial is represented in the result, not via exceptions.
102
+ """
103
+ ...
104
+
105
+
106
+ @runtime_checkable
107
+ class IRoleStore(Protocol):
108
+ """Storage for roles and role assignments.
109
+
110
+ Handles:
111
+ - Role definitions (name -> permissions)
112
+ - Role assignments (principal -> roles, optionally scoped)
113
+
114
+ Roles can be assigned globally or scoped to a tenant/namespace.
115
+ """
116
+
117
+ @abstractmethod
118
+ def get_role(self, role_name: str) -> Role | None:
119
+ """Get role by name.
120
+
121
+ Args:
122
+ role_name: Name of the role to retrieve.
123
+
124
+ Returns:
125
+ Role if found, None otherwise.
126
+ """
127
+ ...
128
+
129
+ @abstractmethod
130
+ def get_roles_for_principal(
131
+ self,
132
+ principal_id: str,
133
+ scope: str = "*",
134
+ ) -> list[Role]:
135
+ """Get all roles assigned to a principal.
136
+
137
+ Args:
138
+ principal_id: ID of the principal.
139
+ scope: Filter by scope ('*' for all, 'global', 'tenant:X', etc.).
140
+
141
+ Returns:
142
+ List of roles assigned to the principal.
143
+ """
144
+ ...
145
+
146
+ @abstractmethod
147
+ def assign_role(
148
+ self,
149
+ principal_id: str,
150
+ role_name: str,
151
+ scope: str = "global",
152
+ ) -> None:
153
+ """Assign a role to a principal.
154
+
155
+ Args:
156
+ principal_id: ID of the principal receiving the role.
157
+ role_name: Name of the role to assign.
158
+ scope: Scope of the assignment (global, tenant:X, namespace:Y).
159
+
160
+ Raises:
161
+ ValueError: If role_name doesn't exist.
162
+ """
163
+ ...
164
+
165
+ @abstractmethod
166
+ def revoke_role(
167
+ self,
168
+ principal_id: str,
169
+ role_name: str,
170
+ scope: str = "global",
171
+ ) -> None:
172
+ """Revoke a role from a principal.
173
+
174
+ Args:
175
+ principal_id: ID of the principal losing the role.
176
+ role_name: Name of the role to revoke.
177
+ scope: Scope from which to revoke (global, tenant:X, namespace:Y).
178
+ """
179
+ ...
180
+
181
+
182
+ @runtime_checkable
183
+ class IPolicyEngine(Protocol):
184
+ """External policy engine (e.g., OPA) for complex authorization.
185
+
186
+ Used when built-in RBAC is insufficient and complex policies
187
+ are needed (multi-tenant isolation, time-based access, etc.).
188
+ """
189
+
190
+ @abstractmethod
191
+ def evaluate(self, input_data: dict[str, Any]) -> AuthorizationResult:
192
+ """Evaluate policy with given input.
193
+
194
+ Args:
195
+ input_data: Policy input including principal, action, resource, context.
196
+
197
+ Returns:
198
+ AuthorizationResult from policy evaluation.
199
+
200
+ Note:
201
+ Should fail closed (deny) on errors. Never raise exceptions
202
+ that would bypass authorization.
203
+ """
204
+ ...
205
+
206
+ @staticmethod
207
+ def build_input(request: AuthorizationRequest) -> dict[str, Any]:
208
+ """Build policy engine input from authorization request.
209
+
210
+ Args:
211
+ request: The authorization request.
212
+
213
+ Returns:
214
+ Dictionary formatted for policy engine input.
215
+ """
216
+ return {
217
+ "principal": {
218
+ "id": request.principal.id.value,
219
+ "type": request.principal.type.value,
220
+ "tenant_id": request.principal.tenant_id,
221
+ "groups": list(request.principal.groups),
222
+ },
223
+ "action": request.action,
224
+ "resource": {
225
+ "type": request.resource_type,
226
+ "id": request.resource_id,
227
+ },
228
+ "context": request.context,
229
+ }
@@ -0,0 +1,178 @@
1
+ """Event Store contract - interface for domain event persistence.
2
+
3
+ The Event Store provides append-only persistence for domain events,
4
+ enabling Event Sourcing pattern with optimistic concurrency control.
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+ from typing import Iterator
9
+
10
+ from ..events import DomainEvent
11
+
12
+
13
+ class ConcurrencyError(Exception):
14
+ """Raised when optimistic concurrency check fails.
15
+
16
+ This occurs when attempting to append events to a stream with
17
+ an expected version that doesn't match the actual stream version.
18
+ """
19
+
20
+ def __init__(self, stream_id: str, expected: int, actual: int):
21
+ """Initialize concurrency error.
22
+
23
+ Args:
24
+ stream_id: The stream that had the conflict.
25
+ expected: Expected version at time of append.
26
+ actual: Actual version found in store.
27
+ """
28
+ self.stream_id = stream_id
29
+ self.expected = expected
30
+ self.actual = actual
31
+ super().__init__(f"Concurrency conflict on stream '{stream_id}': expected version {expected}, actual {actual}")
32
+
33
+
34
+ class StreamNotFoundError(Exception):
35
+ """Raised when attempting to read a non-existent stream."""
36
+
37
+ def __init__(self, stream_id: str):
38
+ self.stream_id = stream_id
39
+ super().__init__(f"Stream not found: {stream_id}")
40
+
41
+
42
+ class IEventStore(ABC):
43
+ """Interface for domain event persistence.
44
+
45
+ Event Store is an append-only log of domain events organized into streams.
46
+ Each stream represents an aggregate's event history.
47
+
48
+ Stream IDs follow convention: "{aggregate_type}:{aggregate_id}"
49
+ Example: "provider:math", "provider_group:default"
50
+
51
+ Version numbers:
52
+ - -1 means "no stream exists" (for new aggregates)
53
+ - 0+ is the actual version (count of events - 1)
54
+ """
55
+
56
+ @abstractmethod
57
+ def append(
58
+ self,
59
+ stream_id: str,
60
+ events: list[DomainEvent],
61
+ expected_version: int,
62
+ ) -> int:
63
+ """Append events to a stream with optimistic concurrency control.
64
+
65
+ Events are appended atomically. Either all events are persisted
66
+ or none are (in case of concurrency conflict).
67
+
68
+ Args:
69
+ stream_id: Identifier of the event stream.
70
+ events: List of domain events to append.
71
+ expected_version: Expected current version of stream.
72
+ Use -1 for new streams (no events yet).
73
+
74
+ Returns:
75
+ New version of the stream after append.
76
+
77
+ Raises:
78
+ ConcurrencyError: When expected_version doesn't match actual.
79
+ """
80
+
81
+ @abstractmethod
82
+ def read_stream(
83
+ self,
84
+ stream_id: str,
85
+ from_version: int = 0,
86
+ ) -> list[DomainEvent]:
87
+ """Read all events from a stream.
88
+
89
+ Args:
90
+ stream_id: Identifier of the event stream.
91
+ from_version: Start reading from this version (inclusive).
92
+ Defaults to 0 (read all events).
93
+
94
+ Returns:
95
+ List of domain events in order of occurrence.
96
+ Empty list if stream doesn't exist.
97
+ """
98
+
99
+ @abstractmethod
100
+ def read_all(
101
+ self,
102
+ from_position: int = 0,
103
+ limit: int = 1000,
104
+ ) -> Iterator[tuple[int, str, DomainEvent]]:
105
+ """Read all events across all streams (for projections).
106
+
107
+ Used to build read models by processing all events in order.
108
+
109
+ Args:
110
+ from_position: Global position to start from (exclusive).
111
+ Use 0 to read from beginning.
112
+ limit: Maximum number of events to return.
113
+
114
+ Yields:
115
+ Tuples of (global_position, stream_id, event).
116
+ """
117
+
118
+ @abstractmethod
119
+ def get_stream_version(self, stream_id: str) -> int:
120
+ """Get current version of a stream.
121
+
122
+ Args:
123
+ stream_id: Identifier of the event stream.
124
+
125
+ Returns:
126
+ Current version number, or -1 if stream doesn't exist.
127
+ """
128
+
129
+ @abstractmethod
130
+ def list_streams(self, prefix: str = "") -> list[str]:
131
+ """List all stream IDs, optionally filtered by prefix.
132
+
133
+ Args:
134
+ prefix: Optional prefix to filter streams.
135
+
136
+ Returns:
137
+ List of stream IDs matching the prefix.
138
+ """
139
+
140
+
141
+ class NullEventStore(IEventStore):
142
+ """Null object implementation - discards all events.
143
+
144
+ Use when event persistence is disabled or for testing.
145
+ """
146
+
147
+ def append(
148
+ self,
149
+ stream_id: str,
150
+ events: list[DomainEvent],
151
+ expected_version: int,
152
+ ) -> int:
153
+ """Accept events but don't persist them."""
154
+ return expected_version + len(events)
155
+
156
+ def read_stream(
157
+ self,
158
+ stream_id: str,
159
+ from_version: int = 0,
160
+ ) -> list[DomainEvent]:
161
+ """Return empty list (no events persisted)."""
162
+ return []
163
+
164
+ def read_all(
165
+ self,
166
+ from_position: int = 0,
167
+ limit: int = 1000,
168
+ ) -> Iterator[tuple[int, str, DomainEvent]]:
169
+ """Yield nothing (no events persisted)."""
170
+ return iter([])
171
+
172
+ def get_stream_version(self, stream_id: str) -> int:
173
+ """Return -1 (stream doesn't exist)."""
174
+ return -1
175
+
176
+ def list_streams(self, prefix: str = "") -> list[str]:
177
+ """Return empty list (no streams)."""
178
+ return []
@@ -0,0 +1,59 @@
1
+ """Metrics Publisher contract for domain layer.
2
+
3
+ This interface allows the domain to publish metrics without depending
4
+ on concrete metrics implementation (Prometheus, statsd, etc.).
5
+ """
6
+
7
+ from abc import ABC, abstractmethod
8
+
9
+
10
+ class IMetricsPublisher(ABC):
11
+ """Contract for publishing metrics from domain layer."""
12
+
13
+ @abstractmethod
14
+ def record_cold_start(self, provider_id: str, duration_s: float, mode: str) -> None:
15
+ """
16
+ Record a cold start event.
17
+
18
+ Args:
19
+ provider_id: Provider identifier
20
+ duration_s: Duration of cold start in seconds
21
+ mode: Provider mode (subprocess, docker, etc.)
22
+ """
23
+ pass
24
+
25
+ @abstractmethod
26
+ def begin_cold_start(self, provider_id: str) -> None:
27
+ """
28
+ Mark the beginning of a cold start.
29
+
30
+ Args:
31
+ provider_id: Provider identifier
32
+ """
33
+ pass
34
+
35
+ @abstractmethod
36
+ def end_cold_start(self, provider_id: str) -> None:
37
+ """
38
+ Mark the end of a cold start.
39
+
40
+ Args:
41
+ provider_id: Provider identifier
42
+ """
43
+ pass
44
+
45
+
46
+ class NullMetricsPublisher(IMetricsPublisher):
47
+ """Null object pattern implementation that does nothing."""
48
+
49
+ def record_cold_start(self, provider_id: str, duration_s: float, mode: str) -> None:
50
+ """No-op implementation."""
51
+ pass
52
+
53
+ def begin_cold_start(self, provider_id: str) -> None:
54
+ """No-op implementation."""
55
+ pass
56
+
57
+ def end_cold_start(self, provider_id: str) -> None:
58
+ """No-op implementation."""
59
+ pass