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.
- core/__init__.py +1 -0
- core/__main__.py +4 -0
- core/ai_advisor.py +345 -0
- core/cli.py +369 -0
- core/dockerizer.py +310 -0
- core/fixer/__init__.py +21 -0
- core/fixer/container.py +171 -0
- core/fixer/dockerfile.py +225 -0
- core/llm.py +212 -0
- core/monitor/__init__.py +33 -0
- core/monitor/collector.py +197 -0
- core/monitor/display.py +279 -0
- core/monitor/snapshot.py +57 -0
- core/optimizer/__init__.py +23 -0
- core/optimizer/engine.py +84 -0
- core/optimizer/rules.py +221 -0
- core/storage.py +161 -0
- core/templates.py +559 -0
- core/utils.py +38 -0
- dockerbrain-1.0.dist-info/METADATA +156 -0
- dockerbrain-1.0.dist-info/RECORD +25 -0
- dockerbrain-1.0.dist-info/WHEEL +5 -0
- dockerbrain-1.0.dist-info/entry_points.txt +2 -0
- dockerbrain-1.0.dist-info/licenses/LICENSE +201 -0
- dockerbrain-1.0.dist-info/top_level.txt +1 -0
core/optimizer/rules.py
ADDED
|
@@ -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
|