dockerbrain 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.
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, asdict
5
+ from datetime import datetime, timezone
6
+ from enum import Enum
7
+ from typing import TYPE_CHECKING
8
+
9
+ from core.utils import calc_cpu_percent
10
+
11
+ if TYPE_CHECKING:
12
+ from docker.models.containers import Container
13
+
14
+
15
+ class Severity(str, Enum):
16
+ HIGH = "HIGH"
17
+ MEDIUM = "MEDIUM"
18
+ LOW = "LOW"
19
+
20
+ @property
21
+ def rank(self) -> int:
22
+ return {"HIGH": 0, "MEDIUM": 1, "LOW": 2}[self.value]
23
+
24
+ @property
25
+ def style(self) -> str:
26
+ return {"HIGH": "bold red", "MEDIUM": "bold yellow", "LOW": "bold green"}[self.value]
27
+
28
+
29
+ @dataclass
30
+ class Suggestion:
31
+ """A single optimisation suggestion produced by a rule."""
32
+
33
+ container_name: str
34
+ rule_name: str
35
+ severity: Severity
36
+ message: str
37
+ action_command: str | None = None
38
+
39
+ def to_dict(self) -> dict:
40
+ d = asdict(self)
41
+ d["severity"] = self.severity.value
42
+ return d
43
+
44
+
45
+ class Rule(ABC):
46
+ """Base class for all optimisation rules."""
47
+
48
+ name: str = "UnnamedRule"
49
+
50
+ @abstractmethod
51
+ def evaluate(self, ctr: Container, stats: dict) -> list[Suggestion]:
52
+ """Return zero or more suggestions for the given container."""
53
+ ...
54
+
55
+
56
+ class IdleContainerRule(Rule):
57
+ """Flag containers that have been idle (CPU < 0.5%) for > 10 minutes.
58
+
59
+ Heuristic: if current CPU is near-zero **and** the container has been
60
+ running for more than 10 minutes, we flag it. A more precise detection
61
+ relies on the monitor's consecutive-poll tracker, but this gives a good
62
+ one-shot signal during ``analyze``.
63
+ """
64
+
65
+ name = "IdleContainerRule"
66
+ _CPU_THRESHOLD = 0.5
67
+ _MIN_UPTIME_SECS = 600
68
+
69
+ def evaluate(self, ctr: Container, stats: dict) -> list[Suggestion]:
70
+ cpu = calc_cpu_percent(stats)
71
+ if cpu >= self._CPU_THRESHOLD:
72
+ return []
73
+
74
+ started_at = ctr.attrs.get("State", {}).get("StartedAt", "")
75
+ if started_at:
76
+ try:
77
+ start_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
78
+ uptime = (datetime.now(timezone.utc) - start_dt).total_seconds()
79
+ if uptime < self._MIN_UPTIME_SECS:
80
+ return []
81
+ except (ValueError, TypeError):
82
+ pass
83
+
84
+ return [
85
+ Suggestion(
86
+ container_name=ctr.name,
87
+ rule_name=self.name,
88
+ severity=Severity.MEDIUM,
89
+ message=(
90
+ f"Container is idle (CPU {cpu:.2f}%) and has been running for "
91
+ f"over 10 minutes. Consider stopping it to reclaim resources."
92
+ ),
93
+ action_command=f"docker stop {ctr.name}",
94
+ )
95
+ ]
96
+
97
+
98
+ class MemoryHogRule(Rule):
99
+ """Flag containers using > 80% of their memory limit."""
100
+
101
+ name = "MemoryHogRule"
102
+ _THRESHOLD = 80.0
103
+
104
+ def evaluate(self, ctr: Container, stats: dict) -> list[Suggestion]:
105
+ mem_usage = stats.get("memory_stats", {}).get("usage", 0)
106
+ mem_limit = stats.get("memory_stats", {}).get("limit", 0)
107
+
108
+ if not mem_limit or mem_limit <= 0:
109
+ return []
110
+
111
+ mem_pct = (mem_usage / mem_limit) * 100.0
112
+ if mem_pct <= self._THRESHOLD:
113
+ return []
114
+
115
+ usage_mb = mem_usage / (1024 * 1024)
116
+ limit_mb = mem_limit / (1024 * 1024)
117
+ new_limit = int(limit_mb * 1.5)
118
+
119
+ return [
120
+ Suggestion(
121
+ container_name=ctr.name,
122
+ rule_name=self.name,
123
+ severity=Severity.HIGH,
124
+ message=(
125
+ f"Memory usage is {mem_pct:.1f}% ({usage_mb:.0f} MB / {limit_mb:.0f} MB). "
126
+ f"Consider increasing the limit or profiling the application."
127
+ ),
128
+ action_command=f"docker update --memory {new_limit}m {ctr.name}",
129
+ )
130
+ ]
131
+
132
+
133
+ class NoMemoryLimitRule(Rule):
134
+ """Flag containers with no memory limit (limit == total host RAM)."""
135
+
136
+ name = "NoMemoryLimitRule"
137
+
138
+ def evaluate(self, ctr: Container, stats: dict) -> list[Suggestion]:
139
+ mem_limit = stats.get("memory_stats", {}).get("limit", 0)
140
+ mem_usage = stats.get("memory_stats", {}).get("usage", 0)
141
+
142
+ _16_GIB = 16 * 1024 * 1024 * 1024
143
+ if mem_limit < _16_GIB:
144
+ return []
145
+
146
+ usage_mb = mem_usage / (1024 * 1024)
147
+ suggested_mb = max(256, int(usage_mb * 2))
148
+
149
+ return [
150
+ Suggestion(
151
+ container_name=ctr.name,
152
+ rule_name=self.name,
153
+ severity=Severity.MEDIUM,
154
+ message=(
155
+ "No memory limit is set. The container can consume all host memory. "
156
+ f"Current usage: {usage_mb:.0f} MB. Consider adding a --memory flag."
157
+ ),
158
+ action_command=f"docker update --memory {suggested_mb}m {ctr.name}",
159
+ )
160
+ ]
161
+
162
+
163
+ class HighRestartRule(Rule):
164
+ """Flag containers with a restart count > 3."""
165
+
166
+ name = "HighRestartRule"
167
+ _THRESHOLD = 3
168
+
169
+ def evaluate(self, ctr: Container, stats: dict) -> list[Suggestion]:
170
+ restart_count = ctr.attrs.get("RestartCount", 0)
171
+ if restart_count <= self._THRESHOLD:
172
+ return []
173
+
174
+ return [
175
+ Suggestion(
176
+ container_name=ctr.name,
177
+ rule_name=self.name,
178
+ severity=Severity.HIGH,
179
+ message=(
180
+ f"Container has restarted {restart_count} times. "
181
+ "Investigate crash loops — check logs with the action command."
182
+ ),
183
+ action_command=f"docker logs --tail 50 {ctr.name}",
184
+ )
185
+ ]
186
+
187
+
188
+ class StaleImageRule(Rule):
189
+ """Flag containers whose image is older than 30 days."""
190
+
191
+ name = "StaleImageRule"
192
+ _MAX_AGE_DAYS = 30
193
+
194
+ def evaluate(self, ctr: Container, stats: dict) -> list[Suggestion]:
195
+ try:
196
+ image = ctr.image
197
+ created_str = image.attrs.get("Created", "")
198
+ if not created_str:
199
+ return []
200
+
201
+ created_dt = datetime.fromisoformat(created_str.replace("Z", "+00:00"))
202
+ age_days = (datetime.now(timezone.utc) - created_dt).days
203
+
204
+ if age_days <= self._MAX_AGE_DAYS:
205
+ return []
206
+
207
+ image_tag = image.tags[0] if image.tags else image.short_id
208
+ return [
209
+ Suggestion(
210
+ container_name=ctr.name,
211
+ rule_name=self.name,
212
+ severity=Severity.LOW,
213
+ message=(
214
+ f"Image '{image_tag}' is {age_days} days old. "
215
+ "Consider rebuilding with the latest base to pick up security patches."
216
+ ),
217
+ action_command=f"docker pull {image_tag}" if image.tags else None,
218
+ )
219
+ ]
220
+ except Exception:
221
+ return []
core/storage.py ADDED
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from core.monitor import ContainerSnapshot
10
+
11
+ _DB_PATH = Path.home() / ".dockerbrain" / "metrics.db"
12
+
13
+ _METRICS_SCHEMA = """
14
+ CREATE TABLE IF NOT EXISTS container_metrics (
15
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
16
+ timestamp TEXT NOT NULL,
17
+ container TEXT NOT NULL,
18
+ status TEXT,
19
+ cpu_percent REAL,
20
+ mem_usage_mb REAL,
21
+ mem_limit_mb REAL,
22
+ mem_percent REAL,
23
+ net_rx_bytes INTEGER,
24
+ net_tx_bytes INTEGER,
25
+ is_idle INTEGER DEFAULT 0,
26
+ idle_polls INTEGER DEFAULT 0
27
+ );
28
+ """
29
+
30
+ _AI_SCHEMA = """
31
+ CREATE TABLE IF NOT EXISTS ai_suggestions (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ timestamp TEXT NOT NULL,
34
+ summary TEXT NOT NULL,
35
+ full_response TEXT
36
+ );
37
+ """
38
+
39
+
40
+ def _get_connection() -> sqlite3.Connection:
41
+ """Return a connection, creating the database and tables if needed."""
42
+ _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
43
+ conn = sqlite3.connect(str(_DB_PATH))
44
+ conn.execute(_METRICS_SCHEMA)
45
+ conn.execute(_AI_SCHEMA)
46
+ conn.commit()
47
+ return conn
48
+
49
+ # Write
50
+ def store_snapshot(snap: ContainerSnapshot) -> None:
51
+ """Persist a single ContainerSnapshot row."""
52
+ with _get_connection() as conn:
53
+ conn.execute(
54
+ """
55
+ INSERT INTO container_metrics
56
+ (timestamp, container, status, cpu_percent,
57
+ mem_usage_mb, mem_limit_mb, mem_percent,
58
+ net_rx_bytes, net_tx_bytes, is_idle, idle_polls)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
60
+ """,
61
+ (
62
+ snap.timestamp,
63
+ snap.name,
64
+ snap.status,
65
+ snap.cpu_percent,
66
+ snap.mem_usage_mb,
67
+ snap.mem_limit_mb,
68
+ snap.mem_percent,
69
+ snap.net_rx_bytes,
70
+ snap.net_tx_bytes,
71
+ int(snap.is_idle),
72
+ snap.idle_polls,
73
+ ),
74
+ )
75
+ conn.commit()
76
+
77
+ def store_metrics(row: dict) -> None:
78
+ """Legacy helper — accepts a plain dict (backward compat)."""
79
+ with _get_connection() as conn:
80
+ conn.execute(
81
+ """
82
+ INSERT INTO container_metrics
83
+ (timestamp, container, cpu_percent,
84
+ mem_usage_mb, mem_limit_mb, mem_percent,
85
+ net_rx_bytes, net_tx_bytes)
86
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
87
+ """,
88
+ (
89
+ datetime.now(timezone.utc).isoformat(),
90
+ row["name"],
91
+ row["cpu_percent"],
92
+ row.get("mem_usage_mb", row.get("mem_usage", 0) / (1024 * 1024)),
93
+ row.get("mem_limit_mb", row.get("mem_limit", 0) / (1024 * 1024)),
94
+ row["mem_percent"],
95
+ row.get("net_rx_bytes", row.get("net_rx", 0)),
96
+ row.get("net_tx_bytes", row.get("net_tx", 0)),
97
+ ),
98
+ )
99
+ conn.commit()
100
+
101
+ # Read
102
+ def get_recent_metrics(container: str, limit: int = 60) -> list[dict]:
103
+ """Retrieve the most recent *limit* rows for a given container."""
104
+ with _get_connection() as conn:
105
+ cursor = conn.execute(
106
+ "SELECT * FROM container_metrics WHERE container = ? ORDER BY id DESC LIMIT ?",
107
+ (container, limit),
108
+ )
109
+ columns = [desc[0] for desc in cursor.description]
110
+ return [dict(zip(columns, r)) for r in cursor.fetchall()]
111
+
112
+
113
+ def get_metrics_since(since_iso: str, container: str | None = None) -> list[dict]:
114
+ """Return all rows with timestamp >= *since_iso*.
115
+
116
+ Args:
117
+ since_iso: ISO-8601 UTC timestamp string (lower bound, inclusive).
118
+ container: Optional container name filter.
119
+ """
120
+ with _get_connection() as conn:
121
+ if container:
122
+ cursor = conn.execute(
123
+ "SELECT * FROM container_metrics WHERE timestamp >= ? AND container = ? ORDER BY timestamp",
124
+ (since_iso, container),
125
+ )
126
+ else:
127
+ cursor = conn.execute(
128
+ "SELECT * FROM container_metrics WHERE timestamp >= ? ORDER BY timestamp",
129
+ (since_iso,),
130
+ )
131
+ columns = [desc[0] for desc in cursor.description]
132
+ return [dict(zip(columns, r)) for r in cursor.fetchall()]
133
+
134
+
135
+ def get_all_container_names() -> list[str]:
136
+ """Return distinct container names from the metrics table."""
137
+ with _get_connection() as conn:
138
+ cursor = conn.execute("SELECT DISTINCT container FROM container_metrics ORDER BY container")
139
+ return [row[0] for row in cursor.fetchall()]
140
+
141
+
142
+ # LLM suggestion cache
143
+ def store_ai_suggestion(summary: str, full_response: str = "") -> None:
144
+ """Cache an AI suggestion for later retrieval."""
145
+ with _get_connection() as conn:
146
+ conn.execute(
147
+ "INSERT INTO ai_suggestions (timestamp, summary, full_response) VALUES (?, ?, ?)",
148
+ (datetime.now(timezone.utc).isoformat(), summary, full_response),
149
+ )
150
+ conn.commit()
151
+
152
+ def get_last_ai_suggestion() -> dict | None:
153
+ """Retrieve the most recent AI suggestion row."""
154
+ with _get_connection() as conn:
155
+ cursor = conn.execute(
156
+ "SELECT timestamp, summary, full_response FROM ai_suggestions ORDER BY id DESC LIMIT 1"
157
+ )
158
+ row = cursor.fetchone()
159
+ if row:
160
+ return {"timestamp": row[0], "summary": row[1], "full_response": row[2]}
161
+ return None