@p10i/create-nestbox 0.5.0 → 0.6.0
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/.dockerignore +18 -18
- package/CHANGELOG.md +9 -0
- package/INSTALL.md +32 -24
- package/README.md +22 -4
- package/bin/create-nestbox.js +2 -60
- package/docker/control/Dockerfile +4 -5
- package/docker/control/server.py +4 -442
- package/docker/nginx/Dockerfile +11 -11
- package/docker/opencode/Dockerfile +6 -2
- package/docker/php-fpm/Dockerfile +22 -22
- package/docker-compose.yaml +1 -20
- package/home/code/__includes/footer.php +2 -2
- package/home/code/__includes/header.php +5 -5
- package/home/configs/nginx/nginx.conf +17 -17
- package/home/configs/opencode/control-mcp.js +1 -21
- package/home/configs/opencode/instructions.md +11 -11
- package/home/configs/opencode/opencode.json +1 -1
- package/home/configs/php/composer.json +8 -8
- package/home/configs/php/init.php +2 -2
- package/home/configs/php/preload.php +4 -4
- package/package.json +2 -6
- package/scripts/check-package.js +0 -8
- package/tests/control.mjs +55 -83
- package/tests/creator.mjs +13 -18
- package/tests/nestbox.ps1 +3 -3
- package/tests/nestbox.sh +3 -3
- package/docker/control/host_bridge.py +0 -205
- package/host-runner.mjs +0 -314
- package/package.host.json +0 -8
- package/tests/host-runner.mjs +0 -215
package/docker/control/server.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
import hashlib
|
|
3
2
|
import http.client
|
|
4
3
|
import json
|
|
5
4
|
import os
|
|
@@ -7,22 +6,15 @@ import re
|
|
|
7
6
|
import socket
|
|
8
7
|
import sys
|
|
9
8
|
import threading
|
|
10
|
-
import time
|
|
11
9
|
import uuid
|
|
12
10
|
from datetime import datetime, timezone
|
|
13
11
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
-
from urllib.parse import
|
|
12
|
+
from urllib.parse import quote, unquote, urlparse
|
|
15
13
|
|
|
16
14
|
DOCKER_SOCKET = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
|
|
17
|
-
WORKSPACE_PATH = os.environ.get("WORKSPACE_PATH", "/workspace")
|
|
18
15
|
PROJECT_NAME = os.environ.get("COMPOSE_PROJECT_NAME", "").strip()
|
|
19
16
|
SERVICE_NAME = os.environ.get("NESTBOX_CONTROL_SERVICE", "control")
|
|
20
|
-
HOST_TIMEOUT = int(os.environ.get("NESTBOX_HOST_RUNNER_TIMEOUT", "3600"))
|
|
21
|
-
HOST_HEARTBEAT_TTL = int(os.environ.get("NESTBOX_HOST_RUNNER_HEARTBEAT_TTL", "10"))
|
|
22
|
-
HOST_LONG_POLL_TIMEOUT = int(os.environ.get("NESTBOX_HOST_LONG_POLL_TIMEOUT", "25"))
|
|
23
|
-
MAX_HOST_JOBS = int(os.environ.get("NESTBOX_MAX_HOST_JOBS", "100"))
|
|
24
17
|
MAX_BODY_BYTES = 128 * 1024
|
|
25
|
-
MAX_HOST_BODY_BYTES = 64 * 1024 * 1024
|
|
26
18
|
MAX_COMMAND_PARTS = 256
|
|
27
19
|
MAX_COMMAND_BYTES = 64 * 1024
|
|
28
20
|
LOG_OUTPUT = os.environ.get("NESTBOX_CONTROL_LOG_OUTPUT", "text").lower()
|
|
@@ -177,243 +169,6 @@ def policy_error(source, target, user, configured_user=""):
|
|
|
177
169
|
return ""
|
|
178
170
|
|
|
179
171
|
|
|
180
|
-
def allows_npm(source):
|
|
181
|
-
labels = (source or {}).get("Labels") or {}
|
|
182
|
-
return bool(source) and truthy(labels, "nestbox.npm.allow") and not truthy(labels, "nestbox.exec.deny-source")
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def package_scripts():
|
|
186
|
-
path = os.path.join(WORKSPACE_PATH, "package.json")
|
|
187
|
-
if not os.path.isfile(path):
|
|
188
|
-
return None
|
|
189
|
-
with open(path, encoding="utf-8") as stream:
|
|
190
|
-
document = json.load(stream)
|
|
191
|
-
scripts = document.get("scripts", {})
|
|
192
|
-
if not isinstance(scripts, dict):
|
|
193
|
-
raise ValueError("package.json scripts must be an object")
|
|
194
|
-
return {str(name): str(command) for name, command in scripts.items()}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
class HostBroker:
|
|
198
|
-
def __init__(self):
|
|
199
|
-
self.condition = threading.Condition()
|
|
200
|
-
self.pending = []
|
|
201
|
-
self.jobs = {}
|
|
202
|
-
self.runner = {}
|
|
203
|
-
self.gone = {}
|
|
204
|
-
|
|
205
|
-
def _validate_ids(self, runner_id, session_id):
|
|
206
|
-
if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", runner_id):
|
|
207
|
-
raise ValueError("invalid runner ID")
|
|
208
|
-
if not re.fullmatch(r"[A-Za-z0-9._:-]{8,128}", session_id):
|
|
209
|
-
raise ValueError("invalid session ID")
|
|
210
|
-
|
|
211
|
-
def connect(self, runner_id, session_id, metadata):
|
|
212
|
-
self._validate_ids(runner_id, session_id)
|
|
213
|
-
now = time.monotonic()
|
|
214
|
-
with self.condition:
|
|
215
|
-
active_id = self.runner.get("id")
|
|
216
|
-
active_age = now - self.runner.get("seen", 0)
|
|
217
|
-
if active_id and active_id != runner_id and active_age <= HOST_HEARTBEAT_TTL:
|
|
218
|
-
raise PermissionError("another host runner is active")
|
|
219
|
-
if active_id and active_id != runner_id:
|
|
220
|
-
for record in self.jobs.values():
|
|
221
|
-
if record.get("runnerId") == active_id and record.get("result") is None:
|
|
222
|
-
record["result"] = {
|
|
223
|
-
"jobId": record["job"]["jobId"],
|
|
224
|
-
"exitCode": 1,
|
|
225
|
-
"stdout": "",
|
|
226
|
-
"stderr": "The assigned host runner disconnected before returning a result.",
|
|
227
|
-
"interrupted": True,
|
|
228
|
-
}
|
|
229
|
-
self.runner = {
|
|
230
|
-
"id": runner_id,
|
|
231
|
-
"sessionId": session_id,
|
|
232
|
-
"seen": now,
|
|
233
|
-
"pid": metadata.get("pid"),
|
|
234
|
-
"platform": metadata.get("platform"),
|
|
235
|
-
"hostname": metadata.get("hostname"),
|
|
236
|
-
}
|
|
237
|
-
self.condition.notify_all()
|
|
238
|
-
|
|
239
|
-
def touch(self, runner_id, session_id):
|
|
240
|
-
self._validate_ids(runner_id, session_id)
|
|
241
|
-
with self.condition:
|
|
242
|
-
if self.runner.get("id") != runner_id or self.runner.get("sessionId") != session_id:
|
|
243
|
-
raise PermissionError("host runner session was superseded")
|
|
244
|
-
self.runner["seen"] = time.monotonic()
|
|
245
|
-
self.condition.notify_all()
|
|
246
|
-
|
|
247
|
-
def status(self):
|
|
248
|
-
with self.condition:
|
|
249
|
-
if not self.runner:
|
|
250
|
-
return {"available": False}
|
|
251
|
-
age = max(0, time.monotonic() - self.runner["seen"])
|
|
252
|
-
return {
|
|
253
|
-
"available": age <= HOST_HEARTBEAT_TTL,
|
|
254
|
-
"ageSeconds": round(age, 3),
|
|
255
|
-
"pid": self.runner.get("pid"),
|
|
256
|
-
"platform": self.runner.get("platform"),
|
|
257
|
-
"hostname": self.runner.get("hostname"),
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
def enqueue(self, job):
|
|
261
|
-
with self.condition:
|
|
262
|
-
if len(self.jobs) >= MAX_HOST_JOBS:
|
|
263
|
-
raise RuntimeError("too many host jobs are pending")
|
|
264
|
-
self.jobs[job["jobId"]] = {"job": job, "result": None, "runnerId": None}
|
|
265
|
-
self.pending.append(job["jobId"])
|
|
266
|
-
self.condition.notify_all()
|
|
267
|
-
|
|
268
|
-
def next(self, runner_id, session_id, timeout):
|
|
269
|
-
deadline = time.monotonic() + timeout
|
|
270
|
-
with self.condition:
|
|
271
|
-
while True:
|
|
272
|
-
if self.runner.get("id") != runner_id or self.runner.get("sessionId") != session_id:
|
|
273
|
-
raise PermissionError("host runner session was superseded")
|
|
274
|
-
self.runner["seen"] = time.monotonic()
|
|
275
|
-
for record in self.jobs.values():
|
|
276
|
-
if record.get("runnerId") == runner_id and record.get("result") is None:
|
|
277
|
-
return record["job"]
|
|
278
|
-
if self.pending:
|
|
279
|
-
identifier = self.pending.pop(0)
|
|
280
|
-
record = self.jobs.get(identifier)
|
|
281
|
-
if record:
|
|
282
|
-
record["runnerId"] = runner_id
|
|
283
|
-
return record["job"]
|
|
284
|
-
remaining = deadline - time.monotonic()
|
|
285
|
-
if remaining <= 0:
|
|
286
|
-
return None
|
|
287
|
-
self.condition.wait(min(1, remaining))
|
|
288
|
-
|
|
289
|
-
def complete(self, runner_id, session_id, identifier, result):
|
|
290
|
-
with self.condition:
|
|
291
|
-
if self.runner.get("id") != runner_id or self.runner.get("sessionId") != session_id:
|
|
292
|
-
raise PermissionError("host runner session was superseded")
|
|
293
|
-
serialized = json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
|
|
294
|
-
fingerprint = hashlib.sha256(serialized).hexdigest()
|
|
295
|
-
record = self.jobs.get(identifier)
|
|
296
|
-
terminal = self.gone.get(identifier)
|
|
297
|
-
if not record and terminal:
|
|
298
|
-
if terminal.get("fingerprint") == fingerprint:
|
|
299
|
-
return True
|
|
300
|
-
if terminal.get("fingerprint"):
|
|
301
|
-
raise RuntimeError("conflicting host result replay")
|
|
302
|
-
raise FileNotFoundError(terminal["error"])
|
|
303
|
-
if not record:
|
|
304
|
-
raise FileNotFoundError("host job is no longer available")
|
|
305
|
-
if record.get("runnerId") != runner_id:
|
|
306
|
-
raise ValueError("host job belongs to another runner")
|
|
307
|
-
if record.get("result") is not None:
|
|
308
|
-
if record.get("fingerprint") == fingerprint:
|
|
309
|
-
return True
|
|
310
|
-
raise RuntimeError("conflicting host result replay")
|
|
311
|
-
if result.get("jobId") != identifier:
|
|
312
|
-
raise ValueError("host result contains a mismatched job ID")
|
|
313
|
-
job = record["job"]
|
|
314
|
-
if job["type"] == "npm.run":
|
|
315
|
-
if not isinstance(result.get("exitCode"), int) or not isinstance(result.get("stdout"), str) or not isinstance(result.get("stderr"), str):
|
|
316
|
-
raise ValueError("invalid npm run result")
|
|
317
|
-
elif job["type"] == "npm.scripts.request":
|
|
318
|
-
if result.get("status") != "confirmation_required" or result.get("requestId") != job.get("requestId") or result.get("operation") != job.get("operation") or result.get("name") != job.get("name"):
|
|
319
|
-
if not isinstance(result.get("exitCode"), int):
|
|
320
|
-
raise ValueError("invalid script change request result")
|
|
321
|
-
elif job["type"] == "npm.scripts.confirm":
|
|
322
|
-
if result.get("status") == "success" and result.get("requestId") != job.get("requestId"):
|
|
323
|
-
raise ValueError("invalid script confirmation result")
|
|
324
|
-
if result.get("status") != "success" and not isinstance(result.get("exitCode"), int):
|
|
325
|
-
raise ValueError("invalid script confirmation result")
|
|
326
|
-
record["result"] = result
|
|
327
|
-
record["fingerprint"] = fingerprint
|
|
328
|
-
self.condition.notify_all()
|
|
329
|
-
return False
|
|
330
|
-
|
|
331
|
-
def _remember(self, identifier, record=None, error="host job is gone"):
|
|
332
|
-
self.gone[identifier] = {
|
|
333
|
-
"fingerprint": record.get("fingerprint") if record else None,
|
|
334
|
-
"error": error,
|
|
335
|
-
"time": time.monotonic(),
|
|
336
|
-
}
|
|
337
|
-
while len(self.gone) > MAX_HOST_JOBS:
|
|
338
|
-
del self.gone[next(iter(self.gone))]
|
|
339
|
-
|
|
340
|
-
def reject(self, runner_id, identifier, error):
|
|
341
|
-
with self.condition:
|
|
342
|
-
record = self.jobs.get(identifier)
|
|
343
|
-
if not record or record.get("runnerId") != runner_id or record.get("result") is not None:
|
|
344
|
-
return
|
|
345
|
-
record["result"] = {
|
|
346
|
-
"jobId": identifier,
|
|
347
|
-
"exitCode": 1,
|
|
348
|
-
"stdout": "",
|
|
349
|
-
"stderr": f"Host result was rejected: {error}",
|
|
350
|
-
"interrupted": True,
|
|
351
|
-
}
|
|
352
|
-
self.condition.notify_all()
|
|
353
|
-
|
|
354
|
-
def wait(self, identifier, timeout=HOST_TIMEOUT):
|
|
355
|
-
deadline = time.monotonic() + timeout
|
|
356
|
-
with self.condition:
|
|
357
|
-
while True:
|
|
358
|
-
record = self.jobs.get(identifier)
|
|
359
|
-
if record and record.get("result") is None:
|
|
360
|
-
age = time.monotonic() - self.runner.get("seen", 0) if self.runner else float("inf")
|
|
361
|
-
owner = record.get("runnerId")
|
|
362
|
-
lost_owner = bool(owner and self.runner.get("id") != owner)
|
|
363
|
-
if lost_owner or age > HOST_HEARTBEAT_TTL:
|
|
364
|
-
record["result"] = {
|
|
365
|
-
"jobId": identifier,
|
|
366
|
-
"exitCode": 1,
|
|
367
|
-
"stdout": "",
|
|
368
|
-
"stderr": "The assigned host runner disconnected before returning a result." if owner else "The host runner disconnected before accepting the job.",
|
|
369
|
-
"interrupted": True,
|
|
370
|
-
}
|
|
371
|
-
if record and record.get("result") is not None:
|
|
372
|
-
result = record["result"]
|
|
373
|
-
del self.jobs[identifier]
|
|
374
|
-
if identifier in self.pending:
|
|
375
|
-
self.pending.remove(identifier)
|
|
376
|
-
self._remember(identifier, record)
|
|
377
|
-
return result
|
|
378
|
-
remaining = deadline - time.monotonic()
|
|
379
|
-
if remaining <= 0:
|
|
380
|
-
expired = self.jobs.pop(identifier, None)
|
|
381
|
-
if identifier in self.pending:
|
|
382
|
-
self.pending.remove(identifier)
|
|
383
|
-
self._remember(identifier, expired, f"host job expired after {timeout} seconds")
|
|
384
|
-
raise TimeoutError(f"host runner did not finish within {timeout} seconds")
|
|
385
|
-
self.condition.wait(min(1, remaining))
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
HOST_BROKER = HostBroker()
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
def host_status():
|
|
392
|
-
return HOST_BROKER.status()
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
def enqueue(job):
|
|
396
|
-
HOST_BROKER.enqueue(job)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
def wait_result(identifier, timeout=HOST_TIMEOUT):
|
|
400
|
-
return HOST_BROKER.wait(identifier, timeout)
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def record_host_result(identifier, source_name, script, result, detached):
|
|
404
|
-
stdout, stderr = str(result.get("stdout", "")), str(result.get("stderr", ""))
|
|
405
|
-
log_output(identifier, "npm.stdout", stdout)
|
|
406
|
-
log_output(identifier, "npm.stderr", stderr)
|
|
407
|
-
log_event("npm.complete", runId=identifier, source=source_name, script=script, detached=detached, executor="host", exitCode=result.get("exitCode"), hostPlatform=result.get("hostPlatform"), hostName=result.get("hostName"), stdoutTruncated=bool(result.get("stdoutTruncated")), stderrTruncated=bool(result.get("stderrTruncated")))
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
def wait_detached(identifier, source_name, script):
|
|
411
|
-
try:
|
|
412
|
-
record_host_result(identifier, source_name, script, wait_result(identifier), True)
|
|
413
|
-
except Exception as error:
|
|
414
|
-
log_event("npm.error", runId=identifier, source=source_name, script=script, detached=True, error=str(error))
|
|
415
|
-
|
|
416
|
-
|
|
417
172
|
def valid_command(command):
|
|
418
173
|
return (isinstance(command, list) and 0 < len(command) <= MAX_COMMAND_PARTS
|
|
419
174
|
and all(isinstance(part, str) and "\0" not in part for part in command)
|
|
@@ -421,7 +176,7 @@ def valid_command(command):
|
|
|
421
176
|
|
|
422
177
|
|
|
423
178
|
class Handler(BaseHTTPRequestHandler):
|
|
424
|
-
server_version = "nestbox-control/0.
|
|
179
|
+
server_version = "nestbox-control/0.6"
|
|
425
180
|
|
|
426
181
|
def setup(self):
|
|
427
182
|
super().setup()
|
|
@@ -457,21 +212,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
457
212
|
if path == "/health":
|
|
458
213
|
try:
|
|
459
214
|
docker_request("GET", "/_ping")
|
|
460
|
-
self.send_json(200, {"ok": True, "dockerSocketAccess": True
|
|
215
|
+
self.send_json(200, {"ok": True, "dockerSocketAccess": True})
|
|
461
216
|
except Exception as error:
|
|
462
|
-
self.send_json(503, {"ok": False, "dockerSocketAccess": False, "error": str(error)
|
|
463
|
-
return
|
|
464
|
-
if path == "/npm/scripts":
|
|
465
|
-
source = self.caller()
|
|
466
|
-
if not allows_npm(source):
|
|
467
|
-
self.send_json(403, {"error": "caller is not allowed to use npm"})
|
|
468
|
-
return
|
|
469
|
-
try:
|
|
470
|
-
scripts = package_scripts()
|
|
471
|
-
except Exception as error:
|
|
472
|
-
self.send_json(400, {"error": str(error)})
|
|
473
|
-
return
|
|
474
|
-
self.send_json(200, {"scripts": scripts}) if scripts is not None else self.send_json(404, {"error": "package.json was not found at /workspace/package.json"})
|
|
217
|
+
self.send_json(503, {"ok": False, "dockerSocketAccess": False, "error": str(error)})
|
|
475
218
|
return
|
|
476
219
|
self.send_json(404, {"error": "not found"})
|
|
477
220
|
|
|
@@ -484,12 +227,6 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
484
227
|
path = urlparse(self.path).path
|
|
485
228
|
if path.startswith("/containers/") and path.endswith("/exec"):
|
|
486
229
|
self.container_exec(unquote(path[len("/containers/"):-len("/exec")]).strip("/"), body)
|
|
487
|
-
elif path == "/npm/run":
|
|
488
|
-
self.npm_run(body)
|
|
489
|
-
elif path == "/npm/scripts/request":
|
|
490
|
-
self.script_request(body)
|
|
491
|
-
elif path == "/npm/scripts/confirm":
|
|
492
|
-
self.script_confirm(body)
|
|
493
230
|
else:
|
|
494
231
|
self.send_json(404, {"error": "not found"})
|
|
495
232
|
|
|
@@ -533,185 +270,10 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
533
270
|
log_event("exec.error", runId=identifier, source=service(source), target=service(target), command=command, error=str(error))
|
|
534
271
|
self.send_json(502, {"error": str(error)})
|
|
535
272
|
|
|
536
|
-
def npm_context(self):
|
|
537
|
-
source = self.caller()
|
|
538
|
-
if not allows_npm(source):
|
|
539
|
-
self.send_json(403, {"error": "caller is not allowed to use npm"})
|
|
540
|
-
return None
|
|
541
|
-
if not host_status().get("available"):
|
|
542
|
-
self.send_json(503, {"error": "host runner is unavailable; run `npm run host --` from the workspace"})
|
|
543
|
-
return None
|
|
544
|
-
return source
|
|
545
|
-
|
|
546
|
-
def npm_run(self, body):
|
|
547
|
-
source = self.npm_context()
|
|
548
|
-
if source is None: return
|
|
549
|
-
script, args = body.get("script"), body.get("args", [])
|
|
550
|
-
if not isinstance(script, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", script) or script == "host":
|
|
551
|
-
self.send_json(400, {"error": "invalid or protected script name"}); return
|
|
552
|
-
if not isinstance(args, list) or not all(isinstance(arg, str) and "\0" not in arg for arg in args):
|
|
553
|
-
self.send_json(400, {"error": "args must be a string array"}); return
|
|
554
|
-
try:
|
|
555
|
-
scripts = package_scripts()
|
|
556
|
-
except Exception as error:
|
|
557
|
-
self.send_json(400, {"error": str(error)}); return
|
|
558
|
-
if scripts is None or script not in scripts:
|
|
559
|
-
self.send_json(404, {"error": "script is not defined in /workspace/package.json"}); return
|
|
560
|
-
identifier = run_id("npm")
|
|
561
|
-
job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.run", "script": script, "args": args, "source": service(source), "createdAt": now_iso()}
|
|
562
|
-
try:
|
|
563
|
-
enqueue(job)
|
|
564
|
-
if body.get("detach"):
|
|
565
|
-
threading.Thread(target=wait_detached, args=(identifier, service(source), script), daemon=True).start()
|
|
566
|
-
self.send_json(202, {"runId": identifier, "detached": True, "executor": "host"}); return
|
|
567
|
-
result = wait_result(identifier)
|
|
568
|
-
record_host_result(identifier, service(source), script, result, False)
|
|
569
|
-
self.send_json(200, {"runId": identifier, "exitCode": result.get("exitCode"), "stdout": str(result.get("stdout", "")), "stderr": str(result.get("stderr", "")), "pid": result.get("pid"), "executor": "host"})
|
|
570
|
-
except Exception as error:
|
|
571
|
-
self.send_json(502, {"error": str(error)})
|
|
572
|
-
|
|
573
|
-
def script_request(self, body):
|
|
574
|
-
source = self.npm_context()
|
|
575
|
-
if source is None: return
|
|
576
|
-
operation, name, command = body.get("operation"), body.get("name"), body.get("command")
|
|
577
|
-
if operation not in {"add", "edit", "delete"} or not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9:_@./ -]{1,128}", name) or name == "host":
|
|
578
|
-
self.send_json(400, {"error": "invalid script change"}); return
|
|
579
|
-
if operation != "delete" and (not isinstance(command, str) or not command.strip() or len(command) > 8192):
|
|
580
|
-
self.send_json(400, {"error": "command must be a non-empty string of at most 8192 characters"}); return
|
|
581
|
-
if operation == "delete" and command is not None:
|
|
582
|
-
self.send_json(400, {"error": "delete does not accept a command"}); return
|
|
583
|
-
identifier = run_id("npm-script-request")
|
|
584
|
-
job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.request", "requestId": identifier, "operation": operation, "name": name, "source": service(source), "createdAt": now_iso()}
|
|
585
|
-
if operation != "delete": job["command"] = command
|
|
586
|
-
try:
|
|
587
|
-
enqueue(job)
|
|
588
|
-
result = wait_result(identifier)
|
|
589
|
-
if result.get("status") != "confirmation_required": raise ValueError(result.get("stderr", "host runner rejected the request"))
|
|
590
|
-
self.send_json(202, {"status": "confirmation_required", "requestId": result.get("requestId"), "operation": operation, "name": name, "expiresAt": result.get("expiresAt")})
|
|
591
|
-
except Exception as error:
|
|
592
|
-
self.send_json(400, {"error": str(error)})
|
|
593
|
-
|
|
594
|
-
def script_confirm(self, body):
|
|
595
|
-
source = self.npm_context()
|
|
596
|
-
if source is None: return
|
|
597
|
-
request_id, code = body.get("requestId"), body.get("code")
|
|
598
|
-
if not isinstance(request_id, str) or not re.fullmatch(r"npm-script-request-[A-Za-z0-9-]{16,80}", request_id) or not isinstance(code, str) or not re.fullmatch(r"[A-Za-z0-9_-]{4,32}", code):
|
|
599
|
-
self.send_json(400, {"error": "invalid request ID or confirmation code"}); return
|
|
600
|
-
identifier = run_id("npm-script-confirm")
|
|
601
|
-
job = {"schemaVersion": 1, "jobId": identifier, "type": "npm.scripts.confirm", "requestId": request_id, "code": code, "source": service(source), "createdAt": now_iso()}
|
|
602
|
-
try:
|
|
603
|
-
enqueue(job)
|
|
604
|
-
result = wait_result(identifier)
|
|
605
|
-
if result.get("status") != "success": raise ValueError(result.get("stderr", "host runner rejected the confirmation"))
|
|
606
|
-
self.send_json(200, {"status": "success", "requestId": request_id, "operation": result.get("operation"), "name": result.get("name")})
|
|
607
|
-
except Exception as error:
|
|
608
|
-
self.send_json(400, {"error": str(error)})
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
class HostHandler(BaseHTTPRequestHandler):
|
|
612
|
-
server_version = "nestbox-host-control/0.5"
|
|
613
|
-
|
|
614
|
-
def setup(self):
|
|
615
|
-
super().setup()
|
|
616
|
-
self.connection.settimeout(HOST_LONG_POLL_TIMEOUT + 10)
|
|
617
|
-
|
|
618
|
-
def log_message(self, fmt, *args):
|
|
619
|
-
log_event("host.http.access", client=self.address_string(), message=fmt % args)
|
|
620
|
-
|
|
621
|
-
def send_json(self, status, value):
|
|
622
|
-
payload = json.dumps(value, ensure_ascii=False).encode()
|
|
623
|
-
self.send_response(status)
|
|
624
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
625
|
-
self.send_header("Content-Length", str(len(payload)))
|
|
626
|
-
self.end_headers()
|
|
627
|
-
self.wfile.write(payload)
|
|
628
|
-
|
|
629
|
-
def runner(self):
|
|
630
|
-
return self.headers.get("X-Nestbox-Runner-Id", "")
|
|
631
|
-
|
|
632
|
-
def session(self):
|
|
633
|
-
return self.headers.get("X-Nestbox-Session-Id", "")
|
|
634
|
-
|
|
635
|
-
def body(self):
|
|
636
|
-
if self.headers.get("Transfer-Encoding"):
|
|
637
|
-
raise ValueError("transfer encoding is not supported")
|
|
638
|
-
length = int(self.headers.get("Content-Length", "0"))
|
|
639
|
-
if length < 0 or length > MAX_HOST_BODY_BYTES:
|
|
640
|
-
raise ValueError("request body is too large")
|
|
641
|
-
value = json.loads(self.rfile.read(length)) if length else {}
|
|
642
|
-
if not isinstance(value, dict):
|
|
643
|
-
raise ValueError("request body must be a JSON object")
|
|
644
|
-
return value
|
|
645
|
-
|
|
646
|
-
def active_session(self):
|
|
647
|
-
try:
|
|
648
|
-
HOST_BROKER.touch(self.runner(), self.session())
|
|
649
|
-
return True
|
|
650
|
-
except PermissionError as error:
|
|
651
|
-
self.send_json(409, {"error": str(error)})
|
|
652
|
-
except ValueError as error:
|
|
653
|
-
self.send_json(400, {"error": str(error)})
|
|
654
|
-
return False
|
|
655
|
-
|
|
656
|
-
def do_GET(self):
|
|
657
|
-
parsed = urlparse(self.path)
|
|
658
|
-
if parsed.path == "/host/health":
|
|
659
|
-
if not self.active_session(): return
|
|
660
|
-
self.send_json(200, {"ok": True, "hostRunner": host_status()})
|
|
661
|
-
return
|
|
662
|
-
if parsed.path == "/host/jobs/next":
|
|
663
|
-
try:
|
|
664
|
-
requested = int((parse_qs(parsed.query).get("timeout") or [HOST_LONG_POLL_TIMEOUT])[0])
|
|
665
|
-
timeout = max(1, min(HOST_LONG_POLL_TIMEOUT, requested))
|
|
666
|
-
job = HOST_BROKER.next(self.runner(), self.session(), timeout)
|
|
667
|
-
if job is None:
|
|
668
|
-
self.send_response(204); self.end_headers(); return
|
|
669
|
-
self.send_json(200, job)
|
|
670
|
-
except PermissionError as error:
|
|
671
|
-
self.send_json(409, {"error": str(error)})
|
|
672
|
-
except (TypeError, ValueError) as error:
|
|
673
|
-
self.send_json(400, {"error": str(error)})
|
|
674
|
-
return
|
|
675
|
-
self.send_json(404, {"error": "not found"})
|
|
676
|
-
|
|
677
|
-
def do_POST(self):
|
|
678
|
-
path = urlparse(self.path).path
|
|
679
|
-
if path == "/host/connect":
|
|
680
|
-
try:
|
|
681
|
-
HOST_BROKER.connect(self.runner(), self.session(), self.body())
|
|
682
|
-
self.send_json(200, {"ok": True})
|
|
683
|
-
except PermissionError as error:
|
|
684
|
-
self.send_json(409, {"error": str(error)})
|
|
685
|
-
except ValueError as error:
|
|
686
|
-
self.send_json(400, {"error": str(error)})
|
|
687
|
-
return
|
|
688
|
-
if not self.active_session(): return
|
|
689
|
-
if path == "/host/heartbeat":
|
|
690
|
-
self.send_json(200, {"ok": True})
|
|
691
|
-
return
|
|
692
|
-
match = re.fullmatch(r"/host/jobs/([A-Za-z0-9._:-]{1,128})/result", path)
|
|
693
|
-
if not match:
|
|
694
|
-
self.send_json(404, {"error": "not found"}); return
|
|
695
|
-
try:
|
|
696
|
-
result = self.body()
|
|
697
|
-
duplicate = HOST_BROKER.complete(self.runner(), self.session(), match.group(1), result)
|
|
698
|
-
self.send_json(202, {"accepted": True, "duplicate": duplicate})
|
|
699
|
-
except FileNotFoundError as error:
|
|
700
|
-
self.send_json(410, {"error": str(error), "jobId": match.group(1)})
|
|
701
|
-
except PermissionError as error:
|
|
702
|
-
self.send_json(409, {"error": str(error)})
|
|
703
|
-
except RuntimeError as error:
|
|
704
|
-
self.send_json(409, {"error": str(error)})
|
|
705
|
-
except ValueError as error:
|
|
706
|
-
HOST_BROKER.reject(self.runner(), match.group(1), str(error))
|
|
707
|
-
self.send_json(400, {"error": str(error)})
|
|
708
|
-
|
|
709
273
|
|
|
710
274
|
def main():
|
|
711
275
|
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", PROJECT_NAME):
|
|
712
276
|
raise SystemExit("COMPOSE_PROJECT_NAME must be a non-empty lowercase project slug")
|
|
713
|
-
host_server = ThreadingHTTPServer(("127.0.0.1", 4089), HostHandler)
|
|
714
|
-
threading.Thread(target=host_server.serve_forever, daemon=True).start()
|
|
715
277
|
ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("NESTBOX_CONTROL_PORT", "4088"))), Handler).serve_forever()
|
|
716
278
|
|
|
717
279
|
|
package/docker/nginx/Dockerfile
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
# syntax=docker/dockerfile:1.7
|
|
2
|
-
|
|
3
|
-
# ---- Stage 1: build the JS/CSS bundles with Rollup ----
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
|
|
3
|
+
# ---- Stage 1: build the JS/CSS bundles with Rollup ----
|
|
4
4
|
FROM node:alpine AS bundles
|
|
5
|
-
|
|
6
|
-
WORKDIR /sources/rollup
|
|
5
|
+
|
|
6
|
+
WORKDIR /sources/rollup
|
|
7
7
|
COPY ./home/configs/rollup/package.json ./package.json
|
|
8
8
|
COPY ./home/configs/rollup/package-lock.json ./package-lock.json
|
|
9
9
|
RUN npm ci --no-audit --no-fund
|
|
10
|
-
|
|
11
|
-
COPY ./home/configs/rollup/rollup.config.js ./rollup.config.js
|
|
12
|
-
COPY ./home/code /home/code
|
|
13
|
-
RUN npx rollup -c
|
|
14
|
-
|
|
10
|
+
|
|
11
|
+
COPY ./home/configs/rollup/rollup.config.js ./rollup.config.js
|
|
12
|
+
COPY ./home/code /home/code
|
|
13
|
+
RUN npx rollup -c
|
|
14
|
+
|
|
15
15
|
# ---- Stage 2: runtime ----
|
|
16
|
-
FROM alpine:
|
|
16
|
+
FROM alpine:latest
|
|
17
17
|
|
|
18
18
|
RUN apk add --no-cache nginx nginx-mod-http-nchan && \
|
|
19
19
|
mkdir -p /run/nginx /var/lib/nginx/tmp
|
|
@@ -10,8 +10,12 @@ FROM ghcr.io/anomalyco/opencode:latest
|
|
|
10
10
|
ARG APP_UID=1000
|
|
11
11
|
ARG APP_GID=1000
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
# Alpine names PHP packages by branch. Select its newest available branch
|
|
14
|
+
# instead of retaining an older branch as the rolling base image advances.
|
|
15
|
+
RUN php_package="$(apk search --no-cache --quiet --exact 'php[0-9]*' | grep -E '^php[0-9]+$' | sort -V | tail -n 1)" && \
|
|
16
|
+
test -n "$php_package" && \
|
|
17
|
+
apk add --no-cache git nodejs "$php_package" "$php_package-dom" "$php_package-mbstring" "$php_package-openssl" "$php_package-phar" "$php_package-tokenizer" "$php_package-xml" "$php_package-xmlwriter" su-exec && \
|
|
18
|
+
ln -s "/usr/bin/$php_package" /usr/local/bin/php && \
|
|
15
19
|
addgroup -g "${APP_GID}" -S nestbox && \
|
|
16
20
|
adduser -u "${APP_UID}" -G nestbox -S -h /home/nestbox nestbox
|
|
17
21
|
|
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
# syntax=docker/dockerfile:1.7
|
|
2
|
-
|
|
3
|
-
# ---- Stage 1: install PHP dependencies with Composer ----
|
|
1
|
+
# syntax=docker/dockerfile:1.7
|
|
2
|
+
|
|
3
|
+
# ---- Stage 1: install PHP dependencies with Composer ----
|
|
4
4
|
FROM composer:latest AS vendor
|
|
5
|
-
|
|
6
|
-
WORKDIR /build
|
|
5
|
+
|
|
6
|
+
WORKDIR /build
|
|
7
7
|
COPY ./home/configs/php/composer.json ./composer.json
|
|
8
8
|
COPY ./home/configs/php/composer.lock ./composer.lock
|
|
9
9
|
RUN composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev
|
|
10
|
-
|
|
11
|
-
# ---- Stage 2: runtime ----
|
|
10
|
+
|
|
11
|
+
# ---- Stage 2: runtime ----
|
|
12
12
|
FROM php:fpm-alpine
|
|
13
|
-
|
|
14
|
-
ARG APP_UID=1000
|
|
15
|
-
ARG APP_GID=1000
|
|
16
|
-
|
|
13
|
+
|
|
14
|
+
ARG APP_UID=1000
|
|
15
|
+
ARG APP_GID=1000
|
|
16
|
+
|
|
17
17
|
# Required system + PHP extensions.
|
|
18
18
|
RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS libzip-dev && \
|
|
19
19
|
apk add --no-cache libzip && \
|
|
20
20
|
docker-php-ext-install zip && \
|
|
21
21
|
apk del .build-deps
|
|
22
|
-
|
|
23
|
-
# PHP config.
|
|
24
|
-
COPY ./home/configs/php/php.ini /usr/local/etc/php/conf.d/custom.ini
|
|
25
|
-
|
|
26
|
-
# Vendor dir from stage 1.
|
|
22
|
+
|
|
23
|
+
# PHP config.
|
|
24
|
+
COPY ./home/configs/php/php.ini /usr/local/etc/php/conf.d/custom.ini
|
|
25
|
+
|
|
26
|
+
# Vendor dir from stage 1.
|
|
27
27
|
COPY --from=vendor /build/vendor /vendor
|
|
28
28
|
COPY ./home/configs/php/composer.json /composer.json
|
|
29
29
|
COPY ./home/configs/php/composer.lock /composer.lock
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
# Application source.
|
|
32
32
|
COPY ./home/configs/php /sources/php
|
|
33
33
|
COPY ./home/code /home/code
|
|
34
|
-
|
|
35
|
-
# Run as a non-root, non-default user. UID/GID are passed as build args so
|
|
36
|
-
# they can match the host user when bind-mounting in development, avoiding
|
|
37
|
-
# the classic "files owned by root after compose down" annoyance on Linux.
|
|
34
|
+
|
|
35
|
+
# Run as a non-root, non-default user. UID/GID are passed as build args so
|
|
36
|
+
# they can match the host user when bind-mounting in development, avoiding
|
|
37
|
+
# the classic "files owned by root after compose down" annoyance on Linux.
|
|
38
38
|
RUN addgroup -g "${APP_GID}" -S nestbox && \
|
|
39
39
|
adduser -u "${APP_UID}" -G nestbox -S -H nestbox && \
|
|
40
40
|
sed -i "s/^user = .*/user = nestbox/; s/^group = .*/group = nestbox/" /usr/local/etc/php-fpm.d/www.conf && \
|
|
41
41
|
chown -R nestbox:nestbox /home/code /sources/php /vendor
|
|
42
|
-
|
|
42
|
+
|
|
43
43
|
USER nestbox
|
package/docker-compose.yaml
CHANGED
|
@@ -59,12 +59,6 @@ services:
|
|
|
59
59
|
target: /workspace
|
|
60
60
|
bind:
|
|
61
61
|
create_host_path: false
|
|
62
|
-
- type: bind
|
|
63
|
-
source: ${WORKSPACE_PATH:-.}/package.json
|
|
64
|
-
target: /workspace/package.json
|
|
65
|
-
read_only: true
|
|
66
|
-
bind:
|
|
67
|
-
create_host_path: false
|
|
68
62
|
environment:
|
|
69
63
|
# Event publication and OpenCode automation stay inside Compose.
|
|
70
64
|
- NESTBOX_BUS_PUBLISH_URL=http://nginx:8080/_nestbox/publish
|
|
@@ -106,7 +100,7 @@ services:
|
|
|
106
100
|
- nestbox.exec.allow-target=true
|
|
107
101
|
restart: unless-stopped
|
|
108
102
|
|
|
109
|
-
# Private, label-gated
|
|
103
|
+
# Private, label-gated API for container exec.
|
|
110
104
|
# This is the only Nestbox service with access to the host Docker socket.
|
|
111
105
|
control:
|
|
112
106
|
build:
|
|
@@ -116,12 +110,6 @@ services:
|
|
|
116
110
|
- "4088"
|
|
117
111
|
volumes:
|
|
118
112
|
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
|
119
|
-
- type: bind
|
|
120
|
-
source: ${WORKSPACE_PATH:-.}
|
|
121
|
-
target: /workspace
|
|
122
|
-
read_only: true
|
|
123
|
-
bind:
|
|
124
|
-
create_host_path: false
|
|
125
113
|
environment:
|
|
126
114
|
- COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
|
|
127
115
|
- NESTBOX_CONTROL_SERVICE=control
|
|
@@ -173,12 +161,6 @@ services:
|
|
|
173
161
|
target: /workspace
|
|
174
162
|
bind:
|
|
175
163
|
create_host_path: false
|
|
176
|
-
- type: bind
|
|
177
|
-
source: ${WORKSPACE_PATH:-.}/package.json
|
|
178
|
-
target: /workspace/package.json
|
|
179
|
-
read_only: true
|
|
180
|
-
bind:
|
|
181
|
-
create_host_path: false
|
|
182
164
|
# The entrypoint fixes this volume's ownership before dropping privileges.
|
|
183
165
|
- opencode-data:/home/nestbox/.local/share/opencode
|
|
184
166
|
working_dir: /workspace
|
|
@@ -188,7 +170,6 @@ services:
|
|
|
188
170
|
labels:
|
|
189
171
|
- nestbox.exec.allow-source=true
|
|
190
172
|
- nestbox.exec.targets=php-fpm,rollup
|
|
191
|
-
- nestbox.npm.allow=true
|
|
192
173
|
restart: unless-stopped
|
|
193
174
|
|
|
194
175
|
volumes:
|