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/cli.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import click
|
|
3
|
+
from core import __version__
|
|
4
|
+
|
|
5
|
+
def print_version(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
6
|
+
"""Print version with build metadata and exit."""
|
|
7
|
+
if not value or ctx.resilient_parsing:
|
|
8
|
+
return
|
|
9
|
+
click.echo(
|
|
10
|
+
f"dockerbrain v{__version__} "
|
|
11
|
+
f"(Python {platform.python_version()}, {platform.system()} {platform.machine()})"
|
|
12
|
+
)
|
|
13
|
+
ctx.exit()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _HiddenHelpCommand(click.Command):
|
|
17
|
+
"""A Click Command that hides the built-in --help from the options list."""
|
|
18
|
+
|
|
19
|
+
def get_params(self, ctx: click.Context) -> list:
|
|
20
|
+
params = super().get_params(ctx)
|
|
21
|
+
for p in params:
|
|
22
|
+
if isinstance(p, click.Option) and p.name == "help":
|
|
23
|
+
p.hidden = True
|
|
24
|
+
return params
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _HiddenHelpGroup(click.Group):
|
|
28
|
+
"""A Click Group whose subcommands all use HiddenHelpCommand."""
|
|
29
|
+
command_class = _HiddenHelpCommand
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@click.group(cls=_HiddenHelpGroup, add_help_option=False)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--help",
|
|
35
|
+
is_flag=True,
|
|
36
|
+
is_eager=True,
|
|
37
|
+
expose_value=False,
|
|
38
|
+
hidden=True,
|
|
39
|
+
callback=lambda ctx, param, value: (click.echo(ctx.get_help(), color=ctx.color) or ctx.exit()) if value else None,
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--version",
|
|
43
|
+
is_flag=True,
|
|
44
|
+
callback=print_version,
|
|
45
|
+
expose_value=False,
|
|
46
|
+
is_eager=True,
|
|
47
|
+
help="Check DockerBrain version.",
|
|
48
|
+
)
|
|
49
|
+
def cli() -> None:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
# Commands
|
|
53
|
+
@cli.command()
|
|
54
|
+
@click.option("--interval", "-i", default=1, show_default=True, help="Polling interval in seconds.")
|
|
55
|
+
@click.option("--duration", "-d", default=None, type=int, help="Total seconds to monitor (default: unlimited).")
|
|
56
|
+
def monitor(interval: int, duration: int | None) -> None:
|
|
57
|
+
"""Display containers' stats."""
|
|
58
|
+
from core.monitor import run_monitor
|
|
59
|
+
run_monitor(interval=interval, duration=duration)
|
|
60
|
+
|
|
61
|
+
@cli.command("suggest")
|
|
62
|
+
@click.option("--container", "-c", default=None, help="Target a specific container (default: all).")
|
|
63
|
+
@click.option("--window", "-w", default=30, show_default=True, help="Minutes of metric history to include.")
|
|
64
|
+
@click.option("--dockerfile", "-f", default=None, type=click.Path(exists=True), help="Analyze a Dockerfile instead of containers.")
|
|
65
|
+
@click.option("--no-rules", is_flag=True, default=False, help="Skip rule-based suggestions, send raw metrics only.")
|
|
66
|
+
def ai_suggest(container: str | None, window: int, dockerfile: str | None, no_rules: bool) -> None:
|
|
67
|
+
"""Get optimization suggestions for Dockerfiles and containers.
|
|
68
|
+
|
|
69
|
+
Two modes:
|
|
70
|
+
|
|
71
|
+
\b
|
|
72
|
+
1. Container mode (default):
|
|
73
|
+
Collects metrics from the last --window minutes and asks LLM
|
|
74
|
+
for optimizations.
|
|
75
|
+
2. Dockerfile mode (--dockerfile PATH):
|
|
76
|
+
Reads the Dockerfile and asks LLM to suggest overall improvements.
|
|
77
|
+
|
|
78
|
+
Requires an API key set in .dockerbrainrc.
|
|
79
|
+
"""
|
|
80
|
+
from core.ai_advisor import run_ai_suggest
|
|
81
|
+
|
|
82
|
+
run_ai_suggest(
|
|
83
|
+
container_name=container,
|
|
84
|
+
window_minutes=window,
|
|
85
|
+
dockerfile_path=dockerfile,
|
|
86
|
+
no_rules=no_rules,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@cli.command()
|
|
90
|
+
@click.option("--dockerfile", "-f", default=None, type=click.Path(exists=True),
|
|
91
|
+
help="Path to a Dockerfile to auto-fix.")
|
|
92
|
+
@click.option("--container", "-c", default=None,
|
|
93
|
+
help="Target a specific container (default: all).")
|
|
94
|
+
def fix(dockerfile: str | None, container: str | None) -> None:
|
|
95
|
+
"""Automatically diagnose and fix Docker issues.
|
|
96
|
+
|
|
97
|
+
\b
|
|
98
|
+
Two modes:
|
|
99
|
+
1. Dockerfile mode (--dockerfile PATH):
|
|
100
|
+
Detects anti-patterns, issues of Dockerfile,
|
|
101
|
+
and overwrites with a .bak backup on confirmation.
|
|
102
|
+
2. Container mode (default):
|
|
103
|
+
Runs analysis, asks LLM for improvements,
|
|
104
|
+
and executes with your confirmation.
|
|
105
|
+
|
|
106
|
+
Requires an API key set in .dockerbrainrc.
|
|
107
|
+
"""
|
|
108
|
+
from core.fixer import run_fix
|
|
109
|
+
|
|
110
|
+
run_fix(dockerfile_path=dockerfile, container_name=container)
|
|
111
|
+
|
|
112
|
+
@cli.command()
|
|
113
|
+
@click.option("--force", is_flag=True, default=False,
|
|
114
|
+
help="Overwrite existing Dockerfile and .dockerignore forcefully.")
|
|
115
|
+
def dockerize(force: bool) -> None:
|
|
116
|
+
"""Generate a Dockerfile and .dockerignore for your project.
|
|
117
|
+
|
|
118
|
+
\b
|
|
119
|
+
Examples:
|
|
120
|
+
dockerb dockerize # Generate a Dockerfile and .dockerignore for your project
|
|
121
|
+
dockerb dockerize --force # Overwrite existing Dockerfile forcefully
|
|
122
|
+
|
|
123
|
+
Requires an API key set in .dockerbrainrc.
|
|
124
|
+
"""
|
|
125
|
+
from core.dockerizer import run_dockerize
|
|
126
|
+
|
|
127
|
+
run_dockerize(project_path=".", force=force)
|
|
128
|
+
|
|
129
|
+
class _NoHelpOptGroup(click.Group):
|
|
130
|
+
"""Group that hides the --help option from the help output."""
|
|
131
|
+
def get_help_option(self, ctx: click.Context) -> click.Option | None:
|
|
132
|
+
opt = super().get_help_option(ctx)
|
|
133
|
+
if opt:
|
|
134
|
+
opt.hidden = True
|
|
135
|
+
return opt
|
|
136
|
+
|
|
137
|
+
@cli.group(cls=_NoHelpOptGroup)
|
|
138
|
+
def template() -> None:
|
|
139
|
+
"""Browse and use curated Dockerfile templates."""
|
|
140
|
+
|
|
141
|
+
@template.command("list")
|
|
142
|
+
def template_list() -> None:
|
|
143
|
+
"""Show all available Dockerfile templates."""
|
|
144
|
+
from rich.console import Console
|
|
145
|
+
from rich.table import Table
|
|
146
|
+
|
|
147
|
+
from core.templates import TEMPLATES
|
|
148
|
+
|
|
149
|
+
console = Console()
|
|
150
|
+
table = Table(
|
|
151
|
+
show_header=True,
|
|
152
|
+
header_style="bold cyan",
|
|
153
|
+
border_style="dim",
|
|
154
|
+
expand=False,
|
|
155
|
+
)
|
|
156
|
+
table.add_column("#", style="dim", width=3)
|
|
157
|
+
table.add_column("Template", style="bold green", no_wrap=True)
|
|
158
|
+
table.add_column("Stack")
|
|
159
|
+
table.add_column("Description")
|
|
160
|
+
|
|
161
|
+
for i, (key, tpl) in enumerate(sorted(TEMPLATES.items()), 1):
|
|
162
|
+
table.add_row(str(i), key, tpl["name"], tpl["description"])
|
|
163
|
+
|
|
164
|
+
console.print()
|
|
165
|
+
console.print(table)
|
|
166
|
+
console.print(
|
|
167
|
+
"\n[dim]Use a template:[/] [cyan]dockerb template use <name>[/]\n"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
@template.command("use")
|
|
171
|
+
@click.argument("name")
|
|
172
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite existing Dockerfile.")
|
|
173
|
+
def template_use(name: str, force: bool) -> None:
|
|
174
|
+
"""Generate a Dockerfile from a template.
|
|
175
|
+
|
|
176
|
+
\b
|
|
177
|
+
Examples:
|
|
178
|
+
dockerb template use fastapi
|
|
179
|
+
dockerb template use nextjs --force
|
|
180
|
+
dockerb template use go
|
|
181
|
+
"""
|
|
182
|
+
from pathlib import Path
|
|
183
|
+
|
|
184
|
+
from rich.console import Console
|
|
185
|
+
from rich.syntax import Syntax
|
|
186
|
+
|
|
187
|
+
from core.templates import get_template, get_template_names
|
|
188
|
+
|
|
189
|
+
console = Console()
|
|
190
|
+
tpl = get_template(name)
|
|
191
|
+
|
|
192
|
+
if tpl is None:
|
|
193
|
+
console.print(f"[red]Unknown template:[/] [bold]{name}[/]")
|
|
194
|
+
console.print(
|
|
195
|
+
f"[dim]Available: {', '.join(get_template_names())}[/]"
|
|
196
|
+
)
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
dockerfile_path = Path("Dockerfile")
|
|
200
|
+
|
|
201
|
+
if dockerfile_path.exists() and not force:
|
|
202
|
+
from rich.prompt import Confirm
|
|
203
|
+
if not Confirm.ask(
|
|
204
|
+
"[yellow]Dockerfile already exists.[/] Overwrite?",
|
|
205
|
+
default=False,
|
|
206
|
+
):
|
|
207
|
+
console.print("[dim]Skipped. No changes made.[/]")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
dockerfile_path.write_text(tpl["dockerfile"], encoding="utf-8")
|
|
211
|
+
console.print(
|
|
212
|
+
f"[green]Created Dockerfile[/] [dim]({tpl['name']})[/]"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
dockerignore_path = Path(".dockerignore")
|
|
216
|
+
if not dockerignore_path.exists():
|
|
217
|
+
dockerignore_content = """\
|
|
218
|
+
.git
|
|
219
|
+
.gitignore
|
|
220
|
+
node_modules
|
|
221
|
+
__pycache__
|
|
222
|
+
*.pyc
|
|
223
|
+
.env
|
|
224
|
+
.venv
|
|
225
|
+
.dockerignore
|
|
226
|
+
Dockerfile
|
|
227
|
+
README.md
|
|
228
|
+
.idea
|
|
229
|
+
.vscode
|
|
230
|
+
"""
|
|
231
|
+
dockerignore_path.write_text(dockerignore_content, encoding="utf-8")
|
|
232
|
+
console.print("[green]Created .dockerignore[/]")
|
|
233
|
+
|
|
234
|
+
console.print()
|
|
235
|
+
console.print(Syntax(tpl["dockerfile"], "dockerfile", theme="monokai", line_numbers=True))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@cli.command()
|
|
239
|
+
def init() -> None:
|
|
240
|
+
"""Create a .dockerbrainrc config file in the current directory.
|
|
241
|
+
|
|
242
|
+
Creates a TOML config with defaults for monitoring thresholds,
|
|
243
|
+
LLM model selection, and other settings. Edit it as per your requirements.
|
|
244
|
+
"""
|
|
245
|
+
from pathlib import Path
|
|
246
|
+
|
|
247
|
+
from rich.console import Console
|
|
248
|
+
|
|
249
|
+
console = Console()
|
|
250
|
+
config_path = Path(".dockerbrainrc")
|
|
251
|
+
|
|
252
|
+
if config_path.exists():
|
|
253
|
+
console.print(f"[yellow] {config_path} already exists.[/]")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
config_content = '''\
|
|
257
|
+
# DockerBrain Configuration
|
|
258
|
+
|
|
259
|
+
# LLM Provider & Models, To switch provider or model, edit below.
|
|
260
|
+
|
|
261
|
+
# GEMINI (Overall Best)
|
|
262
|
+
# provider = "gemini"
|
|
263
|
+
# model = "gemini-3.1-flash-lite-preview", "gemini-flash-latest"
|
|
264
|
+
|
|
265
|
+
# GROQ (Fast)
|
|
266
|
+
# provider = "groq"
|
|
267
|
+
# model = "openai/gpt-oss-120b", "llama-3.3-70b-versatile"
|
|
268
|
+
|
|
269
|
+
# OLLAMA (Local and Free)
|
|
270
|
+
# provider = "ollama"
|
|
271
|
+
# model = "llama3.1", "codestral", "qwen2.5-coder" (Or any other model)
|
|
272
|
+
# base_url = "http://localhost:11434/v1" # change if not default
|
|
273
|
+
|
|
274
|
+
[llm]
|
|
275
|
+
provider = "gemini"
|
|
276
|
+
model = "gemini-3.1-flash-lite-preview"
|
|
277
|
+
api_key = "" # Paste your LLM API key here
|
|
278
|
+
# base_url = "" # Only for Ollama
|
|
279
|
+
|
|
280
|
+
[monitor]
|
|
281
|
+
interval = 5 # Polling interval in seconds
|
|
282
|
+
alert_memory_pct = 80 # Memory % threshold for warnings
|
|
283
|
+
alert_cpu_idle = 0.5 # CPU % below which a container is "idle"
|
|
284
|
+
idle_consecutive_polls = 10 # Consecutive idle polls before flagging
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
[optimize]
|
|
288
|
+
check_secrets = true # Scan for hardcoded secrets in ENV
|
|
289
|
+
check_base_image = true # Flag large base images
|
|
290
|
+
check_apt_recommends = true # Flag apt-get without --no-install-recommends
|
|
291
|
+
check_cache_busting = true # Flag COPY . . before dependency install
|
|
292
|
+
check_dockerignore = true # Warn if .dockerignore is missing
|
|
293
|
+
'''
|
|
294
|
+
|
|
295
|
+
config_path.write_text(config_content, encoding="utf-8")
|
|
296
|
+
console.print(f"[green]Created [bold]{config_path}[/bold][/]")
|
|
297
|
+
|
|
298
|
+
@cli.command()
|
|
299
|
+
def env() -> None:
|
|
300
|
+
"""Check your environment and configs."""
|
|
301
|
+
import sqlite3
|
|
302
|
+
from pathlib import Path
|
|
303
|
+
|
|
304
|
+
import docker
|
|
305
|
+
from rich.console import Console
|
|
306
|
+
from rich.panel import Panel
|
|
307
|
+
from rich.text import Text
|
|
308
|
+
|
|
309
|
+
console = Console()
|
|
310
|
+
lines = Text()
|
|
311
|
+
all_ok = True
|
|
312
|
+
|
|
313
|
+
def _ok(label: str, detail: str) -> None:
|
|
314
|
+
lines.append(f" {label:<20}", style="bold")
|
|
315
|
+
lines.append(f"{detail}\n")
|
|
316
|
+
|
|
317
|
+
def _fail(label: str, detail: str) -> None:
|
|
318
|
+
nonlocal all_ok
|
|
319
|
+
all_ok = False
|
|
320
|
+
lines.append(f" {label:<20}", style="bold red")
|
|
321
|
+
lines.append(f"{detail}\n")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
client = docker.from_env()
|
|
325
|
+
info = client.version()
|
|
326
|
+
_ok("Docker daemon", f"running (v{info.get('Version', '?')})")
|
|
327
|
+
except Exception :
|
|
328
|
+
_fail("Docker daemon", f"not reachable")
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
_ok("Docker SDK", f"docker-py {docker.__version__}")
|
|
332
|
+
except ImportError:
|
|
333
|
+
_fail("Docker SDK", "not installed → run: pip install docker")
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
from core.llm import load_llm_config
|
|
337
|
+
cfg = load_llm_config()
|
|
338
|
+
_ok("LLM Provider", cfg.provider)
|
|
339
|
+
_ok("LLM Model", cfg.model)
|
|
340
|
+
masked = cfg.api_key[:4] + "…" + cfg.api_key[-4:] if len(cfg.api_key) > 8 else "set ✓"
|
|
341
|
+
_ok("API Key", f"{masked}")
|
|
342
|
+
except SystemExit:
|
|
343
|
+
_fail("API Key", "not set in .dockerbrainrc")
|
|
344
|
+
|
|
345
|
+
db_path = Path.home() / ".dockerbrain" / "metrics.db"
|
|
346
|
+
if db_path.exists():
|
|
347
|
+
size_kb = db_path.stat().st_size / 1024
|
|
348
|
+
try:
|
|
349
|
+
conn = sqlite3.connect(str(db_path))
|
|
350
|
+
row_count = conn.execute("SELECT COUNT(*) FROM container_metrics").fetchone()[0]
|
|
351
|
+
conn.close()
|
|
352
|
+
_ok("SQLite DB", f"{db_path} ({size_kb:.0f} KB, {row_count:,} rows)")
|
|
353
|
+
except Exception:
|
|
354
|
+
_ok("SQLite DB", f"{db_path} ({size_kb:.0f} KB)")
|
|
355
|
+
else:
|
|
356
|
+
_fail("SQLite DB", f"not found at {db_path} — run: dockerb monitor")
|
|
357
|
+
|
|
358
|
+
status = "[bold green]All checks passed!" if all_ok else "[bold yellow]Some checks failed"
|
|
359
|
+
console.print(
|
|
360
|
+
Panel(
|
|
361
|
+
lines,
|
|
362
|
+
title="[bold cyan] Environment Check[/]",
|
|
363
|
+
subtitle=status,
|
|
364
|
+
border_style="cyan",
|
|
365
|
+
expand=False,
|
|
366
|
+
padding=(1, 2),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
raise SystemExit(0 if all_ok else 1)
|
core/dockerizer.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from rich.columns import Columns
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.prompt import Confirm
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from core.llm import load_llm_config, generate, LLMConfig
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
_LANGUAGE_SIGNATURES: dict[str, list[str]] = {
|
|
18
|
+
"Python": ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile", "setup.cfg", "poetry.lock"],
|
|
19
|
+
"Node.js": ["package.json", "yarn.lock", "pnpm-lock.yaml", "package-lock.json"],
|
|
20
|
+
"TypeScript": ["tsconfig.json"],
|
|
21
|
+
"Deno": ["deno.json", "deno.jsonc"],
|
|
22
|
+
"Go": ["go.mod", "go.sum"],
|
|
23
|
+
"Rust": ["Cargo.toml", "Cargo.lock"],
|
|
24
|
+
"Java/Kotlin": ["pom.xml", "build.gradle", "build.gradle.kts", "gradlew"],
|
|
25
|
+
"Scala": ["build.sbt"],
|
|
26
|
+
"Ruby": ["Gemfile", "Gemfile.lock"],
|
|
27
|
+
"PHP": ["composer.json", "composer.lock"],
|
|
28
|
+
"C#/.NET": ["*.csproj", "*.sln", "Program.cs"],
|
|
29
|
+
"F#": ["*.fsproj"],
|
|
30
|
+
"C/C++": ["CMakeLists.txt", "*.c", "*.cpp", "configure.ac", "meson.build"],
|
|
31
|
+
"Swift": ["Package.swift"],
|
|
32
|
+
"Dart/Flutter": ["pubspec.yaml"],
|
|
33
|
+
"Elixir": ["mix.exs"],
|
|
34
|
+
"Erlang": ["rebar.config", "rebar.lock"],
|
|
35
|
+
"Haskell": ["stack.yaml", "*.cabal"],
|
|
36
|
+
"Clojure": ["project.clj", "deps.edn"],
|
|
37
|
+
"Perl": ["cpanfile", "Makefile.PL"],
|
|
38
|
+
"R": ["DESCRIPTION", "*.Rproj"],
|
|
39
|
+
"Lua": ["*.rockspec"],
|
|
40
|
+
"Crystal": ["shard.yml"],
|
|
41
|
+
"Static/HTML": ["index.html"],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_SKIP_DIRS = {
|
|
45
|
+
".git", ".venv", "venv", "node_modules", "__pycache__", ".tox",
|
|
46
|
+
".pytest_cache", ".mypy_cache", ".egg-info", "dist", "build",
|
|
47
|
+
".idea", ".vscode", "target", "bin", "obj", ".next", ".nuxt",
|
|
48
|
+
".dart_tool", ".pub-cache", "_build", "deps", "vendor",
|
|
49
|
+
".stack-work", "elm-stuff", "zig-cache",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_SKIP_EXTENSIONS = {
|
|
53
|
+
".pyc", ".pyo", ".exe", ".dll", ".so", ".dylib", ".o",
|
|
54
|
+
".class", ".jar", ".war", ".db", ".sqlite", ".sqlite3",
|
|
55
|
+
".lock", ".log", ".bak", ".swp", ".swo",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_DOCKERIZE_SYSTEM = (
|
|
59
|
+
"You are a world-class Docker expert. Given a project's directory structure and key config "
|
|
60
|
+
"file contents, generate a production-ready Dockerfile.\n\n"
|
|
61
|
+
"CRITICAL RULES:\n"
|
|
62
|
+
"1. Return ONLY a valid JSON object with exactly two keys:\n"
|
|
63
|
+
' - "dockerfile": the full Dockerfile content as a string\n'
|
|
64
|
+
' - "dockerignore": the full .dockerignore content as a string\n'
|
|
65
|
+
"2. No markdown, no code fences, no explanation — ONLY the raw JSON object.\n"
|
|
66
|
+
"3. Use multi-stage builds where appropriate (especially for compiled languages).\n"
|
|
67
|
+
"4. Always use slim or alpine base images.\n"
|
|
68
|
+
"5. Install dependencies BEFORE copying source code (layer caching).\n"
|
|
69
|
+
"6. Chain RUN commands with && to minimize layers.\n"
|
|
70
|
+
"7. Clean up package manager caches (apt, apk, pip, npm).\n"
|
|
71
|
+
"8. Add a non-root USER for security.\n"
|
|
72
|
+
"9. Add a HEALTHCHECK if a web server or API is detected.\n"
|
|
73
|
+
"10. Use exec-form CMD (JSON array).\n"
|
|
74
|
+
"11. Pin base image versions (e.g., python:3.12-slim, not python:latest).\n"
|
|
75
|
+
"12. Add brief comments in the Dockerfile explaining each stage/step.\n"
|
|
76
|
+
"13. The .dockerignore should exclude: .git, .venv, venv, node_modules, __pycache__, "
|
|
77
|
+
"*.pyc, .env, .idea, .vscode, *.md, dist, build, tests, .pytest_cache, .mypy_cache, "
|
|
78
|
+
"and any other development-only files detected in the project.\n"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _scan_project(project_dir: Path) -> dict:
|
|
82
|
+
"""Scan the project directory and return structured info for the AI prompt."""
|
|
83
|
+
|
|
84
|
+
tree_lines: list[str] = []
|
|
85
|
+
|
|
86
|
+
def _walk(current: Path, prefix: str, depth: int) -> None:
|
|
87
|
+
if depth > 3:
|
|
88
|
+
return
|
|
89
|
+
try:
|
|
90
|
+
entries = sorted(current.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
91
|
+
except PermissionError:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
dirs = [e for e in entries if e.is_dir() and e.name not in _SKIP_DIRS and not e.name.startswith(".")]
|
|
95
|
+
files = [e for e in entries if e.is_file() and e.suffix not in _SKIP_EXTENSIONS]
|
|
96
|
+
|
|
97
|
+
for f in files:
|
|
98
|
+
tree_lines.append(f"{prefix}{f.name}")
|
|
99
|
+
for d in dirs:
|
|
100
|
+
tree_lines.append(f"{prefix}{d.name}/")
|
|
101
|
+
_walk(d, prefix + " ", depth + 1)
|
|
102
|
+
|
|
103
|
+
_walk(project_dir, "", 0)
|
|
104
|
+
|
|
105
|
+
# Detect language
|
|
106
|
+
detected_languages: list[str] = []
|
|
107
|
+
for language, signatures in _LANGUAGE_SIGNATURES.items():
|
|
108
|
+
for sig in signatures:
|
|
109
|
+
if "*" in sig:
|
|
110
|
+
# Glob pattern
|
|
111
|
+
if list(project_dir.glob(sig)):
|
|
112
|
+
detected_languages.append(language)
|
|
113
|
+
break
|
|
114
|
+
elif (project_dir / sig).exists():
|
|
115
|
+
detected_languages.append(language)
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
# Read key config files
|
|
119
|
+
key_files_content: dict[str, str] = {}
|
|
120
|
+
priority_files = [
|
|
121
|
+
"requirements.txt", "pyproject.toml", "setup.py", "Pipfile",
|
|
122
|
+
"package.json", "tsconfig.json", "deno.json",
|
|
123
|
+
"go.mod", "Cargo.toml", "pom.xml",
|
|
124
|
+
"build.gradle", "build.sbt",
|
|
125
|
+
"Gemfile", "composer.json", "mix.exs",
|
|
126
|
+
"CMakeLists.txt", "Package.swift", "pubspec.yaml",
|
|
127
|
+
"stack.yaml", "project.clj", "deps.edn",
|
|
128
|
+
"rebar.config", "shard.yml", "cpanfile",
|
|
129
|
+
"Makefile", "Procfile", "app.json", "runtime.txt",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
collected = 0
|
|
133
|
+
for fname in priority_files:
|
|
134
|
+
fpath = project_dir / fname
|
|
135
|
+
if fpath.exists() and collected < 5:
|
|
136
|
+
try:
|
|
137
|
+
lines = fpath.read_text(encoding="utf-8", errors="replace").splitlines()[:200]
|
|
138
|
+
key_files_content[fname] = "\n".join(lines)
|
|
139
|
+
collected += 1
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
has_dockerfile = (project_dir / "Dockerfile").exists()
|
|
144
|
+
has_dockerignore = (project_dir / ".dockerignore").exists()
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"tree": "\n".join(tree_lines[:150]),
|
|
148
|
+
"detected_languages": detected_languages,
|
|
149
|
+
"key_files": key_files_content,
|
|
150
|
+
"has_dockerfile": has_dockerfile,
|
|
151
|
+
"has_dockerignore": has_dockerignore,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _display_scan_results(scan: dict) -> None:
|
|
156
|
+
"""Show the user what was detected before generating."""
|
|
157
|
+
|
|
158
|
+
table = Table(
|
|
159
|
+
show_header=True,
|
|
160
|
+
header_style="bold cyan",
|
|
161
|
+
border_style="bright_blue",
|
|
162
|
+
expand=False,
|
|
163
|
+
)
|
|
164
|
+
table.add_column("Property", style="bold", no_wrap=True)
|
|
165
|
+
table.add_column("Value", style="white")
|
|
166
|
+
|
|
167
|
+
languages = ", ".join(scan["detected_languages"]) if scan["detected_languages"] else "Unknown"
|
|
168
|
+
table.add_row("Detected Language", f"[green]{languages}[/]")
|
|
169
|
+
table.add_row("Configs", ", ".join(scan["key_files"].keys()) or "None")
|
|
170
|
+
table.add_row("Dockerfile", "[yellow]Yes[/]" if scan["has_dockerfile"] else "[dim]No[/]")
|
|
171
|
+
table.add_row(".dockerignore", "[yellow]Yes[/]" if scan["has_dockerignore"] else "[dim]No[/]")
|
|
172
|
+
|
|
173
|
+
console.print()
|
|
174
|
+
console.print(table)
|
|
175
|
+
console.print()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _generate_via_ai(scan: dict, config: LLMConfig) -> dict:
|
|
179
|
+
"""Send project info to the configured LLM and get back Dockerfile + .dockerignore."""
|
|
180
|
+
|
|
181
|
+
# Build the prompt
|
|
182
|
+
parts = ["## Project Analysis\n"]
|
|
183
|
+
|
|
184
|
+
if scan["detected_languages"]:
|
|
185
|
+
parts.append(f"**Detected language(s):** {', '.join(scan['detected_languages'])}\n")
|
|
186
|
+
|
|
187
|
+
parts.append(f"### Directory Structure\n```\n{scan['tree']}\n```\n")
|
|
188
|
+
|
|
189
|
+
for fname, content in scan["key_files"].items():
|
|
190
|
+
parts.append(f"### {fname}\n```\n{content}\n```\n")
|
|
191
|
+
|
|
192
|
+
parts.append("\nGenerate an optimized Dockerfile and .dockerignore for this project.")
|
|
193
|
+
|
|
194
|
+
prompt = "\n".join(parts)
|
|
195
|
+
|
|
196
|
+
with console.status("[bold green]Generating Dockerfile...[/]", spinner="dots"):
|
|
197
|
+
raw = generate(
|
|
198
|
+
prompt=prompt,
|
|
199
|
+
system_instruction=_DOCKERIZE_SYSTEM,
|
|
200
|
+
config=config,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
return json.loads(raw)
|
|
205
|
+
except json.JSONDecodeError:
|
|
206
|
+
console.print(
|
|
207
|
+
Panel(
|
|
208
|
+
f"[red]Could not parse LLM response as JSON.[/]\n\n[dim]{raw[:500]}[/]",
|
|
209
|
+
title="[bold red]Parse Error[/]",
|
|
210
|
+
border_style="red",
|
|
211
|
+
expand=False,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return {}
|
|
215
|
+
|
|
216
|
+
def _preview_files(dockerfile: str, dockerignore: str) -> None:
|
|
217
|
+
"""Show side-by-side preview of the generated files."""
|
|
218
|
+
|
|
219
|
+
left = Panel(
|
|
220
|
+
Syntax(dockerfile, "dockerfile", theme="monokai", line_numbers=True, word_wrap=True),
|
|
221
|
+
title="[bold cyan]Dockerfile[/]",
|
|
222
|
+
border_style="cyan",
|
|
223
|
+
expand=True,
|
|
224
|
+
)
|
|
225
|
+
right = Panel(
|
|
226
|
+
Syntax(dockerignore, "ini", theme="monokai", line_numbers=True, word_wrap=True),
|
|
227
|
+
title="[bold cyan].dockerignore[/]",
|
|
228
|
+
border_style="cyan",
|
|
229
|
+
expand=True,
|
|
230
|
+
)
|
|
231
|
+
console.print()
|
|
232
|
+
console.print(Columns([left, right], equal=True, expand=True))
|
|
233
|
+
|
|
234
|
+
def run_dockerize(project_path: str, force: bool = False) -> None:
|
|
235
|
+
"""Scan a project directory and generate a Dockerfile + .dockerignore via AI."""
|
|
236
|
+
|
|
237
|
+
project_dir = Path(project_path).resolve()
|
|
238
|
+
|
|
239
|
+
if not project_dir.is_dir():
|
|
240
|
+
console.print(f"[red]Not a directory: {project_dir}[/]")
|
|
241
|
+
raise SystemExit(1)
|
|
242
|
+
|
|
243
|
+
console.print(
|
|
244
|
+
Panel(
|
|
245
|
+
f"[bold]Scanning:[/] [cyan]{project_dir}[/]",
|
|
246
|
+
border_style="cyan",
|
|
247
|
+
expand=False,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
scan = _scan_project(project_dir)
|
|
252
|
+
|
|
253
|
+
if not scan["detected_languages"]:
|
|
254
|
+
console.print(
|
|
255
|
+
"[yellow]Could not auto-detect the project language. "
|
|
256
|
+
"Attempt to generate a Dockerfile based on the directory structure.[/]\n"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
_display_scan_results(scan)
|
|
260
|
+
|
|
261
|
+
# Check for existing files
|
|
262
|
+
dockerfile_path = project_dir / "Dockerfile"
|
|
263
|
+
dockerignore_path = project_dir / ".dockerignore"
|
|
264
|
+
|
|
265
|
+
if not force:
|
|
266
|
+
if dockerfile_path.exists():
|
|
267
|
+
if not Confirm.ask(
|
|
268
|
+
f"[yellow]Dockerfile already exists at [cyan]{dockerfile_path}[/cyan]. Overwrite?[/]",
|
|
269
|
+
default=False,
|
|
270
|
+
):
|
|
271
|
+
console.print("[dim]Aborted.[/]")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
config = load_llm_config()
|
|
275
|
+
result = _generate_via_ai(scan, config)
|
|
276
|
+
|
|
277
|
+
if not result:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
dockerfile_content = result.get("dockerfile", "")
|
|
281
|
+
dockerignore_content = result.get("dockerignore", "")
|
|
282
|
+
|
|
283
|
+
if not dockerfile_content:
|
|
284
|
+
console.print("[red]Returned an empty Dockerfile.[/]")
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
# Preview
|
|
288
|
+
_preview_files(dockerfile_content, dockerignore_content)
|
|
289
|
+
|
|
290
|
+
console.print()
|
|
291
|
+
if not Confirm.ask("[bold]Write these files?[/]", default=True):
|
|
292
|
+
console.print("[dim]Aborted. No files written.[/]")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
dockerfile_path.write_text(dockerfile_content + "\n", encoding="utf-8")
|
|
296
|
+
console.print(f"[green bold]Created:[/] [cyan]{dockerfile_path}[/]")
|
|
297
|
+
|
|
298
|
+
if dockerignore_content:
|
|
299
|
+
if dockerignore_path.exists() and not force:
|
|
300
|
+
if Confirm.ask(
|
|
301
|
+
f"[yellow].dockerignore already exists. Overwrite?[/]",
|
|
302
|
+
default=False,
|
|
303
|
+
):
|
|
304
|
+
dockerignore_path.write_text(dockerignore_content + "\n", encoding="utf-8")
|
|
305
|
+
console.print(f"[green bold]Created:[/] [cyan]{dockerignore_path}[/]")
|
|
306
|
+
else:
|
|
307
|
+
console.print("[dim]Skipped .dockerignore.[/]")
|
|
308
|
+
else:
|
|
309
|
+
dockerignore_path.write_text(dockerignore_content + "\n", encoding="utf-8")
|
|
310
|
+
console.print(f"[green bold]Created:[/] [cyan]{dockerignore_path}[/]")
|
core/fixer/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from core.fixer.dockerfile import fix_dockerfile
|
|
2
|
+
from core.fixer.container import fix_containers, _is_safe_command
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def run_fix(
|
|
6
|
+
dockerfile_path: str | None = None,
|
|
7
|
+
container_name: str | None = None,
|
|
8
|
+
) -> None:
|
|
9
|
+
"""Route to dockerfile or container fix mode."""
|
|
10
|
+
if dockerfile_path:
|
|
11
|
+
fix_dockerfile(dockerfile_path)
|
|
12
|
+
else:
|
|
13
|
+
fix_containers(container_name)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"run_fix",
|
|
18
|
+
"fix_dockerfile",
|
|
19
|
+
"fix_containers",
|
|
20
|
+
"_is_safe_command",
|
|
21
|
+
]
|