python-corekit 0.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 (125) hide show
  1. corekit/__init__.py +0 -0
  2. corekit/api/__init__.py +9 -0
  3. corekit/api/handler.py +76 -0
  4. corekit/api/responses.py +40 -0
  5. corekit/api/routers.py +115 -0
  6. corekit/concurrency/__init__.py +9 -0
  7. corekit/concurrency/decorators.py +72 -0
  8. corekit/concurrency/thread_local.py +99 -0
  9. corekit/concurrency/worker.py +65 -0
  10. corekit/config/__init__.py +47 -0
  11. corekit/config/loader.py +153 -0
  12. corekit/config/settings.py +161 -0
  13. corekit/config/sources.py +125 -0
  14. corekit/connections/__init__.py +31 -0
  15. corekit/connections/connectable.py +212 -0
  16. corekit/connections/decorators.py +92 -0
  17. corekit/connections/redis/__init__.py +7 -0
  18. corekit/connections/redis/connection.py +239 -0
  19. corekit/connections/registry.py +80 -0
  20. corekit/connections/sql/__init__.py +10 -0
  21. corekit/connections/sql/connection.py +342 -0
  22. corekit/connections/sql/fields/__init__.py +7 -0
  23. corekit/connections/sql/fields/jsonb.py +67 -0
  24. corekit/connections/sql/migration/__init__.py +57 -0
  25. corekit/connections/sql/migration/base.py +40 -0
  26. corekit/connections/sql/migration/operations.py +416 -0
  27. corekit/connections/sql/migration/registry.py +166 -0
  28. corekit/connections/sql/migration/table.py +27 -0
  29. corekit/connections/sql/query.py +68 -0
  30. corekit/connections/sql/table.py +96 -0
  31. corekit/constants.py +45 -0
  32. corekit/crypto/__init__.py +1 -0
  33. corekit/crypto/constants.py +7 -0
  34. corekit/crypto/enum.py +11 -0
  35. corekit/crypto/hasher.py +89 -0
  36. corekit/data/__init__.py +81 -0
  37. corekit/data/dataset.py +340 -0
  38. corekit/data/expressions/__init__.py +46 -0
  39. corekit/data/expressions/comparison.py +252 -0
  40. corekit/data/expressions/expression.py +98 -0
  41. corekit/data/record.py +147 -0
  42. corekit/data/stats.py +157 -0
  43. corekit/decorators/__init__.py +2 -0
  44. corekit/decorators/exception_handling.py +43 -0
  45. corekit/decorators/warnings.py +35 -0
  46. corekit/docker/__init__.py +7 -0
  47. corekit/docker/watchdog.py +222 -0
  48. corekit/etl/__init__.py +44 -0
  49. corekit/etl/connection.py +44 -0
  50. corekit/etl/extract/__init__.py +0 -0
  51. corekit/etl/extract/extractor.py +48 -0
  52. corekit/etl/extract/schemas.py +18 -0
  53. corekit/etl/load/__init__.py +0 -0
  54. corekit/etl/load/loader.py +53 -0
  55. corekit/etl/load/schemas.py +33 -0
  56. corekit/etl/orchestrator.py +201 -0
  57. corekit/etl/schemas.py +22 -0
  58. corekit/etl/transform/__init__.py +0 -0
  59. corekit/etl/transform/schemas.py +15 -0
  60. corekit/etl/transform/transformer.py +28 -0
  61. corekit/events/__init__.py +38 -0
  62. corekit/events/enum.py +58 -0
  63. corekit/events/frames.py +51 -0
  64. corekit/events/models.py +23 -0
  65. corekit/events/publisher.py +75 -0
  66. corekit/events/reader.py +132 -0
  67. corekit/events/sse.py +109 -0
  68. corekit/events/websocket.py +97 -0
  69. corekit/exceptions/__init__.py +0 -0
  70. corekit/exceptions/base.py +45 -0
  71. corekit/exceptions/custom/__init__.py +0 -0
  72. corekit/exceptions/http/__init__.py +0 -0
  73. corekit/exceptions/http/exceptions.py +37 -0
  74. corekit/exceptions/types.py +17 -0
  75. corekit/files/__init__.py +25 -0
  76. corekit/files/base.py +117 -0
  77. corekit/files/enum.py +30 -0
  78. corekit/files/json.py +12 -0
  79. corekit/files/pickle.py +12 -0
  80. corekit/files/toml.py +43 -0
  81. corekit/http/__init__.py +0 -0
  82. corekit/http/client.py +176 -0
  83. corekit/http/exponential_backoff.py +100 -0
  84. corekit/http/response.py +12 -0
  85. corekit/log_monitor/__init__.py +23 -0
  86. corekit/log_monitor/constants.py +8 -0
  87. corekit/log_monitor/models.py +150 -0
  88. corekit/log_monitor/service.py +418 -0
  89. corekit/notifications/__init__.py +8 -0
  90. corekit/notifications/base.py +51 -0
  91. corekit/notifications/models.py +34 -0
  92. corekit/observability/__init__.py +21 -0
  93. corekit/observability/benchmarkable.py +12 -0
  94. corekit/observability/loggable.py +29 -0
  95. corekit/observability/timing/__init__.py +0 -0
  96. corekit/observability/timing/constants.py +1 -0
  97. corekit/observability/timing/split.py +20 -0
  98. corekit/observability/timing/timer.py +30 -0
  99. corekit/py.typed +0 -0
  100. corekit/registry/__init__.py +12 -0
  101. corekit/registry/registry.py +134 -0
  102. corekit/schemas/__init__.py +0 -0
  103. corekit/schemas/dataclasses/__init__.py +0 -0
  104. corekit/schemas/enum.py +49 -0
  105. corekit/schemas/models/__init__.py +0 -0
  106. corekit/schemas/models/arbitrary.py +11 -0
  107. corekit/schemas/models/date_models.py +18 -0
  108. corekit/schemas/pydantic/__init__.py +0 -0
  109. corekit/schemas/pydantic/fields.py +35 -0
  110. corekit/schemas/types.py +40 -0
  111. corekit/serialization/__init__.py +0 -0
  112. corekit/serialization/enum.py +21 -0
  113. corekit/serialization/serializable.py +42 -0
  114. corekit/serialization/serializer.py +179 -0
  115. corekit/utils/__init__.py +5 -0
  116. corekit/utils/ids.py +5 -0
  117. corekit/utils/raise_exc.py +8 -0
  118. corekit/utils/time.py +21 -0
  119. corekit/utils/validators.py +15 -0
  120. corekit/utils/void.py +8 -0
  121. python_corekit-0.1.0.dist-info/METADATA +417 -0
  122. python_corekit-0.1.0.dist-info/RECORD +125 -0
  123. python_corekit-0.1.0.dist-info/WHEEL +5 -0
  124. python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
  125. python_corekit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,150 @@
