clonebox 0.1.25__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.
- clonebox/__init__.py +14 -0
- clonebox/__main__.py +7 -0
- clonebox/cli.py +2932 -0
- clonebox/cloner.py +2081 -0
- clonebox/container.py +190 -0
- clonebox/dashboard.py +133 -0
- clonebox/detector.py +705 -0
- clonebox/models.py +201 -0
- clonebox/profiles.py +66 -0
- clonebox/templates/profiles/ml-dev.yaml +6 -0
- clonebox/validator.py +841 -0
- clonebox-0.1.25.dist-info/METADATA +1382 -0
- clonebox-0.1.25.dist-info/RECORD +17 -0
- clonebox-0.1.25.dist-info/WHEEL +5 -0
- clonebox-0.1.25.dist-info/entry_points.txt +2 -0
- clonebox-0.1.25.dist-info/licenses/LICENSE +201 -0
- clonebox-0.1.25.dist-info/top_level.txt +1 -0
clonebox/container.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from clonebox.models import ContainerConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContainerCloner:
|
|
14
|
+
def __init__(self, engine: str = "auto"):
|
|
15
|
+
self.engine = self._resolve_engine(engine)
|
|
16
|
+
|
|
17
|
+
def _resolve_engine(self, engine: str) -> str:
|
|
18
|
+
if engine == "auto":
|
|
19
|
+
return self.detect_engine()
|
|
20
|
+
if engine not in {"podman", "docker"}:
|
|
21
|
+
raise ValueError("engine must be one of: auto, podman, docker")
|
|
22
|
+
if shutil.which(engine) is None:
|
|
23
|
+
raise RuntimeError(f"Container engine not found: {engine}")
|
|
24
|
+
self._run([engine, "--version"], check=True)
|
|
25
|
+
return engine
|
|
26
|
+
|
|
27
|
+
def detect_engine(self) -> str:
|
|
28
|
+
if shutil.which("podman") is not None:
|
|
29
|
+
try:
|
|
30
|
+
self._run(["podman", "--version"], check=True)
|
|
31
|
+
return "podman"
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
if shutil.which("docker") is not None:
|
|
36
|
+
try:
|
|
37
|
+
self._run(["docker", "--version"], check=True)
|
|
38
|
+
return "docker"
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
raise RuntimeError("No container engine found (podman/docker)")
|
|
43
|
+
|
|
44
|
+
def _run(
|
|
45
|
+
self,
|
|
46
|
+
cmd: List[str],
|
|
47
|
+
check: bool = True,
|
|
48
|
+
capture_output: bool = True,
|
|
49
|
+
text: bool = True,
|
|
50
|
+
) -> subprocess.CompletedProcess:
|
|
51
|
+
return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)
|
|
52
|
+
|
|
53
|
+
def build_dockerfile(self, config: ContainerConfig) -> str:
|
|
54
|
+
lines: List[str] = [f"FROM {config.image}"]
|
|
55
|
+
|
|
56
|
+
if config.packages:
|
|
57
|
+
pkgs = " ".join(config.packages)
|
|
58
|
+
lines.append(
|
|
59
|
+
"RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y "
|
|
60
|
+
+ pkgs
|
|
61
|
+
+ " && rm -rf /var/lib/apt/lists/*"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
lines.append("WORKDIR /workspace")
|
|
65
|
+
lines.append('CMD ["bash"]')
|
|
66
|
+
return "\n".join(lines) + "\n"
|
|
67
|
+
|
|
68
|
+
def build_image(self, config: ContainerConfig, tag: Optional[str] = None) -> str:
|
|
69
|
+
if tag is None:
|
|
70
|
+
tag = f"{config.name}:latest"
|
|
71
|
+
|
|
72
|
+
dockerfile = self.build_dockerfile(config)
|
|
73
|
+
workspace = Path(config.workspace).resolve()
|
|
74
|
+
|
|
75
|
+
with tempfile.NamedTemporaryFile(prefix="clonebox-dockerfile-", delete=False) as f:
|
|
76
|
+
dockerfile_path = Path(f.name)
|
|
77
|
+
f.write(dockerfile.encode())
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
self._run(
|
|
81
|
+
[
|
|
82
|
+
self.engine,
|
|
83
|
+
"build",
|
|
84
|
+
"-f",
|
|
85
|
+
str(dockerfile_path),
|
|
86
|
+
"-t",
|
|
87
|
+
tag,
|
|
88
|
+
str(workspace),
|
|
89
|
+
],
|
|
90
|
+
check=True,
|
|
91
|
+
)
|
|
92
|
+
finally:
|
|
93
|
+
try:
|
|
94
|
+
dockerfile_path.unlink()
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
return tag
|
|
99
|
+
|
|
100
|
+
def up(self, config: ContainerConfig, detach: bool = False, remove: bool = True) -> None:
|
|
101
|
+
engine = self._resolve_engine(config.engine if config.engine != "auto" else self.engine)
|
|
102
|
+
|
|
103
|
+
image = config.image
|
|
104
|
+
if config.packages:
|
|
105
|
+
image = self.build_image(config)
|
|
106
|
+
|
|
107
|
+
cmd: List[str] = [engine, "run"]
|
|
108
|
+
cmd.append("-d" if detach else "-it")
|
|
109
|
+
|
|
110
|
+
if remove:
|
|
111
|
+
cmd.append("--rm")
|
|
112
|
+
|
|
113
|
+
cmd.extend(["--name", config.name])
|
|
114
|
+
cmd.extend(["-w", "/workspace"])
|
|
115
|
+
|
|
116
|
+
env_file = Path(config.workspace) / ".env"
|
|
117
|
+
if config.env_from_dotenv and env_file.exists():
|
|
118
|
+
cmd.extend(["--env-file", str(env_file)])
|
|
119
|
+
|
|
120
|
+
for src, dst in config.mounts.items():
|
|
121
|
+
cmd.extend(["-v", f"{src}:{dst}"])
|
|
122
|
+
|
|
123
|
+
for p in config.ports:
|
|
124
|
+
cmd.extend(["-p", p])
|
|
125
|
+
|
|
126
|
+
cmd.append(image)
|
|
127
|
+
|
|
128
|
+
if detach:
|
|
129
|
+
cmd.extend(["sleep", "infinity"])
|
|
130
|
+
else:
|
|
131
|
+
cmd.append("bash")
|
|
132
|
+
|
|
133
|
+
subprocess.run(cmd, check=True)
|
|
134
|
+
|
|
135
|
+
def stop(self, name: str) -> None:
|
|
136
|
+
subprocess.run([self.engine, "stop", name], check=True)
|
|
137
|
+
|
|
138
|
+
def rm(self, name: str, force: bool = False) -> None:
|
|
139
|
+
cmd = [self.engine, "rm"]
|
|
140
|
+
if force:
|
|
141
|
+
cmd.append("-f")
|
|
142
|
+
cmd.append(name)
|
|
143
|
+
subprocess.run(cmd, check=True)
|
|
144
|
+
|
|
145
|
+
def ps(self, all: bool = False) -> List[Dict[str, Any]]:
|
|
146
|
+
if self.engine == "podman":
|
|
147
|
+
cmd = ["podman", "ps", "--format", "json"]
|
|
148
|
+
if all:
|
|
149
|
+
cmd.append("-a")
|
|
150
|
+
result = self._run(cmd, check=True)
|
|
151
|
+
try:
|
|
152
|
+
parsed = json.loads(result.stdout or "[]")
|
|
153
|
+
except json.JSONDecodeError:
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
items: List[Dict[str, Any]] = []
|
|
157
|
+
for c in parsed:
|
|
158
|
+
name = ""
|
|
159
|
+
names = c.get("Names")
|
|
160
|
+
if isinstance(names, list) and names:
|
|
161
|
+
name = str(names[0])
|
|
162
|
+
elif isinstance(names, str):
|
|
163
|
+
name = names
|
|
164
|
+
|
|
165
|
+
items.append(
|
|
166
|
+
{
|
|
167
|
+
"name": name,
|
|
168
|
+
"image": c.get("Image") or c.get("ImageName") or "",
|
|
169
|
+
"status": c.get("State") or c.get("Status") or "",
|
|
170
|
+
"ports": c.get("Ports") or [],
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
return items
|
|
174
|
+
|
|
175
|
+
cmd = ["docker", "ps", "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"]
|
|
176
|
+
if all:
|
|
177
|
+
cmd.insert(2, "-a")
|
|
178
|
+
|
|
179
|
+
result = self._run(cmd, check=True)
|
|
180
|
+
items: List[Dict[str, Any]] = []
|
|
181
|
+
for line in (result.stdout or "").splitlines():
|
|
182
|
+
if not line.strip():
|
|
183
|
+
continue
|
|
184
|
+
parts = line.split("\t")
|
|
185
|
+
name = parts[0] if len(parts) > 0 else ""
|
|
186
|
+
image = parts[1] if len(parts) > 1 else ""
|
|
187
|
+
status = parts[2] if len(parts) > 2 else ""
|
|
188
|
+
ports = parts[3] if len(parts) > 3 else ""
|
|
189
|
+
items.append({"name": name, "image": image, "status": status, "ports": ports})
|
|
190
|
+
return items
|
clonebox/dashboard.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
8
|
+
|
|
9
|
+
app = FastAPI(title="CloneBox Dashboard")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _run_clonebox(args: List[str]) -> subprocess.CompletedProcess:
|
|
13
|
+
return subprocess.run(
|
|
14
|
+
[sys.executable, "-m", "clonebox"] + args,
|
|
15
|
+
capture_output=True,
|
|
16
|
+
text=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render_table(title: str, headers: List[str], rows: List[List[str]]) -> str:
|
|
21
|
+
head_html = "".join(f"<th>{h}</th>" for h in headers)
|
|
22
|
+
body_html = "".join(
|
|
23
|
+
"<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>" for row in rows
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
f"<h2>{title}</h2>"
|
|
28
|
+
"<table>"
|
|
29
|
+
f"<thead><tr>{head_html}</tr></thead>"
|
|
30
|
+
f"<tbody>{body_html}</tbody>"
|
|
31
|
+
"</table>"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.get("/", response_class=HTMLResponse)
|
|
36
|
+
async def dashboard() -> str:
|
|
37
|
+
return """
|
|
38
|
+
<!DOCTYPE html>
|
|
39
|
+
<html>
|
|
40
|
+
<head>
|
|
41
|
+
<title>CloneBox Dashboard</title>
|
|
42
|
+
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
43
|
+
<style>
|
|
44
|
+
body { font-family: system-ui, -apple-system, sans-serif; margin: 20px; }
|
|
45
|
+
table { border-collapse: collapse; width: 100%; margin-bottom: 24px; }
|
|
46
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
47
|
+
th { background: #f6f6f6; }
|
|
48
|
+
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
|
|
49
|
+
</style>
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<h1>CloneBox Dashboard</h1>
|
|
53
|
+
<p>Auto-refresh every 5s. JSON endpoints: <code>/api/vms.json</code>, <code>/api/containers.json</code></p>
|
|
54
|
+
|
|
55
|
+
<div id="vms" hx-get="/api/vms" hx-trigger="load, every 5s">Loading VMs...</div>
|
|
56
|
+
<div id="containers" hx-get="/api/containers" hx-trigger="load, every 5s">Loading containers...</div>
|
|
57
|
+
</body>
|
|
58
|
+
</html>
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.get("/api/vms", response_class=HTMLResponse)
|
|
63
|
+
async def api_vms() -> str:
|
|
64
|
+
proc = _run_clonebox(["list", "--json"])
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
return f"<pre>clonebox list failed:\n{proc.stderr}</pre>"
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
items: List[dict[str, Any]] = json.loads(proc.stdout or "[]")
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
return f"<pre>Invalid JSON from clonebox list:\n{proc.stdout}</pre>"
|
|
72
|
+
|
|
73
|
+
if not items:
|
|
74
|
+
return "<h2>VMs</h2><p><em>No VMs found.</em></p>"
|
|
75
|
+
|
|
76
|
+
rows = [[str(i.get("name", "")), str(i.get("state", "")), str(i.get("uuid", ""))] for i in items]
|
|
77
|
+
return _render_table("VMs", ["Name", "State", "UUID"], rows)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.get("/api/containers", response_class=HTMLResponse)
|
|
81
|
+
async def api_containers() -> str:
|
|
82
|
+
proc = _run_clonebox(["container", "ps", "--json", "-a"])
|
|
83
|
+
if proc.returncode != 0:
|
|
84
|
+
return f"<pre>clonebox container ps failed:\n{proc.stderr}</pre>"
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
items: List[dict[str, Any]] = json.loads(proc.stdout or "[]")
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
return f"<pre>Invalid JSON from clonebox container ps:\n{proc.stdout}</pre>"
|
|
90
|
+
|
|
91
|
+
if not items:
|
|
92
|
+
return "<h2>Containers</h2><p><em>No containers found.</em></p>"
|
|
93
|
+
|
|
94
|
+
rows = [
|
|
95
|
+
[
|
|
96
|
+
str(i.get("name", "")),
|
|
97
|
+
str(i.get("image", "")),
|
|
98
|
+
str(i.get("status", "")),
|
|
99
|
+
str(i.get("ports", "")),
|
|
100
|
+
]
|
|
101
|
+
for i in items
|
|
102
|
+
]
|
|
103
|
+
return _render_table("Containers", ["Name", "Image", "Status", "Ports"], rows)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.get("/api/vms.json")
|
|
107
|
+
async def api_vms_json() -> JSONResponse:
|
|
108
|
+
proc = _run_clonebox(["list", "--json"])
|
|
109
|
+
if proc.returncode != 0:
|
|
110
|
+
return JSONResponse({"error": proc.stderr, "stdout": proc.stdout}, status_code=500)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
return JSONResponse(json.loads(proc.stdout or "[]"))
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return JSONResponse({"error": "invalid_json", "stdout": proc.stdout}, status_code=500)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.get("/api/containers.json")
|
|
119
|
+
async def api_containers_json() -> JSONResponse:
|
|
120
|
+
proc = _run_clonebox(["container", "ps", "--json", "-a"])
|
|
121
|
+
if proc.returncode != 0:
|
|
122
|
+
return JSONResponse({"error": proc.stderr, "stdout": proc.stdout}, status_code=500)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return JSONResponse(json.loads(proc.stdout or "[]"))
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
return JSONResponse({"error": "invalid_json", "stdout": proc.stdout}, status_code=500)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_dashboard(port: int = 8080) -> None:
|
|
131
|
+
import uvicorn
|
|
132
|
+
|
|
133
|
+
uvicorn.run(app, host="127.0.0.1", port=port)
|