@dbx-tools/appkit-lakebox 0.0.0-stage → 0.9.131
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/LICENSE +202 -0
- package/README.md +104 -2
- package/index.ts +32 -0
- package/lib/index.d.ts +28 -0
- package/lib/index.js +26 -0
- package/lib/src/_package.d.ts +2 -0
- package/lib/src/_package.js +11 -0
- package/lib/src/_resource-registry.d.ts +41 -0
- package/lib/src/_resource-registry.js +128 -0
- package/lib/src/_service-snapshots.d.ts +55 -0
- package/lib/src/_service-snapshots.js +65 -0
- package/lib/src/config.d.ts +40 -0
- package/lib/src/config.js +86 -0
- package/lib/src/desktop.d.ts +6 -0
- package/lib/src/desktop.js +88 -0
- package/lib/src/metrics.d.ts +59 -0
- package/lib/src/metrics.js +151 -0
- package/lib/src/plugin.d.ts +259 -0
- package/lib/src/plugin.js +329 -0
- package/lib/src/routes.d.ts +75 -0
- package/lib/src/routes.js +441 -0
- package/lib/src/runtime.d.ts +154 -0
- package/lib/src/runtime.js +760 -0
- package/lib/src/services.d.ts +101 -0
- package/lib/src/services.js +188 -0
- package/lib/src/skills.d.ts +5 -0
- package/lib/src/skills.js +21 -0
- package/lib/src/user.d.ts +46 -0
- package/lib/src/user.js +76 -0
- package/lib/src/workspace.d.ts +43 -0
- package/lib/src/workspace.js +138 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +167 -4
- package/scripts/desktop-prerequisites.sh +66 -0
- package/scripts/desktop-session.sh +307 -0
- package/scripts/lakebox-admin.sh +234 -0
- package/scripts/lakebox-service.py +449 -0
- package/skills/html-page/SKILL.md +40 -0
- package/skills/jupyter-lab/SKILL.md +32 -0
- package/skills/lakebox-showcase/SKILL.md +66 -0
- package/skills/network-speed-test/SKILL.md +29 -0
- package/skills/realtime-whiteboard/SKILL.md +28 -0
- package/src/_package.ts +11 -0
- package/src/_resource-registry.ts +160 -0
- package/src/_service-snapshots.ts +84 -0
- package/src/config.ts +128 -0
- package/src/desktop.ts +97 -0
- package/src/metrics.ts +178 -0
- package/src/plugin.ts +446 -0
- package/src/routes.ts +524 -0
- package/src/runtime.ts +952 -0
- package/src/services.ts +239 -0
- package/src/skills.ts +23 -0
- package/src/user.ts +88 -0
- package/src/workspace.ts +185 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Package-owned Lakebox service controller.
|
|
3
|
+
|
|
4
|
+
Runs as one locked Lakebox user. Owns the direct-crun lifecycle for that
|
|
5
|
+
user's services: saved definitions, image preparation through Buildah,
|
|
6
|
+
OCI spec generation, idempotent start and stop, readiness, bounded logs, and
|
|
7
|
+
restoring saved services after a Lakebox start.
|
|
8
|
+
|
|
9
|
+
Commands print one JSON document on stdout. Diagnostics go to service logs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import contextlib
|
|
14
|
+
import fcntl
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
RUNTIME = "/home/sandbox-agent/.dbx-tools/runtime"
|
|
25
|
+
BUNDLE = f"{RUNTIME}/container-v1/root"
|
|
26
|
+
POLICY = f"{BUNDLE}/etc/containers/policy.json"
|
|
27
|
+
HOME = os.environ["HOME"]
|
|
28
|
+
UID = os.getuid()
|
|
29
|
+
USER_HASH = os.path.basename(os.path.realpath(HOME))
|
|
30
|
+
SERVICES = f"{HOME}/.dbx-tools/services"
|
|
31
|
+
STORAGE = f"{HOME}/.dbx-tools/containers"
|
|
32
|
+
RUN = f"/run/lakebox/{USER_HASH}"
|
|
33
|
+
CRUN_ROOT = f"{RUN}/crun"
|
|
34
|
+
LOG_LIMIT = 1_048_576
|
|
35
|
+
LOG_KEEP = 262_144
|
|
36
|
+
HOST_BINDS = ("/usr", "/etc", "/opt", "/var", RUNTIME)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def runtime_env():
|
|
40
|
+
return {
|
|
41
|
+
"HOME": HOME,
|
|
42
|
+
"USER": os.environ.get("USER", ""),
|
|
43
|
+
"LOGNAME": os.environ.get("USER", ""),
|
|
44
|
+
"XDG_RUNTIME_DIR": RUN,
|
|
45
|
+
"PATH": f"{BUNDLE}/usr/bin:{BUNDLE}/usr/sbin:/usr/local/bin:/usr/bin:/bin",
|
|
46
|
+
"LD_LIBRARY_PATH": f"{BUNDLE}/usr/lib/x86_64-linux-gnu:{BUNDLE}/lib/x86_64-linux-gnu"
|
|
47
|
+
f":{BUNDLE}/usr/lib/aarch64-linux-gnu:{BUNDLE}/lib/aarch64-linux-gnu",
|
|
48
|
+
"CONTAINERS_CONF": f"{BUNDLE}/usr/share/containers/containers.conf",
|
|
49
|
+
"CONTAINERS_REGISTRIES_CONF": f"{BUNDLE}/etc/containers/registries.conf",
|
|
50
|
+
"CONTAINERS_STORAGE_CONF": f"{STORAGE}/storage.conf",
|
|
51
|
+
"BUILDAH_ISOLATION": "chroot",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def namespaced(args, log=None, check=True, capture=False):
|
|
56
|
+
"""Runs one runtime command inside the user's single-UID namespace."""
|
|
57
|
+
sink = log if log is not None else subprocess.DEVNULL
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["unshare", "-Ur", "-m", "--", *args],
|
|
60
|
+
env=runtime_env(),
|
|
61
|
+
stdin=subprocess.DEVNULL,
|
|
62
|
+
stdout=subprocess.PIPE if capture else sink,
|
|
63
|
+
stderr=sink,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
if check and result.returncode != 0:
|
|
67
|
+
raise RuntimeError(f"{args[0]} {args[1] if len(args) > 1 else ''} exited {result.returncode}")
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def service_dir(name):
|
|
72
|
+
return f"{SERVICES}/{name}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_definition(name):
|
|
76
|
+
with open(f"{service_dir(name)}/service.json", encoding="utf-8") as handle:
|
|
77
|
+
return json.load(handle)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_json(path, value):
|
|
81
|
+
temporary = f"{path}.tmp.{os.getpid()}"
|
|
82
|
+
with open(temporary, "w", encoding="utf-8") as handle:
|
|
83
|
+
json.dump(value, handle, indent=1)
|
|
84
|
+
os.replace(temporary, path)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@contextlib.contextmanager
|
|
88
|
+
def locked(name):
|
|
89
|
+
os.makedirs(service_dir(name), mode=0o700, exist_ok=True)
|
|
90
|
+
with open(f"{service_dir(name)}/.lock", "w", encoding="utf-8") as handle:
|
|
91
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
92
|
+
yield
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def open_log(name):
|
|
96
|
+
path = f"{service_dir(name)}/service.log"
|
|
97
|
+
if os.path.exists(path) and os.path.getsize(path) > LOG_LIMIT:
|
|
98
|
+
with open(path, "rb") as handle:
|
|
99
|
+
handle.seek(-LOG_KEEP, os.SEEK_END)
|
|
100
|
+
tail = handle.read()
|
|
101
|
+
with open(path, "wb") as handle:
|
|
102
|
+
handle.write(tail)
|
|
103
|
+
return open(path, "a", encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def crun_state(name):
|
|
107
|
+
result = namespaced(["crun", "--root", CRUN_ROOT, "state", container_id(name)], check=False, capture=True)
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(result.stdout).get("status")
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def container_id(name):
|
|
117
|
+
return f"lakebox-{USER_HASH}-{name}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def listening_sockets():
|
|
121
|
+
"""Yields (port, uid) for every listening TCP socket."""
|
|
122
|
+
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
123
|
+
if not os.path.exists(table):
|
|
124
|
+
continue
|
|
125
|
+
with open(table, encoding="utf-8") as handle:
|
|
126
|
+
rows = [line.split() for line in handle.readlines()[1:]]
|
|
127
|
+
for fields in rows:
|
|
128
|
+
if fields[3] == "0A":
|
|
129
|
+
yield int(fields[1].split(":")[1], 16), int(fields[7])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def listener_owner(port):
|
|
133
|
+
"""Returns the UID listening on a loopback TCP port, or None."""
|
|
134
|
+
return next((uid for listening, uid in listening_sockets() if listening == port), None)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def port_open(port):
|
|
138
|
+
with contextlib.suppress(OSError):
|
|
139
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def ensure_storage():
|
|
145
|
+
os.makedirs(f"{STORAGE}/storage", mode=0o700, exist_ok=True)
|
|
146
|
+
os.makedirs(CRUN_ROOT, mode=0o700, exist_ok=True)
|
|
147
|
+
conf = f"{STORAGE}/storage.conf"
|
|
148
|
+
content = (
|
|
149
|
+
'[storage]\ndriver = "vfs"\n'
|
|
150
|
+
f'runroot = "{RUN}/storage"\ngraphroot = "{STORAGE}/storage"\n'
|
|
151
|
+
'[storage.options.vfs]\nignore_chown_errors = "true"\n'
|
|
152
|
+
)
|
|
153
|
+
if not os.path.exists(conf) or open(conf, encoding="utf-8").read() != content:
|
|
154
|
+
with open(conf, "w", encoding="utf-8") as handle:
|
|
155
|
+
handle.write(content)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def prepare_image(definition, log):
|
|
159
|
+
"""Pulls the image when needed and returns the rootfs plus image config."""
|
|
160
|
+
ensure_storage()
|
|
161
|
+
image = definition["image"]
|
|
162
|
+
working = f"lakebox-{definition['name']}"
|
|
163
|
+
if definition.get("pull") == "always":
|
|
164
|
+
namespaced(["buildah", "--signature-policy", POLICY, "pull", image], log)
|
|
165
|
+
marker = f"{service_dir(definition['name'])}/image.json"
|
|
166
|
+
previous = {}
|
|
167
|
+
if os.path.exists(marker):
|
|
168
|
+
with open(marker, encoding="utf-8") as handle:
|
|
169
|
+
previous = json.load(handle)
|
|
170
|
+
if previous.get("image") != image or definition.get("pull") == "always":
|
|
171
|
+
namespaced(["buildah", "rm", working], check=False)
|
|
172
|
+
log.write(f"preparing {image}\n")
|
|
173
|
+
log.flush()
|
|
174
|
+
namespaced(["buildah", "--signature-policy", POLICY, "from", "--name", working, image], log)
|
|
175
|
+
rootfs = namespaced(["buildah", "mount", working], log, capture=True).stdout.strip()
|
|
176
|
+
inspected = namespaced(["buildah", "inspect", "--type", "container", working], log, capture=True)
|
|
177
|
+
config = json.loads(inspected.stdout).get("OCIv1", {}).get("config", {})
|
|
178
|
+
write_json(marker, {"image": image})
|
|
179
|
+
return rootfs, config
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def prepare_host_rootfs(name):
|
|
183
|
+
root = f"{service_dir(name)}/rootfs"
|
|
184
|
+
for directory in ("usr", "etc", "opt", "var", "home", "run", "tmp", "proc", "sys", "dev", "root"):
|
|
185
|
+
os.makedirs(f"{root}/{directory}", exist_ok=True)
|
|
186
|
+
for link in ("bin", "sbin", "lib", "lib64"):
|
|
187
|
+
if not os.path.lexists(f"{root}/{link}"):
|
|
188
|
+
os.symlink(f"usr/{link}", f"{root}/{link}")
|
|
189
|
+
for target in (HOME, RUNTIME):
|
|
190
|
+
os.makedirs(f"{root}{target}", exist_ok=True)
|
|
191
|
+
return root
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def bind(source, target, read_only=True):
|
|
195
|
+
options = ["rbind", "ro" if read_only else "rw"]
|
|
196
|
+
return {"destination": target, "type": "bind", "source": source, "options": options}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def service_environment(definition, image_config, host):
|
|
200
|
+
environment = dict(item.split("=", 1) for item in image_config.get("Env") or [] if "=" in item)
|
|
201
|
+
environment.setdefault("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
|
|
202
|
+
if host:
|
|
203
|
+
user = os.environ.get("USER", "")
|
|
204
|
+
environment.update(HOME=HOME, USER=user, LOGNAME=user, SHELL="/bin/bash", LANG="C.UTF-8")
|
|
205
|
+
environment["TERM"] = "xterm-256color"
|
|
206
|
+
environment.update(definition.get("env") or {})
|
|
207
|
+
return [f"{key}={value}" for key, value in environment.items()]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def service_command(definition, image_config):
|
|
211
|
+
command = definition.get("command") or [
|
|
212
|
+
*(image_config.get("Entrypoint") or []),
|
|
213
|
+
*(image_config.get("Cmd") or []),
|
|
214
|
+
]
|
|
215
|
+
if not command:
|
|
216
|
+
raise RuntimeError("The image defines no entrypoint or command; set command")
|
|
217
|
+
return command
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def service_mounts(defaults, definition, host):
|
|
221
|
+
"""Keeps the default device mounts, binds /proc and /sys, and adds service mounts."""
|
|
222
|
+
mounts = [entry for entry in defaults if entry["destination"] not in ("/proc", "/sys")]
|
|
223
|
+
for entry in mounts:
|
|
224
|
+
if entry["destination"] == "/dev/pts":
|
|
225
|
+
entry["options"] = [option for option in entry["options"] if not option.startswith("gid=")]
|
|
226
|
+
mounts.append({"destination": "/proc", "type": "bind", "source": "/proc",
|
|
227
|
+
"options": ["rbind", "nosuid", "nodev", "noexec"]})
|
|
228
|
+
mounts.append(bind("/sys", "/sys"))
|
|
229
|
+
if host:
|
|
230
|
+
mounts.extend(bind(path, path) for path in HOST_BINDS)
|
|
231
|
+
mounts.append(bind(HOME, HOME, read_only=False))
|
|
232
|
+
mounts.append({"destination": "/tmp", "type": "tmpfs", "source": "tmpfs",
|
|
233
|
+
"options": ["nosuid", "nodev", "mode=1777"]})
|
|
234
|
+
else:
|
|
235
|
+
mounts.extend(bind(path, path) for path in ("/etc/resolv.conf", "/etc/hosts"))
|
|
236
|
+
for mount in definition.get("mounts") or []:
|
|
237
|
+
os.makedirs(mount["source"], exist_ok=True)
|
|
238
|
+
mounts.append(bind(mount["source"], mount["target"], mount.get("readOnly", False)))
|
|
239
|
+
return mounts
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def apply_namespaces(linux, process, host):
|
|
243
|
+
"""Drops network, cgroup, and UTS namespaces; host services map the real UID."""
|
|
244
|
+
namespaces = [entry for entry in linux["namespaces"] if entry["type"] not in ("network", "cgroup", "uts")]
|
|
245
|
+
if host:
|
|
246
|
+
namespaces.append({"type": "user"})
|
|
247
|
+
linux["uidMappings"] = [{"containerID": UID, "hostID": 0, "size": 1}]
|
|
248
|
+
linux["gidMappings"] = [{"containerID": UID, "hostID": 0, "size": 1}]
|
|
249
|
+
process["user"] = {"uid": UID, "gid": UID}
|
|
250
|
+
linux["namespaces"] = namespaces
|
|
251
|
+
linux.pop("cgroupsPath", None)
|
|
252
|
+
linux.pop("resources", None)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def oci_config(definition, rootfs, image_config):
|
|
256
|
+
"""Writes the OCI spec validated on FEVM for one service."""
|
|
257
|
+
directory = service_dir(definition["name"])
|
|
258
|
+
subprocess.run(["crun", "spec"], cwd=directory, env=runtime_env(), check=False,
|
|
259
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
260
|
+
with open(f"{directory}/config.json", encoding="utf-8") as handle:
|
|
261
|
+
config = json.load(handle)
|
|
262
|
+
host = definition["kind"] != "image"
|
|
263
|
+
config.pop("hostname", None)
|
|
264
|
+
config["root"] = {"path": rootfs, "readonly": host}
|
|
265
|
+
config["process"].update(
|
|
266
|
+
args=service_command(definition, image_config),
|
|
267
|
+
terminal=False,
|
|
268
|
+
env=service_environment(definition, image_config, host),
|
|
269
|
+
cwd=definition.get("workingDirectory") or (HOME if host else image_config.get("WorkingDir") or "/"),
|
|
270
|
+
)
|
|
271
|
+
apply_namespaces(config["linux"], config["process"], host)
|
|
272
|
+
config["mounts"] = service_mounts(config["mounts"], definition, host)
|
|
273
|
+
write_json(f"{directory}/config.json", config)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def wait_ready(definition, log, deadline):
|
|
277
|
+
port = definition["port"]
|
|
278
|
+
name = definition["name"]
|
|
279
|
+
while time.time() < deadline:
|
|
280
|
+
if crun_state(name) not in ("running", "created"):
|
|
281
|
+
raise RuntimeError("The service process exited; see the service log")
|
|
282
|
+
if definition.get("readiness", "tcp") == "none":
|
|
283
|
+
return
|
|
284
|
+
owner = listener_owner(port)
|
|
285
|
+
if owner is not None and owner != UID:
|
|
286
|
+
raise RuntimeError(f"Port {port} is held by another account")
|
|
287
|
+
if owner == UID and port_open(port):
|
|
288
|
+
return
|
|
289
|
+
time.sleep(0.5)
|
|
290
|
+
raise RuntimeError(f"The service did not listen on 127.0.0.1:{port} in time")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def stop_container(name, grace=10):
|
|
294
|
+
cid = container_id(name)
|
|
295
|
+
if crun_state(name) == "running":
|
|
296
|
+
namespaced(["crun", "--root", CRUN_ROOT, "kill", cid, "TERM"], check=False)
|
|
297
|
+
deadline = time.time() + grace
|
|
298
|
+
while time.time() < deadline and crun_state(name) == "running":
|
|
299
|
+
time.sleep(0.25)
|
|
300
|
+
namespaced(["crun", "--root", CRUN_ROOT, "kill", cid, "KILL"], check=False)
|
|
301
|
+
namespaced(["crun", "--root", CRUN_ROOT, "delete", "--force", cid], check=False)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def write_status(name, state, error=None):
|
|
305
|
+
status = {"state": state, "updatedAt": time.time()}
|
|
306
|
+
if error:
|
|
307
|
+
status["error"] = error
|
|
308
|
+
write_json(f"{service_dir(name)}/status.json", status)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def already_ready(definition):
|
|
312
|
+
if crun_state(definition["name"]) != "running":
|
|
313
|
+
return False
|
|
314
|
+
return definition.get("readiness", "tcp") == "none" or listener_owner(definition["port"]) == UID
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def launch(definition, log):
|
|
318
|
+
"""Recreates the container from its definition and waits for readiness."""
|
|
319
|
+
name = definition["name"]
|
|
320
|
+
stop_container(name)
|
|
321
|
+
if listener_owner(definition["port"]) is not None:
|
|
322
|
+
raise RuntimeError(f"Port {definition['port']} is already in use")
|
|
323
|
+
if definition["kind"] == "image":
|
|
324
|
+
rootfs, image_config = prepare_image(definition, log)
|
|
325
|
+
else:
|
|
326
|
+
rootfs, image_config = prepare_host_rootfs(name), {}
|
|
327
|
+
oci_config(definition, rootfs, image_config)
|
|
328
|
+
namespaced(["crun", "--root", CRUN_ROOT, "create", "--bundle", service_dir(name), container_id(name)], log)
|
|
329
|
+
namespaced(["crun", "--root", CRUN_ROOT, "start", container_id(name)], log)
|
|
330
|
+
wait_ready(definition, log, time.time() + definition.get("readySeconds", 180))
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def start(name):
|
|
334
|
+
with locked(name):
|
|
335
|
+
definition = read_definition(name)
|
|
336
|
+
if already_ready(definition):
|
|
337
|
+
write_status(name, "ready")
|
|
338
|
+
return describe(name)
|
|
339
|
+
write_status(name, "starting")
|
|
340
|
+
with open_log(name) as log:
|
|
341
|
+
log.write(f"--- start {time.strftime('%Y-%m-%dT%H:%M:%S')}\n")
|
|
342
|
+
log.flush()
|
|
343
|
+
try:
|
|
344
|
+
launch(definition, log)
|
|
345
|
+
except (OSError, RuntimeError, ValueError, KeyError) as error:
|
|
346
|
+
log.write(f"--- error: {error}\n")
|
|
347
|
+
write_status(name, "error", str(error))
|
|
348
|
+
return describe(name)
|
|
349
|
+
write_status(name, "ready")
|
|
350
|
+
log.write("--- ready\n")
|
|
351
|
+
return describe(name)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def stop(name):
|
|
355
|
+
with locked(name):
|
|
356
|
+
stop_container(name)
|
|
357
|
+
write_status(name, "stopped")
|
|
358
|
+
return describe(name)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def remove(name):
|
|
362
|
+
if not os.path.isdir(service_dir(name)):
|
|
363
|
+
return {"removed": False}
|
|
364
|
+
with locked(name):
|
|
365
|
+
stop_container(name)
|
|
366
|
+
namespaced(["buildah", "rm", f"lakebox-{name}"], check=False)
|
|
367
|
+
shutil.rmtree(service_dir(name), ignore_errors=True)
|
|
368
|
+
return {"removed": True}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def describe(name, include_log=False):
|
|
372
|
+
definition = read_definition(name)
|
|
373
|
+
status = {}
|
|
374
|
+
with contextlib.suppress(FileNotFoundError, json.JSONDecodeError):
|
|
375
|
+
with open(f"{service_dir(name)}/status.json", encoding="utf-8") as handle:
|
|
376
|
+
status = json.load(handle)
|
|
377
|
+
state = status.get("state", "stopped")
|
|
378
|
+
running = crun_state(name) == "running"
|
|
379
|
+
if state == "ready" and not running:
|
|
380
|
+
state = "stopped"
|
|
381
|
+
if state == "starting" and not running and not lock_held(name):
|
|
382
|
+
state = "error" if status.get("error") else "stopped"
|
|
383
|
+
result = {"name": name, "state": state, "definition": definition}
|
|
384
|
+
if status.get("error") and state == "error":
|
|
385
|
+
result["error"] = status["error"]
|
|
386
|
+
if include_log:
|
|
387
|
+
result["log"] = tail(name, 200, 16_000)
|
|
388
|
+
return result
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def lock_held(name):
|
|
392
|
+
with contextlib.suppress(FileNotFoundError):
|
|
393
|
+
with open(f"{service_dir(name)}/.lock", "w", encoding="utf-8") as handle:
|
|
394
|
+
try:
|
|
395
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
396
|
+
except BlockingIOError:
|
|
397
|
+
return True
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def names():
|
|
402
|
+
if not os.path.isdir(SERVICES):
|
|
403
|
+
return []
|
|
404
|
+
return sorted(
|
|
405
|
+
entry for entry in os.listdir(SERVICES) if os.path.exists(f"{service_dir(entry)}/service.json")
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def tail(name, max_lines, max_chars):
|
|
410
|
+
path = f"{service_dir(name)}/service.log"
|
|
411
|
+
if not os.path.exists(path):
|
|
412
|
+
return ""
|
|
413
|
+
with open(path, "rb") as handle:
|
|
414
|
+
handle.seek(0, os.SEEK_END)
|
|
415
|
+
size = handle.tell()
|
|
416
|
+
handle.seek(max(0, size - max_chars * 4))
|
|
417
|
+
text = handle.read().decode("utf-8", errors="replace")
|
|
418
|
+
return "\n".join(text.splitlines()[-max_lines:])[-max_chars:]
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def main():
|
|
422
|
+
command = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
423
|
+
if command == "put":
|
|
424
|
+
definition = json.loads(base64.b64decode(sys.argv[2]))
|
|
425
|
+
name = definition["name"]
|
|
426
|
+
with locked(name):
|
|
427
|
+
write_json(f"{service_dir(name)}/service.json", definition)
|
|
428
|
+
output = describe(name)
|
|
429
|
+
elif command == "start":
|
|
430
|
+
output = start(sys.argv[2])
|
|
431
|
+
elif command == "stop":
|
|
432
|
+
output = stop(sys.argv[2])
|
|
433
|
+
elif command == "remove":
|
|
434
|
+
output = remove(sys.argv[2])
|
|
435
|
+
elif command == "list":
|
|
436
|
+
output = {"services": [describe(name, include_log=True) for name in names()]}
|
|
437
|
+
elif command == "logs":
|
|
438
|
+
output = {"log": tail(sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))}
|
|
439
|
+
elif command == "start-all":
|
|
440
|
+
output = {"services": [start(name) for name in names() if read_definition(name).get("autostart")]}
|
|
441
|
+
else:
|
|
442
|
+
print("usage: lakebox-service put|start|stop|remove|list|logs|start-all", file=sys.stderr)
|
|
443
|
+
sys.exit(2)
|
|
444
|
+
json.dump(output, sys.stdout)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
|
449
|
+
main()
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: html-page
|
|
3
|
+
description: Deploy or host a web app, website, HTML page, Three.js scene, 3D game, visualization, or browser demo as a Lakebox service. Use when asked to deploy an app, service, site, or page and return an open URL.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# HTML Page
|
|
7
|
+
|
|
8
|
+
Generate one complete, self-contained page; keep CSS, JavaScript, workers,
|
|
9
|
+
assets, and data inline. Do not use external CDNs.
|
|
10
|
+
|
|
11
|
+
When the user names an existing application, prefer its maintained Docker/OCI
|
|
12
|
+
image. Use `web_search` when needed to find upstream image documentation, then
|
|
13
|
+
verify unprivileged execution, `127.0.0.1:$SERVICE_PORT`, one HTTP port, and
|
|
14
|
+
`$SERVICE_PATH` support. Deploy images only through
|
|
15
|
+
`create_lakebox_service`; never run a container CLI directly. Use the
|
|
16
|
+
self-contained page below when no compatible image exists or the user wants a
|
|
17
|
+
custom browser experience.
|
|
18
|
+
|
|
19
|
+
Route requests such as "deploy this app", "serve this page", "host this web
|
|
20
|
+
demo", or "deploy it as a service" here when the result can run entirely in a
|
|
21
|
+
browser. This includes interactive Three.js scenes and small games.
|
|
22
|
+
|
|
23
|
+
Call `create_lakebox_service` once. Use a short lowercase `name` for each
|
|
24
|
+
distinct page:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"name": "html-page",
|
|
29
|
+
"image": "docker.io/library/python:3.12-alpine",
|
|
30
|
+
"env": { "HTML_CONTENT": "<generated page>" },
|
|
31
|
+
"command": [
|
|
32
|
+
"sh",
|
|
33
|
+
"-c",
|
|
34
|
+
"mkdir -p /site && printf %s \"$HTML_CONTENT\" > /site/index.html && exec python -m http.server \"$SERVICE_PORT\" --bind 127.0.0.1 --directory /site"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The call returns after the server is listening. Call it again with the same
|
|
40
|
+
name to replace the page. Return the service `openUrl` as a Markdown link.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: jupyter-lab
|
|
3
|
+
description: Deploy or run JupyterLab as a Lakebox service behind the user's authenticated route, without a separate Jupyter password or token. Use for notebook app or notebook service requests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# JupyterLab
|
|
7
|
+
|
|
8
|
+
Use the pinned Docker/OCI image through `create_lakebox_service`. Do not run a
|
|
9
|
+
container CLI directly.
|
|
10
|
+
|
|
11
|
+
Call `create_lakebox_service` once:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"name": "jupyter-lab",
|
|
16
|
+
"image": "docker.io/library/python:3.12-slim",
|
|
17
|
+
"mounts": [{ "source": "notebooks", "target": "/work" }],
|
|
18
|
+
"stripPrefix": false,
|
|
19
|
+
"openPath": "lab",
|
|
20
|
+
"command": [
|
|
21
|
+
"sh",
|
|
22
|
+
"-c",
|
|
23
|
+
"[ -x /work/.venv/bin/jupyter ] || { python -m venv /work/.venv && /work/.venv/bin/pip install --quiet jupyterlab==4.4.10; } && exec /work/.venv/bin/jupyter lab --allow-root --no-browser --ip 127.0.0.1 --port \"$SERVICE_PORT\" --ServerApp.root_dir=/work --ServerApp.base_url=\"$SERVICE_PATH\" --IdentityProvider.token= --PasswordIdentityProvider.hashed_password="
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The AppKit route supplies authentication, so do not add another token or
|
|
29
|
+
password. Jupyter serves its full route path, so `stripPrefix` is false.
|
|
30
|
+
Notebooks and the virtual environment persist in `~/notebooks`, so later starts
|
|
31
|
+
skip installation. The first start installs JupyterLab and can take a minute.
|
|
32
|
+
Return the service `openUrl` as a Markdown link.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lakebox-showcase
|
|
3
|
+
description: Recommend and deploy high-value single-user Lakebox browser demos. Use when a user asks what they can do with Lakebox, wants fun Lakebox or Docker demo ideas, or needs a one-port unprivileged showcase.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lakebox Showcase
|
|
7
|
+
|
|
8
|
+
For a broad question such as "what can I do with Lakebox?", recommend a short
|
|
9
|
+
list before deploying anything:
|
|
10
|
+
|
|
11
|
+
1. A retro desktop, paint app, or pixel-art editor built as a self-contained
|
|
12
|
+
HTML page. Use JS Paint and Piskel as design inspiration.
|
|
13
|
+
2. A browser music player or sequencer inspired by Webamp or Efflux.
|
|
14
|
+
3. A data-transformation workbench inspired by CyberChef or IT Tools.
|
|
15
|
+
4. A diagram, drawing, or whiteboard app inspired by Excalidraw or diagrams.net.
|
|
16
|
+
5. A self-contained 3D scene, puzzle, or arcade game.
|
|
17
|
+
6. JupyterLab for a real stateful development environment.
|
|
18
|
+
7. A network speed test for demonstrating the AppKit-to-Lakebox route.
|
|
19
|
+
|
|
20
|
+
Prefer a maintained Docker/OCI image when the user names an existing
|
|
21
|
+
application. If the image is not already known, use `web_search` to find the
|
|
22
|
+
upstream repository or image documentation. Verify that it can run
|
|
23
|
+
unprivileged, bind `127.0.0.1:$SERVICE_PORT`, expose one HTTP port, and work
|
|
24
|
+
beneath `$SERVICE_PATH`. Deploy it only through `create_lakebox_service`; never
|
|
25
|
+
run `docker`, `podman`, or `buildah` directly.
|
|
26
|
+
|
|
27
|
+
For a custom experience, prefer the first five because browser-only code
|
|
28
|
+
packaged in a small image is reliable behind the user-scoped Lakebox route. Use
|
|
29
|
+
the packaged `jupyter-lab` or `network-speed-test` skill when the user selects
|
|
30
|
+
one of those exact demos.
|
|
31
|
+
|
|
32
|
+
Do not recommend these as primary Lakebox showcases:
|
|
33
|
+
|
|
34
|
+
- PairDrop, LAN Games, or multiplayer collaboration. Lakebox routes and homes
|
|
35
|
+
are user-scoped, so cross-user interaction has little value.
|
|
36
|
+
- Webtop. Lakebox already provides a built-in desktop and terminal.
|
|
37
|
+
- Quake, console emulators, or ROM-based apps. Assets, licensing, device access,
|
|
38
|
+
or elevated runtime requirements make them unreliable defaults.
|
|
39
|
+
- Large local ML applications such as offline translation. Model downloads and
|
|
40
|
+
memory use make the first run slow and fragile.
|
|
41
|
+
- An upstream image that assumes port 80, root, a dedicated hostname, or `/`
|
|
42
|
+
asset paths. Use it only after proving that it listens on
|
|
43
|
+
`127.0.0.1:$SERVICE_PORT` and works beneath `$SERVICE_PATH`.
|
|
44
|
+
|
|
45
|
+
When the user asks to deploy a showcase but does not select one, generate a
|
|
46
|
+
polished self-contained retro desktop with two or three interactive mini apps.
|
|
47
|
+
Replace the placeholder HTML below with the complete page, then call
|
|
48
|
+
`create_lakebox_service` once:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"name": "lakebox-showcase",
|
|
53
|
+
"image": "docker.io/library/python:3.12-alpine",
|
|
54
|
+
"env": {
|
|
55
|
+
"HTML_CONTENT": "<generated self-contained retro desktop page>"
|
|
56
|
+
},
|
|
57
|
+
"command": [
|
|
58
|
+
"sh",
|
|
59
|
+
"-c",
|
|
60
|
+
"mkdir -p /site && printf %s \"$HTML_CONTENT\" > /site/index.html && exec python -m http.server \"$SERVICE_PORT\" --bind 127.0.0.1 --directory /site"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Keep all CSS, JavaScript, assets, workers, and data inline. Do not use external
|
|
66
|
+
CDNs. Return the service `openUrl` as a Markdown link.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: network-speed-test
|
|
3
|
+
description: Deploy or run a network speed-test app as a Lakebox service using LibreSpeed to measure browser-to-AppKit-to-Lakebox HTTP throughput.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Network Speed Test
|
|
7
|
+
|
|
8
|
+
Use the pinned Docker/OCI image through `create_lakebox_service`. Do not run a
|
|
9
|
+
container CLI directly.
|
|
10
|
+
|
|
11
|
+
Call `create_lakebox_service` once:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"name": "speed-test",
|
|
16
|
+
"image": "docker.io/library/alpine:3.22",
|
|
17
|
+
"mounts": [{ "source": "speed-test", "target": "/app" }],
|
|
18
|
+
"command": [
|
|
19
|
+
"sh",
|
|
20
|
+
"-c",
|
|
21
|
+
"set -eu; cd /app; arch=$(uname -m); case \"$arch\" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac; [ -x speedtest-backend ] || wget -qO- \"https://github.com/librespeed/speedtest-go/releases/download/v1.1.6/speedtest-go_1.1.6_linux_${arch}.tar.gz\" | tar -xz; printf 'bind_address = \"127.0.0.1\"\\nlisten_port = %s\\nserver_lat = 1\\nserver_lng = 1\\ndatabase_type = \"none\"\\n' \"$SERVICE_PORT\" > settings.toml; exec ./speedtest-backend"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Do not call public speed-test services or external CDNs. The result measures
|
|
27
|
+
browser-to-AppKit-to-Lakebox traffic, including proxy and SSH forwarding
|
|
28
|
+
overhead; it is not an ISP or raw workspace network test. Return the service
|
|
29
|
+
`openUrl` as a Markdown link.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: realtime-whiteboard
|
|
3
|
+
description: Deploy or run a realtime collaborative whiteboard app as a Lakebox service using WBO and user-scoped WebSocket routing.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Realtime Whiteboard
|
|
7
|
+
|
|
8
|
+
Use the pinned Docker/OCI image through `create_lakebox_service`. Do not run a
|
|
9
|
+
container CLI directly.
|
|
10
|
+
|
|
11
|
+
Call `create_lakebox_service` once:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"name": "realtime-whiteboard",
|
|
16
|
+
"image": "docker.io/library/node:24-alpine",
|
|
17
|
+
"mounts": [{ "source": "whiteboard", "target": "/data" }],
|
|
18
|
+
"command": [
|
|
19
|
+
"sh",
|
|
20
|
+
"-c",
|
|
21
|
+
"set -eu; mkdir -p /data/app /data/boards; cd /data/app; [ -f server/server.mjs ] || wget -qO- https://github.com/lovasoa/whitebophir/archive/refs/tags/v2.17.1.tar.gz | tar -xz --strip-components=1; [ -d node_modules ] || npm install --omit=dev --ignore-scripts --no-audit --no-fund; WBO_HISTORY_DIR=/data/boards HOST=127.0.0.1 PORT=\"$SERVICE_PORT\" exec node server/server.mjs"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Boards persist in `~/whiteboard`. The first start downloads and installs WBO.
|
|
27
|
+
WebSocket clients use relative URLs, so the route prefix stays intact. Return
|
|
28
|
+
the service `openUrl` as a Markdown link.
|
package/src/_package.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/** Read one source or packaged resource from the appkit-lakebox package root. */
|
|
6
|
+
export function readPackageResource(...segments: string[]): string {
|
|
7
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const parent = dirname(moduleDirectory);
|
|
9
|
+
const packageRoot = basename(parent) === "lib" ? dirname(parent) : parent;
|
|
10
|
+
return readFileSync(join(packageRoot, ...segments), "utf8");
|
|
11
|
+
}
|