1
+ """
2
+ Configuration models for the log monitor.
3
+
4
+ A configuration is a list of containers, each with rules; a rule is a regex and
5
+ the actions to take when a log line matches it. These parse straight from YAML.
6
+ """
7
+
8
+ from typing import Literal, Union
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from corekit.schemas.enum import StringEnum
13
+
14
+
15
+ class Severity(StringEnum):
16
+ """Severity levels for log pattern matches"""
17
+
18
+ INFO = "info"
19
+ WARNING = "warning"
20
+ CRITICAL = "critical"
21
+
22
+
23
+ class ActionType(StringEnum):
24
+ """Types of actions that can be executed"""
25
+
26
+ LOG = "log"
27
+ NOTIFY = "notify"
28
+ RESTART_CONTAINER = "restart_container"
29
+ PAUSE_CONTAINER = "pause_container"
30
+ EXECUTE_COMMAND = "execute_command"
31
+
32
+
33
+ class RateLimitPeriod(StringEnum):
34
+ """Time periods for rate limiting"""
35
+
36
+ MINUTE = "minute"
37
+ HOUR = "hour"
38
+ DAY = "day"
39
+
40
+
41
+ # Action Models
42
+ class BaseAction(BaseModel):
43
+ """Base class for all action types"""
44
+
45
+ type: ActionType
46
+
47
+
48
+ class LogAction(BaseAction):
49
+ """Log a message when pattern matches"""
50
+
51
+ type: Literal[ActionType.LOG] = ActionType.LOG
52
+ message: str = Field(..., description="Message template with {container} and {text} placeholders")
53
+
54
+
55
+ class NotifyAction(BaseAction):
56
+ """Send notification with throttling"""
57
+
58
+ type: Literal[ActionType.NOTIFY] = ActionType.NOTIFY
59
+ throttle: int = Field(default=0, description="Minimum seconds between notifications for same container")
60
+
61
+
62
+ class RestartContainerAction(BaseAction):
63
+ """Restart a container with safeguards"""
64
+
65
+ type: Literal[ActionType.RESTART_CONTAINER] = ActionType.RESTART_CONTAINER
66
+ max_restarts: int = Field(default=3, description="Maximum restarts within restart_window")
67
+ restart_window: int = Field(default=3600, description="Time window in seconds for max_restarts")
68
+ delay: int = Field(default=0, description="Delay in seconds before restarting")
69
+
70
+
71
+ class PauseContainerAction(BaseAction):
72
+ """Pause a container"""
73
+
74
+ type: Literal[ActionType.PAUSE_CONTAINER] = ActionType.PAUSE_CONTAINER
75
+
76
+
77
+ class ExecuteCommandAction(BaseAction):
78
+ """Execute a shell command"""
79
+
80
+ type: Literal[ActionType.EXECUTE_COMMAND] = ActionType.EXECUTE_COMMAND
81
+ command: str = Field(..., description="Command template with {container} placeholder")
82
+
83
+
84
+ # Union type for all actions
85
+ Action = Union[LogAction, NotifyAction, RestartContainerAction, PauseContainerAction, ExecuteCommandAction]
86
+
87
+
88
+ # Rule Model
89
+ class Rule(BaseModel):
90
+ """Log monitoring rule with pattern matching and actions"""
91
+
92
+ name: str = Field(..., description="Human-readable rule name")
93
+ pattern: str = Field(..., description="Regex pattern to match against log lines")
94
+ severity: Severity = Field(default=Severity.INFO, description="Severity level of matches")
95
+ actions: list[Action] = Field(default_factory=list, description="Actions to execute on match")
96
+ send_notification: bool = Field(
97
+ default=False, description="Whether to send notification on match (uses notification service)"
98
+ )
99
+
100
+
101
+ # Container Config Model
102
+ class ContainerConfig(BaseModel):
103
+ """Configuration for monitoring a specific container or pattern"""
104
+
105
+ name: str = Field(..., description="Container name or regex pattern, '*' for all")
106
+ enabled: bool = Field(default=True, description="Whether this config is active")
107
+ rules: list[Rule] = Field(default_factory=list, description="Rules to apply to this container")
108
+
109
+
110
+ # Advanced Config
111
+ class RateLimit(BaseModel):
112
+ """Rate limit configuration"""
113
+
114
+ count: int = Field(..., description="Number of actions allowed")
115
+ period: RateLimitPeriod = Field(..., description="Time period for the limit")
116
+
117
+ @property
118
+ def period_seconds(self) -> int:
119
+ """Convert period to seconds"""
120
+ mapping = {
121
+ RateLimitPeriod.MINUTE: 60,
122
+ RateLimitPeriod.HOUR: 3600,
123
+ RateLimitPeriod.DAY: 86400,
124
+ }
125
+ return mapping[self.period]
126
+
127
+
128
+ class AdvancedConfig(BaseModel):
129
+ """Advanced monitoring configuration"""
130
+
131
+ rate_limits: dict[ActionType, RateLimit] = Field(default_factory=dict, description="Rate limits per action type")
132
+ ignore_patterns: list[str] = Field(default_factory=list, description="Regex patterns to ignore in logs")
133
+
134
+
135
+ # Global Config
136
+ class GlobalConfig(BaseModel):
137
+ """Global monitoring settings"""
138
+
139
+ log_buffer_size: int = Field(default=1000, description="Max log lines to buffer per container")
140
+
141
+
142
+ # Main Config Model
143
+ class LogMonitorConfig(BaseModel):
144
+ """Complete log monitor configuration"""
145
+
146
+ global_config: GlobalConfig = Field(alias="global", default_factory=GlobalConfig)
147
+ containers: list[ContainerConfig] = Field(default_factory=list)
148
+ advanced: AdvancedConfig = Field(default_factory=AdvancedConfig)
149
+
150
+ model_config = {"populate_by_name": True}
@@ -0,0 +1,418 @@
1
+ """
2
+ React to what containers write to their logs.
3
+
4
+ A YAML file maps containers to rules; each rule is a regex and the actions to
5
+ take when a log line matches -- log it, notify, restart or pause the container,
6
+ or run a command. Actions are rate limited, and restarts are additionally capped
7
+ per container so a crash loop cannot become a restart loop.
8
+
9
+ LogMonitor.run("config.yaml")
10
+
11
+ """
12
+
13
+ import re
14
+ import signal
15
+ import subprocess
16
+ import threading
17
+ import time
18
+ from collections import defaultdict, deque
19
+ from concurrent.futures import Future, ThreadPoolExecutor
20
+ from typing import Any
21
+
22
+ import yaml
23
+ from docker.errors import APIError, NotFound
24
+ from docker.models.containers import Container
25
+
26
+ from corekit.docker.watchdog import Watchdog
27
+ from corekit.log_monitor import constants as log_monitor_constants
28
+ from corekit.log_monitor.models import (
29
+ Action,
30
+ ActionType,
31
+ ExecuteCommandAction,
32
+ LogAction,
33
+ LogMonitorConfig,
34
+ NotifyAction,
35
+ PauseContainerAction,
36
+ RestartContainerAction,
37
+ Rule,
38
+ Severity,
39
+ )
40
+ from corekit.notifications import BaseNotificationService, Notification, NotificationType
41
+
42
+
43
+ class LogMonitor(Watchdog):
44
+ """
45
+ Monitors Docker container logs and triggers actions based on pattern matching.
46
+ Inherits Docker operations from Watchdog, composes with notification services.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ config_path: str | None = None,
52
+ config: LogMonitorConfig | None = None,
53
+ notification_service: BaseNotificationService | None = None,
54
+ enforce_label: bool = False,
55
+ max_workers: int | None = None,
56
+ docker_host: str | None = None,
57
+ handle_signals: bool = False,
58
+ ) -> None:
59
+ """
60
+ :param config_path: YAML configuration to load.
61
+ :param config: an already-built configuration, instead of a path.
62
+ :param notification_service: where notifications go; logs by default.
63
+ :param enforce_label: only act on containers carrying the managed label.
64
+ :param handle_signals: install SIGTERM and SIGINT handlers. Off by
65
+ default because installing them is process-global and only the
66
+ program's entry point should decide that; ``run()`` turns it on.
67
+ """
68
+ super().__init__(docker_host=docker_host, enforce_label=enforce_label)
69
+
70
+ if config is not None:
71
+ self.config = config
72
+ else:
73
+ path = config_path or log_monitor_constants.DEFAULT_CONFIG_PATH
74
+ with open(path) as handle:
75
+ self.config = LogMonitorConfig(**yaml.safe_load(handle))
76
+
77
+ # Notification service composition (not reimplementation)
78
+ self._notification_service = notification_service or BaseNotificationService()
79
+
80
+ # Thread management for graceful shutdown
81
+ self._shutdown_event = threading.Event()
82
+ self._max_workers = max_workers or log_monitor_constants.DEFAULT_MAX_WORKERS
83
+ self._executor: ThreadPoolExecutor | None = None
84
+ self._futures: dict[str, Future] = {}
85
+
86
+ if handle_signals:
87
+ self.install_signal_handlers()
88
+
89
+ # Log monitoring state (LogMonitor-specific)
90
+ # TODO: determine if these should be a constant or part of the config
91
+ # TODO: some of this stuff might make a LOT more sense being stored in redis...
92
+ self.action_counts: dict[ActionType, deque[float]] = defaultdict(
93
+ lambda: deque(maxlen=log_monitor_constants.ACTION_COUNTS_MAX_LEN)
94
+ )
95
+ self.restart_counts: dict[str, deque[float]] = defaultdict(
96
+ lambda: deque(maxlen=log_monitor_constants.RESTART_COUNTS_MAX_LEN)
97
+ )
98
+ self.log_buffers: dict[str, deque[str]] = defaultdict(
99
+ lambda: deque(maxlen=self.config.global_config.log_buffer_size)
100
+ )
101
+ # Properly track notification throttling per container
102
+ self.last_notify_times: dict[str, float] = {}
103
+
104
+ def _signal_handler(self, signum: int, frame: Any) -> None:
105
+ """Handle SIGTERM/SIGINT for graceful shutdown"""
106
+ signal_name = signal.Signals(signum).name
107
+ self.warning(f"Received {signal_name}, initiating graceful shutdown...")
108
+ self.shutdown()
109
+
110
+ @staticmethod
111
+ def _severity_to_notification_type(severity: Severity) -> NotificationType:
112
+ """Map log monitor severity to notification type"""
113
+ mapping = {
114
+ Severity.INFO: NotificationType.INFO,
115
+ Severity.WARNING: NotificationType.WARNING,
116
+ Severity.CRITICAL: NotificationType.CRITICAL,
117
+ }
118
+ return mapping.get(severity, NotificationType.INFO)
119
+
120
+ def _send_notification(self, message: str, severity: Severity) -> None:
121
+ """Send notification using the notification service"""
122
+ notification = Notification(
123
+ message=message, type=self._severity_to_notification_type(severity), meta={"source": "LogMonitor"}
124
+ )
125
+ self._notification_service.notify(notification)
126
+
127
+ def check_rate_limit(self, action_type: ActionType) -> bool:
128
+ """Check if action is within rate limits"""
129
+ if action_type not in self.config.advanced.rate_limits:
130
+ return True
131
+
132
+ rate_limit = self.config.advanced.rate_limits[action_type]
133
+
134
+ # Clean old entries
135
+ cutoff = time.time() - rate_limit.period_seconds
136
+ self.action_counts[action_type] = deque(
137
+ [t for t in self.action_counts[action_type] if t > cutoff],
138
+ maxlen=log_monitor_constants.ACTION_COUNTS_MAX_LEN,
139
+ )
140
+
141
+ return len(self.action_counts[action_type]) < rate_limit.count
142
+
143
+ def _execute_log_action(self, action: LogAction, container_name: str, matched_text: str) -> None:
144
+ """Execute log action - write formatted message to log"""
145
+ message = action.message.format(container=container_name, text=matched_text)
146
+ self.info(f"[LogAction] {message}")
147
+
148
+ def _execute_notify_action(self, action: NotifyAction, container_name: str) -> None:
149
+ """Execute notify action - check throttle only (actual notification sent separately)"""
150
+ # Check throttle
151
+ last_notify = self.last_notify_times.get(container_name, 0.0)
152
+ if time.time() - last_notify < action.throttle:
153
+ self.debug(f"Notification throttled for {container_name}")
154
+ return
155
+ self.last_notify_times[container_name] = time.time()
156
+
157
+ def _execute_restart_action(self, action: RestartContainerAction, container_name: str) -> None:
158
+ """Execute restart action - delegate to Watchdog parent"""
159
+
160
+ # Check restart count
161
+ cutoff = time.time() - action.restart_window
162
+ self.restart_counts[container_name] = deque(
163
+ [t for t in self.restart_counts[container_name] if t > cutoff],
164
+ maxlen=log_monitor_constants.RESTART_COUNTS_MAX_LEN,
165
+ )
166
+
167
+ if len(self.restart_counts[container_name]) >= action.max_restarts:
168
+ self.warning(f"Max restarts ({action.max_restarts}) reached for {container_name}, skipping")
169
+ return
170
+
171
+ if action.delay > 0:
172
+ self.info(f"Waiting {action.delay}s before restarting {container_name}")
173
+ time.sleep(action.delay)
174
+
175
+ try:
176
+ # Delegate to parent Watchdog method
177
+ container = self._client.containers.get(container_name)
178
+ container.restart()
179
+ self.restart_counts[container_name].append(time.time())
180
+ self.info(f"Restarted container: {container_name}")
181
+ except NotFound:
182
+ self.error(f"Container '{container_name}' not found")
183
+ except APIError as e:
184
+ self.error(f"Failed to restart {container_name}: {e}")
185
+
186
+ def _execute_pause_action(self, action: PauseContainerAction, container_name: str) -> None:
187
+ """Execute pause action - delegate to Watchdog parent"""
188
+ self.pause_container_by_name(container_name)
189
+
190
+ def _execute_command_action(self, action: ExecuteCommandAction, container_name: str) -> None:
191
+ """Execute shell command action"""
192
+ command = action.command.format(container=container_name)
193
+ try:
194
+ result = subprocess.run(
195
+ command,
196
+ shell=True,
197
+ capture_output=True,
198
+ text=True,
199
+ timeout=30, # 30 second timeout for safety
200
+ )
201
+ if result.returncode == 0:
202
+ self.info(f"Executed: {command}\nOutput: {result.stdout}")
203
+ else:
204
+ self.error(f"Command failed: {command}\nError: {result.stderr}")
205
+ except subprocess.TimeoutExpired:
206
+ self.error(f"Command timed out: {command}")
207
+ except Exception as e:
208
+ self.error(f"Failed to execute command: {e}")
209
+
210
+ def execute_action(self, action: Action, container_name: str, matched_text: str = "") -> None:
211
+ """Execute configured actions by delegating to specific action handlers"""
212
+ action_type = action.type
213
+
214
+ # Check rate limits
215
+ if not self.check_rate_limit(action_type):
216
+ self.warning(f"Rate limit exceeded for {action_type.value}, skipping")
217
+ return
218
+
219
+ self.action_counts[action_type].append(time.time())
220
+
221
+ if action_type is ActionType.LOG:
222
+ self._execute_log_action(action, container_name, matched_text)
223
+ elif action_type is ActionType.NOTIFY:
224
+ self._execute_notify_action(action, container_name)
225
+ elif action_type is ActionType.RESTART_CONTAINER:
226
+ self._execute_restart_action(action, container_name)
227
+ elif action_type is ActionType.PAUSE_CONTAINER:
228
+ self._execute_pause_action(action, container_name)
229
+ elif action_type is ActionType.EXECUTE_COMMAND:
230
+ self._execute_command_action(action, container_name)
231
+ else:
232
+ self.warning(f"No handler for action type {action_type}")
233
+
234
+ def process_log_line(self, container_name: str, line: str, rules: list[Rule]) -> None:
235
+ """Process a single log line against all rules"""
236
+ for rule in rules:
237
+ match = re.search(rule.pattern, line)
238
+
239
+ if match:
240
+ # Log the match
241
+ log_message = f"{container_name}: {rule.name} - {line.strip()}"
242
+ if rule.severity == Severity.CRITICAL:
243
+ self.error(log_message)
244
+ elif rule.severity == Severity.WARNING:
245
+ self.warning(log_message)
246
+ else:
247
+ self.info(log_message)
248
+
249
+ # Execute actions
250
+ for action in rule.actions:
251
+ self.execute_action(action, container_name, line)
252
+
253
+ # Send notification if configured
254
+ if rule.send_notification:
255
+ message = f"*{rule.name}* in `{container_name}`\n```{line.strip()}```"
256
+ self._send_notification(message, rule.severity)
257
+
258
+ # FIXME: does this actually make more sense being part of watchdog?
259
+ def monitor_container(self, container_name: str, rules: list[Rule]) -> None:
260
+ """Monitor a specific container's logs with graceful shutdown support"""
261
+
262
+ try:
263
+ container: Container = self._client.containers.get(container_name)
264
+ self.info(f"Started monitoring: {container_name}")
265
+
266
+ # Stream logs with tail to avoid replaying entire history
267
+ log_stream = container.logs(stream=True, follow=True, tail=100)
268
+
269
+ for line in log_stream:
270
+ # Check for shutdown signal
271
+ if self._shutdown_event.is_set():
272
+ self.info(f"Shutdown requested, stopping monitor for: {container_name}")
273
+ break
274
+
275
+ decoded_line = line.decode("utf-8", errors="ignore")
276
+
277
+ # Check against ignore patterns FIRST before buffering
278
+ if any(re.search(pattern, decoded_line) for pattern in self.config.advanced.ignore_patterns):
279
+ continue
280
+
281
+ # Only buffer lines that pass ignore patterns
282
+ self.log_buffers[container_name].append(decoded_line)
283
+
284
+ self.process_log_line(container_name, decoded_line, rules)
285
+
286
+ except NotFound:
287
+ self.error(f"Container '{container_name}' not found")
288
+ except APIError as e:
289
+ self.error(f"Docker API error monitoring {container_name}: {e}")
290
+ except Exception as e:
291
+ self.error(f"Unexpected error monitoring {container_name}: {e}")
292
+ finally:
293
+ self.info(f"Stopped monitoring: {container_name}")
294
+
295
+ def get_container_rules(self, container_name: str) -> list[Rule]:
296
+ """Get rules for a specific container"""
297
+ rules: list[Rule] = []
298
+
299
+ for container_config in self.config.containers:
300
+ if not container_config.enabled:
301
+ continue
302
+
303
+ name_pattern = container_config.name
304
+
305
+ # Check if this config applies to this container
306
+ if name_pattern == "*" or re.match(name_pattern, container_name):
307
+ rules.extend(container_config.rules)
308
+
309
+ return rules
310
+
311
+ def is_healthy(self) -> bool:
312
+ """Health check for container orchestration"""
313
+ if self._executor is None:
314
+ return False
315
+
316
+ # Check if shutdown has been requested
317
+ if self._shutdown_event.is_set():
318
+ return False
319
+
320
+ # Check if we have active monitors running
321
+ active_monitors = sum(1 for f in self._futures.values() if not f.done())
322
+
323
+ return active_monitors > 0
324
+
325
+ def get_monitor_status(self) -> dict[str, Any]:
326
+ """Get detailed monitoring status for observability"""
327
+ return {
328
+ "active_monitors": sum(1 for f in self._futures.values() if not f.done()),
329
+ "failed_monitors": sum(1 for f in self._futures.values() if f.done() and f.exception()),
330
+ "total_monitors": len(self._futures),
331
+ "shutdown_requested": self._shutdown_event.is_set(),
332
+ "max_workers": self._max_workers,
333
+ }
334
+
335
+ def start(self) -> None:
336
+ """Start monitoring all containers using thread pool"""
337
+ self.info("Docker Log Monitor starting...")
338
+ self.info(f"Config loaded: {len(self.config.containers)} container configs")
339
+ self.info(f"Thread pool max workers: {self._max_workers}")
340
+
341
+ # Initialize thread pool executor
342
+ self._executor = ThreadPoolExecutor(max_workers=self._max_workers, thread_name_prefix="log_monitor")
343
+
344
+ # Get all running containers (delegate to parent list_containers)
345
+ containers: list[Container] = self.list_containers()
346
+
347
+ for container in containers:
348
+ container_name = container.name
349
+ rules = self.get_container_rules(container_name)
350
+
351
+ if rules:
352
+ # Submit to thread pool instead of creating unlimited threads
353
+ future = self._executor.submit(self.monitor_container, container_name, rules)
354
+ self._futures[container_name] = future
355
+ self.info(f"Submitted monitoring task for: {container_name}")
356
+
357
+ self.info(f"Monitoring {len(self._futures)} containers")
358
+
359
+ # Keep main thread alive and periodically check monitor health
360
+ try:
361
+ while not self._shutdown_event.is_set():
362
+ time.sleep(5)
363
+
364
+ # Check for failed monitors
365
+ for container_name, future in list(self._futures.items()):
366
+ if future.done():
367
+ exception = future.exception()
368
+ if exception:
369
+ self.error(f"Monitor for {container_name} failed: {exception}")
370
+ else:
371
+ self.info(f"Monitor for {container_name} completed normally")
372
+
373
+ # Remove from active futures
374
+ del self._futures[container_name]
375
+
376
+ # Optional: Log status periodically (disabled to reduce log volume)
377
+ # if len(self._futures) > 0:
378
+ # self.debug(f"Active monitors: {len(self._futures)}")
379
+
380
+ except KeyboardInterrupt:
381
+ self.info("KeyboardInterrupt received")
382
+ self.shutdown()
383
+
384
+ def shutdown(self) -> None:
385
+ """Gracefully shutdown all monitoring threads"""
386
+ if self._shutdown_event.is_set():
387
+ self.debug("Shutdown already requested, waiting for completion...")
388
+ else:
389
+ self.info("Initiating graceful shutdown...")
390
+ self._shutdown_event.set()
391
+
392
+ if self._executor:
393
+ active_count = sum(1 for f in self._futures.values() if not f.done())
394
+ if active_count > 0:
395
+ self.info(f"Waiting for {active_count} monitors to stop...")
396
+
397
+ # Wait for threads to finish gracefully
398
+ self._executor.shutdown(wait=True, cancel_futures=False)
399
+
400
+ self.info("All monitoring threads stopped")
401
+
402
+ self.info("LogMonitor shutdown complete")
403
+
404
+ def install_signal_handlers(self) -> None:
405
+ """
406
+ Shut down cleanly on SIGTERM and SIGINT.
407
+
408
+ Process-global, so only an entry point should call it.
409
+ """
410
+ signal.signal(signal.SIGTERM, self._signal_handler)
411
+ signal.signal(signal.SIGINT, self._signal_handler)
412
+
413
+ @classmethod
414
+ def run(cls, config_path: str | None = None, **kwargs: Any) -> None:
415
+ """
416
+ Run as a program: install signal handlers and monitor until stopped.
417
+ """
418
+ cls(config_path=config_path, handle_signals=True, **kwargs).start()
@@ -0,0 +1,8 @@
1
+ """
2
+ Notification delivery and payloads.
3
+ """
4
+
5
+ from corekit.notifications.base import BaseNotificationService
6
+ from corekit.notifications.models import Notification, NotificationType
7
+
8
+ __all__ = ["BaseNotificationService", "Notification", "NotificationType"]
@@ -0,0 +1,51 @@
1
+ """
2
+ Notification delivery.
3
+
4
+ Subclass ``BaseNotificationService`` and implement ``_send``::
5
+
6
+ class EmailNotificationService(BaseNotificationService):
7
+ '''
8
+ Sends notifications by email.
9
+ '''
10
+
11
+ def _send(self, message: str) -> None:
12
+ smtp.send(message)
13
+
14
+ service.notify(Notification(message="disk full", type=NotificationType.ERROR))
15
+
16
+ Override ``_send``, not ``send``: ``notify`` formats the message and calls
17
+ ``_send``, so an override with any other name silently does nothing.
18
+ """
19
+
20
+ from corekit.notifications.models import Notification
21
+ from corekit.observability.loggable import Loggable
22
+
23
+ __all__ = ["BaseNotificationService"]
24
+
25
+
26
+ class BaseNotificationService(Loggable):
27
+ """
28
+ Formats notifications and hands them to a transport.
29
+
30
+ The default transport logs, so an unconfigured service is harmless rather
31
+ than broken.
32
+ """
33
+
34
+ @staticmethod
35
+ def _format(notification: Notification) -> str:
36
+ """
37
+ Render a notification as the text a transport will send.
38
+ """
39
+ return f"[{notification.type} Notification]: {notification.message}"
40
+
41
+ def _send(self, message: str) -> None:
42
+ """
43
+ Deliver formatted text. Override this in a subclass.
44
+ """
45
+ self.warning(message)
46
+
47
+ def notify(self, notification: Notification) -> None:
48
+ """
49
+ Format a notification and deliver it.
50
+ """
51
+ self._send(self._format(notification))
@@ -0,0 +1,34 @@
1
+ """
2
+ Notification payloads.
3
+ """
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from corekit.schemas.enum import StringEnum
10
+
11
+ __all__ = ["Notification", "NotificationType"]
12
+
13
+
14
+ class NotificationType(StringEnum):
15
+ """
16
+ Severity of a notification.
17
+ """
18
+
19
+ NOTE = "NOTE"
20
+ DEBUG = "DEBUG"
21
+ INFO = "INFO"
22
+ WARNING = "WARNING"
23
+ ERROR = "ERROR"
24
+ CRITICAL = "CRITICAL"
25
+
26
+
27
+ class Notification(BaseModel):
28
+ """
29
+ A message to deliver, with its severity and any surrounding context.
30
+ """
31
+
32
+ message: str
33
+ type: NotificationType = NotificationType.INFO
34
+ meta: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,21 @@
1
+ """
2
+ Logging and performance measurement.
3
+
4
+ ``Loggable`` gives a class a logger named after itself; ``Benchmarkable`` adds
5
+ split timing on top. They live together because they are the same concern --
6
+ knowing what a running system is doing.
7
+
8
+ class Importer(Benchmarkable):
9
+ def run(self) -> None:
10
+ self.timing()
11
+ self.info("starting")
12
+ ...
13
+ self.timing("finished")
14
+ """
15
+
16
+ from corekit.observability.benchmarkable import Benchmarkable
17
+ from corekit.observability.loggable import Loggable
18
+ from corekit.observability.timing.split import Split
19
+ from corekit.observability.timing.timer import Timer
20
+
21
+ __all__ = ["Benchmarkable", "Loggable", "Split", "Timer"]