@douglas-agent/sandbank-boxlite 0.6.1
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.
- package/README.md +120 -0
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +259 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +294 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/local-client.d.ts +7 -0
- package/dist/local-client.d.ts.map +1 -0
- package/dist/local-client.js +863 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +41 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
// ─── Python bridge script (embedded) ──────────────────────────────────────────
|
|
7
|
+
const BRIDGE_SCRIPT = `#!/usr/bin/env python3
|
|
8
|
+
"""boxlite_bridge.py — JSON-line bridge between TypeScript and boxlite Python SDK.
|
|
9
|
+
|
|
10
|
+
Protocol:
|
|
11
|
+
→ stdin: one JSON object per line {id, action, ...params}
|
|
12
|
+
← stdout: one JSON object per line {id, result} | {id, error}
|
|
13
|
+
First output line: {ready: true, version: "..."}
|
|
14
|
+
"""
|
|
15
|
+
import asyncio, json, sys, os, traceback
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import boxlite
|
|
20
|
+
except ImportError:
|
|
21
|
+
sys.stdout.write(json.dumps({
|
|
22
|
+
"ready": False,
|
|
23
|
+
"error": "boxlite Python package not found. Install with: pip install boxlite"
|
|
24
|
+
}) + "\\n")
|
|
25
|
+
sys.stdout.flush()
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Bridge:
|
|
30
|
+
def __init__(self, home=None):
|
|
31
|
+
self._home = home or os.environ.get("BOXLITE_HOME", os.path.expanduser("~/.boxlite"))
|
|
32
|
+
self._runtime = None
|
|
33
|
+
self._boxes = {} # box_id -> box object
|
|
34
|
+
self._simple_boxes = {} # box_id -> SimpleBox (for cleanup)
|
|
35
|
+
|
|
36
|
+
async def _ensure_runtime(self):
|
|
37
|
+
if self._runtime is not None:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Create a Boxlite runtime for the custom home_dir, then use SimpleBox
|
|
41
|
+
# with that runtime. SimpleBox's exec is more stable for rapid consecutive
|
|
42
|
+
# calls than Boxlite.create(BoxOptions).exec().
|
|
43
|
+
Options = getattr(boxlite, "Options", None)
|
|
44
|
+
Boxlite = getattr(boxlite, "Boxlite", None)
|
|
45
|
+
if Options and Boxlite:
|
|
46
|
+
try:
|
|
47
|
+
opts = Options(home_dir=self._home)
|
|
48
|
+
self._boxlite_rt = Boxlite(opts)
|
|
49
|
+
except Exception:
|
|
50
|
+
self._boxlite_rt = None
|
|
51
|
+
else:
|
|
52
|
+
self._boxlite_rt = None
|
|
53
|
+
|
|
54
|
+
# Use SimpleBox with the runtime (handles home_dir correctly)
|
|
55
|
+
self._runtime = "simple_box"
|
|
56
|
+
|
|
57
|
+
async def create(self, params):
|
|
58
|
+
await self._ensure_runtime()
|
|
59
|
+
|
|
60
|
+
image = params.get("image", "")
|
|
61
|
+
rootfs = params.get("rootfs_path")
|
|
62
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
63
|
+
|
|
64
|
+
# Normalize env/ports
|
|
65
|
+
env = params.get("env")
|
|
66
|
+
if isinstance(env, dict):
|
|
67
|
+
env = list(env.items())
|
|
68
|
+
ports = params.get("ports")
|
|
69
|
+
if isinstance(ports, list):
|
|
70
|
+
ports = [tuple(p) for p in ports]
|
|
71
|
+
|
|
72
|
+
if self._runtime == "simple_box":
|
|
73
|
+
# SimpleBox uses: image, memory_mib, cpus, disk_size_gb, env, working_dir
|
|
74
|
+
# rootfs_path overrides image when provided (local OCI layout directory)
|
|
75
|
+
sb_kwargs = {"rootfs_path": rootfs} if rootfs else {"image": image}
|
|
76
|
+
if params.get("cpu") is not None:
|
|
77
|
+
sb_kwargs["cpus"] = params["cpu"]
|
|
78
|
+
if params.get("memory_mb") is not None:
|
|
79
|
+
sb_kwargs["memory_mib"] = params["memory_mb"]
|
|
80
|
+
if params.get("disk_size_gb") is not None:
|
|
81
|
+
sb_kwargs["disk_size_gb"] = params["disk_size_gb"]
|
|
82
|
+
if env is not None:
|
|
83
|
+
sb_kwargs["env"] = env
|
|
84
|
+
if params.get("working_dir") is not None:
|
|
85
|
+
sb_kwargs["working_dir"] = params["working_dir"]
|
|
86
|
+
if ports is not None:
|
|
87
|
+
sb_kwargs["ports"] = ports
|
|
88
|
+
|
|
89
|
+
# Disable auto_remove so runtime.get() works after stop (needed for snapshot restore)
|
|
90
|
+
sb_kwargs["auto_remove"] = params.get("auto_remove", False)
|
|
91
|
+
|
|
92
|
+
# Pass the Boxlite runtime to SimpleBox so it uses the correct home_dir
|
|
93
|
+
if getattr(self, "_boxlite_rt", None) is not None:
|
|
94
|
+
sb_kwargs["runtime"] = self._boxlite_rt
|
|
95
|
+
sb = boxlite.SimpleBox(**sb_kwargs)
|
|
96
|
+
box = await sb.__aenter__()
|
|
97
|
+
box_id = str(getattr(box, "id", None)
|
|
98
|
+
or getattr(getattr(box, "_box", None), "id", None)
|
|
99
|
+
or id(box))
|
|
100
|
+
self._boxes[box_id] = box
|
|
101
|
+
self._simple_boxes[box_id] = sb
|
|
102
|
+
else:
|
|
103
|
+
# Boxlite.create(BoxOptions(...), name=None)
|
|
104
|
+
BoxOptions = getattr(boxlite, "BoxOptions", None)
|
|
105
|
+
if BoxOptions is not None:
|
|
106
|
+
opt_kwargs = {}
|
|
107
|
+
if rootfs:
|
|
108
|
+
opt_kwargs["rootfs_path"] = rootfs
|
|
109
|
+
else:
|
|
110
|
+
opt_kwargs["image"] = image
|
|
111
|
+
if params.get("cpu") is not None:
|
|
112
|
+
opt_kwargs["cpus"] = params["cpu"]
|
|
113
|
+
if params.get("memory_mb") is not None:
|
|
114
|
+
opt_kwargs["memory_mib"] = params["memory_mb"]
|
|
115
|
+
if params.get("disk_size_gb") is not None:
|
|
116
|
+
opt_kwargs["disk_size_gb"] = params["disk_size_gb"]
|
|
117
|
+
if env is not None:
|
|
118
|
+
opt_kwargs["env"] = env
|
|
119
|
+
if params.get("working_dir") is not None:
|
|
120
|
+
opt_kwargs["working_dir"] = params["working_dir"]
|
|
121
|
+
if ports is not None:
|
|
122
|
+
opt_kwargs["ports"] = ports
|
|
123
|
+
opts = BoxOptions(**opt_kwargs)
|
|
124
|
+
box = await self._runtime.create(opts)
|
|
125
|
+
else:
|
|
126
|
+
# Legacy fallback: pass as kwargs
|
|
127
|
+
kwargs = {"rootfs_path": rootfs} if rootfs else {"image": image}
|
|
128
|
+
for k in ("cpu", "memory_mb", "disk_size_gb", "working_dir"):
|
|
129
|
+
if params.get(k) is not None:
|
|
130
|
+
kwargs[k] = params[k]
|
|
131
|
+
if env is not None:
|
|
132
|
+
kwargs["env"] = env
|
|
133
|
+
if ports is not None:
|
|
134
|
+
kwargs["ports"] = ports
|
|
135
|
+
create_fn = getattr(self._runtime, "create", None) or getattr(self._runtime, "create_box", None)
|
|
136
|
+
if create_fn is None:
|
|
137
|
+
raise RuntimeError("boxlite runtime has no create/create_box method")
|
|
138
|
+
box = await create_fn(**kwargs)
|
|
139
|
+
|
|
140
|
+
box_id = str(box.id)
|
|
141
|
+
self._boxes[box_id] = box
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"id": box_id,
|
|
145
|
+
"status": "running",
|
|
146
|
+
"image": image,
|
|
147
|
+
"cpu": params.get("cpu", 1),
|
|
148
|
+
"memory_mb": params.get("memory_mb", 512),
|
|
149
|
+
"created_at": now,
|
|
150
|
+
"name": None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async def get(self, box_id):
|
|
154
|
+
box = await self._get_box_async(box_id)
|
|
155
|
+
return {
|
|
156
|
+
"id": box_id,
|
|
157
|
+
"status": self._box_status(box),
|
|
158
|
+
"image": getattr(box, "image", "unknown"),
|
|
159
|
+
"cpu": getattr(box, "cpu", 1),
|
|
160
|
+
"memory_mb": getattr(box, "memory_mb", 512),
|
|
161
|
+
"created_at": str(getattr(box, "created_at", "")),
|
|
162
|
+
"name": getattr(box, "name", None),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async def list_boxes(self):
|
|
166
|
+
results = []
|
|
167
|
+
for box_id, box in self._boxes.items():
|
|
168
|
+
results.append({
|
|
169
|
+
"id": box_id,
|
|
170
|
+
"status": self._box_status(box),
|
|
171
|
+
"image": getattr(box, "image", "unknown"),
|
|
172
|
+
"cpu": getattr(box, "cpu", 1),
|
|
173
|
+
"memory_mb": getattr(box, "memory_mb", 512),
|
|
174
|
+
"created_at": str(getattr(box, "created_at", "")),
|
|
175
|
+
"name": getattr(box, "name", None),
|
|
176
|
+
})
|
|
177
|
+
return results
|
|
178
|
+
|
|
179
|
+
async def exec_cmd(self, box_id, cmd, **kwargs):
|
|
180
|
+
box = await self._get_box_async(box_id)
|
|
181
|
+
|
|
182
|
+
result = None
|
|
183
|
+
errors = []
|
|
184
|
+
|
|
185
|
+
# Strategy 1: box.exec(*cmd)
|
|
186
|
+
try:
|
|
187
|
+
result = await box.exec(*cmd)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
errors.append(f"box.exec(*cmd): {e}")
|
|
190
|
+
|
|
191
|
+
# Strategy 2: box.exec(cmd[0], args=cmd[1:])
|
|
192
|
+
if result is None:
|
|
193
|
+
try:
|
|
194
|
+
result = await box.exec(cmd[0], args=cmd[1:])
|
|
195
|
+
except Exception as e:
|
|
196
|
+
errors.append(f"box.exec(cmd[0], args=...): {e}")
|
|
197
|
+
|
|
198
|
+
# Strategy 3: box._box.exec(cmd[0], args=cmd[1:])
|
|
199
|
+
if result is None and hasattr(box, "_box"):
|
|
200
|
+
try:
|
|
201
|
+
result = await box._box.exec(cmd[0], args=cmd[1:])
|
|
202
|
+
except Exception as e:
|
|
203
|
+
errors.append(f"box._box.exec(...): {e}")
|
|
204
|
+
|
|
205
|
+
if result is None:
|
|
206
|
+
raise RuntimeError(f"All exec strategies failed: {'; '.join(errors)}")
|
|
207
|
+
|
|
208
|
+
stdout = ""
|
|
209
|
+
stderr = ""
|
|
210
|
+
exit_code = 0
|
|
211
|
+
|
|
212
|
+
# Execution object (Boxlite runtime API): collect stdout/stderr streams, then wait
|
|
213
|
+
if hasattr(result, "wait") and callable(result.wait):
|
|
214
|
+
async def _collect(stream_fn):
|
|
215
|
+
try:
|
|
216
|
+
buf = bytearray()
|
|
217
|
+
async for chunk in stream_fn():
|
|
218
|
+
if isinstance(chunk, (bytes, bytearray)):
|
|
219
|
+
buf.extend(chunk)
|
|
220
|
+
else:
|
|
221
|
+
buf.extend(str(chunk).encode("utf-8"))
|
|
222
|
+
return buf.decode("utf-8", errors="replace")
|
|
223
|
+
except Exception:
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
# Read stdout/stderr concurrently to avoid pipe deadlocks
|
|
227
|
+
tasks = []
|
|
228
|
+
has_stdout = hasattr(result, "stdout") and callable(result.stdout)
|
|
229
|
+
has_stderr = hasattr(result, "stderr") and callable(result.stderr)
|
|
230
|
+
if has_stdout:
|
|
231
|
+
tasks.append(asyncio.create_task(_collect(result.stdout)))
|
|
232
|
+
if has_stderr:
|
|
233
|
+
tasks.append(asyncio.create_task(_collect(result.stderr)))
|
|
234
|
+
|
|
235
|
+
if tasks:
|
|
236
|
+
collected = await asyncio.gather(*tasks)
|
|
237
|
+
idx = 0
|
|
238
|
+
if has_stdout:
|
|
239
|
+
stdout = collected[idx]; idx += 1
|
|
240
|
+
if has_stderr:
|
|
241
|
+
stderr = collected[idx]
|
|
242
|
+
|
|
243
|
+
exec_result = await result.wait()
|
|
244
|
+
exit_code = int(getattr(exec_result, "exit_code",
|
|
245
|
+
getattr(exec_result, "returncode", 0)) or 0)
|
|
246
|
+
else:
|
|
247
|
+
# SimpleBox / legacy: result already has stdout/stderr as attributes
|
|
248
|
+
raw_stdout = getattr(result, "stdout", "")
|
|
249
|
+
raw_stderr = getattr(result, "stderr", "")
|
|
250
|
+
if callable(raw_stdout):
|
|
251
|
+
raw_stdout = raw_stdout()
|
|
252
|
+
if callable(raw_stderr):
|
|
253
|
+
raw_stderr = raw_stderr()
|
|
254
|
+
stdout = str(raw_stdout or "")
|
|
255
|
+
stderr = str(raw_stderr or "")
|
|
256
|
+
exit_code = int(getattr(result, "exit_code",
|
|
257
|
+
getattr(result, "returncode", 0)) or 0)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
"stdout": stdout,
|
|
261
|
+
"stderr": stderr,
|
|
262
|
+
"exit_code": exit_code,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async def destroy(self, box_id, force=False):
|
|
266
|
+
box = self._boxes.pop(box_id, None)
|
|
267
|
+
sb = self._simple_boxes.pop(box_id, None)
|
|
268
|
+
|
|
269
|
+
await self._ensure_runtime()
|
|
270
|
+
rt = getattr(self, "_boxlite_rt", None)
|
|
271
|
+
if rt is not None:
|
|
272
|
+
for method_name in ("remove", "destroy", "delete"):
|
|
273
|
+
fn = getattr(rt, method_name, None)
|
|
274
|
+
if fn is None:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
try:
|
|
278
|
+
await fn(box_id, force=force)
|
|
279
|
+
except TypeError:
|
|
280
|
+
await fn(box_id)
|
|
281
|
+
return
|
|
282
|
+
except Exception:
|
|
283
|
+
if box is None and sb is None:
|
|
284
|
+
raise
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
if sb is not None:
|
|
288
|
+
try:
|
|
289
|
+
await sb.__aexit__(None, None, None)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
if box is not None and self._runtime != "simple_box":
|
|
295
|
+
for method_name in ("destroy", "delete", "remove"):
|
|
296
|
+
fn = getattr(self._runtime, method_name, None)
|
|
297
|
+
if fn is not None:
|
|
298
|
+
try:
|
|
299
|
+
await fn(box_id)
|
|
300
|
+
return
|
|
301
|
+
except Exception:
|
|
302
|
+
continue
|
|
303
|
+
if hasattr(box, "destroy"):
|
|
304
|
+
await box.destroy()
|
|
305
|
+
elif hasattr(box, "stop"):
|
|
306
|
+
await box.stop()
|
|
307
|
+
|
|
308
|
+
async def _refresh_box_handle(self, box_id, started=None):
|
|
309
|
+
await self._ensure_runtime()
|
|
310
|
+
rt = getattr(self, "_boxlite_rt", None)
|
|
311
|
+
if rt is None:
|
|
312
|
+
box = self._boxes.get(box_id)
|
|
313
|
+
if box is None:
|
|
314
|
+
raise ValueError(f"Box not found: {box_id}")
|
|
315
|
+
return box
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
fresh = await rt.get(box_id)
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
raise ValueError(f"Box not found: {box_id}") from exc
|
|
321
|
+
if fresh is None:
|
|
322
|
+
raise ValueError(f"Box not found: {box_id}")
|
|
323
|
+
|
|
324
|
+
old_sb = self._simple_boxes.get(box_id)
|
|
325
|
+
old_box = self._boxes.get(box_id)
|
|
326
|
+
if old_sb and hasattr(old_sb, "_box"):
|
|
327
|
+
old_sb._box = fresh
|
|
328
|
+
if started is not None:
|
|
329
|
+
old_sb._started = started
|
|
330
|
+
if old_box and hasattr(old_box, "_box"):
|
|
331
|
+
old_box._box = fresh
|
|
332
|
+
else:
|
|
333
|
+
self._boxes[box_id] = fresh
|
|
334
|
+
return fresh
|
|
335
|
+
|
|
336
|
+
async def stop(self, box_id):
|
|
337
|
+
box = await self._get_box_async(box_id)
|
|
338
|
+
if hasattr(box, "stop"):
|
|
339
|
+
await box.stop()
|
|
340
|
+
await self._refresh_box_handle(box_id, False)
|
|
341
|
+
|
|
342
|
+
async def start(self, box_id):
|
|
343
|
+
fresh = await self._refresh_box_handle(box_id, False)
|
|
344
|
+
if fresh and hasattr(fresh, "start"):
|
|
345
|
+
await fresh.start()
|
|
346
|
+
elif fresh and hasattr(fresh, "__aenter__"):
|
|
347
|
+
await fresh.__aenter__()
|
|
348
|
+
else:
|
|
349
|
+
raise RuntimeError(f"Box has no start method: {box_id}")
|
|
350
|
+
|
|
351
|
+
old_sb = self._simple_boxes.get(box_id)
|
|
352
|
+
if old_sb and hasattr(old_sb, "_started"):
|
|
353
|
+
old_sb._started = True
|
|
354
|
+
|
|
355
|
+
def _normalize_status(self, value):
|
|
356
|
+
status_attr = getattr(value, "status", None)
|
|
357
|
+
if status_attr is not None and status_attr is not value:
|
|
358
|
+
return self._normalize_status(status_attr)
|
|
359
|
+
if isinstance(value, dict) and value.get("status") is not None:
|
|
360
|
+
return self._normalize_status(value.get("status"))
|
|
361
|
+
|
|
362
|
+
text = str(value or "").lower()
|
|
363
|
+
for state in ("configured", "stopping", "stopped", "paused", "running"):
|
|
364
|
+
if state in text:
|
|
365
|
+
return state
|
|
366
|
+
return text or "unknown"
|
|
367
|
+
|
|
368
|
+
def _box_status(self, box):
|
|
369
|
+
status = getattr(box, "status", None)
|
|
370
|
+
if status is not None:
|
|
371
|
+
return self._normalize_status(status)
|
|
372
|
+
|
|
373
|
+
inner = getattr(box, "_box", box)
|
|
374
|
+
info_fn = getattr(inner, "info", None)
|
|
375
|
+
if callable(info_fn):
|
|
376
|
+
try:
|
|
377
|
+
info = info_fn()
|
|
378
|
+
state = getattr(info, "state", None)
|
|
379
|
+
if state is not None:
|
|
380
|
+
return self._normalize_status(state)
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
return "unknown"
|
|
385
|
+
|
|
386
|
+
async def _get_box_async(self, box_id):
|
|
387
|
+
box = self._boxes.get(box_id)
|
|
388
|
+
if box is not None:
|
|
389
|
+
return box
|
|
390
|
+
return await self._refresh_box_handle(box_id)
|
|
391
|
+
|
|
392
|
+
def _get_box(self, box_id):
|
|
393
|
+
box = self._boxes.get(box_id)
|
|
394
|
+
if box is None:
|
|
395
|
+
raise ValueError(f"Box not found: {box_id}")
|
|
396
|
+
# SimpleBox wraps the real Box — unwrap to get snapshot handle
|
|
397
|
+
inner = getattr(box, "_box", box)
|
|
398
|
+
return inner
|
|
399
|
+
|
|
400
|
+
async def _get_fresh_box(self, box_id):
|
|
401
|
+
try:
|
|
402
|
+
fresh = await self._refresh_box_handle(box_id)
|
|
403
|
+
if fresh is not None:
|
|
404
|
+
return getattr(fresh, "_box", fresh)
|
|
405
|
+
except Exception:
|
|
406
|
+
if self._boxes.get(box_id) is None:
|
|
407
|
+
raise
|
|
408
|
+
|
|
409
|
+
return self._get_box(box_id)
|
|
410
|
+
|
|
411
|
+
async def create_snapshot(self, box_id, name):
|
|
412
|
+
# boxlite snapshot uses fork_qcow2 (rename + COW child). If QEMU is
|
|
413
|
+
# running, its FD still points to the renamed inode, so post-snapshot
|
|
414
|
+
# writes corrupt the snapshot file. Must stop → snapshot → restart.
|
|
415
|
+
inner = await self._get_fresh_box(box_id)
|
|
416
|
+
await inner.stop()
|
|
417
|
+
|
|
418
|
+
rt = getattr(self, "_boxlite_rt", None)
|
|
419
|
+
if rt is None:
|
|
420
|
+
raise RuntimeError("Cannot create snapshot: no Boxlite runtime")
|
|
421
|
+
fresh = await rt.get(box_id)
|
|
422
|
+
if fresh is None:
|
|
423
|
+
raise RuntimeError(f"Cannot get fresh handle for box {box_id}")
|
|
424
|
+
snap_handle = getattr(fresh, "snapshot", None)
|
|
425
|
+
if snap_handle is None:
|
|
426
|
+
raise RuntimeError("Fresh handle has no snapshot support")
|
|
427
|
+
|
|
428
|
+
info = await snap_handle.create(name=name)
|
|
429
|
+
snap_name = str(getattr(info, "name", name))
|
|
430
|
+
|
|
431
|
+
# Restart the VM with the new COW child disk
|
|
432
|
+
await fresh.__aenter__()
|
|
433
|
+
# Update SimpleBox/Box internal references
|
|
434
|
+
old_sb = self._simple_boxes.get(box_id)
|
|
435
|
+
old_box = self._boxes.get(box_id)
|
|
436
|
+
if old_sb and hasattr(old_sb, "_box"):
|
|
437
|
+
old_sb._box = fresh
|
|
438
|
+
old_sb._started = True
|
|
439
|
+
if old_box and hasattr(old_box, "_box"):
|
|
440
|
+
old_box._box = fresh
|
|
441
|
+
else:
|
|
442
|
+
self._boxes[box_id] = fresh
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
"id": str(getattr(info, "id", snap_name)),
|
|
446
|
+
"box_id": box_id,
|
|
447
|
+
"name": snap_name,
|
|
448
|
+
"created_at": int(getattr(info, "created_at", 0)),
|
|
449
|
+
"size_bytes": int(getattr(info, "size_bytes", 0)),
|
|
450
|
+
"guest_disk_bytes": int(getattr(info, "guest_disk_bytes", 0) or 0),
|
|
451
|
+
"container_disk_bytes": int(getattr(info, "container_disk_bytes", 0) or 0),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async def restore_snapshot(self, box_id, name):
|
|
455
|
+
# Same stop → fresh handle pattern as create_snapshot.
|
|
456
|
+
# stop() also invalidates the LiteBox handle (cancels shutdown_token).
|
|
457
|
+
inner = await self._get_fresh_box(box_id)
|
|
458
|
+
await inner.stop()
|
|
459
|
+
|
|
460
|
+
rt = getattr(self, "_boxlite_rt", None)
|
|
461
|
+
if rt is None:
|
|
462
|
+
raise RuntimeError("Cannot restore snapshot: no Boxlite runtime")
|
|
463
|
+
fresh = await rt.get(box_id)
|
|
464
|
+
if fresh is None:
|
|
465
|
+
raise RuntimeError(f"Cannot get fresh handle for box {box_id}")
|
|
466
|
+
fresh_snap = getattr(fresh, "snapshot", None)
|
|
467
|
+
if fresh_snap is None:
|
|
468
|
+
raise RuntimeError("Fresh handle has no snapshot support")
|
|
469
|
+
|
|
470
|
+
await fresh_snap.restore(name)
|
|
471
|
+
|
|
472
|
+
# Restart with the restored disk
|
|
473
|
+
await fresh.__aenter__()
|
|
474
|
+
# Update SimpleBox/Box internal references
|
|
475
|
+
old_sb = self._simple_boxes.get(box_id)
|
|
476
|
+
old_box = self._boxes.get(box_id)
|
|
477
|
+
if old_sb and hasattr(old_sb, "_box"):
|
|
478
|
+
old_sb._box = fresh
|
|
479
|
+
old_sb._started = True
|
|
480
|
+
if old_box and hasattr(old_box, "_box"):
|
|
481
|
+
old_box._box = fresh
|
|
482
|
+
else:
|
|
483
|
+
self._boxes[box_id] = fresh
|
|
484
|
+
|
|
485
|
+
async def list_snapshots(self, box_id):
|
|
486
|
+
inner = await self._get_fresh_box(box_id)
|
|
487
|
+
snap_handle = getattr(inner, "snapshot", None)
|
|
488
|
+
if snap_handle is None:
|
|
489
|
+
raise RuntimeError("Box does not support snapshots")
|
|
490
|
+
snapshots = await snap_handle.list()
|
|
491
|
+
return [{
|
|
492
|
+
"id": str(getattr(s, "id", "")),
|
|
493
|
+
"box_id": box_id,
|
|
494
|
+
"name": str(getattr(s, "name", "")),
|
|
495
|
+
"created_at": int(getattr(s, "created_at", 0)),
|
|
496
|
+
"size_bytes": int(getattr(s, "size_bytes", 0)),
|
|
497
|
+
"guest_disk_bytes": int(getattr(s, "guest_disk_bytes", 0) or 0),
|
|
498
|
+
"container_disk_bytes": int(getattr(s, "container_disk_bytes", 0) or 0),
|
|
499
|
+
} for s in snapshots]
|
|
500
|
+
|
|
501
|
+
async def delete_snapshot(self, box_id, name):
|
|
502
|
+
inner = await self._get_fresh_box(box_id)
|
|
503
|
+
snap_handle = getattr(inner, "snapshot", None)
|
|
504
|
+
if snap_handle is None:
|
|
505
|
+
raise RuntimeError("Box does not support snapshots")
|
|
506
|
+
await snap_handle.remove(name)
|
|
507
|
+
|
|
508
|
+
async def clone_box(self, box_id, name=None):
|
|
509
|
+
inner = await self._get_fresh_box(box_id)
|
|
510
|
+
from boxlite import CloneOptions
|
|
511
|
+
cloned = await inner.clone_box(options=CloneOptions(), name=name)
|
|
512
|
+
cloned_id = cloned.id
|
|
513
|
+
self._boxes[cloned_id] = cloned
|
|
514
|
+
info = cloned.info()
|
|
515
|
+
return {
|
|
516
|
+
"id": cloned_id,
|
|
517
|
+
"name": info.name if hasattr(info, "name") else name,
|
|
518
|
+
"status": str(info.state) if hasattr(info, "state") else "stopped",
|
|
519
|
+
"image": str(info.image) if hasattr(info, "image") else "",
|
|
520
|
+
"cpu": int(info.cpus) if hasattr(info, "cpus") else 0,
|
|
521
|
+
"memory_mb": int(info.memory_mib) if hasattr(info, "memory_mib") else 0,
|
|
522
|
+
"created_at": "",
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async def cleanup(self, graceful=True):
|
|
526
|
+
if graceful:
|
|
527
|
+
# Process/bridge shutdown must not destroy tenant boxes. Running and
|
|
528
|
+
# stopped boxes can be reacquired from disk by _get_box_async after
|
|
529
|
+
# the next bridge starts.
|
|
530
|
+
self._boxes.clear()
|
|
531
|
+
self._simple_boxes.clear()
|
|
532
|
+
return
|
|
533
|
+
|
|
534
|
+
for box_id in list(self._boxes.keys()):
|
|
535
|
+
try:
|
|
536
|
+
await self.destroy(box_id)
|
|
537
|
+
except Exception:
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def write_json(obj):
|
|
542
|
+
sys.stdout.write(json.dumps(obj) + "\\n")
|
|
543
|
+
sys.stdout.flush()
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async def main():
|
|
547
|
+
home = os.environ.get("BOXLITE_BRIDGE_HOME")
|
|
548
|
+
bridge = Bridge(home=home)
|
|
549
|
+
loop = asyncio.get_running_loop()
|
|
550
|
+
|
|
551
|
+
write_json({"ready": True, "version": getattr(boxlite, "__version__", "unknown")})
|
|
552
|
+
|
|
553
|
+
while True:
|
|
554
|
+
line = await loop.run_in_executor(None, sys.stdin.readline)
|
|
555
|
+
if not line:
|
|
556
|
+
break
|
|
557
|
+
line = line.strip()
|
|
558
|
+
if not line:
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
req_id = 0
|
|
562
|
+
try:
|
|
563
|
+
cmd = json.loads(line)
|
|
564
|
+
req_id = cmd.get("id", 0)
|
|
565
|
+
action = cmd.get("action", "")
|
|
566
|
+
|
|
567
|
+
if action == "create":
|
|
568
|
+
result = await bridge.create(cmd)
|
|
569
|
+
elif action == "get":
|
|
570
|
+
result = await bridge.get(cmd["box_id"])
|
|
571
|
+
elif action == "list":
|
|
572
|
+
result = await bridge.list_boxes()
|
|
573
|
+
elif action == "exec":
|
|
574
|
+
result = await bridge.exec_cmd(cmd["box_id"], cmd["cmd"])
|
|
575
|
+
elif action == "destroy":
|
|
576
|
+
await bridge.destroy(cmd["box_id"], cmd.get("force", False))
|
|
577
|
+
result = {}
|
|
578
|
+
elif action == "start":
|
|
579
|
+
await bridge.start(cmd["box_id"])
|
|
580
|
+
result = {}
|
|
581
|
+
elif action == "stop":
|
|
582
|
+
await bridge.stop(cmd["box_id"])
|
|
583
|
+
result = {}
|
|
584
|
+
elif action == "create_snapshot":
|
|
585
|
+
result = await bridge.create_snapshot(cmd["box_id"], cmd["name"])
|
|
586
|
+
elif action == "restore_snapshot":
|
|
587
|
+
await bridge.restore_snapshot(cmd["box_id"], cmd["name"])
|
|
588
|
+
result = {}
|
|
589
|
+
elif action == "list_snapshots":
|
|
590
|
+
result = await bridge.list_snapshots(cmd["box_id"])
|
|
591
|
+
elif action == "delete_snapshot":
|
|
592
|
+
await bridge.delete_snapshot(cmd["box_id"], cmd["name"])
|
|
593
|
+
result = {}
|
|
594
|
+
elif action == "clone":
|
|
595
|
+
result = await bridge.clone_box(cmd["box_id"], cmd.get("name"))
|
|
596
|
+
elif action == "ping":
|
|
597
|
+
result = {"pong": True}
|
|
598
|
+
else:
|
|
599
|
+
raise ValueError(f"Unknown action: {action}")
|
|
600
|
+
|
|
601
|
+
write_json({"id": req_id, "result": result})
|
|
602
|
+
except Exception as e:
|
|
603
|
+
write_json({"id": req_id, "error": f"{type(e).__name__}: {e}"})
|
|
604
|
+
|
|
605
|
+
await bridge.cleanup()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
if __name__ == "__main__":
|
|
609
|
+
asyncio.run(main())
|
|
610
|
+
`;
|
|
611
|
+
/**
|
|
612
|
+
* Create a BoxLite local client that communicates with the boxlite Python SDK
|
|
613
|
+
* via a JSON-line subprocess bridge.
|
|
614
|
+
*/
|
|
615
|
+
export function createBoxLiteLocalClient(config) {
|
|
616
|
+
const pythonPath = config.pythonPath ?? 'python3';
|
|
617
|
+
const boxliteHome = config.boxliteHome;
|
|
618
|
+
let process = null;
|
|
619
|
+
let readline = null;
|
|
620
|
+
let requestId = 0;
|
|
621
|
+
let readyPromise = null;
|
|
622
|
+
const pending = new Map();
|
|
623
|
+
// Write bridge script to a temp file
|
|
624
|
+
let bridgeScriptPath = null;
|
|
625
|
+
function getBridgeScriptPath() {
|
|
626
|
+
if (bridgeScriptPath)
|
|
627
|
+
return bridgeScriptPath;
|
|
628
|
+
bridgeScriptPath = join(tmpdir(), `boxlite-bridge-${process?.pid ?? Date.now()}.py`);
|
|
629
|
+
writeFileSync(bridgeScriptPath, BRIDGE_SCRIPT, 'utf-8');
|
|
630
|
+
return bridgeScriptPath;
|
|
631
|
+
}
|
|
632
|
+
function ensureBridge() {
|
|
633
|
+
if (readyPromise)
|
|
634
|
+
return readyPromise;
|
|
635
|
+
readyPromise = new Promise((resolveReady, rejectReady) => {
|
|
636
|
+
const scriptPath = getBridgeScriptPath();
|
|
637
|
+
const env = { ...globalThis.process.env };
|
|
638
|
+
if (boxliteHome) {
|
|
639
|
+
env['BOXLITE_BRIDGE_HOME'] = boxliteHome;
|
|
640
|
+
}
|
|
641
|
+
process = spawn(pythonPath, [scriptPath], {
|
|
642
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
643
|
+
env,
|
|
644
|
+
});
|
|
645
|
+
// Collect stderr for error reporting
|
|
646
|
+
let stderrBuf = '';
|
|
647
|
+
process.stderr?.on('data', (chunk) => {
|
|
648
|
+
stderrBuf += chunk.toString();
|
|
649
|
+
});
|
|
650
|
+
process.on('error', (err) => {
|
|
651
|
+
rejectReady(new Error(`Failed to start boxlite bridge: ${err.message}`));
|
|
652
|
+
cleanup();
|
|
653
|
+
});
|
|
654
|
+
process.on('exit', (code) => {
|
|
655
|
+
if (code !== 0 && code !== null) {
|
|
656
|
+
const msg = stderrBuf || `Bridge exited with code ${code}`;
|
|
657
|
+
rejectReady(new Error(`BoxLite bridge error: ${msg}`));
|
|
658
|
+
// Reject all pending requests
|
|
659
|
+
for (const [id, req] of pending) {
|
|
660
|
+
req.reject(new Error(`BoxLite bridge exited unexpectedly: ${msg}`));
|
|
661
|
+
clearTimeout(req.timer);
|
|
662
|
+
pending.delete(id);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
cleanup();
|
|
666
|
+
});
|
|
667
|
+
readline = createInterface({ input: process.stdout });
|
|
668
|
+
let gotReady = false;
|
|
669
|
+
readline.on('line', (line) => {
|
|
670
|
+
let msg;
|
|
671
|
+
try {
|
|
672
|
+
msg = JSON.parse(line);
|
|
673
|
+
}
|
|
674
|
+
catch {
|
|
675
|
+
return; // Ignore non-JSON output
|
|
676
|
+
}
|
|
677
|
+
// Handle ready signal
|
|
678
|
+
if (!gotReady && 'ready' in msg) {
|
|
679
|
+
gotReady = true;
|
|
680
|
+
if (msg.ready) {
|
|
681
|
+
resolveReady();
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
rejectReady(new Error(`BoxLite bridge init failed: ${msg.error ?? 'unknown error'}`));
|
|
685
|
+
}
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Handle response to a request
|
|
689
|
+
const id = msg.id;
|
|
690
|
+
if (id === undefined)
|
|
691
|
+
return;
|
|
692
|
+
const req = pending.get(id);
|
|
693
|
+
if (!req)
|
|
694
|
+
return;
|
|
695
|
+
pending.delete(id);
|
|
696
|
+
clearTimeout(req.timer);
|
|
697
|
+
if (msg.error) {
|
|
698
|
+
req.reject(new Error(`BoxLite local: ${msg.error}`));
|
|
699
|
+
}
|
|
700
|
+
else {
|
|
701
|
+
req.resolve(msg.result);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
return readyPromise;
|
|
706
|
+
}
|
|
707
|
+
function cleanup() {
|
|
708
|
+
if (bridgeScriptPath) {
|
|
709
|
+
try {
|
|
710
|
+
unlinkSync(bridgeScriptPath);
|
|
711
|
+
}
|
|
712
|
+
catch { /* ignore */ }
|
|
713
|
+
bridgeScriptPath = null;
|
|
714
|
+
}
|
|
715
|
+
readline?.close();
|
|
716
|
+
readline = null;
|
|
717
|
+
process = null;
|
|
718
|
+
readyPromise = null;
|
|
719
|
+
}
|
|
720
|
+
async function send(command, timeoutMs = 300_000) {
|
|
721
|
+
await ensureBridge();
|
|
722
|
+
if (!process?.stdin?.writable) {
|
|
723
|
+
throw new Error('BoxLite bridge is not running');
|
|
724
|
+
}
|
|
725
|
+
const id = ++requestId;
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
const timer = setTimeout(() => {
|
|
728
|
+
pending.delete(id);
|
|
729
|
+
reject(new Error(`BoxLite bridge request timed out after ${timeoutMs}ms`));
|
|
730
|
+
}, timeoutMs);
|
|
731
|
+
pending.set(id, {
|
|
732
|
+
resolve: resolve,
|
|
733
|
+
reject,
|
|
734
|
+
timer,
|
|
735
|
+
});
|
|
736
|
+
process.stdin.write(JSON.stringify({ id, ...command }) + '\n');
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
// ─── BoxLiteClient implementation ───
|
|
740
|
+
return {
|
|
741
|
+
async createBox(params) {
|
|
742
|
+
return send({ action: 'create', ...params });
|
|
743
|
+
},
|
|
744
|
+
async getBox(boxId) {
|
|
745
|
+
return send({ action: 'get', box_id: boxId });
|
|
746
|
+
},
|
|
747
|
+
async listBoxes() {
|
|
748
|
+
return send({ action: 'list' });
|
|
749
|
+
},
|
|
750
|
+
async deleteBox(boxId, force) {
|
|
751
|
+
await send({ action: 'destroy', box_id: boxId, force: force ?? false });
|
|
752
|
+
},
|
|
753
|
+
async startBox(boxId) {
|
|
754
|
+
await send({ action: 'start', box_id: boxId });
|
|
755
|
+
},
|
|
756
|
+
async stopBox(boxId) {
|
|
757
|
+
await send({ action: 'stop', box_id: boxId });
|
|
758
|
+
},
|
|
759
|
+
async exec(boxId, req) {
|
|
760
|
+
const timeoutMs = (req.timeout_seconds ?? 300) * 1000;
|
|
761
|
+
const result = await send({
|
|
762
|
+
action: 'exec',
|
|
763
|
+
box_id: boxId,
|
|
764
|
+
cmd: req.cmd,
|
|
765
|
+
...(req.working_dir ? { working_dir: req.working_dir } : {}),
|
|
766
|
+
...(req.timeout_seconds ? { timeout_seconds: req.timeout_seconds } : {}),
|
|
767
|
+
}, timeoutMs);
|
|
768
|
+
return {
|
|
769
|
+
stdout: result.stdout ?? '',
|
|
770
|
+
stderr: result.stderr ?? '',
|
|
771
|
+
exitCode: result.exit_code ?? 0,
|
|
772
|
+
};
|
|
773
|
+
},
|
|
774
|
+
async execStream(boxId, req) {
|
|
775
|
+
const result = await this.exec(boxId, req);
|
|
776
|
+
const encoder = new TextEncoder();
|
|
777
|
+
return new ReadableStream({
|
|
778
|
+
start(controller) {
|
|
779
|
+
if (result.stdout)
|
|
780
|
+
controller.enqueue(encoder.encode(result.stdout));
|
|
781
|
+
if (result.stderr)
|
|
782
|
+
controller.enqueue(encoder.encode(result.stderr));
|
|
783
|
+
controller.close();
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
},
|
|
787
|
+
async uploadFiles(boxId, path, tarData) {
|
|
788
|
+
// Pipe tar data through exec: base64 decode → tar extract
|
|
789
|
+
const b64 = Buffer.from(tarData).toString('base64');
|
|
790
|
+
// Split into chunks to avoid shell argument limit
|
|
791
|
+
const chunkSize = 50_000;
|
|
792
|
+
const chunks = [];
|
|
793
|
+
for (let i = 0; i < b64.length; i += chunkSize) {
|
|
794
|
+
chunks.push(b64.slice(i, i + chunkSize));
|
|
795
|
+
}
|
|
796
|
+
if (chunks.length === 1) {
|
|
797
|
+
await this.exec(boxId, {
|
|
798
|
+
cmd: ['bash', '-c', `echo '${chunks[0]}' | base64 -d | tar xf - -C '${path}'`],
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
// Write base64 to a temp file in chunks, then decode
|
|
803
|
+
const tmpFile = `/tmp/.boxlite-upload-${Date.now()}`;
|
|
804
|
+
for (const chunk of chunks) {
|
|
805
|
+
await this.exec(boxId, {
|
|
806
|
+
cmd: ['bash', '-c', `printf '%s' '${chunk}' >> ${tmpFile}`],
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
await this.exec(boxId, {
|
|
810
|
+
cmd: ['bash', '-c', `base64 -d ${tmpFile} | tar xf - -C '${path}' && rm -f ${tmpFile}`],
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
async downloadFiles(boxId, path) {
|
|
815
|
+
const result = await this.exec(boxId, {
|
|
816
|
+
cmd: ['bash', '-c', `tar cf - -C '${path}' . 2>/dev/null | base64`],
|
|
817
|
+
});
|
|
818
|
+
const data = Buffer.from(result.stdout.trim(), 'base64');
|
|
819
|
+
return new ReadableStream({
|
|
820
|
+
start(controller) {
|
|
821
|
+
controller.enqueue(new Uint8Array(data));
|
|
822
|
+
controller.close();
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
},
|
|
826
|
+
async createSnapshot(boxId, name) {
|
|
827
|
+
return send({ action: 'create_snapshot', box_id: boxId, name });
|
|
828
|
+
},
|
|
829
|
+
async restoreSnapshot(boxId, name) {
|
|
830
|
+
await send({ action: 'restore_snapshot', box_id: boxId, name });
|
|
831
|
+
},
|
|
832
|
+
async listSnapshots(boxId) {
|
|
833
|
+
return send({ action: 'list_snapshots', box_id: boxId });
|
|
834
|
+
},
|
|
835
|
+
async deleteSnapshot(boxId, name) {
|
|
836
|
+
await send({ action: 'delete_snapshot', box_id: boxId, name });
|
|
837
|
+
},
|
|
838
|
+
async cloneBox(boxId, name) {
|
|
839
|
+
return send({ action: 'clone', box_id: boxId, ...(name ? { name } : {}) });
|
|
840
|
+
},
|
|
841
|
+
async dispose() {
|
|
842
|
+
if (process?.stdin?.writable) {
|
|
843
|
+
process.stdin.end();
|
|
844
|
+
}
|
|
845
|
+
// Give the bridge a moment to cleanup
|
|
846
|
+
await new Promise(resolve => {
|
|
847
|
+
if (!process) {
|
|
848
|
+
resolve();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
const timeout = setTimeout(() => {
|
|
852
|
+
process?.kill();
|
|
853
|
+
resolve();
|
|
854
|
+
}, 3000);
|
|
855
|
+
process.on('exit', () => {
|
|
856
|
+
clearTimeout(timeout);
|
|
857
|
+
resolve();
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
cleanup();
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
}
|