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,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
|