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/fixer/container.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.prompt import Confirm
|
|
9
|
+
|
|
10
|
+
from core.llm import load_llm_config, generate
|
|
11
|
+
from core.optimizer import RuleBasedOptimizer
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
_BLOCKED_COMMANDS = {"rm", "rmi", "system prune", "volume rm", "network rm", "image rm"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_safe_command(command: str) -> bool:
|
|
19
|
+
"""Return True if the docker command is NOT destructive."""
|
|
20
|
+
cmd_lower = command.strip().lower()
|
|
21
|
+
if not cmd_lower.startswith("docker"):
|
|
22
|
+
return False
|
|
23
|
+
parts = cmd_lower.split()
|
|
24
|
+
if len(parts) < 2:
|
|
25
|
+
return False
|
|
26
|
+
sub = parts[1]
|
|
27
|
+
if sub in _BLOCKED_COMMANDS:
|
|
28
|
+
return False
|
|
29
|
+
if len(parts) >= 3:
|
|
30
|
+
two_word = f"{parts[1]} {parts[2]}"
|
|
31
|
+
if two_word in _BLOCKED_COMMANDS:
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_CONTAINER_FIX_SYSTEM_INSTRUCTION = (
|
|
37
|
+
"You are a Docker container optimization expert. Based on the metrics and issues "
|
|
38
|
+
"provided, generate a JSON array of fix actions.\n\n"
|
|
39
|
+
"CRITICAL: Your entire response MUST be a valid JSON array and nothing else. "
|
|
40
|
+
"No markdown, no explanation, no code fences — just the raw JSON.\n\n"
|
|
41
|
+
"Each element in the array must be an object with exactly these keys:\n"
|
|
42
|
+
' - "container": the container name\n'
|
|
43
|
+
' - "command": the exact docker CLI command to run (e.g. "docker update --memory 256m my_container")\n'
|
|
44
|
+
' - "reason": a one-line explanation of why this fix is needed\n\n'
|
|
45
|
+
"Rules:\n"
|
|
46
|
+
"- Only suggest commands that are safe and non-destructive.\n"
|
|
47
|
+
"- Do NOT suggest `docker rm`, `docker rmi`, `docker system prune`, or any delete commands.\n"
|
|
48
|
+
"- Allowed commands include: `docker update`, `docker restart`, `docker stop`, `docker pull`.\n"
|
|
49
|
+
"- Do NOT suggest lowering memory limits just because current usage is low.\n"
|
|
50
|
+
"- If no fixes are needed, return an empty array: []\n"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fix_containers(container_name: str | None = None) -> None:
|
|
55
|
+
"""Analyze containers, get AI fix commands as JSON, confirm and execute each."""
|
|
56
|
+
console.print("[dim]Running analysis...[/]\n")
|
|
57
|
+
optimizer = RuleBasedOptimizer()
|
|
58
|
+
suggestions = optimizer.analyze(container_name=container_name)
|
|
59
|
+
|
|
60
|
+
if not suggestions:
|
|
61
|
+
console.print("[green]No issues found — all containers look healthy![/]")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
console.print(
|
|
65
|
+
f"[bold]Found {len(suggestions)} issue(s). Preparing fixes...[/]\n"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
issues_text = "\n".join(
|
|
69
|
+
f"- [{s.severity.value}] {s.container_name} ({s.rule_name}): {s.message}"
|
|
70
|
+
for s in suggestions
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
prompt = (
|
|
74
|
+
"## Container Issues to Fix\n\n"
|
|
75
|
+
f"{issues_text}\n\n"
|
|
76
|
+
"Generate the JSON array of fix actions."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
config = load_llm_config()
|
|
80
|
+
|
|
81
|
+
with console.status("[bold green]Please wait...[/]", spinner="dots"):
|
|
82
|
+
raw_text = generate(
|
|
83
|
+
prompt=prompt,
|
|
84
|
+
system_instruction=_CONTAINER_FIX_SYSTEM_INSTRUCTION,
|
|
85
|
+
config=config,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
actions = json.loads(raw_text)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
console.print(
|
|
92
|
+
Panel(
|
|
93
|
+
"[red bold]Could not parse response as JSON.[/]\n\n"
|
|
94
|
+
f"[dim]Raw response:[/]\n{raw_text}",
|
|
95
|
+
title="[bold red]Parse Error[/]",
|
|
96
|
+
border_style="red",
|
|
97
|
+
expand=False,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if not actions:
|
|
103
|
+
console.print("[green]No fixes needed.[/]")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
console.print(
|
|
107
|
+
Panel(
|
|
108
|
+
f"[bold]{len(actions)} fix action(s) proposed:[/]",
|
|
109
|
+
border_style="cyan",
|
|
110
|
+
expand=False,
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
executed = 0
|
|
115
|
+
skipped = 0
|
|
116
|
+
blocked = 0
|
|
117
|
+
|
|
118
|
+
for i, action in enumerate(actions, 1):
|
|
119
|
+
container = action.get("container", "?")
|
|
120
|
+
command = action.get("command", "")
|
|
121
|
+
reason = action.get("reason", "")
|
|
122
|
+
|
|
123
|
+
console.print(f"\n[bold cyan]Fix {i}/{len(actions)}[/]")
|
|
124
|
+
console.print(f" Container: [cyan]{container}[/]")
|
|
125
|
+
console.print(f" Reason: [dim]{reason}[/]")
|
|
126
|
+
console.print(f" Command: [bold]{command}[/]")
|
|
127
|
+
|
|
128
|
+
if not _is_safe_command(command):
|
|
129
|
+
console.print(
|
|
130
|
+
f" [red bold]BLOCKED[/] — This command is destructive and cannot be auto-executed."
|
|
131
|
+
)
|
|
132
|
+
blocked += 1
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
if Confirm.ask(" Execute?", default=False):
|
|
136
|
+
try:
|
|
137
|
+
result = subprocess.run(
|
|
138
|
+
command.split(),
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
timeout=30,
|
|
142
|
+
)
|
|
143
|
+
if result.returncode == 0:
|
|
144
|
+
out = result.stdout.strip()
|
|
145
|
+
console.print(f" [green bold]Done[/]{': ' + out if out else ''}")
|
|
146
|
+
executed += 1
|
|
147
|
+
else:
|
|
148
|
+
console.print(f" [red bold]Failed[/]: {result.stderr.strip()}")
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
console.print(f" [red bold]Error[/]: {exc}")
|
|
151
|
+
else:
|
|
152
|
+
console.print(" [dim]Skipped.[/]")
|
|
153
|
+
skipped += 1
|
|
154
|
+
|
|
155
|
+
console.print()
|
|
156
|
+
summary_parts = []
|
|
157
|
+
if executed:
|
|
158
|
+
summary_parts.append(f"[green]{executed} applied[/]")
|
|
159
|
+
if skipped:
|
|
160
|
+
summary_parts.append(f"[yellow]{skipped} skipped[/]")
|
|
161
|
+
if blocked:
|
|
162
|
+
summary_parts.append(f"[red]{blocked} blocked[/]")
|
|
163
|
+
|
|
164
|
+
console.print(
|
|
165
|
+
Panel(
|
|
166
|
+
" | ".join(summary_parts),
|
|
167
|
+
title="[bold]Fix Summary[/]",
|
|
168
|
+
border_style="cyan",
|
|
169
|
+
expand=False,
|
|
170
|
+
)
|
|
171
|
+
)
|
core/fixer/dockerfile.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.columns import Columns
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.prompt import Confirm
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from core.llm import load_llm_config, generate, LLMConfig
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
_DEFAULT_DOCKERIGNORE = """\
|
|
21
|
+
.git
|
|
22
|
+
.gitignore
|
|
23
|
+
.dockerignore
|
|
24
|
+
node_modules
|
|
25
|
+
__pycache__
|
|
26
|
+
*.pyc
|
|
27
|
+
.env
|
|
28
|
+
.venv
|
|
29
|
+
*.md
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_DOCKERFILE_DETECT_SYSTEM = (
|
|
35
|
+
"You are a Dockerfile security and optimization expert. Analyze the Dockerfile and return ONLY a "
|
|
36
|
+
"valid JSON array of issues. No markdown, no explanation, no code fences — just the raw JSON.\n\n"
|
|
37
|
+
"Each issue object must have exactly these keys:\n"
|
|
38
|
+
' - "line": integer (1-indexed) or null if not line-specific\n'
|
|
39
|
+
' - "rule": short snake_case rule name (e.g. "hardcoded-secret", "cache-busting-copy")\n'
|
|
40
|
+
' - "severity": one of "HIGH", "MEDIUM", or "LOW"\n'
|
|
41
|
+
' - "message": a one-sentence explanation of the issue\n\n'
|
|
42
|
+
"Detect all of these if present:\n"
|
|
43
|
+
" - Hardcoded secrets or tokens in ENV instructions\n"
|
|
44
|
+
" - Large or bloated base images (ubuntu, debian, centos — suggest slim/alpine)\n"
|
|
45
|
+
" - apt-get/apk install without --no-install-recommends\n"
|
|
46
|
+
" - Multiple separate RUN apt-get/apk commands (should be chained with &&)\n"
|
|
47
|
+
" - COPY . . before dependency install step (cache-busting)\n"
|
|
48
|
+
" - Missing non-root USER directive\n"
|
|
49
|
+
" - Shell form CMD/ENTRYPOINT instead of exec form (JSON array)\n"
|
|
50
|
+
" - No HEALTHCHECK defined\n"
|
|
51
|
+
" - Packages installed but cache not cleaned (no rm -rf /var/lib/apt/lists/*)\n"
|
|
52
|
+
" - Any other security or optimization issues\n\n"
|
|
53
|
+
"If no issues are found, return an empty JSON array: []"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
_DOCKERFILE_REWRITE_SYSTEM = (
|
|
57
|
+
"You are a Dockerfile optimization expert. You will be given a Dockerfile and a list of detected issues. "
|
|
58
|
+
"Rewrite the Dockerfile to fix ALL the issues. "
|
|
59
|
+
"Return ONLY the improved Dockerfile content with brief comments explaining changes. "
|
|
60
|
+
"IMPORTANT: NEVER place comments on the same line as code (no inline comments). "
|
|
61
|
+
"Always put comments on a separate line ABOVE the instruction they describe. "
|
|
62
|
+
"Do NOT wrap in markdown code fences. Do NOT add any explanation outside the Dockerfile."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ai_detect_dockerfile_issues(content: str, config: LLMConfig) -> list[dict]:
|
|
67
|
+
"""Call LLM to detect issues in a Dockerfile. Returns a list of issue dicts."""
|
|
68
|
+
|
|
69
|
+
with console.status("[bold green]Analyzing Dockerfile...[/]", spinner="dots"):
|
|
70
|
+
raw = generate(
|
|
71
|
+
prompt=f"## Dockerfile to Analyze\n\n```dockerfile\n{content}\n```",
|
|
72
|
+
system_instruction=_DOCKERFILE_DETECT_SYSTEM,
|
|
73
|
+
config=config,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return json.loads(raw)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
console.print(f"[red]Could not parse AI detection response as JSON.[/]\n[dim]{raw}[/]")
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _display_ai_issues_table(issues: list[dict], filename: str) -> None:
|
|
84
|
+
"""Render AI-detected issues as a Rich table."""
|
|
85
|
+
|
|
86
|
+
_SEV_STYLE = {"HIGH": "bold red", "MEDIUM": "bold yellow", "LOW": "bold green"}
|
|
87
|
+
_SEV_RANK = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
|
|
88
|
+
sorted_issues = sorted(issues, key=lambda i: _SEV_RANK.get(i.get("severity", "LOW"), 9))
|
|
89
|
+
|
|
90
|
+
table = Table(
|
|
91
|
+
title=f"Dockerfile Issues — {filename}",
|
|
92
|
+
expand=True,
|
|
93
|
+
border_style="dim",
|
|
94
|
+
show_lines=True,
|
|
95
|
+
)
|
|
96
|
+
table.add_column("#", justify="right", style="dim", width=4)
|
|
97
|
+
table.add_column("Line", justify="right", width=5)
|
|
98
|
+
table.add_column("Rule", style="bold", no_wrap=True)
|
|
99
|
+
table.add_column("Severity", justify="center", width=8)
|
|
100
|
+
table.add_column("Message")
|
|
101
|
+
|
|
102
|
+
for i, issue in enumerate(sorted_issues, 1):
|
|
103
|
+
sev = issue.get("severity", "LOW")
|
|
104
|
+
line_val = str(issue.get("line")) if issue.get("line") else "—"
|
|
105
|
+
table.add_row(
|
|
106
|
+
str(i),
|
|
107
|
+
line_val,
|
|
108
|
+
issue.get("rule", "unknown"),
|
|
109
|
+
Text(sev, style=_SEV_STYLE.get(sev, "dim")),
|
|
110
|
+
issue.get("message", ""),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
console.print()
|
|
114
|
+
console.print(table)
|
|
115
|
+
console.print(f"\n[dim]{len(issues)} issue(s) detected.[/]\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ai_rewrite_dockerfile(content: str, issues: list[dict], config: LLMConfig) -> str:
|
|
119
|
+
"""Call LLM to rewrite the Dockerfile fixing all detected issues."""
|
|
120
|
+
|
|
121
|
+
issues_text = "\n".join(
|
|
122
|
+
f"- [{i.get('severity')}] Line {i.get('line') or '?'} ({i.get('rule')}): {i.get('message')}"
|
|
123
|
+
for i in issues
|
|
124
|
+
)
|
|
125
|
+
prompt = (
|
|
126
|
+
f"## Dockerfile\n\n```dockerfile\n{content}\n```\n\n"
|
|
127
|
+
f"## Detected Issues\n\n{issues_text}\n\n"
|
|
128
|
+
"Please rewrite the Dockerfile to fix all the above issues."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
with console.status("[bold green]Generating fix...[/]", spinner="dots"):
|
|
132
|
+
return generate(
|
|
133
|
+
prompt=prompt,
|
|
134
|
+
system_instruction=_DOCKERFILE_REWRITE_SYSTEM,
|
|
135
|
+
config=config,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _show_diff(original: str, optimized: str, original_path: Path) -> None:
|
|
140
|
+
"""Display a side-by-side Rich diff between original and optimized Dockerfile."""
|
|
141
|
+
|
|
142
|
+
diff = list(difflib.unified_diff(
|
|
143
|
+
original.splitlines(),
|
|
144
|
+
optimized.splitlines(),
|
|
145
|
+
fromfile="Original",
|
|
146
|
+
tofile="Optimized",
|
|
147
|
+
lineterm="",
|
|
148
|
+
))
|
|
149
|
+
if not diff:
|
|
150
|
+
console.print("[green]The Dockerfile is already optimal.[/]")
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
left = Panel(
|
|
154
|
+
Syntax(original, "dockerfile", theme="monokai", line_numbers=True, word_wrap=True),
|
|
155
|
+
title="[bold red]Original[/]",
|
|
156
|
+
border_style="red",
|
|
157
|
+
expand=True,
|
|
158
|
+
)
|
|
159
|
+
right = Panel(
|
|
160
|
+
Syntax(optimized, "dockerfile", theme="monokai", line_numbers=True, word_wrap=True),
|
|
161
|
+
title="[bold green]Optimized[/]",
|
|
162
|
+
border_style="green",
|
|
163
|
+
expand=True,
|
|
164
|
+
)
|
|
165
|
+
console.print()
|
|
166
|
+
console.print(Columns([left, right], equal=True, expand=True))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def fix_dockerfile(dockerfile_path: str) -> None:
|
|
170
|
+
"""AI-powered detect + fix: detects issues, displays them, then rewrites the Dockerfile."""
|
|
171
|
+
original_path = Path(dockerfile_path)
|
|
172
|
+
content = original_path.read_text(encoding="utf-8")
|
|
173
|
+
filename = original_path.name
|
|
174
|
+
|
|
175
|
+
config = load_llm_config()
|
|
176
|
+
issues = _ai_detect_dockerfile_issues(content, config)
|
|
177
|
+
|
|
178
|
+
if not issues:
|
|
179
|
+
console.print("[green bold]No issues detected![/]")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
_display_ai_issues_table(issues, filename)
|
|
183
|
+
dockerignore_issues = [i for i in issues if i.get("rule") == "missing-dockerignore"]
|
|
184
|
+
dockerfile_issues = [i for i in issues if i.get("rule") != "missing-dockerignore"]
|
|
185
|
+
|
|
186
|
+
if dockerignore_issues:
|
|
187
|
+
dockerignore_path = original_path.parent / ".dockerignore"
|
|
188
|
+
if not dockerignore_path.exists():
|
|
189
|
+
if Confirm.ask(
|
|
190
|
+
f"[bold yellow]Create [cyan]{dockerignore_path}[/cyan]?[/] (fixes missing-dockerignore)",
|
|
191
|
+
default=True,
|
|
192
|
+
):
|
|
193
|
+
dockerignore_path.write_text(_DEFAULT_DOCKERIGNORE, encoding="utf-8")
|
|
194
|
+
console.print(f"[green bold]Created:[/] [cyan]{dockerignore_path}[/]\n")
|
|
195
|
+
else:
|
|
196
|
+
console.print("[dim]Skipped .dockerignore creation.[/]\n")
|
|
197
|
+
|
|
198
|
+
if not dockerfile_issues:
|
|
199
|
+
console.print("[green]All issues resolved![/]")
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
optimized = _ai_rewrite_dockerfile(content, dockerfile_issues, config)
|
|
203
|
+
_show_diff(content, optimized, original_path)
|
|
204
|
+
|
|
205
|
+
console.print()
|
|
206
|
+
if not Confirm.ask(
|
|
207
|
+
"[bold yellow]Apply this fix?[/] (overwrites the Dockerfile, a .bak backup will be created)",
|
|
208
|
+
default=False,
|
|
209
|
+
):
|
|
210
|
+
console.print("[dim]Skipped. No changes made.[/]")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
backup_path = original_path.with_suffix(original_path.suffix + ".bak")
|
|
214
|
+
shutil.copy2(original_path, backup_path)
|
|
215
|
+
console.print(f"[dim]Backup saved to: {backup_path}[/]")
|
|
216
|
+
original_path.write_text(optimized, encoding="utf-8")
|
|
217
|
+
console.print(f"[green bold]Dockerfile fixed![/] Written to: [cyan]{original_path}[/]")
|
|
218
|
+
|
|
219
|
+
console.print("\n[dim]Re-checking for remaining issues...[/]")
|
|
220
|
+
remaining = _ai_detect_dockerfile_issues(optimized, config)
|
|
221
|
+
if not remaining:
|
|
222
|
+
console.print("[green bold]All issues resolved![/]")
|
|
223
|
+
else:
|
|
224
|
+
console.print(f"[yellow]{len(remaining)} issue(s) still remain after fix:[/]")
|
|
225
|
+
_display_ai_issues_table(remaining, filename)
|
core/llm.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
|
|
12
|
+
"gemini": {
|
|
13
|
+
"model": "gemini-3.1-flash-lite-preview",
|
|
14
|
+
"base_url": "", # uses google-genai SDK, not OpenAI
|
|
15
|
+
},
|
|
16
|
+
"groq": {
|
|
17
|
+
"model": "llama-3.3-70b-versatile",
|
|
18
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
19
|
+
},
|
|
20
|
+
"ollama": {
|
|
21
|
+
"model": "llama3.1",
|
|
22
|
+
"base_url": "http://localhost:11434/v1",
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_VALID_PROVIDERS = set(_PROVIDER_DEFAULTS.keys())
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class LLMConfig:
|
|
30
|
+
"""Resolved LLM configuration."""
|
|
31
|
+
|
|
32
|
+
provider: str
|
|
33
|
+
model: str
|
|
34
|
+
api_key: str
|
|
35
|
+
base_url: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_rc_section(section: str = "llm") -> dict[str, str]:
|
|
39
|
+
"""Read a section from .dockerbrainrc (simple TOML-like INI parser)."""
|
|
40
|
+
rc_path = Path(".dockerbrainrc")
|
|
41
|
+
if not rc_path.exists():
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
data: dict[str, str] = {}
|
|
45
|
+
in_section = False
|
|
46
|
+
|
|
47
|
+
for line in rc_path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
stripped = line.strip()
|
|
49
|
+
if not stripped or stripped.startswith("#"):
|
|
50
|
+
continue
|
|
51
|
+
if stripped.startswith("["):
|
|
52
|
+
in_section = stripped.strip("[] ").lower() == section
|
|
53
|
+
continue
|
|
54
|
+
if in_section and "=" in stripped:
|
|
55
|
+
key, _, val = stripped.partition("=")
|
|
56
|
+
val = val.split("#")[0].strip().strip('"').strip("'")
|
|
57
|
+
data[key.strip()] = val
|
|
58
|
+
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_llm_config() -> LLMConfig:
|
|
63
|
+
"""Resolve LLM configuration from .dockerbrainrc only."""
|
|
64
|
+
|
|
65
|
+
rc = _read_rc_section("llm")
|
|
66
|
+
provider = (rc.get("provider") or "gemini").lower().strip()
|
|
67
|
+
|
|
68
|
+
if provider not in _VALID_PROVIDERS:
|
|
69
|
+
console.print(
|
|
70
|
+
f"[red bold]Unknown LLM provider:[/] [cyan]{provider}[/]\n"
|
|
71
|
+
f"[dim]Valid providers: {', '.join(sorted(_VALID_PROVIDERS))}[/]"
|
|
72
|
+
)
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
|
|
75
|
+
defaults = _PROVIDER_DEFAULTS[provider]
|
|
76
|
+
model = rc.get("model") or defaults["model"]
|
|
77
|
+
base_url = rc.get("base_url") or defaults["base_url"]
|
|
78
|
+
api_key = rc.get("api_key") or ""
|
|
79
|
+
|
|
80
|
+
if not api_key and provider != "ollama":
|
|
81
|
+
_show_missing_key_error(provider)
|
|
82
|
+
raise SystemExit(1)
|
|
83
|
+
|
|
84
|
+
if provider == "ollama" and not api_key:
|
|
85
|
+
api_key = "ollama"
|
|
86
|
+
|
|
87
|
+
return LLMConfig(
|
|
88
|
+
provider=provider,
|
|
89
|
+
model=model,
|
|
90
|
+
api_key=api_key,
|
|
91
|
+
base_url=base_url,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _show_missing_key_error(provider: str) -> None:
|
|
96
|
+
"""Show a brief error for missing API key."""
|
|
97
|
+
console.print(
|
|
98
|
+
Panel(
|
|
99
|
+
f"Add your key to [cyan].dockerbrainrc[/]:\n"
|
|
100
|
+
f' [cyan]api_key = "your_key_here"[/]',
|
|
101
|
+
title="[bold red]Missing API Key[/]",
|
|
102
|
+
border_style="red",
|
|
103
|
+
expand=False,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _strip_code_fences(text: str) -> str:
|
|
108
|
+
"""Strip markdown code fences that LLMs sometimes wrap responses in."""
|
|
109
|
+
text = text.strip()
|
|
110
|
+
if text.startswith("```"):
|
|
111
|
+
text = text[text.index("\n") + 1:] if "\n" in text else text
|
|
112
|
+
if text.endswith("```"):
|
|
113
|
+
text = text[:-3].rstrip()
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def generate(
|
|
118
|
+
prompt: str,
|
|
119
|
+
system_instruction: str,
|
|
120
|
+
config: LLMConfig | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
"""Send a prompt to the configured LLM and return the response text.
|
|
123
|
+
|
|
124
|
+
Works with any provider: Gemini uses google-genai SDK,
|
|
125
|
+
all others use the OpenAI-compatible API.
|
|
126
|
+
"""
|
|
127
|
+
if config is None:
|
|
128
|
+
config = load_llm_config()
|
|
129
|
+
|
|
130
|
+
if config.provider == "gemini":
|
|
131
|
+
return _generate_gemini(prompt, system_instruction, config)
|
|
132
|
+
else:
|
|
133
|
+
return _generate_openai_compat(prompt, system_instruction, config)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def generate_stream(
|
|
137
|
+
prompt: str,
|
|
138
|
+
system_instruction: str,
|
|
139
|
+
config: LLMConfig | None = None,
|
|
140
|
+
):
|
|
141
|
+
"""Yield response chunks from the configured LLM (streaming)."""
|
|
142
|
+
if config is None:
|
|
143
|
+
config = load_llm_config()
|
|
144
|
+
|
|
145
|
+
if config.provider == "gemini":
|
|
146
|
+
yield from _stream_gemini(prompt, system_instruction, config)
|
|
147
|
+
else:
|
|
148
|
+
yield from _stream_openai_compat(prompt, system_instruction, config)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Gemini backend
|
|
152
|
+
def _generate_gemini(prompt: str, system_instruction: str, config: LLMConfig) -> str:
|
|
153
|
+
from google import genai
|
|
154
|
+
from google.genai import types
|
|
155
|
+
|
|
156
|
+
client = genai.Client(api_key=config.api_key)
|
|
157
|
+
response = client.models.generate_content(
|
|
158
|
+
model=config.model,
|
|
159
|
+
contents=prompt,
|
|
160
|
+
config=types.GenerateContentConfig(
|
|
161
|
+
system_instruction=system_instruction,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
return _strip_code_fences(response.text)
|
|
165
|
+
|
|
166
|
+
def _stream_gemini(prompt: str, system_instruction: str, config: LLMConfig):
|
|
167
|
+
from google import genai
|
|
168
|
+
from google.genai import types
|
|
169
|
+
|
|
170
|
+
client = genai.Client(api_key=config.api_key)
|
|
171
|
+
stream = client.models.generate_content_stream(
|
|
172
|
+
model=config.model,
|
|
173
|
+
contents=prompt,
|
|
174
|
+
config=types.GenerateContentConfig(
|
|
175
|
+
system_instruction=system_instruction,
|
|
176
|
+
),
|
|
177
|
+
)
|
|
178
|
+
for chunk in stream:
|
|
179
|
+
if chunk.text:
|
|
180
|
+
yield chunk.text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# OpenAI-compatible backend
|
|
184
|
+
def _generate_openai_compat(prompt: str, system_instruction: str, config: LLMConfig) -> str:
|
|
185
|
+
from openai import OpenAI
|
|
186
|
+
|
|
187
|
+
client = OpenAI(api_key=config.api_key, base_url=config.base_url)
|
|
188
|
+
response = client.chat.completions.create(
|
|
189
|
+
model=config.model,
|
|
190
|
+
messages=[
|
|
191
|
+
{"role": "system", "content": system_instruction},
|
|
192
|
+
{"role": "user", "content": prompt},
|
|
193
|
+
],
|
|
194
|
+
)
|
|
195
|
+
return _strip_code_fences(response.choices[0].message.content or "")
|
|
196
|
+
|
|
197
|
+
def _stream_openai_compat(prompt: str, system_instruction: str, config: LLMConfig):
|
|
198
|
+
from openai import OpenAI
|
|
199
|
+
|
|
200
|
+
client = OpenAI(api_key=config.api_key, base_url=config.base_url)
|
|
201
|
+
stream = client.chat.completions.create(
|
|
202
|
+
model=config.model,
|
|
203
|
+
messages=[
|
|
204
|
+
{"role": "system", "content": system_instruction},
|
|
205
|
+
{"role": "user", "content": prompt},
|
|
206
|
+
],
|
|
207
|
+
stream=True,
|
|
208
|
+
)
|
|
209
|
+
for chunk in stream:
|
|
210
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
211
|
+
if delta and delta.content:
|
|
212
|
+
yield delta.content
|
core/monitor/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from core.monitor.snapshot import (
|
|
2
|
+
ContainerSnapshot,
|
|
3
|
+
IDLE_CPU_THRESHOLD,
|
|
4
|
+
IDLE_CONSECUTIVE_POLLS,
|
|
5
|
+
MEM_WARNING_PCT,
|
|
6
|
+
MEM_CRITICAL_PCT,
|
|
7
|
+
)
|
|
8
|
+
from core.monitor.display import (
|
|
9
|
+
_format_uptime,
|
|
10
|
+
_make_bar,
|
|
11
|
+
_cpu_color,
|
|
12
|
+
_mem_color,
|
|
13
|
+
build_monitor_layout,
|
|
14
|
+
)
|
|
15
|
+
from core.monitor.collector import (
|
|
16
|
+
ContainerMonitor,
|
|
17
|
+
run_monitor,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ContainerSnapshot",
|
|
22
|
+
"ContainerMonitor",
|
|
23
|
+
"run_monitor",
|
|
24
|
+
"build_monitor_layout",
|
|
25
|
+
"IDLE_CPU_THRESHOLD",
|
|
26
|
+
"IDLE_CONSECUTIVE_POLLS",
|
|
27
|
+
"MEM_WARNING_PCT",
|
|
28
|
+
"MEM_CRITICAL_PCT",
|
|
29
|
+
"_format_uptime",
|
|
30
|
+
"_make_bar",
|
|
31
|
+
"_cpu_color",
|
|
32
|
+
"_mem_color",
|
|
33
|
+
]
|