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.
- corekit/__init__.py +0 -0
- corekit/api/__init__.py +9 -0
- corekit/api/handler.py +76 -0
- corekit/api/responses.py +40 -0
- corekit/api/routers.py +115 -0
- corekit/concurrency/__init__.py +9 -0
- corekit/concurrency/decorators.py +72 -0
- corekit/concurrency/thread_local.py +99 -0
- corekit/concurrency/worker.py +65 -0
- corekit/config/__init__.py +47 -0
- corekit/config/loader.py +153 -0
- corekit/config/settings.py +161 -0
- corekit/config/sources.py +125 -0
- corekit/connections/__init__.py +31 -0
- corekit/connections/connectable.py +212 -0
- corekit/connections/decorators.py +92 -0
- corekit/connections/redis/__init__.py +7 -0
- corekit/connections/redis/connection.py +239 -0
- corekit/connections/registry.py +80 -0
- corekit/connections/sql/__init__.py +10 -0
- corekit/connections/sql/connection.py +342 -0
- corekit/connections/sql/fields/__init__.py +7 -0
- corekit/connections/sql/fields/jsonb.py +67 -0
- corekit/connections/sql/migration/__init__.py +57 -0
- corekit/connections/sql/migration/base.py +40 -0
- corekit/connections/sql/migration/operations.py +416 -0
- corekit/connections/sql/migration/registry.py +166 -0
- corekit/connections/sql/migration/table.py +27 -0
- corekit/connections/sql/query.py +68 -0
- corekit/connections/sql/table.py +96 -0
- corekit/constants.py +45 -0
- corekit/crypto/__init__.py +1 -0
- corekit/crypto/constants.py +7 -0
- corekit/crypto/enum.py +11 -0
- corekit/crypto/hasher.py +89 -0
- corekit/data/__init__.py +81 -0
- corekit/data/dataset.py +340 -0
- corekit/data/expressions/__init__.py +46 -0
- corekit/data/expressions/comparison.py +252 -0
- corekit/data/expressions/expression.py +98 -0
- corekit/data/record.py +147 -0
- corekit/data/stats.py +157 -0
- corekit/decorators/__init__.py +2 -0
- corekit/decorators/exception_handling.py +43 -0
- corekit/decorators/warnings.py +35 -0
- corekit/docker/__init__.py +7 -0
- corekit/docker/watchdog.py +222 -0
- corekit/etl/__init__.py +44 -0
- corekit/etl/connection.py +44 -0
- corekit/etl/extract/__init__.py +0 -0
- corekit/etl/extract/extractor.py +48 -0
- corekit/etl/extract/schemas.py +18 -0
- corekit/etl/load/__init__.py +0 -0
- corekit/etl/load/loader.py +53 -0
- corekit/etl/load/schemas.py +33 -0
- corekit/etl/orchestrator.py +201 -0
- corekit/etl/schemas.py +22 -0
- corekit/etl/transform/__init__.py +0 -0
- corekit/etl/transform/schemas.py +15 -0
- corekit/etl/transform/transformer.py +28 -0
- corekit/events/__init__.py +38 -0
- corekit/events/enum.py +58 -0
- corekit/events/frames.py +51 -0
- corekit/events/models.py +23 -0
- corekit/events/publisher.py +75 -0
- corekit/events/reader.py +132 -0
- corekit/events/sse.py +109 -0
- corekit/events/websocket.py +97 -0
- corekit/exceptions/__init__.py +0 -0
- corekit/exceptions/base.py +45 -0
- corekit/exceptions/custom/__init__.py +0 -0
- corekit/exceptions/http/__init__.py +0 -0
- corekit/exceptions/http/exceptions.py +37 -0
- corekit/exceptions/types.py +17 -0
- corekit/files/__init__.py +25 -0
- corekit/files/base.py +117 -0
- corekit/files/enum.py +30 -0
- corekit/files/json.py +12 -0
- corekit/files/pickle.py +12 -0
- corekit/files/toml.py +43 -0
- corekit/http/__init__.py +0 -0
- corekit/http/client.py +176 -0
- corekit/http/exponential_backoff.py +100 -0
- corekit/http/response.py +12 -0
- corekit/log_monitor/__init__.py +23 -0
- corekit/log_monitor/constants.py +8 -0
- corekit/log_monitor/models.py +150 -0
- corekit/log_monitor/service.py +418 -0
- corekit/notifications/__init__.py +8 -0
- corekit/notifications/base.py +51 -0
- corekit/notifications/models.py +34 -0
- corekit/observability/__init__.py +21 -0
- corekit/observability/benchmarkable.py +12 -0
- corekit/observability/loggable.py +29 -0
- corekit/observability/timing/__init__.py +0 -0
- corekit/observability/timing/constants.py +1 -0
- corekit/observability/timing/split.py +20 -0
- corekit/observability/timing/timer.py +30 -0
- corekit/py.typed +0 -0
- corekit/registry/__init__.py +12 -0
- corekit/registry/registry.py +134 -0
- corekit/schemas/__init__.py +0 -0
- corekit/schemas/dataclasses/__init__.py +0 -0
- corekit/schemas/enum.py +49 -0
- corekit/schemas/models/__init__.py +0 -0
- corekit/schemas/models/arbitrary.py +11 -0
- corekit/schemas/models/date_models.py +18 -0
- corekit/schemas/pydantic/__init__.py +0 -0
- corekit/schemas/pydantic/fields.py +35 -0
- corekit/schemas/types.py +40 -0
- corekit/serialization/__init__.py +0 -0
- corekit/serialization/enum.py +21 -0
- corekit/serialization/serializable.py +42 -0
- corekit/serialization/serializer.py +179 -0
- corekit/utils/__init__.py +5 -0
- corekit/utils/ids.py +5 -0
- corekit/utils/raise_exc.py +8 -0
- corekit/utils/time.py +21 -0
- corekit/utils/validators.py +15 -0
- corekit/utils/void.py +8 -0
- python_corekit-0.1.0.dist-info/METADATA +417 -0
- python_corekit-0.1.0.dist-info/RECORD +125 -0
- python_corekit-0.1.0.dist-info/WHEEL +5 -0
- python_corekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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"]
|