dory-sdk 2.1.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 (69) hide show
  1. dory/__init__.py +70 -0
  2. dory/auto_instrument.py +142 -0
  3. dory/cli/__init__.py +5 -0
  4. dory/cli/main.py +290 -0
  5. dory/cli/templates.py +333 -0
  6. dory/config/__init__.py +23 -0
  7. dory/config/defaults.py +50 -0
  8. dory/config/loader.py +361 -0
  9. dory/config/presets.py +325 -0
  10. dory/config/schema.py +152 -0
  11. dory/core/__init__.py +27 -0
  12. dory/core/app.py +404 -0
  13. dory/core/context.py +209 -0
  14. dory/core/lifecycle.py +214 -0
  15. dory/core/meta.py +121 -0
  16. dory/core/modes.py +479 -0
  17. dory/core/processor.py +654 -0
  18. dory/core/signals.py +122 -0
  19. dory/decorators.py +142 -0
  20. dory/errors/__init__.py +117 -0
  21. dory/errors/classification.py +362 -0
  22. dory/errors/codes.py +495 -0
  23. dory/health/__init__.py +10 -0
  24. dory/health/probes.py +210 -0
  25. dory/health/server.py +306 -0
  26. dory/k8s/__init__.py +11 -0
  27. dory/k8s/annotation_watcher.py +184 -0
  28. dory/k8s/client.py +251 -0
  29. dory/k8s/pod_metadata.py +182 -0
  30. dory/logging/__init__.py +9 -0
  31. dory/logging/logger.py +175 -0
  32. dory/metrics/__init__.py +7 -0
  33. dory/metrics/collector.py +301 -0
  34. dory/middleware/__init__.py +36 -0
  35. dory/middleware/connection_tracker.py +608 -0
  36. dory/middleware/request_id.py +321 -0
  37. dory/middleware/request_tracker.py +501 -0
  38. dory/migration/__init__.py +11 -0
  39. dory/migration/configmap.py +260 -0
  40. dory/migration/serialization.py +167 -0
  41. dory/migration/state_manager.py +301 -0
  42. dory/monitoring/__init__.py +23 -0
  43. dory/monitoring/opentelemetry.py +462 -0
  44. dory/py.typed +2 -0
  45. dory/recovery/__init__.py +60 -0
  46. dory/recovery/golden_image.py +480 -0
  47. dory/recovery/golden_snapshot.py +561 -0
  48. dory/recovery/golden_validator.py +518 -0
  49. dory/recovery/partial_recovery.py +479 -0
  50. dory/recovery/recovery_decision.py +242 -0
  51. dory/recovery/restart_detector.py +142 -0
  52. dory/recovery/state_validator.py +187 -0
  53. dory/resilience/__init__.py +45 -0
  54. dory/resilience/circuit_breaker.py +454 -0
  55. dory/resilience/retry.py +389 -0
  56. dory/sidecar/__init__.py +6 -0
  57. dory/sidecar/main.py +75 -0
  58. dory/sidecar/server.py +329 -0
  59. dory/simple.py +342 -0
  60. dory/types.py +75 -0
  61. dory/utils/__init__.py +25 -0
  62. dory/utils/errors.py +59 -0
  63. dory/utils/retry.py +115 -0
  64. dory/utils/timeout.py +80 -0
  65. dory_sdk-2.1.0.dist-info/METADATA +663 -0
  66. dory_sdk-2.1.0.dist-info/RECORD +69 -0
  67. dory_sdk-2.1.0.dist-info/WHEEL +5 -0
  68. dory_sdk-2.1.0.dist-info/entry_points.txt +3 -0
  69. dory_sdk-2.1.0.dist-info/top_level.txt +1 -0
