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/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)