dory/k8s/client.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ Kubernetes client wrapper.
3
+
4
+ Provides simplified interface to Kubernetes API.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from dory.utils.errors import DoryK8sError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Optional kubernetes import
15
+ try:
16
+ from kubernetes import client, config
17
+ from kubernetes.client.rest import ApiException
18
+ K8S_AVAILABLE = True
19
+ except ImportError:
20
+ K8S_AVAILABLE = False
21
+ client = None
22
+ config = None
23
+ ApiException = Exception
24
+
25
+
26
+ class K8sClient:
27
+ """
28
+ Kubernetes API client wrapper.
29
+
30
+ Handles configuration loading and provides
31
+ simplified access to common operations.
32
+ """
33
+
34
+ def __init__(self, namespace: str | None = None):
35
+ """
36
+ Initialize Kubernetes client.
37
+
38
+ Args:
39
+ namespace: Kubernetes namespace (auto-detected if not provided)
40
+ """
41
+ self._namespace = namespace
42
+ self._core_api: Any = None
43
+ self._initialized = False
44
+
45
+ def _ensure_initialized(self) -> None:
46
+ """Initialize Kubernetes client if not already done."""
47
+ if self._initialized:
48
+ return
49
+
50
+ if not K8S_AVAILABLE:
51
+ raise DoryK8sError(
52
+ "Kubernetes client not available. "
53
+ "Install with: pip install kubernetes"
54
+ )
55
+
56
+ try:
57
+ # Try in-cluster config first
58
+ config.load_incluster_config()
59
+ logger.debug("Using in-cluster Kubernetes config")
60
+ except config.ConfigException:
61
+ try:
62
+ # Fall back to kubeconfig
63
+ config.load_kube_config()
64
+ logger.debug("Using kubeconfig")
65
+ except config.ConfigException as e:
66
+ raise DoryK8sError(f"Failed to load Kubernetes config: {e}", cause=e)
67
+
68
+ self._core_api = client.CoreV1Api()
69
+
70
+ # Auto-detect namespace if not provided
71
+ if not self._namespace:
72
+ import os
73
+ self._namespace = os.environ.get("POD_NAMESPACE", "default")
74
+
75
+ self._initialized = True
76
+
77
+ @property
78
+ def namespace(self) -> str:
79
+ """Get current namespace."""
80
+ self._ensure_initialized()
81
+ return self._namespace
82
+
83
+ @property
84
+ def core_api(self):
85
+ """Get CoreV1Api client."""
86
+ self._ensure_initialized()
87
+ return self._core_api
88
+
89
+ async def get_pod(self, name: str) -> dict[str, Any]:
90
+ """
91
+ Get pod details.
92
+
93
+ Args:
94
+ name: Pod name
95
+
96
+ Returns:
97
+ Pod details as dictionary
98
+
99
+ Raises:
100
+ DoryK8sError: If operation fails
101
+ """
102
+ self._ensure_initialized()
103
+
104
+ try:
105
+ pod = self._core_api.read_namespaced_pod(
106
+ name=name,
107
+ namespace=self._namespace,
108
+ )
109
+ return pod.to_dict()
110
+
111
+ except ApiException as e:
112
+ if e.status == 404:
113
+ raise DoryK8sError(f"Pod not found: {name}", cause=e)
114
+ raise DoryK8sError(f"Failed to get pod {name}: {e}", cause=e)
115
+
116
+ async def get_pod_annotations(self, name: str) -> dict[str, str]:
117
+ """
118
+ Get pod annotations.
119
+
120
+ Args:
121
+ name: Pod name
122
+
123
+ Returns:
124
+ Annotations dictionary
125
+ """
126
+ pod = await self.get_pod(name)
127
+ return pod.get("metadata", {}).get("annotations", {})
128
+
129
+ async def patch_pod_annotations(
130
+ self,
131
+ name: str,
132
+ annotations: dict[str, str],
133
+ ) -> None:
134
+ """
135
+ Patch pod annotations.
136
+
137
+ Args:
138
+ name: Pod name
139
+ annotations: Annotations to add/update
140
+ """
141
+ self._ensure_initialized()
142
+
143
+ body = {
144
+ "metadata": {
145
+ "annotations": annotations,
146
+ }
147
+ }
148
+
149
+ try:
150
+ self._core_api.patch_namespaced_pod(
151
+ name=name,
152
+ namespace=self._namespace,
153
+ body=body,
154
+ )
155
+ logger.debug(f"Patched annotations on pod {name}")
156
+
157
+ except ApiException as e:
158
+ raise DoryK8sError(f"Failed to patch pod {name}: {e}", cause=e)
159
+
160
+ async def get_configmap(self, name: str) -> dict[str, str] | None:
161
+ """
162
+ Get ConfigMap data.
163
+
164
+ Args:
165
+ name: ConfigMap name
166
+
167
+ Returns:
168
+ ConfigMap data, or None if not found
169
+ """
170
+ self._ensure_initialized()
171
+
172
+ try:
173
+ cm = self._core_api.read_namespaced_config_map(
174
+ name=name,
175
+ namespace=self._namespace,
176
+ )
177
+ return cm.data or {}
178
+
179
+ except ApiException as e:
180
+ if e.status == 404:
181
+ return None
182
+ raise DoryK8sError(f"Failed to get ConfigMap {name}: {e}", cause=e)
183
+
184
+ async def create_or_update_configmap(
185
+ self,
186
+ name: str,
187
+ data: dict[str, str],
188
+ labels: dict[str, str] | None = None,
189
+ ) -> None:
190
+ """
191
+ Create or update a ConfigMap.
192
+
193
+ Args:
194
+ name: ConfigMap name
195
+ data: ConfigMap data
196
+ labels: Optional labels
197
+ """
198
+ self._ensure_initialized()
199
+
200
+ configmap = client.V1ConfigMap(
201
+ metadata=client.V1ObjectMeta(
202
+ name=name,
203
+ namespace=self._namespace,
204
+ labels=labels,
205
+ ),
206
+ data=data,
207
+ )
208
+
209
+ try:
210
+ # Try create first
211
+ self._core_api.create_namespaced_config_map(
212
+ namespace=self._namespace,
213
+ body=configmap,
214
+ )
215
+ logger.debug(f"Created ConfigMap {name}")
216
+
217
+ except ApiException as e:
218
+ if e.status == 409:
219
+ # Already exists, update
220
+ self._core_api.replace_namespaced_config_map(
221
+ name=name,
222
+ namespace=self._namespace,
223
+ body=configmap,
224
+ )
225
+ logger.debug(f"Updated ConfigMap {name}")
226
+ else:
227
+ raise DoryK8sError(f"Failed to create ConfigMap {name}: {e}", cause=e)
228
+
229
+ async def delete_configmap(self, name: str) -> bool:
230
+ """
231
+ Delete a ConfigMap.
232
+
233
+ Args:
234
+ name: ConfigMap name
235
+
236
+ Returns:
237
+ True if deleted, False if not found
238
+ """
239
+ self._ensure_initialized()
240
+
241
+ try:
242
+ self._core_api.delete_namespaced_config_map(
243
+ name=name,
244
+ namespace=self._namespace,
245
+ )
246
+ return True
247
+
248
+ except ApiException as e:
249
+ if e.status == 404:
250
+ return False
251
+ raise DoryK8sError(f"Failed to delete ConfigMap {name}: {e}", cause=e)
@@ -0,0 +1,182 @@
1
+ """
2
+ Pod metadata extraction from Kubernetes environment.
3
+
4
+ Retrieves pod information from:
5
+ 1. Downward API environment variables
6
+ 2. Kubernetes API
7
+ 3. /etc/podinfo files
8
+ """
9
+
10
+ import logging
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class PodMetadata:
21
+ """
22
+ Pod metadata extracted from environment.
23
+
24
+ Populated from:
25
+ - Environment variables (POD_NAME, POD_NAMESPACE, etc.)
26
+ - Downward API files (/etc/podinfo/)
27
+ - Kubernetes API (if available)
28
+ """
29
+
30
+ # Core identification
31
+ name: str = ""
32
+ namespace: str = "default"
33
+ uid: str = ""
34
+
35
+ # Node information
36
+ node_name: str = ""
37
+ service_account: str = ""
38
+
39
+ # Labels and annotations
40
+ labels: dict[str, str] = field(default_factory=dict)
41
+ annotations: dict[str, str] = field(default_factory=dict)
42
+
43
+ # Resource info
44
+ cpu_request: str = ""
45
+ cpu_limit: str = ""
46
+ memory_request: str = ""
47
+ memory_limit: str = ""
48
+
49
+ # Container info
50
+ container_name: str = ""
51
+ image: str = ""
52
+
53
+ @classmethod
54
+ def from_environment(cls) -> "PodMetadata":
55
+ """
56
+ Create PodMetadata from environment.
57
+
58
+ Reads from environment variables and downward API files.
59
+ """
60
+ metadata = cls()
61
+
62
+ # Read from environment variables
63
+ metadata.name = os.environ.get("POD_NAME", "")
64
+ metadata.namespace = os.environ.get("POD_NAMESPACE", "default")
65
+ metadata.uid = os.environ.get("POD_UID", "")
66
+ metadata.node_name = os.environ.get("NODE_NAME", "")
67
+ metadata.service_account = os.environ.get("SERVICE_ACCOUNT", "")
68
+ metadata.container_name = os.environ.get("CONTAINER_NAME", "")
69
+
70
+ # Try reading from downward API files
71
+ metadata._read_downward_api_files()
72
+
73
+ # Parse labels/annotations from environment
74
+ metadata._parse_labels_from_env()
75
+
76
+ logger.debug(f"Pod metadata: {metadata}")
77
+ return metadata
78
+
79
+ def _read_downward_api_files(self) -> None:
80
+ """Read metadata from downward API volume mounts."""
81
+ podinfo_path = Path("/etc/podinfo")
82
+
83
+ if not podinfo_path.exists():
84
+ return
85
+
86
+ # Read labels file
87
+ labels_file = podinfo_path / "labels"
88
+ if labels_file.exists():
89
+ self.labels = self._parse_labels_file(labels_file)
90
+
91
+ # Read annotations file
92
+ annotations_file = podinfo_path / "annotations"
93
+ if annotations_file.exists():
94
+ self.annotations = self._parse_labels_file(annotations_file)
95
+
96
+ # Read individual files
97
+ for attr, filename in [
98
+ ("name", "name"),
99
+ ("namespace", "namespace"),
100
+ ("uid", "uid"),
101
+ ("node_name", "nodeName"),
102
+ ]:
103
+ file_path = podinfo_path / filename
104
+ if file_path.exists():
105
+ try:
106
+ value = file_path.read_text().strip()
107
+ if value:
108
+ setattr(self, attr, value)
109
+ except IOError:
110
+ pass
111
+
112
+ def _parse_labels_file(self, path: Path) -> dict[str, str]:
113
+ """
114
+ Parse labels/annotations file.
115
+
116
+ Format: key="value"
117
+ """
118
+ result = {}
119
+ try:
120
+ content = path.read_text()
121
+ for line in content.strip().split("\n"):
122
+ if "=" in line:
123
+ key, value = line.split("=", 1)
124
+ # Remove quotes
125
+ value = value.strip('"')
126
+ result[key] = value
127
+ except IOError:
128
+ pass
129
+ return result
130
+
131
+ def _parse_labels_from_env(self) -> None:
132
+ """Parse labels from POD_LABELS environment variable."""
133
+ labels_env = os.environ.get("POD_LABELS", "")
134
+ if labels_env:
135
+ # Format: key1=value1,key2=value2
136
+ for pair in labels_env.split(","):
137
+ if "=" in pair:
138
+ key, value = pair.split("=", 1)
139
+ self.labels[key] = value
140
+
141
+ def get_processor_id(self) -> str:
142
+ """
143
+ Get processor ID from metadata.
144
+
145
+ Uses label if present, otherwise pod name.
146
+ """
147
+ # Try label first
148
+ processor_id = self.labels.get("dory.io/processor-id")
149
+ if processor_id:
150
+ return processor_id
151
+
152
+ # Fall back to pod name with suffix stripped
153
+ name = self.name
154
+ if name:
155
+ # Strip deployment suffix (e.g., myapp-7f8d9c6b-x4h2j -> myapp)
156
+ parts = name.rsplit("-", 2)
157
+ if len(parts) >= 2:
158
+ return parts[0]
159
+
160
+ return name or "unknown"
161
+
162
+ def is_migration(self) -> bool:
163
+ """Check if this is a migration restart."""
164
+ return self.annotations.get("dory.io/migration") == "true"
165
+
166
+ def get_previous_pod(self) -> str | None:
167
+ """Get previous pod name if this is a migration."""
168
+ return self.annotations.get("dory.io/previous-pod")
169
+
170
+ def to_dict(self) -> dict[str, Any]:
171
+ """Convert to dictionary."""
172
+ return {
173
+ "name": self.name,
174
+ "namespace": self.namespace,
175
+ "uid": self.uid,
176
+ "node_name": self.node_name,
177
+ "service_account": self.service_account,
178
+ "labels": self.labels,
179
+ "annotations": self.annotations,
180
+ "container_name": self.container_name,
181
+ "processor_id": self.get_processor_id(),
182
+ }
@@ -0,0 +1,9 @@
1
+ """Structured logging utilities."""
2
+
3
+ from dory.logging.logger import setup_logging, get_logger, DoryLoggerAdapter
4
+
5
+ __all__ = [
6
+ "setup_logging",
7
+ "get_logger",
8
+ "DoryLoggerAdapter",
9
+ ]
dory/logging/logger.py ADDED
@@ -0,0 +1,175 @@
1
+ """
2
+ Structured logging for Dory SDK.
3
+
4
+ Provides JSON-formatted logging suitable for Kubernetes
5
+ and log aggregation systems.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import sys
11
+ import time
12
+ from typing import Any
13
+
14
+ from dory.types import LogFormat
15
+
16
+
17
+ class JsonFormatter(logging.Formatter):
18
+ """
19
+ JSON log formatter for structured logging.
20
+
21
+ Output format:
22
+ {
23
+ "timestamp": "2024-01-15T10:30:45.123456Z",
24
+ "level": "INFO",
25
+ "logger": "dory.core.app",
26
+ "message": "Processor starting",
27
+ "extra": {...}
28
+ }
29
+ """
30
+
31
+ def format(self, record: logging.LogRecord) -> str:
32
+ """Format log record as JSON."""
33
+ log_dict = {
34
+ "timestamp": self._format_timestamp(record.created),
35
+ "level": record.levelname,
36
+ "logger": record.name,
37
+ "message": record.getMessage(),
38
+ }
39
+
40
+ # Add exception info if present
41
+ if record.exc_info:
42
+ log_dict["exception"] = self.formatException(record.exc_info)
43
+
44
+ # Add extra fields
45
+ extra = {}
46
+ for key, value in record.__dict__.items():
47
+ if key not in (
48
+ "name", "msg", "args", "created", "levelname", "levelno",
49
+ "pathname", "filename", "module", "exc_info", "exc_text",
50
+ "stack_info", "lineno", "funcName", "relativeCreated",
51
+ "thread", "threadName", "processName", "process", "message",
52
+ "msecs", "taskName",
53
+ ):
54
+ extra[key] = self._serialize_value(value)
55
+
56
+ if extra:
57
+ log_dict["extra"] = extra
58
+
59
+ return json.dumps(log_dict)
60
+
61
+ def _format_timestamp(self, created: float) -> str:
62
+ """Format timestamp as ISO 8601."""
63
+ return time.strftime(
64
+ "%Y-%m-%dT%H:%M:%S",
65
+ time.gmtime(created)
66
+ ) + f".{int((created % 1) * 1000000):06d}Z"
67
+
68
+ def _serialize_value(self, value: Any) -> Any:
69
+ """Serialize value for JSON."""
70
+ if isinstance(value, (str, int, float, bool, type(None))):
71
+ return value
72
+ if isinstance(value, (list, tuple)):
73
+ return [self._serialize_value(v) for v in value]
74
+ if isinstance(value, dict):
75
+ return {k: self._serialize_value(v) for k, v in value.items()}
76
+ return str(value)
77
+
78
+
79
+ class TextFormatter(logging.Formatter):
80
+ """
81
+ Human-readable text formatter.
82
+
83
+ Output format:
84
+ 2024-01-15 10:30:45.123 [INFO] dory.core.app: Processor starting
85
+ """
86
+
87
+ def __init__(self):
88
+ super().__init__(
89
+ fmt="%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s",
90
+ datefmt="%Y-%m-%d %H:%M:%S",
91
+ )
92
+
93
+
94
+ class DoryLoggerAdapter(logging.LoggerAdapter):
95
+ """
96
+ Logger adapter that adds context to all log messages.
97
+
98
+ Usage:
99
+ logger = DoryLoggerAdapter(
100
+ logging.getLogger(__name__),
101
+ {"processor_id": "my-processor", "pod": "my-pod-abc123"}
102
+ )
103
+ logger.info("Processing started")
104
+ # Output includes processor_id and pod in extra fields
105
+ """
106
+
107
+ def process(self, msg: str, kwargs: dict) -> tuple[str, dict]:
108
+ """Add extra context to log kwargs."""
109
+ extra = kwargs.get("extra", {})
110
+ extra.update(self.extra)
111
+ kwargs["extra"] = extra
112
+ return msg, kwargs
113
+
114
+
115
+ def setup_logging(
116
+ level: str = "INFO",
117
+ format: str | LogFormat = LogFormat.JSON,
118
+ ) -> None:
119
+ """
120
+ Setup logging configuration for Dory SDK.
121
+
122
+ Args:
123
+ level: Log level (DEBUG, INFO, WARNING, ERROR)
124
+ format: Log format (json or text)
125
+ """
126
+ # Convert enum to string if needed
127
+ if isinstance(format, LogFormat):
128
+ format = format.value
129
+
130
+ # Get root logger
131
+ root_logger = logging.getLogger()
132
+ root_logger.setLevel(getattr(logging, level.upper()))
133
+
134
+ # Remove existing handlers
135
+ for handler in root_logger.handlers[:]:
136
+ root_logger.removeHandler(handler)
137
+
138
+ # Create console handler
139
+ handler = logging.StreamHandler(sys.stdout)
140
+ handler.setLevel(getattr(logging, level.upper()))
141
+
142
+ # Set formatter based on format
143
+ if format == "json":
144
+ handler.setFormatter(JsonFormatter())
145
+ else:
146
+ handler.setFormatter(TextFormatter())
147
+
148
+ root_logger.addHandler(handler)
149
+
150
+ # Set levels for noisy libraries
151
+ logging.getLogger("kubernetes").setLevel(logging.WARNING)
152
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
153
+ logging.getLogger("aiohttp").setLevel(logging.WARNING)
154
+
155
+
156
+ def get_logger(
157
+ name: str,
158
+ extra: dict[str, Any] | None = None,
159
+ ) -> logging.Logger | DoryLoggerAdapter:
160
+ """
161
+ Get a logger with optional extra context.
162
+
163
+ Args:
164
+ name: Logger name (usually __name__)
165
+ extra: Optional extra context to include in all logs
166
+
167
+ Returns:
168
+ Logger or LoggerAdapter
169
+ """
170
+ logger = logging.getLogger(name)
171
+
172
+ if extra:
173
+ return DoryLoggerAdapter(logger, extra)
174
+
175
+ return logger
@@ -0,0 +1,7 @@
1
+ """Prometheus metrics collection."""
2
+
3
+ from dory.metrics.collector import MetricsCollector
4
+
5
+ __all__ = [
6
+ "MetricsCollector",
7
+ ]